CPU设计的新思路
译者序
最近不是RISC-V很火嘛,我就随一波大流,学学CPU的设计。虽然已经会了SpinalHDL这门高级点的开发语言,但看起VexRiscv这个开源软核的源码还是有种混沌邪恶的感觉。CPU说白了就取指、译码、执行这些东西分别实现,然后拼成一个流水线。我按照这种思路去看VexRiscv源码的时候,看到了流水线,但完全没发现数据是怎么流动的,就像是玄学一样。然后我看到了这篇博客才明白:原来VexRiscv使用了新的思路来设计CPU,这里的新是指代码写法上的新,而不是微架构上的新,这种新思路能带来开发效率的极大提升,仅此一份的新。读完这篇博客后,我直呼内行!遂翻译过来为SpinalHDL的推广事业添砖加瓦。
原文链接:
https://tomverbeure.github.io/rtl/2018/12/06/The-VexRiscV-CPU-A-New-Way-To-Design.html
原文标题:The VexRiscv CPU - A New Way to Design
阅读建议:听说过SpinalHDL就行;有RTL开发经验;不用写过CPU但至少对CPU的各个名词要了解。
第一次翻译东西,凑合着看吧。原文读起来更流畅建议看,不知道为啥我翻译过来就感觉有点啰嗦。文章需要点耐心结合代码慢慢看,很值得一读。
目录
·简介
·设计CPU的传统方法
·SpinalHDL 简单回顾
·VexRiscv 流水线插件架构
·桶移位器与乘法器插件
·一些好家伙
·缺点
·结论
简介
在之前的博客,我简单地提到了一下VexRiscv(https://github.com/SpinalHDL/VexRiscv),一个完全用SpinalHDL实现的RISC-V CPU,它的作者Charles Papon同时也是SpinalHDL的创造者。
在之前,我为了比较不同的RISC-V CPU,创建了rv32soc项目。同时我用VexRiscv作为一个基准来和我自己的RISC-V CPU(MR1)作比较。MR1在任何可能的指标上都比不过VexRiscv。
在这一过程中,我花了一些时间试图搞明白CPU的内部结构及其设计思路。但令人沮丧的是我竟然没看懂。VexRiscv除了性能和功能过剩以外,它的实现方式还极大地吊打了不灵活的传统RTL语言(Verilog、SystemVerilog、VHDL)。
VexRiscv的代码证明了我们可以写出和优化过的Verilog一样高效、配置极其便利的RTL。所以VexRiscv获得RISC-V峰会软核大赛一等奖也是实至名归。
话说:”意志薄弱“的人是无法理解VexRiscv的代码库的。它利用了所有传统面对对象语言(Scala)的特性创造了这个魔法般的东西。由于它并没有遵循CPU设计的标准实践流程,使得我花了一段时间才真正理解了它。
这篇文章的目的是解释为什么VexRiscv的设计如此重要和新颖。
设计CPU的传统方法
为了更好地理解VexRiscv设计的创新之处,这里用和它类型相同的传统CPU和它作比较,即:标准流水线、顺序执行、单发射等。能找到许多这样的开源CPU。
既然你看到了这个文章,说明你大概率了解传统RISC CPU的标准部分:一条指令是分为多个流水线阶段(stage)进行处理的。每个stage只负责整个操作的一个部分。stage越多,每个stage的工作量就越少,进而能够减少每个stage的逻辑深度,最终达到提高时钟频率的效果。
一般的RISC CPU有5个stage,不过你也能见到2到11个以上stage的CPU。
毫无疑问,这样的CPU设计需要匹配这种流水线架构:如果CPU有取指stage,那么RTL就一定有一个对应的取指Verilog模块。
pulp platform的开源RISC-V CPU RI5CY是上述传统方法的典型。我们看看它的源码目录,可以找到一堆我们意料之中的功能模块:riscv_if_stage.sv,riscv_id.sv,riscv_ex_stage.sv等等。
所有这些传统的功能模块在riscv_core.sv中紧密地缠绕在一起。
这好吗?这不好。
虽然这并不算一个特别差的实现方式,大家都是这样做的,只有一个小问题:所有的模块是按照流水线阶段划分的,而不是按功能。
就拿一个乘法器举例。一个MUL指令在decoder 模块(idstage)中进行译码。它的结果是mul_operator_ex_o和其它一些诸如操作数和控制信号之类的信号。
在risc_core.sv中,这些信号从id stage中出来后,改名成 mult_operator_i送进ex stage。在ex stage中,信号随后被路由至riscv_mult,在这里才真正发生了乘法的计算,最后得到result_o 这一信号作为输出。
在riscv_ex.sv 的最后,连接到mult_result 的是一个多路复用器,它用于选择哪个ALU的结果被送入regfile_alu_wdata_fw_o 这个信号。这个信号再被传回id 模块用于把结果写回寄存器和转发(forward)逻辑。
但是可以看到,我们为了添加这一个小功能,把这个乘法操作分散到了各个不同的文件中。
本例如下图,Decode和Execute所属的文件需改动
FPGA通常有硬件乘法器单元可以支持很高的时钟频率。但是这种频率只有在乘法器的输入和输出都被寄存器包围的情况下才能达到:输入输出各有一个寄存器。
如果我们既想维持原有的每时钟一个指令的吞吐量,又想把乘法执行分为两个时钟周期,我们需要做如下的改动:
此外,这些乘法单元通常是18x18 bits规格的。如果我们想实现32位的CPU所需要的32x32乘法器,我们需要用多个18x18的乘法器进行拼接。
达到期望频率通常意味着需要更多的寄存器和stage,就像下图一样:
在上面的5阶流水线中,我们滥用了MEMORY和WRITEBACK这两个阶段来截断时序路径从而提高CPU频率。我们同样地需要修改FETCH和DECODE阶段的相关检测逻辑。
这种事情我们做的越多,我们需要动的文件就越多,需要添加的端口就越多。
如果你脑子里已经有很清晰、很明确的架构,这些改动不是什么难事。但如果你想让一些东西高度可配置的话,这种设计方法很快就变成了维护上的噩梦:倘若CPU的时钟频率要求不高,你尚可以把所有逻辑都放到EXECUTE阶段,顺便还省了资源。但要做快速的CPU,只能使用这种费资源的方法。
除此之外,存在未曾设想的道路吗?
SpinalHDL简单回顾
我知道你懒得看我之前写的SpinalHDL博客,所以我就只重复一下最重要的一些点。
首先它是一个Scala库,有一些能够相互连接的硬件原语。
拿传统语言Verilog来说,它工作流程是这样的:
Verilog描述的硬件 -> Verilog解释器 -> 硬件原语
它描述硬件的能力完全由Verilog的语法特性决定。如果该语言不支持确定的选项,你必须搞一些奇技淫巧来实现一些东西。
就拿模块端口的简单例子来说:你的CPU有一个可选的JTAG调试接口。你在某些配置的情况下需要它,而另一些情况不需要。
Verilog的语法并不支持可选端口:它的if generate 语法不能作用在端口上。所以你要么在不需要的时候直接忽略JTAG端口不连它,要么用下面这种丑陋的形式实现它:
module cpu(
...
`ifdef JTAG
input jtag_tck,
input jtag_tms,
input jtag_tdi,
output jtag_tdo,
`endif
...
在SpinalHDL中,流程是这样的:
Scala和硬件原语混合编程 -> 执行程序 -> 生成硬件原语
这些硬件原语连线十分灵活,你可以发挥想象用各种方法实现。
如果你在某个配置下不需要用到JTAG端口,只要不调用创建JTAG端口的Scala函数即可:
可以看看我的MR1 RISC-V核,就是这样写的:
val rvfi = if (config.hasFormal) RVFI(config) else null
为了正确地检验CPU核,需要一个包含一捆RVFI信号的额外端口。但是这个端口在综合核仿真的时候用不上。
if (config.hasFormal) ... else 是标准的Scala代码。RVFI(config) 是SpinalHDL的Bundle类,用于描述一堆信号,类似于SystemVerilog的接口。
如果觉得这样组织硬件很别扭不方便,那一定是你想象力还不够。毕竟Verilog的限制还更多。VexRiscv核把这种写法运用到了极致。
VexRiscv流水线插件架构
VexRiscv围绕流水线的各阶段来构建,每个功能对象可以以插件的形式随意添加。
我们从generic Pipeline开始,它有stages和plugins属性。
trait Pipeline {
type T <: Pipeline
val plugins = ArrayBuffer[Plugin[T]]()
var stages = ArrayBuffer[Stage]()
...
这段代码和CPU没有任何关系。
一个Pipeline对象有很多个stage。叫做Stageable 的元素可以被一个stage传递到下一个stage。插件会被整合进整个流水线,它们可以给每个stage添加逻辑,可以使用其中一个Stageable 作为流水线上某个特定stage的输入,可以插入新的Stageable 到流水线的下一个stage。
Pipeline对象会自动负责管理这些stageable 。如果一个插件在EXECUTE阶段需要stagealbe OP_A,而OP_A是DECODE阶段由其它插件插入流水线的,随后Pipline对象会确保OP_A沿着流水线传递到DECODE阶段。
如果插件在EXECUTE阶段产生了一个stageable RESULT,随后需要将它导入WRITEBACK阶段,流水线阶段会再一次确保它能被送过去,无论在WRITEBACK和EXECUTE之间有多少中间stage。
在VexRiscv中,流水线阶段的描述如下:
class VexRiscv(val config : VexRiscvConfig) extends Component with Pipeline{
type T = VexRiscv
import config._
//Define stages
def newStage(): Stage = { val s = new Stage; stages += s; s }
val decode = newStage()
val execute = newStage()
val memory = ifGen(config.withMemoryStage) (newStage())
val writeBack = ifGen(config.withWriteBackStage) (newStage())
...
可以看出来,VexRiscv是Component(SpinalHDL的一个原语,等价于一个Verilog的module)的一个带有Pipeline字段的子类。
CPU中一定有译码和执行这两个stage,而访存和写回stage是可选的,主要看你期望的配置。stage的顺序由newStage()的调用顺序决定。
一旦定义好了CPU的各个stage,就是时候通过插件来向流水线添加逻辑了!
关于CPU的各个方面的文件都统一地放在了一个文件夹下!
接下来让我们凑近一点看。
桶移位器与乘法器插件
VexRiscv有多种选项来实现RISV-V的左移和右移指令。它们都在ShiftPlugins.scala中实现。
LightShifterPlugin 实现了一个交互式的移位器,每次能左移或右移1 bit,主要用于低性能少资源的场景。
FullBarrelShifterPlugin 实现了一个典型的桶移位器,分为三个操作:
·如果是左移指令,把输入反转
·根据变量的值确定移位的步长进行右移
·如果是左移指令,把输出反转
class FullBarrelShifterPlugin(earlyInjection : Boolean = false) extends Plugin[VexRiscv]{
...
在插件的顶层可以看到earlyInjection 这个配置选项:它决定了是否把最后一次反转操作和前两个操作放在同一个流水线stage,否则就把它放在下一个stage。把它放在下一个stage会增加面积(你需要通过流水线传输中间值,这要求额外的触发器),但这样做减少了逻辑深度。由于桶移位器不能很好的映射到FPGA的LUT上,这样做能很好地减少时序路径。
来看一下它是怎么实现的,让我们从initial reverse and shift开始看:
execute plug new Area{
import execute._
val amplitude = input(SRC2)(4 downto 0).asUInt
val reversed = Mux(input(SHIFT_CTRL) === ShiftCtrlEnum.SLL, Reverse(input(SRC1)), input(SRC1))
insert(SHIFT_RIGHT) := (Cat(input(SHIFT_CTRL) === ShiftCtrlEnum.SRA & reversed.msb, reversed).asSInt >> amplitude)(31 downto 0).asBits
}
第一步,这个代码意思是我们要把这一堆逻辑(Area里面的一堆)放在EXECUTE阶段。
移位的幅度(amplitude)通过input(SRC)从流水线取得 ,移位的方向由input(SHIFT_CTRL)确定。与此同时,结果以insert(SHIFT_RIGHT) 的方式插回流水线。
在第二部分,对SHIFT_RIGHT 的结果进行反转操作如下:
val injectionStage = if(earlyInjection) execute else memory
injectionStage plug new Area{
import injectionStage._
switch(input(SHIFT_CTRL)){
is(ShiftCtrlEnum.SLL){
output(REGFILE_WRITE_DATA) := Reverse(input(SHIFT_RIGHT))
}
is(ShiftCtrlEnum.SRL,ShiftCtrlEnum.SRA){
output(REGFILE_WRITE_DATA) := input(SHIFT_RIGHT)
}
}
}
第一行是关键:基于配置参数earlyInjection,if 语句决定接下来的一团逻辑是放在EXECUTE阶段还是MEMORY阶段!
梅开二度,input(SHIFT_CTRL) 被从流水线中取出,决定接下来要不要反转。input(SHIFT_RIGHT) 用于生成即将被插入到流水线的REGFILE_WRITE_DATA 。
这边有个很优雅的部分:如果earlyInjection 为真,input(SHIFT_RIGHT) 将会被翻译成纯组合逻辑的赋值操作,与它之前的逻辑组合在一起。然而如果earlyInjection 为假,Pipeline对象会根据需求正确地插入个带寄存器的stage。
乘法器在MulPlugin.scala中也是类似的实现:
4-段乘法器在EXECUTE阶段实现,在EXECUTE阶段实现。这些中间结果在MEMORY阶段相加。最后的结果在WRITEBACK阶段创建。
概念上的代码组织看起来像这样:
所有的乘法器代码都在逻辑上组织在一个文件中。这超方便用另一个不同的实现来替换当前实现,或者移除它。
一些好家伙
你可能注意到,不管是移位插件还是乘法插件都以output(REGFILE_WRITE_DATA) 作为结尾。没毛病,它会以一种简单的方式被自动处理。默认情况下,一个流水线stage只会简单地传递一个来自前一个stage的stageable。如果插件认为有必要,它的值会被重写。如果很多的插件在单个stage里对同一个stageable重写也是没问题的,因为在任一个stage里仅有一条指令是活跃的,自然而然地,插件在活跃的时候就只会对一个stageable重写。
这里的译码器,DecodeSimplePlugin,就当是个魔法好了。它包含了逻辑优化器以生成最优的译码逻辑。
riscv-formal 支持对生成的CPU进行形式验证,但我还没试过。
你可以学学我,多花点心思去探索CPU的各个方面。
缺点
我认为主要有两个缺点:
对于传统的RTL设计者,学习曲线过于陡峭。不仅因为Scala本身内容丰富,而且SpinalHDL/VexRiscv又极大运用了这种丰富性(译者注:其实还好啦,谁还没学过点软件编程语言,个人感觉被Verilog恶心过的同时又对函数式语言有了解的话,SpinalHDL是很快上手的)。我的学习方法是循序渐进地边学边用:首先把它当成Verilog的替换工具来取缔一些冗长啰嗦的代码,然后再逐渐引入一些复杂的概念。
但使用VexRiscv最大的问题是它产生的Verilog只有一个展平的文件,使得它很难debug和理解。(译者注:手写的Verilog你过段时间也很难理解。。。据说SpinalHDL的debug比Chisel好多了)
结论
无论你是否将要使用VexRiscv和SpinalHDL,我认为它的实现思路和完全和传统的RTL设计泾渭分明。我认为过一遍它的代码搞明白它是怎么工作的非常有意思和身心愉悦。
VexRiscv不仅仅只是设计思路上的惊艳,它同时还有非常高的性能和很低的资源使用,还有无数的配置选项。高配的1.4 Dhrystone/MH核心和低配的0.5 Dhrystone/MHz核心仅有几行的配置文件上的区别。
就算你不想接受SpinalHDL,你依然可以单纯地用它生成Verilog代码添加进你的工程。我在开始学习SpinalHDL之前就是这么干的。
尽管如此,自从我切换到SpianlHDL来开发我的项目后,我的取舍很明了:除非有更好的理由让我用我自己的MR1核,否则我肯定用VexRiscv啊。
译者:黄衍林
来源:https://zhuanlan.zhihu.com/p/404224698
本文内容仅代表作者观点,不代表平台观点。
如有任何异议,欢迎联系我们。
2021年的第一场雪!英特尔2020年Q4财报解读
科普:简述DES与AES的区别
博文速递:Issues in Physical Design
复杂芯片设计验证之概述