很多 Linux/C/C++ 工程里,真正让人困惑的往往不是代码本身,而是这些报错和现象:
undefined reference to xxx
error while loading shared libraries: libxxx.so: cannot open shared object file
ldd 能看到,readelf 又不一样
deb 包明明装了,程序为什么还是跑不起来
.a 和 .so 到底有什么区别
RUNPATH、RPATH、NEEDED 到底是谁用的
⧉这些问题看起来分散,其实都围绕同一条主线:
源码如何变成可执行文件?可执行文件如何找到共享库?Debian/Ubuntu 又如何把这些库变成包依赖?
本文就把这条链路系统化讲清楚。
先纠正一个小点:你说的 LEF、readlef,在这个上下文里大概率是 ELF 和 readelf。
ELF 是 Linux/Unix 世界常见的二进制文件格式,全称是 Executable and Linkable Format。readelf 是查看 ELF 文件内部信息的工具,其中 readelf -d / readelf --dynamic 用来显示 ELF 文件的 dynamic section,也就是动态链接器运行时会读取的一组元数据。
如果只记一件事,请记住这条分层:
编译期找头文件,链接期解符号,运行期找共享库,打包期把共享库名映射成 Debian 包名。
很多所谓“依赖问题”,本质上是把这些层次混在了一起。
#include <xxx.h> | |
undefined reference to xxx | |
cannot open shared object file | |
readelfNEEDED,但包名不是这个 | |
lddreadelf 看起来不一样 |
一个典型 C/C++ 程序,大致会经历这些阶段:

