1. 程式人生 > >linux程序切換(linux3.4.5,x86)

linux程序切換(linux3.4.5,x86)

引言

本文描述linux x86的程序切換實現原理,敘述了暫存器、堆疊的備份與恢復操作。

Intel設計的意圖是通過硬體方式切換程序,但是linux並沒有使用這種方式,而是使用了軟體方式,文章對這兩種方式分別做了描述。

一、選擇硬體切換還是軟體切換?

  1. x86提供硬體切換方式switching task(早期核心版本採用)

    圖1 32-bit Task State Segment
    Intel在設計x86時,希望以硬體方式切換程序:每個程序有一個TSS(task state segment),這是一個記憶體中的資料結構,包含通用暫存器的值、IO許可權點陣圖資訊,見圖1。另外還有一個特殊的暫存器TR(Task Register),指向某個程序的TSS,見圖2。更改TR的值將會觸發硬體儲存cpu所有暫存器的值到當前程序的TSS中,然後從新程序的TSS中讀出所有暫存器值,載入到cpu對應的暫存器中。整個過程中cpu暫存器的儲存、載入,無需軟體參與。


    圖2 Task State Segment
    x86在設計上有4個特權級,稱為ring0,ring1,ring2,ring3。linux中使用者態對應ring3,核心態對應ring0。TSS中的Stack Seg Priv.Level0~2指向cpu處於特權級使用的棧。Stack Segment表示當前特權級的棧(特權級有0~2 三個級別)。
    為什麼x86有4個特權級,而TSS中記錄了3個特權級的棧,並沒有記錄Priv.Level3的棧(也即使用者態棧)呢?我的理解是系統從使用者態切進入核心態,會把使用者態棧指標儲存在核心棧上,從核心態返回使用者態時,只要從核心棧上pop出使用者態的棧指標就可以了,所以不需要額外記錄Priv.Level3的棧指標。
    程序地址空間的頁目錄指標在CR3暫存器中(參考圖2)。從上面可以看出,TSS包含了一個程序執行所需的硬體暫存器和棧資訊,所以通過TSS的切換可以實現程序切換。
    硬體切換方式有以下缺點:
    • 每個程序都需要一個TSS,耗費記憶體,核心空間有限,限制了程序數量上限值。
    • 每次切換,需要將old task的cpu所有暫存器值儲存到這個task對應的TSS(記憶體中),然後從new task的TSS(記憶體中)取出所有暫存器值恢復到cpu暫存器。考慮到一些暫存器值並不會更改,更新全部暫存器效率低。
    • 程式碼可移植性差。TSS是IA(intel architecture)相關的其他架構cpu不一定有。
    所以新版本核心並沒有採用這種硬體切換的方式,而是採用了軟體切換方式。

  2. linux新版本核心採用軟體方式切換程序
    軟體切換程序需要做3件事:
    • CR3修改程序頁目錄指標,也就是改變程序的地址空間的對映資訊。
    • cpu暫存器的儲存、恢復,這些暫存器是程序執行所必需的硬體資訊。
    • 程序堆疊資訊的更改。

    從硬體切換分析可以看出,intel architecture的cpu切換程序是圍繞TSS進行的,TR暫存器總是會指向一個TSS。硬體設計限制了必須要給TR提供一個TSS,所以在軟體切換方式中必須遵循硬體限制。不過與硬體切換方式不同,軟體切換方式為每個cpu初始化一個TSS,而不是為每個程序提供一個TSS,程式碼參考start_kernel --> trap_init --> cpu_init:
    <span style="font-size:14px;">void __cpuinit cpu_init(void)</span>
    {
            int cpu = smp_processor_id();
            struct task_struct *curr = current;
            struct tss_struct *t = &per_cpu(init_tss, cpu);
            struct thread_struct *thread = &curr->thread;  
    
            load_idt(&idt_descr);
            switch_to_new_gdt(cpu);
    
            /*   
             * Set up and load the per-CPU TSS and LDT
             */
            atomic_inc(&init_mm.mm_count);
            curr->active_mm = &init_mm;
            BUG_ON(curr->mm);
            enter_lazy_tlb(&init_mm, curr);
    
            load_sp0(t, thread);
            set_tss_desc(cpu, t);
            load_TR_desc();
    
    對於cpu而言,一旦設定Task Register指向TSS後,就不會再改變Task Register的值了,所以對於cpu來說,它認為永遠是同一個程序在執行。不改變Task Register的值,就不會觸發硬體自動儲存、載入TSS的操作了,相當於拋棄了intel提供的硬體切換方式。保留TSS的概念,只是為了滿足硬體限制而已,程序切換關心的是硬體執行環境----cpu暫存器的值,並不關心TSS。

    那麼問題來了,如何實現程序切換?其實問題的本質在於如何儲存、恢復cpu的暫存器?intel的硬體切換方式不過就是提供了一種儲存、恢復cpu暫存器的方法而已。另一種方式是通過彙編指令儲存、恢復暫存器,這當然是一種可行的方法,也比較靈活,想儲存哪些暫存器就儲存哪些暫存器(暫存器的恢復也是一樣)。對比分析可以看出,硬體切換方式必須完整地儲存、恢復TSS中的暫存器,顯得有些呆板。

    軟體切換方式儲存暫存器大致分成2個部分,首先硬體單元自動儲存部分暫存器至棧中,這部分暫存器稱為hardware stack frame,其他的一些暫存器通過SAVE_ALL巨集儲存至棧中,這是一段彙編程式碼。恢復暫存器的操作是個逆向過程,從棧中pop出各個暫存器,載入到cpu暫存器中。本段內容在後面有詳細描述。

    在linux中,只用到了TSS中的esp0和iomap欄位,esp0是核心態棧指標,每次切換程序時,linux會把“切換至”的程序核心棧task_struct->thread->sp0賦給tss_struct->x86_tss.sp0。當x86中斷、異常時,cpu控制單元會從tss_sruct->x86_tss.sp0讀取新特權級的核心棧,設定ESP暫存器,從而使ESP指向核心棧而不是指向中斷前的使用者棧,獲取到核心棧指標後,就可以在核心棧上有選擇地儲存硬體暫存器資訊了(對於x86而言儲存的是struct pt_regs結構體中的暫存器,其中一些是硬體控制單元自動壓棧,另一些是軟體壓棧,參考後面分析)。

    優點:
    • 每個cpu一個TSS結構。本cpu中所有程序用的是同一個TSS。節省記憶體。
    • 程序切換,只更改TSS中的esp0和io許可權相關的暫存器,另外通過彙編指令儲存部分暫存器,不用更新全部暫存器。
    • 軟體切換方式不再依賴於x86硬體切換機制,對所有cpu適用,可移植性高。

二、linux程序切換時棧的變化

每個task的棧分成使用者棧和核心棧兩部分。每個task的核心棧是8k。核心棧與current巨集緊密相關,棧低地址是thread_info,棧高地址是task可以實際使用的棧空間。這樣設計的目的在於遮蔽棧指標esp的低13位就可以得到thread_info,從而得到thread_info->task,也就是我們的current巨集。從上面的描述可以看出,這8k棧必須在物理上連續,並且要8k地址對齊(注1)。linux核心棧與current巨集的關係見圖3。


圖3 核心棧與current巨集

pt_regs中的暫存器順序是固定的。

下面通過圖4分析一下棧是如何切換的。當cpu由ring3(使用者態)變成ring0(核心態)時,使用者棧切換到核心棧。過程如下:

  1. 在發生中斷、異常時前,程式執行在使用者態,ESP指向的是Interrupted Procedure's Stack,即使用者棧。
  2. 執行下一條指令前,檢測到中斷(x86不會在指令執行沒有指向完期間響應中斷)。從TSS中取出esp0欄位(esp0代表的是核心棧指標,特權級0)賦給ESP,所以此時ESP指向了Handler's Stack,即核心棧。
  3. cpu控制單元將使用者堆疊指標(TSS中的ss,sp欄位,這代表的是使用者棧指標)壓入棧,ESP已經指向核心棧,所以入棧指的的是入核心棧。
  4. cpu控制單元依次壓入EFLAGS、CS、EIP、Error Code(如果有的話)。此時核心棧指標ESP位置見圖4中的ESP After Transfer to Handler。

    圖4 Stack Usage with Privilege-Level Change

這裡需要做個額外說明,我們這裡的場景是從使用者態進入核心態,所以圖4是描繪得是有特權級變化時硬體控制單元自動壓棧的一些暫存器。如果沒有特權級變化,硬體控制單元自動壓棧的暫存器見圖5。

圖5 Stack Usage with No Privilege-Level Change

圖4、5區別在於如果沒有發生特權級變化,硬體控制單元不會壓棧SS、ESP暫存器,這2個暫存器共佔用8個記憶體單元,如果不在核心棧高階地址處保留8個bytes,將會導致pt_regs->SS、pt_regs->ESP訪問到核心棧頂端以外的地址處,也就是與核心棧高階地址相鄰的另一個頁中,導致缺頁異常,這是一個核心bug。高階地址保留8個bytes,pt_regs->SS、pt_regs->ESP會訪問到保留的8個位元組單元,雖然其中的值是無效的,但是不會觸發核心異常。

其他的暫存器是軟體方式儲存到棧上的,軟體壓棧的程式碼在linux-3.4.5/arch/x86/kernel/entry_32.S中,見SAVE_ALL巨集:

.macro SAVE_ALL
        cld
        PUSH_GS
        pushl_cfi %fs
        /*CFI_REL_OFFSET fs, 0;*/
        pushl_cfi %es
        /*CFI_REL_OFFSET es, 0;*/
        pushl_cfi %ds
        /*CFI_REL_OFFSET ds, 0;*/
        pushl_cfi %eax
        CFI_REL_OFFSET eax, 0
        pushl_cfi %ebp
        CFI_REL_OFFSET ebp, 0
        pushl_cfi %edi
        CFI_REL_OFFSET edi, 0
        pushl_cfi %esi
        CFI_REL_OFFSET esi, 0
        pushl_cfi %edx
        CFI_REL_OFFSET edx, 0
        pushl_cfi %ecx
        CFI_REL_OFFSET ecx, 0
        pushl_cfi %ebx
        CFI_REL_OFFSET ebx, 0
        movl $(__USER_DS), %edx
        movl %edx, %ds
        movl %edx, %es
        movl $(__KERNEL_PERCPU), %edx
        movl %edx, %fs
        SET_KERNEL_GS %edx
.endm


另外說明一下,SAVE_ALL巨集壓棧的暫存器順序與struct pt_regs中暫存器定義的順序是一樣的(struct pt_regs中高地址部分是硬體控制單元自動壓棧,與SAVE_ALL無關,參考圖3),整個struct pt_regs稱為hardware stack frame,定義在linux-3.4.5/arch/x86/include/asm/ptrace.h中:

struct pt_regs {
        long ebx;
        long ecx;
        long edx;
        long esi;
        long edi;
        long ebp;
        long eax;
        int  xds;
        int  xes;
        int  xfs;
        int  xgs;
        long orig_eax;
        long eip;
        int  xcs;
        long eflags;
        long esp;
        int  xss;
};

三、程序切換程式碼實現

執行context_switch彙編時,ESP已經指向核心棧(見上文)。其他通用暫存器在進入異常中斷後或者進入system_call時,通過SAVE_ALL儲存至核心棧。

         參考linux-3.4.5/arch/x86/kernel/entry_32.S檔案:

ENTRY(system_call)
        RING0_INT_FRAME                 # can't unwind into user space anyway
        pushl_cfi %eax                  # save orig_eax
        SAVE_ALL
        GET_THREAD_INFO(%ebp)
接著,核心空間返回使用者空間前會檢查TIF_NEED_RESCHED標記,如果有這個標記,就好呼叫schedule( )執行程序切換。           schedule( ) --> __schedule( ) --> context_switch --> switch_to 巨集完成棧、硬體暫存器的儲存、恢復。 
/*
 * Saving eflags is important. It switches not only IOPL between tasks,
 * it also protects other tasks from NT leaking through sysenter etc.
 */
#define switch_to(prev, next, last)                                     \
do {                                                                    \
        /*                                                              \
         * Context-switching clobbers all registers, so we clobber      \
         * them explicitly, via unused output variables.                \
         * (EAX and EBP is not listed because EBP is saved/restored     \
         * explicitly for wchan access and EAX is the return value of   \
         * __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                                    \
                     "jmp __switch_to\n"        /* regparm call  */     \
                     "1:\t"                                             \
                     "popl %%ebp\n\t"           /* restore EBP   */     \
                     "popfl\n"                  /* restore flags */     \
                                                                        \
                     /* output parameters */                            \
                     : [prev_sp] "=m" (prev->thread.sp),                \
                       [prev_ip] "=m" (prev->thread.ip),                \
                       "=a" (last),                                     \
                                                                        \
                       /* clobbered output registers: */                \
                       "=b" (ebx), "=c" (ecx), "=d" (edx),              \
                       "=S" (esi), "=D" (edi)                           \
                                                                        \
                       __switch_canary_oparam                           \
                                                                        \
                       /* input parameters: */                          \
                     : [next_sp]  "m" (next->thread.sp),                \
                       [next_ip]  "m" (next->thread.ip),                \
                                                                        \
                       /* regparm parameters for __switch_to(): */      \
                       [prev]     "a" (prev),                           \
                       [next]     "d" (next)                            \
                                                                        \
                       __switch_canary_iparam                           \
                                                                        \
                     : /* reloaded segment registers */                 \
                        "memory");                                      \
} while (0)

