CSAPP-C7
0. 从代码文件到可执行文件 过程
预处理生成中间文件 .i
编译器生成 ASCII 语言汇编文件 .s
汇编器生成可重定位目标文件 .o
链接器生成可执行文件 a.out 或 .exe
g++ 生成 .o 文件:g++ -c A.cpp
区别: -o 命令用于指定生成文件的名字。
1.静态链接
两个步骤:
- 符号解析 目标文件定义和引用符号,每个符号代表一个函数,全局变量,或静态变量,符号解析负责将符号和对应的定义连接起来。
- 重定位 链接器将不同文件中的同一个符号定义与同一个内存地址联系起来,并将所有引用指向这个内存地址。
2.目标文件
目标文件纯粹是字节块的集合。有些包含程序代码,有的包含程序数据。
目标文件有三种形式:
- 可重定位目标文件:包含程序代码和数据,可与其它可重定位目标文件进行合并,生成一个可执行目标文件
- 可执行目标文件:包含程序代码和数据,可以直接加载到内存中执行。
- 共享目标文件:一种特殊的可重定位目标文件,可以在编译或运行时动态加载进内存并链接
3. 可重定位目标文件
一个典型的 ELF 可重定位目标文件的格式:
.ELF .text .rodata .data .bss .symtab .rel.text .rel.data. debug .line .strtab 节头部表
- ELF header:16 字节的序列,字长,小端/大端,ELF 头大小,目标文件类型,机器类型(如x86-64),节头部表的文件偏移(理解为起始地址)
- 节头部表:存储各个节(加载 .ELF 和头部表之间的都叫节)
- 节(common):.text 程序代码 .rodata 只读数据 .data 初始化全部变量 .bss 未初始化或初始化为 0 的局部变量(可以直接打标记,不用覆写数据),
- .symtab 符号表,引用好的函数和全局变量的符号信息
- .rel.text 一个.text 节中位置的列表,链接器组合时需要修改这个位置。一般而言,调用全局变量或别的文件的函数需要修改这些位置,调用本地函数的指令不需要修改。特别的,可执行文件不需要再链接,所以常常省略这一部分。
- .rel.data 被模块引用或定义的所有全局变量的重定位信息。如果一个全局变量的初始值是一个外部函数指针或全局变量指针,则他需要被修改。
- .debug 调试符号表,需要使用 -g 编译,额外包含局部变量,类型定义和原始 C 文件。
- .line 原始行号与程序行号之间的映射。
- .strtab 一个字符串表。包含 .symtab 和 .debug 中的符号表
4.符号与符号表
链接器三种不同的符号类型:
- 由模块 m 定义并被其它引用的全局符号:指全局变量以及非 static 修饰的函数
- 由其它模块定义,在模块 m 中引用的外部符号:对应其它模块的全局变量和非 static 函数
- 只在 m 中定义和引用的局部符号:对应带 static 的函数和全局变量。
注意,本地链接器符号 不等于 本地程序变量。.symtab 中的符号表不包含对应于本地非静态程序变量(非static类的局部变量)中的任何符号,他们是在栈上类似数组的方式管理的。
本地变量又叫局部变量(存疑)
静态本地程序变量
定义方法:在定义局部变量前加 static
当函数离开时,这个变量会保留其值,下次进入函数时会恢复它的值。
他的本质是一种特殊的全局变量,不存储在程序栈中。
4B. 太长了写个小标题
不同函数内定义的重名的 static 局部变量会被赋予唯一名,扔进 .data 或 .bss 内。
.symtab 节中包含 ELF 符号表,格式见 P469
typedef struct { int name;//String table Offset char type:4//Function or data,4Bit/0.5Byte binding:4;//Local or global 4Bit/0.5Byte char reserved;//Unused short section;//Section header or index long value;//Section offset or absolute address long size;//Object size in bytes } Elf64_Symbol
包含:名字,数据类型,local or global,指向 section 头部的指针,指针,数据大小。
section 字段的含义:
三个特殊的伪节,在节头部表内是没有条目的。ABS 代表不该被重定位的符号,UNDEF 表示未定义的符号(在本模块内引用,本模块没有定义,定义在其他模块),COMMON 表示未分配位置的未初始化的数据目标。
对于 COMMON 符号,value字段给出对齐要求,size给出最小大小。
注意可执行目标文件里是没有这一坨东西的。
4C. 使用 GNU READELF 程序来查看 .o 文件内容
使用 GNU READELF 程序来查看 .o 文件内容
P469,练习 7.1 P470 待补
5. 符号解析
I. 解析符号引用
解析符号引用:把每个引用和它输入的可重定位目标文件的符号表中唯一的一个确定的符号定义关联起来。
引用和定义在相同文件:非常简单。
引用和定义在不同文件:当编译器遇到一个未定义的符号引用时,会假设该符号在其它模块定义,扔出一个链接器符号踢给链接器。
同名不同符号的重载
出现场合:函数名称相同,参数列表不同
重整:重新编码,例如 Foo-Foo3
II. 多重定义的全局符号
强符号和弱符号:
- 强符号:函数和已初始化的变量
- 弱符号:未初始化的全局变量。
使用以下规则处理多重定义的符号名:
- 不允许有多个重名的强符号
- 一个强符号和多个弱符号同名,则选择强符号作为定义,如果多个弱符号重名,则从中任选一个。
//in A.c int x=588; int main(){f();} //in B.c int x; void f(){x=526;}
可以不产生错误地通过编译。但这样会产生一些麻烦:A.cpp 中的 x 值会被修改。
更危险的例子
变量 x 作为 4Byte 类型的强符号在 A.cpp 中定义,作为 8Byte 类型的弱符号在 B 中声明。修改 B 中的 x 时会访问到不该访问的地址空间。
避免方法: GCC-fno-common 告诉连接器在遇到多重定义的全局变量时报错。
P474 习题:main是否可以做全局变量?在链接前可以!
6. 静态库
怎么处理大量的标准 C 函数?
- 方法1:编译器识别所有的标准函数
缺点:标准函数太多就 r 了 - 方法2:编译器提供一个 .o 文件,要求编译时将程序与 .o 文件链接。
缺点:如果把全部函数封装在 .o 里边,编译出来的每个程序会占用大量的空间。如果单独指定需要链接的函数,则非常麻烦容易出错。
有没有聪明点的办法?
静态库。相关函数被编译为独立的目标模块,然后封装成一个单独的静态库文件。链接时,编译器只复制需要的目标模块。
使用例: g++ main.c usr/lib/libm.a /usr/lib/libc.a
使用 ar 打包静态库:ar rcs libvector.a addvec.c multvec.c
静态库与 .o 进行链接 g++ -static -o A A.o ./libvector.a
直接编译:g++ -o A A.cpp ./libvector.a
关于静态链接库的取名问题:
必须是 libxxx.lib (win) 或 libxxx.a (linux) 的格式
6B. 静态库解析引用
必须在定义符号的库之前出现对该符号的引用,否则链接器会将其忽略。
同一个库可以在命令里边出现多次。
使用例:
foo.c 调用 libx.a 中的函数,该库又调用 liby.a 中的函数,liby.a 有调用 libx.a 中的函数。 gcc foo.c libx.a liby.a libx.a
7. 重定位
符号解析:将所有的符号解析与唯一的符号定义关联起来。
此时,链接器已经知道输入目标模块代码节和数据节的确切大小。重定位操作分为两步:
- 重定位节和符号定义 :将所有相同类型的节合并成同一个节(例如,所有输入模块的 .data 文件全部合并为可执行目标文件的 .data 节),并将所有的符号定义(暂时不修改引用)指向合并后的内存中地址。
- 重定位节中的符号引用:使用下面将要描述的数据结构:可重定位条目修改符号引用指向的地址
I. 重定位条目
汇编器生成目标文件时不知道生成出来的代码和数据在内存中的具体位置,也不知道外部引用。所以,当汇编器遇到位置未知的引用,就会产生一条重定位条目放在 .rel.data 里边。
ELF 重定位条目包含以下的东西:
typedef struct { long offset;//被修改的引用的节偏移。(注意是引用的) long type:32,symbol:32;//type:偏移量类型,symbol:指向修改引用应当指向的符号 long addend; } Elf64_Rela;
ELF 定义了 32 种不同的重定位类型。常见的两种:R_X86_64_PC32 相对地址的引用,R_X86_64_32 重定位一个使用 32 位绝对地址的引用。
以上讨论的引用地址都是 32 位,是因为这两种方式支持 x86-64 小型代码模型,该模型假设可执行文件中的数据+代码小于 2GB,引用的地址可以用 32 位地址来访问。
大于 2GB 的程序可以用 -mcmodel=medium 或 -mcmodel=large 来编译。
II. 重定位符号引用。
编译器生成的需要重定位的 .o 的反汇编代码
e: e8 00 00 00 00 callq ? //call sum() f:R_X86_64_PC32 sum-0x4 13: //继续执行
在预编译阶段,要重定位的地址会用 0 填充。
f: 开头的一行条目,指示 offset,symbol,type,addend
注:事实上,重定位条目存储在 rel.data 或 rel.text 节中,反汇编条目写在这里单纯是方便显示。
维护相对位置引用的流程:
假设要修改的条目为 r,其所在的节(例如.text,.data)头指针为 s ,ADDR(x) 表示 x 在重定位后的地址
refptr=s+r.offset;//计算出要修改的条目的地址 refaddr=ADDR(s)+r.offset;//要修改的条目在连接完成的程序中的地址 *refptr=(unsigned)(ADDR(r.symbol)-refaddr+r.addend);
维护绝对位置引用。略
8. 可执行目标文件
P483
结构:
只读:ELF头,段头部表,.init,.text,.rodata,
可执行:.data , .bss
不加载到内存: .symtab,.debug,.line,.strtab
ELF头还包括了程序的入口点。由于已经链接完成,所以不再需要 rel 节。
P483:程序头部表,代码段和数据段各有
包含:
off 目标文件中的偏移
vaddr/paddr 内存地址
align
filesz 数据段/代码段在文件中的大小
memsz 数据段/代码段在内存中的大小
flags 运行时访问权限
9.加载可执行目标文件
Firm,看不懂
10. 动态库
共享库:一个目标模块,运行时可以加载到任意的内存地址,并与一个内存中的程序连接起来。这个过程称为动态链接。在 Windows 系统下的后缀为 .dll,在 Linux 系统下的后缀为 .so
任何文件系统中对于一个库只有一个 .so 文件。在链接时不复制代码和数据到引用他们的文件中,但会复制重定位和符号表信息。在内存中,一个共享库的 .text 节的副本可以被不同的进程共享。
构造共享库:gcc -shared -fpic -o libvector.so addvec.c multvec.c
带共享库编译:gcc -o prog21 main2.c ./libvector.so
流程:链接器生成部分链接的可执行目标文件 prog21(需要使用 libvector.so 中的重定位和符号表信息),运行 prog21 时,加载器注意到 prog21 含有一个 .interp 节(包含动态链接器的路径名),会将控制权扔给动态链接器而不是程序。
然后,动态链接器通过执行下面的重定位完成链接:
- 重定位 libvector.so 的文本和数据到一个内存段。(此时共享库的位置已固定)
- 重定位 prog21 所有由 libvector.so 定义符号的引用。
注:
事实上,大部分 linux 程序都会再用到一个叫做 libc.so 的共享库,完成加载程序以及传递返回值的工作。
11. 从应用程序中加载和链接共享库
动态链接的优势使用场景:
- 分发文件,减少更新软件的下载量。
- 构建高性能 Web 服务器。
Linux 系统为动态链接器提供的简单接口:
#include<dlfcn.h> void *dlopen(const char* filename,int flag); void *dlsym(void *handle,char *symbol); void *disclose(void *handle); const char *dlerror(void);
1. dlopen:
打开共享库,返回:若成功则为指向句柄的指针,否则则为NULL
flag 参数必须包含 RTLD_NOW 或 RTLD_LAZY ,告诉链接器立刻解析来自外部符号的引用,或者推迟符号解析直到执行库中的代码。
2. dlsym:
输入:指向前面已经打开的共享库的句柄和一个 symbol 名字
返回:这个符号的地址,或 NULL
3. disclose:
卸载对应的共享库。
如果成功则返回 0,否则返回 -1。
4. dlerror:
返回最近的错误信息,最近没有错误信息则返回 NULL
使用示例: P489。
12. 位置无关代码
多个进程如何共享程序的同一个副本,如何编译代码块使得他们可以被加载到内存中的任何位置而无需使用链接器修改?
注意,无需链接器修改的只有代码段,每个进程仍需要拥有他自己的读写数据块。
可以直接加载而无需重新定位的代码称为位置无关代码,简写为 PIC。在编译时使用 -fpic 指令构建。共享库的编译必须采用该选项。
先重申一下我们的目标:我们需要一些加载到不同地方只需要链接器介入,编译器不需要介入的程序。也就是说,程序的代码段必须一致(否则需要重新编译)。
同一个文件的符号引用:本身就是 PIC 的,使用 PC 相对寻址来编译这些引用。
不同文件的符号引用?对共享文件定义的外部过程和对全局变量的引用?
I. PIC 数据引用
重要的性质:数据段和代码段的距离是固定的,因此代码中任何指令和任何变量之间的距离都是一个运行时常量。
对于全局变量 addcnt 的引用,在数据段存放指针数组 GOT(全局偏移量表) ,其数据类型为指针。
程序可以通过 mov delta(%rip) %rax 的方式,把 GOT 数组中的内容拷贝到 %rax 里边。
例如 P490 代码,addcnt 是第一个需要引用的全局变量,GOT[0] 存放指向它的指针,它与调用它的地方相对距离为 0x2008b9,通过 0x2008b9(%rip) 获取它的地址。
II. PIC 函数调用
需要调用一个在别的目标文件中定义的函数。
延迟绑定:将过程地址的绑定推迟到这个函数第一次被调用时。
好处:实际的 .so 包含致死量函数,但大部分不会用到,没有必要对每个函数都进行重定位
两个数据结构:GOT(全局偏移量表,位于数据段)及PLT(过程链接表,位于代码段)
代码看P491。
PLT代码工作流程:
以调用存储在 GOT[2] 中的 addvec 函数为例。PLT[0] 为call dynamic linker 的函数,对应地址为GOT[0]。
GOT[2] 维护 call addvec() 时要跳转到的地址,初值设为 GOT[0],使第一次跳转能跳转到链接器。
在第一次跳转到链接器完后,将 GOT[2] 的值设为 addvec 值。
13. 库打桩机制
烂尾 🤡
请在做 Malloc Lab 之前补完。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?