来源:半导体行业观察
2025-05-11 11:23:43
(原标题:手把手教你设计RISC-V CPU)
如果您希望可以时常见面,欢迎标星收藏哦~
最近些年。RISC-V引起了全球关注。这款革命性的 ISA 凭借其持续的创新,以及无数的学习和工具资源以及来自工程界的贡献,像潮水般席卷了市场。RISC-V 最大的魅力在于它是一款开源 ISA。
在本文中,我(指代本文作者Mitu Raj,下同)将介绍如何从零开始设计一款RISC-V CPU ,我们将讲解定义规格、设计和改进架构、识别和解决挑战、开发 RTL、实现 CPU 以及在仿真/FPGA 板上测试 CPU 的流程。
以下为文章正文:
从命名开始
为你的想法命名或打造品牌至关重要,这样才能激励你不断前进,直至达成目标!我们打算构建一个非常简单的处理器,所以我想出了一个花哨的名字“ Pequeno ”,在西班牙语中是“微小”的意思;完整名称是:Pequeno RISC-V CPU,又名PQR5。
RISC-V 的 ISA 架构有多种风格和扩展。我们先从最简单的RV32I开始,它又称为 32 位基本整数 ISA。该 ISA 适用于构建支持整数运算的 32 位 CPU。因此,Pequeno 的第一个规格如下:
Pequeno 是一款 32 位 RISC-V CPU,支持 RV32I ISA。
RV32I 有 37 条 32 位基本指令,我们计划在 Pequeno 中实现。因此,我们必须深入了解每条指令。我费了一番功夫才完全掌握了 ISA。在此过程中,我学习了完整的规范,并设计了自己的汇编程序pqr5asm,并与一些流行的 RISC-V 汇编程序进行了验证。
“RISBUJ”
上面六个字母的单词总结了 RV32I 中的指令类型。这 37 条指令属于以下类别之一:
R型:所有寄存器上的整数计算指令。
I 型:所有基于寄存器和立即数的整数计算指令。还包括 JALR 和 Load 指令。
S型:全部存储说明。
B型:所有分支指令。
U型:LUI、AUIPC等特殊指令。
J型:类似JAL的跳转指令。
RISC-V 架构中有 32 个通用寄存器,x0-x31. 所有寄存器都是 32 位的。在这 32 个寄存器中,零又称为x0寄存器,是一个很有用的特殊寄存器,它被硬连线为零,无法写入,并且始终读取为零。那么它有什么用呢?你可以使用x0作为虚拟目标来转储您不想读取的结果,或用作操作数零,或生成 NOP 指令来闲置 CPU。
整数计算指令是针对寄存器和/或12位立即数执行的ALU指令。加载/存储指令用于在寄存器和数据存储器之间存储/加载数据。跳转/分支指令用于将程序控制转移到不同的位置。
每条指令的详细信息可以在 RISC-V 规范中找到:RISC-V 用户级 ISA v2.2。
要学习 ISA,RISC-V 规范文档就足够了。不过,为了更清晰起见,您可以研究一下 RTL 中不同开放核心的实现。
除了 37 条基本指令外,我还为 pqr5asm 添加了 13 条伪/自定义指令,并将 ISA 扩展至 50 条指令。这些指令源自基本指令,旨在简化汇编程序员的工作……例如:
NOP指令与ADDI x0, x0, 0这在CPU上当然什么也不做!但它更简单,更容易在代码中解释。
在开始设计处理器架构之前,我们的期望是完全了解每条指令如何以 32 位二进制进行编码以及它的功能是什么。
我用 Python 开发的 RISC-V RV32I 汇编器 PQR5ASM 可以在我的 GitHub 上找到。您可以参考《汇编器指令手册》编写示例汇编代码。编译它,并查看它如何转换为 32 位二进制文件,以便在继续下一步之前巩固/验证您的理解。
规格和架构
在本章中,我们定义了 Pequeno 的完整规格和架构。上次我们只是简单地将其定义为 32 位 CPU。接下来,我们将对其进行更详细的介绍,以大致了解即将设计的架构。
我们将设计一个简单的单核 CPU,它能够按照获取指令的顺序一次执行一条指令,但仍采用流水线方式。我们不支持 RISC-V 特权规范,因为我们目前不打算让我们的核心操作系统支持该规范,也不打算让它支持中断。
该CPU规格如下:
32位CPU,单发射,单核。
经典的五级 RISC 流水线。严格有序流水线。
符合RV32I 用户级 ISA v2.2。支持全部 37 条基本指令。
用于指令和数据存储器访问的独立总线接口。(为什么?以后再讨论……)
适用于裸机应用程序,不支持操作系统和中断。(更确切地说是限制!)
正如上文所述,我们将支持 RV32I ISA。因此,CPU 仅支持整数运算。
CPU 中的所有寄存器都是 32 位的。地址和数据总线也是 32 位的。CPU 采用经典的小端字节寻址内存空间。每个地址对应于 CPU 地址空间中的一个字节。
0x00 - byte7:0, 0x01 - byte15:8 ...
32 位字可以通过 32 位对齐的地址访问,即 4 的倍数的地址:
0x00—— byte 0,0x04—— byte 1……
Pequeno 是一款单发射 CPU,即每次只从内存中获取一条指令,并发出指令进行解码和执行。采用单发射的流水线处理器的最大IPC = 1(或最小/最佳CPI = 1),即最终目标是以每时钟周期 1 条指令的速率执行。这在理论上是可以实现的最高性能。
经典的五级 RISC 流水线是理解任何其他 RISC 架构的基础架构。这对于我们的 CPU 来说是最理想且最简单的选择。Pequeno 的架构就是围绕这种五级流水线构建的。让我们深入探讨一下其底层概念。
简单起见,我们将不支持 CPU 流水线中的计时器、中断和异常。因此,CSR 和特权级别也无需实现。因此, RISC-V 特权 ISA不包含在 Pequeno 的当前实现中。
设计 CPU 最简单的方法是非流水线方式。让我们看看非流水线 RISC CPU 的几种设计方法,并了解其缺点。
让我们假设 CPU 执行指令所遵循的经典步骤序列:获取、解码、执行、内存访问和写回。
第一种设计方法是:将 CPU 设计成一个具有四到五个状态的有限状态机 (FSM),并按顺序执行所有操作。例如:
但这种架构会严重影响指令执行速度。因为执行一条指令需要多个时钟周期。比如,写入寄存器需要 3 个时钟周期。如果是加载/存储指令,内存延迟也会随之增加。这是一种糟糕且原始的 CPU 设计方法。我们彻底抛弃它吧!
第二种方法是:指令可以从指令存储器中取出,解码,然后由完全组合逻辑执行。然后,ALU 的结果被写回到寄存器文件。直到写回的整个过程可以在一个时钟周期内完成。这样的 CPU 称为单周期 CPU。如果指令需要访问数据存储器,则应考虑读/写延迟。如果读/写延迟为一个时钟周期,则存储指令仍可能像所有其他指令一样在一个时钟周期内完成执行,但加载指令可能额外需要一个时钟周期,因为必须将加载的数据写回到寄存器文件。PC 生成逻辑必须处理这种延迟的影响。如果数据存储器读取接口是组合的(异步读取),则 CPU 对于所有指令都将真正变为单周期。
该架构的主要缺点显然是从取指到写入存储器/寄存器文件的组合逻辑关键路径较长,这限制了时序性能。然而,这种设计方法简单,适用于低端微控制器中那些需要低时钟速度、低功耗和低面积的CPU。
为了实现更高的时钟速度和性能,我们可以将 CPU 的指令顺序处理功能分离出来。每个子进程被分配给独立的处理单元。这些处理单元按顺序级联,形成流水线。所有单元并行工作,并对指令执行的不同部分进行操作。通过这种方式,可以并行处理多条指令。这种实现指令级并行性的技术称为指令流水线。该执行流水线构成了流水线 CPU 的核心。
经典的五级 RISC 流水线有五个处理单元,也称为流水线阶段。这些阶段分别是:取指(IF)、解码(ID)、执行(EX)、内存访问(MEM)、写回(WB)。流水线的工作原理可以直观地表示为:
每个时钟周期,一条指令的不同部分会被处理,并且每个阶段都会处理不同的指令。如果仔细观察,会发现只有第 5 个周期,指令 1 才完成执行。这段延迟被称为流水线延迟。Δ此延迟与流水线级数相同。在此延迟之后,第 6 个周期:指令 2 执行完毕,第 7 个周期:指令 3 执行完毕,依此类推……理论上,我们可以计算吞吐量(每周期指令数,IPC),如下所示:
因此,流水线CPU保证每个时钟周期执行一条指令。这是单发射处理器中可能的最大IPC。
通过划分多个流水线阶段的关键路径,CPU 现在也可以以更高的时钟速度运行。从数学上讲,这使得流水线 CPU 的吞吐量比同等的非流水线 CPU 提高了一个倍数。
这被称为流水线加速。简单来说,一个具有s阶段流水线 CPU 的时钟速度是非流水线产品的S倍。
流水线通常会增加面积/功耗,但性能提升是值得的。
数学计算假设流水线永远不会停滞,也就是说,数据在每个时钟周期内都会从一个阶段持续传输到另一个阶段。但在实际的 CPU 中,流水线可能会由于多种原因而停滞,主要原因是结构/控制/数据依赖性。
举个例子:寄存器X不能被Nth指令读到,因为X并不是由(N-1)th指令修改了X读回,这是流水线中数据风险的一个例子。
Pequeno 的架构采用了经典的五级 RISC 流水线。我们将实现严格的顺序流水线。在顺序处理器中,指令的获取、解码、执行和完成/提交都按照编译器生成的顺序进行。如果一条指令停滞,整个流水线都会停滞。
在乱序处理器中,指令按照编译器生成的顺序获取和解码,但执行可以按不同的顺序进行。如果一条指令停顿,除非存在依赖关系,否则它不会停顿后续指令。独立的指令可以向前传递。执行仍然可以按顺序完成/提交(这就是当今大多数CPU的现状)。这为实现各种架构技术打开了大门,通过减少停顿所浪费的时钟周期并最大限度地减少气泡的插入(什么是“气泡”?继续阅读……) ,显著提高吞吐量和性能。
乱序处理器由于指令的动态调度而相当复杂,但现在已成为当今高性能 CPU 中事实上的流水线架构。
五个流水线阶段被设计为独立单元:取指单元(FU)、译码单元(DU)、执行单元(EXU)、内存访问单元(MACCU)和写回单元(WBU)。
取指单元(FU):流水线的第一级,与指令存储器接口。FU 从指令存储器中取指并送至译码单元。FU 可能包含指令缓冲区、初始分支逻辑等。
解码单元(DU):流水线的第二阶段,负责解码来自执行单元 (FU) 的指令。DU 还会启动对寄存器文件的读取访问。来自 DU 和寄存器文件的数据包被重新定时同步,并一起发送到执行单元 (Execution Unit)。
执行单元(EXU):流水线的第三阶段,用于验证并执行来自 DU 的所有解码指令。无效/不支持的指令不允许在流水线中继续执行,它们会成为“气泡”。算术单元 (ALU)负责所有整数算术和逻辑指令。分支单元 (Branch Unit)负责处理跳转/分支指令。加载/存储单元 (Load-Store Unit)负责处理需要访问内存的加载/存储指令。
内存访问单元(MACCU):流水线的第四级,用于与数据存储器接口。MACCU 负责根据 EXU 的指令发起所有内存访问。数据存储器是寻址空间,可能由数据 RAM、内存映射的 I/O 外设、桥接器、互连等组成。
写回单元(WBU):流水线的第五级或最后一级。指令在此完成执行。WBU 负责将 EXU/MACCU 中的数据(加载数据)写回寄存器文件。
在流水线阶段之间,实现了有效-就绪握手。乍一看这并不那么明显。每个阶段都会注册一个数据包并将其发送到下一阶段。该数据包可能是下一阶段或后续阶段要使用的指令/控制/数据信息。该数据包通过有效信号进行验证。如果数据包无效,则在流水线中称为气泡(Bubble)。气泡只不过是流水线中的“洞”(hole),它只是在流水线中向前移动,实际上不执行任何操作。这类似于 NOP 指令。但不要认为它们没有用!在后续部分讨论流水线风险时,我们将看到它们的一种用途。下表定义了 Pequeno 指令流水线中的气泡。
每个阶段还可以通过发出停顿信号来停顿前一个阶段。一旦停顿,该阶段将保留其数据包,直到停顿状态消失。此信号与反转的就绪信号相同。在顺序处理器中,任何阶段产生的停顿都类似于全局停顿,因为它最终会停顿整个流水线。
flush信号用于刷新管道。刷新操作将一次性使之前阶段注册的所有数据包失效,因为它们被识别为不再有用。
举个例子,当流水线在执行跳转/分支指令后,从错误的分支获取并解码了指令,而该指令仅在执行阶段被识别为错误时,流水线应该被刷新,并从正确的分支获取指令!
虽然流水线显著提升了性能,但也增加了 CPU 架构的复杂性。CPU 的流水线技术总是伴随着它的孪生兄弟——流水线风险!现在,我们假设我们对流水线风险一无所知。我们在设计架构时并没有考虑风险。
处理流水线风险
在本章中,我们将探讨流水线风险。我们上次成功设计了 CPU 的流水线架构,但却没有考虑到伴随流水线而来的“邪恶双胞胎”。流水线风险对架构可能造成哪些影响?需要进行哪些架构修改来缓解这些风险?让我们继续,揭开它们的神秘面纱!
CPU 指令流水线中的危险是指一些依赖关系,这些依赖关系会干扰流水线的正常执行。当危险发生时,指令无法在指定的时钟周期内执行,因为这可能导致错误的计算结果或控制流。因此,流水线可能会被迫暂停,直到指令能够成功执行。
在上面的例子中,CPU 按照编译器生成的顺序按序执行指令。假设指令 i2对i1有一定的依赖性,比如i2需要读取某个寄存器,但该寄存器也正在被前一条指令i1修改。因此,i2必须等到i1将结果写回寄存器文件,否则旧数据将被解码并从寄存器文件读取,供执行阶段使用。为了避免这种数据不一致,i2被强制暂停三个时钟周期。流水线中插入的气泡表示暂停或等待状态。只有当i1完成时,i2才会被解码。最终,i2在第 10 个时钟周期而不是第 7 个时钟周期完成执行。由于数据依赖性导致的暂停,引入了三个时钟周期的延迟。这种延迟如何影响 CPU 性能?
理想情况下,我们期望 CPU 以满吞吐量运行,即 CPI = 1。但是,当流水线暂停时,由于 CPI 增加,CPU 的吞吐量/性能会降低。对于非理想 CPU:
管道中发生危险的方式多种多样。管道危险可分为三类:
结构性危险
控制危害
数据危害
结构性风险是由于硬件资源冲突而发生的。例如,当流水线的两个阶段想要访问同一资源时。例如:两条指令需要在同一时钟周期内访问内存。
在上面的例子中,CPU 只有一个内存用于存储指令和数据。取指阶段每个时钟周期都会访问内存以获取下一条指令。因此,如果内存访问阶段的上一条指令也需要访问内存,则取指阶段和内存访问阶段的指令可能会发生冲突。这将迫使 CPU 增加停顿周期,取指阶段必须等待,直到内存访问阶段的指令释放资源(内存)。
减轻结构性危险的一些方法包括:
暂停管道,直到资源可用。
复制资源,这样就不会发生任何冲突。
流水线资源,使得两条指令将处于流水线资源的不同阶段。
让我们分析一下可能导致 Pequeno 管道出现结构性危险的不同情况,以及如何解决。 我们无意使用停工作为缓解结构性危险的选项!
在 Pequeno 的架构中,我们实施了上述三种解决方案来减轻各种结构性危险。
控制风险是由跳转/分支指令引起的。跳转/分支指令是 CPU ISA 中的流程控制指令。当控制权到达跳转/分支指令时,CPU 必须决定是否执行该分支指令。此时,CPU 应该采取以下操作之一。
在 PC+4 处获取下一条指令(不执行分支)或获取分支目标地址处的指令(分支已执行)。
只有在执行阶段计算分支指令的结果时,才能判断决策的正确与否。根据分支是否被执行,确定分支地址(CPU 应该分支到的地址)。如果之前做出的决策是错误的,那么在该时钟周期之前在流水线中获取和解码的所有指令都应该被丢弃。因为这些指令根本不应该被执行!这是通过刷新流水线并在下一个时钟周期获取分支地址的指令来实现的。刷新使指令无效并将其转换为 NOP 或冒泡。这会花费大量的时钟周期作为惩罚。这被称为分支惩罚。因此,控制冒险对 CPU 性能的影响最严重。
在上面的例子中,i10在第 10 个时钟周期完成了执行,但它应该在第 7 个时钟周期完成执行。由于执行了错误的分支指令 (i5),因此损失了 3 个时钟周期。当执行阶段在第 4 个时钟周期识别出错误分支指令时,必须在流水线中进行刷新。这会如何影响 CPU 性能?
如果在上述 CPU 上运行的程序包含 30% 的分支指令,则 CPI 将变为:
CPU 性能降低50%!
为了减轻控制风险,我们可以在架构中采用一些策略……
如果指令被识别为分支指令,则只需暂停流水线即可。该解码逻辑可以在提取阶段本身实现。一旦执行了分支指令并解析了分支地址,就可以提取下一条指令并恢复流水线。
在 Fetch 阶段添加类似分支预测的专用分支逻辑。
分支预测的本质是:我们在取指阶段采用某种预测逻辑来猜测分支是否应该被执行。在下一个时钟周期,我们获取猜测的指令。这条指令要么从 PC+4 处获取(预测分支不被执行),要么从分支目标地址处获取(预测分支被执行)。现在有两种可能性:
如果在执行阶段发现预测正确,则不执行任何操作,管道可以继续处理。
如果发现预测错误,则刷新流水线,从执行阶段解析的分支地址中获取正确的指令。这会产生分支惩罚。
如您所见,分支预测如果预测错误,仍然会招致分支惩罚。设计目标应该是降低错误预测的概率。CPU 的性能很大程度上取决于预测算法的“好坏”。像动态分支预测这样的复杂技术会保存指令历史记录,以便以 80% 到 90% 的概率进行正确预测。
为了减轻 Pequeno 中的控制风险,我们将实现一个简单的分支预测逻辑。更多细节将在我们即将发布的关于提取单元设计的博客中揭晓。
当一条指令的执行对流水线中仍在处理的上一条指令的结果存在数据依赖时,就会发生数据风险。让我们通过示例来了解三种类型的数据风险,以便更好地理解这个概念。
假设一条指令i1将结果写入寄存器 x。下一条指令i2也将结果写入同一寄存器。程序顺序中的任何后续指令都应读取 x 处i2的结果。否则,数据完整性将受损。这种数据依赖关系称为输出依赖关系,可能导致 WAW((Write-After-Write)) 数据风险。
假设一条指令i1读取了寄存器 x。下一条指令i2将结果写入同一寄存器。此时,i1应该读取 寄存器X的旧值,而不是i2的结果。如果 i2在i1读取结果之前将结果写入 x,则会导致数据风险。这种数据依赖称为反依赖,可能导致 WAR ((Write-After-Read))数据风险。
假设一条指令i1将结果写入寄存器 x。下一条指令i2读取同一个寄存器。此时,i2应该读取 i1写入寄存器 x 的值,而不是之前的那个值。这种数据依赖关系被称为真依赖,可能导致 RAW (Read-After-Write)数据风险。
这是流水线 CPU 中最常见、最主要的数据危险类型。
为了减轻有序 CPU 中的数据危险,我们可以采用一些技术:
检测到数据依赖性时,暂停流水线(参见第一张图)。解码阶段可以等到上一条指令执行完成后再执行。
编译重新调度:编译器通过调度代码到稍后执行来重新安排代码,以避免数据风险。这样做的目的是避免程序停顿,同时又不影响程序控制流的完整性,但这并非总是可行。编译器也可以在两个具有数据依赖性的指令之间插入 NOP 指令。但这会导致停顿,从而影响性能。
数据/操作数转发:这是顺序执行 CPU 中缓解 RAW 数据风险的突出架构解决方案。让我们分析一下 CPU 流水线,以了解这项技术背后的原理。
假设两个相邻的指令i1和i2,它们之间存在 RAW 数据依赖性,因为它们都在访问寄存器X。CPU 应该暂停指令i2,直到i1将结果写回寄存器x。如果 CPU 没有停顿机制,则i2会在第三个时钟周期的解码阶段从 x 读取较旧的值。在第四个时钟周期,i2指令会执行错误的 x 值。
如果你仔细观察管道,我们在第三个时钟周期就已经得到了i1的结果。当然,它不会被写回寄存器文件,但结果仍然可以在执行阶段的输出端使用。因此,如果我们能够以某种方式检测数据依赖性,然后将该数据“forward”到执行阶段的输入,那么下一条指令就可以使用转发的数据,而不是来自解码阶段的数据。这样一来,数据风险就得到了缓解!这个想法是这样的:
这称为数据/操作数转发或数据/操作数旁路。我们将数据按时间向前转发,以便流水线中后续的依赖指令可以访问这些被旁路的数据,并在执行阶段执行。
这个想法可以扩展到不同的阶段。在一个按 i1、i2、..in顺序执行指令的 5 级流水线中,数据依赖关系可能存在于:
i1和i2- 需要在执行阶段和解码阶段的输出之间旁路。
i1和i3- 需要在内存访问阶段和解码阶段的输出之间旁路。
i1和i4- 需要在写回阶段和解码阶段的输出之间旁路。
用于缓解源自流水线任何阶段的 RAW 数据风险的架构解决方案如下所示:
请考虑以下情形:
两条相邻指令i1和i2之间存在数据依赖关系,其中第一条指令是 Load。这是数据风险的一种特殊情况。这里,在数据加载到 x1 之前,我们无法执行i2。那么,问题在于我们是否仍然可以通过数据转发来缓解这种数据风险?加载数据仅在 i1的内存访问阶段可用,并且必须将其转发到i2的解码阶段才能防止这种风险。该要求如下所示:
假设加载数据在第 4 个周期的内存访问阶段可用,您需要将此数据“转发”到第 3 个周期,发送到i2的解码阶段输出(为什么是第 3 个周期?因为在第 4 个周期,i 就已经在执行阶段完成了执行!)。本质上,您是在尝试将当前数据转发到过去,除非您的 CPU 进行时间旅行,否则这是不可能的!这不是数据转发,而是“数据回溯”。
数据转发只能沿时间方向向前进行。
这种数据风险称为流水线互锁(Pipeline Interlock)。解决这个问题的唯一方法是,在检测到数据依赖性时插入一个气泡,使流水线暂停一个时钟周期。
在 i1和i2之间插入了 NOP 指令(又称 Bubble)。这会将i2延迟一个周期,因此数据转发现在可以将加载数据从内存访问阶段转发到解码阶段的输出。
到目前为止,我们只讨论了如何缓解 RAW 数据风险。那么,WAW 和 WAR 风险又如何呢?RISC-V 架构本身就具备抵抗有序流水线实现的 WAW 和 WAR 风险的能力!
所有寄存器的写回都按照指令发出的顺序进行。写回的数据总是会被后续写入同一寄存器的指令覆盖。因此,WAW 风险永远不会发生!
写回是流水线的最后一个阶段。当写回发生时,读取指令已经成功完成了对较旧数据的执行。因此,WAR 风险永远不会发生!
为了缓解 Pequeno 中的 RAW 数据风险,我们将使用流水线互锁保护功能硬件实现数据转发。更多细节将在后文揭晓,届时我们将在其中设计数据转发逻辑。
我们理解并分析了现有 CPU 架构中可能导致指令执行失败的各种潜在流水线风险。我们还设计了解决方案和机制来缓解这些风险。让我们整合必要的微架构,并最终设计出 Pequeno RISC-V CPU 的架构,使其完全杜绝所有类型的流水线风险!
在接下来的文章中,我们将深入探讨每个流水线阶段/功能单元的 RTL 设计。我们将讨论设计阶段中不同的微架构决策和挑战。
获取单元
从这里开始,我们开始深入探讨微架构和 RTL 设计了!在本章中,我们将构建和设计Pequeno 的Fetch Unit (FU) 。
取指单元 (FU) 是 CPU 流水线的第一阶段,用于与指令存储器交互。取指单元 (FU) 从指令存储器中取指,并将取指的指令发送到译码单元 (DU) 。正如前文中 Pequeno 的改进架构所讨论的那样,FU 包含分支预测逻辑和刷新支持。
1
接口
让我们定义 Fetch Unit 的接口:
2
指令访问接口
CPU 中 FU 的核心功能是指令访问。指令访问接口 (Instruction Access:I/F)即用于此目的。指令在执行期间存储在指令存储器 (RAM) 中。现代 CPU 从高速缓存 (Cache) 中获取指令,而不是直接从指令存储器中获取。指令缓存(在计算机架构术语中称为主缓存或L1 缓存)更靠近 CPU,通过缓存/存储频繁访问的指令并在附近预取较大块的指令,实现更快的指令访问。因此,无需持续访问速度较慢的主存储器 (RAM)。因此,大多数指令都可以直接从缓存中快速访问。
CPU 不会直接访问带有指令缓存/内存的接口。它们之间会有一个缓存/内存控制器来控制它们之间的内存访问。
定义一个标准接口是一个好主意,这样任何标准指令存储器/缓存 (IMEM) 都可以轻松地插入到我们的 CPU 中,并且只需极少的胶合逻辑甚至无需胶合逻辑。让我们定义两个用于指令访问的接口。请求接口 (I/F )处理从指令存储器 (FU) 到指令存储器的请求。响应接口 (I/F)处理从指令存储器到指令存储器 (FU) 的响应。我们将为指令存储器 (FU) 定义一个简单的基于有效就绪的请求和响应接口 (I/F),因为如果需要,这很容易转换为 APB、AXI 等总线协议。
指令访问需要知道指令在内存中的地址。通过请求接口 (Request I/F) 请求的地址实际上就是 FU 生成的 PC。在 FU 接口中,我们将使用暂停信号 (stall signal) 来代替就绪信号,其行为与就绪信号相反。缓存控制器通常有一个暂停信号来暂停来自处理器的请求。该信号由cpu_stall表示。来自内存的响应是通过响应接口 (Response I/F) 接收到的已取指令。除了已取指令之外,响应还应包含相应的 PC。PC 用作 ID,用于识别已收到响应的请求。换句话说,它指示已取指令的地址。这是 CPU 流水线下一阶段所需的重要信息(如何实现?我们很快就会看到! )。因此,已取指令及其 PC 构成了对 FU 的响应数据包。当内部流水线暂停时,CPU 可能还需要暂停来自指令内存的响应。该信号由mem_stall表示。
此时,让我们定义CPU 管道中的 instruction packet= {instruction, PC}。
3
PC 生成逻辑
FU 的核心是控制请求接口 (I/F) 的 PC 生成逻辑。由于我们设计的是 32 位 CPU,因此 PC 的生成应该以 4 为增量。该逻辑复位后,每个时钟周期都会生成 PC。PC 的复位值可以硬编码。这是 CPU 复位后从中获取并执行指令的地址,即内存中第一条指令的地址。PC 生成是自由运行的逻辑,仅由 c pu_stall暂停。
自由运行的PC可以通过刷新I/F和内部分支预测逻辑来绕过。PC生成算法实现如下:
4
指令缓冲器
FU 内部有两个背靠背的指令缓冲区。缓冲区 1缓冲从指令存储器中获取的指令。缓冲区 1 可以直接访问响应接口 (Response I/F)。缓冲区 2缓冲来自缓冲区 1 的指令,然后通过 DU I/F 将其发送到 DU。这两个缓冲区构成了 FU 内部的指令流水线。
5
分支预测逻辑
正如上文所讨论的,我们必须在 FU 中添加分支预测逻辑来缓解控制风险。我们将实现一个简单且静态的分支预测算法。该算法的主要内容如下:
总是会进行无条件跳转。
如果分支指令是向后跳转,则执行分支。因为可能性如下:
1、这条指令可能是某些do-while 循环的循环退出检查的一部分。在这种情况下,如果我们执行分支指令,则正确的概率更高。
如果分支指令是向前跳转,则不要执行它。因为可能性如下:
2、这条指令可能是某些for 循环或while 循环的循环入口检查的一部分。如果我们不执行分支并继续执行下一条指令,则正确的概率更高。
3、这条指令可能是某个if-else语句的一部分。在这种情况下,我们总是假设if条件为真,并继续执行下一条指令。理论上,这笔交易(bargain)有50%是正确的。
缓冲区 1 的指令包由分支预测逻辑监控和分析,并生成分支预测信号:branch_taken。该分支预测信号随后被注册,并与发送给 DU 的指令包同步传输。分支预测信号通过 DU 接口发送给 DU。
6
DU
这是获取单元和解码单元之间用于发送有效载荷的主要接口。有效载荷包含获取的指令和分支预测信息。
由于这是CPU两个流水线阶段之间的接口,因此实现了有效就绪I/F。以下信号构成了DU I/F:
在之前的博文中,我们讨论了 CPU 流水线中停顿和刷新的概念及其重要性。我们还讨论了 Pequeno 架构中需要停顿或刷新的各种场景。因此,必须在 CPU 的每个流水线阶段中集成适当的停顿和刷新逻辑。确定在哪个阶段需要停顿或刷新至关重要,以及该阶段中哪些逻辑部分需要停顿和刷新。
在实施停顿和刷新逻辑之前的一些初步想法:
流水线阶段可能会因外部或内部产生的条件而停止。
管道阶段可以通过外部或内部生成的条件进行刷新。
Pequeno 中没有集中式的停顿或刷新生成逻辑。每个阶段可能都有自己的停顿和刷新生成逻辑。
流水线中一个阶段只能被下一个阶段所阻塞。任何阶段的阻塞最终都会影响流水线的上游,并导致整个流水线阻塞。
下游流水线中的任何一个阶段都可以刷新某个阶段。这被称为流水线刷新,因为上游的整个流水线都需要同时刷新。在 Pequeno 中,只有执行单元 (EXU)中的分支未命中才需要进行流水线刷新。
停顿逻辑包含产生本地和外部停顿的逻辑。刷新逻辑包含产生本地和流水线刷新的逻辑。
本地停顿在内部产生,并在本地用于停止当前阶段的运行。外部停顿在内部产生,并通过外部发送到上游流水线的下一级。本地和外部停顿均基于内部条件以及下游流水线下一级的外部停顿而产生。
本地刷新 (Local flush)是指在内部生成并用于本地刷新阶段的刷新。外部刷新或管道刷新 (Pipeline flush)是指在内部生成并发送到外部上游管道的刷新。这会同时刷新上游的所有阶段。本地刷新和外部刷新均基于内部条件生成。
只有 DU 可以从外部停止 FU 的运行。当 DU 置位停顿时,FU 的内部指令流水线(缓冲区 1 –> 缓冲区 2)应立即停止,并且由于 FU 无法再接收来自 IMEM 的数据包,它还应向 IMEM 置位mem_stall 。根据 IMEM 中的流水线/缓冲深度,PC 生成逻辑最终也可能被来自 IMEM 的cpu_stall停止,因为 IMEM 无法再接收任何请求。FU 中不存在导致本地停顿的内部条件。
只有 EXU 可以外部刷新 FU。EXU 会在 CPU 指令流水线中启动branch_flush 函数,并传入刷新流水线后要获取的下一条指令的地址 ( branch_pc )。FU 提供了刷新接口 (Flush I/F),以便接受外部刷新。
FU 中的缓冲区 1、缓冲区 2 和 PC 生成逻辑通过branch_flush刷新。来自分支预测逻辑的信号branch_taken也充当了对缓冲区 1 和 PC 生成逻辑的本地刷新。如果分支被采用:
下一条指令应从分支预测的 PC 中获取。因此,PC 生成逻辑应被刷新,并且下一条 PC 应 = branch_pc。
缓冲区 1 中的下一条指令应被刷新并使其无效,即插入 NOP/bubble。
奇怪为什么 Buffer-2 没有被branch_taken刷新?因为来自 Buffer-1 的分支指令(负责刷新生成)应该在下一个时钟周期缓冲到 Buffer-2,并允许其在流水线中继续执行。这条指令不应该被刷新!
指令内存流水线也应该进行适当的刷新。IMEM 刷新mem_flush由branch_flush和branch_taken生成。
让我们整合目前为止设计的所有微架构,以完成 Fetch Unit 的架构。
好了,各位!我们已经成功设计出Pequeno的Fetch Unit了。在接下来的部分中,我们将设计Pequeno 的解码单元(DU:Decode Unit)。
解码单元
解码单元(DU)是 CPU 流水线的第二阶段,负责将来自取指单元(FU)的指令译码,并送至执行单元(EXU)。此外,它还负责将寄存器地址译码,并送至寄存器文件进行寄存器读操作。
让我们定义解码单元的接口。
其中,FU接口是获取单元和解码单元之间接收有效载荷的主要接口。有效载荷包含获取的指令和分支预测信息。此接口已在上一部分讨论过。
EXU接口是解码单元和执行单元之间发送有效载荷的主要接口。有效载荷包括解码后的指令、分支预测信息和解码数据。
以下是构成 EXU I/F 的指令和分支预测信号:
解码数据是 DU 从获取的指令中解码并发送到 EXU 的重要信息。让我们来了解一下 EXU 执行一条指令需要哪些信息。
Opcode、funct3、funct7:标识 EXU 对操作数要执行的操作。
操作数:根据操作码,操作数可以是寄存器数据(rs0,rs1),用于写回的寄存器地址(rdt),或 12 位/20 位立即数。
指令类型:标识必须处理哪些操作数/立即值。
解码过程可能比较棘手。如果您正确理解了 ISA 和指令结构,就可以识别出不同类型的指令模式。识别模式有助于设计 DU 中的解码逻辑。
以下信息被解码并通过 EXU I/F 发送到 EXU。
EXU 将使用此信息将数据解复用到适当的执行子单元并执行指令。
对于 R 型指令,必须解码并读取源寄存器rs1和rs2 。从寄存器读取的数据即为操作数。所有通用用户寄存器都位于 DU 外部的寄存器堆中。DU 使用寄存器堆接口将rs0和rs1 的地址发送到寄存器堆进行寄存器访问。从寄存器堆读取的数据也应与有效载荷一起在同一时钟周期内发送到 EXU。
寄存器文件读取寄存器需要一个周期。DU 也需要一个周期来寄存要发送到 EXU 的有效载荷。因此,源寄存器地址由组合逻辑直接从 FU 指令包解码。这确保了 1) 从 DU 到 EXU 的有效载荷和 2) 从寄存器文件到 EXU 的数据的时序同步。
只有 EXU 可以从外部停止 DU 的运行。当 EXU 置位停止时,DU 的内部指令流水线应立即停止,并且由于无法再接收来自 FU 的数据包,它还应向 FU 置位停止。为了实现同步操作,寄存器文件应与 DU 一起停止,因为它们都位于 CPU 五级流水线的同一级。因此,DU 将外部停止从 EXU 反馈到寄存器文件。DU 内部不存在导致本地停止的情况。
只有 EXU 可以外部刷新 FU。EXU 会在 CPU 指令流水线中启动branch_flush 函数,并传入刷新流水线后要获取的下一条指令的地址 ( branch_pc )。DU 提供了刷新接口 (Flush I/F),以便接受外部刷新。
内部流水线由branch_flush刷新。来自 EXU 的branch_flush应该立即使指向 EXU 的 DU 指令无效,且延迟时间为 0 个时钟周期。这是为了避免在下一个时钟周期 EXU 中出现潜在的控制风险。
在取指单元 (Fetch Unit) 的设计中,我们没有在收到branch_flush 指令后,以 0 周期延迟使 FU 指令失效。这是因为 DU 在下一个时钟周期也会被刷新,因此 DU 中不会发生控制冒险 (control hazard)。所以,没有必要使 FU 指令失效。同样的思路也适用于从 IMEM 到 FU 的指令。
上述流程图展示了来自 FU 的指令包和分支预测数据如何在指令流水线的 DU 中进行缓冲。DU 中仅使用单级缓冲。
让我们整合迄今为止设计的所有微架构,以完成解码单元的架构。
目前我们已经完成了:取指单元(FU)、译码单元(DU)。在接下来的部分中,我们将设计Pequeno的寄存器文件。
寄存器文件
在 RISC-V CPU 中,寄存器文件是一个关键组件,它由一组通用寄存器组成,用于在执行期间存储数据。Pequeno CPU 有 32 个 32 位通用寄存器 ( x0 – x31 )。
寄存器x0称为零寄存器 (zero register)。它被硬连接到一个常量值 0,提供一个有用的默认值,可与其他指令一起使用。假设您想将另一个寄存器初始化为 0,只需执行mv x1, x0即可。
x1-x31是通用寄存器,用于保存中间数据、地址和算术或逻辑运算的结果。
在前文设计的 CPU 架构中,寄存器文件需要两个访问接口。
当中,读访问接口用于读取 DU 发送地址处的寄存器。某些指令(例如ADD)需要两个源寄存器操作数rs1和rs2。因此,读取访问接口 (I/F) 需要两个读取端口,以便同时读取两个寄存器。读取访问应为单周期访问,以便读取数据与 DU 的有效载荷在同一时钟周期内发送到 EXU。这样,读取数据和 DU 的有效载荷在流水线中保持同步。
写访问接口用于将执行结果写回到 WBU 发送地址处的寄存器。执行结束时仅写入一个目标寄存器rdt 。因此,一个写入端口就足够了。写入访问应为单周期访问。
由于 DU 和寄存器文件需要在流水线的同一阶段保持同步,因此它们应该始终一起停止(为什么?请查看上一部分的框图!)。例如,如果 DU 停止,寄存器文件不应将读取数据输出到 EXU,因为这会损坏流水线。在这种情况下,寄存器文件也应该停止。这可以通过将 DU 的停止信号反转生成寄存器文件的read_enable来确保。当停止有效时,read_enable被驱动为低电平,先前的数据将保留在读取数据输出端,从而有效地停止寄存器文件操作。
由于寄存器文件不向EXU发送任何指令包,因此它不需要任何刷新逻辑。刷新逻辑只需在DU内部处理。
总而言之,寄存器文件设计有两个独立的读取端口和一个写入端口。读写访问均为单周期。读取的数据会被寄存。最终架构如下:
目前我们已经完成了:取指单元(FU)、译码单元(DU)、寄存器文件。
后续部分,敬请期待。
https://chipmunklogic.com/digital-logic-design/designing-pequeno-risc-v-cpu-from-scratch-part-1-getting-hold-of-the-isa/
半导体精品公众号推荐
专注半导体领域更多原创内容
关注全球半导体产业动向与趋势
*免责声明:本文由作者原创。文章内容系作者个人观点,半导体行业观察转载仅为了传达一种不同的观点,不代表半导体行业观察对该观点赞同或支持,如果有任何异议,欢迎联系半导体行业观察。
今天是《半导体行业观察》为您分享的第4030期内容,欢迎关注。
『半导体第一垂直媒体』
实时 专业 原创 深度
公众号ID:icbank
喜欢我们的内容就点“在看”分享给小伙伴哦
大众证券报
2025-05-11
半导体行业观察
2025-05-11
半导体行业观察
2025-05-11
半导体行业观察
2025-05-11
半导体行业观察
2025-05-11
半导体行业观察
2025-05-11
证券之星资讯
2025-05-10
证券之星资讯
2025-05-09
证券之星资讯
2025-05-09