14、数码管显示:静态显示与动态扫描、段选与位选控制、数字时钟显示实战

数码管这东西,看着简单,但坑不少。我刚开始用FPGA驱动数码管时,以为不就是亮几个灯嘛,结果一上电,显示乱跳,数字重影,折腾了一整天才搞定。今天咱们就把这块彻底讲透。

14.1 数码管的工作原理

先说说数码管内部是啥结构。说白了,它就是8个LED灯排成"8"字形,外加一个小数点。每个LED对应一个段,从a到g,再加上dp(小数点)。

数码管分两种:共阴极和共阳极。共阴极是所有LED的负极连在一起接地,正极分别控制。共阳极则相反,正极连在一起接VCC,负极分别控制。

我个人习惯用共阴极数码管,因为控制逻辑更直观——给高电平就亮,给低电平就灭。不过在实际项目中,两种都会遇到,你得学会灵活切换。

段选与位选的概念

段选:控制数码管显示什么数字(哪个段亮)
位选:控制哪一位数码管工作(选通哪一位)

举个例子:你要显示"1234",段选决定了每个位置显示1、2、3、4这些数字的段码,位选则决定当前是第1位、第2位、第3位还是第4位在亮。

14.2 静态显示 vs 动态扫描

静态显示,就是每个数码管独立控制,一直亮着。优点是显示稳定,不闪烁。缺点嘛,占用的IO口太多——4位数码管就要32个IO口,FPGA的引脚可经不起这么折腾。

动态扫描就不一样了。它利用人眼的视觉暂留效应,快速轮流点亮每一位数码管。只要刷新频率够快(一般50Hz以上),人眼看起来就是所有位同时亮的。

我在项目中遇到过一个问题:动态扫描的刷新频率太低,导致数码管明显闪烁。后来我把刷新频率提高到100Hz,问题就解决了。你想想看,如果刷新频率低于30Hz,人眼就能感觉到闪烁,那体验就很差了。

对比项 静态显示 动态扫描
IO口占用 多(每位8个) 少(段选8个+位选N个)
显示稳定性 高,无闪烁 需保证刷新频率
功耗 较高 较低
适用场景 位数少(1-2位) 位数多(4位以上)

14.3 段码表的设计

段码表是数码管显示的核心。每个数字对应一个8位二进制数,从高位到低位分别是dp、g、f、e、d、c、b、a。

以共阴极数码管为例,显示数字"0"时,a、b、c、d、e、f亮,g和dp灭,对应的段码就是8'b11000000(注意这里高位在前)。

// 共阴极数码管段码表
// 位顺序:dp g f e d c b a
parameter SEG_0 = 8'b1100_0000;  // 显示0
parameter SEG_1 = 8'b1111_1001;  // 显示1
parameter SEG_2 = 8'b1010_0100;  // 显示2
parameter SEG_3 = 8'b1011_0000;  // 显示3
parameter SEG_4 = 8'b1001_1001;  // 显示4
parameter SEG_5 = 8'b1001_0010;  // 显示5
parameter SEG_6 = 8'b1000_0010;  // 显示6
parameter SEG_7 = 8'b1111_1000;  // 显示7
parameter SEG_8 = 8'b1000_0000;  // 显示8
parameter SEG_9 = 8'b1001_0000;  // 显示9

小技巧:如果你用的是共阳极数码管,段码取反就行。比如共阴极的SEG_0是8'b1100_0000,共阳极就是8'b0011_1111。我一般会在代码里加一个条件编译,方便切换。

14.4 动态扫描的实现

动态扫描的核心就是一个计数器,轮流选通每一位数码管。每次只让一位亮,同时输出对应的段码。

嗯,这里要注意:切换位选时,要先关闭所有位(消隐),再更新段码,最后打开新的位选。否则会出现"鬼影"——前一位的显示残留到后一位上。

// 动态扫描模块
module seg_dynamic_scan (
    input           clk,        // 系统时钟
    input           rst_n,      // 复位
    input  [15:0]   data_in,    // 要显示的4位BCD码
    output reg [3:0] sel,       // 位选信号
    output reg [7:0] seg        // 段选信号
);

// 扫描计数器,分频到1kHz左右
reg [15:0] cnt;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        cnt <= 16'd0;
    else
        cnt <= cnt + 1'b1;
