ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Nios II软核的FPGA电子钟设计与软硬协同实现

基于Nios II软核的FPGA电子钟设计与软硬协同实现

简介:面向FPGA与Nios II学习者及课程设计开发者,这份电子钟工程以Verilog与SOPC协同实现为核心,可用于理解软硬件划分、定时器计时和外设驱动方法。设计采用Qsys生成的1ms定时器计时,8位数码管显示时间,3个按键完成正常显示、调时、调分、调秒及闹钟小时/分钟设置,并加入调整位闪烁、按键对应LED指示,以及蜂鸣器闹钟和流水灯提示,功能链路较完整。压缩包共602个文件、约22.66MB,包含Verilog源码、C/H程序、Nios II工程配置、Quartus与SOPC相关工程文件、MIF初始化数据及编译输出等,便于对照硬件驱动与软件逻辑。已有2296人学习下载,适合作为FPGA数字系统课程设计、SOPC入门实验和答辩展示的参考工程。

1. 从一道"为什么还要塞软核"的题开始

拿到"基于 Nios II 的电子钟"这道题,多数人第一反应是:走时逻辑用一百多行 Verilog 状态机就写完了,为什么还要在 FPGA 里塞一个软核?这个反问恰好是设计的分水岭。只做走时,纯硬件确实省资源;但按键长按短按、闹钟比对、整点报时响法、12/24 小时切换,每改一条需求,纯 Verilog 就要重新综合、布局布线、下载,一轮十几分钟。把这些挪进 Nios II 的 C 代码,改完只重编软件,几秒就能验证。这套方案处理的就是这条软硬边界:FPGA 承担分频、扫描、消抖和并口时序,软核承担状态与交互,从系统搭建、Verilog 模块、中断走时、校时状态机一路讲到验收测试。适合正在做 FPGA 课程设计或毕设、板子已经在手但软硬怎么切还没定的人。

2. 总体架构设计:软硬划分、SOPC 系统与 1Hz 时基

电子钟这个题目看着小,架构上却有一个绕不开的问题:时间和显示到底谁说了算。定错了,后面要么通信开销大得离谱,要么改动一处就得全盘重来。这一章先把职责切清楚,再把系统搭起来。

2.1 哪些逻辑交给硬件,哪些留给软核

我一般用一条判据来切:需要确定时序、不能被其他任务打断的,放硬件;需要改参数、加分支、调交互的,放软件。

按这条判据,落到电子钟上是这样的:

功能归属理由
50MHz 到 1kHz 扫描时钟分频硬件纯计数,时序确定,软件做反而占中断
六位数码管动态扫描与消隐硬件刷新必须连续稳定,软件刷新会被主循环延迟拖出闪烁
按键同步与 20ms 消抖硬件亚稳态处理必须在时钟域内完成
蜂鸣器方波产生硬件2kHz 方波靠软件翻转会抖
时分秒计数与进位软件逻辑简单但规则会变(12/24 小时、闰秒处理)
校时状态机软件分支多、要配合按键长短按,改起来最频繁
闹钟比对与响铃策略软件响几声、响多久、按哪颗键停,全是策略
显示数据打包软件格式跟着显示模式走

有两个典型的错误分法值得说。第一种是把数码管扫描放进 1ms 定时器中断里做,六位数码管就意味着中断里要循环六次写段码和位选,中断执行时间被拉长到几十微秒,主循环稍微一忙就开始丢拍,屏幕出现肉眼可见的暗区。第二种是把校时逻辑写成 Verilog 状态机,看起来"纯硬件很酷",但老师一句"再加个长按 3 秒恢复默认"就得改代码重新综合一次,答辩前根本折腾不起。

还有一点容易忽略:软硬之间的接口要窄。软件不该直接去操作段选和位选引脚,硬件对外只暴露一个 24 位的 BCD 数据寄存器和一两个控制寄存器。接口越窄,后来换显示屏(比如改成 LCD1602 或 OLED)时,软件侧只需要换一个刷新函数,走时和校时逻辑一行都不用动。

2.2 SOPC 系统组建与地址映射

系统在 SOPC Builder 或 Platform Designer 里搭,全部挂在同一个 50MHz 时钟域上,不做跨时钟,这一条能省掉后面一大半的调试时间。