这里有几个关键角色:
.o | |
.a | .o 的归档 |
.so | |
ELF | |
.deb |
很多依赖问题,其实是把这些层次混在了一起。
比如:
.so 没找到。apt它们都叫“依赖问题”,但不是同一层的依赖。
.o、.a、.so 分别是什么?.o:目标文件,还不能直接运行.o 是源码经过编译后得到的目标文件。
它里面有机器码、符号表、重定位信息等,但通常还不是完整程序。
比如:
gcc -c foo.c -o foo.o
⧉得到的 foo.o 里可能定义了一个函数:
intfoo() {
return1;
}
⧉也可能引用了别处的函数:
externintbar();
⧉如果 bar() 还没找到定义,.o 也可以存在。真正要解决这些符号关系,是链接阶段的事情。
.a:静态库,本质是 archive.a 文件不是“运行时要加载的库”。它更像一个装着很多 .o 文件的包。
例如:
gcc -c foo.c -o foo.o
gcc -c bar.c -o bar.o
ar rcs libmylib.a foo.o bar.o
⧉这里 libmylib.a 里面装的是 foo.o、bar.o。
链接时:
gcc main.o -L. -lmylib -o app
⧉链接器会从 libmylib.a 里挑出真正需要的 .o,复制进最终可执行文件。
重点是:
.a是链接期使用的;.a通常不会在程序运行时被加载。
这也是为什么有时候你把某个库静态链接进去了,运行机器上不需要安装对应的 .a 文件。
.so:共享库,运行时由动态链接器加载.so 是 shared object,也就是共享库 / 动态库。
和 .a 不同,链接 .so 时,最终可执行文件通常不会把 .so 的代码完整复制进去,而是在自己的 ELF 元数据里记录:
我运行时需要 libfoo.so.1
⧉这个记录就是 dynamic section 里的 DT_NEEDED,readelf -d 会显示成:
0x0000000000000001 (NEEDED) Shared library: [libfoo.so.1]
⧉程序启动时,ld.so / ld-linux.so 负责找到并加载这些共享库。Linux 的动态链接器会寻找程序需要的 shared objects,准备程序运行,然后把控制权交给程序。
.a 和 .so 的链接逻辑完全不同
这是第一组容易混淆的地方。
.a:按需抽取假设有:
gcc main.o -lA -lB -o app
⧉如果 main.o 用到了 libA.a 里的函数,链接器就会从 libA.a 中抽出对应 .o。
但是静态库有一个经典坑:顺序很重要。
比如:
gcc main.o -lfoo -lbar
⧉和:
gcc main.o -lbar -lfoo
⧉可能结果不一样。
原因是传统链接器处理 archive 时,通常按命令行顺序扫描。GNU ld 文档说明,archive 一般只会按出现顺序搜索一次;如果库之间存在循环引用,可以用 --start-group ... --end-group 让链接器重复搜索,但这会带来明显性能成本。
例如:
gcc main.o \
-Wl,--start-group -lfoo -lbar -Wl,--end-group \
-o app
⧉但这通常是解决循环依赖的兜底手段,不应该滥用。
.so:记录运行时依赖如果链接的是动态库:
gcc main.o -L. -lfoo -o app
⧉最终 app 里通常会记录:
NEEDED libfoo.so.1
⧉注意,这里记录的往往不是你链接时看到的 libfoo.so,而是共享库自己的 SONAME。
典型共享库命名结构是:
libfoo.so # linker name,链接时用
libfoo.so.1 # SONAME,运行时 ABI 名
libfoo.so.1.2.3 # real file,真实文件
⧉常见软链接关系:
libfoo.so -> libfoo.so.1.2.3
libfoo.so.1 -> libfoo.so.1.2.3
libfoo.so.1.2.3
⧉这就是为什么你链接时写:
-lfoo
⧉但 readelf -d 里看到的是:
NEEDED libfoo.so.1
⧉
“依赖”这个词太大了。实际排查时,最好分成六层。
#include <openssl/ssl.h>Build-Depends | ||
undefined reference-lssl -lcrypto | ||
.so | ||
.so 到底在哪里 | RPATHRUNPATH、LD_LIBRARY_PATH、ld.so.cache | |
dlopen() | ||
Dependsdpkg-shlibdeps、${shlibs:Depends} |
比如代码里有:
#include<openssl/ssl.h>
⧉这要求系统里有 OpenSSL 的头文件。
在 Debian/Ubuntu 上,这通常来自:
libssl-dev
⧉这类依赖用于编译。
对应到 Debian 源码包,通常写在:
Build-Depends: libssl-dev
⧉编译能过,不代表链接能过。
比如你用了 OpenSSL 的函数,但链接时没加:
-lssl -lcrypto
⧉就可能遇到:
undefined reference to SSL_new
⧉链接期依赖解决的是:
这时链接器要么从 .a 里抽代码,要么在 .so 上建立动态依赖关系。
这是 readelf -d 里最常见的:
NEEDED
⧉例如:
readelf -dW ./app | grep NEEDED
⧉输出:
0x0000000000000001 (NEEDED) Shared library: [libssl.so.3]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
⧉这表示:
程序启动时,动态链接器必须找到这些库。找不到,程序就起不来。
这类依赖是 ELF 层面的运行时依赖。
NEEDED 只告诉系统:
我要 libfoo.so.1
⧉但它不一定告诉系统:
libfoo.so.1 在哪里
⧉库搜索路径由动态链接器决定。
常见来源包括:
DT_RPATHLD_LIBRARY_PATHDT_RUNPATH/etc/ld.so.cache/lib/usr/lib、/lib64、/usr/lib64 等默认路径Linux 动态链接器在依赖名不带 / 时,会按规则搜索路径:如果存在 DT_RPATH 且没有 DT_RUNPATH,会先用 DT_RPATH;然后考虑 LD_LIBRARY_PATH;再考虑 DT_RUNPATH;接着查 /etc/ld.so.cache;最后查默认库路径。
这里最容易踩的坑是:
DT_RUNPATH只用于直接DT_NEEDED依赖,不会自动传递给子依赖。
例如:
app
└── libA.so
└── libB.so
⧉如果只有 app 有:
RUNPATH=$ORIGIN/lib
⧉它可能帮 app 找到 libA.so,但不一定帮 libA.so 找到 libB.so。
更稳的方式是:
libA.soRUNPATH。libB.so 安装到系统库搜索路径。有些依赖不会出现在 NEEDED 里。
例如程序运行时才调用:
dlopen("libcuda.so.1", RTLD_NOW);
⧉这类依赖常见于:
这意味着:
readelf -d app只能看到 ELF 显式声明的直接动态依赖,不能保证看到所有运行时可能用到的依赖。
ELF 层看到的是:
libssl.so.3
libc.so.6
libstdc++.so.6
⧉Debian 包层看到的是:
libssl3
libc6
libstdc++6
⧉这两套名字不是同一套体系。
中间的映射主要由 Debian 打包工具完成,例如 dpkg-shlibdeps。它会分析可执行文件或共享库依赖哪些 shared libraries,然后把结果写入 debian/substvars 中的依赖变量;它生成依赖信息时会使用 symbols 或 shlibs 文件。
readelf -d常用命令:
readelf -dW ./app
⧉可能看到:
Dynamic section at offset 0x2dd0 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libfoo.so.1]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN/../lib]
0x000000000000000e (SONAME) Library soname: [libbar.so.2]
0x000000000000000c (INIT) 0x1000
0x0000000000000019 (INIT_ARRAY) 0x3d80
0x0000000000000017 (JMPREL) 0x680
0x0000000000000007 (RELA) 0x560
⧉几个最重要的字段如下。
NEEDED表示当前 ELF 文件直接依赖的共享库。
例如:
NEEDED libfoo.so.1
⧉意思是:
运行时必须加载
libfoo.so.1。
你说的 NEED 多半是 NEEDED 的简写或看漏了。readelf 常规输出里一般显示的是 (NEEDED)。
SONAMESONAME 是共享库对外声明的 ABI 名称。
比如一个真实文件叫:
libfoo.so.1.2.3
⧉它的 SONAME 可能是:
libfoo.so.1
⧉其他程序链接它时,最终 NEEDED 里通常写入的就是这个 SONAME。
这也是 Linux 共享库版本管理的关键:
libfoo.so.1 表示 ABI 主版本
libfoo.so.1.2.3 表示具体实现版本
⧉只要 ABI 兼容,libfoo.so.1.2.4 可以替换 libfoo.so.1.2.3。
RPATHRPATH 是老式运行时库搜索路径。
如果 ELF 里有 RPATH,动态链接器可以用它来搜索共享库。
但现代构建里更常见的是 RUNPATH。如果一个对象里同时存在 DT_RPATH 和 DT_RUNPATH,ELF gABI 说明动态链接器只处理 DT_RUNPATH。
RUNPATHRUNPATH 是新式运行时库搜索路径。
常见写法:
gcc main.o -Wl,-rpath,'$ORIGIN/../lib' -o app
⧉这里 $ORIGIN 表示当前程序或共享对象所在目录。ld.so 文档说明,动态链接器会在多个位置展开 $ORIGIN,包括 DT_NEEDED、DT_RPATH、DT_RUNPATH、LD_LIBRARY_PATH 以及 dlopen() 参数等场景。
例如:
app 位于 /opt/myapp/bin/app
RUNPATH=$ORIGIN/../lib
⧉则运行时会搜索:
/opt/myapp/lib
⧉这非常适合做自包含应用。
INITINIT_ARRAY | |
RELAREL | |
JMPRELPLTGOT | |
VERNEEDVERSYM / VERDEF | GLIBC_2.34、GLIBCXX_3.4.29、OPENSSL_3.0.0 |
比如你在新系统上编译,生成的程序依赖 GLIBC_2.34,拿到只提供 GLIBC_2.28 的系统上,就可能无法运行。
一个动态链接的 ELF 可执行文件里,通常有一个 PT_INTERP,用来告诉内核:
请用哪个动态链接器来启动我。
可以这样查看:
readelf -lW ./app | grep 'Requesting program interpreter'
⧉常见输出:
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
⧉ELF gABI 中也说明,参与动态链接的可执行文件会带有 PT_INTERP,系统在 exec 过程中通过它找到程序解释器,也就是动态链接器。动态链接器负责装入共享对象、执行重定位,最后把控制权交给程序。
整个过程可以简化成:

