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指向位置示意图


 
posted @ 2016-06-18 15:35  geshifei  阅读(97)  评论(0编辑  收藏  举报  来源