跳到内容

第 3 卷 / 第 4 章 / 第 1 课

4.1 CPU、ISA 与 RISC-V ​

逻辑门和寄存器组合成能够取指、解码并执行的 CPU。Java 程序无论经解释还是即时编译,其运行效果最终都要由机器指令和运行时共同落实。

数字逻辑提供了门、寄存器和时钟。本章先读懂 ISA 这份对软件可见的契约;下一篇再把它落到一台教学用单周期数据通路。

先区分 ISA、微架构与 ABI ​

“能做加法”还不足以运行程序。软件需要知道有哪些寄存器、指令如何编码、访存产生什么效果、异常时保存哪些状态。这些可观察规则由 instruction set architecture(ISA)定义。

三个层次经常被混在一起:

  • ISA 规定机器指令的语义和软件可见状态,例如 RISC-V、x86-64、AArch64;
  • 微架构 决定某颗处理器怎样实现 ISA,例如流水线深度、缓存、乱序窗口和执行单元数量;
  • ABI 约定程序之间怎样合作,包括参数放在哪些寄存器、谁保存寄存器、栈对齐和目标文件格式。

同一 ISA 可以有完全不同的微架构。相同机器指令在两颗 CPU 上延迟不同,只要架构可见结果符合契约,软件就不应依赖内部步骤。ABI 又位于 ISA 之上:处理器认识 add 和寄存器 x10,但“用 a0(x10)传递第一个普通整数参数”是调用约定赋予的含义,与 C 参数变量叫什么无关。

一段 C 不对应固定汇编 ​

以 RV32I 与常见 RISC-V psABI 为目标,优化后的简单函数可能是:

c
int add(int left, int right) {
    return left + right;
}
asm
add:
    add a0, a0, a1
    ret

a0、a1 接收前两个整数参数,a0 也承载返回值。ret 是伪指令,汇编器通常把它展开为 jalr x0, 0(ra)。

这里有几个必须保留的条件:

  • 目标若是 RV64,C 的 int 仍通常是 32 bit,编译器会使用 addw 等合适指令维护语言语义;
  • 调试构建、插桩、不同优化级别或调用约定可能产生不同代码;
  • C 有符号溢出是未定义行为,编译器可利用这一前提优化,不能把机器加法的低位回绕反推成 C 保证;
  • 函数若需要栈帧,标准 ILP32/LP64 调用约定要求栈指针在过程执行期间保持 16-byte 对齐,不能随手减 8 byte 就声称是合规序言;E 类 ABI 的要求另查。

C 的一行也可能消失、展开成多条指令,或被移动到与源码不同的位置。编译器守的是语言可观察行为,不是逐行翻译仪式。

Java 类文件包含 JVM bytecode。解释器执行实现各条字节码语义的机器代码,JIT 则可把热点字节码编译成目标机器码;AOT 工具又可能采用不同路径。CPython 同样有自己的 bytecode 和解释循环。它们不是“没有指令”,而是源代码先经过了额外的运行时抽象层。

RISC-V 的整数寄存器 ​

本节的 RV32I/RV64I 基础整数 ISA 暴露 32 个整数寄存器 x0 到 x31。x0 读出恒为零,写入被丢弃;其余寄存器的宽度是 XLEN,RV32 为 32 bit,RV64 为 64 bit。

面向嵌入式的 RV32E/RV64E 子集只有 16 个整数寄存器,不能把这张表原样套用。

ABI 给寄存器添加易读别名:

ABI 名寄存器常见用途
zerox0常量零
rax1返回地址
spx2栈指针
a0–a7x10–x17参数;前两个也用于返回值
t0–t6若干 x 寄存器caller-saved 临时值
s0–s11若干 x 寄存器callee-saved 保存值

“caller-saved”不是调用后一定损坏,而是调用者若需要保留就必须自己负责;“callee-saved”表示被调用者使用后要恢复。具体函数可以完全不用栈。

硬件寄存器文件的实现不是 ISA 的承诺。小核可能采用触发器阵列或 SRAM 风格结构,高性能核还会做物理寄存器重命名。软件只看到架构寄存器及其规定效果。

