03. RISC-V 体系结构概览
汇编程序员需要的心智模型——寄存器、内存、指令执行周期——就是计算机体系结构的核心。本章建立 RISC-V 体系结构的全景图,为后续的指令学习打好框架。
冯·诺依曼模型
1945 年,John von Neumann 在一份备忘录中描述了现代计算机的基本架构。七十年后,几乎所有通用计算机仍在遵循这个模型。
五大组件:
+----------+ +----------+
| 输入设备 | | 输出设备 |
+----+-----+ +-----+----+
| ^
v |
+-------------------------------+
| 内存(Memory) |
| 指令和数据共享同一存储空间 |
+-------------------------------+
| ^
v |
+----------+ +----------+
| 控制器 |---->| 运算器 |
| (PC,译码) | | (ALU) |
+----------+ +----------+
CPU对汇编程序员的核心意义:
- 存储程序概念:程序指令和数据存储在同一个内存中。这意味着程序可以修改自身代码(尽管现代 OS 通常禁止),也意味着缓冲区溢出可能覆盖返回地址——这是一种安全漏洞
- **PC(程序计数器)**指向当前要执行的指令所在的内存地址。改变 PC = 改变执行路径,这是所有跳转和分支的本质
- 内存可同时存放代码和数据:.text 段放指令、.data 段放全局变量、stack 段放局部变量和返回地址。从 CPU 视角,它们都只是一段地址区间
寄存器文件
寄存器是 CPU 内部最快的数据存储——零周期延迟,直接连在 ALU 上。RISC 架构的核心设计决策之一就是提供足够多的通用寄存器以最小化内存访问。
RV64I 寄存器文件:32 个 64 位通用寄存器,编号 x0-x31。
x0 ┌────────────────────────────────┐ 恒为零(硬连线)
x1 ├────────────────────────────────┤ 返回地址
x2 ├────────────────────────────────┤ 栈指针
x3 ├────────────────────────────────┤ 全局指针
x4 ├────────────────────────────────┤ 线程指针
x5 ├────────────────────────────────┤
... │ 临时/参数/保存 │
x31 └────────────────────────────────┘| 寄存器 | 描述 |
|---|---|
| x0 | 硬连线恒为 0。写入 x0 的数据被丢弃,读 x0 永远返回 0。这是 RISC-V 设计中最精妙的一处——用 x0 替代了很多本来需要特殊指令的操作(例如 sub x5, x0, x6 实现取负(neg),beq x0, x0, label 实现无条件跳转) |
| x1-x31 | 通用寄存器,可读写。宽度在 RV64 中为 64 位 |
PC(程序计数器) 独立于这 32 个寄存器。PC 保存当前指令的地址(64 位),不是通用寄存器的一部分——你不能像访问 x5 那样直接读写 PC。修改 PC 的唯一方式是通过跳转和分支指令(JAL、JALR、BEQ、BNE 等),或者通过 AUIPC 间接读取 PC 相关信息。
寄存器 vs 内存:寄存器在 CPU 内部,访问延迟 0 个周期(或 1 个周期在深层流水线中)。内存(DRAM)在 CPU 外部,访问延迟通常几十到几百纳秒。RISC 架构正是为了最大化利用寄存器、最小化内存访问而设计的。L1/L2/L3 Cache 缓存用于缓解这一差距,但对汇编程序员来说,寄存器分配的好坏直接影响性能。
RISC-V 寄存器 ABI 名称
寄存器只有编号(x0-x31)是硬件事实;赋予它们语义的 ABI 名称是约定,不是硬件强制。但遵循 ABI 约定是代码能与编译器生成的函数互操作的先决条件。
| 寄存器 | ABI 名 | 用途 | 调用保存 |
|---|---|---|---|
| x0 | zero | 恒为零 | N/A |
| x1 | ra | 返回地址(Return Address) | 调用者 |
| x2 | sp | 栈指针(Stack Pointer) | 被调用者 |
| x3 | gp | 全局指针(Global Pointer) | — |
| x4 | tp | 线程指针(Thread Pointer) | — |
| x5 | t0 | 临时寄存器 0 | 调用者 |
| x6-x7 | t1-t2 | 临时寄存器 | 调用者 |
| x8 | s0 / fp | 保存寄存器 0 / 帧指针 | 被调用者 |
| x9 | s1 | 保存寄存器 1 | 被调用者 |
| x10-x11 | a0-a1 | 函数参数 / 返回值 | 调用者 |
| x12-x17 | a2-a7 | 函数参数 | 调用者 |
| x18-x27 | s2-s11 | 保存寄存器 | 被调用者 |
| x28-x31 | t3-t6 | 临时寄存器 | 调用者 |
调用者保存 vs 被调用者保存:
- 调用者保存(caller-saved):调用函数前,调用者负责把寄存器值保存到栈上。被调用函数可以随意覆盖这些寄存器
- 被调用者保存(callee-saved):被调用函数如果要使用这些寄存器,必须先把原始值保存到栈上,返回前恢复
理解这一约定的实际意义:如果你在写汇编函数,临时变量用 t0-t6(无需保存),跨函数调用的变量用 s0-s11(需配合栈帧)。第 11 章将详细讲解 RISC-V 的完整调用约定。
内存地址空间
地址空间:RV64 理论上有 $2^{64}$ 字节的虚拟地址空间(16 EB),但实际实现通常支持 48 位(256 TB)或 39 位(512 GB)——与 x86-64 和 ARM64 一致。原因很简单:全 64 位地址需要更深的页表,而没有任何应用需要 16 EB 的虚拟内存。
内存按字节编址。地址 N 指向一个独立的字节。但指令有对齐要求:
- 4 字节指令(RV64I 标准指令)必须对齐到 4 字节边界(地址 mod 4 == 0)
- 2 字节指令(C 扩展)对齐到 2 字节边界
- 数据 load/store 在硬件不支持时,非对齐访问会触发异常(某些实现支持硬件非对齐访问)
Load-Store 架构是 RISC 的基石。RISC-V 严格分离:
- ALU 操作(add, sub, and, or, sll, ...):操作数只能来自寄存器,结果只能写入寄存器
- Load 操作(ld, lw, lh, lb, ...):数据从内存流向寄存器
- Store 操作(sd, sw, sh, sb):数据从寄存器流向内存
正确: 错误(不支持):
ld x5, 0(x10) add x5, 0(x10), x6 # RISC-V 不允许
add x5, x5, x6 add x5, x5, (x10) # 内存 ↔ 寄存器 混合操作对比 x86:add eax, [rbx] 一条指令同时完成内存读取和加法。RISC-V 需要两条(ld + add),单条指令更长,但每条指令的执行时间更可预测——这对流水线和超标量设计极为重要。
地址空间布局(Linux RV64 典型):
0x0000_0000_0000_0000 ─┬─ 不可访问(NULL)
│
0x0001_0000_0000_0000 ─┼─ 用户代码 (.text)
│ 用户数据 (.data, .bss)
│ 堆 (heap, brk/sbrk 向上增长)
│ ...
│ 栈 (stack, 向下增长)
0x0000_7FFF_FFFF_FFFF ─┼─ 用户空间上限(48位实现)
│
0xFFFF_8000_0000_0000 ─┼─ 内核空间
│
0xFFFF_FFFF_FFFF_FFFF ─┴─ 内核空间顶这个布局和 x86-64/ARM64 基本一致——都是用户空间低 48 位,内核空间高 48 位。
指令执行周期
了解 CPU 如何"吃掉"一条指令,才能真正理解汇编程序的运行时行为。
经典五级流水线(RISC 的教科书实现):
取指(Fetch) → 译码(Decode) → 执行(Execute) → 访存(Memory) → 写回(Writeback)
│ │
└────────────────── PC 更新(顺序 PC+4 / 跳转改 PC)─────────────┘- 取指(Fetch):从 PC 指向的地址读取 4 字节(C 扩展时 2 字节),送入指令寄存器
- 译码(Decode):解析 opcode(7 位)确定格式类型(R/I/S/B/U/J),提取 rd、rs1、rs2、funct3、funct7、立即数等字段;从寄存器文件读出 rs1 和 rs2 的值
- 执行(Execute):ALU 完成运算——算术类指令直接计算,load/store 类计算地址(基址 + 偏移),分支类计算条件和目标地址
- 访存(Memory):仅 Load/Store 指令在这一阶段工作。Load 从数据缓存读取数据,Store 将数据写入数据缓存。其他指令(算术、分支、AUIPC 等)在此阶段空转
- 写回(Writeback):结果写入目标寄存器 rd(load 指令写读回的数据,ALU 指令写计算结果,JAL/JALR 写返回地址即 PC+4)
PC 更新:每条指令执行后 PC 默认 +4(顺序执行)。跳转/分支指令在 Execute 阶段计算出新 PC 值,覆盖默认的 +4。
流水线的直观理解:不是一条指令走完五步再开始下一条,而是每条指令只占一级,五级流水线可以同时容纳五条处在不同阶段的指令:
周期 1: I1 取指
周期 2: I1 译码 | I2 取指
周期 3: I1 执行 | I2 译码 | I3 取指
周期 4: I1 访存 | I2 执行 | I3 译码 | I4 取指
周期 5: I1 写回 | I2 访存 | I3 执行 | I4 译码 | I5 取指对汇编程序员而言,流水线的关键影响在于数据冒险(data hazard):如果指令 I2 依赖 I1 的计算结果,而 I1 的结果要等到 Writeback 阶段才写入寄存器,I2 在 Decode 阶段就可能读到旧值。现代处理器用转发(forwarding)和流水线停顿(stall)处理这种情况。汇编层面可以手动调度指令顺序来减少停顿(第 18 章会涉及 -O3 编译优化时会讨论这点)。
本章要点
- 冯·诺依曼模型:指令和数据共享内存,PC 指向当前指令,改变 PC = 改变执行流
- RV64I 提供 32 个 64 位通用寄存器,x0 硬连线恒为 0(写丢弃),PC 独立于寄存器文件
- ABI 名称是约定而非硬件强制:a0-a7 传参、t0-t6 临时不需要保存、s0-s11 需被调用者保存
- Load-Store 架构:ALU 操作只处理寄存器,内存访问由 ld/sd 等专用指令完成——与 x86 可直接操作内存的 add 根本不同
- 五级流水线(取指/译码/执行/访存/写回)是理解指令延迟和流水线冒险的基础