编译器的工作过程
编译器主要的工作过程:
预编译阶段:本阶段由预编译器执行,主要是把宏定义替换掉、把头文件包含进来、处理掉注释。
编译阶段:本阶段由编译器执行,是把源代码.c、.S后缀的文件编译成对应的.o二进制文件。
链接阶段:本阶段由链接器执行,把.o文件中的各函数(一段代码)按照一定规则拼接起来,生成一个可执行文件,至此这个可执行文件就可以被系统执行了。
以上过程是必要的,当然还有一些其他非必要的过程:
strip:把可执行程序中的符号信息拿掉,以节省空间。
objcopy:由可执行程序生成可烧录的镜像文件。
……
链接脚本是什么?
程序经过编译阶段后,虽然都是一段一段的二进制,但这些段是不同的,并且每个段都是有名字来分类的。
段的名字分为两种:一种是编译器链接器内部定好的,不可改变的,是先天性的。另一种则是程序员自定指定的名字,当然段的属性和特征也是由程序员自己定的,是后天性的。
先天性的段名:
代码段(.text):又叫文本段,代码段其实就是函数编译后生成的段,这是纯代码组成的,没有变量,笔者理解为是变量的加工机器。
数据段(.data):就是声明并定义为非0的全局变量,如:int a = 1;
bss段(.bss):又叫ZI(zero initial)段,零初始化段,如,int a =0;或int a;就会放在这里,所以变量被声明了而没有初始化也是0。
为什么要给段起名字呢?因为有名字了链接器才能找到这些段并按照链接脚本的规则进行排列。
所以,链接脚本就是个规则文件,程序员可以写个链接脚本来指挥链接器应该怎样排列代码。而且,链接脚本不仅仅是简单地命令链接器地把段按照某个顺序排列,还可以命令链接器把段放在自己喜欢的内存地址上,哪个地址开始,哪个地址结束,哪个段应该放在哪个地址。所以,链接器执行完毕后,每一个段中的每一个指令都有了其对应的地址,因为这地址是链接器给的,所以叫做
链接地址。如下是一个简单的链接脚本示例:
SECTIONS //表示链接脚本的入口
{
. = 0xd0024000
//可以看出整个程序段的排列是.text+.data+.bss
.text : {
start.o //把start.o的.text段作为开头
* (.text) //其他文件的.text不用管,随意排列
}
.data : {
* (.data) //.data段顺序无要求
}
bss_start = .
.bss : {
* (.bss)
}
bss_end = .
}
但是还有个问题,就是下载程序的时候要选择的下载地址那个才是在内存地址中真正的开始位置。不管在链接的时候写了哪个地址,真正能加载到内存中才是王道。然而在下载时选择的地址是可以不同的,所以,程序实际上运行时的地址,是下载时指定的那个地址开头的地址,这就是
运行地址,也就是程序被实际运行时的地址。
位置无关代码和位置相关代码
顾名思义:
位置无关代码:代码运行时不用在乎运行在内存的哪个地址上,也就是任何内存地址都可以运行。
位置相关代码:代码要运行在特定的内存地址上,否则无法正常运行。
为什么会有这两种代码的区别呢?笔者的理解是:函数调用和变量的调用,不管是函数名还是变量名,这个名字对于编译器来说就是用来保存地址的,变量名保存的是变量本身在内存的地址、函数名保存的是这个代码段(函数代码)的开头的地址。所以调用函数和变量实际上就是跳转到其保存的地址指向的内存空间中的。那么这两者的地址是谁给的呢?很明显是链接器给的。也就说函数名和变量名保存的地址是链接地址来的。如果程序实际下载到内存的地址和链接地址不一样,那么这些函数名和变量名保存的地址与函数的代码段、变量保存在内存中的实际地址肯定不会相同,这样程序在调用函数或变量的时候,地址不同了,然后跑错了地址,那个地址全是乱的,进而导致程序无法正常运行。而位置无关代码应该是因为没有做这些调用所以对这些地址无所谓了。
总结:
- 地址与运行地址不相同对位置无关代码没有影响,对位置相关代码有影响。
- 这样看来汇编更容易实现位置无关代码,因为汇编是直接对寄存器和内存操作的,并且汇编的函数调用可以用相对地址来访问,也就是基地址加上偏移量。这应该也是重定位代码要在汇编里实现的原因之一吧。
重定位
重定位就是为了解决这个问题的:内存中的位置相关代码的链接地址和运行地址不相同。
在实际运行时,在CPU运行位置相关代码前把整个代码(包括位置相关代码和位置无关代码)复制一份完全相同的代码,再把复制的这一份代码放在与链接地址相同的内存地址上,再让CPU跳转到这份复制的代码上去继续执行,此时CPU就可以执行位置相关代码了,因为这段复制代码的链接地址与内存地址是一样的了。以上,就是重定位的解决方案。
重定位代码是用汇编写的,因为对于重定位来说汇编有两个关键指令:ldr伪指令与adr伪指令。
ldr伪指令与adr伪指令的区别
按照字面的意思,ldr是长加载指令,adr是短加载指令。
更直接点就是:ldr指令在加载符号地址时,加载的是链接地址;adr指令在加载符号地址时,加载的是运行时地址。这一点很重要,在代码中可以用于判断链接地址与运行地址是否相等以此判断是否需要重定位,另外在复制代码时也是通过对比两个链接地址是否相同来判断是否复制完成的。
下面讲解两个指令大致的实现原理:
首先看下面的汇编代码:
_start:
adr r0, _start
ldr r1, =_start
adr r0,_start在反汇编中对应的代码为:sub r0,pc,#n //n为偏移量
也就是adr伪指令实际上会转变为sub指令代替,而这句sub指令等价于:r0=pc-n,由于pc寄存器存储的是代码当前的运行地址,所以adr加载的肯定是运行地址。
而ldr r1, =_start
在反汇编中对应的代码为:ldr r1, [pc,#n] //n也是偏移量。
可以看出ldr即是指令也是伪指令,这句代码等价于下面C代码:
int r1;
int *pc;
r1 = *(pc+n); //n为偏移量
也就是说,pc中存储的运行地址,加上偏移量后得到新地址,取新地址指向的内存地址存储的内容赋给r1。而这个新地址指向的内存地址所存储的内容必定是一个链接地址,相当于指向了一个指针变量,所以ldr加载的是链接地址。为什么指向的内容一定存储了链接地址呢?因为编译器知道,链接执行完后,对于编译器来说,虽然整段代码段的开始位置会改变,但不管代码重哪里开始运行,每个指令互相之间的相对位置是不会改变的。这个相对位置就决定了偏移量。
如果要对应反汇编文件来理解的话,还要注意有ARM指令流水线的存在。