end

// 位选切换
reg [1:0] scan_pos;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        scan_pos <= 2'd0;
    else if (cnt == 16'd49999)  // 50MHz时钟,1ms切换一次
        scan_pos <= scan_pos + 1'b1;
end

// 段码输出和位选控制
always @(*) begin
    case (scan_pos)
        2'd0: begin
            sel = 4'b1110;  // 选通第1位
            seg = seg_table(data_in[3:0]);   // 显示个位
        end
        2'd1: begin
            sel = 4'b1101;  // 选通第2位
            seg = seg_table(data_in[7:4]);   // 显示十位
        end
        2'd2: begin
            sel = 4'b1011;  // 选通第3位
            seg = seg_table(data_in[11:8]);  // 显示百位
        end
        2'd3: begin
            sel = 4'b0111;  // 选通第4位
            seg = seg_table(data_in[15:12]); // 显示千位
        end
    endcase
end

endmodule

避坑指南:我曾经在动态扫描时忘记加消隐处理,结果数码管显示重影严重,数字看起来像叠在一起。后来我在切换位选前先让所有位选无效(sel=4'b1111),再更新段码,最后才选通目标位,问题就解决了。

14.5 数字时钟显示实战

好了,理论讲完了,咱们来做个实战——用4位数码管显示数字时钟,格式为"HH:MM",中间的小数点闪烁表示秒。

这个项目分三部分:时钟计数模块、数码管驱动模块、顶层连接模块。

时钟计数模块负责计时,产生时和分。数码管驱动模块就是咱们刚才写的动态扫描。顶层模块把两者连起来。

// 数字时钟顶层模块
module digital_clock (
    input           clk,        // 50MHz系统时钟
    input           rst_n,      // 复位
    output [3:0]    sel,        // 位选
    output [7:0]    seg         // 段选
);

// 时钟计数
reg [31:0] clk_cnt;
reg [5:0]  sec, min, hour;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        clk_cnt <= 32'd0;
        sec <= 6'd0;
        min <= 6'd0;
        hour <= 6'd0;
    end
    else begin
        if (clk_cnt == 32'd49_999_999) begin  // 1秒到达
            clk_cnt <= 32'd0;
            if (sec == 6'd59) begin
                sec <= 6'd0;
                if (min == 6'd59) begin
                    min <= 6'd0;
                    if (hour == 6'd23)
                        hour <= 6'd0;
                    else
                        hour <= hour + 1'b1;
                end
                else
                    min <= min + 1'b1;
            end
            else
                sec <= sec + 1'b1;
        end
        else
            clk_cnt <= clk_cnt + 1'b1;
    end
end

// 将时分转换为BCD码
wire [15:0] display_data;
assign display_data = {hour/10, hour%10, min/10, min%10};

// 小数点闪烁(每秒闪烁一次)
wire dp_ctrl;
assign dp_ctrl = (sec[0]) ? 1'b1 : 1'b0;  // 秒的奇偶控制小数点

// 实例化数码管驱动
seg_dynamic_scan u_scan (
    .clk        (clk),
    .rst_n      (rst_n),
    .data_in    (display_data),
    .sel        (sel),
    .seg        (seg)
);

// 注意:这里seg的最高位(dp)需要根据dp_ctrl来控制
// 实际使用时,可以在seg_table中根据dp_ctrl调整小数点位的输出

endmodule

你看,整个数字时钟的核心代码其实没多少行。但实际调试时,我遇到过不少问题。比如时钟计数不准,原因是分频系数算错了——50MHz时钟,1秒需要计数50,000,000次,但计数器是从0开始的,所以应该是49,999,999。

实战要点总结

  • 动态扫描刷新频率建议在60-120Hz之间
  • 切换位选时必须先消隐,再更新段码,最后选通
  • 段码表要仔细核对,一个bit错整个数字就错了
  • 时钟分频要注意边界条件,计数器从0开始计数

说实话,数码管驱动是FPGA入门的基础,但也是检验基本功的好题目。你如果能独立完成这个数字时钟项目,那对时序控制、状态机、分频这些概念就会有更深的理解。

下一章咱们会讲按键消抖和输入控制,到时候就能给这个时钟加上调时功能了。嗯,先把这个消化掉,有问题随时交流。