linux程序启动过程

翻译自:https://lwn.net/Articles/630727/

这个系列有两篇文章,第一篇主要描述当一个用户程序调用execve()系统调用的的时候发生了了什么,内核是怎么运行起来的,更加generic一些,里面会覆盖不同的可执行文件格式;而第二篇主要描述ELF格式可执行程序运行的过程,更加聚焦一些。
作者最近准备增加一个新的系统调用execveat(3.19版内核已经合并到mainline上了),这是一个execve()的近亲,它允许调用者通过已经打开的文件描述符和路径组合起来指定执行哪个可执行程序,execveat&execve的区别和openat&open的区别是非常类似的,这个系统调用的产生使得glibc库提供的fexecve()实现不必再访问于proc文件系统,这个对于沙箱环境(比如Capsicum)的构建是非常友好的。

用户空间的视角

在更加深入内核之前,我们可以先从用户空间视角来看一下程序的执行过程。截止到内核版本3.18之前,开始新的程序只有通过唯一的系统调用接口execve:

    int execve(const char *filename, char *const argv[], char *const envp[]);

filename参数指定了执行哪个程序,argvenvp参数指向一组字符串指针,最后以NULL结尾,分别表示传递到新程序上的命令行参数和环境变量。一个简单的示例demo(do_execve.c)展示了我们可以如何填充他们:参数填充为zero,one,two,命令行填充为"ENVVAR1=1" "ENVVAR2=2",通过另外一个简单的程序(show_info.c)来验证和展示我们在do_execve.c中填充的结果。
通过下面的实验我们看到,argv被正确的传递给被调用程序。通常二进制程序执行的时候argv[0]通常都是程序的名称,但是这个只是惯例,它其实可以通过execve的调用者手动设置的。

    % ./do_execve ./show_info
    argv[0] = 'zero'
    argv[1] = 'one'
    argv[2] = 'two'
    ENVVAR1=1
    ENVVAR2=2

但是程序如果是脚本类的时候会有一些不一样的地方,脚本程序show_info.sh中输出环境变量,和上面一样运行,结果有些不一样:

    % ./do_execve ./show_info.sh
    $0 = './show_info.sh'
    $1 = 'one'
    $2 = 'two'
    ENVVAR1=1
    ENVVAR2=2
    PWD=/home/drysdale/src/lwn/exec

首先,环境变量自动追加了PWD来记录当前工作目录;其次,第一个参数是脚本的文件名称而不是我们设置的zero
而下面的实验不仅展示了/bin/sh脚本解释器自动追加PWD环境变量而且表明内核自己修改它的参数:

    % cat ./wrapper
    #!./show_info
    
    % ./do_execve ./wrapper
    argv[0] = './show_info'
    argv[1] = './wrapper'
    argv[2] = 'one'
    argv[3] = 'two'
    ENVVAR1=1
    ENVVAR2=2

内核移除了第一个参数“zero”同时在头部添加了两个参数:脚本解释器的名称和传递给脚本解释器的文件名称。如果脚本的第一行包含了传递给脚本解释器的额外参数,那么传递的参数变为三个了:脚本解释器的名称,脚本解释器的参数和传递给脚本解释器的文件名称。

    % cat ./wrapper_args
    #!./show_info -a -b -c

    % ./do_execve ./wrapper_args
    argv[0] = './show_info'
    argv[1] = '-a -b -c'
    argv[2] = './wrapper_args'
    argv[3] = 'one'
    argv[4] = 'two'
    ENVVAR1=1
    ENVVAR2=2

简单讲,在执行脚本的时候传递参数的行为就像下面这样:在头部新增脚本解释器名称,其他参数顺序后移

    argv[0]:  'zero'=>'./wrapper4'=>'./wrapper3'=>'./wrapper2'=>'./wrapper' =>'./show_info'
    argv[1]:  'one'   './wrapper5'  './wrapper4'  './wrapper3'  './wrapper2'  './wrapper'
    argv[2]:  'two'   'one'         './wrapper5'  './wrapper4'  './wrapper3'  './wrapper2'
    argv[3]:          'two'         'one'         './wrapper5'  './wrapper4'  './wrapper3'
    argv[4]:                        'two'         'one'         './wrapper5'  './wrapper4'
    argv[5]:                                      'two'         'one'         './wrapper5'
    argv[6]:                                                    'two'         'one'
    argv[7]:                                                                  'two'

