本文环境:x86-64 Linux,GCC + GNU Binutils(ar / readelf / objdump)。
文中所有命令输出均为真实运行结果,示例代码放在文末,可直接复现。

本文重点:

  • 动静态库的制作
  • 动静态库的使用
  • 动态库的查找
  • 可执行程序的 ELF 格式
  • 可执行程序的加载过程
  • 虚拟地址空间与动态库加载的过程

1. 什么是库

库是写好的、成熟的、可以复用的代码。现实开发中,每个程序都会依赖大量底层库——不可能每个人都从零开始写 printf、写网络协议栈,所以库的存在意义非常大。

本质上,库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。它由一个或多个目标文件(.o)组成,使用者只需要拿到头文件 + 库文件就能使用,源码可以不公开。

类型LinuxWindows
静态库.a.lib
动态库(共享库).so.dll

在这里插入图片描述

库在 C 程序构建流程中的位置

库文件的命名规则:lib + 库名 + 后缀,例如 libmymath.a、libmymath.so,库名是 mymath,链接时写 -lmymath。

1.1 静态库与动态库的区别

在这里插入图片描述

静态链接 vs 动态链接

对比项静态库 .a动态库 .so
链接时机编译链接阶段,代码被拷贝进可执行文件程序运行时,由动态链接器加载
可执行文件体积大小
运行时依赖无必须能找到对应 .so
内存占用每个进程一份副本多个进程共享一份代码
库升级需要重新链接所有程序替换 .so 即可(ABI 不变时)

2. 示例代码

后续所有实验都基于下面这个最小的数学库:

目录结构:

demo/
├── main.c
└── mylib/
    ├── include/
    │   └── mymath.h
    └── src/
        ├── add.c
        └── sub.c

mylib/include/mymath.h:

#ifndef MYMATH_H
#define MYMATH_H
int add(int a, int b);
int sub(int a, int b);
#endif

mylib/src/add.c:

#include "mymath.h"
int add(int a, int b) { return a + b; }

mylib/src/sub.c:

#include "mymath.h"
int sub(int a, int b) { return a - b; }

main.c:

#include <stdio.h>
#include "mymath.h"
int main(void) {
    printf("10 + 3 = %d\n", add(10, 3));
    printf("10 - 3 = %d\n", sub(10, 3));
    return 0;
}

3. 静态库的制作与使用

在这里插入图片描述

静态库的制作与使用

3.1 制作

# ① 把库源码编译成目标文件
gcc -c mylib/src/add.c mylib/src/sub.c -Imylib/include

# ② 用 ar 把目标文件归档成静态库
ar -rcs libmymath.a add.o sub.o

# 查看静态库里有哪些目标文件
ar -t libmymath.a

ar 是 GNU 归档工具,常用参数:

  • r(replace):把文件插入归档,已存在则替换
  • c(create):归档不存在时自动创建,不给出提示
  • s:写入符号索引,加快链接时的符号查找
  • t:列出归档中的文件;v:显示详细信息

可以看出,静态库本质就是一堆 .o 文件打成的包。

3.2 使用

gcc main.c -o app_static -Imylib/include -L. -lmymath

三个关键选项:

选项含义示例
-I头文件搜索路径-Imylib/include
-L库文件搜索路径-L.
-l要链接的库名(去掉 lib 前缀和后缀)-lmymath

如果库和头文件已经安装到系统目录(如 /usr/include、/usr/lib),则 -I 和 -L 可以省略,但 -l 永远不能省(libc 除外,gcc 默认链接)。

运行结果:

在这里插入图片描述

制作并使用静态库

注意: 当同一目录下同时存在 libmymath.a 和 libmymath.so 时,gcc 默认优先使用动态库。想强制静态链接,可以加 -static(此时连 libc 也会被静态链接进来)。


4. 动态库的制作与使用

在这里插入图片描述

动态库的制作

4.1 制作

# ① 生成位置无关码(Position Independent Code)
gcc -fPIC -c mylib/src/add.c mylib/src/sub.c -Imylib/include

# ② 生成共享库
gcc -shared -o libmymath.so add.o sub.o
  • -fPIC:生成与位置无关的代码,库被加载到进程地址空间的任意位置都能正确运行(原理见第 8 节)。
  • -shared:告诉 gcc 输出共享库,而不是可执行程序。

4.2 使用:编译通过,运行却失败?

gcc main.c -o app_dyn -Imylib/include -L. -lmymath
./app_dyn

在这里插入图片描述

动态库编译成功,却运行失败

很多初学者会在这里卡住:明明 -L. 告诉了 gcc 库在哪,为什么运行还找不到?

