C语言程序是如何执行的
第一步: 预处理, 对C语言各种预处理命令进行处理
gcc -E test.c -o test.i
第二步: 编译, 将C语言程序转换为对应的汇编语言
gcc -S test.i -o test.s
第三步: 汇编, 将汇编语言转换成可重定位目标文件, 可重定位目标文件有很多段
as test.s -o test.o
第四步: 链接, 链接有三个任务:
- 符号解析, 把每个符号的引用和定义关联起来.
- 合并段: 把可重定位目标文件的段合并
- 重定位: 可重定位目标文件的每个符号的起始地址都从
0x0
开始, 但是生成的可执行目标文件需要按照OS的要求把每个段摆放到对应的地址.
# -lc是链接到libc.a
ld -o test test.o -lc
链接器的符号解析
在C语言中, 符号分为三种类型:
- 全局符号: 包括没有
static
的函数名, 以及没有static
的全局变量. - 外部符号: 用
extern
引用的外部的函数名/变量. - 本地符号: 用
static
修饰的函数名和全局变量.
符号的强弱
- 强符号: 函数/已初始化的全局变量.
- 弱符号: 没有初始化的全局变量.
- 本地符号没有强弱之分.
链接器根据强符号/弱符号规则来处理不同模块间多重符号的定义:
- 强符号不能多次定义, 否则报错.
- 如果一个符号同时按照强符号和弱符号定义, 那么强符号为准.
- 如果只有多个弱符号定义, 那么随便选一个.
因此, 尽量有良好的C语言编程习惯:
- 尽量不使用全局变量.
- 使用全局变量尽量用
static
或者先赋初值. - 外部全局变量尽量用
extern
.
符号解析算法
定义三个集合$E, U, D$:
- $E$: 生成最终ELF文件的所有目标文件的集合.
- $U$: 所有没找到定义的符号集合.
- $D$: 目前为止所有找到定义的符号的集合.
按顺序扫描输入的所有文件$f$:
- 如果$f$是可重定位目标文件, 那么就把$f$放到$E$中, 然后根据对应符号修改$U$和$D$.
- 如果$f$是库文件, 链接器根据$f$中定义的符号, 修改集合$U$和$D$, 如果$U$中存在$f$中定义的符号, 就把它从$U$中取出放到$D$.
- 如果处理过程中, 向$D$中加入了一个已经存在的强符号/扫描完之后, $U$非空, 那么链接器就会报错.
因此, 在链接过程中, 静态库文件一般位于可重定位目标文件顺序之后, 并且静态库文件之间也有顺序. 假设libx.a
调用了libz.a
的函数, 那么文件顺序应该是libx.a libz.a
.
静态链接和动态链接
静态链接: 可执行目标文件与静态链接库
*.a
先链接成ELF文件, 运行的时候就不用再链接了.# 生成静态链接库 ar rcs <静态库名称>.a myproc1.o myproc2.o ...
动态链接: 先生成ELF文件, 然后运行的时候再和动态链接库
*.so
链接.# 生成动态链接库 gcc -shared -fPIC -o mylib.so myproc1.c myproc2.c
-shared
表示生成.so
文件.-fPIC
表示生成位置无关(Position Independent Code)的代码.
链接地址、加载地址、运行地址
首先需要介绍“位置相关代码”和“位置无关代码”.
- 位置无关代码: 例如某些汇编指令里面, 用的是
pc
相对寻址, 没有把地址写死, 那这种指令就是位置无关代码. - 位置相关代码: 某些指令把地址写死了, 那这种指令就是位置相关代码.
链接地址其实就是在链接脚本中提供的一个地址值, 加载地址是代码实际加载位置的地址, 运行地址是这段代码在运行时的地址 (可能是物理地址/也可能是虚拟地址).
链接地址和加载地址不一样
观察如下链接脚本, 假设MMU未使能 (运行地址和链接地址相等)
SECTIONS
{
.text 0x1000 : { *(.text); _etext = .; }
.mdata 0x2000 :
// AT用于指定加载地址
AT { ADDR (.text) + SIZEOF(.text) }
{_data = .; *(.data); _edata = .; }
.bss 0x3000 :
{_bstart = .; *(.bss) *(COMMON); _bend = .; }
}
此时, 对于.mdata
段来说, 链接地址是0x2000
开始, 但是加载地址是ADDR(.text) + SIZEOF(.text)
, 也就是.text
末尾的位置, 如下图所示:
此时, .mdata
段一开始就在加载地址对应的内存中了, 一般是在ROM中, 需要搬到RAM中(也就是链接地址/运行地址对应处), 可以用如下代码:
extern char* _etext, _data, _edata, _bstart, _bend;
char *src = _etext;
char *dst = _data;
/* 把加载地址区的东西搬到链接地址区 */
while (dst < _edata)
*dst ++ = *src ++;
链接地址和运行地址不相同
观察如下链接脚本, 假设MMU使能
SECTIONS
{
. = 0xFFFF000000000000,
_text_boot = .;
.text_boot : { *(.text.boot) }
_etext_boot = .;
. = ALIGN(8);
_text = .;
.text :
{
*(.text)
}
. = ALIGN(8);
_etext = .;
...
}
此时链接地址是0xFFFF000000000000
, 加载地址是由CPU决定, 假设是0x80200000
.
首先加载地址等于运行地址, 都是在0x80200000
上, 之后运行初始化代码, 初始化代码会初始化MMU, MMU会把CPU的运行地址重定位到链接地址处, 此时运行地址就等于链接地址了.
固件程序的重定位和内核重定位
对于固件程序来说, 加载地址和链接地址不相同
固件程序的加载地址在ROM中, 但是链接地址在RAM中, 固件程序首先执行位置无关代码, 将搬运内核的那部分代码从ROM搬到RAM中, 然后用位置有关代码跳转到RAM中继续执行.
跳转到RAM后, 继续执行的代码会把内核ELF文件搬运到RAM中, 然后跳转到内核ELF文件的entry point.
这个过程叫做固件重定位, RISC-V里由OpenSBI完成.
- 对于内核程序来说, 一开始运行地址和链接地址不相同
- 内核可执行文件链接脚本里, 链接地址一般是虚拟地址, 进入到entry point的之后需要配置MMU, 将当前运行地址映射到内核的虚拟地址空间 (链接地址).
- 这个过程叫内核重定位, 在Linux源码里实现.
链接重定位与松弛优化
首先, 可重定位目标文件的符号首地址都是0x0
, 但是这个可重定位目标文件中包含了重定位的信息, 一般存储在某一个例如.rela.text
段中, 使用readelf -a
可以查看到这个段.
这些信息叫做目标文件的可重定位表(relocation table), 这个.rela.text
就是重定位段.
RISC-V常见的重定位类型有如下几种:
R_RISCV_CALL
: 用于处理函数调用中的寻址.R_RISCV_RELEX
: 表示这里可能会进行松弛优化.R_RISCV_PCREL_HI20
: 用于处理pc相对寻址指令的高20位.R_RISCV_PCREL_LO12_I
: 用于处理加载指令的pc相对寻址的低12位.R_RISCV_PCREL_LO12_S
: 用于处理存储指令的pc相对寻址的低12位.R_RISCV_HI20
: 用于处理绝对寻址的高20位.R_RISCV_LO12_I/R_RISCV_LO12_S
: 用于处理加载/存储的绝对寻址的低12位.
松弛优化
对函数调用的松弛优化
函数调用call func
一般会变成如下两条指令:
auipc ra, 0
jalr ra, 0(ra)
其中0
表示需要重定位的地方.
这两条指令的意思是, 首先让ra
等于当前指令pc
加上一个20位立即数imm << 12
, 然后用jalr
指令, 首先将当前pc+4
保存到ra
中, 然后跳转到ra
加上一个12位立即数对应的地址上.
也就是说, call
指令能够跳转到距离当前pc
32位有符号数的区间内, 如果知道了func
地址与函数调用指令pc
的差, 我就可以填入重定位的地方.
但是RISC-V还有一个短跳转指令叫做jal rd, offset
, 表示先将rd <= pc + 4
, 然后跳转到pc + offset
对应地址上, 其中offset
有21位.
如果最终的func
的地址距离auipc
指令/jalr
指令距离在21位有符号数的范围内, 那么就可以采用松弛优化, 将这两条指令直接用一个jal
指令替换.
对全局符号索引的松弛优化
使用绝对寻址/PC相对寻址访问一个符号的时候, 经常需要用下面两条指令:
# 绝对寻址
auipc a0, symbol地址的高20位
addi a0, a0, symbol地址的低12位
# 相对寻址, 数值A就是symbol和pc的差值, 可以索引pc +- 2GB的范围
auipc a0, A的高20位
addi a0, A的低20位
此时, 可以采用一个trick, 首先将gp
寄存器的值设置到数据段.sdata
(.sdata
可以放置经常用的数据, 专门用来进行链接器松弛优化), 然后如果符号地址在gp
寄存器上下2KB的范围, 那么就可以用一条指令addi, a0, gp, offset
进行.
为什么范围设置成上下2KB? 因为2KB是lw, addi
等指令支持的最大寻址范围, 就是12位有符号数的寻址范围.
gcc中的松弛优化
gcc选项-mrelax
使能链接器松弛优化, -mno-relax
关闭松弛优化.