静态库动态库Linux静态库动态库Linux静态库动态库Linux静态库动态库静态库动态库Linux【金丹·87】静态库和动态库编译、链接和加载码农修仙传 · 金丹期 · 第87篇我是玄芯散人带你从炼气修到大乘。境界标识╔══════════════════════════════════╗ ║ 金丹期 · 第87篇 ║ ║ 静态库和动态库 ║ ║ ar / gcc -shared -fPIC ║ ║ dlopen / dlsym / soname ║ ║ 预计阅读28 分钟 ║ ╚══════════════════════════════════╝修仙引入066 那篇把链接报错的五种情境摸过一遍也顺嘴提了一句静态库 vs 动态库的命令长什么样。这一篇算「认门」弟子看完知道ar rcs能打.a、gcc -shared -fPIC能出.so。认完了门再往里走一寸发现全是问题库怎么被打包出来的符号怎么决定谁能看见外头库的版本号怎么让江湖上无数弟子同时跑着不打架程序跑起来后又怎么临场把一个库拉进进程里。修真界把这一关叫「炼器」。064 讲的是「矿石怎么熔」的理论066 讲的是「铸完发现裂了怎么办」的补救。087 要讲四件事先把矿石提纯再把料铸成灵器再教弟子战场上临阵拔刀最后给灵器登记门牌。这一篇把库的制作端和运行时加载端拆开看一遍。硬核主体066 静态库 vs 动态库回顾本篇要往哪里走066 在「静态库 vs 动态库链接行为的实战差异」那一节里写过两个命令一个是ar rcs libfoo.a foo.o bar.o baz.o一个是gcc -fPIC -shared foo.c -o libfoo.so。066 那一篇算把「使用端」点到了。可 066 没碰的是「制作端」。弟子把foo.c写成.o再用ar把若干.o打成一个.a这一段过程 066 没展开。「ar的rcs三个选项分别什么意思」「为什么做出来的.so必须用-fPIC」「dlopen跟普通的链接有什么区别」「符号表里那么一大片函数哪些会被别人看见哪些不会」「soname 这个名字到底管什么用」。这些 066 没答。修真比喻066 是修真界的「辨器」课弟子学会了认出剑与刀的区别。087 是「铸器」课弟子从铸剑到磨刀到拔刀到上户口每一步都要看一遍。静态库的制作ar把.o打成.a修真界里.a文件实际上是若干.o的归档。归档这个动作不需要链接器只需要ararchive这一把工具。ar是 GNU binutils 里的老工具比 gcc 还老专门做「把多个文件打成一捆」这件事。修真界里最常用的命令长这样# 先编译每个 .c 成 .o-c 表示只编译不链接$ gcc-cadd.c sub.c mul.c# 用 ar 把三个 .o 打成一个静态库# rcs 三个字母分别是r替换/插入、c创建归档、s写索引$ ar rcs libmath.a add.o sub.o mul.o修真界里ar rcs三个选项各管一摊事。rreplace/insert把同名成员替换掉没有就插入。ccreate归档不存在就创建。swrite index写一份符号索引让链接器能从归档里直接定位「哪个.o定义了哪个符号」不用全表扫描。这三个选项捆在一起用是标准做法少了s链接器会慢很多少了c第一次创建会失败。修真界里还有几个常用的子命令值得记# t列出归档里的所有成员$ ar t libmath.a add.o sub.o mul.o# x把成员抽出来$ ar x libmath.a add.o# d删除成员$ ar d libmath.a mul.o# --help 看所有选项$ ar--help修真界里.a文件的物理格式很简单就是把每个.o原封不动贴一起前面加一个简单的全局头叫/这个特殊成员。所以.a里的.o跟磁盘上独立的.o在 ELF 视图里是完全等价的链接器看到-lfoo时会从libfoo.a里挑出能解决当前未定义符号的那个.o整段整段塞进可执行文件。修真界里做一组动手演示把三个.o打成一个静态库再链接出可执行文件// math.hintadd(inta,intb);intsub(inta,intb);// add.c#includemath.hintadd(inta,intb){returnab;}// sub.c#includemath.hintsub(inta,intb){returna-b;}// main.c#includestdio.h#includemath.hintmain(void){printf(%d\n,add(3,4));// 期望 7printf(%d\n,sub(10,3));// 期望 7return0;}$ gcc-cadd.c sub.c $ ar rcs libmath.a add.o sub.o $ gcc main.c -L.-lmath-oapp $ ./app77修真界里-L.把当前目录加进库的搜索路径-lmath是「找libmath.a或libmath.so」的简写。链接器按顺序扫描发现add和sub两个未定义符号都能在libmath.a里解决就把对应的.o整个拉进可执行文件。066 已经讲过库顺序的细节被依赖的库要放后面本篇不重复。修真比喻静态库是修真界的「手抄本」弟子把宗门功法一页页抄一份揣身上副本多就占地方。动态库是修真界的「玉简传功」所有人到宗门同一个玉简那里读取省地方但宗门得一直开着。动态库的制作gcc -shared -fPIC出.so修真界里.a是装订成册的印本.so不一样。.so里的代码要在运行时被加载到任意地址还要在多个进程之间共享同一份物理页。所以制作.so时有两件事比.a多一步。第一件加-shared让 gcc 调链接器产出动态库而不是可执行文件。第二件加-fPICPosition Independent Code位置无关代码让编译器生成的代码用相对地址访问不依赖加载地址。这两件事拆开说# 第一步编译时加 -fPIC生成位置无关的 .o$ gcc-c-fPICadd.c sub.c# 第二步链接时加 -shared产出 .so$ gcc-shared-olibmath.so add.o sub.o# 也可以一步到位$ gcc-fPIC-sharedadd.c sub.c-olibmath.so修真界里-fPIC单独写是给编译器听的-shared单独写是给链接器听的。两件事可以合在一条命令里但语义上各管各的-fPIC决定代码生成-shared决定最终产物类型。把这两件事分清楚写 CMakeLists 或者 Makefile 时就不会迷糊。修真界里做出来的.so可以用file看一眼$filelibmath.so libmath.so: ELF64-bit LSB shared object, x86-64, version1(SYSV), dynamically linked,...修真界里这条输出里的「shared object」就是动态库的 ELF 类型缩写跟可执行文件的「executable」是两回事。修真比喻动态库是修真界的「玉简传功」弟子不揣走原件需要时去宗门玉简那里抄阅。宗门里只有一份原件所有弟子都对着同一份读省地方但要靠宗门一直开着。为什么需要-fPIC修真界的「坐标重写」修真界里-fPIC看着像一个可有可无的开关实际上是动态库的硬性要求。这事得从代码怎么访问「自己」讲起。修真界里一段普通的 C 代码如果要访问一个全局变量比如int counter 0; counter 1;编译器会生成这样的指令mov rax, [rip 0x2a3a] ; 把 0x2a3a 当成「counter 的地址」 add dword [rax], 1修真界里麻烦出在两处。第一处是函数调用别的函数。如果函数 A 在编译时知道 B 在自己前方 100 字节的位置编译器会生成call 0x401050这种「绝对地址调用」。代码段被加载到0x401050上没问题加载到0x7f0000001000上就跳错了。第二处是访问函数自身的地址拿函数指针等这事在 C 代码里少碰但编译器内部实现「可重入」代码时绕不开。修真界里-fPIC就是为这两处准备的。GCC 的做法是把代码里所有「跳到固定地址」的地方改成「先查一张表GOT, Global Offset Table表里的地址再指过去」。.so加载到内存时动态链接器ld-linux.so负责把这张表填好。所以叫「位置无关」代码本身不写死任何绝对地址全靠 GOT 中转加载到哪都能跑。修真界里 32 位 x86 上还有更复杂的麻烦。32 位没法用 RIP 相对寻址访问数据必须先算出 GOT 的地址再取数据。GCC 在 32 位上用ebx寄存器专门存 GOT 的地址每个函数入口都得做call __x86.get_pc_thunk.bx; add ebx, ...这种开胃菜开销比 64 位大一截。所以 64 位上偶尔有人想省一点性能会加-fno-pic但开了会带来额外开销不建议这么干。修真比喻修真界的功法书页是用相对坐标写的「前三行」「下面第七行」这种描述不写「第 401050 页」。分舵把书页放在哪都一样读。-fPIC是修真界的「坐标重写法」把书页从绝对页码改成相对坐标不重写法直接拿过去读分舵挪个位置就全错位了。dlopen/dlsym/dlerror/dlclose修真界的临阵拔刀修真界里前面的.so用法都假设「编译时知道要链接哪个库」。可修真界里还有一类用法编译时完全不知道程序会用到哪个库要等运行时才决定。比如一个播放器支持的编码格式由用户在配置文件里指定。比如一个 Web 服务器第三方模块以.so文件形式放在某个目录里服务器启动时扫一遍目录把每个.so都拉进来。比如 Python 的 C 扩展、Emacs 的 dynamic module都走这条路。修真界里这条路叫「动态加载」dynamic loadingPOSIX 标准给了四个函数#includedlfcn.hvoid*dlopen(constchar*pathname,intmode);void*dlsym(void*handle,constchar*symbol);intdlclose(void*handle);char*dlerror(void);修真界里这四个函数的分工很清楚。dlopen打开一个.so返回一个不透明的句柄handle。dlsym在句柄里查一个符号返回它的地址强转成函数指针用。dlclose关掉句柄引用计数减一引用归零时真的卸载。dlerror返回最近一次失败的错误信息没失败就返NULL。修真界里dlopen的第二个参数mode有两个最常用的值RTLD_LAZY函数引用延迟到第一次调用时才解析和RTLD_NOWdlopen调用时立刻把全部符号解析完。RTLD_LAZY效率更高但风险是漏网的符号错误要到运行时才暴露RTLD_NOW启动慢一点但出错立刻现形。实际项目里推荐用RTLD_NOW启动时报错比跑到一半崩溃好排查得多。RTLD_GLOBAL让这个库导出的符号进入全局命名空间RTLD_LOCAL默认把符号限制在本库内。修真界里把四个函数串起来写一段标准的「插件加载器」// plugin_loader.c#includedlfcn.h#includestdio.h#includestdlib.htypedefint(*plugin_init_fn)(void);typedefint(*plugin_run_fn)(constchar*arg);intmain(intargc,char**argv){if(argc2){fprintf(stderr,用法: %s plugin.so\n,argv[0]);return1;}// RTLD_NOW 让所有未定义符号当场报错void*handledlopen(argv[1],RTLD_NOW);if(!handle){fprintf(stderr,dlopen 失败: %s\n,dlerror());return1;}// 清掉 dlopen 可能产生的错误信息dlerror();// 取两个符号初始化函数和运行函数plugin_init_fn init(plugin_init_fn)dlsym(handle,plugin_init);constchar*errdlerror();if(err){fprintf(stderr,找 plugin_init 失败: %s\n,err);dlclose(handle);return1;}plugin_run_fn run(plugin_run_fn)dlsym(handle,plugin_run);errdlerror();if(err){fprintf(stderr,找 plugin_run 失败: %s\n,err);dlclose(handle);return1;}if(init()!0){fprintf(stderr,插件初始化失败\n);dlclose(handle);return1;}intrcrun(hello world);printf(插件返回 %d\n,rc);dlclose(handle);return0;}// plugin_hello.c要编译成 libhello.so#includestdio.hintplugin_init(void){printf(hello 插件初始化\n);return0;}intplugin_run(constchar*arg){printf(hello 插件跑起来: %s\n,arg);return0;}$ gcc-fPIC-sharedplugin_hello.c-olibhello.so $ gcc plugin_loader.c-oplugin_loader-ldl$ ./plugin_loader ./libhello.so hello 插件初始化 hello 插件跑起来: hello world hello 插件返回0修真界里-ldl是链接libdl.so那一行提供dlopen/dlsym等函数的实现。dlsym还有两个特殊句柄RTLD_DEFAULT按默认搜索顺序查找符号RTLD_NEXT从当前库之后的库开始查常用于「hook 别人的函数」。LD_PRELOAD那种劫持malloc的玩法靠的就是RTLD_NEXT。修真比喻动态加载是修真界的「临阵拔刀」。弟子进山门前不知道会遇上哪种妖兽出招前也没法把刀库全背在身上。临场从百宝囊dlopen里掏一把刀dlsym用完塞回去dlclose。掏错刀找不到符号百宝囊会吐一张小纸条dlerror说明白到底哪儿错了。符号可见性修真界的「谁能看见这把剑」修真界里编译出来的.so文件里所有「非 static」的 C 函数和非 static 全局变量默认都进了动态符号表。这意思是nm -D libfoo.so默认能看到一堆符号全是公开的。修真界里这背后有三个问题。第一个问题库里某些「只给自己用」的辅助函数被暴露出来使用方一不小心声明了同名函数就冲突了C 名字修饰name mangling不一致时尤其乱。第二个问题库里任何内部数据结构一改整个库的 ABI二进制接口约定就变了所有依赖这个库的可执行文件都得重编译。第三个问题符号表越大动态链接器解析时间越长库启动越慢。修真界里要修这三件事根子是「符号可见性」symbol visibility。可见性有四种值GCC 上是default、hidden、internal、protected值行为default默认符号进入动态符号表别人能看见hidden符号不进动态符号表外部看不见但函数内仍可调internal比 hidden 还严汇编阶段就当本地符号用protected符号被导出但同一库内调用不经过 PLT性能更好修真界里最常用的一招是把整个库默认设成 hidden再单独挑几个想暴露的函数设成 default。这是「白名单」模式比默认全暴露再单挑隐藏「黑名单」模式安全得多。写法上用编译参数-fvisibilityhidden给整个编译单元默认隐藏再用__attribute__((visibility(default)))给单个函数开窗。实际项目里更常见的做法是用「导出宏」收敛到一个头文件方便跨 Windows / Linux 维护// foo_export.h#ifdef_WIN32#defineFOO_EXPORT__declspec(dllexport)#else#defineFOO_EXPORT__attribute__((visibility(default)))#endifintFOO_EXPORTpublic_api(intx);这种宏叫FOO_EXPORT命名按库名走库的每个公开函数都加这个宏内部函数一个都不加。-fvisibilityhidden作为全局开关配上FOO_EXPORT作为白名单是工业界做公共库的标准做法。Linux 上 glibc、各大开源 C 库都这么写。Windows 上没有 visibility 属性用的是__declspec(dllexport)/__declspec(dllimport)宏就是为了在两边都写一份兼容代码。修真比喻可见性是修真界的「传功规矩」。宗门里有些功法可以外传default有些只能内部弟子修hidden。如果不分清楚外面弟子胡乱喊一招内部功法的名字要么没人应答链接报错要么窜了真法走火入魔ABI 不兼容崩溃。修真界正经宗门的规矩是「外传的有目录内部的不出声」这套规矩在 gcc 上叫-fvisibilityhidden 白名单宏。soname动态库的「身份证号」修真界里.so文件的「名字」比想象复杂。一个.so实际有三种名字real name、soname、linker name。三者的关系修真界里必须搞清楚。修真界里real name是磁盘上文件的真名必须带主版本次版本修订号的命名例如libfoo.so.1.0.0。这一段是文件物理实体动态链接器加载时按这个名字找文件。soname是「库的身份号」记录在.so文件的.dynamic段里的DT_SONAME字段里例如libfoo.so.1。可执行文件的「我需要哪些.so」清单里DT_NEEDED登记的是 soname 不是 real name。这一招让 soname 在 bug fix 升级比如libfoo.so.1.0.0替换为libfoo.so.1.0.5时不用动可执行文件。linker name也叫 developer name是编译时-lfoo找的那个名字比如libfoo.so通常是个符号链接指向soname或real name。修真界里三者的关系画出来渲染错误:Mermaid 渲染失败: Lexical error on line 12. Unrecognized text. .../usr/lib/libfoo.so.1] H -- I[li -----------------------^修真界里给库设 soname 的做法是是在链接时加-Wl,-soname$ gcc-fPIC-shared-Wl,-soname,libmath.so.1 add.c sub.c-olibmath.so.1.0.0# 查看 soname$ readelf-dlibmath.so.1.0.0|grepSONAME 0x000000000000000e(SONAME)Library soname:[libmath.so.1]# 创建 soname 符号链接修真界约定俗成$ln-slibmath.so.1.0.0 libmath.so.1 $ln-slibmath.so.1.0.0 libmath.so# 编译期链接$ gcc main.c -L.-lmath-oapp# 链接器找 libmath.so → libmath.so.1 → libmath.so.1.0.0修真界里 soname 的版本号设计有讲究。约定俗成主版本号major变了表示 ABI 不兼容所有依赖这个库的可执行文件都得重编译从版本号minor变了表示加了新功能但 ABI 兼容修订号patch变了只是 bug fix。这条规矩让发行版比如 Debian的「依赖管理」成为可能。库装到/usr/lib后ldconfig会扫一遍所有.so把每个 soname 对应的文件登记到/etc/ld.so.cache动态链接器加载时直接查缓存不扫目录。/* libmath.version */ LIBABC_1.0 { global: add; sub; local: *; /* 其他所有符号都隐藏 */ };$ gcc-fPIC-shared-Wl,--version-script,libmath.version\add.c sub.c-olibmath.so.1.0.0修真界里这个 map 文件格式是 ld 脚本的子集叫「linker version script」。它不仅管「暴露不暴露」还管「这个符号属于哪个版本」动态链接器加载时能精准挑出版本对应的符号。大库里 glibc、libstdc 都用这个机制保证 ABI 兼容性。修真比喻soname 是修真界的「灵器身份证号」。一把剑real namelibfoo.so.1.0.0有多个名字弟子入门时喊的剑号linker namelibfoo.so、江湖登记的门派代号sonamelibfoo.so.1、以及物理上这把剑的真身。只要门派代号不变剑怎么磨都还是这把剑门派代号一变比如libfoo.so.2就说明这把剑被宗门重新铸造过了所有用旧剑的功法可执行文件都得回炉重修。静态库 vs 动态库修真界的决策树修真界里实战项目总要在两种库之间选。把这一节做成决策树方便弟子按症状开药。修真界里先看四个主要差异对比项静态库.a动态库.so链接时机链接时整段复制进可执行链接时只留 DT_NEEDED 清单运行时加载可执行文件大小大含库代码小只含 GOT/PLT 引用启动速度快不需要加载外部.so慢要扫 DT_NEEDED、解析符号升级库必须重链接可执行文件直接替换.so可执行不用动部署复杂度简单单文件复杂要保证目标机器有对版本的.so-fPIC必需否是修真界里按四种情况做选择第一种情况库要被多个进程用且进程之间需要共享物理内存。Linux 系统库走这条路比如 glibc 和 libpthread 都是.sobash、ssh、vim几十个进程同时在跑全都链libc.so.6物理内存里实际只占一份。如果每个进程都把 libc 静态链进去几百兆物理内存就没了。第二种情况库很少更新且对启动速度敏感。嵌入式 Linux 设备经常这么干所有依赖打包成一个静态二进制部署到设备上不依赖/usr/lib里有什么。这种「单文件可执行」部署起来最省心不用考虑目标机的库版本。Docker 容器里的 Go 程序也走这条路线。第三种情况库要作为插件机制的一部分。这一条就是上一节dlopen的领域必须是.so不能用.a。因为.a在编译期就被复制进可执行了没法「运行时临时加载」。第四种情况库的 ABI 不稳定还在快速迭代。这种情况下用.a更安全.a把代码复制进可执行避免了升级.so时不同进程之间互相干扰。代价是每次库更新都要重链整个项目。修真比喻修真界里选静态库还是动态库跟选「自带干粮」还是「到分舵吃饭」一个道理。自带干粮静态省心但累赘路上补给要够。到分舵吃饭动态轻装上阵但分舵关门.so找不到就饿肚子。长途任务嵌入式部署自带干粮开在固定山门的服务服务器进程到分舵吃就行。几个修真界里常见的踩坑修真界里库这条路上有几颗常踩的雷。第一颗雷.so没加-fPIC。x86_64 上编译能过运行时跳转到库代码时崩在 GOT 重定位上报错是recompile with -fPIC。第二颗雷soname 设了但磁盘上的符号链接没建对。编译时-lfoo找不到libfoo.so运行时DT_NEEDEDlibfoo.so.1找不到libfoo.so.1。这种雷在跨机器部署dev 编出来扔到 prod 跑时高发。第三颗雷dlopen后没dlclose长跑进程里.so累积在内存里导致 OOM。更隐蔽的情况是dlsym返回的函数指针被静态缓存dlclose后函数指针变成野指针下次调用崩。第四颗雷C 库没加extern C就被dlopen加载。C 函数有名字修饰name manglingdlsym(handle, MyFunc)找不到符号。要么给库函数加extern C要么用cfilt反推。第五颗雷符号可见性没管static函数漏到动态符号表。在 C 里static函数本来就是「本翻译单元内可见」但全局变量没说static就默认 external linkage进.so时就成了默认可见。教训所有不想暴露的函数和全局变量要么加static要么靠-fvisibilityhidden兜底。修真比喻修真界这几颗雷每一颗都让弟子写过不少 bug。-fPIC缺了是「铸剑没用好钢」soname 漏了是「灵器没上户口」句柄泄漏是「百宝囊不收口」extern C漏了是「暗号没对上」符号可见性漏了是「宗门密法泄到了江湖」。修真界里这五颗雷踩中一颗调试起来都痛苦。建议弟子每写一个库就拿nm -D、readelf -d、file这三个工具查一遍三分钟的事能省后面三天。修仙术语对照表修仙术语技术现实本篇位置装订器ar静态库归档工具静态库制作印本典籍静态库.a整段复制进可执行静态库制作玉简传功动态库.so运行时加载动态库制作坐标重写-fPIC位置无关代码为什么需要 -fPIC中转书页GOTGlobal Offset Table为什么需要 -fPIC临阵拔刀dlopen运行时加载库dlopen / dlsym暗号簿dlsym按名字查符号dlopen / dlsym错误小纸条dlerror取错误信息dlopen / dlsym临场收刀dlclose关闭库句柄dlopen / dlsym传功规矩符号可见性 visibility符号可见性外传功法目录__attribute__((visibility(default)))符号可见性宗门密法不出声-fvisibilityhidden符号可见性灵器身份证号sonameDT_SONAMEsoname门派代号soname 里的主版本号soname真身real name 如libfoo.so.1.0.0soname暗号linker name 如libfoo.sosoname自带干粮静态部署独立可执行静态 vs 动态到分舵吃饭动态部署运行时加载.so静态 vs 动态铸剑缺钢.so没加-fPIC常见坑灵器无户口soname 没建符号链接常见坑百宝囊不收口dlopen后没dlclose常见坑暗号没对上C 名字修饰与dlsym不匹配常见坑进阶条件能用ar rcs打出一个静态库并用ar t查看成员列表能用gcc -fPIC -shared打出一个动态库并用file确认产物是 ELF shared object能解释为什么动态库必须加-fPIC以及 GOT 在 PIC 里扮演什么角色能用dlopendlsymdlclosedlerror写一个简单的插件加载器能区分RTLD_LAZY和RTLD_NOW的差异并说出一个推荐用RTLD_NOW的真实案例能用-fvisibilityhidden加白名单宏把一个库的导出符号收到只剩几个公开 API能解释 soname 与 real name、linker name 三者的关系并画出从编译期到运行期的查找路径能用-Wl,-soname给.so设 soname并正确建好符号链接让-lfoo编译能过、运行时ldd也能找到修真界里这些条件全通过库的制作和加载这一关就算破了。修真路不止下一篇是 088 Makefile看看弟子怎么把gcc -fPIC -shared -Wl,-soname,libmath.so.1 ...这一长串命令写成一个能自动跑的脚本。下期预告 互动下一篇088 Makefile——把手动编译改成自动化脚本。弟子总不能每次都手敲一长串gcc -fPIC -shared -Wl,-soname,libmath.so.1 ...命令。Makefile 怎么写变量怎么用自动推导怎么省事.PHONY是干啥的GNU Make 的模式规则怎么配 CMake下一篇把这些拆开看。互动话题你修真路上踩过.so没加-fPIC的坑吗报错信息是recompile with -fPIC还是别的你的项目里是用静态库多还是动态库多选型的理由是什么评论区等你。落款本文是「码农修仙传」系列第87篇。系列导航见 xren.ren