C++编译链接流程详解

1.什么是链接

链接是将不同部分的代码和数据收集和组合成一个单一文件的过程,这个文件可被加载到存储器并执行。在大型软件开发中,我们不会将所有的功能实现放入一个源文件中,而是将它分解为更小更容易管理的模块,当我们修改了一个模块时,只需要重写编译这一个模块,再通过链接器链接。

2.编译器的工作流程

当前有两个文件,main.c和sum.c
在这里插入图片描述
在linux系统中,使用命令得到可执行程序prog。

gcc -o prog main.c sum.c

编译器的整个工作流程,如图所示:
在这里插入图片描述
首先是预处理阶段,将源程序的.c文件翻译为.i文件,图中的cpp指的是c预处理器,可用如下命令生成.i文件,-E选项是限制gcc只进行预处理不做编译,汇编及链接操作。

gcc -E -o main.i main.c

接下来是编译阶段,编译器将.i文件翻译成汇编.s文件。cc1指的是c编译器,也可以用gcc完成这一步,加入-S选项。

gcc -S -o main.s mian.i

第三步使用汇编器as将.s文件翻译成可重定位目标.o文件

as -o main.o main.s

最后使用链接器ld构造可执行文件。

ld -static -o prog main.o sum.o

3.目标文件

目标文件有三种形式:
1.可重定位目标文件:包含二进制代码和数据,可以在编译时与其他科重定位目标文件合并,创建一个科执行目标文件。
2.可执行目标文件:包含二进制代码和数据,可被直接拷贝到存储器并执行。
3.共享目标文件:一种特殊类型的课重定位目标文件,可以在加载或者运行时,被动态的加载到存储器并链接。
编译器和汇编器生成可重定位目标文件,链接器生成可执行目标文件。

3.1 可重定位目标文件

每一个可重定位目标文件可大致分为三个部分,如下图所示。ELF是可执行可链接格式的首字母缩写,图中Sections中包含了多个section块。在这里插入图片描述下图中的代码示例将帮助探究可重定位目标文件。在这里插入图片描述
通过以下代码可以查看main.o文件的ELF header,-h选项表示只显示header信息。

readelf -h main.o

在这里插入图片描述
图中Magic条目为魔数,用于确认文件类型,Type条目可以看到文件类型为可重定位文件,Size of this header条目表示ELF header的长度为64字节。可以确定Sections段的起始位置为0x40。Start of section headers表示Section header table的起始位置为1064字节,最后两行说明了Section header table一共包含13个表项,每个表项的大小是64个字节。可计算出整个文件大小为1896字节。
在这里插入图片描述
使用如下命令查看main.o文件的Section header table信息,Section header table描述了不同section的位置和大小。

readelf -S main.o

在这里插入图片描述offset表示每个section的起始位置,size表示section的大小。根据这两个参数信息,可以确定各个section在elf文件中的具体位置。
下面说明包含的常见section块:
.text:以编译程序的机器代码
.data:已经初始化的全局变量和静态变量的值。
.bss:存放未初始化的全局变量。被初始化为0的全局变量和静态变量也存放在bss中。实际上bss section只是一个占位符,区分已初始化和未初始化的变量是为了节省空间。
.rodata:存放只读数据,例如main.c文件中printf语句中的格式串。
接下来重点理解符号表(.symtab)的内容。

3.2符号和符号表

每个可重定位目标模块m都有一个符号表,它包含了m所定义和引用的符号信息,在链接器的上下文中有三种不同的符号:
1.由m定义并能被其他模块引用的全局符号,对应非静态函数和非静态的全局变量。
2. 由其他模块定义并被m引用的全局符号,称为外部符号,对应定义在其他模块中的函数和变量。
3. 只被模块m定义和引用的本地符号,对应带static属性的函数和全局变量。
使用如下命令查看main.o文件的符号表。

readelf -s main.o