16行    push將eflags暫存器壓入prev核心棧(不是使用者棧,因為ESP已經指向核心棧。也不是nex核心棧,因為這時ESP指向的是prev程序核心棧)。在圖4中,eflags暫存器由cpu控制單元自動壓入棧中,這裡為什麼還要用軟體再壓一次呢?查了linux2.6.32版本中是沒有這條指令的,不過到了linux3.4.5中增加了這條指令。猜想是因為某些cpu architecture硬體沒有把eflags壓棧,所以這裡通過軟體方式壓棧?

          17行    ebp壓入prev核心棧。

          18行    把esp值複製到prev->thread.sp。

          19行    把next->thread.sp賦給esp。這個時候棧指標為next核心棧。所以current巨集就已經程式碼next程序了(current巨集是把esp低13位遮蔽得到的)。

          20行    prev->thread.ip設為1標號處。這是prev恢復執行後,第一條指令的執行地址。

          21行    next->thread.ip(標號1地址)壓入next核心棧。

          23行    jmp至__switch_to函式,這個函式設定cpu硬體暫存器。__switch_to返回時自動把next核心棧中的ip指標pop出來(21行壓入的),即標號1地址。所以__switch_to返回後,程式碼從24行開始執行。

          24~26行    恢復next程序ebp、eflags。

          這個時候,可以看到prev的暫存器及使用者棧等資訊已經都儲存在prev核心棧或prev->thread.sp中,next程序的硬體資訊及棧資訊已經恢復,所以此刻,已經可以安全地執行next程序了。

