內核版本:linux-4.9.217
目錄
1 異常向量表
----異常發生後cpu如何跳轉到正確的異常處理入口
2 保存現場
----進入異常入口後如何保存現場
3 中斷處理
----識別了異常,現在要跳轉到真正的處理函數中處理中斷
4 異常返回
----恢復現場返回用戶態
一、異常向量表
這一節要分析內容爲異常發生後cpu如何跳轉到正確的異常處理入口
異常發生時,PC指針會自動跳轉到正確的異常向量入口進行異常處理,這是如何實現的呢?這得從異常向量表說起。
不同類型的異常有不同的異常處理例程,這些異常處理例程通常設計有簡單的入口統一放在一段內存中,稱爲異常向量表,而每個異常處理例程入口稱爲異常向量。
在aarch64架構的異常向量表中有16個異常向量,每個異常向量有128個字節,其存放的主要內容是異常處理入口的代碼。
異常向量表的地址在系統初始化階段存放到VBAR_ELn(VBAR_EL3, VBAR_EL2 and VBAR_EL1)寄存器中,即不同的異常級別都有一份單獨的異常向量表。當異常發生時PC指針通過VBAR_ELn寄存器找到異常向量表基址,然後通過如下幾個條件找到準確的異常向量偏移:
1 異常類型 (SError, FIQ, IRQ, or Synchronous);
2 如果異常發生前的EL與異常級別相同,則向量偏移還和系統當前堆棧指針sp使用的情況(SP_EL0 or SP_ELn)有關;
3 如果異常發生前的EL比異常後的EL級別低, 則向量便宜和異常前的系統的執行狀態(aarch64 or aarch32)有關.
地址偏移 | 異常類型 | 描述 |
VBAR_ELn + 0x000 | Synchronous |
異常EL與異常前的EL相同, 且使用SP_EL0
|
+0x080 | IRQ/vIRQ | |
+0x100 | FIQ/vFIQ | |
+0x180 | SError/vSError | |
+0x200 | Synchronous | 異常EL與異常前的EL相同,且使用SP_ELn |
+0x280 | IRQ/vIRQ | |
+0x300 | FIQ/vFIQ | |
+0x380 | SError/vSError | |
+0x400 | Synchronous | 異常前的EL比異常EL低,異常前系統模式爲aarch64 |
+0x480 | IRQ/vIRQ | |
+0x500 | FIQ/vFIQ | |
+0x580 | SError/vSError | |
+0x600 | Synchronous | 異常前的EL比異常EL低,異常前系統模式爲aarch32 |
+0x680 | FIQ/vFIQ | |
+0x700 | FIQ/vFIQ | |
+0x780 | SError/vSError |
圖1 具體偏移
參考:https://developer.arm.com/docs/100933/0100/aarch64-exception-vector-table
例如, 當前cpu在EL0級別以aarch64運行一個應用程序, 這時cpu收到一個IRQ信號進入IRQ異常,此時PE進入到EL1。此時,異常入口就是VBAR_EL1 + 0x480,cpu就跳轉到這裏。
而在linux內核中異常向量表放在arch/arm64/kernel/entry.S文件中,不同類型的異常在這個表中有不同的異常處理程序入口填充的各個項:
/*
* Exception vectors.
*/
.pushsection ".entry.text", "ax"
.align 11
ENTRY(vectors)
kernel_ventry 1, sync_invalid // Synchronous EL1t
kernel_ventry 1, irq_invalid // IRQ EL1t
kernel_ventry 1, fiq_invalid // FIQ EL1t
kernel_ventry 1, error_invalid // Error EL1t
kernel_ventry 1, sync // Synchronous EL1h
kernel_ventry 1, irq // IRQ EL1h
kernel_ventry 1, fiq_invalid // FIQ EL1h
kernel_ventry 1, error_invalid // Error EL1h
kernel_ventry 0, sync // Synchronous 64-bit EL0
kernel_ventry 0, irq // IRQ 64-bit EL0
kernel_ventry 0, fiq_invalid // FIQ 64-bit EL0
kernel_ventry 0, error_invalid // Error 64-bit EL0
#ifdef CONFIG_COMPAT
kernel_ventry 0, sync_compat, 32 // Synchronous 32-bit EL0
kernel_ventry 0, irq_compat, 32 // IRQ 32-bit EL0
kernel_ventry 0, fiq_invalid_compat, 32 // FIQ 32-bit EL0
kernel_ventry 0, error_invalid_compat, 32 // Error 32-bit EL0
#else
kernel_ventry 0, sync_invalid, 32 // Synchronous 32-bit EL0
kernel_ventry 0, irq_invalid, 32 // IRQ 32-bit EL0
kernel_ventry 0, fiq_invalid, 32 // FIQ 32-bit EL0
kernel_ventry 0, error_invalid, 32 // Error 32-bit EL0
#endif
END(vectors)
對於前面講到的EL0級別發生的中斷異常,其異常向量偏移在VBAR_EL1 + 0x480的地址處,在linux中的內核代碼即爲:
kernel_ventry 0, irq // IRQ 64-bit EL0
二、保存現場
上面分析Linux內核遇到EL0級別的中斷異常發生時,cpu會自動跳轉到對應的異常向量入口
kernel_ventry 0, irq
這個kernel_ventry一個宏,也定義在arch/arm64/kernel/entry.S文件中,展開後如下:
.macro kernel_ventry, el, label, regsize = 64
.align 7
#ifdef CONFIG_UNMAP_KERNEL_AT_EL0
......
#endif
sub sp, sp, #S_FRAME_SIZE
b el\()\el\()_\label
.endm
ARM64_UNMAP_KERNEL_AT_EL0時安全相關的,我們這個場景先不關注。這樣精簡後就是三行代碼:
(1) "mov x30, xzr" 將x30寄存器置爲0;
(2) "sub sp, sp, #S_FRAME_SIZE" 擴展堆棧S_FRAME_SIZE字節大小,爲後續寄存器入棧做準備;
(3) “b el\()\el\()_\label”跳轉到“el\()\el\()_\label”處執行; 將入參"el"和"lable"展開後就是el0_irq,即跳轉到el0_irq處執行。
el0_irq纔是irq異常處理的主要例程,這其中包括了保存現場、異常處理和現場恢復與返回,主要邏輯如下(去掉了一些調試或者我們無需關注的小部分代碼):
el0_irq:
kernel_entry 0
el0_irq_naked:
......
irq_handler
......
b ret_to_user
ENDPROC(el0_irq)
保存現場就是通過宏kernel_entry來實現,這個宏帶有兩個參數,參數el表示異常等級,例如我們這裏irq到來前系統運行在EL0,則參數爲0,即"kernel_entry 0";另一個是regsize表示寄存器的size,如果不傳此參數則默認是64。
保存現場主要保存哪些內容呢?主要有通用寄存器、cpu狀態寄存器pstate、堆棧sp和pc指針。這一系列的內容在linux內核中使用struct pt_regs這個數據結構來管理,這個數據結構定義在arch/arm64/include/asm/ptrace.h文件中:
struct pt_regs {
union {
struct user_pt_regs user_regs;
struct {
u64 regs[31];
u64 sp;
u64 pc;
u64 pstate;
};
};
u64 orig_x0;
u64 syscallno;
u64 orig_addr_limit;
u64 unused; // maintain 16 byte alignment
};
現場的內容保存到哪裏呢?保存到堆棧中。前面我們已經有瞭解到kernel_ventry宏會將在sp中預留S_FRAME_SIZE大小的空間,這個預留出的S_FRAME_SIZE大小就用於保存現場。至於S_FRAME_SIZE,它是一個定義在arch/arm64/kernel/asm-offsets.c的宏,其值大小爲sizeof(struct pt_regs),也就是說在堆棧中預留了一個struct pt_regs結構大小的空間。
好了,回答了保留什麼,保留在哪裏的問題,下面就來看看"kernel_entry 0"的詳細情況。和前面一樣我把這裏不涉及到的代碼都暫時省略。
.macro kernel_entry, el, regsize = 64
...... //regsie == 32的情況先忽略
stp x0, x1, [sp, #16 * 0]
...... //x0~x29入棧
.if \el == 0
mrs x21, sp_el0
mov tsk, sp
and tsk, tsk, #~(THREAD_SIZE - 1) // Ensure MDSCR_EL1.SS is clear,
ldr x19, [tsk, #TI_FLAGS] // since we can unmask debug
disable_step_tsk x19, x20 // exceptions when scheduling.
...... //CONFIG_ARM64_SSBD的情況先忽略
1:
mov x29, xzr // fp pointed to user-space
.else
...... //el1的情況先忽略
.endif /* \el == 0 */
mrs x22, elr_el1
mrs x23, spsr_el1
stp lr, x21, [sp, #S_LR]
stp x22, x23, [sp, #S_PC]
/*
* Set syscallno to -1 by default (overridden later if real syscall).
*/
.if \el == 0
mvn x21, xzr
str x21, [sp, #S_SYSCALLNO]
.endif
/*
* Set sp_el0 to current thread_info.
*/
.if \el == 0
msr sp_el0, tsk
.endif
.endm
上面這段代碼就是kernel_entry,0,64的基本流程,下面歸納爲如下幾個步驟:
【1】將x0~x29保存到sp[0, 8*29]的位置,即pt_reg.regs[0]~pt_reg.regs[29];
【2】然後將當前任務內核堆棧棧頂放到tsk,tsk是一個宏,對於aarch64而言一般是x28寄存器。
這裏要對這個步驟進行簡單的講解。從用戶態進入到IRQ異常後,堆棧指針切換到當前任務的內核堆棧,即current->stack,而任務內核堆棧大小用THREAD_SIZE表示,在aarch64中一般爲16kb。
mov tsk, sp /* 將sp指針值放到tsk */
and tsk, tsk, #~(THREAD_SIZE - 1)
【3】將lr寄存器保存到sp[8*30]的位置, 即pt_reg.regs[30];sp_el0保存到sp[8*31]位置,即pt_reg.regs[31],它保存的異常發生前用戶態堆棧指針的地址;
【4】將ELR_EL0和SPSR_EL0放到sp + 8*32的位置,即pt_reg.pc和pt_reg.psate。ELR_EL0寄存器的內容是異常處理完畢返回到用戶態時的地址,SPSR_EL0保存了異常發生前的PE狀態,二者都用於異常返回。
【5】將sp + 8*35位置清0,即pt_reg.syscallno清0;
【6】sp_el0設置爲前面tsk的地址。
三、interrupt handling
第二章中我們已經瞭解到"el0_irq"首先用"kernel_entry 0"進行了現場保存,接下來就是具體的處理例程----中斷處理,由宏irq_handler來實現,這個宏很簡單:
.macro irq_handler
ldr_l x1, handle_arch_irq
mov x0, sp
irq_stack_entry
blr x1
irq_stack_exit
.endm
真正中斷處理函數爲handle_arch_irq,在arm架構它只是一個函數指針,在gic初始化時linux調用set_handle_irq(gic_handle_irq)函數將handle_arch_irq設置爲真正的中斷處理函數gic_handle_irq()。
函數gic_handle_irq()帶一個參數struct pt_regs *,這段彙編將sp指針作爲入參放到x0中,然後通過"blr x1"跳轉到handle_arch_irq執行真正的中斷處理。這裏將sp作爲gic_handle_irq()的入參,是因爲在進入異常時堆棧指針先預留了一個struct pt_regs大小的位置,並且將現場信息填充到了這段pt_regs內存中。
另外還需要注意,在"blr x1"前後被"irq_stack_entry"和"irq_stack_exit"所環繞;irq_stack_entry用以將當前堆棧切換到中斷棧,即sp指向irq_stack這段內存區間,前提是當前堆棧指針棧頂與tsk棧頂相同;而irq_stack_exit剛好相反,在中斷處理完後將堆棧指針從irq_stack切換回原來的堆棧。
四、異常返回
異常處理完畢後執行"b ret_to_user"進行異常處理的收尾工作,我們還是結合代碼來分析
ret_to_user:
disable_irq // disable interrupts
ldr x1, [tsk, #TI_FLAGS]
and x2, x1, #_TIF_WORK_MASK
cbnz x2, work_pending
finish_ret_to_user:
enable_step_tsk x1, x2
kernel_exit 0
ENDPROC(ret_to_user)
【1】關中斷
【2】檢查當前任務的thread_info.flags & _TIF_WORK_MASK,以判斷當前任務是否有"pengding work",如果有則調用work_pending處理。peding flags包括:
(1)_TIF_NEED_RESCHED,表示current設置了搶佔調度標誌,在異常退出前可提供給高優先級任務一個搶佔的機會;
(2)_TIF_SIGPENDING,有信號掛起,在返回前調用do_signal()進行信號處理;
(3)_TIF_NOTIFY_RESUME,tracehook_notify_resume()處理pengding work;
(4)_TIF_FOREIGN_FPSTATE,任務調試。
【3】kernel_exit 0恢復現場,異常返回。
與前面的kernel_entry保存現場相對應,異常處理完畢後,linux內核調用kernel_exit el宏來恢復現場,並返回到異常發生前的狀態。宏kernel_exit帶有一個參數el,表示異常的級別。我們這裏只關注el0的情況,其他無關的代碼我們也省略。
.macro kernel_exit, el
.if \el != 0
......
.endif
ldp x21, x22, [sp, #S_PC] // load ELR, SPSR
.if \el == 0
......
ldr x23, [sp, #S_SP] // load return stack pointer
msr sp_el0, x23
tst x22, #PSR_MODE32_BIT // native task?
b.eq 3f
......
3:
......
5:
.endif
msr elr_el1, x21 // set up the return data
msr spsr_el1, x22
ldp x0, x1, [sp, #16 * 0]
...... //從堆棧恢復x0~x29
ldr lr, [sp, #S_LR]
add sp, sp, #S_FRAME_SIZE // restore sp
.if \el == 0
alternative_insn eret, nop, ARM64_UNMAP_KERNEL_AT_EL0
#ifdef CONFIG_UNMAP_KERNEL_AT_EL0
......
#endif
.else
eret
.endif
.endm
(1) 從堆棧中恢復x0~x29、lr、sp_el0、ELR和SPSR等寄存器;
其中ELR是異常返回後執行的下一條指令;SPSR保存了cpu進入異常前的狀態寄存器內容。當執行eret指令時硬件自動將ELR加載到pc指針,SPSR自動恢復到cpu狀態寄存器;
(2) 恢復堆棧。
在第二章我們討論過,異常處理初期會執行"sub sp, sp, #S_FRAME_SIZE"指令在堆棧中預留出pt_regs結構大小的內存用於保護現場;現在現場信息已經恢復,執行"add sp, sp, #S_FRAME_SIZE"指令將堆棧sp恢復到原來的位置。
(3) 異常返回。
通過執行eret指令從異常返回。返回後pc指針由ELR寄存器自動恢復,返回後的cpu狀態也由SPSR恢復。這樣異常處理完畢後又回到了異常發生前風平浪靜狀態。