2020-03-10-Linux內核13-進程切換

layout title subtitle date author header-img catalog tags
post
Linux內核13-進程切換
linux內核進程切換是如何實現的
2020-03-10
Tupelo Shen
img/post-bg-unix-linux.jpg
true
Linux
Linux內核
進程
switch_to

1 進程切換

進程切換,又稱爲任務切換、上下文切換、或者任務調度。本文就研究Linux內核的進程切換。我們首先理解幾個概念。

1.1 硬件上下文

我們知道每個進程都有自己的地址空間,但是所有的進程卻共享CPU寄存器。所以,在恢復進程執行之前,內核必須保證該進程在掛起時的寄存器值重新加載到CPU的寄存器中。

這些需要加載到CPU寄存器中的值就成爲硬件上下文。硬件上下文是進程執行上下文的一個子集,進程執行上下文包含進程執行所需要的所有信息。在Linux中,進程的硬件上下文一部分存儲在進程描述符中,而其它部分存儲在內核態的棧中。

在下面的描述中,我們假設,prev指向舊進程,而next指向新進程。因此,我們就可以說,進程切換就是保存prev進程的硬件上下文,然後加載next進程的硬件上下文。因爲進程的切換非常頻繁,所以縮短保存和加載硬件上下文的時間就很重要了。

舊版本的linux利用x86架構提供的硬件支持,並通過遠程調轉指令(GNU-ljump;Intel-jmp far)進行進程切換,跳轉到下一個進程的任務狀態段(TSS)描述符。執行這條跳轉指令的同時,CPU自動執行硬件上下文切換,保存舊的硬件上下文,加載新的硬件上下文。但是,linux2.6版本以後,通過軟件進行進程切換,原因如下:

  • 通過一連串的mov指令,一步步執行切換,可以更好地控制加載數據的合法性。由其是ds和es段寄存器中的值,有可能會被惡意用戶篡改。如果使用遠程跳轉指令是無法進程數據檢查的。

  • 新舊方法所要求的時間是大致相同的。但是,優化硬件上下文的切換是不可能的,因爲都是由CPU完成的,而Linux是使用軟件代替硬件上下文切換的,所以有優化的空間,以便提高執行時間。

進程切換隻能發生在內核態。在進行進程切換之前,用戶態進程使用的所有寄存器內容都已經包含在內核態的棧中了。這其中就包含指定用戶態進程棧指針地址的ss和esp這對寄存器內容。

1.2 任務狀態段-TSS

x86架構包含一個特殊的段寄存器,稱爲任務狀態段(TSS),用來保存硬件上下文內容。儘管Linux不使用硬件上下文切換,但還是給每個不同CPU建立一個TSS。這麼做,基於兩個原因:

  • 當x86架構的CPU從用戶態到內核態時,會從TSS中獲取內核態的棧地址

  • 用戶態進程想要訪問I/O端口的時候,CPU需要訪問存儲在TSS中的I/O權限位,判斷進程是否被允許訪問這個I/O端口。那麼,當用戶態進程執行in或out指令時,I/O控制單元到底做了什麼呢?

    1. 檢查eflags寄存器中IOPL位(2位)。如果等於3,也就是超級用戶權限,也就是進程對於這個I/O端口來說就是一個超級用戶,那麼,直接執行I/O指令。否則,繼續執行檢查。
    2. 訪問tr寄存器,確定當前的TSS,以及正確的I/O訪問權限。
    3. 它檢查I/O端口對應的訪問權限位。如果清零,指令被執行;否則,控制單元發出常規保護的異常。

內核中使用tss_struct結構體描述TSS。init_tss數組爲系統中的每一個CPU包含一個tss_struct結構。每一次進程切換,內核更新TSS相關內容,使CPU控制單元能夠安全地檢索自己想要的信息。因而,TSS反映了當前運行在CPU上的進程的特權級別,但是當進程不運行的時候,無需維護這些信息。

