1 进程切换之前的工作
由于每个进程共享CPU寄存器(咱们就当是单CPU吧),因此在恢复一个进程之前,内核必须确保每个寄存器装入挂起进程时的值,他们就叫“硬件上下文”。在Linux中,进程硬件上下文得一部分放在TSS段,主要用于对内核态堆栈进行寻址,而剩余部分放在内核态堆栈中。
我们再回忆一下硬件中TSS段的内容:
struct tss_struct {
unsigned short back_link,__blh;
unsigned long esp0; /*当前进程的内核栈栈顶地址偏移 */
unsigned short ss0,__ss0h; /* 内核栈段描述符值,系统初始化后整个运行期间不被改变 */
unsigned long esp1;
unsigned short ss1,__ss1h; /* ss1 is used to cache MSR_IA32_SYSENTER_CS */
unsigned long esp2;
unsigned short ss2,__ss2h;
unsigned long __cr3;
unsigned long eip;
unsigned long eflags;
unsigned long eax,ecx,edx,ebx;
unsigned long esp; /*当前进程用户态堆栈栈顶地址偏移 */
unsigned long ebp;
unsigned long esi;
unsigned long edi;
unsigned short es, __esh;
unsigned short cs, __csh;
unsigned short ss, __ssh;
unsigned short ds, __dsh;
unsigned short fs, __fsh;
unsigned short gs, __gsh;
unsigned short ldt, __ldth;
unsigned short trace, io_bitmap_base;
unsigned long io_bitmap[IO_BITMAP_LONGS + 1];
unsigned long io_bitmap_max;
struct thread_struct *io_bitmap_owner;
unsigned long __cacheline_filler[35];
unsigned long stack[64];
} __attribute__((packed));
为了方便起见,我们设prev为切换出的进程描述符,next为切换进得进程描述符,那么进程切换则定义为:保存prev硬件上下文,用next硬件上下文代替(我们在后面的博文会看到prev和next是调度函数schedule()的局部变量)。内核2.6以前是直接利用80x86的far jmp指令跳到next进程的TSS描述符的选择符来执行进程切换,而2.6则使用软件执行进程切换。
进程切换只发生在内核态。在执行切换之前,即通过中断的方式执行系统调用由用户态进入内核态时,linux从用户态转移到内核态有三个途径,系统调用,中断,异常,对应的代码在/arch/i386/kernel/entry.S中。用户进程使用的所有寄存器内容都已被SAVE_ALL汇编指令压入内核态堆栈:
#define SAVE_ALL /
cld; /
pushl %es; /
pushl %ds; /
pushl %eax; /
pushl %ebp; /
pushl %edi; /
pushl %esi; /
pushl %edx; /
pushl %ecx; /
pushl %ebx; /
movl $(__USER_DS), %edx; /
movl %edx, %ds; /
movl %edx, %es;
注意倒数三行这里一个问题,为啥进入内核态后,却要把用户态的数据段选择符__USER_DS装载到ds和es寄存器中呢?这岂不是胡闹吗?回忆一下,linux之所以设置什么ds,cs段选择符也是例行公事,完全没有必要,要明白为什么这里将ds设置成__USER_DS,还要先回忆ds是干什么用的,ds是数据段寄存器,cs是代码段寄存器,ds的保护作用是:如果你访问的段,要求的访问级别高于当前进程的代码段级别的话,访问会导致GP异常。现在进入了内核,当前代码段的访问级别为0,处于最高级别,它访问任何段都不会出错,再者,linux为了减少复杂性,只用了2个级别,那么就没有任何问题了,如果要像intel规定的那样将ds寄存器设置成 __KERNEL_DS,那么看看从内核返回的时候:恢复CS,EIP的值,此时CS的CPL是3。如果DS、ES被设为了__KERNEL_DS,其DPL是0,则要将DS,ES中的值清除。这样就要多一个清除动作,损失了效率,因此就先将ds段寄存器设置成__USER_DS,这么做的一切都是intel的什么狗屁分段机制造成的,汗!
另外,SAVE_ALL中没有对eflags、cs、eip、ss和esp这些用户态指令、堆栈指针地址的寄存器内容保存到栈中,这又是为什么呢?我们再回忆一下Linux内核入门(五)——必要的硬件知识博文中,当执行了一条指令后,CS和eip这对寄存器包含下一条将要执行的指令的逻辑地址。在处理那条指令之前,控制单元会检查在运行前一条指令时是否已经发生了一个中断或异常。如果发生了一个中断或异常(这里是系统调用,相当于中断),那么控制单元将检查是否发生了特权级的变化。如果是(这里肯定是,因为是由用户代码段向内核代码段寻址),控制单元必须开始使用与新的特权级相关的指令段和栈。这个过程包括从TSS段中装载ss0和esp0寄存器,在新的内核态堆栈中保存用户态的ss和esp的值,用引起异常的指令地址装载CS和eip寄存器,在栈中保存eflags、CS及eip的内容。
这里再次强调一下,每个进程都有一个thread_struct结构的字段thread,用于保留进程的一部分硬件上下文:
struct thread_struct {
struct desc_struct tls_array[GDT_ENTRY_TLS_ENTRIES];
unsigned long esp0;
unsigned long sysenter_cs;
unsigned long eip;
unsigned long esp;
unsigned long fs;
unsigned long gs;
unsigned long debugreg[8];
unsigned long cr2, trap_no, error_code;
union i387_union i387;
struct vm86_struct __user * vm86_info;
unsigned long screen_bitmap;
unsigned long v86flags, v86mask, saved_esp0;
unsigned int saved_fs, saved_gs;
unsigned long *io_bitmap_ptr;
unsigned long io_bitmap_max;
};
待会我们就将看到,其实里边最有用的就是eip和esp两个字段,分别表示保存的指令和堆栈栈顶的偏移地址。
但这里不包括ss寄存器的值,因为所有的切换都在内核态,那么只用为内核态的堆栈基址ss0保存一次就行了,你们这些进程切来切去,我都不便应万变,把它保存在每个CPU所对应的那个TSS段中。这里也得出了一个很重要的结论,为了提高效率,所有内核态的进程堆栈段基地址都是相同的。而堆栈,由于其具有后进先出的特点,其作用也主要是用于保存寄存器地址以及代码的临时内部变量。所以,我们就为每个进程保存esp0和esp就行了(esp0表示0级、内核级堆栈栈顶;esp表示用户级堆栈栈顶)。这就是为什么Linux内核的SS0始终为__KERNEL_DS保持不变,无需每次切换都将其保存到init_tss.ss0中的原因了,只需要在Linux内核初始化时将init_tss.ss0设置为__KERNEL_DS就可以了。如果要回到用户态,再从堆栈中得到ss和esp寄存器的值,就OK了。
#define INIT_TSS { /
.esp0 = sizeof(init_stack) + (long)&init_stack, /
.ss0 = __KERNEL_DS, /
.ss1 = __KERNEL_CS, /
.io_bitmap_base = INVALID_IO_BITMAP_OFFSET, /
.io_bitmap = { [ 0 ... IO_BITMAP_LONGS] = ~0 }, /
}
#define __KERNEL_DS (GDT_ENTRY_KERNEL_DS * 8)
#define GDT_ENTRY_KERNEL_DS (GDT_ENTRY_KERNEL_BASE + 1)
#define GDT_ENTRY_KERNEL_BASE 12
所以,预备知识中的必要的硬件知识很重要,希望大家在全面的分析内核之前,一定要把那里面的知识搞透!
进程切换一般发生在调度程序schedule()函数执行之后,这个函数很重要,也很复杂,所以在这里我们只讨论内核如何执行一个进程切换。至于调度程序的其他细节,后面的博文我们会详细讨论。
简单地说,每个进程切换由两步组成:
1. 切换页全局目录以安装一个新的地址空间;
2. 切换内核态堆栈和硬件上下文,因为硬件上下文提供了内核执行新进程所需要得所以信息,包含CPU寄存器。
2 真正的进程切换实务 —— switch_to宏
进程切换的实务工作由switch_to宏执行。它是内核中与硬件关系最密切的例程之一,要理解它到底做来些什么我们必须下些功夫:
#define switch_to(prev,next,last) do { /
unsigned long esi,edi; /
asm volatile("pushfl/n/t" /* Save flags */ /
"pushl %%ebp/n/t" /
"movl %%esp,%0/n/t" /* save ESP */ /
"movl %5,%%esp/n/t" /* restore ESP */ /
"movl $1f,%1/n/t" /* save EIP */ /
"pushl %6/n/t" /* restore EIP */ /
"jmp __switch_to/n" /
"1:/t" /
"popl %%ebp/n/t" /
"popfl" /
:"=m" (prev->thread.esp),"=m" (prev->thread.eip), /
"=a" (last),"=S" (esi),"=D" (edi) /
:"m" (next->thread.esp),"m" (next->thread.eip), /
"2" (prev), "d" (next)); /
} while (0)
首先switch_to宏有三个参数:prev、next和last。前两个参数大家肯定都猜到了,不错,是替换进程和新进程的task_struct。那么第三个参数呢?其实在任何进程切换中,涉及到的是三个进程而不是两个。假设内核决定暂停A和激活B进程,在调度中,prev和next分别指向A和B的进程描述符。switch_to宏一旦使A暂停,A的执行就冻结了。随后,当内核想再次激活A,就必须暂停另一个进程C。于是就要用prev指向C而next指向A来指向另一个switch_to宏。当A恢复它的执行时,就会找它原来的内核栈,其局部变量prev又指向A而next指向B,C就断了。switch_to宏的最后一个参数是输出参数,它表示C的描述符地址在A恢复执行后的内存位置。在进程切换之前,宏把第一个输入参数prev(即在A的内核栈中分配的prev局部变量)表示的变量的内容存入eax寄存器。在完成进程切换后,A已恢复执行时,宏把CPU的eax寄存器的内容写入第三个参数last所指示的A在内存中的位置。因为CPU寄存器不会在切换点发生变化,所以C的描述符地址也存在内存的找个位置。在schedule()执行过程中,参数last指向A的局部变量prev,所以prev被C的地址所覆盖。
实在看晕了就仔细琢磨琢磨下面的图例:
如果还是看不懂,就可以别去理它了,因为最新的内核中last参数已经不起作用了,留着只是与以前兼容。
下面,我们就来详细分析switch_to宏的具体步骤:
1. 把eflags和ebp寄存器内容压入prev所对应的内核栈栈顶:
pushfl
pushl %%ebp
2. 把esp的内容保存到prev->thread.esp中以使该字段指向prev内核栈的栈顶:
movl %5,%%esp
484(%eax)操作数表示内存单元的地址为eax内容加上484。
3. 把next->thread.esp装入esp。此时,内核开始在next的内核栈上操作,因此这条指令实际上完成了从prev向next的切换:
movl next->thread.esp, %%esp
4. 向prev->thread.eip存入标记为1的地址。当被替换的进程重新恢复执行时,进程执行我们下面标记为1的那条指令:
movl $1f, prev->thread.eip
5. 宏把next->thread.eip的值(绝大多数情况下是上面所述标记为1的地址)压入next的内核栈:
pushl next->thread.eip
注意体会,当next执行完了以后的函数后,会回到这个栈的位置,执行eip对应的那条指令。
6. 跳到__switch_to()函数:
jmp __switch_to
7. 如干程序执行后,当A将再次获得CPU时,它执行一些保存eflags和ebp寄存器内容内容的指令,这两条指令的第一条指令被标记为1:
1:
popl %ebp
popfl
主意,这些pop指令是怎样引用prev进程的内核栈的。当进程调度程序再次选择prev作为新进程在CPU上运行时,将执行这些指令。于是,以prev作为第二个参数调用switch_to宏。因此,esp寄存器指向prev的内核栈。
3 __switch_to函数
__switch_to()函数执行大多数switch_to()宏的进程切换。这个函数作用于prev_p和next_p参数,这两个参数表示前一个进程和新进程。这个函数的调用不同于一般函数,因为__switch_to()从eax和edx取参数prev_p和next_p,而不像大多数函数那样从栈中取参数。为了强迫函数从寄存器取出它的参数,内核利用__attribute__和regparm关键字,这两个关键字是C语言非标准的扩展名,由gcc编译程序实现。__switch_to()函数的内容如下:
struct task_struct fastcall * __switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
struct thread_struct *prev = &prev_p->thread,
*next = &next_p->thread;
int cpu = smp_processor_id();
struct tss_struct *tss = &per_cpu(init_tss, cpu);
__unlazy_fpu(prev_p);
if (next_p->mm)
load_user_cs_desc(cpu, next_p->mm);
load_esp0(tss, next);
savesegment(fs, prev->fs);
savesegment(gs, prev->gs);
load_TLS(next, cpu);
if (unlikely(prev->fs | next->fs))
loadsegment(fs, next->fs);
if (prev->gs | next->gs)
loadsegment(gs, next->gs);
if (unlikely(prev->iopl != next->iopl))
set_iopl_mask(next->iopl);
if (unlikely((task_thread_info(next_p)->flags & _TIF_WORK_CTXSW)
|| test_tsk_thread_flag(prev_p, TIF_IO_BITMAP)))
__switch_to_xtra(next_p, tss);
disable_tsc(prev_p, next_p);
return prev_p;
}
函数执行以下步骤:
1. 执行由__unlazy_fpu()宏产生的代码,以有选择地保存prev_p进程的FPU、MMX及XMM寄存器内容:__unlazy_fpu(prev_p)
2. 执行smp_processor_id()宏获得本地(local)CPU的下标,即执行代码的CPU。该宏从当前进程的thread_info结构中的cpu字段获得下标并将它保存到cpu局部变量。
3. 把next_p->thread.esp0装入对应于本地CPU的TSS的esp0字段(load_esp0(tss, next));我们将在“通过sysenter指令发生系统调用”博文看到,以后任何由sysenter汇编指令产生从用户态到内核态的特权级转换将把这个地址拷贝到TSS段中esp寄存器对应的那个字段:
init_tss[cpu].esp0 = next_p->thread.esp0;
4. 把next_p进程使用的线程存储TLS段装入本地CPU的全局描述符表;三个段选择符保存在进程描述符的tls_array数组中(load_TLS(next, cpu)):
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寄存器指向prey_p->thread结构。
6. 如果fs或gs段寄存器已经被prev_p或next_p进程中的任意一个使用(也就是说如果它们有一个非0值),则将next_p进程的thread_struct描述符中保存的值装入这些寄存器中。这一步在逻辑上补充了前一步中执行的操作。主要的汇编语言指令如下:
movl 40(%ebx), %fs
movl 44(%ebx), %fs
ebx寄存器指向next_p->thread结构。代码实际上更复杂,因为当它检测到一个无效的段寄存器值时,CPU可能产生一个异常。代码采用一种“修正(fix-up)”途径来考虑这种可能性。
7. 用next_p->thread.debugreg数组的动态内容装载dr0,…,dr7中的6个调试寄存器。只有在next_p被挂起时正在使用调试寄存器(也就是说,next_p->thread.debugreg[7]字段不为0),这种操作才能进行。这些寄存器不需要被保存,因为只有当一个调试器想要监控prev时prey_p->thread.debugreg才会被修改:
if (unlikely(next->debugreg[7])) {
loaddebug(next, 0);
loaddebug(next, 1);
loaddebug(next, 2);
loaddebug(next, 3);
/* no 4 and 5 */
loaddebug(next, 6);
loaddebug(next, 7);
}
8. 如果有必要,更新TSS中的I/O位图。当prev_p或next_p有其自己的定制I/O权限位图时必须这么做:
if (unlikely(prev->io_bitmap_ptr || next->io_bitmap_ptr))
handle_io_bitmap(next, tss);
因为进程很少修改I/O权限位图,所以该位图在“懒”模式中被处理:当且仅当一个进程在当前时间片内实际访问I/O端口时,真实位图才被拷贝到本地CPU的TSS中。进程的定制I/O权限位图被保存在thread_info结构的io_bitmap_ptr字段指向的缓冲区中。handle_io_bitmap()函数为next_p进程设置本地CPU使用的TSS的io_bitmap字段如下:
a) 如果next_p进程不拥有自己的I/O权限位图,则TSS的io_bitmap字段被设为0x8000。
b) 如果next_p进程拥有自己的I/O权限位图,则TSS的io_bitmap字段被设为0x9000。
TSS的io_bitmap字段应当包含一个在TSS中的偏移量,其中存放实际位图。无论何时用户态进程试图访问一个I/O端口,0x8000和0x9000指向TSS界限之外并将因此引起“General protection”异常(参见第四章的“异常”一节)。do_general_protection()异常处理程序将检查保存在io_bitmap字段的值:如果是0x8000,函数发送一个SIGSEGV信号给用户态进程;如果是0x9000,函数把进程位图(由thread_info结构中的io_bitmap_ptr字段指示)拷贝到本地CPU的TSS中,把io_bitmap字段设为实际位图的偏移(104),并强制再一次执行有缺陷的汇编语言指令。
9. 终止。__switch_to() C函数通过使用下列声明结束:
return prev_p;
由编译器产生的相应汇编语言指令是:
movl %edi,%eax
ret
prev_p参数(在edi中)被拷贝到eax,因为缺省情况下任何C函数的返回值被传递给eax寄存器。注意eax的值因此在调用__switch_to()的过程中被保护起来;这非常重要,因为调用switch_to宏时会假定eax总是用来存放将被替换的进程描述符的地址。
汇编语言指令ret把栈顶保存的返回地址装入eip程序计数器。不过,通过简单地跳转到__switch_to()函数来调用该函数。因此,ret汇编指令在栈中找到标号为1的指令的地址,其中标号为1的地址是由switch_to()宏推入栈中的。如果因为next_p第一次执行而以前从未被挂起,__switch_to()就找到ret_from fork()函数的起始地址(参见后面“fork()和vfork()系统区别”博文)。