掌握数字逻辑电路的知识、具备Verilog语言编程能力是完成本书中CPU设计的基础 。在以往的教学培训中,我们发现大部分初学者对数字逻辑电路的知识掌握得还不错,毕竟数字逻辑电路是大部分工科生本科阶段必学的一门课程,但是Verilog语言编程水平就参差不齐了。不少学生实验中耗费时间过多就是因为Verilog编程能力不过关。对于这个现象,我们一度很困惑,因为Verilog语言的语法很简单,而且设计CPU时只需要使用其中的一个子集(称为“ 可综合Verilog子集 ”),为什么那么多学生掌握得不好呢?后来,我们想明白了,语言只是一种手段,对编程语言的掌握程度与语言本身无关。比如,C语言的语法很简单,用很短的时间就能学会,但在短时间内学会语法就能成为C语言编程高手吗?当然不可能。高水平的C语言编程人员往往在数据结构和算法方面有良好的基础,并且透彻掌握了编译、汇编、链接等方面的知识,如果编写的软件规模比较大则还需要具有一定的软件工程开发经验。Verilog语言也是一样,要想达到比较高的Verilog编程水平,首先要有电路设计的意识,其次要知道不同的电路如何用Verilog语言来描述,还要知道EDA工具在仿真、综合、实现的时候如何对所编写的Verilog代码进行处理。从这个角度来看,学好Verilog语言确实不容易。
如果你原来只是对Verilog有初步的了解和认识,我们并不指望你学完这一章就能顿悟,你还是需要大量的动手实践才能体会Verilog编程中的各种细节。所以,这一节将以实例讲解为主要形式,先给大家提供一个模仿的基础,再通过后续的多次实践,让大家在动手过程中不断体会,最终学会用Verilog语言写出一个可综合的数字逻辑电路。即使你认为自己的Verilog编程水平已经很不错,我们也建议你参考一下本节中代码的风格。从我们的工程实践经验来看,这种代码风格是合理且高效的。如果你已经有很好的Verilog编程能力,想继续精进,那么我们推荐你看看Stuart Sutherland和Don Mills编写的 Verilog and System Verilog Gotchas : 101 Common Coding Errors and How to Avoid Them ,该书的内容没有丰富的实战经验是写不出来的,你可以从该书中学习到丰富的工程实践知识。
如果想用Verilog语言写出一个真正可以在物理上实现的电路,必须先进行电路设计。进行电路设计必须采用面向硬件电路的设计思维方式。那么,这是怎样一种思维方式呢?
面向硬件电路的设计思维方式的核心实际上就是“ 数据通路(Datapath)+控制逻辑(Control Logic) ”。
首先来说数据通路。我们这里不去探究数据通路的精准概念定义,只是想提醒各位读者,数据通路从直观上来讲是一个空间上的事情。电路是你看得见摸得着的。比如,你看到一块电路板,上面有一个个电子器件以及连接这些器件的导线,这些器件和导线就构成了一个电路。芯片内部也类似,既有器件也有导线,只不过物理尺寸小一些而已。电路中这些器件之间传递的是什么?电磁信号。当我们把这些电磁信号离散化之后,可以进一步认为这些器件之间传递的是数据。数据从一个电路系统的输入端传入,经由各个通路完成各种处理之后,最终从电路系统的输出端传输出来。电路系统中这些数据流经的通路就是数据通路。显然,数据通路是电路系统的重要组成部分。在数字逻辑电路的教材中,描述电路设计方案时经常会画图,这种图不是流程图,而是电路结构图。电路结构图主要是用来刻画电路中的数据通路设计的。
数据通路的一个突出特点是: 如果实现了它,它就一直在那里,不会消失;如果没有实现它,它就一直不存在,也不会凭空出现 。打个比方,家里既要有卧室也要有厨房,你在卧室睡觉的时候,厨房始终存在,不是你要做饭的时候临时出现的。回到电路设计,当你设计一个CPU的ALU时,不是在遇到加法指令时就调用加法器来处理一下,遇到移位指令时就调用移位器来处理一下。ALU要支持加法指令和移位指令的处理,ALU里就既要有加法器,也要有移位器。做加法指令的时候,流入ALU的输入数据会流到加法器,加法器处理完的输出结果流到ALU的输出;做移位指令的时候,流入ALU的输入数据会流到移位器,移位器处理完的输出结果流到ALU的输出。这时候你的设计里面就出现了下面两条路径:“ALU入口→加法器入口→加法器出口→ALU出口”和“ALU入口→移位器入口→移位器出口→ALU出口”。那么问题又来了:如何保证处理加法指令的时候让数据只走加法器的这条路径,处理移位指令的时候让数据只走移位器的这条路径呢?你肯定想到在路径上加开关,是不是?这个思路是对的。不过在CMOS电路上通常很难实现电路的物理开关。怎么办?我们变通一下,用逻辑开关来解决这个问题。我们可以在“加法器出口→ALU出口”和“移位器出口→ALU出口”这个位置加一个功能为“二选一”的选择器。它选择哪一个输出取决于指令的类型:当遇到加法指令时,这个选择器输出选择的是加法器出口传过来的值;当遇到移位指令时,这个选择器输出选择的是移位器出口传过来的值。
进行电路设计时,数据通路是基础,控制逻辑是基于数据通路的 。数据通路就像人体的骨骼、肌肉、呼吸系统、血液循环系统、消化系统,而控制逻辑就像人体的神经系统,它由一个中枢(大脑)连接身体的各个部位。在设计电路系统的时候,只有确定了数据通路,才能在控制逻辑设计中考虑该如何控制这个通路上各个多路选择器的选择信号、各个存储器件写入的使能信号。
再次强调,一定要先想清楚电路设计,再开始写代码。
在考虑一个较为复杂的电路系统的设计方案时,初学者往往不知道从何处下手。我们建议采取“ 自顶向下、模块划分、逐层细化 ”的设计步骤。下面通过例子来简要说明。
假设现在我们要设计一个CPU,先在纸上画一个方框,表示它是CPU。然后想想这个CPU有什么输入和输出。首先,CPU其实是一个同步的有限自动状态机电路,所以它要有时钟和复位输入。其次,CPU要访问内存、I/O,因此需要画上内存访问的接口和I/O访问的接口。我们通过一番深入的分析和思考,知道了CPU内部有取指、译码、执行、访存、写回这几个功能模块,就可以在前面CPU的那个大框里画出五个小框,分别对应这几个模块。这几个模块之间可以用带箭头的线连接。这几个模块还可以细分。比如,译码模块可划分出根据指令码生成控制信号的部分和读寄存器堆的部分。模块要细分到什么程度呢?作者个人的习惯是,当可以一气呵成地把模块对应的框中的内容用HDL语言表述出来的时候,细分工作就可以结束了。可见,划分的粒度和设计者的经验有很大关系。如果各位读者经验还不丰富,那么建议划分的粒度细一些。
上述电路结构设计通常不可能只考虑一遍就获得最佳设计方案,需要反复迭代、多次调整,之后才能动手写代码,正所谓“谋定而后动”。 我们强烈建议读者先把电路结构设计考虑周全,再动手写HDL代码 。在实际工作中,有的人喜欢“加一点,调试一下,再加一点,再调试一下”的增量代码开发方法,这种方法可能适合各类APP软件的开发,但是不大适合电路系统的设计,至少不适合CPU的设计。因为CPU中各部分之间的关系十分紧密,常出现牵一发而动全身的情况,所以往往改动一处就要涉及相关的很多地方的改动。改动的地方越多,出错的概率也越大,进而导致要改动更多的地方,最终陷入一种恶性循环。这样的代码开发过程很容易呈现发散的状态,无法在可控的时间内收敛到一个稳定状态。本书中的CPU设计相对简单,大约用数千行Verilog代码就能实现,测试程序集也很少,读者采用试错的代码开发方法也可能在规定时间内完成,但我们还是希望读者养成“谋定而后动”的设计习惯,把迭代尽可能放到设计方案的制定阶段而不是代码编写阶段。有一个学生曾和我分享他设计CPU的过程,他每个设计过程都要反复思考、权衡,“纠结”好几天,等设计思路理顺、方案成熟了,真正动手写代码也就花了一两个小时。调试过程也很顺利,发现的错误主要是由于笔误引入的语法错误,仅出现一两个逻辑错误真的是因为之前没有想到这种情况。这个同学的代码设计开发过程是我们推荐的,希望各位读者也朝这个方向努力。
设计好电路之后,接下来的工作就是如何用Verilog语言把它描述出来。通常来说,使用Verilog描述电路时可以采用两种编程风格,一种称为 行为描述 ,一种称为 电路描述 。顾名思义,行为描述侧重于对模块行为进行描述,而电路描述则直接对电路的逻辑进行描述。通过实例化一系列逻辑门并将它们连接起来的描述方式就是电路描述风格。由于采用行为描述风格写出来的代码表达直观、编码效率高、易维护,所以我们推荐大家采用行为描述的编程风格。
采用基于行为描述的Verilog编程风格时需要注意一个问题:EDA工具在综合阶段根据Verilog代码推导出的电路行为与设计者的预期是否一致。在这里我们不对这个问题展开分析和讨论,而是采取一种更加接近实战的方式:我们给出CPU设计中常见电路的Verilog行为描述示例,读者可以“照葫芦画瓢”,从模仿入手加以学习。根据我们多年的实践经验,示例中给出的描述风格对于目前主流的EDA工具都是安全适用的。读者可以通过模仿快速掌握Verilog编程。
按照我们上面介绍的面向硬件电路的设计思维方式,采用自上而下、逐层分解的设计方法,一个CPU可以看成由一系列数字逻辑电路的“小积木”搭建起来的。本节会列出CPU设计中需要的各种“积木”所对应的Verilog代码实现供大家模仿借鉴。请大家 务必掌握这些电路的描述方式 。如果你在开始学习时对一些Verilog语法要素不太理解,请自行查阅Verilog语言的教材。通过不断模仿、实践、学习、思考就能逐步掌握数字逻辑电路的Verilog编程。
在本书涉及的CPU设计中,有一些必须遵守的硬性规定,包括:
1) 代码中禁止出现initial语句 。
2) 代码中禁止出现casex、casez 。
3) 代码中禁止用“#”表达电路延迟 。
4) 时钟信号clock只允许出现在always@(posedge clock)语句中 。
5) 代码中所有带复位的触发器,要么全部是同步复位,要么全部是异步复位 。
因为Verilog是用于描述电路的,所以定义了“模块”。与模块相关的语法包括模块的声明和模块的实例化,这类似于C语言里面的函数声明和调用。下面给出模块声明和实例化的代码示例。
上面的代码需要注意下面三点:
1)强烈建议采用示例中的端口声明方式。
2)强烈建议进行模块实例化的时候采用示例中的名字相关的端口赋值方式。
3)如果存在两个模块对接的端口,建议把两边的端口定义成一样的名字或是相似度很高的名字。
至于上面将端口宽度参数化的示例,不要求大家掌握,很多Verilog教材中没有这个例子,写在这里只是方便大家查阅。
这里我们进一步讨论一个初学者不好把握的问题——什么时候将一些逻辑封装到一个模块中使用?其实,这个问题并没有标准答案,而是与设计者的个人经验有很大关系。我们依照自己的经验给出如下建议:
1)如果一个逻辑至少会使用两次,而且这个逻辑采用实例化模块的方式后代码的易读性(代码行数、代码含义)优于直接写逻辑,那么应该封装成模块,如译码器、多路选择器。
2)如果一个逻辑的功能规格十分明确,且与外界的交互信号数量不是很多,那么应该封装成模块,如ALU、regfile等。
3)如果一个现有的模块已经达到数千行代码的规模,可以考虑将其拆分成若干个小模块,比如将一个CPU按照流水线划分成若干个模块。
4)建议一个文件中只包含一个模块且文件和模块同名,便于后期代码维护。
一些常见的基础逻辑门的Verilog描述如下:
这些都是位操作, 请注意是“&”不是“&&”,是“|”不是“||” 。
如果运算符两边的信号都是一位,应该用“&”和“|”还是用“&&”和“||”呢?我们建议的代码风格是:当代码想表述一种逻辑关系,如“条件A1满足且条件A2满足,或者条件B满足”,那么用“&&”和“||”;当代码想表述的是逻辑门,如先行进位加法器的先行进位生成逻辑、乘法器里的华莱士树,那么用“&”和“|”。
这里要强调一下Verilog运算符的优先级,见图3.1。大家要注意,二元操作“+”和“-”的优先级很高,比如assign res[31:0]=a[31:0]+b[0]&&c[0];,表达式右侧的运行结果是一个1位的结果,而不是我们认为的32位的结果,该语句等效于assign res[31:0]=(a[31:0]+b[0])&&c[0];。
图3.1 Verilog运算符的优先级
利用好Verilog运算符的优先级,可以把表达式写得简洁、易于阅读,因为有时候括号太多会增加阅读的难度。不过,如果对运算符的优先级记得不是很清楚,那么赶紧查看优先级的规定,或者老老实实地加括号来区分,毕竟保证正确性是第一位的。
下面给出一个3-8译码器的Verilog描述。
通过上面的例子,你应该能够很容易地模仿写出2-4译码器、4-16译码器。当然,如果碰到6-64、7-128等规格的译码器,用上述写法会比较费事。感兴趣的读者可以自行查阅资料学习用generate语句改善编码效率。
我们希望通过上面的例子,读者能直观体会到行为级描述电路的感觉。在这个3-8译码器的例子中,输出out生成第0位的行为是什么?当输入等于0的时候置1,否则置0。所以用行为级描述风格写出的Verilog代码就是assign out[0]=(in==3'd0);。
举一反三,在设计CPU的过程中,如何写出从32位指令码生成当前指令是add.w指令的信号inst_is_add_w呢?通过查阅指令手册中add.w的指令码定义,我们可知其指令码的第31~15位必须是17'b00000000000100000,其余的位都用来表达寄存器操作数的寄存器号,是变量。因此信号inst_is_add_w可以描述如下:
这种描述方法是不是挺直观的?
我们以8-3编码器的Verilog描述为例,可以采用下面的写法:
采用这种写法,功能是没有问题的,但是实现上会有点冗余。这其实是一个优先级编码器,如果能保证设计输入in永远至多只有一个1,即所谓at-most-one-hot(最多有一位为1)向量,那么可以采用下面的写法:
上述两种代码描述方式在输入有且只有一个1的时候是等效的,但是在输入为全0的时候输出结果并不一样。具体使用时需要注意这一点。
在CPU设计中,编码器逻辑的一个典型应用场景是:根据指令译码后的结果生成ALU模块的操作码alu_op。由于处理器中的译码部件在任何时刻只处理一条指令,因此alu_op的编码过程可以采用后一种编码方式。
多路选择器是CPU设计中一种常用的逻辑,建议大家要熟练掌握。通常,数字逻辑电路的教材中使用与非、或非等逻辑门来描述多路选择器。如果按照这种方式编写多路选择器的Verilog代码,会很麻烦,特别是需要写一个几十选一的多路选择器的时候。那么应该如何解决这类问题呢?
我们先来看选择信号select还没有译码的情况。这里特意假定被选择的输入个数不是2的幂次方,同时假定当select输入值超过可选择范围时输出全0。Verilog描述如下:
同case语句相比,上面这种表达方式简明直观。而且,case语句还有一个很大的缺点,就是明明是个组合逻辑,却要将输出的变量声明成reg型。所以,我们没有必要用case语句来编写多路选择器。
不过,上面的例子引入了不必要的优先级关系,例如sel为1的时候自然不为0。所以,这个多路选择器可以采用下面的写法:
回顾一下前面译码器的写法,会发现上面的写法其实是把译码器和基于译码后向量的多路选择器合并在一起写出来的。
若采用上面这种写法,一定不要忘了写“{8{}}”中的8,否则结果out只有第0位是正确的,[7:1]位都会变为0。要特别小心这种笔误,因为它不是语法错,调试工具不会报出这类错误。
如果select信号已经是译码后的位向量形式,也很容易写出来,写法如下:
我们用一个简单LoongArch32 CPU中的ALU作为一个较复杂的组合逻辑设计的例子。这个ALU除了要完成加减运算、比较运算外,还要完成移位运算、逻辑运算。那么它的电路应该如何设计呢?
通过设计分析(具体过程见4.3.1节的描述)可知,ALU内部包含做加减操作、比较操作、移位操作和逻辑操作的逻辑,ALU的输入同时传输给这些逻辑,各逻辑同时运算,最后通过一个多路选择电路将所需的结果选择出来作为ALU的输出。整个电路的结构如图3.2所示。
图3.2 简单LoongArch32 CPU中ALU的电路结构
整个ALU的控制信号alu_op采用12位独热码形式。其Verilog的描述示例如下:
上面的示例代码,希望读者关注如下4点:
1) 写代码时给变量起个好名字非常重要,既不要太长也不要太短,还要尽可能贴切地表达它的语义 。好代码的特点之一就是“代码即注释”。有时候,一些看似无用的代码,如上面示例中将alu_op逐位赋值给op_XXX变量,会让代码的可读性大幅度提升。
2) 代码中的空格、对齐、空行如同文章中的标点和段落 ,也能够大幅度提升代码的可读性。
3) 可以直接用“+”运算符来描述加法器,用“<<”“>>”和“>>>”运算符来描述移位器 。这是因为现今的EDA综合工具已经相当聪明了,能够从这些运算符推导出设计者需要加法器、移位器这类较为复杂的电路,然后根据实现约束从自身携带的IP库中挑选出合适的电路嵌入到你的设计中。
4)在Verilog中, “>>”只被当作逻辑右移处理,所以算术右移操作需要特殊考虑 。上面的例子直接使用了“$signed”内置函数和“>>>”运算符来完成算术右移。
触发器是我们在CPU设计中使用最为频繁的时序器件,大家务必掌握触发器的Verilog描述。
1)对于普通的上跳沿触发的D触发器,其Verilog描述如下:
D触发器的时序特性如图3.3所示。
图3.3 D触发器的时序特性
2)带同步复位的D触发器的Verilog描述有两种写法。
● 写法一
● 写法二
采用上述两种写法实现的电路功能是一样的,但我们 推荐采用写法一这种更行为化的风格 。因为rst清0操作通常具有最高优先级,采用if…else…的写法可以使人在阅读代码的时候一目了然,不容易犯错。
3)带写使能端的D触发器的Verilog描述也有两种写法。
● 写法一
● 写法二
采用上述两种写法实现的电路功能是一样的。我们 推荐采用写法一 ,原因与前面一样,这种写法看起来更直观,而且会带来一些额外的好处——大多数综合工具只为采用第一种风格的代码自动插入门控时钟或者引入带门控的触发器。
这里的例子都是单比特的触发器,读者可以自行将其推广到多比特的情况。
通俗地说,寄存器堆是采用二维组织形式的“一堆寄存器”。在一个单发射五级流水的简单LoongArch32 CPU中,GR对应一个32项、每项32位的寄存器堆。为了支持流水,该寄存器堆要具备每周期读出两个32位数、写入一个32位数的能力。同时这个寄存器堆还有一个特殊之处,即0号寄存器恒为0。其对应的Verilog代码如下:
在上面的例子中,rf[waddr]、rf[raddr1]、rf[raddr2]意味着需要由综合工具推导出针对写地址和读地址的译码电路。实际上,更具体的描述如下所示。这里我们假设decoder_5_32是一个5-32译码器的模块。
上面这种将寄存器堆写和读的译码逻辑与选择逻辑直接表达出来的写法只是为了加深读者对rf[addr]这种写法的理解,尽管代码很短,但是其对应的电路包含很多逻辑。在平时的设计中,我们还是推荐大家采用rf[addr]这种写法。
请读者思考一个问题,当写有效(we=1)且写地址和读地址相同时,此时读出的结果是寄存器中的旧值还是新写入的值?
我们这里说的RAM指的是SRAM,通常用来在CPU中实现指令存储器、数据存储器。它在逻辑行为方面与前述的寄存器堆相似,但是两者的底层实现存在差异。尽管Vivado等FPGA综合工具已支持可推导出RAM的Verilog代码形式,但本书中仍采用更为通用的实例化RAM IP的方式。这部分内容读者将通过本章的实践任务学习掌握,这里就不展开了。
首先要说明的是,流水线电路纯粹是一个数字电路的概念,大家不要认为只有处理器电路中才有流水线。
我们先来看如何设计一个无阻塞的3级流水线电路。这个流水线的电路结构如图3.4所示,其中pipe1_data、pipe2_data、pipe3_data都是触发器,里面存储着各级流水的数据。
图3.4 无阻塞流水线电路结构
下面是这个流水线电路的Verilog代码示例。可以看到,无阻塞流水线其实就是依次串接起来的多组触发器。
无阻塞的流水线通常对应理想情况,很多时候流水线会被阻塞。这就意味着一旦后面的流水级被阻塞,前面的流水级也立刻被阻塞。因为此时后面的流水级不通,系统没有办法接收新数据,所以前面的流水级必须把原有的数据保持在本流水级中(即被阻塞)。若前一级流水级仍然把数据送往后一级流水级,数据就会丢失。除非整个系统在上层协议栈中有数据丢失的检测和重传机制,否则就需要底层的流水线电路具备在出现阻塞的情况下避免数据丢失的机制。
从上面的分析来看,为了使流水线能够应对阻塞,我们需要设法把数据保持在一级流水级。回想一下我们在前面介绍的带使能的触发器的时序行为特征,只要触发器的写使能无效,即使触发器的D输入上的数值发生变化,触发器存储的内容也保持不变。寄存器堆、RAM也具有相似的特性,即只要它的写使能信号保持无效,它内部存储的数据就保持不变。可见,要使流水线应对阻塞的情况,核心在于控制好各级流水线缓存的写使能信号。
那么如何控制这些写使能信号呢?这里给出两套设计策略。我们以一条人工生产流水线为例来说明。
● 策略一 :配备一个生产流水线监管人员,他能同时看到各流水级的状态,并对所有流水级下达命令。一旦在某一时刻该监管人员发现某个流水级出现阻塞,就向这一流水级之前的所有流水级发出下一时刻停止向后传送的命令。
● 策略二 :为每个流水线分配一个监管人员,他和前后流水级的监管人员相互沟通情况,决定下一时刻是否向后传送东西。就某一流水级而言,它会向其后一级发出一个“下一时刻我有东西传送给你”的请求,同时向其前一级发出“下一时刻我可以接收你传送来的东西”的反馈。由于各流水级串联起来环环相扣,因此某一流水级会同时收到后一级传来的下一时刻是否可以接收东西的反馈信息,以及前一级传来的下一时刻它是否有东西传递过来的请求。如果某一流水级当前时刻有东西并想在下一时刻传给后一流水级,但是后一流水级说它下一时刻无法接收传来的东西,那么该流水级在下一时刻要继续持有当前时刻的东西,即产生阻塞。
显然,两种策略都可以完成任务。我们接下来介绍的电路设计采用的是第二种设计策略。
我们设计的流水线的电路结构如图3.5所示。图中的箭头分别表示流水级之间交互并决定本级流水线缓存写使能控制的逻辑和信号。
图3.5 有阻塞流水线的电路结构
下面是这个流水线电路的Verilog代码示例。
上面代码中的pipeX_valid称为第 X 级流水级的有效位,它采用触发器实现。其值为1表示第 X 级流水上当前时钟周期存在有效数据,其值为0表示第 X 级流水上当前时钟周期无有效数据。定义流水级有效位的好处是,清空流水线的时候不用把各级流水线data域的值都置为无效值,只需要将流水级的valid位置0就可以了,从而节约逻辑资源。但是需要注意的是,此时根据各级流水线的data域信息产生控制信号时,不要忘记看这一级的valid信号是否有效。
pipeX_allowin信号是从第 X 级传递给第 X 级的前一级的信号。它的值为1表示下一时钟周期第 X 级流水级可以更新为当前时钟周期第 X 级的前一级流水级的数据,值为0则表示下一时钟周期第 X 级流水级不能接收新数据。
pipeX_ready_go信号描述当前时钟周期第 X 级处理任务的完成状态。它的值为1表示数据在第 X 级的处理任务已完成,可以传递到第 X 级的后一级流水级。比如,CPU的执行流水级用迭代方式运算除法,需要多个时钟周期能完成,那么在执行流水级的除法没有完成前,执行流水级的ready_go信号将一直为0。
pipeX_to_pipeY_valid信号是从第 X 级传递给第 X 级的后一级的信号。它的值为1表示第 X 级流水级的数据希望在下一时钟周期进入到第 X 级的后一级流水级。
理解了上述信号的含义后,读者可自行结合流水线时空图推演整个电路是如何实现对于流水线阻塞的控制的。