Roxy's Library

Back

本文内容基于 2025 秋季《计算机体系结构》双语班课程讲述,如有差错,欢迎指正

RISC-V ALU#

RISC-V 默认将操作数视为有符号数。为了处理无符号数,会在指令助记符后添加后缀 U 来标识,例如 mulhu 表示无符号数的高位乘法。 对于移位指令,SLLI(逻辑左移立即数)与 SRLI(逻辑右移立即数)中的 L 代表逻辑移位(Logical),默认视为无符号操作,即移位后空出的位补 0。

对于溢出的处理。在 RISC-V 中,硬件本身不进行溢出检测,也不会因为溢出而抛出异常。 无论结果是否溢出,ALU 都会输出截断后的 64 位(或 32 位)结果。 溢出的检测工作被移交给了软件层面,程序员需要编写额外的代码逻辑来判断运算结果是否发生了溢出。

加法与减法器#

我们先考虑一位全加器,它接受三个输入:两个加数位 AABB,以及一个进位输入CarryinCarry_{in}。 输出两个结果:本位和 SS 以及向高位的进位 CarryoutCarry_{out}。根据对真值表的分析,我们可以得到以下逻辑表达式:

S=ABCarryinS = A \oplus B \oplus Carry_{in} Carryout=(A&B)(Carryin&A)(Carryin&B)Carry_{out} = (A \& B) | (Carry_{in} \& A) | (Carry_{in} \& B)

那么,一个能实现该操作的硬件就可以被称作一位全加器(Full Adder)

将 32 个这样的 1 位全加器串联起来,使低位的 CarryoutCarry_{out} 连接到高位的 CarryinCarry_{in},便构成了 32 位加法器

对于减法操作,我们可以利用补码的性质将其转化为加法。 在硬件上的实现只需要一个控制信号,如果是减法,则将BB转化为补码形式

减法器

对于移位指令,RISC-V 提供了逻辑移位和算术移位两种方式。

  • 逻辑移位(Logical Shift)在移位时,无论是左移还是右移,空出的位都用 0 填充
  • 算术移位(Arithmetic Shift)则在右移时,空出的高位用符号位(即最高位)填充
  • 没有算术左移,因为算术左移和逻辑左移是一样的

乘法器与除法器#

最基础的乘法操作的基本思想是“移位并相加”,设乘数为 MM,被乘数为 QQ

  • 先检查 QQ 的最低位
  • 如果为 1,则将 MM 加到结果上;0则不加
  • 然后将 MM 左移一位,QQ 右移一位
  • 重复上述步骤直到处理完 QQ 的所有位

乘法器示意图

对于 64 位的数来说,这样的乘法相当于要进行 64 次加法操作,比较复杂。 现代的一些乘法器会将一些加法并行,形成一个树形的结构,达到一个log(n)\log(n)的时间复杂度

在 RISC-V 中,两个 64 位整数相乘会产生 128 位结果。 由于通用寄存器只有 64 位,该操作被拆分为两条指令:mul 用于保存结果的低 64 位,mulh(或 mulhu)用于保存结果的高 64 位。

乘法的操作相对加法来说更加复杂,也更慢。而除法的实现则更加复杂且耗时。

除法的具体操作是:

  • 从被除数的最高位开始,尝试减去除数
  • 如果减法结果为负,则商的当前位为 0,并将被除数恢复
  • 如果减法结果为非负,则商的当前位为 1,并将被除数更新为减法结果
  • 重复上述步骤直到处理完被除数的所有位

这种方法类似于手工进行除法运算,逐位确定商的每一位

除法器示意图

因为每次都要进行减法操作,且有时需要将被除数恢复,导致除法器难以实现并行化,延迟高于乘法

RISC-V 中的DIV指令和REM指令分别用于整数除法和取余操作

浮点运算单元 (FPU)#

RISC-V 遵循 IEEE 754 标准处理浮点数。浮点数由符号位(Sign)、指数位(Exponent)和尾数位(Fraction)组成。

浮点数表示

为了支持浮点运算,RISC-V 架构引入了独立的浮点寄存器堆 f0 到 f31。与通用寄存器不同,浮点寄存器专门用于存放单精度(32 位)或双精度(64 位)浮点数。 单精度数据通常只占用 64 位寄存器的低 32 位。

