ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Linux下undefined reference错误排查:库依赖与链接顺序

2026/9/30 10:44:57 拓冰建站 浏览量
Linux下undefined reference错误排查:库依赖与链接顺序 在公司内网编译一个视频处理模块时链接器突然甩出这么一行/usr/bin/ld: libvcsnew.so: undefined reference to avcodec_free_frame collect2: error: ld returned 1 exit status我第一次看到libvcsnew.so: undefined reference to的时候第一反应是这个 .so 是不是编译坏了。于是赶紧重编 libvcsnew.so结果它自己编得干干净净没有任何报错。那问题出在哪后来才发现报错里的符号avcodec_free_frame根本不属于 libvcsnew.so它是 FFmpeg 的 libavcodec 提供的只是 libvcsnew.so 在内部依赖它。链接器把 libvcsnew.so 当成了一个使用者它需要的符号没人提供于是把这个账记在了库头上。这个报错在 Linux 下太常见了但误导性非常强因为它看起来像是库坏了其实绝大多数时候是链接命令里少写了依赖库或者库的顺序不对。这篇就把我当时那段完整排查经历、用到的工具、以及最后沉淀下来的构建规范一起写出来希望能帮你少走一两天的弯路。1. 链接器说“undefined reference”时它到底在查什么1.1 符号表里只有两种状态提供和需要在 ELF 的世界里每个编译产物不管是 .o 还是 .so身上都挂着一份符号清单。这份清单里的每个符号只有两个去向要么是我提供的defined要么是我需要的undefined。在nm和readelf的输出里前者通常显示成大写 T、D、B、W 之类后者清一色是一个 U。可以把链接器想象成婚姻介绍所的中介。它把命令行上的所有输入文件摆到一张桌子上然后逐个核对谁需要某个符号谁又能提供这个符号。比如libvcsnew.so 需要avcodec_free_framelibavcodec.so 提供avcodec_free_frame链接器的工作就是让两边见面并在最终产物里建立一种这个函数在 libavcodec.so 的第 N 号偏移处的关联。如果全场转了一圈一个需要符号的库始终找不到匹配的提供者链接器就抛出那句著名的libvcsnew.so: undefined reference to avcodec_free_frame注意这句话的语法结构冒号前是谁在要引号里是要什么。它就差没直接告诉你——你看是 libvcsnew.so 缺这个符号去找个库来满足它吧。所以正确解读是libvcsnew.so 在引用一个当前链接流程里不存在的符号。问题不一定出在 libvcsnew.so 身上更常见的是它的依赖链没有完整出现在链接命令行里。1.2 为什么编译 libvcsnew.so 时没有报错链接可执行文件时才报这里有个很容易让人绕晕的地方既然 libvcsnew.so 内部依赖avcodec_free_frame那为什么编译 libvcsnew.so 的时候不报错偏偏等到最后链接可执行文件时才炸原因是共享库在 Linux 下本来就允许一部分符号处于未定义状态。链接器在生成 .so 时默认并不会强制要求把库用到的所有符号都解析完——它可以留一笔欠账等着最终的可执行文件链接阶段或者程序运行时的动态链接器去补上。只有当你显式加了-Wl,--no-undefined等价于-z defs时共享库构建阶段才会强制检查未定义符号一旦有欠账立刻报错。但是链接可执行文件时情况不同。可执行文件是最终装配体链接器默认更严格所有被实际引用的符号都要有明确的去处。如果 libvcsnew.so 带着一笔欠账avcodec_free_frame而这笔账在整个链接命令里没人能还错误就会冒出来。这笔账最终会在运行时由ld.so动态加载系统来结清但前提是它与可执行文件能成功链接——这一步过不去根本轮不到运行时。不同发行版、不同链接器版本的默认严格程度会有一点差异有的环境下链接阶段可能放行让带着欠账的可执行文件跑起来然后等程序执行到相关代码时ld.so再报symbol lookup error。无论哪种方式这笔账早晚都要还。很多开发者在主工程里新接入一个闭源或半开源的 .so 时碰到这个报错第一反应都是怀疑 .so 有问题其实恰恰相反它只是把它的依赖清单摊开给你看。你要做的不是修这个 .so而是让你最终的链接命令覆盖它的全部依赖。1.3 十行代码复现同一个报错如果你手头没有现成的项目我建议你亲手复现一次体感完全不同。下面这段代码十行就能模拟出来// libfoo.c int bar(void); int foo(void) { return bar(); }# 编译共享库默认允许未定义符号所以这一步能过 gcc -shared -fPIC -o libfoo.so libfoo.c # 如果构建时就想严格检查可以用 --no-undefined立刻就会报 # gcc -shared -fPIC -Wl,--no-undefined -o libfoo.so libfoo.c # /usr/bin/ld: libfoo.so: undefined reference to bar再写一个引用foo的主程序// main.c int foo(void); int main(void) { return foo(); }gcc main.c -o app -L. -lfoo在没有额外提供bar的库时这条命令在多数默认严格检查的发行版上会给你报一模一样的错。如果你的系统默认相对宽松可以加-Wl,--no-allow-shlib-undefined强制复现/usr/bin/ld: libfoo.so: undefined reference to bar collect2: error: ld returned 1 exit status这个最小样例的价值在于它让库自己编得过但往外链接就报错这件事变得无比直观。后面排查真实项目时只要按这个思路把bar换成报错里的符号名把libfoo.so换成libvcsnew.so问题就转化成了一个简单的填空题这个符号到底应该由谁提供2. 用 nm、readelf、ldd 把报错符号的归属查得明明白白2.1 把报错里的符号名原样拿出来排错第一步不是翻代码而是把报错里的符号名原样拿出来。它通常长这样/usr/bin/ld: libvcsnew.so: undefined reference to avcodec_free_frame单引号或者反引号之间的内容就是符号名。如果你的工程用了 C符号名可能会长得像乱码libvcsnew.so: undefined reference to _ZN6Player10OpenStreamEPKc别被吓到这是 C 编译器对函数名做修饰后的结果可以用cfilt还原成人话cfilt _ZN6Player10OpenStreamEPKc # 输出Player::OpenStream(char const*)拿到符号名之后先别急着满世界找库。先判断一件最重要的事这个符号在 libvcsnew.so 里到底是它缺的还是它提供的。这个判断直接决定了后面的排查方向。2.2 nm -D 判断“这个符号是谁缺的”判断符号归属最常用的工具是nm -D。-D表示只查看动态符号表也就是真正参与动态链接的那部分符号。命令是nm -D libvcsnew.so | grep avcodec_free_frame输出有两种典型情况行首是一个U说明这个符号在 libvcsnew.so 里是 undefined也就是它需要别人提供。行首是一个地址和T/D/W等说明符号定义在 libvcsnew.so 里是它提供给别人的。如果看到的是U那思路就清晰了符号不是 libvcsnew.so 的问题它只是缺一个提供者。接下来就去全局找谁提供了这个符号。如果看到的是T但链接时依然报 undefined reference那问题就在符号可见性、名字修饰或版本标签上这部分第四章会展开。注意一个非常关键的细节nm不带-D和带-D看到的结果可能完全不同。很多人只跑了nm libvcsnew.so | grep xxx发现符号明明在就一脸懵。因为不带-D看到的是普通符号表.symtab而动态链接只认动态符号表.dynsym。符号在.symtab里不代表它能被外部链接到。所以判断链接器能不能找到务必用nm -D或者readelf -Ws。2.3 ldd -r 和 readelf 交叉验证除了nm -D还有两个工具做交叉验证特别好用。第一个是ldd -r。ldd平时大家用来查看一个程序运行时要加载哪些动态库加上-r后它会尝试对库做重定位并把所有无法解析的符号列出来。比如ldd -r libvcsnew.so如果输出里有undefined symbol: avcodec_free_frame (./libvcsnew.so)就说明这个库自身在加载时是没法单独自洽的它必须和提供该符号的库同时出现在进程中。换句话说ldd -r把潜在链接隐患提前暴露出来了。第二个是readelf -Wsreadelf -Ws libvcsnew.so | grep avcodec_free_frame输出格式更啰嗦但信息更全能看到符号所在的节、版本信息、类型等。当nm的输出不直观或者你想确认符号是否带版本标签时直接看readelf准没错。这三件套配合起来几乎能覆盖所有符号到底在哪的疑问。我在实际排错时通常先nm -D确认一个 U/T 结论再用ldd -r看整个依赖链的欠账最后用readelf确认版本和可见性。这一套下来问题范围通常能缩小到链接命令写错了还是库构建参数不对二选一。3. 最常见的真凶库里符号还在依赖顺序和 --as-needed 却把它弄丢了3.1 --as-needed 干掉了“暂时用不上”的库排查到符号是 U 之后接下来就要找另一个库来提供它。假设你很快找到了avcodec_free_frame确实在 FFmpeg 的 libavcodec.so 里于是你在链接命令里补上了-lavcodec结果……报错还在。这时候你大概率碰到了--as-needed。这个链接器选项在主流发行版上已经默认开启它的行为比字面意思更激进链接器处理-l参数时只有当当前已经积累了一批未解析符号而当前这个库正好能解决其中某些符号它才会把这个库标记为最终产物的 NEEDED 项如果处理到它的时候没有东西需要它这个-l参数会被直接忽略。一句话概括--as-needed让链接器按当前是否有需求来决定是否保留库而不是按命令行里写了就保留。这带来一个非常反直觉的结果库的顺序成了命门。我们项目当时的链接命令长这样gcc main.o -o app -L. -lavcodec -lvcsnew -lm猛一看好像没错-lavcodec也写了。但注意顺序-lavcodec在-lvcsnew前面。在--as-needed下链接器先处理-lavcodec那时 main.o 还没有引用任何 avcodec 的符号于是认为没有需求直接把 libavcodec.so 丢掉了。接下来处理-lvcsnew时一堆avcodec_*的符号需要解析但-lavcodec已经被扔出考虑范围于是照样报 undefined reference。3.2 库顺序为什么这么敏感如果你第一次听说库的顺序影响链接结果可能会觉得很不可思议。毕竟在大多数人的直觉里链接就是把一堆文件打包起来顺序应该无所谓。但 GNU ld 处理库的顺序是有讲究的命令行从左到右扫描维护一个当前未解析符号集合。被依赖的库应该放在依赖者的后面才能保证扫描到它时需求集合里已经积累了对应符号。这条规则对静态库是硬性的因为 .a 本质上是一堆 .o 的压缩包链接器只会抽取当前有需求的目标文件出来而且只扫描一遍一旦错过后面的符号需求就没人处理了。对动态库在没有--as-needed的老环境里顺序有时候碰巧能过但一旦开了--as-needed顺序直接决定生死。所以最稳妥的做法永远是被依赖的库放在依赖它的库后面。把上面的错误顺序反过来gcc main.o -o app -L. -lvcsnew -lavcodec -lm链接器先处理-lvcsnew积累了一堆 avcodec 符号需求再处理-lavcodec发现这些需求正好能被它满足于是把它写入 NEEDED报错消失。为了方便验证链接完成后可以立刻检查最终产物的 NEEDED 列表readelf -d app | grep NEEDED # 或者 ldd app如果 libavcodec 不在列表里而你的程序明明调用了它的符号基本可以断定是--as-needed把它优化掉了。3.3 循环依赖--start-group 兜底但要慎用依赖顺序这条规则在大多数场景下够用但有一类例外躲不掉两个库相互依赖。A.so 需要 B 的符号B.so 又需要 A 的符号。这种情况下无论你把顺序怎么排都会有一方在扫描时缺符号。GNU ld 为此提供了一个兜底方案gcc main.o -o app -L. \ -Wl,--start-group -lvcsnew -lavcodec -Wl,--end-group--start-group和--end-group之间的所有库会被链接器反复扫描直到未解析符号不再减少为止。逻辑上很像多轮相亲第一轮没配上对的第二轮继续找直到匹配稳定。它好用但我建议你抱着这是补丁而非正解的心态去用它。循环依赖本身通常意味着库的拆分有不合理的地方——真正的 A 和 B 之间应该是单向依赖或者存在一个更底层的 C 存放两边公用的符号。用 group 强行续上的后果是链接时间变长、可维护性变差早晚还得回头改架构。我一般在临时验证问题时会用它确认就是依赖没写全确认完再去修库的依赖关系本身。4. 排除了依赖缺失后还剩这几种“符号隐形”的情况4.1 C 名字修饰让同一个函数看起来是两个名字依赖链查完了库顺序也调对了还是报 undefined reference这时候就要怀疑符号是否真的以这个名字存在。C 的坑主要在名字修饰编译器会把函数名、参数类型、命名空间这些信息拼成一个底层符号比如Player::OpenStream(const char*)会变成_ZN6Player10OpenStreamEPKc。如果两边的编译视角不一致就会产生函数明明在链接器却说找不到的怪现象。最常见的两个场景库是用 C 编译的导出的符号叫vcs_open你在 C 代码里没有加extern C就声明并调用它。C 编译器会以为它是个 C 函数生成_Z7vcs_openv之类的修饰名而库导出的是未修饰的vcs_open两边对不上。库本身是 C 写的导出的是_ZN6Player10OpenStreamEPKc你又在一个纯 C 文件里用extern声明去调用它C 侧找的是未修饰名同样失败。解决办法是在库的对外头文件里统一用extern C包裹#ifdef __cplusplus extern C { #endif int vcs_open(const char *path); int vcs_close(int fd); #ifdef __cplusplus } #endif验证时看符号名是否带_Z前缀即可。nm -D libvcsnew.so | grep vcs_open如果输出是T vcs_open而不是T _Z7vcs_open...说明导出的是 C 符号如果报错里带_Z就用cfilt还原确认两边的真实身份。4.2 -fvisibilityhidden符号在库里但链接器看不见还有一种更隐蔽的符号隐形符号确实编译进了 .so动态符号表里却找不到它。这种情况十有八九是编译共享库时加了-fvisibilityhidden。这个编译选项的作用是默认把所有全局符号都藏起来只有显式标记了__attribute__((visibility(default)))的符号才会进入动态符号表。它的初衷是让 .so 对外只暴露你想暴露的接口隐藏内部实现好处是接口清晰、符号表小、加载更快很多大型 C/C 项目都这么干。但副作用就是如果某个函数在实现代码里没有加导出标记用nm libvcsnew.so | grep xxx能看到它在普通符号表里用nm -D libvcsnew.so | grep xxx却什么都看不到——而动态链接只认后者。排查时请务必注意这个差异。解决办法有两种。一是给需要导出的接口统一加导出宏#if defined(_WIN32) #define VCS_API __declspec(dllexport) #else #define VCS_API __attribute__((visibility(default))) #endif VCS_API int vcs_open(const char *path);二是在链接 .so 时用 version script 精确控制导出名单# exports.map { global: vcs_*; local: *; };gcc -shared -fPIC -o libvcsnew.so *.o -Wl,--version-scriptexports.map这个方案比-fvisibilityhidden更精细推荐在库的接口发展到一定规模后切换过来。4.3 符号版本标签对不上也会报 undefined reference如果你发现报错信息里符号名后面跟着一个或加版本号比如libvcsnew.so: undefined reference to avcodec_free_frameLIBAVCODEC_58这是符号版本不匹配。GLIBC 和很多大型开源库会给导出符号打版本标签用来做 ABI 兼容管理。链接时如果要求LIBAVCODEC_58版本而系统里实际提供的 libavcodec 只有LIBAVCODEC_57的对应符号链接器就认为对这个版本不可见于是照样报 undefined reference。处理方法分两步确认。第一步看当前系统库能提供哪个版本objdump -T /usr/lib/x86_64-linux-gnu/libavcodec.so | grep avcodec_free_frame路径换成你机器上的实际库路径。第二步看有没有装混了多个版本的库。这类问题的根源大多是头文件从新版本 SDK 拷来但系统库还是旧版本或者同一套依赖被装到了多个路径链接器找到了错误的那个。解决思路是统一开发环境要么把系统库升级到匹配的版本要么把头文件降级到系统库对应的版本二选一别混着来。在团队项目里这通常意味着把依赖固定在某个版本用标准的包管理或容器环境来约束。5. 从构建源头防坑把 libvcsnew.so 的依赖写对5.1 CMake 里 PUBLIC/PRIVATE 没写对坑的是下游如果 libvcsnew.so 是你们自己维护的最有效的防坑办法是在构建阶段就把依赖关系写对。这里最容易被忽视的就是 CMake 里target_link_libraries的可见性关键字。很多人写add_library(vcsnew SHARED src/vcs.c) target_link_libraries(vcsnew PRIVATE avcodec)觉得PRIVATE 表示只在 vcsnew 内部用挺合理。但对于共享库avcodec 是 vcsnew 对外依赖的一部分——使用方在链接时同样需要解析 vcsnew 里那些 avcodec 符号。写成 PRIVATE生成 vcsnew 的过程没问题但下游链接 app 时 CMake 不会把 avcodec 传出去于是undefined reference to avcodec_...又在使用方手里复现了。正确写法是add_library(vcsnew SHARED src/vcs.c) target_link_libraries(vcsnew PUBLIC avcodec)这样使用方只写一个依赖也会自动展开add_executable(app main.c) target_link_libraries(app vcsnew)如果依赖是从 pkg-config 拿到的建议配合IMPORTED_TARGET一起写可读性和可维护性都好很多find_package(PkgConfig REQUIRED) pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavcodec libavformat libavutil) add_library(vcsnew SHARED src/vcs.c) target_link_libraries(vcsnew PUBLIC PkgConfig::FFMPEG) add_executable(app main.c) target_link_libraries(app vcsnew)排查链接问题时强烈建议开启 verbose 看真实命令cmake --build . --verbose或make VERBOSE1直接看最后那行 gcc/g 的参数里有没有-lavcodec、它在-lvcsnew前面还是后面。很多时候问题一眼就能看出来。5.2 Makefile 和 pkg-config手动排列依赖的规范姿势手写 Makefile 时没有 CMake 帮你处理传递一切依赖都得自己排最容易犯的错误就是把所有-l参数一股脑堆上去顺序完全凭心情。规范姿势就一句话把直接依赖放在前把间接依赖按依赖层级从上层到下层依次往后排。# 错误示范-lavcodec 在 -lvcsnew 前面--as-needed 下大概率翻车 app: main.o libvcsnew.so $(CC) -o $ main.o -L. -lavcodec -lvcsnew -lm # 正确示范先主程序依赖的 vcsnew再 vcsnew 依赖的 avcodec app: main.o libvcsnew.so $(CC) -o $ main.o -L. -lvcsnew -lavcodec -lm如果你给 libvcsnew 写了 pkg-config 的 .pc 文件正确姿势是把依赖写进Requires而不是手动拼LibsName: libvcsnew Description: Video codec system wrapper library Version: 1.0.0 Libs: -L${libdir} -lvcsnew Requires: libavcodec libavformat libavutil Cflags: -I${includedir}使用方直接pkg-config --cflags --libs libvcsnew输出的链接参数顺序基本是符合规范的。这样即使将来依赖链发生变化也只改 .pc 文件一处而不是改所有调用方的 Makefile。5.3 给自己的 .so 加 -z defs把问题拦在库编译阶段最后是个很实用的建议在编译 libvcsnew.so 时主动加上-Wl,--no-undefined等价于-z defs。它的意思是构建共享库时所有引用的符号都必须在当前链接命令里解析完不允许欠账。加了之后如果 vcsnew 引用了 avcodec 的符号但链接命令里漏写了-lavcodec报错会立刻发生在库的构建阶段而且错误信息直接指向libvcsnew.so: undefined reference to xxx让你第一时间就意识到库的依赖没写全而不是等下游集成时再爆雷。在 CMake 里可以这样加CMake 3.13target_link_options(vcsnew PRIVATE LINKER:-z,defs)这条规则不是没有例外如果你的 .so 本身就是设计成插件架构需要由宿主可执行文件在运行时提供符号那就不能加因为那些符号故意留给宿主的。判断标准很简单——你的库是功能库还是插件库功能库请务必加插件库建议在文档里写清宿主应该导出哪些符号否则使用者照样会被 undefined reference 折磨。6. 这套排查顺序我踩了几次坑之后总结的照着做就行6.1 完整决策顺序遇到这个报错按步骤走把前面几章的思路压缩成一套可操作的决策顺序以后再遇到libvcsnew.so: undefined reference to就按这个走别跳步复制完整的报错信息取出符号名如果带_Z前缀先用cfilt还原。执行nm -D libvcsnew.so | grep 符号看是U还是定义条目。如果是U符号属于外部依赖接下来全局搜索提供者找到后按被依赖放后面补进链接命令。如果是定义条目但仍然报错检查可见性nm能看到但nm -D看不到、检查是否 C/C 混用导致名字修饰不一致、检查符号版本标签是否匹配。无论如何用readelf -d检查最终产物的 NEEDED 列表确认该有的库都在。如果最近改过构建配置请先比较旧版本和新版本的最终链接命令差异常在-l顺序或--as-needed相关参数上。这套顺序能覆盖九成以上的场景。最忌讳的是一开始就怀疑库坏了然后重编库因为重编并不能改变它的依赖关系只会浪费一两个小时。6.2 一个搜系统里“谁提供了符号”的小脚本找提供者时大多数人的第一反应是用 grep 翻头文件。但头文件只能告诉你API 存在不能告诉你链接符号在哪个 .so 里。更可靠的方式是直接在库文件上搜符号。这里放一个我常用的脚本把它存成find_sym.sh#!/bin/bash # 用法: ./find_sym.sh 符号名 [搜索目录] # 例: ./find_sym.sh avcodec_free_frame /usr/lib/x86_64-linux-gnu sym$1 dir${2:-/usr/lib/x86_64-linux-gnu} for so in $dir/lib*.so*; do [ -f $so ] || continue if nm -D $so 2/dev/null | grep -qE [TWDBRVA] ${sym}$; then echo $so fi done[TWDBRVA]分别对应代码段、弱符号、数据段、BSS、只读数据、绝对符号等定义类型函数定义基本都在 T 或 W 里。如果搜索结果为空可以把目录换成/lib、/usr/lib或者你自己项目安装的依赖目录。对于多个同名符号的情况脚本会把匹配到的所有库都列出来这时候再结合链接顺序和版本号挑真正该用的那个。6.3 我在这个报错上卡得最久的两个瞬间最后分享两个让我印象深刻的卡壳瞬间算是给后来者提个醒。第一次卡壳就是在链接顺序上。当时的报错和这次的文章标题几乎一样我花了一晚上把 libvcsnew.so 从上到下看了个遍甚至怀疑它是不是用了什么特殊编译参数。直到某次无意中打开 verbose 编译日志看到最终链接命令里-lavcodec排在-lvcsnew前面才意识到是--as-needed在捣鬼。把顺序一调问题当场消失。第二次卡壳更离谱符号在nm里看得见但在nm -D里看不见。当时库是从另一个团队接手过来的对方编译时用了-fvisibilityhidden接口函数又忘了加导出标记。我盯着nm的输出以为符号明明是导出的排查了很久。后来才知道动态链接只认-D看到的.dynsym那一次之后我再也没有混淆过nm和nm -D。这两个坑在文档里其实都写得很清楚但报错信息太有迷惑性人很容易被库坏了的先入为主带偏。希望这篇能把你的排查路径直接拉直少走几个来回。