ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

单周期微架构与RISC-V处理器设计:手写CPU内核实战

单周期微架构与RISC-V处理器设计:手写CPU内核实战

DDCA 这本书,我建议你从第十讲开始前后各翻三遍。前面学的组合逻辑、时序逻辑、有限状态机,到这里全部变成了一台机器的组成部分;后面要学的流水线、Cache、乱序执行,又全都能在这个最简单的框架上找到影子。第十讲讲的是单周期微架构基础与设计,核心内容就是如何用 RV32I 指令集搭出一个能真正执行程序的处理器内核。单周期微架构,简单说就是每条指令在一个时钟周期内走完全程:取指、译码、执行、访存、写回。它跑不快,但结构最透明。这篇我用一个工程师做完实验之后复盘的口吻,把数据通路、控制信号、Verilog 代码和仿真调试的坑串起来讲,适合正在啃 DDCA 微架构章节、或者准备手写第一个 CPU 的同学。

1. 为什么先学单周期微架构:先学会走,再学跑

1.1 单周期到底在解决什么问题

如果你第一次接触“微架构”这三个字,我先给你一个坐标系。计算机体系结构这条线,通常是单周期 -> 多周期 -> 流水线。单周期是最朴素的一种实现:一条指令对应一个时钟周期,周期内所有动作一口气干完。比如一条lw x9, 0(x5),从 PC 取指令、读寄存器 x5、ALU 算地址、访问数据存储器、把读回来的值写回 x9,这些步骤全部在一个周期内完成。下一个时钟上升沿到来时,PC 已经切到下一条指令,整个舞台重新搭一遍。

这个设计看起来有点浪费:明明每条指令只有部分模块在工作,其他模块却在旁边闲着。比如sw不写寄存器,add不访问内存。但正因为所有逻辑都是“同一周期内瞬时完成”,控制逻辑变得极其简单:每个周期只看当前这一条指令,输出一组对应的控制信号,不需要任何历史状态,也不需要处理指令之间的依赖关系。

我经常用一个类比帮助学生理解:单周期就像一个全能小店,顾客进店后,老板一个人完成接待、做菜、结账、送客,整个流程在一个“营业时段”内结束;流水线则是分工明确的餐厅,洗菜、切菜、炒菜、传菜同时进行。小店服务一位顾客的时间可能很长,但流程极其清晰,你站在门口就能看见老板每一步在干嘛。

这里必须先澄清一个误区:单周期并不是把五个阶段“同时并行”完成。它只是让信号在组合逻辑里连续传播:一个周期开始后,指令从 PC 出发,经过一路上的多路选择器、ALU、内存、再回到寄存器堆写入端口,等到周期结束时,时钟沿把这些结果锁存起来。真正“同时”的是这些组合逻辑,而不是指令阶段。

1.2 为什么 CPI=1 反而是教学上的最佳选择

从性能公式看,单周期没有任何优势。CPU 时间 = 指令数 IC × 每条指令周期数 CPI × 时钟周期 Tc。单周期的 CPI 是 1,听起来很理想,但问题是 Tc 必须覆盖最慢那条指令的完整延迟。在这个教学处理器里,最慢的指令几乎永远是lw:它要从指令存储器走到寄存器堆,再从寄存器堆走到 ALU,从 ALU 走到数据存储器,最后从数据存储器回到寄存器堆写入端口。整条路径走完,延迟可能比简单add长两三倍。相比之下,流水线把这条路径切成五段,每一段有自己的寄存器,Tc 可以大幅缩短,虽然 CPI 还是接近 1,但主频却能成倍提升。

那为什么教材还要从单周期讲起?因为教学的目的不是压榨性能,而是把“指令如何变成控制信号,控制信号如何驱动数据通路”这件事完整摊开。多周期虽然节省硬件,但同一个 ALU 要分时复用,状态机跳来跳去;流水线虽然快,但引入冒险,新手很容易被 stall 和 forwarding 淹没。单周期里,每一个控制信号都直接对应当前指令的一个需求,没有任何遮挡。

我在带实验的时候经常跟学生说:如果你能不看任何参考,独立画出一张单周期数据通路图,并写出每类指令的控制信号表,你对计算机的理解就已经超过相当一部分只背过题的人。单周期就是微架构的“解剖课”,CPI=1 恰恰保证了你能把注意力全部放在结构和时序上。