相关的指令包括浮点加载 flw、存储 fsw、加法 fadd.s/d 等。浮点 ALU 的延迟通常也较大

Architecture#

处理器的核心架构可以概括为两大部分:数据通路(Datapath)与控制逻辑(Control)。

数据通路负责数据的存储与运算,包含以下组件:

  • 功能单元(Functional Units):如 ALU、扩展单元(Extender)、多路选择器(MUX),它们属于组合逻辑(Combinational Logic),输出仅取决于当前输入,无记忆功能。
  • 存储单元(State Elements):如程序计数器(PC)、寄存器堆、指令内存与数据内存。它们由时钟控制,用于保存系统状态。
  • 互连部件:比如总线

组合逻辑与储存单元

控制逻辑用于指挥数据通路的运作。它解析指令,生成相应的控制信号(如 RegWriteALUOpMemRead 等),协调各部件在正确的时钟周期内完成操作。

时钟#

现代逻辑电路通常采用**边沿触发(Edge-Triggered)**机制。 在一个时钟周期内,系统需要完成“读寄存器 \to 组合逻辑计算 \to 写回寄存器”的完整流程。

因此在电路设计中,时钟周期一定要长于这一整套逻辑所需的时间,否则数据会丢失

时钟周期示意图

关于时钟周期,定义几个时间参数:

  • tclkqt_{clk-q}:时钟边沿到来后,寄存器输出端数据更新的延迟。
  • tmax_combt_{max\_comb}:数据通过组合逻辑电路的最长路径延迟。
  • tsetupt_{setup}(建立时间):在下一个时钟边沿到来之前,输入数据必须保持稳定的最短时间。时钟到来前新数据需要稳定一段时间
  • tholdt_{hold}(保持时间):在时钟边沿到来之后,输入数据必须继续保持稳定的最短时间。即上一个数据不能走太早

其中tholdt_{hold}一般很容易满足,因为通常来说tholdt_{hold}小于tclkqt_{clk-q}

对于时钟周期来说,需要满足:

tclktclkq+tmax_comb+tsetupt_{clk} \geq t_{clk-q} + t_{max\_comb} + t_{setup}

Single Cycle Datapath#

单周期处理器的核心设计理念是:所有的指令执行过程都在一个时钟周期内完成。 我们将指令的执行过程抽象为三个主要阶段:Fetch、Decode和Execute

Fetch取指

这一阶段,处理器根据PC的地址,从Instruction Memory中读取指令。

由于后面需要访问其他指令,在这个阶段需要将PC加4,指向下一条指令的地址

Decode译码

在这一阶段,处理器解析指令内容,交给Control Unit和寄存器堆。 Control Unit根据操作码等生成相应的控制信号,寄存器堆根据指令中的寄存器地址读取操作数。

Execute执行

在这一阶段,根据指令类型的不同,操作会有所差异。

对于 R-format 指令(如算术运算),ALU 对寄存器读出的数据进行计算,结果写回目标寄存器

对于 Load/Store 指令,ALU 计算内存地址(基址寄存器值 + 符号扩展后的立即数),然后对Data Memory进行读或写操作

对于 Branch 指令(如 BEQ),ALU 比较两个寄存器的值,同时通过加法器计算跳转目标地址(PC + 偏移量),根据比较结果决定是否更新 PC

单周期数据通路

Single Cycle的缺点在于:

  • 时钟周期要长于最长指令的执行时间(Load),其他指令会浪费很多时间
  • 为了不产生 Functional Units的冲突,需要额外的硬件单元,利用率低下

Multi Cycle Datapath#

想要解决 Single Cycle 时钟周期太长的问题,Multi Cycle的思路是让指令用多个时钟周期进行,每个周期只完成指令的一部分操作。

多周期数据通路

  • 虽然图中Load指令完成时间长于Sigle Cycle,但是最终减少了waste time

我们可以通过把一条指令划分成多个步骤的方式来实现多周期处理。 最简单的一种划分方式是将指令执行过程分为五个基本步骤: iFetch、Decode、Execute、Memory Access 和 Write Back

