
简介面向FPGA学习者的NIOS II电子钟完整工程基于Quartus与Qsys搭建融合Verilog硬件驱动与Nios II软件控制内含Qsys定时器、数码管驱动、按键调时、闹钟触发等模块适合SoPC片上编程、中断处理与外设联动等场景。整个工程提供8位数码管显示、3按键切换模式并调节时间/闹钟、闪烁位指示、LED按键反馈及蜂鸣器流水灯闹铃功能可支撑从硬件逻辑编写、Qsys组件配置到C语言软件控制的完整训练外设驱动与软件流程均有清晰实现。压缩包共602个文件其中Verilog源码52个、SystemVerilog源码30个、C源码78个、头文件53个另有Qsys系统文件、SOPC信息文件、BSP支持包、sof/elf配置及编译中间文件整体大小22.66MB目录结构完整可用Quartus直接打开并重新编译。已有2280人学习下载对于理解Nios II软核处理器如何控制FPGA外设具有良好的示范性适合毕业设计、课程实验或FPGA进阶自学者对照移植。1. 用NIOS II在FPGA里做电子钟一个软核处理器为什么比上百行状态机更值得折腾我用纯Verilog写电子钟秒计数还好但加上按键调时、整点报时后状态机开始失控。后来改用NIOS II软核把所有逻辑挪到C代码里FPGA只做总线与外围引脚问题一下子简化成“写代码”。这个标题解决的是在FPGA上搭一个最小NIOS II系统用定时器中断计时驱动数码管显示并支持按键调时。适合学过Verilog、想做SoC软硬件协同设计的开发者也适合那些不想被复杂状态机反复折磨、想快速验证功能的人。做完这个项目你会掌握Qsys建工程、BSP生成、中断与PIO操作这是FPGA从硬件走向嵌入式最常走的一条路。2. NIOS II最小系统搭建从Qsys连线到Hello World跑通2.1 为什么选NIOS II而不是纯Verilog状态机或硬核ARM电子钟这个需求纯Verilog确实可以做而且资源占用少、时序完全可控。但一旦你想加一个闹钟、一个温度显示、一个按键组合修改时间状态机的状态数量会快速增长每个新状态都要考虑跳转和输出越改越怕。用C代码写业务逻辑改动成本低很多这也是软核处理器在FPGA上的核心价值。NIOS II是FPGA上的软核处理器综合到FPGA内部指令和外设都通过Avalon总线访问。相比硬核ARM它最大的优势是可以让你随意裁剪外设今天需要I2C就加I2C明天要PWM就加PWM全部在Qsys里拖拽完成不用重新画PCB。代价是主频一般跑不高常见配置在100MHz上下且会占用不少逻辑资源不适合极端实时或超高刷新率的场景。下面这张表是我选型时的判断依据方案优势代价适合场景纯Verilog状态机逻辑资源少、时序精确扩展功能要重改状态机调试慢功能固定的接口转换、简单控制硬核ARM主频高、生态成熟需要外部芯片不灵活跑Linux、复杂HMINIOS II软核可裁剪、C语言开发、和FPGA内部逻辑无缝连接CPU主频低、占资源教学实验、原型验证、中小规模控制电子钟显然属于中小规模控制选NIOS II最合适。你既保留了FPGA的灵活性又把时间处理、按键消抖、显示扫描这些逻辑交给C语言调试时用printf就行不用满屏看波形。当然如果你以后要做高速信号处理软核就不合适了那是硬件逻辑的活这个选型边界要先说清楚。2.2 最小系统需要的IP和外设我一般会在Qsys里添加下面这些组件它们都是FPGA开发工具自带的IP库不需要自己写。IP作用参数建议PLL将板载晶振倍频到CPU时钟输入50MHz输出100MHzon-chip memory作为CPU的程序和数据内存至少16KB建议32KBJTAG UART调试串口输出printf无需外部引脚PIO输入接按键位宽2支持边沿捕获可不开PIO输出数码管段选和位选段选8位位选6位Timer产生时间基准中断计数器位宽32周期按1ms计算注意on-chip memory不是越大越好它占用RAM块资源电子钟程序很小16KB完全够如果你开了HAL和printf库建议给32KB链接时报错再加。PIO输入默认没有上拉电子钟按键场景里按键把IO拉低IO空闲时必须靠外部上拉或PIO内部上拉如果电平悬空后面调时键会乱跳这是后话。2.3 Qsys连线与导出搭建过程不复杂核心是让每个IP的时钟和复位都从同一个源头走。我在Qsys里的习惯顺序是先添加时钟源clk和PLL把外部晶振输入连到PLL的ref_clkPLL的outclk输出100MHz作为所有IP的clk。添加on-chip memory、JTAG UART、两个PIO、一个Timer。把各IP的slave接口接到CPU的管理器总线上中断请求分别连到CPU的中断控制器端口。设置PIO的方向按键输入、段选输出、位选输出。分配中断优先级Timer用最高优先级这样计时不受按键影响。导出PIO的外部连接等端口然后Generate HDL。这些步骤在开发工具里都是图形化操作新手最容易漏的是第5步总觉得中断随便连就好。实际上如果按键和Timer用同一个中断优先级或者Timer被分配低优先级按键抖动时可能阻塞时间计数秒会突然跳。所以Timer中断要单独接并且排在最靠CPU端口的位置。生成系统后在FPGA工程的顶层例化生成的system模块引脚约束文件里把PIO的端口绑定到FPGA引脚。这一步做完最小系统的硬件就算通了。验证是否跑通可以用命令行生成BSP并编译下载# 根据system.sopcinfo生成BSP目录bsp nios2-bsp hal ../bsp system.sopcinfo # 生成MAKEFILE并编译你的应用 nios2-app-generate-makefile --bsp-dir ../bsp --elf-name demo.elf --src-dir ./src make # 下载并运行--go表示下载后直接运行 nios2-download --go --instance 0 demo.elf这段命令的逻辑是先为NIOS II的HAL库生成Board Support Package再生成应用的Makefile最后把ELF烧进RAM运行。参数--instance 0在多CPU系统里指定第一个CPU单核可以不加。如果你用的开发工具版本较新命令行接口可能有些变化但思路一致。下面是最小的C测试程序#include stdio.h #include system.h #include altera_avalon_pio_regs.h int main(void) { // 点亮PIO_0的第0位验证输出通路 IOWR_ALTERA_AVALON_PIO_DATA(LED_PIO_BASE, 0x01); printf(Hello from Nios II\n); while (1) { } return 0; }这段代码里的LED_PIO_BASE是system.h根据Qsys里的组件自动生成的基地址宏只需要把PIO命名为LED_PIO宏就对应生成。执行后如果LED灯亮且串口能打印说明CPU、内存、PIO、JTAG UART都正常。这一步经常卡在LED_PIO_BASE未定义的编译错误原因多半是Qsys里PIO组件名字带了大写或特殊字符建议统一用大写加下划线命名。这里要提醒一个命名习惯Qsys里每个IP的Instance Name会直接影响生成的宏定义。比如你把PIO命名为LED生成的头文件里就是LED_BASE、LED_PIO_BASE两种宏都存在如果你命名成led_1那么宏名的格式可能变成LED_1_PIO_BASE大小写和数字都会被保留。我习惯统一用大写字母加下划线避免编译器报未定义再回去查名字的时间。如果下载后程序没反应先查nios2-terminal是否能看到HAL启动信息。有些开发工具版本会要求先下载.sof配置FPGA再下载.elf运行如果跳过第一步NIOS II CPU根本不存在程序当然跑不起来。到这里最小系统就绪接下来开始写电子钟逻辑。3. 电子钟逻辑在C代码里长什么样定时器中断、按键消抖、数码管扫描3.1 用定时器中断做时间基准别在主循环里delay电子钟的核心是时间基准。很多人一开始会在主循环里用usleep(1000)来凑1ms再累加秒。这个方法在裸机程序里看起来能跑但主循环一旦处理按键、刷新显示耗时会变化秒就走不准了。我习惯的做法是单独用Timer IP做1ms中断全局变量在中断里累加主循环只做UI交互。Timer IP是标准的Avalon定时器有一个period寄存器和一个控制寄存器。我们把它配置成连续计数计到period值后产生中断并自动清零重来。中断服务函数如下#include system.h #include altera_avalon_timer_regs.h #include sys/alt_irq.h volatile unsigned long tick_ms 0; volatile unsigned char sec 0; volatile unsigned char min 0; volatile unsigned char hour 0; static void timer_isr(void* context) { (void)context; // 清中断标志向STATUS寄存器写0 IOWR_ALTERA_AVALON_TIMER_STATUS(TIMER_BASE, 0); tick_ms; if (tick_ms 1000) { tick_ms 0; sec; if (sec 60) { sec 0; min; } if (min 60) { min 0; hour; } if (hour 24) { hour 0; } } }这段ISR只有几次加法和比较执行时间在几百纳秒以内放在1ms中断里完全不会影响其他中断。IOWR_ALTERA_AVALON_TIMER_STATUS(TIMER_BASE, 0)是清中断标志的标准写法有些库函数需要写1这里按寄存器手册写0如果你用的SDK版本不同以头文件定义为准。中断里绝对不能放阻塞操作比如printf否则会拖慢时间计数秒会明显变慢。初始化Timer的代码如下#include altera_avalon_timer_regs.h void timer_init(void) { // 假设Timer时钟100MHz1ms周期 100000-1 IOWR_ALTERA_AVALON_TIMER_PERIOD(TIMER_BASE, 100000 - 1); IOWR_ALTERA_AVALON_TIMER_CONTROL(TIMER_BASE, ALTERA_AVALON_TIMER_CONTROL_ITO_MSK | ALTERA_AVALON_TIMER_CONTROL_CONT_MSK | ALTERA_AVALON_TIMER_CONTROL_START_MSK); // 注册中断处理函数 alt_ic_isr_register(TIMER_IRQ_INTERRUPT_CONTROLLER_ID, TIMER_IRQ, timer_isr, 0, 0); }Period寄存器的值是计数器从0到设定值所需要的时钟周期数减1所以100MHz时钟下1ms就是100000-1。控制寄存器里ITO是中断使能CONT是连续模式START是启动计数三个位同时置1定时器才会自动重载并持续产生中断。这里有个大坑Timer的计数器位宽如果在Qsys里选了16位最大只能写65535100000根本放不进去。所以上一章参数表里强调要选32位计数器这是电子钟能走准的前提。3.2 按键消抖10ms采样的状态机机械按键按下和释放时电平会抖动几十毫秒不处理的话一次按下可能被当成多次。纯Verilog里消抖要用计数器生成延时NIOS II里用定时器的tick更简单。我定义了一个10ms周期的扫描标志主循环每10ms调用一次key_scan()。采样策略是连续两次读到的键值相同才认为按键状态稳定。代码#include altera_avalon_pio_regs.h #define KEY_PIO_BASE KEY_BUTTON_BASE // 由system.h生成 #define KEY_MASK 0x01 // 只用第0位 static unsigned char key_last 0xFF; volatile unsigned char key_stable 1; volatile unsigned char key_press_flag 0; void key_scan(void) { unsigned char now; unsigned char raw (IORD_ALTERA_AVALON_PIO_DATA(KEY_PIO_BASE) KEY_MASK); // 第一次采样记录第二次开始比较 if (key_last 0xFF) { key_last raw; return; } now raw; if (now key_last) { // 两次相同说明稳定 key_stable now; if (key_stable 0) { // 低电平按下 key_press_flag 1; // 只置位一次等待主循环消耗 } } else { key_last now; } }这里key_last初值设为0xFF是为了让第一个采样点直接进入记录分支。每次扫描间隔10ms连续两次相同至少跨20ms正好躲开常见机械抖动的持续时间。需要说明的是这个消抖逻辑只识别“按下”事件不会在按住期间重复触发因为一旦key_press_flag被置位后主循环处理完会清0而key_stable变0后后续采样不会再置位。如果你需要“长按调时”功能得另加计时不要用这个简单方案。3.3 数码管动态扫描先关位选、送段码、再开位选常见开发板用共阳或共阴数码管段选8位位选6位。动态扫描的原理是让六位轮流点亮利用人眼视觉暂留形成持续显示。刷新率不能太低低于50Hz会闪烁我习惯让每1ms切换一位六位一个周期6ms刷新率167Hz稳定无闪烁。扫描函数直接放到定时器中断里因为中断周期固定显示就不会被主循环拖累unsigned char seg_code[] { 0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90 }; unsigned char digit[6]; unsigned char scan_pos 0; void display_scan(void) { // 先关闭所有位选防止上一位的残影 IOWR_ALTERA_AVALON_PIO_DATA(DIG_POS_BASE, 0); // 送段码注意段码表与共阳/共阴对应 IOWR_ALTERA_AVALON_PIO_DATA(DIG_SEG_BASE, seg_code[digit[scan_pos]]); // 打开当前位选 IOWR_ALTERA_AVALON_PIO_DATA(DIG_POS_BASE, (1 scan_pos)); scan_pos; if (scan_pos 6) { scan_pos 0; } }seg_code这里给的是共阳数码管的0~9段码0xC0表示数字0。共阳数码管把公共端接高电平段选低电平点亮如果你的板子是共阴需要把段码取反或换表。位选同理有些板子用低电平选中DIG_POS_BASE写的值要跟着反逻辑。先关位选再送段码这一步不能省否则切换瞬间会出现上一位的数字残影看上去就是鬼影。主循环里只需要处理按键和修改时间显示完全不参与int main(void) { timer_init(); while (1) { if (key_press_flag) { key_press_flag 0; // 在这里做调时逻辑 } } }这样时间计数和显示刷新都在ISR里完成主循环即使被按键阻塞几十毫秒也不会导致计数丢失。如果说哪个步骤最容易翻车那就是把display_scan()放在主循环里按键消抖用了delay结果扫描频率变得不稳定数码管像抽风一样。这是我在调试某次模拟项目X时亲眼见过的写法改到ISR里后一切正常。4. 秒基准确的关键参数定时器计数值、PLL倍频比、共享变量保护4.1 定时器Period到底怎么填从算周期到看寄存器位宽定时器Period寄存器决定中断频率。公式是Period 定时器时钟频率 × 中断间隔 − 1例如定时器时钟是100MHz中断间隔1ms100000000 × 0.001 − 1 99999。如果中断间隔10us则1000 − 1 999。但不同配置差别很大表里列出常用组合定时器时钟中断间隔Period值16位能否容纳50MHz1ms49999能100MHz1ms99999不能100MHz655us65534能100MHz10us999能如果你的Timer IP在Qsys里保持16位默认且时钟100MHz那1ms周期的Period99999会溢出开发工具在生成时一般不会报错但运行时定时器会按低16位截断后的值计数导致中断频率比预期高很多时间严重偏快。我调试某次电子钟Demo时秒走得比正常快出近一倍查了半天才发现是16位计数器溢出。解决就两条路一是把Qsys里的Timer计数器位宽改成32位二是改用小Period加累加。比如16位下设置Period9999对应中断间隔100us每次中断累加10次才算1ms。后者ISR运行更频繁CPU开销大一点但胜在不改硬件。我一般优先改32位一劳永逸。4.2 PLL倍频比为什么50MHz晶振倍到100MHz是最优解开发板上的晶振常见有50MHz、25MHz、12MHz。NIOS II的CPU时钟通常取100MHz因为PLL在小倍频系数下抖动最小。下表是几组常见配置晶振频率目标频率倍频系数分频系数说明50MHz100MHz21整数PLL首选25MHz100MHz41整数PLL12MHz100MHz253分数PLL抖动大18.432MHz88.47MHz非整数非整数不建议为什么非要整数PLL本质上是对参考时钟做相位比较和压控振荡倍频分频系数为整数时输出时钟的频率和相位都稳定非整数倍频要用分数分频器输出会有周期抖动。对于电子钟这种对相位不敏感的应用抖动其实不会直接导致走时不准因为时间计数的基准是定时器时钟平均频率稳定即可。但抖动会影响UART和外部存储器时序为了后续扩展我习惯让PLL输出精确的整数倍。另一件容易漏的事是PLL的locked信号。Qsys生成系统时如果直接用PLL输出给CPU时钟复位模块必须等待PLL锁定后再释放。常见做法是把PLL的locked接到复位控制IP的d输入或者自己写一个与门。如果不锁就运行程序会随机跑飞表现是JTAG UART偶尔能打印偶尔完全死寂。这种问题用示波器看时钟才能发现属于玄学类故障。4.3 共享变量volatile和临界区两者缺一不可在ISR里修改sec、min、hour主循环里读取这属于典型的中断与上下文共享变量。C编译器很可能把局部缓存优化掉导致主循环看到的还是旧值。第一件事就是给所有跨中断的变量加volatile告诉编译器不要把它们优化进寄存器。但这还不够。如果主循环要一次性读取整个时间比如“12:30:00”而中断正好在读取过程中更新了sec那么你有可能读到“12:30:00”和“12:30:01”的混合体。电子钟场景里一个字节的变量一般不会错到离谱但如果你把时分秒组合成一个32位值那就必须用临界区保护。我用HAL的接口写过这样一个读函数void get_time(unsigned char* h, unsigned char* m, unsigned char* s) { // 关全局中断保证读取原子性 alt_irq_disable_all(); *h hour; *m min; *s sec; alt_irq_enable_all(); }alt_irq_disable_all和alt_irq_enable_all是HAL提供的全局中断开关在临界区内不会被打断。临界区要尽量短只做变量拷贝不能在里面放扫描函数或延时。我在早期的版本里曾经图省事在临界区里调用了printf结果每次读时间都卡顿秒跳得更厉害。后来养成了习惯所有对共享变量的读写在同一个文件内完成对外只提供获取和修改的封装函数这样其他模块就不会绕过临界区直接访问全局变量了。还有一点容易被忽略修改时间时比如按住按键快速加小时主循环也需要对hour做修改。按键处理在主循环里中断每1ms都可能改写hour因为分满60会进位所以“改小时”的代码也要进临界区否则会出现进位同时改同一字节的竞态。在电子钟这种简单应用里一个字节的赋值在大多数架构上是原子的不会出现撕裂但进位涉及“读、加、写”三步并不原子。所以调时函数我也照例关中断void adjust_hour(int delta) { alt_irq_disable_all(); hour delta; if (hour 24) hour 0; if (hour 0) hour 23; alt_irq_enable_all(); }这段代码看着简单却解决了我遇到的最隐蔽的时间跳变问题。如果你在主循环里直接写hour而定时器中断又正好在抬秒进位两个写操作交错偶尔就会把hour写成0或者奇怪的值。这种问题不是每次都复现跑十分钟才跳一次光看代码很难想到所以我把它列为必加的保护。5. NIOS II电子钟避坑清单5个典型翻车现场现象、原因、解决5.1 现象按一下调时键时间跳了好几分钟按下按键后时间不是加1秒而是跳好几分钟通常不是逻辑错而是按键被重复识别。原因有两个一是IO没有上拉按键松开的瞬间电平不确定产生多次下降沿二是消抖只做了“两次相同即有效”但主循环处理事件后没有等待释放导致一次长按被拆成多次短按。解决首先在Qsys的PIO输入里勾选“Input options - Enable pull-up”保证空闲电平稳定其次在消抖状态机里增加“等待释放”状态即只有检测到按下事件且随后检测到释放才允许下一次按下事件生效。我某次在开发板上用了个劣质轻触开关板子刚上电就发现时间乱跳查了半天最后用一根杜邦线飞线上拉电阻解决。5.2 现象数码管第一位特别亮其余位发暗动态扫描显示时第一个数字比其他人眼可见地亮这是占空比不均导致的。如果扫描函数在中断里做理论上每位停留时间相等但若代码里用了switch(scan_pos)而每个case里又调用了不同的延迟或判空逻辑时间就不均了。更常见的是位选驱动电路用的是三极管第一个位选管导通速度比后面的快硬件差异不是软件能完全抹平的。解决在软件上把扫描周期严格固定最简单的写法就是上面的数组取模不要用switch再把扫描频率从1ms一位提到800us一位高频下人眼对占空比差异的感知会减弱。如果还不行检查硬件原理图位选电阻和驱动管型号要保持一致这是实打实的血泪经验。5.3 现象断电重启后时间归零但程序能正常跑FPGA用的是SRAM掉电内容全部丢失NIOS II的程序如果放在RAM里上电后由配置芯片重新加载但时间变量必然归零。这不是bug是架构限制。解决要分档如果你只是课堂演示每次重新上电人工调时也算能接受如果要做成能用的电子钟必须外接RTC芯片。最常见的是I2C接口的RTCNIOS II里挂一个I2C IP上电时读时间到变量掉电期间由备用电池维持。我建议不要把时间存FPGA片内Flash因为每次上电从Flash读回来再初始化变量流程繁琐且Flash写次数有限。RTC芯片才是产品化正路成本不高。5.4 现象JTAG UART打印出来是乱码JTAG UART本质是一个通过JTAG调试口的虚拟串口它不需要波特率设置所以“波特率不匹配”这个传统原因不成立。乱码多半是环境问题程序在PLL还没锁定时就开始printfJTAG链路时钟不稳或者电脑端的调试终端软件把回车换行处理错了又或者你用了别人的工程模板HAL的stdout指向的是普通UART而不是JTAG UART。解决把PLL的locked信号接进复位模块确保时钟稳定后再运行printf之后调用fflush(NULL)刷新HAL的缓冲区在BSP设置里确认stdout设备为jtag_uart。如果你确实需要用板载串口芯片那就要配置额外的UART IP并把BSP的stdout指向它此时才需要关心波特率一致。5.5 现象程序死在alt_main之前连main都没进裸机NIOS II应用默认的入口是alt_mainHAL会先初始化中断、设备驱动然后才调用你的main。如果在alt_main之前死循环几乎没有调试信息只能靠排除法。最常见的原因是BSP把某个外设配置成了系统时钟sys_clk_timer或系统控制台而这个外设的基地址或中断号和你实际Qsys配置不一致初始化时一直在等待中断。我在某次电子钟工程里把Timer设成了sys_clk_timer但Timer的Period填错了导致HAL自检时认为时钟太快直接卡死。解决如果不需要操作系统在BSP的Settings里把sys_clk_timer相关的驱动都设成none只保留JTAG UART作为stdout同时把链接器堆栈大小从默认的2KB改成8KB避免动态扫描的中断嵌套把栈挤爆。改完后alt_main就能顺利通过。6. 进阶用SignalTap验证数码管扫描波形顺手把中断优先级改成你想要的6.1 用SignalTap抓位选和段选波形软件调通了不代表硬件上波形干净。我习惯在调试最后阶段用SignalTap逻辑分析仪抓一下PIO信号。在开发工具里新建SignalTap实例采样时钟选100MHz触发信号选择位选的最高位采集深度设2048点运行几秒后可以看到六组位选脉冲。判断标准每位高电平持续时间应该完全相同周期约为1ms如果有一位高电平明显变宽或变窄说明扫描代码里那个分支多了几行指令或者是某位PIO的驱动能力差导致边沿变慢。SignalTap会占用片上RAM调试完毕后一定要删掉实例再生成最终版本否则下载到板子上的固件资源占用偏大有时会莫名多出几个警告。我最初懒得删程序就崩溃过一次这个坑踩得值。6.2 中断优先级设置与一个养成式习惯在Qsys里各外设的中断信号连到CPU中断控制器的顺序就决定了优先级越靠前的优先级越高。我会把Timer放在第一个按键PIO放中间JTAG UART放最后。这样即使UART在频繁打印也不会打断定时中断导致秒丢失。代码层面除了用alt_irq_disable_all做临界区尽量把ISR变短中断里只改变量和做显示扫描按键的具体逻辑全部丢到主循环。我养成的一个习惯是把所有被中断修改的变量集中放在一个文件里用一组set/get函数访问绝不裸读。这个习惯帮我从电子钟一路搬到后来的传感器采集项目几乎没再为竞态发过愁。做硬件工程就是这样很多问题是软件思维想不到的只有用逻辑分析仪和临界区一点点磨才能把“能走”变成“能看”。希望帮到你。本文还有配套的精品资源点击获取