动态库实战:使用 gcc -shared 创建动态库

说实话,动态库这东西,我刚开始学的时候也觉得挺玄乎的。不就是个 .so 文件嘛,编译时加个 -shared 就完事了?后来踩了坑才明白,这里面的门道可不少。今天咱们就动手做一遍,把动态库的创建和位置无关代码(PIC)这事儿彻底搞明白。

一个最简单的动态库

先写个计算器模块,就两个函数:加法和乘法。代码很简单,但够用。

// calc.h
#ifndef CALC_H
#define CALC_H

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

#endif
// calc.c
#include "calc.h"

int add(int a, int b) {
    return a + b;
}

int mul(int a, int b) {
    return a * b;
}

嗯,这里要注意:头文件里的函数声明前面,我习惯不加任何修饰。动态库的导出符号,默认就是全部可见的。如果你想控制哪些符号对外暴露,那是另一个话题,咱们后面再聊。

编译成动态库

关键命令来了:

gcc -shared -fPIC -o libcalc.so calc.c

这条命令干了三件事:

  • -shared:告诉编译器,我要生成动态库,不是可执行文件
  • -fPIC:生成位置无关代码,这是动态库的灵魂
  • -o libcalc.so:输出文件名,约定俗成用 lib 开头,.so 结尾

你可能会问:不加 -fPIC 行不行?我试过,在某些平台上确实能编译通过。但千万别这么干!

⚠️ 我曾经踩过的坑:有一次在 ARM 平台上编译动态库,忘了加 -fPIC。编译没报错,运行时却随机崩溃。查了两天才发现,是代码段被加载到了不同地址,里面的绝对跳转全乱了。从那以后,我写动态库必加 -fPIC,成了肌肉记忆。

位置无关代码(PIC)到底是个啥?

说白了,PIC 就是让代码里的地址引用不依赖固定的内存位置。你想想看,动态库被加载时,它的基地址是随机的——这是地址空间布局随机化(ASLR)的要求。如果代码里写死了某个变量的地址,加载到别处不就废了?

PIC 的实现方式,核心就是「间接跳转」。看个例子:

// 非 PIC 版本(绝对地址)
mov eax, [0x4000a0]   // 直接访问固定地址

// PIC 版本(相对地址)
lea rbx, [rip + offset]  // 通过当前指令指针 + 偏移量访问
mov eax, [rbx]

第二种写法,不管代码被加载到哪个地址,rip 始终指向当前指令,加上固定的偏移量,就能正确找到数据。这就是「位置无关」的精髓。

用动态库写个测试程序

库写好了,得验证一下能不能用:

// main.c
#include <stdio.h>
#include "calc.h"

int main() {
    int x = 10, y = 20;
    printf("add(%d, %d) = %d\n", x, y, add(x, y));
    printf("mul(%d, %d) = %d\n", x, y, mul(x, y));
    return 0;
}
gcc -o test main.c -L. -lcalc
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
./test

这里有个小细节:-L. 告诉链接器在当前目录找库,-lcalc 链接 libcalc.so。运行时还得设置 LD_LIBRARY_PATH,否则系统找不到 .so 文件。

💡 我个人习惯:开发阶段用 LD_LIBRARY_PATH 临时指定路径,方便调试。但正式部署时,我会把动态库放到 /usr/lib 或 /usr/local/lib,然后运行 ldconfig 更新缓存。这样既干净又安全。

验证 PIC 的效果

咱们用工具看看动态库到底是不是 PIC 的:

readelf -d libcalc.so | grep TEXTREL

如果这条命令没输出,说明没有文本重定位,代码是纯 PIC 的。如果输出了 TEXTREL 条目,那就危险了——意味着代码段里有需要重定位的绝对地址。

再深入一点,看看符号表:

nm -D libcalc.so

输出里会看到 add 和 mul 函数,前面有个 T 标记,表示它们是导出的全局符号。

PIC 的性能代价

说实话,PIC 不是免费的午餐。每次访问全局变量或调用外部函数,都要多一次间接寻址。我做过测试,在 x86_64 上,PIC 版本比非 PIC 版本慢大约 5%-10%。

但这点性能损失,跟 ASLR 带来的安全性提升相比,完全值得。再说了,现代 CPU 的分支预测和缓存机制,已经把这部分开销压得很低了。

核心结论:动态库必须用 -fPIC 编译。这不是可选项,是必选项。除非你写的是内核模块或者裸机程序,否则别想着省这个参数。

本章知识体系

下面这张图,把动态库创建和 PIC 的核心逻辑串起来了:

动态库创建与 PIC 核心逻辑 calc.c / calc.h 源代码 gcc -shared -fPIC libcalc.so 动态库文件 运行时加载 随机基地址 PIC 核心原理 非 PIC:mov eax, [0x4000a0] PIC :lea rbx, [rip + offset] mov eax, [rbx] → 绝对地址,加载位置固定 → 相对地址,与加载位置无关 → 支持 ASLR,安全性更高 readelf -d | grep TEXTREL 验证是否纯 PIC nm -D libcalc.so 查看导出符号 LD_LIBRARY_PATH 运行时库路径 总结:源码 → 编译(-shared -fPIC)→ 动态库 → 运行时加载(随机地址)→ PIC 保证正确执行

从这张图能看出来,整个流程的核心就是 PIC。没有它,动态库就像没有地基的房子——看着挺完整,一加载就塌。

避坑指南

  • 我曾经犯过的错:在 Makefile 里只写了 -shared,忘了 -fPIC。结果在 x86 上跑得好好的,换到 ARM 上就段错误。后来我养成了习惯,写动态库的编译规则时,先把 -fPIC 写上,再补其他参数。
  • 另一个坑:链接时用了 -static 还想着用动态库。这两个是互斥的,-static 会让链接器忽略所有 .so 文件。
  • 还有:动态库的依赖链。如果你的 libcalc.so 依赖了 libmath.so,那链接 main.c 时也得把 libmath.so 带上,否则运行时符号找不到。

好了,动态库的创建和 PIC 原理,咱们就聊到这儿。动手试一遍,比看十遍文章都管用。记住那个命令:gcc -shared -fPIC -o libxxx.so xxx.c,以后写动态库,这就是你的起手式。

可可精品资料库