链接、库与那个折磨新手的 undefined reference
这一篇想解决什么
上一篇我们把 gcc 拆成了四步,停在 .o——那时我说过一句:.o 你还不能直接跑,因为它里面的外部符号还没着落。这个"给符号找下家"的活儿,就是这一篇的主角链接干的事。如果你跟笔者一样,刚学嵌入式/系统编程时被一句 undefined reference to 'xxx' 卡到怀疑人生,那这一篇就是写给你的——我们把链接真正拆开,亲手把一个静态库打出来、链上去、再把那个经典报错复现一遍,看清楚它到底在抱怨什么。
环境跟上一篇一样,host 上 gcc 16.1.1,几个小 .c 文件,不需要硬件。
链接到底在干什么
我们现在要做的是,先把"链接"这件事用人话说清楚,再去碰命令。前面 gcc -c 出来的每个 .o,都是一个半成品:它自己的代码和变量都翻译好了,但它引用了别人的东西——比如调了 printf,用了某个别的 .c 里的函数——这些被引用的符号,在它自己这个 .o 里是"空着"的,只留了个标记说"我要找叫这个名字的东西"。
链接器(ld,gcc 在背后调它)干的就是对账:拿着所有 .o,把它们"想要的符号"和"提供的符号"两两配对,凡是声明了要、却没有任何一个 .o 或库能提供的,就报那个我们最怕看到的 undefined reference。换句话说,这个报错不是语法问题、不是你的 C 写错了,纯粹是"账没平"——某个名字,链接器翻遍了所有给它的零件,没找到谁提供了它。
先复现那个报错,看它到底怎么说
讲一堆不如直接让它崩一次。我们写一个只声明、不定义 foo 的程序:
/* usefoo.c */
int foo(int x); /* 只声明,不定义 */
int main(void) { return foo(1); }然后直接编:
gcc usefoo.c -o usefoo你大概率会收获这么一坨:
/usr/bin/ld: /tmp/ccyLn8r2.o: in function `main':
usefoo.c:(.text+0xa): undefined reference to `foo'
collect2: error: ld returned 1 exit status我们先逐行读懂它在说什么。/usr/bin/ld 是链接器自己;in function 'main' 告诉你是谁在要这个符号——是 main 函数;(.text+0xa) 是 main 在代码段里引用 foo 的那条指令的偏移;undefined reference to 'foo' 是核心——名字叫 foo 的符号没人提供;最后 ld returned 1 是链接失败退出。
你可能会问:我明明声明了 int foo(int x); 啊,为什么还报错?原因在于,声明只是告诉编译器"有这个东西、长这样",编译阶段就放你过了;但链接器要的是"这个东西的实现到底在哪",声明给不了它。所以我们故意只声明不定义,链接器翻遍所有零件也没找到 foo 的实现,账没平,崩了。这就是 undefined reference 的本质——不是编译没过,是链接阶段的符号对账失败。
修法也直白:要么真给个 foo 的定义,要么把提供 foo 的那个 .o 或库加进来链接。我们现在走第二条路,顺便把"静态库"这件事学掉。
真正打一个静态库出来
我们先把 foo 的实现单独放一个文件,编成 .o,再用 ar 把它打包成一个静态库(.a)——这在嵌入式和系统项目里太常见了,HAL、CMSIS、各种第三方底层库,多半都是这么交付的。
/* foo.c —— foo 的实现 */
int foo(int x) { return x * 2; }/* main.c —— 用 foo 的人 */
int foo(int x);
int main(void) { return foo(21); }现在我们分两步:先把 foo.c 单独编成 .o(注意是 -c,只编译不链接),再用 ar 打包:
gcc -c foo.c -o foo.o
gcc -c main.c -o main.o
ar rcs libfoo.a foo.oar rcs 这三个字母我们拆一下:r 是 replace/insert(把 foo.o 放进库)、c 是 create(库不存在就建)、s 是写索引(让链接器能快速查到库里有哪些符号,这一位很重要,少了它老版本 ld 会找不到符号)。跑完你会得到一个 libfoo.a,笔者的实测大小是 1364 字节——它本质上就是一个 .o 的归档包,外加一个符号索引。
链接顺序:一个能让老手也翻车的坑
库打好了,现在把它链上 main。这里有个坑,我们必须亲自踩一遍才记得住。先看错误的写法——把库写在前面、.o 写在后面:
gcc -L. -lfoo main.o -o bad/usr/bin/ld: main.o: in function `main':
main.c:(.text+0xa): undefined reference to `foo'
collect2: error: ld returned 1 exit status明明 libfoo.a 就在旁边,它还是说找不到 foo——很多朋友会卡在这里怀疑人生。底层机制是:GNU ld 是从左往右扫的,而且它是"按需取用"——只有当左边已经有 .o 提出了对某符号的需求时,它才会去右边的库里把这个符号的实现拽出来。你把 -lfoo 放在最左,那时候 main.o 还没出场、谁也没说要 foo,ld 扫过这个库发现"没人需要",就一略而过;等扫到 main.o 发现它要 foo,可库已经扫完了、不会再回头,于是报 undefined。
所以正确顺序是被依赖的对象在左、提供实现的库在右:
gcc main.o -L. -lfoo -o good
echo $?
./good; echo "foo(21) = $?"0
foo(21) = 42 (期望 42)链接通过,foo(21) 返回 42,账平了。记住这个顺序——对象 -L -l库,库永远在依赖它的对象后面。等你后面写 CMake、链一堆第三方库的时候,这个顺序意识能帮你省下大量排错时间。
⚠️ 注意:
-lfoo这个写法是约定的简写——它去找的文件叫libfoo.a(或.so),也就是去掉前缀lib、去掉后缀,前面加-l。新手经常写成-llibfoo或者-lfoo.a,那是找不到的。-L.是告诉它"当前目录也作为一个找库的路径",不然它只去系统目录找。
顺带说一句:静态库 vs 动态库
我们刚打的是静态库(.a)——链接时,链接器把库里被用到的那个 .o 整个拷进你的可执行文件,最后那个程序自己就包含了 foo 的实现,运行时不再需要 libfoo.a。还有一种是动态库(.so),链接时只记一个"运行时去某个地方找它"的引用,真正的代码要等程序跑起来、操作系统才去加载——好处是多个程序能共享同一份、升级方便,代价是运行时依赖那个 .so 还在、版本对得上。
对嵌入式来说,这个区别基本不用纠结:裸机根本没有操作系统去给你动态加载 .so,所以我们几乎只用静态库,甚至很多时候连库都不打,直接链一堆 .o。动态库是 host、或者跑嵌入式 Linux 的设备才操心的事。这一篇我们就停在静态库,够你应付绝大多数裸机项目了。
小结
链接就是把所有 .o 的"要的符号"和"提供的符号"对账,对不平就报 undefined reference——它是链接阶段的账务错误,不是语法错误。我们亲手用 ar rcs 打了一个静态库 libfoo.a,又亲手踩了链接顺序的坑:ld 从左往右按需取用,库必须写在依赖它的对象后面,写成 对象 -L -l库。最后别忘了 -l 的命名约定:libfoo.a 对应 -lfoo。
自测一下:看到 undefined reference to 'foo',你能不能判断这是编译没过还是链接没过、该往哪个方向修;ar rcs 的 s 为什么不能省;为什么 gcc -lfoo main.o 会失败而 gcc main.o -lfoo 能成。
接下来
到目前为止我们都在 host 上玩——gcc 编出来的程序在自己电脑上跑。可嵌入式要的是"在我电脑上编、在板子上跑",这就轮到交叉编译、链接脚本和启动代码登场了。下一篇我们换上 arm-none-eabi-gcc,亲手把一个最小裸机程序编出来,看 objcopy 怎么抽出能烧进芯片的 .bin,以及那个让 .data 从 Flash 搬进 RAM 的精妙设计。