数字电路模拟程序:三次迭代的设计与反思

数字电路模拟程序:三次迭代的设计与反思

转眼间,数字电路模拟程序的作业集已经全部收官了。回顾这三次作业(实验四五六),说实话比我想象中的要折腾得多——本来以为只是个简单的逻辑门模拟,结果越做越像在造一个小型电路仿真引擎。今天就来好好捋一捋这三次作业的设计思路、踩过的坑,以及一些后续可以改进的地方。

先上一个对比表格镇楼,让大家对这个系列作业有个宏观认知:

作业集 对应程序 核心知识点 题量 个人感受难度
实验四 程序1 基础逻辑门(与、或、非、异或、同或)、拓扑排序仿真 40个case ⭐⭐⭐ 中等
实验五 程序2 三态门、译码器、数据选择器、数据分配器、控制引脚角色 34个case ⭐⭐⭐⭐ 较难
实验六 程序4 子电路定义与实例化、异常检测(5种类型) 43个case ⭐⭐⭐⭐⭐ 地狱级

这里有个小插曲需要说明一下:按照正常的迭代顺序,程序3应该是讲反馈电路、时序电路、D触发器、JK触发器这些时序逻辑的内容。但老师出于课程安排考虑,直接跳到了程序4的子电路部分。所以我们这个系列实际上是1→2→4的演进,缺少了程序3这个承上启下的环节。这对后续理解时序电路的仿真逻辑确实造成了一些知识断层,不过这就是后话了。

从数据来看,实验四满分通过(100分/40case),实验五满分通过(100分/34case),实验六就有点惨烈了——总分大概88分左右,扣分点主要在子电路处理的几个case上。这个成绩说实话有点打击人,但回过头来看,正是这些没做对的case让我对子电路的设计有了更深刻的思考。下面我会详细展开。


设计与分析:层层递进的架构演进

实验四:基础逻辑门搭好地基

实验四是整个系列的起点,也是奠定整体架构的关键一环。题目要求模拟五种逻辑门构成的组合电路,输入是外部信号定义和引脚连接关系,输出是每个门的输出引脚电平。

整体架构采用的是经典的Engine模式,由CircuitEngine作为总调度,串联三个核心组件:

CircuitEngine → CircuitParser → CircuitSimulator → OutputFormatter

这种分离的好处很明显:解析、仿真、输出三个职责泾渭分明,后续扩展新组件时互不影响。

类图结构方面,最核心的是Gate抽象类:

Gate(抽象类)
├── name, gateType, id, inputPins, outputPin
├── compute() [抽象方法]
├── isReady(), hasAllInputSignals() [判断是否能执行]

五个具体门类(AndGate, OrGate, NotGate, XorGate, XnorGate)继承Gate,各自实现compute()方法。GateType枚举定义了五种门的标识符前缀和排序权重,GateFactory接口配合DefaultGateFactory实现,根据解析出的GateSpec创建对应的Gate实例——这是工厂模式的典型应用。

这里有个设计决策值得一提:GateSpec类的引入,把"门名解析"和"门实例创建"分成了两步。GateNameParser负责把"A(8)1"这种字符串翻译成结构化的规格描述,DefaultGateFactory再根据规格描述创建具体的门对象。这种解析与创建分离的设计,在实验六增加异常检测时发挥了关键作用——我们可以在创建之前先校验规格的合法性,不合法就直接报错,不用创建一堆无用的对象。

数据层面,CircuitModel是整个系统的数据核心,包含了门实例映射(gates)、引脚映射(pinByKey)、外部扇出(externalFanout)、门扇出(gateFanout)、信号来源追踪(sourceGateOfTarget)等关键数据结构。Pin类虽然简单(pinId, owner, signal, connected),却是连接关系的最小单元。

实验四的类图