基础指令怎样编码 ​

RV32I 的基础指令是 32 bit,低 7 bit opcode 确定主要类别。以 add x1, x2, x3 为例:

text
31       25 24    20 19    15 14  12 11     7 6       0
+----------+--------+--------+------+--------+---------+
| funct7   | rs2    | rs1    |funct3| rd     | opcode  |
| 0000000  | 00011  | 00010  | 000  | 00001  | 0110011 |
+----------+--------+--------+------+--------+---------+

机器字:0x003100B3

字段含义是:

  • opcode 选择 OP 这一大类;
  • rd=x1 是目标寄存器;
  • rs1=x2、rs2=x3 是两个源寄存器;
  • funct3 与 funct7 在同一 opcode 下区分 ADD、SUB 等操作。

可用 Python 直接验证拼装:

python
funct7 = 0b0000000
rs2 = 3
rs1 = 2
funct3 = 0b000
rd = 1
opcode = 0b0110011

instruction = (
    (funct7 << 25)
    | (rs2 << 20)
    | (rs1 << 15)
    | (funct3 << 12)
    | (rd << 7)
    | opcode
)
assert instruction == 0x003100B3

RISC-V 基础 32-bit 指令常归纳为 R、I、S、B、U、J 六种布局。它们保持 rs1、rs2、rd 等字段位置尽量一致,立即数则按用途分布到剩余 bit。B/J 立即数的排列看似打散,是为了复用字段和硬件连线;解码后仍要重排、符号扩展,并考虑最低位隐含为零。

“RISC-V 所有指令都恰好 32 bit”并不正确。基础指令通常是 32 bit;C 扩展提供 16-bit compressed instructions,编码空间也为更长指令预留了规则。支持 C 扩展时,指令地址通常只需 2-byte 对齐,顺序执行的下一条地址也不一定总是 PC + 4。

六种布局服务不同数据流 ​

布局代表指令主要操作数
Radd rd, rs1, rs2两个寄存器源,一个寄存器目标
Iaddi、lw、jalr一个寄存器源、立即数,常有目标
Ssw rs2, imm(rs1)基址、待存数据、偏移,无 rd
Bbeq rs1, rs2, label两个比较源与 PC-relative 偏移
Ului、auipc高位立即数与目标
Jjal rd, labelPC-relative 偏移与链接目标

不是所有扩展指令都服从“最多两个源加一个目标”。例如浮点 fused multiply-add 使用额外源寄存器并有 R4-type 编码。六种布局是理解基础整数编码的入口,不是整个 RISC-V 生态的上限。

几条 RV32I 指令的语义可写成:

asm
add  t0, t1, t2       # t0 = t1 + t2
addi t0, t1, -16      # 12-bit 立即数符号扩展后相加
lw   t0, 8(sp)        # 从地址 sp + 8 读取 32 bit
sw   t0, 12(sp)       # 向地址 sp + 12 写入 32 bit
beq  t0, t1, equal    # 相等时跳到 PC-relative 目标
jal  ra, function     # ra = 返回地址;跳到目标
jalr x0, 0(ra)        # 跳回 ra,不保留新的链接地址

RISC-V 是 load/store ISA:整数算术指令通常操作寄存器,内存数据先 load 再计算,最后按需 store。访存还要遵守对齐、权限、地址翻译、内存模型和具体扩展规定,汇编注释只是正常成功路径。jalr 会把基址加符号扩展偏移后的最低位清零,再检查目标指令对齐;上面返回示例的 ra 已是正常返回地址。

指令语义不等于处理器的五个固定动作 ​

“取指、译码、执行、访存、写回”是很有用的数据流分解,却不是 ISA 要求每条指令按五个时钟周期依次行动。

  • 单周期教学 CPU 在一个长周期内完成整条指令;
  • 多周期 CPU 可复用硬件,让不同指令经历不同状态;
  • 五级流水线让多条指令的阶段重叠;
  • 高性能处理器可能把指令译成微操作,乱序执行后再按架构规则提交。