核心选 Nios II/e(经济型)。它没有指令缓存、没有桶形移位器、乘法器可选,但电子钟对算力的要求几乎为零,用 /e 能比 /s 省下几千个逻辑单元,对中低容量器件很关键。片上存储器给 16KB,程序加数据足够,堆可以关掉,栈给 1KB 就行。

完整组件与地址分配如下:

组件类型基地址大小用途
cpuNios II/e——主控,复位向量指向 onchip_mem
onchip_memOn-Chip Memory0x0000_000016KB程序段 + 数据段
timer_0Interval Timer0x0000_400032B1ms 周期中断,IRQ 0
key_pioPIO (Input)0x0000_408016B3 路消抖后按键脉冲
buzzer_pioPIO (Output)0x0000_40A016B蜂鸣器使能
seg_ctrl_0自定义 Avalon-MM Slave0x0000_410032B六位数码管

自定义外设 seg_ctrl_0 的寄存器定义建议就两个:

  • 偏移 0x00:DATA,只写,低 24 位,从高到低依次是时十位、时个位、分十位、分个位、秒十位、秒个位,每 4 位一个 BCD 码;
  • 偏移 0x04:CTRL,bit0 为显示使能,bit1 为小数点(或分隔符)位置选择,bit[7:4] 为亮度占空比。

这个寄存器划分的好处是软件一次 32 位写就能刷新整屏,不用逐位操作,中断里也不会耽误时间。

提示:中断号分配完记得在软件工程里核对system.h中的TIMER_0_IRQ宏,SOPC 重新生成后中断号可能变,这个坑很常见。

2.3 时基与精度预算:50MHz 怎么变成一秒

时基有两种做法,选哪个直接决定走时精度。

第一种是硬件直接分频出 1Hz,喂给一个 PIO 或者直接驱动秒计数。优点是准,缺点是只能在整秒粒度上操作,想做 1/100 秒秒表或者调试时观察毫秒都不行。

第二种是定时器产生 1ms 周期中断,软件累加到 1000 进位成秒。这是我在这个方案里用的方式,原因是它给软件留了余地:闹钟比对、整点报时判断、按键长按计时都能复用同一个时基。

定时器配置很直接,50MHz 时钟下,周期寄存器写 49999(即计数 50000 次),1 分频,自动重载,中断使能:

// 定时器初始化:1ms 周期中断,自动重载 #include <altera_avalon_timer_regs.h> #include <altera_avalon_timer.h> #include <sys/alt_irq.h> void timer_init(void) { // 关定时器 -> 写周期 -> 清标志 -> 开中断 + 启动 IOWR_ALTERA_AVALON_TIMER_CONTROL(TIMER_0_BASE, ALTERA_AVALON_TIMER_CONTROL_STOP_MSK); IOWR_ALTERA_AVALON_TIMER_PERIODL(TIMER_0_BASE, 49999 & 0xFFFF); IOWR_ALTERA_AVALON_TIMER_PERIODH(TIMER_0_BASE, (49999 >> 16) & 0xFFFF); IOWR_ALTERA_AVALON_TIMER_STATUS(TIMER_0_BASE, 0); // 写 0 清 TO 标志 alt_ic_isr_register(TIMER_0_IRQ_INTERRUPT_CONTROLLER_ID, TIMER_0_IRQ, timer_isr, NULL, 0); IOWR_ALTERA_AVALON_TIMER_CONTROL(TIMER_0_BASE, ALTERA_AVALON_TIMER_CONTROL_ITO_MSK | ALTERA_AVALON_TIMER_CONTROL_CONT_MSK | ALTERA_AVALON_TIMER_CONTROL_START_MSK); }

参数说明:49999是"计数值减一",因为计数器从 0 开始,写到 49999 就是 50000 个时钟周期;如果是 100MHz 系统时钟,这个值要改成 99999。CONT_MSK打开连续运行,不开的话中断只触发一次。