但是内核不支持无限循环嵌套,所以当脚本解释器循环嵌套太多的时候它会返回ELOOP错误

    % ./do_execve ./wrapper6
    Failed to execute './wrapper6', Too many levels of symbolic links

内核对象linux_binprm

现在我们下探到内核空间来看一下execve()系统调用究竟是如何实现的,我们选择fs/exec.c ->do_execve_common()作为研究的入口点,它的主要工作是构建一个struct linux_binprm对象来描述当前程序调用的操作,下面先来看一下其中重要的成员,他们是如何能够表达一个可执行程序的信息。
file指向一个即将被执行程序的文件对象,内核可以通过它来读取文件内容来决策它是哪种文件格式从而可以选择怎么解释执行它。
filenameinterp两个都是指向程序的文件名称,下面会有详细解释为什么需要两个成员来描述这项信息。
bprm_mm_init()方法分配并且初始化相关的struct mm_structstruct vm_aread_struct数据对象,这两个负责管理新程序的虚拟内存空间,栈通常是向下增长的,所以通常新程序的虚拟内存都会靠近arch允许的最高地址,除此之外内核为了安全会有随机化,所以真正的虚拟地址会在最高地址向下有一个随机的偏移。
p指向新程序栈的最高地址,但是会留出来一个NULL指针作为栈结束的标记地址,它的确切值是bprm->p = vma->vm_end - sizeof(void *);。如果有新的信息被添加到新程序的栈上时p的值将会被更新。
argcenvc表示参数和环境变量的值来传递给新程序。
unsafe是一个位图来表示程序执行可能不安全的原因,例如一个程序被ptrace或者它自己通过pctrl设置了PR_SET_NO_NEW_PRIVS.LSM可能会在接下来需要使用到这里设置的信息来决定是否拒绝程序执行。
cred是内核为每个可执行程序分配的cred对象,它主要描述了用户凭证信息。通常是继承自execve()调用者,但是当可执行文件有setuid/setgid标志时会来更新凭证信息,而setuid/setgid不仅修改用户凭证而且会影响兼容性功能,他们都会影响系统的安全性。
per_clear记录着进程隐私信息并且会在之后按照它的记录信息来清除进程的个性化信息。
security主要是为LSM提供支持的,允许存储LSM特定的信息,LSM会通过security_bprm_set_creds()被通知到并且选择设置该项内容。默认的实现中这个hook函数会更新新程序的capabilities属性。

上面填充linux_bprm成员的过程主要是在prepare_binprm()中完成,如果一个脚本的文件被实际执行,通常第一行以#!开头,这个对象会被再次更新。

最后,程序调用的信息会被拷贝到新程序栈的顶部。首先程序名称会首先入栈,接下来是环境变量和参数,最后栈顶端布局如下:

    ---------Memory limit---------
    NULL pointer
    program_filename string
    envp[envc-1] string
    ...
    envp[1] string
    envp[0] string
    argv[argc-1] string
    ...
    argv[1] string
    argv[0] string

程序如何被执行

当构建好linux_binprm对象后,程序真正的执行过程由exec_binprm()和search_binary_handler()来接管.它们会遍历linux_binfmt对象的链表,每一个linux_binfmt都会提供一个handler来负责解释程序。这些linux_binfmt可能会被编译成模块形式,所以需要通过try_module_get()来保证相关的module不会在使用他们的时候被卸载掉。

对于每个linux_binfmt handler对象,他们都提供了load_binary()回调实现,处理对象就是linux_binprm。如果handler支持这种文件格式就返回成功值(>=0),如果不支持就返回失败码(<0)并继续遍历迭代。
一个特殊的程序执行过程中可能还会依赖另一种程序的执行,最明显的例子就是可执行脚本,它需要调用脚本解释器,而脚本解释器通常就是一个二进制程序。考虑到这种情况,search_binary_handler()可以被递归使用,不过他们都是使用同一个linux_binprm对象。但是这个递归深度不可能无限循环,超过最大深度就返回ELOOP
系统的LSM也会参与到这个过程中,在查找哪个linux_binfmt可以处理当前的linux_binprm之前,会触发bprm_check_security hook,给LSM一个机会来决策是否允许执行,此时LSM可能会使用linux_binprm.security对象。

