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

一、实验要求

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

  • 分析execve系统调用中断上下文的特殊之处

  • 分析fork子进程启动执行时进程上下文的特殊之处

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

二、实验过程

  1. fork系统调用

  2. fork系统调用可以建立一个新进程,把当前的进程分为父进程和子进程。新创建的子进程与父进程十分类似,子进程得到与父进程用户级虚拟地址空间相同(但是独立)的一份拷贝,包括文本,数据和bss段、堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝。这就是意味着当父进程调用fork时候,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大区别在于他们有着不同的PID。

    进程的创建过程大致是父进程通过fork系统调用进入内核_ do_fork函数,如下图所示复制进程描述符及相关进程资源(采用写时复制技术)、分配子进程的內核堆栈并对內核堆栈和 thread等进程关键上下文进行初始化,最后将子进程放入就绪队列,fork系统调用返回;而子进程则在被调度执行时根据设置的內核堆栈和thread等进程关键上下文开始执行,对于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;
    
        // ...
    
        // 复制进程描述符,返回创建的task_struct的指针
        p = copy_process(clone_flags, stack_start, stack_size,
                 child_tidptr, NULL, trace);
    
        if (!IS_ERR(p)) {
            struct completion vfork;
            struct pid *pid;
    
            trace_sched_process_fork(current, p);
    
            // 取出task结构体内的pid
            pid = get_task_pid(p, PIDTYPE_PID);
            nr = pid_vnr(pid);
    
            if (clone_flags & CLONE_PARENT_SETTID)
                put_user(nr, parent_tidptr);
    
            // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行
            if (clone_flags & CLONE_VFORK) {
                p->vfork_done = &vfork;
                init_completion(&vfork);
                get_task_struct(p);
            }
    
            // 将子进程添加到调度器的队列,使得子进程有机会获得CPU
            wake_up_new_task(p);
    
            // ...
    
            // 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间
            // 保证子进程优先于父进程运行
            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函数主要完成了调用dup_task_struct复制当前进程(父进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时?进程置为就绪态)、采?写时复制技术逐?复制所有其他进程资源、调?copy_thread_tls初始化子进程内核栈、设置子进程pid等。

    其中copy_thread_tls所做的工作是关键。我们知道执行fork系统调用之后,会由内核态返回两次:一次返回到父进程,这与一般的系统调用返回流程别无二致;而另一次则返回到子进程,为了实现这一点,就需要为子进程构造出合适的执行上下文,也就是初始化其内核栈和进程描述符的thread字段。这正是copy_thread_tls的任务。

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

    正常的?个系统调?都是陷?内核态,再返回到?户态,然后继续执?系统调?后的下?条指令。

    fork和其他系统调?不同之处是它在陷?内核态之后有两次返回,第?次返回到原来的?进程的位置继续向下执?,这和其他的系统调?是?样的。

    在?进程中fork也返回了?次,会返回到?个特 定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调?返回到?户态。

  3. execve系统调用

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

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

    asmlinkage int sys_execve(struct pt_regs regs)
    {
        int error;
        char * filename;
        // 将可执行文件的名称装入到一个新分配的页面中
        filename = getname((char __user *) regs.ebx);
        error = PTR_ERR(filename);
        if (IS_ERR(filename))
            goto out;
        // 执行可执行文件
        error = do_execve(filename,
                (char __user * __user *) regs.ecx,
                (char __user * __user *) regs.edx,
                &regs);
        if (error == 0) {
            task_lock(current);
            current->ptrace &= ~PT_DTRACE;
            task_unlock(current);
            /* Make sure we don‘t return using sysenter.. */
            set_thread_flag(TIF_IRET);
        }
        putname(filename);
        out:
        return error;
    }

    execve系统调用执行过程:

    1. execve系统调用陷入内核,并传入命令行参数和shell上下文环境
    2. execve陷入内核的第一个函数:do_execve,该函数封装命令行参数和shell上下文
    3. do_execve调用do_execveat_common,后者进一步调用__do_execve_file,打开ELF文件并把所有的信息一股脑的装入linux_binprm结构体
    4. __do_execve_file中调用search_binary_handler,寻找解析ELF文件的函数
    5. search_binary_handler找到ELF文件解析函数load_elf_binary
    6. load_elf_binary解析ELF文件,把ELF文件装入内存,修改进程的用户态堆栈(主要是把命令行参数和shell上下文加入到用户态堆栈),修改进程的数据段代码段
    7. load_elf_binary调用start_thread修改进程内核堆栈(特别是内核堆栈的ip指针)
    8. 进程从execve返回到用户态后ip指向ELF文件的main函数地址,用户态堆栈中包含了命令行参数和shell上下文环境

    当execve在调用时陷入内核态,就执行do_execve文件,覆盖当前的可执行程序,所以 返回的是新的可执行程序的起点。main函数位置是静态链接的可执行文件,动态链接的可执行文件需要连接动态链接库后在开始执行。

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

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

    2. 发生中断(包括异常、系统调用等),CPU完成load cs:rip(entry of a specific ISR),即跳转到中断处理程序入口。

    3. 中断上下文切换,具体包括如下几点:
      ①swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了一个快照
      ②rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调用是由系统调用入口处的汇编代码实现?户堆栈和内核堆栈的切换
      ③save cs:rip/ss:rsp/rflags:将当前CPU关键上下文压入进程X的内核堆栈,快速系统调用是由系统调用入口处的汇编代码实现的
      ④此时完成了中断上下文切换,即从进程X的用户态到进程X的内核态

    4. 中断处理过程中或中断返回前调用了schedule函数,其中完成了进程调度算法选择next进程、进程地址空间切换、以及switch_to关键的进程上下文切换等

    5. switch_to调用了__switch_to_asm汇编代码做了关键的进程上下文切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(本例假定为进程Y)的内核堆栈,并完成了进程上下文所需的指令指针寄存器状态切换。之后开始运行进程Y(这里进程Y曾经通过以上步骤被切换出去,因此可以从switch_to下一行代码继续执行)

    6. 中断上下文恢复,与3中断上下文切换相对应。注意这里是进程Y的中断处理过程中,而3中断上下文切换是在进程X的中断处理过程中,因为内核堆栈从进程X切换到进程Y了

    7. 为了对应起见中断上下文恢复的最后一步单独拿出来(6的最后一步即是7)iret - pop cs:rip/ss:rsp/rflags,从Y进程的内核堆栈中弹出3中对应的压栈内容。此时完成了中断上下文的切换,即从进程Y的内核态返回到进程Y的用户态。注意快速系统调用返回sysret与iret的处理略有不同

    8. 继续运行用户态进程Y

相关推荐