第2章 RISC-V指令集架构基础:RV32I核心指令与编码格式
各位同学,欢迎来到第二章。这一章我们聊聊RISC-V的指令集架构基础。说白了,指令集就是CPU和软件之间的“合同”——软件按这个格式写,CPU按这个格式读。RV32I是RISC-V的基石,搞懂它,后面的特权架构、中断处理、外设驱动才能顺起来。
我个人习惯把指令集分成两个层面来理解:编码格式(二进制长什么样)和语义(CPU该怎么执行)。咱们先看编码格式。
2.1 指令编码格式:六种基本类型
RISC-V的指令长度固定为32位(RV32I)。但它的编码格式非常规整,一共只有6种:R、I、S、U、B、J。你想想看,x86那套变长指令动辄几十种格式,RISC-V用6种就覆盖了整数指令全集,这就是精简的魅力。
| 格式 | 典型指令 | 操作数 | 特点 |
|---|---|---|---|
| R | ADD, SUB, AND, OR | rd, rs1, rs2 | 三个寄存器操作数,无立即数 |
| I | ADDI, LW, JALR | rd, rs1, imm[11:0] | 一个12位有符号立即数 |
| S | SW, SB | rs1, rs2, imm[11:0] | 存储指令,立即数分两段 |
| U | LUI, AUIPC | rd, imm[31:12] | 20位高位立即数 |
| B | BEQ, BNE, BLT | rs1, rs2, imm[12:1] | 条件分支,立即数编码最复杂 |
| J | JAL | rd, imm[20:1] | 无条件跳转,21位有符号立即数 |
关键点:所有指令的opcode(操作码)都在最低7位[6:0]。这意味着译码器只需要看最低7位就能确定指令类别。我在做FPGA实现时,第一级译码就用一个7-bit的case语句,干净利落。
这里有个细节——B格式和S格式的立即数编码是故意错位的。B格式的立即数位[12|10:5|4:1|11]被拆成4段,而S格式是[11:5|4:0]。为什么?因为B格式的立即数在硬件上需要左移1位(分支目标地址是偶数),这种编码方式让立即数位的位置和R格式的rs2/rs1字段对齐,简化了译码逻辑。嗯,设计者确实花了心思。
2.2 RV32I基础指令集详解
RV32I一共只有47条指令(算上伪指令也就50多条)。我当年从ARM转到RISC-V时,第一反应是“就这?”——但实际用下来发现,这些指令组合起来足够高效。
2.2.1 算术运算指令
- ADD/SUB:加减法,R格式
- ADDI:加立即数,I格式。注意:没有SUBI,减法用ADDI加负数实现
- LUI:加载高位立即数,U格式。加载20位到寄存器[31:12]
- AUIPC:PC加立即数,U格式。常用于位置无关代码
我的经验:在FPGA上实现LUI时,要注意它不修改低12位。如果你需要加载一个32位常数,通常用LUI+ADDI组合。但ADDI会符号扩展,所以当常数低12位的最高位为1时,需要额外加一条ADDI来修正。这个坑我踩过——当时仿真一直对不上,查了半天才发现是符号扩展的问题。
2.2.2 逻辑与移位指令
- AND/OR/XOR:按位与/或/异或,R格式
- ANDI/ORI/XORI:与/或/异或立即数,I格式
- SLL/SRL/SRA:左移/逻辑右移/算术右移,R格式
- SLLI/SRLI/SRAI:移位立即数,I格式(移位量用低5位)
注意:RV32I没有带进位的加减法(ADC/SBB),也没有乘除法指令(这些在M扩展里)。这是RISC-V的模块化设计思想——基础指令集只做最核心的事。
2.2.3 比较指令
- SLT/SLTU:有符号/无符号小于则置位,R格式
- SLTI/SLTIU:有符号/无符号小于立即数则置位,I格式
比较指令的结果是0或1,存在rd里。为什么没有大于、大于等于?因为这些都可以通过交换操作数或组合指令实现。指令集设计者追求的是最小完备集。
2.2.4 分支与跳转指令
- BEQ/BNE/BLT/BGE/BLTU/BGEU:条件分支,B格式
- JAL:跳转并链接,J格式。保存返回地址到rd
- JALR:跳转并链接寄存器,I格式。用于函数返回和间接跳转
避坑指南:我曾经在实现B格式指令时,把立即数拼接顺序搞反了。B格式的立即数位顺序是[12|10:5|4:1|11],不是简单的[12:1]。如果你用Verilog做拼接,一定要对照ISA手册的图2.3来写。我当时仿真分支跳转总是不对,最后用$display打印出立即数值才发现问题——低1位被错误地置0了。
2.2.5 加载与存储指令
- LB/LH/LW/LBU/LHU:加载字节/半字/字(有符号/无符号),I格式
- SB/SH/SW:存储字节/半字/字,S格式
加载指令的地址由rs1+imm[11:0]计算得到。注意:RISC-V不要求地址对齐——你可以从任意地址加载一个字。但不对齐的访问在硬件上可能需要两次内存访问,性能会下降。我在项目中通常还是按对齐来写代码,除非有特殊需求。
2.2.6 特殊指令
- FENCE:内存屏障,用于多核同步
- FENCE.I:指令屏障,用于自修改代码
- ECALL:环境调用,触发异常,进入机器模式
- EBREAK:调试断点
- CSRRW/CSRRS/CSRRC等:CSR(控制状态寄存器)访问指令
CSR指令在特权架构中非常重要。咱们后面会专门讲,这里先记住它们属于I格式的变体。
2.3 寄存器约定与ABI
RISC-V有32个通用寄存器x0~x31。x0是硬连线的0,写入无效。其他寄存器在ABI(应用程序二进制接口)中有约定用途。
| 寄存器 | ABI名称 | 用途 | 是否被调用者保存 |
|---|---|---|---|
| x0 | zero | 硬连线0 | — |
| x1 | ra | 返回地址 | 否 |
| x2 | sp | 栈指针 | 是 |
| x3 | gp | 全局指针 | — |
| x4 | tp | 线程指针 | — |
| x5~x7 | t0~t2 | 临时寄存器 | 否 |
| x8 | s0/fp | 保存寄存器/帧指针 | 是 |
| x9 | s1 | 保存寄存器 | 是 |
| x10~x17 | a0~a7 | 函数参数/返回值 | 否 |
| x18~x27 | s2~s11 | 保存寄存器 | 是 |
| x28~x31 | t3~t6 | 临时寄存器 | 否 |
ABI的核心规则:被调用者保存(Callee-saved)的寄存器,如果函数要用,必须先保存到栈上,返回前恢复。调用者保存(Caller-saved)的寄存器,调用函数前如果还有用,调用者自己保存。这个约定保证了函数调用的正确性。
我个人习惯在写汇编时,把频繁使用的变量放在s0~s11里,临时计算用t0~t6。参数传递用a0~a7。这样代码可读性好,也符合编译器预期。
2.4 特权架构简介
RISC-V的特权架构定义了三种模式:机器模式(M-mode)、监管模式(S-mode)、用户模式(U-mode)。咱们做FPGA软核,最常用的是M-mode(裸机程序)和M-mode+S-mode(运行Linux)。
- M-mode:最高权限,可以访问所有CSR,处理所有异常和中断。复位后CPU默认在M-mode。
- S-mode:操作系统内核运行的模式,可以管理页表、处理系统调用。
- U-mode:用户程序运行的模式,权限最低,不能直接访问硬件。
异常和中断的处理流程:CPU检测到异常→跳转到M-mode的异常向量表基地址(mtvec)→保存异常原因(mcause)和返回地址(mepc)→执行异常处理程序→执行mret返回。
我的建议:刚开始做FPGA RISC-V验证时,先只跑M-mode的裸机程序。等M-mode下的指令集验证通过、中断处理稳定了,再引入S-mode和页表。一步一个脚印,别贪多。
2.5 本章知识体系图
下面这张SVG图把本章的核心知识点串起来了。你可以看到指令编码格式、指令分类、寄存器ABI、特权模式之间的关系。