多周期设计允许功能单元的复用。例如,我们可以只使用一个 Memory 既存指令又存数据,只使用一个 ALU 既做地址计算又做算术运算。 为了实现这种分步执行,必须引入一些额外的寄存器来保存每个时钟周期产生的中间数据。 这也无意中增加了周期长度

对于控制信号来说,在同一个指令的不同周期是会变化的,通常采用有限状态机来生成每个周期所需的控制信号

有限状态机可以根据指令和和当前状态,输出控制数据通路的信号以及下一状态的逻辑

Pros&cons

  • 更有效地使用时钟周期,时钟周期取决于最长的单个步骤,而不是最长的指令
  • 可以对功能单元进行复用
  • 缺点是变得更加复杂,需要额外的寄存器,MUX 和控制逻辑

Pipeline#

Pipeline通过重叠执行多条指令来提升性能,我们不需要等上一条指令完全执行完毕才开始下一条

与 Multi Cycle 相比,Pipeline需要在同一时间使用某个功能单元,因此需要像 Single Cycle 一样增加一些硬件资源

在同一时间,Pipeline 中的每个阶段都在处理不同的指令,因此需要更多的寄存器来保存每个阶段的中间数据。 控制信号也需要像数据一样在每个阶段之间进行传递。流水线寄存器会负责完成这些任务

流水线设计不能降低单条指令的Latency,甚至因为引入了流水线寄存器,单条指令的执行时间反而略有增加。 但它极大地提高了吞吐率

理想状态下流水线的加速比等于流水线的级数。假设单周期时间为 TsingleT_{single},流水线级数为 NN,则理想的流水线周期时间为 Tpipe=Tsingle/NT_{pipe} = T_{single} / N。 但实际上,流水线的时钟周期取决于最慢的那个阶段。如果各阶段时间不平衡,加速比将小于 NN

理论上,Pipeline的级数越多,性能提升越大,但实际设计中会受到以下因素的限制:

  • 功耗限制:芯片上不能同时做太多事情
  • Hazards(冒险):存在下一个时钟周期无法正确执行下一条指令的情况

Hazards#

以下我们考虑的Hazard都基于五级流水线架构这五个阶段及其涉及的关键硬件组件如下:

  • Fetch (IF):取指。从Instruction Memory中读取指令。
  • Decode (ID):译码与读寄存器。涉及寄存器堆(Register File)的读取。
  • Execute (EX):执行。主要使用ALU进行计算。
  • Memory (MEM):访存。访问Data Memory进行数据的读取或写入。
  • Write Back (WB):写回。将结果写回到寄存器堆中。

Hazard来自于资源冲突或数据依赖,主要分为三类:结构冒险(Structural Hazard)、数据冒险(Data Hazard)和控制冒险(Control Hazard)

一个最简单的解决Hazard的方式就是等待,直到所需的资源可用

Structural Hazard#

结构冒险的原因是多条指令试图在同一个时钟周期内使用同一个硬件资源

一个常见的结构冒险是内存访问冲突。例如,处于 MEM 阶段的指令需要访问内存读写数据,而同时处于 IF 阶段的指令需要访问内存读取指令。 如果我们只有一个统一的内存,就会发生冲突。 解决这个问题的方案非常直观:把统一的 Memory 分成Instruction Memory 和 Data Memory,从而允许同时进行取指和访存

另一个问题是寄存器堆的读写冲突。例如,处于 WB 阶段的指令需要将结果写回寄存器堆,而同时处于 ID 阶段的指令需要从寄存器堆读取操作数。 解决方案是利用时钟周期的相位:我们规定在时钟周期的前半段进行写操作,后半段进行读操作

在五级流水线这种简单架构中,结构冒险很容易解决。 但在后续的乱序执行(Out-of-Order)等复杂架构中,结构冒险会更加难处理

Data Hazard#

数据冒险产生的原因在于流水线中的数据依赖。即当前指令需要的数据是由前一条指令产生的,但由于流水线的重叠执行,前一条指令尚未完成结果的写入,导致当前指令无法获取正确的数据

