ARTICLE DETAIL

资讯详情

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

Verilog数字钟设计实战:从计数器分频到按键消抖的FPGA入门

Verilog数字钟设计实战:从计数器分频到按键消抖的FPGA入门 带我入行FPGA的老师说过一句话成也基本功、败也基本功。做了几年的数字逻辑设计接了各种复杂的外设接口和控制算法项目之后我越发认同这句话。如果你刚接触Verilog正愁不知道从哪个项目下手那“Verilog数字钟”就是那种绕不开的经典它不负责做一个多好看的产品它负责把你往后几年写逻辑要用到的那几根主心骨——计数器、分频、扫描显示、按键消抖、模块例化——一次性串起来。这篇文章我把自己当时做的方案、代码、仿真流程和踩过的坑全部整理出来给准备在FPGA开发板上跑一个数字钟的初学者做一份尽可能完整的参照。为什么我推荐数字钟当第一个完整工程因为它迷迷糊糊地做一遍你就把“数字逻辑设计的通用打底思路”给通了。后面不管你去做UART通信、I2C读EEPROM、异步FIFO还是DDR3读写控制底层逻辑说到底还是这些对信号做分频、对状态做计数、对多路数据做选择与仲裁、在合适的时序点上把数据“卡”出去。数字钟把这几件事压缩进了一个反复可见的任务里1Hz时钟怎么来、60秒怎么进到分钟、24小时怎么清零、数码管怎么轮流点亮、按键怎么消除抖动每一步都有明确的波形可验证。这篇文章我会用“讲透原理 给能抄的代码 记录疑难排查”的方式来写内容尽量照顾到第一次接触FPGA的新手也保留一些设计复盘时的细节思考。1. 项目概述与整体方案选型1.1 数字钟项目到底练了什么数字钟这个项目表面上就是一个计时工具但它背后几乎覆盖了FPGA入门阶段最核心的组合逻辑和时序逻辑基本功。这里我直接按知识点拆开来说你对照自己目前的学习进度就知道它为什么值钱。第一是时钟分频。开发板上的晶振频率通常是50MHz或者100MHz数字钟需要一个秒脉冲那第一步就是把高速时钟“降”下来。这一环节涉及计数器、比较器、复位初始化也是后面所有时序逻辑的基础。第二是进制计数器。秒和分是六十进制小时是二十四进制这背后是模数可变计数器的写法包括什么时候产生进位、什么时候清零。第三是动态扫描显示。数码管通常要轮流点亮每位数码管点亮一小段时间利用视觉暂留让整体看起来是全亮的这又涉及位选与段选的时序配合。第四是按键消抖。机械按键按下和释放的瞬间电平不稳定必须做去抖处理否则校时的时候分钟数字会乱跳。第五是状态切换。正常计时、调整分钟、调整小时这三个模式需要一个类似状态机的东西来管理。这些点单独拆开来看着都不难但放在同一个工程里模块之间互相有握手、有使能、有进位传递细节一下子变得具体起来。很多人在学校都能背“阻塞赋值非阻塞赋值”的区别但只有在数字钟这种项目里才会真正意识到什么时候用时钟使能、什么时候用脉冲、什么时候打一拍写错了波形就立刻不对劲。这是单纯刷语法题换不来的经验。1.2 方案选型分频计数器还是PLL时钟IP在真正动笔之前我先聊一下方案选型因为这里有个很容易让新手迷糊的分岔路生成秒脉冲到底是用计数器自己对晶振时钟分频还是直接调用PLL或者MMCM时钟IP把50MHz倍频或分频成一个合适的时钟我的建议是入门阶段用计数器分频而且只做时钟使能脉冲不要直接生成一个占空比50%的1Hz时钟。理由是PLL IP固然方便但很多低端开发板或者在线仿真环境不一定支持而且PLL主要是用来做高频率和低抖动的时钟为1Hz去调用一个PLL属于大炮打蚊子。更重要的是时序逻辑里如果直接把分频后的信号当“真实时钟”去驱动计数器会形成多个时钟域。FPGA里最怕的就是时钟树复杂一个项目里到处都是“CLK_1Hz”“CLK_1kHz”后端的时序约束会非常麻烦跑上板的时候也很容易莫名其妙出问题。所以正确做法是保留50MHz作为全局主时钟用计数器产生一个“秒使能”脉冲这个脉冲宽度只有一个周期频率是1Hz。计数器模块的时钟仍然是50MHz只在秒使能拉高的时候计数一次。这本身上就是跨时钟域处理的一种雏形理解了它后面你会更容易弄懂异步FIFO和跨时钟域同步这些进阶话题。1.3 模块划分与整体数据流我把整个数字钟拆成五个模块每个模块职责单一方便单独仿真和调试。顶层模块只负责例化和连线内部不写任何逻辑。用一句大白话说顶层就是“搭积木的地板”下面五块积木分别是时钟分频模块、秒分时计数模块、按键消抖模块、校时状态控制模块、数码管动态扫描模块。数据流向是这样的50MHz晶振时钟进入分频模块产生秒使能脉冲这个脉冲送到计数器模块秒计数到60就产生分进位分计数到60就产生时进位时计数到24整体清零计数器的六个值秒十位、秒个位、分十位、分个位、时十位、时个位分别送到扫描显示模块由扫描模块动态点亮对应的数码管按键模块用来检测用户的校时按键消抖后输出一个单脉冲触发校时状态机在“正常模式、调节分钟、调节小时”之间切换。这样的好处是任何一个环节出了问题你可以单独写一个testbench去验证不需要每次都把整个工程跑一遍。实际开发中这也是最重要的素养模块边界清晰才能快速定位故障。如果你一上来就写一个大always块把所有逻辑怼在一起后续调试的时候眼睛真的会看花。2. 分频与计时核心模块实现2.1 秒使能脉冲的产生原理与代码先从最核心的分频模块开始。假设开发板晶振是50MHz也就是一秒钟会有50,000,000个时钟上升沿。要从里面挑出“每秒钟只有一个高脉冲”的信号就需要一个计数器从0数到49,999,999当计数到最大值时把秒使能信号拉高一个周期同时计数器归零。为什么计数上限不是50,000,000而是49,999,999因为从0开始计数0到49,999,999一共是50,000,000个数。这个“多算一个”的错觉写代码的时候非常容易犯属于数字钟项目第一坑。类似于生活中数楼层底楼算0层还是1层搞错了整栋楼的逻辑都会歪掉。module clk_div_en #( parameter DIV_N 50_000_000 )( input wire clk, input wire rst_n, output reg en_1hz ); // 计数最大值需要 log2(50M) 位$clog2自动向上取整 reg [$clog2(DIV_N)-1:0] cnt; localparam CNT_MAX DIV_N - 1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt d0; en_1hz 1b0; end else if (cnt CNT_MAX) begin cnt d0; en_1hz 1b1; end else begin cnt cnt 1b1; en_1hz 1b0; end end endmodule这段代码里我刻意用了$clog2。很多刚学Verilog的人对这个系统函数不熟其实它就是帮你算需要多少位宽的计数器。比如50,000,000$clog2(50_000_000)的结果是26表示26位二进制数能覆盖从0到67,108,863的范围足够用了。如果手写固定位宽比如reg [25:0] cnt也行但写死位宽最大的问题是当DIV_N改小或者改大的时候还得专门去算一次位宽。用$clog2就一劳永逸。还有个小细节值得注意我把秒使能信号拉高的同时计数器直接归零。这样en_1hz在同一个时钟周期里既被拉高计数器也清零。然后下一个时钟周期en_1hz拉低。所以en_1hz是一个宽一个周期、频率为1Hz的脉冲信号。这个脉冲不是时钟它只是为了告诉计数模块“现在该进一秒了”。这个思路要特别记住它能帮你避免在项目里到处制造“虚拟时钟”。2.2 六十进制秒和分计数器的两种写法秒和分的计数逻辑本质上是同一个问题六十进制。但在FPGA里实现六十进制有两种常见的编码方式。第一种是用一个6位二进制计数器从0数到59到59后下一个使能回0同时输出一个进位脉冲。显示的时候要把0到59拆成十位和个位就得做除法和取模。虽然在FPGA中实现0到59范围的除法和取模综合器能优化得不错但一旦遇到更复杂的取值范围除法和取模的组合逻辑面积会明显增大。第二种方式是把十位和个位分开成两个独立计数器。个位是十进制从0到9十位是六进制从0到5。个位每十次清零一次同时给十位进一位十位到5后下一拍和个位一起清零同时给分计数器进一位。这样显示模块拿到的本来就是十进制数不需要再做除法和取模运算代码也更容易读懂。我强烈推荐第二种方式这也是绝大多数真实设计里“分频式计数器”的用法。秒和分模块可以直接复用同一个参数化计数器只是参数不同秒个位和分个位都计0到9秒十位和分十位都计0到5。Verilog里模块是可以例化很多份的我们不需要把六十进制逻辑复制粘贴四遍。module bcd_counter #( parameter MOD 10 )( input wire clk, input wire rst_n, input wire en, output reg [3:0] q, output reg carry ); localparam WIDTH $clog2(MOD); always (posedge clk or negedge rst_n) begin if (!rst_n) begin q d0; carry 1b0; end else if (en) begin if (q MOD - 1) begin q d0; carry 1b1; end else begin q q 1b1; carry 1b0; end end else begin carry 1b0; end end endmodule这里又出现了一个参数MOD例化的时候传人10或者6就行了。需要注意的是carry不能只在高电平保持它必须是一个单时钟周期的脉冲。如果拉高后不自动拉低后面的分十位、分个位就会被这个“持续的高电平”反复触发整个时钟走秒速度就会乱套。所以在else分支里必须让carry归零防止计数期间carry一直等于上一次的值。如果是初学者还有一个容易犯的问题q MOD - 1这个地方到底要不要再加一个en的条件。仔细看代码所有计数的行为都在en为高的分支里所以比较条件是安全的。当计数器到达MOD-1且en有效时下一拍清零并拉高carry。这个时序画出来是一条非常标准的流水线使能脉冲到达、计数器加一、到顶清零、进位输出一环扣一环。2.3 二十四进制小时计数器的特殊处理小时的部分和秒分不太一样它要处理的是24小时翻零点也就是从23时59分59秒走一夜之后直接跳到00时00分00秒。如果是“分个位分十位”那种拆开计数的方式时十位和时个位之间有非对称的限制当十位是2的时候个位只能走到3到4就必须整体清零返回到00。写起来要多加分支判断其实也不复杂但不如用小时整体计数来得直观。我用的方法是小时用模24的计数器直接计数数值范围是0到23。显示的时候把这个值拆成十位和个位。因为在FPGA里实现一个范围受限的“除以10取余”是非常简单的组合逻辑综合器能直接优化成几张查找表。放在这个过程里完全不用担心资源问题。module hour_counter #( parameter MOD 24 )( input wire clk, input wire rst_n, input wire en, output reg [4:0] q, output reg carry ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin q d0; carry 1b0; end else if (en) begin if (q MOD - 1) begin q d0; carry 1b1; end else begin q q 1b1; carry 1b0; end end else begin carry 1b0; end end endmodule小时只有0到23范围很小5位位宽就够。拆成两位显示的时候用组合逻辑直接算十位和个位或者建一个小查找表都行。我当时在FPGA上实际看到00:00:00跳变的时候还是很有成就感的。不过在这里要特别提醒一下如果你用的是开发板上已有的时钟芯片或者GPS秒脉冲可能还要做异步信号的同步处理。但自己设计的计数器之间不存在这个问题因为它的使能信号是同步的已经由同一个50MHz时钟管着了这在时序上非常安全。2.4 六个计数值如何连成串接下来要把秒的个位、十位、分的个位、十位、小时的计数器串成一个完整的计时链条。这里的关键是“嵌套使能”。秒个位的使能是秒使能脉冲秒十位的使能是秒个位的进位分个位的使能是秒十位的进位以此类推。本质上就像一个齿轮传动系统小轮转十圈带动大轮走一格。在顶层模块里应该是这样的连接关系wire en_1hz; wire carry_s_l, carry_s_h, carry_m_l, carry_m_h, carry_h; bcd_counter #(.MOD(10)) u_sec_low( .clk(clk), .rst_n(rst_n), .en(en_1hz), .q(sec_low), .carry(carry_s_l) ); bcd_counter #(.MOD(6)) u_sec_high( .clk(clk), .rst_n(rst_n), .en(carry_s_l), .q(sec_high), .carry(carry_s_h) ); bcd_counter #(.MOD(10)) u_min_low( .clk(clk), .rst_n(rst_n), .en(carry_s_h), .q(min_low), .carry(carry_m_l) ); bcd_counter #(.MOD(6)) u_min_high( .clk(clk), .rst_n(rst_n), .en(carry_m_l), .q(min_high), .carry(carry_m_h) ); hour_counter #(.MOD(24)) u_hour( .clk(clk), .rst_n(rst_n), .en(carry_m_h), .q(hour), .carry(carry_h) );这段例化代码里有一个细节你可能会注意分钟和小时的进位最终其实并不参与其他计数但还是要保留一个carry端口这是为了后续扩展闹钟功能或者整点报时用的。有时候做个功能的时候要多留一手哪怕暂时不用给端口和信号命名也都规范清晰后面扩展根本不用改架构。用这种方式把计数器串起来硬件结构非常清晰任何一个进位信号出问题都可以用仿真直接看到是哪个模块卡住了。我印象里这种纯逻辑的级联方式跑在50MHz下进位链的性能完全没有瓶颈。如果你有兴趣可以在示波器或调试软件里观察一下秒个位的进位和秒十位计数沿之间的时序关系你会看到一个干净利落的脉冲链。3. 按键消抖与校时逻辑实现3.1 为什么机械按键必须消抖很多第一次做数字钟的同学都会在按键校时这个功能上被折磨得够呛。明明按下一次加一实际却跳了好几秒。原因是机械按键的内部是金属弹片按下和释放的瞬间弹片会发生连续几次接触电平在几十毫秒内反复跳变如果不处理控制器会把一次按压当成多次触发。消抖的本质就是“用延迟换稳定”当检测到按键电平发生变化的时候不立刻采信而是稳定地等待一段抖动时间。常规的抖动时间一般在5到20毫秒之间我通常按20毫秒设计。在50MHz时钟下20毫秒就是1,000,000个周期一个20位计数器可以轻松覆盖这个范围。有人可能觉得软件上也能写延时消抖为什么硬件的处理方式值得单独拿出来讲。因为硬件设计里没有“延时函数”这个概念所有延时都必须通过“采样计数”的方式体现。这一思维转换是很多从单片机转到FPGA的人最需要适应的第一个点。3.2 消抖模块的常见实现最稳妥的做法是两级流程第一级连续采样去抖第二级边沿检测产生单脉冲。去抖的代码我写了一个比较精简的版本它的思路是维护一个“当前稳态电平”每当按键输入和稳态电平不一样时开始计数在计数时间窗口内如果按键又变回去了那说明刚才是一次抖动计数器清零不更新稳态如果按键电平在时间窗口内一直保持不变就认为新的电平是稳定的更新稳态值。module key_debounce #( parameter CNT_MAX 20d1_000_000 // 50MHz下约20ms )( input wire clk, input wire rst_n, input wire key_in, // 低有效按键按下为0 output reg key_pulse // 单时钟周期脉冲 ); reg [19:0] cnt; reg stable; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 20d0; stable 1b1; end else if (key_in stable) begin cnt 20d0; end else if (cnt CNT_MAX) begin cnt 20d0; stable key_in; end else begin cnt cnt 1b1; end end reg stable_d; always (posedge clk or negedge rst_n) begin if (!rst_n) begin stable_d 1b1; key_pulse 1b0; end else begin stable_d stable; // 低有效按键稳定电平从1变成0说明“按下” key_pulse (~stable) stable_d; end end endmodule这个模块的输入假设按键是低有效默认状态下key_in为高电平1。按下之后经过大约20毫秒消抖stable被更新为0。稳定电平从1变0的那一刻(~stable) stable_d检测到一个下降沿输出一个单周期的key_pulse高脉冲。稳定之后无论再按多久stable都不会继续翻转所以key_pulse不会重复触发。这样一个脉冲正好用于校时加一。顺便说一个调试技巧仿真里如果不想等20毫秒可以临时把CNT_MAX改成一个很小的值比如10先验证消抖逻辑本身能正常输出单脉冲。验证通过后再改回1,000,000。直接在超大计数值下仿真会很浪费时间这类“参数可缩放”的设计思路是仿真效率的关键。3.3 校时状态机NO_SET、SET_MIN、SET_HOUR校时功能需要一个状态机来管理当前处于哪个模式。我用了三段式状态机的写法这是目前工业界最流行也最不容易出错的风格。三段式把状态迁移、次态逻辑、输出逻辑分开放初学者看着代码冗余一些但好处是结构清晰仿真定位问题时很直观。正常模式下秒分时自动走按下设置键进入调节分钟模式这时分钟不再自动累加而是由调节按键每按一次加一分钟再次按下设置键进入调节小时模式小时由调节按键控制第三次按下设置键回到正常模式时钟继续走。这就是最简单的校时流程。localparam S_NORMAL 2d0; localparam S_SETMIN 2d1; localparam S_SETHOUR 2d2; reg [1:0] state, next_state; always (posedge clk or negedge rst_n) begin if (!rst_n) state S_NORMAL; else state next_state; end always (*) begin next_state state; case (state) S_NORMAL: if (key_set_pulse) next_state S_SETMIN; S_SETMIN: if (key_set_pulse) next_state S_SETHOUR; S_SETHOUR: if (key_set_pulse) next_state S_NORMAL; default: next_state S_NORMAL; endcase end // 根据状态输出校时使能 wire setmin_en (state S_SETMIN); wire sethour_en (state S_SETHOUR);这里要重点讲一下“三段式”最容易被忽略的点。第一段是时序逻辑负责状态寄存器的更新第二段是组合逻辑根据当前状态和输入计算下一个状态第三段才是根据状态产生输出。组合逻辑块里要用阻塞赋值时序逻辑块里要用非阻塞赋值。别把两者搞混否则仿真和实际硬件行为会不一致这种bug非常让人抓狂。在校时状态下分钟和小时增加的方式和自然计数不同。自然计数是秒在全速跑分钟靠进位但校时的时候我们希望按下“加一”按键分钟立刻加一而且不会因为秒钟还在走而被清零。最简洁的做法是让计数器模块支持“外部加一使能”和“正常计数使能”两条路径。用一个小逻辑做或操作分钟的使能等于“正常状态下的分进位”或“校分状态下的按键脉冲”。这样计数器模块本身不用改只是顶层把使能信号组合了一下。wire min_en (state S_NORMAL) ? carry_s_h : key_add_pulse;同理小时使能wire hour_en (state S_NORMAL) ? carry_m_h : key_add_pulse;还要注意一个问题在调节分钟的时候秒钟是否继续走。我当时的处理是只要不是正常状态秒使能直接拉低让秒停住。这样可以避免校时过程中秒又自动走给用户造成“怎么调完分钟秒对不上”的困扰。这个行为在真实数字钟里也很常见校时时秒停止退出校时后秒从当前值继续走。3.4 段码表与数码管扫描显示显示部分我采用六个数码管分别显示时十位、时个位、分十位、分个位、秒十位、秒个位。FPGA开发板上的数码管通常是共阳或共阴的位选控制某一位置亮段选控制显示数字的笔画。因为不能同时给六个数码管不同的数据所以只能一次点亮一位按一定频率轮询利用人眼的视觉暂留效果看起来所有位都亮着。这个跟电影切换帧的道理差不多。扫描频率很关键太慢会看到明显的闪烁太快又可能因为位选切换瞬间产生拖影和亮度不均。我建议每位数码管停留1毫秒到2毫秒也就是说完整扫描六个位需要6到12毫秒对应刷新率80到160赫兹左右肉眼看上去就非常稳定了。用50MHz时钟实现1毫秒的定时就是50000个时钟周期。显示模块的大致逻辑是每1毫秒切换一次扫描位置先从秒个位开始给位选信号选中第一个数码管同时根据当前秒个位的值去段码表查一下对应的七段编码送到段选端口维持1毫秒然后切换到秒十位重复这个过程依此类推。下面是一段共阳数码管的段码参考表这里把a段到g段加小数点dp依次对应段选信号的bit0到bit7。共阳极表示低电平点亮段所以取反后得到的就是这些值。如果你的板子是共阴直接用取反后的值或者按表重新整理。case (bcd_num) 4h0: seg_data 8b1100_0000; // 0 4h1: seg_data 8b1111_1001; // 1 4h2: seg_data 8b1010_0100; // 2 4h3: seg_data 8b1011_0000; // 3 4h4: seg_data 8b1001_1001; // 4 4h5: seg_data 8b1001_0010; // 5 4h6: seg_data 8b1000_0010; // 6 4h7: seg_data 8b1111_1000; // 7 4h8: seg_data 8b1000_0000; // 8 4h9: seg_data 8b1001_0000; // 9 default: seg_data 8b1111_1111; endcase扫描的时候还有一个常见的“拖影”问题。位选切换的瞬间如果段选数据还没有稳定上一个数字的残影会被下一个数码管“捡”到看起来就是数字不清楚或者带着淡淡的阴影。解决办法是在位选切换的那一拍先把段选置空也就是“消影”然后再切换到位选并输出新的段码。很多新手写扫描逻辑时容易漏掉这一拍我在自己的代码里专门留了这段注释希望你别踩同一个坑。// 切换前先关闭所有位选消除残影 seg_sel 6b111111; // 再更新段码和位选 case (scan_pos) 3d0: begin seg_data seg_table(sec_low); seg_sel ~6b000001; end 3d1: begin seg_data seg_table(sec_high); seg_sel ~6b000010; end // ... endcase4. 仿真验证与工具链实战4.1 用Icarus Verilog加GTKWave快速验证在把设计下载到板子上之前仿真验证是必不可少的一步。对新手来说我强烈推荐先用开源工具链Icarus Verilog配合GTKWave作为主仿真环境。为什么不用Vivado或者Quartus自带仿真器因为大集成环境的仿真启动速度慢一旦出现License问题你还得分心去排查工具本身而不是专注在代码上。Icarus Verilog是轻量级的编译器配合GTKWave看波形对数字钟这种小工程完全够用。安装好工具之后先准备一个testbench。testbench本质上也是一个Verilog模块不过它不烧进FPGA只是在电脑上模拟硬件行为。在testbench里生成50MHz时钟和复位信号例化顶层数字钟模块然后把关心的信号存成VCD波形文件仿真结束后用GTKWave打开。iverilog -o clock_tb.vvp digital_clock.v clk_div_en.v bcd_counter.v hour_counter.v key_debounce.v seg_scan.v tb_digital_clock.v vvp clock_tb.vvp gtkwave clock_tb.vcd如果把真实的分频参数直接扔进去仿真跑一天可能也就走几秒。实战的做法是把分频模块的参数DIV_N临时改成很小的值比如4这样就相当于每4个时钟周期产生一次秒使能整个仿真速度肉眼可见。验证完分频、进位、扫描逻辑的正确性后再换回真实的50M参数去板子上做长时间测试。4.2 仿真看到的正确波形长什么样第一次跑仿真的时候你是需要一个“预期波形”来对照的。我把最重要的几个关键信号列一下这样你看到波形时心里有数。首先是复位释放后en_1hz脉冲应该以DIV_N个时钟周期为间隔稳定地出现高电平脉冲。然后是秒个位q从0到9循环递增每当q从9变0的那一刻carry_s_l拉高一个周期。再往下游看秒十位q从0到5循环递增每当秒十位从5变0且秒个位同时变0时carry_s_h拉高一个周期。分钟和小时的进位依次类推。在状态机切到校时状态后秒使能被关掉分钟或小时由按键脉冲触发递增。如果你看到carry拉高后没有在下一拍归零或者计数模数错乱先检查是不是把MOD - 1写成了MOD。这是十个新手九个人都会犯的问题。还有一个隐藏的问题是复位信号的有效电平如果你的开发板复位是高有效而代码里写的都是negedge rst_n那复位永远不生效状态机会跑到未知状态。这类问题在波形里表现非常明显初始状态是X计数器不按预期跑。4.3 综合和上板前的关键检查仿真通过只代表逻辑正确真正要让它在FPGA上跑起来还需要经历综合、实现和生成比特流这几个步骤。在跑综合之前有几个检查务必先做一遍不然报错或者时序问题会让你摸不着头脑。第一检查顶层模块的端口和开发板原理图是否一致。很多开发板上数码管是低有效按键是低有效LED可能是高有效这些有效电平不一致时代码里的取反要对应改。第二使用引脚约束的时候确认时钟脚绑定到了专用时钟输入引脚。如果随便绑定到普通IO引脚也能跑但时钟质量会变差个别情况下会造成时序违例。第三如果你用了PLL或者需要外部复位确认复位释放时间确实足够避免FPGA配置完成后逻辑还没有进入到已知状态。还有一个非常容易忽略的问题不要在多个always块里对同一个reg赋值。这种代码在仿真工具里往往会给出多驱动警告综合工具甚至会直接报错。数字钟工程里各个模块都是独立例化一般不会出现这种问题但有时候为了图省事在顶层又加了个always试图对某个模块内部信号做修改就很容易把自己绕进去。4.4 关于License报错的经验用Quartus或者Vivado自带仿真器时很多新手会遇到类似“error: failure to obtain a verilog simulation license”的报错。这个报错本身不是你的代码有语法问题而是仿真工具没有拿到有效的授权文件。常见诱因有安装的版本和License不匹配、系统环境变量LM_LICENSE_FILE没有配置好、系统时间被修改过、或网络环境变化导致License验证失效。我不建议在这个问题上花太多时间死磕尤其是个人学习阶段。如果你使用的是官方正版授权检查一下环境变量和License文件路径即可如果只是入门学习直接把仿真任务放到Icarus Verilog或者其他开源工具上会顺畅得多。工具只是手段验证逻辑本身才是目的。不要在没必要的工具配置上消耗热情这是我踩过很多次坑后的真实体会。综合和实现阶段如果遇到“Timing constraint not met”之类的警告在数字钟这种低速设计中其实不用过多担心时钟只有50MHz逻辑规模也很小通常是约束文件没写好而不是电路本身有问题。先把功能跑通再回来看时序约束才能更清楚自己在干嘛。5. 常见问题排查与扩展方向5.1 常见现象速查表这里我把项目调试过程中出现概率较高的现象和解决方向整理成一个表格方便你卡住的时候快速对照。下面的每一行都是我在实际调试中或者帮别人看代码时真实遇到过的不是凭空罗列。现象可能原因排查方向数码管只亮一位其余全灭扫描时钟没有产生或者位选信号持续选中同一只是检查扫描分频计数器是否工作数码管亮度明显不均有的亮有的暗扫描停留时间不一致或者位选切换顺序有错统一每位停留的时钟周期数数字带残影位选切换和段码更新同拍导致旧段码显示在新位上切换前先关闭位选再更新段码秒跳过数字秒使能脉冲宽度超过一个周期或者计数器使能逻辑错误确认秒使能只有单周期高电平按压一次按键时间跳多次消抖时间不够或边沿检测缺失增加消抖计数时间确认输出是单脉冲校时状态一进去就自动乱跳状态机输出与按键脉冲同拍冲突或按键脉冲在状态切换后产生误触发对脉冲信号做打拍同步考虑状态切换后加入稳定周期这个表格里的现象有些其实是因为多个原因叠加引起的排查的时候要耐住性子。出现任何奇怪的问题第一反应不应该是去改代码而是打开仿真波形把对应信号按时间轴放大看往往一瞬间就能定位问题。举个例子有位朋友跟我反馈说扫描显示的时候总是第一位特别亮最后一位特别暗。我第一反应就是他的扫描逻辑里最后一位在位选切换后停留时间明显短于其他位因为他把切换判断放在了每个case分支的末尾导致最后一圈扫描还没执行完又被新一轮复位了。这类问题不用仿真都能看出来只要把扫描模块单独拉出来数时钟周期就能发现。5.2 如何继续扩展成更完整的“小系统”数字钟只是起点做完基本功能之后你可以继续往里面加东西。加一个整点报时功能用分和秒的进位产生一个脉冲驱动蜂鸣器响一声加一个闹钟功能预存一个比较值当秒分时和预设值相等时触发输出加一个日期模块在小时清零时给日期计数进位。这些扩展在架构上都非常顺因为前期的模块已经预留了进位端口和顶层清晰的数据流。如果你想借这个项目往通信方向过渡可以尝试把时间数据打包成UART帧每隔一秒发送到电脑上显示。这个扩展非常有意思它强迫你去思考并行数据如何转换成串行数据、波特率怎么用计数器产生、帧头和校验位怎么组织。做完之后你会发现之前数字钟里的分频和使能思想几乎原封不动可以迁移到UART模块中。如果你对跨时钟域设计感兴趣还可以试着把秒使能信号通过异步FIFO传到另一个时钟域里观察多比特数据跨时钟域时的亚稳态问题。数字钟的计数器之间是同步级联但当你把时间数据送到LCD、OLED和PC端时跨时钟域就不可避免。提前在数字钟上把“最慢的数据同步到最快时钟域”的模型跑通后面阅读异步FIFO的Read/Write指针同步逻辑会觉得非常亲切。5.3 代码风格与项目复盘最后想聊一点偏个人习惯的内容。我从这个项目之后写Verilog基本固定了一套自己觉得高效的工作流先画结构图再写代码、信号命名采用统一前缀、参数全部参数化、模块端口按输入/输出分组排列。这些不是强制要求但当你代码量上来之后一致性好会让调试效率提升特别多。对于数字钟这种入门项目很多同学会抄网上的代码或者跑通仿真就很高兴直接下载到板上看到时间在走就觉得“会了”。我的建议是在跑通之后花一个下午把整个工程推翻重新写一遍不要看旧代码尽量靠记忆和理解把五个模块从头搭一遍。第二次写的速度会明显快很多但对模块划分和信号意义的理解会发生质的改变。我在做数字钟之前也曾经背着几个计数器代码写得滚瓜烂熟但一遇到顶层连线就开始晕。直到自己独立把这个工程完成后才真正建立了“硬件是并行发生”的直觉每个模块都在同时工作它们靠信号线在合适的时机互通信息。把这句话想透了后面再去学状态机、异步FIFO、总线协议都会有一种“万变不离其宗”的踏实感。我自己后来面试过不少做FPGA的应届生发现能把数字钟讲得清楚明白的人往往基础不会太差。因为这个项目太小小到你无法用复杂的技术术语蒙混过关它又太大大到会把你在编码风格、模块复用、调试思路上的所有毛病暴露无遗。希望这篇文章能帮你把这个“第一道关卡”稳稳迈过去。
返回列表