C语言编译、链接简介

一、编译步骤简介

一个.c文件,是如何“变成一个”可执行文件呢。以main.c为例,如何得到main?你会回答是通过编译器的编译后输出来的,就像以下命令:

gcc main.c -o main

gcc是一个集合了编译器,链接器,将一个输入的C文件变成可执行文件,这需要经过预编译(prepressing)、编译(compilation)、汇编(assernbly)、链接(linking)四个步骤。本文就介绍这四个步骤主要做了啥。

如图,整个编译过程的大概就是这么个情况:
在这里插入图片描述

  1. 预编译:main.c首先进入预编译处理器,该部分处理以“#”开始的预编译指令,如#include、宏展开等,并删除注释,添加行号,方便调试打印程序,生成main.i文件。
  2. 词法分析:词法分析器处理main.i文件。将字符串切割成一个个记号(mark),例如:sum=2+1;会产生五个记号:“sum”、“=”、“2”、“+”、“1”。
  3. 语法分析:语法分析器将产生的记号组织成一个个表达式,以表达式为节点,组织成一颗语法树。(如上图)
  4. 语义分析:语义分析器处理声明以及数据类型、给语法树的节点赋予数据类型。
  5. 中间代码:根据语法树生成中间代码,以上的步骤是硬件平台无关的,而中间代码之后的处理则需要根据程序运行的硬件平台来决定。
  6. 代码生成器:代码生成器将中间代码转换成对应硬件平台的汇编代码main.s
  7. 汇编器:汇编器根据 汇编指令与机器指令的对照表 将汇编代码翻译成机器指令,生成目标文件main.o。
  8. 链接器:合并输入的.o文件、确定符号内存地址、进行符号重定位,输出真正的可执行文件。

编译过程做的总体上说就是将高级语言翻译成机器指令,分配指令和数据的在内存中地址,使CPU能从内存中的正确位置中取出正确的指令执行正确的数据读写操作。

note:机器指令是CPU能够识别执行的二进制数据

二、目标文件的组成

目标文件就是汇编代码经过翻译生成的机器指令二进制代码,就是上面的main.o,可执行文件就是由这些个目标文件经过链接组成的。所以了解目标文件非常重要。

目标文件由若干个段(section)组成,每个段中存放不同的内容,下面介绍一个目标文件中的基本段类型:文件头、代码段、数据段、bss段、常量段、段表、符号表、重定位表。

其结构大概就是下图这么个情况:
在这里插入图片描述

1、文件头

文件头位于目标文件开始位置,它定义了elf魔数,目标文件的属性、运行的软硬件平台、程序入口地址、段表的位置及长度、段的数量。有了文件头的信息,链接器就能知道如何处理当前的目标文件。

2、段表

段表:记录了目标文件中所有段的地址以及属性(读写or可执行)等信息。链接器通过文件头可找到段表,通过段表则能找到目标文件中所有段。

3、代码段、数据段、只读数据段、bss段

  • 代码段中存放的就是机器指令
  • 数据段中存放 已经初始化的全局变量以及静态变量
  • 只读数据段存放字符串常量以及被const修饰的变量,通过硬件确保其不会被程序修改
  • bss段存放未初始化的全局变量以及静态变量所占用的内存大小

问:为啥区分数据段和bss段?
.
答:数据段保存了初始化了的全局变量和静态变量,可执行文件装载时需要这些数值。而未初始化的全局变量和静态变量是0,目标文件不需要记住他们的值,只需要知道这些变量占用的空间大小,在程序装载时预留出内存空间(这些空间默认是0).

4、重定位表

当一个段(代码段或数据段)中有需要重定位的符号时,就会有一个它专属的重定位表,重定位表记录了该段需要进行重定位所需的所有信息。(第三节详细介绍重定位)

5、符号表

c代码中函数和变量统称为符号,符号的值就是函数或变量的地址,符号表中记录目标文件中所定义的可供外部使用的符号。

三、静态链接

上一节分析了目标文件的组成,目标文件本质上是一个cpu能识别执行的可执行文件,那么他比真正的可执行文件差在哪里呢?本节通过简单的介绍静态链接,回答这个问题。

静态链接分三步走:1、分配空间与地址;2、符号地址确定;3、符号重定位

1、分配空间与地址

