结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
一、实验要求
以fork和execve系统调用为例分析中断上下文的切换
分析execve系统调用中断上下文的特殊之处
分析fork子进程启动执行时进程上下文的特殊之处
以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
二、实验过程
fork系统调用
fork系统调用可以建立一个新进程,把当前的进程分为父进程和子进程。新创建的子进程与父进程十分类似,子进程得到与父进程用户级虚拟地址空间相同(但是独立)的一份拷贝,包括文本,数据和bss段、堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝。这就是意味着当父进程调用fork时候,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大区别在于他们有着不同的PID。
进程的创建过程大致是父进程通过fork系统调用进入内核_ do_fork函数,如下图所示复制进程描述符及相关进程资源(采用写时复制技术)、分配子进程的內核堆栈并对內核堆栈和 thread等进程关键上下文进行初始化,最后将子进程放入就绪队列,fork系统调用返回;而子进程则在被调度执行时根据设置的內核堆栈和thread等进程关键上下文开始执行,对于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; // ... // 复制进程描述符,返回创建的task_struct的指针 p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace); if (!IS_ERR(p)) { struct completion vfork; struct pid *pid; trace_sched_process_fork(current, p); // 取出task结构体内的pid pid = get_task_pid(p, PIDTYPE_PID); nr = pid_vnr(pid); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, parent_tidptr); // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行 if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); get_task_struct(p); } // 将子进程添加到调度器的队列,使得子进程有机会获得CPU wake_up_new_task(p); // ... // 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间 // 保证子进程优先于父进程运行 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; }
copy_process函数主要完成了调用dup_task_struct复制当前进程(父进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时?进程置为就绪态)、采?写时复制技术逐?复制所有其他进程资源、调?copy_thread_tls初始化子进程内核栈、设置子进程pid等。
其中copy_thread_tls所做的工作是关键。我们知道执行fork系统调用之后,会由内核态返回两次:一次返回到父进程,这与一般的系统调用返回流程别无二致;而另一次则返回到子进程,为了实现这一点,就需要为子进程构造出合适的执行上下文,也就是初始化其内核栈和进程描述符的thread字段。这正是copy_thread_tls的任务。
static __latent_entropy struct task_struct *copy_process( struct pid *pid, int trace, int node, struct kernel_clone_args *args) { ... p = dup_task_struct(current, node); ... /* copy all the process information */ shm_init_task(p); retval = security_task_alloc(p, clone_flags); if (retval) goto bad_fork_cleanup_audit; retval = copy_semundo(clone_flags, p); if (retval) goto bad_fork_cleanup_security; retval = copy_files(clone_flags, p); if (retval) goto bad_fork_cleanup_semundo; retval = copy_fs(clone_flags, p); if (retval) goto bad_fork_cleanup_files; retval = copy_sighand(clone_flags, p); if (retval) goto bad_fork_cleanup_fs; retval = copy_signal(clone_flags, p); if (retval) goto bad_fork_cleanup_sighand; retval = copy_mm(clone_flags, p); if (retval) goto bad_fork_cleanup_signal; retval = copy_namespaces(clone_flags, p); if (retval) goto bad_fork_cleanup_mm; retval = copy_io(clone_flags, p); if (retval) goto bad_fork_cleanup_namespaces; retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p, args->tls); if (retval) goto bad_fork_cleanup_io; ... return p; ...
正常的?个系统调?都是陷?内核态,再返回到?户态,然后继续执?系统调?后的下?条指令。
fork和其他系统调?不同之处是它在陷?内核态之后有
两次返回
,第?次返回到原来的?进程的位置继续向下执?,这和其他的系统调?是?样的。在?进程中fork也返回了?次,会返回到?个特 定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调?返回到?户态。
- execve系统调用
execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。
execve() 系统调用通常与 fork() 系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork() 一个子进程,然后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,然后在子进程中使用 execve() 来运行指定的程序。
asmlinkage int sys_execve(struct pt_regs regs) { int error; char * filename; // 将可执行文件的名称装入到一个新分配的页面中 filename = getname((char __user *) regs.ebx); error = PTR_ERR(filename); if (IS_ERR(filename)) goto out; // 执行可执行文件 error = do_execve(filename, (char __user * __user *) regs.ecx, (char __user * __user *) regs.edx, ®s); if (error == 0) { task_lock(current); current->ptrace &= ~PT_DTRACE; task_unlock(current); /* Make sure we don‘t return using sysenter.. */ set_thread_flag(TIF_IRET); } putname(filename); out: return error; }
execve系统调用执行过程:
- execve系统调用陷入内核,并传入命令行参数和shell上下文环境
- execve陷入内核的第一个函数:do_execve,该函数封装命令行参数和shell上下文
- do_execve调用do_execveat_common,后者进一步调用__do_execve_file,打开ELF文件并把所有的信息一股脑的装入linux_binprm结构体
- __do_execve_file中调用search_binary_handler,寻找解析ELF文件的函数
- search_binary_handler找到ELF文件解析函数load_elf_binary
- load_elf_binary解析ELF文件,把ELF文件装入内存,修改进程的用户态堆栈(主要是把命令行参数和shell上下文加入到用户态堆栈),修改进程的数据段代码段
- load_elf_binary调用start_thread修改进程内核堆栈(特别是内核堆栈的ip指针)
- 进程从execve返回到用户态后ip指向ELF文件的main函数地址,用户态堆栈中包含了命令行参数和shell上下文环境
当execve在调用时陷入内核态,就执行do_execve文件,覆盖当前的可执行程序,所以 返回的是新的可执行程序的起点。main函数位置是静态链接的可执行文件,动态链接的可执行文件需要连接动态链接库后在开始执行。
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