一、概述
内核态和用户态
内核态:当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。
用户态:当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。此时处理器在特权级最低的(3级)用户代码中运行。当正在执行用户程序而突然呗中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核态。
进程上下文与中断上下文
程序在执行过程中,通常有用户态和内核态两种状态,CPU处于内核态根据上下文环境进一步细分,因此有了下面三种状态:
- 内核态,运行于进程上下文,内核代表进程运行于内核空间。
- 内核态,运行于中断上下文,内核代表硬件运行于内核空间。
- 用户态,运行于用户空间。
上下文简单来说就是一个环境。用户空间的应用程序,通过系统调用,进入内核空间,这个时候用户空间的进程要传递很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存器值、变量等。所谓的进程上下文,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器的值和当时的环境等。
相对于进程而言,就是进程执行时的环境。具体来说就是各个变量和数据,包括所有的寄存器变量,进程打开的文件,内存信息等。一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文
- 用户级上下文:正文、数据、用户堆栈以及共享存储区;
- 寄存器上下文:通用寄存器、程序寄存器(IP)、处理器状态寄存器、栈指针;
- 系统级上下文:进程控制块、内存管理信息、内核栈。
当发生进程调度时,进行进程切换就是上下文切换(context switch).操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。而系统调用进行的模式切换(mode switch)。模式切换与进程切换比较起来,容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。
硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的 一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“ 中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。中断时,内核不代表任何进程运行,它一般只访问系统空间,而不会访问进程空间,内核在中断上下文中执行时一般不会阻塞。
二、fork系统调用中断上下文
fork、vfork、clone都是用户态C函数库提供的封装接口,用于进行系统调用,分别调用内核态的sys_fork、sys_vfork、sys_clone函数,它们最终都调用do_fork()来实现进程的创建;do_fork主要为子进程分配新pid,然后调用copy_process()复制进程描述符。
三者的主要区别在于:
- fork()子进程全面拷贝父进程拥有的包括页表项在内的资源,clone_flags为SIGCHLD;使用写时复制(COW)技术来降低复制开销,即子进程先共享父进程的物理页,这些区域的页表条目都被标记为只读并且区域结构被标记为私有写时复制,只要有一个进程试图写一个页面则会触发一个保护故障,故障处理程序在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的副本,恢复页面可写权限,然后重新执行写操作。
- vfork()创建的进程能共享父进程的内存地址空间,子进程对地址空间的任何修改都对父进程可见,反之亦然。因此为了防止父进程重写子进程需要的数据,阻塞了父进程的执行,一直到子进程退出或执行一个新程序为止。
- clone()函数功能更为强大,可以让你选择性地继承父进程的资源,可以让你像vfork一样与父进程共享地址空间,也可以不和父进程共享,创造出来的新进程也可以不和原进程是父子关系,可以是兄弟关系。
int sys_fork(struct pt_regs *regs) { return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL); } int sys_vfork(struct pt_regs *regs) { return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs->sp, regs, 0, NULL, NULL); } sys_clone(unsigned long clone_flags, unsigned long newsp, void __user *parent_tid, void __user *child_tid, struct pt_regs *regs) { if (!newsp) newsp = regs->sp; return do_fork(clone_flags, newsp, regs, 0, parent_tid, child_tid); }
do_fork的第一个参数clone_flags与clone()函数的flags相同,第二个stack_start参数与clone()函数的child_stack相同。可以看到三个系统调用只是使用了不同的参数调用do_fork()。
其中sys_clone没有clone的C函数中的fn和args参数,实际上封装函数用fn指针覆盖子进程压栈的clone返回后跳转的地址,args指针正好存放在子进程堆栈中的fn下面。当clone() C函数结束时,CPU从栈中取出返回地址跳转到fn(args)函数。
sys_fork使用的clone_flag为SIGCHLD,即子进程结束时发送给父进程SIGCHLD信号;sys_vfork使用的clone_flag为CLONE_VFORK | CLONE_VM | SIGCHLD。用户栈使用的都是父进程的栈。
struct kernel_clone_args { u64 flags; int __user *pidfd; int __user *child_tid; int __user *parent_tid; int exit_signal; unsigned long stack; unsigned long stack_size; unsigned long tls; }; long _do_fork(struct kernel_clone_args *args) { u64 clone_flags = args->flags; struct completion vfork; struct pid *pid; 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 (args->exit_signal != SIGCHLD) trace = PTRACE_EVENT_CLONE; else trace = PTRACE_EVENT_FORK; if (likely(!ptrace_event_enabled(current, trace))) trace = 0; } // 拷贝父进程task_struct以及其中的一些资源,返回创建的task_struct的指针 p = copy_process(NULL, trace, NUMA_NO_NODE, args); add_latent_entropy(); if (IS_ERR(p)) return PTR_ERR(p); /* * Do this prior waking up the new thread - the thread pointer * might get invalid after that point, if the thread exits quickly. */ 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, args->parent_tid); // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行 if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); get_task_struct(p); } // 将子进程添加到调度器的队列,使得子进程有机会获得CPU wake_up_new_task(p); /* forking complete and child started to run, tell ptracer */ if (unlikely(trace)) ptrace_event_pid(trace, pid); // 如果设置了 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); return nr; } /* 创建进程描述符以及子进程所需要的其他所有数据结构 为子进程准备运行环境 */ static struct task_struct *copy_process(unsigned long clone_flags, unsigned long stack_start, unsigned long stack_size, int __user *child_tidptr, struct pid *pid, int trace) { int retval; struct task_struct *p; // 分配一个新的task_struct,此时的p与当前进程的task,仅仅是stack地址不同 p = dup_task_struct(current); // 检查该用户的进程数是否超过限制 if (atomic_read(&p->real_cred->user->processes) >= task_rlimit(p, RLIMIT_NPROC)) { // 检查该用户是否具有相关权限,不一定是root if (p->real_cred->user != INIT_USER && !capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN)) goto bad_fork_free; } retval = -EAGAIN; // 检查进程数量是否超过 max_threads,后者取决于内存的大小 if (nr_threads >= max_threads) goto bad_fork_cleanup_count; // 初始化自旋锁 // 初始化挂起信号 // 初始化定时器 // 完成对新进程调度程序数据结构的初始化,并把新进程的状态设置为TASK_RUNNING retval = sched_fork(clone_flags, p); // copy父进程task_struct中的各类结构体, 包括mm、files、fs、sig等,有深拷贝和浅拷贝(只拷贝父进程task_struct中的某些结构指针并增加引用计数) 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); // 初始化子进程的内核栈 retval = copy_thread(clone_flags, stack_start, stack_size, p); if (retval) goto bad_fork_cleanup_io; if (pid != &init_struct_pid) { retval = -ENOMEM; // 这里为子进程分配了新的pid号 pid = alloc_pid(p->nsproxy->pid_ns_for_children); if (!pid) goto bad_fork_cleanup_io; } /* ok, now we should be set up.. */ // 设置子进程的pid p->pid = pid_nr(pid); // 如果是创建线程 if (clone_flags & CLONE_THREAD) { p->exit_signal = -1; // 线程组的leader设置为当前线程的leader p->group_leader = current->group_leader; // tgid是当前线程组的id,也就是main进程的pid p->tgid = current->tgid; } else { if (clone_flags & CLONE_PARENT) p->exit_signal = current->group_leader->exit_signal; else p->exit_signal = (clone_flags & CSIGNAL); // 创建的是进程,自己是一个单独的线程组 p->group_leader = p; // tgid和pid相同 p->tgid = p->pid; } if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) { // 如果是创建线程,那么同一线程组内的所有线程、进程共享parent p->real_parent = current->real_parent; p->parent_exec_id = current->parent_exec_id; } else { // 如果是创建进程,当前进程就是子进程的parent p->real_parent = current; p->parent_exec_id = current->self_exec_id; } // 将pid加入PIDTYPE_PID这个散列表 attach_pid(p, PIDTYPE_PID); // 递增 nr_threads的值 nr_threads++; // 返回被创建的task结构体指针 return p; }
其主要过程为:
- 检查参数clone_flags所传递标志的一致性,在某些情况下返回错误代码。
- 调用dup_task_struct()为子进程复制一份进程描述符,包括复制父进程的thread_info结构和内核栈,同时把新进程描述符的使用计数器(tsk->usage)设置为2,用来表示进程描述符正在被使用而且其相应的进程处于活动状态。
- 检查系统中存在的进程数量(nr_thread)是否超过max_threads值。
- 调用sched_fork()函数,用于进程调度相关内容的初始化,如根据父进程的clone_flags设置子进程的优先级和权重等,为保证公平调度父子进程之间会共享父进程的时间片,并设置子进程的状态为TASK_RUNNING,这样才会被调度器放入运行队列中。
- 根据clone_flags有选择地进行一系列task_struct中各种字段的copy操作,主要包括:
- copy_files(),使用dup_fd复制父进程task_struct中的file_struct指针,并将file对象里的引用计数+1(f_count成员)或者生成新副本。
- copy_fs(),复制父进程根目录,进程所在目录。同样需要将引用计数+1或者生成新副本。
- copy_signal()和copy_sighand()复制信号和信号处理函数。
- copy_mm()复制用户地址空间,如果是内核线程则没有用户空间。如果有CLONE_VM标志则共享父进程mm结构,并将父进程mm的usr引用计数+1;否则不与父进程共享,调用allocate_mm()分配出一个新mm(包括分配新的PGD)并初始化,然后调用dup_mmap(mm)拷贝父进程vm_area_struct页面映射表,页面写保护标记也在复制时被设置。
前面所述的有条件地根据clone_flags执行这些copy操作的具体含义为:对于sys_fork其clone_flags为SIGCHLD,所以copy_files, copy_fs, copy_sighand, copy_mm全部执行,即四大项资源全部进行了深拷贝;而sys_vfork的clone_flags为VFORK|CLONE_VM|SIG_CHLD,因此只执行了copy_files,copy_fs以及copy_sighand,而copy_mm因为CLONE_VM标志的设置只将父进程mm指针赋值给了子进程mm字段,并将引用计数+1。
执行copy_thread()主要保证了父子进程堆栈上下文一致。获取子进程的pt_regs指针,*childregs = *current_pt_regs()拷贝父进程内核栈中的pt_regs(即执行系统调用时CPU寄存器的值)给子进程;childregs->ax = 0将子进程的返回值值0,并将子进程返回地址设为汇编语言函数ret_from_fork所在地址;子进程描述符的thread.esp字段被设置为子进程内核栈的基地址。
调用pid=alloc_pid()分配一个pid,p->pid = pid_nr(pid)赋值给子进程task_struct相应字段;设置tgid,若为线程(CLONE_THREAD)则pgid与该线程组的领头线程相同,否则等于pid。
最后copy_process()结束,返回新进程的task_struct结构指针p。由此,子进程上下文已经被设置好。
do_fork()执行完copy_process后执行wake_up_new_task(p),将子进程唤醒,加入到调度器运行队列中,包括activate_task()加入到运行队列红黑树和check_preempt_curr()检查新进程是否可以抢占当前进程两个主要步骤。
三、execve系统调用中断上下文
对于 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文件 <strong>retval = exec_binprm(bprm);</strong> // 执行完毕 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(); <strong>ret = search_binary_handler(bprm);</strong> 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() 函数实现了核心功能,即当前正在执行的进程内存空间会被加载进来的可执行程序所覆盖,并根据具体情况指向新可执行程序。若新的可执行程序为静态链接的文件,main函数的入口地址为新进程的 IP 寄存器所指向的值;若为动态链接,IP 值为加载器 ld 的入口地址,ld 负责动态链接库的处理工作。
综上所述,通过execve系统调用执行的新进程,都会将原来的进程完全替换掉。
execve系统调用过程及其上下文的变化情况:
陷入内核--->加载新的进程--->将新的进程,完全覆盖原先进程的数据空间--->将IP值设置为新的进程的入口地址--->返回用户态,新程序继续执行下去。
老进程的上下文被完全替换,但进程的 pid 不变,所以 execve 系统调用不会返回原进程,而是返回新进程。
四、Linux系统的一般执行过程
对于用户态进程的相互之间切换,主要有如下步骤:
- 发生中断,将当前进程的eip、esp、eflags保存到内核栈中
- 加载新进程的eip、esp
- 中断处理过程中调用schedule()函数,其中的switch_to做了关键的进程上下文切换
- 运行新的用户态进程。
系统调用的层次为:用户程序->INT 0x80->system_call->系统调用进程->内核程序
通过系统调用,当前运行的程序便从用户态转至内核态,在这个过程中,就涉及上下文的切换。
进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。
中断上下文,为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。
Linux内核工作在进程上下文或者中断上下文。提供系统调用服务的内核代码代表发起系统调用的应用程序运行在进程上下文;另一方面,中断处理程序,异步运行在中断上下文。中断上下文和特定进程无关。