
提交完最后一个工程文件我靠在椅背上长出一口气。屏幕上那串仿真波形最后一次滚动结束时钟沿、状态跳转、输出信号全部对得上预期。第十二届“数字逻辑与部件设计”竞赛初赛就这样结束了。回头看看这两周备赛的日子从最初翻课本补卡诺图到后来在Verilog代码里反复调状态机再到最后抱着开发板在实验室跟数码管较劲整个过程比我想象中要“磨人”得多。说实话这门课在上理论的时候我从来没觉得自己能把一个数字系统从零搭出来。但初赛结束那一刻我必须承认这种“被题目逼着把知识串起来”的感觉恰恰是课堂上学不到的。这篇文章不打算写什么高大上的总结就当一个普通参赛者把初赛阶段踩过的坑、看过的资料、敲过的代码、调过的板子原原本本摆出来。如果你正在准备下一届比赛或者刚学完数字逻辑想找个项目练手这篇复盘应该能帮你少走不少弯路。1. 初赛落幕十二届的赛制变化与我们的定位1.1 这届初赛考的是什么“数字逻辑与部件设计”说白了就是把数字电路的理论知识落到实际部件上。比赛初赛的惯例是给你若干道设计题要求在限定时间内写代码、跑仿真、现场上板验证最后提交设计和报告。今年的赛制跟前几届相比有个明显变化——基础题和进阶题分开计分。基础题是组合逻辑和简单时序逻辑的混合考察基本功全部做对才能拿到晋级资格进阶题则是一道相对完整的部件设计比如自动售货机控制器、电子密码锁、交通灯状态机之类需要综合运用状态机设计和模块化思路。这意味着如果你只会做“看起来能跑”的实验很难在基础题上拿满分更别谈进阶题。我们团队的定位从一开始就很明确不追炫技稳过初赛。所以备赛方向始终围绕“基础题的零失误”和“进阶题的完整实现”展开没有花太多时间在研究冷门技巧上。1.2 三个人怎么分工才高效参赛允许三人一组但这里我要多说一句组队不是凑人头分工不合理的话效率反而比单人赛还低。我们组的分工是这样的角色负责内容关键产出我核心逻辑设计、Verilog编码、上板调试可综合代码、仿真波形队友A仿真验证、测试向量设计、波形核对测试报告、覆盖率分析队友B设计文档、PPT、功能演示脚本最终提交报告这个分工的好处是每个人都能发挥长处而且没有明显的瓶颈环节。队友A特别擅长“挑毛病”他写的testbench总能找到我代码里没考虑到的边角情况这一点在初赛中帮了大忙因为仿真验证的充分程度直接决定你上板的成功率。队友B是文档高手她能把我们调试中的零散记录整理成一份逻辑清晰的设计报告这一点到后期会展开讲。但需要注意的是分工不等于各干各的。我们每天至少碰一次头互相review代码和文档。尤其是核心逻辑设计其他两个人必须懂否则一旦我临时有事整个项目就卡壳了。这种“人人理解全貌”的做法在我们最后的冲刺阶段体现出了极大价值。1.3 备赛时间线怎么安排两周备赛时间我们分了四个阶段第1到3天复习数字逻辑核心知识把课本上的触发器、计数器、状态图重新过一遍同时熟悉开发环境和开发板操作。第4到7天专项练习组合逻辑题、时序逻辑题、状态机题各做几道经典的重点积累常见错误。第8到11天模拟初赛每天下午抽三个小时完整做一套往届题严格计时题目做完还要整理报告素材。第12到14天查漏补缺针对模拟中暴露的问题集中强化比如时序约束、跨时钟域处理等。这个安排看起来平平无奇但执行起来最大的难点是每天都坚持完整计时做题。模拟初赛那几天我们三个人都有一瞬间想放弃的冲动因为连续三小时盯着波形图确实很消耗精力。不过最后的结果也证明模拟真的比临时抱佛脚有用得多。2. 从门电路到有限状态机备赛期最容易踩坑的知识点2.1 组合逻辑的“隐形杀手”竞争冒险很多人复习组合逻辑时觉得就是根据真值表写表达式、化简、画电路图考试能过就行。但到了竞赛里你会发现竞争冒险是上板调试时最隐蔽的问题之一。什么是竞争冒险简单说就是信号从输入到输出经过不同路径的延迟不同导致某个瞬间输出出现不该有的毛刺。用生活化的话讲就像做菜时两道菜同时需要同一个锅但一个先到、一个后到出锅的节奏就对不上了。在FPGA/开发板实验中这种毛刺最容易出现在计数器进位、译码器输出和多路选择器切换的瞬间。举个例子两个信号同时变化时组合逻辑输出可能短暂地出现一个错误电平这个电平如果恰好被时序逻辑采样到就会引发一连串错误。我的建议是凡是涉及到时序逻辑采样的组合逻辑输出都必须考虑是否需要寄存一拍。初赛里有一道基础题就专门设计了这种陷阱——直接用组合逻辑的进位信号去触发下一个计数器结果仿真时看起来完全正常上板后却偶尔乱跳。后来我们在进位输出后面加了一级D触发器打拍才彻底解决。这就是“仿真过了但板子不听话”的典型案例。2.2 时序逻辑建立时间与保持时间是实打实的规矩时序逻辑的核心是触发器而触发器的核心参数就是建立时间和保持时间。好多同学学到这里只是背定义不理解它到底影响什么。建立时间的意思是数据在时钟有效沿到来之前必须稳定一段时间保持时间则要求在时钟沿之后数据还得再稳一段时间。这就好比乘火车你必须在发车铃响之前站到站台上建立时间发车之后也不能立刻扒门跳车保持时间。如果违反这两个时间约束触发器输出的状态就不可预知也就是所谓的亚稳态。在写Verilog的时候建立时间和保持时间主要受代码风格的影响。好多人习惯在always块里同时写组合逻辑和时序逻辑或者不加考虑地使用阻塞赋值和非阻塞赋值混用极容易导致时序违规。备赛时我们把常见的时序问题整理成了一份检查清单常见错误后果正确做法always块中阻塞赋值和非阻塞赋值混用仿真与综合结果不一致时序逻辑统一用非阻塞赋值组合逻辑用阻塞赋值用组合逻辑输出直接跨时钟域亚稳态加两级同步器打两拍异步复位信号不受约束复位释放时可能出现亚稳态异步复位同步释放时钟没有统一管理到处分频时钟偏斜、毛刺优先使用时钟使能而不是衍生时钟这份清单在初赛阶段救了我们好几次尤其是“时钟使能代替分频”这一条后面会专门说。2.3 有限状态机先画状态图再写代码进阶题几乎离不开有限状态机但很多初学者拿到题目就直接写代码写到一半发现状态少定义了一个又回头改改完状态转移条件又对不上。这种“代码往左、设计往右”的做法效率极低。我个人的习惯是拿到状态机题目第一件事永远是画状态图。状态怎么命名、从哪个状态跳到哪个状态、跳转条件是什么、每个状态的输出是什么全部在纸上画清楚再推到代码。初赛进阶题我们选的是一个自动售货机控制器状态数量不算多但如果直接动手写代码极容易漏掉“投币金额超过商品价格需要找零”的分支。就是因为先在纸上画了完整的状态图我们才发现这个分支需要专门定义状态和输出而不是靠组合逻辑硬凑。另外还要注意状态机的编码方式。初赛这种规模的状态机建议用独热码one-hot虽然多占寄存器但输出逻辑简单、速度快而且综合后不容易出错。二进制编码省资源但状态跳转逻辑复杂调试时看波形会头疼。这个选择在进阶题拿分上很关键。3. 初赛两道核心设计题的完整解题过程3.1 基础题多功能控制器中的计数器设计今年的基础题其中一道是给一个多功能控制器的子模块要求实现一个可预置数的模60计数器。所谓“模60”就是我们熟悉的秒/分计数从0数到59然后回0并且产生一个进位信号。这道题看起来不难但要做到满分需要同时满足几个条件支持异步复位、支持同步预置数、支持计数使能、输出BCD码8421码并且进位信号要足够干净。我们最终的核心代码如下module counter_60 ( input wire clk, input wire rst_n, input wire en, input wire load, input wire [7:0] data_in, // BCD: {tens[3:0], ones[3:0]} output reg [7:0] cnt_out, // BCD output output reg carry ); // 分离十位和个位 reg [3:0] tens; reg [3:0] ones; always (posedge clk or negedge rst_n) begin if (!rst_n) begin tens 4d0; ones 4d0; carry 1b0; end else if (load) begin tens data_in[7:4]; ones data_in[3:0]; carry 1b0; end else if (en) begin if (ones 4d9) begin ones 4d0; if (tens 4d5) begin tens 4d0; carry 1b1; // 59 - 00, 产生进位 end else begin tens tens 1b1; carry 1b0; end end else begin ones ones 1b1; carry 1b0; end end else begin carry 1b0; // 保持状态时进位拉低 end end assign cnt_out {tens, ones}; endmodule这段代码有几个值得注意的细节进位信号只在59到00跳变的那一个时钟周期拉高其他地方都是低的。这样下游模块可以直接把它作为使能信号不用再做脉冲检边。load优先级大于en这符合“预置数操作优先于计数”的直觉也避免了同时操作时的冲突。输出采用BCD码而不是二进制是因为后面要直接驱动数码管显示BCD码省去了进制转换模块节省资源也减少出错可能性。仿真时我们不仅测了正常的0到59循环还专门测了“计数到一半预置数再继续”的情况因为这是评委最容易挖坑的地方。实测下来load信号拉高后下一个时钟上升沿计数器就被正确装载为设定值功能完全符合题目要求。3.2 进阶题自动售货机控制器的状态机设计进阶题我们抽到的是自动售货机控制器。需求简化为商品单价2元投币口支持1元硬币和5角硬币当投入金额达到2元时出货超过2元要自动找零找零只能找回5角硬币即如果有1元多余就找两个5角但实际简化版设定了只能找5角所以最大投入序列被限制超出部分退回。这题的核心就是设计一个摩尔型状态机状态用累计金额表示0元、0.5元、1元、1.5元、2元、找零状态。我在这里想强调一个容易出错的地方状态机的输出到底应该用什么方式给。自动售货机的出货信号和找零信号应该是脉冲还是电平如果按直觉在某个状态下把“出货”信号拉高那么只要状态机停留在这个状态出货信号就一直有效。这在实际系统中是不合理的——供应商店员的直觉是出货信号应该是一个短暂的脉冲而不是一直亮着的灯。所以我们在设计时把“出货”和“找零”作为状态机转移过程中产生的脉冲信号在进入对应状态的同一拍拉高只在那一拍有效。下面是状态转移的核心片段localparam S0 3d0; // 累计0元 localparam S50 3d1; // 累计0.5元 localparam S100 3d2; // 累计1元 localparam S150 3d3; // 累计1.5元 localparam S200 3d4; // 累计2元出货 localparam S250 3d5; // 累计2.5元出货并找零5角 reg [2:0] state, next_state; reg out_goods; reg out_change; always (posedge clk or negedge rst_n) begin if (!rst_n) state S0; else state next_state; end always (*) begin next_state state; case (state) S0: next_state coin_05 ? S50 : (coin_1 ? S100 : S0); S50: next_state coin_05 ? S100 : (coin_1 ? S150 : S50); S100: next_state coin_05 ? S150 : (coin_1 ? S200 : S100); S150: next_state coin_05 ? S200 : (coin_1 ? S250 : S150); S200: next_state S0; S250: next_state S0; default: next_state S0; endcase end always (*) begin out_goods 1b0; out_change 1b0; case (state) S200, S250: out_goods 1b1; S250: out_change 1b1; default: begin out_goods 1b0; out_change 1b0; end endcase end这里我必须强调两个关键点组合逻辑的next_state和时序逻辑的state分离。很多新手把状态转移写在一个always块里同时用阻塞赋值做组合判断很容易出现“仿真结果看起来对但上板后时序一团糟”的问题。把组合逻辑和时序逻辑分开写可读性更强综合质量也更稳定。default分支必须覆盖所有未定义状态。FPGA的综合工具不会帮你自动给所有未定义状态赋初值如果状态机跑到一个未定义状态里那就“回不来了”。我们专门在仿真里把state强制赋成3b111结果确实能回到S0这就验证了default分支的必要性。这道题的仿真验证我们做了很久因为硬币输入的组合太多了。队友A专门写了一个自动测例随机产生100组硬币序列检查每次达到S200或S250时goods信号是否拉高、金额累计是否正确。跑完这100组case我们才对状态机的正确性有了信心。4. 仿真通过不等于板上跑通上板调试的真实教训4.1 按键抖动不只影响单次触发上板调试最让人抓狂的问题之一就是按键。开发板上的机械按键按下和释放的瞬间会因为机械触点弹跳产生抖动这个抖动持续时间通常有10到20毫秒。如果不做处理本来想“按一次”实际却会被识别成“按了好几次”。我们这次就栽过一回。在手动调试数字钟模块时按一次“加分钟”按钮分钟数直接跳了两三下。当时第一反应是代码的逻辑有问题排查了半天才发现是按键抖动。解决按键抖动常见的方法是“延时消抖”检测到按键按下后等20毫秒再采样一次如果电平还稳定就认为是有效按下。我们在工程里写了一个简单的消抖模块reg [19:0] cnt; reg key_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_reg 1b1; // 假设按键低电平有效 cnt 20d0; end else if (key_in ! key_reg) begin cnt 20d0; // 电平变化重新计时 key_reg key_in; end else if (cnt[19]) begin key_reg key_in; // 稳定20ms采样 end else begin cnt cnt 1b1; end end这个模块的思路是只有当按键电平持续稳定大约20毫秒后才更新内部寄存器的值。实际测试下来无论是快速按还是慢速按都不会再出现重复触发的问题。4.2 数码管动态扫描的刷新频率陷阱我们的显示模块用的是四位数码管动态扫描。原理很简单轮流点亮每一位利用人眼的视觉暂留效应看起来四个数字是同时亮的。但这里有个频率设置的学问。扫描频率太低数字会闪烁太高每个位的点亮时间太短亮度会不够。常见的经验值是刷新频率在60Hz到100Hz之间。我们最初设置的是每隔1毫秒切换一位也就是四个位循环一遍需要4毫秒刷新率250Hz。看起来250Hz应该很稳才对但实际上板后发现亮度明显不均匀——高位暗、低位亮。排查原因是因为我们在切换位选信号的瞬间段选信号还没有稳定导致每位第一个瞬间把前一位的数据带了出来产生“拖影”看起来就是亮度不均。解决办法是先把段选数据准备好再切换位选信号。简单说就是先传数据、再换显示位。这个过程中的细节看起来很微小但上板实测效果差别很大。4.3 分频产生的毛刺时钟使能才是正解很多同学在做分频时钟的时候习惯直接用计数器输出作为新时钟信号。这在仿真里没有任何问题因为仿真里的信号是理想的。但真实的FPGA开发板上任何逻辑产生的信号都带有延迟而用组合逻辑产生的“分频时钟”很容易出现毛刺。我之前在写一个分频模块时是这样写的// 一段有问题风格的分频代码 reg clk_div; always (posedge clk) begin if (cnt N-1) begin cnt 0; clk_div ~clk_div; // 用寄存器输出做分频时钟 end ... end这段代码在仿真时功能正确但在实际板子上的时序分析里会产生一个由寄存器输出驱动的次级时钟网络而这个网络的时钟偏移、毛刺都无法保证。尤其在更高的时钟频率下极容易让下游电路采样出错。更好的做法是使用时钟使能clock enable始终保持单个全局时钟但通过使能信号控制逻辑是否工作。比如每秒产生一个使能脉冲在这个脉冲有效的时钟周期里才进行计数或状态更新其余时刻保持状态不变。这样既实现了“分频”的效果又避免了引入次级时钟带来的种种问题。这个改进让我们的初赛工程稳定性提升了一个档次。4.4 一个完整的排查链路状态机为什么不跳转最后分享一个在实操中遇到的bug排查过程这种排查思维我觉得比具体代码更值得记录。故障现象自动售货机模块上板后投币没有任何反应状态机停留在初始状态。排查过程首先怀疑按键输入有问题。在测试代码里把按键信号直接接到LED观察结果按下去LED能正常变化说明按键硬件的输入被正确读到了。因此按键模块没问题。接着怀疑消抖模块把有效电平滤掉了。单独测试消抖模块的仿真确实能正常输出稳定的电平。排除消抖模块。然后怀疑状态机的时钟有问题。用在线逻辑分析仪抓取状态机的时钟信号和复位信号发现复位信号并不是稳定电平而是时不时出现一个短脉冲。到这里问题范围缩小到了复位电路。进一步检查发现复位信号接在开发板的一个全局复位按键上而这个按键本身也有抖动而且我们用的是异步复位也就是说复位信号的每一个毛刺都会立刻作用于整个状态机。结果状态机刚被拉出复位态又被毛刺打回复位态看起来就像“死”在了初始状态。解决方案把原来的异步复位改成“异步复位同步释放”电路同时对复位按键做消抖。这个改动之后状态机再也没出现过“卡死”的现象。复盘下来这个bug其实不是某个单一模块写错了而是几个模块组合在一起产生的“共振”。排查的时候如果一上来就怀疑状态机代码本身很容易陷入死循环。先看输入再看时钟再看复位最后看状态转移这个顺序到哪都适用。5. 设计报告怎么写评委才愿意给分5.1 报告不只是“记录”更是“说服”初赛的最终成绩由代码功能分和设计报告分组成。很多人觉得报告就是代码加波形截图随便凑一凑就行其实这个观念大错特错。评委在短时间内要看很多份报告他怎么判断你做的这个东西靠不靠谱不是靠逐行读代码而是靠报告里的表述是否清晰、结构是否完整、证据链是否闭合。说白了报告的目的是说服评委你不仅做出了功能而且你真的理解你做出的东西。我们初赛报告最终采用了这样的结构需求分析把题目要求逐条拆解转换成具体的技术指标。总体设计画出模块划分图说明每个模块的职责和接口。模块详设每个核心模块给出设计思路、状态图或关键代码、仿真波形。仿真验证列出测试用例标注每个用例覆盖的功能点。上板调试记录遇到的问题和解决过程展示最终功能演示。总结与反思讲清楚哪些地方做得好哪些地方还有改进空间。这里我特别想说一下“上板调试”这一节。很多同学报告里只写结果不写过程评委其实很难判断你是不是真的自己做的。反倒是你如实记录一个bug从出现到解决的全过程比如我们前面写的复位毛刺排查就显得特别真实可信。评委想看到的不是一个“完美无缺”的项目而是一个“会思考、会发现问题、会解决问题”的参赛者。5.2 波形截图不是乱截要“带注释”仿真波形截图是设计报告里最有说服力的证据但也是很多人处理得最粗糙的部分。一张好的波形图应该具备这几个要素完整的时间轴从波形开始到结束能看出一个完整的操作序列。关键信号标注用箭头或方框把重要的状态跳转、输出变化标出来。对应操作说明在图下面用文字描述“这一段是在测试预置数功能可以看到load信号拉高后计数器在下一个时钟沿被装载为设定值”。反观很多报告里的波形图随便截一段信号名不标时间轴不给评委根本看不出你测的是什么。这种图放了等于没放。另外如果仿真工具支持建议把信号按功能分组排列比如一组是时钟和复位一组是控制信号一组是数据输出一组是状态编码。这样看图的人能一眼抓住重点而不是在一堆信号里找想看的那根线。5.3 综合报告与资源占用也要给出来数字逻辑项目不同于写单片机程序它最终要落到硬件电路上。所以在报告里给出综合后的资源占用和时序分析结果是专业性的一个重要体现。在工程里跑一下综合把项目占用了多少个逻辑单元、多少个寄存器、多少I/O引脚报告中写清楚。同时如果工具能生成时序分析报告把关键路径的最高工作频率截图放上去再写一句“经过优化本项目最高工作频率为xxMHz满足题目要求”这就很直观地向评委展示了你的工程化能力。队友B刚开始整理这部分时有点懵不知道这些数据从哪看。实际上Xilinx和AlteraIntel的IDE工程里都有现成的综合报告只要熟悉一下界面就能找到。不用会做多深的时序约束但至少要知道资源占用和关键路径在哪里看这个技能放到以后的课程设计或毕业设计里一样用得上。5.4 评委最容易挑刺的五个地方以我们往届参赛和这次自己答辩的经验评委最常盯着问的问题往往集中在几个固定的点上提前准备能够省掉很多麻烦评委常问/常检查的点为什么容易被挑刺应对方法状态机的未定义状态代码没有default分支明确写出default跳转复位策略使用了异步复位但没有同步释放说明复位设计理由时钟方案使用了多个衍生时钟改为时钟使能统一时钟输出信号是否打拍组合逻辑输出直接接外部引脚输出寄存器化进位/脉冲信号宽度信号持续多拍导致下游误触发明确脉冲信号的单周期特性这几个问题在答辩环节几乎必问。我们准备报告的时候专门针对这几个点写了“设计说明”提前把答案准备好。结果答辩时评委真的问了其中一个——时钟方案——我们直接把报告里的时钟使能设计思路讲了一遍评委点头那一瞬间我之前熬夜调板子的疲惫都值了。6. 从初赛到复赛初赛暴露的短板与强化路线6.1 初赛暴露的几个问题初赛虽然顺利结束但复盘下来我们组的问题其实不少只是这些问题没有在赛题范围内被彻底引爆而已。第一对时序约束的理解停留在“能用就行”。我们把工程综合到板子上能跑就认为万事大吉了。但评委问“你的设计最高频率是多少”的时候我们并没有跑过完整的时序分析只能支支吾吾说一个大概值。这一点在复赛如果涉及到更高性能的设计会成为明显的短板。第二跨时钟域处理只停留在概念层面。初赛的单一时钟域尚能应付但复赛若是涉及UART接口、异步FIFO或者多个不同频率的模块配合跨时钟域就是绕不过去的坎。我们目前只掌握了“打两拍”这种最基本的同步器思路对于更复杂的场景比如格雷码跨时钟域传递还没有系统学习。第三上板调试效率偏低。我们在实验室花了大把时间用LED和数码管“裸眼”观察信号却不太会用逻辑分析仪这些片上调试工具。这次是因为运气好bug不算太刁钻但复赛题目复杂度上升后光靠肉眼观察大概率会让人崩溃。6.2 复赛可能涉及的方向根据往届经验“数字逻辑与部件设计”的复赛通常不会只考单一模块而是倾向于多模块协同的小型系统设计。我判断有几个方向值得提前准备串口通信UART收发是各种通信接口里最容易上手的但实现起来需要准确处理波特率分频、起始位、数据位、停止位中间任何一个时序错误都会导致数据乱码。PWM输出实验利用计数器产生占空比可调的PWM波形可以用来做呼吸灯、电机调速等扩展实验这个方向对理解计数器和比较器结合的思路很有帮助。简单图像/按键系统比如VGA显示一个简单图形或者键盘扫描加显示这类题目更讲究模块之间如何协作对总线通信和时序配合的要求更高。6.3 我的强化路线与练手项目接下来的强化计划我给自己定了一个“三步走”第一步把北邮、华科等高校往年的数字逻辑课程设计题目翻出来挑5个完整的系统题限定时间内做完。这一步是为了扩大题目视野避免复赛题目超出自己的“题库”。第二步买一块带串口和按键的开发板自己写一个UART收发实验电脑发一个字节FPGA接收后加一来回复。这个练手项目虽然小但能逼我把波特率分频、同步器、状态机全部用上。第三步学习使用逻辑分析仪进行片上调试。能够实时观察FPGA内部信号会让排查问题的效率提升至少一倍。另外我还准备在复赛前把Xilinx Vivado或Intel Quartus的时序约束入门内容过一遍至少学会怎么添加主时钟约束怎么看时序报告里的建立时间和保持时间余量。哪怕最终只是照葫芦画瓢也能让自己的工程“看着更专业”。写到这初赛复盘的正文差不多就结束了。如果硬要说一句“心得”那就是数字逻辑这种课光靠刷题和背概念真的不够必须亲手写完一整套代码、跑过仿真、上过板子、调过bug那些知识才真正长在身上。不管复赛最后能走到哪一步这几周的时间对我来说已经值回票价了。比赛结束后我准备再花一个周末把我们初赛里所有的代码重写一版去掉那些调试时留下的临时补丁当成一个干净整洁的正式项目存下来。这也是我能给准备参赛的同学最实在的建议别怕代码写得不完美大胆写大胆烧板子。所有看起来高大上的数字系统设计都是从第一行最简单的assign和always开始的。