这一篇在干嘛?
上一篇我们做了一个 0~9 循环的单个十进制计数器。这一篇往前跨一大步:让计数值能一路累加到几十万甚至更大,并用 6 位数码管把每一位都显示出来。核心新增两块知识——多级计数器的进位级联思想,以及数码管「动态扫描」显示法。
实验任务与原理
实验任务
设计 6 位十进制计数显示器电路,要求所设计的电路具有以下功能:
- 能够实现 0-99999999 的计数并显示;
- 具备复位清零功能。
任务描述与程序的位宽以原文为准
原文任务写的是 0-99999999(八位),但配套程序按 6 位数码管、数据位宽 20 位、累加到 999999 实现,模块注释也写的是「6 位数码管要显示的数值」。学习时以程序和板上实际现象为准,理解了结构之后,把位宽和计数上限改大就是水到渠成的事。
实验原理
前面我们学习了 10 进制计数器,如果我们再在每个计数器加一个溢出位,信号接下一个计数器,这样我们连接 6 个,时钟输入 50MHz 分频 0.1Hz,图 1.1 是设计示意图。

图 1.1
这里有两个关键思想:
- 级联(carry chain):一位十进制计数器只能数 0~9。给它加一个「溢出/进位」输出——每当它从 9 归零时输出一个脉冲,去推动下一位计数器加一。就像机械水表:个位齿轮转满一圈,拨动十位齿轮走一格。级联多少位,就能表示多大的数。
- 动态扫描:6 位数码管如果每有一位就要一组段选线,引脚消耗巨大。动态扫描的办法是:所有位共用一组段选线(DIG),某一时刻只点亮一位数码管,位选信号(SEL)轮流选通,每个位只亮约 1ms 就切到下一位。因为切换速度快于人眼残留(6 位轮一圈只要 6ms),看起来就像 6 位同时亮着。
工程结构
实验程序供三个:顶层模块、计数模块及数码扫描译码模块。
top_seg_led(顶层):连接计数模块与显示模块;count(计数模块):对系统时钟分频计时,产生周期性累加脉冲,维护 20 位显示数据;seg_led(数码扫描译码模块):把二进制数据换成各位 BCD 码,再轮流输出位选与段选。
Verilog 实现
顶层模块 top_seg_led
module top_seg_led(
//global clock
input CLOCK , // 全局时钟信号
input rst, // 复位信号(低有效)
//seg_led interface
output [7:0] SEL , // 数码管位选信号
output [7:0] DIG // 数码管段选信号
);
//wire define
wire [19:0] data; // 数码管显示的数值
wire [ 5:0] point; // 数码管小数点的位置
wire en; // 数码管显示使能信号
wire sign; // 数码管显示数据的符号位
assign SEL[7:6] = 2'b11;
//计数器模块,产生数码管需要显示的数据
count u_count(
.clk (CLOCK ), // 时钟信号
.rst_n (rst), // 复位信号
.data (data ), // 6 位数码管要显示的数值
.point (point ), // 小数点具体显示的位置,高电平有效
.en (en ), // 数码管使能信号
.sign (sign ) // 符号位
);
//数码管动态显示模块
seg_led u_seg_led(
.clk (CLOCK ), // 时钟信号
.rst_n (rst), // 复位信号
.data (data ), // 显示的数值
.point (point ), // 小数点具体显示的位置,高电平有效
.en (en ), // 数码管使能信号
.sign (sign ), // 符号位,高电平显示负号(-)
.seg_sel (SEL[5:0] ), // 位选
.seg_led (DIG ) // 段选
);
Endmodule顶层只做「接线」:SEL[7:6] 固定拉高关闭高两位数码管,低 6 位交给扫描模块。数据、小数点、使能、符号这四根「总线」在两个子模块之间直通。
计数模块 count
计数:
module count(
//mudule clock
input clk , // 时钟信号
input rst_n, // 复位信号
//user interface
output reg [19:0] data , // 6 个数码管要显示的数值
output reg [ 5:0] point, // 小数点的位置,高电平点亮对应数码管位上的小数点
output reg en , // 数码管使能信号
output reg sign // 符号位,高电平时显示负号,低电平不显示负号
););
//parameter define
parameter MAX_NUM = 23'd5000_000; // 计数器计数的最大值
//reg define
reg [22:0] cnt; // 计数器,用于计时 100ms
reg flag; // 标志信号
//计数器对系统时钟计数达 10ms 时,输出一个时钟周期的脉冲信号
always @ (posedge clk or negedge rst_n) begin
if (!rst_n) begin
cnt <= 23'b0;
flag <= 1'b0;
end
else if (cnt < MAX_NUM - 1'b1) begin
cnt <= cnt + 1'b1;
flag <= 1'b0;
end
else begin
cnt <= 23'b0;
flag <= 1'b1;
end
end
//数码管需要显示的数据,从 0 累加到 999999
always @ (posedge clk or negedge rst_n) begin
if (!rst_n) begin
data <= 20'b0;
point <= 6'b000000;
en <= 1'b0;
sign <= 1'b0;
end
else begin
point <= 6'b000000; //不显示小数点
en <= 1'b1; //打开数码管使能信号
sign <= 1'b0; //不显示负号
if (flag) begin //显示数值每隔 0.01s 累加一次
if (data < 20'd999999)
data <= data + 1'b1;
else
data <= 20'b0;end
end
end
endmodule算一算累加速度
MAX_NUM = 5_000_000,系统时钟 50MHz,所以flag每 5,000,000 / 50,000,000 = 0.1 秒拉高一次,显示数值每 0.1 秒加一(原文个别注释写作 10ms/0.01s,按参数换算应为 100ms,以参数为准)。想让它跳得更快或更慢,只需要改这一个参数——这就是「参数化设计」的好处。
这个模块演示了经典的「计时—打旗—干活」三段式:第一个 always 只负责数时钟攒够 0.1 秒并打出 flag 脉冲;第二个 always 在 flag 有效时才让 data 累加,数到 999999 归零。两段各司其职,比把所有逻辑塞进一个 always 清晰得多。
数码扫描译码模块 seg_led
//数码扫描译码
module seg_led(
input clk , // 时钟信号
input rst_n , // 复位信号
input [19:0] data , // 6 位数码管要显示的数值
input [5:0] point , // 小数点具体显示的位置,从高到低,高电平有效
input en , // 数码管使能信号
input sign , // 符号位(高电平显示"-"号)
output reg [5:0] seg_sel, // 数码管位选,最左侧数码管为最高位
output reg [7:0] seg_led // 数码管段选
);
//parameter define
localparam CLK_DIVIDE = 4'd10 ; // 时钟分频系数
localparam MAX_NUM = 13'd5000 ; // 对数码管驱动时钟(5MHz)计数 1ms 所需的计数值
//reg define
reg [ 3:0] clk_cnt ; // 时钟分频计数器
reg dri_clk ; // 数码管的驱动时钟,5MHz
reg [23:0] num ; // 24 位 bcd 码寄存器
reg [12:0] cnt0 ; // 数码管驱动时钟计数器
reg flag ; // 标志信号(标志着 cnt0 计数达 1ms)
reg [2:0] cnt_sel ; // 数码管位选计数器
reg [3:0] num_disp ; // 当前数码管显示的数据
reg dot_disp ; // 当前数码管显示的小数点
//wire define
wire [3:0] data0 ; // 十万位数
wire [3:0] data1 ; // 万位数
wire [3:0] data2 ; // 千位数
wire [3:0] data3 ; // 百位数
wire [3:0] data4 ; // 十位数
wire [3:0] data5 ; // 个位数
//提取显示数值所对应的十进制数的各个位
assign data0 = data % 4'd10; // 个位数
assign data1 = data / 4'd10 % 4'd10 ; // 十位数
assign data2 = data / 7'd100 % 4'd10 ; // 百位数
assign data3 = data / 10'd1000 % 4'd10 ; // 千位数
assign data4 = data / 14'd10000 % 4'd10; // 万位数
assign data5 = data / 17'd100000; // 十万位数除法和取余:二进制变 BCD 的「土办法」
% 10得到个位,/ 10 % 10得到十位……用一连串除法取余把 20 位二进制数「掰」成 6 个 4 位 BCD 码,思路直观、代码好写,代价是要消耗一些除法器逻辑资源。入门阶段完全够用,后面学到 BCD 转换算法(如大数移位加 3 法)时可以回头优化。
//对系统时钟 10 分频,得到的频率为 5MHz 的数码管驱动时钟 dri_clk
always @(posedge clk or negedge rst_n) begin
if(!rst_n) begin
clk_cnt <= 4'd0;
dri_clk <= 1'b1;
end
else if(clk_cnt == CLK_DIVIDE/2 - 1'd1) begin
clk_cnt <= 4'd0;
dri_clk <= ~dri_clk;
end
else begin
clk_cnt <= clk_cnt + 1'b1;
dri_clk <= dri_clk;
end
end
//将 20 位 2 进制数转换为 8421bcd 码(即使用 4 位二进制数表示 1 位十进制数)
always @ (posedge dri_clk or negedge rst_n) begin
if (!rst_n)
num <= 24'b0;
else begin
if (data5 || point[5]) begin //如果显示数据为 6 位十进制数,
num[23:20] <= data5; //则依次给 6 位数码管赋值
num[19:16] <= data4;
num[15:12] <= data3;
num[11:8] <= data2;
num[7:4] <= data1;
num[3:0] <= data0;
end
else begin
if (data4 || point[4]) begin //如果显示数据为 5 位十进制数,则给低 5 位数码管赋值
num[19:0] <= {data4, data3, data2, data1, data0};
if(sign)
num[23:20] <= 4'd11; //如果需要显示负号,则最高位(第 6 位)为符号位else
num[23:20] <= 4'd10; //不需要显示负号时,则第6位不显示任何字符
end
else begin //如果显示数据为4位十进制数,则给低4位数码管赋值
if (data3 || point[3]) begin
num[15:0] <= {data3,data2,data1,data0};
num[23:20] <= 4'd10; //第6位不显示任何字符
if(sign) //如果需要显示负号,则最高位(第5位)为符号位
num[19:16] <= 4'd11;
else //不需要显示负号时,则第5位不显示任何字符
num[19:16] <= 4'd10;
end
else begin //如果显示数据为3位十进制数,则给低3位数码管赋值
if (data2 || point[2]) begin
num[11:0] <= {data2,data1,data0};
//第6、5位不显示任何字符
num[23:16] <= {2{4'd10}};
if(sign) //如果需要显示负号,则最高位(第4位)为符号位
num[15:12] <= 4'd11;
else //不需要显示负号时,则第4位不显示任何字符
num[15:12] <= 4'd10;
end
else begin //如果显示数据为2位十进制数,则给低2位数码管赋值
if (data1 || point[1]) begin
num[7:0] <= {data1,data0};
//第6、5、4位不显示任何字符
num[23:12] <= {3{4'd10}};
if(sign) //如果需要显示负号,则最高位(第3位)为符号位
num[11:8] <= 4'd11;
else //不需要显示负号时,则第3位不显示任何字符
num[11:8] <= 4'd10;
end
else begin //如果显示数据为1位十进制数,则给最低位数码管赋值
num[3:0] <= data0;
//第6、5位不显示任何字符
num[23:8] <= {4{4'd10}};
if(sign) //如果需要显示负号,则最高位(第2位)为符号位
num[7:4] <= 4'd11;else //不需要显示负号时,则第2位不显示任何字符
num[7:4] <= 4'd10;
end
end
end
end
end
end这一大段嵌套 if 在干一件很有用的事:按有效位数对齐显示。数值是 3 位就只点亮低 3 位数码管,高位的空位不显示字符(4'd10 是「空白」字符),需要负号时把符号放在最高有效位上。这也是数码管程序里最值得抄走的「通用模板」。
//每当计数器对数码管驱动时钟计数时间达1ms,输出一个时钟周期的脉冲信号
always @ (posedge dri_clk or negedge rst_n) begin
if (rst_n == 1'b0) begin
cnt0 <= 13'b0;
flag <= 1'b0;
end
else if (cnt0 < MAX_NUM - 1'b1) begin
cnt0 <= cnt0 + 1'b1;
flag <= 1'b0;
end
else begin
cnt0 <= 13'b0;
flag <= 1'b1;
end
end
//cnt_sel从0计数到5,用于选择当前处于显示状态的数码管
always @ (posedge dri_clk or negedge rst_n) begin
if (rst_n == 1'b0)
cnt_sel <= 3'b0;
else if(flag) begin
if(cnt_sel < 3'd5)
cnt_sel <= cnt_sel + 1'b1;
else
cnt_sel <= 3'b0;
end
else
cnt_sel <= cnt_sel;
end//控制数码管位选信号,使6位数码管轮流显示
always @ (posedge dri_clk or negedge rst_n) begin
if(!rst_n) begin
seg_sel <= 6'b111111; //位选信号低电平有效
num_disp <= 4'b0;
dot_disp <= 1'b1; //共阳极数码管,低电平导通
end
else begin
if(en) begin
case (cnt_sel)
3'd0 :begin
seg_sel <= 6'b111110; //显示数码管最低位
num_disp <= num[3:0]; //显示的数据
dot_disp <= ~point[0]; //显示的小数点
end
3'd1 :begin
seg_sel <= 6'b111101; //显示数码管第1位
num_disp <= num[7:4];
dot_disp <= ~point[1];
end
3'd2 :begin
seg_sel <= 6'b111011; //显示数码管第2位
num_disp <= num[11:8];
dot_disp <= ~point[2];
end
3'd3 :begin
seg_sel <= 6'b110111; //显示数码管第3位
num_disp <= num[15:12];
dot_disp <= ~point[3];
end
3'd4 :begin
seg_sel <= 6'b101111; //显示数码管第4位
num_disp <= num[19:16];
dot_disp <= ~point[4];
end
3'd5 :begin
seg_sel <= 6'b011111; //显示数码管最高位
num_disp <= num[23:20];dot_disp <= ~point[5];
end
default :begin
seg_sel <= 6'b111111;
num_disp <= 4'b0;
dot_disp <= 1'b1;
end
endcase
end
else begin
seg_sel <= 6'b111111; //使能信号为0时,所有数码管均不显示
num_disp <= 4'b0;
dot_disp <= 1'b1;
end
end
end
//控制数码管段选信号,显示字符
always @ (posedge dri_clk or negedge rst_n) begin
if (!rst_n)
seg_led <= 8'hc0;
else begin
case (num_disp)
4'd0 : seg_led <= {dot_disp,7'b1000000}; //显示数字 0
4'd1 : seg_led <= {dot_disp,7'b1111001}; //显示数字 1
4'd2 : seg_led <= {dot_disp,7'b0100100}; //显示数字 2
4'd3 : seg_led <= {dot_disp,7'b0110000}; //显示数字 3
4'd4 : seg_led <= {dot_disp,7'b0011001}; //显示数字 4
4'd5 : seg_led <= {dot_disp,7'b0010010}; //显示数字 5
4'd6 : seg_led <= {dot_disp,7'b0000010}; //显示数字 6
4'd7 : seg_led <= {dot_disp,7'b1111000}; //显示数字 7
4'd8 : seg_led <= {dot_disp,7'b0000000}; //显示数字 8
4'd9 : seg_led <= {dot_disp,7'b0010000}; //显示数字 9
4'd10: seg_led <= 8'b11111111; //不显示任何字符
4'd11: seg_led <= 8'b10111111; //显示负号(-)
default:
seg_led <= {dot_disp,7'b1000000};
endcase
end
end扫描部分的时间账:驱动时钟 dri_clk 是 50MHz 十分频得到的 5MHz;MAX_NUM = 5000,所以每个数码管点亮 5000/5MHz = 1ms,6 位轮完一圈 6ms,刷新率约 167Hz——远超人眼闪烁感知频率,所以看到的是 6 位「同时」稳定显示。
引脚约束
原文引脚约束(含时钟周期约束注释)如下:
#时钟周期约束
set_pin_assignment { CLOCK } { LOCATION = R7; IOSTANDARD = LVCMOS33; PULLUP; }
set_pin_assignment { DIG[0] } { LOCATION = C15; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[1] } { LOCATION = C16; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[2] } { LOCATION = B6; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[3] } { LOCATION = A5; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[4] } { LOCATION = B5; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[5] } { LOCATION = A8; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[6] } { LOCATION = A7; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[7] } { LOCATION = A6; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[0] } { LOCATION = D11; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[1] } { LOCATION = E11; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[2] } { LOCATION = F10; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[3] } { LOCATION = C13; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[4] } { LOCATION = E10; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[5] } { LOCATION = E12; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[6] } { LOCATION = E16; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[7] } { LOCATION = F16; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { rst } { LOCATION = A2; IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }和上一篇对比:数码管 DIG/SEL 引脚完全相同;不同的是按键换成了单个 rst(复位,低有效,接键 1 的 A2 引脚),不再需要加载和使能按键——这个实验是纯自动计数。
上板流程与现象
编译工程、生成 bit 流并下载到 FPGA 后,直接观察数码管即可:
- 数码管从 0 开始,每 0.1 秒加一,个位满 10 向十位进位,依次类推;
- 按(松开)复位键后计数值清零,重新从 0 累加;
- 数值超过 6 位显示范围后从 999999 归零循环。

数码 1,十分之一秒计数时钟,满 10 进位。
「看起来同时亮」其实是轮流亮
如果用手机相机慢镜头对着数码管拍,你能拍到某一位亮、其余灭的瞬间。若上板后发现数码管有重影或闪烁,通常是每位点亮时间(
MAX_NUM)或驱动时钟分频系数设置不当——点亮时间太短会暗,太长刷新慢会闪。
现象与总结
这一篇真正的收获是两个可复用的设计模式:一是计数器级联/进位思想(本程序用除法取余直接拆位,本质上等价于把多位十进制当成一个整体计数),二是数码管动态扫描(分频驱动时钟 → 1ms 轮流选通 → 段选查表译码)。后面所有要在数码管上显示数值的实验,扫描译码模块都可以原样复用,只需要替换 count 模块。
自测
自测
count模块中MAX_NUM = 23'd5000_000对应多长的累加周期?若想让数值每秒加一,这个参数应改成多少?- 动态扫描为什么必须用 1ms 左右的位切换时间?切换太快或太慢分别会出什么问题?
data5 || point[5]、data4 || point[4]这串判断的目的是什么?当 data = 25 时,num寄存器的高 3 个 4 位会写入什么值?seg_sel为什么复位值和「不显示」状态都是6'b111111?结合位选低电平有效解释。- 若要把计数上限从 999999 改成 99999999(八位),至少需要改动哪些位宽与比较值?请列一份修改清单。