简介:面向FPGA与SOPC初学者的电子钟设计包,围绕NIOS II软核与Verilog混合开发,解决数码管驱动、定时计时、按键调时及闹钟联动的完整实现问题。压缩包共602个文件、约22.66MB,含52个Verilog源文件与30个SystemVerilog文件用于硬件逻辑和显示驱动,78个C源文件、53个头文件对应NIOS II软件程序,另有mif初始化、qsys/sopcinfo系统配置、sof下载文件及Quartus工程中间文件,便于还原和二次编译。已有2296人学习下载,适合电子信息、自动化等专业课程设计及FPGA入门者参考。内容完整呈现1ms定时器计时、8位数码管显示、三按键六模式调节当前时间与闹钟、调整位闪烁、按键LED指示以及蜂鸣器闹钟和流水灯等模块,读者可据此理解软硬件协同分工、状态机与按键处理思路,并对照工程结构完成自己的设计。
1. FPGA 里跑软核做电子钟:这套设计的定位与适用人群
刚开始学习 FPGA 的人,大多经历过这样的过程:先写分频器点亮跑马灯,再用纯硬件计数器堆一个电子钟,代码几十行就能走秒,但一旦要加校时、闹钟、整点报时、串口对时,状态机就会迅速变得非常复杂,改一处牵动全局。这套基于 Nios II 软核的电子钟走的是另一条路:把 CPU 放进 FPGA 内部,用 C 语言写时间逻辑,Verilog 只负责把引脚时序做稳做准,电子钟的核心逻辑因此转到软件层。它适合刚学完 Verilog 语法、想弄清楚 SOPC 软硬件协同到底怎么跑起来的开发者,也适合需要一套完整可复现工程做二次开发底座的人。你拿到的不只是一份能走秒的代码,而是一条从 Quartus II 建工程、SOPC 搭建、Nios II 软件编译到程序固化的完整链路,跑通之后,这块板子就能变成你自己的外设实验平台。
2. 系统架构拆解:Nios II 软核与外设的分工边界
2.1 顶层模块与 SOPC 系统的划分
顶层文件承担两个职责:对外连接开发板的物理引脚,对内例化 SOPC 生成的系统模块。它本身不包含任何业务逻辑,只把端口一一对应上,这样以后更换开发板时,改动范围就限定在顶层和约束文件里。常见做法是把顶层写成纯连线结构,时钟、复位、按键、数码管位选、段选、蜂鸣器这几组信号全部引出,SOPC 模块则以黑匣子形式被例化进来。
端口定义大致是这样:
module clock_top ( input wire clk_50m, // 板载 50MHz 晶振 input wire rst_n, // 低有效复位按键 input wire [3:0] key_n, // 4 个独立按键,低有效 output wire [5:0] seg_sel, // 6 位数码管位选,低有效 output wire [7:0] seg_data, // 段选 a~g + dp output wire buzzer // 无源蜂鸣器 ); wire clk_sys; wire clk_sdram; wire pll_locked; // PLL:50M 输入,输出系统时钟与 SDRAM 相移时钟 pll_core u_pll ( .inclk0 (clk_50m), .c0 (clk_sys), .c1 (clk_sdram), .locked (pll_locked) ); // SOPC 系统:内部含 Nios II 核、RAM、Timer、PIO、JTAG UART clock_sopc u_sopc ( .clk_clk (clk_sys), .reset_reset_n (rst_n & pll_locked), .pio_key_export (key_n), .pio_seg_sel_export (seg_sel), .pio_seg_data_export(seg_data), .pio_beep_export(buzzer) ); endmodule这里需要说明两点。reset_reset_n用rst_n & pll_locked而不是直接接按键,是因为 PLL 锁定之前系统时钟并不稳定,此时释放复位会让 Nios II 核取到无效指令,表现就是下载后程序偶尔不跑、重新上电又正常,这种问题非常隐蔽。另外,SDRAM 的相移时钟由 PLL 单独产生,如果工程里用了外部 SDRAM 存程序,这一路是必须的,否则读写会偶发错误。
软硬件边界怎么划,是这个项目第一个需要决策的地方。我倾向于把实时性强的部分留在硬件:1Hz 秒脉冲、数码管动态扫描、按键消抖,这些工作在纳秒到微秒级,交给 C 代码轮询既浪费 CPU 又不稳。Nios II 软件只做三件事:读消抖后的按键事件、维护时分秒的 BCD 值、把 6 位显示数据写进 PIO。这样软件主循环可以写得很简单,中断源也少,调试时不容易互相干扰。
2.2 时钟、复位与 PLL 的规划
整块板子只用一个 50MHz 晶振,所有时钟都从它派生。PLL 输出两路:clk_sys保持 50MHz 给 SOPC 和逻辑使用,clk_sdram做 -75° 左右相移给 SDRAM 控制器用。为什么不直接用晶振驱动系统?因为晶振走的是全局时钟引脚,直接接入逻辑也能跑,但一旦后面要加 SDRAM、要调时序余量,没有 PLL 就没有调整手段,等于自己放弃了后续可能的优化空间。
复位设计上有个细节值得单独说。按键复位是异步的,但释放是机械抖动,直接拿来复位会引入亚稳态风险。合格的做法是做「异步复位、同步释放」:
module reset_sync ( input wire clk, input wire rst_async_n, output wire rst_sync_n ); reg [1:0] sync; always @(posedge clk or negedge rst_async_n) begin if (!rst_async_n) sync <= 2'b00; else sync <= {sync[0], 1'b1}; end assign rst_sync_n = sync[1]; endmodule这段代码的作用是把复位释放沿对齐到时钟上升沿,避免整块逻辑在同一时刻因为复位信号抖动而进入不同状态。参数上没什么可调的,两级同步寄存器是通用做法,时钟频率高、环境噪声大时可以加到三级,代价是多一个周期的复位延迟,对电子钟这种应用完全可以忽略。
2.3 显示与按键的接口约定
PIO 的位宽和电平约定必须在软硬件两侧对齐,这是最容易出现「代码没错但就是不亮」的地方。段选 8 位对应 a、b、c、d、e、f、g、dp,位选 6 位对应 6 个数码管,共阴数码管段选高电平点亮,共阳则取反。按键 PIO 设为输入,4 位,低有效,硬件侧已经消抖完毕,软件读到 0 就代表按下。
| 信号 | 方向 | 位宽 | 有效电平 | 说明 |
|---|---|---|---|---|
| pio_key | 输入 | 4 | 低有效 | bit0 校时,bit1 校分,bit2 闹钟,bit3 停止闹钟 |
| pio_seg_sel | 输出 | 6 | 低有效 | 位选,同一时刻只拉低一位 |
| pio_seg_data | 输出 | 8 | 高有效 | 段码 a~g + dp |
| pio_beep | 输出 | 1 | 高有效 | 驱动三极管基极,输出 1kHz 方波 |
注意位选是低有效。如果开发板用的是 74HC138 译码器做位选,那 PIO 输出的就是 3 位二进制编码而不是 6 位独热码,这属于硬件差异,必须在 SOPC 里改 PIO 位宽,软件侧的写值也要跟着改,不要拿着别人的段码表直接套。
2.4 存储映射与地址分配的取舍
Nios II 系统里至少要有一块程序存储器。入门板子上通常有两种选择:片上 RAM(on-chip memory)和外部 SDRAM。片上 RAM 容量一般只有几十 KB,跑一个电子钟程序绰绰有余,好处是时序简单、不需要额外控制器、编译综合快;缺点是程序稍大就放不下,而且占用了 FPGA 内部的块 RAM 资源。
我的建议是:先跑通阶段用片上 RAM,把工程验证完,再切到 SDRAM。切换时只需要在 SOPC 里重新分配地址、在 BSP 里重新设置链接脚本,代码一行不用改。如果目标板有 EPCS 配置芯片,程序最终要固化进去,这时候必须走「程序存 EPCS、运行时搬到 SDRAM」的路径,因为片上 RAM 掉电即失,无法固化。
地址分配上,Timer、JTAG UART、各个 PIO 都会被自动分配基地址,手动改过之后务必重新 Generate,否则软件侧的system.h里的宏定义还是旧的,编译能过但读写会指向错误的外设,现象就是按键没反应、显示乱码。
3. 从零跑通工程:Quartus II 到 Nios II 软件工程的完整链路
3.1 建工程、选器件与引脚约束
第一步是建 Quartus II 工程。器件选型必须和手上的板子一致,封装、速度等级、引脚数错一个,后面分配引脚会直接报错。以常见的入门级 Cyclone IV 小板为例,工程向导里填好工程名和顶层实体名,器件家族选对,其余保持默认即可。
引脚约束写在.qsf里,或者用 Pin Planner 图形化分配后自动生成。这里给出约束的写法片段,具体引脚号必须以自己板子的原理图为准:
# 时钟与复位 set_location_assignment PIN_E1 -to clk_50m set_location_assignment PIN_M15 -to rst_n set_location_assignment PIN_M2 -to key_n[0] # 数码管段选 a~g,dp set_location_assignment PIN_A4 -to seg_data[0] set_location_assignment PIN_B4 -to seg_data[1] # 位选 set_location_assignment PIN_D6 -to seg_sel[0]约束里还要补上 IO 标准,一般 3.3V LVTTL:
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[*]这里有一条实际经验:时钟引脚必须放在专用时钟管脚上,否则 Quartus 会在编译时报「引脚不能驱动全局时钟网络」的警告,虽然有时能编译通过,但时钟偏斜变大,后面调试显示闪烁时你会怀疑到代码头上。约束写完之后先别急着加逻辑,跑一次全编译确认引脚无冲突,这一步花两分钟,能省掉后面半小时的困惑。
3.2 SOPC 系统搭建与地址分配
老版本 Quartus 里叫 SOPC Builder,新版本叫 Qsys 或 Platform Designer,本质是一个模块化的系统集成器,把 Nios II 核和各种外设连到 Avalon 总线上。这个工程需要的组件不多,列清楚:
- Nios II 处理器,选经济型(Nios II/e)即可,电子钟不需要硬件乘除法
- On-chip Memory,容量 32KB~64KB,作为程序和数据区
- Interval Timer,周期设为 1ms,用于软件侧的时基
- PIO,16 位以内分四组:按键 4 位输入、段选 8 位输出、位选 6 位输出、蜂鸣器 1 位输出
- JTAG UART,用于
printf调试输出和程序下载 - System ID,用于下载时校验软硬件匹配
系统搭好之后,每个组件的自动分配地址点一下,然后 Generate。生成过程会输出clock_sopc.v、clock_sopc.qip和一份.sopcinfo,后者是软件工程的输入。这里要留意一个顺序:SOPC 生成之后,顶层例化的端口名必须和生成的模块完全一致,多一个空格都会导致综合时找不到端口。
中断号的分配也要看一眼。如果打算用 Timer 中断驱动时基,IRQ 编号得记住,软件侧alt_ic_isr_register里的参数要用。不做中断、纯轮询也能跑,但轮询方式下按键响应会有 20ms 左右的抖动感,体验差一截。
3.3 BSP 与应用程序编译下载
打开 Nios II Software Build Tools for Eclipse,新建 Nios II Application and BSP from Template,指向刚才的.sopcinfo,CPU 选cpu,工程模板选 Hello World 即可。BSP 建好之后有几步必须设置:
第一,stdout、stderr指向jtag_uart,否则printf没有任何输出,调试时你会以为程序没跑起来。第二,链接脚本里的.text、.rodata、.rwdata、.heap、.stack全部指向 on-chip memory,不要留默认值。第三,如果后面要固化到 EPCS,需要额外勾选「enable alt_load」,让程序在启动时从 EPCS 搬到 RAM。
主程序跑通的最小验证是这样:
#include <stdio.h> #include "system.h" #include "altera_avalon_pio_regs.h" int main(void) { printf("[clock] boot ok, sys clk = %d Hz\n", ALT_CPU_FREQ); while (1) { // 读按键 PIO,硬件侧已消抖,读到 0 表示按下 alt_u32 key = IORD_ALTERA_AVALON_PIO_DATA(PIO_KEY_BASE); // 写段码,先固定显示 12:34:56 验证接线 IOWR_ALTERA_AVALON_PIO_DATA(PIO_SEG_DATA_BASE, 0x3F); IOWR_ALTERA_AVALON_PIO_DATA(PIO_SEG_SEL_BASE, 0x3E); if ((key & 0x01) == 0) { printf("key0 pressed\n"); } } return 0; }IORD_ALTERA_AVALON_PIO_DATA和IOWR_ALTERA_AVALON_PIO_DATA是 PIO 的标准读写宏,参数是 BSP 里system.h生成的基地址宏,不要手写地址。ALT_CPU_FREQ是 CPU 频率宏,能打印出正确值说明 BSP 配置没问题。
编译通过后,右键工程 Run As → Nios II Hardware。这一步有几个常见失败:报Target FPGA is not configured,说明 Quartus 那边还没下载.sof,先下硬件再跑软件;报System ID mismatch,说明软件工程和当前硬件不匹配,通常是改了 SOPC 没重新生成 BSP;报Unable to communicate with JTAG,检查下载器驱动和板子供电。这三条覆盖了九成下载失败的情况。
3.4 程序固化到配置芯片
能在线跑起来只是第一步,掉电之后程序就没了。要让它上电自动运行,得把.sof和.elf合成一个.jic文件写进 EPCS。
流程是:Quartus 里打开 Convert Programming Files,输出格式选 JTAG Indirect Configuration File,配置器件选板子上的 EPCS 型号,然后在 Input files 里添加.sof,再在 Properties 里挂上 Nios II 的.elf,生成.jic。之后用 Programmer 加载.jic,勾选 Program/Configure,烧写完成后断电重启。
有个坑值得提前说:.elf挂进.jic的前提是 BSP 里开启了alt_load,并且程序段确实被转换成了 hex 段。如果固化后上电不跑、但在线下载正常,八成是这里漏了。另一个检查点是 MSEL 拨码开关,不同配置方式的 MSEL 组合不一样,拨错了板子根本不会从 EPCS 读配置。
4. 核心代码拆解:计时进位、消抖与数码管扫描
4.1 1Hz 使能脉冲与使能式设计
电子钟的心跳是一个 1Hz 信号。新手很容易写成「分频出一个 1Hz 时钟,然后拿它当时钟去驱动计数器」,这在 FPGA 里是要尽量避免的情况,因为它产生了一个新的时钟域,工具会把它当普通信号走线而不是全局时钟网络,偏斜大、时序报告难看。
正确写法是用使能脉冲:
module tick_gen #( parameter integer CLK_FREQ = 50_000_000, // 输入时钟频率 parameter integer TICK_HZ = 1 // 需要的脉冲频率 )( input wire clk, input wire rst_n, output reg tick // 单周期高脉冲 ); localparam integer CNT_MAX = CLK_FREQ / TICK_HZ; reg [31:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 32'd0; tick <= 1'b0; end else if (cnt == CNT_MAX - 1) begin cnt <= 32'd0; tick <= 1'b1; // 只拉高一个周期 end else begin cnt <= cnt + 32'd1; tick <= 1'b0; end end endmoduleCNT_MAX由参数算出,换板子只改CLK_FREQ一个数,不用动逻辑。tick在计数到顶时拉高一个周期,后面的计时模块用if (tick)来推进,整个系统仍然只有clk一个时钟域,时序分析干干净净。除了 1Hz,这个模块还能复用产生 1kHz 的扫描节拍和 1kHz 的蜂鸣方波,改TICK_HZ就行,例化三次比写三遍分频器省事得多。
4.2 BCD 计时进位链
时间用 BCD 码存,每一位十进制数字占 4 个二进制位,好处是显示时不用做二进制到十进制的除法,直接查段码表就行。代价是进位逻辑要单独写。
// BCD 自增:低四位到 9 归零并向高四位进位 function [7:0] bcd_inc; input [7:0] v; begin if (v[3:0] == 4'd9) bcd_inc = {v[7:4] + 4'd1, 4'd0}; else bcd_inc = {v[7:4], v[3:0] + 4'd1}; end endfunction always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sec <= 8'h00; min <= 8'h00; hour <= 8'h00; end else if (tick_1hz) begin if (sec == 8'h59) begin sec <= 8'h00; if (min == 8'h59) begin min <= 8'h00; hour <= (hour == 8'h23) ? 8'h00 : bcd_inc(hour); end else begin min <= bcd_inc(min); end end else begin sec <= bcd_inc(sec); end end end注意bcd_inc是纯组合函数,没有时钟,可以放心在时序块里调用。小时的边界判断用8'h23而不是8'h24,因为 23 是最后一个合法值,到 23 之后归零。这里有个经典错误:有人把sec == 8'h59写成sec[3:0] == 4'd9 && sec[7:4] == 4'd5,逻辑上等价但可读性差,而且一旦改成 60 进制以外的进制就全乱。
还有一个隐藏问题:校时的时候如果直接改sec寄存器,正在跑的进位链可能在同一个周期把它覆盖掉。稳妥做法是给校时单独一条赋值分支,优先级高于正常进位:
if (adj_en) begin // 校时优先,按下一次加一 hour <= (hour == 8'h23) ? 8'h00 : bcd_inc(hour); end else if (tick_1hz) begin // 正常进位 end4.3 按键消抖与校时状态机
机械按键按下时会有 5~20ms 的抖动,直接进逻辑会一次按下触发好几次。消抖放在硬件里做,软件侧拿到的就是干净的单周期脉冲。
module key_debounce #( parameter integer CLK_FREQ = 50_000_000, parameter integer DEBOUNCE_MS = 20 )( input wire clk, input wire rst_n, input wire key_in, // 低有效 output reg key_press // 按下瞬间输出单周期高脉冲 ); localparam integer CNT_MAX = CLK_FREQ / 1000 * DEBOUNCE_MS; reg [1:0] sync; // 两级同步,消除亚稳态 reg key_stable; // 稳定后的键值 reg key_d; // 上一拍稳定值 reg [31:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) sync <= 2'b11; else sync <= {sync[0], key_in}; end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; key_stable <= 1'b1; end else if (sync[1] != key_stable) begin if (cnt == CNT_MAX - 1) begin cnt <= 0; key_stable <= sync[1]; // 稳定 20ms 后才认 end else begin cnt <= cnt + 1'b1; end end else begin cnt <= 0; // 中途抖动,计数清零重来 end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_d <= 1'b1; key_press <= 1'b0; end else begin key_d <= key_stable; key_press <= key_d & ~key_stable; // 下降沿即按下 end end endmodulesync两级寄存是为了防止按键信号是异步输入带来的亚稳态。cnt到CNT_MAX - 1才更新稳定值,中途任何抖动都会把计数清零,等于重新计时 20ms,这样误触发的概率极低。key_press只在一拍为高,软件写if (key_press)就是「按了一次」。
校时的状态机放在软件里更合适,因为这里涉及长按连加、超时退出这些逻辑,C 语言写起来直观:
// hour_adj: 0=正常走时, 1=调时, 2=调分 static int hour_adj = 0; static alt_u32 last_ms = 0; void clock_poll_key(alt_u32 key_press, alt_u32 now_ms) { if (key_press & 0x01) { // key0:切换调整项 hour_adj = (hour_adj + 1) % 3; last_ms = now_ms; } if (key_press & 0x02) { // key1:+1 if (hour_adj == 1) adj_hour(1); if (hour_adj == 2) adj_min(1); } // 5 秒无操作自动退回正常走时,擦掉后悔药 if (hour_adj != 0 && (now_ms - last_ms) > 5000) { hour_adj = 0; } }now_ms由 Timer 每毫秒自增维护,last_ms记录最后一次操作时刻。这个自动退出机制看着不起眼,但少了它,用户调完时忘了按键切回去,钟就停在原地不动,是实物调试时最常被抱怨的问题之一。
4.4 数码管动态扫描与整点报时
六个数码管共用一套段选线,靠位选快速轮换来「同时」显示。刷新频率太低会闪,太高会损失亮度,1kHz 左右比较合适,也就是每位约 166Hz。
module seg_scan #( parameter integer CLK_FREQ = 50_000_000, parameter integer SCAN_HZ = 1000 )( input wire clk, input wire rst_n, input wire [23:0] bcd, // {hour[7:0], min[7:0], sec[7:0]} output reg [5:0] sel, // 位选,低有效 output reg [7:0] seg // 段选,高有效 ); localparam integer ONE_MS = CLK_FREQ / SCAN_HZ; reg [31:0] cnt; reg [2:0] pos; // 1ms 节拍切换一位 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; pos <= 0; end else if (cnt == ONE_MS - 1) begin cnt <= 0; pos <= (pos == 3'd5) ? 3'd0 : pos + 3'd1; end else begin cnt <= cnt + 1'b1; end end // 当前位对应的 BCD 数字 reg [3:0] digit; always @(*) begin case (pos) 3'd0: digit = bcd[3:0]; // 秒个位 3'd1: digit = bcd[7:4]; // 秒十位 3'd2: digit = bcd[11:8]; // 分个位 3'd3: digit = bcd[15:12]; // 分十位 3'd4: digit = bcd[19:16]; // 时个位 default: digit = bcd[23:20]; endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sel <= 6'b111111; seg <= 8'h00; end else begin sel <= ~(6'b000001 << pos); // 只拉低当前位 case (digit) 4'd0: seg <= 8'h3F; 4'd1: seg <= 8'h06; 4'd2: seg <= 8'h5B; 4'd3: seg <= 8'h4F; 4'd4: seg <= 8'h66; 4'd5: seg <= 8'h6D; 4'd6: seg <= 8'h7D; 4'd7: seg <= 8'h07; 4'd8: seg <= 8'h7F; default: seg <= 8'h6F; endcase end end endmodule段码表是共阴数码管的编码,共阳要把每一位取反。sel的移位取反保证同一时刻只有一位为低,避免两位同时点亮导致重影。这里不需要额外消隐,因为sel和seg在同一拍更新,切换瞬间的毛刺会被数码管的余晖盖住;如果发现切换处有微弱窜亮,可以在位选切换后延迟几个周期再更新段选。
整点报时比想象中简单,把分、秒都为零的时刻取出来,让蜂鸣器响一段时间:
// 整点判断:分=0 且 秒<2,响 1 秒 wire hour_chime = (min == 8'h00) && (sec < 8'h02); // 1kHz 方波驱动无源蜂鸣器 reg [15:0] beep_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) beep_cnt <= 16'd0; else if (tick_1k) beep_cnt <= beep_cnt + 16'd1; end assign buzzer = hour_chime & beep_cnt[0];用计数器最低位取反产生方波,占空比天然是 50%,无源蜂鸣器发声最响。这里要注意判断条件用sec < 8'h02而不是sec == 8'h00,因为tick_1hz只有一拍,如果逻辑里存在别的分支延后了响应,单拍判断可能漏掉,给两个数的窗口更稳。
5. 避坑与排查:六个让人熬夜的典型故障
现象一:下载.sof后数码管全亮,程序像是没跑。原因通常是复位没释放或者 PLL 没锁定。顶层把reset_reset_n直接接了按键,按下才释放,而板子的按键是常开低有效,上电时逻辑一直处于复位态。解决办法是按前面 2.2 节的写法接rst_n & pll_locked,并在 Quartus 的 SignalTap 里挂上看pll_locked是否真的拉高;如果一直为低,检查 PLL 的输入时钟引脚是不是分到了专用时钟管脚。
现象二:Nios II 程序编译通过,Run As 时提示 System ID mismatch。原因是软件工程的 BSP 是基于旧的.sopcinfo生成的,硬件改了 PIO 位宽或加了组件之后没重新生成 BSP。解决方式是右键 BSP 工程选 Nios II → Generate BSP,然后 Clean 整个工程重新编译。养成习惯:只要动过 SOPC,就先重建 BSP 再编译应用,能省掉大量无谓的怀疑。
现象三:数码管显示正确但一直在闪,或者亮度明显偏暗。扫描频率算错了。有人把SCAN_HZ当成每位刷新率,实际是整个六位的循环率,结果每位只有几十赫兹,肉眼就能看到闪烁。按 4.4 节的写法,ONE_MS = CLK_FREQ / SCAN_HZ得到的是每一位的驻留时间,六位循环一轮是 6ms,对应 166Hz,这个值才是应该核对的。亮度不够则是限流电阻选大了,或者段选驱动能力不足,需要加三极管或驱动芯片,不是代码问题。
现象四:按键要按好几下才有反应,或者一次按下加了好几。消抖模块的CNT_MAX算错了。CLK_FREQ / 1000 * DEBOUNCE_MS里如果CLK_FREQ和实际时钟不一致(比如 PLL 倍频到了 100MHz 但参数还是 50M),消抖时间就只有一半,机械抖动没滤干净。另一种可能是按键 PIO 的电平约定搞反了,硬件侧按低有效消抖,软件侧却按高有效判断,表现就是按下没反应、松开才触发。用 SignalTap 抓key_press的波形,一眼就能看出来。
现象五:程序在线跑得好好的,固化到 EPCS 后上电不启动。三个检查点依次排查。第一,BSP 有没有开alt_load,没开的话程序段没被搬进.jic。第二,MSEL 拨码开关是不是配置成了从 EPCS 启动的模式,不同板子组合不同,得查原理图。第三,.jic生成时.elf是不是挂在了正确的位置,有些人只加了.sof忘了挂软件,烧进去当然只有硬件没有程序。这三条按顺序过一遍,基本能定位。
现象六:整点报时偶尔不响,或者响了不停。判断条件写成了单拍相等比较,sec == 8'h00只在一瞬间成立,如果那一拍正好被别的分支占用就漏掉了。另一种相反的情况是用了min == 8'h00却没限制秒,导致整整一分钟都在响。用窗口判断sec < 8'h02并把条件锁定一个完整的秒周期,是比较稳妥的做法。蜂鸣器如果是无源的,还要确认驱动频率在它的谐振点附近,1kHz 到 2.7kHz 之间因型号而异,频率偏了声音会很小,容易被误判成「没响」。
6. 二次开发:把电子钟拆成可复用的时间基准与外设框架
跑通之后最有价值的动作,不是继续往电子钟上加功能,而是把里面能复用的部分抽出来。这套工程里至少有三块是通用的:tick_gen能产生任意频率的使能脉冲,key_debounce能处理任意数量的按键,seg_scan稍加改动就能支持不同位数的数码管。把它们整理成一个ip/目录,下次做别的项目直接拷过去,比每次重写省事得多。
改造的第一步是参数化。把段码表从case语句里挪出来,改成从外部传入的查找表:
module seg_scan #( parameter integer CLK_FREQ = 50_000_000, parameter integer SCAN_HZ = 1000, parameter integer DIGITS = 6, // 数码管位数 parameter [7:0] SEG_TABLE [0:9] = '{ 8'h3F, 8'h06, 8'h5B, 8'h4F, 8'h66, 8'h6D, 8'h7D, 8'h07, 8'h7F, 8'h6F } )( input wire clk, input wire rst_n, input wire [DIGITS*4-1:0] bcd, output reg [DIGITS-1:0] sel, output reg [7:0] seg );DIGITS改成 8 就能支持八位数码管,换成共阳只需把SEG_TABLE按位取反,逻辑一行不用动。sel的位宽跟着参数走,pos的位宽也要相应改成$clog2(DIGITS),避免位数不够导致循环错位。
第二步是把软硬件接口抽象成一套「寄存器表」。现在的做法是软件直接读写 PIO 基地址,外设一多就散得到处都是。更好的方式是定义一个固定偏移的寄存器映射,软件侧封装成clock_read_time()、clock_set_time()、clock_get_key_event()三五个函数,底层走 PIO 还是走 Avalon 内存映射的寄存器,上层不关心。这样以后把硬件计时换成软件计时、或者把显示换成 LCD 模块,改的只是那一层封装。
| 改造方向 | 改动位置 | 验证方法 |
|---|---|---|
| 增加日期与星期 | 4.2 节进位链、段码扫描扩位 | 手动把日期改到 12 月 31 日 23:59,看是否跨年 |
| 换 LCD1602 显示 | 删掉seg_scan,加 LCD 驱动 IP | 用 JTAG UART 打印一份同样的内容做对照 |
| 加串口对时 | SOPC 里加 UART,软件加解析 | 电脑端发一帧时间,看秒是否被校正 |
| 加掉电保存 | I2C 接 EEPROM,软件读写 | 断电 10 秒再上电,比对保存值 |
验证方法这块我特别想强调一点:不要只看「看起来对了」。跨年、闰日、校时越界这些边界,肉眼看一天也遇不上,写个临时的调试按键把时间直接设到 23:59:55,盯着它走完那五秒,比什么都可靠。
最后说个我自己的习惯。从那以后,我每次改完 SOPC 的地址分配,都强制走一遍「重新 Generate → 重建 BSP → Clean 工程 → 全编译 → 下载验证」的完整流程,哪怕只改了一个 PIO 的位宽。这五步看着繁琐,但它把所有「软件和硬件对不上」的问题都挡在了下载之前,比事后拿着 SignalTap 一层层追信号强太多。希望帮到你。
本文还有配套的精品资源,点击获取