1.基础知识
1.1 进程上下文与中断上下文介绍
1.1.1 进程上下文
(1)进程上文:其是指进程由用户态切换到内核态是需要保存用户态时cpu寄存器中的值,进程状态以及堆栈上的内容,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
(2)进程下文:其是指切换到内核态后执行的程序,即进程运行在内核空间的部分。
1.1.2 中断上下文
1)中断上文:硬件通过中断触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。中断上文可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被中断的进程环境。
(2)中断下文:执行在内核空间的中断服务程序。
1.2 不同之间状态的切换的原因
在现在操作系统中,内核功能模块运行在内核空间,而应用程序运行在用户空间。现代的CPU都具有不同的操作模式,代表不同的级别,不同的级别具有不同的功能,其所拥有的资源也不同;在较低的级别中将禁止使用某些处理器的资源。Linux系统设计时利用了这种硬件特性,使用了两个级别,*别和最低级别,内核运行在*别(内核态),这个级别几乎可以使用处理器的所有资源,而应用程序运行在较低级别(用户态),在这个级别的用户不能对硬件进行直接访问以及对内存的非授权访问。内核态和用户态有自己的内存映射,即自己的地址空间。
当工作在用户态的进程想访问某些内核才能访问的资源时,必须通过系统调用或者中断切换到内核态,由内核代替其执行。进程上下文和中断上下文就是完成这两种状态切换所进行的操作总称。我将其理解为保存用户空间状态是上文,切换后在内核态执行的程序是下文。
1.3 用户态到内核态的切换的时机
1.进程上下文主要是异常处理程序和内核线程。内核之所以进入进程上下文是因为进程自身的一些工作需要在内核中做。例如,系统调用是为当前进程服务的,异常通常是处理进程导致的错误状态等。
2.中断上下文是由于硬件发生中断时会触发中断信号请求,请求系统处理中断,执行中断服务子程序。
2.fork()与execve()系统调用分析
2.1 fork系统调用
首先,fork()对应的系统调用do_fork函数来创建进程的,其主要作用在于:
复制进程描述符及相关进程资源(采?写时复制技术)、分配?进程的内核堆栈并对内核堆栈和thread等进程关键上下?进?初始化,最后将?进程放?就绪队列, fork系统调?返回;??进程则在被调度执?时根据设置的内核堆栈和thread等进程关键上下?开始执?。
_do_fork()主要调用了两个关键函数:copy_process和wake_up_new_task。其中copy_process完成复制?进程、获得pid,wake_up_new_task将?进程加?就绪队列等待调度执?。
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; } 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); pid = get_task_pid(p, PIDTYPE_PID); nr = pid_vnr(pid); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, args->parent_tid); 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); return nr; }
对于copy_process函数,它会用当前进程的一个副本来创建新进程并分配pid。它会复制寄存器中的值、所有与进程环境相关的部分,每个clone标志。新进程的实际启动由调用者来完成。
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函数的流程: (1)调用 dup_task_struct 复制一份task_struct结构体,作为子进程的进程描述符; (2)初始化与调度有关的数据结构,调用了sched_fork,这里将子进程的state设置为TASK_RUNNING; (3)复制所有的进程信息,包括fs、信号处理函数、信号、内存空间(包括写时复制)等; (4)调用copy_thread_tls,设置子进程的堆栈信息; (5)为子进程分配一个pid。
对于wake_up_new_task函数,?进程创建好了进程描述符、内核堆栈等,就可以将?进程添加到就绪队列,使之有机会被调度执?,进程的创建?作就完成了,?进程就可以等待调度执?,?进程的执?从这?设定的ret_from_fork开始。
补充:
1.dup_task_thread()主要为子进程分配好内核栈。
2.copy_thread_tls()函数调用copy_thread,在早期版本3.18.6该函数叫copy_thread,它负责构造fork系统调?在?进程的内核堆栈,也就是fork系统调?在??进程各返回?次,?进程中和其
他系统调?的处理过程并??致,?在?进程中的内核函数调?堆栈需要特殊构建,为?进程的运?准备好上下?环境。另外还有线程局部存储TLS(thread local storage) 则是为?持多线程编
程引?的,我们不去深究。它的作用主要是对子进程幵始执行的起点 ret_from_kernel_thread(内核线程) 或 ret_from_fork(用户态进程), 以及在子进程中 fork 系统调用的返回值等都进行赋值。
注意
正常的?个系统调?都是陷?内核态,再返回到?户态,然后继续执?系统调?后的下?条指令。fork和其他系统调?不同之处是它在陷?内核态之后有两次返回,第?次返回到原来的?进程的位置继续向下执?,这和其他的系统调?是?样的。在?进程中fork也返回了?次,会返回到?个特定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调?返回到?户态。
2.2 execve系统调用
2.2.1 execve系统调用的功能
进程创建的过程中,子进程先按照父进程复制出来,然后与父进程分离,单独执行一个可执行程序。这要用到系统调用execve。在调?execve系统调?时,当前的执?环境是从?进程复制过来的,execve系统调?加载完新的可执?程序之后已经覆盖了原来?进程的上下?环境。 execve在内核中帮我们重新布局了新的?户态执?环境即初始化了进程的用户态堆栈。
2.2.2 execve系统调用的流程
sys_execve()=> do_execve() =>do_execveat_common()=> __do_execve_file=> exec_binprm()=> search_binary_handler() =>load_elf_binary() => start_thread()
在使用exec*函数的时候,一般首先需要调用一个fork来生成一个新的子进程(否则原有的进程将会被覆盖掉),然后新的进程调用execve()系统调用来执行指定的ELF文件。内核调用sys_execve函数来实现execve。
sys_execve通过调用 do_execve_common,首先访问需要加载文件所在的目录文件,然后通过search_binary_handle在目录中检索需要执行的文 件,并根据文件类型来采用对应的加载函数对其进行加载。在加载的过程中,将原来进程的代码段、以及堆栈等利用所加载的文件中的对应值进行替换,最后重新设 定EIP和ESP来使可执行文件运行起来。
至于运行参数以及环境变量,则是首先传递到系统调用,然后传递到一个struct linux_binprm类型的结构体中,最后,该结构体变量作为参数参与load工作,将程序的运行参数以及环境变量参数应用到可执行程序上。
其详细流程如下图所示:
3 Linux系统的一般执行过程
3.1 进程上下文切换跟系统调用区别
首先,进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。
因此,进程的上下文切换就比系统调用时多了一步:在保存内核态资源(当前进程的内核状态和 CPU 寄存器)之前,需要先把该进程的用户态资源(虚拟内存、栈等)保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。
所以,系统调用过程通常称为特权模式切换,而不是上下文切换。系统调用属于同进程内的 CPU 上下文切换。但实际上,系统调用过程中,CPU 的上下文切换还是无法避免的。
3.2 中断上下文切换
为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。
跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。所以,即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文,其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。
3.3 通过系统调用分析linux系统的一般执行过程
从用户态到内核态的转变,需要通过系统调用来完成。
在这个过程中就发生了 CPU 上下文切换,整个过程是这样的:
1、保存 CPU 寄存器里原来用户态的指令位
2、为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置。
3、跳转到内核态运行内核任务。
4、当系统调用结束后,CPU 寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程。
所以,一次系统调用的过程,其实是发生了两次 CPU 上下文切换。(用户态-内核态-用户态)
不过,需要注意的是,系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的:进程上下文切换,是指从一个进程切换到另一个进程运行;而系统调用过程中一直是同一个进程在运行。
参考内容:《Linux性能优化实战》