1. 项目概述与设计思路
数字时钟是FPGA学习中最经典的实战项目之一,它完美融合了时序逻辑设计、计数器应用和显示控制等多个核心概念。我自己刚开始学FPGA的时候,第一个完整项目就是数字时钟,当时踩了不少坑,但也收获巨大。这个项目特别适合有一定Verilog基础,想要提升模块化设计能力的开发者。
整个设计的核心思路很简单:我们需要一个精确的时钟源,通过分频器产生1秒的基准信号,然后用计数器累计秒数、分钟和小时。最后通过数码管动态扫描技术,让这些数字能够清晰地显示出来。听起来不难对吧?但实际实现时会遇到很多细节问题,比如时钟分频的精度、计数器的进位逻辑、数码管的消影处理等等。
我建议采用模块化设计方法,将系统分为五个主要模块:分频器模块、秒计数器模块、分钟计数器模块、小时计数器模块,以及数码管显示驱动模块。这种设计思路不仅使代码结构清晰,更重要的是便于调试和功能扩展。比如后期你想添加闹钟功能或者日期显示,只需要在现有基础上增加相应模块即可。
2. 时钟分频器设计详解
时钟分频是整个系统的基础,也是最容易出错的地方。FPGA开发板通常提供50MHz或100MHz的系统时钟,而我们需要的是1Hz的秒时钟信号。这就需要进行精确的分频计算。
以常见的50MHz时钟为例,一个时钟周期是20ns。要得到1秒的周期,我们需要计数50,000,000个时钟周期。在Verilog中,我们需要一个足够位宽的计数器来实现这个分频:
module clock_divider (
input clk, // 50MHz系统时钟
input rst_n, // 复位信号
output reg clk_1s // 1秒时钟输出
);
reg [25:0] counter; // 26位计数器,最大计数67,108,863
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
counter <= 0;
clk_1s <= 0;
end else if (counter == 26'd49_999_999) begin
counter <= 0;
clk_1s <= ~clk_1s; // 翻转产生1Hz时钟
end else begin
counter <= counter + 1;
end
end
endmodule
这里有个实用技巧:我习惯使用26位计数器而不是25位,虽然25位理论上足够计数到33,554,431,但留出一些余量可以让代码更加健壮。在实际调试中,我发现有时候由于时序问题,计数器可能会偶尔漏计数,所以适当增加位宽是个好习惯。
另一个重要细节是时钟翻转的条件判断。一定要使用==比较而不是>=,否则在计数器溢出时会出现不可预知的行为。我曾经在这个细节上浪费了好几个小时调试,希望你们能避开这个坑。
3. 计数器模块设计与实现
计数器是数字时钟的核心,我们需要设计六十进制的秒计数器、六十进制的分钟计数器和二十四进制的小时计数器。这三个模块的设计思路相似,但各有其特点。
先来看秒计数器模块的设计:
module second_counter (
input clk_1s, // 1秒时钟输入
input rst_n, // 复位信号
output reg [5:0] sec_count, // 秒计数值
output reg min_pulse // 分钟进位脉冲
);
always @(negedge clk_1s or negedge rst_n) begin
if (!rst_n) begin
sec_count <= 0;
min_pulse <= 0;
end else begin
if (sec_count == 6'd59) begin
sec_count <= 0;
min_pulse <= 1; // 产生分钟进位脉冲
end else begin
sec_count <= sec_count + 1;
min_pulse <= 0;
end
end
end
endmodule
这里我使用了negedge触发而不是posedge,这是为了避免时钟竞争条件。在实际的硬件设计中,时钟边沿的选择对系统稳定性有很大影响。分钟和小时计数器的设计类似,但需要注意进位逻辑的处理。
分钟计数器模块需要接收秒计数器产生的进位脉冲:
module minute_counter (
input clk_1s, // 系统时钟用于同步
input min_pulse, // 分钟进位信号
input rst_n,
output reg [5:0] min_count,
output reg hour_pulse
);
always @(negedge clk_1s or negedge rst_n) begin
if (!rst_n) begin
min_count <= 0;
hour_pulse <= 0;
end else if (min_pulse) begin // 检测到进位脉冲
if (min_count == 6'd59) begin
min_count <= 0;
hour_pulse <= 1;
end else begin
min_count <= min_count + 1;
hour_pulse <= 0;
end
end else begin
hour_pulse <= 0;
end
end
endmodule
这种设计确保了计数器的同步性,避免了异步逻辑可能带来的亚稳态问题。小时计数器的设计思路类似,只是模数变为24。
4. 数码管显示原理与驱动设计
数码管显示是项目中最直观的部分,也是调试时最容易看到效果的部分。常见的数码管有共阳极和共阴极两种,我们的开发板通常是共阳极的,这意味着段选信号低电平有效,位选信号也是低电平有效。
动态扫描的原理是利用人眼的视觉暂留效应,快速轮流点亮每个数码管。只要刷新频率足够高(通常大于50Hz),人眼就会认为所有数码管同时点亮。这种设计可以大大减少IO口的使用,否则每个数码管都需要独立的8个IO口。
首先我们需要设计数码管的位选控制:
// 数码管位选控制
reg [1:0] scan_counter; // 4个数码管需要2位计数器
reg [3:0] digit_select; // 位选信号
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
scan_counter <= 0;
end else if (clk_1ms) begin // 使用1ms时钟进行扫描
scan_counter <= scan_counter + 1;
end
end
always @(*) begin
case (scan_counter)
2'b00: digit_select = 4'b1110; // 选中第一个数码管
2'b01: digit_select = 4'b1101; // 选中第二个数码管
2'b10: digit_select = 4'b1011; // 选中第三个数码管
2'b11: digit_select = 4'b0111; // 选中第四个数码管
endcase
end
段选信号需要根据当前显示的数字来生成。我们需要一个译码器将4位二进制数转换为7段数码管的控制信号:
// 七段数码管译码器
always @(*) begin
case (display_data)
4'h0: segment = 8'b0000_0010; // 显示0
4'h1: segment = 8'b1001_1110; // 显示1
4'h2: segment = 8'b0010_0100; // 显示2
4'h3: segment = 8'b0000_1100; // 显示3
4'h4: segment = 8'b1001_1000; // 显示4
4'h5: segment = 8'b0100_1000; // 显示5
4'h6: segment = 8'b0100_0000; // 显示6
4'h7: segment = 8'b0001_1110; // 显示7
4'h8: segment = 8'b0000_0000; // 显示8
4'h9: segment = 8'b0000_1000; // 显示9
default: segment = 8'b1111_1111; // 全灭
endcase
end
在实际项目中,数码管的亮度调节是个实用技巧。可以通过调整扫描频率或使用PWM来控制每个数码管的点亮时间,从而调节整体亮度。我建议扫描频率设置在200-500Hz之间,这样既不会有闪烁感,亮度也足够。
5. 数字提取与数据分配策略
计数器输出的是二进制数值,而数码管需要显示的是十进制数字。我们需要将秒和分钟的计数值分解为十位和个位,这个过程称为数字提取。
对于秒计数器的6位输出,我们需要将其转换为两个4位的BCD码(十位和个位)。这里我推荐使用查找表的方式,虽然会占用一些逻辑资源,但代码简单可靠:
// 秒数值分解
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sec_ones <= 0;
sec_tens <= 0;
end else begin
case (sec_count)
6'd0: begin sec_ones <= 0; sec_tens <= 0; end
6'd1: begin sec_ones <= 1; sec_tens <= 0; end
// ... 2-9
6'd10: begin sec_ones <= 0; sec_tens <= 1; end
6'd11: begin sec_ones <= 1; sec_tens <= 1; end
// ... 一直到59
6'd59: begin sec_ones <= 9; sec_tens <= 5; end
default: begin sec_ones <= 0; sec_tens <= 0; end
endcase
end
end
虽然看起来需要写很多case语句,但实际上Verilog综合器会优化这个结构,不会真的生成60个比较器。对于分钟计数器也是同样的处理方式。
小时显示的处理稍有不同,原始要求是用LED灯以二进制形式显示小时数。这个相对简单,只需要将小时计数器的输出直接连接到LED引脚即可:
assign hour_led = hour_count; // 将小时数直接输出到LED
数据分配模块需要将四个数码管的数据整合到一个总线中,并根据扫描计数器的值选择当前要显示的数据:
// 数据总线分配
wire [15:0] data_bus;
assign data_bus = {min_tens, min_ones, sec_tens, sec_ones};
// 根据扫描计数器选择当前显示的数据
reg [3:0] current_data;
always @(*) begin
case (scan_counter)
2'b00: current_data = data_bus[3:0]; // 秒个位
2'b01: current_data = data_bus[7:4]; // 秒十位
2'b10: current_data = data_bus[11:8]; // 分个位
2'b11: current_data = data_bus[15:12]; // 分十位
endcase
end
这种设计使得显示逻辑非常清晰,每个数码管显示什么数据一目了然。在实际调试时,你可以通过修改data_bus的分配来快速调整显示内容。
6. 顶层模块设计与系统集成
顶层模块的作用是将各个子模块连接起来,形成完整的数字时钟系统。好的顶层设计应该清晰简洁,主要完成模块实例化和信号连接。
module digital_clock_top (
input clk, // 50MHz系统时钟
input rst_n, // 复位按钮
output [3:0] sel, // 数码管位选
output [7:0] seg, // 数码管段选
output [4:0] led // 小时显示LED
);
// 内部连接信号定义
wire clk_1s;
wire [5:0] sec_count;
wire [5:0] min_count;
wire [4:0] hour_count;
wire min_pulse;
wire hour_pulse;
// 模块实例化
clock_divider u_clock_divider (
.clk(clk),
.rst_n(rst_n),
.clk_1s(clk_1s)
);
second_counter u_second_counter (
.clk_1s(clk_1s),
.rst_n(rst_n),
.sec_count(sec_count),
.min_pulse(min_pulse)
);
minute_counter u_minute_counter (
.clk_1s(clk_1s),
.min_pulse(min_pulse),
.rst_n(rst_n),
.min_count(min_count),
.hour_pulse(hour_pulse)
);
hour_counter u_hour_counter (
.clk_1s(clk_1s),
.hour_pulse(hour_pulse),
.rst_n(rst_n),
.hour_count(hour_count)
);
display_driver u_display_driver (
.clk(clk),
.rst_n(rst_n),
.sec_count(sec_count),
.min_count(min_count),
.hour_count(hour_count),
.sel(sel),
.seg(seg),
.led(led)
);
endmodule
在顶层设计中,信号命名的一致性很重要。我习惯使用u_作为实例化模块的前缀,这样在阅读代码时能够快速区分模块实例和信号线。
7. 常见问题与调试技巧
在实际实现过程中,你可能会遇到各种问题。我总结了一些常见的问题和解决方法,希望能帮你节省调试时间。
数码管显示闪烁或亮度不均:这通常是扫描频率不合适导致的。建议使用1ms的扫描时钟,每个数码管点亮1ms,这样刷新频率就是250Hz(4个数码管)。如果亮度不够,可以增加点亮时间,但要注意刷新频率不能低于50Hz,否则会有闪烁感。
显示数字错误或乱码:首先检查七段译码表是否正确,特别是共阳极和共阴极的区分。然后检查数字提取逻辑,确保十位和个位分解正确。可以使用SignalTap或类似工具抓取data_bus信号,看每个数码管对应的数据是否正确。
计数器不进位或进位错误:这通常是时钟边沿或使能信号的问题。确保进位脉冲的宽度足够被下一个模块捕获。我建议使用系统时钟来同步所有的使能信号,避免异步设计。
资源占用过多:如果发现FPGA资源不够用,可以优化一些设计。比如用算术运算代替查找表来实现数字提取,或者使用时序逻辑而不是组合逻辑来实现译码器。
时序违例:在高速时钟下可能会出现时序问题。可以通过插入寄存器来缩短关键路径,或者使用时钟使能而不是分频时钟。现代FPGA设计更推荐使用时钟使能而不是分频时钟,这样有利于时序分析。
调试时的一个重要技巧是分模块验证。先确保每个模块单独工作正常,再逐步集成。比如先测试分频器是否能产生准确的1Hz时钟,再测试计数器功能,最后再集成显示模块。
另一个实用技巧是使用LED作为调试指示灯。你可以将关键信号(如进位脉冲、时钟信号)连接到LED上,通过观察LED的闪烁情况来判断信号是否正常。这种方法虽然原始,但非常有效。
8. 功能扩展与进阶设计
完成基本功能后,你可能会想要添加更多功能。这里介绍几个常见的扩展方向:
闹钟功能:添加时间比较器和报警输出。可以设计一个闹钟设置模块,当时钟时间与闹钟设置时间匹配时,触发报警信号。
日期显示:扩展计数器模块,增加天、月、年的计数。需要注意不同月份的天数差异和闰年处理。
亮度调节:使用PWM控制数码管亮度。可以通过按键调节占空比,实现亮度的分级控制。
串口配置:添加UART模块,可以通过电脑串口工具设置时间和闹钟。
红外遥控:添加红外接收模块,使用遥控器控制时钟功能。
以闹钟功能为例,我们需要增加闹钟设置寄存器和时间比较器:
module alarm_clock (
input clk,
input rst_n,
input [4:0] hour_set,
input [5:0] min_set,
input [5:0] current_hour,
input [5:0] current_min,
output reg alarm_output
);
reg [4:0] alarm_hour;
reg [5:0] alarm_min;
// 闹钟时间设置
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
alarm_hour <= 0;
alarm_min <= 0;
end else if (set_enable) begin
alarm_hour <= hour_set;
alarm_min <= min_set;
end
end
// 时间比较
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
alarm_output <= 0;
end else if ((current_hour == alarm_hour) &&
(current_min == alarm_min)) begin
alarm_output <= 1;
end else begin
alarm_output <= 0;
end
end
endmodule
这些扩展功能不仅能让你的数字时钟更加实用,也是学习更复杂FPGA设计的好机会。每个扩展都可以作为一个独立的模块来设计和测试,最后再集成到主系统中。
181



被折叠的 条评论
为什么被折叠?