每個TSS具有8個字節長度的任務狀態段描述符(TSSD)。這個描述符包含一個32位的基地址,指向TSS的起始地址 以及20位的Limit域,表示頁的大小。TSSD的S標誌被清零,說明這是一個系統段(參見第2章的段描述符)。

Type域設置爲9或者11都可以,表明該段是一個TSS段即可。Intel最初的設計中,系統中的每個進程都應該引用自己的TSS:Type域的低第2個有效位稱爲Busy位,如果被設爲1,進程正在CPU上執行;設爲0,沒有執行。在Linux的設計中,每個CPU就只有一個TSS,所以,Busy位總是設爲1。換句話說,Linux中Type域一般爲11。

創建的這些TSSD存儲在全局描述符表(GDT)中,該表的基地址存儲在CPU的gdtr寄存器中。每個CPU的tr寄存器包含對應TSS的TSSD選擇器,還包含兩個隱藏的、不可編程的域:TSSD的Base和Limit域。使用這種方法,CPU可以直接尋址TSS,而不必非得訪問GDT中TSS的地址。

1.3 線程域

每當進程切換時,將要被替換掉的進程硬件上下文內容都應該被保存到某個地址。顯然不能保存在TSS中,因爲Linux爲每個CPU就建立了一個TSS,而不是爲每個進程建立TSS。

因而,進程描述符中添加了一個類型爲thread_struct的結構,通過它,內核保存舊進程的硬件上下文。後面我們會看到,該數據結構包含了大部分的CPU寄存器,除了通用目的寄存器,比如eax、ebx等,它們被存儲在內核態的棧中。

2 執行進程切換

  1. 進程切換的時機:

    • 中斷處理程序中直接調用schedule()函數,實現進程調度。
    • 內核線程,是一個特殊的進程,只有內核態沒有用戶態。所以即可以主動調用schedule()函數進行調度,也可以被中斷處理程序調用。
    • 內核態進程沒法直接主動調度,因爲schedule()是一個內核函數,不是系統調用。所以只能在中斷處理程序進行調度。
  2. 關鍵代碼梳理

    • 首先,schedule()函數會調用next = pick_next_task(rq, prev);,所做的工作就是根據調度算法策略,選取要執行的下一個進程。

    • 其次,根據調度策略得到要執行的進程後,調用context_switch(rq, prev, next);,完成進程上下文切換。其中,最關鍵的switch_to(prev,next, prev);切換堆棧和寄存器的狀態。

我們假設prev指向被切換掉的進程描述符,next指向將要執行的進程描述符。我們將會在第7章發現,prev和next正是schedule()函數的局部變量。

2.1 switch_to宏

進程硬件上下文的切換是由宏switch_to完成的。該宏的實現與硬件架構是息息相關的,要想理解它需要下一番功夫。下面是基於X86架構下的該宏實現的彙編代碼:

