前言
在Linux环境下进行C/C++开发时,我们每天都在使用各种库,但你是否真正理解这些库是如何工作的?当程序编译链接通过却运行报错找不到动态库时,你是否感到困惑?本文将带你深入理解动静态库的制作与使用、ELF文件格式、以及程序从编译到运行的全过程。
本文将覆盖以下重点内容:
- 动静态库的制作与使用
- 动态库的查找机制与常见问题
- ELF文件格式深度解析
- 可执行程序的加载过程
- 虚拟地址空间与动态库加载原理
- GOT/PLT机制与动态链接的底层实现
1. 什么是库
1.1 库的本质
重点概念: 库是写好的、现有的、成熟的、可以复用的代码。本质上是一种可执行代码的二进制形式,可以被操作系统载入内存执行。
现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始。库的存在大大提高了软件开发的效率。
1.2 库的分类
库分为两种类型:
| 类型 | Linux后缀 | Windows后缀 | 特点 |
|---|---|---|---|
| 静态库 | .a |
.lib |
编译时链接,代码复制到可执行文件中 |
| 动态库 | .so |
.dll |
运行时加载,多个程序共享使用 |
bash
# 查看系统中的C标准库(静态库和动态库)
$ ls -l /lib/x86_64-linux-gnu/libc-2.31.so
-rwxr-xr-x 1 root root 2029592 May 1 02:20 /lib/x86_64-linux-gnu/libc-2.31.so
$ ls -l /lib/x86_64-linux-gnu/libc.a
-rw-r--r-- 1 root root 5747594 May 1 02:20 /lib/x86_64-linux-gnu/libc.a
# 查看C++标准库
$ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so -l
lrwxrwxrwx 1 root root 40 Oct 24 2022 /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so -> ...
$ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
/usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
1.3 示例:自定义库的头文件
以一个简单的带缓冲区的文件IO库为例:
c
// my_stdio.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
#define SIZE 1024
#define FLUSH_NONE 0 // 不刷新
#define FLUSH_LINE 1 // 行刷新
#define FLUSH_FULL 2 // 满刷新
// 自定义文件结构体
typedef struct IO_FILE {
int flag; // 刷新方式
int fileno; // 文件描述符
char outbuffer[SIZE]; // 输出缓冲区
int cap; // 缓冲区容量
int size; // 当前缓冲区大小
} mFILE;
// 函数声明
mFILE *mfopen(const char *filename, const char *mode);
int mfwrite(const void *ptr, int num, mFILE *stream);
void mfflush(mFILE *stream);
void mfclose(mFILE *stream);
c
// my_string.h
#pragma once
int my_strlen(const char *s);
c
// my_string.c
#include "my_string.h"
int my_strlen(const char *s)
{
const char *end = s;
while (*end != '\0') end++;
return end - s;
}
2. 静态库的制作与使用
2.1 静态库的特点
重点概念: 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库,即使删掉库文件,程序照样可以运行。
静态库的优点:
- 运行时不需要依赖外部库文件
- 部署简单,一个可执行文件即可运行
静态库的缺点:
- 生成的可执行文件体积较大
- 当多个程序使用同一个库时,内存中会有多份拷贝,浪费内存
- 库更新后需要重新编译链接整个程序
2.2 静态库的制作
易错点: 静态库中不要包含main函数 !静态库本质是对
.o目标文件的打包,main函数应由用户程序提供。
静态库的制作主要包含两个步骤:生成目标文件(.o)和使用ar工具打包成.a文件。
2.2.1 生成目标文件
bash
# 将源文件编译为目标文件,但不进行链接
$ gcc -c my_stdio.c -o my_stdio.o
$ gcc -c my_string.c -o my_string.o
-c选项:只编译(生成目标文件),不进行链接。
2.2.2 使用ar工具打包
bash
# 使用ar命令将多个.o文件打包成静态库
$ ar -rc libmystdio.a my_stdio.o my_string.o
ar命令详解:
ar是GNU归档工具,用于创建、修改和提取静态库。- 常用选项:
r(replace):将指定的文件插入到归档文件中,如果文件已存在则替换(replace)。c(create):创建归档文件,如果库文件不存在则新建(create)。如果不加c,ar可能会输出警告信息。t(table):列出归档文件中的所有目标文件,通常与v一起使用显示详细信息。v(verbose):显示详细信息,与rc或t等配合使用。
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
# 输出显示了文件的权限、UID/GID、大小、时间等信息
2.2.3 完整Makefile示例
makefile
# Makefile 示例
libmystdio.a: my_stdio.o my_string.o
@ar -rc $@ $^ # ar是gnu归档工具,rc表示replace and create
@echo "build $@ done"
%.o: %.c
@gcc -c $< -o $@
@echo "compiling $< to $@ done"
.PHONY: clean
clean:
@rm -rf *.a *.o
@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"
2.3 静态库的使用
c
// main.c - 测试程序
#include "my_stdio.h"
#include "my_string.h"
#include <stdio.h>
int main()
{
const char *s = "Hello, Linux Library!";
printf("%s: length = %d\n", s, my_strlen(s));
mFILE *fp = mfopen("/tmp/log.txt", "a");
if (fp == NULL) {
printf("Failed to open file\n");
return 1;
}
mfwrite(s, my_strlen(s), fp);
mfwrite("\n", 1, fp);
mfclose(fp);
printf("Write success!\n");
return 0;
}
三种使用场景:
bash
# 场景1:头文件和库文件安装到系统路径下
$ gcc main.c -lmystdio
# 场景2:头文件和库文件在同一个目录下
$ gcc main.c -L. -lmystdio
# -L: 指定库路径 -l: 指定库名(去除lib前缀和.a后缀)
# 场景3:头文件和库文件有独立路径
$ gcc main.c -I./include -L./lib -lmystdio
# -I: 指定头文件搜索路径
易错点: 库文件名称和链接时的库名规则:去掉前缀
lib,去掉后缀.so或.a。例如:libc.so→c,libmystdio.a→mystdio
编译验证:
bash
# 编译后查看可执行文件大小
$ gcc main.c -L. -lmystdio -o test_static
$ ls -lh test_static
-rwxrwxr-x 1 whb whb 17K Oct 29 15:46 test_static
# 删除静态库后程序仍可运行
$ rm libmystdio.a
$ ./test_static
Hello, Linux Library!: length = 20
Write success!
3. 动态库的制作与使用
3.1 动态库的特点
重点概念: 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
动态库的优点:
- 可执行文件体积小
- 多个程序共享同一份动态库,节省内存和磁盘空间
- 库更新后不需要重新编译程序(接口兼容的情况下)
动态库的缺点:
- 运行时依赖库文件,部署需要额外考虑库的路径
- 首次加载时有一定性能开销
3.2 动态库的制作
动态库的制作同样需要先生成目标文件,但编译时必须添加特殊选项,然后链接成共享库。
3.2.1 生成位置无关的目标文件
bash
# 使用 -fPIC 选项生成位置无关的目标文件
$ gcc -fPIC -c my_stdio.c -o my_stdio.o
$ gcc -fPIC -c my_string.c -o my_string.o
-fPIC(Position Independent Code,位置无关代码):生成可以在内存中任意位置加载而无需重定位的代码。这对于动态库来说是必需的,因为不同的进程可能会将库加载到不同的虚拟地址。PIC通过使用全局偏移表(GOT)和相对寻址等方式实现。
3.2.2 链接为动态库
bash
# 使用 -shared 选项创建动态库
$ gcc -shared -o libmystdio.so my_stdio.o my_string.o
-shared:指示GCC生成一个共享库(动态库)而不是可执行文件。它会将目标文件组织成适用于动态加载的格式,并添加必要的符号表和重定位信息。
结合使用:
你也可以直接一步到位:
bash
$ gcc -fPIC -shared my_stdio.c my_string.c -o libmystdio.so
3.2.3 完整Makefile示例
makefile
libmystdio.so: my_stdio.o my_string.o
gcc -shared -o $@ $^ # -shared: 生成共享库
@echo "build $@ done"
%.o: %.c
gcc -fPIC -c $< -o $@ # -fPIC: 生成位置无关代码
@echo "compiling $< to $@ done"
.PHONY: clean
clean:
@rm -rf *.so *.o
@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"
3.3 动态库的使用
bash
# 场景1:头文件和库文件安装到系统路径下
$ gcc main.c -lmystdio
# 场景2:头文件和库文件在同一个目录下
$ gcc main.c -L. -lmystdio
# 场景3:头文件和库文件有独立路径
$ gcc main.c -I./include -L./lib -lmystdio
# 查看可执行程序的依赖
$ ldd a.out
linux-vdso.so.1 => (0x00007fffacbbf000)
libmystdio.so => not found # 注意:显示not found!
libc.so.6 => /lib64/libc.so.6 (0x00007f891735000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8917905000)
3.4 动态库运行搜索路径问题
关键问题: 编译时通过
-L指定了库路径,但运行时还是找不到库!
这是因为-L只告诉链接器 在哪里找库,但程序运行时,动态链接器需要知道库的位置。
解决方案:
方案1:拷贝到系统共享库路径
bash
$ sudo cp libmystdio.so /usr/lib/
# 或者
$ sudo cp libmystdio.so /usr/local/lib/
方案2:建立软链接
bash
$ sudo ln -s $(pwd)/libmystdio.so /usr/lib/libmystdio.so
方案3:设置环境变量(临时)
bash
$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$(pwd)
$ ./a.out
# 程序正常运行!
方案4:修改系统配置(永久)
bash
# 创建配置文件
$ sudo vim /etc/ld.so.conf.d/myconfig.conf
# 添加库路径:/path/to/your/lib
$ sudo ldconfig # 更新库搜索缓存
方案5:编译时指定运行时路径(推荐)
bash
$ gcc main.c -L. -lmystdio -Wl,-rpath='$ORIGIN'
# -Wl,-rpath: 将运行时库搜索路径写入可执行文件
# $ORIGIN: 表示可执行文件所在目录
易错点总结:
-L是编译时的库搜索路径,不影响运行时LD_LIBRARY_PATH是运行时的环境变量,仅对当前会话有效-Wl,-rpath将运行路径嵌入可执行文件,是最可靠的部署方式
4. 目标文件与ELF格式
4.1 从编译到链接
bash
# 示例源文件
# hello.c
#include <stdio.h>
extern void run();
int main() {
printf("hello world!\n");
run();
return 0;
}
# code.c
#include <stdio.h>
void run() {
printf("running...\n");
}
# 分别编译
$ gcc -c hello.c -o hello.o
$ gcc -c code.c -o code.o
$ ls
code.c code.o hello.c hello.o
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
重点概念: 编译的过程就是将程序源代码翻译成CPU能够直接运行的机器代码。
.o文件被称作目标文件,是ELF格式的二进制文件。
4.2 ELF文件类型
ELF(Executable and Linkable Format)是Linux下可执行文件的格式,有四种类型:
| 类型 | 说明 | 示例 |
|---|---|---|
| 可重定位文件 | 包含代码和数据,可与其他目标文件链接 | .o文件 |
| 可执行文件 | 可以直接执行的程序 | a.out |
| 共享目标文件 | 动态链接库 | .so文件 |
| 内核转储 | 进程执行上下文的转储 | core dump文件 |
4.3 ELF文件结构
重点: 一个ELF文件由四部分组成:

