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

1. CPU上下文

Linux 是一个多任务操作系统,它支持远大于 CPU 数量的任务同时运行。当然,这些任务实际上并不是真的在同时运行,而是因为系统在很短的时间内,将 CPU 轮流分配给它们,造成多任务同时运行的错觉。

CPU上下文

CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任何任务前,必须的依赖环境。

CPU上下文切换

就是把当前任务的CPU上下文保存起来,然后加载新任务的CPU上下文到寄存器和程序计数器,最后跳转到程序计数器所指的新位置,运行新任务。

而这些保存下来的上下文,会存储在系统内核中。并在任务重新调度执行时再次加载进来。这样就能保证原来的状态不受影响,让任务看起来还是连续运行。

CPU上下文切换的类型 

根据任务的不同,可以分为以下三种类型

  • 进程上下文切换
  • 线程上下文切换
  • 中断上下文切换

进程上下文切换

linux按照特权等级,把进程的运行空间分为内核空间和用户空间,分为对应下图中的Ring0和Ring3

  • 内核空间(Ring0)具有最高运行权限,可以直接访问所有资源
  • 用户空间(Ring3)只能访问受限资源,无法直接访问内存等硬件设备,必须通过系统调用陷入到内核中,才能访问这些特权资源。

系统调用

从用户态到内核态的切换,需要通过系统调用来完成。比如我们查看文件内容时,就需要多次系统调用来完成:首先调用open()打开文件,然后调用read()读取文件内容,再调用write()将内容写到标准输出,最后再调用close()关闭文件。

在这个过程中就发生了CPU上下文切换,整个过程如下:

  1. 保存CPU寄存器里原来用户态的指令位
  2. 为了执行内核态代码,CPU寄存器需要更新为内核态指令的新位置。
  3. 跳转到内核态运行内核任务
  4. 当系统调用结束后,CPU寄存器需要恢复原来保存的用户态,然后切换到用户空间,继续运行进程。

所以一次系统调用的过程,其实发生了两次CPU上下文切换。(用户态-->内核态-->用户态)

不过需要注意的是,系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们常说的进程上下文切换是不一样的:进程上下文切换,是指从一个进程切换到另一个进程;而系统调用过程中一直是同一个进程在运行。

所以系统调用通常称为特权模式切换,而不是上下文切换。系统调用属于同进程内的CPU上下文切换。但实际上,系统调用过程中,CPU的上下文切换还是无法避免的。

进程上下文切换跟系统调用又有什么区别呢

首先,进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。

线程上下文切换

线程与进程的最大区别在于:线程是调度的基本单位,而进程是资源分配的基本单位。说白了,所谓内核中的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟了虚拟内存和全局变量等资源。

2. fork系统调用

_do_fork以调用copy_process开始, 后者执行生成新的进程的实际工作, 并根据指定的标志复制父进程的数据。在子进程生成后, 内核必须执行下列收尾操作:

  • 调用 copy_process 为子进程复制出一份进程信息
  • 如果是 vfork(设置了CLONE_VFORK和ptrace标志)初始化完成处理信息
  • 调用 wake_up_new_task 将子进程加入调度器,为之分配 CPU
  • 如果是 vfork,父进程等待子进程完成 exec 替换自己的地址空间
long _do_fork(struct kernel_clone_args *args) {
    //复制进程描述符和执行时所需的其他数据结构
    p = copy_process(NULL, trace, NUMA_NO_NODE, args);
    //将子进程添加到就绪队列
    wake_up_new_task(p);
    //返回子进程pid(父进程中fork返回值为子进程的pid)
    return nr;
}

copy_process函数主要完成了调用dup_task_struct复制当前进程(父进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时子进程置为就绪态)、采用写时复制技术逐一复制所有其他进程资源、调用copy_thread_tls初始化子进程内核栈、设置子进程pid等。其中最关键的就是dup_task_struct复制当前进程(父进程)描述符task_struct和copy_thread_tls初始化子进程内核栈。其大致流程如下

  1. 调用 dup_task_struct 复制当前的 task_struct
  2. 检查进程数是否超过限制
  3. 初始化自旋锁、挂起信号、CPU 定时器等
  4. 调用 sched_fork 初始化进程数据结构,并把进程状态设置为 TASK_RUNNING
  5. 复制所有进程信息,包括文件系统、信号处理函数、信号、内存管理等
  6. 调用 copy_thread_tls 初始化子进程内核栈
  7. 为新进程分配并设置新的 pid
static 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_thread_tls是一个特定于体系结构的函数,用于复制进程中特定于线程(thread-special)的数据, 重要的就是填充task_struct->thread的各个成员,这是一个thread_struct类型的结构, 其定义是依赖于体系结构的。它包含了所有寄存器(和其他信息),内核在进程之间切换时需要保存和恢复的进程的信息。