#define switch_to(prev, next, last)                             \
do {                                                            \
    /*
     * 進程切換可能會改變所有的寄存器,所以我們通過未使用的輸出變量顯式地修改它們。
     * EAX和EBP沒有被列出,是因爲EBP是爲當前進程訪問顯式地保存和恢復的寄存器,
     * 而EAX將會作爲函數__switch_to()的返回值。
     */
    unsigned long ebx, ecx, edx, esi, edi;                      \
                                                                \
    asm volatile("pushfl\n\t"               /* save    flags */ \
             "pushl %%ebp\n\t"              /* save    EBP   */ \
             "movl %%esp,%[prev_sp]\n\t"    /* save    ESP   */ \
             "movl %[next_sp],%%esp\n\t"    /* restore ESP   */ \
             "movl $1f,%[prev_ip]\n\t"      /* save    EIP   */ \
             "pushl %[next_ip]\n\t"         /* restore EIP   */ \
             __switch_canary                                    \
             __retpoline_fill_return_buffer                     \
             "jmp __switch_to\n"            /* regparm call  */ \
             "1:\t"                                             \
             "popl %%ebp\n\t"               /* restore EBP   */ \
             "popfl\n"                      /* restore flags */ \
                                                                \
             /* 輸出參數 */                                     \
             : [prev_sp] "=m" (prev->thread.sp),                \
               [prev_ip] "=m" (prev->thread.ip),                \
               "=a" (last),                                     \
                                                                \
               /* 列出所有可能會修改的寄存器  */                \
               "=b" (ebx), "=c" (ecx), "=d" (edx),              \
               "=S" (esi), "=D" (edi)                           \
                                                                \
               __switch_canary_oparam                           \
                                                                \
               /* 輸入參數 */                                   \
             : [next_sp]  "m" (next->thread.sp),                \
               [next_ip]  "m" (next->thread.ip),                \
                                                                \
               /* 爲函數__switch_to()設置寄存器參數 */          \
               [prev]     "a" (prev),                           \
               [next]     "d" (next)                            \
                                                                \
               __switch_canary_iparam                           \
                                                                \
             : /* reloaded segment registers */                 \
            "memory");                                          \
} while (0)

上面是一段GCC內嵌彙編代碼,關於其詳細的語法使用方法可以參考GCC內嵌彙編使用手冊

  • 首先,該宏具有3個參數,prevnextlast

    • prevnext這2個參數很容易理解,分別指向新舊進程的描述符地址;
    • last,是一個輸出參數,用來記錄是從哪個進程切換來的。
  • 爲什麼需要last參數呢?

    當進程切換涉及到3個進程的時候,3個進程分別假設爲A、B、C。假設內核決定關掉A進程,激活B進程。在schedule函數中,prev指向A的描述符,而next指向B的描述符。只要switch_to宏使A失效,A的執行流就會凍結。後面,當內核想要重新激活A,必須關掉C進程,就要再執行一次switch_to宏,此時prev指向C,next指向A。當A進程想要繼續執行之前的執行流時,會查找原先的內核態棧,發現prev等於A進程描述符,next等於B進程描述符。此時,調度器失去了對C進程的引用。保留這個引用非常有用,我們後面再討論。

圖3-7分別展示了進程A、B和C內核態棧的內容,及寄存器eax的值。還展示了last的值,隨後被eax中的值覆蓋。

switch_to宏的處理過程如下:

  1. 將新舊進程描述符存放到CPU寄存器中:

     movl prev, %eax
     movl next, %edx
    
  2. 保存舊進程的內核態棧,比如eflagsebp寄存器的內容。

     pushfl
     pushl %ebp
    
  3. 保存舊進程棧指針espprev->thread.esp

     movl %esp,484(%eax)
    

    操作數484(%eax)表明目的地址是寄存器eax中的地址加上484

  4. 將新進程的棧指針加載到esp寄存器中。

    新進程的棧指針位於next->thread.esp中。從現在起,內核在新進程的內核態棧上操作,所以,這條指令纔是執行舊進程切換到新進程的開始。因爲內核態棧的地址和進程描述符的地址緊密相關,那麼改變內核棧意味着改變了當前的進程。

     movl 484(%edx), %esp
    
  5. 保存標籤1的地址->prev->thread.eip

    標籤1標記進程當前執行的指令。這條指令意味着,再恢復進程A執行的時候,就從標籤1處的地址中的指令開始執行。

     movl $1f, 480(%eax)
    
  6. 加載新進程的指令流。

     pushl 480(%edx)
    

    意義和第5步差不多,就是執行順序相反。

  7. 跳轉到__switch_to()函數執行,是一個C函數。

     jmp __switch_to
    
  8. 至此,進程A被進程B取代:開始執行B進程的指令。第一步應該是先彈出eflags和ebp寄存器的值。

     1:
         popl %ebp
         popfl
    
  9. 拷貝eax寄存器的內容(第1步加載的)到last變量中。

     movl %eax, last
    

    也就是說,last記錄了被取代的進程。

