man gcc 所说——

When you invoke GCC, it normally does preprocessing, compilation, assembly, and linking.

当我们 gcc -o main main.c 的时候,其实这四个步骤都执行过了。如果我们想的话,我们可以将这四步分开进行

gcc -E main.c > main.i
gcc -S main.i  # out comes main.s
gcc -c main.s  # out comes main.o
gcc main.o     # out comes a.out

这里,main.c 是源文件 (source file), main.i 是预编译后的源文件 (preprocessed source file), main.s 是汇编文件 (assembly file), main.o 是机器码,或称「目标代码」 (the so-called "machine code" or "object code"), a.out 是可执行文件 (executable file). (注:其中 .o 和 executable 在 Linux 上往往是所谓的 ELF (Executable and Linkable Format) -- 咦,readelf 里的 elf 是哪来的呢?)

这大多是早就听说过了的东西了;可是这啥「链接」到底是干啥的?还有,我 #include 进来的不只是头文件 (header files) 吗?怎么看似只有声明(而没有定义)的东西却是能用的?最近,由于工作的需要我接触到了 "DLL" (Dynamical-Link Library, 动态链接库) 这个东西,于是展开了研究「C 语言程序到底是怎么编译的」的一次小旅程。最初的工作需要是「看看这个 DLL 里有什么函数」——因为我猜 DLL 既然是个「库」,它应该有一些函数给我用吧?——然后就止不住地花费了工内工后的各种时间研究之。

首先,我很快地了解到了 Linux 下的 .so 文件在概念上跟 DLL 是一样的,于是我就打算试试自己制作并使用一个自己的 .so. 很快,我找到了这篇我看了多次的文章(Shared libraries with GCC on Linux) —— 在我的 Linux 机器上,跟着文章的指引,我做出并调用了我的第一个动态链接库。

这「动态链接库」到底怎么个「动态」法呢?——如果我将编译出来的 libfoo.so 改名为 libgood.so, 然后另外编译一个对外公布的 API/接口 和 libgood.so 完全一样,但是内部的实施细节和 libgood.so 完全没有任何关系的某 libbad.so, 将其改名为 libfoo.so, 然后运行我的 main, 会怎么样呢?我试了试,果然,未经重新编译的 main 执行时毫无意见地执行了 libbad.so 里的「恶意」代码。至此,相比我们都明显地感受到「从网上下载 DLL 放到系统目录里」到底是一种什么样的危险行为了吧?

继续关注我的初衷——「这个 .so 提供了些什么函数呢?」——很快,我又发现了 nm, readelf, 和 objdump 这三个命令。(事后发现,三者全是 GNU 在 binutils 里所有提供的。)由于我对我要找的函数的名字有几个猜测,所以在试 nm 的时候,就已经找到了「一部分」(事后证明其实是 全部 )我预期中的函数了;readelf 详细的信息也算是确认了我的猜想以及从 nm 那获得的信息。

可是问题来了——为什么我 nm 或者 readelf 一个 .so 文件的时候,会有那么多的「名称」指代着貌似是未定义的东西的呢?——我之前就略为听说过 "symbol table" 这个东西,估计 nm 给的就是我的机器码的 "symbol table" 了吧(如其 man page 所言,显然是的)。这「未定义的符号」到底是怎么回事呢?还有,Shared libraries with GCC on Linux 一文里这什么 LD_LIBRARY_PATH, 啥 ld, ldd, gcc-L, -l, -Wl,-rpath=... 又是什么意思呢?我继续研究…

然后我读到了这篇文章 (A Quick Tour of Compiling, Linking, Loading, and Handling Libraries on Unix)。虽然其不是讲 C 语言的(而是 C++),并且不知道内容是否和我想了解的一致,但是见到其有使用 nm 等 binutils 的例子、解读,我就读了。顺手也就那我之前的 libgood.so, libbad.so 之类的来试了试。这篇文章里,值得一提的是 C++ 的所谓 "name mangling" -- 这样东西使得阅读 nm 的输出十分困难——幸好 nm 有个 --demangle 的选项。

