
编译过了运行却“翻车”Linux 动态库加载失败的完整排查手册写C/C的人应该都经历过这种比编译报错更让人上火的场景gcc干干净净出了一行提示make顺利跑完你满怀信心敲下./app结果屏幕上甩来一句error while loading shared libraries: libfoo.so.1: cannot open shared object file: No such file or directory。编译过了运行却崩在启动加载动态库这一关。更难受的是这种问题不像编译错误那样有明确的“文件:行号”指引它往往牵扯到搜索路径、soname、符号解析、缓存机制甚至一个环境变量就能让你的程序在别人机器上好好的、在你机器上死活跑不起来。这篇文章就是一份针对 Linux 动态库加载失败的完整排查手册。我会从最直观的ldd输出开始一直深入到strace系统调用层面把“找不到库”“找到错版本”“符号冲突”“加载路径顺序”这几类最常见问题的原理和排查链路全部拆开揉碎。无论你是刚接触 Linux 下的 C/C 开发还是已经在生产环境里被动态库坑过几回这份手册里的命令和思路应该都能帮你少走弯路。1. 先搞懂“编译过了”和“能跑起来”是两回事1.1 一个典型的“翻车”现场先还原一下最常见的报错长什么样。假设我们有一个主程序app它依赖一个自定义动态库libhello.so$ gcc -o app main.c -L. -lhello $ ./app ./app: error while loading shared libraries: libhello.so: cannot open shared object file: No such file or directory注意第一行命令gcc在编译链接的时候通过-L.告诉链接器“去当前目录找库”通过-lhello让它链接libhello.so。这一步成功了所以生成了app这个可执行文件。但到第二行真的去运行app时系统却告诉你找不到libhello.so。这就是最典型的认知误区很多人以为“链接的时候找到了库运行的时候也一定能找到”。实际上链接器的搜索路径和运行加载器的搜索路径是两套体系它们各自有各自的规则。编译链接时用-L指定路径只是让链接器能拿到库、解析符号、生成可执行文件而真正把这个可执行文件加载进内存、把依赖的.so一个一个拉起来是内核加动态加载器通常是ld-linux-x86-64.so.2干的事它根本不认识-L选项。1.2 动态链接的两段式生命周期想彻底理解这类问题脑子里得有一个清晰的模型一个动态链接程序的“链接”其实是分两阶段完成的。第一阶段是编译链接期。编译器把源码变成目标文件链接器ld或gcc内部调用的链接器负责把目标文件和你指定的库拼在一起。这个阶段最重要的产物之一是可执行文件里的DT_NEEDED字段里面记录的并不是库的绝对路径而通常只是库的soname比如libhello.so。换句话说链接器只是在你编译时把“这个程序需要libhello.so”这个需求写进了文件头并没有把库的代码复制进去。第二阶段是运行加载期。你执行./app时内核先启动动态加载器动态加载器读取app的DT_NEEDED然后按照一套固定的搜索规则去磁盘上找这些库找到之后再读取它们的DT_NEEDED一层一层把所有依赖库全部加载进进程地址空间最后才把控制权交给main。这个阶段如果任何一个库找不到、找到的是错误的版本、或者库里缺少某个符号程序就会在真正执行main之前直接终止。理解了这个两段式模型再回头看报错“编译过了、运行翻车”就一点不奇怪你只是完成了第一阶段第二阶段才刚刚开始。这也是为什么排查动态库问题的时候第一反应不应该是“重新编译”而应该是“看看运行时加载器到底是怎么找库的”。2. 第一板斧看懂 ldd 的输出定位 not found2.1 ldd 输出的每一列意味着什么ldd是排查动态库缺失最常用的命令没有之一。它本质上会运行动态加载器把目标文件的所有依赖库按搜索规则解析一遍然后打印出来$ ldd ./app linux-vdso.so.1 (0x00007ffe8d7d4000) libhello.so not found libc.so.6 /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1234567000) /lib64/ld-linux-x86-64.so.2 (0x00007f1234569000)输出三列第一列是依赖库的 soname第二列之后是运行时加载器实际找到的绝对路径括号里是加载地址。看到libhello.so not found时事情就清楚了你的程序在启动时需要libhello.so但加载器按它的搜索规则全盘找了一遍没找到。我一般会先确认一下这个库文件是不是真的存在$ ls -l libhello.so -rwxr-xr-x 1 root root 12345 Jan 1 00:00 libhello.so存在那问题就变成了文件在但加载器不去当前目录找。这是新手最容易踩的第一坑。Linux 动态加载器的默认搜索路径不包括当前目录。这和 Windows 下先在应用程序所在目录找 DLL 的机制完全不同。此时最简单的临时解法是设置环境变量$ LD_LIBRARY_PATH. ./app这会告诉加载器“额外去当前目录找”。为什么说是“临时解法”因为LD_LIBRARY_PATH只在当前进程环境里生效换个终端、换个环境、或者写进 systemd 服务里又找不到库了。后面我会讲更规范的解决方式但排查阶段用它快速验证“是不是路径问题”非常高效。2.2 ldd 显示正常程序还是起不来的几种情况有一种更隐蔽的情况ldd ./app输出全部正常每个依赖都有路径没有not found但程序运行还是报错或者直接崩溃。这时候不要怀疑ldd欺骗了你它确实执行了加载器但ldd只是把库的加载路径打印出来并没有真正运行程序内部的代码。有几个事情是ldd看不出来的第一递归依赖的问题。ldd打印的是整个依赖树但如果某个深层库本身依赖了一个不存在的库ldd也会显示出来所以这个一般能拦住。可如果依赖树里有循环依赖或者某个库依赖了相同 soname 但不同路径的库ldd只显示最终解析结果不会告诉你解析过程有多曲折。第二符号解析失败。库文件都在路径也都在但程序加载某个库时发现它缺少一个程序需要的符号比如undefined symbol: foo_bar。这类错误经常不是出现在ldd阶段而是在真正dlopen或运行时报出来。第三权限问题。库文件存在路径也对但当前用户对库文件没有读权限。加载器不会给你一个优雅的Permission denied它通常会报成找不到文件或者干脆表现为程序由于库加载失败而终止。这种情况ls -l一看就能注意到访问权限列。第四架构不匹配。如果你的程序是 64 位的却给它配了 32 位的库路径加载器会直接忽略或者报错ldd有时会输出类似的错误信息但更多时候表现得很像“找不到库”。我自己在排查时会遵循一个原则ldd正常只是第一关后面还得靠strace和readelf继续深挖永远不要因为ldd全绿就以为万事大吉。3. 版本错位与 soname编译时能过运行时炸的元凶3.1 链接器找 libxxx.so加载器找 libxxx.so.N动态库在 Linux 下有一套自己的命名和版本管理规则。一个典型的库安装后通常会有三个名字真实文件名如libhello.so.1.2.3、soname如libhello.so.1和链接器名字如libhello.so。它们的区别很关键名字类型示例谁用作用真实文件名libhello.so.1.2.3磁盘存储库的实际文件sonamelibhello.so.1运行时加载器嵌入可执行文件的DT_NEEDED记录的是接口兼容版本链接器名libhello.so编译期链接器-lhello找的就是这个名字编译时你执行gcc -lhello链接器去找libhello.so这个文件通常是一个符号链接指向某个真实版本。链接器把这个库的 soname 记进可执行文件。假如这个库的 soname 是libhello.so.1那么你的可执行文件里的DT_NEEDED就会是libhello.so.1而不是libhello.so。等到运行时加载器只认 soname它去找libhello.so.1。这就是一个非常经典的翻车场景你编译时系统里有libhello.so - libhello.so.1.0.0链接器很满意可执行文件写入了libhello.so.1后来你升级了库把libhello.so.1删了只留下libhello.so.2或者你把编译好的二进制拷贝到另一台只装了libhello.so.2的机器上运行时就会出现error while loading shared libraries: libhello.so.1: cannot open shared object file你明明看到/usr/local/lib/libhello.so就躺在那里ls一下还显示得清清楚楚但程序就是不认。因为加载器找的是 soname不是链接器名。3.2 用 readelf 确认程序到底需要哪个版本遇到这种情况第一条命令就是用readelf直接查看可执行文件里记录的依赖$ readelf -d ./app | grep NEEDED 0x0000000000000001 (NEEDED) Shared library: [libhello.so.1] 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]输出非常明确这个程序需要的是libhello.so.1不是libhello.so。然后你去系统里搜这个 soname$ find /usr /lib /lib64 -name libhello.so* 2/dev/null /usr/local/lib/libhello.so.2.0.0 /usr/local/lib/libhello.so.2 /usr/local/lib/libhello.so这里只有libhello.so.2和指向它的libhello.so没有libhello.so.1那加载器当然找不到。这时候有两个方向解决如果程序源码在你手上最干净的做法是重新链接让你的程序依赖系统里实际存在的 soname 版本。如果程序是第三方二进制没法重编那就得想办法提供它需要的libhello.so.1。在某些发行版上可以直接把高版本的库做一个符号链接ln -s libhello.so.2 libhello.so.1但这是极其 hack 的做法只有当你非常确信它们 API/ABI 兼容时才建议这么干不然程序可能在运行到某个函数时直接崩溃而且崩得很莫名其妙。还有一个细节值得单独提很多大型软件依赖的库链特别长比如一个 Qt 程序可能依赖七八个 Qt 库每个 Qt 库又依赖一堆系统库。这个时候手动readelf -d一个个查会疯掉的更高效的方式是写个循环把整个依赖树扫一遍$ ldd ./app | awk {print $1} | while read lib; do echo $lib readelf -d /lib/x86_64-linux-gnu/$lib 2/dev/null | grep NEEDED done这个脚本能把所有依赖库的DT_NEEDED全部列出来哪个库版本不对一目了然。不过别太依赖脚本理解 soname 与链接器名的区别才是根治问题的前提。4. LD_LIBRARY_PATH、rpath 与 runpath搜索路径的优先级之战4.1 加载器的真实搜索顺序找到了库存在、soname 也匹配但程序还是加载了错误版本或者在某些环境下加载失败下一步就要关注搜索路径的优先级了。Linux 动态加载器搜索一个 soname 时一般遵循这个顺序内嵌在可执行文件里的DT_RPATH如果存在且没有DT_RUNPATH优先级最高甚至高于LD_LIBRARY_PATH环境变量LD_LIBRARY_PATH可执行文件里的DT_RUNPATH注意如果DT_RPATH被转换成DT_RUNPATH则优先级会掉到LD_LIBRARY_PATH之后/etc/ld.so.cache缓存文件由ldconfig生成默认目录/lib、/usr/lib等通常是/lib64、/usr/lib64具体取决于发行版和架构很多人以为LD_LIBRARY_PATH是万能的设了就一定优先其实不是。当可执行文件带有RPATH旧式DT_RPATH时它会比LD_LIBRARY_PATH还要先命中。这就会导致一个非常诡异的现象你明明设了LD_LIBRARY_PATH/opt/mylib但程序依旧加载了/usr/lib下的旧版本库因为你构建这个程序时给它写死了一个RPATH/usr/lib。反过来还有一种情况你用-Wl,--enable-new-dtags设置了RUNPATHDT_RUNPATH那么这个优先级就在LD_LIBRARY_PATH之后此时你设置环境变量反而能覆盖程序的默认库路径。这两种机制一字之差行为完全不同排查时必须用readelf -d看清楚你的程序到底是RPATH还是RUNPATH。4.2 构建时怎么埋 RPATH/RUNPATH排查是不变的起点但更好的做法是在构建阶段就把路径规划好。我见过太多团队库装在/opt/company/lib程序里没有任何路径信息只能靠每个运行环境的.bashrc里export LD_LIBRARY_PATH/opt/company/lib续命。这种方式在单个开发机上没啥问题一旦上生产、上容器、上 systemd环境变量经常不生效然后就是通宵排查。更稳健的做法是用链接选项把搜索路径写进可执行文件$ gcc -o app main.c -L. -lhello -Wl,-rpath,/opt/company/lib-Wl,-rpath会把/opt/company/lib写入可执行文件的RPATH或RUNPATH。默认情况下GNU 链接器写的是RPATHDT_RPATH为了获得更好的可覆盖性推荐加--enable-new-dtags让它写成RUNPATHDT_RUNPATH$ gcc -o app main.c -L. -lhello -Wl,-rpath,/opt/company/lib -Wl,--enable-new-dtags注意如果目标是“相对可执行文件位置的相对路径”还可以用$ORIGIN这是加载器支持的动态变量在运行时展开为可执行文件所在目录$ gcc -o app main.c -L. -lhello -Wl,-rpath,$ORIGIN/lib -Wl,--enable-new-dtags这种方式特别适合随安装包分发、没有固定系统前缀的软件。不过要小心引号的转义问题在 Makefile 里写$ORIGIN时$会被 Make 解释需要写成$$ORIGIN这是个经典坑我记得当年第一次踩到的时候浪费了快一个小时。4.3 ldconfig 缓存与 ld.so.conf 的关系再来说/etc/ld.so.cache。很多系统管理员喜欢直接把库放/usr/local/lib然后运行ldconfig让系统“记住”这个路径程序就能找到了。这个缓存包含的是从/etc/ld.so.conf及各发行版默认目录里扫描到的所有库路径和 soname 的索引。这里有一个比较容易混淆的点如果你修改了/etc/ld.so.conf或者加入了新的库必须重新运行ldconfig刷新缓存否则加载器不会感知到变化。有时候你把一个动态库复制到/usr/local/lib后发现程序还是找不到第一反应是去检查缓存$ ldconfig -p | grep libhello libhello.so.1 (libc6,x86-64) /usr/local/lib/libhello.so.1如果缓存里没有这一行说明ldconfig没跑或者/usr/local/lib不在扫描路径里。此时可以强制指定配置目录重建缓存$ echo /usr/local/lib /etc/ld.so.conf.d/local.conf $ ldconfig这里还有个隐蔽的顺序问题ldconfig缓存命中的优先级在LD_LIBRARY_PATH和RUNPATH之后。这意味着即使你把新库加入了缓存如果程序本身带有RPATH且RPATH里有一个旧版本的同类库缓存里的新版本依然轮不到上场。遇到这种情况光折腾ldconfig是没用的得回到readelf看程序内嵌的路径。5. dlopen 运行时加载失败比启动崩溃更隐蔽的雷5.1 用 dlerror 拿到第一手错误信息上面几节主要讨论程序启动时由动态加载器主导的加载失败。但还有一大类问题发生在程序运行过程中通过dlopen手动加载插件或模块的时候。这种问题更隐蔽程序已经跑起来了日志里可能只有一句空洞的“module load failed”没有任何细节。如果你写的代码是直接调用dlopen的一定要养成相信dlerror()返回值的习惯。dlopen失败后dlerror()会返回一个包含具体原因的 C 字符串可能是“文件找不到”“符号未定义”“参数错误”等但很多人在实际代码里只判断返回值是否为NULL日志里只打一句“failed to load”把最有价值的诊断信息丢掉了。void* handle dlopen(./plugins/core.so, RTLD_NOW | RTLD_LOCAL); if (!handle) { fprintf(stderr, dlopen error: %s\n, dlerror()); exit(1); }我强烈建议所有做插件系统的项目在产品代码里保留这个dlerror的输出路径线上排障能省一大半力气。日志里哪怕只有一行dlopen error: libssl.so.1.1: cannot open shared object file问题就已经定位到渗透层了。5.2 符号冲突、全局符号介入与可见性还有一种dlopen阶段才会碰到的隐蔽问题符号冲突。假设你的主程序已经链接了一个静态包含老版本libpng插件里又dlopen了一个内置新版本libpng的库由于 ELF 的全局符号介入机制插件里对png_read_image的调用可能解析到了主程序里的老版本符号运行时就会开始各种奇怪的崩溃而且崩溃栈往往指向一个看起来毫不相关的函数。定位这类问题可以用dlsym配合RTLD_DEEPBIND标志但这是个双刃剑RTLD_DEEPBIND让插件优先解析自己的符号可以规避一部分冲突但也可能让插件调不到主程序刻意导出的符号于是引出新的诡异问题。更长期的预防策略是在编译插件和动态库时通过-fvisibilityhidden控制符号可见性只导出刻意标记的 API。这个做法能让动态库的“表面”干净很多内部实现细节不会污染全局符号表。查看一个动态库导出了哪些符号用nm -D是最直接的$ nm -D ./libhello.so U printfGLIBC_2.2.5 0000000000001120 T hello_world 0000000000001150 T hello_internalT表示已经定义且可导出的符号U表示未定义的导入符号。如果你发现一个库里导出了大量以internal_开头的符号而且主程序或其他库也有同名符号那么符号冲突的风险就始终存在。通过对比nm -D的输出你可以快速判断是不是符号撞了车。另外值得一提的场景是构建加-l时链接到的库与运行dlopen的不是同一份。很多项目在编译插件时用-lfoo解析了编译期的符号但插件真正运行时的符号是运行时动态解析的如果运行路径下的libfoo.so和编译路径下的不一致就会出现“编译过了运行翻车”的翻版。唯一能做的就是在部署时对着ldd的输出严格确认产物链接的每一份库的来源。6. 深挖底层strace 看加载全过程nm 和 objdump 查符号6.1 strace 能看到最真实的加载路径有时候上面所有常规手段都用完了问题依旧存在。这时我会祭出最后的大杀器strace。strace可以追踪程序运行时的所有系统调用。对动态库加载来说最有价值的系统调用就是openat因为加载器每找一个库都会调用openat去某个路径尝试打开文件。你可以直观地看到加载器到底尝试了哪些路径、按什么顺序尝试、最后停在哪一步。$ strace -f -e traceopenat ./app 21 | grep libhello openat(AT_FDCWD, /etc/ld.so.cache, O_RDONLY|O_CLOEXEC) 3 openat(AT_FDCWD, /usr/local/lib/libhello.so.1, O_RDONLY|O_CLOEXEC) -1 ENOENT (No such file or directory) openat(AT_FDCWD, /usr/lib/x86_64-linux-gnu/libhello.so.1, O_RDONLY|O_CLOEXEC) -1 ENOENT (No such file or directory) openat(AT_FDCWD, /lib/x86_64-linux-gnu/libhello.so.1, O_RDONLY|O_CLOEXEC) -1 ENOENT (No such file or directory)看到ENOENT一行行刷过去加载器依次尝试了哪些路径全部一目了然。如果某个你明明已经设置过的路径根本没出现在strace输出里那就说明这个路径压根没有进入加载器的搜索列表问题多半出在环境变量没传进来或者程序被 setuid/setgid 影响了——当你以 root 权限或者 setuid 执行程序时加载器为了安全会忽略LD_LIBRARY_PATH这是很多生产环境“变量明明设了却不生效”的经典原因。用strace的时候记得加-f选项因为它要追踪dlopen触发的子线程/子进程加载不加-f只能看到主线程的系统调用可能会漏掉关键线索。6.2 核对符号表nm、objdump、readelf排查依赖库问题时nm、objdump、readelf三个工具各有分工。我习惯按下面的方式组合使用nm -D查看动态符号表快速确认某个库导出/未导出哪些符号。objdump -T查看动态段和符号表详情比nm -D更详细能看到符号版本信息。readelf -d查看 ELF 的动态段标记如DT_NEEDED、DT_RPATH、DT_RUNPATH、DT_SONAME。举个例子如果ldd某个库显示正常但运行时报undefined symbol那么嫌疑集中在依赖库的真实符号表上$ objdump -T /usr/local/lib/libhello.so.1 | grep foo_bar如果输出为空说明这个库根本没有导出foo_bar那么运行时的链接器自然会在加载或调用阶段报错。如果输出有但显示值在某个小版本号之后才出现也可能是同一个 soname 的不同版本导致的不兼容。我在实际项目中遇到过最头疼的情况是两个库导出了同名符号一个来自系统的libcrypto.so一个来自程序自带的libcrypto.so它们在 ELF 里都有对应关系最终解析到哪个完全取决于搜索顺序。这时候readelf -d加objdump -T联合排查能帮你理清到底哪个库先被加载、符号表如何合并。实在不行还能上gdb在main之前打断点但那是另外一个话题了。6.3 构建侧预防cmake 和链接选项怎么设置不容易翻车排查到这一步问题的根因基本已经水落石出。但我一直觉得动态库加载问题“会修”不如“不让它发生”。总结一下我在项目里用到的几条构建侧预防措施直接可抄第一尽量用-Wl,--enable-new-dtags生成RUNPATH而不是RPATH。原因前面说过RPATH优先级过高会导致LD_LIBRARY_PATH失效用户想临时换一个库版本都不行。RUNPATH则把优先权让给环境变量可调试性更好。第二用CMake的时候推荐在目标上明确设置INSTALL_RPATH和INSTALL_RPATH_USE_LINK_PATH。一个常见的 CMake 配置是set_target_properties(app PROPERTIES INSTALL_RPATH $ORIGIN/../lib BUILD_WITH_INSTALL_RPATH FALSE INSTALL_RPATH_USE_LINK_PATH TRUE )这样安装后的程序会优先在自身相对路径../lib下找库非常适合标准发布结构如bin/lib/。已安装的版本不受当前工作目录影响也不会过度依赖环境变量。第三检查一下你自己的-L与-rpath-link。尤其是交叉编译场景只为目标板子编译出来的库是不能被主机上的加载器解析的。很多人在嵌入式开发里遇到的“动态库加载失败”其实是因为把主机平台当成了运行平台。这种情况下最需要检查的不是加载路径而是目标架构和readelf -h里的Machine字段。第四在 CI/CD 流水线里加上ldd检查。产物的ldd输出如果出现not found直接就判定构建失败避免把“必翻车”的产物发到部署环境。这个策略我一直很推荐几行脚本就能挡住很多低级问题。7. 真实踩坑复盘与一份能用的检查清单7.1 两个我印象深刻的翻车案例第一个是某次在客户现场部署一个服务二进制在本机构建、本地测试一切正常拷到客户机器上启动就报cannot open shared object file。用readelf -d一看依赖一个libssl.so.1.1客户机器上装的是libssl.so.3。本机能跑是因为本机同时装了libssl1.1的兼容包。这个案例的根因很简单编译环境和运行环境的基础系统不一致。最后我的处理是建议客户在部署文档里补上运行依赖的安装步骤或者在发布包里带上匹配的.so并设置RUNPATH指向自己的库目录。它教会我一个教训编译通过从来不代表部署环境就绪交付二进制时一定要附带一份完整的运行时依赖清单。第二个案例是线上服务偶发崩溃而且只在流量高峰出现。刚开始所有人都怀疑是并发问题搞了好几天内存分析。后来用strace去抓发现某些线程会突然尝试加载一个不存在的库错误被代码吞掉了随后内存状态被污染最终崩溃。再往下挖发现是某个插件在初始化时dlopen一个可选的硬件加速库库缺失后错误处理逻辑有缺陷导致后续流程用了空指针。这个案子让我对动态库加载失败的认知拓宽了一层加载失败不总是表现为启动报错也可能是运行被吞掉错误后引发的连锁效应。7.2 动态库加载失败排查速查列表根据这些年踩坑经验我整理了一份从浅入深的排查清单遇到问题照着走大部分情况都能定位先复现拿到完整报错信息是./app启动时的error while loading shared libraries还是运行中日志里的dlopen failed信息越完整越好。跑ldd ./app看哪一行是not found。全绿也要接着查因为ldd不能覆盖所有失败模式。确认目标文件架构file ./app看是 32 位还是 64 位跟系统架构和库架构对不对得上。用readelf -d ./app | grep NEEDED找出程序真正依赖的 soname。在系统里搜索是否存在目标 sonamefind / -name libxxx.so*注意区分 soname 与链接名。如果存在检查搜索路径readelf -d ./app | grep -E RPATH|RUNPATH再看环境变量LD_LIBRARY_PATH、ldconfig -p。如果认为路径没问题还是加载了错误版本用strace -f -e traceopenat ./app看加载器实际尝试了哪些路径。如果运行时报undefined symbol用nm -D/objdump -T核对符号是否真的存在。如果真的涉及dlopen确认代码里是否打印了dlerror()没有的话先补上日志。7.3 最后几个从实践中总结的小建议绕了这么一大圈回到开头的那个场景。你敲下./app屏幕弹出error while loading shared libraries现在你应该知道这不是一个“玄学”问题而是一个有清晰排查路径的技术问题soname 对不对、路径优先级对不对、符号对不对、架构对不对四个维度扫一遍基本都能落定。根据我的个人经验这类问题最大的成本其实不在“修”而在“定位”。很多人一看到动态库相关报错就条件反射式地export LD_LIBRARY_PATH试一次不行就换路径再试运气好蒙对了就不管了运气不好就陷入“重新编译—相同报错—再编译”的死循环。所以我强烈建议每一位做 Linux 服务端或嵌入式开发的同学花一个下午把readelf -d、ldd、strace、nm -D这几个命令练熟它们看起来基础但在排障时一个比一个能打。另外一个小技巧是在项目的 README 里固定写一段“依赖与运行环境”说明列出构建产物依赖的每一个 soname 以及它们的预期来源路径。真到了接手你项目的同事半夜被动态库问题叫醒的时候这份文档会比任何调试命令都来得好使。