结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程
一、分析execve系统调用中断上下文的特殊之处
(1)实例:execve系统调用加载一个可执行程序
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { int pid; pid = fork(); if (pid < 0) { fprintf(stderr, "Fork Failed\n"); exit(-1); } else if (pid == 0) {
// execlp是对系统调用execve的一层封装 execlp("/bin/ls", "ls", NULL); printf("ls command run finished\n"); } else { wait(NULL); printf("Child Completed\n"); exit(0); } return 0; }
运行结果如下:
从运行结果可以看出,在执行了系统调用 execve 后,子进程得到了执行,但父进程却没有被执行。参考 exec 系列函数的说明,也可印证这个结果,execve 函数执行成功后不会返回,而且代码段、数据段、bss段和调用进程的栈会被加载进来的程序覆盖掉。
(2)关键代码分析
对于 execve 系统调用,最主要的处理过程都在 do_execve_common() 函数中,以下为该函数的主要部分
static int do_execve_common(struct filename *filename,struct user_arg_ptr argv,struct user_arg_ptr envp) { struct linux_binprm *bprm; // 用于解析ELF文件的结构 struct file *file; struct files_struct *displaced; int retval; current->flags &= ~PF_NPROC_EXCEEDED; // 标记程序已被执行 retval = unshare_files(&displaced); // 拷贝当前运行进程的fd到displaced中 bprm = kzalloc(sizeof(*bprm), GFP_KERNEL); retval = prepare_bprm_creds(bprm); // 创建一个新的凭证 check_unsafe_exec(bprm); // 安全检查 current->in_execve = 1; file = do_open_exec(filename); // 打开要执行的文件 sched_exec(); bprm->file = file; bprm->filename = bprm->interp = filename->name; retval = bprm_mm_init(bprm); // 为ELF文件分配内存 bprm->argc = count(argv, MAX_ARG_STRINGS); bprm->envc = count(envp, MAX_ARG_STRINGS); retval = prepare_binprm(bprm); // 从打开的可执行文件中读取信息,填充bprm结构 // 下面的4句是将运行参数和环境变量都拷贝到bprm结构的内存空间中 retval = copy_strings_kernel(1, &bprm->filename, bprm); bprm->exec = bprm->p; retval = copy_strings(bprm->envc, envp, bprm); retval = copy_strings(bprm->argc, argv, bprm); // 开始执行加载到内存中的ELF文件 retval = exec_binprm(bprm); // 执行完毕 current->fs->in_exec = 0; current->in_execve = 0; acct_update_integrals(current); task_numa_free(current); free_bprm(bprm); putname(filename); if (displaced) put_files_struct(displaced); return retval; }
在上述代码片段中,最关键的为 exec_binprm() 函数,故需要对其再暂开了解。
static int exec_binprm(struct linux_binprm *bprm) { pid_t old_pid, old_vpid; int ret; old_pid = current->pid; rcu_read_lock(); old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent)); rcu_read_unlock(); ret = search_binary_handler(bprm); if (ret >= 0) { audit_bprm(bprm); trace_sched_process_exec(current, old_pid, bprm); ptrace_event(PTRACE_EVENT_EXEC, old_vpid); proc_exec_connector(current); } return ret; }
其中,search_binary_handler() 函数实现了核心功能,即当前正在执行的进程内存空间会被加载进来的可执行程序所覆盖,并根据具体情况指向新可执行程序。
a. 若新的可执行程序为静态链接的文件,main函数的入口地址为新进程的 IP 寄存器所指向的值;
b. 若为动态链接,IP 值为加载器 ld 的入口地址,ld 负责动态链接库的处理工作。
综上,通过 execve 系统调用执行的新进程,都会将原来的进程完全替换掉。
(3)总结
execve系统调用过程及其上下文的变化情况
a. 陷入内核
b. 加载新的进程
c. 将新的进程,完全覆盖原先进程的数据空间
d. 将 IP 值设置为新的进程的入口地址
e. 返回用户态,新程序继续执行下去。老进程的上下文被完全替换,但进程的 pid 不变,所以 execve 系统调用不会返回原进程,而是返回新进程。
二、分析fork子进程启动执行时进程上下文的特殊之处
(1)实例:fork系统调用加载一个可执行程序
(2)关键代码分析
对于 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; 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); 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); 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; }
do_fork() 主要做了如下工作:
a. 调用了 copy_process 函数,复制当前进程产生子进程,并且传入关键参数为子进程设置响应进程上下文;
b. 调用 wake_up_new_task 函数,将子进程放入调度队列中,从而有机会 CPU 调度并得以运行。
关键函数 copy_process() 主要做了如下工作:
a. 调用 dup_task_struct 复制一份task_struct结构体,作为子进程的进程描述符;
b. 初始化与调度有关的数据结构,调用了sched_fork,这里将子进程的state设置为TASK_RUNNING;
c. 复制所有的进程信息,包括fs、信号处理函数、信号、内存空间(包括写时复制)等;
d. 调用copy_thread,这里设置了子进程的堆栈信息;
e. 为子进程分配一个pid。
关键函数 copy_thread() 主要做了如下工作:
a. 对子进程的thread.sp赋值,即子进程 esp 寄存器的值;
b. 将父进程的寄存器信息复制给子进程;
c. 将子进程的eax寄存器值设置为0,所以fork调用在子进程中的返回值为0;
d. 子进程从ret_from_fork开始执行,所以它的地址赋给thread.ip,也就是将来的eip寄存器。
(3)总结
通过 fork 系统调用创建的进程,基本流程为父进程将信息复制给子进程,子进程再对其中的一些关键参数做出相应修改,子进程的进程上下文是基于父进程的进程上下文而形成的。
三、以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
对于用户态进程的相互之间切换,主要有如下步骤
a. 发生中断,将当前进程的eip、esp、eflags保存到内核栈中
b. 加载新进程的eip、esp
c. 中断处理过程中调用schedule()函数,其中的switch_to做了关键的进程上下文切换
d. 运行新的用户态进程
系统调用的层次为:用户程序->INT 0x80->system_call->系统调用进程->内核程序
通过系统调用,当前运行的程序便从用户态转至内核态,在这个过程中,就涉及上下文的切换。
进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。
中断上下文,为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。
Linux内核工作在进程上下文或者中断上下文。提供系统调用服务的内核代码代表发起系统调用的应用程序运行在进程上下文;另一方面,中断处理程序,异步运行在中断上下文。中断上下文和特定进程无关。