028 动静态库 —— 动态库

动静态库 —— 动态库

1. 库的制作者 如何制作动态库

1. 编写库的源代码和头文件

  1. 创建头文件:声明库的对外接口函数。

  2. 创建源文件:实现头文件中声明的函数。

2. 编译为位置无关目标文件

-fPIC 关键作用:生成位置无关代码(Position Independent Code),使代码可被加载到内存任意位置,这是动态库的核心要求。

3. 链接生成动态库文件

-shared 参数:指示链接器生成共享库(.so 文件)。

4. 组织发布文件

将以下文件提供给使用者:

1
2
3
4
├── include/           # 头文件目录
│ └── mathlib.h # 接口声明
└── lib/ # 库文件目录
└── libmathlib.so # 动态库二进制

2. 动态库 demo

1. mathlib.h(头文件)—— 对外接口声明

1
2
3
4
5
6
7
8
//相当于 #pragma once , 用于防止重复包含头文件
#ifndef MATHLIB_H
#define MATHLIB_H

int add(int a, int b);
int sub(int a, int b);

#endif //表明头文件结束

2. add.csub.c(源文件)—— 函数定义

1
2
3
4
5
#include "mathlib.h"
int add(int a, int b)
{
return a + b;
}
1
2
3
4
5
#include "mathlib.h"
int sub(int a, int b)
{
return a - b;
}

3. test.c(测试文件)

1
2
3
4
5
6
7
8
#include <stdio.h>
#include "mathlib.h"
int main()
{
printf("add(3, 5) = %d\n", add(3, 5));
printf("sub(10, 4) = %d\n", sub(10, 4));
return 0;
}

4. 目录结构

1
2
3
4
5
6
.
├── add.c // 加法实现
├── sub.c // 减法实现
├── mathlib.h // 接口声明头文件
├── lib/ // 输出的动态库放在这里
├── test.c // 用于测试动态库的代码

5. 编译成位置无关码

1
2
gcc -fPIC -c add.c -o add.o     # 生成 add.o
gcc -fPIC -c sub.c -o sub.o # 生成 sub.o

什么是 -fPIC?

位置无关代码就是一段“到哪都能跑”的代码,它不依赖自己必须加载到某个固定地址。

  • 普通代码:写死了“我只能住在 0x123456 这个地址附近”。
  • 位置无关代码:随便系统安排我住哪,我都能运行!

动态库 .so 是一种 可被多个程序同时加载 的“共享代码段”,但每个程序自己内存布局不同,比如:

  • 程序 A:把 .so 放在内存地址 0x100000
  • 程序 B:放在 0x200000

如果 .so 里面代码写死了“我在 0x100000”,那程序 B 加载后就炸了!所以我们必须写出:可在任意地址运行的 .so 代码 —— 这就是位置无关代码(PIC)。


怎么生成位置无关代码?
在编译 .c.cpp 文件时加上:gcc -fPIC -c add.c -o add.o


所以,位置无关代码(Position Independent Code) 是动态库的“打包必需品”,它能让我们的代码“住哪都行,拷贝即跑”,这是 Linux 系统让多个程序共享一份 .so 的关键机制。

用途 描述
创建动态库 gcc -shared ... 时要求 .o 是位置无关的
支持多个程序加载共享代码 系统可将同一个 .so 加载到任意地址
降低代码冲突和内存浪费 PIC 允许系统更好地重用内存页(节省内存)

我们已经知道了 PIC 是位置无关码,那么 -f 又是什么?
-f 是 GCC 用来启用/关闭编译“特性(feature)”的选项前缀,-fPIC 是告诉编译器生成位置无关代码,是动态库开发必备的特性之一。 反之如果看到 -fno-XXX 那就是 禁用 某个特性。

6. 链接生成动态库 .so

1
2
mkdir -p lib                                 # 创建输出目录
gcc -shared -o lib/libmathlib.so add.o sub.o # 链接成动态库

7. 测试程序调用该动态库

编译时指定头文件和库路径:

1
gcc test.c -I. -L./lib -lmathlib -o test	// 注意:-I. 是头文件路径,-L./lib 是动态库路径,-lmathlib 自动匹配 libmathlib.so

参数说明:

  • test.c:测试源文件。
  • -I.:当前目录,告诉编译器头文件 mathlib.h 在当前目录。
  • -L./lib:告诉编译器去 ./lib/ 目录下找 .so 文件。
  • -lmathlib:表示链接 libmathlib.so(前缀 lib 和后缀 .so 是自动补全的)。
  • -o test:输出最终的可执行文件 test

运行前设置动态库查找路径:因为 libmathlib.so 在当前目录,不在 /usr/lib 等默认路径,需用 LD_LIBRARY_PATH 指定路径:LD_LIBRARY_PATH=./lib ./test

1
2
3
临时环境变量设置:
LD_LIBRARY_PATH=./lib ./test # 单次运行有效
export LD_LIBRARY_PATH=./lib:$LD_LIBRARY_PATH # 当前会话有效

image-20250613230234991

如果遇到:

1
>./test: error while loading shared libraries: libmathlib.so: cannot open shared object file: No such file or directory

这是一个 非常经典的 Linux 动态库加载错误,表示已经 链接成功,但是运行时报错。根本原因:Linux 在运行程序时会去“系统的动态库搜索路径”中查找链接的 动态库(libmathlib.so),但我们的库在 ./lib/ 目录,不在默认路径中! 系统默认查找动态库的路径包括:

  • /lib
  • /usr/lib
  • /usr/local/lib

