从四个问题透析 Linux 下 C++ 编译 & 链接
摘要:编译 &链接对 C&C++程序员既熟悉又陌生,熟悉在于每份代码都要经历编译 &链接过程,陌生在于大部分人并不会刻意关注编译 &链接的原理。本文通过开发过程中碰到的四个典型问题来探索 64 位 linux 下 C++编译 &链接的那些事。
编译原理:
将如下最简单的 C++程序(main.cpp)编译成可执行目标程序,实际上可以分为四个步骤:预处理、编译、汇编、链接,可以通过
g++ main.cpp –v 看到详细的过程,不过现在编译器已经把预处理和编译过程合并。
预处理:g++ -E main.cpp -o main.ii,-E 表示只进行预处理。预处理主要是处理各种宏展开;添加行号和文件标识符,为编译器产生调试信息提供便利;删除注释;保留编译器用到的编译器指令等。
编译:g++ -S main.ii –o main.s,-S 表示只编译。编译是在预处理文件基础上经过一系列词法分析、语法分析及优化后生成汇编代码。
汇编:g++ -c main.s –o main.o。汇编是将汇编代码转化为机器可以执行的指令。
链接:g++ main.o。链接生成可执行程序,之所以需要链接是因为我们代码不可能像 main.cpp 这么简单,现代软件动则成百上千万行,如果写在一个 main.cpp 既不利于分工合作,也无法维护,因此通常是由一堆 cpp 文件组成,编译器分别编译每个 cpp,这些 cpp 里会引用别的模块中的函数或全局变量,在编译单个 cpp 的时候是没法知道它们的准确地址,因此在编译结束后,需要链接器将各种还没有准确地址的符号(函数、变量等)设置为正确的值,这样组装在一起就可以形成一个完整的可执行程序。
问题一:头文件遮挡
在编译过程中最诡异的问题莫过于头文件遮挡,如下代码中 main.cpp 包含头文件 common.h,真正想用的头文件是图中最右边那个包含 name
成员的文件(所在目录为./include),但在编译过程中中间的 common.h(所在目录为./include1)抢先被发现,导致编译器报错:Test 结构没有 name 成员,对程序员来讲,自己明明定义了 name 成员,居然说没有 name 这个成员,如果第一次碰到这种情况可能会怀疑人生。应对这种诡异的问题,我们可以用-E 参数看下编译器预处理后的输出,如下图。
预处理文件格式如下:# linenum filename flag,表示之后的内容是从文件名为 filaname 的文件中第 linenum 行展开的,flag 的取值可以是 1,2,3,4,可以是用空格分开的多值,1 表示接下来要展开一个新文件;2 表示一个文件展开完毕;3 表示接下来内容来自一个系统头文件;4 表示接下来的内容应该看做是 extern C 形式引入的。
从展开后的输出我们可以清楚地看到 Test 结构确实没有定义 name 这个成员,并且 Test 这个结构是在./include1 中的 common.h 中定义的,到此真相大白,编译器压根就没用我们定义的 Test 结构,而是被别的同名头文件截胡了。我们可以通过调整-I 或者在头文件中带上部分路径更详细制定头文件位置来解决。
目标文件:
编译链接最终会生成各种目标文件,Linux 下目标文件格式为 ELF(Executable Linkable Format),详细定义见/usr/include/elf.h 头文件,常见的目标文件有:可重定位目标文件,也即.o 结尾的目标文件,当然静态库也归为此类;可执行文件,比如默认编译出的 a.out 文件;共享目标文件.so;核心转储文件,也就是 core dump 后产出的文件。Linux 文件格式可以通过 file 命令查看。
一个典型的 ELF 文件格式如下图所示,文件有两种视角:编译视角,以 section 头部表为核心组织程序;运行视角,程序头部表以 segment 为核心组织程序。这么做主要是为了节约存储,很多细碎的 section 在运行时由于对齐要求会导致很大的内存浪费,运行时通常会将权限类似的 section 组织成 segment 一起加载。
通过命令 objdump 和 readelf 可以查看 ELF 文件的内容。
对可重定位目标文件常见的 section 有:
符号解析:
链接器会为对外部符号的引用修改为正确的被引用符号的地址,当无法为引用的外部符号找到对应的定义时,链接器会报 undefined reference to XXXX 的错误。另外一种情况是,找到了多个符号的定义,这种情况链接器有一套规则。在描述规则前需要了解强符号和弱符号的概念,简单讲函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
针对符号的多重定义链接器处理规则如下(作者在 gcc 7.3.0 上貌似规则 2,3 都按 1 处理):
1. 不允许多个强符号定义,链接器会报告重复定义貌似的错误
2. 如果一个强符号和多个弱符号同名,则选择强符号
3. 如果符号在所有目标文件中都为弱符号,那么选择占用空间最大的一个
有了这些基础,我们先来看一下静态链接过程:
1. 链接器从左到右按照命令行出现顺序扫描目标文件和静态库
2. 链接器维护一个目标文件的集合 E,一个未解析符号集合 U,以及 E 中已定义的符号集合 D,初始状态 E、U、D 都为空
3. 对命令行上每个文件 f,链接器会判断 f 是否是一个目标文件还是静态库,如果是目标文件,则 f 加入到 E,f 中未定义的符号加入到 U 中,已定义符号加入到 D 中,继续下一文件
4. 如果是静态库,链接器尝试到静态库目标文件中匹配 U 中未定义的符号,如果 m 中匹配 U 中的一个符号,那么 m 就和上步中文件 f 一样处理,对每个成员文件都依次处理,直到 U、D 无变化,不包含在 E 中的成员文件简单丢弃
5. 所有输入文件处理完后,如果 U 中还有符号,则出错,否则链接正常,输出可执行文件
问题二:静态库顺序
如下图所示,main.cpp 依赖 liba.a,liba.a 又依赖 libb.a,根据静态链接算法,如果用 g++ main.cpp liba.a libb.a 的顺序能正常链接,因为解析 liba.a 时未定义符号 FunB 会加入到上述算法的 U 中,然后在 libb.a 中找到定义,如果用 g++ main.cpp libb.a liba.a 的顺序编译,则无法找到 FunB 的定义,因为根据静态链接算法,在解析 libb.a 的时候 U 为空,所以不需要做任何解析,简单抛弃 libb.a,但在解析 liba.a 的时候又发现 FunB 没有定义,导致 U 最终不为空,链接错误,因此在做静态链接时,需要特别注意库的顺序安排,引用别的库的静态库需要放在前面,碰到链接很多库的时候,可能需要做一些库的调整,从而使依赖关系更清晰。
动态链接:
之前大部分内容都是静态链接相关,但静态链接有很多不足:不利于更新,只要有一个库有变动,都需要重新编译;不利于共享,每个可执行程序都单独保留一份,对内存和磁盘是极大的浪费。
要生成动态链接库需要用到参数“-shared -fPIC”表示要生成位置无关 PIC(Position Independent Code)的共享目标文件。对静态链接,在生成可执行目标文件时整个链接过程就完成了,但要想实现动态链接的效果,就需要把程序按照模块拆分成相对独立的部分,在程序运行时将他们链接成一个完整的程序,同时为了实现代码在不同程序间共享要保证代码是和位置无关的(因为共享目标文件在每个程序中被加载的虚拟地址都不一样,要保证它不管被加载在哪都能工作),而为了实现位置无关又依赖一个前提:数据段和代码段的距离总是保持不变。
由于不管在内存中如何加载一个目标模块,数据段和代码段间的距离是不变的,编译器在数据段前面引入了一个全局偏移表 GOT(Global Offset Table),被引用的全局变量或者函数在 GOT 中都有一条记录,同时编译器为 GOT 中每个条目生成一个重定位记录,因为数据段是可以修改的,动态链接器在加载时会重定位 GOT 中的每个条目,这样就实现了 PIC。
大体原理基本就这样,但具体实现时,对函数的处理和全局变量有所不同。由于大型程序函数成千上万,而程序很可能只会用到其中的一小部分,因此没必要加载的时候把所有的函数都做重定位,只有在用到的时候才对地址做修订,为此编译器引入了过程链接表 PLT(Procedure Linkage Table)来实现延时绑定。PLT 在代码段中,它指向了 GOT 中函数对应的地址,第一次调用时候,GOT 存放的不是函数的实际地址,而是 PLT 跳转到 GOT 代码的后一条指令地址,这样第一次通过 PLT 跳转到 GOT,然后通过 GOT 又调回到 PLT 的下一条指令,相当于什么也没做,紧接着 PLT 后面的代码会将动态链接需要的参数入栈,然后调用动态链接器修正 GOT 中的地址,从这以后,PLT 中代码跳转到 GOT 的地址就是函数真正的地址,从而实现了所谓的延时绑定。
对共享目标文件而言,有几个需要关注的 section:
有了以上基础后,我们看一下动态链接的过程:
1. 装载过程中程序执行会跳转到动态链接器
2. 动态链接器自举通过 GOT、.dynamic 信息完成自身的重定位工作
3. 装载共享目标文件:将可执行文件和链接器本身符号合并入全局符号表,依次广度优先遍历共享目标文件,它们的符号表会不断合并到全局符号表中,如果多个共享对象有相同的符号,则优先载入的共享目标文件会屏蔽掉后面的符号
4. 重定位和初始化
问题三:全局符号介入
动态链接过程中最关键的第 3 步可以看到,当多个共享目标文件中包含一个相同的符号,那么会导致先被加载的符号占住全局符号表,后续共享目标文件中相同符号被忽略。当我们代码中没有很好的处理命名的话,会导致非常奇怪的错误,幸运的话立刻 core dump,不幸的话直到程序运行很久以后才莫名其妙的 core dump,甚至永远不会 core dump 但是结果不正确。
如下图所示,main.cpp 中会用到两个动态库 libadd.so,libadd1.so 的符号,我们把重点
放在 Add 函数的处理上,当我们以 g++ main.cpp libadd.so libadd1.so 编译时,程序输出“Add in add lib”说明 Add 是用的 libadd.so 中的符号(add.cpp),当我们以 g++ main.cpp libadd1.so libadd.so 编译时,程序输出“Add in add1 lib”说明 Add 是用的 libadd1.so 中的符号,这时候问题就大了,调用方 main.cpp 中认为 Add 只有两个参数,而 add1.cpp 中认为 Add 有三个参数,程序中如果有这样的代码,可以预见很可能造成巨大的混乱。具体符号解析我们可以通过 LD_DEBUG=all ./a.out 来观察 Add 的解析过程,如下图所示:左边是对应 libadd.so 在编译时放在前面的情况,Add 绑定在 libadd.so 中,右边对应 libadd1.so 放前面的情况,Add 绑定在 libadd1.so 中。
运行时加载动态库:
有了动态链接和共享目标文件的加持,Linux 提供了一种更加灵活的模块加载方式:通过提供 dlopen,dlsym,dlclose,dlerror 几个 API,可以实现在运行的时候动态加载模块,从而实现插件的功能。
如下代码演示了动态加载 Add 函数的过程,add.cpp 按照正常编译“g++ -fPIC –shared –o libadd.so add.cpp”成 libadd.so,main.cpp 通过“g++ main.cpp -ldl”编译为 a.out。main.cpp 中首先通过 dlopen 接口取得一个句柄 void *handle,然后通过 dlsym 从句柄中查找符号 Add,找到后将其转化为 Add 函数,然后就可以按照正常的函数使用,最后 dlclose 关闭句柄,期间有任何错误可以通过 dlerror 来获取。
问题四:静态全局变量与动态库导致 double free
在全面了解了动态链接相关知识后,我们来看一个静态全局变量和动态库纠结在一起引发的问题,代码如下,foo.cpp 中有一个静态全局对象 foo_,foo.cpp 会编译成一个 libfoo.a,bar.cpp 依赖 libfoo.a 库,它本身会编译成 libbar.so,main.cpp 既依赖于 libfoo.a 又依赖 libbar.so。
编译的 makefile 如下:
运行 a.out 会导致 double free 的错误。这是由于在一个位置上调用了两次析构函数造成的。之所以会这样是因为链接的时候先链接的静态库,将 foo_的符号解析为静态库中的全局变量,当动态链接 libbar.so 时,由于全局已经有符号 foo_,因此根据全局符号介入,动态库中对 foo_的引用会指向静态库中版本,导致最后在同一个对象上析构了两次。
解决办法如下:
1. 不使用全局对象
2. 编译时候调换库的顺序,动态库放在前面,这样全局只会有一个 foo_对象
3. 全部使用动态库
4. 通过编译器参数来控制符号的可见性。
总结:
通过四个编译链接中碰到的问题,基本把编译链接的这些事覆盖了一遍,有了这些基础,在日常工作中应对一般的编译链接问题应该可以做到游刃有余。由于篇幅有限,文章省略了大量的细节,主要集中在大的框架原理性梳理,如果想进一步深挖相关的细节,可参与相关参考文献,以及阅读 elf.h 相关的头文件。
参考文献:
1. 《链接器和加载器》
2. 《深入理解计算机系统》
3. 《程序员的自我修养》
4. http://www.gnu.org/software/binutils/
注 1:本文所涉及工具可从http://www.gnu.org/software/binutils/获取详细信息
注 2:本文示例代码图片中,每个窗口下面的白色区域有这份代码对应的文件名称,注意匹配对应文中说明
版权声明: 本文为 InfoQ 作者【华为云开发者社区】的原创文章。
原文链接:【http://xie.infoq.cn/article/9cfb4997328c43b7a8b0360b7】。文章转载请联系作者。
评论