资讯动态

FPGA数字钟进阶:基于NIOS II软核的系统设计与Avalon外设实现

发布时间:2026/9/11 23:28:12 来源:尧图企业网站定制
简介一份基于NIOSII软核处理器的FPGA数字钟实战训练资源包面向电子工程、嵌入式系统方向的初学者与进阶者。资源完整呈现了基于SOPC Builder的系统架构设计、NIOSII内核参数配置、IP核创建与集成、Verilog模块编程、C语言软件开发以及ModelSim仿真验证的完整流程同时深入讲解了计数器、显示驱动、中断控制器、DMA控制器等模块的工作原理并介绍了时序分析、存储器管理与SignalTap硬件调试等关键技能。包体共86个文件约378KB以26个Verilog源码文件和18个dat仿真数据文件为核心并含ModelSim的do仿真脚本、hex/mif初始化文件、Quartus工程配置与IP核封装文件目录结构清晰便于对照工程逐模块学习。目前已有286人学习下载。借助整套工程与源码读者可完整复现数字钟设计掌握NIOSII软核与FPGA外设的协作机制、HAL驱动开发经验及软硬件协同验证方法为后续更复杂的SoC设计打下坚实基础。1. FPGA 训练里的数字钟软核方案到底要解决什么问题FPGA 实训里数字钟大多被当成 Verilog 分频练习写一个大计数器扫六位数码管再用按键改预置数。这种做法能验证语法却只练到了 RTL 的一小部分。基于 NIOS II 处理器重新做一遍数字钟评判点就变了你要在 Quartus 的 Platform Designer 里搭出一个能跑程序的处理器系统自己挂外设、分地址、安排中断再用 C 程序去操纵这套硬件。对已经会用 Verilog 写组合逻辑和状态机的人来说这是从“写逻辑”过渡到“做系统”最直接的一步。对课程设计和竞赛训练也一样把秒计数和显示扫描留给硬件把调时和按键交给 C 程序恰好是软硬件划分最自然的一份答案。这篇就按完整流程讲硬件构建、外设封装、软件中断和精度验证。2. 用 Platform Designer 搭 NIOS II 最小系统并规划好 Avalon 地址映射2.1 NIOS II 选 /e 还是 /s先算清楚资源账新建 NIOS II 系统时处理器变体是第一个决定。NIOS II/e 是最小面积版本没有指令缓存也不带硬件乘除法占用逻辑很少NIOS II/s 有五级流水线和硬件乘除性能更好但面积明显增加NIOS II/f 则有分支预测和指令缓存适合跑复杂协议栈。数字钟这种工程CPU 只在 1ms 中断里处理按键和显示刷新每秒才做一次时间更新对算力几乎无要求。我通常直接选 /e把省下的 LE 留给后续自定义外设和显示扫描逻辑。NIOS II 变体典型逻辑占用Cyclone IV主要特点数字钟项目里的取舍/e约 600 到 900 LE面积最小无硬件乘除够用中断程序代码也不大/s约 1200 到 1800 LE五级流水线硬件乘除更从容适合后续扩展/f约 1800 到 2500 LE分支预测指令缓存小芯片上浪费资源收益有限实际数字会因 Quartus 版本、综合选项和 IP 配置有浮动编译后以 Project 里的 Logic Elements 报告为准不用纠结估算值。一个容易忽略的点是 NIOS II/e 没有缓存所以 On-Chip Memory 若只有 16 KB程序放进去完全够跑启动也快。真到了代码膨胀到几十 KB 的时候再考虑 /s 加外部存储器也不迟。2.2 六类 IP 的最小集合与 Base Address 规划Platform Designer 图形界面里新工程要添加以下六个东西基本就是微型单片机系统的最小集NIOS II Gen2 处理器核选 /eOn-Chip Memory RAM16 KB作为程序运行区和数据区JTAG UART用来做 printf 打印和调试输出System ID 核避免调试器误连到别的工程Interval Timer产生 1ms 周期的中断时基两组 PIO一组接数码管位选和段码一组接按键输入。时钟部分建议再加一个 ALTPLL把板载晶振频率变成系统想要的频率。连线时要特别注意把所有外设的 irq 中断输出接到 NIOS II 处理器的 irq 输入否则 HAL 里注册的中断回调永远不会被调用。连完线后在 Base Addresses 页自动分配地址下面是一份典型映射表。外设基地址中断号用途On-Chip Memory0x00000000—程序与数据JTAG UART0x000010205调试打印System ID0x00001030—防误连Interval Timer0x0000104001ms 中断PIO_Key0x000010502读取按键PIO_Seg0x00001060—显示数据这个映射会在生成 BSP 后自动出现在 system.h 里软件只需包含头文件不必手工记地址。中断号越小优先级越高所以把定时器设为 0 是正确的因为显示刷新和按键扫描都依靠它。2.3 用 qsys-generate 固化配置再例化到顶层图形界面改完配置应该被当作工程文件保存下来。命令行生成器件配置的好处是每次重做结果一致也方便拷给别人复现。在 Nios II Command Shell 或 Linux 终端执行qsys-generate nios_clock.qsys --synthesisVERILOG --output-directoryoutput其中--synthesisVERILOG指生成的系统顶层是 Verilog 格式--output-directoryoutput把生成文件放在 output 目录里。如果你希望用 Tcl 脚本记录整个人机交互过程还可以用qsys-script --scriptcreate_system.tcl这样更接近真实的工程自动化管理方式。生成后在 Quartus 顶层里例化整个系统并连接板级时钟和复位。代码片段如下module top ( input wire clk_50m, input wire rst_n, output wire [5:0] seg_sel, output wire [7:0] seg_led ); wire clk_sys; pll u_pll ( .inclk0 (clk_50m), .c0 (clk_sys) ); nios_clock u_sys ( .clk_clk (clk_sys), .reset_reset_n (rst_n), .pio_seg_sel_export (seg_sel), .pio_seg_led_export (seg_led) ); endmodule这里 PLL 和 PIO 的端口名以 Platform Designer 生成的名为准。要注意复位信号最好先经过异步复位同步器再接入系统避免上电瞬间的复位释放引起总线竞争。提示如果板载晶振只有 50 MHz而定时器和自定义外设也工作在 50 MHz可以跳过 PLL直接让 clk 引脚接晶振。此时clk_50m进系统时仍建议接一个 IBUF 或直接在 qsys 内部连好但 ALTPLL 的抖动指标会更稳定。2.4 生成 BSP 并验证最小启动系统硬件生成后软件侧先建 Bare-metal HAL 的 BSP。命令行是nios2-bsp hal ./bsp nios_clock.sopcinfo nios2-app-generate-makefile --app-dir ./app --bsp-dir ./bsp make -C app nios2-download -g app/hello_uart.elf nios2-terminal第一行把硬件描述文件 nios_clock.sopcinfo 转成 BSP 工程第二行生成应用工程 Makefile第三行编译第四行把 ELF 下载到 FPGA 上并运行。如果 JTAG UART 和 System ID 配置正确终端里会看到输出。这一步通了再往下加自定义外设才不容易引入“到底是总线问题还是逻辑问题”的排查困难。3. 把秒计数做成 Avalon 从外设硬件 BCD 时间核与显示扫描3.1 为什么要用硬件计数器而不是软件数秒数字钟最核心的误差来源是“一秒怎么产生”。用 NIOS II 的 Interval Timer 中断去数 1ms再到软件里累加出秒做法简单但存在两个隐患一是中断响应延迟会随 CPU 忙闲变化极端情况下按键长按或 printf 阻塞会丢中断二是系统复位或程序跑飞后软件计数全部归零。更可靠的做法是把分频和 BCD 计数全部放到 FPGA 逻辑里CPU 只负责读取和设置和时间准确性相关的部分完全不受软件干扰。这也是 FPGA 上做数字钟和单片机上做数字钟的本质差别。3.2 一个能直接综合的 BCD 时间计数器下面是一个挂在 Avalon-MM 总线上的从外设占用三个地址偏移分别对应秒、分、小时。读写都用 32 位数据线实际只用到低 8 位其中低 4 位是个位高 4 位是十位。写寄存器时优先加载新值写完后继续按 1Hz 秒脉冲递增module bcd_time_core ( input wire clk, input wire rst_n, input wire pps_1hz, input wire avs_read, input wire avs_write, input wire [1:0] avs_address, input wire [31:0] avs_writedata, output reg [31:0] avs_readdata ); reg [3:0] sec_ones, sec_tens; reg [3:0] min_ones, min_tens; reg [3:0] hour_ones, hour_tens; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sec_ones 0; sec_tens 0; min_ones 0; min_tens 0; hour_ones 0; hour_tens 0; end else if (avs_write) begin case (avs_address[1:0]) 2d0: begin sec_ones avs_writedata[3:0]; sec_tens avs_writedata[7:4]; end 2d1: begin min_ones avs_writedata[3:0]; min_tens avs_writedata[7:4]; end 2d2: begin hour_ones avs_writedata[3:0]; hour_tens avs_writedata[7:4]; end endcase end else if (pps_1hz) begin if (sec_ones 4d9) begin sec_ones 0; if (sec_tens 4d5) begin sec_tens 0; if (min_ones 4d9) begin min_ones 0; if (min_tens 4d5) begin min_tens 0; if ({hour_tens, hour_ones} 8h23) begin hour_tens 0; hour_ones 0; end else if (hour_ones 4d9) begin hour_ones 0; hour_tens hour_tens 1b1; end else begin hour_ones hour_ones 1b1; end end else min_tens min_tens 1b1; end else min_ones min_ones 1b1; end else sec_tens sec_tens 1b1; end else sec_ones sec_ones 1b1; end end always (*) begin case (avs_address[1:0]) 2d0: avs_readdata {24d0, sec_tens, sec_ones}; 2d1: avs_readdata {24d0, min_tens, min_ones}; 2d2: avs_readdata {24d0, hour_tens, hour_ones}; default: avs_readdata 32d0; endcase end endmodule这段逻辑里写操作优先级最高保证按键调时写入的瞬间不会被下一秒脉冲覆盖。进位链按“秒个位到秒十位再到分个位”的顺序逐级展开达到 59 秒后分钟加一小时部分用{hour_tens, hour_ones} 8h23判断 23 点到点后直接回归 00。Avalon 读操作是组合逻辑给出数据固定一个周期读出因此不需要 waitrequest 信号适合挂在普通总线上。注意如果你的 NIOS II 系统时钟不是 50 MHz而是 100 MHz则后面分频模块里的计数上限也要相应改成 100_000_000。位宽要留够避免综合时被自动裁剪导致仿真通过而实际跑飞。3.3 1Hz 分频和六位数码管扫描秒脉冲由系统时钟分频产生常见做法是用一个计数器对 PLL 输出时钟计数。以 50 MHz 为例module clk_div_1hz #(parameter DIV 50_000_000) ( input wire clk, input wire rst_n, output reg tick_1hz ); reg [25:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; tick_1hz 0; end else if (cnt DIV - 1) begin cnt 0; tick_1hz 1b1; end else begin cnt cnt 1b1; tick_1hz 1b0; end end endmoduletick_1hz在计数器到达 N-1 时只拉高一个时钟周期后面的 BCD 计数器正是靠这个单周期脉冲触发的。分频参数DIV用 parameter 定义不同工程换晶振频率时只改一处。另一个模块做六位数码管扫描每 1ms 切换一位用占空比方式驱动六个位选module seg_scan #( parameter SCAN_MAX 50_000 ) ( input wire clk, input wire rst_n, input wire [23:0] bcd_data, output reg [5:0] seg_sel, output reg [7:0] seg_led ); reg [15:0] scan_cnt; reg [2:0] pos; always (posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt 0; pos 0; end else if (scan_cnt SCAN_MAX - 1) begin scan_cnt 0; pos pos 1b1; end else begin scan_cnt scan_cnt 1b1; end end function [6:0] bcd_to_seg; input [3:0] bcd; case (bcd) 4h0: bcd_to_seg 7b1000000; 4h1: bcd_to_seg 7b1111001; 4h2: bcd_to_seg 7b0100100; 4h3: bcd_to_seg 7b0110000; 4h4: bcd_to_seg 7b0011001; 4h5: bcd_to_seg 7b0010010; 4h6: bcd_to_seg 7b0000010; 4h7: bcd_to_seg 7b1111000; 4h8: bcd_to_seg 7b0000000; 4h9: bcd_to_seg 7b0010000; default: bcd_to_seg 7b1111111; endcase endfunction always (*) begin seg_sel 6b111111; seg_led 8hff; case (pos) 0: begin seg_sel[0] 0; seg_led[6:0] bcd_to_seg(bcd_data[3:0]); end 1: begin seg_sel[1] 0; seg_led[6:0] bcd_to_seg(bcd_data[7:4]); end 2: begin seg_sel[2] 0; seg_led[6:0] bcd_to_seg(bcd_data[11:8]); end 3: begin seg_sel[3] 0; seg_led[6:0] bcd_to_seg(bcd_data[15:12]); end 4: begin seg_sel[4] 0; seg_led[6:0] bcd_to_seg(bcd_data[19:16]); end 5: begin seg_sel[5] 0; seg_led[6:0] bcd_to_seg(bcd_data[23:20]); end endcase end endmoduleSCAN_MAX在 50 MHz 下取 50_000对应 1ms 切换一个位六位数码管完整刷新周期为 6ms肉眼看不到闪烁。如果换到 100 MHz 时钟需要同步放大到 100_000。段码表按共阳极数码管常见接法给出共阴极板子要取反。把这两个模块做成 Platform Designer 的组件或者干脆用 Qsys 里的 User Component 向导封装就能像普通外设一样挂到 Avalon 总线上。4. 软件侧的定时中断、寄存器读写与六位数码管刷新4.1 HAL 中断回调的注册方式NIOS II 上最省事的做法是使用 HAL 提供的alt_ic_isr_register。虽然老代码里也能看到alt_irq_register但在新版 BSP 里推荐带中断控制器 ID 的版本。注册前要先确认 Platform Designer 里把 Interval Timer 的 irq 连到了 CPU否则这个回调永远不触发。中断服务程序尽量只做加法、置标志不做 printf否则会把中断占住导致显示刷新卡顿。#include system.h #include io.h #include altera_avalon_timer_regs.h #include sys/alt_irq.h static volatile unsigned int tick_ms 0; static void timer_isr(void *context) { (void)context; tick_ms; IOWR_ALTERA_AVALON_TIMER_STATUS(TIMER_0_BASE, 0); } int init_timer(void) { 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); return alt_ic_isr_register(TIMER_0_IRQ_INTERRUPT_CONTROLLER_ID, TIMER_0_IRQ, timer_isr, NULL, 0); }IOWR_ALTERA_AVALON_TIMER_STATUS(TIMER_0_BASE, 0)这行不能省。定时器中断标志不写零清除ISR 返回后会立刻再次进入造成中断风暴。ITO_MSK是允许中断CONT_MSK是连续计数START_MSK是启动定时器。这三个宏在 altera_avalon_timer_regs.h 里都有定义。4.2 主循环里读取硬件时间并刷新显示软件不负责数秒只负责把 BCD 时间从硬件外设读出来再交给扫描外设显示。假设 bcd_time_core 的基地址是 CLOCK_BASEseg_scan 的基地址是 SEG_BASE那么一次刷新只需三次 IORD 和一次 IOWR#include system.h #include io.h #define CLOCK_BASE CLOCK_CORE_BASE #define SEG_BASE SEG_SCAN_BASE void render_time(void) { unsigned int sec IORD(CLOCK_BASE, 0) 0xff; unsigned int min IORD(CLOCK_BASE, 1) 0xff; unsigned int hour IORD(CLOCK_BASE, 2) 0xff; unsigned int bcd_data (hour 16) | (min 8) | sec; IOWR(SEG_BASE, 0, bcd_data); }IORD 的第一个参数是外设基地址第二个是寄存器地址偏移。硬件外设内部按 32 位对齐所以地址偏移 0、1、2 正好对应三个寄存器。SEC 的低 4 位是秒个位高 4 位是秒十位 0xff只取有效部分。写入时三个部分拼成一个 24 位 BCD 数由 seg_scan 模块自动扫描显示。4.3 用 tick_ms 做非阻塞延时按键去抖和长按检测都需要时基。基于 tick_ms 实现延时不占用 CPU 自旋等待static unsigned int last_scan 0; void app_loop(void) { if (tick_ms - last_scan 2) { last_scan tick_ms; scan_keys(); render_time(); } }tick_ms - last_scan这种写法是无符号数不会被回绕问题卡住2ms 轮询一次足够覆盖机械按键最常见的抖动区间。主循环里没有 sleep其他任务也能插进来。对于数字钟来说显示刷新不必做到每 1ms 一次2ms 或 5ms 都能保持视觉稳定。5. 按键去抖、调时模式与 23:59:59 跨日边界5.1 软件去抖的 10ms 窗口机械按键按下和松开时会有约 5ms 到 20ms 的抖动。常见做法是在 2ms 轮询里连续读取按键状态只有状态稳定持续 10ms 才认为有效。代码上用一个简单的三变量状态static unsigned int key_raw 0; static unsigned int key_stable 0; static unsigned int key_prev 0; static unsigned int key_cnt 0; int key_scan(void) { unsigned int cur IORD(KEY_BASE, 0); int event 0; if (cur ! key_raw) { key_raw cur; key_cnt 0; } else if (key_cnt 5) { unsigned int changed key_stable ^ cur; key_stable cur; if (changed cur) { event cur; } } key_prev key_stable; return event; }去抖原理是先用key_raw记住上一拍电平当检测到变化时清零计数连续 5 次采样值一致也就是经过 10ms 后才把新值并入key_stable。changed cur表示只有从 0 变 1 的位才算按下事件。返回值为按键掩码例如KEY_MODE、KEY_UP、KEY_DOWN。这里没有处理长按若要做连续加秒可在主循环里检测key_stable仍处于按下状态且超过 500ms就每 100ms 补一次。5.2 模式状态机与调时写入调时逻辑适合用一个小状态机。对应关系是正常走时按 MODE 进入“调分”状态再按进入“调时”状态第三次按回到正常走时。在调分或调时状态下UP 键让对应字段加一DOWN 键让对应字段减一。每次修改后直接把三个寄存器写回硬件让硬件计数器立即生效。typedef enum { MODE_RUN, MODE_SET_MIN, MODE_SET_HOUR } clock_mode_t; void handle_key(int mask) { static clock_mode_t mode MODE_RUN; if (mask KEY_MODE) { mode (mode MODE_RUN) ? MODE_SET_MIN : (mode MODE_SET_MIN) ? MODE_SET_HOUR : MODE_RUN; } if (mode MODE_RUN) return; unsigned int sec IORD(CLOCK_BASE, 0) 0xff; unsigned int min IORD(CLOCK_BASE, 1) 0xff; unsigned int hour IORD(CLOCK_BASE, 2) 0xff; if (mode MODE_SET_MIN) { if (mask KEY_UP) { min (min 0xf0) 0x50 ? 0x00 : min 0x01; } if (mask KEY_DOWN) { min (min 0x0f) 0x00 ? 0x59 : min - 0x01; } } else if (mode MODE_SET_HOUR) { if (mask KEY_UP) { hour (hour 0x23) ? 0x00 : hour 0x01; } if (mask KEY_DOWN) { hour (hour 0x00) ? 0x23 : hour - 0x01; } } IOWR(CLOCK_BASE, 0, sec); IOWR(CLOCK_BASE, 1, min); IOWR(CLOCK_BASE, 2, hour); }这里用了 BCD 数加减时的回绕判断。分的十位是 5、个位是 9 时表示 59加一后应当是 00分的个位是 0 时减一应当回到 59。小时用0x23判断 23 点。需要注意IOWR 写入是并行执行最后一次写小时时前面的秒和分已经被写入因此三次写入之间不会有 1Hz 脉冲插入造成一边写一边进位的问题。5.3 十二小时制与跨日硬件计数器直接按 24 小时制回绕到 0023:59:59 的下一秒是 00:00:00这一部分在 Verilog 里已经处理完软件不需要额外判断。若题目要求 12 小时制显示常见做法是显示层转换不改硬件计数。转换规则是小时大于 12 就减 12并点亮 PM 指示灯小时等于 0 时显示 12。这个逻辑放在 render_time 里最合适因为底层时间仍保持 24 小时制调时和跨日判断都不受影响。关键点是硬件进位和软件回绕必须保持一致。如果硬件写的是 23:59:59下一秒应自动变 00:00:00如果软件在设置分钟时把0x59加成了0x60就会导致显示乱码。为了排查这类问题可以打印每天 23:59:59 前后几次硬件寄存器的值确认硬件逻辑是否可靠。6. 验证与校准让 NIOS II 数字钟每天误差进入秒级6.1 用 SignalTap 抓内部 1Hz 秒脉冲SignalTap 是 FPGA 上最直接的验证手段。把pps_1hz、sec_ones、sec_tens三个信号加入观测列表采样时钟选系统时钟深度设 512触发条件设为pps_1hz上升沿。运行后能看到秒脉冲宽度到底是 20 ns 还是已经异常拉宽。如果发现cnt DIV - 1判断没错但 tick 没有按预期周期出现先查复位信号释放时序再看 PLL 是否锁定。这类问题在纯软件仿真里很难暴露SignalTap 抓一两次就会清晰。6.2 用 JTAG UART 做长时间偏差测量时间准不准不能靠肉眼要通过串口持续输出。在主循环里判断秒寄存器变化时打印一次static unsigned int last_sec_disp 0; if (sec ! last_sec_disp) { last_sec_disp sec; printf(T %02x:%02x:%02x\r\n, hour, min, sec); }让系统连续跑 10 分钟用秒表或手机计时软件记录首尾时刻。偏差除以实际经过的时间就是 ppm。举例跑了 600 秒慢了 0.6 秒意味着误差约 1000 ppm这通常是晶振频率不对而不是逻辑问题如果偏差只有 0.02 秒也就是 33 ppm 左右属于普通晶振的正常范围。6.3 用软件校准系数抵消晶振偏差板载晶振的标称频率和实际频率总有一定偏差常见在 ±20 ppm 到 ±50 ppm 之间也就是每天误差约 1.7 秒到 4.3 秒。校准有多种最实用的一种是软件补偿把实测偏差换算成每天需要补的秒数记录在校准变量里。每次硬件时间走到 23:59:59 后根据校准变量插入额外秒或丢弃下一秒脉冲的读取。校准值需要通过多次测量求平均不能只测一次就写入。如果你希望更精确更换温补晶振或 32.768 kHz 晶振模块是最彻底的方案但数字钟训练项目里用软件校准已经能进入每天 1 秒以内。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价