2.2 __switch_to()函數

實際上大部分的進程切換工作是由__switch_to()函數完成的,它的參數是prev_p和next_p,分別指向舊進程和新進程。這個函數和普通的函數有些差別,因爲__switch_to()函數從eax和edx寄存器中獲取prev_p和next_p這兩個參數(在分析switch_to宏的時候已經講過),而不是像普通函數那樣,從棧中獲取參數。爲了強制函數從寄存器中獲取參數,內核使用__attribute__regparm進行聲明。這是gcc編譯器對C語言的一個非標準擴展。__switch_to()函數定義在include/asm-i386/system.h文件中:

__switch_to(struct task_struct *prev_p,
        struct task_struct *next_p)
        __attribute__(regparm(3));

這個函數執行的內容:

  1. 執行__unlazy_fpu()宏,保存舊進程的FPU、MMX和XMM寄存器

    __unlazy_fpu(prev_p);

  2. 執行smp_processor_id()宏,獲取正在執行代碼的CPU的ID。從thread_info結構的cpu成員中獲取。

  3. 加載新進程的next_p->thread.esp0到當前CPU的TSS段中的esp0成員中。通過調用sysenter彙編指令從用戶態切換到內核態引起的任何特權級別的改變都會導致將這個地址拷貝到esp寄存器中。

     init_tss[cpu].esp0 = next_p->thread.esp0;
    
  4. 將新進程的線程本地存儲(TLS)段加載到當前CPU的GDT中。3個段選擇器存儲在進程描述符的tls_array數組中。

     cpu_gdt_table[cpu][6] = next_p->thread.tls_array[0];
     cpu_gdt_table[cpu][7] = next_p->thread.tls_array[1];
     cpu_gdt_table[cpu][8] = next_p->thread.tls_array[2];
    
  5. 存儲fs和gs段寄存器的內容到舊進程的prev_p->thread.fs和prev_p->thread.gs中。彙編指令如下:

     movl %fs, 40(%esi)
     movl %gs, 44(%esi)
    

    寄存器esi指向prev_p->thread結構。gs寄存器用來存放TLS段的地址。fs寄存器實際上windows使用。

  6. 加載新進程的fs或gs寄存器內容。數據來源是新進程的thread_struct描述符中對應的值。彙編語言如下:

     movl 40(%ebx),%fs
     movl 44(%ebx),%gs
    

    ebx寄存器指向next_p->thread結構。

  7. 載入新進程的調式寄存器中的信息。

     if (next_p->thread.debugreg[7]){
         loaddebug(&next_p->thread, 0);
         loaddebug(&next_p->thread, 1);
         loaddebug(&next_p->thread, 2);
         loaddebug(&next_p->thread, 3);
         /* no 4 and 5 */
         loaddebug(&next_p->thread, 6);
         loaddebug(&next_p->thread, 7);
     }
    
  8. 更新TSS中的I/O權限位(如果有必要的話)。也就是如果新舊進程對I/O訪問有自己特殊的要求的話就需要更改。

     if (prev_p->thread.io_bitmap_ptr || next_p->thread.io_bitmap_ptr)
         handle_io_bitmap(&next_p->thread, &init_tss[cpu]);
    
  9. __switch_to()函數結束。

     return prev_p;
    

    相應的彙編語言就是:

     movl %edi,%eax
     ret
    

    因爲switch_to總是假設eax寄存器保存舊進程的進程描述符的地址。所以,這裏把prev_p變量再次寫入到eax寄存器中。

    ret指令把棧上要返回的地址寫入到eip寄存器中。其實,棧上的返回地址就是標籤爲1處的指令地址,這是由switch_to壓棧的。如果新進程從來沒掛起過,因爲是第一次執行,然後就會跳轉到ret_from_fork()函數返回的起始地址處(這部分等講進程的創建時再細說)。至此,完成了進程的切換。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章