一.实验目的
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
二.实验过程
1.fork系统调用
fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。调用fork之后,数据、堆、栈有两份,代码仍然为一份但是这个代码段成为两个进程的共享代码段都从fork函数中返回。
通过fork系统调用我们可以创建进程,下面我们就梳理一下fork的大概过程,主要关注进程上下文切换已经相关函数的作用。最后在我们之前构建的gdb环境中跑一跑以验证。
1.1 0号、1号和2号进程
既然进程是通过父进程通过fork系统调用来创建的,那么父进程又是怎么来的呢,类似于一个鸡生蛋,蛋生鸡的问题。其实,0号进程是由系统硬编码直接编码创建的,运行在内核态,它是唯一一个没有通过fork或kernel_thread创建的进程。
正如我们知道的,start_kernel完成内核的初始化,也就是内核的启动函数。而上图中的set_task_stack_end_magic(&init_task)就是设置整个系统的第一个进程。其中init_task是用来描述0号进程的结构体,它在init_task.c中被填充:
1号进程,也即init进程。它由0号进程通过kernel_thread创建,是系统中所有其它用户进程的祖先进程
2号进程kthreadd也是由0号进程创建,并始终运行在内核空间, 负责所有内核线程的调度和管理
1.2 fork的过程
进程的创建过程?致是?进程通过fork系统调?进?内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采?写时复制技术)、分配?进程的内核堆栈并对内核堆栈和thread等进程关键上下?进?初始化,最后将?进程放?就绪队列, fork系统调?返回;??进程则在被调度执?时根据设置的内核堆栈和thread等进程关键上下?开始执?:
系统调用的过程在实验二我们已经了解了——通过某个系统调用执行该系统调用的内核函数,现在我们直接来看fork对应的内核函数_do_fork。它主要调用了两个关键函数:copy_process和wake_up_new_task。其中copy_process完成复制?进程、获得pid,wake_up_new_task将?进程加?就绪队列等待调度执?。一个个来看:
copy_process:
它会用当前进程的一个副本来创建新进程并分配pid。它会复制寄存器中的值、所有与进程环境相关的部分,每个clone标志。新进程的实际启动由调用者来完成。
dup_task_struct:
copy_process会调用函数dup_task_struct。dup_task_struct复制当前进程(?进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时?进程置为就绪态)、采?写时复制技术逐?复制所有其他进程资源
copy_thread_tls :
初始化?进程内核栈、设置?进程pid等
wake_up_new_task :
?进程创建好了进程描述符、内核堆栈等,就可以将?进程添加到就绪队列,使之有机会被调度执?,进程的创建?作就完成了,?进程就可以等待调度执?,?进程的执?从这?设定的ret_from_fork开始
2.execve系统调用
图示
和普通系统系统调用对比
当前的可执?程序在执?,执?到execve系统调?时陷?内核态,在内核???do_execve加载可执??件,把当前进程的可执?程序给覆盖掉
。当execve系统调?返回 时,返回的已经不是原来的那个可执?程序了,?是新的可执?程序。
execve返回的是新的可执?程序执?的起点,静态链接的可执??件也就是main函数的?致位置,动态链接的可执??件还需 要ld链接好动态链接库再从main函数开始执?。
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_?le ->
- exec_binprm()-> // 根据读入文件头部,寻找该文件的处理函数
- search_binary_handler() ->
- load_elf_binary() -> // 加载elf文件到内存中
- start_thread() // 开始新进程
3.进程切换
进程切换时机
- ?户进程上下?中主动调?特定的系统调?进?中断上下?,系统调?返回 ?户态之前进?进程调度。
- 内核线程或可中断的中断处理程序,执?过程中发?中断进?中断上下?, 在中断返回前进?进程调度。
- 内核线程主动调?schedule函数进?进程调度
进程上下?
- ?户地址空间:包括程序代码、数据、?户堆栈等。 (
CR3
寄存器代表进程??录表,即地址空间、数据) - 控制信息:进程描述符(
thread
)、内核堆栈(sp
寄存器)等。 - 进程的CPU上下?,相关寄存器的值(指令指针寄存器
ip
代表进程的CPU上下?)。
进程切换过过程
- 切换?全局?录(
CR3
)以安装?个新的地址空间,这样不同进程的虚拟地 址如0x8048400
(32位x86)就会经过不同的?表转换为不同的物理地址。 - 切换内核态堆栈和进程的CPU上下?,因为进程的CPU上下?提供了内核执 ?新进程所需要的所有信息,包含所有CPU寄存器状态。
4. Linux系统的一般执行过程
1.在分析fork和execve系统调用前我们首先来了解linux中断的具体过程,
linux中具有中断门和系统门(相当于中断的描述符)总共有255个放在中断符描述表中,中断门包括段选择符用来到GDT中寻找对应的段描述符,段偏移用来描述中断服务程序的地址对应着eip的值,当中断发生时系统利用中断控制器读取的中断向量来找到对应的中断门从而找到中断服务程序的地址,在进入到中断服务例程之前首先控制单元完成一些列的操作:
1)确定中断向量。
2)利用中断向量在IDT中找到对应中断门,在中断门中得到段选择符从而可以从GDT中找到中断服务例程的段基址。
3)确定中断发生的特权级合适(linux只有内核态和用户态两种特权级,此步用来检查中断程序的特权是否低于引起中断的程序的特权,低优先级程序不能引起高优先级程序)
4)检查是否发生特权级变化(用户态陷入内核态,这时候需要设置内核的堆栈),如果发生读取当前程序的tss段(通过tr寄存器读取)来选择新特权级的ss和esp指针,然后保存旧的ss和esp指针。
5)若发生的是故障,用引起异常的指令地址修改cs 和eip寄存器的值,以使得这条指令在异常处理结 束后能被再次执行。
6)在栈中保存eflags、cs和eip的内容。
7)如果异常产生一个硬件出错码,则将它保存在栈中。
8)装载cs和eip寄存器,其值分别是IDT表中第i项门 描述符的段选择符和偏移量字段。这对寄存器值 给出中断或者异常处理程序的第一条指定的逻辑地址。