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

Attend 2020-06-14

一、实验要求

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

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

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

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

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

二、实验步骤

  1. fork系统调用

    fork系统调用用来创建子进程。首先通过一个简单的程序验证一下fork的行为。

    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <sys/types.h>
    #include <sys/wait.h>
     
    int main() {
        pid_t pid;
        pid = fork();
        
        if (pid < 0) {
            printf("Fork failed\n");
        } else if (pid == 0) {
            printf("This is child process\n");
        } else {
            printf("This is parent process\n");
        }
    ?
        return 0;

    编译并执行,可以看到两个分支下的语句都被打印了出来。这是因为两条语句是由父子两个进程分别执行的。这正是fork系统调用的特殊之处:一处调用,两处返回。

查阅arch/x86/entry/syscalls/syscall_64.tbl可知,64位下,fork库函数对应的系统调用号为57,入口地址为___x64_sys_fork。其实现位于kernel/fork.c源文件中,还有与之相关的vfork,clone系统调用也一同定义在该文件中。

SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
    struct kernel_clone_args args = {
        .exit_signal = SIGCHLD,
    };
?
    return _do_fork(&args);
#else
    /* can not support in nommu mode */
    return -EINVAL;
#endif
}

可以看到,fork的具体工作交由__do_fork完成。

/*
 *  Ok, this is the main fork-routine.
 *
 * It copies the process, and if successful kick-starts
 * it and waits for it to finish using the VM if required.
 *
 * args->exit_signal is expected to be checked for sanity by the caller.
 */
long _do_fork(struct kernel_clone_args *args)
{
    // ...
    
    struct pid *pid;
    struct task_struct *p;
    int trace = 0;
    long nr;
?
    // ...
?
    p = copy_process(NULL, trace, NUMA_NO_NODE, args);
    
    // ...
?
    wake_up_new_task(p);
?
    // ...
    
    put_pid(pid);
    return nr;}
 

__do_fork函数主要完成了调用copy_process复制父进程、获得pid、调用wake_up_new_task将子进程加入就绪队列等待调度执行等。我们知道,在Linux中,除了0号进程由手工创建外,其他进程都是通过复制已有进程创建而来,而这正是fork的主要工作,具体的任务交由copy_process完成。

/*
 * This creates a new process as a copy of the old one,
 * but does not actually start it yet.
 *
 * It copies the registers, and all the appropriate
 * parts of the process environment (as per the clone
 * flags). The actual kick-off is left to the caller.
 */
static __latent_entropy struct task_struct *copy_process(
                    struct pid *pid,
                    int trace,
                    int node,
                    struct kernel_clone_args *args)
{
    int pidfd = -1, retval;
    struct task_struct *p;
    
    // ...
    
    p = dup_task_struct(current, node);
    
    // ...
    
    /* copy all the process information */
    shm_init_task(p);
    
    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等。

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

int copy_thread_tls(unsigned long clone_flags, unsigned long sp,unsigned long arg, struct task_struct *p, unsigned long tls)
{
    // ...
    
    frame->ret_addr = (unsigned long) ret_from_fork;
    p->thread.sp = (unsigned long) fork_frame;
    *childregs = *current_pt_regs();
    childregs->ax = 0;
    
    // .
 

struct task_struct thread字段保存了进程的部分硬件上下文信息,包括一些关键的CPU寄存器,如sp。可以看到copy_thread_tls中,将thread.sp字段设置成了fork_frame起始地址,正如下文会提到的,这将是子进程内核栈的栈顶位置。

对于子进程的内核栈,使用fork_frame进行填充,其定义在arch/x86/include/asm/switch_to.h头文件中:

struct fork_frame {
    struct inactive_task_frame frame;
    struct pt_regs regs;
};

fork_frame在系统调用寄存器结构体pt_regs的基础上,增加了inactive_task_frame结构体,

struct inactive_task_frame {
#ifdef CONFIG_X86_64
    unsigned long r15;
    unsigned long r14;
    unsigned long r13;
    unsigned long r12;
#else
    unsigned long flags;
    unsigned long si;
    unsigned long di;
#endif
    unsigned long bx;
?
    /*
     * These two fields must be together.  They form a stack frame header,
     * needed by get_frame_pointer().
     */
    unsigned long bp;
    unsigned long ret_addr;
};

其中,ret_addr指定了子进程返回时的执行地址,其被设置为ret_from_fork。这样,初始化完成的子进程内核栈的布局便如下图所示。子进程被加入就绪队列后,就可以正常地参与到进程的调度切换过程中了。

2.execve系统调用

execve系统调用于为进程载入执行镜像。前述的fork主要用于创建新进程,但并没有为进程指定新任务,而这正是exec的功能。所以fork一般于execve相互配合启动一个新程序。用户态函数库提供了exec函数族来通过execve系统调用加载执行一个可执行文件,它们的差异在于对命令行参数和环境变量参数的传递方式不同。64位下,execve系统调用号为56,函数入口为__x64_sys_execve。

该系统调用的实现位于fs/exec.c中:

SYSCALL_DEFINE3(execve,
        const char __user *, filename,
        const char __user *const __user *, argv,
        const char __user *const __user *, envp)
{
    return do_execve(getname(filename), argv, envp);
}

其调用了do_execve,后者调用了do_execveat_common,最终的工作由__do_execve_file完成。这仍然是很长的一段函数实现,我们选取关键代码:

/*
 * sys_execve() executes a new program.
 */
static int __do_execve_file(int fd, struct filename *filename,
                struct user_arg_ptr argv,
                struct user_arg_ptr envp,
                int flags, struct file *file)
{
    char *pathbuf = NULL;
    struct linux_binprm *bprm;
    struct files_struct *displaced;
    int retval;
    // ...
    bprm->file = file;
    // ...
    retval = prepare_binprm(bprm);
    // ...
    retval = copy_strings(bprm->envc, envp, bprm);
    // ...
    retval = exec_binprm(bprm);
    // ...
    return retval;
}

该函数的主要功能是从文件中载入ELF可执行文件并执行。其中exec_binprm实际执行了文件。后者的关键是调用search_binary_handler,这是真正替换进程镜像的地方。

static int exec_binprm(struct linux_binprm *bprm)
{
    pid_t old_pid, old_vpid;
    int ret;
?
    /* Need to fetch pid before load_binary changes it */
    old_pid = current->pid;
    rcu_read_lock();
    old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
    rcu_read_unlock();
?
    ret = search_binary_handler(bprm);
    if (ret >= 0) {
        audit_bprm(bprm);
        trace_sched_process_exec(current, old_pid, bprm);
        ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
        proc_exec_connector(current);
    }
?
    return ret;
}

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

三、实验总结

附上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的内核态。

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

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

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

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

  5. 继续运??户态进程Y。

相关推荐