ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从Nand到CPU:详解Xor、Mux与DMux的真值表及HDL实现

从Nand到CPU:详解Xor、Mux与DMux的真值表及HDL实现

第一次翻开这套“从零开始构建现代计算机”的课程时,大多数人前半段都挺舒服:用Nand搭出Not、And、Or,逻辑清楚,验证简单,甚至会产生一种“原来底层也不过如此”的错觉。然后翻到下一节,看到“实现异或门、Mux、DMux”这个任务,人就愣住了——门还是那个门,但要求突然抽象起来:什么叫多路选择器?什么叫多路分配器?那个多出来的sel端到底是干嘛的?我还没搞懂怎么画线,就要开始设计带选择信号的芯片了?

这篇文章就是写给卡在这个位置的你。今天我们把第一章里最容易被忽略、又最关键的三个组合逻辑门——异或门(Xor)、多路选择器(Mux)、多路分配器(DMux)——从真值表到HDL代码完整走一遍。我会把每个门的行为逻辑、构造思路、仿真验证方法都摊开讲,还会贴上我自己在编译和测试时踩过的坑。这套课程后面要建ALU、寄存器、内存和CPU,而这三个门就是路上最早的三块铺路石。

1. 先搞清楚这一章到底在忙什么:从“基本门”到“能用的小工具”

1.1 上一节我们完成了什么:Nand是起点,但不是终点

这套课程的底层逻辑从一开始就很极端:整个计算机只用一种最原始的Nand门,然后一层一层往上堆。上一节我们用手写HDL的方式验证了Not、And、Or三个门,思路统一为“先把Nand组合出我们熟悉的基本逻辑,再把它们当作黑盒使用”。

关键点在于:到了这一节,我们手里已经不只有Nand,还有And、Or、Not这些已经完成并且验证过的模块。这意味着写Xor、Mux、DMux的时候,你可以直接调用And(a=..., b=..., out=...)这种接口,不用再回到Nand那一层去痛苦的化简。

很多初学者在这里会有一个误区,总觉得“既然底层是Nand,那我就该一直用Nand实现一切”。这种想法很可爱,但完全跑偏了。真实芯片设计里,抽象层级一旦建立,你就要快速信任底层模块,把注意力放到当前这一层怎么组合上。不然你搭CPU的时候还得纠结每个与非门怎么排布,那谁也做不下去。

1.2 这一节的三个任务,难点到底在哪

从功能上看,Xor、Mux、DMux都不是“对输入做固定运算”这么简单了:

  • Xor虽然只增加了一个异或逻辑,但真值表的形状和And/Or完全不同,不是“有几个1决定输出”的直觉能推出来的。
  • Mux第一次引入了选择端sel。输出不再是输入的简单函数,而是要看sel的脸色。这是一个抽象跳跃:输入信号之外,还有控制信号。
  • DMux更反向,它只有一个数据输入in,却要把这个信号发给多个出口中的一个,本质上是一个“分配器”。

你可能会发现,这三个门里有两个(Mux和DMux)都和“选择”有关。这正是本章要训练的核心能力:控制信号和数据信号分开思考。以后你写内存、写寄存器、写ALU,到处都是这种“用控制信号决定数据往哪儿走”的套路,今天这两个门就是最基础的思想体操。

2. 三个门的行为逻辑:真值表、直觉与用途

2.1 XOR:两个输入不一样,输出才为1

异或的规则用一句话概括:两个输入相异则输出1,相同则输出0。真值表长这样:

about
000
011
101
110

你可以把它类比成“两个人必须给出相反的选择,灯才会亮”。如果两个人同时举手或者同时不举手,灯泡就不亮。

实际用途上,Xor最常见的身份是“比较两个比特是否不同”,以及作为半加器、加法器的核心部件——二进制加法里1+0和0+1的结果为1,而1+1的结果为0且要进位,这个“本位结果”正好就是Xor。换句话说,加法器里的SUM本质就是一个Xor链。后面做ALU的时候,你会发现到处都是它。

2.2 Mux:一个带开关的二选一通道

Mux全称Multiplexer,中文常叫多路选择器,名字听着高级,实际就是一个可编程的开关,从两个输入中挑一个送到输出。sel = 0时输出a,sel = 1时输出b。

selout
0a
1b