仿真算法是本实验的技术亮点——采用拓扑排序BFS来确保电路按照依赖顺序执行。核心逻辑是:

  1. 维护一个remainingDeps映射,记录每个门还有多少输入依赖未满足
  2. 每当一个门的输出确定,就更新所有依赖它的门的剩余依赖数
  3. remainingDeps归零且所有输入信号到位时,门就可以执行了,加入执行队列

这种设计比单纯的递归求值要优雅得多,避免了循环依赖导致的栈溢出问题。我一开始想用递归做,结果遇到复杂点的电路就爆栈了,后来改成拓扑排序才搞定。

输入解析也有点门道。外部输入用INPUT:行定义,引脚连接用[...]表示。SourceRef类负责区分信号是来自外部输入还是另一个门的输出——这个区分在后续扩展元件时至关重要,因为外部输入和门输出的传播路径完全不同。

实验四的40个测试case全部通过,满分收官。基础门元件测试(case1-7)、门级联组合(case8-20)、复杂连接与分支(case21-30)、边界与特殊组合(case31-35)、多层级联电路(case36-40),每个档次都顺利通过。执行时间在116-156ms之间,内存占用稳定在21-22MB左右。


实验五:组合电路元件——打怪升级

实验五在程序1的基础上新增了四种元件:三态门(S)、译码器(M)、数据选择器(Z)、数据分配器(F)。这四种元件有一个共同特点——它们都有控制引脚,这直接导致了原有Pin体系的改造。

PinRole枚举的引入是本实验的第一个关键变化:

enum PinRole { CONTROL, INPUT, OUTPUT }

Pin类新增了role字段,用来区分引脚的角色。这样在仿真时,三态门的控制引脚为1才导通,为0则输出高阻态;译码器的控制引脚优先级最高,先于输入引脚处理。

同时引入了Signal工具类,把原来散落的魔数(-1表示无效、0表示低电平、1表示高电平)统一成了命名常量:UNKNOWN=-2INVALID=-1LOW=0HIGH=1。这个改动看似微不足道,实际上在处理三态门的高阻态输出时帮了大忙——以前用-1既表示"没接到信号"又表示"高阻态",逻辑混乱得不行;现在有了INVALID专门表示高阻态,代码语义清晰多了。

四种新元件的设计要点

  • 三态门:控制引脚pinId=0,输入pinId=1,输出pinId=2。控制为1时正常输出输入信号,为0时输出高阻态(Signal.INVALID)。这个概念在后续的时序电路中会反复出现——三态门本质上就是一个可控开关

  • 译码器:控制引脚优先解析(pinId从小到大),然后是输入引脚,最后是输出引脚。当控制引脚满足条件(S1=1,S2+S3=0)时正常工作,输出只有一路为0其余为1;不满足时所有输出无效。输出格式比较特殊,采用了类似M(3)1:3的形式,表示Y3输出0而其余输出1。

  • 数据选择器(MUX):标识符Z(n)表示有n个控制引脚,根据控制端的二进制值选择对应的输入通路。比如Z(2)有2个控制端4个输入端,S1S0=01就选D1。

  • 数据分配器(DEMUX):标识符F(n)表示有n个控制引脚,根据控制端的二进制值选择对应的输出通路,无效通路输出-。比如F(3)有3个控制端8个输出端,只有一路输出等于输入信号,其余7路都是-

实验五类图

OutputFormatter为了适配译码器和分配器的特殊输出格式,新增了appendDecoderLine()appendDemuxLine()方法。这属于适配器模式的思路——让不同元件按自己需要的方式格式化输出,而不是统一走一条道。如果以后还要加新的特殊输出元件,只需要在OutputFormatter里新增一个append方法就行,不影响已有的格式化逻辑。

实验五的34个case全部通过,满分收官。虽然元件复杂度上升了,但由于复用了实验四的仿真引擎,整体工作量并没有想象中那么大。这验证了良好架构设计的可扩展性——只要接口设计得合理,新增功能就不会动一发而牵全身。

