除了《CSAPP》的第七章有讲述相关内容外,这本书差不多是这个领域的独苗了。读这本书的动机是之前买了树莓派并尝试在上面进行裸机编程,进行到链接一个环节时发现了懵懂之处。

一个程序,从source到binary要经过compiler、assembler、linker三个环节,而最终能被运行,还要求loader。

我在倒腾树莓派的时候,compiler使用rustc,assembler使用gnu-as,linker使用gnu-ld,而loader则按照c-abi制作。对于前两者,Rust和arm/aarch64 asm都有学过,而学习linker script的时候发现了理论缺失,故向此书求索,作此读书笔记以帮自己加强对书本内容的记忆与理解。

第一章,链接和加载

地址绑定的历史

早年的打孔机年代,程序员使用速查表手动编写裸机指令,得到一个程序,如果需要多个程序组合运行,则将多个打孔纸卡拼装起来,添加上偏移地址。如果某个程序有变化(长了,短了,或者内容变了),则程序员需要手动重新拼装和编址。拼装,又称组装,亦即assembly,即是汇编语言assembly language一词的来源。这是早期的人肉链接,此时还没区分连接器和加载器。

子程序和库的概念比汇编器更早诞生,领导ENIAC的约翰莫克里曾写过一个程序,用于从磁带上装载程序以及其子程序,此时就需要将子程序从磁带上的地址重定向到其装载的地址,这是两个最基本的连接器函数,重定向库查找,在汇编器之前就诞生了。重定向加载器使得程序员可以假设自己的程序是从0号地址启动的,并且将真实地址绑定延迟到了子程序链接到主程序的时候。

随着操作系统的诞生,重定向加载器就从链接器中分离出来,也成了必需品。在os诞生前,每个程序都拥有全部内存,而os诞生后,程序要和os以及其他程序共享内存,这就意味着程序的真实地址直到被载入前,都无法得知。这样程序的地址绑定时间被延迟到了装载的时候。至此,连接器和装载器分开,链接器做了部分地址绑定,在程序内部分配相对地址装载器做了最终重定向工作,以分配真实地址

随着系统变得复杂,连接器的工作也变得更多:复杂的名称管理,地址绑定。Fortran要链接器完成子程序、通用块、共享数据区的空间分配和地址绑定,这样链接器就开始处理对象代码库的管理,包含多种语言编译而成的库,编译器要处理已编译代码的库调用以支持IO处理和其他high level操作(譬如libc的版本兼容)。

后来程序渐渐变得比内存还大,于是链接器就引入了overlay/遮罩技术,亦即将程序分段按需求换入主存。overlay技术在大型机上应用始于硬盘诞生的1960年,停于虚拟内存诞生的1970s中叶,又在1980年开始的微机诞生年代重新活跃,最终在1990s年代因为虚拟内存引入到微机而式微,现在依然活跃在内存有限的嵌入式环境以及其他需要程序员/编译器精确控制内存使用以提升性能的领域。

由于硬件重定向(汇编的特殊寻址指令)和虚拟内存的诞生,linker和loader的复杂度得到下降,因为每个程序又能得到“所有”内存地址空间了。不过有了硬件重定向的计算机,也不只是运行一个程序,有时候还会同一个程序多副本运行。多副本场景下,程序的部分内容一样,其他部分是副本实例特有。如果共有部分可以被分离出来,那么计算机对于这部分就能只使用一个实例,节省大量的存储空间。编译器和汇编器就被改造成了将对象编译成多个区段section的模式,有只读区段和可写取数据区段;然后链接器将他们链接起来,从而所有的代码在一个区段,数据在一个区段;区段的地址绑定发生在编译期。

除了相同程序的不同副本间代码区可以单独拿出来,在同一台计算机上跑的不同程序,也有相同的区段,譬如C系都有的fopen和prinft,譬如基于Windows、X Window、macOS运行的GUI程序,都共用相同的GUI库,于是很多系统就提供了shared libraries/共享库,所有程序共同同一个副本实例,这也节省了很多存储空间和提升了运行效率。

对于静态链接库,其构建的时候地址就绑定好了,链接器会在程序链接时寻找这些地址。然而静态链接库还是不太方便灵活,因为一旦库有所变更,所有程序都可能需要重新链接,而且创建静态链接库也十分枯燥。于是系统就添加了动态链接库,其中库的区段和符号的地址只有在调用程序运行时才会绑定,有时候甚至会延迟到库第一次被调用时才绑定。这为扩展程序的函数提供了一个有力又高效的方法,特别时ms的win更是广泛使用运行时加载动态链接库的方式来构建和扩展程序。

2018年11月9日

Linking vs Loading

待续