类比成铁路道岔最形象:一条主轨过来,道岔决定火车走左边的a路还是右边的b路。sel就是那个搬道岔的人。注意这里a和b是数据信号,sel是控制信号,两种信号在电路中的职责完全不同。

Mux在整机里到处都是:CPU里选哪个寄存器作为ALU输入,选哪条指令的立即数,数据总线上选哪个外设的数据……本质上都是“把这组数据或那组数据送到同一个目的地”。你现在觉得它简单,但到了寄存器堆那一章,你会发现自己根本离不开它。

2.3 DMux:把一条数据按选择分发出去

DMux全称Demultiplexer,多路分配器,和Mux正好反过来。它只有一个数据输入in和两个输出a、b,sel决定in的信号送去哪个出口:sel = 0时,in送到a,b保持0;sel = 1时,in送到b,a保持0。

selab
0in0
10in

生活化理解就是分诊台:来了一位病人,护士根据病情把他分到内科(a)或外科(b),另一边的床位就空着。再想想快递分拣机,一个包裹进传送带,分拣口决定它落到哪个装货框里,其他框是空的。这就是“分配”二字的含义。

DMux最典型的应用场景是寄存器选择:CPU执行store操作时,总线上一份数据需要写入N个寄存器里的某一个,DMux就是那个按地址把数据送进对应寄存器的传送员。没有它,寄存器堆根本没法知道这次该谁收数据。

3. HDL实现:从Nand到Xor的代数化简与代码落地

3.1 从布尔代数看Xor的结构:为什么是“两个与门加一个或门”

写Xor之前,先别急着抄代码。我们把Xor的布尔表达式写出来:

out = a·(~b) + (~a)·b

中文念一遍:a为1且b为0,或者a为0且b为1时,输出为1。这正好对应真值表里两行输出为1的情况。

这种“把真值表中每个输出为1的行,用与门表示条件,再用或门把所有条件并起来”的方法,叫“积之和”形式。它是组合逻辑设计最基础的工具之一。你可以不用它,但用它推Xor的结构是最不容易出错的。

于是结构就清晰了:先分别求a、b的反,然后把a和~b做一个与,把~a和b做一个与,最后把两个与的结果用或门合并。

3.2 代码落地:中间线网的命名要有逻辑

我用的HDL写法长这样:

CHIP Xor { IN a, b; OUT out; PARTS: Not(in=a, out=na); Not(in=b, out=nb); And(a=a, b=nb, out=w1); And(a=na, b=b, out=w2); Or(a=w1, b=w2, out=out); }

我来逐行解释一下:

  • Not(in=a, out=na)和Not(in=b, out=nb)先算两个反信号。
  • And(a=a, b=nb, out=w1)实现第一项a·(~b),w1就是这条中间线的名字。
  • And(a=na, b=b, out=w2)实现第二项(~a)·b,w2同理。
  • Or(a=w1, b=w2, out=out)把两条路径合并,输出到out。

这里有个很多新手不注意的细节:线网的命名习惯特别重要。刚开始你可以随便用w1、w2,图快。但到了Mux4Way、Mux8Way这种多个逻辑块叠在一起的芯片,如果中间线都叫w1、w2,代码一长你自己就先晕了。

我自己的习惯是:用有含义的名字,比如notSel、aAndNotB、muxA,读代码的时候就像在读一张电路图。课程内置的仿真器只看名字的匹配,不看你名字有没有语义,但这个习惯会在后面帮你省大量排查时间。

3.3 两种常见错误:漏声明内心线与out到底能不能做中间变量

写Xor最常见的编译错误有两种。

第一种是忘了声明内部线网。只要PARTS里有非输入、非输出的连线名,你都得先在CHIP内部列出。像上面代码里的na、nb、w1、w2,如果少写一个,仿真器会提示找不到对应的pin,报错信息虽然能看懂,但第一次遇见的人很容易蒙。

第二种是把out直接当成中间变量反复用。在课程这套HDL文法里,out是一个外部输出pin,你在PARTS里可以连接它,但更好的做法是别把复杂逻辑直接堆在out上,而是先算到内部线网,最后一步再连到out。这样你的代码层次清楚,以后改成更大模块时也好调试。

4. 选择器的两种姿势:Mux与DMux的实现拆解

4.1 Mux:用sel先分离出两条候选路径

Mux的行为写作布尔表达式就是:

out = a·(~sel) + b·sel

翻译成人话:sel=0时放行a,sel=1时放行b。实现思路和Xor有点神似,也是“两个与门加一个或门”,只不过控制条件从数据变成了sel。

CHIP Mux { IN a, b, sel; OUT out; PARTS: Not(in=sel, out=notSel); And(a=a, b=notSel, out=wa); And(a=b, b=sel, out=wb); Or(a=wa, b=wb, out=out); }

这个代码的逻辑链条很直观:

  1. 先对sel取反,得到notSel。
  2. 把a和notSel做与:当sel=0时,notSel=1,a原样通过;sel=1时,notSel=0,wa被强制置0。
  3. 把b和sel做与:对称地,sel=1时b通过,sel=0时wb为0。
  4. 最后用或门把wa和wb合并:总有一方被“切掉”了,另一方原样送到out。

你发现没有,这里的And门其实就是在当“闸门”用。与门的本质是一个受控开关:一端接数据,另一端接控制,控制为1就放行,控制为0就强制清零。这是数字电路里非常常用的一个套路,理解了这个,后面各种使能信号、选通信号都不会怕。

4.2 DMux:方向反过来,但思路完全对称

DMux的布尔表达式就两行:

a = in·(~sel) b = in·sel

这次不需要或门了,因为两个输出分别被sel和notSel控制:

CHIP DMux { IN in, sel; OUT a, b; PARTS: Not(in=sel, out=notSel); And(a=in, b=notSel, out=a); And(a=in, b=sel, out=b); }

第一次写DMux的人容易犯嘀咕:为什么a和b可以直接作为And的输出?因为它们本身就是CHIP声明的输出pin,完全合法。你可以把输出pin理解为一条“既对外又内部可连接”的线。

还有一个小坑:这里And(a=in, b=notSel, out=a)和And(a=in, b=sel, out=b)共用的是同一个in信号。同一个内部信号可以驱动多个门的输入,这在HDL里没有任何问题,就像真实电路中的一根导线可以并联到多个门一样。有些初学者会担心“in是不是会被分走导致信号变弱”,放心,逻辑信号被多个输入共享是完全正常的设计,不会“不够用”。

4.3 这两个门会成长为更大的水站:Mux16、Mux4Way16与DMux4Way

课程后面的任务很自然地会用到这里的基础门:Mux16就是把16个Mux单比特门并排拼起来,每个位对应一个Mux,它们的sel共用。Mux4Way16则是“多路选择器套多路选择器”:先用两个Mux从四路里粗选出两路,再用第三个Mux做最终选择。

DMux那边也一样,DMux4Way是一个用两个高位选择和两个低位选择逐层分配的结构。你现在写Mux和DMux时形成的“用sel分层控制数据流向”的思维,到那些地方会直接被复用。所以这一节不是孤立的作业,而是在给后面热身。

5. 仿真验证与踩坑实录:用测试脚本把自己按在地上摩擦

5.1 跑通一次完整测试:工具会告诉你哪儿错了

写完了HDL,怎么知道对不对?这套课程自带一套仿真环境,流程非常死板但有效:

  1. 新建Xor.hdl文件,把代码贴进去。
  2. 打开测试脚本Xor.tst,里面已经写好了所有输入组合。
  3. 加载你的芯片,运行单步或全跑。
  4. 工具会把你的输出和Xor.cmp中期望的正确答案逐行对比,并用Xor.out记录结果。

测试脚本长这样(课程自带的就不用动了):

load Xor.hdl, output-file Xor.out, compare-to Xor.cmp, output-list a%B3.1.3 b%B3.1.3 out%B3.1.3; set a 0, set b 0, eval, output; set a 0, set b 1, eval, output; set a 1, set b 0, eval, output; set a 1, set b 1, eval, output;

这里output-list a%B3.1.3的意思是:显示a,用二进制格式,占3列、前面补1位空格、后面补3位空格。不用记具体参数,知道“这行决定对比输出里的格式”就够了。

整个流程最核心的习惯是:不要自己写脚本,直接用配套的.tst。因为测试用例是设计者精心设计的,包含了边界情况和所有输入组合。你手动只测几个值,很容易漏掉错误。比如Xor如果第3行错了,回头去看代码,八成不是“逻辑懂了但写错”,而是某个连接写反了。

5.2 我在编译阶段踩过的最经典的几个错误

说几个我实际踩过、而且后来发现几乎人人都会踩的编译错误。

第一个是大小写和拼写。Not(in=a, out=na)里的Not不能写成not,And不能写成and。课程这套HDL对大小写是敏感的,报错提示会说“无法识别”之类的,但不仔细看很难想到是大小写问题。我的建议是:所有芯片名、管脚名严格按照教材示例的写法照抄,不要自己改成“更顺眼”的拼写。

第二个是端口名不匹配。比如And(a=a, b=b)如果手滑写成And(a=a, b=sel),而sel并不存在,仿真器会报“管脚未连接”或者“找不到输入”。这类错误看代码时很难发现,因为它不违背语法,只是违背了你的设计意图。我排查这类问题的笨办法是:把中间线网的连接顺序在纸上画一遍,再和代码逐行对。

第三个错误比较高级:把out漏连了。有的芯片仿真报错说“输出pin未连接”,说明逻辑都对,但最后一步忘了把中间结果接到out上。这种错误最气人,因为工具不告诉你“你忘了连out”,只告诉你“out没有驱动来源”。遇到这种,第一反应就是检查PARTS里有没有哪一行用了out=...。

5.3 逻辑翻车现场:真值表背反是最高频事故

编译通过只是第一步,真正的考验是逻辑对不对。我第一次写Mux时,始终觉得sel=1应该输出a,因为“a排在前面嘛”。结果写完跑测试,前两行就报错。查了半天,发现我把选择逻辑整个写反了:And(a=a, b=sel)让sel=1时放行的居然是a,完全和规定相反。

这种“真值表和直觉打架”的问题,靠看代码很难一眼发现。我的方法是:对每个门,自己先在纸上写一行布尔表达式,再对照代码。Mux就写out = a·(~sel) + b·sel,然后去看代码里wa和wb分别对应哪一项。只要表达式对了,代码照着实现,基本不会翻车。

还有一次更隐蔽:Xor的测试脚本要求比较格式是out%B3.1.3,我用默认输出调式时发现数字都对,但对比仍然标红。后来才意识到是输出格式里的空格问题——虽然数值相同,但字符宽度不一致,对比程序仍然认为“不一样”。这种不是逻辑错误,纯粹是环境使用问题,遇到了别慌,检查输出格式设置就行。

6. 扩展一步:这三个门怎么长成ALU的一部分

6.1 从“单比特”到“多比特”:并联是最直白的复用

一个Xor一次只能处理一个比特,但CPU里数据是16位的,怎么把那套电路用在16位数据上?

答案是并联:把16个Xor门并排放在一起,每个Xor的第i位分别处理两个16位数据的第i位,输出组成了16位的异或结果。整个过程“毫无技术含量”,纯粹是重复,但它建立了数字设计里一个至关重要的习惯——先搞定1位的逻辑,再按位扩展到任意宽度。Mux16也是完全一样的思路,16个Mux共享同一个sel。

6.2 在后续章节里,三个门各自扮演什么角色

这里给你透个底,看看今天的付出会换来什么:

  • Xor是全加器和ALU的核心。二进制加法的本位和高位判断都依赖Xor。你以后看Add16或者ALU内部的逻辑,会发现Xor的身影到处都是。
  • Mux是CPU的控制枢纽。ALU需要一个输入宽度选择、寄存器堆需要选择读取哪个寄存器、PC需要决定下一条指令取哪个地址……这些全部是Mux的应用。它也很可能成为你第二更复杂芯片(如Mux4Way16)的基本单元。
  • DMux则会在内存模块里大显身手。写一个寄存器的时候,要用DMux把“写使能”信号精确送到对应的寄存器;构建RAM时,地址解码本质上就是DMux的一大片变体。

所以你现在花时间把这十几个线连明白,绝不是白费。以后每一章你都会和这三个门重逢,只是它们会被不断打包进更大的模块里,成为你手下的“老朋友”。

我个人第一次把Xor和Mux、DMux全部跑通测试的时候,最大的收获其实不是“我会写这三个门了”,而是终于看懂了一句话:所谓的计算机,就是无数个这样的小开关按照规则连在一起。选择端、使能端、数据通路,这些看起来抽象的概念,在这一章的仿真器里都变成了可观察、可调试、可控制的实际信号。如果说Nand是计算机世界的原子,那今天这三个门就是第一批像样的“分子”,而它们的组合规律,就是你接下来一路搭到CPU都要反复使用的语言。

RELATED READING

延伸阅读

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