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

一、以fork和execve系统调用为例分析中断上下文的切换

1.fork系统调用

fork系统调用用于从已存在进程中创建一个新进程,新进程称为子进程,而原进程称为父进程。fork调用一次,返回两次,这两个返回分别带回它们各自的返回值。

下面是fork()返回的不同值。

负值:创建子进程失败。
:返回到新创建的子进程。
正值:返回给父亲或调用者。该值包含新创建子进程的进程ID。

使用fork函数得到的子进程是父进程的一个复制品,它从父进程处继承了整个进程的地址空间,包括进程上下文、进程堆栈、内存信息、打开的文件描述符、信号控制设定、进程优先级、进程组号、当前工作目录、根目录、资源限制、控制终端等,而子进程所独有的只有它的进程号、计时器等。因此可以看出,使用fork系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段里的绝大部分内容,使得fork系统调用的执行速度并不很快。

fork的返回值这样设计是有原因的,fork在子进程中返回0,子进程仍可以调用getpid函数得到自己的进程ID,也可以调用getppid函数得到父进程的进程ID。在父进程中使用getpid函数可以得到自己的进程ID,然而要想得到子进程的进程ID,只有将fork的返回值记录下来,别无它法。

子进程代码和父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据和静态数据)。数据的复制采用的是写时复制,即只有在任一进程(父进程或者子进程)对数据执行了写操作时,复制才会发生。

此外创建子进程后,父进程打开的文件描述符(在fork之前打开的文件)在子进程中也是打开的,并且共享文件读写偏移量,且文件描述符的引用计数加1。

Linux下用于创建进程的API有三个fork,vfork和clone,这三个函数分别是通过系统调用sys_fork,sys_vfork以及sys_clone实现的。而且这三个系统调用,都是通过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);      //确定PID:注意这里不是生成新的PID,而是确定局部PID;
    
        if (clone_flags & CLONE_PARENT_SETTID)
            put_user(nr, parent_tidptr);
    
        if (clone_flags & CLONE_VFORK) {
            p->vfork_done = &vfork;
            init_completion(&vfork);    //初始化vfork的完成处理程序
            get_task_struct(p);
        }
    
        wake_up_new_task(p);   //保证子进程在父进程之前调用,如果子进程立即调用exec,这样可以极大的减少复制内存页的工作量
    
        /* 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;

}

 

 

 do_fork函数的主要就是复制原来的进程成为另一个新的进程。流程如下:

  • 1、在do_fork()函数中首先创建一个task_struct结构体指针,再对传递给do_fork的flag参数进行处理和检查,看当前进程是否设置了跟踪标记ptrace。
  • 2、然后进入了copy_process函数,实现将父进程的寄存器以及所有进程执行环境的相关部分复制给子进程。
  • 3、如果是 vfork(设置了CLONE_VFORK和ptrace标志)初始化完成处理信息。
  • 4、调用 wake_up_new_task 将子进程加入调度器,为之分配 CPU。
  • 5、如果是 vfork,父进程等待子进程完成 exec 替换自己的地址空间
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()函数主要用来创建子进程的描述符以及与子进程相关数据结构。流程如下:

  • 调用 dup_task_struct 复制当前的 task_struct
  • 检查进程数是否超过限制
  • 初始化自旋锁、挂起信号、CPU 定时器等
  • 调用 sched_fork 初始化进程数据结构,并把进程状态设置为 TASK_RUNNING
  • 复制所有进程信息,包括文件系统、信号处理函数、信号、内存管理等
  • 调用 copy_thread_tls 初始化子进程内核栈
  • 为新进程分配并设置新的 pid

 

2.execve系统调用

 

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

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

系统调用execve的内核入口为sys_execve。

sys_execve是调用do_execve实现的。do_execve则是调用do_execveat_common实现的。

总结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上下文环境

二、对比fork,execve和普通的系统调用

操作系统中的状态分为核心态和用户态。大多数系统交互式操作需求在内核态执行。如设备IO操作或者进程间通信。特权指令:一类只能在核心态下运行而不能在用户态下运行的特殊指令。不同的操作系统特权指令会有所差异,但是一般来说主要是和硬件相关的一些指令。用户程序只在用户态下运行,有时需要访问系统核心功能,这时通过系统调用接口使用系统调用。

当应用程序中需要操作系统提供服务时,如请求I/O资源或执行I/O 操作,应用程序必须使用系统调用命令。由操作系统捕获到该命令后,便将CPU的状态从用户态转换到系统态,然后执行操作系统中相应的子程序(例程),完成所需的功能。执行完成后,系统又将CPU状态从系统态转换到用户态,再继续执行应用程序。

fork系统调用特殊之处在于fork创建了一个子进程,涉及进程的上下文切换:子进程的复制了父进程所有的上下文环境信息,但二者的PID不同,各自作为独立的进程被调度。fork调用一次,返回两次。第⼀次返回到原来的父进程的位置继续向下执行,这和其他的系统调用是⼀样的。第二次是子进程被创建出来后,返回到一个特定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调用返回到用户态。

execve系统调用特殊之处在于execve在保持进程ID不变的情况下,使用新的上下文环境“覆盖”原上下文环境,也即原来的上下文环境不再保存,相关的环境数据直接被丢弃。 因此,当execve系统调用返回时,返回的已经不是原来的那个可执行程序了,而是新的可执行程序。execve返回的是新的可执行程序执行的起点,静态链接的可执行文件也就是main函数的⼤致位置,动态链接的可执行文件还需要ld链接好动态链接库再从main函数开始执行。

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

1、正在运行的用户态进程 X。

2、发生中断(包括异常、系统调用等),跳转到中断处理程序入口。

3、中断上下文切换,保存 EIP/ESP/EFLAGS 的值到内核态堆栈,加载EIP和ESP寄存器的值,从进程X的用户态到进程X的内核态。具体包括如下:

(1)swapgs指令:保存现场

(2)rsp point to kernel stack:加载当前进程内核堆栈栈顶地址到RSP寄存器

(3)save cs:/rip/ss:rsp/flags:将当前CPU关键上下文压入进程X的内核堆栈。

4、中断处理过程中或中断返回前调用了 schedule 函数,其中:

(1)进程调度算法选择 next 进程;

(2)进程地址空间切换;

(3)switch_to 做了关键的进程上下文切换:switch_to 调用 __switch_to_asm 汇编代码做了关键的进程上下文切换,将当前进程 X 的内核堆栈切换到进程调度算法选出来的 next 进程(假定为进程 Y)的内核堆栈,并完成了进程上下文所需的指令指针寄存器状态切换。然后进程 Y 开始运行。

5、恢复中断上下文,与步骤3的中断上下文切换相对应:

iret - pop cs:rip/ss:rsp/flags

从 Y 进程的内核堆栈中弹出步骤3对应的压栈内容,此时完成了中断上下文的切换,从Y进程的内核态返回到Y进程的用户态。

6、继续运行用户态进程X。

 

posted @ 2020-06-15 20:08  别骂了  阅读(156)  评论(0编辑  收藏  举报