什么是库?
库的本质
库(Library)是写好的、成熟的、可复用的代码的二进制形式。本质上,库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。
试想一下,如果每次编写程序都需要从零开始实现printf、scanf这些基础功能,那开发效率将何其低下。库的出现,让我们能够站在巨人的肩膀上编程。
库的两种类型
| 类型 | Linux | Windows | 特点 |
|---|---|---|---|
| 静态库 | .a |
.lib |
编译时链接到可执行文件,运行时不再需要 |
| 动态库 | .so |
.dll |
运行时才加载,多个程序可共享 |
在Linux系统中,我们可以通过以下命令查看系统中的C/C++库:
# 查看C动态库
ls -l /lib/x86_64-linux-gnu/libc-2.31.so
# 查看C静态库
ls -l /lib/x86_64-linux-gnu/libc.a
# 查看C++动态库
ls -l /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so
# 查看C++静态库
ls -l /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
静态库的制作与使用
制作静态库
静态库的制作需要两个步骤:
-
将源文件编译成目标文件(
.o) -
使用
ar归档工具将目标文件打包成静态库
Makefile
libmystdio.a: my_stdio.o my_string.o
@ar -rc $@ $^
@echo "build $@ to $^...done"
%.o: %.c
@gcc -c $<
@echo "compiling $< 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"
ar命令说明 :-r表示替换(replace),-c表示创建(create)。合起来-rc表示"创建或替换归档文件中的成员"。
查看静态库内容:
# 列出静态库中的文件
ar -tv libmystdio.a
# 输出:
# rw-rw-r-- 1000/1000 2848 Oct 29 14:35 my_stdio.o
# rw-rw-r-- 1000/1000 1272 Oct 29 14:35 my_string.o
使用静态库
// main.c
#include "my_stdio.h"
#include "my_string.h"
#include <stdio.h>
int main() {
const char *s = "abcdefg";
printf("%s: %d\n", s, my_strlen(s));
mFILE *fp = mfopen("/log.txt", "a");
if(fp == NULL) return 1;
mfwrite(s, my_strlen(s), fp);
mfwrite(s, my_strlen(s), fp);
mfwrite(s, my_strlen(s), fp);
mfclose(fp);
return 0;
}
三种使用场景:
# 场景1:头文件和库文件安装到系统路径
gcc main.c -lmystdio
# 场景2:头文件和库文件在当前目录
gcc main.c -L. -lmystdio
# 场景3:头文件和库文件在独立路径
gcc main.c -I头文件路径 -L库文件路径 -lmystdio
编译选项说明:
-
-I:指定头文件搜索路径 -
-L:指定库文件搜索路径 -
-l:指定库名(去掉lib前缀和.a/.so后缀)
静态库的特点:程序在编译链接时就把库的代码复制到可执行文件中,程序运行时不再需要静态库。删除静态库后程序依然可以运行。
动态库的制作与使用
制作动态库
动态库的制作需要两个关键点:
-
-shared:生成共享库格式 -
-fPIC:生成位置无关代码(Position Independent Code)
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*
@echo "clean ... done"
.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
@echo "output stdc ... done"
使用动态库
使用方法与静态库类似:
# 场景1:安装到系统路径
gcc main.c -lmystdio
# 场景2:当前目录
gcc main.c -L. -lmystdio
# 场景3:独立路径
gcc main.c -I头文件路径 -L库文件路径 -lmystdio
动态库的运行时搜索问题
编译成功后,运行程序可能会遇到问题:
$ ./a.out
./a.out: error while loading shared libraries: libmystdio.so: cannot open shared object file: No such file or directory
$ ldd a.out
linux-vdso.so.1 => (0x00007fff4d396000)
libmystdio.so => not found
libc.so.6 => /lib64/libc.so.6 (0x00007fa2aef30000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa2af2fe000)
解决方案:
拷贝到系统库路径 :/usr/lib、/usr/local/lib、/lib64
创建软链接到系统库路径
设置环境变量 :LD_LIBRARY_PATH
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.
配置ldconfig:
# 编辑配置文件
cat /etc/ld.so.conf.d/bit.conf
/root/tools/linux
# 重新加载配置
ldconfig
深入理解ELF文件格式
什么是ELF?
ELF(Executable and Linkable Format)是Linux系统下可执行文件、目标文件和共享库的标准文件格式。有以下四种类型:
| 类型 | 扩展名 | 说明 |
|---|---|---|
| 可重定位文件 | .o |
链接时使用的目标文件 |
| 可执行文件 | 无/.out |
可运行的程序 |
| 共享目标文件 | .so |
动态库 |
| 核心转储文件 | core dump | 进程崩溃时的内存快照 |
ELF文件结构
一个ELF文件由以下四部分组成:
+------------------+
| ELF Header | ← 文件开始,描述文件主要特性
+------------------+
| Program Headers | ← 告诉操作系统如何加载(执行视图)
| (Segment Header) |
+------------------+
| |
| Sections/ | ← 实际数据
| Segments |
| |
+------------------+
| Section Headers | ← 描述各个节的信息(链接视图)
+------------------+
两种视图:链接视图与执行视图
ELF文件提供了两种不同的视角来理解文件结构:
链接视图(Linking View) :对应节头表(Section Header Table)
-
粒度更细,按功能模块划分
-
链接器关注的是链接视图
-
包含
.text、.data、.bss、.rodata等节
执行视图(Execution View) :对应程序头表(Program Header Table)
-
告诉操作系统如何加载可执行文件
-
将具有相同属性的节合并成段(Segment)
-
例如:所有可读可执行的节合并为一个LOAD段
常见的节(Section)
| 节名 | 说明 |
|---|---|
.text |
代码节,存放机器指令 |
.data |
数据节,存放已初始化的全局变量和静态变量 |
.bss |
存放未初始化的全局变量和静态变量(不占磁盘空间) |
.rodata |
只读数据节,如字符串常量 |
.symtab |
符号表,记录函数名、变量名与代码的对应关系 |
.got |
全局偏移表(Global Offset Table) |
.plt |
过程链接表(Procedure Linkage Table) |
查看ELF文件信息
查看ELF头信息:
readelf -h hello.o
输出示例:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 728 (bytes into file)
查看节头表:
readelf -S a.out
查看程序头表(段信息):
readelf -l a.out
输出示例:
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align
LOAD 0x000000 0x400000 0x400000 0x000d24 0x000d24 R E 200000
LOAD 0x000e10 0x600e10 0x600e10 0x000254 0x000258 RW 200000
DYNAMIC 0x000e28 0x600e28 0x600e28 0x0001d0 0x0001d0 RW 8
查看符号表:
readelf -s hello.o
为什么要把Section合并成Segment?
主要原因是为了减少内存碎片,提高内存使用效率。
假设页面大小为4096字节:
-
如果
.text为4097字节,.init为512字节 -
不合并:占用3个页面(4096 + 4096 + 512)
-
合并后:只需2个页面(4096 + 4096)
此外,将相同属性的节合并成一个段,可以统一设置访问权限(可读、可写、可执行)。
程序编译与链接的完整过程
编译过程回顾
# 源代码 → 编译 → 目标文件
gcc -c hello.c # 生成 hello.o
gcc -c code.c # 生成 code.o
# 链接 → 可执行文件
gcc hello.o code.o -o main.exe
目标文件中的地址占位
使用objdump -d反汇编目标文件:
objdump -d hello.o
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi
f: e8 00 00 00 00 callq 14 <main+0x14> # 调用printf,地址为0
14: b8 00 00 00 00 mov $0x0,%eax
19: e8 00 00 00 00 callq 1e <main+0x1e> # 调用run,地址为0
1e: b8 00 00 00 00 mov $0x0,%eax
23: 5d pop %rbp
24: c3 retq
注意到callq指令后面的地址都是0x00000000,这是因为编译器不知道printf和run函数在内存中的位置。这些地址会在链接时被修正。
符号表与未定义符号
查看目标文件的符号表:
readelf -s hello.o
Symbol table '.symtab' contains 14 entries:
Num: Value Size Type Bind Vis Ndx Name
10: 0000000000000000 37 FUNC GLOBAL DEFAULT 1 main
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND run
-
UND表示未定义(Undefined),即在本目标文件中找不到该符号 -
puts是printf的实现,来自C标准库 -
run定义在code.c中
链接后的地址修正
链接器会将多个目标文件合并,并修正所有外部符号的地址:
objdump -d main.exe | grep -A5 -B5 "<run>:"
0000000000001149 <run>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi
1158: e8 f3 fe ff ff callq 1050 <puts@plt>
115d: 90 nop
115e: 5d pop %rbp
115f: c3 retq
0000000000001160 <main>:
1160: f3 0f 1e fa endbr64
1164: 55 push %rbp
1165: 48 89 e5 mov %rsp,%rbp
1168: 48 8d 3d a0 0e 00 00 lea 0xea0(%rip),%rdi
116f: e8 dc fe ff ff callq 1050 <puts@plt>
1174: b8 00 00 00 00 mov $0x0,%eax
1179: e8 cb ff ff ff callq 1149 <run> # 修正为实际地址0x1149
117e: b8 00 00 00 00 mov $0x0,%eax
1183: 5d pop %rbp
1184: c3 retq
可以看到,run函数的调用地址已被修正为0x1149。
链接的本质:将编译后的所有目标文件连同静态库组合、拼装成一个独立的可执行文件,过程中需要根据重定位表修正外部符号的地址。
虚拟地址空间与程序加载
ELF中的地址
ELF程序在未加载到内存时就已经有地址了。当代计算机采用"平坦模式"(Flat Mode),对代码和数据统一编址。
查看反汇编结果最左侧的地址就是ELF的虚拟地址(逻辑地址):
objdump -S a.out
0000000000400640 <_start>:
400640: f3 0f 1e fa endbr64
400644: 31 ed xor %ebp,%ebp
400646: 49 89 d1 mov %rdx,%r9
...
将一个可执行文件变成进程,操作系统需要完成两件大事,且顺序严格:
第一步:创建进程的内核数据结构(先有"容器")
操作系统必须先创建一个空的进程"壳子",包括:
-
分配
task_struct(进程描述符,PCB进程控制块) -
分配
mm_struct(内存描述符) -
分配一个空的页表(还没映射任何物理内存)
-
分配
vm_area_struct(虚拟内存区域描述符,记录代码段、数据段、堆、栈的布局) -
分配PID(进程ID)
此时的进程"有壳无肉"------它有了身份证和地址空间的框架,但里面空空如也,什么代码都没加载,什么数据都没有。
第二步:将程序的内容加载到进程的地址空间中
操作系统将ELF可执行文件的各个段(Segment)映射到刚刚创建的虚拟地址空间中,并填充页表。
-
.text(代码段)→ 映射到虚拟地址的代码区 -
.data(数据段)→ 映射到虚拟地址的数据区 -
记录程序入口点
Entry Point(如0x1060) -
设置
EIP/PC指向Entry Point
进程地址空间的初始化
进程的mm_struct和vm_area_struct在创建时,数据从哪里来?
从ELF的各个Segment来!
每个Segment都有自己的起始地址和长度,用来初始化内核结构中的[start, end]范围数据。
程序入口地址也记录在ELF Header中:
readelf -h a.out
Entry point address: 0x400640
程序加载过程图解
+--------------------------------------------------+
| 内核空间
| (操作系统内核代码)
+--------------------------------------------------+
|
| 进程虚拟地址空间
|
| +--------------------------------------------+
| | 栈 (Stack)
| | ↓ 向下增长
| +--------------------------------------------+
| |
| | 共享库区域 (共享库映射到此)
| | (如 libc.so, ld-linux.so)
| |
| +--------------------------------------------+
| | 堆 (Heap) ↑ 向上增长
| +--------------------------------------------+
| |
| | .data (已初始化数据)
| | .bss (未初始化数据)
| | .text (代码段)
| |
| +--------------------------------------------+
| | 保留区域
+--------------------------------------------------+
动态链接的深入剖析
动态链接的动机
静态链接的缺点:
-
文件体积大:每个程序都包含所有依赖库的代码
-
内存浪费:相同库代码在内存中重复出现
-
更新不便:库更新后需要重新链接所有程序
动态链接的优势:
-
节省空间:同一份库在内存中只保留一份,被所有进程共享
-
更新方便:替换库文件即可,无需重新链接程序
-
按需加载:程序运行时才加载需要的库
_start 入口点
C/C++程序的入口点不是main函数,而是_start函数(由glibc或链接器提供):
-
设置堆栈:为程序创建初始堆栈环境
-
初始化数据段:复制已初始化数据,清零未初始化数据
-
动态链接:调用动态链接器解析和加载依赖的动态库
-
调用
__libc_start_main:执行额外初始化 -
调用
main函数:执行用户代码 -
处理返回值 :调用
_exit终止程序
动态库的相对地址
动态库为了能够加载到任意进程的任意位置,采用相对编址方案:
# 查看库的反汇编
objdump -S /lib64/libc-2.17.so | less
所有地址都是相对于库起始地址的偏移量,这就是-fPIC参数的作用。
进程如何找到动态库
进程虚拟地址空间
+------------------+
| |
| 代码段 (.text) | ← 跳转到共享区
| ↓ |
| 调用库函数 |
| ↓ |
+------------------+
| |
| 共享库区域 | ← 动态库映射到此
| (libc.so) |
| (ld-linux.so) |
| |
+------------------+
动态库被映射到进程地址空间的共享库区域后,通过库起始虚拟地址 + 方法偏移量即可定位任意方法。
GOT(全局偏移表)与PLT(过程链接表)
为什么需要GOT?
问题 :代码段(.text)是只读的,但动态库加载地址不固定,需要修改函数跳转地址。
解决方案 :在可读写的.data段中预留一片区域存放函数跳转地址,这就是GOT(Global Offset Table)。
GOT工作原理
代码段调用流程:
call puts@plt
↓
PLT桩代码:
jmp *GOT[puts]
↓
GOT表项:
存有puts的真实地址(运行时填入)
延迟绑定(Lazy Binding)
为了优化性能,动态链接采用延迟绑定策略:函数第一次被调用时才进行地址解析。
第一次调用流程:
-
调用
puts@plt -
PLT跳转到GOT,GOT指向PLT中的桩代码
-
桩代码调用动态链接器解析
puts的真实地址 -
动态链接器将真实地址填入GOT
-
跳转到真实的
puts函数
后续调用流程:
-
调用
puts@plt -
PLT跳转到GOT,GOT已存有真实地址
-
直接跳转到
puts函数
查看PLT和GOT
objdump -S a.out | grep -A5 "puts@plt"
0000000000001050 <puts@plt>:
1050: f3 0f 1e fa endbr64
1054: f2 ff 25 75 2f 00 00 bnd jmpq *0x2f75(%rip) # 跳转到GOT表项
105b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
库之间的依赖
不仅可执行程序调用库,库也可能调用其他库。每个库都有自己的GOT表,因为:
-
代码段只读,不能直接修改
-
不同进程的地址空间不同,库的绝对地址不同
-
每个进程、每个库都有独立的GOT表
库间调用原理:与可执行程序调用库完全一致,都是通过GOT表跳转。
静态链接与动态链接的对比总结
| 对比维度 | 静态链接 | 动态链接 |
|---|---|---|
| 链接时机 | 编译时 | 程序加载时 |
| 可执行文件大小 | 大(包含所有库代码) | 小(只有地址表) |
| 内存占用 | 高(每份程序独立) | 低(共享一份库) |
| 库更新 | 需重新链接 | 替换库文件即可 |
| 启动速度 | 快(无需解析) | 稍慢(需加载解析) |
| 性能 | 高(直接调用) | 稍低(GOT查表) |
| 代码复用 | 二进制级别 | 二进制级别,且共享 |
操作命令速查表
文件查看
| 命令 | 说明 |
|---|---|
file hello.o |
查看文件类型 |
readelf -h a.out |
查看ELF头 |
readelf -l a.out |
查看程序头(段信息) |
readelf -S a.out |
查看节头信息 |
readelf -s a.out |
查看符号表 |
objdump -d a.out |
反汇编所有代码段 |
objdump -S a.out |
反汇编并显示源代码 |
ldd a.out |
查看依赖的动态库 |
size a.out |
查看各段大小(text/data/bss) |
库操作
| 命令 | 说明 |
|---|---|
ar -rc lib.a *.o |
创建静态库 |
ar -tv lib.a |
列出静态库内容 |
gcc -shared -fPIC -o lib.so *.o |
创建动态库 |
gcc -L. -lmylib main.c |
链接库 |
export LD_LIBRARY_PATH=. |
设置库搜索路径 |
ldconfig |
更新库缓存 |
图解完整过程
时间线 ────────────────────────────────────────────────────────────────►
用户输入 ./a.out
│
│ shell 进程
│
▼
【1】fork()
├── 内核分配 task_struct
├── 内核分配 mm_struct(复制父进程的)
├── 内核分配 PID = 1234
└── 子进程创建完成(但运行的是shell代码)
▼
【2】子进程调用 execve("./a.out") ◄── 此时进程已存在(PID=1234)
│
├── 内核打开 a.out
├── 读取 ELF Header
├── 读取 Program Headers
├── 清空旧的 mm_struct
├── 建立新的 mm_struct:
│ ├── 代码区域:vm_start=0x400000, vm_end=0x401000
│ ├── 数据区域:vm_start=0x600000, vm_end=0x601000
│ ├── 堆区域
│ └── 栈区域
├── 建立文件到虚拟地址的映射(vma → 文件偏移)
├── 设置 Entry Point = 0x1060
└── 设置 PC = 0x1060
▼
【3】CPU 开始执行 a.out 的指令
│
├── PC = 0x1060
├── MMU 查页表:0x1060 对应的物理页还没分配 → 缺页中断
├── 内核从磁盘读取 .text 段到物理内存帧
├── 更新页表映射
├── CPU 继续执行
└── 程序真正"跑起来"
▼
【4】_start → 动态链接器 → __libc_start_main → main()
进程 PID=1234 正式成为运行 a.out 的进程
关键问答:把知识点串起来
Q1: 为什么目标文件中的地址是0,而可执行文件中有具体地址?
因为编译时不知道 ,链接时才确定。链接器把所有目标文件合并后,才能知道每个符号的具体位置,然后修正地址。这就是重定位。
Q2: 为什么动态库要用-fPIC?
因为加载地址不固定 。-fPIC让代码使用相对寻址,无论库被映射到哪个地址,偏移量不变。否则,如果代码中使用了绝对地址,加载到不同位置就无法运行。
Q3: GOT表为什么在数据段而不在代码段?
因为需要被修改。代码段只读,而GOT表在程序运行时需要被动态链接器写入真实地址。放在可读写的数据段就可以修改。
Q4: 为什么需要PLT,直接用GOT不行吗?
为了延迟绑定。如果直接用GOT,程序启动时就要解析所有库函数地址,严重影响启动速度。PLT提供了"用到才解析"的机制,只解析实际调用的函数。
Q5: 物理内存中动态库真的只有一份吗?
是的,通过虚拟内存机制 。物理内存中只有一份libc.so的代码,但被映射到不同进程的不同虚拟地址。每个进程有自己独立的GOT表(在物理内存中是独立的),但代码段是共享的。
Q6: 静态链接和动态链接的本质区别是什么?
静态链接:编译时解决所有地址问题 → 独立运行
动态链接:运行时解决所有地址问题 → 需要依赖库
静态重定位:链接时修正地址(一次搞定)
动态重定位:加载时修正GOT(每次加载都要做)