设计RISC-V CPU第 2 部分:成功执行(某些)指令
作者 | Hannah McLaughlin译者 | Sambodhi策划 | 赵钰莹本文最初发表于作者个人网站,经原作者 Hannah McLaughlin 授权,InfoQ 中文站翻译并分享。
本系列的前一篇文章 (https://www.infoq.cn/article/ImeTgCPtCCzpiShdHBv6?fileGuid=JbpR9Co2YWYMff95) 基本上解释了什么是 FPGA,并提供了“hello world”的 nMigen 示例。在本文中,我将详细介绍我的 CPU 当前设计,并回顾我在设计期间所犯的各种错误。就像之前的文章一样,我的目标主要是那些刚接触硬件设计的软件工程师,但是我希望对 nMigen、RISC-V 或者 CPU 设计有兴趣的朋友也能有兴趣。
但愿在此过程中我所犯下的错误可以作为思考这些问题的有益框架:
数字逻辑设计与软件设计有何本质区别?
数字逻辑设计和软件设计有何相似之处?
你可以在这里 (https://github.com/lochsh/riscy-boi/tree/33229b5fdcee90cfdf776ccf925f560f0fa5ce82?fileGuid=JbpR9Co2YWYMff95) 查看 CPU 代码,或者在这里 (https://github.com/lochsh/riscy-boi?fileGuid=JbpR9Co2YWYMff95) 查看最新版本。
RISC-V 简介RISC-V(发音为 risk five)是一个开放的标准指令集架构(instruction set architecture ,ISA)。“RISC”意为“精简指令集计算机”(reduced instruction set compute),广义上说,指 ISA 优先处理简单指令。与此相反,CISC(complex instruction set computer,复杂指令集计算机) 的 ISA 是经过优化的,以尽可能少的指令来执行操作,因此其指令往往更加复杂。ARM 架构是 RISC;x86 相关架构是 CISC。
一般工业上,ISA 是有专利的,因此要实施 ISA,你需要从供应商那里获得(昂贵的)许可证。商业性 ISA 的文档记录很少,即使签署了这类许可协议后,也不一定能了解到设计细节背后的动机。
摘自 RISC-V 规范:
我们定义 RISC-V 的目标包括:
完全开放的 ISA,学术界和工业界均可免费使用。
真正的 ISA 不只适合模拟或二进制转换,还适合直接的本地硬件实现。
ISA 可以避免“过度架构化”的特定微架构风格,但是允许任何这种风格的有效实现。
另外,许多商业架构非常复杂,并受到向后兼容性的限制。RISC-V 是一个全新的起点!
ISA 并没有详细说明如何设计 CPU,它仅仅定义了 CPU 的抽象模型,主要是定义 CPU 必须支持哪些指令,包括:
对指令进行编码(也就是如何构建 CPU 运行的机器码)。
寄存器(非常快、非常小的存储位置,由 CPU 直接访问)。
怎样才能制造出能够满足这些需求的 CPU,就要看我们的了。
简要说明(如果你对 CPU 没有意见,可略过)我要设计一个单级 CPU;也就是,每个时钟周期只退一条指令,并且没有流水线。为了达到最大的效率,CPU 通常具有流水线。想要避免这种情况,可以让设计更简单,更适合我的学习。或许这样做会使设计的其他方面变得复杂(例如程序计数器有 3 个输出),但我确实发现,当我只需要考虑这个时钟周期和下一个时钟周期时,我会更容易记住设计。在下一个实现加载指令的博文中,我可能会更详细地讨论这个问题。对于这些如何在一个周期内工作,我有一个计划,但是它们的确是一个特别的挑战,也许我会因此改变我的设计。
设计 CPURISC-V 定义了各种 ISA 模块,我将实现 RV32I,它是一个基本的 32 位整数指令集。
要设计我的 CPU,我首先研究了 JAL(跳转链接指令)和 ADDI(添加立即数指令),并绘制了解码这些指令所需硬件的框图。假如你不习惯思考机器码如何编码,我想 ADDI 指令更容易掌握,那么我们就从它入手。假如你对这个完全不熟悉,你可能会喜欢我的 ARM 汇编介绍。
解码 ADDI
在寄存器 rs1 中,ADDI 增加了经过符号扩展的 12 位立即数。
现在我们来分析一下,想象一下我们在解码以下指令:
立即数类似于源代码中的文字值;该值本身是在指令中编码的,而非从寄存器中检索。
对于这里列出的所有指令,操作码字段具有相同的值。
读完操作码后,我们就知道 funct3 字段是第 12~14 位(3 表示 3 位),它对这是否是 ADDI 指令(或 SLTI、ANDI 和 c)进行编码。
所以我们必须:
检索存储在寄存器 rs1 中的值。
因此,我们的寄存器文件需要一个输入来选择该寄存器的读数,以及一个输出来从寄存器读取数据。
将其添加到立即值中。
算术逻辑单元(ALU)是非常有用的。
将结果存储在寄存器 rd 中。
寄存器文件也需要写入选择和写入数据输入。
此外,还需要使用符号扩展:这只需要取一个比(本例中的)32 位更窄的数字,并填入其余的位,以便正确地执行二补数算法。在下图中,我将不包括这一过程。
这一切都表明,我们需要一种方法来检索指令本身,并把它传递给指令解码器。程序计数器告诉指令存储器从哪个地址检索指令。
我使用青色粗线表示数据流,洋红色细线表示控制流。
JAL我们来尝试对 JAL 做同样的事情。
跳转链接(JAL)指令……立即将一个有符号的偏移量编码为 2 个字节的倍数。在符号扩展之后,偏移量被添加到pc,形成跳转目标地址。……JAL 在期存器 rd 中存储了跳转(pc+4)后的指令地址。
这里面有很多东西,尤其是如何你不熟悉机器码。注意到上面的程序计数器设置下一个指令将被执行。一般来说,程序计数器只是在每个周期内向下一个地址递增,这就是我们继续执行程序的方式!但是,例如我们的程序调用存储在内存中其他地方的子程序;我们需要一种跳跃到子程序地址的方法。也就是我们希望的无限循环(在嵌入式软件中无处不在!);我们需要一种方法来跳转回到循环开始时的地址。JAL 考虑了这些情况。
JAL 的“链接”部分是将下一条指令存储在目标寄存器中的部分。当跳转用于执行一个子程序时,这很方便:一旦子程序完成,我们就可以跳回那个存储的指令地址。
下面是 JAL 的编码:
注:
该操作码唯一地定义了 JAL 指令(其他指令不共享该操作码)。
在得到偏移量时,我们需要对立即数进行解混洗(unshuffle)。
不对立即数的 LSB 进行编码,因为它必须是 0:只支持 2 字节倍数的偏移。
解混洗给我的代码带来了一些麻烦,因此它没有成为下面框图的一部分,这使我感到很有趣。但是它是指令解码器内部的一部分,而非其接口,我们可以根据这些图来判断。在 RISC-V 中,洗牌后的位看似荒谬,但是选择它们是为了与其他指令格式最大程度的重叠。
将 ADDI 和 JAL 结合起来
接下来的挑战是绘制实现 ADDI 和 JAL 的框图。最明显的问题是:在两种情况下, ALU 的输入和输出布线都不一样。我们需要一些逻辑块,这些逻辑块可以根据控制信号来选择:一个多路复用器。
同时,我们也需要一种方法,让程序计数器知道下一个指令的地址是来自于设定的地址,还是来自于当前地址的递增。
现在我的设计就是这样的(不包括我已经有的一些东西,因为我知道以后会需要它们,例如寄存器文件中的两个读取选择 / 数据信号):
实施设计如前所述,我在前一篇文章中提到过,我使用 nMigen 实现了设计。因为我现在设计的是单级 CPU,所以更多的是组合式的,而不是同步式,因为我不需要流水线所需的额外状态。它可能意味着我的设计无法快速运行,但我并不关心这个。
我并不认为我在本文中发布的全部实现的源代码都是有用的,但是我将在这里包含一些代码,以说明我所犯的错误和我从中学到的东西。
错误 1:按顺序思考逻辑电路当我弄不清楚同步更新什么时候生效时,我一开始就错误实现了程序计数器。由于不了解pc和pc_next之间的区别,所以一开始我只有pc和pc_inc输出。我花了一些时间去适应思考整个逻辑电路“同时发生”,而不是像我写软件时那样按顺序思考。所以才会弄糊涂。用这种方式正确地构思你的电路是关键,而且如果你习惯编写软件,这也是个挑战。
"""Program Counter"""
import nmigen as nm
INSTR_BYTES = 4
class ProgramCounter(nm.Elaboratable):
"""
Program Counter
* load (in): low to increment, high to load an address
* input_address (in): the input used when loading an address
* pc (out): the address of the instruction being executed this clock cycle
* pc_next (out): the address of the instruction being executed next clock
cycle
* pc_inc (out): the address after that of the instruction being executed
this clock cycle
"""
def __init__(self, width=32):
self.load = nm.Signal()
self.input_address = nm.Signal(width)
self.pc = nm.Signal(width)
self.pc_next = nm.Signal(width)
self.pc_inc = nm.Signal(width)
def elaborate(self, _):
m = nm.Module()
m.d.comb += self.pc_inc.eq(self.pc + INSTR_BYTES)
m.d.sync += self.pc.eq(self.pc_next)
with m.If(self.load):
m.d.comb += self.pc_next.eq(self.input_address)
with m.Else():
m.d.comb += self.pc_next.eq(self.pc_inc)
return m
在思考我的实现时,我现在想回答以下问题:
我的状态是什么?
怎样计算输出?
怎样更新状态?
这听起来很简单,但它一直明确问题。对此情况:
pc是我的状态(你会注意到,它是同步分配的,因此其值将在下一个时钟周期开始时更新)。
输出包括pc、pc_inc和pc_next:
pc输出是当前状态。
pc_inc就是pc + 4。
根据负载情况,pc_next在pc_inc和input_address之间进行交换。
pc在每个时钟周期中取pc_next的值。
错误 2:创建组合循环由于上述不正确的程序计数器实现导致了此错误,因此这不是一个额外的错误。我认为这个特别的结果应该有一个特别的章节。
如果程序计数器只有pc和pc_inc输出,则会创建以下组合循环:
pc的输出是 ALU 的输入。
但是,pc的输出通过 ALU 的输出来计算。
这两个信号都位于组合域内,所以它们被有效地连接起来,形成一个循环。
由于我的组合模拟从来没有解决过,这个反馈循环的创建导致我的测试搁置。当我试图合成设计时,合成工具会拒绝。若能合成这样的设计,输出将不断变化。
错误 3:未正确创建框图本博客的真爱粉们可能会注意到,上面的框图与上一篇文章结尾处的框图有很大的不同:之前我没有对 ALU 的输入进行复用。不知道 JAL 为什么不能工作,试着通过我的框图跟踪它,就像我们上面所做的那样。重演一遍。
看一下我原来的框图草图。
放大,看这里:
以上内容可能有些琐碎,但是我想说的是,这里的教训是积极地思考你的框图(或者在实现你的设计时使用的任何参考),确保它对你实际有意义,并做你想做的事。当然,在查看任何需求或其他设计文档时,这对于软件设计来说也是一样的。
杂项错误另外,我也犯过许多其他的错误,但是这些错误都不值一提,最值得注意的是,JAL 指令中有一个非常糟糕的立即数的解混洗。但是这只是一个编程错误,并没有给我们任何有趣的教训。
运行我的第一个程序!通过测试和 GTKWave 的帮助,我找到了我在实施过程中所犯的最后一个错误,这是一个令人激动的时刻。
为便于测试,指令解码代码还包含汇编指令所用的代码。这里,我使用它创建了一个简单的程序,它包含两个指令:
一条ADDI指令,其中rd和rs1是同一个寄存器,而且立即数为1,这意味着它将增加rs1中的值。
一条JAL指令,偏移量为 -4,也就是说,它跳回到ADDI指令。这样就形成了一个无限循环,每个时钟周期内寄存器中的值递增。
LED 代码是我的板子所特有的。我在寄存器中选择了 4 个位(每个 LED 一个),并将它们显示在电路板的 LED 上。我选择了中间的位,以便它们不会变化得太快而看不清(如果我选择 LSB 就会这样),或者变化的太慢而看起来不酷(如果我选择 MSB 就会这样)。
program = [encoding.IType.encode(
1,
reg,
encoding.IntRegImmFunct.ADDI,
reg,
encoding.Opcode.OP_IMM),
# jump back to the previous instruction for infinite loop
encoding.JType.encode(0x1ffffc, link_reg)]
imem = nm.Memory(width=32, depth=256, init=program)
imem_rp = m.submodules.imem_rp = imem.read_port()
m.d.comb += [
imem_rp.addr.eq(cpu_inst.imem_addr),
cpu_inst.imem_data.eq(imem_rp.data),
]
colours = ["b", "g", "o", "r"]
leds = nm.Cat(platform.request(f"led_{c}") for c in colours)
m.d.sync += leds.eq(cpu_inst.debug_out[13:17])
下面就是 LED 的动图,非常精彩!
下一阶段将实现 RV32I 指令的其余部分。也许我会从加载指令开始,因为我认为它们会给我目前的单级架构带来挑战。我有一个如何解决这个问题的计划。
软件设计与数字逻辑设计的异同现在我已经完成了更多的 CPU 的设计和实现,我对这个主题有了一些更清晰的想法。在软件设计和数字逻辑设计之间,我认为会有一些明显的区别。思来想去,不知是否只是程度不同而已。
并发性先来看看并发性的问题。这个主题有时会被误解,因此让我们澄清一下:并发发生的条件是,你的系统的不同部分可能在不同的时间执行,也可能不按顺序执行。若系统能支持同时执行多个任务,则该系统是并发的;这可能是并行的,也可能包括任务间的切换,在任何特定时间内只实际执行一个任务。
当然,软件的默认状态是不具有并发性:它通常需要显式创建(例如通过创建线程),或者至少需要显式处理(例如在中断处理程序中)。任何从事过并发软件的软件工程师都会同意,处理并发性是一项独特的挑战,因为它为解决棘手和细微的错误提供了空间。由于上述两个原因,软件通常具有有限的并发性。
假设你的状态是并发,并且你的程序的每一步、每个单项状态都在不断的更新。
数字逻辑的默认状态就是这样的。通常来说,在软件中,你需要显式地创建(或至少显式地处理)并发,而在硬件中,你必须有意识地使某些事情按顺序进行。
前面我说过,软件的并发性是个挑战。假如数字硬件具有更多并发性,设计起来会更具挑战性吗?或许吧!因为我在硬件设计方面缺乏经验,所以我很难发表意见。
或许在软件中,并发是如此困难,原因之一在于许多软件都有一个讨厌的习惯,即依赖于大量的状态。区区千位的内存的可能状态有多少种?$$2^{1024}$$。这是一个我无法概念化的大数字。但是,根据许多程序的标准,一个千位也不算什么。
对于硬件来说,状态更有价值。我的 FPGA 也就那么大了。这是 ice40hx8k,它有 8000 个逻辑单元。每个单元有一个 4 输入的 LUT 和一个触发器:因此我们假设 8000 位的状态。真可怜!让我们看看如何调用malloc的方法来创建数百万位的纯粹混沌。
如果你是设计芯片人员,你的设计规模当然与成本直接相关!
验证我们已经在考虑并发性。另一件我想知道的事情是,数字硬件是否具有使正是验证更容易的基本属性,或至少证明与模型的等价性。在我们的风险评估中,当我研究一个系统,它一旦出现故障就会导致人员死亡,因此我们必须假定软件故障的可能性是 100%。事实上,这通常意味着仅仅依靠软件缓解是不够的。我们系统中的 FPGA 组件被认为更值得信赖,我感到很奇怪。难道是硬件有什么基本属性让它变得“更好”?
再说一次,我认为这没有什么根本性的区别。归根结底,软件的存在是经过正是验证的。一般而言,函数式编程更适合于实现这种情况,因为状态和行为是分开的。最可能的区别就是对状态的谨慎管理。上述数字硬件的特性鼓励这样做,但在软件中也不是不可能做到。
大型验证工程师团队在芯片设计方面的工作可能也意味着一些根本的区别,因为你可能还没在软件中看到这种应用。不过,也有一些软件项目被给予如此严格的要求!其中最有名的是 NASA 的许多软件,例如航天飞机的软件。令人遗憾的是,大多数公司并没有意识到在他们的软件中应用这种严谨性是值得的(代价太高)。如果存在大量的生命和资金处于危险之中,软件的编写和测试就会与许多硬件一样严格。
作者介绍:Hannah McLaughlin,住在英国牛津的软件工程师,供职于 Perspectum Diagnostics,为医学图像诊断工具编写 C++。曾在 CMR Surgical 供职,在那里为下一代手术机器人编写裸机嵌入式 C。对 Rust 很感兴趣,已经写过很多 Python 代码,愿意尝试更多的函数式编程。
原文链接:
https://mcla.ug/blog/risc-v-cpu-part-2.html
你也「在看」吗???
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线