关于c++:从四个问题透析Linux下C编译链接

38次阅读

共计 5449 个字符,预计需要花费 14 分钟才能阅读完成。

摘要:编译 & 链接对 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/b…

注 1 :本文所波及工具可从 http://www.gnu.org/software/b…

注 2 :本文示例代码图片中,每个窗口上面的红色区域有这份代码对应的文件名称,留神匹配对应文中阐明

点击关注,第一工夫理解华为云陈腐技术~

正文完
 0