这里的空间是指可执行文件中每个段所在的地址及占用的空间大小。静态链接需要将若干个.o目标文件合并成一个可执行程序,合并的方式是将目标文件中相同属性的段合并到一起,如图所示:

a.o、b.o、c.o的代码段合并到输出文件的代码段;数据段合并到输出文件的数据段;
在这里插入图片描述

地址是指可执行程序装载到内存上后,各个段在内存中的地址,当然这个地址是虚拟地址VMA.静态链接后,每个段在内存中的地址就被确定下来。

连接器负责将相同属性的段合并,根据运行的平台(32位or64位)确定段在内存中的虚拟地址。

2、确定符号地址

在上一步中,我们能确定段在内存中的地址,接下来就能通过偏移量计算符号在内存中的地址。例如:

假设main.o中定义了main函数,main符号在main.o中的.text段中的偏移地址是固定的(假设是0x1000),假设main.o段被装载到内存的虚拟地址为0x5800,那么main符号的虚拟地址也能通过计算偏移地址得到为:0x1000+0x5800=0x6800。

类似的,在地址空间分配完成后,所有符号的地址都能确定下来。

3、符号重定位

首先允许我简单的引见你两个在重定位中最重要的表:

  • 重定位表:在上一节提到,重定位表中包含了重定位所需要的所有信息,包括符号名称,重定位入口地址等。
  • 全局符号表:链接器会读取所有输入的目标文件的符号表,合并所有符号表生成一个全局符号表。

以一个例子来看符号重定位做了什么:

假设main.o中在0x7000处调用了fuck.o中的fuck函数,在重定位之前,由于连接器不知道fuck函数的地址(定义在另一个文件中),故暂时将其符号值设置为0;当链接器开始重定位时,他读取main.o中的重定位表,得知需要重定位fuck,于是链接器到全局符号表中查找fuck,拿到fuck的地址(0x1100),然后到fuck的重定位入口(0x7000),修改fuck符号的值为0x1100.

所以,重定位解决了不同模块之间函数、变量引用时的地址不确定性问题,这也是链接器所做的最重要的工作

经过符号重定位后,可执行程序中所有的符号的虚拟地址都确定下来了,就能将程序装载到内存中运行,这就是目标文件与可执行文件的区别。

四、装载

装载就是把在磁盘中的可执行文件,读取到内存中,CPU才能通过总线读取内存中的指令,程序才能真正的跑起来。

现代的操作系统都采用了虚拟内存的管理策略。装载程序时采用动态装载的方式。

动态装载是指:由于程序运行的局部性原理,将程序运行时常用的部分驻留在内存中,其他不常用的数据则放入硬盘里面。当程序需要使用哪个模块时,就将该模块从硬盘中加载到内存,如果不用到,就把把放在硬盘里。

五、动态链接

1、动态链接

若a.o,b.o都需要使用lib.o中的函数,在静态链接时,a,b输出文件中都有一份lib.o,当同时运行a和b两个程序时,在内存中同时存在了两份lib.o,这就造成了内存的浪费。

这只是静态链接的缺点之一,解决这些问题的办法就是本节的主角–动态链接。

动态链接的基本思想是:将程序拆分成若干个模块(.so),在程序运行时才将模块链接成一个完整的程序。

动态链接器先将程序所需的所有共享模块装载到进程地址空间,确定符号的地址,然后将程序中未决议的符号绑定到共享文件进行重定位。即把在静态链接的过程延迟到装载模块之后。

动态链接如何解决以上问题?

首先将a.o及其所需的模块装载到内存,包括lib.o,然后链接成完整的程序;当需要运行b程序时,将b及其所需的模块装载到内存,由于lib.o已经存在于内存中了,故不需要再装载lib.o,就能直接链接成完整程序。

动态链接的优点有:

  • 节省内存空间,减少了内存数据的换入换出
  • 程序维护性:程序升级时只需要发布新模块,不需要重新编译整个程序
  • 兼容性:程序可使用由不同操作系统提供的动态链接库,不需要针对不同系统编写不同的代码。

地址无关代码

但是这么做有一些问题。

问题是:静态链接时,在模块中的指令和数据中有一些绝对地址的引用,这就要求在链接产生输出文件时,要事先假设模块在虚拟内存中的地址。但是在动态链接时,不同模块的装载地址不能一样。

