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

实验要求:

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

实验内容:

1. 以fork为例分析中断上下文的切换

        fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。它不需要参数并返回一个整数值。下面是fork()返回的不同值。一个现有进程可以调用fork函数创建一个新进程。由fork创建的新进程被称为子进程(child process)。fork函数被调用一次但返回两次。两次返回的唯一区别是子进程中返回0值而父进程中返回子进程ID。因此,可以通过返回值来判定该进程是父进程还是子进程。

        fork系统调用是通过do_fork来实现的,具体过程如下:首先是用户程序调用fork(),然后是库函数fork(),系统调用fork(通过系统调用号),通过sys_call_table中寻到sys_fork()的函数地址,调用sys_fork,最后调用do_fork();

        do_fork()的关键代码:

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

       _do_fork以调用copy_process开始, 后者执行生成新的进程的实际工作, 并根据指定的标志复制父进程的数据。

  • 调用 copy_process 为子进程复制出一份进程信息
  • 如果是 vfork(设置了CLONE_VFORK和ptrace标志)初始化完成处理信息
  • 调用 wake_up_new_task 将子进程加入调度器,为之分配 CPU
  • 如果是 vfork,父进程等待子进程完成 exec 替换自己的地址空间

        copy_process函数代码:

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

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

      copy_thread_tls函数代码:

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

       copy_thread_tls是一个特定于体系结构的函数,用于复制进程中特定于线程(thread-special)的数据, 重要的就是填充task_struct->thread的各个成员,这是一个thread_struct类型的结构, 其定义是依赖于体系结构的。它包含了所有寄存器(和其他信息),内核在进程之间切换时需要保存和恢复的进程的信息。该函数用于设置子进程的执行环境,如子进程运行时各CPU寄存器的值、子进程的内核栈的起始地址(指向内核栈的指针通常也是保存在一个特别保留的寄存器中)

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

2.以execve为例分析中断上下文的切换

       当前的可执⾏程序在执⾏,执⾏到execve系统调⽤时陷⼊内核态,在内核⾥⾯⽤do_execve加载可执⾏⽂件,把当前进程的可执⾏程序给覆盖掉。当execve系统调⽤返回时,返回的已经不是原来的那个可执⾏程序了,⽽是新的可执⾏程序。 execve返回的是新的可执⾏程序执⾏的起点,静态链接的可执⾏⽂件也就是main函数的⼤致位置,动态链接的可执⾏⽂件还需要ld链接好动态链接库再从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;
    }    

execve系统调用的执行过程:

  • 陷入内核
  • 加载新的可执行文件并进行可执行性检查
  • 将新的可执行文件映射到当前运行进程的进程空间中,并覆盖原来的进程数据
  • 将EIP的值设置为新的可执行程序的入口地址。如果可执行程序是静态链接的程序,或不需要其他的动态链接库,则新的入口地址就是新的可执行文件的main函数地址;如果可执行程序还需要其他的动态链接库,则入口地址是加载器ld的入口地址
  • 返回用户态,程序从新的EIP出开始继续往下执行。至此,老进程的上下文已经被新的进程完全替代了,但是进程的PID还是原来的。从这个角度来看,新的运行进程中已经找不到原来的对execve调用的代码了,所以execve函数的一个特别之处是他从来不会成功返回,而总是实现了一次完全的变身。   

     最终execve系统调用从内核返回到用户态时,返回到的不是触发execve触发的下一条指令,下一条指令已经不存在了,已经被覆盖掉了,最终返回到new_ip,也就是ELF entry的位置。特殊之处像fork一样修改了内核堆栈栈低的关键的cpu上下文,但与fork不同,execve修改了中断上下文,fork修改的是进程上下文。

3.execve系统调用和fork子进程启动执行时中断上下文的特殊之处

       系统调用可以看做是一种特殊的中断,因此自然涉及中断上下文,也就是切换到用户内核栈,同时保存相关的寄存器使得中断结束后能够正常返回。
       fork系统调用,特殊之处在于其创建了一个新的进程,并且在父子进程中各有一次返回。对于fork的父进程来说,fork系统调用和普通的系统调用基本相同。但是对fork子进程来说,需要设置子进程的进程上下文环境,这样子进程才能从fork系统调用后返回。
       execve系统调用,由于execve使得新加载可执⾏程序已经覆盖了原来⽗进程的上下⽂环境,而原来的中断上下文就是保存的是原来的、被覆盖的进程的上下文,因此需要修改原来的中断上下文,使得系统调用返回后能够指向现在加载的这个可执行程序的入口。

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

以正在运行的用户态进程X切换到用户态进程Y为例具体表述如下:

  • 正在运行的用户态进程X
  • 发生中断(包括异常、系统调用等),硬件完成以下动作:1)save cs:eip/ss:eip/eflags:当前CPU上下文压入用户态进程X的内核堆栈;2)load cs:eip/ss:esp:加载当前进程内核堆栈相关信息,跳转到中断处理程序处,即中断处理程序的起点
  • SAVE_ALL,保存现场,此时完成了中断上下文的切换,即从进程X的用户态到进程X的内核态
  • 中断处理过程中或中断返回前调用了schedule函数进行进程上下文切换。将当前用户进程X的内核堆栈切换到挑选出的next进程Y的内核堆栈,并完成进程上下文所需的EIP等寄存器的状态切换;
  • 标号1,即$1f,之后开始运行用户态进程Y
  • restore_all,恢复现场,与SAVE_ALL保存现场相对应
  • 从Y进程的内核堆栈弹出步骤2硬件完成的压栈内容,此时完成中断上下文的切换,即从进程Y的内核态返回进程Y的用户态;
  • 继续运行进程Y

 

posted @ 2020-06-15 12:12  zxbs  阅读(199)  评论(0编辑  收藏  举报