4、指令集精讲(下):M扩展(乘除法)、F/D扩展(单双精度浮点)、特权架构(Machine/Supervisor/User模式)
好,我们接着往下聊。上一节我们把基础整数指令集(I扩展)过了一遍,那都是基本功。但一个真正能打的处理器核心,光有加减法和逻辑运算是不够的。你想想看,如果让你用移位和加法去实现一个32位乘法,那得多少条指令?性能直接崩了。
所以这一节,我们来啃几块硬骨头:M扩展(乘除法)、F/D扩展(单双精度浮点),以及特权架构。这三块内容,是区分一个玩具核和一个工业级核的关键。
4.1 M扩展:别再用软件模拟乘除法了
M扩展,说白了就是给处理器加上了硬件乘除法器。我个人习惯,只要做通用计算核,M扩展是必选的。为什么?因为省心,省时间。
乘法指令
RISC-V的乘法指令很有意思,它不像x86那样一个MUL搞定所有。它把乘法拆成了四个场景:
- MUL:取低64位(或32位)结果。这是最常用的。
- MULH:取有符号乘法的上64位。
- MULHU:取无符号乘法的上64位。
- MULHSU:一个操作数有符号,一个无符号,取上64位。
为什么要这么分?我刚开始学的时候也觉得麻烦。后来做定点数运算库才明白——高半位的值在很多算法里比低半位更重要。比如做乘法累加,你如果不取高半位,溢出就丢了。
核心要点:MULH/MULHU/MULHSU这三条指令,是专门为高级语言中的“全精度乘法”准备的。C语言里两个64位整数相乘,结果可能是128位,编译器就会自动生成MUL+MULH的组合。
除法指令
除法就简单多了,只有两条:
- DIV:有符号除法,取商。
- DIVU:无符号除法,取商。
- REM:有符号除法,取余数。
- REMU:无符号除法,取余数。
这里有个坑,我踩过。DIV指令在除数为0时,不会报异常,而是直接返回全1(即-1或最大值)。余数则返回被除数本身。嗯,这里要注意,你的软件层必须自己处理除零异常,硬件不会帮你做这件事。
避坑指南:我曾经在一个项目中,把DIV指令的除零行为理解成了“返回0”,结果导致整个控制系统的PID参数计算全部跑飞。排查了整整两天才发现是除零问题。记住:RISC-V的DIV除零返回-1,不是0。
4.2 F/D扩展:浮点运算,精度与性能的博弈
浮点扩展分两档:F(单精度)和D(双精度)。D扩展是F的超集,选了D就必须选F。
浮点指令集的设计哲学很有意思——它把浮点寄存器(f0-f31)和整数寄存器(x0-x31)完全分开。为什么?说白了就是为了减少数据通路上的冲突。整数和浮点运算可以并行执行,互不干扰。
浮点加载存储
FLW/FSW(单精度)、FLD/FSD(双精度)。用法和整数加载存储一模一样,只是操作的是浮点寄存器。
浮点运算指令
FADD.S/FADD.D、FSUB.S/FSUB.D、FMUL.S/FMUL.D、FDIV.S/FDIV.D、FSQRT.S/FSQRT.D……这些名字很直观,一看就知道是干嘛的。
但有个细节要注意:浮点比较指令。比如FEQ.S(等于)、FLT.S(小于)、FLE.S(小于等于)。它们的结果是写入整数寄存器的。为什么?因为条件分支指令(BEQ、BNE等)只认整数寄存器。你想想看,如果比较结果写在浮点寄存器里,那分支指令还得去读浮点寄存器,多绕一圈。
个人经验:在做浮点运算密集型的应用(比如FFT、矩阵运算)时,我建议尽量使用FMA(融合乘加)指令,比如FMADD.S。它把乘法和加法合并成一条指令,精度更高,延迟也更低。编译器一般会自动做这个优化,但手写汇编时别忘了用。
浮点状态寄存器(FCSR)
浮点运算中,异常处理很重要。FCSR寄存器记录了浮点运算的异常标志,比如溢出、除零、无效操作等。我建议在每次浮点运算密集的代码段结束后,检查一下FCSR。我曾经在一个雷达信号处理项目中,因为没检查溢出标志,导致目标检测算法一直输出错误结果,查了三天才发现是浮点溢出被静默吞掉了。
4.3 特权架构:从裸机到操作系统
好,前面讲的都是用户程序能用的指令。但一个完整的处理器,必须能运行操作系统。这就引出了特权架构。
RISC-V定义了三个特权级:
| 级别 | 编码 | 名称 | 用途 |
|---|---|---|---|
| M | 11 | Machine模式 | 最底层,安全监控、固件 |
| S | 01 | Supervisor模式 | 操作系统内核 |
| U | 00 | User模式 | 普通应用程序 |
数字越大,权限越高。M模式最高,U模式最低。
Machine模式:一切从这里开始
处理器复位后,默认进入Machine模式。这是最核心的模式,所有控制寄存器(CSR)在这里都能访问。
关键寄存器:
- mstatus:机器状态寄存器,控制全局中断使能、特权级切换等。
- mtvec:机器模式中断向量表基址。处理器发生异常或中断时,会跳到这里。
- mepc:异常返回地址。发生异常时,硬件自动保存当前PC到这里。
- mcause:异常原因。告诉你发生了什么异常。
我刚开始写M模式代码时,最常犯的错误是忘记保存mepc。如果你在异常处理函数中调用了其他函数,mepc可能会被覆盖。所以,进入异常处理的第一件事,就是把mepc压栈。
核心要点:MRET指令用于从Machine模式的异常处理中返回。它会从mepc恢复PC,从mstatus恢复之前的中断使能状态。记住:不要用JALR代替MRET,否则中断状态会乱掉。
Supervisor模式:操作系统的地盘
S模式是为操作系统内核准备的。它提供了虚拟内存管理(通过satp寄存器)和中断委托机制。
关键寄存器:
- stvec:S模式中断向量表基址。
- sepc:S模式异常返回地址。
- scause:S模式异常原因。
- satp:页表基址寄存器。开启分页就靠它。
这里有个设计上的巧妙之处:中断委托。默认情况下,所有中断和异常都交给M模式处理。但M模式可以把一部分中断(比如时钟中断、外部中断)委托给S模式。这样,操作系统可以直接处理这些中断,不用每次都经过M模式绕一圈。性能提升很明显。
避坑指南:我曾经在一个移植Linux的项目中,发现时钟中断总是触发不了。查了半天,原来是mideleg寄存器(中断委托寄存器)没有正确配置。时钟中断默认是M模式的,你不把它委托给S模式,Linux内核根本收不到时钟中断,整个系统就卡死了。
User模式:隔离与保护
U模式是权限最低的模式。应用程序跑在U模式下,不能直接访问CSR寄存器,不能执行特权指令(比如WFI、MRET、SRET)。
U模式的意义在于隔离。一个应用程序崩溃了,不会影响到操作系统和其他进程。你想想看,如果没有U模式,一个野指针就能把整个系统搞挂。
从U模式切换到S模式,必须通过ECALL(环境调用)指令。应用程序通过ECALL发起系统调用,操作系统在S模式处理,处理完再通过SRET返回U模式。这就是系统调用的硬件基础。
4.4 小结
这一节内容不少,但都是硬核知识。M扩展让处理器能做硬件乘除法,F/D扩展让处理器能高效处理浮点运算,特权架构让处理器能运行操作系统。
我个人觉得,这三块内容里,特权架构是最容易被忽视的。很多初学者觉得“我先做个裸机核,操作系统以后再说”。但等你真的想跑Linux时,发现M/S/U模式的设计已经深深嵌入了整个处理器架构,改起来非常痛苦。所以,我建议从一开始就把特权架构规划好,哪怕初期只实现M模式,也要留好S模式和U模式的接口。
下一节,我们会把这些指令集的知识落地,开始动手设计一个简单的RISC-V处理器核心。到时候,你会看到这些指令是如何在硬件流水线上执行的。