最近调一块四位共阴数码管显示模块,遇到一个挺经典的怪问题:数字用肉眼看完全正常,可拿手机一拍,照片里全是重影,一个“1”旁边叠着半个“3”,亮的地方糊成一片,看起来像显示驱动彻底乱套了。同事过来看了一眼,说你这FPGA代码时序肯定写崩了。我第一反应也是去查代码,结果查了大半个晚上,发现问题根本不在什么高深的逻辑错误,而是动态扫描刷新时序里的一个老坑——重影。
这篇文章就把这个坑彻底讲透。围绕的题目很具体:FPGA驱动多位数码管,刷新时出现重影,怎么定位、怎么修。我会从动态扫描的原理讲起,拆解重影产生的几种根因,再给出一套可以直接抄作业的四段式时序方案和Verilog代码,最后附上调试实录和常见问题速查表,给正在做FPGA数码管小项目、尤其是还在用动态扫描刷新显示的朋友做个参考。
1. 把现象聊透:你看到的“重影”到底是什么
1.1 动态扫描是怎么工作的
先建立一个共同语境。多位数码管显示,FPGA一般不会给每一位都配一套独立的段驱动,那样太浪费引脚和驱动单元。实际操作是让所有数码管共用同一组段选线(a~g、dp共8根),然后通过位选线轮流选通每一位,让它们按照一定频率交替点亮。
这个思路本质上就是时间分片复用。人眼有视觉暂留效应,只要切换速度够快,比如每位数码管的刷新频率在60Hz以上,看起来就觉得所有位是同时点亮、持续显示的。举个例子,4位数码管如果每位扫描周期是1ms,那么整体显示刷新一遍需要4ms,等效刷新率250Hz,这个频率下闪烁基本不可感知。
动态扫描的关键参数有三个:段选信号(显示什么内容)、位选信号(点亮哪一位)、刷新周期(多长时间切换一位)。重影问题,就出在这三个参数相互配合的时序关系上。
1.2 重影的两副面孔:拖影和串影
我习惯把重影分成两类,因为它们的成因和修法完全不同。
第一类是拖影,或者叫残影。现象是某个数字的笔画旁边或者后面,跟着一个淡淡的同形残像,就像显示器拖尾一样。这种情况通常是段选信号切换慢了,上一帧的段码还在段选线上,位选已经切换到了下一位,于是下一位短暂地显示了上一位的内容。
第二类是串影,也叫串位。现象更严重,当前位的笔画旁边能清楚地看到另一位数字的轮廓,甚至两个数字叠在一起,形成鬼影。这种往往是位选信号和段选信号同时动作,或者位选没有彻底关断,导致新选通的一位把还没稳定的段选电平“接住”了。串影比拖影更常见,也是大部分人拍照片时看到的“重影”。
1.3 先分清是电学重影还是人眼余辉
这里要先泼一盆冷水:并不是所有看起来像重影的现象,都是FPGA代码或者硬件电路有问题。
人眼的视觉暂留特性在某些条件下也会产生类似重影的观感。比如刷新率太慢,当刷新率低于40Hz时,眼睛会捕捉到数字变化的过程,前一位的残像还没来得及消退,下一位的笔画已经叠上来了,看起来就像重影。这种属于扫描参数不合理,不是逻辑错误。
我自己调试时的一个土办法:用手机慢动作拍摄,或者把刷新频率直接降到每秒1次,一帧一帧地看。如果低频下每一位切换时能看到明显的笔画残留时序错误,那是电学时序问题;如果低频下显示正常、一旦提高频率就闪和糊,那可能是刷新周期和人眼暂留叠加的问题。两者处理方向不一样,第一步就搞错的话,后面容易被带偏。
2. 重影根因排查:从时序上揪出元凶
2.1 段选位选的竞争冒险,原因就这么简单
绝大多数重影问题的根源,一句话就能概括:段选信号和位选信号之间发生了竞争冒险。
数码管不是锁存器。它本质上就是一组LED,公共端(COM)被位选信号拉通后,各个段引脚上的电平会立刻决定哪几段发光,不存在“保存上一帧状态”的能力。所以当位选切换到某一位时,段选线上正在传输什么电平,这一位就会立即显示什么内容。
如果你的代码里,段选和位选是在同一个时钟沿、由两个不同的always块同时更新的,那么在实际硬件上,两个寄存器输出的延时会有微小差异。比如位选寄存器的Q端先稳定,段选寄存器的Q端还在变化,这时新选通的那位数码管就会看到一段“中间态”的段选信号,也就是上一帧的残留数据与新数据混叠在一起。肉眼看到的,就是拖影或者鬼影。
用图论的方式描述一个错误的时序轴,是这个样子:
- 位选开始切换的同一时刻,段选也在变化
- 位选先稳定,段选后稳定
- 在某一个极短的时间窗口内,新位+旧段选 = 重影
解决方向也由此确定:必须让位选切换的时间点,避开段选变化的时间窗口。
2.2 一个反直觉的真相:位选、段选谁先谁后
很多人听说要避免同时切换,于是把段选改成先动、位选后动。但实测有时候还是重影,原因是少做了一个步骤:没有彻底关断位选。
这里有一个反直觉的点,也是我踩过坑之后才想明白的。动态扫描切换位,正确顺序不是“先换段选再换位选”,而是“先关闭所有位选,再做后续所有动作,最后才打开新位选”。
为什么先换段选不安全?因为段选线是8根并行信号,它们经过FPGA内部组合逻辑传输时,各自的延时不均匀。你以为段选已经稳定成了新值,实际上可能还有一两根线在翻转。此时如果你直接把新位选打开,那新位选看到的就是一个“半稳定”的段选,精细的笔画边界上就会出现杂散的点亮,形成重影。
所以正规做法必须引入消隐时间:位选先全部无效,等段选线上所有信号都稳定下来,再打开新位选。这就相当于给数码管加了一个“寄存器建立时间”的概念。时序轴如下:
- 第一步,把位选全部置为无效电平,所有数码管熄灭
- 第二步,更新段选信号为目标值
- 第三步,等待段选稳定,通常1~2个时钟周期就够
- 第四步,打开新位选,点亮目标位
这套时序做完,位选永远不可能看到旧段选,重影问题从根上就堵死了。
2.3 反向刷新:一个容易踩的共阴共阳陷阱
还有一种不算重影、但表现很类似的问题,来自共阴和共阳数码管的驱动逻辑反了。
共阴数码管的公共端接地,段选信号高电平点亮;共阳数码管公共端接高电平,段选信号低电平点亮。位选信号同理:共阴管位选低电平无效、高电平选通,共阳管位选高电平无效、低电平选通。
如果板子上是共阳管,段码表却用了共阴的,显示内容会整个反转:不该亮的段全亮,该亮的段全灭。这时候拍照看起来就像一片笔画的乱影,甚至像重影叠了东西。尤其是多位数码管共用段选线,一位反向显示的内容会透过位选影响旁边的位,视觉上就是一种糊影。
拿到一块型号不明的数码管,不要只查引脚图就开工。用万用表二极管档实测一下:红表笔接COM端,黑表笔接某个段引脚,如果能点亮,说明是共阴管;反接能点亮则说明是共阳管。这一步做扎实,能省下后面一整天的排查时间。
2.4 刷新率:太低会闪,太高也有隐患
刷新率对重影的影响经常被低估。
刷新率太低,比如每位数码管只有25Hz,肉眼会明显看到闪烁。闪烁本身不是重影,但视觉暂留效应会让前后帧的笔画发生视觉叠加,产生残影感。尤其当显示内容还在动态更新时,这种叠加会让人觉得数字在“拖尾”。
刷新率太高也未必是好事。动态扫描每位导通时间变短,平均亮度下降。为了补偿亮度,有些人会加大驱动电流,而数码管LED在过流状态下余辉变长,切换时更容易留下拖影。所以刷新率要在一个合理区间里取平衡:4位数码管推荐每位扫描周期在0.5ms~2ms之间,整体刷新率也就是125Hz~500Hz这个范围。
我在项目里一般用1ms一位的扫描周期,4位整体刷新率250Hz,看起来既不闪,亮度也足够,驱动电流也不用顶格设计。
3. 一套可复用的四段式状态机方案
3.1 信号规划与硬件前提
先说硬件前提。这套方案适用于共阴或共阳数码管,FPGA输出引脚直接驱动段选和位选。如果数码管位数较多或者尺寸较大,建议加三极管或驱动芯片,但FPGA侧的时序逻辑不变。
我以一个4位共阴数码管为例。段选8根(a~g、dp),位选4根(com0~com3)。FPGA型号不限,只要引脚数量够用、时钟频率在几十兆赫兹级别就可以。我这里以50MHz系统时钟举例,因为这是最常见的学习板配置。
需要提前准备的数据有:一个16位寄存器data,存放4位十六进制数(对应4位数码管要显示的数字),还有一张共阴段码表。段码表在代码里用case语句实现,数字0~9对应8位段选信号,这里注意共阴段码是高电平点亮。
3.2 四步时序:灭灯、换码、开灯、保持
核心方案是一个基于计数器的四步时序,把1ms扫描周期拆成1000个时钟周期,前几个周期专门用来做安全的位切换。完整时序如下:
- 第0个周期:灭灯。位选全部置为无效,所有数码管熄灭。
- 第1个周期:换码。将当前要显示的段码更新到段选寄存器。
- 第2个周期:开灯。将对应位选拉高,点亮当前位。
- 第3~999个周期:保持。维持当前位点亮状态,直到下一位扫描周期开始。
这个方案的好处非常直接。位选和段选永远不会在同一个时钟周期变化,也不存在段选还没稳定就开灯的情况。多出来的两个“无用”周期,换来了时序上的绝对安全。
实际测试里,1ms的周期中只浪费了2us用于消隐,占0.2%,对亮度的影响可以忽略不计。
3.3 Verilog核心代码与注释
代码逻辑分三部分:分频计数、位索引更新、四步时序控制。完整核心代码给出来,可以直接在开发板上跑。
module seg_scan ( input wire clk, // 50MHz 系统时钟 input wire rst_n, // 低电平复位 input wire [15:0] data, // 4位十六进制显示数据 output reg [3:0] seg_sel, // 位选信号,共阴管高电平选通 output reg [7:0] seg_data // 段选信号,共阴段码 ); // 1ms 扫描周期计数器:50MHz / 1000 = 1ms reg [9:0] scan_timer; always @(posedge clk or negedge rst_n) begin if (!rst_n) scan_timer <= 10'd0; else if (scan_timer == 10'd999) scan_timer <= 10'd0; else scan_timer <= scan_timer + 10'd1; end // 当前扫描位索引,0~3 循环 reg [1:0] digit_idx; always @(posedge clk or negedge rst_n) begin if (!rst_n) digit_idx <= 2'd0; else if (scan_timer == 10'd999) digit_idx <= digit_idx + 2'd1; end // 四步时序控制 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin seg_sel <= 4'b0000; // 所有位截止 seg_data <= 8'h00; // 段选清空 end else begin case (scan_timer) // 第一步:灭灯,位选全部无效 10'd0: seg_sel <= 4'b0000; // 第二步:换码,段选更新为目标数字 10'd1: begin case (digit_idx) 2'd0: seg_data <= seg_code(data[3:0]); 2'd1: seg_data <= seg_code(data[7:4]); 2'd2: seg_data <= seg_code(data[11:8]); default:seg_data <= seg_code(data[15:12]); endcase end // 第三步:开灯,只点亮当前位 10'd2: begin case (digit_idx) 2'd0: seg_sel <= 4'b0001; 2'd1: seg_sel <= 4'b0010; 2'd2: seg_sel <= 4'b0100; default:seg_sel <= 4'b1000; endcase end // 第四步:保持,其余时间不做任何动作 default: ; endcase end end // 共阴段码表,数字 0~9,dp 点不点亮 function [7:0] seg_code; input [3:0] num; begin case (num) 4'h0: seg_code = 8'b0011_1111; // 0 4'h1: seg_code = 8'b0000_0110; // 1 4'h2: seg_code = 8'b0101_1011; // 2 4'h3: seg_code = 8'b0100_1111; // 3 4'h4: seg_code = 8'b0110_0110; // 4 4'h5: seg_code = 8'b0110_1101; // 5 4'h6: seg_code = 8'b0111_1101; // 6 4'h7: seg_code = 8'b0000_0111; // 7 4'h8: seg_code = 8'b0111_1111; // 8 4'h9: seg_code = 8'b0110_1111; // 9 default: seg_code = 8'b0000_0000; endcase end endfunction endmodule有几点需要注意。段码表后面的default分支我置了全灭,用来处理非数字输入,这是为了保险。位选和段选在scan_timer的不同计数值上更新,天然保证了两者不会同时变化。如果遇到共阳数码管,只需把段码表和位选逻辑取反,核心时序不用改。
实际测试中,这个代码综合后位选和段选之间的时序关系非常干净,用逻辑分析仪抓波形可以看到两个信号的变化至少间隔一个时钟周期,完全没有交叠窗口。
3.4 分频器和位选输出的常见坑
代码写对了,硬件实现上还有几个坑容易栽跟头。
第一个坑:不要用组合逻辑输出分频时钟。有些初学者习惯写一个计数器,然后用assign生成一个分频时钟信号,再把位选寄存器用这个“时钟”驱动。这种写法在FPGA里属于不推荐的时钟生成方式,因为生成的分频时钟边沿可能不干净,位选信号会带有毛刺,毛刺被数码管一放大,就是肉眼可见的鬼影。正确做法是:系统时钟统一驱动所有寄存器,分频结果只作为使能信号(tick)使用。
第二个坑:位选信号要寄存器输出,不要直接用组合逻辑做。比如用always块里根据digit_idx生成一个case,但忘记加寄存器声明,综合工具会生成LUT组合逻辑,信号在输入变化时会有一段不稳定的竞争窗口。数码管对这段窗口非常敏感。解决方案是变量声明成reg,在always的时钟边沿赋值,让位选通过寄存器输出。
第三个坑:不要以为加了一个大电容或者RC滤波能解决重影。信号线上的毛刺如果频率很高,RC滤波确实能削掉一部分幅度,但代价是信号边沿变缓。位选和段选边沿变缓之后,切换窗口反而拉长,重影可能更严重。硬件滤波只能做辅助,核心还是要从时序逻辑上解决。
4. 一个4位数码管项目的完整排障实录
4.1 硬件搭接与引脚规划
回到开头那个翻车现场,我用的是一块4位共阴数码管模块,型号拆下来看丝印是3611BS。网上能搜到不少这张图,但这里强调一句:同一系列型号,不同批次共阴共阳都可能不一样,查资料只能参考,最后还是要用万用表实测,别嫌麻烦。
引脚规划是这样:段选a~g、dp分别接到FPGA的8个IO口,位选com0~com3接4个IO口。限流电阻放在段选一侧,每个段选串一个330欧姆电阻。这里有个经验:限流电阻尽量放在段选侧而不是位选侧。如果放在位选侧,电流会经过多段LED汇集到公共端,位选电阻压降随点亮段数变化,会导致不同数字显示亮度不一致,而且电流分配不均匀容易让位选三极管过载。
开发板用的是50MHz板载晶振,代码就是上一节那套。上电之后,肉眼看起来数字显示正常,但手机一拍,问题就暴露了。
4.2 逻辑分析仪实测:波形怎么判
把逻辑分析仪探针夹到位选和段选引脚上,采样率设成50MHz,触发方式选位选上升沿。抓到的波形一看就明白了:
段选和位选几乎在同一个时刻跳变,位选信号上升沿附近,段选线上还有几个高低电平的跳变。这些跳变正是另一位的段码残留。按照代码逻辑,位选切换那位应该显示“3”,但段选线上还在一帧一帧地更新旧数据,结果就是新位选被拉通的一瞬间,先显示了前一帧的笔画轮廓,形成了照片里的重影。
同时发现一个问题:位选信号整体不是规则的“每次只选通一位”波形,相邻位选之间偶尔会出现一个极短的脉冲,像是毛刺。这个毛刺是位选逻辑里用了组合逻辑case生成导致的。把位选改成寄存器输出后,毛刺消失,段选和位选的跳变点也彻底错开了。
改完代码重新抓波形,能看到清晰的时序:位选全部为低的消隐窗口,接着段选更新,稳定一个周期后位选再拉高。整个切换过程干净利落。
4.3 不用仪器的土办法:手机拍照法
如果手头没有逻辑分析仪,也有一个非常有效的土办法:手机拍照。
操作流程是这样:把手机相机调成专业模式,快门速度设为1/100秒左右,ISO固定,对着数码管拍一张。如果照片里出现重影,说明切换瞬间存在段选和位选交叠;如果照片清晰,但肉眼看到闪烁,那是刷新率问题。快门越短,越能捕捉到高速切换瞬间的问题,我试过用1/1000秒快门拍,重影现象会被放大得非常明显,方便判断。
还有一个办法:把扫描周期人为调慢,比如把1ms改成100ms。这时候能肉眼直接观察到每一位的点亮顺序。如果某一位点亮瞬间,旁边一位闪了一下,说明位选和段选切换顺序有问题。这个方法不需要任何仪器,对排查也有帮助。
5. 常见问题速查表:症状、原因与对策
把动态扫描里常见的问题整理成一张表,方便大家对照排查。
| 症状 | 可能原因 | 处理对策 |
|---|---|---|
| 显示笔画有拖影,边缘有残像 | 段选信号切换慢,位选切换时看到旧段码 | 增加消隐时序,先灭灯再换码再开灯 |
| 一位数字旁边叠着另一位数字轮廓 | 段选和位选同时变化,存在竞争窗口 | 用状态机或计数器分步切换,确保两者错开 |
| 整体闪烁严重 | 刷新率太低,低于40Hz | 缩短扫描周期,4位数码管建议每位低于2ms |
| 有一位始终不亮或亮度偏低 | 位选驱动能力不足,或者限流电阻过大 | 检查位选三极管,降低限流电阻,或提高扫描占空比 |
| 显示内容整体反相,像“鬼画符” | 共阴共阳配置错误,段码表与硬件不匹配 | 用万用表确认极性,取反段码表 |
| 位选之间有毛刺,偶尔跳变 | 位选用了组合逻辑,产生竞争冒险 | 位选改成寄存器输出,在时钟边沿统一赋值 |
| 亮度不均,数字不同亮度不同 | 限流电阻放在位选侧,电流分配不均 | 限流电阻改到段选侧,每个段单独串电阻 |
| 换页或刷新数据时出现短暂乱码 | 数据更新没有与扫描周期同步 | 数据寄存器在消隐窗口内更新,或加双缓冲 |
这里额外说一个很容易被忽略的点:数据更新的时机要和扫描周期错开。如果你的data总线在动态扫描期间随时可能被外部模块改写,而改写瞬间刚好落在“开灯”阶段,那这一位就会显示出一个半新半旧的数据,看起来像一闪而过的乱码。解决方法很简单:数据更新前先触发一个忙信号,或者让外部模块只在消隐窗口内更新数据。不想搞复杂的话,直接把data信号用寄存器打一拍,也能减少不少问题。
6. 后记:一句口诀和几条早该知道的经验
这次排障下来,最大的收获不是写出了那版能跑的代码,而是把所有关于动态扫描重影的知识,总结成了一句话:先灭灯,再换数,后开灯。七个字,每次写数码管驱动的时候默念一遍,基本就不会出重影问题。
踩过几次坑之后,我再补充几条经验,送给正在做类似项目的朋友。
第一,拿到模块先别急着写代码。花半小时用万用表量清楚共阴共阳、画好引脚对照表,比烧录后对着现象猜半天要划算得多。
第二,调试时一定要分清楚“肉眼观察”和“仪器测量”的差异。肉眼觉得正常不代表时序没问题,手机拍照是个极好的低成本检测手段,建议每个做数码管显示的人都试一次。
第三,解决重影的关键不是提高刷新率,而是保证切换瞬间的时序安全。把刷新率从250Hz提到500Hz,如果段选和位选还是同时变化,重影依然在;相反,把切换顺序改对,哪怕刷新率降到125Hz,画面也是干净的。
第四,如果你的动态扫描将来要扩展到更多位,比如8位数码管,这套四步时序依然适用,只需要把digit_idx扩展成3位,再把位选case扩展即可。扫描周期需要相应缩短,保持整体刷新率不要低于100Hz,否则闪烁会很明显。
最后再分享一个小技巧:开发板资源够的话,可以在FPGA里加一个“测试模式”,把扫描周期用拨码开关切换成1ms和100ms两档。快速档用于正常显示,慢速档用于观察每一位的切换过程。这个功能看起来不起眼,但在现场排查问题时能省下大量时间。我后来做任何涉及多路动态输出的项目,都会在里面留这样一个慢速观察模式,已经帮我抓出过好几个信号切换时序的隐蔽问题。