ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从逻辑门到CPU:手搓一台可执行程序的计算机完整路径

从逻辑门到CPU:手搓一台可执行程序的计算机完整路径 真正让“手搓CPU”显得高不可攀的不是逻辑门的数量而是缺少一种分段抽象的能力先用几百个门搭出一个加法器再用加法器搭出ALU再用ALU、寄存器和控制逻辑搭出一个能执行指令的处理器最后把处理器接到存储器和输入输出上让它变成一台会跑程序的计算机。每一层都只是上一层的“封装箱”而最简单的那颗种子就是“与、或、非”这些基础门。这一篇是《从0手搓CPU》系列的第11集重点不是复刻某颗现代芯片而是回答一个底层问题如果你手里只有基础逻辑门要怎么把它们组织成一台完整计算机。读完之后你会得到一张搭建路径图先做什么模块、再做什么模块每个模块解决什么问题以及为什么“先定义指令集”比“先画电路”更容易做对。如果你正在学计算机组成原理、做CPU小项目或者只是好奇“CPU到底是怎么从晶体管长出来的”这篇文章应该能帮你在心里建起一条完整的因果链。1. 这篇文章真正要解决的问题每次讨论“用逻辑门搭计算机”讨论到最后都会变成两个极端。一边认为这是纯数字电路的知识和软件开发没关系另一边认为这是不可能完成的任务需要画几万根线、焊几百块芯片。两个判断都不对。用基础逻辑门搭出一台计算机真正考验的不是手速而是理解“组合逻辑做运算、时序逻辑做记忆、控制逻辑做调度”这三件事如何协同。你不需要在一张电路图上画出所有门但你需要知道全加器怎么生成寄存器怎么会记住上次状态指令编码为什么能驱动ALU操作以及PC在什么时刻步进到下一条指令。如果你常看技术社区会发现很多提问都和这条链路相关有人问“学软件的要学计算机组成原理吗”有人在查“cpu温度在哪看”还有人在折腾“yolo cpu 多进程慢1.4秒”这类性能问题。表面上是工具和优化问题往里挖一层全落在“CPU怎么执行指令、怎么调度资源、怎么管理状态”这套底层机制上。所以这篇文章要解决的是把“逻辑门”和“计算机”之间那层薄纱揭开。读完你不仅知道模块叫什么还能按顺序把它们复现出来。真正重要的是建立一条抽象链门 → 运算单元 → 存储单元 → 控制单元 → 数据通路 → 可执行程序的机器。2. 基础概念逻辑门只能做两件事很多人刚接触数字电路时觉得逻辑门很容易理解。与门是两个输入都为1输出才为1或门是有一个为1输出就是1非门让1变0、0变1。这确实不难。难的是想明白“这些规则凑在一起怎么变成计算机”。我的判断是逻辑门从功能上只做两件事一是“根据当前输入算出一个结果”二是“把结果保存下来”。前者叫组合逻辑后者叫时序逻辑。计算机的一切都是这两种逻辑的组合。2.1 组合逻辑输出只取决于当前输入全加器是最典型的组合逻辑。给它两个加数和一个进位输入它立刻算出一位和与进位输出。你输入什么它下一秒就输出什么不需要记住任何历史信息。组合逻辑用真值表就能描述。两个输入时最多4行三个输入时最多8行。ALU的很多运算比如按位与、按位或、按位异或都是纯粹的组合逻辑给定操作数立刻得到结果。从这个意义上说组合逻辑是“电路里的纯函数”。2.2 时序逻辑把“上一次”记住计算机不能只算不算存。任何程序都需要状态循环计数器的值、当前执行到哪条指令、寄存器里保存的中间变量。这些状态不是当前瞬间输入能决定的它依赖过去的历史。时序逻辑的出现就是为了解决“记住”这件事。最简单的记忆单元是锁存器更强健的设计是触发器。触发器在时钟上升沿到来时把输入采样后锁存到输出时钟没有跳变时输出保持不变。寄存器、程序计数器、内存单元本质上都是触发器阵列。2.3 继电器、晶体管和逻辑门的关系很多初学者想做“用继电器组成逻辑门”的实验这是很直观的入门方式继电器通过电磁铁控制开关把电流通断变成逻辑0和逻辑1。但真实芯片里用的是晶体管CMOS电路里一对互补的MOS管就能组成与非门或反相器。从物理角度看继电器、晶体管、逻辑门是不同层的实现。真正贯穿始终的是布尔代数用两个电压状态表示0和1用基本运算规则表示与、或、非。你不需要真的用继电器搭完整CPU但理解这层递进会对“逻辑门到底从哪里来”有更踏实的认识。2.4 小结论逻辑门本身并不神秘神秘的是“把几千个逻辑门组成一个系统”。但如果你先把系统拆成“组合逻辑计算、时序逻辑保存、控制逻辑指挥”三块再逐个击破就会发现它并不比写一个复杂软件更难。3. 一台完整计算机至少要有什么“只用基础逻辑门搭出一台计算机”里的计算机应该满足什么标准最简单也最严格的标准是它能存储程序、能执行指令、能对输入做出反应并且输出结果。只要满足这一点你搭出来的就是一台真正意义上的计算机即使它只能运行十几条指令。一台可运行的计算机至少要包含三大部分。3.1 运算单元ALUALU负责算术运算和逻辑运算比如加法、减法、与、或、非、比较。它的输入是两个操作数和一个操作选择信号输出是运算结果有时还附带零标志、进位标志等状态位。在基础逻辑门层面ALU是组合逻辑。给定操作数和操作码结果在若干门延迟后稳定输出。它本身不需要时钟是纯计算部件。3.2 存储单元寄存器、寄存器堆和内存寄存器保存CPU当前正在使用的数据寄存器堆是多个寄存器的集合通过地址端口选择要读写的寄存器。内存则保存指令和数据CPU通过地址线访问指定位置。存储单元是时序逻辑的典型应用。D触发器是基本细胞多个D触发器并列组成寄存器寄存器阵列加上地址译码器就变成一个容量更大的存储器。3.3 控制单元让每一步该干什么控制单元负责“翻译”当前指令并向各部件发出控制信号。比如一条加法指令被取进来后控制单元要告诉ALU执行加法、告诉寄存器堆打开写使能、告诉PC下一步加一。控制单元可以用组合逻辑实现也可以做成微程序控制器。在简单CPU里它通常是把指令操作码映射成一组控制信号的译码器。3.4 时钟让所有部件按节奏协同时钟信号是CPU的节拍器。状态变化发生在时钟上升沿组合逻辑在前一个状态和新状态之间完成计算。没有时钟整个电路会陷入“数据追数据”的混乱状态。这三个部分再加上程序计数器PC就构成最简CPU的核心框架。PC存放下一条指令地址指令存储器按地址给出指令控制单元解析指令ALU计算寄存器保存结果时钟推动下一个周期。4. 第一块积木先做一个全加器搭计算机的第一步不是直接画CPU而是先做一个1位全加器。加法是几乎所有算数运算的基础减法可以通过补码变成加法乘法又可以分解为多次加法。4.1 为什么从加法开始如果你只有逻辑门能执行的最有价值算术运算就是加法。两个二进制位相加存在四种情况000、011、101、110并向高位进位。这第二个结果就是进位但单独的异或门能得到“和”却不知道“要不要进位”所以需要半加器。半加器只能处理两个输入但多位加法里还要考虑来自低位的进位。于是全加器出现它接收加数a、加数b、低位进位cin输出本位和sum与向高位进位cout。4.2 全加器的门级逻辑全加器的输出逻辑非常简单sum a ⊕ b ⊕ cincout (a b) | (a cin) | (b cin)第一行是三个输入的异或第二行是“任意两个输入为1就产生进位”。这可以完全用与门、或门、异或门表达是典型的组合逻辑。4.3 用Verilog描述全加器在进入纯逻辑门的连线图之前用硬件描述语言描述设计效率更高。同样的设计综合工具会帮你映射成逻辑门。// 文件名full_adder.v module full_adder ( input a, input b, input cin, output sum, output cout ); assign sum a ^ b ^ cin; assign cout (a b) | (a cin) | (b cin); endmodule这段代码没有任何时钟所以它是纯组合逻辑。只要输入变化输出会经过门延迟后更新。综合成电路后三四个逻辑门就能实现。4.4 如何验证全加器编写一个简单的testbench遍历8种输入组合检查输出是否符合真值表// 文件名full_adder_tb.v timescale 1ns/1ps module full_adder_tb; reg a, b, cin; wire sum, cout; full_adder uut ( .a(a), .b(b), .cin(cin), .sum(sum), .cout(cout) ); integer i; initial begin for (i 0; i 8; i i 1) begin {a, b, cin} i; #10; $display(a%b b%b cin%b - sum%b cout%b, a, b, cin, sum, cout); end $finish; end endmodule在Icarus Verilog或Verilator等常见仿真工具上运行输出会覆盖全部输入组合。这个验证思路会贯穿整个手搓CPU过程每个模块先单独验证再拼接成子系统。5. 第二块积木触发器和寄存器全加器再厉害也没有记忆。CPU要做循环、要保存中间结果必须有存储部件。这张“记忆”的底牌就是触发器。5.1 为什么需要触发器组合逻辑的输出是“当前输入的函数”但它不保存任何过去的信息。可计算机的指令是一条接一条执行的上一条指令的结果往往要作为下一条指令的输入。如果无法保存结果程序就串不起来。触发器解决的是“在某个时间点把数据锁住”。最常见的D触发器在时钟上升沿把D端口的值传递到Q端其他时间Q端保持不变。5.2 D触发器的Verilog描述数字电路课程里D触发器通常用门级电路实现比如两个锁存器串联形成主从结构。但在项目开发中直接描述功能更高效// 文件名d_flip_flop.v module d_flip_flop ( input clk, input rst_n, input d, output reg q ); always (posedge clk or negedge rst_n) begin if (!rst_n) q 1b0; else q d; end endmodule这里使用非阻塞赋值因为它模拟的是寄存器行为所有赋值都在时钟沿之后统一生效不会产生组合逻辑中的竞争。5.3 从D触发器到寄存器堆多个D触发器并列就组成寄存器。如果要保存一个8位数需要8个D触发器。再给每个寄存器一个编号用地址译码器选择读写目标就构成了寄存器堆。以下是4位寄存器的简单示例// 文件名register4.v module register4 ( input clk, input rst_n, input [3:0] d, output reg [3:0] q ); always (posedge clk or negedge rst_n) begin if (!rst_n) q 4b0; else q d; end endmodule寄存器堆在CPU里的角色是存放操作数和指令结果。它的读端口通常不需要时钟属于组合逻辑路径写端口则必须在时钟沿锁存数据。5.4 这部分的常见问题新手常犯的错误是把always块里的和混用。非阻塞赋值用于时序逻辑阻塞赋值用于组合逻辑。一旦把寄存器写成了阻塞赋值综合时会生成意想不到的锁存器或者出现仿真和硬件行为不一致的现象。另一个容易踩的坑是忘记复位信号。如果寄存器上电后是随机值PC可能会从乱七八糟的地址开始取指程序根本跑不起来。因此每个时序模块都应该有明确的复位逻辑。6. 第三块积木ALU有了计算能力和存储能力下一步是把它们组合成CPU的核心运算部件ALU。ALU既要做加法之类的算术运算也要做与、或、异或等位运算有时还要能比较大小。6.1 ALU需要哪些操作最简单的ALU至少应当支持加法ADD按位与AND按位或OR按位异或XOR如果后续想运行更丰富的程序再加入减法、左移、右移、比较等操作。操作码位数决定支持多少个操作3位操作码最多支持8种。6.2 ALU的Verilog实现下面是一个8位ALU支持4种操作。op[2:0]选择具体运算result输出计算结果。这是一个纯组合逻辑模块// 文件名alu.v module alu #(parameter WIDTH 8) ( input [WIDTH-1:0] a, input [WIDTH-1:0] b, input [2:0] op, output reg [WIDTH-1:0] result ); localparam OP_ADD 3b000; localparam OP_AND 3b001; localparam OP_OR 3b010; localparam OP_XOR 3b011; always (*) begin case (op) OP_ADD: result a b; OP_AND: result a b; OP_OR: result a | b; OP_XOR: result a ^ b; default: result {WIDTH{1b0}}; endcase end endmodule很多初学者会问a b不是直接用加法器吗是不是没用逻辑门其实这里只是把“加法器”封装成了一个可被综合的模块。综合工具会把它展开成门级电路。关键是理解它的接口和行为而不是纠结于手写了一行加法符号就算完成任务。6.3 ALU如何被CPU调用一条加法指令在执行阶段CPU会把两个操作数送到ALU的a和b端口把控制单元给出的操作码送到op端口然后在同一次时钟周期结束前采集result输出。ALU没有时钟它是“异步算完”的。它不像寄存器需要等时钟沿而是输入稳定后经过若干个门延迟输出就稳定了。时钟的边沿作用发生在输出的下游是用来“把ALU算好的结果锁存到寄存器”的。7. 把模块拼成CPU最小数据通路有了寄存器、ALU、存储器还差把它们串起来的指挥系统。这一步就是CPU的诞生。7.1 最小CPU需要哪些部件一颗能执行指令的最小CPU通常包含程序计数器PC指令存储器IMEM按地址取指令寄存器堆保存操作数和中间结果ALU执行运算控制单元生成各部件控制信号PC不是普通寄存器它还需要“加一”能力让CPU能够顺序执行下一条指令。如果想支持跳转还要在跳转指令发生时把跳转目标地址写入PC。7.2 取指阶段CPU在时钟上升沿把PC送到指令存储器地址端指令存储器返回一条指令比如16位。PC在下一次时钟沿到来后加一指向下一条指令。如果指令存储器是异步读那么取指几乎不需要额外等待如果指令存储器是同步读就需要在下一个周期才能得到指令数据通路设计会随存储器的时序模型而改变。7.3 译码阶段控制单元解析指令的操作码生成控制信号。比如一条指令是ADD R0, R1控制单元需要确认ALU执行加法操作寄存器堆读端口选中R0和R1寄存器堆写使能打开把ALU结果写回目标寄存器这些控制信号本质上是“操作码 → 各部件使能位”的映射表在硬件上可以是一张真值表加一个译码器。7.4 执行阶段ALU根据操作码计算结果结果可能直接写回寄存器也可能作为存储器地址去访问内存或者被用来计算跳转目标。一个“单周期CPU”会把取指、译码、执行都在一个时钟周期内完成所以时钟不能太快必须保证最长的组合逻辑路径能在周期内稳定输出。这也就是为什么提高主频会受到关键路径限制。7.5 一个极简CPU的Verilog框架下面的示例不是一个完整商用的CPU而是展示如何用模块化方式定义一条最小的指令执行链。它把指令存储、寄存器、ALU、控制逻辑都封装在同一个模块内部便于初学者观察每个时钟沿发生了什么。// 文件名tiny_cpu.v —— 教学用极简CPU示例 // 指令编码instr[7:6]操作码instr[5:0]立即数 // 00: LOADI R0, imm - R0 imm // 01: ADD R0, imm - R0 R0 imm // 10: HALT - 停机 module tiny_cpu ( input clk, input rst_n, output [7:0] r0_out, output [7:0] pc_out ); reg [7:0] r0; reg [7:0] pc; reg halt_flag; reg [7:0] imem [0:15]; assign r0_out r0; assign pc_out pc; initial begin pc 0; r0 0; halt_flag 0; // 预置小程序R0 10; R0 R0 5; 停机 imem[0] 8b00_001010; imem[1] 8b01_000101; imem[2] 8b10_000000; end always (posedge clk or negedge rst_n) begin if (!rst_n) begin pc 0; r0 0; halt_flag 0; end else if (!halt_flag) begin case (imem[pc][7:6]) 2b00: r0 imem[pc][5:0]; 2b01: r0 r0 imem[pc][5:0]; 2b10: halt_flag 1; default: pc pc; endcase pc pc 1; end end endmodule这个模块看起来不像门级电路但综合成硬件时r0会变成寄存器集合r0 imem[pc][5:0]会变成ALU中的加法器case语句会变成控制译码逻辑。这就是“从逻辑门到CPU”中间的那层抽象。7.6 最小CPU能运行什么上面这款极简CPU只能处理立即数加载、加法和停机严格来说已经是一台“存储程序计算机”了。扩展方向可以是增加内存访问指令让CPU能读写数据存储器增加跳转指令让CPU支持循环和分支增加更多通用寄存器让指令能表达更复杂的操作增加减法、移位等ALU操作每加一条指令控制译码逻辑、ALU选择信号、寄存器写使能都要同步更新。因此手搓CPU的正确路线不是一上来就画所有电路而是先定义好指令集再画数据通路最后写控制逻辑。8. 从CPU到完整计算机总线、存储器和输入输出一颗CPU能执行指令还不能直接当计算机用。它必须和存储、输入输出设备连接起来才能完成“接收输入、处理数据、输出结果”的完整工作。8.1 内存怎么接进来内存可以简单理解成一个巨大的寄存器文件外部给地址给读写信号给数据总线它就能返回或写入数据。CPU要操作内存需要地址总线CPU输出要访问的内存地址数据总线写入内存或从内存读出的数据控制总线读写使能、时钟等控制信号如果CPU和内存共用一套总线就会出现“同一时刻谁占用总线”的问题。最简单的做法是分时复用取指时CPU访问指令存储器执行访存指令时CPU访问数据存储器更高级的做法是使用多路选择器切换地址来源。8.2 三条总线的分工地址总线决定寻址范围。8位地址总线最多访问256个存储单元16位地址总线最多访问65536个。数据总线宽度决定一次传输多少数据常见的有8位、16位、32位、64位。控制总线是总线的“交通规则”。它传递读写使能、复位、时钟等信号让不同设备知道何时可以占用总线、何时必须释放。如果你在做裸机硬件总线仲裁的优先级设计很容易出错。8.3 输入输出设备的简单处理最简单的方式是“内存映射I/O”把输入设备的状态寄存器、输出设备的显示寄存器映射到特定内存地址范围。CPU发出一条普通访存指令如果地址落在I/O区间总线就把访问定向到设备寄存器而不是内存。另一个方式是端口I/O用独立指令访问端口。x86里就有专门的in/out指令。对从逻辑门起步的CPU来说内存映射更直观因为它复用已有的访存指令不需要新增指令集。8.4 运行一个小程序的完整流程假设要在极简CPU上运行“计算105并输出结果”的程序流程是PC指向第一条指令地址从指令存储器取回LOADI R0, 10控制单元让R0写入立即数10PC加一取回ADD R0, 5控制单元让ALU计算R05再把结果写回R0PC加一取回输出指令把R0写入输出设备寄存器用户在LED或串口上看到结果15每一步都对应一个时钟周期或几个周期。逻辑门在这一过程中没有魔法只是按写好的“规则”不断传递电信号。9. 手搓CPU最容易踩的坑做CPU这类硬件项目很多错误的发现方式不是“看报错”而是“看波形”。下面这张表是新手在手动搭CPU时最常遇到的问题按排查顺序整理。问题现象可能原因排查方式解决方案时钟上升沿没有捕捉到数据寄存器端口接错或复位信号一直生效查看复位信号时序确认rst_n是否被拉高检查复位逻辑让复位在时钟有效前释放ALU结果一直不变操作码端口没有被控制单元驱动在波形里观察op信号是否随指令变化检查控制单元译码逻辑和连接PC不递增PC的使能信号或自身加一逻辑没有工作看PC和时钟波形确认时钟是否连续独立测试PC模块排除时钟和复位问题同一条指令反复执行跳转目标计算错误检查指令存储器地址和PC更新逻辑在testbench中单步执行逐条比对数据总线同时被多个模块驱动没有做三态门或多路选择查看波形确认总线出现多驱动冲突增加总线选择逻辑确保同一时刻只有一个发送者仿真结果和预期不一致组合逻辑和时序逻辑临界出现了竞争风险用非阻塞赋值重写时序逻辑严格区分always (*)和always (posedge clk)指令存储器读时序不匹配把同步RAM当成异步RAM使用查阅存储器模型说明比较读写时序增加一拍等待或改用异步读存储器这些坑的背后本质上都是同一个问题没有先画清楚数据通路就急着写代码或连线。实际上模块之间的信号边界越清晰硬件设计就越容易调试。10. 工程建议与学习路线手搓CPU的过程不只是“用逻辑门拼机器”的复古活动。它是学习计算机组成原理、数字电路、体系结构最直接也最扎实的路径。10.1 先定义指令集再画数据通路很多新手一上来就画寄存器、ALU结果越画越乱。正确的第一步是定义指令集每条指令的编码格式、操作数来源、执行语义是什么。指令集一旦确定数据通路就是“为执行每条指令所需的数据流动”画一张图。先有指令再有通路先有通路再有控制信号。如果你先画电路再定指令最后一定会推翻重来。10.2 仿真先行硬件后置即使你最终的目标是在FPGA板卡上跑程序也建议先在仿真工具里验证CPU功能。用testbench逐条指令执行观察寄存器、PC、内存、总线的波形比直接在板子上点灯要快得多。仿真可以通过$display打印关键信号但更推荐看波形文件。波形能直观告诉你某个信号在哪个时钟沿变化、哪个信号晚了几个门延迟。10.3 模块级验证不能省每个子模块都要有独立testbench。全加器测试8种输入组合寄存器测试复位和写入ALU测试所有操作码CPU测试典型指令序列。模块验证通过后再组装组装后从最短指令开始跑一点点增加指令。最好的测试用例是一个能计算阶乘或求最大公约数的小程序它能把分支、循环、寄存器读取、写回全部覆盖到。10.4 从手搓CPU到现代体系结构当你完成一颗能跑汇编的CPU后就可以研究更复杂的主题流水线怎么切级、数据冒险怎么解决、分支预测怎么做、Cache怎么组织、中断怎么响应。这些内容在《计算机组成原理》和《计算机体系结构》课程里都有但自己写一遍CPU后再看它们会轻松得多。如果你更偏软件方向手搓CPU也能帮你理解编译器后端为什么要把变量分配到寄存器、为什么缓存命中率对性能影响巨大、为什么多线程程序会在共享变量上出现可见性问题。CPU不是和程序员无关的黑盒它是所有程序最终解释者。10.5 坚持“最小系统”原则不要一上来就想做一个支持几十条指令、带流水线、带Cache的CPU。先用最小系统跑通闭环指令只三条、寄存器就一两个、内存几个字节、输出就用LED。每加一条指令都是一次完整的需求变更保持每步都可验证才能学到最多的工程细节。11. 总结与后续方向这一篇从逻辑门讲到触发器、再从触发器讲到ALU和CPU最终形成一台能执行指令的简单计算机。核心思路只有一条计算机不是凭空出现的黑盒而是把“运算、存储、控制、时序”按抽象层逐级拼装出来的系统。如果你正打算从零手搓CPU建议下一个动作不是马上画电路而是用Verilog写一个极简CPU或者用Logisim图形化搭一个数据通路。把自己写的程序变成一条条指令看它们在波形图上动起来比只看书要有用得多。后续可以继续深入研究的方向包括单周期CPU改成多周期CPU、加中断和异常处理、接入串口通信、运行一段C语言交叉编译后的程序。真正的成长发生在你写完第一颗“虽然简单但能跑”的CPU之后。抓住那个时刻后面的体系结构学习会顺畅很多。
返回列表