5.4.2 进程的投入运行
丑媳妇总是要见公婆的,进程最终一定是要切换到用户空间的,进程1也躲不过去。在内核创建进程1时,进程0是当前进程,因此,进程1要“回到”用户空间,需要经过两个步骤:
1)要将进程0赶出CPU。也就是在内核空间,进程1要“恢复”为当前进程,这是进程1“返回”用户空间的前提条件。
2)进程1从内核空间“回到”用户空间。
显然,从进程0“恢复”到进程1需要进程1在内核空间的现场;进程1从内核空间“回到”用户空间需要进程1在用户空间的现场。但是,事实上,不仅是进程1,对于所有刚刚创建的进程,它们并没有经历过从用户空间切到内核空间,然后在内核空间被其他进程抢占的过程,哪里来的保护现场?所以,就需要操作系统助它们一臂之力,人为地为新创建的进程伪造现场。
这一节,我们首先来看看在这两次转换过程中,保护现场的原理。然后,我们再来讨论内核是如何在原理的指引下伪造这两个现场的。事实上,不仅进程1如此,其他进程也如此,这里的讨论适用于所有进程。
1.用户现场的保护
我们通过讨论一个进程从用户空间切换到内核空间来观察用户现场是如何保护的。
(1)从用户栈切换到内核栈
当一个进程正在用户空间运行时,一旦发生中断,那么进程将从用户空间切换到内核空间运行。进程在内核空间运行时,CPU各个寄存器同样将被使用,因此,为了在处理完中断后,程序可以在用户空间的中断处得以继续执行,需要在穿越的一刻保护这些寄存器的值,以免被覆盖,即所谓的保护现场。Linux使用进程的内核栈保存进程的用户现场。因此,在中断时,CPU做的第一件事就是将栈从用户栈切换到内核栈,如图5-23所示。
图 5-23 从用户栈切换到内核栈
Intel从硬件层面设计了TSS段(task-state segment)支持任务的管理,其中记录了任务的状态信息。既然是一个段,每个TSS也在GDT中占据一个表项。如同代码段将段选择子保存在寄存器CS中,CPU要求将TSS段选择子保存在专用寄存器TR(Task Register)中。
Intel建议为每一个进程准备一个独立的TSS段,每当任务切换时,更换CPU中TR寄存器指向当前任务的TSS段。天下没有免费的午餐,方便的代价就是效率的低下。而事实上,任务切换时,本不必保存如TSS中包含的如此复杂的上下文,而且TR寄存器的格式比较特殊,远非直接装入一个地址那么简单,还需要进行一些计算,因此切换TR的代价也比较大。因此,Linux并没有使用TSS段保存任务状态信息。
但是当中断发生时,CPU自动从任务寄存器TR中找到TSS段,然后从该段中装载ss和esp。“我的地盘听我的”,所以Linux还要遵从Intel的“霸王”条款,必须得使用TSS。但是Linux处理得比较巧妙,其并没有为每个任务设计一个TSS段,而是为每个CPU准备一个TSS段。内核只是在初始化阶段设置TR,使之指向一个TSS段,从此以后永不改变TR的内容。TSS段的初始内容如下:
内核初始化时,在函数cpu_init中初始化了TR寄存器,代码如下:
其中,函数set_tss_desc设置GDT中的TSS段的描述符,函数load_TR_desc设置TR寄存器。
虽然TSS不需要切换了,但是TSS中的ss和sp0却需要随着任务的切换,走马灯式地更换,记录下一个投入运行的任务的内核栈的ss和sp0。这样,就保证了在中断发生时,CPU可以正确地从TSS中加载当前进程内核栈的ss和esp。在宏INIT_TSS中,TSS段中记录的内核栈的ss0被设置为__KERNEL_DS,所以这里ss0永远不需要改变,只需切换sp0即可。可见,TSS是“铁打的营盘,流水的兵”。
但是不知道读者思考过没有,当中断发生时,当CPU从TSS段中加载ss0和sp0分别到ss和esp时,尚未保存用户现场,那么此时保存在ss和esp中的用户栈的信息岂不是被覆盖了?Intel的工程师们当然清楚这一点,事实上,CPU在加载内核栈信息前,会将寄存器ss和esp中的值首先临时保存到CPU内部,除了保存寄存器ss和esp的值外,CPU临时保存的还包括寄存器eflags、cs、eip中的值。
经过这一步后,进程已经完成了栈的切换,进程在向内核空间前进的道路上迈出了第一步。
(2)保存用户空间的现场
切换完栈后,CPU在进程的内核栈中保存了进程在用户空间执行时的现场信息,包括eflags、cs、eip、ss和esp,如图5-24所示。
图 5-24 保存用户空间的现场
在进程退出内核空间时,中段处理函数最后会调用x86的指令iret将CPU压入的这几个值恢复到对应的寄存器中。
(3)穿越中断门
接下来,进程就将进行最后的穿越了,当然,内核在初始化时就已经为CPU初始化了中断相关的部分,代码如下:
代码第6~26行初始化中断描述符表idt_table,其包含256项,每一项均是一个64位的描述符。CPU运行过程中,可能有多种情况需要中断正在执行的指令,转而先去处理中断。包括外部设备来的信号,或者是执行指令时发生了异常,如发生了除数是0的情况。因此,中断描述符表中包括几种不同的描述符,但是大同小异。这些描述符也被称为门描述符(gate descriptor),以中断门为例,其格式如图5-25所示。
图 5-25 中断门格式
对于图5-25,重点关注其中两个字段,一个是"Segment Selector",这个是段选择子,也就是对应这个中断的处理函数所在的段;另外一个是"Offset",其表示的是中断处理函数在段内的偏移。因为Linux使用的是平坦内存模式,段基址为0,所以实际上这个段内偏移就是中断处理函数的地址。
上面代码中包含两个loop循环,填充了256项中断描述符。每个门的段选择子都是__KERNEL_CS,只有中断处理函数不同。前NUM_EXCEPTION_VECTORS项对应的中断处理函数是early_idt_handlers,其余项的中断处理函数是ignore_int。这两个函数都是内核初始化早期的临时中断处理函数,在内核建立好基本环境后,会使用真正的中断处理函数替换这些临时的,代码如下:
中断描述符表构建完成后,内核还需要将其地址告诉CPU,CPU中为此设计了一个专用寄存器idtr。除了中断描述表的地址外,当然还需要将这个表长度也载入这个寄存器。x86设计了指令lidt来加载idtr寄存器,见函数startup_32代码中的第3行。
了解了中断门的数据结构后,我们就很容易理解在穿越中断门的一刹那,CPU的所作所为了。CPU首先将根据寄存器IDTR,找到中断描述符表。然后以中断向量作为下标,在中断描述符表中找到对应的门,CPU将其中的段选择子加载到寄存器cs,将其中的偏移地址加载到寄存器eip,如图5-26所示。
图 5-26 穿越中断门
经过这一步后,进程彻底的穿越到了内核空间。
因为Linux没有使用TSS,所以除了CPU自动将cs、eip等几个寄存器压入栈外,中断处理函数需要将其他的寄存器也压入内核栈,保存进程完整的用户空间的现场。在进程退出内核空间时,中段处理函数负责将其压入栈的这些值再恢复到相应的寄存器中。
2.内核现场的保护
当进程在内核空间运行时,在发生进程切换时,依然需要保护切换走的进程的现场,这是其下次运行的起点。那么进程的内核现场保存在哪里合适呢?前面我们看到进程的用户现场保存在进程的内核栈,那么进程的内核现场当然也可以保存在进程的内核栈。
但是,当调度函数准备切换到下一个进程时,下一个进程的内核栈的栈指针从何而来?在前面讨论进程从用户空间切换到内核空间时,我们看到,CPU从进程的TSS段中获取内核栈的栈指针。那么当在内核空间发生切换时,调度函数如何找到准备切入进程的内核栈的栈指针?
除了进程的内核栈外,进程在内核中始终存在另外一个数据结构——进程的户口,即任务结构。因此,进程的内核栈的栈指针可以保存在进程的任务结构中。在任务结构中,特意抽象了一个结构体thread_struct来保存进程的内核栈的栈指针、返回地址等关键信息。
调度函数使用宏switch_to切换进程,我们来仔细观察以下这段代码,为了看起来更清晰,删除了代码中的注释:
在每次进程切换时,调度函数将准备切出的进程的寄存器esp中的值保存在其任务结构中,见第5行代码。然后从下一个投入运行的进程的任务结构中恢复esp,见第6行代码。除了栈指针外,程序下一次恢复运行时的地址也有一点点复杂,不仅仅是简单的保存eip中的值,有一些复杂情况需要考虑,比如稍后我们会看到对于新创建的进程,其恢复运行的地址的设置。所以调度函数也将eip保存到了任务结构中,第7行代码就是保存被切出进程下次恢复时的运行地址。第8行代码和第10行的jmp,以及函数__switch_to最后的ret指令联手将投入运行的进程的地址,即next->thread.ip,恢复到寄存器eip中。
除了eip、esp外,宏switch_to将其他寄存器如eflags、ebp等保存到了进程内核栈中。
每次中断时,CPU会从TSS段中取出当前进程的内核栈的栈指针,因此,当发生任务切换时,TSS段中的esp0的值也要更新为下一个投入运行任务的内核栈的栈指针。在宏switch_to中,即上面第10行代码处,调用函数__switch_to的目的就是设置TSS段中的esp0:
综上,进程在内核中的切换过程如图5-27所示,其中next表示即将投入运行的任务,prev表示当前任务,但是马上将被切出。被切出进程下一次恢复运行时的地址并不一定是就是当前指令指针中的地址,所以图中eip使用了虚线,其表达的意图就是进程恢复运行时的地址也保存在了进程的任务结构中。
图 5-27 内核中的进程切换
3.伪造现场
我们先来看看内核是如何伪造内核现场的。其实伪造内核现场只需要伪造三个关键的地方:进程恢复运行时的地址,即eip;进程的内核栈的栈指针,这个无需解释了,进程当然需要知道目前的栈顶在哪里;最后还要准备内核栈的栈底,为了日后在进程返回到用户空间后,发生中断时,CPU可以找到内核栈,从内核栈的栈底开始保存用户现场。
根据前面的讨论,如图5-27,在调度器调度下一个进程投入运行时,即宏switch_to中,将从下一个投入运行的进程的任务结构的thread_struct中加载sp到寄存器esp;加载ip到寄存器eip;并在这个宏调用的函数__switch_to中,将TSS段中的sp0指向thread_struct中的sp0。因此,要伪造这三个数据,只需设置任务结构中的结构体thread_struct中下面几个值即可:
进程的任务结构在复制进程时创建,因此,这几个数据在复制进程时伪造是再合适不过了。复制进程时,与结构体thread_struct相关的复制函数为copy_thread,其代码如下:
先来看一下结构体pt_regs,这个结构体就是为了解释内核栈底部保存的进程的用户现场而设计的,其中的字段完全按照压栈的各个寄存器的顺序设计。第3行代码中的宏task_pt_regs就是获取内核栈中pt_regs的,并使用childregs指向这个区域。
显然,第5行代码是在伪造栈指针。第6行代码是在为TSS段伪造内核栈的栈底。但是这两个变量的值可能让人有些困惑,我们通过图5-28来直观展示一下。
图 5-28 进程内核栈
根据图5-28可见,childregs是进程在内核态运行时使用的内核栈的栈底。childregs+1是在childregs的基础上,向地址增大方向偏移一个pt_regs的大小。也就是说,从childregs到childregs+1正是给伪造用户现场预留的空间。
函数copy_thread中的第10行和第14行都是在为新复制的进程伪造返回时的运行地址。只不过第10行是针对内核线程的,从进程0复制进程就属于这种情况。函数ret_from_kernel_thread与ret_from_fork唯一的不同是,ret_from_kernel_thread在返回到用户空间前,其将执行一个函数,然后才恢复进程的用户现场,具体如下:
PT_EBX(%esp)就是pt_regs中寄存器ebx处的值,显然,这个值是一个函数地址。那么,这个新复制的进程,在返回用户空间之前到底执行了一个什么函数呢?在函数copy_thread伪造eip时,其实已经设置了寄存器ebx的值:
寄存器ebx中保存的是函数copy_thread的第2个参数sp,我们再来看看这个参数是什么:
我们来回顾一下进程1的创建过程:
可见,寄存器ebx中保存的是函数kernel_init的地址。而恰恰是kernel_init,开启了创建进程的第二阶段,即exec的过程。也就是说,在复制进程后,在返回用户空间之前,内核开启了exec过程,加载可执行程序。与我们编写普通程序时,在复制之后,使用exec执行新的程序异曲同工。
显然,在加载可执行程序之后,是一个伪造用户现场的合理时机。因为,只有这个时候,才知道新执行的程序的入口地址,也就是进程切换到用户空间后的执行地址。而且,也只有在进程的地址空间建立好之后,才能知道进程的用户栈的位置。相关代码如下:
在加载了可执行文件后,函数load_elf_binary确定了进程在用户空间的入口elf_entry,也确定了进程的用户栈所在的位置bprm->p,然后调用函数start_thread伪造进程的用户空间的现场,代码如下:
在函数start_thread中,各个段寄存器均被设置为了用户空间的段,进程的栈也指向了用户空间的栈。于是,在ret_from_kernel_thread最后,从进程的内核栈恢复用户现场时,进程被彻底打回原形,从天上掉到了人间,进程又作回了凡人,在用户空间从入口地址elf_entry处开始执行。