结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程
实验要求
结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
完成一篇博客总结分析Linux系统的一般执行过程,以期对Linux系统的整体运作形成一套逻辑自洽的模型,并能将所学的各种OS和Linux内核知识/原理融通进模型中。
关于中断和进程切换,有一个直观的例子。当我们打开一个命令行窗口,输入一条命令来执行某个程序时,如果这个程序是一个持续运行的或者需要长时间计算的程序,那么除非按下Ctrl+C打断程序或者等待程序执行完毕,我们是没有办法在命令行继续执行其他命令的。在这个过程中,键盘输入,程序加载,进程切换都起到了至关重要的作用,所以只有理解了中断和进程的切换,我们才能更加深刻地理解操作系统的运行机制。
计算机之所以能完成各种各样的功能,是因为存储在硬盘上的程序被加载到了内存,分配了相关的资源成为了进程实体之后,才能在后续获得CPU开始执行。由于不同的进程对于资源的需求是不同的,有的需要CPU计算资源多,有的需要I/O资源多一点,为了更好的利用系统资源,就需要对进程进行切换。
而对于进程切换,资源分配这样的特殊操作,肯定不是所有进程自身可以完成的。这就需要操作系统本身来执行特定的命令,如schedule函数发起进程调度和进程切换。而像这样特殊的程序代码必定不是所有的进程都可以访问到的,所以操作系统设计了两种进程执行环境:用户态和内核态。只有在内核态时,才能以更高的访问级别去执行特殊的代码。用户态和内核态的切换,是进行各种特殊操作的前提。
除此之外,进程在执行过程中,必定有各种数据和状态的变化,如果要切换进程,那对于现在在执行的进程,必须要把这些数据保存起来,以便之后进程再次获得了CPU资源之后进行执行。这也就是各种上下文环境的保存,很显然,我们可以料想到这样的操作必不可能是进程本身的代码可以实现的,这个时候就需要中断处理程序了,具体来说这个过程其实是CPU相关硬件和操作系统软件来共同完成的。
像进程切换这样的操作,是由操作系统来实现的,除此之外操作系统还有很多其他的功能,这些功能被封装在系统调用里面。系统调用需要从用户态切换到内核态,也被称为陷阱,是中断的一种情况,除此中断还有硬中断等其他情况。
用户调用了系统调用接口,就会引发int $int 0x80 软中断,CPU对这个中断信号进行响应,从用户态切换到内核态,通过中断向量表IDT找到系统调用的总入口system_call ,通过这总入口,在系统调用表中找到具体的系统调用函数,如 sys_fork,sys_write,sys_exit 等,相应的事务处理完毕之后,再 iret 从内核态回到用户态,继续执行之前的用户进程。
有了这样的大致认识,以fork系统调用为例,分析下一个典型的系统调用的大致流程:

用Fork系统调用具体分析中断上下文切换
在Linux系统中用 task_struct 数据结构来描述一个进程。新的进程要用fork系统调用来产生,fork实际上复制了父进程然后进行部分数据的更新从而产生一个新的进程。那么问题来了,既然需要一个父进程作为模板来复制,那么最初的那个进程该复制谁呢?
实际上,正如操作系统需要事先写在ROM的BIOS程序来引导一样,0号进程的task_struct 也是有操作系统的设计者事先写好的,也就是sched.h 中的INIT_TASK。start_kernel执行起来后,会通过init_task 生成0号进程,接下来就可以用fork产生其他进程了。
init/main.c
pid = kernel_thread(kernel_init, NULL, CLONE_FS); ... pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
第一个pid是在创建1号进程kernel_init, 后续主要用于管理用户进程
第二个pid是在创建2号进程kthreadd , 后续主要用于管理内核进程
可以看到这两个进程的创建都是调用的 kernel_thread 方法,用do_fork来实现
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
return _do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn,
(unsigned long)arg, NULL, NULL, 0);
}
初始化了0,1和2号进程之后,接下来新的进程都可以统一用 _do_fork 来进行创建了。
接下来,以一个用户态的进程A为例,分析A如何通过 fork 系统调用 产生一个子进程B。
调用流程:

- 用户态的进程A发出系统调用软中断
- CPU响应int$0x80或syscall中断信号,执行int指令进行中断上下文处理: 将SS ESP EFLAGS CS EIP 的值按序压入A的堆栈;然后把 CS:EIP(中断服务程序入口) SS:ESP(指向内核的堆栈)加载到CPU寄存器。此时的堆栈已经切换为内核堆栈。至此完成了中断上下文的切换,进程A从用户态切换到了内核态。
- 跳转到system_call.s 中的_system_call_处执行,继续将ES DS EAX EDI EBP ESI EDX ECX 压栈保存现场,这个时候的数据是保存在内核堆栈。
- 开始执行系统调用程序 sys_fork, 具体来说就是do_fork。do_fork调用copy_process()复制父进程,获得pid,设置子进程上下文环境, 调用wake_up_new_task将子进程加入就绪队列。
- 父进程返回子进程的pid。这时如果有进程需要调度,会执行schedule进行进程切换
- 恢复现场,将之前保存到内核堆栈的值弹出,恢复到对应的寄存器,iret或sysret回到用户态int$0x80或syscall指令的下一条继续执行
fork子进程启动执行时进程上下文的特殊之处

