RISC-V:本应做得更好
我常被问及为何反感RISC-V,每次都得零散解释一番。得到的回应往往是“你根本没懂它的精妙之处”——这显然算不上什么反驳。被问过N次后,我决定把所有想法整理成文,下次再被问起直接发链接就行。而且,如果有人想有理有据地反驳我,也能以本文为参照,逐条针对我的观点展开讨论。本文所有观点仅代表我个人,与我的雇主、任何神祇或房东无关。我的猫们部分赞同、部分反对,它们的观点将另行发表。
一、没有指令集能通吃所有场景
RISC-V的支持者会让你相信,它很快就能包揽超级计算机、微型控制器,以及二者之间的所有应用场景。这绝无可能,任何指令集(ISA)都做不到。说白了,高端CPU的需求,与低成本微控制器核心的需求完全对立。这种差异不只是微架构层面的选择,更是会从根本上影响CPU架构本身。我百分百确信,RISC-V最终会占领廉价一次性微控制器市场,但这并非因为它的ISA设计有多出色,而是得益于它比8051略胜一筹——而8051的门槛低得就像个减速带。
廉价微控制器核心的需求是什么?
先看看这类芯片的用途:它们通常用于在更大的芯片中对接并快速配置硬件模块,比如MP3播放器、SD卡或U盘里的控制芯片。核心工作由定制IP完成,CPU核心只需要偶尔触发寄存器或做些配置。这类场景下,关键指标是中断延迟(越低越好)和核心尺寸(越小越好),基本不需要复杂运算。量产的低成本设备中,代码要么运行在不可更新的ROM里,要么运行在可更新的RAM里;NOR闪存成本太高,不适合超大规模量产。无论是ROM还是RAM,代码体积都很重要:ROM存储密度低,RAM则会占用大量芯片面积。因此,代码密度是这类场景的核心需求。既然不需要复杂运算,硬件除法器(甚至乘法器)都可以省掉;单一场景下也不需要权限隔离——毕竟不会有外部不可信代码运行。
你可能会说:“你说的不就是RV32IC(或RV32EC)吗?” 确实有点像,但严格来说还得加上RV32I_Zicsr扩展。没有Zicsr,就没有符合规范的中断处理方式——因为没有临时寄存器来暂存其他寄存器的值。MIPS专门预留了两个寄存器($k0和$k1)干这个,而RISC-V需要mscratch/sscratch寄存器,这就得靠Zicsr扩展。没有Zicsr,就只能用一些奇怪的方法处理中断,直接退回到8051的水平——怎么麻烦怎么来。
小型嵌入式核心不是乱序执行的怪兽,能做到每周期执行一条指令就谢天谢地了。我们来乐观估算一下,RISC-V中断处理程序暂存ABI要求的寄存器、调用C语言编写的处理函数需要多少周期:
- 先用CSRRW指令暂存一个寄存器(比如t0),获取暂存其他寄存器的基地址;
- 暂存ra、sp、gp、tp、t1-t6和a0-a7;
- 再用一次CSRRW指令恢复t0的值并暂存; 这至少需要21个周期。
退出中断时同理:用CSRRW读取暂存寄存器的地址,再执行19次加载操作,至少20个周期。还没完,因为这些操作必须用汇编实现,还要加上跳转到C处理函数的JAL指令和返回的RET指令——就算每条指令只花2个周期,这又要4个周期。算下来,在C处理函数开始工作前,中断本身至少要消耗44个周期。
而作为竞品的Cortex-M0(廉价32位核心),中断进入只需15个周期,退出12个周期;它会在硬件层面自动压栈ABI易失寄存器,处理函数可以直接用C语言编写,总开销只有27个周期。这速度差得可不是一星半点!
你可能会说我没考虑RV32E——它的寄存器数量减半,压栈和出栈各能省6个周期,中断开销降到38个周期,但还是比Cortex-M0多了三分之一。也就是说,在这类嵌入式核心最核心的任务上,RISC-V明显落后于现有主流竞品。而CLIC扩展和各种厂商私有的“快速中断/自动压栈”扩展的存在,更是雪上加霜:基础ISA逼得厂商不得不发明非标准硬件,才能追上十年前的Cortex-M0水平,这进一步割裂了所谓的“标准”。更可笑的是,就算加上压缩指令扩展,RISC-V典型的中断前导代码,也比Cortex-M0零字节的硬件路径更庞大、更缓慢。
二、压缩指令设计糟糕透顶
我们来仔细看看RISC-V的压缩指令,设计得简直离谱。比如,用16位指令实现“寄存器+偏移量”的字节存储,能支持的偏移量范围是多少?0到3。不是33,也不是303,就是3!那半字存储呢?也好不到哪去,只能是0或2。这是什么鬼设计?至少字存储的偏移量范围还算正常,是0到124字节,但前两者的设计实在让人摸不着头脑。更离谱的是,半字存储指令和字节存储指令编码方式类似,但偏移量选项反而更少?原因居然是:字节存储指令中用于偏移的某一位被硬编码为0——明明可以用这一位把偏移范围扩展到至少6,却没这么做!
相比之下,Cortex-M0的字节存储偏移范围是0到31,半字是0到62,字是0到124,显然能覆盖更多场景。RISC-V为什么会搞成这样?我真的不知道,但实在找不到合理的解释。常见的借口是“偏移量大就用全长度指令”,但这样一来,代码密度就会下降——而RISC-V支持者之前还在吹嘘C扩展的代码密度优势呢。
还有更糟的:这些字节和半字存储指令根本不在C扩展里,而是属于另一个叫Zcb的扩展。也就是说,就算它们那可怜的偏移量范围能满足你的需求,你也可能根本用不上。关于“扩展”的问题,我们后面再细说。
三、服务器核心需要什么?
服务器核心追求的是原始吞吐量,属于乱序执行核心的范畴——在这里,硅片成本几乎可以忽略不计,因为无论核心多大,缓存的尺寸都比它大得多。现代乱序执行核心能同时解码8条甚至10条指令,并发发送到多个执行端口;很多核心甚至能在同一个周期内处理多个分支指令(仔细想想,这有多夸张)。
代码密度在这里不像过去那么重要了。当然它还是有影响,但做一个稍大一点的L1指令缓存并非难事,而且从整体规模来看,核心占用的硅片面积几乎可以忽略。真正重要的是,要能尽可能轻松地同时取指和解码多条指令。同时,指令最好能清晰表达自身意图,这样才能最有效地与其他指令合并或拆分。
看起来“易解码”和“信息丰富”这两个需求是矛盾的——众所周知,“易解码”意味着“固定长度”,而“信息丰富”则意味着“指令要长”。显然我们不需要超长的固定长度指令,那该如何平衡?指令长度为2的幂次会让对齐等操作更简单,那选多长呢?2字节太短,8字节太长,4字节固定长度是个不错的折中方案。它有足够的编码空间,可以实现几乎所有你想要的功能:比如每条指令编码3个寄存器,分支指令支持长偏移量。
这听起来是不是很熟悉?因为AArch64(以及A32)已经证明这种设计非常有效,MIPS也出于同样的原因做出了相同选择。你可能会说,ARM也有Thumb指令集,MIPS也有microMIPS,但在高性能计算领域,Thumb已经被淘汰了。当年苹果和ARM联合设计AArch64时,大量建模和测试表明,Thumb指令集在每瓦指令数和每秒指令数上都是净损失。如果MIPS能活到现在的晶体管成本时代,想必也会淘汰microMIPS。
核心结论是:压缩指令在大型核心里根本没用,它们会干扰指令的快速并行解码——因为要花时间找指令边界。你可能会说“RISC-V很容易判断指令长度”,但“容易”和固定长度指令那种“即时且无开销”完全不是一回事。
回到高端核心的原始性能需求上。任何代码最常见的操作之一是什么?数组访问。这就是为什么x86有[ebx + esi * 4]这样的寻址模式,ARM有[R0, R1, LSL #2]。没有这种模式,你就得像个傻瓜一样,先把一个寄存器左移两位,再加到另一个寄存器上,最后才能访问内存——一个数组访问要三条指令。
对此,常见的借口是“乱序核心会把这三条指令融合成一条”。呵呵,如果真能做到,那他们能拿不少奖。据我所知,没有任何核心能融合超过两条连续指令,原因很明显:要考虑、跟踪和处理的指令组合数量会爆炸式增长。既然这个借口站不住脚,那怎么办呢?在RISC-V规范发布几年后,有人提出了Zba扩展来解决这个问题。它提供了三条SHxADD指令(x为1、2或3),把移位和加法合并成一条指令,总算把数组访问从三条指令减少到两条。这还是不如“寄存器+移位寄存器”的寻址模式,但至少“核心可以融合指令”这个说法不再完全是扯淡——前提是有人能做出这样的核心。
但问题又来了:SHxADD指令总是4字节,而内存访问指令本身是2或4字节,所以数组访问的代码长度变成了6或8字节,而ARM只需要4字节。之前那些吹嘘C扩展代码密度有多好、多适合高性能核心的人,此刻都沉默了吧?更讽刺的是,Zba扩展直到2021年才获批,比基础规范晚了两年多——他们花了两年时间才意识到数组的存在!
其实这个问题很容易解决:目前RISC-V把四分之三的编码空间分给了压缩指令(所有低两位不是0b11的指令)。只要拿出一部分编码空间来实现更好的寻址模式,就能同时提升代码密度和数组访问能力。但这么合理的事,他们偏偏不做。高通曾提出过这个方案,甚至做了原型,但最终不了了之——能把高通衬托得像理性代言人,可见RISC-V的问题有多严重。
再说回SHxADD指令:你不一定能用得上它,因为Zba是可选扩展。听“可选”这两个字听烦了吧?那我们接下来就聊聊这个。
四、可选扩展泛滥,规范名存实亡
RISC-V和USB-C、RCS有什么共同点?它们表面上都是标准,但有趣的是,声称符合这些标准实际上毫无意义,尽管技术上是真的。我的USB-C线缆只支持USB 2.0,合规吗?当然,USB 3的双绞线是可选的。没有电子标记的线缆合规吗?当然,电子标记是可选的。我的USB 3.0 USB-C线缆可以不支持20Gbps吗?当然,20Gbps支持是可选的。它可以支持20Gbps但不支持100W充电吗?当然,这也是可选的。我的手机完全合规的RCS实现,可以不支持把短信升级为视频通话吗?当然,MIVC是可选的。通话时不能发图片?当然,这也是可选的。不支持加密?那还用说,也是可选的!
如果所有功能都是可选的,那符合规范还有什么意义?说白了,就是规范制定者整天闭门造车,和现实世界脱节。这种情况通常有两种原因:要么是不了解办公室外还有现实世界的学者,要么是“委员会设计”模式——现实世界根本没有话语权,因为没来得及提交参会申请,让主席纳入表决议程。
看看这段官方描述:“RISC-V B(位操作)扩展是一套标准的指令集增强功能,旨在通过高效的位级操作提升性能和代码密度。它分为多个独立的子扩展:Zba、Zbb、Zbc和Zbs。”只有委员会设计的流程,才会一本正经地写出这种话。
制定规范时,每把一个功能设为可选,就会把可能的实现分成两个不兼容的阵营。这么做的次数多了,规范就变得毫无意义。而RISC-V的设计者在这方面真是搞砸了,几乎所有功能都是可选的:乘法——可选,除法——可选,用户模式——可选, supervisor模式——可选,CSR寄存器——可选,压缩指令——可选,“额外压缩指令”——还是可选。我敢说,如果能蒙混过关,他们连加法都能设为可选!
RISC-V最初发布的基础指令集包含CSR寄存器,正如我之前所说,这是符合规范的中断处理和权限隔离所必需的。这本来是合理的,没什么问题——但他们很快就“修正”了这一点!CSR被移到了Zicsr扩展里,现在基础ISA连中断处理和权限隔离的能力都没有了。更可笑的是,这背后的逻辑蠢得离谱。
如果有很多可选功能,代码首先想知道的是什么?当然是“我的硬件支持功能X吗?”x86用CPUID指令来回答这个问题,RISC-V呢?有好消息也有坏消息:有个叫misa的CSR寄存器可以回答部分问题(当然不是全部,那样就太合理了)。你发现问题了吗?它是CSR寄存器,而CSR支持是可选的(Zicsr扩展不是强制的)。更糟的是,就算实现了misa,也不要求它的内容准确——它可以被硬编码为全零,是否有意义完全是可选的。没错!检测可选功能的唯一方法,本身就是可选的。
还有更糟的:misa前面的“M”表示这是机器模式(Machine Mode)的CSR寄存器。机器模式是RISC-V的最高特权模式(也是唯一非可选的特权模式),这个寄存器在低特权模式下是不可读的——哪怕你运气好,遇到了(a)支持Zicsr的硬件,(b)misa没有被硬编码为全零的硬件,(c)实现了其他特权模式的硬件。这意味着普通用户代码根本无法根据硬件能力调整自己的行为。就算硬件实现了可选的supervisor模式,supervisor模式也无法检测核心功能。官方的说辞是“去问机器模式的 supervisor”,但问题是,你根本不知道那个supervisor是什么,也不知道怎么“问”。目前常用的是OpenSBI,但你根本无法检测机器模式运行的是不是它。
另一个有趣的可选功能是系统计时器。RISC-V规范里有计时器,当然,它是可选的。而且它不是通过CSR寄存器访问的——这太正常了,毕竟一致性不是他们的追求。官方的借口是“为了节省CSR的编码空间”。我猜他们是担心4096个CSR寄存器不够用?这野心也太大了吧——目前没有任何架构用到这么多,就连x86都没有。
那计时器寄存器如果不是CSR,在哪里?内存映射!地址在哪里?既然计时器是任何操作系统都需要的核心外设,而且CPU核心规范里提到了它,那它的地址肯定是固定的、可靠的——骗你的!这个规范里没有这么合理的设计!地址是“实现定义”的,可以在任何地方。祝你好运,别搞崩了!
我再举一个可选性导致的离谱场景:异常和中断向量。当异常或中断发生时(假设实现了可选的Zicsr),CPU会跳转到哪里?这取决于一堆可选的、且支持情况不一的配置寄存器、委托寄存器,以及各种过于复杂的玩意儿,最终核心会选择使用机器模式或supervisor模式的向量寄存器(mtvec或stvec)。这个CSR寄存器指向处理程序,但它的最低两位决定了“模式”。模式有两种:直接模式(最低两位0b00),所有异常和中断都跳转到寄存器高位指定的地址;向量模式(最低两位0b01),所有异常跳转到寄存器高位指定的地址,所有中断跳转到该地址加上4倍中断号的位置,旨在简化和加速中断处理。
我对这个看似合理的设计有什么意见?这两种模式都是可选的!规范里甚至没有强制要求实现简单的直接模式!你可以在运行时通过写入最低两位再读回,检测某个模式是否实现,但完全有可能出现只支持向量模式、只支持直接模式、两者都支持,或者两者都不支持(厂商自己发明了另一种模式)的核心。这让编写通用内核变得异常困难——你根本不知道会遇到什么情况。我实在想不通为什么不把直接模式设为强制,但我敢说,做出这个决定的人,肯定穿着超大号鞋子、顶着大红鼻子、涂着厚厚的白色粉底(像小丑一样)。
五、辩解站不住脚
看到这里,你可能会跳出来为这种荒谬的设计辩护,要么说“其他架构也有可选功能”,要么说“隐藏misa是为了支持虚拟化,你没读过Popek & Goldberg的论文吗?”我们来逐个击破这些无力的借口。
先说“其他架构”,我们看看过去几十年常用的架构:x86、ARMv7和AArch64。x86有CPUID指令,可以在用户模式下轻松告诉你当前核心支持哪些功能。ARM有ID_AA64PFR0_EL1和ID_AA64ISAR0_EL1寄存器,至少内核可以访问(用户空间不行)。但更重要的一点是,ARM的设计不会致命:在x86和ARM中,可选功能分为两类:
- 高性能计算功能,通常在特殊场景下用内联汇编或手工编写的汇编实现(比如视频编码、流体模拟),或者由libc库实现(比如memcpy、memset、strlen);
- 与NOP兼容的可选功能,在不支持的硬件上运行时会被当作NOP执行,不会有问题,比如x86的endbr64和AArch64的大部分PAC指令集。
请注意,完全正常的编译代码所需的功能,在这些架构中都不是可选的:乘法、除法、寻址模式,这些都是必有的。这意味着针对这些架构的普通C编译器,不需要面临两难选择:要么“为最低兼容标准编译,让代码能在所有版本的架构上运行”,要么“假设乘法、合理的寻址模式等存在,准备好在不支持这些功能的核心上崩溃”。
当然,有人会说“那就针对你用的核心编译啊”。我从来没说过这种ISA的荒谬之处无法通过各种变通方法解决,我只是说,这种糟糕的设计在我们还不懂事的1970年代是可以接受的,但在我们已经有足够经验的2000年代,完全不可原谅。
再说说虚拟化的借口。首先,Popek & Goldberg的论文讨论的是,在没有专门虚拟化支持的情况下,架构如何实现虚拟化。确实,通过捕获所有在用户模式和supervisor模式下行为不同的指令,可以虚拟化任何架构。但另一种方法是直接在架构设计时加入虚拟化支持。x86不符合Popek & Goldberg的虚拟化标准(至少因为POPF指令),但我的x86电脑上照样能运行VM——因为x86后来添加了虚拟化支持。虽然事后添加难度很大,但还是做到了。如果在架构设计时就考虑,那根本不是难事。也就是说,在架构设计阶段,用Popek & Goldberg的论文来为决策辩护完全是扯淡。架构设计阶段正是把事情做对的时候,Popek & Goldberg甚至提到,捕获所有指令在理论上对虚拟化的证明很有趣,但不实用。
那RISC-V的设计者做了什么?他们用“虚拟化需要……如果hypervisor想向VM隐藏硬件能力怎么办?”来为misa不向supervisor和用户模式暴露辩护。纯粹是扯淡!x86和ARM都能很好地处理这种情况,RISC-V本来也可以——只要让hypervisor可以修改misa的返回值,同时允许所有人读取它。看起来设计者只是听说过Popek & Goldberg的论文,根本没读过正文,只看了摘要。
他们还用Popek & Goldberg的论文为另一个问题辩护:执行代码无法检测当前的CPU模式。这同样是胡说八道。x86通过POPF等指令间接暴露模式,ARM则直接在CurrentEL MSR寄存器中公开。而在RISC-V上检测当前模式简直是一场冒险,有时可能做到,但并非所有情况都可行。为什么需要这个功能?比如,如果你正在编写一个想支持所有RISC-V核心的内核。我曾为[rePalm](https://dmitry.gr/?r=05.Projects&proj=27. rePalm)项目的内核做过这项工作,可以给你讲讲其中的决策树,以及每个分支有多坑:
首先,如果核心没有Zicsr,那你肯定运行在机器模式下,但你无法知道核心没有Zicsr——除非尝试读取CSR寄存器,但这有两个问题:第一,没有Zicsr的话,没有通用的方法捕获无效指令触发的异常;第二,读哪个CSR?别忘了,所有CSR都是可选的。你可能想读misa,但别忘了你正在检测当前模式——如果你在supervisor模式下,就算有Zicsr,这个操作也会失败。那试试读sstatus?这个寄存器在supervisor模式和机器模式下都能读,这下安全了吧?想多了!supervisor模式是可选的,如果核心不支持,就没有sstatus寄存器,还是会触发异常。
那简化一下问题,假设Zicsr存在,能区分S模式和M模式吗?还是不行!一个看似可行的方法是:把stvec指向一个处理程序,它只把sepc加4然后返回(跳过出错的指令),然后执行misa读取操作。如果在机器模式下,读取会成功;如果在supervisor模式下,会触发异常,正常的机器监视器会抛出非法指令异常,处理程序会跳过该指令,你就能判断自己在supervisor模式下。这下成功了?差不多……但别忘了,supervisor模式是可选的。假设你在一个不支持supervisor模式的核心上运行在机器模式下,尝试设置stvec时就会触发异常,因为这个寄存器不存在。你可能会说“那也捕获这个异常啊”,但要做到这一点,你需要设置mtvec,而你无法确定自己是不是一直在supervisor模式下——毕竟supervisor模式是可选的。就这样,所有功能都可选,加上设计者对Popek & Goldberg论文的完全误解,把你逼进了死胡同。
六、有用的指令缺失,编码混乱不堪
尽管RISC-V似乎为所有功能都做了扩展——连厨房水槽相关的操作都不放过,但一些明显有用的指令却缺失了。我已经说过缺少“寄存器+寄存器”寻址模式的问题,这里就不再重复了。还有一些明显的“低垂果实”被忽略了,而且你别说RV32I是为了简单才这样,我要说的这些功能在硬件上极其简单,基本上就是在ASIC上接几根线的事。
1. 位测试并分支
这条指令可以替代两条指令(SLLI + BGEZ/BLTZ),而且不需要临时寄存器。为了了解它的使用频率,我找了一个随机的AArch64二进制文件(最新的树莓派Raspbian内核“vmlinuz-6.1.0-49-arm64”,SHA256:B3B686DE 82CC7B84 EFEB8F6B 309A4E6C 53E7461F 281D3E56 F42E4AA3 B6207075),反汇编后统计TBZ/TBNZ指令的数量,结果是35393次。相比之下,RET指令有70109次,这意味着平均每两个函数就会用到(或可以用到)一次位测试分支指令。
在硬件上实现这条指令极其简单,而且位测试分支在协议解析或位字段操作中非常常见。对于大型乱序核心来说,少用一个寄存器会让寄存器重命名更简单,也不需要尝试融合两条指令;对于没有指令融合功能的小型MCU核心来说,这直接减少了代码体积,提升了速度。为什么这么明显的功能没做?我不知道。
2. 位字段操作:提取和插入
这些指令在处理网络数据包和硬件寄存器时极其有用。位字段提取可以用两条指令模拟(SLLI + SRLI/SRAI,取决于是否需要符号扩展);位字段插入则需要更多操作:创建掩码的反码、与目标寄存器按位与、将源寄存器移位到正确位置、与目标寄存器按位或,根据操作的位不同,通常需要3到6条指令。位字段清除(BFC)是一个更简单的特例,也很有用,需要2到3条指令模拟。但在硬件上,这些操作只是接几根线——不需要复杂逻辑,什么都不需要!
不需要像AArch64那样设计得很巧妙,但RISC-V的设计者确实可以从AArch64身上学到很多,包括巧妙的位字段处理。我用同一个内核镜像做了统计,有6284次位字段插入指令,8881次位字段提取指令——平均每9个函数就有两个会用到这些操作。更讽刺的是,RISC-V确实有一个位操作扩展Zbs,但一看就知道是学者设计的:干净、简单、容易解释、优雅,但完全没用。谁会需要只提取一个位的指令?说真的,这真是错失良机。
3. 立即数编码混乱不堪
RISC-V是我遇到的第一个立即数在指令中随机分布的架构,而且没有明显的理由。这让模拟RISC-V变得非常麻烦,因为和MIPS或ARM等架构相比,重组立即数要花很多时间。MIPS直接用指令的0到15位存储立即数,ARM的编码虽然复杂一些,但至少只有几种格式。RISC-V设计者的理由是“立即数的相同位来自指令的相同位”——这个理由蠢得让人头疼,只有没写过Verilog的软件工程师才会觉得这有助于简化硬件。
你看,不管怎么设计,立即数都需要一个多路选择器(mux),因为不同指令的立即数长度和末尾零的数量不同。而这个多路选择器根本不在乎输入的是指令的哪些位,反正都是线。就算这个理由很蠢,我们也来看看他们是否实现了自己的目标:立即数的相同位是否真的总是来自指令的相同位置?
看看实际情况:
- I型指令中,立即数的第1位来自指令的第21位,立即数的第11位来自指令的第31位;
- S型指令中,这两个位分别来自指令的第8位和第31位;
- B型指令中,来自第8位和第7位;
- J型指令中,来自第21位和第20位。
你看,他们确实做到了“相同位来自相同位置”——只要忽略“相同”和“位置”的实际含义就行。
但这只是编码混乱的冰山一角!J型指令的立即数分布顺序是:20 10 9 8 7 6 5 4 3 2 1 11 19 18 17 16 15 14 13 12。这种设计除了设计者把宾果游戏的数字顺序当成了立即数的位顺序,还有什么合理的解释?但这和压缩指令集的混乱相比,根本不算什么。
RISC-V的压缩指令集至少有9种格式,还不包括Zcb扩展(它又增加了8种)!但这还没完!同一种格式(比如CI)在不同指令中,相同位位置的立即数编码方式也不同。算下来,几乎每条指令都有一种独特的编码格式!本来完全不需要这样!Thumb和microMIPS都提供了正确的范例,但RISC-V的设计者无视这些,选择了最糟糕的方式。
我们再来看看压缩指令中的立即数——毕竟他们说“立即数的相同位总是来自指令的相同位置”:
- L.LWSP(从栈加载字)的立即数编码顺序是:5 x x x x x 4 3 2 7 6;
- 对应的存储指令C.SWSP的立即数编码顺序是:5 4 3 2 7 6;
- C.LW(从寄存器寻址的内存加载字)的立即数编码顺序是:5 4 3 x x x 2 6;
- 对应的存储指令C.SW用了相同的编码,看来设计团队这次没来得及打乱位顺序;
- 如果要加载字节,用C.LB,它的立即数编码顺序是:0 1;
- 如果要加载半字,用C.LHU,它的立即数编码顺序只有:1;
- 我甚至不想提CM.PUSH和CM.POP,它们的编码复杂到规范用了一整章来解释如何解码——这简直是“简单直观编码”的绝佳反例。
他们声称充分利用了16位指令的所有编码空间,但有些“低垂果实”低得都需要OSHA(美国职业安全与健康管理局)警告了!比如,逻辑移位指令包含6位立即数,理由是64位指令需要这么多,但压缩指令集中已经有很多只支持RV64的编码了——也就是说RV64C和RV32C本来就不兼容。既然如此,RV32C中的所有移位指令为什么要多带一个零位?32位寄存器最多只能移位32位,规范要求这个位必须为零,但没有任何编码使用这个位为1的情况。朋友们,一边说充分利用编码空间,一边让32位寄存器有移位61位的能力,这就是自欺欺人。RV32E更过分,它的很多压缩指令都带有一个额外的位,用来编码不存在的寄存器(x16到x31)。既然RV32E和RV32I的ABI不兼容,也不会共享代码,那用这些额外的位给RV32E增加更有用的编码,本来是个绝佳的主意——这可能就是他们没这么做的原因。
再看看其他压缩指令的立即数编码:
- C.J的位顺序混乱到能通过NIST随机数统计测试:11 4 9 8 10 6 7 3 2 1 5;
- 条件跳转指令C.BEQZ/C.BNEZ的编码和C.J几乎没有共同点,位顺序是:8 4 3 x x x 7 6 2 1 5;
- C.LI的立即数位顺序是:5 x x x x x 4 3 2 1 0——看起来还算正常,但别担心,还有更离谱的;
- 调整栈指针的C.ADDI16SP,立即数位顺序是:9 x x x x 4 6 8 7 5;
- 获取栈变量地址的C.ADDI4SPN,立即数位顺序是:5 4 9 8 7 6 2 3。
完全符合他们承诺的“逻辑、合理、清晰”。
想象一下,如果你是CPU(或模拟器),要解码一条指令。正常的CPU会这样做:看1到3位确定指令格式,再看2到5位确定具体指令,然后就可以执行了。但RISC-V不一样,要复杂得多:首先看最低两位确定指令是2字节还是4字节;如果是2字节,看最高三位确定指令类型——到这里还没问题。但接下来,你发现这条指令有立即数,需要把这些零散的位重新拼起来。然后你又想起,有些寄存器-寄存器指令是用立即数格式编码的,用一个特定的立即数表示这是寄存器-寄存器操作。比如C.NOT用的是0b111101,C.ZEXT.W(如果你的硬件实现了一堆乱七八糟的扩展)用的是0b111100。而microMIPS和Thumb完全不需要这种设计,它们是怎么做到的?这显然是RISC-V设计者出生前就失传的古老秘密。
还没完,压缩指令集中存在冲突编码,取决于实现了哪些扩展。有些扩展组合根本不可能(比如Zcmp和D)。这比之前所有问题都严重,因为它不只是美观或效率问题。尽管积累了几十年的向后兼容包袱,x86都成功避免了同一个字节序列在不同实现中含义不同的陷阱。我再说一遍:那个以编码糟糕著称的架构,在近50年里都保持了语义稳定性,而一群有几十年经验可以借鉴的人设计的全新架构,居然在5年内都没做到这一点。
架构允许未实现的编码在后续版本中成为指令,也允许已实现的指令在后续版本中被移除,但同一个编码在同一架构的不同实现中含义不同(甚至一开始就不同),这完全是疯了!我记得DSM-5(《精神障碍诊断与统计手册》第五版)里有一整章讲这种情况!
这种情况导致的不是清晰的崩溃,而是……任何可能的结果。谁能预测当一个浮点存储指令悄悄变成双寄存器移动或跳转指令时,你的程序会怎么样?这不是B级程序员恐怖电影的剧情,而是真的可能发生!比如指令0xA002,在不同的核心上,它可能是把双精度浮点寄存器0存储到栈偏移0(C.FSDSP f0, 0(sp)),也可能是跳转指令(CM.JT 0)。至少随机跳转可能很快会导致崩溃,让你发现问题。再看指令0xAC66,它可能是把双精度浮点寄存器存储到栈(C.FSDSP f25, 0x18(sp)),也可能是把a0移到s0、a1移到s1(CM.MVA01S s0, s1)——没有控制流变化,只是两个寄存器被破坏,一个值没有存储到栈里。
有些人会说这种混淆不可能发生,因为每个人都知道(或应该知道)自己用的核心是什么。这些人显然没接触过现实世界。很多时候,你会从供应商那里拿到二进制 blob,直接链接到你的微控制器代码里——MEMS传感器供应商就常这么做,把他们“超级保密”的校准算法做成二进制文件。假设我们有这样一个二进制文件,链接到产品代码中,用在支持Zcmp扩展的MCU上,一切正常。后来我们升级到更大的MCU,需要更好的浮点支持来处理新功能,这个新MCU支持双精度浮点。突然,程序开始随机崩溃、行为异常,没有“未定义指令”陷阱,也没有明显的原因。
原来,新核心支持D扩展,因此不能支持Zcmp。但由于编码冲突,我们没有通过正常的“未定义指令”陷阱发现问题,而是花了几周时间调试随机崩溃——随机寄存器被破坏,反汇编器也没用,它会正确地把指令反汇编成两种合法形式之一;调试器帮不上忙,它显示有些指令似乎什么都没做,栈里的一些值很快就被破坏了;我们多次修改中断处理程序,浪费了大量工程师时间,确保上下文保存和恢复正确;MCU供应商的现场应用工程师花了几天时间,帮我们排除电源噪声导致寄存器和栈随机损坏的可能。而且,自从升级MCU后,传感器校准就一直不正常,但传感器通常即使不校准也能勉强工作,没人在随机崩溃中注意到这一点。这种调试场景,才是我一直在好莱坞推销的B级程序员恐怖电影的剧情。
亲爱的RISC-V委员会,你们今天课后留下来,把这句话在白板上写100遍:升级处理器不应该把一个合法的二进制文件变成语义完全不同的另一个合法二进制文件。
七、“配置文件”治标不治本
很多RISC-V支持者会说,可选性不再是问题了,因为RISC-V基金会推出了“配置文件”(profiles)——也就是一份“强制可选扩展”列表(没错,再读一遍),只要实现了这些扩展,就可以声称符合某个配置文件。说白了,整个生态系统不得不发明第二层标准,目的就是告诉大家,第一层标准中哪些部分是必须实现的。如果你发现自己需要建立第二个标准,来明确第一个标准中哪些部分必须实现,那说明第一个标准根本没完成。只有委员会设计的流程,才会导致这种情况。
为了避免针对RISC-V的最低标准编译,或者避免尴尬地为每一种扩展组合构建数千个略有不同的软件包,那些妄想RISC-V桌面能成功的厂商,计划强制使用RVA23配置文件。Ubuntu、红帽和Android都属于这一阵营。有趣的是,在本文发布时推荐的所有可用RISC-V单板计算机中,几乎没有一台符合RVA23标准——StarFive的VisionFive 2、香蕉派BPI-F3、荔枝派4A、香橙派RV2,甚至HiFive Premier P550都不符合。这些设备都无法运行Ubuntu LTS或未来的AOSP版本。更可笑的是,网上一些讨论开始谈论“几乎符合RVA23”的核心。旨在解决碎片化问题的配置文件,居然与目前市面上大部分“RISC-V”硬件不兼容,反而把RISC-V世界分成了“几乎RVA23”和“RVA23之后”两部分,大部分现有设备都属于前者。支持者会说这个配置文件系统是胜利,但我认为这是对“无限可选性”这个错误决策的直接控诉。大型桌面级核心不需要把除法指令、数组寻址和基础SIMD设为可选,而且根本不需要花十年时间才意识到这一点。此外,很多不符合RVA23标准的厂商可能要倒霉了,因为Android和Linux会抛弃它们,不管它们有多“接近”标准。这就是盲目跟风、过早入局的代价——有时只能“几乎”成功。
作为一个有趣的题外话,有一个Linux发行版完全适配这种疯狂的RISC-V生态——Gentoo,因为它的理念就是:从源代码编译每个软件包,充分利用目标CPU的特性和优化。也就是说,RISC-V理想的(或者说唯一可行的)软件分发模式,显然是Gentoo。本文发布后,我要找个承包商做一个蝙蝠信号灯样式的灯笼,上面印着Gentoo的标志,对准RISC-V总部的方向。Gentoo,你被召唤了,一场你从未意识到自己一直在准备的战斗已经打响!
八、根源:“Not Invented Here”综合征
如果你读RISC-V的原理/借口文档,剔除我们前面已经认定是彻头彻尾的谎言、夸大其词,或者对现有架构(以及整个世界)运作方式完全误解的部分,最后会剩下这句话,它可能解释了一切:“最终,我们认为从零开始更符合我们的需求,而不是相应地修改OpenRISC。”在此之前,他们解释说OpenRISC满足了他们的所有需求,除了有延迟槽,而且他们承认已经存在一个没有延迟槽的版本。真相大白了:为什么位分布得像宾果游戏,为什么有数百个冲突的扩展和冲突的编码,为什么明显有用的指令找不到——仅仅因为它们不是“我们自己发明的”。
公平地说,另一个可能的解释是学术圈和资助体系的运作方式。资助项目提案往往会过度简化、夸大其现实适用性和影响力。鉴于很多RISC-V“研究”是在各种资助下完成的,学术圈的激励机制可能产生了影响。由于这个假设无法用现有信息验证,我们就说到这里。
九、RISC-V的未来
这一切并不意味着RISC-V注定失败。正如我所说,我完全预计它会取代8051,成为廉价嵌入式核心的首选——当需要少量逻辑,或者需要给强大的加速器或DMA引擎做简单控制时,就会用到它。就像Linux内核一样,它的价格是关键。ARM要收许可费,而RISC-V规范是免费的,而且(这很重要)有可以免费许可的核心。在性能不重要、价格最重要的场景下,RISC-V会因为价格优势胜出。“足够好”在这种场景下是个很低的门槛,而RISC-V刚好达标。这个市场虽然不耀眼,但很重要,也需要新鲜血液。不过,RISC-V必须清楚,它被选中不是因为优秀,而是因为便宜。这不是对廉价小型核心的侮辱——我曾为各种设计糟糕的ISA写过大量汇编代码。RISC-V确实比PIC和8051有所改进,尽管这算不上什么赞美。
在大型计算领域,我认为可以分为两类:
- 机器学习加速器:这类设备很可能会附带RISC-V核心。这和上面的廉价核心场景很像,因为大部分计算会由专门的硅片模块完成,这些模块优化了每周期的矩阵乘法(MATMUL)数量。RISC-V核心的任务是配置DMA,重新配置这些大型模块,以计算下一层的输出。这种设计的变体可能是RISC-V核心加上一个非常宽的向量引擎,用于机器学习工作负载中常见的逐元素操作。同样,这个核心不需要很花哨,甚至不需要乱序执行,只要向量宽度足够就行。它的整数流水线对性能影响不大,RISC-V的缺陷可以看作是不用许可Cortex-A55的代价。我预计RISC-V会占领这个市场,原因和廉价控制核心一样——这里不需要传统意义上的高性能CPU,足够好的核心就行,而“足够好”的解决方案通常是按“价格升序排序,取第一个”来选择的。
- 桌面和单板计算机:这类设备用于交互式计算、浏览、游戏等“桌面工作”。我不认为RISC-V能在这个市场的高端成为重要玩家。说白了,这个架构的设计就不适合这类场景,正如我上面指出的。此外,这个市场的利润足够支付ARM设计更好的AArch64核心的许可费,还能获得更庞大的软件生态支持。在你喊“开源”之前,请注意,RISC-V规范的开源在这里无关紧要,因为开源规范不会凭空给你一个设计良好的乱序核心,而且如果有人设计出这样的核心,也不会免费提供。开源规范不代表所有实现都是免费的。这个市场的中低端,可能会继续是新RISC-V核心和旧ARM核心混合的局面。