Linux 的行程切換

Linux 的行程切換 (內文切換) 是與處理器密切相關的程式碼,每個處理器的實作方式均有相當大的差異,但基本原理都是將上一個行程 (prev, 以下稱為舊行程) 的暫存器保存後,再將程式計數器設定給下一個行程 (next, 以下稱為新行程)。在 IA32 (x86) 的處理器中,Linux 的行程切換程式碼如範例 4 所示,該行程切換函數 switch_to(prev, next, last) 是一個內嵌於 C 語言的組合語言巨集,採用 GNU 的內嵌組合語言語法 。

首先,switch_to 最外層是一個 do { … } while (0) 的迴圈,這個語法很奇怪,因為該迴圈根本永遠都只會執行一次,那又為何要用迴圈呢?這純粹只是為了要把中間的組合語言用區塊結構 {…} 包起來而已,但卻很容易誤導讀者,以為那是一個無窮迴圈 。

範例 4. Linux在 IA32 (x86) 處理器上的行程切換程式碼

行號 Linux 2.6.29.4 檔案 arch/x86/include/asm/system.h
…    …
30    #define switch_to(prev, next, last) \
31    do {\
32    /* \
33     * Context-switching clobbers all registers, so we clobber\
34     * them explicitly, via unused output variables.\
35     * (EAX and EBP is not listed because EBP is saved/restored \
36     * explicitly for wchan access and EAX is the return value of \
37     * __switch_to()) \
38     */ \
39    unsigned long ebx, ecx, edx, esi, edi; \
40    \
41    asm volatile("pushfl\n\t" /* save flags */ \
42    "pushl %%ebp\n\t" /* save EBP */ \
43    "movl %%esp,%[prev_sp]\n\t"/* save ESP */ \
44    "movl %[next_sp],%%esp\n\t"/* restore ESP */ \
45    "movl $1f,%[prev_ip]\n\t" /* save EIP */ \
46    "pushl %[next_ip]\n\t" /* restore EIP */ \
47    "jmp __switch_to\n" /* regparm call */ \
48    "1:\t" \
49    "popl %%ebp\n\t" /* restore EBP */ \
50    "popfl\n" /* restore flags */ \
51    \
52    /* output parameters */ \
53    [prev_sp] "=m" (prev->thread.sp), \
54    [prev_ip] "=m" (prev->thread.ip), \
55    "=a" (last), \
56    \
57    /* clobbered output registers: */ \
58    "=b" (ebx), "=c" (ecx), "=d" (edx), \
59    "=S" (esi), "=D" (edi) \
60    \
61    /* input parameters: */ \
62    : [next_sp]  "m" (next->thread.sp), \
63     [next_ip]  "m" (next->thread.ip), \
64    \
65    /* regparm parameters for __switch_to(): */ \
66    [prev]     "a" (prev), \
67    [next]     "d" (next) \
68    \
69    : /* reloaded segment registers */ \
70    "memory"); \
71    } while (0)
…    …

第41行開始才是行程切換的動作,指令pushfl 用來儲存旗標暫存器到堆疊中,pushl %%ebp 用來儲存框架暫存器 (ebp) 到堆疊中,movl %%esp, %[prev_sp] 則用來儲存舊行程的堆疊暫存器 (esp) 到 (prev->thread.sp) 欄位中,而 pushl %[next_sp], %%esp 則是將新行程的堆疊暫存器 (next->thread.sp) 取出後,放到CPU的esp暫存器中,於是建構好新行程的堆疊環境。接著,第45行的movl $1f, %[prev_ip] 將標記 1 的位址放入舊行程的 prev->thread.ip 欄位中。接著46行用指令pushl %[next_ip] 將新行程的程式計數器 next->thread.ip 推入堆疊中,然後利用指令jmp __switch_to跳入C語言的 switch_to() 函數 中,當該函數的返回指令被執行時,將會發生一個奇妙的結果。

由於 switch_to() 函數是一個C語言函數,原本應該被其他C語言函數呼叫的,呼叫前原本上層函數會先將下一個指令的位址存入堆疊中,然後才進行呼叫。C語言函數在返回前會從堆疊中取出返回點,以返回上一層函數繼續執行 。雖然我們是利用組合語言指令 jmp __switch_to 跳入該函數的,但C語言的編譯器仍然會以同樣的方式編譯,於是返回時仍然會從堆疊中取出 pushl %[next_ip] 指令所推入的位址,因而在 switch_to() 函數返回時,就會將程式計數器設為 next->thread.ip,於是透過函數返回的過程,間接的完成了行程切換的動作。

既然新行程已經在 switch_to() 函數返回時就開始執行了,那麼內文切換的動作不就已經完成了嗎?既然如此為何又需要第 49-50 兩行的程式呢?我們必須進一步回答這個問題。

第45行之所以將標記1放入prev->thread.ip中,是為了讓舊行程在下次被喚醒時,可以回到標記 1 的位置。當下次舊行程被喚醒後,就會從標記 1 的位址開始執行,舊行程可以利用第49-50行的popl %%ebp; popfl 兩個指令,恢復其 ebp (框架指標) 與旗標暫存器,然後再度透過 switch_to(),切換回舊行程 (只不過這次舊行程變成了函數switch_to(prev, next, last) 中的 next 角色,不再是『舊行程』了。

或許我們可以說 switch_to() 函數其實並不負責切換行程,因為該函數會將處理器中各種需要保存的值存入舊行程 prev 的 task_struct 結構中,以便下次 prev 行程被喚醒前可以回存這些暫存器值,其實並沒有切換或執行新行程的功能,但因為 jmp __switch_to 指令前的 pushl %[next_ip] 指令,導致該函數在返回時順便做了行程切換的動作,這種隱含性是作業系統設計時一種相當弔詭的技巧,也是學習 Linux 時對程式人員最大的挑戰之一。

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License