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

1.1 六种exec函数

有6种不同的exec函数可供使用,这些函数最终都是通过系统调用execve来实现的:

1  <unistd.h>
2     int execl(const char *pathname, const char *arg1, ... /* (char*)0 */ );
3     int execlp(const char *filename, const char *arg1, ... /* (char*)0 */ );
4     int execle(const char *pathname, const char *arg1, ... 
5                                                     /* (char*)0, char * const *envp */);
6     int execv(const char *pathname, char * const argv[]);
7     int execvp(const char *filename, char * const argv[]);
8     int execve(const char *pathname, char * const argv[], char * const envp[]);

 

 

 

 上图是他们之间的关系

1.2 详解系统调用execve()

这部分我们重点关注下如下问题:

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

这里假定execve执行的程序文件为aout格式的,具体来说是aout格式中的“非可重入代码”,即可执行程序包含正文段(text)、数据段(data)和未初始化数据段(bss)。虽然aout格式已非主流,elf才是当前流行的可执行程序文件的格式,但elf格式比较复杂,涉及到动态加载(loader)与动态链接(linker),而aout格式相对简单,用来了解上述问题是比较合适的。这些问题的答案同样适用于elf格式(或其他格式)的可执行文件。

系统调用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,它们代表的传给可执行文件的参数和环境变量仍然保留在用户空间中。

2.1 do_execve主要流程

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

 

 

2.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指向可执行文件的路径(该路径字符串已经拷贝到内核空间)。

 

2.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函数的主要工作工程。但具体情况比这复杂,需要考虑内核尚未为某种格式的可执行文件设置代理人的情形。

aout格式对应的inux_binfmt结构为aout_format,其定义如下:

 

<fs/binfmt_aout.c>
    static struct linux_binfmt aout_format = {
                                    NULL, 
                                    THIS_MODULE, 
                                    load_aout_binary, 
                                    load_aout_library, 
                                    aout_core_dump, 
                                    PAGE_SIZE
    };

 

可见aout类可执行文件的加载函数为load_aout_binary,这是流程图中的重点。

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

 

 

 

 

2.5 start_thread

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

 start_thread(regs, ex.a_entry, current->mm->start_stack);

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)

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

 

posted @ 2020-06-13 10:29  olddriver555111  阅读(276)  评论(0)    收藏  举报