解决加载找不到动态库的方法:

  • 拷贝到系统默认的库路径/lib64/ 或 /usr/lib64/。
  • 在系统默认的库路径/lib64/ 或 /usr/lib64/下建立软连接。
  • 将自己的库所在的路径,添加到环境变量 LD_LIBRARY_PATH 中。
  • /etc/ld.so.conf.d/ 建立自己的动态库路径的配置文件,然后重新 sudo ldconfig 命令刷新缓存即可。

实际情况,我们用的库都是别人的成熟的库,都采用直接安装到系统的方式!

3. 静态库 VS 动态库

特性 静态库(.a 动态库(.so
链接方式 编译期拷贝符号 运行时动态加载
可执行文件大小 大(包含所有库函数) 小(只包含引用信息)
升级方式 需重新编译应用 替换 .so 即可
使用场景 发布单文件程序、部署简单 支持版本隔离、插件式架构、共享资源

4. 动态库的加载(重点)

一旦动态库被加载,它的代码就会被映射到进程的地址空间。此后,该库的代码就是我们进程自己的一部分,任何执行都在进程内部完成。操作系统始终知道 当前有哪些库被加载、哪些进程在使用哪些库,这一切都由 内核 + 动态链接器 管理(先描述再组织)。

  • 什么是“建立映射”:当运行一个程序,操作系统会为它创建虚拟地址空间(每个进程都有自己独立的)。动态库的内容,并不是直接“复制”一份到我们的进程里,而是操作系统建立了“映射关系”,访问它时就像是我们自己的内存一样。
  • 每个进程的动态库加载情况在哪看cat /proc/<pid>/maps | grep .so

动态库通过映射机制变成我们进程的一部分,它的代码执行就在我们的地址空间里;但代码本体可能在物理内存中被多个进程共享,而操作系统负责管理这一切。


1. 动态库在进程运行时,是如何被加载的?

结论:动态库是由 操作系统的动态链接器 在程序运行时加载进内存的。

加载时机分为两种:

加载方式 说明 举例
隐式加载(默认) 程序启动时自动加载 .so 文件 gcc test.c -lxxx 编译出的程序
显式加载 程序运行过程中用 dlopen() 主动加载 插件机制、热更新系统常用
谁负责加载?

Linux 中负责加载 .so 的是:/lib64/ld-linux-x86-64.so.2 ,这就是动态链接器,程序启动时它会完成:

  1. 找到依赖的 .so
  2. .so 加载进内存。
  3. 解析符号地址表(.got / .plt)。
  4. 将函数地址绑定到调用位置(延迟绑定)。

我们可以通过 ldd 命令查看哪个程序使用了这个链接器。


2. 动态库加载后,是否会被“所有进程共享”?

结论:是的,部分共享! 动态库在被多个程序使用时,内存中可以共享一份代码段,但 数据段不会共享
原因:代码段只读、可重定位、不包含状态数据;数据段:每个进程有自己的全局变量/堆栈。

例子:当运行两个程序时,操作系统发现他们都用到了同一个库:

  • .text 段代码是只读的,所以直接 映射到同一块物理内存
  • .data 段是每个进程自己的,分配在各自的地址空间中

所以,动态库的“代码部分”是可以被多个进程共享的,从而节省内存!

共享库和缓存的关系

动态库一旦被加载一次(如被某个程序加载),.so 文件内容可能已经缓存在内存页中,之后被其他程序再次加载时,操作系统可以 直接使用缓存的页,而不必重新从磁盘读取。这也就是 Linux 内核的 Page Cache 机制。


3. 物理地址和虚拟地址

  • 物理地址 很好理解,就是真实的内存地址,就是机器上内存条中某个具体物理位置。
  • 虚拟地址 则是 操作系统为进程“伪造”的地址空间,每个进程以为自己从 0 开始拥有 4GB(其实并没有)。

谁来把虚拟地址 → 转成 → 物理地址?
操作系统 + CPU 一起完成,依赖于 MMU(内存管理单元)+ 页表

MMU 是 CPU 内负责“地址翻译”的硬件单元,它会根据当前进程的“页表”把虚拟地址转换为物理地址。


4. 编译、加载、运行三个阶段的“地址含义”

1. 编译阶段:编译器只生成“相对地址”

编译器不知道程序会加载到内存的哪一块,所以使用 偏移量 / 虚拟地址模板(编译器也要考虑操作系统)。例子:编译后并不会写死“某个变量在物理地址 0x80000000”,而是说:”该变量位于 .data 段中的偏移 +0x04”。

2. 程序尚未加载前(可执行文件中)
  • 程序文件(ELF)中写的是 虚拟地址模板
  • 比如:
    • 代码段 .text 映射地址是 0x08048000。
    • 数据段 .data 映射地址是 0x0804a000。
  • 这些只是“建议地址”,加载时由操作系统决定。
3. 程序加载运行后

操作系统为每个进程创建 虚拟地址空间,并按照 ELF 文件中的要求分配地址空间,将指令、数据装入虚拟地址中。然后,CPU 执行指令时看到的地址是虚拟地址!


CPU 读到的指令里面的地址,是虚拟地址吗?
是的! 程序运行期间,CPU 执行的所有地址操作(读取代码、读取变量、跳转函数)都是虚拟地址。然后由 MMU 将这些地址实时映射成物理地址。


编译完成的程序里有没有地址的概念?
有! 但它们是虚拟地址模板或偏移量(不是最终的内存地址)。

image-20250614193537214

所以,编译看偏移,加载定地址,执行用虚拟,MMU 做翻译。