甚至“访存阶段”也不是每条指令都访问数据内存。把这五个名字当作分析部件的视角,而不是所有 CPU 内部都必须存在的节拍表。

架构状态通常包括整数寄存器、PC、特权状态及可见内存效果。微架构中的流水线寄存器、预测器和重命名表不直接属于普通程序可见的 ISA 状态。

RISC 与 CISC 不是处理器排行榜 ​

RISC 倾向规则编码、load/store 组织和较少寻址形式;x86 保留变长编码、丰富寻址方式与长期兼容性。今天的实现会跨越早期分类边界:RISC-V 可拥有复杂扩展和乱序核心,x86 可把复杂指令译成内部操作并投入大量专用优化。

因此,以下推断都不可靠:

  • 指令更少,所以一定更快;
  • 译码更简单,所以整颗芯片必然更小;
  • 内部微操作“像 RISC”,所以 x86 只是套壳 RISC;
  • 固定长度编码必然带来更小程序。

性能取决于工作负载、编译器、内存系统、实现宽度、频率、功耗限制等。ISA 影响设计空间,却不会单独决定结果。

rep movsb 与显式循环 ​

x86-64 的 rep movsb 按 RCX 计数,从 [RSI] 向 [RDI] 复制 byte;指针增减方向由 direction flag 决定。现代实现可能为它选择专用硬件路径,但 ISA 只规定可观察语义。

下面是 RV64I 的循环片段,假设源、目的范围有效且不重叠,count 是无符号 XLEN 位长度。它修改 a0、a1、a2,没有函数序言/返回,也不保留 memcpy 需要返回的原目的指针。先处理长度为零,就不会在空范围上先读一次:

asm
# a0 = destination, a1 = source, a2 = count
    beq  a2, zero, done
loop:
    lbu  t0, 0(a1)
    sb   t0, 0(a0)
    addi a1, a1, 1
    addi a0, a0, 1
    addi a2, a2, -1
    bne  a2, zero, loop
done:

动态执行的指令条数比一条 rep movsb 多,不代表一定更慢;反过来也不能仅凭专用指令就宣布胜出。缓存命中、长度、对齐、微架构优化和库实现都会改变结果。实际 memcpy 可按向量宽度、长度与目标 CPU 选择策略,但它要求源、目的不重叠;允许重叠时应使用 memmove,这个只向前复制的循环也不能自动处理所有重叠方向。

函数调用是 ABI 组织出的协议 ​

一条 jal ra, function 只完成跳转并保存返回地址。完整调用还需要:

  1. 调用者把参数放入约定位置;
  2. 必要时保存 caller-saved 活跃值;
  3. 被调用者为局部存储和保存寄存器建立对齐栈帧;
  4. 返回值写入 a0 / a1 等约定位置;
  5. 被调用者恢复 callee-saved 状态并通过 ra 返回。

递归并不是由 JAL 特别支持的。每层调用只要把仍需使用的返回地址和局部状态保存到独立栈帧,就能再次调用自身。叶子函数不再调用别人,常可让 ra 留在寄存器里而不落栈。

动手读一份 ISA 契约 ​

  1. 计算 sub x5, x6, x7 的 R-type 字段,并说明它与 ADD 怎样靠 funct7 区分。
  2. 解释 x0 如何让 addi rd, x0, imm 充当加载小立即数的操作。
  3. 为 max(int, int) 写出 RV32I 汇编,注明使用的是 signed 还是 unsigned 分支。
  4. 找出上面复制循环若遗漏第一次 beq,在 count=0 时会发生什么。
  5. 区分 ISA、微架构和 ABI:栈对齐、L1 大小、add 的溢出结果分别属于哪一层。
  6. 解释启用 compressed extension 后,为什么“PC 永远加 4”不再成立。

把契约接上线网 ​

到这里,我们知道每个机器字应该产生什么架构效果,却还没决定寄存器、ALU 和内存端口如何连线。下一篇用一台刻意简化的单周期 RV32I 数据通路跟踪 add、lw、sw 与 beq,并说明教学模型在哪些地方不能代表现代 CPU。

参考资料 ​

Built with VitePress | Software Systems Atlas