该函数用于设置子进程的执行环境,如子进程运行时各CPU寄存器的值、子进程的内核栈的起始地址(指向内核栈的指针通常也是保存在一个特别保留的寄存器中)

int copy_thread_tls(unsigned long clone_flags, unsigned long sp,
    unsigned long arg, struct task_struct *p, unsigned long tls)
{
    struct pt_regs *childregs = task_pt_regs(p);
    struct task_struct *tsk;
    int err;
    /*  获取寄存器的信息  */
    p->thread.sp = (unsigned long) childregs;
    p->thread.sp0 = (unsigned long) (childregs+1);
    memset(p->thread.ptrace_bps, 0, sizeof(p->thread.ptrace_bps));

    if (unlikely(p->flags & PF_KTHREAD)) {
        /* kernel thread 内核线程的设置  */
        memset(childregs, 0, sizeof(struct pt_regs));
        p->thread.ip = (unsigned long) ret_from_kernel_thread;
        task_user_gs(p) = __KERNEL_STACK_CANARY;
        childregs->ds = __USER_DS;
        childregs->es = __USER_DS;
        childregs->fs = __KERNEL_PERCPU;
        childregs->bx = sp;     /* function */
        childregs->bp = arg;
        childregs->orig_ax = -1;
        childregs->cs = __KERNEL_CS | get_kernel_rpl();
        childregs->flags = X86_EFLAGS_IF | X86_EFLAGS_FIXED;
        p->thread.io_bitmap_ptr = NULL;
        return 0;
    }
    /*  将当前寄存器信息复制给子进程  */
    *childregs = *current_pt_regs();
    /*  子进程 eax 置 0,因此fork 在子进程返回0  */
    childregs->ax = 0;
    if (sp)
        childregs->sp = sp;
    /*  子进程ip 设置为ret_from_fork,因此子进程从ret_from_fork开始执行  */
    p->thread.ip = (unsigned long) ret_from_fork;
    task_user_gs(p) = get_user_gs(current_pt_regs());

    p->thread.io_bitmap_ptr = NULL;
    tsk = current;
    err = -ENOMEM;

    if (unlikely(test_tsk_thread_flag(tsk, TIF_IO_BITMAP))) {
        p->thread.io_bitmap_ptr = kmemdup(tsk->thread.io_bitmap_ptr,
                        IO_BITMAP_BYTES, GFP_KERNEL);
        if (!p->thread.io_bitmap_ptr) {
            p->thread.io_bitmap_max = 0;
            return -ENOMEM;
        }
        set_tsk_thread_flag(p, TIF_IO_BITMAP);
    }

    err = 0;

    /*
     * Set a new TLS for the child thread?
     * 为进程设置一个新的TLS
     */
    if (clone_flags & CLONE_SETTLS)
        err = do_set_thread_area(p, -1,
            (struct user_desc __user *)tls, 0);

    if (err && p->thread.io_bitmap_ptr) {
        kfree(p->thread.io_bitmap_ptr);
        p->thread.io_bitmap_max = 0;
    }
    return err;
}

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

  

3.execve系统调用

我们主要关注下面几点

  1. 子进程是如何摆脱父进程自立门户的?子进程如何摆脱对父进程用户空间的依赖?
  2. 为什么说execve“一去不复返”?即为什么execve无法返回到(父进程)用户空间调用execve的地方?那么该系统调用返回到用户空间时,又返回到了哪里?
  3. 有效用户ID及有效组ID的处理。
  4. 传递给execve系统调用的argv如何传递给可执行文件的入口main函数?

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

    asmlinkage int sys_execve(struct pt_regs regs)
    {
        int error;
        char * filename;
    
        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;
    }    

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

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

3.1 do_execve主要流程

do_execve定义在<fs/exec.c>中。它的主要流程(忽略掉异常情况的处理)如下:

 

3.2 linux_binprm结构

可执行文件(目标文件)作为一个文件之外,还有一些其他的专属信息,为了将运行一个可执行文件时所需的信息组织在一起,内核定义了linux_binprm结构,其定义如下:

 

    <include/linux/binfmts.h>
    struct linux_binprm{
        char buf[BINPRM_BUF_SIZE];
        struct page *page[MAX_ARG_PAGES];
        unsigned long p; /* current top of mem */
        int sh_bang;
        struct file * file;
        int e_uid, e_gid;
        kernel_cap_t cap_inheritable, cap_permitted, cap_effective;
        int argc, envc;
        char * filename;    /* Name of binary */
        unsigned long loader, exec;
    };

buf用来从可执行文件中读入前128个字节,据此可以判断处可执行文件的类型(比如aout、elf、java、或者脚本等)。