1.3 从 ISA 到微架构:一张指令编码就是一张接线图

为什么 DDCA 在讲微架构之前,要先花大力气讲 RISC-V 指令格式?因为 ISA 的编码方式直接决定了数据通路怎么接。RISC-V 的 RV32I 指令非常规整:操作码字段永远在 [6:0],两个源寄存器 rs1、rs2 不管什么指令类型都固定在相同位置,目标寄存器 rd 也固定在 [11:7]。这意味着寄存器堆的读端口和写端口不需要根据指令类型做一堆多路选择,指令里的字段天然就能连到寄存器堆的地址端口上。

举个例子:一条add x8, x6, x7。指令二进制里 [19:15] 是 rs1=6,[24:20] 是 rs2=7,[11:7] 是 rd=8。单周期里,寄存器堆的 A1 端口直接拿 [19:15],A2 直接拿 [24:20],ALU 把两个输出加起来,结果通过写数据多路选择器送到寄存器堆的 WD3,同时控制单元发现 opcode 是 0110011(R 型),送出 RegWrite=1、ALUSrc=0、MemtoReg=00 等信号。一个周期结束,时钟沿把 ALU 结果写进 x8。

这套“编码字段 -> 物理端口”的对应关系,就是微架构设计的起点。你不需要先懂流水线,也不需要懂虚拟内存,只需要跟着指令格式,把一个一个模块连起来。这也是 RISC-V 比 x86 更适合教学的原因之一:x86 的指令长度不固定,字段位置千变万化,想画出整洁的单周期数据通路基本是噩梦。

2. 数据通路与控制信号:把这台机器拆开看

2.1 五个阶段的物理对应与信号流向

单周期数据通路如果画成图,是一条从左上角 PC 出发,绕一圈回到 PC 和寄存器堆的环路。文字描述大概是这样:PC 输出地址给指令存储器,指令存储器返回 32 位指令;指令分两路,一路进入控制单元译码,另一路拆出字段,进入寄存器堆、立即数扩展和 ALU 操作数选择;ALU 的结果和访存结果通过多路选择器汇合,最终写回寄存器堆;与此同时,分支/跳转判断结果决定下一个 PC。

具体拆开看,五个阶段在硬件上是这样落地的:

  • 取指:PC 寄存器 -> 指令存储器(IM)。指令存储器按字地址读,地址用addr[31:2]索引,因为一条指令占 4 字节。
  • 译码:指令进入主译码器,同时立即数扩展模块根据类型拼出 32 位立即数。
  • 执行:寄存器堆读出 rs1、rs2,ALU 根据控制信号选择 B 端口是寄存器值还是立即数,完成加法、减法、逻辑运算或比较。
  • 访存:数据存储器(DM)只有lw会真的读出数据,sw会真的写入数据。读写使能都来自控制信号,其他指令使能为 0,数据存储器保持沉默。
  • 写回:写数据多路选择器(MemtoReg Mux)在 ALU 结果、内存读出数据、PC+4 三者之间选一个,送到寄存器堆的写数据端口。

注意,这些阶段在单周期里不是时间上的先后,而是数据通路上的不同位置。一个组合逻辑周期里,信号同时从取指端出发,也几乎同时到达写回端。时序上只需要保证:时钟上升沿来之前,所有写使能的信号都已经稳定,并且被写入的数据已经在端口上等待。

2.2 指令格式与立即数扩展:最容易翻车的地方

RV32I 的基础指令有六种编码格式,分别是 R、I、S、B、U、J。它们布局各不相同,但都有一个相同的 [6:0] 操作码字段。这里我不画比特位图,直接列出最容易被忽略的立即数分布:

  • R 型:无立即数,rs2、rs1、funct3、rd、opcode 依次排布,funct7 在最高位。
  • I 型:指令 [31:20] 是 12 位立即数,直接放在高位,扩展时只需符号扩展。
  • S 型(sw):立即数拆分成了两段,高 7 位在 [31:25],低 5 位在 [11:7],必须拼成instr[31:25] + instr[11:7]再符号扩展。
  • B 型(beq/bne 等):立即数拆分成了四段,最高位在 [31],次高位在 [7],中间在 [30:25] 和 [11:8],拼接后还要左移一位补 0,因为编码里只放 imm[12:1],最低位恒为 0。
  • U 型(lui/auipc):20 位立即数在 [31:12],扩展时直接放到高 20 位,低 12 位补 0。
  • J 型(jal):立即数分散在 [31]、[19:12]、[20]、[30:21],拼接后左移一位补 0。