四、switch_to(prev, next, last)為什麼存在第3個變數

首先看switch_to巨集是如何呼叫的。

/*
 * context_switch - switch to the new MM and the new
 * thread's register state.
 */
static inline void
context_switch(struct rq *rq, struct task_struct *prev,
               struct task_struct *next)
{
    ....
    switch_to(prev, next, prev);
    ....
    finish_task_switch(this_rq(), prev);
}

所以,switch_to中的第三個引數其實就是struct task_struct *prev,注意這是個指標。另外還必須注意在switch_to後面,finish_task_switch還會用到prev。

        參考switch_to巨集實現,switch_to中只有31行把暫存器eax的值賦給了last(last是struct task_struct *prev,相當於改變了指標prev)。那麼eax中值又是什麼呢?彙編的輸入部分44行把prev賦給了eax。綜合起來就是:switch_to執行前,prev存在eax中,執行完後,eax賦給prev,這就是說如果在執行期間prev被改變,或者因其他因素導致prev改變,那麼執行完後prev還是會恢復成執行前的值。前面說過,context_switch執行完switch_to切換到新程序中,還需要用到prev,所以必須保證prev不能變。

        核心既然這樣設計,說明prev可能會改變(沒有last引數的話),看圖6,程序A切換到程序B執行,經過N次排程後,當前執行程序為C,此時需要將C切換到A。