内存方面,实验五的内存占用(21-23MB)比实验四(21-22MB)略高,主要因为新增了PinRoleSignal等类型信息,以及三态门/译码器等元件的状态维护。不过增幅不大,说明数据结构的设计还是比较紧凑的。


实验六:子电路+异常检测——跌宕起伏

实验六是整个系列的终章,也是我摔得最惨的一集。它包含两个核心主题:子电路异常检测

子电路的设计借鉴了硬件描述语言(如Verilog/VHDL)的思路,支持用户自定义子电路,然后在主电路中引用。语法格式如下:

C1:                  ← 子电路起始,编号1
INPUT: A B           ← 声明输入端口
OUT: C               ← 声明输出端口
[A X1-1]             ← 内部连接
[B X1-2]
[X1-0 Y1-1]
[A Y1-2]
[Y1-0 C]             ← 输出连接到端口C
endc                 ← 子电路结束

子电路定义完成后,在主电路中可以通过C1-A的形式引用它的输入端口,子电路内部门输出则带前缀显示,如C1-X1-0

类图演进方面SubCircuit类成为新增的核心数据结构,包含id、输入端口列表、输出端口列表、内部连接线列表等信息。CircuitModel新增了subCircuits映射和portInputPinList映射来管理子电路实例。CircuitParser则增加了hasError/errorMessage字段,以及validateConnection等异常检测方法。

最关键的设计决策是:实例化子电路时,内部门名统一加前缀。比如子电路C1里面的X1门,在主电路中就变成了C1-X1。这个前缀机制解决了命名冲突问题——两个不同的子电路可以有同名的内部门,因为实例化后的全局名字完全不同。

两阶段解析是另一个技术要点:

  1. 第一阶段:遍历所有输入行,识别子电路定义块(C{n}:endc之间的内容),解析出端口列表和连接线,存入subCircuits映射
  2. 第二阶段:再次遍历输入行,处理主电路中的INPUT:行和[...]连接行。遇到子电路端口引用(如C1-A)时,实例化对应的子电路,建立端口映射

这种分而治之的策略让复杂的解析逻辑变得清晰可控——子电路定义和主电路解析各自独立,互不干扰。

异常检测是实验六的另一个核心功能,要求检测五种错误:

优先级 错误类型 错误信息
1 多输入(多个输出引脚作源) "include more than one input"
2 没有输入 "include none input"
3 没有输出 "include none output"
4 顺序反了 "input and output sequence error"
5 输入信号冲突 "input signal conflict"

这里有个容易混淆的概念:连接信息中的"输入"和"输出"跟元件引脚的"输入"和"输出"方向是反的。元件的输入引脚在连接信息中属于"输出端"(信号的接收方),元件的输出引脚在连接信息中属于"输入端"(信号的提供方)。这个反转关系理解错了,异常检测的2和3就会搞反。

异常处理采用标志位+错误信息的方式:CircuitParser增加hasErrorerrorMessage字段,在解析连接行时调用validateConnection进行检查。优先级通过if-else链实现——只处理第一次遇到的最高优先级异常。多条连接行都有异常时,只取最早出现的那一条。
实验六的类图

测试结果方面,实验六总共43个case,异常检测部分(case30-43)全部正确,满分14分到手。问题出在子电路处理的几个case上——case8(多子电路)、case23/24(单子电路)、case26/28/29(多子电路)、case41(单个异常与子电路混合场景)共7个case未通过,总分约88/100。具体原因我会在采坑心得里详细分析。


采坑心得:那些让我夜不能寐的问题

拓扑排序:看起来简单,做起来全是坑

实验四的拓扑排序听着很美好,但实现起来细节一堆。最容易出错的地方是remainingDeps的初始化——每个门到底应该有多少依赖?

我的第一版实现是直接把门的所有输入引脚数作为依赖数,但这样就会出问题:有些引脚可能连接的是外部输入,根本不需要等别的门算完。外部输入在仿真开始前通过propagateExternalInputs()方法就已经传播到位了,不需要等待任何门的计算。

