库制作与原理 · 链接知识笔记
参考: 《深入理解计算机系统》 以下简称(CS:APP)第7章:链接
📖 目录
- 一、链接的基本概念
- 二、库的本质与分类
- 三、静态库的制作与使用
- 四、动态库(共享库)的制作与使用
- 五、动态库的加载问题
- [六、ELF 文件格式详解](#六、ELF 文件格式详解)
- [6.1 ELF 三种类型](#6.1 ELF 三种类型)
- [6.2 ELF Header](#6.2 ELF Header)
- [6.3 Section Header Table(节头表)](#6.3 Section Header Table(节头表))
- [6.4 Program Header Table(段头表)](#6.4 Program Header Table(段头表))
- 七、链接的完整过程
- [7.1 符号解析(Symbol Resolution)](#7.1 符号解析(Symbol Resolution))
- [7.2 重定位(Relocation)](#7.2 重定位(Relocation))
- 八、程序加载与动态链接
- [8.1 加载器如何工作](#8.1 加载器如何工作)
- [8.2 GOT 与 PLT 机制](#8.2 GOT 与 PLT 机制)
- 九、第三方库使用示例(ncurses)
- 十、常用工具命令速查表
一、链接的基本概念
什么是链接
链接(Linking) 是将各种代码和数据片段收集并组合成一个单一文件的过程。这个文件可以加载到内存中执行。
链接在编译的最后一步 执行,由 链接器(Linker, ld) 完成。
为什么需要链接
- 模块化:程序拆分成多个源文件,各自独立编译、维护
- 效率:修改一个文件只需重新编译该文件,再链接即可(增量构建)
- 复用:通过库(Library)复用标准函数(printf、malloc 等)
编译器驱动程序的工作流程
以 gcc main.c code.c -o prog 为例,完整流程如下:
main.c ──→ [预处理器 cpp] ──→ main.i ──→ [编译器 ccl] ──→ main.s ──→ [汇编器 as] ──→ main.o
code.c ──→ [预处理器 cpp] ──→ code.i ──→ [编译器 ccl] ──→ code.s ──→ [汇编器 as] ──→ code.o
│
▼
[链接器 ld] ──→ prog(可执行文件)
各阶段说明:
| 阶段 | 工具 | 输入 → 输出 | 工作内容 |
|---|---|---|---|
| 预处理 | cpp |
.c → .i |
头文件展开、宏替换、条件编译 |
| 编译 | ccl |
.i → .s |
语法分析、生成汇编代码 |
| 汇编 | as |
.s → .o |
汇编代码转机器指令(生成可重定位目标文件) |
| 链接 | ld |
.o → 可执行文件 |
符号解析、重定位 |
"语言→编译→汇编→链接(Windows 的 IDE 通常集成这些步骤,Linux 下 gcc 默认一站式完成,也可分步执行
gcc -E、gcc -S、gcc -c)"
二、库的本质与分类
库的本质
库本质上就是一组预编译好的目标文件(.o)的集合,被封装成一个文件,方便链接器使用。
两种库的对比
| 特性 | 静态库(Static Library) | 动态库 / 共享库(Shared Library) |
|---|---|---|
| Linux 后缀 | .a(archive) |
.so(shared object) |
| Windows 后缀 | .lib |
.dll |
| 链接时机 | 编译时(链接阶段) | 加载时或运行时 |
| 代码归属 | 复制到可执行文件中 | 在内存中共享一份 |
| 可执行文件大小 | 大 | 小 |
| 更新库 | 需重新链接 | 替换 .so 即可(需兼容 ABI) |
| 内存占用(多进程) | 每个进程各有一份 | 物理内存仅一份,共享使用 |
| 运行依赖 | 无(独立运行) | 需要系统中有对应 .so |
系统库的常见位置
bash
# Ubuntu / Debian
$ ls -l /lib/x86_64-linux-gnu/libc-2.31.so # 动态库(C)
$ ls -l /lib/x86_64-linux-gnu/libc.a # 静态库(C)
$ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so # C++ 动态库
$ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a # C++ 静态库
# CentOS / RHEL
$ ls /lib64/libc-2.17.so -l
$ ls /lib64/libc.a -l
三、静态库的制作与使用
3.1 制作静态库
利用 ar(archiver)命令将多个 .o 文件打包成 .a 文件:
bash
$ ar -rc libmystdio.a my_stdio.o my_string.o
ar 参数说明:
r:replace,将目标文件插入库中(替换同名文件)c:create,创建库t:列出库中包含的目标文件v:verbose,显示详细信息
查看静态库内容:
bash
$ ar -tv libmystdio.a
rw-rw-r-- 1000/1000 2848 Oct 29 14:35 2024 my_stdio.o
rw-rw-r-- 1000/1000 1272 Oct 29 14:35 2024 my_string.o
3.2 静态库的 Makefile 示例
makefile
# 完整的生产级 Makefile
libmystdio.a:my_stdio.o my_string.o
@ar -rc $@ $^
@echo "build $^ to $@ ... done"
%.o:%.c
@gcc -c $<
@echo "compling $< to $@ ... done"
.PHONY:clean
clean:
@rm -rf *.a *.o stdc*
@echo "clean ... done"
.PHONY:output
output:
@mkdir -p stdc/include
@mkdir -p stdc/lib
@cp -f *.h stdc/include
@cp -f *.a stdc/lib
@tar -czf stdc.tgz stdc
@echo "output stdc ... done"
3.3 使用静态库
bash
# 方式1:直接指定路径
$ gcc main.c libmystdio.a
# 方式2:通过 -L 指定库路径,-l 指定库名(去掉 lib 前缀和 .a 后缀)
$ gcc main.c -L. -lmystdio
# 方式3:完整指定头文件和库路径
$ gcc main.c -I ./include -L ./lib -lmystdio
参数说明:
-I:指定头文件搜索路径(找.h)-L:指定库文件搜索路径(找.a/.so)-l:指定库名-static:强制使用静态链接(即使有 .so 也优先用 .a)
CS:APP 补充 :链接器在符号解析时,会从左到右扫描命令行中的输入文件。对于每个未解析的引用 ,去后面的库中查找定义。因此 -l 库应放在调用它的目标文件之后。
四、动态库(共享库)的制作与使用
4.1 制作动态库
bash
# 编译时需加 -fPIC(位置无关代码),链接时加 -shared
$ gcc -fPIC -c my_stdio.c
$ gcc -fPIC -c my_string.c
$ gcc -shared -o libmystdio.so my_stdio.o my_string.o
Makefile 示例:
makefile
libmystdio.so:my_stdio.o my_string.o
gcc -o $@ $^ -shared
%.o:%.c
gcc -fPIC -c $<
.PHONY:clean
clean:
@rm -rf *.so *.o stdc*
.PHONY:output
output:
@mkdir -p stdc/include
@mkdir -p stdc/lib
@cp -f *.h stdc/include
@cp -f *.so stdc/lib
@tar -czf stdc.tgz stdc
4.2 关键概念:-fPIC
PIC(Position Independent Code,位置无关代码) 是动态库的核心技术。
为什么需要 PIC?
- 动态库在加载时被映射到进程的虚拟地址空间中
- 如果多个进程共用同一个 .so,它们在各自地址空间中的加载地址可能不同
- PIC 代码不依赖绝对地址,通过 GOT(Global Offset Table) 和 PC-relative 寻址,无论加载到哪个地址都能正确执行
CS:APP 补充 :PIC 代码的编译和链接过程与非 PIC 代码相同,只是在编译时需要加
-fPIC标志。链接时,链接器会自动将 PIC 代码合并到最终的可执行文件中。
4.3 使用动态库
bash
# 编译链接(与静态库命令相同,但优先链接 .so)
$ gcc main.c -L. -lmystdio
# 查看生成的 a.out 依赖哪些动态库
$ ldd a.out
linux-vdso.so.1 => (0x00007fff...)
libmystdio.so => not found
libc.so.6 => /lib64/libc.so.6 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)
五、动态库的加载问题
5.1 "not found" 错误的根源
编译链接时,链接器只在 .o 文件中记录 .so 的名字 (通过 .dynamic 段),不嵌入路径 。运行时,动态链接器 /lib64/ld-linux-x86-64.so.2 需要找到该 .so 文件。
5.2 解决方案(三种方法)
| 方法 | 操作 | 适用范围 | 持久性 |
|---|---|---|---|
| 环境变量 | export LD_LIBRARY_PATH=. |
当前 shell 会话 | 临时 |
| 配置缓存 | 编辑 /etc/ld.so.conf.d/*.conf 后运行 ldconfig |
全系统 | 永久 |
| 系统目录 | 将 .so 复制到 /usr/lib 或 /lib64 |
全系统 | 永久 |
bash
# 方法1:LD_LIBRARY_PATH(临时)
$ export LD_LIBRARY_PATH=.
$ ./a.out
# 方法2:ldconfig(永久)
$ cat /etc/ld.so.conf.d/bit.conf
/root/tools/linux
$ ldconfig # 刷新缓存 /etc/ld.so.cache
# 方法3:拷贝到系统目录
$ sudo cp libmystdio.so /usr/lib/
CS:APP 补充 :动态链接器(
ld-linux.so)本身也是一个共享库,它通过读取可执行文件中的.interp段知道自己的路径。它的工作流程是:加载器加载程序 → 读取 .interp → 启动动态链接器 → 加载所有依赖的 .so → 执行重定位 → 将控制权交给程序。
5.3 ldd 命令详解
ldd 用于查看一个可执行文件或共享库依赖哪些动态库:
bash
$ ldd /usr/bin/ls
linux-vdso.so.1 (0x00007fff...)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2
/lib64/ld-linux-x86-64.so.2
六、ELF 文件格式详解
6.1 ELF 三种类型
ELF(Executable and Linkable Format) 是 Linux 下目标文件的标准格式。CS:APP 将目标文件分为三类:
| 类型 | 后缀 | 说明 | readelf -h 中的 Type |
|---|---|---|---|
| 可重定位目标文件 (Relocatable) | .o |
编译器和汇编器产出,尚未链接 | REL (Relocatable file) |
| 可执行目标文件 (Executable) | (无/.out) |
可直接加载运行 | EXEC (Executable file)(现代 gcc 显示为 DYN) |
| 共享目标文件 (Shared Object) | .so |
动态库,可加载时链接或运行时动态加载 | DYN (Shared object file) |
bash
# 查看 .o 文件的 ELF 类型
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
6.2 ELF Header
ELF 文件最开头是 ELF 头,包含文件的基本元信息。通过 readelf -h 查看:
bash
$ readelf -h hello.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 # ELF魔数
Class: ELF64 # 64位
Data: 2's complement, little endian # 小端
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file) # 可重定位文件
Machine: Advanced Micro Devices X86-64
Entry point address: 0x0 # .o没有入口点
Start of program headers: 0 (bytes into file) # .o没有程序头
Start of section headers: 728 (bytes into file)
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 12
可执行文件 vs 可重定位文件的 ELF 头对比:
| 字段 | hello.o(可重定位) |
a.out(可执行) |
|---|---|---|
| Type | REL | DYN(Shared object file) |
| Entry point | 0x0 | 0x1060(有入口地址) |
| Program headers | 0 个 | 13 个(LOAD、DYNAMIC 等) |
| Section headers | 13 个 | 31 个 |
📄 PDF 第7-1节显示了 ELF Header 的完整结构示意图。
6.3 Section Header Table(节头表)
readelf -S 查看可执行文件的所有节(Section)。
bash
$ readelf -S a.out
There are 31 section headers, starting at offset 0x19d8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000400238 00000238
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.ABI-tag NOTE 0000000000400254 00000254
...
[16] .text PROGBITS 0000000000400640 00000640
00000000000004b2 0000000000000000 AX 0 0 16
[25] .data PROGBITS 0000000000601060 00001060
0000000000000004 0000000000000000 WA 0 0 1
[26] .bss NOBITS 0000000000601064 00001064
0000000000000004 0000000000000000 WA 0 0 1
...
关键 Section 一览:
| Section | 全称 | 说明 | 权限 |
|---|---|---|---|
.text |
代码段 | 已编译的机器指令 | AX(可分配、可执行) |
.data |
数据段 | 已初始化的全局/静态变量 | WA(可写、可分配) |
.bss |
Block Started by Symbol | 未初始化的全局/静态变量(不占文件空间) | WA |
.rodata |
只读数据段 | 字符串常量、const 变量等 |
A(只读) |
.symtab |
Symbol Table | 符号表(函数名、全局变量名) | --- |
.debug |
调试信息 | -g 编译生成的调试符号 |
--- |
.line |
行号表 | 源代码行号与机器指令的映射 | --- |
.strtab |
String Table | 符号表中符号名的字符串数据 | --- |
.init / .fini |
初始化/终止代码 | 构造/析构函数代码 | AX |
.got |
Global Offset Table | 全局偏移表(动态链接用) | WA |
.got.plt |
GOT for PLT | PLT 使用的 GOT | WA |
.plt |
Procedure Linkage Table | 过程链接表 | AX |
.dynamic |
动态链接信息 | 动态链接器需要的元数据 | WA |
.interp |
Interpreter | 动态链接器路径(如 /lib64/ld-linux-x86-64.so.2) |
A |
🔑 CS:APP 关键点 :
.bss段在目标文件中不占用磁盘空间 ,它是一个占位符。在加载时,加载器为其分配内存并初始化为零。这就是为什么声明int arr[10000];不会让 .o 文件变大的原因。
6.4 Program Header Table(段头表)
readelf -l 查看 Program Header(也称 Segment Header):
bash
$ readelf -l a.out
Elf file type is EXEC (Executable file)
Entry point 0x4003e0
There are 9 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040
0x00000000000001f8 0x00000000000001f8 R E 8
INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238
0x000000000000001c 0x000000000000001c R 1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x0000000000000744 0x0000000000000744 R E 200000
LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x0000000000000218 0x0000000000000220 RW 200000
DYNAMIC 0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
0x00000000000001d0 0x00000000000001d0 RW 8
NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254
0x0000000000000044 0x0000000000000044 R 4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 10
GNU_RELRO 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x00000000000001f0 0x00000000000001f0 R 1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym ...
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
...
核心概念:Section vs Segment
| Section(节) | Segment(段) | |
|---|---|---|
| 视角 | 链接视图(Linking View) | 执行视图(Execution View) |
| 用途 | 链接器处理(合并符号、重定位) | 加载器加载到内存(mmap) |
| 头表 | Section Header Table | Program Header Table |
| 存在文件 | .o 和可执行文件都有 |
仅可执行文件和 .so 有 |
| 粒度 | 细(.text、.data、.bss...) | 粗(多个 section 合并为 1 个 segment) |
为什么 Section 要合并成 Segment?
- 每个 Segment 的加载单元是 页(4KB) ,如果
.text占 4097 字节,.init占 512 字节,分开映射就需要3个页(浪费 12KB - 多出来的页实际用不上) - 合并后,内容相近的 Section 在同一个 Segment 里,共享权限(如
.init + .text + .rodata合并为 R E 段,.data + .bss + .got合并为 RW 段)
6.5 ELF 相关 C 结构体定义
来自 Linux 内核 <elf.h>:
c
// ELF Header (64位)
typedef struct elf64_hdr {
unsigned char e_ident[16]; /* ELF魔数,0x7f 'E' 'L' 'F' */
Elf64_Half e_type; /* 类型: REL/EXEC/DYN */
Elf64_Half e_machine; /* 架构: x86-64 */
Elf64_Word e_version;
Elf64_Addr e_entry; /* 入口点虚拟地址 */
Elf64_Off e_phoff; /* Program header table 文件偏移 */
Elf64_Off e_shoff; /* Section header table 文件偏移 */
Elf64_Word e_flags;
Elf64_Half e_ehsize; /* ELF头大小 */
Elf64_Half e_phentsize; /* 每个 Program header 的大小 */
Elf64_Half e_phnum; /* Program header 的数量 */
Elf64_Half e_shentsize; /* 每个 Section header 的大小 */
Elf64_Half e_shnum; /* Section header 的数量 */
Elf64_Half e_shstrndx; /* Section name string table 的索引 */
} Elf64_Ehdr;
// Program Header (64位)
typedef struct elf64_phdr {
Elf64_Word p_type; /* 类型: LOAD/DYNAMIC/INTERP... */
Elf64_Word p_flags; /* 权限: R/RW/RX */
Elf64_Off p_offset; /* 在文件中的偏移 */
Elf64_Addr p_vaddr; /* 虚拟地址 */
Elf64_Addr p_paddr; /* 物理地址(大多数平台忽略) */
Elf64_Xword p_filesz; /* 文件中的大小 */
Elf64_Xword p_memsz; /* 内存中的大小(>= filesz) */
Elf64_Xword p_align; /* 对齐要求 */
} Elf64_Phdr;
// Section Header (64位)
typedef struct elf64_shdr {
Elf64_Word sh_name; /* Section名(在 .shstrtab 中的索引) */
Elf64_Word sh_type; /* 类型 */
Elf64_Xword sh_flags; /* 属性 */
Elf64_Addr sh_addr; /* 虚拟地址 */
Elf64_Off sh_offset; /* 文件偏移 */
Elf64_Xword sh_size; /* 大小 */
Elf64_Word sh_link; /* 链接到其他 section 的索引 */
Elf64_Word sh_info; /* 附加信息 */
Elf64_Xword sh_addralign; /* 对齐 */
Elf64_Xword sh_entsize; /* 如果包含表,每个条目的大小 */
} Elf64_Shdr;
七、链接的完整过程
链接分为两步:符号解析(Symbol Resolution) → 重定位(Relocation)。
7.1 符号解析(Symbol Resolution)
符号表
readelf -s 查看目标文件的符号表:
bash
# hello.o 的符号表
$ readelf -s hello.o
Symbol table '.symtab' contains 14 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
...
10: 0000000000000000 37 FUNC GLOBAL DEFAULT 1 main # 已定义
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts # 未定义
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND run # 未定义
# code.o 的符号表
$ readelf -s code.o
Symbol table '.symtab' contains 13 entries:
Num: Value Size Type Bind Vis Ndx Name
10: 0000000000000000 23 FUNC GLOBAL DEFAULT 1 run # 已定义
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts # 未定义
符号类型:
| 符号类型 | 说明 |
|---|---|
| GLOBAL 定义 | 本模块定义的全局符号(函数、全局变量),可被其他模块引用 |
| GLOBAL 引用 (UND) | 本模块引用、但在其他模块定义的符号(外部符号) |
| LOCAL | 本模块私有的符号(static 函数/变量),不可被其他模块引用 |
链接器如何解析符号
hello.o 定义了: main
hello.o 引用(UND): puts, run
code.o 定义了: run
code.o 引用(UND): puts
链接器扫描所有 .o 文件:
- 维护一个未解析符号表
- 扫描到
hello.o→ 记录main已定义,记录puts,run未解析 - 扫描到
code.o→ 记录run已定义,run解析成功 - 最后
puts仍未解析 → 去libc中查找 → 在libc.so中找到定义 → 解析成功
如果最终仍有未解析符号 → 链接错误(undefined reference)。
链接前后反汇编对比
链接前(hello.o) :所有外部调用的目标地址都是 0x00000000(占位)
asm
# hello.o 反汇编
0000000000000000 <main>:
0: 55 push %rbp
...
f: e8 00 00 00 00 callq 14 <main+0x14> # 调 printf(地址待定)
19: e8 00 00 00 00 callq 1e <main+0x1e> # 调 run(地址待定)
链接后(a.out):外部调用的地址被填为实际地址
asm
# a.out 反汇编
0000000000001149 <run>:
1149: 55 push %rbp
1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi # puts的参数
1158: e8 f3 fe ff ff callq 1050 <puts@plt> # 通过 PLT 调 puts
0000000000001160 <main>:
1160: 55 push %rbp
1168: 48 8d 3d a0 0e 00 00 lea 0xea0(%rip),%rdi
116f: e8 dc fe ff ff callq 1050 <puts@plt> # 调 puts(通过 PLT)
1179: e8 cb ff ff ff callq 1149 <run> # 调 run(已解析)
7.2 重定位(Relocation)
链接器完成符号解析后,执行重定位:
-
合并相同的 Section :将所有
.o的.text合并为一个.text,.data合并为一个.data -
为每个 Section 分配运行时地址:确定最终虚拟地址
-
修改符号引用:根据重定位条目,将占位地址修改为实际地址
链接过程可视化:
hello.o ──.text──┐ ┌── .text (0x400640)
├── 合并 ──────→├── .rodata (0x400b00)
code.o ──.text──┘ ├── .data (0x601060)
└── ...其他 section
八、程序加载与动态链接
8.1 加载器如何工作
当用户在 shell 中输入命令执行程序时:
shell → fork() → execve() → 加载器(loader) → 加载 ELF 到内存 → 启动
加载过程详述:
- Shell 调用
execve:传入可执行文件名、参数列表、环境变量 - 加载器读取 ELF Header:解析 Entry point、Program Header Table
- 根据 Program Header 映射 Segment 到内存 :
- 每个
LOAD类型的 Segment 调用mmap()映射到进程地址空间 .textSegment 映射到代码区(R E).data+.bssSegment 映射到数据区(RW)
- 每个
- 读取
.interp段 :找到动态链接器路径(/lib64/ld-linux-x86-64.so.2) - 启动动态链接器 :内核把控制权交给
ld-linux.so - 动态链接器执行 :
- 加载所有依赖的共享库(递归的)
- 执行重定位(重定位 GOT 中的条目)
- 执行
.init段中的初始化代码
- 跳转到 Entry point (
_start→__libc_start_main→main)
进程虚拟地址空间布局:
0x00000000 ┌─────────────────────┐
│ 内核空间 │ (用户态不可见)
0x40000000 ├─────────────────────┤
│ 代码段 (.text) │ R E(只读可执行)
│ 只读数据 (.rodata) │ R(只读)
│ 数据段 (.data) │ RW(可读写)
│ BSS 段 (.bss) │ RW(初始为0)
│ 堆 (heap) │
│ ↓ │
│ ... 空闲 ... │
│ ↑ │
│ 共享库映射 │ (libc.so 等)
│ ↑ │
│ 栈 (stack) │ RW(向下增长)
0x7fffffff └─────────────────────┘
CS:APP 补充 :C/C++ 程序的
main函数并非真正"第一个"执行的函数。ELF 的 Entry point 是_start,由汇编编写(位于crt0.o/crt1.o中),它调用__libc_start_main,后者再调用main。main返回后,__libc_start_main会调用exit()执行清理。
详细启动链:
_start (Entry point)
└─→ __libc_start_main(main, argc, argv, __libc_csu_init, __libc_csu_fini, ...)
├─→ __libc_csu_init() → 执行全局构造函数 (.init_array)
└─→ main(argc, argv)
└─→ return
└─→ _exit() → 系统调用退出
8.2 GOT 与 PLT 机制
这是动态链接最核心的机制。为什么需要 GOT 和 PLT?
- 动态库(.so)的代码在编译时不知道会被加载到哪个地址
- 为了实现位置无关(PIC),对全局变量 的访问通过 GOT(Global Offset Table)
- 对函数调用 的访问通过 PLT(Procedure Linkage Table) + GOT
GOT(Global Offset Table)
GOT 是一个位于 .got / .got.plt 段中的数据表,每个全局符号(函数/变量)在 GOT 中有一个条目,存储该符号的实际地址。
.got.plt 段布局:
GOT[0] = .dynamic 段地址
GOT[1] = link_map(动态链接器内部数据)
GOT[2] = dl_resolve(动态链接器的符号解析入口)
GOT[3] = &puts ← 初始指向 PLT 中的 push/resolve 指令
GOT[4] = &printf ← 初始指向 PLT 中的 push/resolve 指令
...
PLT(Procedure Linkage Table)
PLT 是代码段(.plt 段)中的一组存根(stub)代码,每个外部函数有一个条目。
第一次调用时(延迟绑定 / Lazy Binding):
main 调用 puts:
└─→ call <puts@plt> ← 跳转到 PLT 条目
└─→ jmp *GOT[puts] ← 此时 GOT[puts] 指向 PLT 中的下一行
└─→ push 索引号 ← 将 puts 在符号表中的索引压栈
└─→ jmp 公共PLT条目
└─→ 调用动态链接器 _dl_runtime_resolve
└─→ 找到 puts 在 libc.so 中的地址
└─→ 写入 GOT[puts]
└─→ 跳转到 puts 函数执行
第二次及以后调用:
main 调用 puts:
└─→ call <puts@plt> ← 同前
└─→ jmp *GOT[puts] ← GOT[puts] 已指向 puts 的实际地址!
└─→ 直接跳转到 puts 函数执行 ← **不再经过解析过程**
这就是延迟绑定的威力:只有被调用的函数才被解析,不被调用的函数根本不解析,节省了启动时间。
查看 GOT 和 PLT
bash
# 查看 .got.plt 段
$ readelf -S a.out
[24] .got.plt PROGBITS 0000000000601000 00001000
0000000000000060 0000000000000008 WA 0 0 8
# 反汇编查看 PLT
$ objdump -d a.out
...
0000000000400590 <.plt>:
400590: ff 35 72 0a 20 00 pushq 0x200a72(%rip) # GOT[1]
400596: ff 25 74 0a 20 00 jmpq *0x200a74(%rip) # GOT[2]
40059c: 0f 1f 40 00 nopl 0x0(%rax)
00000000004005a0 <puts@plt>:
4005a0: ff 25 72 0a 20 00 jmpq *0x200a72(%rip) # GOT[3]
4005a6: 68 00 00 00 00 pushq $0x0 # 索引0
4005ab: e9 e0 ff ff ff jmpq 400590 <.plt> # 回到公共条目
九、第三方库使用示例(ncurses)
PDF 第4节以 ncurses 为例展示了安装和使用第三方库的过程。
安装
bash
# CentOS
$ sudo yum install -y ncurses-devel
# Ubuntu
$ sudo apt install -y libncurses-dev
编译链接
bash
$ gcc progress.c -lncurses
进度条示例程序
c
#include <stdio.h>
#include <string.h>
#include <ncurses.h>
#include <unistd.h>
#define PROGRESS_BAR_WIDTH 30
#define BORDER_PADDING 2
#define WINDOW_WIDTH (PROGRESS_BAR_WIDTH + 2 * BORDER_PADDING + 2)
#define WINDOW_HEIGHT 5
#define PROGRESS_INCREMENT 3
#define DELAY 300000 // 300ms
int main() {
initscr();
start_color();
init_pair(1, COLOR_GREEN, COLOR_BLACK);
init_pair(2, COLOR_RED, COLOR_BLACK);
cbreak();
noecho();
curs_set(FALSE);
int max_y, max_x;
getmaxyx(stdscr, max_y, max_x);
int start_y = (max_y - WINDOW_HEIGHT) / 2;
int start_x = (max_x - WINDOW_WIDTH) / 2;
WINDOW *win = newwin(WINDOW_HEIGHT, WINDOW_WIDTH, start_y, start_x);
box(win, 0, 0);
wrefresh(win);
int progress = 0;
int max_progress = PROGRESS_BAR_WIDTH;
while (progress <= max_progress) {
werase(win);
int completed = progress;
int remaining = max_progress - progress;
int bar_x = BORDER_PADDING + 1;
int bar_y = 1;
// 已完成部分(绿色)
attron(COLOR_PAIR(1));
for (int i = 0; i < completed; i++)
mvwprintw(win, bar_y, bar_x + i, "#");
attroff(COLOR_PAIR(1));
// 未完成部分(红色)
attron(A_BOLD | COLOR_PAIR(2));
for (int i = completed; i < max_progress; i++)
mvwprintw(win, bar_y, bar_x + i, " ");
attroff(A_BOLD | COLOR_PAIR(2));
// 百分比
char percent_str[10];
snprintf(percent_str, sizeof(percent_str), "%d%%",
(progress * 100) / max_progress);
int percent_x = (WINDOW_WIDTH - strlen(percent_str)) / 2;
mvwprintw(win, WINDOW_HEIGHT - 1, percent_x, percent_str);
wrefresh(win);
progress += PROGRESS_INCREMENT;
usleep(DELAY);
}
delwin(win);
endwin();
return 0;
}
该程序原理解析:
ncurses库提供了终端图形界面的封装,通过initscr()初始化终端窗口,newwin()创建子窗口,box()绘制边框,mvwprintw()在指定位置输出文本,可实现各种终端 UI 效果。编译时通过-lncurses链接该库。
十、常用工具命令速查表
文件查看
| 命令 | 用途 | 示例 |
|---|---|---|
file |
查看文件类型(是否为 ELF) | file a.out |
size |
查看各段大小 | size a.out |
strings |
提取文件中的可打印字符串 | `strings a.out |
du -sh |
查看文件磁盘占用 | du -sh *.o |
ELF 分析
| 命令 | 用途 | 示例 |
|---|---|---|
readelf -h |
查看 ELF Header | readelf -h a.out |
readelf -S |
查看 Section Header Table | readelf -S a.out |
readelf -l |
查看 Program Header Table | readelf -l a.out |
readelf -s |
查看符号表 | readelf -s a.out |
readelf -r |
查看重定位条目 | readelf -r hello.o |
readelf -d |
查看 .dynamic 段 | readelf -d a.out |
objdump -d |
反汇编代码段 | objdump -d a.out |
objdump -S |
反汇编并混编源码 | objdump -S a.out |
objdump -h |
查看 Section 摘要 | objdump -h a.out |
objdump -t |
查看符号表 | objdump -t a.out |
库管理
| 命令 | 用途 | 示例 |
|---|---|---|
ar -rc |
创建静态库 | ar -rc libxxx.a a.o b.o |
ar -tv |
查看静态库内容 | ar -tv libxxx.a |
ar -x |
解压静态库 | ar -x libxxx.a |
ldd |
查看动态库依赖 | ldd a.out |
ldconfig |
刷新动态库缓存 | sudo ldconfig |
nm |
查看目标文件符号 | nm a.out |
调试与运行时
| 命令 | 用途 | 示例 |
|---|---|---|
strace |
追踪系统调用(看 mmap 加载段) | `strace ./a.out 2>&1 |
gdb |
调试器,可检查 GOT 表 | gdb a.out 内 x/8gx &_GLOBAL_OFFSET_TABLE_ |
gcc -v |
查看编译链接完整过程 | gcc -v main.c |
gcc -Wl,--verbose |
查看链接器详细输出 | gcc -Wl,--verbose main.c |
📚 参考资源
- 《深入理解计算机系统》(CS:APP)第3版,第7章:链接
- Linux 内核源码
<include/linux/elf.h>--- ELF 结构体定义 - GCC 官方文档 :关于
-fPIC、-shared、-static等选项的详细说明 man ld/man ld.so--- 链接器和动态链接器的行为说明man gcc--- GCC 编译器选项大全