所以,真正让动态程序跑起来的,不只是 app 自己,还有动态链接器和一整棵共享库依赖树。
现在从 ELF 层跳到 Debian/Ubuntu 的包管理层。
.deb 文件本身是什么?.deb 本质上也是一个归档文件。
Debian 的 deb(5) 手册说明,.deb 是 Debian 二进制包格式;其文件是一个 ar archive,并且内部成员必须按固定顺序出现。
一个典型 .deb 里有:
debian-binary
control.tar.*
data.tar.*
⧉含义如下:
debian-binary | 2.0 |
control.tar.* | control 文件、maintainer scripts、shlibs、symbols、conffiles、md5sums 等 |
data.tar.* | /usr/bin/xxx、/usr/lib/xxx.so、/etc/xxx.conf |
可以用这些命令查看:
ar t package.deb
⧉或者:
dpkg-deb -I package.deb # 查看控制信息
dpkg-deb -c package.deb # 查看安装文件列表
⧉二进制包的 control 文件里会有类似内容:
Package: foo
Version: 1.0-1
Architecture: amd64
Depends: libc6 (>= 2.34), libssl3 (>= 3.0.0)
Recommends: foo-data
Suggests: foo-doc
Conflicts: old-foo
Breaks: foo-plugin (<< 1.0)
Replaces: old-foo
Provides: virtual-foo
Description: ...
⧉Debian Policy 规定,包之间的关系通过控制字段声明,例如 Depends、Pre-Depends、Recommends、Suggests、Enhances、Breaks、Conflicts 等。
可以这样理解:
Depends | |
Pre-Depends | Depends,甚至在解包或配置前就要求满足;基础系统包里更常见,普通包不要随便用 |
Recommends | |
Suggests | |
Conflicts | |
Breaks | |
Replaces | Conflicts / Breaks 配合 |
Provides | mail-transport-agent |
依赖表达式也有语法规则。
比如:
Depends: libc6 (>= 2.34), libssl3 (>= 3.0.0)
⧉逗号表示 AND:
A, B
⧉意思是 A 和 B 都要满足。
竖线表示 OR:
default-mta | mail-transport-agent
⧉意思是满足其中之一即可。
deb-control(5) 手册也说明,Depends、Pre-Depends、Recommends、Suggests 的语法是一组可替代包;逗号分组表示 AND,竖线表示 OR,并且竖线优先级更高。

