前言

接触《程序员的自我修养》也算是一种机缘巧合。最初是因为折腾Tesseract文本识别,不满足于命令行调用,想要从动态链接库入手使用Tesseract的特性;然后照着Tesseract的wiki和网上的教程开始折腾动态链接库和Python C接口,跌跌撞撞总算是成功了,但总感觉一路上都是被人牵着鼻子走,自己实际不懂得背后的原理。

后来有了些空闲时间,决定还是得了解一些“底层”的知识,一来破除知其然不知其所以然的痛苦,二来帮助自己写出真正高质量的代码,所谓知彼知己,百战百胜。而系统地讲解编译、链接和装载这方面的书就非《程序员的自我修养》莫属了。

学到的几点知识

总的来说《程序员的自我修养》算是给我打开了一扇门,学到了太多“意想不到”的知识,这里摘出其中几点。

可执行文件是段结构的

惭愧地说,在读《程序员的自我修养》之前,可执行文件对我来说一直是迷一样的存在,我所接触到的最深的也就是汇编了,但一直将汇编理解为高级程序语言的最下级,也就没有再继续深究。

不论是ELF还是PE,都是基于段结构的。编译好的代码保存在哪,数据保存在哪等都是由段来控制的。段结构也有严格的规定,算是机器语言之上的第一层数据结构了。

静态链接的主要工作是段的合并和重定位

以前一直认为链接器干的工作复杂且难以理解,深入进去看链接器的工作过程才发现也不过如此。静态链接相当于就是几个目标文件的合并,首先就是把这多个目标文件的相应段合并起来,再修正变量和函数的地址,最后加入运行时代码,静态链接的主要工作就算做完了。

分页和段结构是紧密相连的

现代操作系统都是以页为单位来管理内存空间的,而内存的页和可执行文件的段也是紧密相连的。比如代码段为只读,就是依托于页结构,由操作系统控制页为只读。分页不仅仅是为了节约内存,也是程序装载的要求。

虚拟内存和物理内存的关系

以前学计算机组成原理和操作系统一直不太能理解虚拟内存存在的必要性。现在才弄清楚原来物理内存和绝大多数程序要都没有关系,物理内存是由CPU管理的,算是由CPU提供的抽象,我们每天打交道获得的内存地址都是虚拟内存地址。

虚拟内存的好处不言而喻,最主要的就是模拟出了一个进程独占全部内存空间的环境,使得可执行文件可以“标准化”地运行,比如可执行文件一定在以统一的确定地址开头的一片内存区域。而不必关心具体在哪片物理内存上。即使是连续的虚拟内存地址,也不知道在物理内存上被分成几块。

可执行文件的装载并不是说全部装载到了内存

大多数人都认为打开一个程序后,这个程序的所有内容都加载到了内存中。但实际上很有可能打开一个程序后,这个程序的大部分内容都还是存在于磁盘中。

因为可执行文件的文件头就包含了如段的地址、大小等信息,将其加载到虚拟内存空间也只是“虚拟内存地址”和“可执行文件段地址”之间的映射关系,再来“虚拟内存地址”和页之间也是一种映射关系而已;这些映射关系保存在进程中,CPU执行需要哪些指令或数据只是通过这些映射关系最后确定到可执行文件中,完全没有全部加载到内存的必要。甚至,程序的执行就是缺页错误“驱动”的。

栈区就是程序“生命”的体现

创建变量、函数调用绝大多数都是在和栈打交道,栈记录了局部变量,栈的消长记录了函数的调用和返回,程序的运行就是通过栈来反映的。当一个函数调用了另一个函数,无非就是将当前寄存器的值全部压入栈,参数和返回地址压入栈,再更新PC寄存器的值就在调用另一个函数了;调用完了一个一个出栈也就恢复了之前的环境。

此堆非彼堆

说个笑话,也大概就一年前才知道原来进程里的“堆”和数据结构里的“堆”是两个完全不相关的概念。进程里的堆可以看作是一片空白的内存了,有什么想临时使用的变量或临时保存的数据都可以往堆里放,用的时候申请一片内存,不用了就扔掉,很简单的概念。

动态链接不仅仅是推迟链接的时间

理解动态链接还要从需求谈起,一部分原因就是静态链接浪费空间和更新困难。动态链接虽然主要工作还是链接,但关键点却不止于此,比如动态链接库的依赖,符号的管理,加载时的内存分配等等,也不是三言两语能说完的。

地址无关代码理论和实现

PIC无疑时动态链接的一大特性,从汇编的角度才能理解到PIC遇到的困难和目前采用的解决方法的优缺点。为提升动态链接效率而提出了GOT、PLT等结构,与系统妥协而采用的trick。同样PIC也不是三言两语就能讲透彻的。

DLL非地址无关的正当性

只懂得PIC的在听说Windows的DLL不是地址无关的,都对微软产生一些蔑视:PIC这么好的东西怎么能不用?站在Windows的角度去看PIC才能看得出来非地址无关也不是一无是处的,甚至在某些环境下非地址无关比PIC性能还要好,微软以空间换时间的做法也不是没有道理的。

环境变量以进程为单位是继承的

环境变量以前对我来说也不清晰,看起来简单,但真的从原理上讲又讲不出来。现在才弄明白环境变量是由操作系统支持的,其实也只是一个稍微特殊点的普通变量。

环境变量存在于每个进程中,并且进程可以修改环境变量;新创建的子进程总是默认继承父进程的环境变量,这就是环境变量的工作原理。弄懂了原理,才能够看透环境变量。

可执行文件不是一开始就执行main()函数

或者说看得到的代码实际是需要运行时环境支持的。可执行文件最开始执行,调用的是运行时的代码,做一些初始化的步骤,然后main()函数才像一个普通函数那样被调用。或者说main()函数并没有什么特别的,最多就是名字特别而已。

和本书相关的文章

读《程序员的自我修养》时也让我想到了一些问题,趁着网上很少有相关的文章,也就把我的一些拙见写出来:

后记

《程序员的自我修养》给了我很多帮助,但我觉得唯一美中不足的是没有介绍x64的相关知识,不过也应该理解此书写作时还是x86的天下。但如今Linux几乎全都是x64了,由此引入的一些新的特性也使得书上内容有些脱节,希望作者能够出第二版以x64为基础作介绍吧。