在这里插入图片描述
图中最后一列给出了符号的名字,在main.c中我们定义了两个全局变量count和value,main函数内定义了两个局部静态变量a和b。通过Name可以找到它们,其中Bind表示它们的作用域,Ndx表示它们所在section的索引值,value表示它们所在位置相对于所在section的偏移量。size表示所占字节数。count和value的Type值为OBJECT,表示该符号是个数据对象,虽然count和value都是全局变量但处于不同的section,原因是value未初始化。a和b都属于局部变量,Bind值为Local,name中的命名是为了防止静态变量冲突。可以看到printf函数的Ndx值为UND,表示未定义,原因是它属于外部符号,未在main.c中定义。在main函数中还定义了局部变量x,但未在符号表中出现,原因是非静态局部变量在运行中栈被管理。

3.3链接器符号解析

通过以下代码来说明符号解析过程
在这里插入图片描述
图中的代码仅对函数foo做了声明,mian函数调用了foo,我们只对这个文件进行编译和汇编,不执行链接操作,编译和汇编是没问题的,对于foo函数,编译器认为它是在其他模块定义的,在符号表中生成了对应的foo符号,但Ndx值为UND。在链接阶段会报错:

undefind reference to 'foo'

以上情况为找不到符号定义。如果在可重定位文件中出现了同名的符号,该如何处理?
强符号:函数和已初始化的全局变量。
弱符号:未初始化的全局变量。
汇编器会将强弱信息隐藏在符号表中。多个同名强符号出现链接器会报错 。

4 静态库解析过程

通过以下代码来说明链接器是如何使用静态库来解析引用的。
在这里插入图片描述
代码中有一个外部函数addvec,在vector.h中定义,使用了外部库libvector,使用如下命令进行链接

gcc -static -o prog main.o ./libvector.a libc.a

链接器先处理main.o,再处理libvector.a,最后处理libc.a。
在扫描中,链接器维护了一个可重定位目标文件集合E,在扫描中发现了可重定位目标文件就会放到这个集合中,在链接要完成时,这个集合中的文件最终会被合并起来形成可执行文件。
链接器维护了一个集合U,会将在扫描中把引用了但尚未定义的符号放在这个集合里。还维护了一个集合D,存放输入文件已经定义的符号。
最开始时三个集合都是空的,对于命令中的每个文件f,链接器会判断f是一个目标文件还是一个静态库文件。如果f是一个目标文件,那么链接器会把f添加到可重定位目标文件集合E,同时修改集合U和D来反映f中的符号定义和引用。
针对上面的命令,链接器会先判断main.o是目标文件,然后把没main.o放到集合E中,通过main.o的符号表可以看到这个目标文件存在两个不在当前模块定义的符号,分别是addvec和printf,如下图所示。
在这里插入图片描述
链接器将addvec和printf放入未定义的符号集合U中,此时链接器假定它们在其他模块中被定义,没有报错。main.o已经定义的全局符号x,y,z以及main会放到已经定义的符号集合D中。

在这里插入图片描述
main.o文件处理完毕,继续处理下一个文件,发现下一个文件是静态库文件,链接器会尝试在静态库文件中寻找集合U中未解析的符号。例如在addvect.o中找到了addvec的符号,便会将addvect.o加入集合E,从集合U删除addvec。同时也要将addvect.o中定义的其它符号添加到集合D中,假设向D中添加了一个符号addcnt。重复上述步骤,扫描完所有文件,直到集合U和集合D不再变化。此时,不在集合E中的文件都将不再参与链接。如果集合U非空,则会报经典错误,undefined reference。要注意,命令中文件的先后顺序很重要。

5 重定位

链接器完成了符号解析后,它就把代码中的每个符号引用和确定的一个符号定义(目标模块中的一个符号表表目)联系起来,此时,链接器知道它的输入目标模块中的代码节和数据节的确切大小,开始重定位。
重定位由两步组成:
1.重定位节和符号定义,链接器将所有相同类型的的节合并为同一类型的新节,然后,链接器将运行时存储器地址赋给新的聚合节和每个符号。这一步完成时,程序中每个指令和全局变量都有唯一运行时的存储器地址。
2.重定位节中的符号引用,在这步中,链接器修改代码节和数据节对每个符号的引用,使它们指向正确的地址。为了执行这一步,链接器依赖于称为重定位表目的数据结构。