这是 Debian 打包里非常关键的一步。
假设你编译出来一个程序:
debian/foo/usr/bin/foo
⧉它的 ELF 依赖是:
readelf -dW debian/foo/usr/bin/foo | grep NEEDED
⧉输出:
NEEDED libssl.so.3
NEEDED libc.so.6
⧉但最终 deb 包里不能写:
Depends: libssl.so.3, libc.so.6
⧉因为 Debian 包管理器识别的是包名,而不是 .so 名。
它应该写成类似:
Depends: libssl3, libc6
⧉这个转换主要由 dpkg-shlibdeps 完成。
常见 debian/control 写法是:
Package: foo
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
⧉构建时,dpkg-shlibdeps 会扫描 ELF 文件,找出链接了哪些共享库,再通过 symbols 或 shlibs 文件推导出应该依赖哪些 Debian 包。dpkg-shlibdeps 文档说明,它会计算可执行文件的共享库依赖,并写入 debian/substvars;它会查找库对应的 symbols 或 shlibs 信息。
Debian Policy 也说明,如果共享库包提供了 symbols 文件,dpkg-shlibdeps 通常会优先使用它,而不是 shlibs 文件;symbols 能提供更细粒度的符号级最小版本依赖。
简单说:
ELF:
NEEDED libssl.so.3
dpkg-shlibdeps:
找到哪个包提供 libssl.so.3
查 symbols / shlibs
推导版本约束
deb:
Depends: libssl3 (>= x.y.z)
⧉这就是 ELF 层和 deb 包层之间的桥。
readelf 和 ldd 有什么区别?
很多人排查依赖时会直接用:
ldd ./app
⧉它确实方便,但要知道它和 readelf 看的不是同一层。
readelf -dW ./app | dynamic section | NEEDED;看不到当前系统实际解析路径;看不到 dlopen 插件依赖 | |
ldd ./app | LD_LIBRARY_PATH 影响;不适合随便分析不可信二进制 |
readelf -dW ./app 能看到:
NEEDED
RUNPATH
RPATH
SONAME
INIT_ARRAY
RELA
VERNEED
⧉ldd ./app 可能显示:
libssl.so.3 => /usr/lib/x86_64-linux-gnu/libssl.so.3
libc.so.6 => /usr/lib/x86_64-linux-gnu/libc.so.6
⧉ldd 手册明确提醒,不要对不可信的可执行文件使用 ldd,因为某些情况下可能导致任意代码执行;更安全的替代方式是用:
objdump -p /path/to/program | grep NEEDED
⧉但这种方式只显示直接依赖,而 ldd 会显示完整依赖树。
所以经验规则是:
看 ELF 自己写了什么:
readelf -dW ./app
看当前系统实际解析到哪里:
ldd ./app
分析不可信文件:
优先 readelf / objdump,不要直接 ldd
⧉.so 名当成 deb 包名ELF 里看到:
libssl.so.3
⧉Debian 包里通常是:
libssl3
⧉不是同一个名字。
.a 的链接顺序不对gcc main.o -lfoo -lbar
⧉和:
gcc main.o -lbar -lfoo
⧉可能一个成功,一个失败。
静态库按需抽取,顺序很重要。
RUNPATH 会传递app -> libA.so -> libB.so
⧉app 的 RUNPATH 不一定能帮 libA.so 找 libB.so。
如果 libA.so 依赖 libB.so,更可靠的做法是让 libA.so 自己也有正确的 RUNPATH。
readelf,漏掉 dlopen()插件、GPU 后端、数据库驱动等依赖,可能运行时才加载。
这些不一定出现在:
readelf -d
⧉里。
strip 会影响动态依赖strip 可以删调试信息、普通符号表等,但动态链接所需的 .dynamic、.dynsym、NEEDED 等通常不能删。
否则程序就没法被动态链接器正确加载。
--as-needed现代发行版或构建系统常使用:
-Wl,--as-needed
⧉GNU ld 文档说明,--as-needed 会影响 ELF 的 DT_NEEDED 输出:只有当某个动态库在当时确实满足非弱未定义符号引用时,链接器才会为它生成 DT_NEEDED。
所以有时候你命令行里写了:
-lfoo
⧉但最终:
readelf -d app | grep foo
⧉什么都没有。
这不一定是错,而可能是 --as-needed 认为它没被真正用到。

