之所以跳过第4章直接开始第7章是因为第4章我之前学过一些, 另一方面C语言的链接, 很多书都闭口不提, 但这一块又确实是目前我理解C语言的一个痛点, 所以准备先看链接...
链接是将各种代码和数据部分收集起来并组合成为单一文件的过程, 然后这个文件可以被加载(或被拷贝)到存储器中执行... 那么我们为什么需要链接呢? 因为我们希望代码的分离编译, 然后通过链接最终形成可执行的程序. 当然, 对于一些小程序而言, 分离编译似乎是多次一举, 但是对于一个较大的程序而言, 如果能够使得程序间的各个模块相对独立, 那么我们修改某个模块的代码时也只需要重新编译该模块, 从而大量地减少了不必要的重复编译, 同时也使得代码模块直接耦合度更低, 更加容易管理...
7.1 编译器驱动程序
首先是两个实例程序的代码 :
接下来这张图展示了这两个实例文件的翻译过程...
我们拆开看的话大致可以分为这么几个步骤 :
1. 源文件预处理, 通过预处理器cpp生成经过预处理的中间文件main.i...
或者是 gcc -E source_file.c -o out_put ...
2. 编译为汇编代码, 通过编译器ccl生成汇编程序main.s
或者是 gcc -S source_file.c ...
3. 生成可重定位目标文件, 通过汇编器as生成main.o
或者是 gcc -c source_file.c ...
4. main.c 和 swap.c 经过以上相同的3步分别生成了main.o 和 swap.o, 此时运行连接器ld, 生成最终的可执行目标文件.
或者是 gcc [source_file_list] ...
7.2 静态链接
类似Unix ld这样的就是静态链接器, 以一组可重定位目标文件和命令行参数作为输入, 然后生成一个完全连接的可以加载和运行的可执行目标文件作为输出.
在我们输入的可重定位目标文件(也就是.o结尾的文件)是由各种不同的section 组成的... 指令在一个section中, 初始化的全局变量在另一个section, 未初始化的变量又在另外一个section...
那么为了构造出最后的可执行文件, 连接器主要是完成两个任务 :
1. 符号解析 : 将每一个符号引用和符号定位联系起来.
2. 重定位 : 编译器和汇编器生成的是从地址零开始的代码和数据节. 而链接器的作用就是把每个符号定义与一个存储器位置联系起来, 然后修改所以对这个符号的引用的位置, 使它们指向这个存储器位置, 从而重定位这些节...
7.3 目标文件
目标文件有三种形式 :
1. 可重定位目标文件 : 包含二进制的代码和数据, 用来与其他可重定位目标文件合并生成可执行目标文件...
2. 可执行目标文件 : 包含二进制代码和数据, 可以直接被拷贝到存储器执行.
3. 共享目标文件 : 一种特殊的可重定位目标文件, 可以在加载或者运行时被动态地加载到存储器并链接. (后面就会知道, 这其实就是所谓的动态库文件)
各个系统之间目标文件的格式并不相同, Unix早起使用的是COFF(Common Object File Format), 而Windows NT使用的就是COFF的一个变种, 叫做PE(Portable Executable)... 而现代Unix系统例如Linux使用的是ELF(Executable and Linkable Format)格式, 我们主要讨论这里ELF...
7.4 可重定位目标文件(ELF文件详解)
这一块感觉书上讲得比较突兀, 我上网查询了一些资料之后准备各处搜刮过来的一些资料来对书上的内容做补充. 当然首先我们得明确一下ELF文件的结构 :
目标文件形式并不是单一, 正如所说的, 有三种形式. 所以你会发现图中左右两边对目标文件结构的解析是不同的, 可重定位目标文件需要链接器做进一步处理,所以一定有Section Header Table;可执行目标文件需要加载运行,所以一定有Program Header Table;而共享目标文件(共享库)既要加载运行,又要在加载时做动态链接,所以既有Section Header Table又有Program Header Table。
左边是从链接器的视角来看ELF文件,开头的ELF Header描述了体系结构和操作系统等基本信息,并指出Section Header Table和Program Header Table在文件中的什么位置,Program Header Table在链接过程中用不到,所以是可有可无的,Section Header Table中保存了所有Section的描述信息,通过Section Header Table可以找到每个Section在文件中的位置。右边是从加载器的视角来看ELF文件,开头是ELF Header,Program Header Table中保存了所有Segment的描述信息,Section Header Table在加载过程中用不到,所以是可有可无的。从上图可以看出,一个Segment由一个或多个Section组成,这些Section加载到内存时具有相同的访问权限。有些Section只对链接器有意义,在运行时用不到,也不需要加载到内存,那么就不属于任何Segment。注意Section Header Table和Program Header Table并不是一定要位于文件的开头和结尾,其位置由ELF Header指出,上图这么画只是为了清晰。
接着我们一块一块来分析, 先来看ELF头的结构 :
190 #define EI_NIDENT 16 191 192 typedef struct elf32_hdr{ 193 unsigned char e_ident[EI_NIDENT]; 194 Elf32_Half e_type; /* file type */ 195 Elf32_Half e_machine; /* architecture */ 196 Elf32_Word e_version; 197 Elf32_Addr e_entry; /* entry point */ 198 Elf32_Off e_phoff; /* PH table offset */ 199 Elf32_Off e_shoff; /* SH table offset */ 200 Elf32_Word e_flags; 201 Elf32_Half e_ehsize; /* ELF header size in bytes */ 202 Elf32_Half e_phentsize; /* PH size */ 203 Elf32_Half e_phnum; /* PH number */ 204 Elf32_Half e_shentsize; /* SH size */ 205 Elf32_Half e_shnum; /* SH number */ 206 Elf32_Half e_shstrndx; /* SH name string table index */ 207 } Elf32_Ehdr;
上图中没有定义的数据类型在这里进行了相关说明 :
总的来说, ELF头描述了整个文件的组织. 而且根据上面的结构图我们可以算出ELF头总共是52个字节(从下图中 size of this header 也可以找到), ELF头前16个字节(就是你之前看到的那个数组)描述了生成该文件的系统的字的大小和字节顺序, 之后还有很多信息可以看下面的图. 因为ELF是以二进制的形式存在的, 强行阅读也不是不可以(其实我之前一直准备自己对着二进制文件分析的, 无奈找不到资料, 也不知道里面那些数字到底什么意思), 但是我们可以使用readelf来解析, 看起来舒服一点, 得到的结果如下图 :
1 $ readelf -h main.o 2 ELF Header: 3 Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 4 Class: ELF32 5 Data: 2's complement, little endian 6 Version: 1 (current) 7 OS/ABI: UNIX - System V 8 ABI Version: 0 9 Type: REL (Relocatable file) 10 Machine: Intel 80386 11 Version: 0x1 12 Entry point address: 0x0 13 Start of program headers: 0 (bytes into file) 14 Start of section headers: 200 (bytes into file) 15 Flags: 0x0 16 Size of this header: 52 (bytes) 17 Size of program headers: 0 (bytes) 18 Number of program headers: 0 19 Size of section headers: 40 (bytes) 20 Number of section headers: 8 21 Section header string table index: 5
从第14行我们可以知道这个section header table的起始位置是200(十进制), 搭配19和20行有知道这个table中有8个大小为40bytes的seciton header. 另一方面, 从13, 17 和 18行可以知道这个文件是relocatable object file而不是executable object file, 因为这个文件并不存在program header... 另外其他行也能为我们提供一些有意思的信息, 都很简单这里就不提了...
另外我们可以用file指令大致验证一下我们的想法 :
nzhl@ubuntu:~/Link$ file main.o main.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
可以看到, 这个文件是个32位小端机器上的relocatable object file, 使用的是 Intel 80386构架, 正好和上面的分析相符合...
然后我们接着来看section header table里面的section header ... 下面是一个典型的ELF可重定位目标文件及其各部分的作用的解释(这个是书上的, 虽然我看得云里雾里, 但是想想还是贴过来算了...) :
下面这个是和上面那个ELF头对应的section header table :
1 Section Headers: 2 [Nr] Name Type Addr Off Size ES Flg Lk Inf Al 3 [ 0] NULL 00000000 000000 000000 00 0 0 0 4 [ 1] .text PROGBITS 00000000 000034 00002a 00 AX 0 0 4 5 [ 2] .rel.text REL 00000000 0002b0 000010 08 6 1 4 6 [ 3] .data PROGBITS 00000000 000060 000038 00 WA 0 0 4 7 [ 4] .bss NOBITS 00000000 000098 000000 00 WA 0 0 4 8 [ 5] .shstrtab STRTAB 00000000 000098 000030 00 0 0 1 9 [ 6] .symtab SYMTAB 00000000 000208 000080 10 7 7 4 10 [ 7] .strtab STRTAB 00000000 000288 000028 00 0 0 1 11 Key to Flags: 12 W (write), A (alloc), X (execute), M (merge), S (strings) 13 I (info), L (link order), G (group), x (unknown) 14 O (extra OS processing required) o (OS specific), p (processor specific)
可以发现这个表正好和ELF头对应, 正好是八个section header... 第二行说明了每个header中的每一列的作用 :
Name : 所要描述的section的名称.
Type : 这个我也不懂...
Addr : 可以发现这一列的每一个行都是0, 原因在于Addr
是这些段加载到内存中的地址(我们讲过程序中的地址都是虚拟地址),加载地址要在链接时填写,现在空缺,所以是全0.
Off : 该section在文件中的位置, 注意是在当前我们分析的这个文件中的位置, 就是通过这个位置来找到对应的section.
Size : 该section的大小.
后面几项暂时用不到就省略了...(其实是没有查到到底是干嘛用的)
有了上面这些信息, 就可以开始探究一下那些段到底存的都是什么鬼...(主要是CSAPP中上面那些解释很抽象啊, 根本看不懂...) 于是我拿书中最早给出的main.c的代码做了个实验 :
void swap(); int buf[2] = {1, 2}; int main(){ swap(); return 0; }
目前能够分析出来的有以下几项 :
.data :
从0x58开始, 可以很清楚的看到是1, 2...(小端32位机器, 每个int都占用4位), 算过来大小正好是0x8.
所以这里是已经初始化的全局C变量是可以理解的...
这里还需要提一下.bss段 : C语言的全局变量如果在代码中没有初始化,就会在程序加载时用0初始化。这种数据属于.bss
段,在加载时它和.data
段一样都是可读可写的数据,但是在ELF文件中.data
段需要占用一部分空间保存初始值,而.bss
段则不需要。也就是说,.bss
段在文件中只占一个Section Header而没有对应的Section,程序加载时.bss
段占多大内存空间在Section Header中描述。然后我们再看main.o中的section header :
从0x58 + 8 == 0x60开始, 但是大小为0.
.shstrtab :
所以这一块保存的其实是section header string table 也就是各个section的名字...
.strtab :
这一块是 string table, 其实就是程序中用到的符号的名字, 当然局部变量是默认不存在的(在编译的过程中局部变量已经在栈上处理掉了)...
.symtab这一块可以直接用readelf看来(因为实在看不懂二进制里面写的是什么...) :
所以说.symtab是符号表, 其中Value列是每个符号所代表的地址, 在可重定位目标文件中, 符号地址都是相对于该符号的Section的相对地址, Size是该符号所代表的地址所占的大小. Bind知名该符号是LOCAL还是GLOBAL的, 只有在汇编程序中使用了.globl指示过才会变成GLOBAL, 比如buf就是GLOBAL的... Vis不清楚是什么, Ndx是每个符号所在的Section的编号, Section的标号在Section Header Table可以找到(就在上面)... 现在我们可以来看一下buf, buf是data段的符号, 由于它位于data段的开头, 所以value等于0...
.text :
左边是机器指令的字节,右边是反汇编结果。显然,所有的符号都被替换成地址了,比如call 12,注意没有加$
的数表示内存地址,而不表示立即数。这条指令后面的<main+0x12>
并不是指令的一部分,而是反汇编器从.symtab
和.strtab
中查到的符号名称,写在后面是为了有更好的可读性。目前所有指令中用到的符号地址都是相对地址,下一步链接器要修改这些指令,把其中的地址都改成加载时的内存地址,这些指令才能正确执行。
然后尝试着将这两个文件链接成为可执行目标文件看看发生了什么变化 :
nzhl@ubuntu:~/Link$ ld main.o swap.o -m elf_i386 -o main
nzhl@ubuntu:~/Link$ readelf -a main
首先是ELF头 :
首先是文件类型, type从REL变成了EXEC, 然后是程序起始位置从0变成了0x8048094, 最后是之前没有的Program Header变成了3个, section Header 从12个变成了9个...
然后再来看Section Header段 :
之前Addr都是0, 要在连接时填写, 这里已经填写完成了... rel.text就是用在链接过程的, 在这里没有, 所以被删除了, 至于.note.GNU-stack 和 .rel.eh_frame也被删除, 我也知道为什么, 说实话这两个Section在我看的资料里面都不存在的, 所以我其实不知道这两个Section是在干嘛...
另外新增加了一个Program Header :
从图中可以清晰的看到各个Segment与Section Header的映射关系...
另外这里的Offset指的是在当前文件中这个Segment的地址, VirtAddr
列指出第一个Segment加载到虚拟地址0x08048000(注意在x86平台上后面的PhysAddr
列是没有意义的,并不代表实际的物理地址, 为什么无效我也不清楚, 照抄的资料), FileSiz值得是这个段在文件中的大小, 而后面指的是实际加载到内存中的大小, 这里可以看到对于第二个segment, 在文件中只有0xc而之后变成了0x10, 我个人认为应该是为了对齐... Flg描述的是几个Segment的读写执行等权限, 最后的Align是说x86平台的内存页面大小, 这里是4k...
.text
段和前面的ELF Header、Program Header Table一起组成一个Segment(这个segment从0开始到0x154, 然后搭配前面看很容易分析出来),.data
段组成另一个Segment(总长度是0x10)。
这里用一张图来描述在该文件中与加载到内存中的关系(图中是资料中用到的例子, 所以属于和上面我自己尝试的例子有出入) :
这个可执行文件很小,总共也不超过一页大小,但是两个Segment必须加载到内存中两个不同的页面,因为MMU的权限保护机制是以页为单位的,一个页面只能设置一种权限。此外还规定每个Segment在文件页面内偏移多少加载到内存页面仍然要偏移多少,比如第二个Segment在文件中的偏移是0xa0,在内存页面0x08049000中的偏移仍然是0xa0,所以从0x080490a0开始,这样规定是为了简化链接器和加载器的实现。从上图也可以看出.text
段的加载地址应该是0x08048074
,_start
符号位于.text
段的开头,所以_start
符号的地址也是0x08048074,从符号表中可以验证这一点。
最后看下.text段 :
可以看到所有的相对地址都被换成了绝对地址, 另外对比一下调用swap的这行 :
现在可以理解书中所说的关于.rel.text的作用了, 因为在main.c中并不存在swap函数的定义, 所以swap函数属于外部函数, 所以被标记在.rel.text中, 在进行链接过程的时候, 链接器根据.rel.text来修改这个值, 另外这里的offset就是这个.text段中要改动的位置, 我们可以查到在main.o中.text段的位置是0x34, 然后这里偏移0x12, 然后我们可以到main.o文件中0x45的位置看一下 :
可以看到正好是0xfffffffc8e(小端)...
然后是main中的.text的位置是0x94, 编译可以根据call的那行指令与main的差值求得, 也就是80480a5-8048094 = 0x11, 然后大概去0xa5的位置去找 :
可以看到.text段这里被改成了e80e000000, 正好就是.text的指令...
这里我们还可以了解另外一点, 就是反汇编生成的汇编代码中右边的指令call 80480b8, 其实和main无关, 这只是为了让反汇编生成的代码可读性更好而加上的...