正确的做法是:只有当输入来自另一个门的输出时,才计入依赖。具体来说,遍历每个门的inputPins,检查sourceGateOfTarget映射中是否存在该引脚——存在说明它来自另一个门的输出,依赖数+1;不存在说明它来自外部输入,不计入依赖。

还有一个隐藏的坑:依赖关系的更新时机。必须在门计算完成后立即更新依赖它的下游门的remainingDeps,不能等所有门都算完再统一更新,否则BFS的队列就会乱——某个门的依赖已经满足了但没有及时入队,导致后续门永远等不到输入信号。

我一开始就在这里翻车了。具体表现是:简单电路(case1-7)没问题,但一遇到多层级联的电路(case36-40),有些门就始终没有被计算,输出结果缺了好几行。后来加了日志才发现,是依赖更新延迟导致的。改成即时更新后,所有case就都过了。

控制引脚:重新定义Pin的语义

实验五引入控制引脚后,原来的Pin类瞬间不够用了。我一开始想用pinId的大小来区分控制引脚和普通输入——题目说了控制引脚pinId最小嘛,那pinId小的就是控制引脚呗?结果发现译码器的输出引脚id也很大,三态门的三个引脚0/1/2各有各的含义,光靠id根本区分不了。

解决方案是显式引入PinRole枚举。虽然这意味着要修改Pin类的结构,影响面不小,但这是值得的——语义的显式化让代码的可读性和可维护性大幅提升。后续在仿真时,判断三态门是否导通、检查译码器控制引脚状态,都可以直接通过role字段快速判断,不用再靠id的数值范围去猜。

同时引入的Signal工具类也是同样的思路——用命名常量代替魔数。以前signal == -1到底表示"没接到信号"还是"高阻态"?没人说得清。现在signal == Signal.INVALID明确表示高阻态,signal == Signal.UNKNOWN表示信号未确定,一眼就能看懂。

这个教训让我认识到:代码的可读性不仅仅关乎"写的时候爽不爽",更关乎"出bug的时候好不好查"。如果变量含义模糊,排查问题就要花两倍的时间。

子电路命名:前缀是救命稻草

实验六最让我头疼的是命名冲突问题。假设子电路C1和C2都有内部的X1门,那主电路里这两个X1该如何区分?如果都叫X1,CircuitModel.gates映射里后创建的就会覆盖先创建的,直接导致数据丢失。

我最初尝试用作用域嵌套的方式管理——每个子电路实例有自己的gates子映射,仿真时在子映射之间切换。但这个方案实现起来复杂度爆炸:跨子电路的信号传播需要额外处理,pinByKey的查找逻辑也要改成分层查找,整个解析器差点被改得面目全非。

折腾了半天,最后还是老老实实用了前缀方案:实例化时给所有内部门名加上{subCircuitId}-前缀。比如C1里的X1变成C1-X1,C2里的X1变成C2-X1,全局唯一,不可能冲突。

这个方案朴素但有效,而且实现起来意外地简单——只需要在实例化方法里遍历内部连接,把涉及的门名都加上前缀就完事了。有时候最简单的方案就是最好的方案,不需要过度设计。

异常检测的优先级:第一个错误最重要

异常处理的优先级处理也有玄机。题目要求多条异常只处理最前面一条,这意味着我必须在解析过程中边检测边记录,而不是把所有错误都收集起来再排序。

我一开始用的是列表收集方案:遍历所有连接行,把遇到的错误都加到列表里,最后取第一个。结果发现顺序不好控制——遍历子电路定义时的顺序会影响错误收集的顺序,而题目说的"最前面一条"指的是输入文本中的先后顺序,不是解析器内部的遍历顺序。

最后改成在parseConnectionLine里用if-else if-else if链按优先级检查,遇到第一个错误就设置hasError=true并记录errorMessage,后续的连接行就不再处理了。这保证了结果的一致性。