精度这块必须算清楚,否则答辩时被问"你的钟一天误差多少"会答不上来。误差来源就三条:

  • 晶振本身:板上常用的普通有源晶振精度在 ±20ppm 到 ±50ppm 之间。50ppm 换算成一天是 50×10⁻⁶×86400 ≈ 4.3 秒。这是物理上限,代码写得再好也突破不了。
  • 中断响应延迟:只要定时器是自动重载的,中断晚到只会让本次处理晚一点,下一个周期仍然是严格的 1ms,延迟不累积。真正会累积的是丢中断——ISR 执行太久,下一个中断到来时标志还没清,那一次就永久丢了。所以 ISR 里只做计数,绝不刷屏。
  • 软件进位逻辑:if (++ms >= 1000)和ms = 0这个写法本身没有误差,但如果写成"每次中断 ms += 1,然后判断 ms % 1000",对 1000 取模的开销在 /e 核上不小,能省则省。

如果对精度有更高要求,通常的做法是加一颗独立 RTC 芯片走 I²C,软核掉电后时间不丢,FPGA 这边只负责读取和显示。题目不强制的话,不值得为此增加外设。

3. 硬件逻辑实现:扫描、消抖与蜂鸣器驱动

三个模块都不复杂,但每个都有几个"不改就出问题"的细节。这一章给的是可以直接搬进工程的写法。

3.1 六位数码管动态扫描与消隐

六位数码管共用一组段选线,靠位选轮流点亮,人眼靠视觉暂留看成同时亮。扫描频率太低会闪,太高则亮度不足、驱动管来不及开关。6 位 × 每 1ms 换一位 = 单屏刷新率约 166Hz,这个值在实测里既不闪亮度也够,是我常用的取值。

// 六位数码管动态扫描:1kHz 换位节拍,换位瞬间消隐一拍 module seg_scan ( input wire clk, // 50MHz input wire rst_n, input wire [23:0] bcd_data, // 6 位 BCD,[23:20] 为最高位 input wire en, // 显示使能 output reg [7:0] seg, // 段选 a~g,dp,共阴低有效取反 output reg [5:0] sel // 位选,低有效 ); reg [15:0] div_cnt; reg tick_1k; reg [2:0] scan_idx; reg blank_phase; // 消隐相位标志 // 50MHz / 50000 = 1kHz always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt <= 16'd0; tick_1k <= 1'b0; end else if (div_cnt == 16'd49_999) begin div_cnt <= 16'd0; tick_1k <= 1'b1; end else begin div_cnt <= div_cnt + 1'b1; tick_1k <= 1'b0; end end // 位选推进 + 换位时先关段选,防止上一位残影串到下一位 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scan_idx <= 3'd0; sel <= 6'b111_111; seg <= 8'h00; blank_phase <= 1'b0; end else if (tick_1k) begin scan_idx <= (scan_idx == 3'd5) ? 3'd0 : scan_idx + 1'b1; sel <= ~(6'b000_001 << ((scan_idx == 3'd5) ? 3'd0 : scan_idx + 1'b1)); seg <= 8'h00; // 消隐 blank_phase <= 1'b1; end else if (blank_phase) begin seg <= ~seg_code(bcd_data[{scan_idx, 2'b00} +: 4]); blank_phase <= 1'b0; end end // 共阴段码表 0~9 function [7:0] seg_code; input [3:0] d; case (d) 4'd0: seg_code = 8'h3F; 4'd1: seg_code = 8'h06; 4'd2: seg_code = 8'h5B; 4'd3: seg_code = 8'h4F; 4'd4: seg_code = 8'h66; 4'd5: seg_code = 8'h6D; 4'd6: seg_code = 8'h7D; 4'd7: seg_code = 8'h07; 4'd8: seg_code = 8'h7F; 4'd9: seg_code = 8'h6F; default: seg_code = 8'h00; endcase endfunction endmodule