在遍历结束后,如果当前程序还不能处理,系统可能会尝试来加载binfmt-XXXX这种名称的驱动,XXXX是程序的第三和第四个字节的十六进制表示。这种机制是在内核1.3.57版本(1996年)上添加的,主要是为了能够以一种更加灵活的方式来关联可执行程序和它的驱动。不过最近的binfmt_misc机制提供了一种相似的但是更加灵活便捷的方式。

二进制格式

内核支持一些可执行二进制的格式,我们可以通过在代码中搜索关键词register_binfmt & insert_binfmt()来找到都有哪些struct linux_binfmt实例,更加简洁的方式就是ls fs/binfmt_*,内核的命名非常规范的,我们可以找到所有支持的格式。下面是所有可以配置的格式,注释摘抄自fs/Kconfig.binfmts文件,我就不再当搬运工了

binfmt_script.c: Support for interpreted scripts, starting with a #! line.
binfmt_misc.c: Support miscellaneous binary formats, according to runtime configuration.
binfmt_elf.c: Support for ELF format binaries.
binfmt_aout.c: Support for traditional a.out format binaries.
binfmt_flat.c: Support for flat format binaries.
binfmt_em86.c: Support for Intel ELF binaries running on Alpha machines.
binfmt_elf_fdpic.c: Support for ELF FDPIC binaries.
binfmt_som.c: Support for SOM format binaries (an HP/UX PA-RISC format).
(plus a couple of other architecture-specific formats).

下面的章节主要解释:解释脚本和“misc”机制,后面一篇文章专门讲述ELF格式,ELF基本上是所有可执行程序最终都要调用的。

script

如果一个可执行程序以#!开头那么我们就认为他是一个脚本程序,并通过fs/binfmt_script.c中的handler来执行。在检查前两个字节后,会继续解析这一行的内容,从#!之后到第一个空格之间的内容就是解释器的内容,空格之后直到换行的内容是解释器的参数。
内核在解析可执行文件时,linux_binprm.buf只有128字节,所以如果解释器的长度大于128字节,它会被截断。
接下来,会从新程序的栈上移除argv[0],然后追加下列参数并且调整argc的值:

the program name
(optionally) the collected interpreter arguments
the name of the interpreter program

与上面用户空间程序观察到的结果结合,新程序的栈排布就像这样:

    ---------Memory limit---------
    NULL pointer
    program_filename string
    envp[envc-1] string
    ...
    envp[1] string
    envp[0] string
    argv[argc-1] string
    ...
    argv[1] string
    program_filename string
    ( interpreter_args )
    interpreter_filename string

脚本handler处理过程中会更改linux_binprminterp的值,它最后是解释器的文件名称而不是脚本文件名称,这就解释了为什么linux_binprm中为什么有两个字符串:interp是我们当前要执行的程序,而filenameexecve()中调用的文件。同时file也会指向新的解释器程序文件,buf中保存的是解释器的前128字节。
之后如果解释器还是个脚本,脚本handler会递归调用search_binary_handler(),再次重复当前过程,这个过程中interp会一直指向最新的解释器,但是filename一直是最初的脚本文件名称。如果解释器是ELF格式,那么就会移交到ELF handler中

misc

早期的内核版本支持一种动态添加可执行文件格式支持的方式,它通过文件的第三和第四个字节的内容来搜索内核模块,但是它并不是非常的完美,两个字节最多支持256种格式,非常可能会被用光,所以需要一种新的内核模块来处理。misc二进制格式提供了一种更加便捷并且支持动态添加的方式,只需要挂载proc并且通过/proc/sys/fs/binfmt_misc接口就能实现运行时配置。

它可以通过文件名的后缀或者在文件特殊的偏移上magic匹配,就像脚本解释器一些样,不过magic也必须在文件的前128字节内,之后解释器程序被调用并且原始程序文件名称放在argv[1]上传递给它。

java文件就是非常好的例子:如果文件是以.jar/.class结尾或者有0xCAFEBASE magic,就会自动调用JVM程序来加载这个文件。因为misc配置并不支持指定参数,所以需要一个封装的脚本来提供相关命令行参数支持,misc来调用这个封装的脚本,最终调用JVM的ELF可执行格式文件

它的内部实现和脚本解释器比较像,只不过它会再次在它注册配置里面搜索匹配格式处理,匹配的格式处理可能有些可选项,例如移除argv[0].

script和misc格式都会调用解释器程序,不过最终都会以调用ELF二进制程序为终点,这个内容见下篇内容。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章