Linux内核工程导论——进程:ELF文件执行原理


1、 进程的执行

我们都知道一个现象,windows下的进程在linux下无法双击打开,反之也一样。但是同样是是用C或者golang写的程序分别在linux下编译和在windows下编译都可以执行。当然,如果你调用了操作系统特有的系统调用也是不可以执行的。确切的说是编译不通过的。我们这里讨论没有调用操作系统相关的系统调用,都使用标准的C库函数。标准C库函数在后台也是调用的系统调用的,但是这个转换工作是分别在不同操作系统的不同C库实现中完成的。

为什么没有调用操作系统相关的系统调用还无法执行呢?有人会说因为在linux下编译的是elf格式,在windows下编译的是exe格式。这个二进制格式是在内核中支持的,因为当调用了加载可执行程序的系统调用了,内核必须要知道它加载的可执行文件的格式,以便从中识别信息(例如32位还是64位架构,数据是大端还是小端存储的,符号表放在哪里,程序的入口在哪里)。这个对存储格式的识别过程有点像文件系统,内核必须要清楚的知道不同文件系统的组织格式,才能正确的索引和修改里面的数据。让内核拥有特定格式识别的能力的机制就叫做驱动。同样是网卡发送数据需要不同的驱动,同样是文件系统,读取数据需要不同的驱动,同样是二进制文件,执行代码也需要驱动。windows没有elf驱动,linux内核里也没有exe驱动。由于linux的开源特性,你完全可以写一个内核的exe驱动,让exe程序可以直接在linux中执行。

但是,就这么简单吗?非也。在windows中编译代码使用的基础库也是只能在windows上运行的。而这个基础库规定了进程做系统调用时函数参数该以何种顺序压入堆栈,该如何进行系统调用(linux和windows陷入系统调用的方式不一样)。也就是说如果基础库设计的足够好,能在两个操作系统之间兼容(符号表是一样的),处理不同让基础库去处理也可以。还不能忘记一个程序还会依赖很多动态库,这些动态库也是系统相关的。有的代码甚至会直接绕过基础库操作系统调用。还有进程执行需要加载器,加载器也得能够识别其他平台的格式。这也是wine能够工作的基础。linux下的wine程序就是通过将底层的所有不同做转换,让exe二进制在linux上兼容。所以,可以看出,如果内核的系统调用足够多的与windows一致,再实现一些兼容的基础库,linux也是可以高效的兼容exe程序的。不过目前wine大部分转换在用户空间完成,难免损失效率。就算是在内核态完成,由于不同的逻辑设计,转换的代价也会不小的。

像Linux和windows的这种情况叫做二进制不兼容,也即ABI不同。ABI会规定底层的调用和参数传递,二进制文件布局的具体格式。如果一个内核支持一个ABI,那么无论在什么操作系统,一次编译就可以处处执行了。

2、 elf文件格式

磁盘存储结构一般都要有头部,elf也一样。头部有3部分,elf头部、segment头部和section头部。其中一个二进制文件只有一个elf头部,多个segment头部和多个section头部。一个segment逻辑上包含多个section。

那么segment和section又是什么概念呢?segment常见的有PT_LOAD、PT_DYNAMIC、PT_INTERP、PT_NOTE、PT_PHDR等。我们来思考进程执行的必要条件。

二进制文件在磁盘中的布局并不是内存中的布局,所以需要一个映射和一个实现这个映射的程序,还有linux上的二进制文件一般需要加载共享库(例如libc几乎是必备的),这个工作并不是内核中完成的,因为内核不认识库这种概念,内核看来,所有的程序都是可执行代码段,有的代码段是可以映射和重定位的。执行外部库搜索和加载的程序称为加载器,elf格式的是ld-linux.so,a.out格式是ld.so。而由于加载器可能有多种实现,也可能有多个版本,所以每个二进制文件中都需要指明使用哪个加载器,指明使用哪个加载器的功能就是用segment实现的,这种segment就是PT_INTERP类型。由于golang一般使用静态链接,所以你会发现几乎只有golang的elf格式中是没有PT_INTERP类型的segment的。

一个程序一般会有.dynamic段,而这个段就是放在类型为PT_DYNAMIC的segment中的。为什么要单独一个呢?因为这个段包括这个segment也都是用来服务于动态加载的。我们使用ldd命令可以读取到一个二进制依赖的库,这个依赖关系就是写在这里的。也就是说这个地方记录了当前elf执行需要的库的名字。至于到哪里去找这些库,就是ld-linux.so的事情了。

PT_NOTE则是记录程序的一些辅助信息。程序可能会有什么辅助信息呢?比如程序的类型,程序的所有者,程序的描述。这些信息不参与程序的执行,只有描述作用。

PT_LOAD就是真正的程序存储的地方。这个构成了程序的主体。

而section就是segment里面具体组织数据的格式了。每个section都有名字,这个名字是编译器给起的,你也可以自定义名字,都以小数点开头。例如.text .data等。连接器和加载器共同识别一些段,所以可以进行商量好的操作。例如加载器看到.text段就知道是代码段,而这个.text段的创作者则是链接器。

3、 进程加载器

前面说过elf文件的加载器是ld-linux.so,而a.out文件的加载器是ld.so。但是这两个加载使用的配置路径都是一样的:/etc/ld.so.conf文件。这个文件里一般是include ld.so.conf.d目录下的所有文件,所以要想添加一个库路径在目录下建立一个文件最好。因为文件名是对这个库用途的良好说明。添加完了需要运行ldconfig,因为实际的ld-linux.so并不是一个个去搜索路径,那样会极慢。而是从缓存中直接查询。这个缓存文件就是ld.so.cache,这个文件中有每个库的路径,是使用ldconfig程序使用ld.so.conf文件计算出来的。所以每次修改了库配置都需要执行这个命令。

你也可以做个实验,所有linux进程能够有效运行的原因是因为ld-linux.so位于同样的目录/lib/下。如果这个文件被移动或者重命名,几乎所有程序都不能执行(用golang编译的不使用ld-linux.so加载的程序仍可以执行)。此时如果你想恢复执行,你得将ld-linux.so继续拷贝到/lib/目录下,然而你会发现mv命令也无法执行了。但是builtin的cd之类的命令却是可以的。恢复的办法是显示的使用./ld-linux.so mv a b,当然还要加上必要的参数。这里只是要论证一点:所有gcc编译的进程如果要执行,其实本质上是加载器程序先执行,然后由加载器调用实际的进程执行。就好像python程序无法直接执行,但是经过shell的设置后就可以自动找到python程序来执行。

另外,你有可能同一个库有多个版本,这是不冲突的,只要你将路径都加入即可。

 

相关内容