原因是:-L 只是告诉编译期的链接器 ld 去哪找库,它只在可执行文件里记下一句「我需要 libmymath.so」(DT_NEEDED),并不记录库的位置。程序运行时负责加载库的是另一个程序——动态链接器 ld-linux-x86-64.so.2,它有自己的一套查找规则。

ldd 命令可以查看程序依赖哪些动态库、以及这些库是否找得到,libmymath.so => not found 一目了然。


5. 动态库的查找

5.1 查找顺序

在这里插入图片描述

动态链接器 ld.so 查找.so 的顺序

动态链接器按以下顺序查找(详见 man ld.so):

  1. ELF 中的 DT_RPATH(仅在没有 DT_RUNPATH 时生效,已不推荐)
  2. 环境变量 LD_LIBRARY_PATH
  3. ELF 中的 DT_RUNPATH
  4. 缓存文件 /etc/ld.so.cache(由 ldconfig 根据 /etc/ld.so.conf 生成)
  5. 系统默认目录 /lib、/usr/lib(64 位系统还有 /lib64、/usr/lib64 等)

5.2 让程序找到动态库的 4 种方法

方法一:拷贝到系统默认目录(需要 root,会污染系统,不推荐用于自己的测试库)

sudo cp libmymath.so /usr/lib/

方法二:在系统默认目录建立软链接

sudo ln -s $(pwd)/libmymath.so /usr/lib/libmymath.so

方法三:设置环境变量 LD_LIBRARY_PATH(只对当前 shell 生效,适合调试)

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$(pwd)

方法四:配置 /etc/ld.so.conf.d/,再执行 ldconfig(永久生效)

echo "$(pwd)" | sudo tee /etc/ld.so.conf.d/mymath.conf
sudo ldconfig

补充:用 RUNPATH 把搜索路径写进程序本身(随程序发布时最推荐)

gcc main.c -o app_rpath -Imylib/include -L. -lmymath -Wl,-rpath,'$ORIGIN'

$ORIGIN 表示「可执行文件所在目录」,这样把程序和 .so 放在一起拷到任何地方都能运行。

在这里插入图片描述

两种方式让程序找到动态库

5.3 静态链接 vs 动态链接:体积对比

在这里插入图片描述

静态链接 vs 动态链接:体积对比

全静态链接的程序把 libc 里用到的代码也拷贝了进来,体积是动态链接版本的约 49 倍。这也是为什么系统里绝大多数程序都选择动态链接。


6. 可执行程序的 ELF 格式

在 Linux 中,目标文件(.o)、可执行程序、动态库(.so)、内核转储(core dump) 用的都是同一种格式——ELF(Executable and Linkable Format)。

$ file app_dyn libmymath.so add.o
app_dyn:      ELF 64-bit LSB pie executable, x86-64, ..., dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...
libmymath.so: ELF 64-bit LSB shared object, x86-64, ..., dynamically linked, ...
add.o:        ELF 64-bit LSB relocatable, x86-64, ...
ELF 类型说明示例
可重定位文件(REL)尚未链接,地址未确定add.o
可执行文件(EXEC / DYN-PIE)可直接运行app_dyn
共享目标文件(DYN)动态库libmymath.so
核心转储(CORE)进程崩溃时的内存快照core

6.1 ELF 的组成

一个 ELF 文件由四部分组成:

  • ELF Header:文件的「身份证」,描述文件类型、目标机器、入口地址,以及两张表的位置。
  • Program Header Table(程序头表):描述段(Segment),告诉加载器如何把文件映射进内存。可执行文件和动态库必须有。
  • Sections(节):真正的代码和数据,如 .text、.data、.bss、.rodata、.symtab 等。
  • Section Header Table(节头表):描述每个节的名字、位置、大小,链接器和调试器使用。

常见的节:

节内容
.text机器指令(代码)
.rodata只读数据,如字符串常量
.data已初始化的全局 / 静态变量
.bss未初始化的全局 / 静态变量(文件中不占空间,只记录大小)
.symtab / .strtab符号表 / 字符串表
.dynsym / .dynstr动态链接用的符号表
.rela.*重定位信息
.plt / .got动态库函数调用的跳转表(见第 8.3 节)
.dynamic动态链接信息(依赖哪些库、RUNPATH 等)
.interp动态链接器的路径

6.2 ELF Header

在这里插入图片描述

readelf -h app_dyn

  • Magic:前 4 个字节 7f 45 4c 46 即 \x7f E L F,内核靠它识别 ELF 文件。
  • Type:DYN (Position-Independent Executable),现代 gcc 默认生成 PIE 程序。
  • Entry point:程序入口地址 0x10a0,对应的是 _start 而不是 main。

6.3 两种视图:Section 与 Segment

在这里插入图片描述