这个拼接逻辑是第一个大坑。很多新手写完 I 型扩展之后,顺手就用同一个逻辑去扩展 S 型和 B 型,结果sw存到错误地址,beq跳到错误位置。最稳的做法是给立即数扩展模块单独加一个 2~3 位的ImmSrc选择信号,按类型区分。

I 型和 S 型扩展逻辑的区别必须写清楚:I 型是{20{instr[31]}}, instr[31:20];S 型是{20{instr[31]}}, instr[31:25], instr[11:7]。它们虽然都是 12 位立即数,但字段位置完全不同,不能复用。

2.3 控制信号表:单周期的心脏

单周期的灵魂在控制单元。主译码器根据操作码 opcode 输出一组控制信号,典型教学处理器的控制信号包括:RegWrite、MemWrite、MemRead、MemtoReg、ALUSrc、Branch、Jump、ALUOp、ImmSrc。下表是我在实验中最常用的一张表,覆盖了 R 型算术、addi、lw、sw、beq、jal这几条核心指令。

指令RegWriteALUSrcMemWriteMemReadMemtoRegBranchJumpALUOpImmSrc
R 型 (add/sub)1000000010xx
addi1100000000I 型
lw1101010000I 型
sw0110xx0000S 型
beq0000xx1001B 型
jal1x001001xxJ 型

表中MemtoReg是 2 位:00 表示选 ALU 结果,01 表示选数据存储器读出的值,10 表示选 PC+4(用于 jal 的链接地址)。ALUOp是 2 位:00 表示必须执行加法,01 表示必须执行减法,10 表示交给 ALU 译码器根据 funct 字段细分。

这张表不要死记硬背,背下来的代价是后面换一个指令集就废了。正确的方法是把每条指令需要的硬件路径在数据通路图上描一遍:lw要写寄存器所以 RegWrite=1,要算内存地址所以 ALUSrc 必须选立即数,要读内存所以 MemRead=1,写回要选内存数据所以 MemtoReg=01。每个信号都有物理含义,推出来就忘不掉。

2.4 ALU 在单周期里的三个身份

同一个 ALU,在单周期里至少有三个完全不同的用途。第一是计算内存地址:lw/sw需要把 rs1 的值和 12 位立即数相加,得到访存地址。第二是执行常规运算:R 型指令的加法、减法、逻辑与、逻辑或、大小比较都发生在 ALU。第三是充当比较器:beq需要判断两个寄存器是否相等,做法就是做一次减法,如果结果为 0,zero信号拉高,分支条件成立。

这三个身份对应到控制信号上,就是 ALUSrc 和 ALUOp 的配合。lw的 ALUSrc=1,B 端口选立即数,ALUOp=00 强制加法;beq的 ALUSrc=0,两个源寄存器直接进 ALU,ALUOp=01 强制减法;R 型指令的 ALUSrc=0,ALUOp=10,由 ALU 译码器读指令的 funct3 和 funct7 决定具体操作。

我特别想强调一点:ALU 的zero信号是一个组合逻辑输出,任何指令都会产生它。分支指令只在 Branch=1 的时候才用它,非分支指令即使 ALU 结果恰好为 0,也不会影响 PC。所以写代码时不要让zero和分支逻辑耦合得太隐晦,显式地写成PCSrc = Branch & zero | Jump,后面查问题会轻松很多。

3. 用 Verilog 手写一个单周期 RISC-V 核心

3.1 顶层模块与模块划分

理论讲完,直接上代码。我建议第一次做单周期实验时,不要急着追求完整 RV32I,而是先支持这样一组指令:add、sub、and、or、slt、addi、lw、sw、beq、jal。这十条指令能覆盖 R、I、S、B、J 五种格式,足够跑小程序,也让控制信号表保持在一张纸的范围内。