fork系统调用之后,父进程返回子进程的pid, 但是子进程在被设置内核堆栈和thread等进程关键上下文后,被加入就绪队列。
当CPU空闲时,执行schedule进行进程切换,若此时子进程得到调度机会开始执行,那么问题来了,子进程并没有返回用户态,那该如何执行呢?
这也这是fork的特殊之处:它在陷入内核态之后有两次返回,第一次是返回到原来父进程的位置继续向下执行;第二次是在子进程中,它会返回到一个特定的点 ret_from_fork ,通过内核构造的堆栈环境,它可以模拟正常系统调用返回用户态。
具体到进程上下文切换时:
当进程还处于中断处理程序未返回时,此时还是在内核态,可以进行进程调度。
根据调度算法选出下一个进程 pick_next_task() ; 调用context_switch() 进行进程上下文切换。
进程的上下文信息包括, 指向可执行文件的指针, 栈, 内存(数据段和堆), 进程状态, 优先级, 程序I/O的状态, 授予权限, 调度信息, 审计信息, 有关资源的信息(文件描述符和读/写指针), 关事件和信号的信息, 寄存器组(栈指针, 指令计数器)等等
进程上下文切换往往发生在不同的进程之间,而中断上下文往往只是进程本身在用户态和内核态切换。
context_switch():
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next)
{
struct mm_struct *mm, *oldmm;
/* 完成进程切换的准备工作 */
prepare_task_switch(rq, prev, next);
...
/* * 切换进程的执行环境, 包括堆栈和寄存器
* 同时返回上一个执行的程序 */
switch_to(prev, next, prev);
....
return finish_task_switch(prev);
}
context_switch()首先调用switch_mm切换CR3寄存器对应的进程页目录表及地址空间和数据,然后调用宏switch_to来进行CPU上下文切换,将调度后进程内核堆栈的信息恢复到对应的寄存器主要是EIP的值。至此进程切换的关键上下文就完成了。
但是fork产生的子进程内核堆栈就有点特殊了。

struct pt_regs 是内核堆栈中保存的终端上下文,struct inactive_task_frame就是fork 子进程的进程上下文。在恢复EIP时,由于它之前没有执行过,所以没有历史的EIP,所以要用多出来的 inactive_task_frame对应 ret_from_fork 作为子进程的执行入口。
execve系统调用中断上下文的特殊之处

execve系统调用跟之前分析过的fork系统调用的总体流程大致相同,只是在具体系统调用函数处理时略有不同。
execve系统调用加载一个新的可执行程序,在处理过程中单独创建了一个中断上下文,即修改了触发该系统调用时所保存的那个中断上下文,使得返回到用户态的位置修改为新程序的elf_entry或者ld动态链接器的起点地址。
具体来说:
调用execve系统调用时,也用到了fork来产生一个新的进程。当前的执行环境是从父进程复制过来的,execve系统调用加载完新的可执行程序之后已经覆盖了原来父进程的上下文环境。execve系统调用在内核中重新布局了新的用户态执行环境,当调用返回时,返回的已经不是原来的那个可执行程序了,而是新的可执行程序的起点,静态链接的可执行文件也就是main函数的大致位置,动态链接的可执行文件还需要ld链接好动态链接库再从main函数开始执行。
关键在于do_execve的最后一步:start_thread
#define start_thread(regs, new_eip, new_esp) do {
__asm__("movl %0,%%fs ; movl %0,%%gs": :"r" (0));
set_fs(USER_DS);
regs->xds = __USER_DS;
regs->xes = __USER_DS;
regs->xss = __USER_DS;
regs->xcs = __USER_CS;
regs->eip = new_eip;
regs->esp = new_esp;
}
eip被设置为新进程的new_eip,将argc以及argv之后用户空间堆栈的栈顶current->mm->start_stack写进esp,这样当从系统调用返回到子进程的用户空间中时,将从新的入口main函数开始执行,并且通过esp可以获取传递给main函数的argc和argv参数。
实验总结
在完成本次实验的过程中,回顾了之前中断上下文切换,进程上下文切换,fork和execve这两个具体的系统调用的实现。将整个Linux的运作流程串联起来,形成了整体的知识体系,进一步加深了理解。
也让我意识到,当你觉得一个系统比较难理解时,尝试去解答某些具体的问题,在逐步寻找答案的过程中,就会对整个体系有了更为全面和具体的认识。

浙公网安备 33010602011771号