引言

回顾: 从C代码到指令序列

预处理 -> 编译 -> 汇编 -> 链接 -> 执行

 

本次课内容:

  • (静态)链接究竟发生了什么?

ELF目标文件格式

编译后的文件里面有什么?

代码 + 数据

  • 虽然没什么实际意义, 但确实可以没有数据
    • 例如dummy

 

没有了吗?

  • 可以没有
    • 例如NEMU读入的bin文件
  • 也可以有
    • gcc编译出的文件还有很多不为人知的内容

为什么需要其他内容?

有很多实际的需求

  • 调试需要调试信息
    • 是否打开Enable debug information会影响NEMU的大小
    • addr2line可以根据调试信息, 将地址翻译到源文件位置
  • 权限管理
    • 代码可读可执行, 但不能写(write-code.c)
    • 数据可读可写, 但不能执行(exec-data.c)
    • 需要知道代码和数据之间的边界
  • 入口位置: 代码不一定都从头开始执行

 

需要更多的数据

  • 以及用来组织这些数据的数据(元数据)

Binutils - 生成/解析二进制文件的工具集

ld - the GNU linker.
as - the GNU assembler.

addr2line - Converts addresses into filenames and line numbers.
ar - A utility for creating, modifying and extracting from archives.
c++filt - Filter to demangle encoded C++ symbols.
dlltool - Creates files for building and using DLLs.
elfedit - Allows alteration of ELF format files.
gprof - Displays profiling information.
gprofng - Collects and displays application performance data.
nlmconv - Converts object code into an NLM.
nm - Lists symbols from object files.
objcopy - Copies and translates object files.
objdump - Displays information from object files.
ranlib - Generates an index to the contents of an archive.
readelf - Displays information from any ELF format object file.
size - Lists the section sizes of an object or archive file.
strings - Lists printable strings from files.
strip - Discards symbols.

对系统程序员很有用

  • 例如objcopy可以生成Verilog中readmemh()的输入

如果你来设计文件格式, 你将如何设计?

需求: 代码和数据分离

  • 子需求1 - 记录它们在文件中的位置
  • 子需求2 - 记录它们的长度
  • 子需求3 - 记录它们的其他属性
  • 子需求4 - 记录它们的名称

 

这好办, 弄个数据结构

typedef struct {
  off_t offset;
  size_t len;
  uint32_t attr1, attr2;
  char name[32];
} SectionHeader;
  • 这个数据结构记录的程序对象称为 “节”(section)
  • 这个数据结构称为 “节头”(section header)

ELF中一些常见的节

说明 | 说明
.text 代码 | .bss 未初始化数据
.data 可写数据 | .debug 调试信息
.rodata 只读数据 | .line 行号信息

 

.bss - Block Starting Symbol的缩写(section.c)

C99.6.7.8 #10 - 未初始化的全局/静态变量的初值为0

If an object that has automatic storage duration is not initialized explicitly, its
value is indeterminate. If an object that has static storage duration is not
initialized explicitly, then:
- if it has pointer type, it is initialized to a null pointer;
- if it has arithmetic type, it is initialized to (positive or unsigned) zero;
- if it is an aggregate, every member is initialized (recursively) according to these
rules;
- if it is a union, the first named member is initialized (recursively) according to
these rules.

新问题1

Q1: 如果节的名称太长, 怎么办?

A1: 把元数据的字符串集中放在另一个区域

  • 节头中的name换成一个 “指针”: 字符串在文件中的位置
  • 或者把这个区域当作一个新的节, 节头中记录字符串在新节中的位置
    • ELF格式采用此方案, 这个专门的节叫.strtab(string table)

新问题2

Q2: 可以通过节头找到一个节, 那该如何找到节头?

A2: 约定一个位置

  • 方案1 - 直接约定将节头放在距离文件开始x字节的位置
    • 不太灵活, 其他文件内容要绕开
    • 如果还有其他内容也需要这么找, 就要约定x, y, z…
  • 方案2 - 节头的位置无需固定, 但将其位置信息记录在一个约定的位置
    • ELF格式采用此方案, 将节头的位置记录在ELF头中, 并约定ELF头位于ELF文件的起始位置
typedef struct {
  // ...
  off_t sh_offset; // section header offset
  // ...
} ELFHeader

新问题3

Q3: 找到第一个节头后, 如何找到下一个?

A3: 需要考虑如何组织多个节头?

  • 方案1 - 链表
    • 编译后节头一般无需动态增删
  • 方案2 - 数组, 多个节头连续存放, 形成一张 “节头表”
    • ELF格式采用此方案, 并将节头的数量记录在ELF头