模块划分如下:

  • PC 寄存器:输出当前地址,接收下一条地址。
  • 指令存储器:组合读,按字地址取指令。
  • 寄存器堆:两个异步读端口,一个同步写端口。
  • 立即数扩展:根据 ImmSrc 扩展成 32 位。
  • ALU:完成加减、与或、比较,输出 zero。
  • 数据存储器:组合读、同步写。
  • 主译码器:根据 opcode 输出基础控制信号。
  • ALU 译码器:根据 funct3、funct7 和 ALUOp 生成 ALU 控制信号。
  • 顶层数据通路:用多路选择器把这些模块连起来。

拿一张纸,先画出 PC 到指令存储器,再画指令到寄存器堆和立即数扩展,接着画 ALU、数据存储器、写回多路选择器,最后画 PC 的多路选择器。模块划分清楚了,写 Verilog 就只是翻译工作。

3.2 关键代码:PC、寄存器堆与 ALU

先看 PC 寄存器和寄存器堆。PC 必须带复位,复位值 0x00000000。这个模块看起来简单,但漏了复位会导致仿真一上来就乱跳。

module pc_reg #(parameter WIDTH=32) ( input logic clk, input logic reset, input logic [WIDTH-1:0] pcnext, output logic [WIDTH-1:0] pc ); always_ff @(posedge clk) begin if (reset) pc <= 32'h0000_0000; else pc <= pcnext; end endmodule

寄存器堆采用异步读、同步写,这是教学处理器最常见的写法。异步读意味着读数据不需要等时钟,只要地址稳定,rd1、rd2立刻输出;同步写则在时钟上升沿把数据写入。注意寄存器 0 在 RISC-V 里恒为 0,但实际硬件中它只是一个普通寄存器,最干净的做法是在写端口加判断:写地址为 0 时忽略写入。

module regfile ( input logic clk, input logic we3, input logic [4:0] a1, a2, a3, input logic [31:0] wd3, output logic [31:0] rd1, rd2 ); logic [31:0] rf [0:31]; assign rd1 = rf[a1]; assign rd2 = rf[a2]; always_ff @(posedge clk) begin if (we3 && a3 != 5'b0) rf[a3] <= wd3; end endmodule

ALU 部分用 3 位控制信号,编码分别是 000 与、001 或、010 加、110 减、111 有符号比较。zero信号单独用 assign 输出,只要 result 为 0 就拉高。这里最有迷惑性的是slt,比较时一定要用有符号数,$signed(a) < $signed(b)返回 1 位,赋给 32 位时自动零扩展成 0 或 1。

module alu ( input logic [31:0] a, b, input logic [2:0] alucontrol, output logic [31:0] result, output logic zero ); always_comb begin case (alucontrol) 3'b000: result = a & b; 3'b001: result = a | b; 3'b010: result = a + b; 3'b110: result = a - b; 3'b111: result = ($signed(a) < $signed(b)) ? 32'b1 : 32'b0; default: result = 32'b0; endcase end assign zero = (result == 32'b0); endmodule

3.3 控制单元与立即数扩展

立即数扩展是我在实验课上看到错误率最高的模块。这里给出一个 3 位 ImmSrc 的版本:000 对应 I 型、001 对应 S 型、010 对应 U 型、011 对应 B 型、100 对应 J 型。B 型和 J 型都需要拼接后左移一位,并补上符号位,别偷懒。

module immext ( input logic [31:0] instr, input logic [2:0] immsrc, output logic [31:0] imm ); always_comb begin case (immsrc) 3'b000: imm = {{20{instr[31]}}, instr[31:20]}; 3'b001: imm = {{20{instr[31]}}, instr[31:25], instr[11:7]}; 3'b010: imm = {instr[31:12], 12'b0}; 3'b011: imm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0}; 3'b100: imm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0}; default: imm = 32'b0; endcase end endmodule

主译码器用 case 语句直接按 opcode 分支。以下是一个简化但可运行的控制单元。这里alusrca是给后续扩展 lui/auipc 预留的 A 端口选择信号,在基础版里固定为 0。

