13. 汇编器、链接器与 ELF
前 12 章专注于 RISC-V 汇编语言和系统接口本身——指令怎么写,寄存器怎么用,syscall 怎么调。本章将镜头拉远一层:汇编器(Assembler)如何将你的 .s 文件转化为 .o?链接器(Linker)如何将多个 .o 拼成可执行文件?可执行文件内部到底是什么结构?理解这些工具链环节是独立开发、调试和优化汇编程序的前提。
过渡到本地工具链
本书前 12 章的代码均可在 RISC-V ALE 在线环境中运行——这是一个浏览器内汇编器+模拟器,零安装即可验证逻辑。但从本章起,所有代码将使用本地 GNU RISC-V 工具链 + QEMU,原因有三:
- 完整工具链功能:ALE 不支持宏、条件汇编、段指令等高级汇编器特性
- ELF 分析:readelf 和 objdump 需要实际的 ELF 文件
- 真实 Linux 环境:系统调用行为、内存布局、动态链接在完整 Linux 用户态下表现准确
安装
# macOS
brew install riscv64-elf-gcc riscv64-elf-binutils qemu
# Ubuntu / Debian
sudo apt install gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu qemu-user-static
# Arch Linux
sudo pacman -S riscv64-linux-gnu-gcc riscv64-linux-gnu-binutils qemu-user-static
# 验证安装
riscv64-linux-gnu-as --version
riscv64-linux-gnu-ld --version
qemu-riscv64 --version注意区分两个工具链前缀:
riscv64-linux-gnu-*:目标 Linux 用户态,支持 syscall,链接 glibcriscv64-elf-*:目标裸机(bare-metal),无 OS,用于嵌入式/bootloader 开发
本书使用前者——我们编写的程序运行在 Linux 用户态上。
完整构建流程
┌─────────────┐ as ┌──────────┐ ld ┌──────────────┐ qemu ┌──────────┐
│ hello.s │ ──────────→ │ hello.o │ ──────────→ │ hello (ELF) │ ─────────→ │ 输出 │
│ (汇编源文件) │ │ (目标文件)│ │ (可执行文件) │ │ │
└─────────────┘ └──────────┘ └──────────────┘ └──────────┘每步的分工:
- as (Assembler):助记符→机器码,生成重定位入口(待填的符号地址),输出 .o 文件
- ld (Linker):将多个 .o 文件和库合并,解析符号→地址,填平所有重定位入口,输出 ELF 可执行文件
- qemu (Emulator):翻译 RISC-V 指令到宿主机 ISA,转发 syscall
汇编器指令详解
汇编器指令(assembler directives,也称伪操作 pseudo-ops)不生成机器码——它们控制汇编器本身的行为。第 10 章已介绍了数据定义和段切换指令,以下是更高级的控制指令。
.globl —— 导出符号
.globl _start # 将 _start 导出到符号表
.globl my_function # 使 my_function 对其他 .o 可见
.globl global_var # 使数据符号全局可见默认情况下,汇编器中定义的符号是局部的——只在其所在 .o 文件内部可见。.globl 将符号放入符号表,使链接器和其他 .o 文件可以引用它。
.equ —— 定义常量
.equ STDOUT, 1
.equ STDERR, 2
.equ EXIT_SUCCESS, 0
.equ SYS_write, 64
.equ SYS_exit, 93
.equ BUFFER_SIZE, 4096
# 使用时等同于立即数
li a0, STDOUT
li a7, SYS_write
la a1, buf
li a2, BUFFER_SIZE
ecall.equ 在汇编时展开为字面值——不会在目标文件中生成任何数据或代码。用它替换代码中的魔法数字,显著提升可读性和可维护性。
.macro / .endm —— 定义宏
宏是汇编器的模板系统——定义一次,多次展开,通过 \参数名 引用参数:
# 1. 简单的 push/pop 宏
.macro push reg
addi sp, sp, -8
sd \reg, 0(sp)
.endm
.macro pop reg
ld \reg, 0(sp)
addi sp, sp, 8
.endm
# 使用
push ra
push s0
# ... 函数体 ...
pop s0
pop ra
# 2. 带多个参数的宏
.macro push2 r1, r2
addi sp, sp, -16
sd \r1, 8(sp)
sd \r2, 0(sp)
.endm
.macro pop2 r1, r2
ld \r2, 0(sp)
ld \r1, 8(sp)
addi sp, sp, 16
.endm
# 3. syscall 封装宏(第 12 章见过)
.macro syscall_exit code=0
li a0, \code
li a7, 93
ecall
.endm
# 调用时可选参数
syscall_exit # 等价于 syscall_exit 0
syscall_exit 42 # 传 42 作为退出码宏的参数通过 \参数名 引用(GNU 汇编器语法)。注意 push/pop 宏的用武之地——每处 call site 省去两行样板,在一个百行函数中可以节省大量重复。
.rept / .endr —— 重复块
# 用 10 条 nop 填 40 字节(10 x 4)
.rept 10
nop
.endr
# 初始化填充数据
.rept 256
.byte 0x00
.endr.rept 在汇编时将内容复制展开 n 次——不是循环,是编译期代码生成。用于填充对齐空间或生成重复数据模式。
.if / .else / .endif —— 条件汇编
# 根据宏定义选择汇编路径
.equ DEBUG, 1
.macro debug_print msg
.if DEBUG
# 条件编译:仅在 DEBUG=1 时生成输出代码
li a0, 2 # stderr
la a1, \msg
li a2, 10
li a7, 64
ecall
.endif
.endm
# 使用
debug_print dbg_msg
# 如果 DEBUG=0,上述宏展开为空——零性能开销的条件日志条件汇编基于 .equ 常量或 .ifdef symbol 判断。常见用途:
- 调试/发布版本切换:通过改变一个 .equ 常量的值来开关调试代码
- 平台适配:为不同 CPU 变体选择不同指令序列
- 特性开关:编译时选择是否包含某功能模块
.include —— 包含文件
.include "syscall_macros.inc" # 引入公共宏定义
.include "constants.inc" # 引入常量定义将公用的宏和常量集中在 .inc 文件中,多个 .s 文件共享。这是汇编项目中管理复用的标准方法——虽然没有 C 的 #include 强大(无类型检查、无 include guard),但足够日常使用。
符号与重定位
符号 = 地址的标签
在汇编器和链接器的视角中,符号(symbol)是地址的命名。函数名、全局变量名、段起始标签——都是符号。符号表记录每个符号的名称、值和属性。
.text
.globl my_func # 全局符号:链接器可见
.type my_func, @function
my_func:
...
.Linternal_loop: # 局部符号(.L 前缀):仅汇编器可见
...
j .Linternal_loop局部符号(.L 前缀)不被放入目标文件的符号表——它们只在汇编阶段用于计算偏移。这防止符号表膨胀,也避免名称冲突(两个 .o 文件中都有 .Lloop 互不干扰)。
重定位:填地址的占位符
汇编器在将 .s 转为 .o 时,许多符号的实际运行时地址还未知——比如 .rodata 中的字符串标签的相对地址、对库函数的调用目标。汇编器在这些位置留下重定位入口(relocation entry),记录"此处需要一个地址,指向某某符号"。链接器在最终链接时将所有符号安排到具体地址后,遍历重定位表填入最终值。
RISC-V 关键重定位类型:
| 重定位类型 | 说明 | 典型场景 |
|---|---|---|
| R_RISCV_HI20 | 符号地址的高 20 位 | lui 中的立即数 |
| R_RISCV_LO12_I | 符号地址的低 12 位(I-type) | addi 中的立即数 |
| R_RISCV_LO12_S | 符号地址的低 12 位(S-type) | sd/sw 中的偏移 |
| R_RISCV_CALL | 函数调用 | call 伪指令下的两条重定位 |
| R_RISCV_PCREL_HI20 | PC 相对的高 20 位 | auipc 中的立即数 |
| R_RISCV_PCREL_LO12_I | PC 相对的低 12 位 | jalr/addi 的立即数 |
| R_RISCV_BRANCH | 分支目标 | B-type 指令(beq/bne 等) |
| R_RISCV_JAL | 无条件跳转 | J-type 指令(jal) |
| R_RISCV_32 | 32 位绝对地址 | .word symbol |
| R_RISCV_64 | 64 位绝对地址 | .dword symbol |
la 伪指令背后的重定位
la t0, symbol 展开后的两条指令各自携带一个重定位入口:
# la t0, near_symbol 的底层展开
auipc t0, 0 # R_RISCV_PCREL_HI20: fill hi20 with (sym - PC) >> 12
addi t0, t0, 0 # R_RISCV_PCREL_LO12_I: fill lo12 with (sym - PC) & 0xFFF汇编器在 .o 文件中留下的不是 auipc t0, 0 / addi t0, t0, 0——而是两条带重定位标记的指令,其立即数字段标记为 R_RISCV_PCREL_HI20(sym) 和 R_RISCV_PCREL_LO12_I(sym)。链接时,链接器算出 PC_of_auipc 到 address_of_sym 的差,填入 hi20 和 lo12 字段。
重定位操作数:%pcrel_hi(sym) 和 %pcrel_lo(sym) 是汇编器输出中指示重定位类型的助记符:
auipc t0, %pcrel_hi(near_symbol) # 提示链接器这里是 PC-relative hi20
addi t0, t0, %pcrel_lo(near_symbol)你可以在 .s 文件中直接使用这些操作数(汇编器理解它们),但通常用 la/call 伪指令让汇编器自动选择即可。
ELF 文件结构概览
ELF(Executable and Linkable Format)是 Linux 上标准的目标文件(.o)、可执行文件、共享库(.so)和 core dump 格式。它组织数据为两套视图:
链接视图(编译时) 执行视图(运行时)
┌──────────────────┐ ┌──────────────────┐
│ ELF Header │ │ ELF Header │
├──────────────────┤ ├──────────────────┤
│ Program Header │ ← 可选 │ Program Header │ ← 必需
│ Table │ │ Table │
├──────────────────┤ ├──────────────────┤
│ Section 1 │ │ Segment 1 │
│ (.text) │ │ (LOAD, RX) │
├──────────────────┤ ├──────────────────┤
│ Section 2 │ │ Segment 2 │
│ (.rodata) │ │ (LOAD, R) │
├──────────────────┤ ├──────────────────┤
│ Section 3 │ │ Segment 3 │
│ (.data) │ │ (LOAD, RW) │
├──────────────────┤ ├──────────────────┤
│ ... │ │ ... │
├──────────────────┤ ├──────────────────┤
│ Section Header │ ← 必需 │ Section Header │ ← 可选
│ Table │ │ Table │
└──────────────────┘ └──────────────────┘ELF Header:文件的开头 64 字节(64 位 ELF),包含魔数(\x7fELF)、类型(.o / 可执行 / .so)、目标 ISA(RISC-V)、入口地址等。
Program Header Table:告诉 OS 加载器(loader)如何将文件映射到内存——哪些段需要加载(PT_LOAD)、权限(R/W/X)、在文件和内存中的偏移和大小。可执行文件的 PHT 是必需的。
Section Header Table:列出所有段(section)的元信息——名称、类型、地址、偏移、大小。链接器依赖段视图做符号解析;OS 加载器依赖段视图做内存映射。
关键段
| 段名 | 类型 | 内容 | 运行时 |
|---|---|---|---|
| .text | SHT_PROGBITS | 机器指令 | 可读可执行 |
| .rodata | SHT_PROGBITS | 只读数据(字符串、常量数组) | 只读 |
| .data | SHT_PROGBITS | 已初始化全局变量 | 可读可写 |
| .bss | SHT_NOBITS | 未初始化全局变量 | 可读可写,加载时清零 |
| .symtab | SHT_SYMTAB | 符号表 | 调试用(可 strip) |
| .strtab | SHT_STRTAB | 字符串表(符号名称) | 调试用(可 strip) |
| .rela.text | SHT_RELA | .text 段的重定位表 | 仅 .o 文件(链接后可 strip) |
| .shstrtab | SHT_STRTAB | 段名称表 | 调试用 |
.rela.text 是重定位信息的载体——.text 中每条需要重定位的位置在此有一个 Elf64_Rela 结构记录偏移、类型、和关联符号。链接后,可执行文件中通常不需要重定位表(静态链接已将地址全部填平)。
objdump 与 readelf 实战
objdump —— 反汇编与段/符号查看
# 反汇编 .text 段(最常用)
riscv64-linux-gnu-objdump -d hello
# 反汇编所有包含代码的段
riscv64-linux-gnu-objdump -D hello
# 反汇编时显示原始字节
riscv64-linux-gnu-objdump -d -j .text hello # 仅 .text 段
# 查看所有段的信息
riscv64-linux-gnu-objdump -h hello
# 输出示例:
# Idx Name Size VMA LMA File off Algn
# 0 .text 0000024c 00000000000100b0 00000000000100b0 000000b0 2**2
# 1 .rodata 00000096 0000000000010300 0000000000010300 00000300 2**3
# 查看符号表
riscv64-linux-gnu-objdump -t hello
# 输出示例:
# 000000000001011c g F .text 00000036 my_function
# 0000000000010308 g O .data 00000008 global_var
# 查看重定位表(仅 .o 文件)
riscv64-linux-gnu-objdump -r hello.o
# 输出示例:
# OFFSET TYPE VALUE
# 0000000000000000 R_RISCV_HI20 msg
# 0000000000000004 R_RISCV_LO12_I msg
# 显示伪指令别名(默认) vs 真实硬指令
riscv64-linux-gnu-objdump -d hello # 显示 mv/li/ret 等伪指令
riscv64-linux-gnu-objdump -d -M no-aliases hello # 显示 addi/jalr 等硬指令readelf —— ELF 结构分析
# ELF 文件头
riscv64-linux-gnu-readelf -h hello
# 输出:Magic、Class (ELF64)、Machine (RISC-V)、Entry point address 等
# 程序头(segment 视图,可执行文件)
riscv64-linux-gnu-readelf -l hello
# 输出:LOAD 段、文件/内存映射关系、对齐和权限
# 段头(section 视图,所有 ELF 文件)
riscv64-linux-gnu-readelf -S hello
# 输出:所有 section 的名称、类型、地址、大小
# 符号表
riscv64-linux-gnu-readelf -s hello
# 输出:符号列表、类型(FUNC/OBJECT/NOTYPE)、绑定(GLOBAL/LOCAL/WEAK)
# 重定位表
riscv64-linux-gnu-readelf -r hello.o
# 查看 ELF 文件中的所有 note(如 .note.gnu.build-id)
riscv64-linux-gnu-readelf -n hello实用调试工作流
# 1. 汇编并查看目标文件
riscv64-linux-gnu-as -o test.o test.s
riscv64-linux-gnu-objdump -r test.o # 检查重定位——确认符号引用正确
riscv64-linux-gnu-objdump -d test.o # 检查指令序列——确认伪指令展开符合预期
# 2. 链接并查看可执行文件
riscv64-linux-gnu-ld -o test test.o
riscv64-linux-gnu-readelf -h test # 查看入口点地址
riscv64-linux-gnu-objdump -d test # 查看最终地址
# 3. 运行并调试
qemu-riscv64 ./test # 运行
qemu-riscv64 -strace ./test 2>&1 # 显示 syscall 调用轨迹
qemu-riscv64 -g 1234 ./test # 启动 GDB 服务器(端口 1234)
# 另开终端:riscv64-linux-gnu-gdb ./test → target remote :1234对比 .o 和最终可执行文件
用同一个程序对比 .o 和 ELF,可直观理解链接器的工作:
# 汇编后:重定位入口存在
riscv64-linux-gnu-objdump -r hello.o
# → 所有 la / call 处显示 R_RISCV_PCREL_HI20 等重定位
# 链接后:重定位已填平
riscv64-linux-gnu-objdump -d hello
# → 所有立即数已是实际地址值这是"汇编器留下占位符,链接器填入实际值"这一原理的直接可视化。
本章要点
.globl导出符号、.equ定义常量、.macro定义代码模板、.rept编译期循环、.if条件汇编——掌握这 5 条指令即可高效管理汇编项目- 符号 = 地址标签;局部符号(
.L前缀)仅汇编器可见;重定位入口是链接器填地址的占位符 la/call的底层使用 R_RISCV_PCREL_HI20 和 R_RISCV_PCREL_LO12_I 重定位实现 PC 相对寻址- ELF 文件 = 链接视图(section 导向)+ 执行视图(segment 导向);.text/.rodata/.data/.bss 是核心数据段
objdump -d/-h/-t/-r和readelf -h/-l/-S/-s是分析 RISC-V 程序的多功能工具;对比 .o 和最终 ELF 即可直观理解重定位过程