typedef struct {
  // ...
  off_t sh_offset; // section header table offset
  uint16_t sh_num; // number of section header
  // ...
} ELFHeader
riscv64-linux-gnu-readelf -S a.out  # 查看ELF文件中的节

ELF文件格式

RTFM

静态链接

回顾: 从C代码到指令序列

预处理 -> 编译 -> 汇编 -> 链接 -> 执行

 

  • 汇编 - 根据指令集手册, 把汇编代码(指令的符号化表示)翻译成二进制目标文件(指令的编码表示)
    • 这一步将得到目标文件
  • 链接 - 合并多个目标文件, 生成可执行文件

编译/汇编都无法解决的问题: 跨节的函数和数据引用

// main.c
extern void f();
int a = 0;
int main() { f(); return 0; }
// f.c
extern int a;
int *pa = &a;
void f() { a = 1; *pa = 2; }
riscv64-linux-gnu-gcc -march=rv64g -fno-pic -c -O2 main.c
riscv64-linux-gnu-gcc -march=rv64g -fno-pic -c -O2 f.c
0000000000000000 <main>:
   0:   ff010113  addi  sp,sp,-16
   4:   00113423  sd    ra,8(sp)
*  8:   00000097  auipc ra,0x0
*  c:   000080e7  jalr  ra # 8 <main+0x8>
  10:   00813083  ld    ra,8(sp)
  14:   00000513  li    a0,0
  18:   01010113  addi  sp,sp,16
  1c:   00008067  ret
0000000000000000 <f>:
*  0:   000007b7  lui   a5,0x0
*  4:   0007b703  ld    a4,0(a5) # 0 <f>
*  8:   000006b7  lui   a3,0x0
   c:   00100793  li    a5,1
* 10:   00f6a023  sw    a5,0(a3) # 0 <f>
  14:   00f72023  sw    a5,0(a4)
  18:   00008067  ret
0000000000000000 <pa>:
* ...

编译和汇编均以文件为单位进行, 标记*的指令/数据无法得知最终地址

链接的本质工作

  1. 符号解析(symbol resolution) - 处理符号的引用
    • 将符号的引用与符号的定义建立关联
  2. 重定位(relocation) - 合并相同的节
    • 确定每一个符号的最终地址, 并填写到引用处

袁春风, 南京大学《计算机系统基础》第四章 程序的链接

符号解析

符号表

为了让链接器解析符号, 汇编器需将符号信息记录到ELF目标文件中

  • .symtab节 - 符号表(symbol table), 每一个表项记录一个符号的信息
    • 名称 - C代码中的全局变量名/函数名, 作为符号的键值
      • 在ELF格式中, 同样是字符串在.strtab节中的位置
      • 局部的自动变量名不是符号, 编译器已经将其分配到寄存器/栈帧
    • 地址 - 符号在程序中的地址
    • 大小 - 变量/函数的长度
    • 所属节
      • 一个确定的数值 - 该符号在相应节中定义
      • UND - 该符号未解析
    • 其他属性 - 类型, 绑定关系(全局/局部等), 可见性
riscv64-linux-gnu-readelf -s a.o  # 查看ELF文件中的符号表

符号解析的过程

链接器维护两个集合: D(已定义的符号)和U(未解析的符号)

 

链接器扫描参与链接的文件, 查看其符号表, 分析每个符号的定义情况

  • 对于符号表中的全局符号x, 按下表进行操作
所属节有效 所属节为UND
已在D中 多重定义错误 解析为D中的x
已在U中 将U中的x解析为当前x, 并加入D 加入U
均不在 加入D 加入U
  • 若某符号多重定义, 则报告multiple definition of xxx
    • 局部符号不检查多重定义, 故多个源文件可定义同名的static变量
  • 若扫描后U非空, 则报告undefined reference to xxx
  • 若未报错, 则将参与链接的文件合并成一个可执行文件

静态库

但如果链接库函数, 就会遇到新的问题(假设不支持动态链接)

  • 方案1 - 把所有库函数放在一个.o文件中
    • 不使用的库函数也要链接进来(glibc有2000+个函数)
      • /usr/lib/x86_64-linux-gnu/libc-*.so接近2MB
      • 编译一个hello程序都要2MB, 1000个程序就浪费2GB磁盘空间了
  • 方案2 - 每个库函数一个.o, 让用户来指定
    • 太麻烦了
    gcc a.c /usr/lib/printf.o /usr/lib/fopen.o ...
  • 方案3 - 采用一种组合上述方案优点的新方案: 静态库
    • 将每个库函数的.o打包成一个.a文件, 用户只需要指定这个.a文件
    • 链接器在符号解析时按需取出用到的.o文件, 没有用到的.o文件不参与链接

