这是一本关于程序编译、链接、装载和库的书,涉及操作系统和编译原理。
花了一天的时间草草的翻了一遍,没怎么深入思考,稍微增加了一点见识吧。不过进入到动态链接库之后的章节,就越翻越快了,进入脑子里的东西越来越少。这本书比较好的地方在于可以将以前学习的很多知识点串起来,从一个程序的编译到链接,再到程序的装载运行。
《Linkers and Loaders》
《Intel 64 and IA-32 Architectures Software Developers Manual》,这个手册挺牛逼的,记得以前写操作系统的时候,需要查阅相关的内容。主要是一些处理器相关的内容。
没有什么问题不能通过增加一层抽象解决
上面是运行时,下面是硬件。中间是 kernel,通过 “中断机制” 进行调用。
为什么需要链接?因为编译文件的时候,存在一些无法确定的符号,需要将多个文件凑到一起,才可以找到这些符号,确定下来。比如一个 extern 变量,比如 include 其他头文件,那么在当前源文件可以使用里面的变量和函数名,但是却无法找到那个符号的具体地址。
链接过程:地址和空间分配,符号解析,重定位。
一个 hello world 程序,很重要的是链接运行时(runtime)文件。这样才可以运行。程序的启动过程其实还是要看这个 “运行时(runtime)”
一个目标文件分成几个 section 节,有时候也叫 segment 段。最重要的就是代码段和数据段,代码段里面可能存在一些符号还没有链接。此时需要有一个段叫做 .rel.text 来记录还没有链接的符号。.data 段和 .bss 段存放数据,.bss 存放未初始化的、初始化为 0 的全局变量和静态变量,还有 .rodata 只读数据段,存放字符串常量等。数据段和代码段分开,可以从性能角度考虑原因,可以充分利用局部性原理。为了调试,需要使用某些段保存调试信息,比如对应的代码行号。
file:查看文件信息
objdump:查看二进制
nm:打印符号表
readelf:读取 elf
c++filt: 解析符号
ar:压缩程序,压缩多个 .o 为一个 .a
符号修饰可以用于区分不同语言的目标文件,区分重载的函数。比如 c 语言的目标文件中的变量函数以一个下滑线开头,Fortran 语言的目标文件以下滑线开头和结尾。再比如 C++ 中重载的函数,可以按照一定规则将签名变成一个字符串以进行区分。
使用 c++filt 来将修饰过的符号变成签名。使用 extern "C" 导出 C 命名规则的修饰。
这样编译出来的两个目标文件应该怎样链接呢?
多个目标文件,每个目标文件都是分段的,为了生成一个可执行文件,需要将这些段进行合并,比如将所有的目标文件代码段合到一起。
第一步,空间和地址分配。链接之后的目标文件代码段会指向一个虚拟地址 VMA(Virtual Memory Address),这个 VMA 指向虚拟空间地址。空间的分配,“空间” 二字有两个含义,一个是输出的目标文件中的空间,占存储大小的;一个是虚拟地址的空间,占运行时内存大小。地址应该怎么确定?确定谁的地址?“符号” 的地址。空间分配完成之后,每个段的地址确定了之后,那么每个段上面的符号,比如函数符号,我们就可以通过段的偏移量加上符号在段上的偏移量来算出这个符号的偏移量。从而确定符号的地址。这里应该需要一个全局的符号表,方便后面查找一个符号对应的地址。
第二步,符号解析与重定位。对象是谁?是 .text 上的指令。因为这些指令在编译的时候,无法确却的知道操作数的地址,所以可以设置一个假的地址,然后把这个信息存放到 .rel.text 符号表上面。后面可以通过 .rel.text 来对这些符号进行重定位。
装载是什么?最简单的装载就是将整个文件放到一块内存,然后就可以执行那里的代码了。
装载实际上是由操作系统来完成的。
现代操作系统中,装载通过页映射来完成。分为三个步骤:进程的建立,虚拟地址空间的创建,其实就是建数据结构;读取可执行文件头,建立虚拟地址空间和文件的映射;将 CPU 指令寄存器设置到入口。之后开始执行,会发生页错误,然后利用中断机制将执行交给操作系统,操作系统帮你加载对应的页之后,返回到当前进程。
以 Linux 为例子:
运行时链接成一个完整的程序。
希望共享指令可以在装载的时候不会因为装载地址的改变而改变,这样可以做到多个进程共享动态链接库。为此需要引入 PIC,地址无关代码。那么地址可以分为四种情况进行讨论:代码或数据,模块内部或外部。
延迟绑定用于减少启动时候链接的开销,只有用到了才绑定。当执行 bar@plt 这个函数时,第一次 jmp 是跳转到下一条指令 push,执行一个子程序来解析 bar 符号;之后的跳转都是直接跳到对应的地址。在 PLT 子函数中,会将 GOT 对应的地址 push 进去,所以后面执行都是直接跳转。
SO-NAME:库命名规则,用来记录共享库的依赖关系,每个共享库都有一个对应的 SO-NAME。比如有 liba.so.2.6.2 和 liba.so.2.7.1 两个版本,那么我们可以将其命名为 liba.so.2,在使用这两个动态链接库的时候,我们可以直接使用 liba.so.2,从而避免依赖一个具体版本的库,可以使用某个大版本且 ABI 兼容的库。
基本版本的机制:编译的时候,可以用 ld --version-script
来设置版本。它也可以用来隐藏符号。对 SO-NAME 机制的补充。
一个进程的内存布局。
涉及的系统调用:brk,mmap。
brk:设置数据段的结束地址。
mmap:向操作系统申请一段虚拟地址空间。
malloc:初始的时候,先申请一块大内存。当用户申请小于 128k 内存,按照堆分配算法为它分配一块空间;申请大于 128k 内存,使用 mmap 申请一块空间。堆分配算法,就是空闲块管理算法,使用空闲链表或者位图的方式,对象池(按照固定的大小分配内存)。
终于到了运行库了。一个 hello world 程序的真正入口,并不是 main,而是 “运行时” runtime 的入口。