5.1 重定位表目

当汇编器生成一个目标模块时,它不知道数据和代码最终将存储在存储器中的位置,也不知道这个模块引用的外部定义的函数或者全局变量的位置。当汇编器遇到了最终位置未知的目标引用,它就会生成一个重定位表目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位表目在rela.text中,已经初始化的数据在rela.data中。
下图展示了重定位表目的格式,其中offset是需要被修改的引用的节偏移,symbol是指被修改的引用指向的符号,type告知链接器如何修改引用。
在这里插入图片描述
这里介绍两种最基本的type:
1.R_386_PC_32:重定位相对地址引用 2.R_386_32:重定位绝对地址引用

5.2 重定位符号引用

现在看一下ELF重定位表目的格式:

在这里插入图片描述
上图展示了链接器重定位算法的伪代码。该程序在每个section和相关联的重定位表目上迭代执行。我们假设当算法运行时,链接器已经为每个节和符号都选择了运行时相对地址(分别用ADDR(s)和ADDR(r.symbol)表示)。第三行计算的是需要重定位的4字节引用数组s中的地址,如果引用使用的是PC寻址,就用5到9行重定位,如果使用绝对寻址,就通过11到13行进行重定位。数据符号填的是绝对地址,函数符号因为要设计指令跳转,填的都是偏移量。

5.2.1 重定位PC相关的引用

什么是PC?
PC寄存器是计算机中的一种特殊寄存器,它通常用于存储程序计数器(Program Counter)的值。程序计数器是一个用来指示当前正在执行的指令位置的寄存器。
在计算机的执行过程中,指令是按照顺序存储在内存中的,程序计数器就是用来跟踪下一条要执行的指令在内存中的地址。
当计算机执行程序时,会不断地从内存中读取指令,执行它们,然后更新程序计数器的值,使其指向下一条要执行的指令。这样,计算机可以按照预定的顺序依次执行程序的指令,从而完成各种任务。
PC寄存器通常是一个固定长度的二进制数,其大小取决于计算机体系结构和指令集架构。在现代计算机体系结构中,PC寄存器往往是32位或64位的,具体取决于计算机的位数。
总结起来,PC寄存器是用来存储下一条要执行的指令在内存中地址的寄存器,它是计算机执行程序的关键部分。
现在来看一个完成链接形成可执行文件的例子,说明下我们要计算的是哪个值。

#include <stdio.h>

void foo() {
    printf("Hello, World!\n");
}

int main() {
    foo();
    return 0;
}

我们将以上代码编译链接生成可执行文件,使用objdump反汇编看下在main函数中调用foo函数是怎么写的。
在这里插入图片描述
观察在main函数中,callq之后1149为foo函数的入口地址,这个地址在重定位节和符号定义时已经确定,我们现在要计算的是它相对于当前call指令的偏移量,在图中的可执行文件已经计算好了该值,就是中间的 dc ff ff ff。e8为操作数,要注意的是这里采用小端序,可以看出它是复数,补码表示。所以它的值用十进制表示就是-36。这个值是链接完成后的值,在未链接时,这个值是-4。
怎么计算偏移量是多少?就用函数地址把当前call指令地址一减可以吗?1168-1149(注意是十六进制),答案是不行,在运行程序时call指令存放在1168,执行call指令后PC值为116d,由于此时碰到了call指令,不能按照PC寄存器所存放的地址去执行,需要进行指令跳转,于是CPU拿出PC寄存器中的值与call后面这个偏移量相加,得到跳转地址。