坑1 - 库的顺序

#include <stdio.h>
#include <math.h>

int main() {
  int a = 2;
  printf("%lf", sqrt(a));
  return 0;
}
gcc a.c       # undefined reference to 'sqrt'
gcc a.c -lm
gcc -lm a.c   # undefined reference to 'sqrt'

原因: 链接大量文件时开销较大

  • 因此链接器ld默认采用单趟(single pass)解析
    • 扫描静态库时, 只取出当前集合U中相关的.o文件
    • 链接库在命令中的位置会影响符号解析过程
      • U初始为空, 故静态库排在最左边时不会取出任何.o文件
  • 可通过-(-)启用多趟解析(时间开销增大), 具体查阅man ld

坑2 - name mangling

// a.c
void f() { }
extern void g();
int main() { g(); return 0; }
// b.cpp
extern void f();
void g() { f(); }
void g(int x) { f(); f(); }
gcc -c a.c && g++ -c b.cpp && gcc a.o b.o
readelf -s b.o
#   8: 0000000000000000    12 FUNC    GLOBAL DEFAULT    1 _Z1gv
#  10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _Z1fv
#  11: 000000000000000c    24 FUNC    GLOBAL DEFAULT    1 _Z1gi

mangling: 为了支持C++的函数名重载, 编译器需要将形参信息编码到函数名中

  • 符号名称变了, 符号解析失败
  • 解决方法: 通过extern "C" { ... }包含需要让C代码链接的函数

坑3 - 符号解析时无法检查变量类型

// main.c
#include <stdio.h>
int a = 0, b = 0;
extern void f(), g();
int main() { 
  f(); printf("a = %d(0x%08x), b = %d(0x%08x)\n", a, a, b, b);
  g(); printf("a = %d(0x%08x), b = %d(0x%08x)\n", a, a, b, b);
  return 0;
}
// f.c
void f() { extern float  a; a = -1.0; }

// g.c
void g() { extern double a; a = -1.0; }
gcc main.c f.c g.c && ./a.out

运行输出结果

a = -1082130432(0xbf800000), b = 0(0x00000000)
a = 0(0x00000000), b = -1074790400(0xbff00000)

坑4 - C语言的试探性定义

// main.c
#include <stdio.h>
int a; // tentative definition
extern void f();
int main() { 
  a = 1; f(); printf("in main: a = %d\n", a);
  return 0;
}
// f.c
#include <stdio.h>
int a = 5;
void f() { printf("in f: a = %d\n", a); a ++; }

gcc main.c f.c # linking error: multiple definition of 'a'
gcc main.c f.c -fcommon && ./a.out

运行输出结果

in f : a = 1
in main : a = 2

C语言的试探性定义和COMMON块

开启-fcommon时, gcc将试探性定义的符号放到目标文件的COMMON块

  • 符号的所属节为COM

符号解析时

  • 若存在同名且已定义的全局符号, 则将COMMON块中的符号解析到它
  • 若否, 则解析到COMMON块中的所有同名符号大小最大者

 

试探性定义是C标准定义的, 但在实际开发中是个大坑

  • 新版gcc默认打开-fno-common选项, 将这种变量直接放到.bss
    • 可能会报多重定义的链接错误, 违反C标准的行为
    • 但遵守C标准又太坑了
  • 一些老旧的工程为了通过编译, 要么改代码, 要么打开-fcommon
    • 如果采用后者, 就要小心了

启发 - 机器永远是对的

  1. 养成正确的学习习惯
    • 有些自信的同学会吐槽: gcc有bug吧/gcc真垃圾
    • 正确做法: STFW学习
  1. 养成良好的编程习惯, 尽量不要用全局变量
    • 否则, 尽量加static修饰(局部符号不参与符号解析); 再否则
      • 借助名字空间管理全局变量
        • C语言不支持, 但可以手动给变量添加前缀
      • 不要使用试探性定义
        • 定义变量时初始化(符号已解析)
        • 外部变量使用extern修饰
    • 血的教训: 以前x86-qemu的AM定义了一个叫cpus的全局变量, 把做oslab的同学坑惨了 😂

重定位

重定位 = 填坑

