实验要求:
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
一 相关知识
1 进程调度的时机
- ⽤户进程上下⽂中主动调⽤特定的系统调⽤进⼊中断上下⽂,系统调⽤返回⽤户态之前进⾏进程调度。
- 内核线程或可中断的中断处理程序,执⾏过程中发⽣中断进⼊中断上下⽂,在中断返回前进⾏进程调度。
- 内核线程主动调⽤schedule函数进⾏进程调度。
第⼀种和第⼆种情况可以统⼀起来,中断处理程序执⾏过程主动调⽤schedule函数进⾏进程调度,与前述两类调度时机对应
2 中断上下文
- 中断上下⽂代表当前进程执⾏,所以中断上下⽂中的get_current可获取⼀个指向当前进程描述符的指针,即指向被中断进程,相应的中断上下⽂切换的信息存储于该进程的内核堆栈中。中断有多种类型,⽐如有不可屏蔽中断、可屏蔽中断、异常、陷阱(系统调⽤)等。
- 内核线程以进程上下⽂的形式运⾏在内核态,本质上还是进程,但它有调⽤内核代码的权限,⽐如主动调⽤schedule()函数进⾏进程调度。
3 中断上下文和进程上下文
进程上下⽂切换时需要保存要切换进程的相关信息(如thread.sp与thread.ip),这与中断上下⽂的切换是不同的。中断是在⼀个进程当中从进程的⽤户态到进程的内核态,或从进程的内核态返回到进程的⽤户态,⽽切换进程需要在不同的进程间切换。但⼀般进程上下⽂切换是嵌套到中断上下⽂切换中的,⽐如前述系统调⽤作为⼀种中断先陷⼊内核,即发⽣中断保存现场和系统调⽤处理过程。其中调⽤了schedule函数发⽣进程上下⽂切换,当系统调⽤返回到⽤户态时会恢复现场,⾄此完成了保存现场和恢复现场,即完成了中断上下⽂切换。
二 实验步骤
1 fork函数
/* * Create a kernel thread. */ pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags) { return _do_fork(&args); } SYSCALL_DEFINE0(fork) { return _do_fork(&args); } SYSCALL_DEFINE0(vfork) { return _do_fork(&args); } SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp, { return _do_fork(&args) }
通过上⾯的代码可以看出fork、 vfork和clone这3个系统调⽤,以及do_fork和kernel_thread内核函数都可以创建⼀个新进程,⽽且都是通过_do_fork函数来创建进程的,只不过传递的参数不同。
_do_fork函数主要完成了调⽤copy_process()复制⽗进程、获得⽤wake_up_new_task将⼦进程加⼊就绪队列等待调度执⾏等。
//_do_fork关键部分代码 long _do_fork(struct kernel_clone_args *args) { //复制进程描述符和执⾏时所需的其他数据结构 p = copy_process(NULL, trace, NUMA_NO_NODE, args); wake_up_new_task(p);//将⼦进程添加到就绪队列 return nr;//返回⼦进程pid(⽗进程中fork返回值为⼦进程的pid) }
copy_process()是创建⼀个进程的主要的代码。如下是copy_process()函数的关键代码,完整代码⻅kernel/fork.c
static __latent_entropy struct task_struct *copy_process( struct pid *pid, int trace, int node, struct kernel_clone_args *args) { //复制进程描述符task_struct、创建内核堆栈等 p = dup_task_struct(current, node); /* copy all the process information */ shm_init_task(p); … // 初始化⼦进程内核栈和thread retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p, args->tls); … return p;//返回被创建的⼦进程描述符指针 }
copy_process函数主要完成了:
- 调⽤dup_task_struct复制当前进程(⽗进程)描述符task_struct
- 信息检查、初始化、把进程状态设置为TASK_RUNNING(此时⼦进程置为就绪态)、采⽤写时复制技术逐⼀复制所有其他进程资源
- 调⽤copy_thread_tls初始化⼦进程内核栈
- 设置⼦进程pid等。
接下来具体看dup_task_struct和copy_thread_tls
dup_task_struct作用:
- 在专业高速缓冲内存上分配task_struct,并完成初始化
- 在普通内存中分配thread_info及连续的两个页面,完成初始化
- 将task_struct和thread_info联系起来
主要代码:
static struct task_struct *dup_task_struct(struct task_struct *orig, int node) {… //实际完成进程描述符的拷⻉,具体做法是*tsk = *orig err = arch_dup_task_struct(tsk, orig); … tsk->stack = stack; ... //实际完成进程描述符的拷⻉,具体做法是*tsk = *orig setup_thread_stack(tsk, orig); clear_user_return_notifier(tsk); clear_tsk_need_resched(tsk); set_task_stack_end_magic(tsk);
…
}
copy_thread_tls作用:
负责构造fork系统调⽤在⼦进程的内核堆栈,也就是fork系统调⽤在⽗⼦进程各返回⼀次,⽗进程中和其他系统调⽤的处理过程并⽆⼆致,⽽在⼦进程中的内核函数调⽤堆栈需要特殊构建,为⼦进程的运⾏准备好上下⽂环境。
主要代码:
int copy_thread_tls(unsigned long clone_flags, unsigned long sp, unsigned long arg, struct task_struct *p, unsigned long tls) { frame->ret_addr = (unsigned long) ret_from_fork; p->thread.sp = (unsigned long) fork_frame; *childregs = *current_pt_regs(); childregs->ax = 0; ... /* * Set a new TLS for the child thread? */ if (clone_flags & CLONE_SETTLS) { err = do_arch_prctl_64(p, ARCH_SET_FS, tls);
do_fork 总结
进程的创建过程⼤致是⽗进程通过fork系统调⽤进⼊内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程放⼊就绪队列, fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂开始执⾏。
2 execve系统调用
execve(执行文件)在父进程中fork一个子进程,在子进程中调用exec函数启动新的程序。exec函数一共有六个,其中execve为内核级系统调用,其他(execl,execle,execlp,execv,execvp)都是调用execve的库函数。
表头文件:
#include<unistd.h>
定义函数:
int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
execve()用来执行参数filename字符串所代表的文件路径,第二个参数是利用指针数组来传递给执行文件,并且需要以空指针(NULL)结束,最后一个参数则为传递给执行文件的新环境变量数组。成功无返回值,失败返回-1。
execve系统调过程:
sys_execve-->do_execve -->do_execveat_common -->__do_execve_file -->search_binary_handler -->load_elf_binary -->start_thread
- execve陷入内核,传入命令行参数和shell上下文环境
- sys_execve调用do_execve封装命令行参数和shell上下文
- 调用do_execveat_common,do_execveat_common调用__do_execve_file,打开ELF文件并把信息的装入linux_binprm结构体
- __do_execve_file中调用search_binary_handler,寻找解析ELF文件的函数
- search_binary_handler找到ELF文件解析函数load_elf_binary,解析ELF文件,把ELF文件装入内存,修改进程的用户态堆栈,修改进程的数据段代码段
- load_elf_binary调用start_thread修改进程内核堆栈
- 返回用户态,此时ip指向ELF文件的main函数地址
三 Linux的一般执行过程
1) 正在运⾏的⽤户态进程X。
2) 发⽣中断(包括异常、系统调⽤等), CPU完成load cs:rip(entry of a specific ISR),即跳转到中断处理程序⼊⼝。
3) 中断上下⽂切换,具体包括如下⼏点:
- swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了⼀个快照。
- rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调⽤是由系统调⽤⼊⼝处的汇编代码实现⽤户堆栈和内核堆栈的切换。
- save cs:rip/ss:rsp/rflags:将当前CPU关键上下⽂压⼊进程X的内核堆栈,快速系统调⽤是由系统调⽤⼊⼝处的汇编代码实现的。
此时完成了中断上下⽂切换,即从进程X的⽤户态到进程X的内核态。
4) 中断处理过程中或中断返回前调⽤了schedule函数,其中完成了进程调度算法选择next进程、进程地址空间切换、以及switch_to关键的进程上下⽂切换等。
5) switch_to调⽤了__switch_to_asm汇编代码做了关键的进程上下⽂切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(本例假定为进程Y)的内核堆栈,并完成了进程上下⽂所需的指令指针寄存器状态切换。之后开始运⾏进程Y(这⾥进程Y曾经通过以上步骤被切换出去,因此可以从switch_to下⼀⾏代码继续执⾏)。
6) 中断上下⽂恢复,与(3)中断上下⽂切换相对应。注意这⾥是进程Y的中断处理过程中,⽽(3)中断上下⽂切换是在进程X的中断处理过程中,因为内核堆栈从进程X切换到进程Y了。
7) 为了对应起⻅中断上下⽂恢复的最后⼀步单独拿出来(6的最后⼀步即是7) iret - pop cs:rip/ss:rsp/rflags,从Y进程的内核堆栈中弹出(3)中对应的压栈内容。此时完成了中断上下⽂的切换,即从进程Y的内核态返回到进程Y的⽤户态。注意快速系统调⽤返回sysret与iret的处理略有不同。
8) 继续运⾏⽤户态进程Y。