结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

一、任务要求

  • 以fork和execve系统调用为例分析中断上下文的切换
  • 分析execve系统调用中断上下文的特殊之处
  • 分析fork子进程启动执行时进程上下文的特殊之处
  • 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程

二、fork系统调用

fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。调用fork之后,数据、堆、栈有两份,代码仍然为一份但是这个代码段成为两个进程的共享代码段都从fork函数中返回。fork给父进程返回子进程pid,给其拷贝出来的子进程返回0,这也是他的特点之一,一次调用,两次返回,所以与一般的系统调用处理流程也必定不同。

Linux下用于创建进程的API有三个fork,vfork和clone,这三个函数分别是通过系统调用sys_fork,sys_vfork以及sys_clone实现的(目前讨论的都是基于x86架构的)。而且这三个系统调用,都是通过do_fork来实现的,只是传入了不同的参数。所以我们可以得出结论:所有的子进程是在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; }

不难发现,创建新进程是由copy_process()这个函数实现的

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; ...

copy_process()做的主要工作如下:

(1)调用 dup_task_struct 复制一份task_struct结构体,作为子进程的进程描述符;

(2)初始化与调度有关的数据结构,调用了sched_fork,这里将子进程的state设置为TASK_RUNNING;

(3)复制所有的进程信息,包括fs、信号处理函数、信号、内存空间(包括写时复制)等;

(4)调用copy_thread_tls,设置子进程的堆栈信息;  

(5)为子进程分配一个pid。

进程的创建过程大致是父进程通过fork系统调用进入内核_ do_fork函数,如下图所示复制进程描述符及相关进程资源(采用写时复制技术)、分配子进程的內核堆栈并对內核堆栈和 thread等进程关键上下文进行初始化,最后将子进程放入就绪队列,fork系统调用返回;而子进程则在被调度执行时根据设置的內核堆栈和thread等进程关键上下文开始执行。

三、execve系统调用

进程创建的过程中,子进程先按照父进程复制出来,然后与父进程分离,单独执行一个可执行程序。这要用到系统调用execve(),在c语言库中提供一整套库函数。

 

execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。

 

execve() 系统调用通常与 fork() 系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork() 一个子进程,然后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,然后在子进程中使用 execve() 来运行指定的程序。

 

execve() 系统调用的函数原型为:

int execve(const char *filename, char *const argv[],
                char *const envp[]);

filename 用于指定要运行的程序的文件名,argv 和 envp 分别指定程序的运行参数和环境变量。除此之外,该系列函数还有很多变体,它们执行大体相同的功能,区别在于需要的参数不同,包括 execl、execlp、execle、execv、execvp、execvpe 等。它们的参数意义和使用方法请读者自行查看帮助手册。

需要注意的是,exec 系列函数的返回值只在遇到错误的时候才有意义。如果新程序成功地被执行,那么当前进程的所有数据就都被新进程替换掉了,所以永远也不会有任何返回值。

对于已打开文件的处理,在 exec() 系列函数执行之前,应该确保全部关闭。因为 exec() 调用之后,当前进程就完全变身成另外一个进程了,老进程的所有数据都不存在了。如果 exec() 调用失败,当前打开的文件状态应该被保留下来。让应用层处理这种情况会非常棘手,而且有些文件可能是在某个库函数内部打开的,应用对此并不知情,更谈不上正确地维护它们的状态了。

所以,对于执行 exec() 函数的应用,应该总是使用内核为文件提供的执行时关闭标志(FD_CLOEXEC)。设置了该标志之后,如果 exec() 执行成功,文件就会被自动关闭;如果 exec() 执行失败,那么文件会继续保持打开状态。使用系统调用 fcntl() 可以设置该标志。

系统调用execve的内核入口为sys_execve,定义在<arch/kernel/process.c>中,代码为:

复制代码
/*
  *sys_execve() executes a new program.
  */
asmlinkage int sys_execve(struct pt_regs regs)
{
    int error;
    char * filename;

    /*regs.ebx中存放指向第一个参数的指针,通过getname函数将指针指向的参数有用户空间拷贝到系统空间*/
    filename = getname((char *) regs.ebx);
    error = PTR_ERR(filename);
    if (IS_ERR(filename))
        goto out;
    /*通过这个函数完成主体部分*/
    error = do_execve(filename, (char **) regs.ecx, (char **) regs.edx, &regs);
    if (error == 0)
        current->ptrace &= ~PT_DTRACE;
    /*释放在函数中获得的缓冲*/
    putname(filename);
    out:
    return error;
} 
复制代码