合并节后, 节中的符号相对于该节的位置会有变动

  • 这个好解决, 按顺序分配一下地址就好

同时也需要重填引用这些符号的位置

  • 编译器和汇编器挖的坑, 它们摆烂填了0
    • 当然还需要留下 “需要填哪里”的信息: .relxxx.relaxxx
      • xxx是需要重填的节名称
0000000000000000 <main>:
   0:   ff010113  addi  sp,sp,-16
   4:   00113423  sd    ra,8(sp)
*  8:   00000097  auipc ra,0x0
*  c:   000080e7  jalr  ra # 8 <main+0x8>
  10:   00813083  ld    ra,8(sp)
  14:   00000513  li    a0,0
  18:   01010113  addi  sp,sp,16
  1c:   00008067  ret
0000000000000000 <f>:
*  0:   000007b7  lui   a5,0x0
*  4:   0007b703  ld    a4,0(a5) # 0 <f>
*  8:   000006b7  lui   a3,0x0
   c:   00100793  li    a5,1
* 10:   00f6a023  sw    a5,0(a3) # 0 <f>
  14:   00f72023  sw    a5,0(a4)
  18:   00008067  ret
0000000000000000 <pa>:
* ...
riscv64-linux-gnu-readelf -r f.o
riscv64-linux-gnu-objdump -Dr f.o

填坑 = 解方程

不同的重定位类型决定了解不同的方程, 填不同形状的坑

  • R_RISCV_HI20/R_RISCV_LO12_I/R_RISCV_LO12_S/…

f.o中访问pa变量为例, 需要计算offset, 使得以下assert成立:

assert(offset   // offset = 访存地址
       == &pa); // 合并节后确定

算出offset后, 按照luild(I型立即数)的指令格式填进去即可

  • 地址为0x10sw指令要填S型立即数

 

main.o中对f函数的引用是类似的

assert(&main   // 合并节后确定
       + 8      // &main + 8 = auipc指令的pc
       + offset // &main + 8 + offset = 跳转目标
       == &f);  // 合并节后确定

算出offset后, 按照auipcjalr的指令格式填进去即可

链接松弛(relaxation)

如果f()距离调用处很近, 感觉用一条jal指令就可以了?

  • 没错! 如果链接器发现offset可以用20位有符号立即数表示, 就可以省一条指令了
    • 需要通过重定位属性R_RISCV_RELAX来指示

 

其他松弛的例子:

  • auipc ra, 0 + jalr ra, ra, 0 -> jal ra, offset
  • auipc ra, 0 + jalr ra, ra, 0 -> c.jal ra, offset
  • auipc ra, 0 + jalr x0, ra, 0 -> c.j ra, offset
  • lui t0, 0 + lw t1, 0(t0) -> lw t1, offset(gp)
  • RTFM

offset太大就不行了

auipc+jalr可以寻址当前PC±2GB的范围

  • 如果目标函数再远一些, 链接器就搞不定了

 

因此, 虽然地址有64位, 但一般来说少量指令能寻址的范围没那么远

 

这就带来一个权衡: 代码大小 vs. 寻址范围

  • code model用来告诉编译器如何选择

code model

RISC-V目前常用的code model有

  • medium low, 缩写medlow - 程序位于0±2GB范围, 可通过lui寻址
  • medium any, 缩写medany - 程序位于任意位置±2GB范围, 可通过auipc寻址
  • 可以通过gcc的-mcmodel=xxx来选择(RTFM)

x86-64还有一个large的code model, 编译器对程序位置和大小不做假设

  • 这样就要生成多条指令来寻址了(large-buf.c)
#include <stdio.h>
int a = 0;
int main() { 
  a = 1;
  printf("a = %d\n", a);
  return 0;
}
gcc -O2 a.c
gcc -mcmodel=large -O2 a.c

总结

这些有什么用?

一个例子: 陈同学将RT-Thread移植到RV64I, 提供给第三期”一生一芯”的同学运行

  • RT-Thread依赖的newlib默认用-march=rv64gc编译, 怎么办?
    • 有的同学自己动手编译newlib, 需要解决各种环境问题
      • 这对所有同学来说很不可控
    • 但移植过程需要解决一堆链接问题
      • 没有正确的认识, 几乎无法完成移植任务

总结:

  1. 链接 = 符号解析 + 重定位
    • 从需求理解ELF为什么这样设计, 比记住ELF的细节更重要
    • 理解错误从何而来, 养成良好的编程习惯
  2. 使用正确的工具理解链接的细节 - readelf, objdump