ELF 的
NEEDED、RUNPATH、SONAME是二进制运行时链接信息;Debian 的Depends、Recommends、Provides是包管理层信息。二者有关联,但不是同一层东西。
当你理解了这条链路,再看:
undefined reference
cannot open shared object file
NEEDED
RUNPATH
libxxx.so.N
Depends: libxxxN
⧉这些信息就不再是零散术语,而是一套完整系统。
下面这些资料适合进一步查细节,建议优先看官方文档和 man page。
readelf -d、readelf -h、readelf -l、readelf -V 等选项含义。ld.so(8) 手册LD_LIBRARY_PATH、RPATH、RUNPATH、$ORIGIN。PT_INTERP、.dynamic、动态链接器如何参与进程启动。--start-group、--end-group、--as-needed、-Bstatic、-Bdynamic、-rpath 等链接器选项。deb(5) 手册.deb 文件内部结构:debian-binary、control.tar.*、data.tar.*。Depends、Pre-Depends、Recommends、Suggests、Breaks、Conflicts 等字段的正式语义。deb-control(5)dpkg-shlibdeps(1)${shlibs:Depends}。shlibs、symbols、SONAME 和 Debian 共享库依赖生成机制。ldd(1) 手册ldd 的行为和安全注意事项,尤其是不要对不可信二进制直接运行 ldd。