page是一个物理页面指针数组,这些物理页面用来存储execve系统调用中参数argv以及envp所指向的字符串表。数组的size为MAX_ARG_PAGES(32),但具体会分配多少个物理页面,取决于argv已经envp所指向的字符串表的大小。

p用来指向page数组所代表的存储空间的“游标”。

file即可执行文件对应的文件表项。

当可执行文件设置了set-user-ID或者set-group-ID,e_uid和e_gid分别用来存储可执行文件的所有者ID和所在组ID.

filename指向可执行文件的路径(该路径字符串已经拷贝到内核空间)。

3.3 linux_binfmt结构以及search_binary_handler

每一种可执行文件都有对应的“装载器”,用来处理可执行文件的加载甚至是链接,此即linux_binfmt结构。其定义如下:

    <include/linux/binfmts.h>
    struct linux_binfmt {
        struct linux_binfmt * next;
        struct module *module;
        int (*load_binary)(struct linux_binprm *, struct  pt_regs * regs);
        int (*load_shlib)(struct file *);
        int (*core_dump)(long signr, struct pt_regs * regs, struct file * file);
        unsigned long min_coredump;    /* minimal dump size */
    };

其中关键的是几个函数指针,顾名思义,load_binary用来加载可执行文件;load_shlib用来加载共享库;而core_dump用来生成转储文件。

不同的“加载器”通过next指针构成一个链表,链表头即为formats。

每个加载器就像是内核为每种格式的可执行文件设置的代理人,每当执行一个可执行文件时,内核遍历formats中的每个代理人,查看该可执行文件是否归某个代理人处理,如果对上了号,代理人则“认领”该可执行文件,负责后续的加载、执行等事务。这就是search_binary_handler函数的主要工作工程。但具体情况比这复杂,需要考虑内核尚未为某种格式的可执行文件设置代理人的情形。 

3.4 目标文件在内存中的布局如下图所示:

 

3.5 start_thread

在可执行文件加载完成,并且传递给main函数的argc和argv参数处理完毕后,load_aout_binary调用start_thread来设置子进程返回用户空间后的入口(即main函数)以及用户空间堆栈的栈顶指针。 

start_thread的实现如下:

    <include/asm/processor.h>
    #define start_thread(regs, new_eip, new_esp) do {        \
        __asm__("movl %0,%%fs ; movl %0,%%gs": :"r" (0));    \
        set_fs(USER_DS);                    \
        regs->xds = __USER_DS;                    \
        regs->xes = __USER_DS;                    \
        regs->xss = __USER_DS;                    \
        regs->xcs = __USER_CS;                    \
        regs->eip = new_eip;                    \
        regs->esp = new_esp;                    \
    } while (0)

可见,这里将可执行文件的入口ex. a_entry写进eip,而将准备好argc以及argv之后用户空间堆栈的栈顶current->mm->start_stack写进esp,这样当从系统调用返回到子进程的用户空间中时,将从aout文件的入口main函数开始执行,并且通过esp可以获取传递给main函数的argc和argv参数。

 4. Linux系统的一般执行过程

进程上下文切换一般有如下几步:

  • 发生中断,保存当前进程的eip、esp、eflags到内核栈中。
  • 加载新进程的eip、esp。
  • 调用调度函数schedule函数,其中的switch_to完成了上下文的切换。
  • 运行新的进程。

进程调度的时机⼀般都是中断处理后和中断返回前的时机点进行,只有内核线程可以直接调⽤schedule函数主动发起进程调度和进程切换。进程调度根据中断上下文的切换是还是进程上下文的切换分为以下两类:

1、中断上下文的进程调度:用户进程上下⽂中主动调⽤特定的系统调用进⼊中断上下⽂,系统调用返回用户态之前进行进程调度。或者内核线程或可中断的中断处理程序,执行过程中发⽣中断进⼊中断上下文,在中断返回前进行进程调度。

2、进程上下文的进程调度:内核线程主动调⽤schedule函数进⾏进程调度。

正在运行的用户态进程X切换到运行用户态进程Y的过程

       1: 发生中断 ,完成以下步骤:

1 save cs:eip/esp/eflags(current) to kernel stack
2 load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack)  

   2:SAVE_ALL //保存现场,这里是已经进入内核中断处里过程

       3:中断处理过程中或中断返回前调用了schedule(),其中的switch_to做了关键的进程上下文切换

       4:标号1之后开始运行用户态进程Y(这里Y曾经通过以上步骤被切换出去过因此可以从标号1继续执行) 

       5: 通过restore_all来恢复现场

       6: 继续运行用户态进程Y

 

posted @ 2020-06-15 01:29  luoyang712  阅读(233)  评论(0编辑  收藏  举报