
都说学计算机体系结构不吃香了但真到了写RTL、调硬件的时候很多人才发现能徒手写一个CPU依然是区分“懂系统”和“只会调模块”的分水岭。我自己的感受是在Verilog入门阶段与其写一百个流水灯和计数器不如沉下心来把一颗RISC-V单周期CPU完整实现一遍。这件事做完你对指令集、数据通路、控制信号到底在干什么、为什么会有这些信号的认知会在一瞬间串成一条线。这篇博客我会从零开始带你把一颗RV32I子集的单周期CPU用Verilog手写出来。内容包括核心模块代码、顶层数据通路连接、仿真验证的完整流程以及我实际调试中反复踩过的坑。适合刚学完Verilog语法、想进阶数字IC设计或者正在上计算机体系结构课、想把课本里的MIPS换成RISC-V的同学。全程不需要开发板也能跑一个开源的Icarus Verilog就足够了。1. 动手之前RISC-V指令集和单周期架构的核心思路1.1 为什么选RISC-V而不是MIPS或ARM很多人第一次写CPU课本上用的是MIPS。MIPS确实经典但它是商业指令集生态越来越封闭。RISC-V在这几年几乎是数字IC岗位和计算机体系结构课程的事实标准。先说清楚一点我做的不是完整RV32I而是RV32I的一个实用子集包含算术逻辑、访存、分支跳转这几类最关键的指令。把这十几条指令打通你已经拥有了一颗精简但五脏俱全的CPU扩展起来只是加译码和控制逻辑的事。RISC-V相比MIPS最大的优势是简洁。它的指令格式非常规整六种基本格式R、I、S、B、U、J每一种的立即数位域都是有规律的硬件译码时只需要简单的拼接和符号扩展不需要像x86那样做复杂的变长译码。这对单周期CPU来说极其友好Verilog实现起来很直观。1.2 单周期CPU的关键特征CPU1和时钟周期的权衡单周期CPU的意思是每条指令在一个时钟周期内完成全部操作。从取指、译码、执行、访存到写回所有步骤都在同一个周期内组合完成。这样做的好处是控制逻辑非常简单状态机都不需要坏处是时钟频率会被最慢的那条指令拖累。比如load指令要走完指令存储器、寄存器堆读、ALU、数据存储器、寄存器堆写回五段路整个周期的长度就必须以它为准。我经常用一个类比来解释这个事单周期就像一个生产线每个工位都必须在同一个节拍内完成自己的工作。最慢的工位决定了整个产线的速度但调度逻辑极其简单。流水线CPU则像把产线拆成多级每级只干一点活整体吞吐率上去了但需要加流水线寄存器、处理冒险问题复杂度立刻上一个档次。所以先写好单周期再去碰流水线是非常合理的进阶路径。1.3 指令集设计范围这几条指令足以跑通汇编程序我规划的指令子集覆盖R、I、S、B、U、J六种格式总计15条指令足够编写循环、数组访问、函数调用这类简单的汇编程序。格式指令功能R型ADD, SUB, AND, OR, XOR, SLT寄存器间算术逻辑运算与比较I型ADDI, ORI立即数算术逻辑运算I型LW从内存加载32位数据到寄存器I型JALR寄存器间接跳转并保存返回地址S型SW将寄存器数据写入内存B型BEQ相等则分支跳转U型LUI加载立即数到寄存器高位J型JAL直接跳转并保存返回地址这套指令足够做什么我后面会写一个计算1到10累加和的测试程序还会给一个用LW和SW访问数组的例子。这些都是汇编层面的事你不需要先装编译器手写机器码就行——这反而是理解编码格式的最佳方式。2. 数据通路与模块划分把架构图画清楚再写代码2.1 六个核心模块的职责与连接逻辑写Verilog之前先在纸上把数据通路画明白。我见过太多人一上来就写代码结果写到控制单元的时候开始到处补线最后整个结构乱成一锅粥。单周期CPU我规划为六个模块PC程序计数器每个时钟上升沿更新指向下一条指令地址指令存储器ROM输入PC地址输出32位指令编码寄存器堆RegFile两个读端口、一个写端口支持异步读、同步写立即数扩展模块根据指令格式将立即数位域扩展为32位符号数ALU执行加减、逻辑运算、比较控制单元由opcode和funct3/funct7译码出所有控制信号数据存储器RAM由ALU计算结果作为地址读写字数据模块间的关系用一个文字描述就是PC给出地址指令存储器输出指令控制单元和立即数扩展模块译码寄存器堆输出两个源操作数ALU结合立即数完成计算再根据指令类型决定是写回寄存器还是访问数据存储器最终在周期末更新寄存器堆和PC。2.2 控制信号规划每根信号都有明确含义控制单元是整个CPU的“大脑”。我最终需要的控制信号有七个全部由指令的opcode和funct字段唯一决定信号名宽度作用RegWrite1寄存器堆写使能为1时允许写寄存器ALUSrc1ALU第二操作数来源0选rs21选立即数MemWrite1数据存储器写使能SW指令置1MemRead1数据存储器读使能LW指令置1MemtoReg1写回寄存器数据来源0选ALU结果1选存储器读出数据Branch1分支指令标志与ALU零信号共同决定是否跳转Jump1无条件跳转标志JAL和JALR置1ALUOp2ALU操作编码决定ALU控制器如何解析funct字段这里有一个细节为什么不直接用ALU控制信号而是先给一个ALUOp因为我不想让主控制单元直接依赖funct7和funct3那样真值表会非常混乱。ALUOp相当于一个二级译码的中间信号主控制单元只根据opcode给Operation级指令ALU控制器再根据funct精确产生ALU运算选择。这是课本上标准的分层译码思路代码可读性好很多。2.3 指令编码与立即数扩展的设计细节RISC-V的立即数扩展坑很多我在这里吃过亏值得专门说一下。I型指令的立即数在inst[31:20]只需要将其符号扩展到32位S型指令的立即数需要把inst[31:25]拼到高位、inst[11:7]拼到低位即{ {21{inst[31]}}, inst[30:25], inst[11:7] }。B型是最容易出错的它的立即数分散在第31、7、30:25、11:8位域而且最低位永远为0表示半字偏移有误不是半字是分支地址需要2字节对齐吗对于RV32I来说所有指令地址都是2字节对齐不对RV32I因为存在16位压缩指令扩展RV32C非压缩指令要求4字节对齐所以B型立即数最低位固定为0。我们做的是非压缩RV32I不用考虑压缩指令所以B型扩展式是{ {20{inst[31]}}, inst[7], inst[30:25], inst[11:8], 1b0 }。而JAL的立即数格式是{ {12{inst[31]}}, inst[19:12], inst[20], inst[30:21], 1b0 }。U型指令LUI最简单直接把20位立即数搬到31:12位低12位补零。这些拼接逻辑看起来繁琐但每一个字节位都对应着ISA手册的编码图做一遍之后你对RISC-V编码规则的印象会非常深刻。3. Verilog核心代码实现关键模块逐段剖析3.1 PC与指令存储器的实现PC模块只有一个功能在时钟上升沿到来时根据控制信号更新地址。在单周期设计中PC更新的组合逻辑是在顶层完成的PC模块本身只负责寄存当前值。一个标准的写法module pc( input clk, input rst, input [31:0] pc_next, output reg [31:0] pc ); always (posedge clk or posedge rst) begin if (rst) pc 32h0; else pc pc_next; end endmodulePC的next值需要在顶层计算非跳转时是pc 4跳转或分支成立时是目标地址。分支目标地址的计算公式是pc 4 {{20{imm[31]}}, imm[31:20]?}不这里要用扩展后的imm。具体说是pc_next (branch_taken || jump) ? branch_target : pc 4其中branch_target pc 4 (imm_ext 1)。为什么是pc4而不是pc因为RISC-V分支偏移是相对于分支指令下一条指令的这一点和MIPS一模一样初学者特别容易忘。指令存储器我用一个初始化为零的reg数组模拟ROM实际工程中会替换为SRAM或ROM IP核。为了仿真时能加载程序使用$readmemh一次性把机器码读进来reg [31:0] instr_mem [0:4095]; initial $readmemh(program.hex, instr_mem); assign instr instr_mem[pc[31:2]];注意pc[31:2]因为指令存储器按字节寻址但一条指令占4字节所以字索引是地址的高30位。这里的位宽是4096个字的地址空间够用。用pc[31:2]会让内存基址按照字对齐访问这也是RISC-V的基本约定。3.2 寄存器堆异步读、同步写是关键寄存器堆是CPU里最容易写出时序bug的模块之一。我的设计是复位时把所有寄存器清零读端口是组合逻辑写端口只在时钟上升沿且写使能有效时更新。还有一个专用做法当写地址为x0时无论写使能是否有效都不写入数据因为RISC-V规范规定x0永远为0。module regfile( input clk, input rst, input [4:0] rs1, input [4:0] rs2, input [4:0] rd, input we, input [31:0] wdata, output [31:0] rdata1, output [31:0] rdata2 ); reg [31:0] regs [0:31]; integer i; always (posedge clk or posedge rst) begin if (rst) begin for (i 0; i 32; i i 1) regs[i] 32h0; end else if (we (rd ! 5h0)) begin regs[rd] wdata; end end assign rdata1 regs[rs1]; assign rdata2 regs[rs2]; endmodule这里有一个重要的注意事项读操作是异步组合逻辑意味着当前周期给rs1立刻就能得到寄存器值不需要等待时钟。而写操作是同步的时钟边沿才会写入。这正好契合单周期CPU的时间模型前半个周期读寄存器、执行计算后半个周期把结果写回。如果读写都做成同步一个周期根本干不完事。3.3 ALU的实现加减、逻辑、比较一网打尽ALU我设计成三个输入信号两个32位操作数和一个4位ALU控制信号。控制信号和运算的对应关系用case语句实现代码直接对应硬件多路选择器module alu( input [31:0] a, input [31:0] b, input [3:0] alu_ctrl, output reg [31:0] result, output zero ); always (*) begin case (alu_ctrl) 4b0000: result a b; 4b0001: result a - b; 4b0010: result a b; 4b0011: result a | b; 4b0100: result a ^ b; 4b0101: result ($signed(a) $signed(b)) ? 32h1 : 32h0; default: result 32h0; endcase end assign zero (result 32h0); endmodule有符号比较必须使用$signed(a) $signed(b)不能直接写a b因为Verilog默认把reg类型当作无符号数所以负数比较会得出错误结果。SLT指令是给分支和条件判断用的这里提前实现。zero信号用来给BEQ提供“相等”的判断因为BEQ需要的是两数相减结果为零。3.4 立即数扩展模块格式不同拼接规则不同扩展模块根据指令格式选择不同的拼接路径。我通过opcode来区分但没有直接判断每一条指令而是通过顶层传入了一个2位的ImmSel信号。控制单元把指令分类为I型、S型、B型、U型、J型扩展模块各自处理module imm_ext( input [31:0] inst, input [2:0] imm_sel, output reg [31:0] imm_ext ); always (*) begin case (imm_sel) 3b000: imm_ext { {21{inst[31]}}, inst[30:20] }; // I型 3b001: imm_ext { {21{inst[31]}}, inst[30:25], inst[11:7] }; // S型 3b010: imm_ext { {20{inst[31]}}, inst[7], inst[30:25], inst[11:8], 1b0 }; // B型 3b011: imm_ext { inst[31:12], 12h0 }; // U型 3b100: imm_ext { {12{inst[31]}}, inst[19:12], inst[20], inst[30:21], 1b0 }; // J型 default: imm_ext 32h0; endcase end endmodule这个模块的设计思路是我把“立即数到底是什么格式”完全交由ImmSel决定主控制单元不需要关心拼接细节。实际上模块内部对B型做了左移一位也就是把分支偏移乘2。为什么在这里直接左移因为分支目标地址计算时用的是字节偏移而RV32I跳转的粒度是4字节所以ISA编码时最低位不存储留为0。这样在计算目标地址时就不需要再乘2直接pc 4 imm_ext即可。3.5 控制单元真值表与Verilog实现控制单元是全部设计里最像“查表”的部分。我根据opcode、funct3、funct7 生成所有控制信号。为了清晰我用一个二维数组逻辑写成always块本质上是多级if-else实现真值表always (*) begin // 默认值清零 reg_write 0; alu_src 0; mem_write 0; mem_read 0; mem_to_reg 0; branch 0; jump 0; alu_op 2b00; imm_sel 3b000; case (opcode) 7b0110011: begin // R型 reg_write 1; alu_op 2b10; imm_sel 3b000; end 7b0010011: begin // I型算术 reg_write 1; alu_src 1; alu_op 2b11; imm_sel 3b000; end 7b0000011: begin // LW reg_write 1; alu_src 1; mem_read 1; mem_to_reg 1; alu_op 2b00; imm_sel 3b000; end 7b0100011: begin // SW alu_src 1; mem_write 1; alu_op 2b00; imm_sel 3b001; end 7b1100011: begin // BEQ branch 1; alu_op 2b01; imm_sel 3b010; end 7b0110111: begin // LUI reg_write 1; alu_src 1; alu_op 2b00; imm_sel 3b011; end 7b1101111: begin // JAL reg_write 1; jump 1; imm_sel 3b100; end 7b1100111: begin // JALR reg_write 1; jump 1; alu_src 1; alu_op 2b00; imm_sel 3b000; end endcase endALUOp编码我定义为00代表加法用于写地址、取LUI01代表减法用于BEQ比较10代表R型指令需要继续查看funct11代表I型算术指令用funct3区分是ADDI还是ORI。这里的关键是当ALUOp为10或11时还需要一个二级的ALU控制器根据funct字段计算最终的alu_ctrl。这部分不复杂用case嵌套funct3和funct7即可。3.6 顶层数据通路把所有模块串起来顶层模块把上面所有组件连接在一起。代码里最核心的部分是三个多路选择器和PC更新的逻辑// 两个读端口数据 wire [31:0] read_data1, read_data2; wire [31:0] imm; wire [31:0] alu_result; wire [31:0] mem_read_data; // ALU第二操作数选择 wire [31:0] alu_in2 alu_src ? imm : read_data2; // 写回数据选择 wire [31:0] write_back_data mem_to_reg ? mem_read_data : alu_result; // 分支判断 wire branch_taken branch zero; wire jump_flag jump; wire [31:0] pc_next (branch_taken || jump_flag) ? branch_target : pc_plus4;我需要小心JAL和JALR的目标地址计算差别。JAL的目标地址是pc 4 imm_ext相对跳转而JALR的目标地址是read_data1 imm_ext寄存器间接跳转。所以我需要单独给两个跳转目标wire [31:0] jal_target pc_plus4 imm_ext; wire [31:0] jalr_target (read_data1 imm_ext) ~32h1; ~32h1的作用是强制将地址最低位清零保证地址对齐这也是RISC-V规范的要求。另外JAL和JALR在执行时都还需要把返回地址pc 4写入rd所以顶层要处理MemtoReg信号中额外增加一种选择返回地址。我选择的方案是把MemtoReg扩宽为2位00选ALU结果、01选存储器数据、10选pc_plus4。数据存储器模块和指令存储器结构相似但有读写两个端口module data_mem( input clk, input we, input [31:0] addr, input [31:0] wdata, output [31:0] rdata ); reg [31:0] mem [0:4095]; assign rdata mem[addr[31:2]]; always (posedge clk) begin if (we) mem[addr[31:2]] wdata; end endmodule这份代码里面有一个隐含问题需要注意LW和SW要求地址必须是字对齐的如果你的程序访问了非4的整数倍地址addr[31:2]会截断低位导致访问错误到相邻字。我在调试时就吃过一次亏一个数组地址写了0x10000001结果读出来的数据和预期完全不对查了半天才发现是地址对齐问题。4. 环境搭建与仿真验证让CPU真正跑起来4.1 测试程序与机器码生成方法写完CPU第一件事就是写个测试程序。我选了一个简单的累加程序计算1到10的和把结果写入地址0x100。汇编逻辑如下addi x1, x0, 1 ; x1 1 addi x2, x0, 10 ; x2 10循环上限 addi x3, x0, 0 ; x3 0累加器 loop: add x3, x3, x1 addi x1, x1, 1 bne x1, x2, loop ; 如果x1 ! x2 则跳回loop sw x3, 0(x0) ; 结果写入地址0这段程序可以验证加法、立即数、分支、存储这几大类指令。把汇编转成机器码不需要用编译器直接查编码表手写即可。比如addi x1, x0, 1的编码是opcode0010011rd00001funct3000rs100000立即数000000000001组合起来的十六进制是0x00100093。这个过程虽然繁琐但做两三条就熟练了。如果你实在不想手写也可以用riscv64-unknown-elf-gcc编译后用objcopy抽取二进制再转成hex但那就多了一层工具链依赖我还挺建议第一次做的人手写几行机器码对编码格式的理解有奇效。4.2 使用Icarus Verilog和GTKWave跑仿真我的开发环境是Linux命令行加Vim仿真工具用的是Icarus Verilog和GTKWave这两个工具都是免费的Windows上也能安装。首先把所有.v文件列出来编译iverilog -o cpu_tb cpu_tb.v pc.v regfile.v alu.v imm_ext.v control.v alu_control.v data_mem.v instr_mem.v top.v编译通过后生成波形文件vvp cpu_tbtestbench里最核心的三件事时钟翻转、复位时序、任务结束。我习惯在测试程序运行足够长周期后用$finish结束仿真同时在关键节点用$display打印寄存器值。一个简单的testbench框架module cpu_tb; reg clk 0; reg rst 1; always #5 clk ~clk; cpu_top u_cpu_top( .clk(clk), .rst(rst) ); initial begin #15 rst 0; // 跑60个周期后结束 #600 $finish; end // 可选在每个时钟上升沿打印x3的值 always (posedge clk) begin $display(pc%0d x3%0d x1%0d, u_cpu_top.pc, u_cpu_top.regs[3], u_cpu_top.regs[1]); end endmodule打印语句放在testbench里直接引用了内部连线这算是一种简单粗暴的调试方式。更专业的做法是写好波形文件后用GTKWave看波形initial begin $dumpfile(cpu_tb.vcd); $dumpvars(0, cpu_tb); end跑完仿真后执行gtkwave cpu_tb.vcd打开波形界面添加内部信号即可观察每个时钟周期PC的变化、ALU结果、寄存器堆写数据等信号。波形观察可以让你直观看到指令的执行节拍这点比打印日志高效得多。4.3 用波形验证数据的正确性仿真结束后如果累加程序运行正确你应该能看到一个逐渐增加再清零不因为是累加1到10x3最终应该是55。用$display打印的x3会依次显示0、1、3、6、10、15……最后停在55。如果你看到的x3不是这个序列那就要回头查控制信号了。我还习惯在测试程序末尾加一个“死循环”来让程序运行结束后不再跳转比如sw x3, 0(x0) ; 保存结果 jal x0, 0 ; 跳到当前指令空转这样即使仿真继续运行也不会再执行其他指令方便观察最终结果。JAL指令的立即数是0跳转目标是pc40跳回当前指令的下一条不对这里需要小心JAL的目标是pc 4 0所以会跳到下一条指令并不是死循环。如果要死循环应该用jal x0, -4即立即数是4位的负4这需要计算立即数编码比较麻烦。所以我更常用的做法是把最后一条指令写成beq x0, x0, 0这样每次比较都相等跳转目标为pc40同样会跑到下一条。所以还是不行。最简单的办法是在testbench中直接控制仿真周期数跑完固定周期就结束然后检查结果寄存器。或者在测试指令里用一个自跳转指令真正的死循环需要让跳转偏移为-4也就是0xFF5FF06F我手头不保证这个编码正确所以我在实际测试中往往是靠$finish控制仿真时长。这是一个只用于验证的小技巧大家理解就行。5. 调试技巧与常见问题排查我踩过的坑都在这5.1 症状与根因速查表在实现单周期CPU的过程中我整理了一张调试速查表。每当波形异常时先对照这张表定位问题会比漫无目的地瞎看波形高效得多。症状可能原因排查方法PC一直是0复位信号拉高时间不够或者pc_next计算错误检查testbench复位时序查看pc_next信号波形寄存器数据永远不更新RegWrite控制信号没拉高或rd写地址不对用波形观察RegWrite对照指令opcode验证译码逻辑ALU结果莫名其妙是负数立即数符号扩展写错低12位之外的部分没填符号位检查立即数扩展模块重点看负数立即数BEQ分支跳错位置分支偏移计算少加了pc4或立即数位序拼接错检查branch_target计算逻辑手动计算一个BEQ测试用例LW读出的数据是高阻态x数据存储器复位未初始化或地址位宽截断错误检查data_mem模块的初始化和地址对齐JAL返回地址写成目标地址MemtoReg没有扩展返回地址选项检查顶层多路选择器返回地址必须是pc45.2 排查技巧一用“单步观察每个周期”的方式定位我调试最快的一次是给每个信号都加上$display然后一行一行看每个时钟周期的PC、ALU输入输出、RegWrite、分支标志。虽然笨但对单周期CPU这种组合逻辑占比高的设计效率反而很高。每一个周期内所有信号都应该是稳定的比如执行ADD指令的那个周期RegWrite必须在1ALUOp必须是10ALU的输入必须是寄存器堆的两个输出。任何一个信号不对都能直接锁定到对应的控制分支或数据通路。单周期CPU没有流水线冒险所以每个周期都是独立的。这就意味着断点调试基本不需要你只要盯着一个周期内的信号组合是否和指令语义一致即可。这个特性让单周期比流水线好调太多新手入门时真的是福音。5.3 排查技巧二给控制单元写一个独立testbench控制单元的正确性直接决定CPU能不能跑起来但它又是一个纯组合逻辑模块独立的testbench非常好写。我通常是写一个遍历所有opcode的testbench打印每条指令对应的控制信号再手动检查真值表。这一步做完就能把“译码错误”这类问题从CPU顶层调试中彻底隔离出去。我常用的方式是把控制信号打包成一个位宽较大的数组统一打印reg [8:0] ctrl_vector; assign ctrl_vector {reg_write, alu_src, mem_write, mem_read, mem_to_reg[1:0], branch, jump, alu_op[1:0]};这样在testbench里只需一行$display(%h, ctrl_vector)就能看到整组控制信号。做真值表对照时特别方便。5.4 排查技巧三先跑一条最简单指令写完整块CPU第一次仿真不要急着跑累加程序。先只跑一条ADDI x1, x0, 1。这样待观察的信号面非常小PC从0到4、RegWrite拉高、rd1、wdata1。这条跑通了再逐渐加入更多指令。我见过很多同学上来就加载大程序结果满屏波形根本不知道从哪看起。小步快跑永远是最稳的调试节奏。跑通ADDI后我建议按顺序加入ADD验证R型、SW/LW验证访存、BEQ验证分支、JAL验证跳转和写回地址。每一步都在一个汇编程序里只做增量式修改这样一旦出问题只可能是新加的那条指令有问题。5.5 一个容易忽略的细节RISC-V的x0寄存器RISC-V的x0是硬件写死的0任何写入它的操作都不生效。我在实现寄存器堆时特意加了一个rd ! 5h0的判断。但如果你没有加这个判断某些指令比如addi x0, x0, 0会把0写进x0姑且还能工作但万一写入一个非零值整个程序数据就全乱了。某些指令集教程里不会强调这个点但在RISC-V中这是一个必须遵守的规范。5.6 关于工具链的一个建议从命令行仿真开始很多初学者一上来就打开Vivado或Quartus的GUI点半天按钮生成工程结果连仿真波形长什么样都没见过几次。我强烈建议先用命令行工具把仿真跑通。我实测下来Icarus Verilog加GTKWave的组合足够流畅完成单周期CPU的全部验证工作而且跨平台、免费、脚本友好。你的精力应该放在理解CPU结构上而不是浪费在等待IDE加载上。等你在命令行环境跑通了再迁移到FPGA工程的流程会非常顺。当然如果你手上有开发板最后把CPU下载到板子上、接一个串口打印结果那是很有成就感的一件事。但这属于另一半话题——时钟约束、IO规划、上板调试每一样都能再写一篇长文。先把仿真玩明白打好基础再说。6. 从单周期到下一步这个设计还能继续扩展什么我个人体会是单周期CPU的代码写完只是第一步真正的收获是你开始从“信号”的角度理解计算机执行程序的本质。在这个基础上很多方向都可以继续往下走。比如给CPU加中断控制器处理外设中断实现乘法除法指令扩充指令集或者走流水线路线加上五级流水线以及对应的冒险处理单元。如果你还想继续深入RISC-V我建议下一步试试实现一个完整的RV32IM或者给CPU加上简单的SoC总线、接上一块RAM和UART外设。这些扩展在架构上都依赖你这一步写出来的数据通路和控制逻辑底子打得好后面全是水到渠成的事。我自己写完单周期CPU之后再去看流水线CPU理解速度明显比之前快了一大截。这颗CPU虽然简单但它是无数计算机工作者认知的起点。写完之后你再看任何CPU设计相关的书再也不用对着结构图发呆因为每一根线、每一个信号都是你自己亲手连过的。