逻辑说明:div_cnt负责降频,tick_1k是单周期脉冲,只在计数到顶时为高。位选推进和消隐放在同一个时序块里,tick_1k到来时先更新位选、同时把seg清 0,下一拍才把新数据送上段选,这样换位瞬间段选是全灭的,不会出现上一位的数字在新一位上留一道残影。{scan_idx, 2'b00}等价于scan_idx × 4,就是取 24 位数据中对应的那 4 位 BCD。

参数怎么改:扫描频率改49_999这个比较值,想更亮可以把下标改小(但别低于 4ms,反射式数码管要留出响应时间);驱动共阳数码管时把~seg_code的取反符去掉,同时把sel改成高有效;如果板上加了 74HC595 串行驱动,那这里要改成移位输出,段选和位选的时序要按芯片手册重新排。

工程里还有个常被忽略的电气问题:段选每段限流电阻一般 220Ω~470Ω,位选如果直接由 FPGA 引脚驱动,六位全亮的瞬间电流可能达到几十毫安,超过单脚驱动能力。稳妥做法是位选加三极管或者专用驱动芯片,这一点在画板子时就得定,不是代码能补救的。

3.2 按键消抖状态机与边沿检测

机械按键按下和松开时触点会弹跳,抖动时间典型 5~20ms。如果 FPGA 直接采样,一次按下会变成一串脉冲,软件侧就会"按一下加了好几个数"。

处理分三层:异步输入先过两级触发器做同步,消除亚稳态;再用计数器确认电平稳定时间;最后输出单周期脉冲,让软件只认一次。

// 20ms 消抖:50MHz 下计数 1_000_000 约等于 20ms module key_debounce ( input wire clk, input wire rst_n, input wire key_in, // 原始输入,按下为低 output reg key_pulse // 单周期脉冲,按下瞬间有效 ); localparam CNT_MAX = 20'd999_999; reg [19:0] cnt; reg sync0, sync1, key_stable; // 两级同步,防亚稳态 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sync0 <= 1'b1; sync1 <= 1'b1; end else begin sync0 <= key_in; sync1 <= sync0; end end // 电平稳定保持 20ms 才认,变化瞬间输出脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_stable <= 1'b1; key_pulse <= 1'b0; end else if (sync1 != key_stable) begin if (cnt == CNT_MAX) begin cnt <= 20'd0; key_stable <= sync1; key_pulse <= ~sync1; // 稳定到低电平 = 按下 end else begin cnt <= cnt + 1'b1; key_pulse <= 1'b0; end end else begin cnt <= 20'd0; key_pulse <= 1'b0; end end endmodule

CNT_MAX决定了消抖窗口,20ms 是经验值,按键质量差可以加到 30ms,但不要超过 50ms,否则快速连按会跟不上。key_pulse只有一拍有效,所以软件侧用 PIO 读边沿寄存器,读一次就清掉,不用再做任何去抖。

三颗按键(MODE / UP / DOWN)各例化一份,输出接到 key_pio 的 bit0~bit2。如果板子按键数量紧张,也可以用 AD 键盘,一个 ADC 通道分压识别多键,那是另一套写法。

3.3 蜂鸣器与整点报时的驱动方式

先分清有源和无源。有源蜂鸣器内置振荡电路,给高电平就响,控制最简单;无源蜂鸣器本质是个电磁或压电片,必须外部给方波,频率决定音调。开发板上配无源的居多,因为它能弹不同音调,做整点报时的"叮咚"两声更好听。

无源蜂鸣器需要 2kHz 左右的方波,用硬件分频最简单:50MHz / 25000 = 2kHz,占空比 50% 就是数到 12500 翻转一次。

// 无源蜂鸣器驱动:产生 2kHz 方波,en 控制是否发声 module buzzer_drv ( input wire clk, // 50MHz input wire rst_n, input wire en, // 1 = 响,0 = 静音 output wire buz_out ); reg [14:0] cnt; reg wave; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 15'd0; wave <= 1'b0; end else if (!en) begin cnt <= 15'd0; wave <= 1'b0; end else if (cnt == 15'd12_499) begin cnt <= 15'd0; wave <= ~wave; end else cnt <= cnt + 1'b1; end assign buz_out = wave; endmodule

方波在硬件产生,软件只负责拉高或拉低 buzzer_pio 的那一位。整点报时策略全放软件:秒和分都归零时,拉高 en 响 200ms,静音 200ms,再响一次,用 1ms 时基计数控制节奏即可,硬件一行都不用改。闹钟响铃同理,只是持续到用户按键停止。

注意:蜂鸣器启动电流不小,直接接 FPGA 引脚容易把电源拉出毛刺,导致软核复位。板上一般有三极管驱动,如果没有,务必自己加。

4. Nios II 软件实现:中断走时、校时状态机与寄存器读写

软件这一层其实只有三件事:让时间准、让用户能改时间、让改完的时间显示出来。分开写清楚,代码量不大。

4.1 定时器中断与走时进位

中断服务程序必须短。我的做法是 ISR 里只做毫秒累加和秒进位,最多再置一个"秒变化"标志给主循环,显示刷新一律放到主循环里。

#include <system.h> #include <altera_avalon_timer_regs.h> typedef struct { unsigned char h, m, s; } rtc_t; static volatile rtc_t g_now = {12, 0, 0}; // 当前时间 static volatile unsigned int g_ms = 0; // 毫秒累加 static volatile unsigned char g_sec_flag = 0; // 秒变化标志,主循环消费 static void timer_isr(void *ctx) { // 清 TO 标志位,必须写 0,否则中断会反复进 IOWR_ALTERA_AVALON_TIMER_STATUS(TIMER_0_BASE, 0); if (++g_ms >= 1000) { g_ms = 0; g_sec_flag = 1; if (++g_now.s >= 60) { g_now.s = 0; if (++g_now.m >= 60) { g_now.m = 0; if (++g_now.h >= 24) g_now.h = 0; } } } }

g_now必须加volatile,否则编译器可能把它优化进寄存器,主循环永远读到旧值。另一个隐性问题是读一致性:主循环读g_now.h/m/s时,如果正好被中断打断在"秒进位到分进位"的中间,读到的时分秒可能是错位组合。稳妥写法是先关中断做一次快照再开:

static rtc_t rtc_snapshot(void) { rtc_t t; alt_irq_context ctx = alt_irq_disable_all(); // 关中断 t = g_now; alt_irq_enable_all(ctx); // 恢复 return t; }

在 /e 核上关中断的时间只有几微秒,完全不影响 1ms 时基。这个习惯我从做第一个时钟项目起就保留着,比事后查"为什么显示偶尔跳一下"省事太多。

4.2 校时与闹钟的人机交互状态机

按键只有三颗:MODE、UP、DOWN。交互逻辑用状态机写最清楚,状态表如下:

当前状态MODE 短按UPDOWN
RUN进入 SET_HOUR,秒清零无动作无动作
SET_HOUR进入 SET_MIN时 +1,模 24时 −1
SET_MIN进入 SET_ALARM_H分 +1,模 60分 −1
SET_ALARM_H进入 SET_ALARM_M闹钟时 +1闹钟时 −1
SET_ALARM_M返回 RUN,写入生效闹钟分 +1闹钟分 −1

代码骨架:

typedef enum { ST_RUN = 0, ST_SET_HOUR, ST_SET_MIN, ST_SET_ALARM_H, ST_SET_ALARM_M } ui_state_t; static ui_state_t g_state = ST_RUN; static rtc_t g_alarm = {7, 30, 0}; // 闹钟默认 07:30 static unsigned char g_alarm_en = 1; // 每帧调用一次,key 为 -1 / 0(MODE) / 1(UP) / 2(DOWN) void ui_step(int key) { rtc_t t = rtc_snapshot(); switch (g_state) { case ST_RUN: if (key == 0) { t.s = 0; rtc_write(&t); g_state = ST_SET_HOUR; } break; case ST_SET_HOUR: if (key == 0) g_state = ST_SET_MIN; else if (key == 1) { t.h = (t.h + 1) % 24; rtc_write(&t); } else if (key == 2) { t.h = (t.h + 23) % 24; rtc_write(&t); } break; case ST_SET_MIN: if (key == 0) g_state = ST_SET_ALARM_H; else if (key == 1) { t.m = (t.m + 1) % 60; rtc_write(&t); } else if (key == 2) { t.m = (t.m + 59) % 60; rtc_write(&t); } break; // SET_ALARM_H / SET_ALARM_M 同理,改为操作 g_alarm default: if (key == 0) g_state = ST_RUN; break; } }

几个约定值得写进注释:进入设置时秒清零,这是电子钟的通用做法,用户改完时间就是整分开始;+1和+23配合取模实现加减双向且不出现负数,比写分支判断干净;设置过程中走时不停,只是显示的是被修改的副本。闹钟触发判断放在秒变化时做:

if (g_sec_flag && g_alarm_en && g_now.s == 0 && g_now.h == g_alarm.h && g_now.m == g_alarm.m) { buzzer_on(); g_alarm_ringing = 1; }

用秒变化做触发条件,保证一次闹钟只触发一次,不会因为主循环跑得快重复响。

4.3 Avalon 自定义外设的读写封装

自定义外设暴露给软件的只有两个寄存器,用IOWR/IORD直接读写,封装成函数后主循环看起来就很干净。

#include <io.h> #define SEG_DATA_REG 0 // 偏移 0:24 位 BCD #define SEG_CTRL_REG 1 // 偏移 1:控制位 // 把 rtc 结构体打包成 6 位 BCD 写进显示控制器 static void seg_flush(rtc_t t, unsigned char dot_pos) { unsigned int packed = ((unsigned int)(t.h / 10) << 20) | ((unsigned int)(t.h % 10) << 16) | ((unsigned int)(t.m / 10) << 12) | ((unsigned int)(t.m % 10) << 8) | ((unsigned int)(t.s / 10) << 4) | ((unsigned int)(t.s % 10)); IOWR(SEG_CTRL_0_BASE, SEG_DATA_REG, packed); IOWR(SEG_CTRL_0_BASE, SEG_CTRL_REG, (1u << 0) | // 显示使能 ((unsigned int)dot_pos << 1) | // 分隔符位置 (0x8u << 4)); // 亮度占空比 50% }

IOWR(base, offset, data)里的 offset 是 32 位字数,不是字节地址,这一点新手常错——写IOWR(SEG_CTRL_0_BASE, 1, x)访问的是基地址 + 4 字节处。seg_flush每次全量刷新整屏,六个数字一次性写下去,硬件那边扫描模块自己循环取数,软件不需要关心中间过程。

主循环大致长这样:读按键脉冲 → 交给ui_step处理 → 若处于设置状态则闪烁对应位 → 否则按秒变化标志调seg_flush。整个主循环没有延时函数,全靠 1ms 中断驱动,CPU 占用率很低。

提示:Nios II 工程编译后如果提示system.h里找不到SEG_CTRL_0_BASE,是 SOPC 修改后没有重新生成 BSP,右键 BSP 工程执行一次 generate 即可。

5. 避坑与排查:五个最容易翻车的点

这一章的五条,基本都是我在别人板子上真实见过的故障。按"现象—原因—解决"梳理,照着查能省掉大半天。

5.1 数码管出现重影和亮度不均

现象:某一位数字比别的亮,或者相邻两位的数字有淡淡叠影,静止画面看不出来,一动就明显。

原因:换位时没有消隐,上一位的段码还在引脚上,新位选一打开就把旧数字点到新位置上了。另一种可能是位选驱动能力不足,六位扫描时电流被分摊,亮的那一位恰好是扫描周期占得多的。

解决:按 3.1 的做法,在位选切换的同一拍把段选清零,下一拍再送数据。如果消隐后仍有轻微叠影,检查段选线上有没有对地电容过大导致下降沿拖尾,必要时降低扫描频率到 200Hz。亮度不均则要确认六路的限流电阻一致、位选三极管都工作在饱和区。

5.2 下载 sof 后断电就"变砖"

现象:用下载器把 sof 烧进去,板子上电一切正常;拔掉下载器重新上电,屏幕全灭,什么反应都没有。

原因:sof 是 SRAM 对象文件,配置信息存在 FPGA 内部的易失性配置存储器里,掉电就没了。板上那颗配置芯片(EPCS 系列)是空的。

解决:把 sof 转成 jic 文件,通过下载器的 Active Serial 模式烧进配置芯片。转换时注意选对配置器件型号和容量,烧写完成后把启动模式开关拨到 AS 位置。这一步答辩前一定要做一遍,现场演示拔掉下载器更可信。

5.3 程序跑飞或卡在 alt_main 不动

现象:软件下载后没有输出,JTAG UART 里一片空白,暂停调试发现程序停在某个异常地址。

原因:排前三的是——片上存储器太小,代码段放不下被截断;栈溢出,局部大数组把栈挤穿;中断里调用了不可重入的库函数(比如printf),浮点格式化把栈用光。

解决:先看编译输出的 map 文件,确认 text 段和 bss 段加起来没超过 onchip_mem 容量;栈从默认的 512 字节调到 1KB 以上,同时在链接脚本里给堆留 0 或者干脆关掉malloc;ISR 里禁止任何格式化输出,调试信息通过一个变量带出来在主循环打印。这三条查完,九成的跑飞都能定位。

5.4 走时每天快慢几秒

现象:跑一晚上,第二天早上发现快了五六秒,或者一小时就慢一秒。

原因:如果误差量级在一天几秒,大概率是晶振精度决定的,属正常范围;如果一小时就差一秒(约 280ppm),那多半是定时器周期值算错了——比如系统时钟实际是 50MHz 但 PLL 配置成了 48MHz,或者周期寄存器写成了 49999 却忘了减一。

解决:先用示波器量 PLL 输出的实际频率,确认是 50MHz 整数;再把周期值按f_clk / 1000 - 1重新算一遍。确认硬件无误后,剩下的误差要靠校准:在软件里加一个"每日补偿秒数"参数,或者在硬件上换更高精度的晶振。答辩时主动说清楚误差的来源和量级,比含糊带过更有说服力。

5.5 按键一次动作触发好几回

现象:按一下 UP 键,时间加了 3 分钟;松手时又跳一次。

原因:软件侧还在按电平判断,而不是认边沿;或者消抖模块的脉冲没被消费掉,PIO 的边沿寄存器没清;也可能是按键没有上拉,悬空时乱跳。

解决:硬件侧确认消抖模块输出的是单周期脉冲,且软件读一次边沿寄存器就自动清除(PIO 的 edgecapture 模式写 1 清零);如果消抖做在软件里,就在主循环里做 20ms 计数确认,且只在"上一次是松开、这一次是按下"时才触发。松手误触发通常是按键没接上拉电阻,检查板上是否有 10k 上拉,没有就在代码里打开 FPGA 内部弱上拉。

6. 验收与答辩:一张测试表和三条讲解主线

验收这件事,最忌讳现场凭感觉说"你看它走得多准"。带一张测试表进场,逐项打勾,比讲十分钟原理都有用。

测试项测试方法合格判据
基本走时与标准时间同步后连续运行 24 小时日误差 ≤ 5 秒(普通晶振量级)
显示完整性观察 00:00:00 到 23:59:59 全程无缺笔、无重影、无闪烁
校时功能分别修改时、分,各加减 5 次每次响应一次,模值回绕正确
校时边界23:59 按 UP 加 1 分正确进位为 00:00
闹钟触发设为当前时间后 1 分钟误差 ≤ 1 秒,响铃可按键停止
整点报时观察 12:00:00、13:00:00准点响,且只响一次
掉电重启断电 10 秒后上电显示恢复,无花屏、无卡死
长时间稳定性连续通电 8 小时无死机、无显示漂移

答辩讲三件事就够了,别贪多。

第一件是软硬划分的理由。老师最常问的就是"为什么这部分不做进 FPGA"。把 2.1 那张表搬出来,重点讲"数码管扫描如果放软件里,中断执行时间会拉长到几十微秒,主循环一忙就丢拍",这个因果链比背概念管用。

第二件是地址映射和中断流程。拿出 SOPC 的地址分配表和system.h里的宏对照,说明 CPU 是怎么通过 Avalon 总线一次 32 位写把六个数字送进自定义外设的。如果能把 SignalTap 抓到的位选扫描波形和总线写时序放在一页 PPT 上,说服力会强很多。

第三件是误差分析。主动说出晶振精度决定的 4.3 秒/天上限,再说清楚哪些是自己代码能控制的、哪些是物理限制。这一步是拉开档次的地方——大部分人会做,但很少有人能说清自己的钟为什么是这个精度。

演示顺序也有讲究:先通电看走时,再演示校时,然后设闹钟等它响,最后拔掉下载器重启一次。拔线这个动作一定要做,前面 5.2 那条坑我踩过一次,现场被追问"拔了还能不能跑"的时候,那种尴尬至今记得。

从那以后我每次做带软核的板子,都强制把"生成 jic、烧配置芯片、断电重启"这一步放进验收流程里,不管多赶时间都不跳过。这套工程从系统搭建、Verilog 模块到 C 代码的结构都留得比较完整,需要的可以拿去对着自己的板子改引脚和地址,改完直接就能跑起来。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进