module maindec ( input logic [6:0] op, output logic regwrite, memwrite, memread, branch, jump, output logic [1:0] memtoreg, output logic alusrca, output logic [1:0] alusrc, output logic [2:0] immsrc, output logic [1:0] aluop ); always_comb begin regwrite = 0; memwrite = 0; memread = 0; branch = 0; jump = 0; memtoreg = 2'b00; alusrca = 0; alusrc = 2'b00; immsrc = 3'b000; aluop = 2'b00; case (op) 7'b0110011: begin // R-type regwrite = 1; alusrc = 2'b00; aluop = 2'b10; end 7'b0010011: begin // addi regwrite = 1; alusrc = 2'b01; aluop = 2'b00; immsrc = 3'b000; end 7'b0000011: begin // lw regwrite = 1; alusrc = 2'b01; memread = 1; memtoreg = 2'b01; immsrc = 3'b000; end 7'b0100011: begin // sw alusrc = 2'b01; memwrite = 1; immsrc = 3'b001; end 7'b1100011: begin // beq branch = 1; aluop = 2'b01; immsrc = 3'b011; end 7'b1101111: begin // jal regwrite = 1; memtoreg = 2'b10; jump = 1; immsrc = 3'b100; end default: begin regwrite = 0; memwrite = 0; memread = 0; branch = 0; jump = 0; end endcase end endmodule

ALU 译码器的逻辑也很简单:ALUOp 为 00 时强制 ADD,01 时强制 SUB,10 时根据 funct3 和 funct7 细分。RISC-V 的add与sub使用相同的 funct3 但不同 funct7,所以funct7[5]可以用来区分。

module aludec ( input logic [2:0] funct3, input logic [6:0] funct7, input logic [1:0] aluop, output logic [2:0] alucontrol ); always_comb begin case (aluop) 2'b00: alucontrol = 3'b010; // ADD 2'b01: alucontrol = 3'b110; // SUB default: case (funct3) 3'b000: alucontrol = funct7[5] ? 3'b110 : 3'b010; // sub / add 3'b111: alucontrol = 3'b000; // and 3'b110: alucontrol = 3'b001; // or 3'b010: alucontrol = 3'b111; // slt default: alucontrol = 3'b010; endcase endcase end endmodule

3.4 仿真验证:一个能跑通的小程序

代码写完后,别急着上板,先用仿真跑。最常用的轻量工具是 Icarus Verilog,配合 GTKWave 看波形。写一个顶层测试平台,把 PC、寄存器堆输出、ALU 结果、内存内容全部拉成观测信号。

测试程序我习惯用一个小例子:把 x6 和 x7 相加,结果存到数据存储器地址 0x0,再读回来,后面可以接一条beq自己跟自己比较。指令存储器初始化文件长这样:

00000000: 00300313 // addi x6, x0, 3 00000004: 00500393 // addi x7, x0, 5 00000008: 00730833 // add x8, x6, x7 0000000c: 00a02023 // sw x8, 0(x0) 00000010: 00002483 // lw x9, 0(x0)

真实实验中,我强烈建议先用 RARS 或装一套 RISC-V GNU 工具链,把汇编转成二进制,再手动填写指令存储器初始化文件。手算机器码太容易出错,而且错了非常难查。仿真通过后,再用 Vivado 或 Quartus 做综合,看资源报告和时序报告。

4. 调试实录:仿真中踩过的那些坑

4.1 立即数拼接与符号扩展的坑

我第一次做单周期实验,仿真sw时发现写入数据存储器的地址总是差很多。查了半天,问题出在立即数扩展:我用 I 型扩展去处理 S 型,把sw的立即数 [31:25] 和 [11:7] 当成了连续的 12 位。RISC-V 的 S 型立即数把低 5 位放在 [11:7],高 7 位放在 [31:25],中间隔着 rs2 和 rs1。正确写法就是上面immext里的3'b001分支,拼接时必须带上中间的字段。

这个坑的典型症状是:所有 R 型和 I 型指令跑得正常,一遇到sw、lw或者分支就出乱子。排查方法很简单:单独建一个测试模块,把立即数扩展模块拉出来,给不同的 ImmSrc,打印输出立即数,和汇编器给出的值对比。符号位同样值得单独检查,imm[31]必须等于指令最高位,12 位立即数最高的位是instr[31],很多人一不小心就把它当成 0 了。