我然后干脆就补了下「从 .c 到可执行文件」这一路下来我的源文件所经过的各个步骤。没多久我就花了时间读 man gcc -- 事实表明这是十分正确的 —— 关于 gcc -E, -S, -c 的疑问全部打消了,我也见到了我的 .c 编译出来的汇编码——还是稍有激动的一个时刻。既然同一个程序的 5 个不同形态 (.c, .i, .s, .o, executable) 都见识到了,我当然也就用 nm 之类的工具研究了下我的 .o.

情况大概就在这时候明朗起来了。一个函数(或者是其他的什么东西——见 Wikipedia 的 nm 条目)会在 .o 里弄出一个「符号」来;该「符号」的名称与函数(之类的)的名称一样 —— 除非有 name mangling. 我意识到了一个我认为是非常的关键的现象:

我的程序所声明了而未定义的函数都是在「链接」的时候才被决定用于指代哪个函数体定义的!

"linking" 开始之前我的程序以一种什么形态存在呢?——就是 .o 文件了。正如 nm 所会报告,我的 .o 主程序有很多 "undefined" symbols. 看来,「链接」就是一个 把多个 .o 沿着同样的符号名对接起来 的过程!

$ vim declares_a_few_functions.h
$ vim in_main_we_use_those_functions_declared_yet_undefined.c
$ vim just_define_those_functions.c
$ gcc -c *.c  # they all compile just fine...
$ gcc -o main *.o  # the linker doesn't complain either!
$ ./main
It works!

知道了「『链接』其实是沿着 symbol table 把多个 .o 连起来」这码事之后,许多东西都明了起来了——如果一堆 .o 能连得起来没有「漏」(即 undefined symbol),而且其中有一个 symbol 叫 main 的话,linker 就可以输出一个 executable; 如果这堆 .o 全部都已经连起来了,却还是有「漏」,或者没有定义 main, 那么如果你给个 -shared flag 给 gcc 的话, linker 还是可以输出一个 .so 供你继续拿来「连」其他东西的。-L 就是向 linker 指明「去哪找能连的东西」;-l 就是(还是向 linker) 指明「要连的那捆东西叫什么」;gcc-Wl,-rpath= 选项或者 Linux 的 LD_LIBRARY_PATH 环境变量就是让 linker 在其将输出的 executable/so 里留下话给 loader (i.e. 执行 ./main 时加载我们的程序的那个程序)说「去这些目录里找需要的 .so!」;而 ldd 就可以显示一个给定的 executable 里,我们的 linker 到底留了些什么话。

很自然的考虑就是——「显然有些库是自动加载的——那么,它们存在于哪里??」—— Aha! 这个问题恰恰又指出了 Linux 系统和 C 语言环境紧密的关系——忽然间,/usr/bin/, /usr/lib/, /usr/include/ (或者 /bin/, /lib/;还有 /usr/local/bin/, /usr/local/lib/, /usr/local/include/)的意义几乎不言自明了—— include/ 就是 gcc 找我们 #include <blahlib.h> (或者 #include "locallib.h" —— 我不是 C 程序员,我分不清)时搜索 blahlib.h 的地方;lib/ 就是找 ldd 能显示的 linker 说我们的 executable 所需的 .so 的地方。不如我试一下,把的 libfoo.so 放到 /usr/lib/ 里?果然,-L, -Wl,-rpath=, LD_LIBRARY_PATH 都可以省掉了!

如果进一步阅读,想必我们还能不耗太多功夫地搞清楚『「静态库」和「动态库」的区别是什么?』、『为什么我们现在一般使用动态库?』、『.o, .so 还有那些链接顺序的花样?』之类的问题,不过对于我这样一个不是 C 程序员,却又十分困惑「那些什么 strdup 啥的函数到底是哪里来的」的人来讲,这实在算是一片疑惑得到了解答了!我觉得我从此可以愉快地编译自己写的 C 代码了!

相关链接: