一、前言
本阶段三次作业(第4、5、6次)围绕“数字电路模拟器”展开,是一次典型的迭代式增量开发。从最基础的与或非门,到多输入门、组合器件(译码器、选择器),再到支持子电路(模块)定义与实例化,难度呈阶梯式上升,每次作业都在前一版的基础上增加新特性,同时要求兼容旧语法。
三次作业的知识点总结如下:
| 作业 | 知识点 | 核心难点 |
| 第四次 | 5种基本门(与、或、非、异或、同或),输入信号,输出标记 | 正则解析连接线,迭代计算稳定 |
| 第五次 | 多输入门(参数化)、三态门、译码器、多路选择器、数据分配器 | 引脚编号管理,复杂器件逻辑 |
| 第六次 | 子电路定义(Cx:块)、子电路实例化、引脚方向、5种错误检测 |
内部与外部引脚映射,递归计算 |
题量:每次作业需实现约5~10种元件,并提供完整的解析、连接、计算和输出流程。第三次还增加了对子电路定义的解析,相当于开发了一个微型硬件描述语言的前端。
难度:第4次属于入门级,只需理解继承和多态;第5次开始考验对复杂器件(如译码器)的算法实现;第6次则要求系统设计能力,因为子电路内部包含元件和连接,必须处理好命名空间和信号传播。
通过这三次练习,我不仅巩固了Java面向对象编程,还接触了编译原理中的词法/语法分析思想,以及图论中的拓扑排序与迭代收敛算法。
二、设计与分析
2.1 作业四——数字电路模拟程序-1
类图设计

设计思路:采用模板方法模式,ElectronicElement 定义 calculate() 抽象方法,子类实现具体逻辑。每个元件拥有 InputPin[](引脚数量可变)和 OutputPin(单输出)。引脚类封装了 getInput() 和 setInput(),并支持从源元件同步(syncFromSource)。
核心算法:主循环 while(changed) 反复调用所有元件的 calculate(),直到所有输出不再变化。这种“盲目迭代”对于无反馈组合电路是可行的,因为信号传播最多经过门级数,通常几次即可收敛。
解析策略:使用正则表达式区分三种语句:
INPUT: A-1 B-0 → 存入 inputSignals 映射。
[A1-1 A2-0] → 连接,创建元件并设置输入。
[A1 OUT] → 标记需要输出的元件名。
SourceMonitor 分析图:

心得:第一次作业让我深刻体会到“面向对象”的威力——每种门只需重写 calculate,无需重复定义引脚管理。但 InputPin 和 OutputPin 作为独立类增加了对象开销,且引脚编号从0开始,而题目示例中常出现 -1 作为信号值,容易混淆。
2.2 作业五——数字电路模拟程序-2
类图设计

统一引脚模型:废弃 InputPin/OutputPin 对象,改用 int[] inputs 和 int[] outputs,通过 setInput(pin, value) 直接赋值。这简化了内存管理,也方便批量重置。
复杂器件逻辑:
译码器 (MGate):根据控制位计算地址,对应输出为0,其余为1。
多路选择器 (ZGate):根据控制位选择一路数据输入输出。
数据分配器 (FGate):根据控制位将数据输入送到对应输出端。
三态门 (SGate):使能端为1时输出数据,否则输出高阻(-1)。
命名与参数解析:支持 A(3)1 表示3输入与门编号1。使用正则 ^([A-Z])\((\d+)\)(\d+)$ 提取参数。
计算引擎优化:引入 Connection 列表,传播时只更新目标引脚,但仍需全量迭代所有元件以确保组合逻辑稳定。迭代次数限制为1000。
SourceMonitor 数据:

心得:数组模型让代码更紧凑,但 formatOutput 必须为每种器件重写(如三态门输出引脚编号为2,译码器输出格式为 M1:3 表示第3路为0),导致代码分散。此外,未对输入引脚进行“已驱动”检查,可能会出现未连接引脚被默认-1参与计算,造成错误结果。
2.3 作业六——数字电路模拟程序-3
类图设计

子电路定义解析:遇到 C1: 开始收集直到 endc。内部包含 INPUT:、OUT: 和若干 [ ... ] 连接线。定义块不立即实例化,而是存入 subDefs 映射。
子电路实例化:当出现 C1-A 形式的引脚引用时,创建 SubCircuitInstance(若尚未创建)。该实例拥有与普通元件相同的 inputs/outputs 数组,但实际计算由内部元件完成。
内部构建:在 SubCircuitInstance 构造时,解析 def.internalLines,建立从子电路输入端口到内部元件引脚的映射(inputTargets),以及从内部元件输出到子电路输出端口的映射(outputSources)。内部连接保存在 internalConns 中。
计算流程:compute() 首先将 inputs 值通过 inputTargets 传递给内部元件,然后迭代计算内部连接(类似主循环),最后将 outputSources 中的内部输出值赋给 outputs 数组。
错误检测:
多个输出源(include more than one input)
没有输入信息(include none input)
没有输出源(include none output)
顺序错误(第一个不是输出源)
信号冲突(同一引脚被多次驱动)
SourceMonitor 分析图:

心得:子电路的引入使系统具备了层次化设计能力,但实现较为粗糙——一个子电路定义只能有一个全局实例(因为按名称唯一创建),无法参数化。此外,内部元件命名时加入了前缀(如 C1-A1)以避免冲突,但全局 allComps 中混杂了子电路内部和外部的元件,输出时需额外过滤。
三、踩坑心得
在三次作业的编码和调试过程中,我踩了不少坑,下面详述几个典型案例。
3.1 引脚编号与信号值混淆(作业4)
现象:输入 INPUT: A-1,连接 [A A1-0],预期 A1 输出1,实际输出 -1(未连接)。
原因:解析 INPUT: 时,我将信号名和值存入 inputSignals,但连接中的 [A A1-0] 中第一个 token 是信号名 A,第二个是目标引脚 A1-0。我的代码错误地将 A 当作元件名去查找,未匹配到,于是跳过了连接。
解决:修改解析逻辑,先判断 token 是否为单个大写字母且存在于 inputSignals,若是则作为信号源处理,否则视为元件。同时,信号值应在连接建立时直接设置目标引脚的输入,而非通过后续同步。
教训:数据结构与解析顺序必须严格匹配,建议先定义所有信号,再处理连接。
3.2 迭代计算陷入死循环(作业5)
现象:包含译码器 M2 和选择器 Z2 的电路,程序卡死,CPU 100%。
原因:在迭代循环中,每次 calculate() 都根据当前输入重新计算,但译码器的输出(多个引脚)是互斥的,一旦某个输出变为1,另一个变为0,导致下游元件不断变化,无法收敛(实际上是因为译码器输出所有位同时变化,但迭代顺序使得某些元件先看到旧值)。
解决:对于组合电路,理论上只需迭代到稳定,但应避免振荡。我限制了最大迭代次数(1000),并在每次迭代前将所有元件的输入重置为 -1,然后重新传播信号(确保每次迭代从干净状态开始)。同时,保证 calculate() 是纯函数(无副作用),仅依赖当前输入。
教训:组合电路不应有反馈,否则可能振荡。迭代算法必须保证单调性,或者采用拓扑排序一次计算。
3.3 子电路内部引脚无法正确映射(作业6)
现象:定义 C1: 内部有 INPUT: A B,内部连线 [A A1-0],但实例化后 A1 始终无输出。
原因:在解析内部连线时,我将 A 当作外部信号处理,而不是子电路的输入端口。因为我的 parseInternalPin 没有过滤掉输入端口名。
解决:修改 parseInternalPin,如果 token 是 def.inputs 或 def.outputs 中的名称,直接返回 null(不处理),而是在上层分别建立 inputTargets 和 outputSources 映射。对于 [A A1-0],第一个 token 是输入端口,应记录输入端口A到 A1 引脚的映射;第二个 token 是目标引脚,正常解析。
教训:子电路内部的“源”可能是外部端口,而不是具体元件,必须区分处理。
3.4 错误检测顺序不当导致误报(作业6)
现象:输入 [A1-0 A2-0] 正常,但 [A1-0 A2-0 A3-0] 报“多个输出源”。
原因:我的错误检测逻辑是“从第二个 token 开始如果发现有输出源则报错”,但 A1-0 是输出源,A2-0 和 A3-0 是输入接收端,不应该报错。实际上,多个接收端是允许的(扇出)。
解决:正确识别 token 角色:输出源必须是元件输出引脚(isOutputPin)或信号;输入接收端必须是元件输入引脚(isInputPin)。只有在接收端位置出现输出源才算“多个输出源”。我重写了角色判断,使用 Pin.isInput 标识方向。
教训:错误检测必须基于准确的语义,不能仅凭 token 位置。
3.5 输出排序错误(作业6)
现象:输出顺序为 A1, A10, A2,不符合数值升序。
原因:直接按字符串排序,10排在2前面。
解决:提取末尾数字,使用 Comparator.comparingInt(Main::extractNumber)。
教训:涉及编号时,务必考虑数值比较。
四、改进建议
基于三次作业的实践,我认为可以从以下几个方面进行改进,使代码更具可维护性和扩展性。
4.1 重构解析器,采用状态机或递归下降
当前 Main 中的解析逻辑混杂了词法分析和语法分析,且使用大量正则和 if-else,难以维护。建议将解析分为:
Lexer:将输入行转换为 Token 流。
Parser:根据语法规则构建 AST(抽象语法树),例如 ConnectionNode、SignalNode、SubCircuitDefNode。
Builder:根据 AST 创建元件和连接。
这样不仅清晰,而且便于扩展新语法。
4.2 引入拓扑排序优化计算
当前迭代算法对每个元件都执行 calculate,即使其输入未变化。可改为:
建立有向图(元件为节点,连接为边)。
从输入信号开始,按拓扑序传播(使用队列)。若存在环,则报错(组合电路不应有环)。
这样一次遍历即可完成计算,时间复杂度 O(V+E)。
4.3 支持子电路多实例与参数化
当前子电路只允许一个全局实例,限制了复用。可修改为:
SubCircuitInstance 持有自己的内部元件副本(深拷贝定义中的元件原型)。
允许通过参数指定端口宽度(如 C1#(3) 表示实例化宽度为3的译码器)。
使用原型模式复制定义,避免互相干扰。
4.4 增加日志与单元测试
测试每个门电路的逻辑真值表。
测试解析器对合法/非法输入的响应。
测试错误检测的覆盖度。
日志输出可帮助调试,如设置 DEBUG 模式打印每次迭代的元件状态。
4.5 优化输出格式,统一使用格式化器
不同元件的输出格式各异(如三态门输出 -2,译码器输出 :3),可定义一个 OutputFormatter 接口,每种元件实现自己的格式化逻辑,避免在 formatOutput 中使用 instanceof。
五、总结
通过这三次作业的迭代开发,我收获了宝贵的工程经验,也对面向对象设计有了更深的理解。
纵向对比三次作业:
第4次:让我熟悉了继承和多态,以及正则表达式的使用。但设计较为稚嫩,引脚对象过度设计。
第5次:统一了引脚模型,新增复杂器件,锻炼了算法实现能力。但代码逐渐臃肿,解析与计算耦合严重。
第6次:引入子电路,提升了抽象层次,同时强制进行错误处理,让我意识到健壮性的重要性。但也暴露了前期设计不足导致的重构成本。
个人成长:
编码习惯:从“写完了事”到“先设计再编码”,开始画类图、考虑扩展点。
调试能力:学会使用断言和日志,快速定位问题(如引脚映射错误)。
面向对象原则:深刻体会到“开闭原则”的价值——对扩展开放,对修改关闭。例如工厂模式可使新增元件无需改动已有代码。
全局思维:子电路实现让我意识到,系统设计时要考虑命名空间、作用域和生命周期,否则后期寸步难行。
仍需深入学习的方向:
设计模式:工厂、访问者、观察者模式在该场景中都有用武之地。
编译原理:如何处理嵌套子电路?如何实现作用域和符号表?
并发编程:大规模电路如何并行模拟?
形式化验证:如何证明电路逻辑正确性,而不是靠迭代猜测。
总之,这次作业集是一次极佳的“实战演练”,让我从零开始搭建了一个迷你EDA工具。虽然代码仍有诸多不足,但正是这些不足指引了未来改进的方向。我相信,只要持续重构和学习,我的软件设计能力定能更上一层楼。