还有一点值得注意:同一条连接行可能同时满足多种异常条件。比如[A(2)1-0 O(2)1-0]这条连接,既没有输出目标(因为两个都是门的输出引脚),又包含了多个输入源。按照优先级规则,"多个输入"的优先级高于"没有输出",所以应该报"include more than one input"。这个优先级判断在validateConnection方法里是通过检查顺序来保证的——先检查优先级1(多输入),再检查2(无输入),以此类推。

实验六的部分case未通过:我的反思

说实话,实验六只拿了88分还是有点沮丧的。异常检测的case(30-43)全部正确,问题出在子电路处理的几个case上——特别是多子电路的场景(case8、case26、28、29),还有两个单子电路case(case23、case24)也意外翻车了。

事后分析,问题很可能出在以下几个地方:

子电路实例间的信号传播。当主电路中连接了多个子电路实例时,如果某个信号需要跨子电路传播(比如C1的输出连接到C2的输入),我的实现可能没有正确处理这种跨实例的依赖关系。拓扑排序时,C1内部门的计算结果需要先传播到C1的输出端口,再通过主电路的连接传播到C2的输入端口,最后才能驱动C2内部门的计算。这条传播链路中任何一环断了,后续计算就会出错。

端口映射的维护portInputPinList映射记录了子电路输入端口到内部引脚的对应关系,但这个映射的更新可能不够及时。当子电路的输出端口连接到主电路的某个门时,需要在gateFanout中正确注册这个连接,否则仿真时就找不到传播路径。

单子电路case23/24的失败更让人困惑——这两个case应该不涉及跨实例传播的问题。我怀疑可能是子电路内部的连接解析有细微bug,比如端口的pinId编号与题目要求不一致,或者resolveTargetInputPin方法在处理带前缀的门名时出了问题。

没有满分确实遗憾,但这些错误案例恰恰是最好的学习素材。下次再遇到类似的复杂系统设计,我会提前多考虑边界情况和组件间的交互。


改进建议:面向未来的优化方向

1. 子电路采用组合模式重构

目前SubCircuit是一个独立的数据类,和Gate体系的耦合度不够。如果要支持更复杂的层次化设计(比如子电路嵌套子电路),可以考虑让SubCircuit也实现类似Gate的接口,使其成为电路构件的一种。题目其实也给了这个设计建议——采用组合模式,将子电路和电路元件作为抽象元件类的子类。

这样设计的好处是层次化电路和扁平化电路可以用同一套仿真引擎处理,代码会更加统一。当然,这需要对现有的仿真算法做较大改动——Gate.compute()的返回值是单个int,而子电路可能有多个输出,需要重新设计输出机制。属于中长期目标。

2. 异常处理改用异常类替代标志位

目前的异常处理采用的是hasError布尔标志配合errorMessage字符串。这种方式有几个缺点:

  • 错误类型只能通过字符串区分,不够类型安全
  • 无法携带更丰富的错误上下文(如行号、错误位置等)
  • 异常处理和正常逻辑混在一起,降低了代码可读性

建议改用自定义异常类的方式,定义CircuitParseException,内部携带错误类型枚举和行号等信息。这样异常处理可以用try-catch的自然控制流,代码会清晰很多。CircuitParser不再需要hasErrorerrorMessage字段,CircuitEngine.run()里直接try { parser.parse() } catch (CircuitParseException e) { return e.getMessage(); }就行了。

3. 增加单元测试

目前三个实验都没有写正式的单元测试,全靠PTA平台的测试用例来验证。这种方式有几个问题:

  • 测试用例覆盖面有限,边界情况可能遗漏
  • 出错时定位问题耗时较长——不知道是解析错了、仿真错了还是格式化错了
  • 回归测试麻烦,每次改代码都要重新提交到PTA跑一遍

