简介:这套资料面向ALTERA FPGA嵌入式开发者,提供了I2C主控制器IP的完整VHDL实现,并展示如何通过Avalon接口挂接到NIOS II软核处理器,适用于需要与传感器、存储器等外设通信的项目。I2C协议只需两根信号线即可实现全双工通信,NIOS II作为可配置软核CPU,配合Avalon标准总线,能灵活构建片上系统。资源共13个文件,压缩包仅190KB,核心为4个VHDL源文件,从底层位控制、字节控制到顶层模块层层封装;另附C语言寄存器头文件、HAL驱动源文件、PDF版I2C规范文档、配置脚本及工程备份,方便在Quartus和Nios II SBT中直接阅读、移植和调试。已有386人学习下载。这份资料不仅给出可运行的I2C主控制器代码,还包含寄存器映射和驱动层调用方式,能帮助开发者快速理解Avalon总线时序、IP核封装与软硬件协同设计,省去从零编写协议和排查时序问题的时间,特别适合课程设计、项目验证作为参考。
1. 先看清这四样东西的关系:I2C IP、Nios、Avalon 和 Altera 到底怎么拼起来
很多第一次在 Altera 系列 FPGA 上跑 Nios 的开发者,接到“挂个 I2C”这种需求时,第一反应都是找一段 GPIO 模拟 I2C 的代码,两根线翻来翻去。但标题里同时出现 I2C IP、Nios、Avalon 接口、ALTERA,说明要走的是一条更正经的路线:把厂商提供的 I2C 控制器 IP 通过 Avalon 总线挂到 Nios 软核上,让 CPU 像读写内存一样操作 I2C 寄存器,再由 IP 内部的硬件状态机去完成 SCL/SDA 时序。这条路比 GPIO 模拟难在要先弄懂 Avalon 接口和中断联动,但换回来的是 CPU 占用率低、时序稳定、带 ACK 错误检测。
这篇文章按我实际做过的调试顺序来写:先讲 Avalon 从端口和 IP 寄存器之间的关系,再讲在 Platform Designer 里怎么把线连对,接着给一段能跑的 Nios 驱动代码,然后列几个我在板级调试时踩过的坑,最后用一个内部回环的法子验证链路通没通。适合谁?一类是有 Nios 基础但第一次碰 Avalon 外设的,另一类是已经把 I2C IP 挂上但死活调不通的人。
2. Avalon 接口与 I2C 寄存器堆:软核访问外设的那层翻译逻辑
2.1 Avalon-MM 从端口:把 I2C 控制器包装成一段内存
Avalon-MM(Memory-Mapped)是 Altera 平台里最常用的一种片上总线接口,主端口那边通常是 Nios II CPU,从端口这边就是各种外设 IP。I2C IP 要能被 Nios 控制,必须有一个 Avalon-MM 从端口作为“翻译层”,把总线上进来的地址、读写信号、数据转换成 IP 内部的控制信号。换句话说,CPU 根本不需要知道 SCL 怎么翻转,它只需要向某个地址写一个字节,总线逻辑会自动把这个字节送到 IP 的发送寄存器里。
这里面的关键信号不多,我列一个我习惯对照的表格,方便你在调试时一眼找线索:
| Avalon-MM 从端口信号 | 方向 | 与 I2C IP 内部的关系 |
|---|---|---|
| avs_clk | 输入 | 决定 IP 内部状态机的节拍,通常与 Nios 主时钟同源 |
| avs_reset | 输入 | 复位内部状态机与寄存器,不能悬空 |
| avs_address | 输入 | 选择寄存器偏移地址,比如 0x0 是控制字,0x1 是状态字 |
| avs_write / avs_writedata | 输入 | 写入控制寄存器或发送数据寄存器 |
| avs_read / avs_readdata | 输出 | 读出状态寄存器或接收数据寄存器 |
| avs_waitrequest | 输出 | 外设忙时拉高,总线会自动插入等待周期 |
| irq | 输出 | 中断请求线,接到 Nios 的中断控制器 |
从这张表能看出一个经常被忽略的点:waitrequest。如果 I2C IP 内部正在处理上一笔传输,数据寄存器还没准备好接收新数据,它会通过拉高 waitrequest 让 Avalon 主端口等待。Nios 的 Avalon 主端口遇到 waitrequest 会自动暂停访问,所以软件层面看起来只是这笔读写“慢了一点”,不会出错。反过来说,如果某个移植过来的 IP 忘了处理 waitrequest,或者把它一直拉高,CPU 就会死等,表现出来就是程序卡在某一行 IOWR 上。
寄存器堆的布局,不同厂商的 I2C IP 不完全一样,但业界常见的顺序是:控制寄存器(CR)、状态寄存器(SR)、发送数据寄存器(DR)、接收数据寄存器(DR)、预分频寄存器(PR)。控制字里一般有 I2C 使能位、中断使能位、发送起始条件位、发送停止条件位;状态字里一般有传输忙标志、ACK 接收标志、发送缓冲区空标志、接收缓冲区满标志。这些位定义在具体 IP 的数据手册里都有表,你拿到手的第一件事不是写代码,而是把这张寄存器表看熟。
2.2 硬件 IP 与 GPIO 模拟的取舍:什么时候别偷懒
GPIO 模拟 I2C 在很多小项目里确实能跑,尤其在只挂一个 EEPROM、速率 100kHz 的场景下,CPU 全程参与拉线反而显得简单直观。但它的代价是:每传一个 bit 都要 CPU 去翻转 GPIO 并精确延时,总线时钟一旦跑到几十 MHz,延时的抖动会直接影响 SCL 时序;而且模拟代码里很难处理时钟拉伸和多主机仲裁。硬件 IP 把这些时序细节全部收进状态机里,Nios 只需要发起命令和等待完成。
在三种场景下我一般直接选 IP:一是 I2C 速率要求到 400kHz 甚至 1MHz;二是从设备可能在传输过程中拉低 SCL 做时钟拉伸,比如某些传感器;三是系统里 Nios 还要同时处理网络协议栈或显示刷新,没空在一个字节一个字节的模拟时序里耗。反过来,如果只是一次性烧录、项目就几百行代码、I2C 从设备固定不变,GPIO 模拟反而开发更快,因为不用处理 Platform Designer 里那些连线。
但选硬件 IP 也意味着你要接受一个“黑匣子”的现实:你看不到总线级状态机的内部,只能通过寄存器位来判断当前在做什么。所以我的习惯是拿到 IP 后先做一次寄存器回环验证,而不是直接去接真实芯片。至于回环怎么做,最后一章会给出具体代码思路。
3. 在 Platform Designer 里把 I2C IP 挂上 Nios:最小系统搭建步骤
3.1 连线清单与 Tcl 自动化脚本
Platform Designer 里添加 I2C IP 是一件很容易但很容易漏线的事。图形界面里拖进去一个核之后,很多新手看到一大堆引脚就懵。其实大部分引脚都是自动连接的:点击“Auto Connect”会把 clk 和 reset 按同名规则接好,但 interrupt 和对外接口需要手动指定。我整理了一个最小系统的连线清单:
- clk:连接 Nios 的时钟源,和 CPU 共用同一个 clk
- reset:连接 Nios 的复位输出
- avalon_slave:连接到 Nios 的数据主端口,命名通常叫 data_master
- interrupt:连接到 Nios 的 interrupt 输入,中断编号由系统自动分配
- i2c_serial:这是 I2C 的物理引脚,导出到顶层模块,连接 FPGA 的外部引脚
如果每次都在图形界面上点,效率低而且容易漏。我一般会用 Tcl 脚本把 IP 创建和基本参数设置固化下来,改动时钟频率或地址范围时直接改脚本重跑。下面是一段和你做这件事类似的 Tcl 脚本骨架,注意不同版本的关键字可能略有差异,以你手头工具实际提示为准。
# 创建I2C控制器IP实例,并设置基础参数 set i2c_inst [create_ip -name altera_i2c -vendor Altera -library altera -version 1.0] set_property -name {CTRL_CLK_FREQ} -value {100000000} -objects $i2c_inst set_property -name {I2C_FREQ} -value {400000} -objects $i2c_inst set_property -name {USE_AVALON_MM} -value {true} -objects $i2c_inst # 导出I2C物理接口,让顶层模块可以连接到外部引脚 add_interface i2c_serial conduit end set_interface_property i2c_serial associatedClock clk set_interface_property i2c_serial associatedReset reset add_interface_port i2c_serial sda_export export Signal sda add_interface_port i2c_serial scl_export export Signal scl这段脚本里最关键的是两个频率参数:CTRL_CLK_FREQ 是总线时钟频率,也就是连到 Nios 同一个 clk 的那个频率;I2C_FREQ 是目标 SCL 频率。很多人只记得设置 I2C_FREQ,忘了把 CTRL_CLK_FREQ 改对,结果 SCL 实际频率和预期差很远。脚本最后导出 i2c_serial 端口是为了在顶层模块里能把 sda 和 scl 引到 FPGA 引脚上,如果不做这一步,Nios 里怎么读寄存器都是空转。
参数设置完成后,在 Platform Designer 里分配基地址。建议手动分配一个独立的地址段,比如从 0x00010000 开始,长度 0x20,这样既避开 CPU 内部存储器的地址空间,也给将来挂其他外设留出余地。自动分配有时会把它挤到不容易记住的高地址段,对写软件和查日志都是一种负担。
3.2 三个一上来就要定死的参数:基地址、中断号、预分频值
我在调试 Nios 外接设备时,吃过最大的亏就是没把三个参数统一记录,导致软件里写的地址和硬件里实际分配的对不上,查了半天最后发现是地址常量写错了。基地址的确定方法很直接:在 Platform Designer 里打开 Address Map 标签页,找到 i2c_inst 那一行,记下系统分配的起始地址,同时把 Length 改成 0x20 预留足够空间。
中断号同样在 Platform Designer 里能看到,通常在 “Interrupt” 栏里显示一个编号。这个编号是 Nios II HAL 层分配的中断优先级编号,软件注册中断处理函数时要传这个值。如果你用的系统里还挂了其他外设,比如 UART、定时器,它们的中断号是依次排的,别搞混。我习惯在代码文件头部写一段 注释,把基地址、中断号、时钟频率三条写在同一个位置,这样无论过多久翻回来都能快速对上号。
预分频值的计算是参数里最容易翻车的一环。常见的设计逻辑是:总线时钟先经过一个分频器,得到 I2C 模块内部的基础时钟,再由这个基础时钟产生 SCL。不同 IP 对这个公式的定义不同,有的直接在预分频寄存器里写分频系数,有的还要再除一个固定倍数。我拿到新 IP 的第一件事,是翻开它的数据手册找到“Clock Generation”那一节,看清楚公式长什么样再填数。
举个例子,假设总线时钟是 100MHz,目标 SCL 是 400kHz,常见设计里预分频系数约等于 100MHz 除以(目标频率乘以某个固定刻度)。如果你填进去之后实测 SCL 是 200kHz,大概率是公式里还有一个二分频你没算进去。这时不要急着改软件,先拿示波器看引脚上的真实频率,再反推正确的系数。
这里给一张我常用的参数记录表格,方便你在工程里维护:
| 参数名 | 我的设置值 | 备注 |
|---|---|---|
| 总线时钟频率 | 100 MHz | 与 Nios 主时钟同源 |
| 目标 I2C 频率 | 400 kHz | 高速模式需检查从设备是否支持 |
| I2C 预分频寄存器 | 125(举例) | 实际值按数据手册公式计算 |
| 基地址 | 0x00010000 | 在 Platform Designer 中确认 |
| 中断号 | 根据系统分配 | 注册中断回调时使用 |
4. Nios 软核读写 I2C 寄存器:从轮询到中断的驱动写法
4.1 轮询方式:先跑通最小读写路径
Nios II 的软件访问 Avalon 外设,最直接的方式是使用 IORD 和 IOWR 宏。宏在系统头文件里已经定义好,会根据外设基地址和偏移量生成总线访问。我们要做的第一版驱动,先不加中断,用轮询状态寄存器的方式,完成一个“向 I2C 总线发起写操作并等待完成”的函数。先把这条最小路径跑通,再谈优化。
下面是一段我调过的、基于常见寄存器布局的驱动代码片段。寄存器偏移地址和位定义以你的 IP 手册为准,我用通用名字代替,方便你看懂逻辑:
#define I2C_BASE 0x00010000 #define I2C_PR 0x00 /* 预分频寄存器 */ #define I2C_CR 0x01 /* 控制寄存器 */ #define I2C_SR 0x02 /* 状态寄存器 */ #define I2C_DR 0x03 /* 数据寄存器 */ #define I2C_CR_ENABLE 0x01 #define I2C_CR_START 0x02 #define I2C_CR_STOP 0x04 #define I2C_CR_ACK_ENABLE 0x08 #define I2C_SR_BUSY 0x01 #define I2C_SR_ACK_ERR 0x02 #define I2C_SR_TX_READY 0x04 static void i2c_init(unsigned int base, unsigned short prescale) { IOWR(base, I2C_PR, prescale); IOWR(base, I2C_CR, I2C_CR_ENABLE | I2C_CR_ACK_ENABLE); } static int i2c_wait_not_busy(unsigned int base, int timeout) { while (timeout--) { unsigned short sr = IORD(base, I2C_SR); if (!(sr & I2C_SR_BUSY)) { return 0; } } return -1; } static int i2c_write_byte(unsigned int base, unsigned char val) { if (i2c_wait_not_busy(base, 1000) != 0) { return -1; } IOWR(base, I2C_CR, I2C_CR_START); IOWR(base, I2C_DR, val); if (i2c_wait_not_busy(base, 1000) != 0) { return -1; } if (IORD(base, I2C_SR) & I2C_SR_ACK_ERR) { return -2; } return 0; }代码逻辑分三步:初始化时先写预分频寄存器,再写控制寄存器使能 I2C 模块;发起传输前查询状态寄存器的 BUSY 位,如果总线忙就等待;写数据寄存器时把数据交给 IP,由硬件状态机完成后续移位输出。这里最关键的是 ACK 错误标志要在每次传输结束后立即读,因为下一次传输到来时它会被新状态覆盖。
参数说明:prescale 的值来自你在第 3 章里算出的预分频系数,这里没有做任何隐式计算,直接透传给寄存器;timeout 用了一个简单的递减计数,假如总线时钟 100MHz,循环 1000 次的时间开销很小,但如果从设备一直拉低 SCL,这个超时会触发并返回 -1。实际工程里建议把 timeout 改成基于定时器计数的毫秒值,更可控。
4.2 中断方式:把 CPU 从等待中解放出来
轮询方式在调试阶段足够用,但产品里如果 I2C 传输频繁,CPU 一直被“查询状态-等待完成”的循环占住,其他任务会被拖慢。中断方式的做法是:I2C IP 在完成一次传输、接收数据可用或者产生错误时拉高 irq,Nios 收到中断后进入回调函数,在回调里读状态寄存器判断事件类型。
注册中断用的是 Nios II HAL 提供的 alt_irq_register 函数。中断号必须与 Platform Designer 中分配给该 IP 的编号一致,回调函数签名固定为 void (handler)(void)。看一个注册和回调的模板:
#include "sys/alt_irq.h" #define I2C_IRQ_NUM 5 /* 实际编号以 Platform Designer 分配为准 */ static volatile unsigned char i2c_rx_buffer; static volatile int i2c_irq_flag = 0; static void i2c_isr(void *context) { unsigned short sr = IORD(I2C_BASE, I2C_SR); if (sr & I2C_SR_TX_READY) { /* 可以继续写入下一笔数据 */ } if (sr & I2C_SR_ACK_ERR) { i2c_irq_flag = -1; } /* 注意:清除中断标志通常需要写状态寄存器或读数据寄存器触发,具体看手册 */ } void i2c_setup_interrupt(void) { alt_ic_isr_register(I2C_IRQ_NUM, I2C_BASE, i2c_isr, NULL, NULL); IOWR(I2C_BASE, I2C_CR, I2C_CR_ENABLE | I2C_CR_ACK_ENABLE | 0x10); }回调函数里有两个细节容易出错。第一,中断使能位写在控制寄存器里,这行代码最后写了一个 0x10,这是示例用的“中断使能位”,实际位的位置查手册;第二,清除中断标志的动作必须明确,有的 IP 是读状态寄存器自动清零,有的是写 1 清零,有的要读数据寄存器才清。如果清错,中断会反复触发,表现为系统一直跳进回调函数,主循环根本跑不动。我排查过的一个案例,就是漏了读数据寄存器,导致接收完成中断标志永远为 1。
另一个经验是回调函数里尽量少做事,只置标志位和收数据。因为你不知道 I2C 中断会不会打断另一个更紧急的中断,把耗时操作放在中断里会拖长系统响应时间。我一般把解析数据的逻辑放在主循环里,发现 i2c_irq_flag 置位后再去处理。
5. Avalon 接口联调的五个高频坑:硬件连错、软件读错与中断翻车
5.1 读寄存器全是 0xFF:先查时钟和复位,别急着查代码
现象:Nios 程序里 IORD 读到的状态寄存器值恒为 0xFF,写控制寄存器也没有任何反应,I2C 引脚波形完全不存在。原因通常是 IP 没有正常工作,而最常见的是 reset 信号没接对。Platform Designer 中如果这个 IP 的 reset 悬空,内部寄存器可能一直处于未初始化状态,读出来的就是上电后的随机值或总线未驱动的高电平。解决:回到 Platform Designer,确认 i2c_inst 的 reset 端口连接到了 Nios 的 reset 输出;检查 clk 连接实际有没有信号,不要只在图形界面里看连线,最好在顶层模块里用 Signal Tap 抓一下 clk 和 reset。如果这两个没问题,再核对基地址是否真的映射到了这个 IP,有时地址重叠会让总线访问落到另一个外设上。
5.2 SCL 实测频率差一半:预分频公式里有个二分频
现象:按手册公式算好预分频值,示波器一测,SCL 频率正好是目标频率的二分之一。原因:IP 内部在预分频之后又做了一次固定分频,手册里公式写的是“总线时钟 除以(预分频值加一)再除以二”,但很多人只看了前半句。解决:用示波器实测频率反推。比如写入预分频值 249 后 SCL 是 100kHz,你想要 400kHz,那就把预分频值缩小到 62 附近再细调。校正一次之后,把纠正过的计算方式写进工程文档,下次换时钟频率时直接套用完整公式,免得再次踩同一个坑。
5.3 中断反复触发把系统卡死:清中断标志的方式不对
现象:程序能进入中断回调,但回调一退出又立即进入,主循环完全跑不起来,Debug 模式下能看出 PC 一直停在中断向量附近。原因:中断标志没有按 IP 规范清除。我见过某款 IP 在接收数据后必须读一次数据寄存器才能清除接收完成标志,而写代码的人只清了自己的软件标志,硬件标志仍然有效。解决:先看数据手册里“Interrupt Flag Clear”这一节,明确清除方式是读状态字、写 1 清零还是读数据。如果是读数据寄存器清标志,在回调里不管用不用都要把数据读出来存到变量里,同时防止编译器优化掉这次读操作。
5.4 ACK 错误标志总是置位:检查上拉电阻和从设备地址
现象:向 EEPROM 写地址时,状态寄存器里 ACK 错误位一直为 1,波形能看到 SCL 有输出,但 SDA 上从设备始终没有应答。原因:I2C 总线缺少上拉电阻,SDA 和 SCL 都被拉低,从设备无法驱动总线响应;另一个常见原因是从设备地址写错,比如 7 位地址没有左移一位。解决:拿万用表量 SDA 和 SCL 在空闲时的电平,正常应为高电平;再核对设备地址计算,I2C 通信时发送的字节是“设备地址左移 1 位加上读写位”,比如设备地址 0x50,实际发送 0xA0 写命令。很多新手在这里把原始地址直接发了出去,从设备当然不应答。
5.5 访问 I2C 外设时整个系统挂死:Avalon 总线访问了不存在的地址段
现象:程序运行到 IOWR 访问 I2C 基地址时,CPU 从此卡死,连两个 LED 翻转的任务都不再执行。原因:要么基地址写成了某个未映射的区域,要么 Platform Designer 里该从设备没有正确连接到 CPU 的数据主端口。Avalon 总线访问一个没有从设备应答的地址,表现就是挂死,因为总线上没有任何设备拉低 waitrequest 或返回数据。解决:核对代码里 I2C_BASE 与 Platform Designer 地址表是否一致,同时确认在 Platform Designer 里 i2c_inst 的 avalon_slave 端口连接了 data_master。有一个快速验证办法:在代码里连续读取几个不同地址段,看哪些能返回非全 F 的数,基本就能确认映射范围。
6. 用寄存器回环验证链路:比先接真实芯片更省心
调试 I2C 外设最怕的是“不知道是 FPGA 这边的问题还是外部芯片那边的问题”。我在接真实 EEPROM 之前,会先在 FPGA 内部做一个回环:让 I2C 控制器的主端口连接一个用逻辑代码搭的虚拟从机,从机不回真实设备,只做最基本的应答和回读。这样能一次性把 Avalon 接口、寄存器读写、中断路径全部验证完,再上真实芯片时只需要调试总线时序和外设配置。
虚拟从机的核心逻辑很简单:检测到 START 条件后,接收第一个字节,如果地址匹配就回 ACK;后续收到的数据暂存到寄存器里;等收到 STOP 条件后,把暂存的数据准备在发送寄存器里,等待主机下一次读操作。下面是一段简化的 SystemVerilog 骨架,只做地址匹配和单字节回环:
module i2c_virtual_slave #( parameter SLAVE_ADDR = 7'h50 )( input wire scl, inout wire sda, input wire clk ); reg [7:0] rx_byte; reg [7:0] tx_byte; reg addr_matched; // 这里体现的是最简状态机框架: // 1. 检测 SCL 高电平时的 SDA 下降沿作为 START // 2. 每个 SCL 高电平采样一位 SDA // 3. 收到 8 位后与 SLAVE_ADDR 比较,匹配则拉低 sda 回 ACK // 4. 收完全部字节后令 rx_byte 内容也能被主机读回 assign sda = (addr_matched && transmitting) ? tx_byte[bit_pos] : 1'bz; endmodule这段代码不是完整实现,但它说明了回环验证的核心思路:用可综合的逻辑模拟一个最简从设备,把 I2C IP 的输出接在 FPGA 内部到这个模块的输入上,而不是直接引出到外部引脚。在顶层文件里把 i2c_serial.sda 和 scl 连到这个虚拟从机,然后在 Nios 里向地址 0x50 发一个写命令,再发读命令,看读回来的数据是否等于之前写入的值。如果相等,说明整条 Avalon 到 I2C 的链路已经通了。
我养成的调试习惯是:新板子拿到手,第一件事不是接传感器,而是跑一遍这个回环。回环通过之后,再接外部设备,把虚拟从机换成真实芯片,这样出问题时能把范围缩小到总线参数和外部电路上。你也试试这个顺序,会少很多对着波形图挠头的夜晚,希望帮到你。
本文还有配套的精品资源,点击获取