ch1-程序内存布局与链接器
现代编译器基本流程:源代码(source code)→ 预处理器(preprocessor)→ 编译器(compiler)→ 汇编程序(assembler)→ 目标代码(object code)→ 链接器(linker)→ 可执行文件(executables),最后打包好的文件就可以给电脑去判读执行了。
链接器(Linker):将一个或者多个由编译器或者汇编器生成的目标文件和一些外部目标文件(库)链接为一个可执行二进制文件,即对所有输入的目标文件进行内存布局重新排布,整合为一个整体的的内存布局。
链接脚本文件:可以通过编写链接脚本文件来控制链接器对内存排列的顺序和布局,让最终生成的可执行文件的内存布局与我们所期待的一致。
程序内存布局
在将源代码编译为可执行二进制文件之后,就得到了一个由完全由字节组成的文件,这些字节可以划分为代码
和数据
两部分。
代码
部分:是由一条条指令组成,这些指令可以被CPU解码并执行。
数据
部分:是CPU可读写的内存空间。
而这两部分又可以根据其功能被划分为更为细小的单位:段(Section)
,不同的段在会被编译器放在不同的位置,因此就组成了程序的内存布局(Memory Layout)
一种比较经典的内存布局如下:
代码部分只有.text
一个段,而数据部分则被划分为了多个段:.rodatad
、.data
、.bss
、heap
、stack
.text
:存放程序的所有汇编代码.rodata
:已初始化字段,存放只读的全局数据,如:常数、常量字符串等.data
:已初始化字段,存放可修改的全局数据.bss
:未初始化字段,保存未初始化的全局数据,通常是由程序的loader进行零初始化,即将这块区域的字节清零heap(堆)
:用来存放程序运行时动态分配的数据,通常用来存放大小不确定、生命周期不确定的数据,是向高地址增长的stack(栈)
:用作函数调用上下文的保存与恢复,并且每个函数作用域内的局部变量也会被放入到栈帧中,栈的大小是在编译期就已经确定的,通常用来存放大小确定、生命周期确定的数据,是向低地址增长的(栈空间大小是确定的,空间提前被申请好了)
从一个函数的视角来看,它能够访问的变量有以下几种:
- 函数的输入与局部变量:保存在一些寄存器或者是在该函数的栈帧里面,如果是在栈帧里面话是基于当前栈指针加上一个offset来访问的。
- 全局变量:保存在数据段
.data
和.bss
中,某些情况下gp(x3)寄存器保存两个数据段中间的一个位置,即全局变量是基于gp加上一个偏移量来访问的。 - 堆上的动态变量:实际数据被保存在堆上,大小在运行时才能确定,而且我们只能访问编译期间能够确定大小的变量,即栈上或者全局数据段中的数据,因此需要通过一个运行时分配内存得到的一个指向堆上数据的指针来访问堆上的数据。指针的位宽在编译期间是能够确定的,该指针会作为局部变量被放在栈帧中,也可以作为全局变量放在全局数据段中。
编译与链接
简单编译流程
源代码到可执行文件的编译过程大概可以分为下面几个阶段:
- 编译器(Compiler)将源代码文件从高级编程语言转化成汇编语言,此时输出的文件仍然是一个ASCII或者其他编码的文本文件。
- 汇编器(Assembler)将上一步得到的汇编语言源文件中文本格式转化为机器码,得到一个二进制的目标文件(Object File)。
- 链接器(Linker)将上一步得到的目标文件以及一些外部目标文件(库文件)链接在一起,即进行内存布局的重新排列组合,整合成一个可执行文件。
链接器主要工作
汇编器输出的每个目标文件都有一个独立的程序内存布局,其描述了目标文件中的各个Section所在的位置,而链接器所做的事情就是将所有输入的目标文件独立的内存布局整合成一个整体的内存布局。
在此期间链接器主要完成了两件事情,具体如下:
将所有输入的目标文件中的段进行重新排布,得到最终的目标内存布局。
如上图所示,链接过程中,分别将来自于不同目标文件
1.o
和2.o
的段按照段的功能进行分类,相同功能的段会被排放拼装在一起,最终得到目标文件output.o
。此外,目标文件
1.o
和2.o
的内存布局是存在冲突的,同一个地址在不同的内存布局中存放不同的内容,而在合并后的内存布局中,这些冲突将会被消除。将
符号
替换成具体的地址。什么是
符号
?在进行模块化编程的时候,每个模块都会提供一些向其他模块公开的(public属性的)全局变量、函数等以供让其他模块可以直接访问,同时也会访问其他模块公开的内容,要
访问一个变量或者一个函数
,在源代码级别
,我们只需要知道它们的名字即可直接调用
,这些名字
就被称为符号
。同时基于符号来自于模块内部还是其他模块,可以将进一步将符号分为内部符号
和外部符号
。但是,在
机器码级别
,即目标文件或可执行文件中,并不是通过符号来索引想要访问的变量或者函数,而是直接通过变量或者函数的地址来进行访问
,如,想要调用一个函数,需要在指令的机器码中找到函数入口的绝对地址或者相对于当前PC的相对地址,才能访问该函数。符号
何时被替换成具体的地址
?因为符号对应的变量是放在某个段中的固定位置的(如全局变量在
.bss
或者.data
段中,函数则放在.text
中),所以当符号所在的段在内存布局中的位置确定了,就能知道它们的具体地址了。当一个
模块被转化为目标文件之后,它的内部符号在目标文件中就已经被转化成了具体地址
,因为先确定的内存布局才生成的目标文件,即目标文件给出了模块的内存布局,也就是说模块内的各个段的位置都已经被确定了。但是,模块所引用的外部符号的地址无法确定,只能将这些外部符号记录下来,放在目标文件中一个名为符号表
的区域内,由于后续可能还需要重新定位(链接时,多个模块的内存布局会被合并),内部符号也同样被记录在符号表中。外部符号需要等到链接的时候才能够被转化为具体的地址
。假如模块1用到了模块2提供的内容,当两个模块的目标文件链接在一起的时候,它们的内存布局会被合并,即两个模块的各个段的位置在最终的目标文件中都被确定了下来,此时模块1用到的模块2的外部符号可以被转化为具体的地址。同时因为两个模块的段在合并后的内存布局中被重新排布,内部符号最终的地址可能和它们在模块自身的局部内存布局中相比已经发生了变化,因此需要进行地址修正,即重定位(R elocation)。
链接脚本调整内存布局
程序是在Qemu上运行的,这里的代码并不是完整的代码,这里主要是介绍链接脚本的作用。
内核的第一条指令
使用汇编指令编写进入内核后的第一条指令
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
li x1, 100