圖6 程序切換後保留對prev的引用

  switch_to(A, B, A)時,在程序A棧中prev = A, next = B。

        switch_to(C, A, C)切換到A中後,根據context->switch --> finish_task_switch要求,prev必須為切換前的程序C。

        假定swtich_to沒有第三個引數last,那麼當switch_to(C, A, C)切換至A後,A棧中的prev = A(因為已經切換到A程序,所以prev用的是A棧中的區域性變數prev),並不是C,邏輯上就出問題了。

        switch_to是如何解決這個問題的呢,看switch_to(C, A, C)的44行和31行,執行完後prev被改成了C。

五、struct thread_stuct中的sp與sp0

struct thread_struct {
	/* Cached TLS descriptors: */
	struct desc_struct	tls_array[GDT_ENTRY_TLS_ENTRIES];
	unsigned long		sp0;
	unsigned long		sp;

        在解釋這2個欄位之前,先看看copy_thread函式,程式碼在linux-3.4.5/arch/x86/kernel/process_32.c中。

int copy_thread(unsigned long clone_flags, unsigned long sp,
        unsigned long unused,
        struct task_struct *p, struct pt_regs *regs)
{
        struct pt_regs *childregs;
        struct task_struct *tsk;
        int err;

        childregs = task_pt_regs(p);
        *childregs = *regs;
        childregs->ax = 0;
        childregs->sp = sp;

