资讯动态

基于NIOS II软核的FPGA电子钟设计:从Verilog到C语言的软硬件协同实践

发布时间:2026/10/14 18:23:47 来源:尧图企业网站定制
简介这是一份基于FPGA与NIOS II软核处理器的电子钟完整设计工程适用于正在学习SOPC嵌入式系统、Verilog硬件描述语言或准备相关课程设计/竞赛的读者。工程围绕Qsys生成的timer_1ms定时器计时、8个数码管显示、三按键切换调节当前时间与闹钟、闪烁标志标示正在调整的位、按键LED反馈及蜂鸣器闹钟响应时流水灯指示等功能展开从硬件驱动到NIOS II软件实现均提供完整源码与工程配置能帮助读者理解软硬件协同设计的基本流程。资源包共602个文件涵盖Verilog/SV硬件源码、C/C程序、Qsys与Quartus工程文件、MIF初始化数据以及sof下载文件等压缩包约22.66MB保留编译脚本、调试信息和目录结构便于按模块查阅。已有2280人学习/下载文件类型完整尤其适合对照学习timer_1ms计时逻辑、按键状态机处理和数码管动态扫描驱动也可作为电子钟类SOPC项目的直接参考模板。1. 用 FPGA 做电子钟为什么我选 NIOS II 软核而不是纯 Verilog同样是做课程设计里的电子钟A同学用纯 Verilog 写了三百行状态机改一个 12/24 小时制切换的需求牵扯到计数器、显示译码和按键判断三处改动改完还以为踩平了坑结果进位逻辑又翻车。我拿这套基于 NIOS II 的 FPGA 电子钟设计时思路刚好相反把 FPGA 当成一块可以自定义外设的板子软核处理器跑 C 语言累加、调时、扫描全交给代码。改需求就是改函数不是改时序。它适合正在学 NIOS II 嵌入式、做数字系统课程设计或者想把 FPGA 和 C 语言串起来练手的开发者如果只想学 Verilog 语法这套设计的重点不在 RTL 编码下载前先想清楚自己能接受多少软核工具链的折腾。2. 系统架构与软核选型模块切分、PLL 时钟树与地址映射NIOS II 电子钟和“用 Verilog 写一个时钟”最大的区别是系统的复杂度从时序逻辑转移到了软件逻辑。做这个设计之前得先把软硬件边界划定否则后面每加一个功能都要重新动 Qsys 里的连线非常痛苦。2.1 电子钟拆成哪五块软硬边界放到中断与 PIO 处一个能交差的电子钟按功能拆大概五块计时基准、时间存储、显示输出、按键输入、提示输出。纯 Verilog 方案里每一块都可能是一个独立模块模块之间靠信号握手改一处往往要连带改三四处的端口声明。NIOS II 方案里这五块被拆成“软核里跑的逻辑”和“FPGA 里固化的外设”两部分。我一般这样划边界计时基准用硬件定时器中断这是时钟的心脏不能放在主循环里靠软件数指令周期时间存储就是 C 语言里的结构体变量存在 onchip memory显示输出用一组 PIO 直接推数码管的段码和位选按键输入用另一组 PIO加边沿捕获提示输出可以是一个蜂鸣器 PIO 或者直接复用 JTAG UART 打串口日志。这里有个容易被新手忽略的点数码管的动态扫描放在哪里。很多人习惯在 Verilog 里写一个扫描模块但 NIOS II 方案里这活儿完全可以交给 C 语言主循环。扫描周期是毫秒级的软核跑 100MHz 时一个循环指令开销远小于 1ms完全来得及。我在项目里就是让主循环不停调用一个 display_scan() 函数逐位刷新六个数码管位选周期控制在 10ms 以内人眼看起来就是稳定显示。按键消抖也是同样的思路。我不做专用的硬件消抖模块而是让按键 PIO 产生低电平信号软件里用连续多次采样确认电平稳定后再响应。这样做的收益是可编程逻辑资源被省下来而且消抖的“连续几次”阈值随时可以改不用重新综合编译。2.2 NIOS II/e 与 NIOS II/f 的取舍缓存、乘法器与代码空间NIOS II 软核有三种内核economy、standard、fast。电子钟这种场景性能和资源怎么平衡直接影响编译通过率和运行稳定性。对比项NIOS II/eNIOS II/f流水线简单结构带动态分支预测硬件乘法无靠软件模拟支持32x32 硬件乘法指令缓存无可配置典型 4KB资源占用低约 600-900 LE高约 1800-2500 LE适合场景简单 IO 控制、状态机有运算量、实时性要求纯计时功能用 e 核也跑得动但一旦涉及时间换算、闰年判断、按键长按短按的逻辑软件模拟乘法的效率会让你明显感觉到卡顿。我最终选的是 f 核最小配置开硬件乘法指令缓存设 4KB关闭 MMU。这样资源占用比默认配置低一截跑 C 代码的体感却比 e 核流畅很多。有个血泪经验f 核默认的综合策略优先“性能”在低端 FPGA 上同时挂 PLL、两个 PIO、定时器和 JTAG UART资源利用率很容易逼近上限。我会在 Quartus 的 Analysis Synthesis 设置里把 Optimization Technique 改成 Area牺牲一点时序余量换编译通过率。对电子钟这种对时序不敏感的设计完全值得。2.3 时钟树设计外部 50MHz 与内部 100MHz 的换算关系开发板上晶振一般给 50MHzNIOS II/f 跑到 100MHz 需要 PLL 倍频。时钟树看起来简单——一个 PLL 分两路输出——但这里藏着好几个容易翻车的细节。我的分配方式是这样PLL 输出clk_core给 NIOS II 处理器、onchip memory 和定时器频率设 100MHz保证指令执行速度PLL 输出clk_periph给 PIO 和 JTAG UART频率设 50MHzIO 访问不需要那么高的速率慢时钟域下的时序约束反而更好满足。时钟信号频率挂载模块用途clk_core100MHzNIOS II/f、onchip_memory、timer_0指令执行、1ms 中断基准clk_periph50MHzPIO、JTAG UART数码管扫描、按键采样、串口调试注意复位链PLL 的 locked 信号要作为整个 Avalon 总线的复位源之一而不是直接把外部复位按键接到所有模块。常见做法是让 PLL locked 输出接一个reset_bridge再分发给 NIOS II、timer 和 PIO。否则会出现一种玄学现象程序下载后第一次运行正常复位一次后就死机原因是部分外设已经复位而处理器还在跑旧的取指流程。时序约束方面如果 Quartus 报了跨时钟域的告警先看是不是 clk_core 和 clk_periph 之间有交互路径。电子钟设计里两个时钟域几乎没有数据交换没有实际路径就加set_false_path约束掉告警清零后续看时序报告也清爽。3. 最小 NIOS II 系统搭建Qsys 组件清单、定时器参数与复位链这一章是整个工程的骨架。Qsys新版本里叫 Platform Designer里连线连错一个后面 C 代码写得再漂亮也跑不起来。我第一次搭这个系统时光 reset 桥就反复试了三种接法最后把组件清单和关键参数沉淀成了固定模板之后每次重建工程直接照抄。3.1 从空工程到带外设清单七个组件的连接方式我搭建的最小系统包含七个组件少一个都不行PLL、NIOS II/f 核、onchip memory、定时器、按键 PIO、数码管 PIO、JTAG UART。SysID 组件可加可不加加上能防止调试时程序版本和硬件不匹配建议保留。组件名类型关键参数用途clk_pllAltera PLL输入 50MHz输出 100MHz / 50MHz内部时钟生成cpuNios II Generation 2f 核ICache 4KB硬件乘法处理器memOn-Chip Memory16KB双口 RAM程序与变量存储timer_0Avalon Timerperiod 1msclock rate 100MHz时基中断key_pioAvalon PIO4-bit Input边沿捕获按键输入seg_pioAvalon PIO14-bit Output段码位选输出jtag_uartJTAG UART默认配置串口调试输出onchip memory 的大小要留够标准电子钟工程加上 printf 调试输出代码段加数据段大约 10-14KB。设 16KB 是底线如果计划加菜单、多模式切换、日志缓冲直接设 32KB。容量太大也不行低端 FPGA 的 M9K 块是有限的32KB 会吃掉不少块给其他逻辑留的空间就少了。定时器参数是这里最不能填错的。period 1表示定时器计数到 1ms 就触发一次中断配合clock rate 100000000Qsys 会自动算出计数器的 period 寄存器初值。如果你把 clock rate 填成 50MHz 而实际挂在 100MHz 时钟上实际中断周期会变成 0.5ms时钟会走得快一倍这是一个特别隐蔽的坑。3.2 地址映射与中断号删掉多余组件才能稳定跑Qsys 里每个 Avalon 外设都会被分配一个基地址NIOS II C 代码里的IORD/IOWR宏就是靠这些地址访问寄存器的。地址映射不用手动记system.h 文件里会自动生成TIMER_0_BASE、KEY_PIO_BASE这些宏。但有两个东西必须自己确认中断号以及各外设的地址范围有没有重叠。我一般会在 Qsys 的 Address Map 页面检查一遍确保没有重叠然后把自动分配的地址段抄下来。中断号的分配顺序也看这里后面用alt_ic_isr_register注册中断服务函数时第二个参数填的 IRQ 号必须和 Qsys 列表一致填错了编译不报错但中断永远不进你的回调函数。这套系统我最后用 TCL 脚本固化了方便以后重建。从 Qsys 的 Generate 菜单可以导出整个系统的 TCL 脚本我用脚本改参数比 GUI 点选可靠得多至少不会漏连线。create_system my_clock add_component clk_pll altera_clock_source set_parameter clk_pll clock_freq 50000000 add_component cpu nios2_gen2 set_parameter cpu hardware_multiplier yes set_parameter cpu icache_size 4096 add_component mem onchip_memory2 set_parameter mem size 16384 add_component timer_0 altera_avalon_timer set_parameter timer_0 period 1 set_parameter timer_0 clock_rate 100000000 add_component key_pio altera_avalon_pio set_parameter key_pio direction Input set_parameter key_pio width 4 add_component seg_pio altera_avalon_pio set_parameter seg_pio direction Output set_parameter seg_pio width 14 add_connection clk_pll.clk cpu.clk add_connection clk_pll.clk_reset cpu.reset add_connection clk_pll.clk mem.clk add_connection clk_pll.clk timer_0.clk add_connection clk_pll.clk key_pio.clk add_connection clk_pll.clk seg_pio.clk add_connection timer_0.interrupt cpu.interrupt_receiver脚本里每行add_connection都把时钟和复位接一遍这里最容易漏的就是 timer 的中断连接。另外注意 reset 信号的方向clk_pll.clk_reset输出的是高有效复位如果某个外设期望低有效复位中间要加 inversion 组件。我统一让所有外设都吃同一个clk_reset避免混用搞出半复位状态。脚本写完后在 Qsys 里 Source 一下就能生成和 GUI 搭建完全一致的系统。之后每次改参数只改对应一行重新生成。我建议你拿到工程后第一件事就是导出 TCL 脚本存一份这相当于整份硬件系统的“后悔药”。4. 用 C 写时间逻辑1ms 时基中断、显示扫描与按键状态机硬件系统生成后剩下的事情就是写 C 代码。电子钟的软件部分有三个核心时基中断负责计时主循环负责显示扫描和按键处理。三者之间用共享变量通信要特别注意中断里改数据、主循环里读数据的同步问题。4.1 时基中断与进位把定时器中断变成秒表基定时器配置成每 1ms 中断一次ISR 里做的事情越少越好。我见过有人把数码管扫描也塞进 ISR 的结果主循环空闲时 CPU 占用率显示 99%按键响应变得迟钝。#include sys/alt_irq.h #include altera_avalon_timer_regs.h #include system.h typedef struct { uint32_t ms; uint8_t sec; uint8_t min; uint8_t hour; uint8_t day; uint8_t month; uint16_t year; } clock_t; volatile clock_t now {0, 0, 0, 0, 1, 1, 2024}; static void timer_isr(void* context) { // 必须先读状态寄存器并写回清除否则中断会反复触发 uint32_t status IORD_ALTERA_AVALON_TIMER_STATUS(TIMER_0_BASE); IOWR_ALTERA_AVALON_TIMER_STATUS(TIMER_0_BASE, 0); if ((status ALTERA_AVALON_TIMER_STATUS_TO_MSK) ! 0) { now.ms; if (now.ms 1000) { now.ms 0; now.sec; if (now.sec 60) { now.sec 0; now.min; if (now.min 60) { now.min 0; now.hour; if (now.hour 24) { now.hour 0; } } } } } } void 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); alt_ic_isr_register(TIMER_0_IRQ_INTERRUPT_CONTROLLER_ID, TIMER_0_IRQ, timer_isr, NULL, 0); }ISR 里第一行读状态寄存器是必须的第二行写 0 是把中断标志位清掉。如果不做这一步定时器中断标志一直置位ISR 返回后 CPU 立刻再次进入中断表现为程序卡死在中断入口。alt_ic_isr_register的第一个参数是中断控制器 ID第二个是 IRQ 号这些都在 system.h 里。注意最后两个参数context 传 NULL 没问题但如果以后做多个时钟实例可以把实例指针传进来ISR 里通过 context 参数区分是哪个定时器触发的。我一般不给 ISR 加 printf打印一条串口日志的耗时可能超过 1ms会直接影响下一次中断的响应时间累计就开始漂了。4.2 显示扫描段码表、位选与刷新率数码管动态扫描的原理是利用人眼视觉暂留六个数码管轮流点亮保证每位的刷新频率不低于 60Hz 就不会闪烁。段码表按共阴数码管设计如果你的板子是共阳需要把整个表取反。static const uint8_t SEG_CODE[16] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F, 0x77, 0x7C, 0x39, 0x5E, 0x79, 0x71 }; static uint8_t digit_buf[6]; void display_scan(void) { static uint8_t pos 0; // 先写 0 整个灭掉避免上一轮段码残留在当前位 IOWR(SEG_PIO_BASE, 0x0000); // 再写段码和位选一次写入 14 位 IOWR(SEG_PIO_BASE, (SEG_CODE[digit_buf[pos]] 8) | (1u pos)); pos (pos 1) % 6; }digit_buf里存的不是数字本身而是显示内容的索引比如要显示“12:34:56”digit_buf 依次存 1、2、3、4、5、6。时间进位时只需要改一个中间变量显示部分不用知道具体时间值。这段代码放在主循环里调用就行加个简单的分频if (tick_counter % 5 0) display_scan();tick_counter 每 1ms 加一这样扫描周期是 3ms完整六位一轮 18ms刷新率约 55Hz不闪。4.3 按键消抖连续采样与释放检测按键是整套设计里最容易出“玄学”问题的地方。电平抖动、长按连触、切换模式时跳变根因都是没有用状态机做消抖。typedef enum { KEY_IDLE, KEY_DOWN, KEY_WAIT_RELEASE } key_state_t; void key_scan(void) { static key_state_t state KEY_IDLE; static uint8_t stable_cnt 0; uint32_t raw IORD(KEY_PIO_BASE) 0xF; switch (state) { case KEY_IDLE: if ((raw KEY_MASK) 0) { stable_cnt 0; state KEY_DOWN; } break; case KEY_DOWN: if ((raw KEY_MASK) 0) { if (stable_cnt 3) { state KEY_WAIT_RELEASE; do_key_action(); } } else { state KEY_IDLE; } break; case KEY_WAIT_RELEASE: if ((raw KEY_MASK) ! 0) { state KEY_IDLE; } break; } }KEY_MASK 按实际按键位定义比如只有 KEY0 接了调时功能就定义#define KEY_MASK 0x01。stable_cnt 连续三次采样到低电平才确认按键按下这个阈值对应约 30ms 消抖时间物理按键足够稳定了。KEY_WAIT_RELEASE 状态是防止长按连触的关键。如果不等待释放一个按键按下去会触发好几次动作。我一开始偷懒没做这个状态结果按下调时键一次跳了五秒后来补上释放检测才解决。do_key_action 里做具体的调时逻辑比如切换当前调整位、对秒/分/小时做加一操作。这里注意加的操作直接改now结构体里的字段但这些字段同时被中断里的进位逻辑修改理论上存在竞争。我的处理方式是不在按键里做秒的进位判断只做字段增量进位逻辑统一收敛到 ISR 里判断保证同一时刻只有一个位置在推进时间。5. 常见问题排查软核跑飞、时钟偏快、按键乱跳的六个典型坑软核系统调试起来比纯 Verilog 麻烦的地方在于编译通过不代表能跑能跑不代表时间准。我把项目里遇到过的坑按现象、原因、解决三段式列出来照着排查能省不少时间。5.1 跑不起来与跑飞复位链、中断注册与 memory 越界坑一下载 .sof 后 JTAG 终端一直刷乱码程序根本没跑到 main。原因多半是 onchip memory 容量不够编译生成的 .elf 里代码段和数据段的地址超出了 memory 的实际范围CPU 取指取到空的地址空间。解决查看编译报告里 memory usage把 onchip memory 扩到 32KB 再重建。如果资源紧张把编译优化等级开到 -O2 能显著压代码体积但调试时变量观察可能受影响建议在最终版本再开。坑二初始化定时器中断后一使能就进硬件异常程序卡死。原因ISR 里没有清中断状态寄存器导致中断标志一直有效CPU 反复进入同一个中断服务函数。ISR 入口必须先读 STATUS 再写回 0这步不做后面代码动都不动。解决严格按前面 4.1 的写法IORD 读状态IOWR 写 0 清标志。另外注册中断服务函数时irq 号必须与 Qsys 里分配的中断号一致可以在 system.h 里查TIMER_0_IRQ宏。坑三Nios II 调试器能连上核心但 Run As 没反应或提示 timeout。原因Qsys 里没有保留 JTAG 调试模块或者综合时把未使用的调试模块优化掉了。NIOS II 核内嵌的调试逻辑默认不占用额外引脚但某些综合选项会把它裁掉。解决在 NIOS II 处理器配置页保留 JTAG Debug Module 的 Level 1 以上同时检查 Quartus 综合设置里有没有开 “Remove unused logic”。这个坑我栽过一次表现形式特别隐蔽——第一次能下第二次就 timeout最后发现是优化选项在作怪。5.2 时间不对与显示残留频率校准、扫描时序与电平抖动坑四电子钟每天快或慢好几秒而且不是线性偏差。原因分两种晶振本身的 ppm 偏差以及定时器 clock rate 参数和实际挂载时钟频率不匹配。后者会让偏差大到肉眼可察比如每秒都快一倍。解决先用示波器测定时器中断对应 GPIO 翻转的频率确认 1ms 基准是否准确。如果只是晶振 ppm 级别的慢快软件校准的办法是累计误差比如每累计 86400 个 tick24 小时快 3 秒就在软件里设定每天补偿逻辑提前 3 秒触发进位。这种校准简单有效但只适合对长期精度要求不苛刻的场景。坑五数码管显示正常但某个位明显比别的暗或显示残影。原因动态扫描时没有先消隐再送新位选。上一轮的段码还停留在当前位新的位选已经切到下一只数码管残影就出现了。解决display_scan 里第一行先写 0 全灭再写段码和位选。另外检查位选信号是低有效还是高有效共阴共阳接法不同段码表也要相应取反。这块对着原理图看一遍就能确认不需要盲目试。坑六按键调时按下一次跳好几秒或者切换模式时连续触发两次。原因按键电平抖动没有滤掉或者按键处理里没有释放检测。按下瞬间电平在高低之间跳变软件采样到了多个有效沿。解决用 4.3 的状态机替换简单的 if 判断。连续三次采样确认按键稳定进入 KEY_WAIT_RELEASE 等待释放。如果要求更高还可以在释放后再加一次稳定采样但电子钟场景没必要。烧写相关的坑单独提一句大部分调试场景只烧 .sof程序存在片上 RAM 里掉电就没了。想做成上电自启动的电子钟得把 .elf 转成 flash 格式并烧录到配置器件。命令行方式是这样elf2flash --base0x0 --end0x7fffff --reset0x0 \ --inputclock.elf --outputclock.flash quartus-pgm -c 1 -m JTAG -o p;output_files/clock.sof quartus-pgm -c 1 -m JTAG -o bv;clock.flash第一行把 .elf 转成 flash 镜像base 和 end 参数要与 Qsys 里配置器件的地址范围对应。第二行烧 .sof第三行烧 flash。顺序不能反先烧硬件镜像再烧程序镜像。如果你用的是 Quartus 图形界面菜单里也有对应的 Flash Programmer 向导效果一样。6. 上板前的验证清单与后续扩展串口校时与外部 RTC6.1 上板前的验证清单项目收尾阶段不建议直接拔电看效果我习惯按下面这个清单走一遍每一项都有明确的通过标准。检查项操作通过标准1ms 基准定时器中断里翻转一个测试 GPIO用示波器测周期 1.00ms抖动小于 10us秒进位串口打印 now.sec观察到 59 到 00 跳变显示刷新肉眼观察六位数码管无闪烁、无残影按键响应短按调时键单次触发无连跳上电自启动烧录 flash 后拔电重上电自动运行到主界面串口打印是 NIOS II 调试最简单的工具。在 main 函数里加一段循环打印就能把内部时间可视化成文本流比盯着数码管判断快得多。6.2 扩展把电子钟变成一个小型时基系统基础功能跑通后有两个扩展方向性价比很高。一是串口校时PC 端发一串固定格式文本比如SET 12:34:56软核在串口中断里解析并更新 now 结构体。实现难度不大但彻底解决了手动按键调时对不准的问题。二是外接带温度补偿的 I2C 实时时钟芯片。NIOS II 做主机的 I2C 控制器用两个 PIO 模拟就行代码量不大精度直接提升一个量级还能在掉电后持续走时。我去年的模拟项目就是在这个电子钟基础上加了 I2C 总线把时钟精度从“每天快 3 秒”干到了“一个月差不到 1 秒”整个系统的可靠性上了一个台阶。现在我拿到任何 NIOS II 工程第一件事不是翻 C 代码而是先把 reset 链捋一遍再到 system.h 里核对中断号和地址映射最后才动软件。这个顺序帮我避开了大量返工。这次的电子钟设计也是一样如果你打算照着做建议先把第三章的组件清单和第五章的坑通读一遍再动手。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