建议引入JUnit,对核心类编写单元测试。比如Gate.compute()的各种输入组合、CircuitSimulator.simulate()对简单电路和复杂电路的仿真结果、CircuitParser.parseConnectionLine()对异常情况的检测等。Mock对象可以用Mockito来处理依赖,隔离测试目标。

4. 考虑时序电路的扩展

虽然程序3被跳过了,但时序电路的仿真确实是个有意思的课题。组合电路可以用拓扑排序一次性算完,但时序电路需要迭代收敛——信号在环路上传播,直到稳定为止。

如果要支持这个扩展,可能需要引入仿真周期的概念,每周期计算一次,直到所有信号稳定或达到最大迭代次数。这也意味着拓扑排序算法需要升级——当前的BFS假设电路是无环的,而时序电路天然包含反馈环。这是一个很有挑战性的扩展方向,也是后续学习的一个目标。


总结:从做作业到理解软件工程

回顾这三次作业,从实验四的100分到实验六的88分,成绩并不是一帆风顺的。但正是这些波折让我收获了比满分更多的东西。

架构设计的重要性。实验四奠定的基础架构(Engine模式+工厂模式+拓扑排序)在后续两个实验中发挥了巨大作用。实验五只新增了四个元件类和一些引脚管理逻辑,复用了几乎所有原有组件;实验六的子电路机制也建立在CircuitModelCircuitParser的框架之上。这让我深刻体会到那句老话:磨刀不误砍柴工。前期在设计上的投入,换来了后期扩展的低成本。

迭代开发的思维。从程序1到程序2到程序4,每次迭代都不是推倒重来,而是在现有基础上添砖加瓦。这种增量式的开发方式比一次性设计一个大而全的系统要可控得多。实验六的子电路命名方案就是在实验四、五的基础上自然演进出来的,而不是一开始就能预见的。反过来说,如果一开始就为了"将来可能的扩展"而做过度设计,反而会增加不必要的复杂度。

调试能力的锤炼。实验六扣分的那些case,逼着我去分析信号传播的路径、检查映射维护的时机、追踪命名冲突的根源。查bug的过程痛苦,但确实锻炼了系统性的调试思维。好的架构不仅要能跑起来,还要容易查错——这一点我还有很长的路要走。如果当初写了单元测试,定位问题的时间至少能缩短一半。

知识的遗憾。程序3的缺失确实是个遗憾。反馈电路、触发器这些时序逻辑的概念,在数字电路和计算机组成原理中都是核心内容。跳过这个环节,对完整理解电路仿真还是有影响的。特别是时序电路需要的"迭代收敛"思想,和组合电路的"拓扑排序"思想完全不同,直接跳到子电路就缺少了这个认知过渡。希望后续有机会自己补上这一课。

关于那7个没过的case。说实话,88分不是终点。事后复盘分析,子电路的信号传播和端口映射是最可能的bug根源。如果时间允许,我会尝试重构子电路的实例化逻辑,用更清晰的数据结构来管理端口映射关系,而不是靠字符串拼接key来做查找。但截止日期不等人,这个优化只能留到以后了。

总的来说,这三次作业让我从一个只会写「输入-处理-输出」单链路的初学者,逐渐学会了分层设计、模块解耦、增量迭代的工程思维。虽然实验六没拿满分有点遗憾,但这恰恰是最真实的成长写照——完美的作业只存在于想象之中,真正有价值的经验往往来自那些不完美的实践

最后,感谢这三次作业让我折腾了这么久,只能无奈╮(╯▽╰)╭打出gg,代码虐我千百遍,我待代码如初恋。下一个项目,继续加油!


作者信息

  • 作者:温健豪
  • 学号:25201732
  • 学校:南昌航空大学
  • 专业:软件工程
  • 课程:Java程序设计
  • 作业集:数字电路模拟程序(实验四五六)

posted @ 2026-06-15 12:45  温健豪_25201732  阅读(15)  评论(0)    收藏  举报