常见Section说明:
| 节名 | 说明 |
|---|---|
.text |
代码节,保存机器指令 |
.data |
数据节,已初始化的全局变量和静态变量 |
.bss |
未初始化的全局变量和静态变量(不占磁盘空间) |
.rodata |
只读数据,如字符串常量 |
.symtab |
符号表,函数名、变量名和代码的对应关系 |
.got |
全局偏移表,用于动态链接 |
.plt |
过程链接表,用于延迟绑定 |
bash
# 查看目标文件的节信息
$ readelf -S hello.o
There are 13 section headers, starting at offset 0x728:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000025 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 00000065
0000000000000000 0000000000000000 WA 0 0 1
[ 3] .bss NOBITS 0000000000000000 00000065
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .rodata PROGBITS 0000000000000000 00000065
000000000000000e 0000000000000000 A 0 0 1
[ 5] .symtab SYMTAB 0000000000000000 00000200
00000000000000f0 0000000000000018 6 8 8
[ 6] .strtab STRTAB 0000000000000000 000002f0
0000000000000039 0000000000000000 0 0 1
...
bash
# 使用size命令查看目标文件各段大小
$ size hello.o
text data bss dec hex filename
333 0 0 333 14d hello.o
4.4 ELF Header详解
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
Version: 0x1
Entry point address: 0x0 ← 目标文件入口为0
Start of program headers: 0 (bytes into file) ← 目标文件无程序头表
Start of section headers: 728 (bytes into file)
Flags: 0x0
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
bash
# 查看可执行文件的ELF Header
$ gcc hello.o code.o -o main.exe
$ readelf -h main.exe
ELF Header:
Type: DYN (Shared object file) ← 可执行文件也是ELF
Entry point address: 0x1060 ← 有实际入口地址
Start of program headers: 64 (bytes into file) ← 有程序头表
Number of program headers: 13
Number of section headers: 31

