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的。这是其中一个segment,事实上,所有让内核加载elf文件时候所提供给内核的信息都是segment的形式存在的,内核只需要使用segment完成从磁盘到内存的映射加载工作。
一个程序一般会有.dynamic段,而这个段就是放在类型为PT_DYNAMIC的segment中的。为什么要单独一个呢?因为这个段包括这个segment也都是用来服务于动态加载的。我们使用ldd命令可以读取到一个二进制依赖的库,这个依赖关系就是写在这里的。也就是说这个地方记录了当前elf执行需要的库的名字。至于到哪里去找这些库,就是ld-linux.so的事情了。我们可以看到通过segment指定的这一个闭环:PT_INTERP指定了加载器,PT_DYNAMIC指定了需要的库,而这些需要的库又是通过加载器去实际的加载的。
PT_NOTE则是记录程序的一些辅助信息。程序可能会有什么辅助信息呢?比如程序的类型,程序的所有者,程序的描述。这些信息不参与程序的执行,只有描述作用。
PT_LOAD就是真正的程序存储的地方。这个构成了程序的主体。
而section就是segment里面具体组织数据的格式了。每个section都有名字,这个名字是编译器给起的,你也可以自定义名字,都以小数点开头。例如.text .data等。连接器和加载器共同识别一些段,所以可以进行商量好的操作。例如加载器看到.text段就知道是代码段,而这个.text段的创作者则是链接器。如下图所示,同一个特elf文件,连接器关心的内容和加载器关心的内容是不一样的。
我们使用readelf -h /usr/ls 命令可以查看到一个典型的头部:
这个头部里的program headers就是segment列表。可以看到elf的头部大小是64字节,所以prgram headers的起始地址是从64字节的文件偏移开始,也就是紧挨着elf的头部,执行的时候只关心prgram headers。头部指明有9个program header,每个program header的大小是56个字节。有29个section,每个section的大小是64字节。但是我们可以发现program headers和section headers中间会有不小的缝隙,这里面的缝隙就是每一个section表的具体数据了,同时也是每一个segment的具体数据。因为segment和section是映射关系,他们共享这一大块数据,但是对于这个数据的认知角度不同。而segment table位于这段数据的前面,section table位于这段数据的后面。
我们继续通过readelf -l /bin/ls命令观察二进制的segment细节。这里现实的都是不带PT前缀的,第一个segment永远是PHDR,因为这个segment是用来说明program headers的位置的,虽然在头部有指定在文件中的偏移,但是并没有指定这个头部放在内存的哪里。所有在segment头部的条目都是既有文件地址又有内存地址的。值得注意的是他还有物理地址,这个地址只在某些机器上有效,大部分的机器都是直接用了virtaddr,并且system v格式的ABI是根本不识别物理地址的。很多人容易看错这个图表,发现怎么一个segment有两行地址,第二行是大小,并不是地址,这在头部是标识,只是不那么明显。GNU_STACK表示的是我们的栈,重要的在他的RW权限,所以我们知道了这个程序的栈是没有可执行权限的。如果你用exestack -s /bin/ls 你就会发现这个segment就有了执行权限变成RWE了。现代的编译器默认都不会给栈以执行权限的,如果发现了有,那可能是有安全问题了。
我们也能在这个命令的下方发现section到segment的映射表。仔细观察segment表会发现有两个连续的LOAD segment,分别是2,3编号,在和section的映射表里观察2,3编码,我们可以发现两者存储的section并不相同。典型的存储数据.data .bss等在03,而存储代码的.text在02。程序在启动的时候内核首先加载LOAD segment的内容到内存,然后用PT_INTERP指定的加载器加载LD_PRELOAD和DYNAMIC segment中指定的库到内存,并且对这些库进行初始化,就是调用库的INIT segment( .init section)中的逻辑。
这些section的具体用途,靠文本来说理解起来会非常费劲,但是如果自己动手写一下链接脚本就比较容易理解。
上图是readelf -S /bin/ls 的部分结果,首先我们看到了一系列的section,我们先不去关心每个section的意义。我们需要知道我们现在观察的是一个可执行文件,但不只是可执行文件具有elf格式,静态库,动态库,甚至编译中间的.o文件,也都是elf格式的。但是例如.o格式的中间编译文件是没有经过链接步骤的,所以他的很多section的address会是0,经过链接之后才会有真实的赋值。并且所拥有的section的种类也一般是有区别的。每个section的offset就表明了他们具体的section数据在文件中的偏移,都是位于segment table和section table之间。
读取每一个section的内容的时候,readelf一般会提供常用的选项,例如 readelf -r /bin/ls 或者 readelf -d /bin/ls 等都可以读取到具体的section内容。这个section table在执行的时候是不会被加载到内存中的,因为加载器和内核都是识别segment table。
3、 链接脚本
4、 进程加载器
前面说过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程序来执行。
另外,你有可能同一个库有多个版本,这是不冲突的,只要你将路径都加入即可。