若A的装载地址是0x800,B的装载地址也是0x800,若一个程序同时使用A,B模块,则必然冲突。

这就要求模块在编译时,不能假设自己在虚拟内存中的地址。

**那么就等动态链接器把模块装进内存后,模块的虚拟地址就确定下来了,这时候再去修改程序中的绝对地址引用,**这样就解决了模块之间的地址冲突问题。因为已经装载的两个模块的地址肯定不一样。

但这又产生了另一个问题:当多个程序使用同一个模块时(lib.o),同一个模块在不同进程的虚拟地址空间的地址是不一样的(例如在A程序中,lib.o模块的虚拟地址是0x100,在B程序中为0x200,通过MMU映射到同一个物理地址)。那么上述的修改绝对地址的修改是要改成0x100还是0x200呢?

为了解决这个问题,就需要引入地址无关代码了。

首先介绍一个事实:共享模块中的代码段是唯一的,而每一个进程都有一份共享模块数据段的副本(进程之间的数据肯定是不一样的啦)

那么可以将A程序的lib.o的虚拟地址0x100放在A的数据段,B程序的lib.o的虚拟地址0x200放在B的数据段中;当B程序要访问lib.o时,就通过数据段中的0x200找到B。

如图
在这里插入图片描述

2、延迟绑定

动态链接情况下,程序开始执行之前都需要进行动态链接,这使得程序的启动变慢了。为了解决这个问题,将一部分函数的绑定工作(符号查找及重定位)延迟,即程序开始不对这些函数进行链接,只有当这些函数需要执行时,再进行绑定。

3、动态链接器自举

动态链接器也是一个共享模块,他能帮助其他模块重定位,但是他却不能引用其他模块,因为在他起来之前,其他模块都不能重定位。所以动态链接器必须不依赖任何库,同时能完成自己对自己的符号重定位,这就是自举。

六、程序的内存分布

可执行程序加载到内存后,内存的分布如图:(linux为例)
在这里插入图片描述
该图示为4G的内存分布,其中0xffffffff-0xc0000000的1G划分给linux内核,其余的3G为用户空间。上面讲的可执行文件就放在readonly中。

栈在linux中是向下增长的,也就是说push操作使地址减少;ESP寄存器放的是栈顶的地址,EBP寄存器放的是栈基的地址。在一个函数中,栈基地址一般不会改变,栈顶地址会改变,通过栈基地址+偏移的方式,可访问栈内容。

每一个函数都有一个属于自己的栈帧,用于保存函数的返回地址、参数、非静态局部变量和上下文。栈帧如图:
在这里插入图片描述
函数调用时,使用栈来保存和传递参数,函数返回时,使用eax和edc寄存器(32位)来存储返回的结果。对于返回数据大于8个字节的,在调用函数时,会先在栈中留出块空间(可理解为参数),函数将执行结果复制到该空间,函数返回后,调用者就可以从该空间中读取到函数结果

struct demo{
	char c[32];
};
struct fuck(char x)
{
	struct t; 
	//将t数组的值全部设置为x+1
	......
	return t;
}
int main(){
	char x='c';
	struct demo d=fuck(x);
	you();
	return 0;
}

上面代码中调用函数时栈的情况可大概由下图表示:

EBP寄存器中放的是地址,这个地址里的值是上一个栈帧的EBP的值。通过EBP,程序就能正确地在函数返回时,回到上一个函数执行时的栈帧。

调用fuck函数时,首先将fuck()的下一个指令(you)的地址放入栈中,函数完成后返回就能继续执行main,然后将EBP的值入栈,接着是函数参数入栈,由于fuck函数的返回值大小大于8字节,所以在栈中留出32个字节的空间作为返回值的缓存

接着将一部分会被修改的寄存器入栈保存,然后跳转到fuck执行。

fuck函数返回时,将局部变量t的值复制到栈中留出的32个字节的地址中,然后弹出寄存器的值,并恢复EBP的值为main的EBP值,然后跳转到you执行。

CPU返回main后,会将栈中缓存的返回值复制到局部变量d中。所以整个过程中,32字节的结果被复制了两次。
在上面的例子中,数据被复制了两次

  • 7
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值