程序员的自我修养----链接、装载与库
程序 → 进程
中心思想:
- "Any problem in computer science can be solved by another layer of indirection"
- 任何过程都由由时间划分的子过程构成
参与者:
- 底层硬件: CPU, 内存, 磁盘, I/O设备
- 操作系统(OS): Linux, Windows
- 系统调用(System Call)
- 内存管理
- 进程/线程调度
- I/O设备驱动
- 编译器: gcc/g++, cl
- 静态/动态链接器: ld, link
- 语言库: Glibc, MSVC
- 标准: POSIX, ANSI C
- 目标文件: ELF格式(可执行文件, 共享目标文件
.so
, 核心转储文件.coredump
), COFF/PE格式(.dll
,.exe
)
重要基础: 虚拟地址空间(Virtual Memory Address)
工具: GNU工具bultin, dumpbin
组成:
- 代码(text/code, 算法的实例)
- 数据(data, 数据结构的实例)
- 上下文/运行状态(context)
视图:
- 编译视图
- 链接视图: 地址空间分配, 符号决议, 重定位. 从 Section 的角度看
- 静态链接
- 动态链接
- 文件视图(磁盘上,即为程序):
- 进程视图(内存中,即为进程): 从 Segment 的角度看
- 系统视图
基本硬件
- 总线: 携带信息字节在各个组件间传递
- I/O设备: 系统与外部世界的联系通道.每个I/O设备通过控制器(是I/O设备本身或者系统的主板上的芯片组)或适配器(插在主板插槽上的卡)与I/O总线相连
- 主存: 主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据.
- 处理器: 即
CPU
,是解释(执行)主存中的指令的引擎,其核心是大小一个字的寄存器,称为程序计数器(PC)
.任何时刻,PC
都指向主存中的某条机器指令.CPU
的模型由指令架构集
决定.CPU
从PC
指向的内存中读取指令,解释其中的位,执行该指令指示的简单操作,随后更新PC
.这些简单操作主要围绕主存
,寄存器文件(Register File)
和算数/逻辑单元(Algorithm and Logic Unit)
.ALU
计算新的数据和地址值.
提高 CPU 速度: 对称多处理器(Symmetrical Multi-Processing, SMP)
程序的运行依赖:
程序
操作系统的作用(前三者微内核, 加上四为宏内核):
- 进程调度: 决定谁使用 CPU
- 访问同步: 决定谁存取 I/O
- 进程通信: 决定如何分享信息
- 设备驱动
内存的处理: 虚拟地址映射
- 虚拟地址空间: 隔离地址空间, 防止恶意访问
- 分段 和 分页: 提高内存访问效率, flat 模式需要连续, 即线性访存, 而当内存需要 频繁存取 的时候, 会影响交换效率. 例如, 总内存 128M, 进程A 10M, B 100M, C 20M, 如果连续, 当 C 运行时, 则需要将前二者都换下
- 确定进程运行地址
- MMU 硬件(位于 CPU )实现 物理地址(即 内存中的地址) 与 虚拟地址的 映射
线程概念:
- 并发而来
- 访问权限:
- 线程私有: 局部变量, TLS(Thread Local Storage, TLS), 函数参数
- 线程共享, 进程所有: 全局变量, 堆, 静态变量, 代码, 打开的文件
- 线程调度与优先级
- 状态: 运行, 就绪, 等待
- 优先级调度
- 竞争与原子操作(Atomic)
- 竞争: 访问共享数据
- 单子操作: 不会被 调度 打断的操作, 一般由 CPU 指令原生提供
- 同步(Aynchronization)与锁(Lock)
- 本质: 操作系统提供的 原子操作
- 原理: 访问数据前获取(Acquire)锁, 访问后释放(Rlease)锁,
- 二元信号量(Binary Semaphore): 占用 与 非占用
- (多元)信号量: 初始值为 N 允许 N 个线程并发访问, 获取和释放的可以不是同一个
- 互斥量(Mutex): 谁获取谁释放, 其他类上
- 临界区(Critical Section): 前三者可被其他进程访问, 它仅限本进程, 其他类上
- 可重入函数
- 时机: 多线程同时执行 或者 函数调用自身
- 特点
- 不使用任何(局部)静态或全局的非 const 变量
- 不返回任何(局部)静态或全局的非 const 变量的 指针
- 仅依赖调用方的参数
- 不依赖单个资源的锁
- 不调用任何不可重入函数
- 过度优化:
- 时机: CPU 的 动态调度(即乱序执行) 和 编译器为优化而造成的 指令顺序交换
volatile
关键字阻止 编译器的过度优化- CPU 提供
barrier
指令(不一定就叫这个), 它会阻止 CPU 将该指令前的指令交换到其后
静态链接
# 预编译
$ gcc -E hello.c -o hello.i
# 编译
$ gcc -S hello.c -o hello.s
# 汇编
# -c 表示只编译不链接
$ gcc -c hello.c -o hello.o
# 链接
$ ld -static /usr/lib/crt1.o /usr/lib/crti.o \
/usr/lib/gcc/i486-linux-gnu/4.1.3/crtbeginT.o \
-L/usr/lib/gcc/i486-linux-gnu/4.1.3 -L/usr/lib -L/lib \
--start-group -lgcc -lgcc_eh -lc \
--end-group /usr/lib/gcc/i486-linux-gnu/4.1.3/crtend.o /usr/lib/crtn.o
# -d: 反汇编
# -s: 十六进制打印
# -x: 打印段信息, readelf -S 更好
# -r: 查看重定位表
$ objdump -h/-x
# -s: 符号表
# -h: 头部信息
$ readelf
# -t: 查看静态库文件包含的目标文件
# -x: 解压
$ ar
编译器行为: 前 4 为前端, 其他为后端
- 词法分析: 有限状态机 将 字符序列分割成 记号(Token)
- 语法分析: 使用 上下文无关语法 生成 语法树
- 语义分析: 静态语义, 如声明和类型的匹配
- 中间语言生成: 进行优化, 并转换成中间代码, 如 三地址码
- 代码生成器
- 代码优化器
用可执行文件格式存储的文件类型:
- 可重定位文件(Relocatable File):
.o
,.obj
, 可被链接成 可执行文件 或 共享目标文件 包括 静态链接库 - 可执行文件(Executable File): 可直接执行的文件
- 共享目标文件(Shared Object File):
.so
, 用以链接生成其他文件, 或者被 动态链接器 连入进程 - 核心转储文件(Core Dump File): 进程意外终止时记录当时上下文
elf 文件由多个段组成, 这些段的结构被定义在 /usr/include/elf.h
主要的几个段 Section 以及其作用
.text
(只读) : 机器代码.data
(读写) : 初始化的全局变量(包括普通类型和静态类型)和初始化的局部静态变量.bss
(读写) : 未初始化的全局变量(包括普通类型和静态类型)和未初始化的局部静态变量. 其实他们会被初始化为 0, 但也因此, 他们就没必要放置于.data
中占据内存空间了. 该段只是为未初始化的全局变量(包括普通类型和静态类型)和未初始化的局部静态变量预留位置, 实际上并没有内容, 不占据空间.rodata
(只读) : 字符串.dynamic
: 动态链接信息.hash
: 符号哈希表.strtab
: 字符串表.symtab
: 符号表.shstrtab
: 段表字符串表.plt
,.got
: 动态链接的 跳转表 和 全局入口表.init
,fini
: 程序初始化和终结代码段Section Table
: 由文件头标明偏移, 记录各个表的信息(段描述符, 一个 40B), 被编译器和链接器使用, 解析 elf 文件
GCC可以使用
__attribute__((section("Section")))
将程序的数据或函数放在 Section 内
段名只在链接和编译时有意义, 运行时无意义
在链接之后, 可能会增加许多符号, 可以直接在源码中使用extern char 符号名[]
引用他们, 链接器会在链接时重定位他们
与链接相关的即为重定位表 .rel*
, .symtab
, .strtab
符号是一个变量的抽象
符号表 .symtab
的具体内容:
- 首先符号表中并不存在具体的变量名字符串,他们被存储在字符表
.strtab
中, 符号表只需引用符号在字符表中对应的下标或偏移即可 - 其结构体如下
typedef struct { Elf32_Word st_name; // 符号名, 即符号名字符串在.strtab的下标 Elf32_Addr st_value; // 符号值: // 1. 符号表征的内容在段中偏移 // 2. COMMON 对齐字节 // 3. 可执行文件 虚拟地址 Elf32_Word st_size; // 符号大小, 即占用的字节 unsigned char st_info; // 符号绑定信息: 局部0, 全局1, 弱引用2 // 符号类型: 未知0, 数据对象1, 函数2, 段3, 文件名4 unsigned char st_other; // 无用 Elf32_Half st_shndx; // 符号所在段 // 特殊: ABS 0xfff1(绝对的值,如文件名4), COMMON 0xfff2(未初始化全局符号定义), UNDEF 0(未定义) } Elf32_Sym;
- 重定位表
.rel*
, 即.text
有要重定位的地方就会有.rel.text
,.data
有重定位就有.rel.data
typedef struct { Elf32_Addr r_offset; // 重定位入口,即符号在被重定位段中的偏移 // 注: 该变量与.symtab中的st_value不同, r_offset指向被引用处, st_value指向该符号代表的实际数据 Elf32_Word r_info; // 低8位: 重定位入口类型, 根据这个类型使用相应的算法进行重定位 // 高24位: 符号在符号表的下标, 也是通过这个信息与.symtab中的符号对应 // 绝对寻址: S(保存在被修正位置的值)+A(r_offset计算的被修正位置), // 相对寻址: S+A-P(符号实际地址, 可由r_info高24位得到) } Elf32_Rel;
重定位是什么:
重新计算各个目标地址的过程即为重定位
为什么需要重定位:
在最开始的"纸带程序时期", 如果使用绝对地址, 每次修改或者重新加载, 可能都需要重新计算, 十分繁琐, 耗时且容易出错. 而且, 随着编程语言的进步, 现在大多数程序都是由模块构成, 所以选择使用更高效的重定位进行地址调整工作.
模块间符号的引用 导致了 被引用的符号需要重定位
数据类型是绝对寻址, 近跳转相对寻址
怎么进行重定位
使用符号(Symbol)代替跳转的地址,将符号以及相关信息储存在名为符号表(.symtab
)的数据结构中,并将对应地址上的机器码换成某些特定的临时假地址,例如, 0, 0xFCFFFFFF. 此后地址的计算就与代码(.data)脱离了关系, 只有因此而生的段如.rel有关.链接器主导此过程
会不会在重定位的过程中产生相同符号呢
会, 所以有了强弱符号之分
默认函数和初始化的全局变量为强符号, 未初始化的为弱符号
规则:
- 不允许强符号的多次定义
- 一强多弱选一强
- 全弱, 选占据空间最大的
__attribute__((weak))
定义符号强弱
__attribute__((weakref))
用以声明对一个外部函数或者变量的引用为弱引用
函数拥有参数以及返回值, 那么它的符号该怎么表示呢
函数签名(Function Signature),由编译器决定.
GCC:C++filt
用以解析被修饰过的名称
MSVC:UnDecorateSymbolName()
API用以转换
静态链接过程:
- 空间与地址分配: 多个目标文件按相似段规则合并, 并重新按照ELF结构产生映射关系并存储于段表中, 主要是 VMA 空间
- 确定所有符号地址, 留待重定位解析
- 符号解析与链接时重定位: 查找由所有输入目标文件的符号表组成的全局符号表, 之后利用
.symtab
,.strtab
,.rel*
进行重定位- 有些地址需要等到链接时, 进行重定位, 利用一些特定值进行占位, 譬如, 数据地址用
0x0
, 函数地址用0xFFFFFFF4
, 即 -4 , 用于call
后面, 如0xe8 fc ff ff ff
, 其中0xe8
即为相对偏移近跳转, 紧跟的数即为 相对下一条指令的偏移量, 可以看出其解析后指向的地址即为 偏移量 的地址
- 有些地址需要等到链接时, 进行重定位, 利用一些特定值进行占位, 譬如, 数据地址用
装载
转载: 因为只有程序所需指令和数据全部在内存中才能运行, 所以需要把他们装入内存中
- 静态装载: 将程序的所有内容都装入内存中
- 动态装载: 利用局部性原则将程序最常用的部分装如内存, 不常用的放入磁盘, 需要时再将其装入内存, 并把多余的部分写回磁盘
基于集成于CPU上的MMU, 使用虚拟地址空间对CPU处理的地址与内存上的物理地址进行映射
相关细节:
- Linux: mmap(), brk(), OS占1GB
- Windows: AWE用于替换内存中的数据,但虚拟地址保持不变
利用局部性原则进行装载:
- 覆盖装载(Overlay): 利用调用路径进行装载, 将各个模块组合成树的关系
- 页映射(Paging): 即以4KB或者4MB为单位, 进行装载与映射
建立进程:
- 操作系统创建独立的虚拟地址空间: 即创建相关数据结构, 分配页目录(Page Directory), 虚拟空间与物理内存的映射关系
- 操作系统读取可执行文件头, 建立虚拟空间与可执行文件的映射关系
- 操作系统将CPU的RIP设置成可执行文件的入口地址
可执行文件也被称为镜像(Image)
装载时引入装载类型Segment, 是相同数据类型的集合, 例如.init, .fini, .text等都属于代码Segment, .bss, .data, .symtab等为数据Segment, 相同Segment的数据被映射到同一片VMA
一个进程可被分为四种VMA区域:(p表示私有, 可用于写时复制, s表示共享)
- 代码VMA, r-xp, 有映像文件
- 数据VMA, rwxp, 有映像文件
- 堆VMA, rwxp, 无映像文件,匿名,向上扩展
- 栈VMA, rw-p, 无映像文件,匿名,向下扩展
- 内核虚拟共享对象vdso(Kernel Virtual Dynamic Shared Objects)
- 它总是被加载在0xffffe000的位置
- 里面有个名为
__kernel_vsyscall
的函数, 它负责新型的系统调用,sysenter
指令 dd if=输入文件名 of=输出文件名 bs=一个块的大小 skip=从文件开头到此跳过的块数 count=要搬运的块数
if=/proc/self/mem
时, 可获取当前进程的内存快照
由于页映射机制, 所以Segment需要对齐, 然而对齐可能会浪费大量内存资源, 所以采取了将位于Segment之间的page映射两次的方法.
比如, 首先将可执行文件以页为单位分割,不必考虑各个Segment的对齐问题, 分割后的即为装入内存中的页. VMA0起始地址 0x08048000, 长度 0x709E5, 按 4B 对齐到 0x080B89E8. 采用Segment对齐机制后VMA1地址则为 0x080B89E8 + 0x1000, 且实际内存页是相连的.
进程栈的初始由OS完成
进程堆由OS完成, 然后由动态链接器维护
Linux内核装载ELF过程: 其中序号为调用顺序
- bash: 调用fork()
- fork(): 创建子进程
- execve(const char *filename, char *const argv[], char *const envp[]): 执行指定文件
- sys_execve(): 真正的系统调用接口
- do_execve(): 查找文件,并读取其前128个字节,用来确定装载处理函数, 如elf魔数, #!/bin/sh, #!/usr/bin/python, #!也是一种魔数
- search_binary_handle(): 搜索匹配合适的装载程序, 如load_elf_binary(), load_aout_binary(), load_script()
- load_elf_binary(): 装载elf, 位于 fs/Binfmt_elf.c
- 检查elf文件有效性
- 寻找 .interp, 设置动态链接器路径
- 对elf进行映射
- 初始elf进程环境
- 将系统调用的返回地址修改成elf文件的入口点
- 静态, 即为elf文件头中的e_entry指向的地址
- 动态, 入口点为动态链接器
PE装载: 基地址, 相对虚拟地址(Relative Virtual Address)
- 读取文件第一页, 包括DOS头, PE文件头, 段表
- 检查目标地址是否可用, 否则另选
- 利用段表对段进行映射
- 装载地址非目标地址, Rebasing
- 装载DLL
- 对导入符号进行解析
- 根据PE头参数, 建立初始化堆栈
- 建立主线程并启动进程
动态链接
动态链接: 将链接的过程推迟到运行时再进行
(动态)共享对象(Dynamic Shared Objects): ELF动态链接文件. windows中叫动态链接库(Dynamical Linking Library), .dll. 其主要作用便是节省内存空间, 不必像静态库那般一个进程一个库
动态链接库: 相当于再操作系统和程序之间加了一个中间层, 消除程序对不同平台依赖的差异性. 共享对象是库的实现形式
模块: 动态链接下, 一个程序被分成了若干个文件, 即可执行文件与共享对象 Lib.so, 把他们分别看成一个模块
共享对象的最终装载地址编译时不确定, 在最终装载时由装载器确定
地址无关代码(Position-independent Code, PIC): 不需要更改指令部分的代码, 将需要共享的指令部分分离出来, 放至数据部分, 这样指令部分就不需要更改, 而数据部分可以在每个进程中都拥有一个副本. 或者说是 位置无关, 简而言之, 就是为模块的引用加了一个中间层, 指令只需引用固定的中间层即可, 再由中间层去管理那些位置不定的模块
为什么需要地址无关代码
因为程序模块中可能存在一些对绝对地址的引用, 所以在链接产生输出文件时, 就要假设模块被装载的目标地址. 然而, 动态链接时, 各个模块的装载地址不能一样, 如果固定装载地址, 则可能需要人为进行地址分配, 如果模块数过多, 或者更新频繁, 则会耗费大量人力资源, 且可能出错, 也不利于各个组织的合作.
为什么不使用编译重定位
只有使用动态链接时, 需要装载时重定位, 否则可以在静态链接时就可以确定各个模块的虚拟地址基址
装载时重定位: 将重定位推迟到装载时, Windows中称为基址重置(Rebasing)
装载时重定位需要修改指令, 无法做到同一份指令被多个进程共享, 失去了动态链接的优势, 由此提出了 PLT 技术
4种情况:
- 模块内部的函数调用, 跳转等
无需考虑重定位, 因为即使在装载合并为Segment后, 一个模块的内部代码依旧相对固定, 则可以 当前指令地址(PC) + 偏移 进行寻址
存在共享对象全局符号介入问题(Global Symbol Interposition) - 模块内部的数据访问, 如模块内定义的静态变量(不包括全局变量)
指令中不应包含绝对地址, 则需要使用相对地址, 比如static int a; static int *p=&a;
, 其中p中保存的即为绝对地址
可以利用 当前PC + 偏移即可访问数据, 但是访问数据的指令一般是mov
,lea
等, 无法使用当前PC.
ELF常见地利用函数调用获取当前 PC. 因为call
指令会将 下一条指令的 PC 入栈, 则可以调用一个只含两条指令的函数mov (%esp),%ecx; ret
(%esp
指向 call 的下一条指令的 PC )获取当前PC, 随后就可以add $dataSegment_offset, %ecx; mov $value,variableInData_offset(%ecx)
- 模块外部的数据访问, 如其他模块定义的全局变量
模块间的数据访问目标地址要等装载时才能决定, 比如说共享对象中的全局变量
ELF在数据段中建立了一个只想这些变量的指针数组, 即全局偏移表(Global Offset Table, GOT), 进行间接引用. 即为 .got段
在编译时确定GOT相对于当前指令的偏移, 如同上列模块内部的数据访问一般: 当前指令 + .got相对PC的偏移 + 变量在.got中的偏移 - 模块外部的函数调用, (间接)跳转等
也可以使用GOT保存目标函数的地址
reaelf -d foo.so | grep TEXTREL
: 如果有任何输出, 那就不是PIC
对于共享模块的全局变量的引用问题: ELF共享库在编译时, 默认把定义在模块内部的全局变量当作定义在其他模块的全局变量, 即当作前述的第三种情况. 如果在可执行文件中存在该全局变量的副本, 则令GOT中对应的地址指向该副本(可以看出可执行文件的全局变量使用情况2的处理方法), 否则,指向该模块内部的副本
lib.so中定义了全局变量G, 进程A, B都使用了lib.so, A改变G时, B中的G会受影响么
上文曾提过, 每个进程都拥有数据段的副本, 所以没有影响. 但是依旧需要某些数据可以被进程共享, 如erron全局变量, 即进程间通信. 采用共享数据段技术
在上述情况2中提及static int a; static int *p=&a;
, p保存的是绝对地址, 且a的地址随着装载地址改变也会改变. 其实也可以采用装载时重定位技术, .rel*中拥有相应的重定位入口, 由动态链接器对其进行重定位, 采用下述的基址重置手段
动态链接的可执行文件默认PIC, 但是其数据段依旧需要重定位
PLT(Procedure Linkage Table): 一种实现延迟绑定(Lazing Bind)的结构
因为变量引用和函数引用有些区别. 所以 .got分成了.got和 .got.plt, 分别对应全局变量和函数引用的地址, .plt用于 .got.plt
.plt代码如下
// 此处为真正的形式
PLT0:
// *GOT为 .dynamic段的地址, 它描述了本模块动态链接相关的信息, 后两项由动态链接器在装载共享模块时初始化
push *(GOT + 4) // *(GOT + 4)保存的是模块的ID
jmp *(GOT + 8) // *(GOT + 8)保存_dl_runtime_resolve()地址
.....
F@plt:
jmp *(F@GOT)
push n
jmp PLT0
// 此处为演示
F@plt:
jmp *(F@GOT) // 绝对跳转, 通过GOT进行相对跳转, 但是为了延迟绑定, 初始时, 里面的地址指向下一条指令
// 在第一次调用F后, _dl_runtime_resolve会将F@GOT的值改为F所在的真正的地址
// 并且由于此处使用的是jmp指令, 所以F函数返回时, 返回的还是更之前的call的下一条指令
// F这个符号在 .rel.plt中
// _dl_runtime_resolve参数, 他利用栈传参
push n // 决议符号在 .rel.plt中的下标
push moduleID // 模块ID
jmp _dl_runtime_resolve // 完成符号解析和重定位
为什么需要延迟绑定
因为在程序开始之前, 动态链接器需要对全局以及静态的数据进行GOT定位, 然后间接寻址; 对模块间的都用也要先定位GOT, 然后进行间接跳转; 且动态链接的工作在程序运行时完成, 以上都大大拖慢了程序的运行速度.
所以ELF采取了延迟绑定: 函数第一次用到才进行绑定, 如果没有用到则不绑定
PLT的来历
假设liba.so需要调用lib.so中的函数F, 那么动态链接器应该采用某个函数进行地址绑定, 该函数需要知晓地址绑定发生在哪个模块, 哪个函数, 所以函数原型应是_dl_runtime_resolve(module, function)
. 所以我们可以在上述演示代码看见_dl_runtime_resolve
函数. 为了尽量节省空间, 增加效率, 采用了第一个方案.
动态链接的依赖结构:
- .interp段: 存放动态链接器的路径, Linux为
/lib/ld-linux.so.2
, 它实际是一个软链接, 这样就不用迁就链接器的版本升级 - .dynamic段: 动态链接器所需基本信息
typedef struct { Elf32_Sword d_tag; // 类型值, 由此决定联合体的选取 // DT_SYMTAB: 动态链接符号表地址, d_ptr为 .dynsym地址 // DT_STRTAB: 动态链接字符串表地址, d_ptr为 .dynstr地址 // DT_STRSZ: 动态链接字符串表大小, d_val为大小 // DT_HASH: 动态链接哈希表地址, d_ptr为 .hash地址 // DT_SONAME: 本共享对象的SO-NAME, 即共享库的文件名去掉次版本号和发布版本号, 只保留主版本号 // DT_RPATH: 动态链接共享对象搜索路径 // DT_INIT: 初始化代码地址 // DT_FINI: 结束代码地址 // DT_NEED: 依赖的共享对象文件, d_ptr为其名 // DT_REL, DT_RELA: 动态链接重定位表地址 // DT_RELENT, DT_RELAENT: 动态重定位表数量 union { Elf32_Word d_val; Elf32_Addr d_ptr; } d_un; }Elf32_Dyn;
readelf -d
查看 - 动态符号表 .dynsym: 表示模块之间符号的导入导出关系, 只与动态链接相关
- .symtab保存所有符号, 包括 .dynsym, 其结构也与 .symtab一样
- 需要保存符号名的字符串表 .dynstr
- 为了加快查找速度, 可能还有辅助的符号哈希表 .hash
readelf -sD
查看
- 动态链接重定位表: 因为编译时导入符号的地址未知, 在运行时才能修正, 所以需要重定位表
- rel.dyn, rel.plt分别对应 .got和数据段中的数据引用修正和 .got.plt中函数引用修正
- R_386_JUMP_SLOT和R_386_GLOB_DAT重定位过程为: 先在.rel*中找到他们存放他们地址的.got*对应处, 随后动态链接器会在相关库中寻找他们的地址, 并将其填入.go*的相应位置
- R_386_RELATIVE: 需要基址重置(Rebasing), 用于数据部分中的绝对地址, 他需要加上一个装载地址值
- 注意: 函数所在段与是否为PIC有关. 若非PIC, 则类似于静态链接, 它的类型变为 R_386_PC32
- 堆栈初始化: 由操作系统通过堆栈传给动态链接器, 包括
- 堆由CRT初始化
- 栈由操作系统初始化
- 这个信息是一个类似于 .dynamic的结构体, 就存放于栈开头, 位于环境变量值得后面
- 类型有文件句柄 AT_EXEFD 2, 程序头表地址 AT_PHDR 3, 若有3, 则还包括程序头表入口大小 AT_PHENT 4, 入口数量 AT_PHNUM 5, 动态链接器装载地址AT_BASE 7, 入口地址AT_ENTRY 9
动态链接的步骤与实现:
- 启动动态链接器本身: 他在初始化时不可以链接其他对象, 需要自举(Bootstrap)
- 动态链接器的入口地址即为自举代码的入口
- 找到GOT, 则可找到它的 .dynamic, 获得自身的重定位表和重定位符号, 从而得到其自身所有的重定位入口, 先将其全部重定位, 之后才可以使用其全局变量和静态变量以及调用函数
- 装载所有需要的共享对象
- 将可执行文件和链接器本身的符号表都合并到一个全局符号表(Global Symbol Table)中, 此时共享库还未被装载.
- 查找可执行文件的 .dynamic获取其依赖项(DT_NEED), 查找该依赖对象, 并进行循环装载. 最终依赖关系可能构成一个图, 采用广度优先(常见)或深度优先
- 可能不同的共享对象有相同的全局符号, 则会产生覆盖现象, 名为共享对象全局符号介入(Global Symbol Interpose)
Linux为此定义了一个规则: 后加入的同名符号被忽略
也因为该规则, PIC需要将模块内的函数调用看成模块外的调用, 因为模块内函数是相对地址定位的, 如果其被覆盖, 则需要重定位从而更改指令部分, 这与PIC矛盾. 或使用static定义
- 重定位和初始化
动态装载器重新遍历各个文件的GOT/PLT表, 将其修正重定位, 并执行共享对象的.init, 在可执行文件退出时, 执行其对应.fini段. 可执行文件的相关段由CRT负责 - Linux动态链接器的实现
- 实际是一个可执行的共享对象
- 静态链接
- PIC
- 可被当作可执行文件执行, 其装载地址由内核决定
系统也支持显式运行时链接(Explicit Run-time Linking), 也叫运行时加载. 所谓插件便是基于此原理. Linux中, 由动态链接器提供, 实现位于/lib/libdl.so
, 声明于<dlfcn.h>:
void *dlopen(const char *filename, int flag);// 打开动态库, 将其加载到进程地址空间
// 相对路径查找顺序: LD_PRELOAD, LD_LIBRARY_PATH, /etc/ld.so.cache中的路径, /lib, /usr/lib
// 若filename=0, 则返回全局符号表的句柄, 并可以执行他们
// flag为解析方式, RTLD_LAZY即为延迟绑定
// 返回被加载模块的句柄, 在返回前, 先执行模块的.init
void *dlsym(void *handle, char* symbol); // 在句柄handle中查找符号symbol
// 查找变量或函数, 返回地址; 查找常量, 返回值; 出错则返回0, 且dlerror()返回错误信息, 否则0
// 符号优先级, 按广度优先遍历, 先找到的被使用
char *dlerror(); // 判断其他三个函数调用是否成功
void *dlclose(void *handle); // 卸载被加载的模块
// 先执行.fini, 然后将符号从符号表去除, 取消映射关系, 关闭模块文件
Windows动态链接
Windows动态链接库为DLL(Dynamic-Link Library), 相当于Linux的共享对象, 其扩展名为 .dll, .ocx, .CPL(控制面板程序)
PE在Win32之后拥有独立空间, 采用基址(Base Address)以及相对地址(RVA, Relative Virtual Address)进行定位
DLL拥有两个数据段, 一个私有, 一个共享
DLL需要显示地指出导出符号, 使用关键词__declspec(dllexport)
, __declspec(dllimport)
. 或者使用 .def 文件声明导入导出符号, 其类似于ld链接器的脚本文件. 它也可以对导出符号进行重命名. 编译器将被他们声明的函数放在 .drectve段 传递给链接器
.def 文件可以导出重定向, 如 KERNEL32.DLL的 HeapAlloc 被重定向到 NTDLL.DLL的 RtlAllocHeap函数. 其实现是将导出函数的RVA指向导出表中的一个字符串, 如NTDLL.RtlAllocHeap 这就说明他被重定向
Windows下的 .lib 文件中包含的是对应 .dll 文件的导出符号, 被称为导入库(Import Library)
Windows的API的函数调用惯例都是__stdcall
, 其被宏定义为WINAPI
DLL也支持运行时显式链接: LoadLibrary()
, GetProcAddress()
, FreeLibrary
创建DLL文件时, 会得到EXP文件, 它的 .edata段 存放链接器遍历的所有目标文件而收集的所有导出符号信息而创建的导出表, 第二次链接时, 就会输入回DLL文件
PE文件很少导入导出变量, 因此导出符号和导出函数可以换用
Linux中存放导出符号以及导入符号于 .dynsym, Windows存放于导出符号表, 一个IMAGE_EXPORT_DIRECTORY
的结构体, 定义于Winnt.h
, 它提供符号名和符号地址的映射关系
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name; // dll名
DWORD Base; // 一般为1, 二分查找到函数, 再在序号表找其对应序号, 序号-Base 即为函数在地址表的下标
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image 导出地址表(必需)
DWORD AddressOfNames; // RVA from base of image 符号名表, 按序排列
DWORD AddressOfNameOrdinals; // RVA from base of image 序号(必需)
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
PE中有个名为导入表的结构体
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA) 指向导入名称表
} DUMMYUNIONNAME;
DWORD TimeDateStamp; // 0 if not bound,
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time stamp of DLL bound to (Old BIND)
DWORD ForwarderChain; // -1 if no forwarders
DWORD Name;
DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses) Import Address Table, 指向导入地址数组, 类似于GOT
// 如果最高位是1, 后31位位序号值
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
Windows的导入导出表位于 .rdata, 为只读. 但是Windows的动态链接器位于内核, 所以可以先修改为可写, 再修改为只读
PE也采用间接调用的方式调用导入函数, 它的代码不是PIC
DLL优化:
- 重定基地址
- 序号: 仅供内部使用的导出函数只有序号没有符号名
- 导入函数绑定: 将导出函数的地址保存到模块的导入地址表, 减少符号解析的过程, 使用 editbin程序
DLL HELL: 经常发生版本不兼容问题
- 原因
- 旧版本DLL代替新版本DLL
- 完全向下兼容不可能
- 新版DLL可能存在BUG
- 方法
- 静态链接: 不依赖DLL
- 使用文件保护(Windows File Protection, WFO)技术, 阻止未授权程序覆盖系统的DLL
- 避免DLL冲突: 让每个程序拥有一份自己依赖的DLL
- 使用名为 Mainifest文件 的清单文件描述程序集, 他是一个XML文件
- 对于每个版本的DLL, 在WinSxS(Windows Side-By-Side)目录下都由一个独立的目录, 其命名包括了机器类型, 名字, 公钥以及版本号
共享库
共享库基于ABI(Application Binary Interface)兼容, 与语言相关: 只有添加导出符号和修正Bug且不改变导出函数的语义, 功能, 行为以及接口类型才兼容
共享库命名: libname.so.x.y.z
- x为主版本号, 重大升级, 不兼容
- y为次版本号, 增量升级, 向后兼容
- z为发布版本号, 修正BUG, 改正性能, 完全兼容
程序记住共享库的SO-NAME, 即libname.so.x, 以其为名建立软链接. 某些可能比较特殊, 如动态链接器ld-2.6.1.so, 其SO-NAME为ld-linux.so
基于符号的版本机制解决次版本交汇问题:
- 次版本交汇问题: 程序需要高版本共享库, 而系统中只有低版本
- Linux中的Glibc库使用GCC_前缀
增加 .symver汇编宏指令指定符号版本, 并允许多个版本的同一符号存在于同一个共享库中,
如asm(".symver old_printf, printf@VERS_1.1"); asm(".symver new_printf, printf@VERS_1.2")
文件层次标准FHS(File Hierarchy Standard): 规定文件如何存放
/lib
: 系统最关键和基础的共享库, 如Glibc, 主要是/bin
和/sbin
中程序需要用的库,以及系统启动时需要的库/usr/lib
: 非系统运行时所需要的关键的共享库, 主要是开发时可能用到的库, 也包括静态库,目标文件等/usr/local/lib
: 第三方应用程序的库, 如python的库
共享库查找优先级: LD_PRELOAD
, LD_LIBRARY_PATH
, /etc/ld.so.cache
中的路径, /lib
, /usr/lib
Linux中为此存在一个名为ldconfig的程序, 用以为共享库目录下的各个共享库创建, 删除, 更新相应的SO-NAME, 并将其收集存放至/etc/ld.so.cache
中
Linux中还有一个名为LD_DEBUG
环境变量, 用以帮助开发调试共享库
GCC提供了__attribute__((constructor/destructor))
分别声明共享库的构造函数以及析构函数
库
函数的调用惯例:(Window在前面加_, 如__cdecl
, GCC将其置于__attribute__(())
中, 如__attribute__((cdecl))
, 其中 cdecl为默认)
调用惯例 | 出栈方 | 参数传递 | 名字修饰 |
---|---|---|---|
cdecl | 函数调用方 | 右至左入栈 | _函数名 |
stdcall | 函数本身 | 右至左入栈 | _函数名@参数字节数 |
fastcall | 函数本身 | 头两个4B如寄存器, 其余右至左入栈 | @函数名@参数字节数 |
pascal | 函数本身 | 左至右入栈 | 较复杂, 见pascal文档 |
堆分配算法: 从操作系统批发, 由运行库管理
- 空闲链表(Free List):
- 位图(Bitmap): 将堆分成大量块(block), 第一个为头, 其余为主题, 块的状态为 头11/主体10/空闲00
运行库
本文在上面讲述过程序装载的过程, 下面讲述程序的运行步骤:(由此可知, 程序不从main
函数开始)
- 操作系统创建进程后, 将控制权交给程序入口, 这个入口往往是运行库的某个入口函数
- 入口函数对运行库和程序运行环境进行初始化, 包括堆, I/O, 线程, 全局变量构造等
- 初始化和OS版本相关的全局变量
- 初始化堆
- 初始化I/O
- OS将各种具有输入输出概念的实体--设备, 磁盘文件, 命令行, 管道, 网络, 信号--统称为文件, OS提供相关的操作函数
- Linux, Windows分别用文件描述符(File Descriptor), 句柄(Handle)描述文件(C标准未规定文件结构体)
- 建立用户态的打开文件表-→如果可以继承父进程, 则从父进程继承句柄-→初始化标准输入输出
- 获取命令行参数和环境变量: 装载器回将他们压入栈中, 栈顶为argc, 随后是argv, envp. 直接访问栈的底端即可
- 初始化C库的数据
- 入口函数完成初始化后, 调用
main
函数, 正式开始执行程序主体 main
函数执行完毕, 返回到入口函数, 入口函数完成清理工作, 包括全局变量析构, 堆销毁, 关闭I/O等, 最后进行系统调用(通常是exit()
函数)结束进程
glibc的程序入口为_start
(libc/sysdeps/x86/start.S
, 程序启动的代码位于libc/csu
)
一个C语言运行时库CRT(Runtime Library)大致有如下功能:
- 启动与退出: 入口函数及其依赖的函数
- 标准函数: C标准库
- I/O封装:
- 堆
- 语言实现: 一些语言相关的特殊功能, 比如C++的构造/析构函数
- 调试: 实现调试功能的代码
C语言标准库:
- 常见标准库:
- 标准输入输出 <stdio.h>
- 文件操作 <stdio.h>
- 字符操作 <ctyoe.h>
- 字符串操作 <string.h>
- 数学函数 <math.h>
- 资源管理 <stdlib.h>
- 格式转换 <stdlib.h>
- 时间/日期 <time.h>
- 断言 <assert.h>
- 各种类型的常数 <limits.h> & <float.h>
- 变长参数: <stdarg.h>
跟编译器有关, 比如GCC提供了两种机制- 参数传栈实现, 参见GNU-C-Manual中的
5.5 Variable Length Parameter Lists
- 宏定义实现, 参见GCC-Manual的
6.21 Macros with a Variable Number of Arguments
- 参数传栈实现, 参见GNU-C-Manual中的
- 非局部跳转: <setjmp.h>
- setjmp: 正常返回为0
- longjmp: 让程序的执行流回到当初setjmp返回的时刻, 并返回longjmp指定的返回值
glibc发行版组成: 位于
/usr/include
的头文件, 位于/lib/lib.so.6
的动态标准库, 位于/usr/lib/libc.a
的静态标准库, 后二者为二进制文件
Linux下的系统调用头文件为: <unistd.h>
Linux下,可执行文件运行时需要的目标文件(按链接顺序排序, 可执行文件及其所需库位于56之间)
/usr/lib/crt1.o
: 入口函数, _start-→__libc_csu_main()转传递了两个函数指针, _init(), _fini(), 用以实现构造函数和析构函数/usr/lib/crti.o
: _init, _fini的开始部分/usr/lib/gcc/x86-Linux-gnu/版本号/crtbegin.o
(可选): 真正用于实现构造函数/usr/lib/gcc/x86-Linux-gnu/版本号/libgcc.o
(可选): 处理不同平台的差异性/usr/lib/gcc/x86-Linux-gnu/版本号/libgcc_eh.o
(可选): 支持C++的异常处理/usr/lib/gcc/x86-Linux-gnu/版本号/crtend.o
(可选): 真正用于实现析构函数/usr/lib/crtn.o
: _init, _fini的结束部分, 几乎只是出栈的那一部分
CRT在多线程中的改进:
- 使用线程局部存储TLS(Thread Local Storage)
- 即线程的私有空间
- 隐式: 在定义时加上关键字
__thread
即可, Windows为__declspec(thread)
- 显式: 调用相关函数
- 加锁
- 改进函数调用方式: 如
strcmp()
改成strncmp()
C++全局构造与析构:
- 函数流程: _start -→ __libc_csu_main -→ __libc_csu__init -→ _init -→ __do_global_ctors_aux(位于
crtbegin.o
) - 文件编译时, 会创造一个名为 .ctors 的段, 它里面存放有指向全局构造函数的指针, 在链接时, 各个文件中的.ctors段会合并
- 链接器将
crtbegin.o
中的 .ctors 存储的数字 -1 更改为全局构造函数的数量, 并将其起始地址定义为符号__CTOR_LIST__
crtend.o
中的 .ctors 存储的数字即为 0, 表示结束, 且定义了一个符号__CTOR_END__
, 指向 .ctors 的末尾- 析构类似, 但是要注意: 先构造的后析构
系统调用(System Call)
系统调用: 即系统提供的函数, 上文曾提过, Linux系统调用需要引入头文件 <unist.h>
特权级与中断
- 两种特权级: 用户态, 内核态. 通过CPU中的寄存器(CS, SS等)中的特权级字段实现
- 中断
- 软件中断:
int n
指令, n 为名为中断描述符表(Interrupt Descriptor Table, IDT)的指针数组的下标, 它指向对应的中断处理程序(Interrupt Service Routine, ISR) - Linux系统调用软中断为
int 0x80
, Windows系统调用软中断为int 0x2E
- 硬件中断: 来自硬件的异常信号
- 软件中断:
Linux(0.12版本)系统调用实现: fork()
为例
- 触发中断
- 切换堆栈
- 中断处理函数
int fork(void); #define __NR_fork 2 // 此处即为fork函数的定义, 他是通过宏定义实现的 #define _syscall0(type,name) \ type name(void) \ { \ long __res; \ __asm__ volatile ("int $0x80" \ : "=a" (__res) \ : "0" (__NR_##name)); \ if (__res >= 0) \ return (type) __res; \ errno = -__res; \ return -1; \ } static inline _syscall0(int,fork) set_system_gate(0x80,&system_call); // 此处设置了中断号为 0x80 相对应的中断处理程序 .align 2 _system_call: // 于此处切换堆栈 // 为何此处函数名之前加了 _ // 还记得上文曾提到的函数名修饰嘛: // 在编译时, 编译器会在采用 cedel调用惯例的函数的函数名前加 _ 来修饰函数 push %ds push %es push %fs pushl %eax # save the orig_eax pushl %edx pushl %ecx # push %ebx,%ecx,%edx as parameters pushl %ebx # to the system call movl $0x10,%edx # set up ds,es to kernel space mov %dx,%ds mov %dx,%es movl $0x17,%edx # fs points to local data space mov %dx,%fs cmpl _NR_syscalls,%eax jae bad_sys_call call _sys_call_table(,%eax,4) pushl %eax 2: movl _current,%eax cmpl $0,state(%eax) # state jne reschedule cmpl $0,counter(%eax) # counter je reschedule ret_from_sys_call: movl _current,%eax cmpl _task,%eax # task[0] cannot have signals je 3f cmpw $0x0f,CS(%esp) # was old code segment supervisor ? jne 3f cmpw $0x17,OLDSS(%esp) # was stack segment = 0x17 ? jne 3f movl signal(%eax),%ebx movl blocked(%eax),%ecx notl %ecx andl %ebx,%ecx bsfl %ecx,%ecx je 3f btrl %ecx,%ebx movl %ebx,signal(%eax) incl %ecx pushl %ecx call _do_signal popl %ecx testl %eax, %eax jne 2b # see if we need to switch tasks, or do more signals 3: popl %eax popl %ebx popl %ecx popl %edx addl $4, %esp # skip orig_eax pop %fs pop %es pop %ds iret fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read, sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link, sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod, sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount, sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm, sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access, sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir, sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid, sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys, sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit, sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid, sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask, sys_setreuid,sys_setregid, sys_sigsuspend, sys_sigpending, sys_sethostname, sys_setrlimit, sys_getrlimit, sys_getrusage, sys_gettimeofday, sys_settimeofday, sys_getgroups, sys_setgroups, sys_select, sys_symlink, sys_lstat, sys_readlink, sys_uselib }; .align 2 _sys_fork: call _find_empty_process testl %eax,%eax js 1f push %gs pushl %esi pushl %edi pushl %ebp pushl %eax call _copy_process // int copy_process(int nr,long ebp,long edi,long esi,long gs,long none, // long ebx,long ecx,long edx, long orig_eax, // long fs,long es,long ds, // long eip,long cs,long eflags,long esp,long ss) // 系统调用的参数最多只有3个, 用寄存器存放 // 该函数是被系统调用的函数, 它有18个参数, 且fork()函数没有参数, 没用到寄存器 所以通过栈传递参数 // 最后五个参数在产生中断时, CPU自动压入 // 在它之前的7个参数, 由system_call函数压入 // 第6个是调用sys_fork函数时压入的返回地址 // 前5个是本函数压入的五个数据 addl $20,%esp 1: ret
Intel之后支持一组专门针对系统调用的指令--sysenter
, sysexit
调用sysenter
之后, 系统会跳转到某个由寄存器指定的函数执行, 并自动完成特权级转换, 堆栈切换功能等
导读
目录
第 1 部分 简介
第一章 温故而知新
1.1 从Hello World说起
1.2 万变不离其宗
1.3 站得高,望得远
1.4 操作系统做什么
1.4.1不要让CPU打盹
1.4.2设备驱动
1.5 内存不够怎么办
1.5.1 关于隔离
1.5.2 分段
1.5.3 分页
1.6 众人拾柴火焰高
1.6.1 线程基础
1.6.2 线程安全
1.6.3 多线程内部情况
1.7 本章小结
第 2 部分 静态链接
第 2 章 编译和链接
2.1 被隐藏了的过程
2.1.1 预编译
2.1.2 编译
2.1.3 汇编
2.1.4 链接
2.2 编译器做了什么
2.2.1 词法分析
2.2.2 语法分析
2.2.3 语义分析
2.2.4 中间语言生产
2.2.5 目标代码生成与优化
2.3 链接器年龄比编译器长
2.4 模块拼装----静态链接
2.5 本章小结
第 3 章 目标文件里有什么
3.1 目标文件的格式
3.2 目标文件是什么样的
3.3 挖掘 SimpleSection.o
3.3.1 代码段
3.3.2 数据段和只读数据段
3.3.3 BSS 段
3.3.4 其他段
3.4 ELF 文件结构描述
3.4.1 文件头
3.4.2 段表
3.4.3 重定位表
3.4.4 字符串表
3.5 链接的接口----符号
3.5.1 ELF 符号表结构
3.5.2 特殊符号
3.5.3 符号修饰与函数签名
3.5.4 extern "C"
3.5.5 弱符号与强符号
3.6 调试信息
3.7 本章小结
第 4 章 静态链接
4.1 空间与地址分配
4.1.1 按序叠加
4.1.2 相似段合并
4.1.3 符号地址的确定
4.2 符号解析与重定位
4.2.1 重定位
4.2.2 重定位表
4.2.3 符号解析
4.2.4 指令修正方式
4.3 COMMON 块
4.4 C++相关问题
4.4.1 重复代码消除
4.4.2 全局构造与析构
4.4.3 C++与 ABI
4.5 静态库链接
4.6 链接过程控制
4.6.1 链接控制脚本
4.6.2 最 "小" 的程序
4.6.3 使用 ld 链接脚本
4.6.4 ld 链接脚本语法简介
4.7 BFD 库
4.8 本章小结
第 5 章 Windows PE/COFF
5.1 Windows 的二进制文件格式 PE/COFF
5.2 PE 的前身----COFF
5.3 链接指示信息
5.4 调试信息
5.5 大家都有符号表
5.6 Windows 下的 ELF----PE
5.6.1 PE 数据目录
5.7 本章小结
第 3 部分 装载与动态链接
第 6 章 可执行文件的装载与进程
6.1 进程虚拟地址空间
PAE
6.2 装载的方式
6.2.1 覆盖装入
6.2.2 页映射
6.3 从操作系统角度看可执行文件的装载
6.3.1 进程的建立
6.3.2 页错误
6.4 进程虚存空间分布
6.4.1 ELF 文件链接视图和执行视图
6.4.2 堆和栈
6.4.3 堆的最大申请数量
6.4.4 段地址对齐
6.4.5 进程栈初始化
6.5 Linux 内核装载 ELF 过程简介
6.6 Windows PE 的装载
6.7 本章小结
第 7 章 动态链接
7.1 为什么要动态链接
7.2 简单的动态链接例子
7.3 地址无关代码
7.3.1 固定装载地址的困扰
7.3.2 装载时重定位
7.3.3 地址无关代码
7.3.4 共享模块的全局变量问题
7.3.5 数据段地址无关性
7.4 延迟绑定(PLT)
7.5 动态链接相关结构
7.5.1 .interp 段
7.5.2 .dynamic 段
7.5.3 动态符号表
7.5.4 动态链接重定位表
7.5.5 动态链接时进程堆栈初始化信息
7.6 动态链接的步骤和实现
7.6.1 动态链接器自举
7.6.2 装载共享对象
全局符号介入
7.6.3 重定位和初始化
7.6.4 Linux 动态链接器实现
7.7 显式运行时链接
7.7.1 dlopen()
7.7.2 dlsym()
7.7.3 dlerror()
7.7.4 dlclose()
7.7.5 运行时装载的演示程序
7.8 本章小结
第 8 章 Linux 共享库的组织
8.1 共享库版本
8.1.1 共享库兼容性
8.1.2 共享库版本命名
8.1.3 SO-NAME
8.2 符号版本
8.2.1 基于符号的版本控制
8.2.2 Solaris 中的符号版本控制
8.2.3 Linux 中的符号版本
8.3 共享库系统路径
8.4 共享库查找过程
8.5 环境变量
8.6 共享库的创建和安装
8.6.1 共享库的创建
8.6.2 清除符号信息
8.6.3 共享库的安装
8.6.4 共享库构造和析构函数
8.6.5 共享库脚本
8.7 本章小结
第 9 章 Windows 下的动态链接
9.1 DLL 简介
9.1.1 进程地址空间和内存管理
9.1.2 基地址和 RVA
9.1.3 DLL 共享数据段
9.1.4 DLL 的简单例子
9.1.5 创建 DLL
9.1.6 使用 DLL
9.1.7 使用模块定义文件
9.1.8 DLL 显式运行时链接
9.2 符号导出导入表
9.2.1 导出表
9.2.2 EXP 文件
9.2.3 导出重定向
9.2.4 导入表
9.2.5 导入函数的调用
9.3 DLL 优化
9.3.1 重定基地址
9.3.2 序号
9.3.3 导入函数绑定
9.4 C++ 与动态链接
9.5 DLL HELL
9.6 本章小结
第 4 部分 库与运行库
第 10 章 内存
10.1 程序的内存布局
10.2 栈与调用惯例
10.2.1 什么是栈
10.2.2 调用惯例
10.2.3 函数返回值传递
10.3 堆与内存管理
10.3.1 什么是堆
10.3.2 Linux 进程堆管理
10.3.3 Windows 进程堆管理
10.3.4 堆分配算法
10.4 本章小结
第 11 章 运行库
11.1 入口函数和程序初始化
11.1.1 程序从 main 开始吗
11.1.2 入口函数如何实现
11.1.3 运行库与 I/O
11.1.4 MSVC CRT 的入口函数初始化
11.2 C/C++ 运行库
11.2.1 C 语言运行库
11.2.2 C 语言标准库
11.2.3 glibc 与 MSVC CRT
11.3 运行库与多线程
11.3.1 CRT 的多线程困扰
11.3.2 CRT 改进
11.3.3 线程局部存储实现
11.4 C++ 全局构造与析构
11.4.1 glibc全局构造与析构
11.4.2 MSVC CRT 的全局构造和析构
11.5 fread 实现
11.5.1 缓冲
11.5.2 fread_s
11.5.3 fread_nolock_s
11.5.4 _read
11.5.5 文本换行
11.5.6 fread 回顾
11.6 本章小结
第 12 章系统调用与 API
12.1 系统调用介绍
12.1.1 什么是系统调用
12.1.2 Linux 系统调用
12.1.3 系统调用的弊端
12.2 系统调用原理
12.2.1 特权级与中断
12.2.2 基于 int 的 Linux 的经典系统调用实现
12.2.3 Linux 的新型系统调用机制
12.3 Windows API
12.3.1 Windows API 概览
12.3.2 为什么要使用 Windows API
12.3.3 API 与子系统
12.4 本章小结
第 13 章 运行库实现
13.1 C 语言运行库
13.1.1 开始
13.1.2 堆的实现
13.1.3 IO 与文件操作
13.1.4 字符串相关操作
13.1.5 格式化字符串
13.2 如何使用 Mini CRT
13.3 C++ 运行库实现
13.3.1 new 与 delete
13.3.2 C++ 全局构造与析构
13.3.3 atexit 实现
13.3.4 入口函数修改
13.3.5 stream 与 string
13.4 如何使用 Mini CRT++
13.5 本章小结
附录 A
A.1 字节序(Byte Order)
A.2 ELF 常见段
A.3 常用开发工具命令行参考
A.3.1 gcc,GCC 编译器
A.3.2 ld,GNU 链接器
A.3.3 objdump,GNU 目标文件可执行文件查看器
A.3.4 cl,MSVC 编译器
A.3.5 link,MSVC 链接器
A.3.6 dumpbin,MSVC 的 COFF/PE 文件查看器
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了