应用程序在 Linux 上的执行过程
执行文件是如何在 shell 中被"执行"的。本文中尽可能少用一些源码,免得太过于无聊,主
要讲清这个过程,感兴趣的同学可以去查看相应的源码了解更多的信息。
1.父进程的行为: 复制,等待
执行应用程序的方式有很多,从 shell 中执行是一种常见的情况。交互式 shell 是一个进
程(所有的进程都由 pid 号为 1 的 init 进程 fork 得到,关于这个话题涉及到 Linux 启动和初
始化,以及 idle 进程等,有空再说),当在用户在 shell 中敲入./test 执行程序 时,shell 先 fork()
出一个子进程(这也是很多文章中说的子 shell),并且 wait()这个子进程结束,所以当 test 执行
结束后,又回到了 shell 等待用户输入(如果创建的是所谓的后台进程,shell 则不会等待子
进程结束,而直接继续往下执行)。所以 shell 进程的主要工作是复制一个 新的进程,并等
待它的结束。
2.子进程的行为: "执行"应用程序
2.1 execve()
另一方面,在子进程中会调用 execve()加载 test 并开始执行。这是 test 被执行的关键,下
面我们详细分析一下。
execve()是操作系统提供的非常重要的一个系统调用,在很多文章中被称为 exec()系统
调用(注意和 shell 内部 exec 命令不一样),其实在 Linux 中并没有 exec()这个系统调用,exec
只是用来描述一组函数,它们都以 exec 开头,分别是:
#include
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
这几个都是都是 libc 中经过包装的的库函数,最后通过系统调用 execve()实现(#define
__NR_evecve 11,编号 11 的系统调用)。
exec 函数的作用是在当前进程里执行可执行文件,也就是根据指定的文件名找到可执
行文件,用它来取代当前进程的内容,并且这个取代是不可逆 的,即被替换掉的内容不再
保存,当可执行文件结束,整个进程也随之僵死。因为当前进程的代码段,数据段和堆栈等
都已经被新的内容取代,所以 exec 函数族 的函数执行成功后不会返回,失败是返回-1。可
执行文件既可以是二进制文件,也可以是可执行的脚本文件,两者在加载时略有差别,这里
主要分析二进制文件的 运行。
2.2 do_execve()
在 用 户 态 下 调 用 execve() , 引 发 系 统 中 断 后 , 在 内 核 态 执 行 的 相 应 函 数 是
do_sys_execve(),而 do_sys_execve()会调用 do_execve()函数。do_execve()首先会读入可执
行文件,如果可执行文件不存在,会报错。然后对 可执行文件的权限进行检查。如果文件
不是当前用户是可执行的,则 execve()会返回-1,报 permission denied 的错误。否则继续读入
运行可执行文件时所需的信息(见 struct linux_binprm)。
2.3 search_binary_handler()
接着系统调用 search_binary_handler(),根据可执行文件的类型(如 shell,a.out,ELF 等),
查找到相应的 处理函数(系统为每种文件类型创建了一个 struct linux_binfmt,并把其串在一
个链表上,执行时遍历这个链表,找到相应类型的结构。如果要自己定义一种可执行文件格
式,也需要实现这么一个 handler)。然后执行相应的 load_binary()函数开始加载可执行文件。
2.4 load_elf_binary()
加载 elf 类型文件的 handler 是 load_elf_binary(),它先读入 ELF 文件的头部,根据 ELF
文件的头部信息读入各种数 据(header information)。再次扫描程序段描述表,找到类型为
PT_LOAD 的段,将其映射(elf_map())到内存的固定地址上。如果没有动态链接 器的描述段,
把返回的入口地址设置成应用程序入口。完成这个功能的是 start_thread(),start_thread()并不
启动一个线程,而只 是用来修改了 pt_regs 中保存的 PC 等寄存器的值,使其指向加载的应
用程序的入口。这样当内核操作结束,返回用户态的时候,接下来执行的就是应用程序 了。
2.5 load_elf_interp()
如果应用程序中使用了动态链接库,就没有那么简单了,内核除了加载指定的可执行文
件,还要把控制权交给动态连接器(program interpreter,ld.so in linux)以处理动态链接的程序。
内 核搜寻段表,找到标记为 PT_INTERP 的 段中所对应的动态连接器的名称,并使用
load_elf_interp()加载其映像,并把返回的入口地址设置成 load_elf_interp()的返回值,即动态
链接器入口。当 execve 退出的时候动态链接器接着运行。动态连接器检查应用程序对共享
连接库的依赖性,并在需要时对其进行加载,对程序的外部引用进行重定位。然后动 态连接
器把控制权交给应用程序,从 ELF 文件头部中定义的程序进入点开始执行。(比如 test.c 中
使用了 userlib.so 中函数 foo(),在编译 的时候这个信息被放进了 test 这个 ELF 文件中,相应
的语句也变成了 call fakefoo()。当加载 test 的时候,知道 foo()是一个外部调用,于是求助于动
态链接器,加载 userlib.so,解析 foo()函数地址,然后让 fakefoo()重定向到 foo(),这样 call foo()
就成功了。)
简短的说,整个在 shell 中键入./test 执行应用程序的过程为:当前 shell 进程 fork 出一
个子进程(子 shell),子进程使用 execve 来脱离和父进程的关系,加载 test 文件(ELF 格式)
到内存中。如果 test 使用了动态链接库,就需要加载动态链接器(或者叫程序解释 器),进一
步加载 test 使用到的动态链接库到内存,并重定位以供 test 调用。最后从 test 的入口地址开
始执行 test。
PS: 现代的动态链接器因为性能等原因都采用了延迟加载和延迟解析技术,延迟加载
是动态连接库在需要的时候才被加载到内存空间中(通过页面 异常机制),延迟解析是指到
动态链接库(以加载)中的函数被调用的时候,才会去把这个函数的起始地址解析出来,供调
用者使用。动态链接器的实现相当的复 杂,为了性能等原因,对堆栈的直接操作被大量使
用,感兴趣的可以找相关的代码看看。