实验要求
结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
完成一篇博客总结分析Linux系统的一般执行过程,以期对Linux系统的整体运作形成一套逻辑自洽的模型,并能将所学的各种OS和Linux内核知识/原理融通进模型中。
进程上下文和中断上下文
进程上下文:
- 进程上文:指进程由用户态切换到内核态时需要保存用户态的cpu寄存器中的值,进程状态以及堆栈上的内容,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
- 进程下文:其是指切换到内核态后执行的程序,即进程运行在内核空间的部分。
中断上下文:
- 中断上文:硬件通过中断触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。中断上文可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被中断的进程环境)。
- 中断下文:执行在内核空间的中断服务程序。
当工作在用户态的进程想访问某些内核才能访问的资源时,必须通过系统调用或者中断切换到内核态,由内核代替其执行。进程上下文和中断上下文就是完成这两种状态切换所进行的操作总称。保存用户空间状态是上文,切换后在内核态执行的程序是下文。
进程上下文主要是异常处理程序和内核线程。内核之所以进入进程上下文是因为进程自身的一些工作需要在内核中做。例如,系统调用是为当前进程服务的,异常通常是处理进程导致的错误状态等。中断上下文是由于硬件发生中断时会触发中断信号请求,请求系统处理中断,执行中断服务子程序。总的来说,陷入(或因为异常)到内核时,内核代表某一个进程运行,需要访问进程的一些变量和寄存器等,此时的上下文称为进程上下文;而中断时,内核不代表任何进程运行,即中断上下文和特定的进程无关。
分析fork子进程启动执行时进程上下文的特殊之处
库函数fork是?户态创建?个?进程的系统调?API接?。fork函数用来创建一个新进程,新进程称为子进程。fork调用一次将会返回两次,分别来源于父进程和子进程的返回。但是他们的返回值不同,父进程返回的是子进程的PID,而子进程返回的值为0。Linux下用于创建进程的API有三个:fork,vfork和clone,这三个函数分别是通过系统调用sys_fork,sys_vfork以及sys_clone实现的。而且这三个系统调用,都是通过do_fork来实现的,只是传入了不同的参数。do_fork函数的源码如下:
long do_fork(unsigned long clone_flags, unsigned long stack_start, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr) { struct task_struct *p; int trace = 0; long nr; /* * Determine whether and which event to report to ptracer. When * called from kernel_thread or CLONE_UNTRACED is explicitly * requested, no event is reported; otherwise, report if the event * for the type of forking is enabled. */ if (!(clone_flags & CLONE_UNTRACED)) { if (clone_flags & CLONE_VFORK) trace = PTRACE_EVENT_VFORK; else if ((clone_flags & CSIGNAL) != SIGCHLD) trace = PTRACE_EVENT_CLONE; else trace = PTRACE_EVENT_FORK; if (likely(!ptrace_event_enabled(current, trace))) trace = 0; } p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace); /* * Do this prior waking up the new thread - the thread pointer * might get invalid after that point, if the thread exits quickly. */ if (!IS_ERR(p)) { struct completion vfork; struct pid *pid; trace_sched_process_fork(current, p); pid = get_task_pid(p, PIDTYPE_PID); nr = pid_vnr(pid); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, parent_tidptr); if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); get_task_struct(p); } wake_up_new_task(p); /* forking complete and child started to run, tell ptracer */ if (unlikely(trace)) ptrace_event_pid(trace, pid); if (clone_flags & CLONE_VFORK) { if (!wait_for_vfork_done(p, &vfork)) ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid); } put_pid(pid); } else { nr = PTR_ERR(p); } return nr; }
对于父进程的中断上下文切换:在fork触发系统调用时,和其他的系统调用没有区别,用户态下int $0x80或syscall指令触发软中断,CPU被软中断打断后,执行中断处理函数,即系统调用处理函数system_call,进入内核态。系统调?陷?内核态,从?户态堆栈转换到内核态堆栈,然后把相应的CPU关键的现场栈顶寄存器、指令指针寄存器、标志寄存器等保存到内核堆栈,保存现场。系统调???的汇编代码还会通过系统调?号执?系统调?内核处理函数,最后恢复现场和系统调?返回将CPU关键现场栈顶寄存器、指令指针寄存器、标志寄存器等从内核堆栈中恢复到对应寄存器中,并回到?户态int $0x80或syscall指令之后的下?条指令的位置继续执?。
对于父进程,其中断上下文的切换是常规的系统调用的主动陷入。通过系统调用号检索到具体的fork系统调用处理函数为do_fork,即最终在父进程中将调用do_fork函数。如上面代码所示,do_fork函数是负责子进程的创建的。do_fork函数主要完成的工作是:通过copy_process()复制父进程、调用wake_up_new_task将子进程加入就绪队列等待调度执行、返回子进程pid。
copy_process是最主要的一个函数,在copy_process函数中主要完成了:通过dup_task_struct复制当前进程(?进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时?进程置为就绪态)、采?写时复制技术逐?复制所有其他进程资源、调?copy_thread_tls初始化?进程内核栈、设置?进程pid等。
其中,copy_thread_tls主要是在为子进程构造内核堆栈,为子进程的运行准备好上下文环境。这一步非常重要,因为在这里,确定了子进程的ip,即子进程一开始进行的程序的位置,这里ip指向ret_from_fork,即子进程将从这里开始执行。
?进程创建好了进程描述符、内核堆栈等,就可以通过wake_up_new_task(p)将?进程添加到就绪队列,使之有机会被调度执?,进程的创建?作就完成了,?进程就可以等待调度执?。ret_from_fork将是子进程初始执行的地方,可以预想,子进程将在系统调用返回后回到用户态,然后继续在fork函数调用后执行之后的程序。
这里,已经可以看出fork子进程启动执行时进程上下文的特殊之处:fork子进程和创建它的父进程都会从系统调用API:fork处返回,所以会返回两次,执行的又是同一个程序,所以可能会造成一个程序有两个互斥的结果。但实际上是由两个进程所运行得到的,所以并不矛盾。
分析execve系统调用中断上下文的特殊之处
execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。execve() 系统调用通常与 fork() 系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork() 一个子进程,然后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,然后在子进程中使用 execve() 来运行指定的程序。
Linux系统提供了execl、execlp、execle、execv、execvp和execve等六个用以加载执行一个可执行文件的库函数,它们统称为exec函数,差异在于对于命令行参数和环境变量参数的传递方法。exec函数通过execve系统调用进入内核,对应的系统调用内核处理函数为sys_execve或者__x64_sys_execve,它们都是通过调用do_execve来具体执行加载可执行文件的。do_execve函数具体实现如下:
int do_execve(char * filename, char __user *__user *argv, char __user *__user *envp, struct pt_regs * regs) { struct linux_binprm *bprm; // 保存和要执行的文件相关的数据 struct file *file; int retval; int i; retval = -ENOMEM; bprm = kzalloc(sizeof(*bprm), GFP_KERNEL); if (!bprm) goto out_ret; // 打开要执行的文件,并检查其有效性(这里的检查并不完备) file = open_exec(filename); retval = PTR_ERR(file); if (IS_ERR(file)) goto out_kfree; // 在多处理器系统中才执行,用以分配负载最低的CPU来执行新程序 // 该函数在include/linux/sched.h文件中被定义如下: // #ifdef CONFIG_SMP // extern void sched_exec(void); // #else // #define sched_exec() {} // #endif sched_exec(); // 填充linux_binprm结构 bprm->p = PAGE_SIZE*MAX_ARG_PAGES-sizeof(void *); bprm->file = file; bprm->filename = filename; bprm->interp = filename; bprm->mm = mm_alloc(); retval = -ENOMEM; if (!bprm->mm) goto out_file; // 检查当前进程是否在使用LDT,如果是则给新进程分配一个LDT retval = init_new_context(current, bprm->mm); if (retval 0) goto out_mm; // 继续填充linux_binprm结构 bprm->argc = count(argv, bprm->p / sizeof(void *)); if ((retval = bprm->argc) 0) goto out_mm; bprm->envc = count(envp, bprm->p / sizeof(void *)); if ((retval = bprm->envc) 0) goto out_mm; retval = security_bprm_alloc(bprm); if (retval) goto out; // 检查文件是否可以被执行,填充linux_binprm结构中的e_uid和e_gid项 // 使用可执行文件的前128个字节来填充linux_binprm结构中的buf项 retval = prepare_binprm(bprm); if (retval 0) goto out; // 将文件名、环境变量和命令行参数拷贝到新分配的页面中 retval = copy_strings_kernel(1, &bprm->filename, bprm); if (retval 0) goto out; bprm->exec = bprm->p; retval = copy_strings(bprm->envc, envp, bprm); if (retval 0) goto out; retval = copy_strings(bprm->argc, argv, bprm); if (retval 0) goto out; // 查询能够处理该可执行文件格式的处理函数,并调用相应的load_library方法进行处理 retval = search_binary_handler(bprm,regs); if (retval >= 0) { free_arg_pages(bprm); // 执行成功 security_bprm_free(bprm); acct_update_integrals(current); kfree(bprm); return retval; } out: // 发生错误,返回inode,并释放资源 for (i = 0 ; i MAX_ARG_PAGES ; i++) { struct page * page = bprm->page; if (page) __free_page(page); } if (bprm->security) security_bprm_free(bprm); out_mm: if (bprm->mm) mmdrop(bprm->mm); out_file: if (bprm->file) { allow_write_access(bprm->file); fput(bprm->file); } out_kfree: kfree(bprm); out_ret: return retval; }
execve的特殊之处在于,当前的可执行程序在执行,执行到execve系统调用时陷入内核态,在内核态中用do_execve加载可执行文件,把当前的进程的可执行程序给覆盖掉了。当execve系统调用返回时,返回的不再是原来的那个可执行程序了,而是新的可执行程序。execve返回的是新的可执行程序执行的起点,静态链接的可执行文件就是main函数的大致位置,动态链接的可执行文件还需要ld链接好动态链接库之后再从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)中对应的压栈内容。此时完成了中断上下文的切换
(8)继续运行用户态进程Y。