kernel:linux-4.9
cpu: ARMV8
背景
在廣袤的代碼中堆棧無疑是一個高熱度的技術用語, 就linux而言你能常觀察到的幾個場景有:
- 用戶態堆棧
函數func_foo中用堆棧來保存寄存器、局部變量等等:
圖 1 用戶態堆棧實例
- 內核堆棧
在內核中也需要使用堆棧,典型的場景就是異常處理中使用堆棧保存異常現場:
圖2 內核堆棧實例
有一個細思極恐的事情,在同一個cpu上這些"堆棧"都是用同一個符號"sp"來指示。
- 用戶態正在使用"sp"保存局部變量, 時鍾中斷來了,linux進入異常處理流程, 然后又用"sp"來保存現場;
- 進程prev正在以120碼的速度歡暢的使用"sp"來調用函數運行, 然后切換到了進程next; 進程next也要用"sp"來進行自己的函數調用。
問題來了,都在用"sp", 這是萬物共享時代的終極產物? 還是"sp"會分身術?
這一切都是通過上下文切換來完成的,而”sp”就是上下文中的一個小部分。
一、ARMv8堆棧指針簡介
堆棧的切換流程和硬件息息相關,我們這里以armv8為背景來進行講述。Armv8中對異常運行級別進行了划分,不同的運行級別使用的sp可能會有所不同。
當程序運行在EL0時使用的是SP_EL0;其他Exception level下,可以使用SP_EL0或者當前Exception level所對應的SP_ELn寄存器;具體使用SP_EL0還是SP_EL1是由PSTATE.SP決定,對應的寄存器是Spsel。若Spsel==0,那么強制使用SP_EL0,否則使用用SP_ELn。在linux中Spsel默認位1。因而異常發生時,默認會切換到SP_ELn。
二、用戶態與內核態的堆棧切換
實際上在上第一章已經可以從硬件意義上解釋問題1了。就armv8的Linux而言,用戶態程序(EL0異常級別)發生異常、進入到內核態(EL1異常級別) sp會從SP_EL0切換到SP_EL1。
下面我們就結合一個例子看看Linux是如何基於cpu架構特點從軟件上來完成sp的切換的。
任務P在用戶態運行時堆棧指針sp實際指向的是SP_EL0寄存器,而SP_EL0存放的就是任務P的用戶態堆棧虛擬地址,其值在P的/proc/$pid/maps中的[stack]這個vma區間中。此時任務P發生了一次異常,這會引發如下的一系列連鎖反應:
- 異常發生 堆棧寄存器切換
由於中斷、系統調用等引發一次系統異常,運行級別從EL0切換到EL1,sp也由硬件自動從SP_EL0切換到SP_EL1,此時SP_EL1指向內核地址空間。在此之前SP_EL1中已經存放了任務P的內核態堆棧的地址,即task_struct->stack中的某個位置(注1)。
- 保存用戶態堆棧指針SP_EL0 等異常現場
進入異常處理流程初期,由kernel_entry宏將SP_EL0寄存器內容保存到SP_EL1指向的內核堆棧中,然后將SP_EL0挪作它用,比如current宏的實現。
- 恢復用戶態堆棧指針 等異常現場
在異常處理流程后期,由kernel_exit宏將之前存放到SP_EL1內核堆棧中存放的SP_EL0的值恢復到SP_EL0中;
- 異常返回 堆棧寄存器切換
在異常處理處理流程的終點會執行"eret"返回到用戶態,PE運行級別從EL1恢復到EL0,sp也隨之從SP_EL1切換到SP_EL0。
一圖勝千言,整個流程如下所示:
圖3 內核態用戶態sp上下文切換
三、進程之間的堆棧切換
了解了用戶態/內核態之間的堆棧指針切換后,我們再來看看進程與進程之間的sp是如何切換的。這個過程稍顯復雜,我們從簡單到細致一步一步區分析。
任務之間的切換細節對於我們分析進程之間堆棧切換有着承上啟下的作用。對於缺少想象空間的我來說,舉例子永遠是我最喜歡的方式。下面我就例舉進程prev切換到進程next的詳細情況,順便把堆棧切換的流程夾雜其中。
要注意的是,進程之間切換一定是要在內核中發生,因而需要有異常發生。
- 發生異常 堆棧寄存器切換 保存異常現場
進程prev運行過程中發生一次系統異常(系統調用、中斷等等),異常級別由EL0變為EL1,sp也隨之從SP_EL0切換到SP_EL1, 然后進入異常處理流程入口由kernel_entry宏將SP_EL0寄存器內容存放到SP_EL1所對應的內核堆棧中;
- 發生調度
在系統異常處理流程中發生一次調度(如prev系統調用阻塞、或者被更高優先級任務搶占),進入__schedule()調度函數。
- 調度產生切換
調度函數__schedule() 首先選擇下一個將要運行的任務next; 然后,經過一系列准備之后調用cpu_switch_to(prev, next)函數從任務prev切換到任務next運行。
ENTRY(cpu_switch_to) /* 兩個參數:x0=prev, x1=next*/ /* 取prev任務的cpu_context到寄存器x8; */ mov x10, #THREAD_CPU_CONTEXT add x8, x0, x10 /* 保存prev任務的現場”x19~29, sp, lr”到prev的cpu_context */ mov x9, sp stp x19, x20, [x8], #16 // store callee-saved registers stp x21, x22, [x8], #16 stp x23, x24, [x8], #16 stp x25, x26, [x8], #16 stp x27, x28, [x8], #16 stp x29, x9, [x8], #16 str lr, [x8] /* 取next任務的cpu_context到寄存器x8 */ add x8, x1, x10 ldp x19, x20, [x8], #16 // restore callee-saved registers ldp x21, x22, [x8], #16 ldp x23, x24, [x8], #16 ldp x25, x26, [x8], #16 ldp x27, x28, [x8], #16 ldp x29, x9, [x8], #16 ldr lr, [x8] /* 將next任務原來現場的lr,sp加載到當前現場*/ mov sp, x9 /* x9保存的是next任務的內核堆棧 */ and x9, x9, #~(THREAD_SIZE - 1) msr sp_el0, x9 /* 確保current宏取到的是next任務 */ ret /* ret指令*/ ENDPROC(cpu_switch_to)
這個函數的邏輯還是很清晰:
[1] THREAD_CPU_CONTEXT是一個任務cpu_context相對於task_struct的偏移, 即THREAD_CPU_CONTEXT + &task_struct就是task_struct.thread.cpu_context的地址;
[2] 將prev的現場信息(x19~29, sp, lr寄存器)保存到自己的task_struct.thread.cpu_context結構中,在下次自己切換回來時恢復取用;
[3] 然后再從next的cpu_context結構中取出next的現場信息到當前cpu的x19~x29, sp, lr寄存器中, 這樣堆棧sp, 鏈接寄存器lr等等寄存器都切換到了next任務;
[4]更新sp_el0,最后執行ret指令。
這里有兩條指令需要注意:
mov sp, x9
這條指令直接將x9寄存器的內容填充到sp;由於此時系統處於EL1異常級別,因而sp指向的是SP_EL1,因而這里實際上是將原來prev的內核堆棧切換到了next任務內核堆棧的某個位置(注2);
ret
在aarch64架構中使用bl和ret指令來實現函數的調用與返回。bl指令先將下一條指令放到lr寄存器,然后跳轉到目標地址執行;ret指令執行時cpu跳轉到lr寄存器中所指向的地址執行。由於arch_switch_to()的后面部分從next任務的cpu_context結構中取出了現場信息填入了lr寄存器,因而這里的ret指令會跳轉到next任務lr指針地址執行(注3), ret執行完成后當前cpu上的上下文實際上已經更朝換代,火車前進的軌道由prev切換到了next,從此一去.....可能還會返。
- 新任務運行
現在pc是沿着next的軌道在前進。那next的這個軌道是通向哪里呢?最終最終,就是走到異常返回的流程,即下面兩個流程;
- 恢復新任務異常現場
執行kernel_exit,從當前內核堆棧(已經切換至next任務的內核堆棧)中取出next的用戶態堆棧恢復到SP_EL0寄存器;
- 返回新任務用戶態
在內核態的最后階段指向"eret"指令返回到next用戶態上下文,PE運行級別從EL1恢復到EL0,sp也由自動切換到SP_EL0,這時的SP_EL0已經是next任務的用戶態堆棧(注4)了。
總結一下任務之間的堆棧指針是如何切換的:
圖4 進程之間堆棧切換情況
四、細節
第二章和第三章已經按部就班的講述了用戶態/內核態、任務與任務之間的堆棧指針sp如何切換的。但是仍然有一些內容我們一筆帶過並未認真去揣摩細節,特別是前面各個章節中的注1..注4等等注意的地方。下面我們就對這些“注”細節進行講解。
4.1 新任務調度的情況
新任務的特點就是創建好后從來沒有運行過。這種情況一般是fork()系統調用創建好next,next掛入到就緒隊列中,由prev任務進入到內核態調用__schedule()函數選擇next任務運行。
也就是說next是第一次被調度運行,所以它的用戶態堆棧、它的內核棧都是全新的。
那它的用戶態堆棧、內核棧的內容是什么呢?要回答這個問題,我們需要用一個章節的時間來了解一下。
- Fork新任務堆棧相關初始化流程
當next任務通過fork()調用創建時,會執行如下兩個與堆棧相關的關鍵流程:
圖5 fork新任務堆棧初始化相關流程
【1】在copy_process初期調用alloc_thread_stack_node()函數為新任務next分配大小為16Kb(其他架構可能大小會有差異)的內核堆棧,並賦給新任務task_struct->stack;
【2】對於普通的fork()系統調用(為了簡化討論vfork與clone的情況暫時不考慮)copy_mm函數會將父進程的各個vma以及頁表都拷貝到新任務next,也就是說新任務next與父進程的地址空間是一樣的;其中[stack]也是從父進程拷貝過來的一組vma;
【3】調用copy_thread函數初始化任務的內核堆棧和上下文結構。
int copy_thread(unsigned long clone_flags, unsigned long stack_start, unsigned long stk_sz, struct task_struct *p) { struct pt_regs *childregs = task_pt_regs(p); //指向新任務內核棧頂pt_regs memset(&p->thread.cpu_context, 0, sizeof(struct cpu_context)); 。。。。。。。 if (likely(!(p->flags & PF_KTHREAD))) { //我們只考慮用戶態任務的情況 *childregs = *current_pt_regs(); //拷貝父進程的內核棧內容 childregs->regs[0] = 0; //子任務fork()返回值為0 。。。。。。。 } else { 。。。。。。。 } //設置cpu_context.pc和cpu_context.sp, 在arch_switch_to會用到 p->thread.cpu_context.pc = (unsigned long)ret_from_fork; p->thread.cpu_context.sp = (unsigned long)childregs; ptrace_hw_copy_thread(p); return 0; }
上面的函數,注意是准備p->thread.cpu_context.pc 和 p->thread.cpu_context.sp。
其中,p->thread.cpu_context.pc是pc指針,指向ret_from_fork函數;p->thread.cpu_context.sp是內核堆棧指針,它所指向的位置和內容由如下流程確定。
首先,通過task_pt_regs(p)提取新任務p內核堆棧中存放struct pt_regs的起始位置(實際為(p->stack + 16kb - 16) - sizeof(struct pt_regs)的位置);
其次,復制父進程堆棧中pt_regs中的內容到新進程堆棧的pt_regs中,但是pt_regs->regs[0]除外,因為這個是fork()系統調用的返回值,而新任務返回值為0。
*childregs = *current_pt_regs() childregs->regs[0] = 0;
上面的情況如下圖所示:
圖6 初始化后子任務的內核堆棧情況
- fork新任務總結一下
對於一個新創建的任務,它的內核堆棧指針就是上圖中p->thread.cpu_context.sp,這就是前面(注2)在切換到新任務的情況;同時,parent任務內核堆棧中存放的SP_EL0值也復制到新任務的的內核堆棧中來了,這樣在新任務從fork()系統調用內核態返回到用戶態、恢復用戶態堆棧指針時,用戶態堆棧指針SP_EL0實際上是等於parent的用戶態堆棧指針的,這就是(注4)在切換到新任務的情況,同時fork()返回值為0。這樣也解釋了fork()系統調用的某些現象(這里就不詳細展開)。
我們再說說前面的(注3),上面除了了堆棧相關的操作外,還設置了新任務的p->thread.cpu_context.pc。讓我們再次把視線拉回到arch_switch_to()函數。
ldp x29, x9, [x8], #16 ldr lr, [x8] /* 新任務cpu_context.cpu加載到lr */ mov sp, x9 /* 切換到新任務的內核堆棧 */
其中x8就是新任務的p->thread.cpu_context結構,上面的兩條指令就是將堆棧指針切換到next的內核堆棧;然后將cpu_context.cpu放到lr,此時的cpu_context.cpu已經初始化為ret_from_fork函數,因而arch_switch_to()函數的”ret”指令就跳轉到ret_from_fork()函數了,ret_from_fork()具體細節就不展開了,但是最終還是要進行到kernel_exit、eret返回到用戶態。
4.2 已有調度史任務的情況
場景2會簡單一些,大部分內容在其他章節已經由講過,說一下next任務堆棧指針的情況:
圖7 進程切換過程中內核堆棧的變化
[1] 在異常的情況下從用戶態進入內核態,此時SP從SP_EL0切換到SP_EL1,此時SP_EL1指向內核堆棧pt_regs的起始位置,這就是(注1)中的某個位置;
[2] 在異常處理入口SP_EL1向下增長保留出一個struct pt_regs的空間以保存SP_EL0等異常現場寄存器;
[3] 在異常處理中發生調度,調用arch_switch_to函數,SP_EL1也因為函數調用等原因向下增長。在arch_switch_to函數中會將當前SP_EL1的值保存到next任務的cpu_context結構中,然后cpu調度到其他任務執行;
[4] 當next任務再次被調度到運行時,內核會從next任務的cpu_context中取出保存的SP_EL1和pc,繼續next之前未運行完畢的arch_switch_to()以及更上層的函數;
[5] 執行kernel_exit,next任務的SP_EL1最終恢復到pt_regs起始位置,SP0_EL1也從堆棧中恢復。
總結:
堆棧指針sp在linux中的切換是隨着異常級別在SP_EL0和SP_EL1之間變化; SP_EL0和SP_EL1的變化則是在Linux軟件中通過各種場景下的現場保存、恢復、初始化等等來決定的。
這部分內容本身牽涉的比較廣比較多,因而講的邏輯也不是很順,拋磚引玉,希望能夠對各位讀者有所幫助 心已足以。