现在按照公式:
在这里插入图片描述
ADDR(s)对应的是当前section的地址,r.offset是指在当前section中位于这个偏移量的应用需要修改。r.symbol为foo函数地址,*refptr的初始值为-4。这里由上面反汇编的结果可知redaddr=1168,是call指令位置。计算refptr=0x1149+(-4)-0x116d=-36(十进制),这与图中0xffffffdc一致。

5.2.2 重定位绝对引用

与上面的类似,看下公式就可以理解。在说明动态链接库之前要介绍可执行目标文件。

5.3 可执行目标文件

在上面的内容讲述了链接器是如何将多个目标模块合并成一个可执行文件的,下图展示了一个ELF可执行文件中的个类信息。
在这里插入图片描述
可执行文件与可重定位目标文件的格式极其相似,ELF头部描述文件的总体格式,还包括程序的入口点,就是程序运行时要执行的第一条指令地址,ELF可执行文件被设计为容易加载到存储器,连续的可执行文件的块被映射到连续的存储器段,段头表描述了这种映射关系。
在这里插入图片描述
off:文件偏移,vaddr/paddr:虚拟/物理地址 align:段对齐 filesz:目标文件中的段大小 memsz:存储器的段大小 flag:运行权限许可
从段头表中,我们可以看到可执行目标文件的内容初始化为两个存储器段,第一行和第二行告诉我们第一个代码段对齐到一个2的12次方(4KB)边界,有读/执行许可。
第三行和第四行告诉我们第二个段被对齐到一个4KB的边界,有读写许可,开始与0x8049448处,存储器大小0x104字节。并用从文件偏移0x448处开始的0xe8个字节初始化,在此例中,偏移ox448处正是.data节的开始,该段中剩下的字节对应于运行时被初始化为零的.bss数据。

5.4 加载可执行目标文件

使用如下命令运行可执行文件时

./p2

使用加载器执行程序,加载器将程序代码拷贝到内存中,跳转到程序的第一条指令,运行。现在看一下运行时内存映像,如下图所示。
在这里插入图片描述

5.5 动态链接库

使用静态库有一些缺点,静态库也需要定期维护和更新,如果有了新版本,需要程序与新的库重新链接。另一个问题是一些常用的函数例如I/O函数会被很多程序使用,这些函数的代码段会被复制到每个运行的进程文本段中,对存储器的资源造成浪费。
共享库致力于解决静态库缺陷,在Unix系统中常用.so结尾。共享库的“共享”在两个方面有所不同,所有引用该库的目标文件共享这个.so文件中的代码和数据,不像静态库的内容那样被拷贝和嵌入到它们引用的可执行文件中。在存储器中,一个共享库的.text节可以被不同的正在运行的进程共享。
我们使用如下命令生成动态链接库。库名为libvector.so

gcc -shared -fPIC -o libvector.so addvec.c multvec.c

-fPIC选项指示编译器生成与位置无关的的代码,-shared选项指示链接器创建一个共享的目标文件。
我们创建一个可执行文件p2,用它来链接动态库。下图展示了动态链接库的过程。
在这里插入图片描述
在链接阶段,没有任何libvector.so中的代码和数据节被拷贝到可执行文件p2中,链接器拷贝了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。p2包含一个新的节.interp,这个节包含动态链接的路径名。
动态链接器通过执行下面的重定位完成链接任务。
1.重定位libc.so的文本和数据段到内存某个段。
2.重定位libvector.so的文本和数据到另一个存储器段。
3.重定位p2中所有对由libc.so和libvector.so定义的符号引用。

加载器是如何工作的?
简单概述下加载器的工作流程:Unix系统中每个程序都运行在一个进,程的上下文中,这个进程上下文有自己的虚拟地址空间。当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制品,子进程通过execve系统调用启动加载器,加载器删除子进程已有的虚拟存储器段,并创建一组新的代码,数据,堆和栈段。新的栈和堆被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的组块(chunks),新的代码和数据段初始化为可执行文件的内容。最后加载器跳转到_start地址,它最终会调用应用的main函数,除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据拷贝,直到CPU引用一个被映射的虚拟页,才会进行拷贝,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值