《计算机基础实践课》手写 CPU ——小记随笔
硬件语言筑基
一个芯片的内部电路是怎么样的?
一般情况下,你所接触到的处理器芯片,已经不是传统意义上的 CPU 了,比如在业界很有名的国产手机芯片华为麒麟 990 芯片。这样一款芯片,包含了 CPU 核、高速缓存、NPU、GPU、DDR、PMU 等模块。
而在芯片设计时,根据不同模块的功能特点,通常把它们分为数字电路模块和模拟电路模块。
模拟电路还是像早期的半导体电路那样,处理的是连续变化的模拟信号,所以只能用传统的电路设计方法。而数字电路处理的是已经量化的数字信号,往往用来实现特定的逻辑功能,更容易被抽象化,所以就产生了专门用于设计数字电路的硬件描述语言。
现在业界的 IEEE 标准主要有 VHDL 和 Verilog HDL 这两种硬件描述语言。在高层次数字系统设计领域,大部分公司都采用 Verilog HDL 完成设计,我们后面的实现也会用到 Verilog。
Verilog 代码和 C 语言、Java 等这些计算机编程语言有本质的不同,在可综合(这里的“可综合”和代码“编译”的意思差不多)的 Verilog 代码里,基本所有写出来的东西都对应着实际的电路。
芯片前端设计工程师写 Verilog 代码的目的,就是把一份电路用代码的形式表示出来,然后由计算机把代码转换为所对应的逻辑电路。
芯片如何设计?
这和 Verilog 语言的 module 模块化设计思想是一致的,上一层模块对下一层子模块进行例化,就像其他编程语言的函数调用一样。根据包含的子功能模块一直例化下去,最终就能形成 hierarchy 结构。
从一段代码入门 Verilog
以一个 4 位十进制计数器模块为例
module counter( //端口定义 input reset_n, //复位端,低有效 input clk, //输入时钟 output [3:0] cnt, //计数输出 output cout //溢出位 ); reg [3:0] cnt_r ; //计数器寄存器 always@(posedge clk or negedge reset_n) begin if(!reset_n) begin //复位时,计时归0 cnt_r <= 4'b0000 ; end else if (cnt_r==4'd9) begin //计时10个cycle时,计时归0 cnt_r <=4'b0000; end else begin cnt_r <= cnt_r + 1'b1 ; //计时加1 end end assign cout = (cnt_r==4'd9) ; //输出周期位 assign cnt = cnt_r ; //输出实时计时器 endmodule
模块结构
一个模块的定义是以关键字 module 开始,以 endmodule 结束。module 关键字后面跟的 counter 就是这个模块的名称。
- Verilog 模块的接口必须要指定它是输入信号还是输出信号。
- 输入信号用关键字 input 来声明,比如上面第 4 行代码的 input clk;
- 输出信号用关键字 output 来声明,比如代码第 5 行的 output [3:0] cnt;
- 还有一种既可以输入,又可以输出的特殊端口信号,这种双向信号,我们用关键字 inout 来声明。
数据类型
在可综合的 Verilog 代码里,基本所有写出来的东西都会对应实际的某个电路,Verilog 代码中定义的数据类型就能充分体现这一点。
reg [3:0] cnt_r;
寄存器 reg 类型表示抽象数据存储单元,它对应的就是一种寄存器电路。reg 默认初始值为 X(不确定值),换句话说就是,reg 电路在上电之后,输出高电平还是低电平是不确定的,一般是在系统复位信号有效时,给它赋一个确定值。比如例子中的 cnt_r,在复位信号 reset_n 等于低电平时,就会给 cnt_r 赋“0”值。
reg 类型只能在 always 和 inital 语句中被赋值,如果描述语句是时序逻辑,即 always 语句中带有时钟信号,寄存器变量对应为触发器电路。比如上述定义的 cnt_r,就是在带 clk 时钟信号的 always 块中被赋值,所以它对应的是触发器电路;如果描述语句是组合逻辑,即 always 语句不带有时钟信号,寄存器变量对应为锁存器电路。
wire [1:0] cout_w;
我们常说的电子电路,也叫电子线路,所以电路中的互连线是必不可少的。Verilog 代码用线网 wire 类型表示结构实体(例如各种逻辑门)之间的物理连线。wire 类型不能存储数值,它的值是由驱动它的元件所决定的。驱动线网类型变量的有逻辑门、连续赋值语句、assign 等。如果没有驱动元件连接到线网上,线网就默认为高阻态“Z”。
parameter SIZE = 2’b01;
为了提高代码的可读性和可维护性,Verilog 还定义了一种参数类型,通过 parameter 来声明一个标识符,用来代表一个常量参数,我们称之为符号常量,即标识符形式的常量。这个常量,实际上就是电路中一串由高低电平排列组成的一个固定数值。
数值表达
cnt_r <= 4’b0000;
给寄存器 cnt_r 赋以 4’b0000 的值。这个值怎么来的呢?其中的逻辑“0”低电平,对应电路接地(GND)。同样的,逻辑“1”则表示高电平,对应电路接电源 VCC。除此之外,还有特殊的“X”和“Z”值。逻辑“X”表示电平未知,输入端存在多种输入情况,可能是高电平,也可能是低电平;逻辑“Z”表示高阻态,外部没有激励信号,是一个悬空状态。
当然,为了代码的简洁明了,Verilog 可以用不同的格式,表示同样的数值。比如要表示 4 位宽的数值“10”,二进制写法为 4’b1010,十进制写法为 4’d10,十六进制写法为 4’ha。这里我需要特殊说明一下,数据在实际存储时还是用二进制,位宽表示储存时二进制占用宽度。
运算符
接下来我们看看 Verilog 的运算符,对于运算符,Verilog 和大部分的编程语言的表示方法是一样的。
但在硬件语言里,位运算符可能和一些高级编程语言不一样。其中包括 ~ & | ^(按位取反、按位与,按位或,以及异或);还有移位运算符,左移 << 和右移 >> ,在生成实际电路时,左移会增加位宽,右移位宽保存不变。
条件、分支、循环语句
写法和其它高级编程语言几乎一样,这里我们重点来对比不同之处,也就是用 Verilog 实现条件、分支语句有什么不同。
- 用 if 设计的语句所对应电路是有优先级的,也就是多级串联的 MUX 电路。
- 而 case 语句对应的电路是没有优先级的,是一个多输入的 MUX 电路。设计时,只要我们合理使用这两个语句,就可以优化电路时序或者节省硬件电路资源。
- 此外,还有循环语句,一共有 4 种类型,分别是 while,for,repeat 和 forever 循环。注意,循环语句只能在 always 块或 initial 块中使用。
过程结构
跳变沿
最能体现数字电路中时序逻辑的就是 always 语句了。always 语句块从 0 时刻开始执行其中的行为语句;每当满足设定的 always 块触发条件时,便再次执行语句块中的语句,如此循环反复。芯片设计师通常把 always 块的触发条件,设置为时钟信号的上升沿或者下降沿。这样,每次接收到一个时钟信号,always 块内的逻辑电路都会执行一次
just once
还有一种过程结构就是 initial 语句。它从 0 时刻开始执行,且内部逻辑语句只按顺序执行一次,多个 initial 块之间是相互独立的。理论上,initial 语句是不可以综合成实际电路的,多用于初始化、信号检测等,也就是在编写验证代码时使用。
Verilog 代码编写
算术逻辑单元(Arithmetic&logical Unit,ALU)是 CPU 的执行单元,是所有中央处理器的核心组成部分。
利用 Verilog,我们可以设计一个包含加、减、与、或、非等功能的简单 ALU 模块,代码如下:
module alu(a, b, cin, sel, y); input [7:0] a, b; input cin; input [3:0] sel; output [7:0] y; reg [7:0] y; reg [7:0] arithval; reg [7:0] logicval; // 算术执行单元 always @(a or b or cin or sel) begin case (sel[2:0]) 3'b000 : arithval = a; 3'b001 : arithval = a + 1; 3'b010 : arithval = a - 1; 3'b011 : arithval = b; 3'b100 : arithval = b + 1; 3'b101 : arithval = b - 1; 3'b110 : arithval = a + b; default : arithval = a + b + cin; endcase end // 逻辑处理单元 always @(a or b or sel) begin case (sel[2:0]) 3'b000 : logicval = ~a; 3'b001 : logicval = ~b; 3'b010 : logicval = a & b; 3'b011 : logicval = a | b; 3'b100 : logicval = ~((a & b)); 3'b101 : logicval = ~((a | b)); 3'b110 : logicval = a ^ b; default : logicval = ~(a ^ b); endcase end // 输出选择单元 always @(arithval or logicval or sel) begin case (sel[3]) 1'b0 : y = arithval; default : y = logicval; endcase end endmodule
- 上一个例子中我们一起研究了一个 4 位 10 进制的计算器,里面用到了时钟设计。也就是说,这个计算器是通过时序逻辑实现的,所以 always 块中的赋值语言使用了非阻塞赋值“<=”。
- 这个例子中实现的 ALU 模块,用到的是组合逻辑,所以 always 块中使用阻塞赋值“=”。
怎么区分阻塞赋值和非阻塞赋值呢?阻塞赋值对应的电路结构往往与触发沿没有关系,只与输入电平的变化有关;而非阻塞赋值对应的电路结构往往与触发沿有关系,只有在触发沿时,才有可能发生赋值的情况。
PS:另外,算术执行单元和逻辑处理单元的两个 always 块是并行执行的,需要留意一下。
如何通过仿真验证代码
现在很多企业采用的是 VCS—verilog 仿真器或者是 NC-verilog 仿真器,这些工具都需要花重金去购买才能使用,普通人用起来成本太高了。
除了重金购买这些 EDA 工具之外,我们还有更节约成本、也更容易学习入门的选择。我给你推荐两个轻量级开源软件,分别是 Iverilog 和 GTKWave。
- Iverilog 是一个对 Verilog 进行编译和仿真的工具,
- GTKWave 是一个查看仿真数据波形的工具。
代码是如何生成具体电路的?
通过逻辑综合,我们就能完成从 Verilog 代码到门级电路的转换。而逻辑综合的结果,就是把设计的 Verilog 代码,翻译成门级网表 Netlist。
逻辑综合需要基于特定的综合库,不同的库中,门电路基本标准单元(Standard Cell)的面积、时序参数是不一样的。所以,选用的综合库不一样,综合出来的电路在时序、面积上也不同。因此,哪怕采用同样的设计,选用台湾的台积电(TSMC)工艺和上海的中芯国际(SMIC)的工艺,最后生产出来的芯片性能也是有差异的。
通常,工业界使用的逻辑综合工具有 Synopsys 的 Design Compiler(DC),Cadence 的 RTL Compiler,Synplicity 的 Synplify 等。然而,这些 EDA 工具都被国外垄断了,且需要收取高昂的授权费用。
这里我们选择 Yosys,它是一个轻量级开源综合工具。虽然功能上还达不到工业级的 EDA 工具,但是对于我们的学习已经完全够用了。
指令架构
指令集可以说是一个 CPU 的基石,要实现 CPU 的计算和控制功能,就必须要定义好一系列与硬件电路相匹配的指令系统。所以,在设计 CPU 之初,工程师就应该清楚 CPU 应该具有怎样的指令架构。
什么是指令集?
我给你打个比方:假如你有一条狗,经过一段时间的训练,它能“听懂”了你对它说一些话。当你对它说“坐下”,它就乖乖地坐在地上;当你对它说“汪汪叫”;它就汪汪汪地叫起来,当你对它说“躺下”,它马上就会躺下来……这里你说的“坐下”、“汪汪叫”、“躺下”这些命令,就相当于计算机世界里的指令。
不同的 CPU 有不同的指令集,根据它们的繁简程度可以分为两种:复杂指令集 CISC 和精简指令集 RISC。指令集架构(英文叫 Instruction Set Architecture,缩写为 ISA)是软件和硬件的接口,不同的应用需求,会有不同的指令架构。我们要想设计一款 CPU,指令集体系就是设计的出发点。
RISC-V 指令集架构
在开始设计一款处理器之前,我们需要选定它的指令集架构。RISC-V 指令集具有明显的优势:
- RISC-V 完全开放
- RISC-V 指令简单
- RISC-V 实行模块化设计,易于扩展
要满足现代操作系统和应用程序的基本运行,RV32G 指令集或者 RV64G 指令集就够了(G 是通用的意思 ,而 I 只是整数指令集,G 包含 I),注意 RV32G 指令集或者 RV64G 指令集,只有寄存器位宽和寻址空间大小不同。
我们先从 RISC-V 的核心开始。它最核心的部分是一个基础整数指令集,叫做 RV32I。RV32I 包含的指令是固定的,永远不会改变。这为编译器设计人员,操作系统开发人员和汇编语言程序员提供了稳定的基础知识框架。
从图中我们可以看到,有些字母带有下划线。我们把带有下划线的字母从左到右连接起来,就可以组成一个 RV32I 的指令。对于每一个指令名称,集合标志{}内列举了指令的所有变体,变体用加下划线的字母或下划线字符 _ 表示。如果大括号内只有下划线字符 _,则表示对于此指令变体不需用字母表示。
指令格式
我们先从 RV32I 的指令格式说起。从下图可以看到,RISCV 总共也就只有 6 种指令格式。
六种指令各司其职,我把它们的作用整理成了表格,这样你看起来一目了然。
不要小看这些指令,我们来分析一下它们到底有哪些优势。
- 这些指令格式规整有序,结构简单。因为指令只有六种格式,并且所有的指令都是 32 位长度的,所以这些指令解码起来就比较简单,可以简化解码电路,提高 CPU 的性能功耗比。
- RISC-V 的一个指令中可以提供三个寄存器操作数,而不是像 x86 一样,让源操作数和目的操作数共享一个字段,因此相比 x86 指令,RISC-V 减少了软件的程序操作。
- 这些指令格式的所有立即数的符号位总是在指令的最高位,它意味着,有可能成为关键路径的立即数符号扩展,可以在指令解码前进行。这样可以加速符号扩展电路,有利于 CPU 流水线的时序优化。
RV32I 寄存器
在 RISC-V 的规范里定义了 32 个通用寄存器。其中,有 31 个是常规寄存器,1 个恒为 0 值的 x0 寄存器。0 值寄存器的设置,是为了满足汇编语言程序员和编译器编写者的使用需要,他们可以使用 x0 寄存器作为操作数,来完成功能相同的操作。
比如说,我们如果需要插入一个空操作,就可以使用汇编语句 “addi x0 , x0, 0 ”(相当于 0+0=0)来代替其他指令集中的 nop 空指令。
由于访问寄存器中的数据要比访问存储器的速度快得多,一般每条 RISC-V 指令最多用一个时钟周期执行(忽略缓存未命中的情况),而 ARM-32 或者 x86-32 则需要多个时钟周期执行的指令。因为 ARM-32 只有 16 个寄存器,而 X86-32 仅仅只有 8 个寄存器。因此,寄存器越多,编译器和汇编程序员的工作就会越轻松。
RV32I 的各类指令解读
算术与逻辑指令
在 RV32I 的指令中,包括算术指令(add, sub)、数值比较指令(slt)、逻辑指令(and, or, xor)以及移位指令 (sll, srl, sra)这几种指令。
这些指令和其他指令集差不多,它们从寄存器读取两个 32 位的值,并将 32 位的运算结果再写回到目标寄存器。RV32I 还提供了这些指令的立即数版本,就是如下图所示的 I 型指令:
RV32I 也提供了寄存器和寄存器操作的指令,包括加减运算、数值比较、逻辑操作和移位操作。这些指令的功能和前面的立即数指令相似,不同的是,这里把指令中的立即数对应位置替换成了源寄存器 rs2。
需要指出的是,在寄存器和寄存器操作的算术指令中,必须要有减法指令,这和立即数操作指令有所不同。
RV32I 的 Load 和 Store
与 CISC 指令集具有众多的寻址方式不同,RV32I 省略了像 x86-32 指令集那样的复杂寻址模式。在 RISC-V 指令集中,对内存的读写只能通过 LOAD 指令和 STORE 指令实现。而其他的指令,都只能以寄存器为操作对象。
如上图所示,加载和存储的寻址模式只能是符号扩展 12 位的立即数,加上基地址寄存器得到访问的存储器地址。因为没有了复杂的内存寻址方式,这让 CPU 流水线可以对数据冲突提前做出判断,并通过流水线各级之间的转送加以处理,而不需要插入空操作(NOP),极大提高了代码的执行效率。
分支跳转指令
基本指令只有 40 多条,其中只有 6 条有条件跳转指令,减少了跳转指令的条数,这样硬件设计上更为简单。
有条件分支跳转
RV32I 中的条件跳转指令是通过比较两个寄存器的值,并根据比较结果进行分支跳转。比较可以是:相等(beq),不相等 (bne),大于等于(bge),或小于(blt)
无条件分支跳转
无条件跳转指令还可以细分为直接跳转和间接跳转这两种指令。
直接跳转指令 JAL 如下图所示。RISC-V 为 JAL 指令专门定义了 J-TYPE 格式。
JAL 指令的执行过程是这样的。首先,它会把 20 位立即数做符号位扩展,并左移一位,产生一个 32 位的符号数。然后,将该 32 位符号数和 PC 相加来产生目标地址(这样,JAL 可以作为短跳转指令,跳转至 PC±1 MB 的地址范围内)。同时,JAL 也会把紧随其后的那条指令的地址,存入目标寄存器中。这样,如果目标寄存器是零,则 JAL 就等同于 GOTO 指令;如果目标寄存器不为零,JAL 可以实现函数调用的功能。
间接跳转指令 JALR 如上图所示。JALR 指令会把 12 位立即数和源寄存器相加,并把相加的结果末位清零,作为新的跳转地址。同时,和 JAL 指令一样,JALR 也会把紧随其后的那条指令的地址,存入到目标寄存器中。
RV32I 的其他指令
除了内存地址空间和通用寄存器地址空间外,RISC-V 中还定义了一个独立的控制与状态寄存器(Control Status Register,CSR)地址空间。
每个处理器实现的 CSR 会因设计目标不同而有差异,但这些 CSR 的访问方式却是一致的,访问这些 CSR 的指令定义在了用户指令集中(Zicsr 指令集扩展)。
有了上图这些 CSR 指令,能够让我们轻松访问一些程序性能计数器。这些计数器包括系统时间、时间周期以及执行的指令数目。
手写CPU(一):迷你CPU架构设计与取指令实现
什么是 CPU 流水线?
在 CPU 中是使用流水线作业。以经典的五级流水线为例,流水线中一条指令的生命周期分为五个阶段:
- 取指阶段(Instruction Fetch):取指阶段是指将指令从存储器中读取出来的过程。程序指针寄存器用来指定当前指令在存储器中的位置。读取一条指令后,程序指针寄存器会根据指令的长度自动递增,或者改写成指定的地址。
- 译码阶段(Instruction Decode):指令译码是指将存储器中取出的指令进行翻译的过程。指令译码器对指令进行拆分和解释,识别出指令类别以及所需的各种操作数。
- 执行阶段(Instruction Execute):指令执行是指对指令进行真正运算的过程。例如指令是一条加法运算指令,则对操作数进行相加操作;如果是一条乘法运算指令,则进行乘法运算。在“执行”阶段最关键的模块为算术逻辑单元(Arithmetic Logical Unit,ALU),它是实施具体运算的硬件功能单元。
- 访存阶段(Memory Access):访存是指存储器访问指令将数据从存储器中读出,或写入存储器的过程。
- 写回阶段(Write-Back):写回是指将指令执行的结果写回通用寄存器的过程。如果是普通运算指令,该结果值来自于“执行”阶段计算的结果;如果是存储器读指令,该结果来自于“访存”阶段从存储器中读取出来的数据。
MiniCPU 的架构
先明确一下我们想实现的目标:使用 Verilog 硬件描述语言,基于 RV32I 指令集,设计一个 32 位的经典五级流水线的处理器核。它将会支持运行大多数 RV32I 的基础指令。
那什么样的架构设计才能实现这个目标呢?参照 CPU 流水线的五个步骤,我们可以对处理器核的各个功能模块进行划分,主要模块包括指令提取单元、指令译码单元、整型执行单元、访问存储器和写回结果等单元模块。
从图中可以看到,我们要设计的不仅仅是一个 CPU 内核了,它更像是一个 SOC(System on Chip 的缩写)。
因为我们要对它进行一些仿真验证,就必须要包含存放指令、数据的 ROM 和 RAM,还有一些简单的外设。比如用于串口通信的 UART 以及一些通用输入、输出端口 GPIO 都属于外设。CPU 通过系统总线(System Bus)和这些外设进行通信。
在我们这个 CPU 架构中,体现五级流水线的主要模块有哪些。
- pre_if 模块,这里我把它叫作分支预测或者预读取模块,因为它主要是先对上一个指令进行预处理,判断是不是分支跳转指令。如果是跳转指令,则产生跳转后的 PC 值,并对下一条指令进行预读取。
- if_id 模块,取指通路模块,它是取指到译码之间的模块,上面的指令预读取之后就会首先送入 if_id 模块,如果当前流水线没有发出指令清除信号,if_id 模块就会把指令送到译码模块。
- id_ex 模块,它是译码到执行之间的模块,用于将完成指令译码之后的寄存器索引值,以及指令执行的功能信息,根据流水线控制模块的控制信号,选择性地发送给执行模块去执行。
- ex_mem 模块负责指令执行之后将数据写入存储器中或者从存储器中读出数据的过程。
- mem_wb 模块将指令执行的运算结果或者从存储器读出的数据,写回到通用寄存器.
流水线的第一步:指令预读取
由于我们的指令长度是 32 位的,也就是一条指令在存储器中占有 4 个字节的空间,所以一般情况下,CPU 中的程序计数器(PC)是以 4 递增的。
预读取模块 verilog 代码展示
module pre_if ( input [31:0] instr, input [31:0] pc, output [31:0] pre_pc ); wire is_bxx = (instr[6:0] == `OPCODE_BRANCH); //条件跳转指令的操作码 wire is_jal = (instr[6:0] == `OPCODE_JAL) ; //无条件跳转指令的操作码 //B型指令的立即数拼接 wire [31:0] bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0}; //J型指令的立即数拼接 wire [31:0] jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0}; //指令地址的偏移量, bimm[31] 为1 表示成立即跳转,否则就读取下个指令 wire [31:0] adder = is_jal ? jimm : (is_bxx & bimm[31]) ? bimm : 4; assign pre_pc = pc + adder; endmodule
条件跳转指令执行时是否发生跳转,要根据相关的数据来判断,这就需要指令执行之后才能知道是否需要跳转(具体如何判断,我们后面第十节课再展开)。
但是,我们的 CPU 是多级流水线架构,一条指令执行需要多个时钟周期。如果要等到跳转指令执行完成之后再去取下一条指令,就会降低我们的指令执行效率。而指令预读取模块刚好可以解决这个问题。不管指令是否跳转,都提前把跳转之后的下一条指令从存储器中读取出来,以备流水线的下一阶段使用,这就提高了 CPU 的执行效率。
取指数据通路模块
由上述的指令预读取模块把指令从存储器中读取之后,需要把它发送给译码模块进行翻译。但是,预读取模块读出的指令,并不是全部都能发送后续模块去执行。例如上面的条件分支指令,在指令完成之前就把后续的指令预读取出来了。如果指令执行之后发现跳转的条件不成立,这时预读取的指令就是无效的,需要对流水线进行冲刷(flush),把无效的指令都清除掉。
取指通路模块 if_id 主要产生 3 个信号。首先是给后面解码模块提供的指令信号 reg_instr。如果流水线没有发生冲突,也就是没有发出清除信号 flush,则把预读取的指令保存,否则把指令清“0”。
//指令通路 always @(posedge clock) begin if (reset) begin reg_instr <= 32'h0; end else if (flush) begin reg_instr <= 32'h0; end else if (valid) begin reg_instr <= in_instr; end end
第二个是更新 PC 值,如果指令清除信号 flush=“0”,则把当前指令对应的 PC 值保存为 reg_pc,否则就把 reg_pc 清“0”。
//PC值通路 always @(posedge clock) begin"" if (reset) begin reg_pc <= 32'h0; end else if (flush) begin reg_pc <= 32'h0; end else if (valid) begin reg_pc <= in_pc; end end
最后一个是流水线冲刷的标志信号 reg_noflush。当需要进行流水线冲刷时,reg_noflush=“0”,否则 reg_noflush=“1”。
//流水线冲刷标志位 always @(posedge clock) begin if (reset) begin reg_noflush <= 1'h0; end else if (flush) begin reg_noflush <= 1'h0; end else if (valid) begin reg_noflush <= in_noflush; end end
以下就是 if_id 模块的完整代码:
// IF_ID module if_id( input clk, input reset, input [31:0] in_instr, input [31:0] in_pc, input flush, input valid, output [31:0] out_instr, output [31:0] out_pc, output out_noflush ); reg [31:0] reg_instr; reg [31:0] reg_pc; reg [31:0] reg_pc_next; reg reg_noflush; assign out_instr = reg_instr; assign out_pc = reg_pc; assign out_noflush = reg_noflush; //指令传递 always @(posedge clk or posedge reset) begin if (reset) begin reg_instr <= 32'h0; end else if (flush) begin reg_instr <= 32'h0; end else if (valid) begin reg_instr <= in_instr; end end //PC值转递 always @(posedge clk or posedge reset) begin if (reset) begin reg_pc <= 32'h0; end else if (flush) begin reg_pc <= 32'h0; end else if (valid) begin reg_pc <= in_pc; end end //流水线冲刷标志位 always @(posedge clk or posedge reset) begin if (reset) begin reg_noflush <= 1'h0; end else if (flush) begin reg_noflush <= 1'h0; end else if (valid) begin reg_noflush <= 1'h1; end end endmodule
好了,到这里 CPU 流水线的第一步——取指,我们就讲完了。在取指阶段就是把存储器里的指令读出,并传递给后续的译码模块进行处理。
手写CPU(二):如何实现指令译码模块?
指令是如何翻译的?
RISC-V 指令架构,明确了我们的 MiniCPU 选用的是 RV32I 指令集。其中每条指令都是 32 位,且分为 6 种指令格式,不同格式的指令中包含了不一样的指令信息。
不过指令格式不同,指令译码模块翻译指令的工作机制却是统一的。首先译码电路会翻译出指令中携带的寄存器索引、立即数大小等执行信息。接着,在解决数据可能存在的数据冒险(这个概念后面第九节课会讲)之后,由译码数据通路负责把译码后的指令信息,发送给对应的执行单元去执行。
译码模块的设计
通过上面的分析,你是否对译码模块的设计已经有了头绪?是的,译码模块就是拆解从取指模块传过来的每一条指令。译码时,需要识别出指令的操作码,并根据对应的指令格式提取出指令中包含的信息。
module decode ( input [31:0] instr, //指令源码 output [4:0] rs1_addr, //源寄存器rs1索引 output [4:0] rs2_addr, //源寄存器rs2索引 output [4:0] rd_addr, //目标寄存器rd索引 output [2:0] funct3, //功能码funct3 output [6:0] funct7, //功能码funct7 output branch, output [1:0] jump, output mem_read, output mem_write, output reg_write, output to_reg, output [1:0] result_sel, output alu_src, output pc_add, output [6:0] types, output [1:0] alu_ctrlop, output valid_inst, output [31:0] imm ); localparam DEC_INVALID = 21'b0; reg [20:0] dec_array; //---------- decode rs1、rs2 ----------------- assign rs1_addr = instr[19:15]; assign rs2_addr = instr[24:20]; //---------- decode rd ----------------------- assign rd_addr = instr[11:7]; //---------- decode funct3、funct7 ----------- assign funct7 = instr[31:25]; assign funct3 = instr[14:12]; // ----------------------------- decode signals --------------------------------- // 20 19-18 17 16 15 14 13-12 11 10 9--------3 2---1 0 // branch jump memRead memWrite regWrite toReg resultSel aluSrc pcAdd RISBUJZ aluctrlop validInst localparam DEC_LUI = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b01, 1'b0, 1'b0, 7'b0000100, 2'b00, 1'b1}; localparam DEC_AUIPC = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b00, 1'b1, 1'b1, 7'b0000100, 2'b00, 1'b1}; localparam DEC_JAL = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b10, 1'b0, 1'b0, 7'b0000010, 2'b00, 1'b1}; localparam DEC_JALR = {1'b0, 2'b11, 1'b0, 1'b0, 1'b1, 1'b0, 2'b10, 1'b1, 1'b0, 7'b0100000, 2'b00, 1'b1}; localparam DEC_BRANCH = {1'b1, 2'b00, 1'b0, 1'b0, 1'b0, 1'b0, 2'b00, 1'b0, 1'b0, 7'b0001000, 2'b10, 1'b1}; localparam DEC_LOAD = {1'b0, 2'b00, 1'b1, 1'b0, 1'b1, 1'b1, 2'b00, 1'b1, 1'b0, 7'b0100000, 2'b00, 1'b1}; localparam DEC_STORE = {1'b0, 2'b00, 1'b0, 1'b1, 1'b0, 1'b0, 2'b00, 1'b1, 1'b0, 7'b0010000, 2'b00, 1'b1}; localparam DEC_ALUI = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b00, 1'b1, 1'b0, 7'b0100000, 2'b01, 1'b1}; localparam DEC_ALUR = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b00, 1'b0, 1'b0, 7'b1000000, 2'b01, 1'b1}; assign {branch, jump, mem_read, mem_write, reg_write, to_reg, result_sel, alu_src, pc_add, types, alu_ctrlop, valid_inst} = dec_array; always @(*) begin case(instr[6:0]) `OPCODE_LUI : dec_array <= DEC_LUI; `OPCODE_AUIPC : dec_array <= DEC_AUIPC; `OPCODE_JAL : dec_array <= DEC_JAL; `OPCODE_JALR : dec_array <= DEC_JALR; `OPCODE_BRANCH : dec_array <= DEC_BRANCH; `OPCODE_LOAD : dec_array <= DEC_LOAD; `OPCODE_STORE : dec_array <= DEC_STORE; `OPCODE_ALUI : dec_array <= DEC_ALUI; `OPCODE_ALUR : dec_array <= DEC_ALUR; default : begin dec_array <= DEC_INVALID; end endcase end // -------------------- IMM ------------------------- wire [31:0] Iimm = {{21{instr[31]}}, instr[30:20]}; wire [31:0] Simm = {{21{instr[31]}}, instr[30:25], instr[11:7]}; wire [31:0] Bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0}; wire [31:0] Uimm = {instr[31:12], 12'b0}; wire [31:0] Jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0}; assign imm = {32{types[5]}} & Iimm | {32{types[4]}} & Simm | {32{types[3]}} & Bimm | {32{types[2]}} & Uimm | {32{types[1]}} & Jimm; endmodule
整个代码可以分为三个部分:
- 第 28 行到 37 行负责完成指令的源寄存器、目标寄存器、3 位操作码和 7 位操作码的译码,
- 第 40 行至 73 行负责完成指令格式类型的识别
- 第 75 行至 87 行负责完成立即数译码。
RISC-V 寄存器和操作码在不同指令都是固定的,方便译码器实现。
从上面的代码我们可以看到,译码的过程就是先识别指令的低 7 位操作码 instr[6:0],根据操作码对应的代码标识,产生分支信号 branch、跳转信号 jump、读存储器信号 mem_read……这些译码之后的指令控制信息。然后,把译码得到的信息交到 CPU 流水线的下一级去执行。
译码控制模块设计
前面的译码模块得到的指令信号,可以分为两大类。
- 一类是由指令的操作码经过译码后产生的指令执行控制信号,如跳转操作 jump 信号、存储器读取 mem_read 信号等;
- 另一类是从指令源码中提取出来的数据信息,如立即数、寄存器索引、功能码等。
为了能对流水线更好地实施控制,这里我们需要把译码后的数据和控制信号分开处理。首先来看译码控制模块的实现:
module id_ex_ctrl( input clk, input reset, input in_ex_ctrl_itype, input [1:0] in_ex_ctrl_alu_ctrlop, input [1:0] in_ex_ctrl_result_sel, input in_ex_ctrl_alu_src, input in_ex_ctrl_pc_add, input in_ex_ctrl_branch, input [1:0] in_ex_ctrl_jump, input in_mem_ctrl_mem_read, input in_mem_ctrl_mem_write, input [1:0] in_mem_ctrl_mask_mode, input in_mem_ctrl_sext, input in_wb_ctrl_to_reg, input in_wb_ctrl_reg_write, input in_noflush, input flush, input valid, output out_ex_ctrl_itype, output [1:0] out_ex_ctrl_alu_ctrlop, output [1:0] out_ex_ctrl_result_sel, output out_ex_ctrl_alu_src, output out_ex_ctrl_pc_add, output out_ex_ctrl_branch, output [1:0] out_ex_ctrl_jump, output out_mem_ctrl_mem_read, output out_mem_ctrl_mem_write, output [1:0] out_mem_ctrl_mask_mode, output out_mem_ctrl_sext, output out_wb_ctrl_to_reg, output out_wb_ctrl_reg_write, output out_noflush ); reg reg_ex_ctrl_itype; reg [1:0] reg_ex_ctrl_alu_ctrlop; reg [1:0] reg_ex_ctrl_result_sel; reg reg_ex_ctrl_alu_src; reg reg_ex_ctrl_pc_add; reg reg_ex_ctrl_branch; reg [1:0] reg_ex_ctrl_jump; reg reg_mem_ctrl_mem_read; reg reg_mem_ctrl_mem_write; reg [1:0] reg_mem_ctrl_mask_mode; reg reg_mem_ctrl_sext; reg reg_wb_ctrl_to_reg; reg reg_wb_ctrl_reg_write; reg reg_noflush; ……………… //由于这里的代码较长,结构相似,这里省略了一部分 always @(posedge clk or posedge reset) begin if (reset) begin reg_noflush <= 1'h0; end else if (flush) begin reg_noflush <= 1'h0; end else if (valid) begin reg_noflush <= in_noflush; end end endmodule
上一节课学习取指模块的时候我们说过,并不是所有从存储器中读取出来的指令,都能够给到执行单元去执行的。比如,当指令发生冲突时,需要对流水线进行冲刷,这时就需要清除流水线中的指令。同样的,译码阶段的指令信号也需要清除。
译码控制模块就是为了实现这一功能,当指令清除信号 flush 有效时,把译码模块产生的 jump、branch、mem_read、mem_write、reg_write……这些控制信号全部清“0”。否则,就把这些控制信号发送给流水线的下一级进行处理。
译码数据通路模块设计
和译码模块类似,译码数据通路模块会根据 CPU 相关控制模块产生的流水线冲刷控制信号,决定要不要把这些数据发送给后续模块。其中,译码得到的数据信息包括立即数 imm、源寄存器索引 rs1 和 rs2、目标寄存器索引 rd 以及功能码 funct3 和 funct7。具体的设计代码如下所示:
module id_ex( input clk, input reset, input [4:0] in_rd_addr, input [6:0] in_funct7, input [2:0] in_funct3, input [31:0] in_imm, input [31:0] in_rs2_data, input [31:0] in_rs1_data, input [31:0] in_pc, input [4:0] in_rs1_addr, input [4:0] in_rs2_addr, input flush, input valid, output [4:0] out_rd_addr, output [6:0] out_funct7, output [2:0] out_funct3, output [31:0] out_imm, output [31:0] out_rs2_data, output [31:0] out_rs1_data, output [31:0] out_pc, output [4:0] out_rs1_addr, output [4:0] out_rs2_addr ); reg [4:0] reg_rd_addr; reg [6:0] reg_funct7; reg [2:0] reg_funct3; reg [31:0] reg_imm; reg [31:0] reg_rs2_data; reg [31:0] reg_rs1_data; reg [31:0] reg_pc; reg [4:0] reg_rs1_addr; reg [4:0] reg_rs2_addr; ………… //由于代码较长,结构相似,这里省略了一部分,完整代码你可以从Gitee上获取 always @(posedge clk or posedge reset) begin if (reset) begin reg_rs2_addr <= 5'h0; end else if (flush) begin reg_rs2_addr <= 5'h0; end else if (valid) begin reg_rs2_addr <= in_rs2_addr; end end endmodule
我们以目标寄存器的索引地址 reg_rd_addr 信号为例,分析一下它是怎么流通的。当流水线冲刷信号 flush 有效时,目标寄存器的索引地址 reg_rd_addr 直接清“0”,否则当信号有效标志 valid 为“1”时,把目标寄存器的索引地址传递给流水线的下一级。
always @(posedge clk or posedge reset) begin if (reset) begin reg_rd_addr <= 5'h0; end else if (flush) begin reg_rd_addr <= 5'h0; end else if (valid) begin reg_rd_addr <= in_rd_addr; end end
手写CPU(三):如何实现指令执行模块
CPU 的执行概述
回顾前面我们已经设计完成的 CPU 流水线步骤:
- 取指模块根据程序计数器(PC)寻址到指令所在的存储单元,并从中取出指令。
- 译码模块对取出的指令进行翻译,得到功能码、立即数、寄存器索引等字段,然后根据某些字段读取一个或两个通用寄存器的值。
经过流水线的这两个步骤之后,下一步就需要把这些指令信息发送给执行单元去执行相关操作。根据译码之后的指令信息,我们可以把指令分为三类,分别是算术逻辑指令、分支跳转指令、存储器访问指令。
那便开始设计我们的核心模块 —— ALU (算数逻辑单元)
在 ALU 模块中,指令可以分成三类来处理:
- 第一类是普通的 ALU 指令,包括逻辑运算、移位操作等指令;
- 第二类指令负责完成存储器访问指令 Load 和 Store 的地址生成工作;
- 第三类是负责分支跳转指令的结果解析和执行。
执行控制模块的设计
我们在译码模块里根据指令的 7 位操作码 opcode 字段,还产生了一个 ALU 执行的指令控制字段 aluCrtlOp。这正是上文提到的 ALU 模块把指令分成三类执行的控制信号。
根据 2 位执行类型字段 aluCrtlOp,以及指令译码得到的操作码 funct7 和 funct3,就可以设计我们的执行控制模块了。
module ALUCtrl ( input [2:0] funct3, input [6:0] funct7, input [1:0] aluCtrlOp, input itype, output reg [3:0] aluOp ); always @(*) begin case(aluCtrlOp) 2'b00: aluOp <= `ALU_OP_ADD; // Load/Store // 当 aluCtrlOp 等于(01)时,需要根据 funct3 和 funct7 产生 ALU 的操作码。在前面的译码模块中,已经为我们提供了 I 型指令类型的判断信号 itype。如果是 itype 信号等于“1”,操作码直接由 funct3 和高位补“0”组成;如果不是 I 型指令,ALU 操作码则要由 funct3 和 funct7 的第五位组成。 2'b01: begin if(itype & funct3[1:0] != 2'b01) aluOp <= {1'b0, funct3}; else aluOp <= {funct7[5], funct3}; // normal ALUI/ALUR end 2'b10: begin case(funct3) // bxx `BEQ_FUNCT3: aluOp <= `ALU_OP_EQ; `BNE_FUNCT3: aluOp <= `ALU_OP_NEQ; `BLT_FUNCT3: aluOp <= `ALU_OP_SLT; `BGE_FUNCT3: aluOp <= `ALU_OP_GE; `BLTU_FUNCT3: aluOp <= `ALU_OP_SLTU; `BGEU_FUNCT3: aluOp <= `ALU_OP_GEU; default: aluOp <= `ALU_OP_XXX; endcase end default: aluOp <= `ALU_OP_XXX; endcase end endmodule
通用寄存器
在 ALU 模块开始执行运算之前,我们还需要提前完成一个操作——读取通用寄存器。在参与 ALU 逻辑运算的两个操作数中,至少有一个来自于通用寄存器,另一个可以来自于通用寄存器或者指令自带的立即数,如下图所示:
每读取一个寄存器,就需要输入一个寄存器索引,并输出一个通用寄存器中的值。两个操作数对应的寄存器需要同时读取,所以通用寄存器模块需要两个读地址接口和两个读数据输出接口。
此外,处于流水线上的指令是并发执行的,在读取通用寄存器的同时,可能还需要写入数据到通用寄存器,所以需要一套写地址和写数据接口。因此,通用寄存器模块的设计框图如下:
module gen_regs ( input clk, input reset, input wen, input [4:0] regRAddr1, regRAddr2, regWAddr, input [31:0] regWData, output [31:0] regRData1, output [31:0] regRData2 ); integer ii; reg [31:0] regs[31:0]; // write registers always @(posedge clk or posedge reset) begin if(reset) begin for(ii=0; ii<32; ii=ii+1) regs[ii] <= 32'b0; end else if(wen & (|regWAddr)) regs[regWAddr] <= regWData; end // read registers // 这里添加了一个写控制使能信号 wen。因为写寄存器是边沿触发的,在一个时钟周期内写入的寄存器数据,需要在下一个时钟周期才能把写入的数据读取出来。为了提高读写效率,在对同一个寄存器进行读写时,如果写使能 wen 有效,就直接把写入寄存器的数据送给读数据接口,这样就可以在一个时钟周期内,读出当前要写入的寄存器数据了。 assign regRData1 = wen & (regWAddr == regRAddr1) ? regWData : ((regRAddr1 != 5'b0) ? regs[regRAddr1] : 32'b0); assign regRData2 = wen & (regWAddr == regRAddr2) ? regWData : ((regRAddr2 != 5'b0) ? regs[regRAddr2] : 32'b0); endmodule
通用寄存器总共有 32 个,所以通用寄存器模块上的读写地址都是 5 位(25=32)。
其中,还有一个寄存器比较特殊,从代码中也可以看到它的特殊处理,即读地址 regRAddr1 = 5’b0 时的寄存器。我们把第一个寄存器叫做 0 值寄存器,因为在 RISC-V 指令架构中就规定好了,第一个通用寄存器必须编码为 0,也就是把写入该寄存器的数据忽略,而在读取时永远输出为 0。
ALU 模块设计
上述执行控制模块根据三类指令产生的 ALU 操作信号 aluOp,在 ALU 模块就能以此为依据,执行相应的运算了。操作码对应的 ALU 操作如下表所示:
根据表格中的操作编码和对应的运行操作,很容易就可以设计出 ALU 模块,具体的设计代码如下:
module alu ( input [31:0] alu_data1_i, input [31:0] alu_data2_i, input [ 3:0] alu_op_i, output [31:0] alu_result_o ); reg [31:0] result; // 两个源操作数的和,不过当运算码 aluOp 的第 3 位和第 1 位为“1”时做的是相减运算,这是为减法指令或者后面的比较大小而准备的运算。你可以对照上面的 ALU 运算表格来理解。 wire [31:0] sum = alu_data1_i + ((alu_op_i[3] | alu_op_i[1]) ? -alu_data2_i : alu_data2_i); // 比较两个操作数是否相等,这就是根据前面的两个操作相减的结果判断,如果它们的差不为“0”,也就是 sum 信号按位与之后不为“0”,则表示两个操作数不相等。 wire neq = |sum; // 两个操作数的大小比较,如果它们的最高位(也就是符号位)相等,则根据两个操作数相减的差值的符号位(也是数值的最高位)判断。如果是正数,表示源操作数 1 大于源操作数 2,否则表示源操作数 1 小于源操作数 2。 // 如果它们的最高位不相等,则根据 ALU 运算控制码 aluOp 的最低位判断。如果 aluOp 最低位为“1”,表示是无符号数比较,直接取操作数 2 的最高位作为比较结果。如果 aluOp 最低位为“0”,表示是有符号数比较,直接取操作数 1 的最高位作为比较结果。 // 这边的实现是根据真值表 + 卡诺图简化的方式简化得出的组合电路翻译成代码描述,所以不一定符合人线性思维。 wire cmp = (alu_data1_i[31] == alu_data2_i[31]) ? sum[31] : alu_op_i[0] ? alu_data2_i[31] : alu_data1_i[31]; // 取自源操作数 2 的低五位,表示源操作数 1 需要移多少位(32)。 wire [ 4:0] shamt = alu_data2_i[4:0]; // shin 信号是取出要移位的数值,根据 aluOp 判断是左移还是右移,如果是右移就直接等于源操作数 1,如果是左移就先对源操作数的各位数做镜像处理。 wire [31:0] shin = alu_op_i[2] ? alu_data1_i : reverse(alu_data1_i); // shift 信号是根据 aluOp 判断是算术右移还是逻辑右移,如果是算术右移,则在最高位补一个符号位 wire [32:0] shift = {alu_op_i[3] & shin[31], shin}; // shiftt 信号是右移之后的结果,这里用到了$signed() 函数对移位前的数据 shift 进行了修饰,$signed() 的作用是决定如何对操作数扩位这个问题。具体的过程是,在右移操作前,$signed() 函数先把操作数的符号位,扩位成跟结果相同的位宽,然后再进行移位操作,而 shiftr 就是右移后的结果。 wire [32:0] shiftt = ($signed(shift) >>> shamt); wire [31:0] shiftr = shiftt[31:0]; // 左移的结果 shiftl,是由右移后的结果进行位置取反得到的 wire [31:0] shiftl = reverse(shiftr); always @(*) begin case(alu_op_i) `ALU_OP_ADD: result <= sum; `ALU_OP_SUB: result <= sum; `ALU_OP_SLL: result <= shiftl; `ALU_OP_SLT: result <= cmp; `ALU_OP_SLTU: result <= cmp; `ALU_OP_XOR: result <= (alu_data1_i ^ alu_data2_i); `ALU_OP_SRL: result <= shiftr; `ALU_OP_SRA: result <= shiftr; `ALU_OP_OR: result <= (alu_data1_i | alu_data2_i); `ALU_OP_AND: result <= (alu_data1_i & alu_data2_i); `ALU_OP_EQ: result <= {31'b0, ~neq}; `ALU_OP_NEQ: result <= {31'b0, neq}; `ALU_OP_GE: result <= {31'b0, ~cmp}; `ALU_OP_GEU: result <= {31'b0, ~cmp}; default: begin result <= 32'b0; end endcase end function [31:0] reverse; input [31:0] in; integer i; for(i=0; i<32; i=i+1) begin reverse[i] = in[31-i]; end endfunction assign alu_result_o = result; endmodule
手写CPU(四):如何实现CPU流水线的访存阶段?
流水线数据冒险
在开始设计访存模块之前,我们得先解决一个问题,即流水线的数据冒险。
执行不同的指令时会发生这样的情况:一条指令 B,它依赖于前面还在流水线中的指令 A 的执行结果。当指令 B 到达执行阶段时,因为指令 A 还在访存阶段,所以这时候就无法提供指令 B 执行所需要的数据。这就导致指令 B 无法在预期的时钟周期内执行。
当指令在流水线中重叠执行时,后面的指令需要用到前面的指令的执行结果,而前面的指令结果尚未写回,由此导致的冲突就叫数据冒险。
add x2,x0,x1 sub x6,x2,x3
在不做任何干预的情况下,sub 依赖于 add 的执行结果,这导致 sub 指令要等到 add 指令走到流水线的第五个阶段,把结果写回之后才能执行,这就浪费了三个时钟周期。
我们最直接的处理办法就是通过编译器调整一些指令顺序。不过指令存在依赖关系的情况经常发生,用编译器调整的方式会导致延迟太长,处理的结果无法让我们满意。
所以能不能通过向内部资源添加额外的硬件,来尽快找到缺少的运算项呢?这当然可以。对于上述的指令序列,一旦 ALU 计算出加法指令的结果,就可以将其作为减法指令执行的数据输入,不需要等待指令完成,就可以解决数据冒险的问题。
将 add 指令执行阶段运算的结果 x2 中的值,直接传递给 sub 指令作为执行阶段的输入,替换 sub 指令在译码阶段读出的寄存器 x2 的值。这种硬件上解决数据冒险的方法称为前递(forwarding)。
数据前递模块的设计
在流水线中的位置如下图所示:
正如上图中的 forwarding 模块,可以看到它的数据来自于流水线中的执行模块 EX、访存模块 MEM、写回模块 WB 的输出,经过 forwarding 模块处理后,把数据传递到执行模块的输入。
流水线根据当前指令的译码信号,选择读取通用寄存器的数据作为执行模块的操作数,或者选择来自前递模块的数据作为执行模块的操作数。
module forwarding ( // 前递模块输入的端口信号 rs1 和 rs2,来自于指令译码后得到的两个通用寄存器索引 input [4:0] rs1, input [4:0] rs2, // exMemRd 信号是来自访存模块的对通用寄存器的访问地址 input [4:0] exMemRd, // exMemRw 是流水线访存阶段对通用寄存器的写使能控制信号 input exMemRw, // memWBRd 和 memWBRw 分别是写回模块对通用寄存器的地址和写使能控制信号 input [4:0] memWBRd, input memWBRw, input mem_wb_ctrl_data_toReg, input [31:0] mem_wb_readData, input [31:0] mem_wb_data_result, input [31:0] id_ex_data_regRData1, input [31:0] id_ex_data_regRData2, input [31:0] ex_mem_data_result, output [31:0] forward_rs1_data, output [31:0] forward_rs2_data ); //检查是否发生数据冒险 wire [1:0] forward_rs1_sel = (exMemRw & (rs1 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01 :(memWBRw & (rs1 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10 : 2'b00; wire [1:0] forward_rs2_sel = (exMemRw & (rs2 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01 :(memWBRw & (rs2 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10 : 2'b00; wire [31:0] regWData = mem_wb_ctrl_data_toReg ? mem_wb_readData : mem_wb_data_result; //根据数据冒险的类型选择前递的数据 assign forward_rs1_data = (forward_rs1_sel == 2'b00) ? id_ex_data_regRData1 : (forward_rs1_sel == 2'b01) ? ex_mem_data_result : (forward_rs1_sel == 2'b10) ? regWData : 32'h0; assign forward_rs2_data = (forward_rs2_sel == 2'b00) ? id_ex_data_regRData2 : (forward_rs2_sel == 2'b01) ? ex_mem_data_result : (forward_rs2_sel == 2'b10) ? regWData : 32'h0; endmodule
当需要读取的通用寄存器的地址等于访存,或者写回阶段要访问通用寄存器地址时(也就是 rs1 == exMemRd 和 rs1 == memWBRd),就判断为将要发生数据冒险。
当然,由于通用寄存器中的零寄存器的值永远为“0”,所以不会发生数据冒险,需要排除掉这种特殊情况(也就是 exMemRd != 5’b0 和 memWBRd != 5’b0)。根据这样的判断结果,就会产生前递数据的两个选择信号 forward_rs1_sel 和 forward_rs2_sel。
而 ex_mem_data_result 信号是访存阶段需要写到通用寄存器的数据,regWData 是回写阶段需要更新到通用寄存器的数据。这样,通过判断将要发生数据冒险的位置,前递模块选择性地把处于流水线中的数据前递,就可以巧妙地解决流水线中的数据冒险问题了。
添加前递模块并不能避免所有的流水线停顿。比如,当一条读存储器指令(LOAD)之后紧跟一条需要使用其结果的 R 型指令时,就算使用前递也需要流水线停顿。因为读存储器的数据必须要在访存之后才能用,但 load 指令正在访存时,后一条指令已经在执行。所以,在这种情况下,流水线必须停顿,通常的说法是在两条指令之间插入气泡。
访存控制模块设计
流水线中一条指令的生命周期分为五个阶段。流水线的访存阶段就是指,将数据从存储器中读出或写入存储器的过程。这个阶段会出现由 LOAD / STORE 指令产生的内存访问。
因为访存阶段的功能就是对存储器读写,所以访存控制信号中,最重要的两个信号就是存储器读控制信号 memRead 和写控制信号 memWrite。当然,访存的控制信号通路也会受流水线冲刷等流水线管理信号的控制,具体的代码如下:
module ex_mem_ctrl( input clk, input reset, input in_mem_ctrl_memRead, //memory读控制信号 input in_mem_ctrl_memWrite, //memory写控制信号 input [1:0] in_mem_ctrl_maskMode, //mask模式选择 input in_mem_ctrl_sext, //符合扩展 input in_wb_ctrl_toReg, //写回寄存器的数据选择,“1”时为mem读取的数据 input in_wb_ctrl_regWrite, //寄存器写控制信号 input flush, //流水线数据冲刷信号 output out_mem_ctrl_memRead, output out_mem_ctrl_memWrite, output [1:0] out_mem_ctrl_maskMode, output out_mem_ctrl_sext, output out_wb_ctrl_toReg, output out_wb_ctrl_regWrite ); reg reg_mem_ctrl_memRead; reg reg_mem_ctrl_memWrite; reg [1:0] reg_mem_ctrl_maskMode; reg reg_mem_ctrl_sext; reg reg_wb_ctrl_toReg; reg reg_wb_ctrl_regWrite; assign out_mem_ctrl_memRead = reg_mem_ctrl_memRead; assign out_mem_ctrl_memWrite = reg_mem_ctrl_memWrite; assign out_mem_ctrl_maskMode = reg_mem_ctrl_maskMode; assign out_mem_ctrl_sext = reg_mem_ctrl_sext; assign out_wb_ctrl_toReg = reg_wb_ctrl_toReg; assign out_wb_ctrl_regWrite = reg_wb_ctrl_regWrite; always @(posedge clk or posedge reset) begin if (reset) begin reg_mem_ctrl_memRead <= 1'h0; end else if (flush) begin reg_mem_ctrl_memRead <= 1'h0; end else begin reg_mem_ctrl_memRead <= in_mem_ctrl_memRead; end end always @(posedge clk or posedge reset) begin if (reset) begin reg_mem_ctrl_memWrite <= 1'h0; end else if (flush) begin reg_mem_ctrl_memWrite <= 1'h0; end else begin reg_mem_ctrl_memWrite <= in_mem_ctrl_memWrite; end end ………… //由于代码较长,结构相似,这里省略了一部分,完整代码你可以从Gitee上获取 endmodule
就把上一阶段送过来的控制信号(比如存储器读控制信号 memRead、存储器写控制信号 memWrite……等),通过寄存器保存下来,然后发送给存储器读写控制模块(dmem_rw.v)或者流水线的下一级使用。
访存数据通路模块设计
访存数据通路就是把访存阶段读取到的存储器数据,或者是指令执行产生的结果发送流水线的下一级处理。
由于下一级也就是流水线的最后一级——写回,所以访存的数据通路主要包括要写回的通用寄存器地址 regWAddr、访问存储器读取的数据 regRData2、指令运算的结果 result 等。
module ex_mem( input clk, input reset, input [4:0] in_regWAddr, //写回寄存器的地址 input [31:0] in_regRData2, //读存储器的数据 input [1:0] ex_result_sel, //执行结果选择 input [31:0] id_ex_data_imm, //指令立即数 input [31:0] alu_result, //ALU运算结果 input [31:0] in_pc, //当前PC值 input flush, //流水线数据冲刷控制信号 output [4:0] data_regWAddr, output [31:0] data_regRData2, output [31:0] data_result, output [31:0] data_pc ); reg [4:0] reg_regWAddr; reg [31:0] reg_regRData2; reg [31:0] reg_result; reg [31:0] reg_pc; wire [31:0] resulet_w = (ex_result_sel == 2'h0) ? alu_result : (ex_result_sel == 2'h1) ? id_ex_data_imm : (ex_result_sel == 2'h2) ? (in_pc +32'h4) : 32'h0; assign data_regWAddr = reg_regWAddr; assign data_regRData2 = reg_regRData2; assign data_result = reg_result; assign data_pc = reg_pc; always @(posedge clk or posedge reset) begin if (reset) begin reg_regWAddr <= 5'h0; end else if (flush) begin reg_regWAddr <= 5'h0; end else begin reg_regWAddr <= in_regWAddr; end end always @(posedge clk or posedge reset) begin if (reset) begin reg_regRData2 <= 32'h0; end else if (flush) begin reg_regRData2 <= 32'h0; end else begin reg_regRData2 <= in_regRData2; end end always @(posedge clk or posedge reset) begin if (reset) begin reg_result <= 32'h0; end else if (flush) begin reg_result <= 32'h0; end else begin reg_result <= resulet_w; end end always @(posedge clk or posedge reset) begin if (reset) begin reg_pc <= 32'h0; end else if (flush) begin reg_pc <= 32'h0; end else begin reg_pc <= in_pc; end end endmodule
和上面的访存控制模块类似,访存数据通路模块也是根据流水线的冲刷控制信号 flush,判断访存阶段的数据是否需要清零。如果不需要清零,就把上一阶段送过来的数据通过寄存器保存下来。
ex_result_sel 就是对流水线执行阶段的结果进行选择。
- 当(ex_result_sel == 2’h0)时,就选择 ALU 的运算结果;
- 当(ex_result_sel == 2’h1)时,就会选择指令解码得到的立即数(其实就是对应 LUI 指令);
- 当(ex_result_sel == 2’h2)时,选择 PC 加 4 的值,也就是下一个 PC 的值。
手写CPU(五):CPU流水线的写回模块如何实现?
数据前递模块,只局限于解决算术操作和数据传输中的冒险问题。在 CPU 流水线中还可能存在结构冒险和控制冒险的问题,我们在进行流水线规划时,已经合理地避免了结构冒险。但是,控制冒险还可能出现,下面我们就来探讨一下流水线的控制冒险问题。
流水线控制冒险
假如在流水线取出分支指令后,紧跟着在下一个时钟周期就会取下一条指令。但是,流水线并不知道下一条指令应该从哪里取,因为它刚从存储器中取出分支指令,还不能确定上一条分支指令是否会发生跳转。上面这种流水线需要根据上一条指令的执行结果决定下一步行为的情况,就是流水线中的控制冒险。
控制冒险可以使用流水线停顿的方法解决,就是在取出分支指令后,流水线马上停下来,等到分支指令的结果出来,确定下一条指令从哪个地址取之后,流水线再继续。
但是这种方法对性能的影响是很大的。到目前为止,我们还没有找到根本性的解决控制冒险问题的方法。
但这并不代表我们没有办法去优化它,我们可以采用分支预测的方法提升分支阻塞的效率。
当每次遇到条件分支指令时,预测分支会发生跳转,直接在分支指令的下一条取跳转后相应地址的指令。如果分支发生跳转的概率是 50%,那么这种优化方式就可以减少一半由控制冒险带来的性能损失。
预读取模块(if_pre.v),实现的就是这个功能,相关代码如下:
wire is_bxx = (instr[6:0] == `OPCODE_BRANCH); //条件挑转指令的操作码 wire is_jal = (instr[6:0] == `OPCODE_JAL) ; //无条件跳转指令的操作码 //B型指令的立即数拼接 wire [31:0] bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0}; //J型指令的立即数拼接 wire [31:0] jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0}; //指令地址的偏移量 wire [31:0] adder = is_jal ? jimm : (is_bxx & bimm[31]) ? bimm : 4; assign pre_pc = pc + adder;
看到这你可能还有疑问,如果条件分支不发生跳转的话又会怎么样呢?这种情况下,已经被读取和译码的指令就会被丢弃,流水线继续从不分支的地址取指令。要想丢弃指令也不难,只需要把流水线中的控制信号和数据清“0”即可,也就是当预测失败的分支指令执行之后,到达访存阶段时,需要将流水线中处于取指、译码和执行阶段的指令清除。
展示一下控制冒险模块的整体代码,之后再详细解读。代码如下所示:
module hazard ( input [4:0] rs1, input [4:0] rs2, input alu_result_0, input [1:0] id_ex_jump, input id_ex_branch, input id_ex_imm_31, input id_ex_memRead, input id_ex_memWrite, input [4:0] id_ex_rd, input [1:0] ex_mem_maskMode, input ex_mem_memWrite, // pcFromTaken 是分支指令执行之后,判断和分支预测方向是否一致的信号。 output reg pcFromTaken, // pcStall 是控制程序计数器停止的信号,如果程序计数器停止,那么流水线将不会读取新的指令。 output reg pcStall, // IF_ID_stall 是流水线中从取指到译码的阶段的停止信号。 output reg IF_ID_stall, // ID_EX_stall 是流水线从译码到执行阶段的停止信号。 output reg ID_EX_stall, // 流水线ID_EX段清零信号 output reg ID_EX_flush, // 流水线EX_MEM段清零信号 output reg EX_MEM_flush, // 流水线IF_ID段清零信号 output reg IF_ID_flush ); // branch_do 信号就是条件分支指令的条件比较结果,由 ALU 运算结果和立即数的最高位(符合位)通过“与”操作得到; wire branch_do = ((alu_result_0 & ~id_ex_imm_31) | (~alu_result_0 & id_ex_imm_31)); // ex_mem_taken 是确认分支指令跳转的信号,由无条件跳转(jump)“或”条件分支指令(branch)产生。 wire ex_mem_taken = id_ex_jump[0] | (id_ex_branch & branch_do); // id_ex_memAccess 是存储器的选通信号,当对存储器的“读”或者“写”控制信号有效时产生; wire id_ex_memAccess = id_ex_memRead | id_ex_memWrite; // ex_mem_need_stall 信号表示流水线需要停顿,当执行 sb 或者 sh 指令时就会出现这样的情况。 wire ex_mem_need_stall = ex_mem_memWrite & (ex_mem_maskMode == 2'h0 | ex_mem_maskMode == 2'h1); always @(*) begin if(id_ex_memAccess && ex_mem_need_stall) begin pcFromTaken <= 0; pcStall <= 1; IF_ID_stall <= 1; IF_ID_flush <= 0; ID_EX_stall <= 1; ID_EX_flush <= 0; EX_MEM_flush <= 1; end else if(ex_mem_taken) begin pcFromTaken <= 1; pcStall <= 0; IF_ID_flush <= 1; ID_EX_flush <= 1; EX_MEM_flush <= 0; end else if(id_ex_memRead & (id_ex_rd == rs1 || id_ex_rd == rs2)) begin pcFromTaken <= 0; pcStall <= 1; IF_ID_stall <= 1; ID_EX_flush <= 1; end else begin pcFromTaken <= 0; pcStall <= 0; IF_ID_stall <= 0; ID_EX_stall <= 0; ID_EX_flush <= 0; EX_MEM_flush <= 0; IF_ID_flush <= 0; end end endmodule
什么情况下才会产生上面的输出控制信号呢?一共有三种情况,我这就带你依次分析一下。
- 第一种情况是解决数据相关性问题。
数据相关指的是指令之间存在的依赖关系。当两条指令之间存在相关关系时,它们就不能在流水线中重叠执行。
例如,前一条指令是访存指令 Store,后一条也是 Load 或者 Store 指令,因为我们采用的是同步 RAM,需要先读出再写入,占用两个时钟周期,所以这时要把之后的指令停一个时钟周期。
if(ID_EX_memAccess && EX_MEM_need_stall) begin pcFromTaken <= 0; pcStall <= 1; IF_ID_stall <= 1; IF_ID_flush <= 0; ID_EX_stall <= 1; ID_EX_flush <= 0; EX_MEM_flush <= 1; end
- 第二种情况是分支预测失败的问题
当分支指令执行之后,如果发现分支跳转的方向与预测方向不一致。这时就需要冲刷流水线,清除处于取指、译码阶段的指令数据,更新 PC 值。
// 分支预测失败,需要冲刷流水线,更新pc值 else if(EX_MEM_taken) begin pcFromTaken <= 1; pcStall <= 0; IF_ID_flush <= 1; ID_EX_flush <= 1; EX_MEM_flush <= 0; end
- 第三种情况就是解决数据冒险问题。
当前一条指令是 Load,后一条指令的源寄存器 rs1 和 rs2 依赖于前一条从存储器中读出来的值,需要把 Load 指令之后的指令停顿一个时钟周期,而且还要冲刷 ID _EX 阶段的指令数据。
else if(ID_EX_memRead & (ID_EX_rd == rs1 || ID_EX_rd == rs2)) begin pcFromTaken <= 0; pcStall <= 1; IF_ID_stall <= 1; ID_EX_flush <= 1; end
写回控制模块设计
先来看看写回控制模块,这个模块实现起来就非常简单了,它的作用就是选择存储器读取回来的数据作为写回的结果,还是选择流水线执行运算之后产生的数据作为写回结果。
module mem_wb_ctrl( input clk, input reset, input in_wb_ctrl_toReg, input in_wb_ctrl_regWrite, output data_wb_ctrl_toReg, output data_wb_ctrl_regWrite ); reg reg_wb_ctrl_toReg; reg reg_wb_ctrl_regWrite; assign data_wb_ctrl_toReg = reg_wb_ctrl_toReg; assign data_wb_ctrl_regWrite = reg_wb_ctrl_regWrite; always @(posedge clk or posedge reset) begin if (reset) begin reg_wb_ctrl_toReg <= 1'h0; end else begin reg_wb_ctrl_toReg <= in_wb_ctrl_toReg; end end always @(posedge clk or posedge reset) begin if (reset) begin reg_wb_ctrl_regWrite <= 1'h0; end else begin reg_wb_ctrl_regWrite <= in_wb_ctrl_regWrite; end end endmodule
代码里有两个重要的信号需要你留意。一个是写回寄存器的数据选择信号 wb_ctrl_toReg,当这个信号为“1”时,选择从存储器读取的数值作为写回数据,否则把流水线的运算结果作为写回数据。另一个是寄存器的写控制信号 wb_ctrl_regWrite,当这个信号为“1”时,开始往目标寄存器写回指令执行的结果。
写回数据通路模块设计
写回数据通路模块产生的信号主要包括写回目标寄存器的地址 reg_WAddr,流水线执行运算后的结果数据 result,从存储器读取的数据 readData。
module mem_wb( input clk, input reset, input [4:0] in_regWAddr, input [31:0] in_result, input [31:0] in_readData, input [31:0] in_pc, output [4:0] data_regWAddr, output [31:0] data_result, output [31:0] data_readData, output [31:0] data_pc ); reg [4:0] reg_regWAddr; reg [31:0] reg_result; reg [31:0] reg_readData; reg [31:0] reg_pc; always @(posedge clk or posedge reset) begin if (reset) begin reg_regWAddr <= 5'h0; end else begin reg_regWAddr <= in_regWAddr; end end always @(posedge clk or posedge reset) begin if (reset) begin reg_result <= 32'h0; end else begin reg_result <= in_result; end end always @(posedge clk or posedge reset) begin if (reset) begin reg_readData <= 32'h0; end else begin reg_readData <= in_readData; end end always @(posedge clk or posedge reset) begin if (reset) begin reg_pc <= 32'h0; end else begin reg_pc <= in_pc; end end assign data_regWAddr = reg_regWAddr; assign data_result = reg_result; assign data_readData = reg_readData; assign data_pc = reg_pc; endmodule
是的,写回阶段的模块没有了流水线的停止控制信号 stall 和流水线的冲刷控制信号 flush。这是因为写回阶段的数据经过了数据冒险和控制冒险模块的处理,已经可以确保流水线产生的结果无误了,所以写回阶段的数据不受停止信号 stall 和清零信号 flush 的控制。
手写CPU(六):如何让我们的CPU跑起来?
系统总线设计
总线是连接多个部件的信息传输线,它是各部件共享的传输介质。在某一时刻,只允许有一个部件向总线发送信息,而多个部件可以同时从总线上接收相同的信息。MiniCPU 的系统总线用来连接 CPU 内核与外设,完成信息传输的功能。
module sys_bus ( // cpu -> imem input [31:0] cpu_imem_addr, output [31:0] cpu_imem_data, output [31:0] imem_addr, input [31:0] imem_data, // cpu -> bus input [31:0] cpu_dmem_addr, input [31:0] cpu_dmem_data_in, input cpu_dmem_wen, output reg [31:0] cpu_dmem_data_out, // bus -> ram input [31:0] dmem_read_data, output [31:0] dmem_write_data, output [31:0] dmem_addr, output reg dmem_wen, // bus -> rom input [31:0] dmem_rom_read_data, output [31:0] dmem_rom_addr, // bus -> uart input [31:0] uart_read_data, output [31:0] uart_write_data, output [31:0] uart_addr, output reg uart_wen ); assign imem_addr = cpu_imem_addr; assign cpu_imem_data = imem_data; assign dmem_addr = cpu_dmem_addr; assign dmem_write_data = cpu_dmem_data_in; assign dmem_rom_addr = cpu_dmem_addr; assign uart_addr = cpu_dmem_addr; assign uart_write_data = cpu_dmem_data_in; always @(*) begin case (cpu_dmem_addr[31:28]) 4'h0: begin //ROM cpu_dmem_data_out <= dmem_rom_read_data; dmem_wen <= 0; uart_wen <= 0; end 4'h1: begin // RAM dmem_wen <= cpu_dmem_wen; cpu_dmem_data_out <= dmem_read_data; uart_wen <= 0; end 4'h2: begin // uart io uart_wen <= cpu_dmem_wen; cpu_dmem_data_out <= uart_read_data; dmem_wen <= 0; end default: begin dmem_wen <= 0; uart_wen <= 0; cpu_dmem_data_out <= 0; end endcase end endmodule
这里我们设计的系统总线其实是一个“一对多”的结构,也可以说是“一主多从”结构,就是一个 CPU 内核作为主设备(Master),多个外设作为从设备(Slave)。
CPU 内核具有系统总线的控制权,它可以通过系统总线,发起对外设的访问,而外设只能响应从 CPU 内核发来的各种总线命令。因此,每个外设都需要有一个固定的地址,作为 CPU 访问特定外设的标识。
只读存储器 ROM 的实现
ROM 是个缩写,它表示只读存储器(Read Only Memory)。ROM 具有非易失性的特点。什么是非易失性呢?说白了就是在系统断电的情况下,仍然可以保存数据。
由于历史原因,虽然现在使用的 ROM 中,有些类型不仅是可以读,还可以写,但我们还是习惯性地把它们称作只读存储器。比如,现在电子系统中常用的 EEPROM、NOR flash 、Nand flash 等,都可以归类为 ROM。
随机访问存储器 RAM
除了存放指令的 ROM,我们还需要一个存放变量和数据的 RAM(Random Access Memory)。
RAM 和特点跟 ROM 正好相反,它是易失性存储器,通常都是在掉电之后就会丢失数据。但是它具有读写速度快的优势,所以通常用作 CPU 的高速缓存。
RAM 之所以叫做随机访问存储器,是因为不同的地址可以在相同的时间内随机读写。这是由 RAM 的结构决定的,RAM 使用存储阵列来存储数据,只要给出行地址和列地址,就能确定目标数据,而且这一过程和目标数据所处的物理位置无关。
外设 UART 设计
UART 的全称叫通用异步收发传输器(Universal Asynchronous Receiver/Transmitter),它是一种串行、异步、全双工的通信协议,是电子设备间进行异步通信的常用模块。
UART 负责对系统总线的并行数据和串行口上的串行数据进行转换,通信双方采用相同的波特率。在不使用时钟信号线的情况下,仅用一根数据发送信号线和一根数据接收信号线(Rx 和 Tx)就可以完成两个设备间的通信,因此我们也把 UART 称为异步串行通信。
本文作者:Blue Mountain
本文链接:https://www.cnblogs.com/BlueMountain-HaggenDazs/p/17648047.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步