        p->thread.sp = (unsigned long) childregs;
        p->thread.sp0 = (unsigned long) (childregs+1);

        p->thread.ip = (unsigned long) ret_from_fork;

先解釋一下task_pt_regs,在前面的描述中,核心棧高地址部分壓入了通用暫存器及使用者棧指標資訊,這些暫存器作為一個整體pt_regs存放在棧高地址部分(核心struct pt_regs結構)。task_pt_regs返回的就是pt_regs的起始地址。

/*
 * The below -8 is to reserve 8 bytes on top of the ring0 stack.
 * This is necessary to guarantee that the entire "struct pt_regs"
 * is accessible even if the CPU haven't stored the SS/ESP registers
 * on the stack (interrupt gate does not save these registers
 * when switching to the same priv ring).
 * Therefore beware: accessing the ss/esp fields of the
 * "struct pt_regs" is possible, but they may contain the
 * completely wrong values.
 */
#define task_pt_regs(task)                                             \
({                                                                     \
       struct pt_regs *__regs__;                                       \
       __regs__ = (struct pt_regs *)(KSTK_TOP(task_stack_page(task))-8); \
       __regs__ - 1;                                                   \
})
KSTK_TOP(task_stack_page(task)返回核心棧高階地址處的地址值,其中-8表示從高階地址處往下偏移8個位元組,參考圖3。
那麼什麼需要保留8個位元組呢?這是在2005年提交的一個patch,為了解決一個bug:

commit 5df240826c90afdc7956f55a004ea6b702df9203  
    [PATCH] fix crash in entry.S restore_all  
        Fix the access-above-bottom-of-stack crash.

對於這個bug我的理解是:在圖5中,如果沒有特權級變化(比如說在核心態中,來了一箇中斷),硬體控制單元是不會壓棧儲存SS、ESP暫存器的,如果不保留8個位元組,那麼我們看到的核心棧見圖7: 圖7 內核棧沒有儲存8個位元組空間 圖中左邊核心棧中pt_regs並不含有右邊紅字暫存器xss、esp的值,此時,如果程式碼訪問pt_regs->xss或者pt_regs->esp,必然訪問到核心棧頂端的虛線框地址單元處,而這兩個單元不屬於核心棧範圍,所以會導致crash。保留8 bytes記憶體單元,雖然避免了crash,但是需要注意如果沒有特權級變化,讀到的xss、esp的值是無效的。根據copy_thread函式中:
childregs = task_pt_regs(p);
p->thread.sp = (unsigned long) childregs;
p->thread.sp0 = (unsigned long) (childregs+1);
可以知道sp、sp0的指向位置示意圖如下:

圖8 sp、sp0指向位置示意圖