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 为目标,优化后的简单函数可能是:
int add(int left, int right) {
return left + right;
}add:
add a0, a0, a1
reta0、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 名 | 寄存器 | 常见用途 |
|---|---|---|
zero | x0 | 常量零 |
ra | x1 | 返回地址 |
sp | x2 | 栈指针 |
a0–a7 | x10–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 为例:
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 直接验证拼装:
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 == 0x003100B3RISC-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。
六种布局服务不同数据流
| 布局 | 代表指令 | 主要操作数 |
|---|---|---|
| R | add rd, rs1, rs2 | 两个寄存器源,一个寄存器目标 |
| I | addi、lw、jalr | 一个寄存器源、立即数,常有目标 |
| S | sw rs2, imm(rs1) | 基址、待存数据、偏移,无 rd |
| B | beq rs1, rs2, label | 两个比较源与 PC-relative 偏移 |
| U | lui、auipc | 高位立即数与目标 |
| J | jal rd, label | PC-relative 偏移与链接目标 |
不是所有扩展指令都服从“最多两个源加一个目标”。例如浮点 fused multiply-add 使用额外源寄存器并有 R4-type 编码。六种布局是理解基础整数编码的入口,不是整个 RISC-V 生态的上限。
几条 RV32I 指令的语义可写成:
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 需要返回的原目的指针。先处理长度为零,就不会在空范围上先读一次:
# 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 只完成跳转并保存返回地址。完整调用还需要:
- 调用者把参数放入约定位置;
- 必要时保存 caller-saved 活跃值;
- 被调用者为局部存储和保存寄存器建立对齐栈帧;
- 返回值写入
a0/a1等约定位置; - 被调用者恢复 callee-saved 状态并通过
ra返回。
递归并不是由 JAL 特别支持的。每层调用只要把仍需使用的返回地址和局部状态保存到独立栈帧,就能再次调用自身。叶子函数不再调用别人,常可让 ra 留在寄存器里而不落栈。
动手读一份 ISA 契约
- 计算
sub x5, x6, x7的 R-type 字段,并说明它与 ADD 怎样靠funct7区分。 - 解释
x0如何让addi rd, x0, imm充当加载小立即数的操作。 - 为
max(int, int)写出 RV32I 汇编,注明使用的是 signed 还是 unsigned 分支。 - 找出上面复制循环若遗漏第一次
beq,在 count=0 时会发生什么。 - 区分 ISA、微架构和 ABI:栈对齐、L1 大小、
add的溢出结果分别属于哪一层。 - 解释启用 compressed extension 后,为什么“PC 永远加 4”不再成立。
把契约接上线网
到这里,我们知道每个机器字应该产生什么架构效果,却还没决定寄存器、ALU 和内存端口如何连线。下一篇用一台刻意简化的单周期 RV32I 数据通路跟踪 add、lw、sw 与 beq,并说明教学模型在哪些地方不能代表现代 CPU。