C语言-有关链接过程的一些知识点

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指令能够跳转到距离当前pc32位有符号数的区间内, 如果知道了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关闭松弛优化.