数据依赖关系主要分为三种类型:

  • Write After Read (WAR,写后读):后序指令尝试在前序指令读取之前写入数据
    • 在五级流水线中,读操作在 ID 阶段(第 2 阶段),写操作在 WB 阶段(第 5 阶段),后序指令的写操作发生得很晚,因此在五级流水线中通常不存在此问题
  • Write After Write (WAW,写后写):两条指令尝试写入同一个寄存器
    • 由于五级流水线是顺序执行且按顺序写回(均在 WB 阶段),因此也不会出现乱序写入的问题
  • Read After Write (RAW):后序指令需要读取前序指令写入的结果
    • 这是五级流水线中唯一需要真正解决的依赖。例如指令 II 在第 5 阶段才写入 R1R_1,而紧随其后的指令 JJ 在第 2 阶段就需要读取 R1R_1。此时 II 尚未写入,JJ 读到的是旧值,导致错误

下面我们关注 RAW

一种解决方法是 Stall,即让流水线停顿几个周期,直到前序指令完成写回。这种方法会影响CPI

一种更实用的方法是 Forwarding(转发)。虽然数据尚未写回寄存器,但是数据已经计算完了(或已从内存中读取),可以直接从流水线寄存器中取出数据,转发给需要的指令使用

转发单元的逻辑

是否进行转发的检测逻辑是比较后续指令的源寄存器(Rs,RtRs, Rt)与前序指令的目标寄存器(RdRd

转发可以在两个阶段进行:

  • EX/MEM 阶段转发:如果前一条指令(在 EX/MEM 流水线寄存器中)的目标寄存器 RdRd 等于当前指令(在 ID/EX 流水线寄存器中)的源寄存器 RsRs,且该前序指令是写寄存器操作(RegWriteRegWrite 有效且 Rd0Rd \neq 0),则将 EX/MEM 中的 ALU 计算结果直接转发给当前 ALU 输入
  • MEM/WB 阶段转发:如果前第二条指令(在 MEM/WB 流水线寄存器中)的目标寄存器 RdRd 等于当前指令的源寄存器 RsRs,且满足写条件,则将 MEM/WB 中的数据(可能是 ALU 结果或内存读取值)转发给当前 ALU 输入

转发单元逻辑

双重冒险

当指令序列中连续三条指令都涉及同一个寄存器。例如:

双重冒险示意图

此时,指令 1 和指令 2 都要写寄存器 $1 ,而指令 3 需要读 $1。根据程序顺序,指令 3 应该使用指令 2 的最新结果

因此,转发逻辑必须具备优先级:EX/MEM 阶段的转发优先级高于 MEM/WB 阶段

Load-Use Hazard

给一个场景:

    lw   x1, 0(x2)    # 指令 1
    add  x3, x1, x4   # 指令 2
plaintext

在指令2的 EX 阶段,指令1 仍处于 MEM 阶段,数据尚未从内存中读出来。 故此时需要指令2 stall一个周期才可以解决 hazard

Memory to Memory Copy Case

考虑以下指令序列:

    lw   x1, 0(x2)    # 指令 1
    sw   x1, 4(x3)    # 指令 2
plaintext

此时需要添加一个forward。 在指令1的 MEM 阶段完成后把数据foward到指令2的 MEM 阶段,才能不暂停流水线

Control Hazard#

控制冒险主要针对分支指令(如 BEQ, BNE)。核心问题在于:处理器需要执行完分支指令的比较操作并计算出跳转地址后,才能确定下一条指令的地址(PC)。

如果使用stall的方法,只有在上一条指令EX阶段完成后才能确定下一条指令地址

另一种思想是把决策计算的步骤提前,最早可以提前到ID阶段

  • 我们在 ID 阶段增加额外的比较器电路
  • 这样,如果在 ID 阶段就能判断是否跳转,那么只需要清除 IF 阶段预取的那 1 条指令即可。将分支代价减少到了 1 个周期

这种做法会引入额外的 Data Hazard

  • 如果分支指令依赖于紧邻的前一条 ALU 指令结果:数据在 EX 阶段生成,而分支在 ID 阶段就需要。此时需要暂停一个周期
  • 如果分支指令依赖于前一条 Load 指令:数据在 MEM 阶段生成,分支在 ID 阶段需要,这通常需要暂停两个周期
RISC-V ALU & Basic Architecture
https://astro-pure.js.org/blog/computerarch_09_25
Author GreyRat
Published at December 31, 2025
Comment seems to stuck. Try to refresh?✨