引言

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

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

 

AM项目有那么多源文件, 最后如何生成一个可执行文件?

 

本次课内容:

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

ELF目标文件格式

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

代码 + 数据

  • 可以没有数据, 尽管这样没什么实际意义
    • 例如dummy

 

没有了吗?

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

为什么需要其他内容?

有很多实际的需求

  • 调试需要调试信息
    • 是否打开Enable debug information会影响NEMU的大小
    • addr2line根据调试信息将地址转换为源文件位置(gdb)
addr2line -e xxx.elf 0x1234
  • 权限管理
    • 代码可读可执行, 但不能写(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()的输入
objcopy # 查看支持的目标
objcopy -O verilog a.out a.v

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

需求: 代码和数据分离

  • 子需求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换成一个 “指针”
    • 记录字符串在文件中的位置
  • 或者把这个区域当作一个新的节, 节头中的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
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; }
rv32gcc -fno-pic -c -O2 main.c
rv32gcc -fno-pic -c -O2 f.c
00000000 <main>:
   0:   ff010113  addi  sp,sp,-16
   4:   00112623  sw    ra,12(sp)
*  8:   00000097  auipc ra,0x0
*  c:   000080e7  jalr  ra # 8 <main+0x8>
  10:   00c12083  lw    ra,12(sp)
  14:   00000513  li    a0,0
  18:   01010113  addi  sp,sp,16
  1c:   00008067  ret
00000000 <f>:
*  0:   000007b7  lui   a5,0x0
*  4:   0007a783  lw    a5,0(a5) # 0 <f>
*  8:   00000737  lui   a4,0x0
   c:   00100693  li    a3,1
* 10:   00d72023  sw    a3,0(a4) # 0 <f>
  14:   00200713  li    a4,2
  18:   00e7a023  sw    a4,0(a5)
  1c:   00008067  ret
00000000 <pa>:
*  0:   0000      .2byte  0x00

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

链接的本质工作

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

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

符号解析

符号表

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

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

符号解析的过程

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

 

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

  • 对于符号表中的全局符号x, 按下表进行操作
所属节确定 所属节为UND
均不在D或U中 加入D 加入U
已在U中 将U中的x解析为当前x, 并加入D 加入U
已在D中 多重定义错误 解析为D中的x
  • 若某符号多重定义, 则报告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文件不参与链接
gcc -static hello.c
readelf -s a.out | grep "\bFUNC\b" | wc
readelf -s /usr/lib/x86_64-linux-gnu/libc.a  | grep "\bFUNC\b" | wc

AM将程序自身的.o, am-xxx.aklib-xxx.a链接成可执行文件

坑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语言不支持, 但可以手动给变量添加前缀(AM的框架代码)
      • 不要使用试探性定义
        • 定义变量时初始化(符号已解析)
        • 外部变量使用extern修饰
    • 血的教训: 以前x86-qemu的AM定义了一个叫cpus的全局变量, 把做oslab的同学坑惨了 😂

重定位

按链接脚本合并相同的节

例子: AM的链接脚本

  • 内存布局
    • 将哪些节按照什么顺序放在哪里
    • .代表当前地址
  • 符号定义
    • 定义一些符号给程序使用
la sp, _stack_pointer
extern char _heap_start;
 Area heap = RANGE(&_heap_start, PMEM_END);
  • 入口地址
    • ENTRY(_start)

 

# 查看gcc的默认链接脚本
rv64gcc -Wl,--verbose a.c
ENTRY(_start)
PHDRS { text PT_LOAD; data PT_LOAD; }

SECTIONS {
  /* _pmem_start and _entry_offset
     are defined in LDFLAGS */
  . = _pmem_start + _entry_offset;
  .text : {
    *(entry)
    *(.text*)
  } : text
  .rodata : { *(.rodata*) }
  .data : { *(.data) } : data
  .bss : {
    _bss_start = .;
    *(.bss*)
    *(.sbss*)
    *(.scommon)
  }
  _stack_top = ALIGN(0x1000);
  . = _stack_top + 0x8000;
  _stack_pointer = .;
  _end = .;
  _heap_start = ALIGN(0x1000);
}

确定地址 = 填坑

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

  • 按照新位置重新计算符号的地址即可

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

  • 编译器和汇编器挖的坑, 它们摆烂填了0
    • 当然还需要留下 “需要填哪里”的信息: .relxxx.relaxxx
      • xxx是需要重填的节名称
00000000 <main>:
   0:   ff010113  addi  sp,sp,-16
   4:   00112623  sw    ra,12(sp)
*  8:   00000097  auipc ra,0x0
*  c:   000080e7  jalr  ra # 8 <main+0x8>
  10:   00c12083  lw    ra,12(sp)
  14:   00000513  li    a0,0
  18:   01010113  addi  sp,sp,16
  1c:   00008067  ret
00000000 <f>:
*  0:   000007b7  lui   a5,0x0
*  4:   0007a783  lw    a5,0(a5) # 0 <f>
*  8:   00000737  lui   a4,0x0
   c:   00100693  li    a3,1
* 10:   00d72023  sw    a3,0(a4) # 0 <f>
  14:   00200713  li    a4,2
  18:   00e7a023  sw    a4,0(a5)
  1c:   00008067  ret
00000000 <pa>:
*  0:   0000      .2byte  0x00
readelf -r f.o
rvobjdump -Dr f.o

填坑 = 解方程

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

  • R_RISCV_HI20/R_RISCV_LO12_I/R_RISCV_LO12_S/…

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

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

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

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

 

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

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

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

链接器松弛(linker relaxation)

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

  • 没错! 如果链接器发现offset可以用21位有符号立即数表示, 就可以省一条指令了
  • 需要通过重定位属性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

坑太小就不行了

.globl _start
_start:
  jal fun
.fill 0xffffc
fun:
  ret
rv64gcc -ffreestanding -nostdlib a.s
# relocation truncated to fit: R_RISCV_JAL against `fun'

采用call伪指令能编译出auipc+jalr, 能寻址当前PC±2GB的范围

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

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

指令序列 寻址范围
jal 当前PC±1MB
auipc+jalr 当前PC±2GB
更多指令 更大范围

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

总结

总结

  • ELF目标文件 = 节 + 元数据(节头) + …
    • 从需求理解ELF为什么这样设计, 比记住ELF的细节更重要

 

  • 链接 = 符号解析 + 重定位
    • 符号解析 = 将符号的引用和其定义建立关联
    • 重定位 = 合并节 + 确定符号地址 + 在引用处填写地址

 

  • 这些有什么用?
    • 将来大家在自己设计的处理器上运行其他程序, 很大概率会在移植过程中遇到链接相关的问题
    • 更重要地, 理解错误从何而来, 养成良好的编程习惯