ELF 文件的两种视图

ELF 被设计成同时服务两类「读者」:

  • 链接视图(Section):给链接器看。粒度细,便于符号解析、重定位。
  • 执行视图(Segment):给加载器(内核 + 动态链接器)看。把权限相同的多个节合并成一个段,一次性 mmap 进内存。

为什么要合并?因为内存按页(通常 4KB)管理、按页设置权限。如果每个节单独映射,一个只有几十字节的节也要独占一页,造成大量浪费。合并后:

  • 减少页内碎片
  • 减少 mmap 次数
  • 统一设置页权限:代码段 R E、只读数据段 R、数据段 RW

用 readelf -l 可以看到段与节的对应关系:

在这里插入图片描述

readelf -l app_dyn

可以清楚看到:第 03 号段(R E,可读可执行)包含了 .init .plt .text .fini 这些代码节;第 05 号段(RW)包含了 .data .bss .got 等可写节。


7. 可执行程序的加载过程

在这里插入图片描述

从敲下回车到 main() 的过程

当在 shell 中敲下 ./app_dyn 时,大致发生了这些事:

  1. bash fork() + execve():创建子进程,并用新程序替换它。
  2. 内核解析 ELF:校验 Magic,读取程序头表。
  3. 内核映射 PT_LOAD 段:根据每个 LOAD 段的偏移、虚拟地址、权限建立映射。注意,这里只是建立虚拟地址到文件的映射,真正的物理页要到第一次访问时触发缺页中断才会加载(按需分页)。
  4. 加载动态链接器:内核发现有 PT_INTERP 段(/lib64/ld-linux-x86-64.so.2),于是把它也映射进来,并把控制权交给动态链接器,而不是程序自己的 _start。
  5. 动态链接器工作:读取 .dynamic 中的 DT_NEEDED,按第 5 节的顺序找到 libmymath.so、libc.so.6 并映射进内存,然后完成重定位。
  6. 跳转到程序入口 _start:调用 __libc_start_main,执行初始化(.init、全局构造函数),准备好 argc / argv / envp。
  7. 进入 main()。

静态链接的程序没有 PT_INTERP 段,第 4、5 步直接跳过,内核映射完就跳到 _start。

7.1 编译后的程序在加载前就有「地址」吗?

有。看 readelf -l 的输出,每个段都有 VirtAddr,objdump -d 反汇编时每条指令前面也有地址,例如 119b: call 1070 <add@plt>。

这说明:编译器在生成程序时,就已经按「虚拟地址空间」的布局给代码和数据编好了地址(PIE 程序是从 0 开始的相对地址,加载时再加上一个随机基址)。这就是平坦模式(flat model)下的逻辑地址 = 虚拟地址。

所以「虚拟地址空间」并不只是操作系统的概念,编译器、链接器、加载器、操作系统都在遵守同一套约定:

  • 编译器:按虚拟地址给指令和数据编址
  • 加载器:按 Program Header 把段放到对应虚拟地址
  • 操作系统:通过页表把虚拟地址映射到物理内存
  • CPU:执行时只认虚拟地址,由 MMU 完成转换

8. 虚拟地址空间与动态库加载

8.1 动态库被放在哪里?

写一个小程序,在运行时打印自己的 /proc/<pid>/maps,可以看到真实的内存布局,整理如下:

在这里插入图片描述

进程虚拟地址空间

关键观察:

  • 可执行文件本体被映射在低地址(0x55f9...),分成 r--p / r-xp / r--p / rw-p 多段,与程序头表中的 LOAD 段一一对应。
  • libmymath.so、libc.so.6、ld-linux-x86-64.so.2 都被映射在栈和堆之间的共享区(mmap 区)。
  • 每次运行这些基址都不同——这是 ASLR(地址空间布局随机化) 的效果。

8.2 为什么动态库必须是位置无关的(-fPIC)

既然动态库每次被加载的地址都不同,那么库里面如果写死了「add 函数在 0x7f750ec0c0f9」,换个地址就全错了。

传统做法是加载时修改代码段里的地址(加载时重定位),但这样代码页被改写后,每个进程都要有一份自己的副本,共享就失去了意义。

PIC 的做法是:

  • 库内部的跳转全部使用相对于当前指令(RIP)的偏移,无论库加载在哪里,相对距离都不变。
  • 需要访问外部符号(其他库的函数 / 全局变量)时,不直接写地址,而是通过数据段中的 GOT(全局偏移表)间接访问。运行时只需要修改 GOT(数据页,每个进程本来就各有一份),代码页保持不变,可以被所有进程共享。

在这里插入图片描述

多个进程共享同一份动态库的物理内存

8.3 PLT / GOT:动态库函数是怎么被调用到的