这个函数的主要部分是do_execve。regs.ebx保存着系统调用execve的第一个参数,即可执行文件的路径名。因为路径名存储在用户空间中,这里要通过getname拷贝到内核空间中。getname在拷贝文件名时,先申请了一个page作为缓冲,然后再从用户空间拷贝字符串。为什么要申请一个页面而不使用进程的系统空间堆栈?首先这是一个绝对路径名,可能比较长,其次进程的系统空间堆栈大约为7K,比较紧缺,不宜滥用。用完文件名后,在函数的末尾调用putname释放掉申请的那个页面。

简单分析一下这个函数的思路:先通过open_err()函数找到并打开可执行文件,然后要从打开的文件中将可执行文件的信息装入一个数据结构linux_binprm,do_execve先对参数和环境变量的技术,并通过prepare_binprm读入开头的128个字节到linux_binprm结构的bprm缓冲区,最后将执行的参数从用户空间拷贝到数据结构bprm中。内核中有一个formats队列,该队列的每个成员认识并只处理一种格式的可执行文件,bprm缓冲区中的128个字节中有格式信息,便要通过这个队列去辨认。do_execve()中的关键是最后执行一个search_binary_handler()函数,找到对应的执行文件格式,并返回一个值,这样程序就可以执行了。

有6种不同的exec函数可供使用,这些函数最终都是通过系统调用execve来实现的:

复制代码
<unistd.h>
int execl(const char *pathname, const char *arg1, ... /* (char*)0 */ );
int execlp(const char *filename, const char *arg1, ... /* (char*)0 */ );
int execle(const char *pathname, const char *arg1, ... /* (char*)0, char * const *envp */);
int execv(const char *pathname, char * const argv[]);
int execvp(const char *filename, char * const argv[]);
int execve(const char *pathname, char * const argv[], char * const envp[]);
复制代码

它们间的关系如下图:

 

sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们代表的传给可执行文件的参数和环境变量仍然保留在用户空间中。

do_execve定义在<fs/exec.c>中。关于do_execve函数的执行流程,这里找一张个人认为总结得比较好的网图:

 

 

四、以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程

  中断和中断返回有中断上下⽂的切换,CPU和内核代码中断处理程序⼊⼝的汇编代码结合起来完成中断上下⽂的切换。进程调度过程中有进程上下⽂的切换,⽽进程上下⽂的切换完全由内核完成,具体包括:从⼀个进程的地址空间切换到另⼀个进程的地址空间;从⼀个进程的内核堆栈切换到另⼀个进程的内核堆栈;还有进程的CPU上下⽂的切换。 
  中断上下⽂和进程上下⽂的⼀个关键区别是堆栈切换的⽅法。中断是由CPU实现的,所以中断上下⽂切换过程中最关键的栈顶寄存器sp和指令指针寄存器ip是由CPU协助完成的;进程切换是由内核实现的,所以进程上下⽂切换过程中最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针寄存器ip的切换是在内核堆栈切换的基础上巧妙利⽤call/ret指令实现的。一个完整的linux系统运行过程,可描述如下:
 
  1 正在运⾏的⽤户态进程X发⽣中断时(包括异常、系统调⽤等),CPU完成load cs:rip(entry of a specifific ISR),即跳转到中断处理程序⼊⼝。然后进入到中断上下文切换的步骤:
  [1] swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了⼀个快照。
  [2] rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调⽤是由系统调⽤⼊⼝处的汇编代码实现⽤户堆栈和内核堆栈的切换。
  [3] save cs:rip/ss:rsp/rflflags:将当前CPU关键上下⽂压⼊进程X的内核堆栈,快速系统调⽤是由系统调⽤⼊⼝处的汇编代码实现的。
     至此,完成了中断上下⽂切换,即从进程X的⽤户态到进程X的内核态。
  2 中断处理过程中或中断返回前调⽤了schedule函数,其中完成了进程调度算法选择next进程、进程地址空间切换、以及switch_to关键的进程上下⽂切换等。
  3 switch_to调⽤了__switch_to_asm汇编代码做了关键的进程上下⽂切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(假定为进程Y)的内核堆栈,并完成了进程上下⽂所需的指令指针寄存器状态切换。之后开始运⾏进程Y。
  4 中断上下⽂恢复,与1中断上下⽂切换相对应。注意这⾥是进程Y的中断处理过程中,⽽1中断上下⽂切换是在进程X的中断处理过程中,因为从用户态切换到内核态,会导致内核堆栈从进程X切换到进程Y了。
  5 为了对应起⻅中断上下⽂恢复的最后⼀步,单独拿出来iret - pop cs:rip/ss:rsp/rflflags,从Y进程的内核堆栈中弹出1中对应的压栈内容。此时完成了中断上下⽂的切换,即从进程Y的内核态返回到进程Y的⽤户态。这里因sysret和iret的不同而略有差异。
  6 继续运⾏⽤户态进程Y。
posted @ 2020-06-13 19:29  LittleTurtle  阅读(178)  评论(0编辑  收藏  举报