4.5 为什么要将Section合并为Segment
重要原理: Section合并的主要原因是减少页面碎片,提高内存使用效率。
举例说明:
- 假设操作系统内存页大小为4096字节(4KB,内存管理的基本单位)
- 如果
.text段为4097字节,.init段为512字节- 不合并:需要3个页面(
.text占2个,.init占1个) - 合并后:只需2个页面,节省1个页面
- 不合并:需要3个页面(
两个视图的对比:
| 视图类型 | 对应结构 | 作用 |
|---|---|---|
| 链接视图 | Section Header Table | 链接时使用,按功能模块划分 |
| 执行视图 | Program Header Table | 加载时使用,按访问权限合并 |
bash
# 查看执行视图(Program Headers)
$ readelf -l main.exe
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x0000000000000744 0x0000000000000744 R E 200000 ← 只读可执行段
LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
0x0000000000000218 0x0000000000000220 RW 200000 ← 可读写段
Section to Segment mapping:
Segment Sections...
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym
.dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
.init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
关键理解:
- 链接视图(节头表):告诉链接器如何组织各个节
- 执行视图(程序头表):告诉操作系统如何加载程序到内存
- 加载时将相同权限的Section合并成Segment(如只读可执行的合并、可读写的合并)
5. 理解链接与加载
5.1 静态链接过程
核心概念: 无论是自己的
.o,还是静态库中的.o,本质都是把.o文件进行连接的过程。
bash
# 查看目标文件的反汇编
$ objdump -d code.o
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # b <run+0xb>
b: e8 00 00 00 00 callq 10 <run+0x10> # puts的地址暂为0
10: 90 nop
11: 5d pop %rbp
12: c3 retq
bash
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 字符串地址暂为0
b: e8 00 00 00 00 callq 10 <main+0x10> # printf的地址暂为0
10: b8 00 00 00 00 mov $0x0,%eax
15: e8 00 00 00 00 callq 1a <main+0x1a> # run的地址暂为0
1a: b8 00 00 00 00 mov $0x0,%eax
1f: 5d pop %rbp
20: c3 retq
关键发现: 在目标文件
.o中,call指令后面的跳转地址都被设为00 00 00 00!
为什么地址都是0?
- 编译
hello.c时,编译器完全不知道printf和run函数在哪里 - 这些外部符号的地址只能在链接时确定
- 目标文件中有一个重定位表,记录了需要修正的地址位置
5.2 符号表与重定位
bash
# 查看目标文件的符号表
$ 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 ← 在本文件定义
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts ← 未定义(UND)!
$ 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 _GLOBAL_OFFSET_TABLE_
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts ← 未定义(UND)!
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND run ← 未定义(UND)!
UND(Undefined):表示该符号在本目标文件中找不到定义,需要链接器在其他目标文件或库中查找。
5.3 链接后的变化
bash
# 链接后查看可执行文件
$ gcc hello.o code.o -o main.exe
# 查看符号表 - run函数有了明确的地址
$ readelf -s main.exe | grep run
52: 0000000000001149 23 FUNC GLOBAL DEFAULT 16 run
# 地址: 0x1149, 在第16个section中
63: 0000000000001160 37 FUNC GLOBAL DEFAULT 16 main
# 地址: 0x1160, 在第16个section中
bash
# 反汇编可执行文件 - call指令有了正确的地址
$ objdump -d main.exe
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 # 2004
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 # 200f
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> # 正确地址!
117e: b8 00 00 00 00 mov $0x0,%eax
1183: 5d pop %rbp
1184: c3 retq
链接过程总结:
- 多个
.o的代码段合并到一起,统一编址- 根据重定位表,修正所有未确定的函数地址
- 静态库的链接同理,将库中的
.o合并到可执行文件
图片2:静态链接过程 -.o文件合并与地址重定位

6. ELF加载与进程地址空间
6.1 虚拟地址的概念
重要概念: 一个ELF程序,在没有被加载到内存的时候,就已经有了地址!这个地址是虚拟地址(逻辑地址)。
当代计算机采用"平坦模式"工作,ELF要求对自己的代码和数据进行统一编址。实际上,虚拟地址 = 起始地址 + 偏移量。对于目标文件,起始地址被认为是0。
bash
# objdump反汇编显示的就是虚拟地址
$ objdump -d main.exe
0000000000001149 <run>: # 0x1149就是run函数的虚拟地址(偏移量)
1149: ...
0000000000001160 <main>: # 0x1160就是main函数的虚拟地址(偏移量)
1160: ...
6.2 进程虚拟地址空间结构
当可执行程序被加载到内存时,操作系统会为其创建进程地址空间,由task_struct中的mm_struct管理。mm_struct包含一个指向vm_area_struct链表头的指针mmap,每个vm_area_struct描述一段虚拟内存区域(VMA),记录了起始地址、结束地址、访问权限等。
task_struct -> mm_struct -> vm_area_struct链表:
VMA for 栈区: vm_start ~ vm_end (可读写)
VMA for 堆区: vm_start ~ vm_end (可读写)
VMA for 数据段: vm_start ~ vm_end (可读写)
VMA for 代码段: vm_start ~ vm_end (只读可执行)
VMA for 共享库区域: vm_start ~ vm_end (只读可执行)
VMA for BSS段: vm_start ~ vm_end (可读写)
图片3:进程地址空间与vm_area_struct结构

进程虚拟地址空间整体布局:

关键理解:
- 虚拟地址机制不仅需要OS支持,编译器也需要支持
- 进程
mm_struct和vm_area_struct在创建时,从ELF各个Segment获取初始化数据- 每个Segment有起始地址和长度,用于填充内核结构和页表
6.3 程序入口地址与加载执行
bash
$ readelf -h main.exe | grep Entry
Entry point address: 0x1060 # 程序入口地址!
注意: 程序的入口并不是
main函数!在C/C++程序中,入口点是_start,它由C运行时库提供。
下面这张图展示了程序从磁盘到内存执行的核心流程:
图片4:程序加载与入口点执行

程序启动过程:
_start→ 初始化堆栈、数据段_start→ 调用动态链接器解析动态库依赖- 动态链接器 → 加载依赖的动态库,完成符号重定位
_start→ 调用__libc_start_main__libc_start_main→ 调用main函数main返回 →__libc_start_main处理返回值 → 调用exit
7. 动态链接与动态库加载
7.1 动态链接概述
为什么需要动态链接? 静态链接会将所有代码合并到可执行文件,导致:
- 文件体积大
- 多个程序包含相同代码,浪费磁盘和内存
- 库更新需要重新编译所有程序
动态链接的优势:
- 将共享代码提取为独立的动态库
- 多个程序共享同一份物理内存副本
- 库更新只需替换
.so文件
7.2 进程如何看到动态库
动态库在被使用时,操作系统需要让进程"看到"它。这个过程涉及:找到磁盘上的库文件、加载到内核缓冲区、映射到进程地址空间的共享区、建立页表、获得起始虚拟地址。
图片5:进程通过映射看到动态库


简单来说:
- 进程根据库文件路径找到磁盘上的
.so文件; - 将文件内容读入内核缓冲区(即物理内存中的文件缓存);
- 在进程的虚拟地址空间中分配一块共享区VMA;
- 建立页表,将VMA的虚拟地址映射到物理内存中库的数据块;
- 于是进程就可以通过虚拟地址访问库中的代码和数据了。
7.3 动态库与PIC(位置无关代码)
核心原理: 动态库为了能在任意内存地址加载,使用
-fPIC编译选项,生成位置无关代码。
bash
# 查看动态库的反汇编
$ objdump -d libmystdio.so
0000000000001129 <mfopen>:
1129: endbr64
112d: push %rbp
112e: mov %rsp,%rbp
# 动态库中的函数使用相对寻址,不依赖绝对地址
7.4 动态库加载流程

7.5 GOT(全局偏移表)与PLT(过程链接表)
核心问题: 代码段(.text)在内存中是只读的,如何修改函数跳转地址?
答案: 使用GOT表!GOT表存储在可读写的数据段(.data区域),动态链接时修改GOT表中的地址即可。
GOT的工作原理:

库映射后的地址计算
当动态库被映射到进程地址空间后,动态链接器会获得该库在进程中的起始虚拟地址(如libc.so映射后起始地址为0x44332211)。库中每个函数的偏移量在编译时就已经确定(例如puts函数在库中的偏移量为0x112233),则puts在进程中的实际虚拟地址为:
起始地址 + 函数偏移量 = 0x44332211 + 0x112233
动态链接器会将计算出的实际地址填入GOT表对应的条目中。这样,当程序调用puts时,就会通过GOT表间接跳转到正确的位置。
图片6:静态库加载与GOT地址填充


PLT(延迟绑定/懒加载):
优化机制: 与其在程序启动时解析所有函数,不如在函数第一次被调用时才解析(延迟绑定)。
第一次调用流程:
图片7:延迟绑定第一次调用

流程描述:
- 程序调用
func@plt PLT条目跳转到GOT表对应项GOT表初始保存的是桩代码(stub)的地址- 桩代码调用动态链接器解析真实函数地址
- 动态链接器更新
GOT表为该真实地址 - 再次跳转并执行真实函数
后续调用流程:
图片8:延迟绑定后续调用

之后程序再次调用func@plt时,GOT表已存储真实地址,直接跳转,避免了重复解析的开销。
bash
# 查看PLT和GOT节
$ readelf -S main.exe | grep -E "got|plt"
[13] .plt PROGBITS 0000000000001020 00001020
[14] .plt.got PROGBITS 0000000000001040 00001040
[15] .plt.sec PROGBITS 0000000000001050 00001050
[24] .got PROGBITS 0000000000003fb8 00002fb8
[25] .got.plt PROGBITS 0000000000003fd0 00002fd0
bash
# 查看PLT反汇编
$ objdump -d main.exe
0000000000001050 <puts@plt>:
1050: endbr64
1054: bnd jmpq *0x2f75(%rip) # 3fd0 <puts@GLIBC_2.2.5>
105b: nopl 0x0(%rax,%rax,1)
总结:
- PIC(位置无关代码)= 相对编址 + GOT表
- GOT表使代码段可以共享,每个进程维护自己的GOT表
- PLT实现延迟绑定,只在首次调用时解析地址
7.6 库间依赖
动态库之间也存在依赖关系!每个动态库都有自己的GOT表,用于解析对其他库函数的引用。
可执行程序 ──依赖──→ libmystdio.so ──依赖──→ libc.so
GOT表 GOT表 GOT表
┌────┐ ┌────┐ ┌────┐
│....│ │....│ │....│
└────┘ └────┘ └────┘
图片9:库间依赖与内存布局

这也就是为什么动态库也都采用ELF格式 ------ 统一的格式使得库间调用也能通过GOT机制实现地址无关。当liba.so需要调用libb.so中的函数时,liba.so的GOT表会存储libb.so中该函数的实际地址,由动态链接器在加载时填充或首次调用时更新。
8. 总结
8.1 静态链接 vs 动态链接
| 特性 | 静态链接 | 动态链接 |
|---|---|---|
| 链接时机 | 编译时 | 运行时 |
| 库代码位置 | 合并到可执行文件 | 独立文件,运行时加载 |
| 可执行文件大小 | 较大 | 较小 |
| 内存效率 | 每个程序一份拷贝 | 多个程序共享一份拷贝 |
| 部署复杂度 | 简单(一个文件) | 需管理库路径 |
| 更新维护 | 需重新编译 | 更换.so即可 |
8.2 链接过程的三个层次
1. 编译时重定位(静态链接)
→ 合并.o文件,修正函数地址
→ 生成独立可执行文件
2. 加载时重定位(动态链接)
→ 加载动态库到内存
→ 建立虚拟地址映射
3. 运行时重定位(延迟绑定PLT)
→ 首次调用时解析函数地址
→ 更新GOT表
8.3 关键命令速查
| 命令 | 功能 |
|---|---|
ar -rc libxxx.a xxx.o |
创建静态库 |
gcc -shared -fPIC -o libxxx.so xxx.c |
创建动态库 |
gcc main.c -L. -lxxx |
链接库 |
gcc main.c -I./include -L./lib -lxxx |
指定路径链接 |
ldd a.out |
查看动态库依赖 |
readelf -h a.out |
查看ELF头 |
readelf -S a.out |
查看节头表 |
readelf -l a.out |
查看程序头表 |
readelf -s a.out |
查看符号表 |
objdump -d a.out |
反汇编 |
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:. |
设置库搜索路径 |
写在最后
理解库的制作与原理,不仅是面试的常见考点,更是日常开发中排查链接错误、设计模块化架构的基础。从静态库到动态库,从编译链接到加载执行,每一个环节都体现了操作系统和编译器设计者的智慧。
希望本文能帮助你在遇到"undefined reference"、"cannot open shared object file"这些问题时,能够快速定位原因并找到解决方案。