4.2 分支与跳转的目标地址为什么总是算错

分支目标算错是另一个高频问题。RISC-V 的 B 型立即数是相对于当前指令地址的字节偏移,所以beq的目标应该是PC + SignImm。千万不要套用 MIPS 教材里的PC+4 + SignImm:RISC-V 和 MIPS 的偏移语义不同,用错会导致每一次分支都多跳 4 字节,程序像疯了一样乱窜。在单周期里,PC 寄存器当前值就是当前指令地址,直接用PC + 扩展后的立即数即可。

另一个隐蔽问题是 B 型立即数左移一位。指令编码的 B 型立即数只包含 imm[12:1],不包含 imm[0](恒为 0)。如果扩展模块忘了左移补 0,分支目标会变成原来的 1/2,程序跳到指令中间。同样,JAL 的立即数也要左移一位。这个左移不是一个“可选项”,而是 RISC-V 编码的硬性要求。

至于 JALR,还需要额外处理最低位清零:目标地址是rs1 + imm,并且 bit 0 必须为 0。教学版如果不实现 JALR 可以偷懒,但一旦实现,这一位务必清掉。

4.3 寄存器堆写入时序和复位策略

寄存器堆的同步写是时序问题的集中点。单周期里,写入数据wd3(可能是 ALU 结果、内存读回值或 PC+4)必须在时钟上升沿前稳定一段时间。如果MemtoReg选择的是数据存储器读出的值,那么数据存储器的组合读延迟会直接加在关键路径上,这也是为什么lw总是最慢指令的原因。在波形图上,你会看到wd3一直到周期末才稳定,如果在它稳定前时钟沿就到了,寄存器就写入了错误的值。排查时,把wd3、we3、a3三个信号和时钟沿对齐看,基本能定位。

复位策略也要提前想清楚:PC 必须复位,因为第一条指令从哪里取完全取决于它;寄存器堆可以不复位,但测试程序里用到的寄存器要保证在第一条写指令之前不会被意外读取为未知值。在仿真里最容易踩的坑是仿真一开始x0不是 0,或者某个寄存器显示为 x。给寄存器堆加一个简单的初始化循环,或者在测试激励里先清零关键寄存器,都能解决。

4.4 关键路径与时钟周期的取舍

综合之后,你一定会收到时序报告。单周期处理器的关键路径几乎都是lw:PC -> 指令存储器 -> 寄存器堆读端口 -> ALU -> 数据存储器 -> 写回多路选择器 -> 寄存器堆写端口。这条路径决定了你能跑多高的时钟频率。如果你在 FPGA 上综合,时序报告里最长的 reg-to-reg 路径就是你调整时钟周期的依据。

教学里不需要追求高性能,但理解这条路径的组成非常有价值。你可以试着在数据通路的不同位置加流水线寄存器,比如把访存和写回切开,这其实就是流水线的雏形。很多人是从单周期做完了之后,才真正看懂流水线为什么要切五段,为什么每一段的延迟大致均衡,为什么访存和写回各自占用一段。这些理解,光看教材是很难建立的。

5. 我做完单周期后的几个体会

把整个单周期处理器调通那天,我的感觉不是“我又会了一个模块”,而是“CPU 原来真的只是一堆组合逻辑加上几个寄存器”。这句话听起来简单,但只有亲手把 PC、寄存器堆、ALU、数据存储器、控制单元一个个连起来,看着仿真波形里 PC 按照你预期的顺序跳动,寄存器堆按照指令一条条更新,才算真正进到了计算机体系结构的门里面。

如果让我给后来者三个建议:第一,先画数据通路和控制信号表,再写代码,不要反过来;第二,每条指令单独仿真,别等全部写完再调试,否则错误会互相掩盖;第三,立即数扩展和分支目标计算这种边角逻辑,要多花时间核对,它们最容易出错也最难发现。DDCA 第十讲的内容到这里就够你折腾好几周了,但折腾完,后面再学流水线、Cache、乱序执行,你会发现所有概念都长在单周期这棵树上,剩下的只是往上攀爬而已。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进