CPU是怎么把一段机器码变成最终结果的?很多学过数字电路的同学面对这个问题时是懵的——你懂与门、触发器、加法器,也背过指令集手册,但“指令”和“门电路”之间到底隔着什么,中间缺的那一层东西就叫做微架构。DDCA这门课进入Lesson 10之后,单周期微架构就是把这层空缺补上的第一块砖。它用一套数据通路加一个控制单元,让每一条指令在一个时钟周期内完成取指、译码、执行、访存、写回的全过程。这篇内容我按自己带学生做处理器设计的经验,把单周期的模块拼装、指令流动、控制信号和时序预算全部拆开讲一遍,适合正在学计算机组成原理、准备用Verilog写CPU,或者被流水线冒险搞得一头雾水后想回头补基础的同学。
1. 为什么非要从“单周期”开始学微架构
1.1 ISA是“接口”,微架构是“实现”
先把概念摆正。ISA(Instruction Set Architecture)是程序员能看到的机器契约:有哪些指令、多少个寄存器、内存怎么寻址、指令编码长什么样。它不关心这些指令在硬件里到底是怎么被执行的。而微架构是硬件设计者真正要在门电路层面搭建的东西:PC怎么更新、寄存器堆怎么读写、ALU连在哪、数据存储器什么时候使能。
这两个层次的区别很重要,但初学者特别容易混。有人背下了RV32I的所有指令编码,却画不出一条lw指令从取指到写回经过哪些部件;也有人一上来就写了一大堆Verilog模块,但每个信号的来龙去脉说不清楚。单周期微架构之所以是所有教材的起点,就是因为它把ISA到硬件的映射做得最直白——一个指令对应一条完整路径,没有重叠、没有流水线冒险、没有乱序,控制单元也简单到可以直接用真值表描述。你在这个阶段建立的模块划分,后面做多周期、流水线时都会原样保留。
1.2 单周期最核心的假设:一条指令在一拍内走完全程
单周期设计的基本观点是:整个CPU在一个时钟周期内完成一条指令的所有步骤。寄存器堆在这一拍前面读、最后写,存储器在这一拍取指令,数据存储器在这一拍读或写,ALU在这一拍算出结果。时钟周期因此被锁定为——从PC输出地址开始,到寄存器堆完成写入为止,最慢的那条指令走完整条路径所需的时间。
这意味着几乎所有指令都在“迁就”最慢的指令。lw通常是最长的路径,因为它既要读指令存储器、读寄存器堆、经过ALU计算地址,还要读数据存储器、最后写回寄存器堆。比它短的指令,比如add,老老实实在更短的时间内完成了,但时钟周期不会因此变短。这种“浪费”在性能上确实难看,但它换来的是控制逻辑的极度简化:没有状态机,没有周期计数,一拍定乾坤。对验证而言,这个特性是无价的——任何一条指令跑不出预期结果,你能沿着唯一的那条路径逐点排查信号。
1.3 单周期阶段建立的“思维钢印”会一直跟着你
我常和学生说一句话:流水线处理器里90%的模块你在单周期阶段都见过。取指阶段的PC和指令存储器、译码阶段的寄存器堆和立即数扩展、执行阶段的ALU、访存阶段的数据存储器、写回阶段的寄存器堆写入,这些在单周期里是一条完整通路的五个拼图块,在流水线里只是被插入的流水寄存器隔开了而已。
所以这个阶段的真正任务不是“做一个能跑的程序”,而是把每个模块的输入输出、每个控制信号的功能、每条指令的路径彻底焊死在脑子里。后面当你面对数据冒险需要前递,面对控制冒险需要冲刷流水线时,你对“哪个信号在哪一级产生、哪一级消费”的理解,全都来自单周期阶段建立的这张地图。
2. 单周期数据通路的骨架:从取指到写回的模块拼装
2.1 五大核心模块的职责与接口
单周期数据通路的核心部件并不复杂,按功能可以拆成五块,我按指令的流动方向逐个说明。
PC(程序计数器)是状态元件,保存当前指令地址。每个时钟上升沿到来时,它根据控制逻辑的输出决定下一个地址是PC+4还是分支跳转目标。在字节寻址的指令集里,指令按4字节对齐,所以PC+4是默认步长。这里的重点是PC的输入来自一个多路选择器,选择信号Branch与ALU零标志相与的结果。
指令存储器是只读的,输入是PC给出的地址,输出是对应地址的32位指令编码。它不关心指令内容,只负责查表输出。注意在单周期教学型处理器中,我们简化成指令存储器可以直接输出完整指令,不涉及指令缓存未命中、总线等待等真实系统的问题。
寄存器堆是单周期数据通路的“心脏”。它有32个寄存器,两个读端口读rs1和rs2,一个写端口写rd。这里有个关键设计——异步读、同步写。所谓异步读,就是只要给出读地址,寄存器数据就在组合逻辑延迟后出现在读数据线上;而写则必须等到时钟边沿,同时写使能有效,才会把数据锁存进去。这个“先读老值、后写新值”的时序是单周期能在同一拍完成读写而不互相干扰的根本原因。
ALU执行算术和逻辑运算。在R型指令中计算真正的运算结果,在访存指令中计算基址加偏移后的有效地址,在分支指令中通过减法判断两个寄存器是否相等。具体做什么运算,由ALU控制信号决定,这个信号由主控制单元给出的ALUOp和指令的低位funct字段联合解码产生。
数据存储器只在lw和sw指令中被访问。lw需要读使能MemRead,sw需要写使能MemWrite。和指令存储器不同,数据存储器在单周期通路中位于比较靠后的位置,它的输出会通过多路选择器决定是否写回寄存器堆。
每个模块的接口可以用一句话记住:PC决定“去拿哪条指令”,指令存储器给出“这条指令是什么”,寄存器堆给出“参与运算的源数据”,ALU算出“结果或地址”,数据存储器负责“内存读写”,最后写回寄存器堆形成闭环。
2.2 一条路串起来,信号是怎么“回流”的
把这五个模块按顺序串起来,就得到单周期数据通路的主干:PC指向指令存储器,指令编码被拆分后,一部分送寄存器堆读地址,一部分送立即数扩展单元生成32位立即数,寄存器堆的读数据和立即数通过ALUSrc选择器进入ALU,ALU结果经过MemtoReg选择器决定是直接写回还是从数据存储器读出后写回,写回数据进入寄存器堆的写数据端口。与此同时,受影响的控制信号从主控制单元分发给各个多路选择器和使能端口。
第一次画这个图的人最容易懵的地方,是“PC到底怎么知道下一条指令在哪”。对这个问题的理解要分两条路径:如果当前不是分支指令,或者分支条件不成立,下一条地址就是PC+4,这个值由加法器算好;如果当前是分支指令且条件成立,下一条地址是PC+4加上符号扩展并左移两位后的偏移量,这个值由另一个加法器算好。到底选哪条,由Branch信号和ALU零标志相与后送给PC输入端的多路选择器。
我建议你在纸上把这个闭合回路完整画一遍,从取指开始,经过每个多路选择器,最后回到PC的输入,标出每条信号的宽度和含义。画完这一遍,你就掌握了单周期数据的“血流方向”,后面看任何控制信号都有坐标系了。
3. 指令在通路中的真实旅行:从R型到访存再到分支
3.1 R型指令:算数运算的完整路径
以add指令为例。它是最经典的三寄存器指令,格式是add rd, rs, rt,把rs和rt的值相加写入rd。取指之后,指令编码被解析出来:rs字段作为寄存器堆A1端口的读地址,rt字段作为A2端口的读地址,rd字段则被送到写数据端口的地址选择器上。这就是RegDst信号的作用——它决定寄存器堆的写地址到底来自R型指令的rd字段,还是来自访存指令的rt字段。
两个读端口把源操作数送给ALU的A端和B端。由于是R型指令,ALUSrc信号必须为0,让B端选择寄存器堆的输出,而不是立即数扩展单元的输出。ALU在ALUOp和funct字段的解码下执行加法,结果直接作为写数据,经过MemtoReg=0的选择,送到寄存器堆的写端口。RegWrite为1,下一个时钟上升沿到来时,结果锁存进rd寄存器。
这条路径中值得注意的细节是,R型指令根本不碰数据存储器,MemRead和MemWrite都是0,所以数据存储器处于闲置状态。单周期的“浪费”在这里已经初见端倪——数据存储器每个周期都通电,但只有lw和sw真正使用它。
下面是R型指令关键控制信号的值,我把后面三种指令的信号一并对齐展示,方便对照。
| 控制信号 | R型(add) | lw | sw | beq |
|---|---|---|---|---|
| RegWrite | 1 | 1 | 0 | 0 |
| RegDst | 1(写rd) | 0(写rt) | x | x |
| ALUSrc | 0(寄存器值) | 1(立即数) | 1 | 0 |
| ALUOp | 10(按funct解码) | 00(加法) | 00 | 01(减法/比较) |
| MemRead | 0 | 1 | 0 | 0 |
| MemWrite | 0 | 0 | 1 | 0 |
| MemtoReg | 0(ALU结果) | 1(存储器数据) | x | x |
| Branch | 0 | 0 | 0 | 1 |
3.2 lw/sw访存指令:数据存储器的读写细节
lw指令的路径要长一截。lw rt, imm(rs) 的意思是,从基址寄存器rs取出一个地址,加上符号扩展后的立即数偏移,得到有效地址,从该地址读出数据写入rt寄存器。
指令取指后,寄存器堆的A1端口读出rs的值。由于ALUSrc=1,ALU的B端不再接寄存器堆的第二个读端口,而是接立即数扩展单元的输出。ALU此时必须执行加法,因为基址加偏移本身就是一次地址加法。ALU输出的是有效地址,它作为数据存储器的地址输入。MemRead为1时,数据存储器的输出端口给出该地址的数据,经过MemtoReg=1的选择器,最终在时钟边沿写入rt寄存器。
这里有个特别容易忽略的点:立即数扩展必须是符号扩展,而不是零扩展。lw/sw的偏移量是16位有符号数,范围是-32768到32767。如果偏移是负的,比如-4,编码是0xFFFC,零扩展后会变成0x0000FFFC,加上基址0x00001000就得到0x0001000C,完全错误;符号扩展成0xFFFFFFFC,加基址得到正确的0x0000100C。同理,指令里那4个字节宽的偏移在实际取指后会左移两位再参与地址计算,这一点在下一条指令里更关键。
sw的路径和lw几乎一样,只是方向相反:ALU算好的有效地址送到数据存储器的地址端口,寄存器堆rt端口读出的数据送到写入数据端口,MemWrite为1触发写入。sw不需要写回寄存器堆,所以RegWrite为0。注意此时RegDst和MemtoReg都无所谓,这就是表格里填x的原因——无关项在某些实现里会固定接0或接某个常量,只要不影响这条指令的功能即可,不用纠结。
3.3 beq指令:分支的本质是“比较 + 选择下一条PC”
分支指令的独特之处在于它改变了PC的更新逻辑。beq rs, rt, label 的意思是,如果rs的值等于rt的值,就跳转到label,否则顺序执行下一条。
在数据通路上,beq仍然走寄存器堆读端口,两个源操作数直接送入ALU的A端和B端,ALUSrc为0。ALU此时做减法,作为“比较相等”的实现方式:如果结果为0,说明两个操作数相等,零标志ZF为1。这个零标志和Branch控制信号相与,产生PCSrc信号,送到PC输入端的MUX上,决定下一条地址是PC+4还是跳转目标。
跳转目标的计算是初学者最容易漏掉的一环。beq的立即数字段是16位偏移,但它表示的不是字节偏移,而是相对于当前指令地址下一条指令的偏移,单位是指令条数。硬件上必须把这个16位立即数符号扩展,再左移两位(乘以4),然后与PC+4相加,才能得到正确的目标地址。也就是说,目标地址 = (PC + 4) + (SignExtImm << 2)。如果你忘了左移,指令目标会指向错误位置,程序直接跑飞;如果你用了零扩展而不是符号扩展,向后分支会跳到高位地址上去,这个错误更难排查。
很多初学者在理解beq时会有个困惑:ALU刚算完结果,PC马上就更新,这不算冒险吗?答案是不算。因为单周期里PC的更新发生在时钟边沿,而beq从取指、读寄存器、比较到计算目标地址,全程都是组合逻辑路径,在一个周期内已经全部稳定。PC在下一拍才使用这个稳定后的值。所以单周期设计天然没有分支冒险,这是它的“福利”,也是一个容易形成思维惯性的地方——后面切到流水线,分支延迟槽和预测才是让你头疼的东西。
4. 控制单元:8个控制信号的真值表与设计思路
4.1 每个控制信号到底在“拨动”哪个开关
控制单元是数据通路的“指挥”。它输入指令的opcode字段,输出一组控制信号。很多同学能背出真值表,但问一句“这个信号到底干了什么”,就说不清了。我建议你这样理解:数据通路里凡是出现\MUX\的地方,旁边基本都站着一个控制信号。
RegWrite控制寄存器堆的写使能。所有要写寄存器堆的指令(R型和lw)必须为1,sw、beq这类不写寄存器的指令为0。RegDst控制写地址来自哪里:R型指令写回rd字段,lw写回rt字段,所以这个选择器是必要的,哪怕只有两种来源也要单独设一个信号。
ALUSrc是几乎所有初学者画图时最容易搞混的一个。它决定ALU的B端到底接寄存器堆的第二个读数据,还是接立即数扩展的输出。R型指令要拿两个寄存器做运算,所以是0;lw/sw要用基址加偏移,所以是1;beq比较两个寄存器,所以是0。
MemRead和MemWrite一对信号管数据存储器的读写使能,lw置MemRead,sw置MemWrite,其他指令两者都为0。MemtoReg管写回寄存器堆的数据是来自ALU结果还是数据存储器输出,R型选前者,lw选后者。
4.2 真值表不是背出来的,是推出来的
可能有人觉得上面的表需要刻意背,但实际上你可以从每条指令的语义直接推出来。比如看到beq,第一反应是“它要比较两个寄存器”——所以ALUSrc=0,ALUOp要让ALU做比较;第二反应是“它不写寄存器也不碰存储器”——所以RegWrite=0、MemRead=0、MemWrite=0;第三反应是“条件成立要跳转”——所以Branch=1。你只需要记住每种指令的“动作清单”,真值表自然就出来了。
画控制单元时还有个小技巧:先用x把无关项标出来,再用硬件描述时统一处理。x在处理上有两种做法,一种是具体电路里强制接0或接1,另一种是在Verilog的case语句里用default分支涵盖一切未列出的组合。教学用的单周期CPU里,我习惯在控制单元里给x赋一个固定值,比如把RegDst的x置0、MemtoReg的x置0,这样逻辑更干净,也不影响功能正确性。
4.3 ALUOp与funct字段:两级解码是怎么分工的
ALUOp是一个编码后的信号,它不是直接告诉ALU“做加法”或“做减法”,而是告诉一个专门的ALU控制子单元“当前指令属于哪一类,你要不要看funct字段”。这就是两级解码:主控制单元只负责粗分类,具体到加法、减法、与、或、比较这类细粒度操作,由ALU控制子单元结合指令低位的funct字段来最终确定。
举个MIPS风格的例子:ALUOp为00时,ALU控制子单元直接输出加法,不需要看funct字段,因为这对应lw/sw/addi这类固定要用加法的指令;ALUOp为01时直接输出减法,对应beq的比较操作;ALUOp为10时,ALU控制子单元必须去看funct字段,因为R型指令的funct编码(比如0x20是加法、0x22是减法、0x24是与、0x25是或、0x2A是小于比较)才真正规定了运算类型。
用伪代码描述ALU控制子单元的逻辑就是:
output = 加法 case ALUOp: 2'b00: output = 加法 2'b01: output = 减法 2'b10: case funct: 32: output = 加法 34: output = 减法 36: output = 与 37: output = 或 42: output = 小于比较为什么这样设计?因为主控制单元只需要看opcode就能给出粗分类信号,而opcode在指令的高位,解码速度快;具体的funct字段在低位,由ALU子单元在ALU操作前负责精细解码。如果让主控制单元直接解码所有操作,它会变成一个超大查表逻辑,延迟增加,也没有必要。这种分工在真实处理器里很常见,你后面看RISC-V的控制逻辑时会发现完全相同的思路。
5. 单周期的时钟到底能跑多快:关键路径与时序预算
5.1 把lw的每一段延迟撕开来看
单周期的时钟周期不是拍脑袋定的,它等于最长路径的延迟总和。那条最长路径通常由lw贡献,因为它经过了取指、读寄存器堆、ALU计算、读数据存储器、写寄存器堆这五段。如果一条更长的路径存在,比如某些复杂寻址模式或者带大延迟的乘法器,那就以它为准。
用一个典型参数做个计算,延迟单位纳秒,数值不一定代表真实工艺,只是为了展示思路:PC的时钟到Q延迟算1ns,指令存储器访问时间算2ns,寄存器堆读延迟1ns,ALU延迟2ns,数据存储器读延迟2ns,寄存器堆写入建立时间1ns。把这些加起来,lw的关键路径是1+2+1+2+2+1=9ns,对应的时钟频率上限大概是111MHz。
其他指令的路径短得多。add只需要1+2+1+2+1=7ns,beq算上分支目标加法器也大概8ns。但单周期时钟周期是9ns,所有指令都按9ns跑。换句话说,大多数周期里你有2到3纳秒是白白浪费的。这正是单周期性能的硬伤:不是CPI高,而是时钟周期被最慢指令拖长。
5.2 只看CPI是一种统计陷阱
单周期的CPI是1,乍一看这是完美值——每条指令只占一个周期。但性能是周期数和时钟周期的乘积,不是CPI单独决定的。同样一段程序,运行时间 = 指令数 × CPI × 时钟周期。单周期CPI最低,时钟周期却最长。
我做个直观的比喻:单周期就像一个只开一个窗口的食堂,每个人从点菜到取餐必须等整条流水线走完,哪怕你只点了杯饮料,也要等炸鸡出锅的时间。多周期和流水线则是把备菜、烹饪、打包拆成不同工位,设计目标是让每个工位的耗时尽可能均衡,这样虽然单个顾客的“服务时间”可能变长,但整个食堂的吞吐量上去了。
把这个思路落回数字设计,结论是:单周期适合做教学和正确性验证,因为它直观、可控、容易调试。真正追求性能的处理器不会在单周期架构上空跑,而是会演进到多周期甚至流水线。多周期把每条指令拆成若干步骤,复用ALU、存储器等部件,硬件面积大大降低;流水线则让不同指令的不同步骤重叠执行,在理想情况下CPI回到1且时钟周期只需要按最慢的“级”来确定。理解了单周期的瓶颈,你才会明白流水线里那些寄存器为什么会出现,每条指令为什么被划分为固定的五级。
6. 单周期在Verilog实践中值得记住的几个坑
6.1 寄存器堆的“异步读、同步写”时序别写反
这是新手写单周期CPU时最常见也最隐蔽的bug。寄存器堆的读是纯组合逻辑:assign rd1 = (addr1 != 0) ? regs[addr1] : 0; 读地址进来,数据就出来,不要等时钟。而写必须同步:在时钟上升沿且写使能有效时,把数据写入。如果你不小心把读也写成了同步逻辑,比如在always中加了时钟边沿才赋值,那整个通路就变成流水线了,一条指令的读会等到下一拍才完成,控制信号时序全乱。
反过来,如果你把写写成了组合逻辑,数据一出现在写端口就立刻更新寄存器,那同一拍里你的R型指令可能读到还没写回的新值,或者是旧指令残留的数据,这叫写穿。单周期依赖“先读旧值、后写新值”的天然保护,一旦破坏,程序行为完全无法预测。写寄存器堆时我建议先写一个独立的testbench,单独验证异步读、同步写,再接入CPU主模块。
6.2 立即数符号扩展,错一个位全盘皆错
之前提过lw偏移量必须是符号扩展,这里再补一个sw时的相似问题。sw把寄存器堆rt端口的数据写给存储器的写数据端口,地址由基址和符号扩展后的偏移相加得到。如果你写成零扩展,负数偏移会变成一个很大的正数,程序访问到错误地址,simulation里可能不报错,但波形一拉出来全是非法地址。
判断扩展方式有个简单规则:任何会被用来做地址加法或分支偏移的立即数,原则上都要符号扩展;只有逻辑指令里的立即数按无符号处理。你要是拿不准,就翻指令集手册里立即数位域的定义,看它是signed还是unsigned。代码里我习惯单独写一个立即数扩展模块,输入inst字段加上一个ImmSrc控制信号,区分I型、S型、B型的位域重组和符号扩展,再把模块输出统一接给ALU的B端MUX。模块化之后,调试扩展问题时一眼就能定位。
6.3 beq的比较结果和控制信号的优先级别犯迷糊
beq判断相等要用减法且看零标志,但要注意:零标志在单周期里是ALU的额外输出,不是单独一个寄存器。有些同学习惯把ALU的结果写到一个result寄存器里,下一拍再判断,这在单周期里就是错误——beq的TD我们要求在同一拍内完成比较和PC选择,整个组合路径必须在时钟沿前稳定。
另一个坑是Branch信号与零标志相与后再选PC。如果你直接把Branch接到了PC的MUX上,而不考虑零标志,那beq不管条件是否成立都会跳转,任何分支指令都会无条件跳走。检查时可以先写一条rs不等于rt但故意带了相等标签的beq,看它是否错误跳转;再写一条rs等于rt的beq,看它是否成功跳转。两个用例通过,这个MUX逻辑才算合格。
6.4 在单周期阶段就要建立“信号生命周期”意识
单周期没有流水线寄存器,所有信号都在一个周期内有效,这让调试变得简单,但也容易让你养成“信号随时可用”的直觉。一旦你将来做多周期或流水线,每个控制信号必须在特定周期生效并保持到被消费的那一级。比如MemtoReg信号在译码阶段就产生了,但它真正起作用是在写回阶段,中间隔着访存级,所以它必须跟着指令数据一路打拍传送。
我建议你在写单周期Verilog时就养成一个习惯:给每个模块写明注释,标注它的输入信号是在“哪个阶段产生、哪个阶段消费”。这个习惯的成本极低,但等你在流水线里为了一个前递信号查三小时波形时,就会感谢自己留下的这些标记。
最后再分享一个我自己教学时的体会:单周期微架构是那种“看十遍图不如亲手搭一遍”的内容。花一晚上用Verilog把RV32I或MIPS的一个子集搭出来,控制信号对着真值表一个一个接,再写几段汇编——一段做加法循环、一段访问数组、一段跑分支——看着波形里PC一步步走对,那一刻你对处理器执行程序这件事才算真正有了实感。搭完之后,建议你再做一件小事:把每条指令的关键路径在纸上画出来,标出哪些模块是它不经过的。你会发现,对性能的理解、对流水线必要性的理解,都会从这张图里自然长出来。