emm……或许光用文字的话,各位会看得越来越无聊?
那么,看这边(扫描设备过于潦草警告):
这是计算姬小娜,顾名思义,计算机做的脑子,女孩子的身体,看着似乎更加有亲和力一点,但实际上,当她开口说话的时候你就会明白……她为什么叫小娜了。
emm……挺膈应人的。
所以,为了理解小娜是怎么想的,咱们可能得费点劲。
众所周知,计算机系统是我们与机器交互的一个重要链接点。试着理解计算机系统,了解与小娜沟通的过程,或许对我们理解计算机系统有所帮助呢?
你的程序在电脑眼里长啥样
我们看看这段代码:print("hello world")
虽然我们习惯从高级语言的视角,用一些成形的单词和指令执行它,但计算机一开始习惯拆开来看它们每一个字母的ascii码,或者说,把它们拆成好几个唯一的二进制编码。毕竟,就像我们熟悉英文一样,小娜熟悉的是机器语言:
hello world = 01101000 01100001 01101100 01101100 01101111 01110111 01101111 01110010 01101100 01100100
先让电脑读懂程序——编译系统
但是我们可不是40k的机油佬,并不能那么轻松地读懂这些仅由01字串组成的东西,更别提表达出来了。所以,对于咱写出来的代码,要让她试着读懂并运行,往往需要一个“翻译官”。
对于和操作系统关系密切的C语言来说,这个“翻译”的过程更有点像……“我翻译我自己”,即,用C语言编写的编译器编译自己。整个程序从我们能看懂的东西,变成小娜能看懂的东西,大概要经历四个阶段,这四个阶段都可以通过编译器的不同指令整出来,具体可以参考原书第七章——
预处理阶段
我们一般会在程序开头用诸如#include<stdio.h>
的语句调用一些头文件,以方便库函数的调用。预处理器(cpp)则会在预处理的时候将它们和你写出的程序本尊一起,排好序,塞进另一个以.i为拓展名的C程序中。
编译阶段
一个编译器会将.i文件“翻译”成由汇编语言组成的.s文件,对于小娜和她的同类而言,汇编语言的普适性比起高级语言高得多,因为经常有数个高级语言的编译器的目标语言都是同一个汇编语言。
但是,汇编语言只是机器语言“翻译”成,或者说,以某种固定方式定义、组织我们普通人类还能勉强认读的样子,想让小娜读懂,还得再花些功夫。
汇编阶段
这个阶段会将我们之前得到的.s文件,通过汇编器(as)打包成一个包含代码和数据的.o文件,一个“可重定位目标程序(一说文件,可能是翻译问题)”,本身是程序的机器语言表示,在文件头的部分会标注“REL”。但是,编译出来的除主函数之外的函数都被识别为了”NOTYPE”,即未知类型,通过文本查看器打开的该文件也大概率是一团乱码。也就是说,到这个阶段,也只是做出了翻译的半成品,小娜实际上还是看不懂。
每一个可重定位文件一般都包含以下三个部分:
1、Elf header:Elf文件的信息,通常的文件都包含一个描述文件大致信息的文件头,而在Linux系统中,C程序的编译结果基本为Elf文件。
对Elf文件头而言比较关键的几个部分包括魔数(Elf Magic,确认文件类型,校验该程序是否可执行)、文件类型(32位还是64位,可重定位/可执行还是共享等,相关数据存放于不同的位置)、文件头长度(可以用以确认Section的起始位置)等。
2、Section:分节容纳已经编译好的机器代码(.text),动、静态变量的值(.data),未初始化或初始化0的各类变量(.bss,Better Save Space),只读数据(.rodata)和其它各类信息。
单独说一下符号表
比较关键的是“符号表”:.symtab。存储着若干符号,对应着源程序的文件名(类型显示为FILE),其中的函数名称,被源程序引用的函数名称(类型显示为NOTYPE,因为定义并不在源程序中),初始化和未初始化的变量(类型显示为OBJECT,全局变量未初始化部分显示为COM(common)对象,与bss区别在于仅包含未初始化全局变量)。书中的描述更偏向于将它们分成全局(谁都可以用)和局部(只能自己用)两部分,而全局变量针对模块的方面可以分成“自己引用别人的”和“别人引用自己的”两部分。
ps:其实符号的局部与否可以依靠static属性存在与否来判断
至于模块是啥玩意……其实实质上也是个程序,但是,一个程序可以由多个源文件编译而来,单个模块实质上也是这些源文件中的一个,但是,作为程序的模块,它并不是一个独立的文件,而是在其中发挥作用的不可或缺的组分(除非你用不上它)。
3、Section header table:描述Section信息的表,表项数和表项大小在Elf header中体现,可以借此计算出表单大小和整个文件的大小。
链接阶段
这个阶段下,连接器(ld)将.o文件转化为无后缀的“可执行目标文件”,也将程序中存在于C标准库中的函数一并合并到主程序。之所以叫“链接”,是因为程序所包含的函数在编译过程中一般都需要依靠上一阶段产生的符号表,将原程序中引用到的一切信息与符号表中的符号关联起来。
符号解析
当然,我们直接将可重定位目标文件进行链接是不现实的。因为模块与模块,即构成程序的文件之间可能存在着变量名、关键字的冲突,以及引用的变量或函数未经定义之类的问题。这时候,我们要先进行“符号解析”。
变量部分
对于局部变量,由于命名的限制条件比较苛刻(满足唯一性),所以这种关联完全可以达成简单的一一对应。
但是全局变量就有点可怕了。前面我们提到了“模块”的概念,但是,如果你在编写多个模块的时候,习惯性地在不同文件使用了数个相同名称的变量,那么,此时压力给到小娜——
小娜麻了。在她的思维里,每个变量具有唯一性。而面对这么多同名同姓的兄贵,她脑子里的编译程序要么会报错,要么就以某种方式选出一个作为标准定义,而把剩下的同名变量全扬了,好让自己的脑子不像刚才那样冒烟。
这时候,为了确保程序的正确运行,不同的链接器会做出不同的措施。以Linux系统为例,编译时,编译器向汇编器输出的所有全局变量对应的符号有强有弱,函数和被初始化者为强,未初始化者则为弱。
而链接处理时,强符号被选择的优先级高于弱符号,同时存在多个强符号是不允许的,但弱符号在链接时便可任选其一。比方说,我们的程序不可能有多个main函数,但可以有多个被重复调用的循环变量i。
静态库部分
所有的系统都有生成静态库的能力,静态库即将相关的目标模块打包成的单独文件。这些被调用模块的库不需要被调用,只会在链接的时候,让自己被目标程序用到的部分被复制到程序中。说白了,就像一个工具箱,哪里要用啥,就从里头取——除了里头的工具数量是无限的,其它没啥差别。但是,古早的编译会将整个工具箱塞到程序里,故让程序变得臃肿。静态库技术则是另起一个工具箱,容纳这些取出的工具,再打包到程序中。
当输入链接指令时,C编译器会顺序读取命令,判断输入文件是目标文件还是静态库文件,然后分置到如下三个集合中:
集合E:存放可重定位目标文件(.o),最后,这个集合中的文件会被合并成可执行文件。
集合U:存放引用了但尚未被定义的符号,当连接器扫描到静态库时,则会查找库中的模块是否有这些未定义符号,若存在则将对应模块存入集合E中,并删除集合U中被找到的符号,将对应模块的其它已定义符号存入集合D。
集合D:存放输入文件中已经被定义的符号
在整个与单个静态库链接的过程中,链接器会持续维护这三个集合保持其特性,直到集合U和D不再发生变化,这时候,链接器就会把用不上的部分,也就是E中不存在的部分扬了。然后再去读取下一个文件,若是碰上静态库就以此类推。
理论上,一个完整的程序在这个过程后,集合U为空。若是U不空的话……那就得好好考虑是不是自己忘记调用什么库了。
不过,静态库链接的方法也有要注意的点。因为连接器从左到右读指令,所以,要是输入顺序一错,在调用目标文件之前就调用了库,库会因为U里没有东西而不被调用,于是……boom。
重定位
如上进行操作之后,我们便能确保处理出的可执行文件中,每一个调用都和一个既定符号一一对应。我们便可以正式开始链接过程——
重定位条目
汇编器生成目标模块时,并不知道它所引用的外部定义(也就是本体内部不存在)的函数以及全局变量到底在哪,于是,对这些找不到家的孩子,咱就设立一个“重定位条目”,帮助它们在目标文件合成为可执行文件的时候找到自己的归属,即修改这些引用到正确的位置上。
elf文件的重定位条目分为offset(需要被修改的引用的节相对于函数起始位置的偏移)、symbol(被修改引用应该指向的符号)、type(告诉链接器如何修改新的引用,分为相对地址和绝对地址的重定位)和addend(一些类型重要的重定位需要依靠它对被修改引用做偏移调整)四个字段。
代码的重定位条目存放在.rel.text节中,而已初始化数据则放在.rel.data节中。
重定位节和符号定义
将所有文件的同类型节(section)合并为一个新节,使得同一程序中所有函数和变量都有指定的运行地址,同时将不同模块的引用通过重定位条目重新定位至一定的位置。
重定位符号引用
对一个引用而言,其基本从汇编指令call(0xe8)开始,但对于链接前不知道究竟是哪个模块定义了其引用的函数/变量的前提下,重定位条目往往安插于此,而被call的部分则由0填充。
在重定位的过程中,对于函数,首先计算出引用的运行时地址ref_addr = 引用所在函数本身的地址 + offset
,而对于全局变量,ref_addr = 引用所在全局变量本身的地址
;再通过symbol定义其运行时指向的函数地址ref_ptr = symbol指向的函数的地址 - ref_addr + addend
。这样,我们便会得到一个新的call,指向原先的引用指向的目标函数的地址 ref_ptr。
可能这样说难以理解,换个说法,一群工人原本被派来修一条名叫和兴路的笔直道路,但工人们一开始并不知道这条路修到哪里,更别提修多长了,所以只敢在开始修路的地方搭上路标和路障。后来,工程师带着蓝图和地图来了,告诉他们这条路该修到哪,还对着起点和终点比划了一通,丈量出了路的长度。那大家伙自然是开开心心地拿着压路机创过去:
所以,我们得到了已重定位的.text节和.data节,在加载器加载的时候,这些内容会被直接复制到内存,并直接执行。
当然,引用本身分为相对引用(目标为相对于某一地址的偏移,书中介绍了PC相对引用)和绝对引用(目标为在整个程序中的偏移),函数的引用过程使用相对引用,而绝对引用用于全局变量。
你的电脑能读懂的程序长啥样
终于,经过如上过程,我们得到了一个可执行文件。同可重定位文件结构相仿,这个文件包括了:
1、Elf header:内容与之前相仿,但其中一项记录了程序的入口(所要执行的第一条指令的位置)
2、Segment header table:将连续的文件节映射到运行时的内存段
3、Section:可执行文件的节,其中.init节定义了一个名为”_init”的函数,用于程序的初始化,其它节除了关键的数据节和符号节被重定位完成之外,与原先差别不大,只是因为重定位完成,便不再需要,也不再存在.rel.text节和.rel.data节。
程序运行时,从Elf header到.rodata的代码段,以及.data、.bss数据段在程序运行时会被加载到内存中,其余部分(调试信息等)则不被加载。
4、Section header table:描述Section信息的表。
除此之外,可执行文件的程序头部表(program header table)还描述了可执行文件的连续的片被映射到的连续的内存段,其结构如下:
off:该段代码在文件中的偏移量
vaddr/paddr:该段代码在内存中的起始位置
filesz:代码段在文件中的大小
memsz:代码段在实际运行过程中的大小,会因为加载初值为0的bss段等因素与filesz产生大小的差别
flags:格式为
rwx
,对应该片代码是否具有读/写/执行的权限,若某一位为-
则为否align:对齐要求,应使vaddr mod align = off mod align,限制段的规模,使得程序到内存的传输更高效,属于优化手段
所以,现在程序的样子,小娜终于能看懂啦~
至于小娜是如何让程序在脑子里运转的……前面的区域,下次再来探索吧(笑)
后记
如果你觉得编者所编写的内容对你理解《深入理解计算机系统》没有帮助,那么请对着编者A接Q接E接长达七页的欧拉,编者是练过的,扛得住(
如果确实有那么一点点的帮助,或者觉得小娜可爱,那么请点个赞支持一波(
后记2
这是作者配图的原稿: