C++编译与链接

本文最后更新于 2024年5月21日 上午

C++编译与链接

参考资料:

  1. C / C++程序编译过程
  2. 浅析C/C++编译流程
  3. 《深入理解计算机系统》第七章

一、概述

编译链接流程.png
  • 简单来说,C/C++编译的整个过程分为四个阶段:预处理(Pre-Processing)编译(Compilation)汇编(Assembling)链接(Linking)
  • 其中源程序、修改了的源程序和汇编程序都是文本文件,而可重定位目标程序和可执行目标程序都是二进制文件

二、编译

2.1 预处理

  • 预处理阶段做的事情就是预处理器(cpp)根据以字符#开头的代码修改原始的C程序。
    • 比如#include <stdio.h>,将头文件stdio.h中的内容加到程序文本中
    • 比如#define info "Hello, world\n",会将宏定义的info替换成字符串"Hello, world\\n"(当然我这里只是为了举例,一般我们不这么写)
    • 此外,会将注释比如// A simple program.删除
  • 预处理是直接对源文件进行处理(不关注语法规则), 然后得到另一个C程序,通常以.i作为文件扩展名。
  • 可以在Linux系统(我这里用的是Ubuntu)下直接使用gcc -E main.c -o main.i命令得到预处理后的C程序main.i

2.2 编译

  • 编译阶段做的事情就是编译器(cc1)将C程序main.i翻译成汇编语言程序main.s:
    • 检查C程序的语法错误
    • 将文件翻译成中间代码,即汇编语言
    • 可选地优化翻译后的中间代码,获得更好的性能
  • 可以使用gcc -S main.i -o main.s得到翻译后的汇编程序main.s
  • 汇编语言是有用的,它为不同高级语言的不同汇编器提供了通用的输出语言,C汇编器和Fortran汇编器产生的输出文件都是一样的汇编语言。

2.3 汇编

  • 汇编阶段做的事情就是汇编器(as)将main.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件main.o(Windows下为xx.obj而Linux下为xx.o)中。main.o是一个二进制文件,如果我们使用文本编辑器打开main.o,会看到一堆乱码。

  • 我们可以使用gcc -c main.s -o main.o得到可重定位目标程序main.o

  • 目标文件与可执行文件的组织形式非常类似,只是有些变量和函数的地址还未确定(这里其实就是指引用的全局变量和函数),程序不能执行。所以下一步链接的一个重要作用就是找到这些变量和函数的地址。

三、链接

  • 链接被分为两步:

    1. ELF文件段合并,符号表合并完毕之后,再进行符号解析;
    2. 符号重定位。
  • ELF文件段的合并就是将相同的文件段合并在一起,符号解析就是链接器将每个符号的引用和符号定义建立关联。 而重定位就是计算每个定义的符号在虚拟地址空间的绝对地址,然后将可执行文件符号引用处的地址修改为重定位后的地址信息。

3.1 ELF文件

  • 在 Linux 下,将目标文件与可执行文件统称为 ELF文件(Executable and Linkable Format)。目标文件有三种形式:
    1. 可重定位目标文件:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件,对应linux的.o和静态链接库
    2. 可执行目标文件:包含二进制代码和数据,其形式可以被直接拷贝到存储器中执行,对应可执行文件
    3. 共享目标文件:一种特殊类型的可重定位目标文件,可以再加载或运行时被动态加载到存储器并链接,对应动态链接库
ELF文件.png
  • ELF文件的段:
    • ELF Header文件头:描述了整个目标文件的属性,包括是否可执行、是动态链接还是静态链接、入口地址是什么、目标硬件、目标操作系统、段表偏移等信息。还会记录节头部表的文件偏移,其中条目的大小和数量。节头部表中会描述不同节的位置和大小,每个节都有一个固定大小的条目。
    • .text代码段:存放编译后的机器指令,也即各个函数的二进制代码。一个C语言程序由多个函数构成,C语言程序的执行就是函数之间的相互调用。
    • .rodata:只读数据段,存放一般的常量、字符串常量等(对应漫谈C语言内存管理中说的常量区)。
    • .data:数据段,存放已初始化的全局变量和静态变量(对应漫谈C语言内存管理中说的全局数据区)。
    • .bss:也是数据段(Block Storage Start),但是存放未初始化的全局变量和静态变量,以及所有初始化为0的全局或静态变量。这个段中的数据在程序加载时会被全部初始化为0。
    • .rel.text、.rel.data:重定位段,包含了目标文件中需要重定位的全局符号以及重定位入口。
    • .symtab:符号表,存放在程序中定义和引用的函数和全局变量的信息。里面存放的是结构体数组,结构体表示“条目”。
    • .rel.text:目标文件中的代码会调用别的目标文件中的函数,编译时并不知道调用函数的具体地址,所以在.text段中调用外部函数的跳转指令是空白的,需要在链接的时候修改。而.rel.text的功能就是记录.text段中所有需要链接时修改的位置,被称为重定位条目。
    • .rel.data:被当前目标文件引用或者定义的所有全局变量的重定位信息。本质和函数的重定位是相同的道理,但是因为全局变量的位置需要链接的时候确定,无论是自己用的还是向外部提供的都需要链接的时候更改为真正的地址。而目标文件内部的函数调用可以使用PC偏移跳转实现,所以编译时可以确定。
    • .strtab:字符串表,主要是符号表中的符号(函数名,全局变量名等)的字符串,可以理解为一堆字符串常量。

3.2 符号和符号表

  • 变量(函数名)就是地址的助记符,是为了方便人处理而存在的,它们也被称为“符号”,它们的起始地址就成为“符号定义”,当它们被调用的时候也称为符号引用。

    • 由当前模块定义,并能被别的模块引用的全局符号。对应当前模块定义的非静态的C函数和全局变量。
    • 由其他模块定义,并被当前模块引用的全局符号。这些称为外部符号,对应在其他模块中定义的非静态的C函数和全局变量。
    • 只被当前模块定义和引用的局部符号。对应当前模块定义的带static属性的C函数和全局变量。
  • .symtab符号表中符号条目结构如下:

符号表条目.png
  • name表示符号名,就是一个字符串,但这里的字段存储的是在字符串表中的偏移;type表示数据或者函数;binding表示符号是本地的还是全局的;value字段在可重定位模块中是距定义目标的节起始位置的偏移,对于可执行文件是一个绝对运行时地址;size是目标大小。
  • section字段表示这个符号被分配在了目标文件的哪一个节。前面介绍的ELF文件的格式中提到了各种节,但是这里除了这部分存在的节之外,可重定位目标文件还有三个特殊的伪节:ABS表示不该被重定位的符号;UNDEF代表未定义的符号,就是外部符号;COMMON表示还未被分配位置的没有初始化的数据目标。
  • 注意这里的COMMON段存放的是未初始化的全局变量;.bss段存放的是未初始化的静态变量,以及初始化为0的全局或静态变量。如果一个全局变量被分配到了.bss段,就表示它已经被确定存在了,还有一部分的全局变量即使定义了,在链接的时候还会应用重名被删除掉,所以会被放在伪节COMMON段中,等待链接的时候再做处理。
  • 再说下符号表是什么,这对于理解后面的链接本质极为关键。符号表本质上是一种数据库,用来存储代码中的变量,函数调用等相关信息。该表以key-value的方式存储数据。变量和函数的名字就用来对应表中的key部分,value部分包含一系列信息,例如变量的类型,所占据的字节长度,或是函数的返回值。

3.3 符号解析

  • 链接工作的第一步就是要解析出所有输入的可重定位目标文件的符号表,将代码中的引用和定义关联起来。但是不同的目标文件中可能会定义相同名字的全局符号,比如同名的全局变量(static修饰的全局变量可以不受同名问题影响),以及同名的函数(补充一下只要函数的名字相同就会出现冲突,像C++中提供的函数重载等功能都是在语言的层面去实现的,语言会将函数名的符号解析成函数名+参数列表)。

  • 符号解析阶段会解决符号同名问题,符号分强/弱:函数名和已初始化的全局变量是强符号;未初始化的全局变量是弱符号。Linux使用下面的规则来处理多重定义的符号名:

    • 不允许有多个同名的强符号;
    • 如果有一个强符号和多个弱符号同名,选择强符号;
    • 如果有多个弱符号同名,任意挑选一个。
  • 关于符号的同名可能造成很多意外的错误,比如叫做sum的全局变量在A文件中可能是int sum,而在B文件中是long sum,但各自文件在编译的时候都是按照自己定义的类型进行操作的。如果最终链接器选择了int类型,那么B文件在写sum的时候会将对应地址按照long类型来操作。由于两个类型的size不同,会导致修改了别的变量。

  • 关于全面提到的COMMON段和.bss段,就是因为同名符号的处理,那些弱符号不确定是否真的会被定义,所以被放置在伪段交给链接器在链接的时候确定是否要定义这个全局变量。

3.4 重定位

  • 链接简单来说就是在多模块程序中,一个源文件会引用到其他源文件的全局变量或者方法,但是编译成目标文件都是针对单个源文件的,所以此时目标文件并不知道被引用的其他模块的变量或者函数的具体地址。所以此时目标文件中这些被引用的变量或者函数的地址是处于被搁置的状态,而链接就是将这些目标文件合并在一起,让这些被引用的变量或者函数的地址确定下来的过程,即将符号定义的地址填入符号引用处。
  • 重定位包含了两个步骤:第一步是重定位节和符号定义,链接器在符号解析之后将所有同类型的节合并成新的聚合节,此时程序中的每一条指令和全局变量都有确定的地址了;第二步是重定位节中的符号引用,链接器将修.text, .data中对每个符号的引用,这需要重定位条目的支持。
  • 解释一下为什么是.text, .data两个段需要做符号的重定位,对外部函数的调用显然是需要重定位的,在代码流程中使用外部的全局变量做运算赋值等显然也是需要的,但以上两点都只会出现在.text段。还有一种是当前文件的全局变量定义时,初始化为外部的全局变量的地址,这时就需要在.data段做重定位了。

3.4.1 重定位条目

  • 编译器在编译代码的时候凡是遇到位置未知的目标引用(并不只是外部引用),都会先留白然后记录一条重定位条目,告诉链接器如何进行修改。重定位条目被放在.rel.text, .rel.data
重定位条目.png
  • offset是需要被修改的引用相对于所在节的偏移;symbol标识被修改的引用应该指向的符号;type告诉链接器如何修改;addend是一个有符号常数,做偏移调整。
  • ELF定义32种不同的重定位类型,最基础的两种分别是:
    • R_X86_64_PC32,重定位一个使用32位PC相对地址的引用。
    • R_X86_64_32,重定位一个使用32位绝对地址的引用。

3.4.2 重定位符号引用

重定位算法.png
  • 我们在修改每个目标文件中的重定位符号时,是在原文件中进行修改的,所以我们要修改的地址是各自原目标文件中的地址。第一步refptr = s + r.offsets表示节的偏移,r.offset是要修改的位置的偏移,所以refptr确定了最后将重定位的结果写在哪里。
  • 函数调用的重定位一般使用PC相对地址,我们先通过ADDR(s)获得当前这个节的运行时地址,然后加上r.offset就是这条要进行重定位指令所在的地址。然后我们通过ADDR(r.symbol)获得调用的函数实际运行地址,于是通过减法就可以计算出PC相对地址了。
  • 全局变量的重定位一般使用绝对地址,直接通过ADDR(r.symbol)获得全局变量的绝对地址写入refptr就行。

四、库文件

  • C/C++中的库文件,类似Java的Jar包,本质就是一个压缩文件,里面是实现某个功能模块的各种代码的打包。通过库文件,可以方便地复用代码。比如C 语言标准库中提供有大量的函数,如 scanf(), printf(), strlen() 等,只要在源文件中引入对应的头文件,就可以访问到库文件的函数或者变量。
  • 而这种头文件和库文件相结合的访问机制的好处在于,我们可以通过头文件去对外暴露一些外部要用到的接口和变量的同时,不对外暴露内部实现的源码。
  • 库文件在链接阶段处理,有2种链接方式,一种是静态链接,即在生成可执行文件之前链接,另外一种是动态链接,即在生成可执行文件之后进行。

4.1 静态链接库

  • 静态链接库(.a文件)就是将整个库文件在生成可执行文件之前的链接阶段都打包到可执行文件中。

优势是:

  1. 可执行文件可以独立运行,无需另外带上库文件
  2. 相对于动态链接库,可以在运行的时候节省链接的时间(不过几乎可以忽略)

劣势是:

  1. 一旦库文件需要更改,整个可执行文件都要重新链接生成新的可执行文件。
  2. 和使用动态链接库生成的可执行文件相比,静态链接库生成的可执行文件的体积更大,所以更占用磁盘空间。
  3. 系统中如果有多个运行的程序都使用到同一个库文件,则都需要加载到内存而导致内存空间的浪费。

4.2 动态链接库

  • 动态链接库(.so文件)则是在运行时才链接到可执行文件中。

优势是:

  1. 一旦库文件需要更改,只需要更换库文件即可,可执行程序无需修改。
  2. 和使用静态链接库生成的可执行文件相比,使用动态链接库的可执行文件的体积更小,所以更省磁盘空间。
  3. 系统中如果有多个运行的程序使用到该库文件,可以复用一部分空间,即不会造成内存空间的浪费。(强调一下,同时运行的多个程序在内存中复用同一个动态库的代码段等,系统内存中只存一份)。

劣势是:

  1. 可执行文件不可以独立运行,需另外带上库文件
  2. 相对于静态链接库,在运行的时候会增加动态链接的时间(不过几乎可以忽略)

五、动态链接

  • 对于动态共享库而言,主要目的就是允许多个正在运行的继承共享内存中的相同库代码,这里遇到的问题就是多个进程如何共享一个库的代码呢?
  • 方法一:给每个共享库事先分配一个块专用的地址空间,要求加载器总在这个地址加载共享库。但是存在如下的缺点:
    1. 对地址空间使用效率不高,因为即使不加载这个共享库,这部分空间依旧得分配出来。
    2. 一旦库被修改,还要调整这块分配的空间,这使得它难以管理
    3. 如果共享库很多,就很容易出现地址空间之间出现许多不能使用的内存碎片空间
  • 方法二: 基于方法一的种种缺点,所以出现了“与位置无关的代码”,目的是让库代码可以在任意地址加载执行。(即用相对地址)

5.1 位置无关代码

  • 可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code, PIC)GCC编译的时候使用-fpic选项指示生成PIC代码。
  • 首先要知道的是,在一个目标模块(包含共享目标模块)中,数据段总是在代码段后面的,代码段和数据段之间的相对距离固定,和代码段和数据段的绝对地址无关。一个目标模块内部的符号的引用可以使用PC相对寻址来实现引用,所以一个目标模块内的的调用或者引用偏移量是已知的,所以本身就是PIC代码。但是对于别的目标模块的函数调用和全局变量引用就需要处理为PIC,在链接时重定位。
  • 在生成PIC代码的时候,编译器会在数据段开始创建一张全局偏移量表(Global Offset Table, GOT)GOT包含每个被这个目标木块引用的全局数据目标的表目,然后给每个表目生成一个重定位记录,在加载动态库时,动态链接器会重定位GOT的每个表目,使得它包含正确的位置。
  • 在代码中的指令会直接写到GOT[x]位置取调用函数的地址,或者全局变量的地址。而加载动态库的时候根据加载的位置修改GOT数组。

5.2 PIC数据引用

PIC数据引用.png
  • 对于全局变量引用的加载,是在加载动态库的时候就完成对所有引用的全局变量,对应的GOT数组中绝对地址的写入。
  • 此后代码在运行的时候就可以通过GOT数组直接查到全局变量的绝对地址。

5.3 PIC函数调用

PIC函数引用.png
  • 函数的重定位引入了延迟绑定的概念,每个外部函数都有对应的GOT空间存储函数的绝对地址,但不是像全局变量在加载的时候就完成对所有函数对应的GOT的更改,而是将函数地址的绑定推迟到第一次调用该函数时。这样可以避免去绑定很多根本不会调用到的函数,注意代码中有写调用某个函数,实际执行流程不一定会走到调用该函数。

  • 延迟绑定引入了过程链接表(Procedure Linkage Table, PLT)PLT是代码段的一部分而GOT在数据段。

  • 上图(a)展示了GOTPLT 如何协同工作,在addvec()被第一次调用时,延迟解析它的运行时地址:

    • 第1步。不直接调用addvec,程序调用进人PIT[2],这是addvecPLT条目;
    • 第2步。第一条PLT指令通过GOT[4]进行间接跳转。因为每个GOT条目初姶时都指向它对应的 PLT 条目的第二条指令,这个间接跳转只是简单地把控制传送回PIT[2]中的下一条指令;
    • 第3步。在把addvecID(0x1)压人栈中之后,PIT[2]跳转到 PIT[0]
    • 第4步。PIT[0]通过GOT[1]间接地把动态链接器的一个参数压人栈中,然后通过GOT[2]问接跳转进动态链接器中。动态链接器使用两个栈条目来确定addvec的运行时位置,用这个地址重写GOT[4],再把控制传递给addvec
  • 图(b)给出的是后续再调用addvec时的控制流:

    • 第1步。和前面一样,控制传递到PIT[2]
    • 第2步。不过这次通过GOT[4]的间接跳转会将控制直接转移到addvec

C++编译与链接
https://lluvialuo.github.io/2024/05/21/C-编译与链接/
作者
Lluvia Luo
发布于
2024年5月21日
许可协议