Skip to content
Published at:

13. 汇编器、链接器与 ELF

前 12 章专注于 RISC-V 汇编语言和系统接口本身——指令怎么写,寄存器怎么用,syscall 怎么调。本章将镜头拉远一层:汇编器(Assembler)如何将你的 .s 文件转化为 .o?链接器(Linker)如何将多个 .o 拼成可执行文件?可执行文件内部到底是什么结构?理解这些工具链环节是独立开发、调试和优化汇编程序的前提。

过渡到本地工具链

本书前 12 章的代码均可在 RISC-V ALE 在线环境中运行——这是一个浏览器内汇编器+模拟器,零安装即可验证逻辑。但从本章起,所有代码将使用本地 GNU RISC-V 工具链 + QEMU,原因有三:

  1. 完整工具链功能:ALE 不支持宏、条件汇编、段指令等高级汇编器特性
  2. ELF 分析:readelf 和 objdump 需要实际的 ELF 文件
  3. 真实 Linux 环境:系统调用行为、内存布局、动态链接在完整 Linux 用户态下表现准确

安装

bash
# 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,链接 glibc
  • riscv64-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 —— 导出符号

asm
.globl _start            # 将 _start 导出到符号表
.globl my_function       # 使 my_function 对其他 .o 可见
.globl global_var        # 使数据符号全局可见

默认情况下,汇编器中定义的符号是局部的——只在其所在 .o 文件内部可见。.globl 将符号放入符号表,使链接器和其他 .o 文件可以引用它。

.equ —— 定义常量

asm
.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 —— 定义宏

宏是汇编器的模板系统——定义一次,多次展开,通过 \参数名 引用参数:

asm
# 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 —— 重复块

asm
# 用 10 条 nop 填 40 字节(10 x 4)
    .rept 10
    nop
    .endr

# 初始化填充数据
    .rept 256
    .byte 0x00
    .endr

.rept 在汇编时将内容复制展开 n 次——不是循环,是编译期代码生成。用于填充对齐空间或生成重复数据模式。

.if / .else / .endif —— 条件汇编

asm
# 根据宏定义选择汇编路径
.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 —— 包含文件

asm
.include "syscall_macros.inc"   # 引入公共宏定义
.include "constants.inc"        # 引入常量定义

将公用的宏和常量集中在 .inc 文件中,多个 .s 文件共享。这是汇编项目中管理复用的标准方法——虽然没有 C 的 #include 强大(无类型检查、无 include guard),但足够日常使用。

符号与重定位

符号 = 地址的标签

在汇编器和链接器的视角中,符号(symbol)是地址的命名。函数名、全局变量名、段起始标签——都是符号。符号表记录每个符号的名称、值和属性。

asm
.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_HI20PC 相对的高 20 位auipc 中的立即数
R_RISCV_PCREL_LO12_IPC 相对的低 12 位jalr/addi 的立即数
R_RISCV_BRANCH分支目标B-type 指令(beq/bne 等)
R_RISCV_JAL无条件跳转J-type 指令(jal)
R_RISCV_3232 位绝对地址.word symbol
R_RISCV_6464 位绝对地址.dword symbol

la 伪指令背后的重定位

la t0, symbol 展开后的两条指令各自携带一个重定位入口:

asm
# 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_auipcaddress_of_sym 的差,填入 hi20 和 lo12 字段。

重定位操作数%pcrel_hi(sym)%pcrel_lo(sym) 是汇编器输出中指示重定位类型的助记符:

asm
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 加载器依赖段视图做内存映射。

关键段

段名类型内容运行时
.textSHT_PROGBITS机器指令可读可执行
.rodataSHT_PROGBITS只读数据(字符串、常量数组)只读
.dataSHT_PROGBITS已初始化全局变量可读可写
.bssSHT_NOBITS未初始化全局变量可读可写,加载时清零
.symtabSHT_SYMTAB符号表调试用(可 strip)
.strtabSHT_STRTAB字符串表(符号名称)调试用(可 strip)
.rela.textSHT_RELA.text 段的重定位表仅 .o 文件(链接后可 strip)
.shstrtabSHT_STRTAB段名称表调试用

.rela.text 是重定位信息的载体——.text 中每条需要重定位的位置在此有一个 Elf64_Rela 结构记录偏移、类型、和关联符号。链接后,可执行文件中通常不需要重定位表(静态链接已将地址全部填平)。

objdump 与 readelf 实战

objdump —— 反汇编与段/符号查看

bash
# 反汇编 .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 结构分析

bash
# 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

实用调试工作流

bash
# 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,可直观理解链接器的工作:

bash
# 汇编后:重定位入口存在
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/-rreadelf -h/-l/-S/-s 是分析 RISC-V 程序的多功能工具;对比 .o 和最终 ELF 即可直观理解重定位过程