linux进程切换(linux3.4.5,x86)
引言
本文描述linux x86的进程切换实现原理,叙述了寄存器、堆栈的备份与恢复操作。
Intel设计的意图是通过硬件方式切换进程,但是linux并没有使用这种方式,而是使用了软件方式,文章对这两种方式分别做了描述。
一、选择硬件切换还是软件切换?
- x86提供硬件切换方式switching task(早期内核版本采用)
图1 32-bit Task State Segment
图2 Task State Segmentx86在设计上有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不一定有。
- linux新版本内核采用软件方式切换进程
软件切换进程需要做3件事:
- CR3修改进程页目录指针,也就是改变进程的地址空间的映射信息。
- cpu寄存器的保存、恢复,这些寄存器是进程执行所必需的硬件信息。
- 进程堆栈信息的更改。
对于cpu而言,一旦设置Task Register指向TSS后,就不会再改变Task Register的值了,所以对于cpu来说,它认为永远是同一个进程在执行。不改变Task Register的值,就不会触发硬件自动保存、加载TSS的操作了,相当于抛弃了intel提供的硬件切换方式。保留TSS的概念,只是为了满足硬件限制而已,进程切换关心的是硬件执行环境----cpu寄存器的值,并不关心TSS。<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的寄存器?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(内核态)时,用户栈切换到内核栈。过程如下:
- 在发生中断、异常时前,程序运行在用户态,ESP指向的是Interrupted Procedure's Stack,即用户栈。
- 运行下一条指令前,检测到中断(x86不会在指令执行没有指向完期间响应中断)。从TSS中取出esp0字段(esp0代表的是内核栈指针,特权级0)赋给ESP,所以此时ESP指向了Handler's Stack,即内核栈。
- cpu控制单元将用户堆栈指针(TSS中的ss,sp字段,这代表的是用户栈指针)压入栈,ESP已经指向内核栈,所以入栈指的的是入内核栈。
- 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)
/*
* 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.
childregs = task_pt_regs(p);
p->thread.sp = (unsigned long) childregs;
p->thread.sp0 = (unsigned long) (childregs+1);
可以知道sp、sp0的指向位置示意图如下:
图8 sp、sp0指向位置示意图