先看重定位信息在各个阶段的变化:

在这里插入图片描述

重定位: 从 main.o 到 PLT/GOT

  • 编译后 main.o:call add 的目标地址还是空的,只留下一条重定位项 R_X86_64_PLT32 add,意思是「等链接时填上 add 的 PLT 入口」。
  • 链接后 app_dyn:call 已经被改成 call 1070 <add@plt>,同时在 GOT 中预留了槽位,对应的重定位项是 R_X86_64_JUMP_SLOT add,等运行时由动态链接器填入真实地址。

完整的调用流程:

在这里插入图片描述

PLT / GOT 调用流程

  1. main 执行 call add@plt,跳到 PLT 中的一小段桩代码。
  2. 桩代码执行 jmp *GOT[add],到 GOT 表里取目标地址。
  3. 第一次调用时,GOT 槽中还不是 add 的真实地址,而是指向解析逻辑,于是进入动态链接器的 _dl_runtime_resolve。
  4. 动态链接器在已加载的库中查找符号 add,找到后回填到 GOT 槽。
  5. 跳转到 libmymath.so 中真正的 add 执行。之后再调用 add,第 2 步直接就能拿到真实地址,只多一次间接跳转。

这种「用到时才解析」的策略叫延迟绑定(Lazy Binding),可以加快程序启动速度。

补充:许多发行版出于安全考虑默认开启 -z now(Full RELRO),在程序启动时就解析全部符号,然后把 GOT 设为只读,防止 GOT 被篡改。机制不变,只是解析时机提前。可以用 LD_BIND_NOW=1 ./app_dyn 观察同样的效果。

8.4 静态库为什么不需要这些?

静态链接时,add.o、sub.o 的代码被直接拷贝进可执行文件,链接器在链接阶段就知道它们最终的地址,直接把 call 的目标改成真实函数地址:

$ nm app_static | grep -E ' T (add|sub|main)'
00000000000011a2 T add
0000000000001149 T main
00000000000011ba T sub

add 和 main 在同一个可执行文件里,不需要 PLT / GOT,也不需要运行时查找。


9. 总结

问题结论
库是什么一组可复用的目标文件,以二进制形式发布
静态库怎么做gcc -c 生成 .o,ar -rcs 打包
动态库怎么做gcc -fPIC -c 生成位置无关的 .o,gcc -shared 生成 .so
怎么使用库-I 头文件路径,-L 库路径,-l 库名
动态库运行找不到-L 只管编译期;运行期看 LD_LIBRARY_PATH / RUNPATH / ld.so.cache / 系统目录
ELF 两种视图Section 给链接器,Segment 给加载器;同权限的节合并成段
程序如何加载内核映射 LOAD 段 → 交给 ld.so → 加载依赖库 + 重定位 → _start → main
动态库为何能共享PIC 让代码页与加载地址无关,外部地址集中放在可写的 GOT 中
函数如何跨库调用call xxx@plt → jmp *GOT → 首次由 ld.so 解析并回填

常用命令速查

ar -t   libxxx.a          # 查看静态库里的目标文件
nm -D   libxxx.so         # 查看动态库导出的符号
ldd     ./app             # 查看程序依赖的动态库
file    ./app             # 查看文件类型
readelf -h ./app          # ELF 头
readelf -S ./app          # 节头表(Section)
readelf -l ./app          # 程序头表(Segment)及映射关系
readelf -d ./app          # 动态段:NEEDED、RUNPATH 等
readelf -r ./app          # 重定位表
objdump -d ./app          # 反汇编
cat /proc/<pid>/maps      # 查看进程的内存映射

附录:一键复现

把第 2 节的代码放好后,在 demo/ 目录下新建 Makefile:

INC := -Imylib/include
SRC := mylib/src/add.c mylib/src/sub.c

.PHONY: static shared clean

static:
	gcc -c $(SRC) $(INC)
	ar -rcs libmymath.a add.o sub.o
	gcc main.c -o app_static $(INC) -L. -lmymath -static
	rm -f add.o sub.o
	./app_static

shared:
	gcc -fPIC -c $(SRC) $(INC)
	gcc -shared -o libmymath.so add.o sub.o
	gcc main.c -o app_dyn $(INC) -L. -lmymath -Wl,-rpath,'$$ORIGIN'
	rm -f add.o sub.o
	./app_dyn

clean:
	rm -f *.o *.a *.so app_static app_dyn

然后执行 make static 或 make shared 即可。注意 Makefile 中命令前必须是 Tab,$$ORIGIN 中的 $$ 是 Makefile 对 $ 的转义。

如果本文对你有帮助,欢迎点赞、收藏、关注~有问题欢迎在评论区交流。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