文章主要内容
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
分析fork
查看do_fork源码
源码位于/linux-5.4.34/kernel/fork.c目录下
源码分析
do_fork算法流程为:
- 调用了 copy_process 函数,复制当前进程产生子进程,并且传入关键参数为子进程设置响应进程上下文
- 调用 wake_up_new_task 函数,将子进程放入调度队列中,从而有机会 CPU 调度并得以运行。
其中,copy_process算法流程为:
- 调用 dup_task_struct 复制一份task_struct结构体,作为子进程的进程描述符
- 初始化与调度有关的数据结构,调用了sched_fork,将子进程的state设置为TASK_RUNNING
- 复制所有的进程信息,包括fs、信号处理函数、信号、内存空间(包括写时复制)等
- 调用copy_thread,设置子进程的堆栈信息
- 为子进程分配一个pid
而copy_thread的算法流程为:
- 对子进程的thread.sp赋值,即子进程 esp 寄存器的值
- 将父进程的寄存器信息复制给子进程
- 将子进程的eax寄存器值设置为0,所以fork调用在子进程中的返回值为0
- 子进程从ret_from_fork开始执行,所以它的地址赋给thread.ip,也就是将来的eip寄存器
do_fork功能可总结为:
_do_fork具体进程的创建大概就是把当前进程的描述符等相关进程资源复制一份,从而产生一个子进程,并根据⼦进程的需要对复制的进程描述符做一些修改,然后把创建好的子进程放进运行队列(操作系统原理中的就绪队列)
fork系统调用过程
- 在正常触发系统调用时,用户态有一个int $0x80或syscall指令触发系统调用,跳转到系统调用入口的汇编代码。int $0x80指令触发entry_INT80_32并以iret返回系统调用,syscall指令触发entry_SYSCALL_64并sysret或iret返回系统调用。
- 系统调用陷入内核态,从用户态堆栈转换到内核态堆栈,然后把相应的CPU关键的现场栈顶寄存器、指令指针寄存器、标志寄存器等保存到内核堆栈,保存现场。系统调用入口的汇编代码还会通过系统调用号执行系统调用内核处理函数,最后恢复现场和系统调⽤返回将CPU关键现场栈顶寄存器、指令指针寄存器、标志寄存器等从内核堆栈中恢复到对应寄存器中,并回到用户态int $0x80或syscall指令之后的下一条指令的位置继续执行。这是前述深⼊理解系统调用部分介绍过的系统调用的大致处理过程
fork与普通系统调用对比
fork也是一个系统调用,和一般的系统调用执行过程大致是一样的。但fork和其他系统调用不同之处是它在陷入内核态之后有两次返回,第一次返回到原来的子进程的位置继续向下执行,这和其他的系统调用是一样的。在子进程中fork也返回了一次,会返回到一个特定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调用返回到用户态。
fork系统调用返回
fork系统调用分析
首先编写一个测试程序fork.c
int main()
{
pid_t pid=fork();
if(pid==-1)
{
perror("fork");
return -1;
}
else if(pid==0) //子进程执行的代码
{
execlp("/bin/ls", "ls", NULL);
}
else //父进程执行的代码
{
wait(NULL);
printf("father done\\n");
}
return 0;
}
反汇编代码
objdump -S fork -o fork.s
通过观察其系统调用号为56,实际调用函数为do_fork。
然后按照实验二的方法进入虚拟机,设置断点后逐步调试
使用bt命令查看函数栈情况
分析execve
Linux系统一般会提供了execl、execlp、execle、execv、execvp和execve等6个用以加载执行一个可执行文件的库函数,这些库函数统称为exec函数,差异在于对命令⾏参数和环境变量参数的传递方式不同。exec函数都是通过execve系统调用进入内核,对应的系统调用内核处理函数为sys_execve或__x64_sys_execve,它们都是通过调用do_execve来具体执行加载可执行文件的工作。
整体的调用关系为:
sys_execve()或__x64_sys_execve ->
do_execve() –>
do_execveat_common() ->
__do_execve_file ->
exec_binprm()->
search_binary_handler() ->
load_elf_binary() ->
start_thread()。
查看execve系统调用接口函数源码
源码位于/linux-5.4.34/tools/include/nolibc/nolibc.h
execve系统调用接口函数解析
filename为可执行文件的名字,argv是以NULL结尾的命令行参数数组,envp同样是以NULL结尾的环境变量数组(使用命令manexecve,可查看其说明)。编程使用的exec系列库函数都是execve系统调用接口函数的封装接口
execve系统调用过程
execve与普通系统调用对比
当前的可执行程序在执行,执行到execve系统调用时陷入内核态,在内核里面用do_execve加载可执行文件,把当前进程的可执行程序给覆盖掉。当execve系统调用返回时,返回的已经不是原来的那个可执行程序了,而是新的可执行程序。execve返回的是新的可执行程序执行的起点,静态链接的可执行文件也就是main函数的大致位置,动态链接的可执行文件还需要ld链接好动态链接库再从main函数开始执行。
execve系统调用实验
同fork实验类似,首先编写一个测试程序execve.c,但这里使用之前编写的fork.c同样能触发execve调用,此处我们不再重复步骤
直接使用bt命令查看函数栈即可
Linux系统的一般执行过程
-
正在运行的用户态进程X
-
发生中断(包括异常、系统调用等),CPU完成load cs:rip(entry of a specific ISR),即跳转到中断处理程序入口。
-
中断上下文切换,具体包括如下几点:
①swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了一个快照
②rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调用是由系统调用入口处的汇编代码实现⽤户堆栈和内核堆栈的切换
③save cs:rip/ss:rsp/rflags:将当前CPU关键上下文压入进程X的内核堆栈,快速系统调用是由系统调用入口处的汇编代码实现的
④此时完成了中断上下文切换,即从进程X的用户态到进程X的内核态 -
中断处理过程中或中断返回前调用了schedule函数,其中完成了进程调度算法选择next进程、进程地址空间切换、以及switch_to关键的进程上下文切换等
-
switch_to调用了__switch_to_asm汇编代码做了关键的进程上下文切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(本例假定为进程Y)的内核堆栈,并完成了进程上下文所需的指令指针寄存器状态切换。之后开始运行进程Y(这里进程Y曾经通过以上步骤被切换出去,因此可以从switch_to下一行代码继续执行)
-
中断上下文恢复,与3中断上下文切换相对应。注意这里是进程Y的中断处理过程中,而3中断上下文切换是在进程X的中断处理过程中,因为内核堆栈从进程X切换到进程Y了
-
为了对应起见中断上下文恢复的最后一步单独拿出来(6的最后一步即是7)iret - pop cs:rip/ss:rsp/rflags,从Y进程的内核堆栈中弹出3中对应的压栈内容。此时完成了中断上下文的切换,即从进程Y的内核态返回到进程Y的用户态。注意快速系统调用返回sysret与iret的处理略有不同
-
继续运行用户态进程Y