在Linux下面,对二进制程序有着严格的格式要求,这就是ELF,这个格式可以根据编译的结果不同,分为不同的格式。
ELF的三种类型
一:可重定位文件
在编译的时候,先做预处理工作,例如将头文件嵌入到正文中,将定义的宏展开,然后就是真正 的编译过程,最终编译成为.o文件,这就是ELF的第一种类型,可重定位文件,长这样:
二、可执行文件:
要想让函数作为库文件被重用,不能以.o的形式存在,而是要形成库文件,最简单的类型是静态链接库.a文件(Archives),仅仅将一系列对象文件(.o)归档为一个文 件,使用命令ar创建
ar cr libstaticprocess.a process.o
虽然这里libstaticprocess.a里面只有一个.o,但是实际情况可以有多个.o。当有程序要使用这个静态连接库的时候,会将.o文件提取出来,链接到程序中。在这个命令里,-L表示在当前目录下找.a文件,-lstaticprocess会自动补全文件名,比如加前缀 lib,后缀.a,变成libstaticprocess.a,找到这个.a文件后,将里面的process.o取出来,和 createprocess.o做一个链接,形成二进制执行文件staticcreateprocess。这个链接的过程,重定位就起作用了,原来createprocess.o里面调用了create_process函数,但是不能确定位置,现在将process.o合并了进来,就知道位置了,形成的二进制文件叫可执行文件,是ELF的第二种格式:
这个格式和.o文件大致相似,还是分成一个个的section,并且被节头表描述。只不过这些section是多个.o文件合并过的。但是这个时候,这个文件已经是马上就可以加载到内存里面执行的文件了,因而这些section被分成了需要加载到内存里面的代码段、数据段和不需要加载到内存里面的部分,将小的section合成了大的段segment,并且在最前面加一个段头表 (Segment Header Table)。在代码里面的定义为struct elf32_phdr和struct elf64_phdr,这里面除了有对于段的描述之外,最重要的是p_vaddr,这个是这个段加载到内存的虚拟地址。
三、共享对象文件
静态链接库有个缺点,就是一旦链接进去,代码和变量的section都合并了,因而程序运行的时候,就不依赖于这个库是否存在,是相同的代码段,如果被多个程序使用的话,在内存里面就有多份,而且一旦静态链接库更新了,如果二进制执行文件不重新编译,也不随着更新。因为出现了另外一种——动态链接库,不仅是一组对象文件的简单归档,而是多个对象文件的重新组合,可被多个程序共享。
gcc -shared -fPIC -o libdynamicprocess.so process.o
当一个动态链接库被链接到一个程序文件中的时候,最后的程序文件并不包括动态链接库中的代 码,而仅仅包括对动态链接库的引用,并且不保存动态链接库的全路径,仅仅保存动态链接库的 名称。动态链接库就是第三种类型,共享对象文件。
基于动态连接库创建出来的二进制文件格式还是ELF,但是稍有不同。首先,多了一个.interp的Segment,这里面是ld-linux.so,这是动态链接器,也就是说,运行时的链接动作都是它做的。另外,ELF文件中还多了两个section,一个是.plt,过程链接表(Procedure Linkage Table, PLT),一个是.got.plt,全局偏移量表(Global Offset Table,GOT)。
比如对于dynamiccreateprocess这个程序要调用libdynamicprocess.so里的create_process函数。由于是运行时才去找,编译的时候,压根不知道这个函数在哪里,所以就在PLT里面建立一项 PLT[x]。这一项也是一些代码,有点像一个本地的代理,在二进制程序里面,不直接调用 create_process函数,而是调用PLT[x]里面的代理代码,这个代理代码会在运行的时候找真正的 create_process函数。而代理代码就是用到了GOT这里面也会为create_process函数创建一项GOT[y]。这一项是运行时create_process函数在内存中真正的地址。
对于GOT怎么直到create_process函数在内存中真正的地址?一开始创建了GOT[y]里面是空的,他调用plt[0],PLT[0]转而调用GOT[2],这里面是是ld-linux.so的 入口函数,这个函数会找到加载到内存中的libdynamicprocess.so里面的create_process函数的地址,然后把这个地址放在GOT[y]里面。
运行程序为进程
上面讲完了ELF格式,生成ELF文件之后还是个程序,那怎么把这个文件加载到内存里面呢?在内核中,有这么一个数据结构用来定义加载二进制文件的方法:
struct linux_binfmt {
struct list_head lh;
struct module *module;
int (*load_binary)(struct linux_binprm *);
int (*load_shlib)(struct file *);
int (*core_dump)(struct coredump_params *cprm);
unsigned long min_coredump; /* minimal dump size */
} __randomize_layout;
对于ELF文件,有对应的实现:
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};
原理exec这个系统调用最终调用的load_elf_binary
进程树
既然所有的进程都是从父进程fork过来的,那总归有一个祖宗进程,这就是咱们系统启动的init进程。在解析Linux的启动过程的时候,1号进程是/sbin/init。如果在centOS 7里面,我们ls一下,可 以看到,这个进程是被软链接到systemd的。PID 1的进程就是我们的init进程systemd,PID 2的进程是内核线程kthreadd,这两 个我们在内核启动的时候都见过。其中用户态的不带中括号,内核态的带中括号。
总结
对高级编程语言,比如对C语言:
1、我们先将文件编译成so文件和可执行文件,放在硬盘上。
2、用户态的进程A执行fork(),创建进程B。
3、在进程B的处理逻辑中,执行exec系列系统调用,这个系统调用会通过load_elf_binary方法,将刚才生成的可执行文件,加载到进程B的内存中执行。