1. 项目缘起与整体设计思路
1.1 为什么要在 FPGA 里折腾 AXI_IIC
手头这个项目说起来不复杂:一块 Xilinx 的 FPGA 板子,挂了一颗 EEPROM,需要通过 IIC 总线做读写验证。听起来像是入门级练手,但真正动手之后才发现,从 AXI 总线到 IIC 时序之间的那层封装,坑远比想象中多。
我最初的想法很朴素——Xilinx 的 Vivado 里不是自带 AXI_IIC IP 核吗?直接例化、连线、写寄存器不就完事了?结果第一轮调试就卡了整整两天:读出来的数据全是 0xFF,写进去的数据回读对不上,状态寄存器一直停在某个诡异的位。后来一步步扒时序、抓波形、翻手册,才把问题逐个定位。
这篇内容就是把这整个过程完整记录下来。核心关键词是AXI_IIC、IIC、EEPROM、FPGA、Xilinx,适合正在用 Vivado 做 IIC 外设调试的工程师,也适合刚接触 AXI 总线协议、想找一个完整案例来练手的 FPGA 入门者。不管你是第一次碰 AXI_IIC IP 核,还是已经用过但被某些细节坑过,这里应该都能找到对你有用的东西。
1.2 整体方案选型:IP 核还是自己写
在动手之前,有一个绕不开的选择:用 Xilinx 官方的 AXI_IIC IP 核,还是自己用 Verilog 写一个 IIC 主机控制器?
两种方案我都试过,各自的适用场景差别很大:
| 对比维度 | AXI_IIC IP 核 | 自写 IIC 控制器 |
|---|---|---|
| 开发速度 | 快,例化即用 | 慢,需要完整实现时序 |
| 灵活性 | 受限于 IP 核寄存器定义 | 完全可控,任意裁剪 |
| 资源占用 | 相对固定,偏大 | 可优化到极小 |
| 调试难度 | 寄存器多,状态机不透明 | 代码全透明,波形即逻辑 |
| 适用场景 | 快速原型、标准 IIC 设备 | 特殊时序、多主机、资源敏感 |
我最终选择 AXI_IIC IP 核作为主线,原因是这个项目的 EEPROM 是标准 IIC 从设备,协议规整,用 IP 核能省下大量时间。但为了搞清楚 IP 核内部到底在干什么,我同时用 Verilog 写了一个简化版的 IIC 主机做对照,两边波形一对比,很多问题就一目了然了。
提示:如果你只是做一次性的 EEPROM 读写验证,直接用 IP 核。如果你要做产品级的多设备 IIC 总线管理,建议至少把自写控制器作为备选方案评估。
1.3 系统架构与数据通路
整个系统的数据通路是这样的:上位机通过 UART 发送读写命令,FPGA 内部的 MicroBlaze 软核(或者你自己写的 AXI Master 状态机)解析命令后,通过 AXI4-Lite 总线访问 AXI_IIC IP 核的寄存器,IP 核再把它转换成 IIC 的 SCL/SDA 时序,最终作用到 EEPROM 上。
这里有一个关键点容易被忽略:AXI4-Lite 是内存映射总线,而 IIC 是串行时序协议,两者之间的桥接完全由 IP 核内部的寄存器状态机完成。你写一个 AXI 事务,IP 核并不会立刻在 SCL/SDA 上产生波形,而是先把数据存到内部的发送 FIFO,然后由 IIC 状态机在合适的时机逐位发送。理解这一点,对后面分析“为什么写完了读不到”至关重要。
数据通路可以简化为:
上位机 → UART → MicroBlaze → AXI4-Lite → AXI_IIC IP → SCL/SDA → EEPROM反向读取时,EEPROM 的数据先进入 IP 核的接收 FIFO,再由 AXI Master 通过 AXI4-Lite 读事务取回,最后经 UART 送回上位机。整条链路上任何一个环节出问题,表现都是“读不到正确数据”,所以排查时必须分段隔离。
2. AXI_IIC IP 核核心细节解析
2.1 寄存器映射:你必须记住的几个关键寄存器
Xilinx 的 AXI_IIC IP 核寄存器不少,但真正高频使用的就那么几个。我把它们整理成一张表,方便你调试时快速查阅:
| 寄存器偏移 | 名称 | 关键位说明 |
|---|---|---|
| 0x00 | GIE | bit0 全局中断使能 |
| 0x04 | ISR | bit0 发送FIFO空,bit1 接收FIFO满,bit2 总线忙 |
| 0x08 | IER | 中断使能,按需配置 |
| 0x0C | SOFTR | bit0 软复位,写1后自动清零 |
| 0x10 | CR | bit0 使能IIC,bit1 方向(0写1读) |
| 0x14 | SR | bit0 总线忙,bit1 地址已发送,bit2 收到ACK |
| 0x18 | DTR | 发送数据写入此寄存器 |
| 0x1C | DRR | 接收数据从此寄存器读出 |
| 0x20 | ADR | 从设备地址(7位,左移1位存放) |
| 0x24 | TO | 超时计数器 |
| 0x28 | TSUSTA | 起始条件保持时间 |
| 0x2C | TSUSTO | 停止条件保持时间 |
| 0x30 | THDSTA | 起始条件保持时间(重复起始) |
| 0x34 | TSUDAT | 数据建立时间 |
| 0x38 | TSUACK | ACK建立时间 |
| 0x3C | THDACK | ACK保持时间 |
| 0x40 | TSUSTO | 停止条件建立时间 |
第一次调试时,我犯了一个低级错误:把从设备地址直接写成了 0xA0。实际上 ADR 寄存器存放的是 7 位地址左移一位后的值,对于常见的 24C02 EEPROM,7 位地址是 0x50,左移一位后是 0xA0,但 IP 核要求写入 ADR 的是 0x50 再左移,也就是 0xA0 这个值本身。等等,这里容易绕晕——正确的做法是:ADR 寄存器写入的是 7 位地址值,IP 核内部会自动处理读写位。我当时的错误是把 8 位地址(含读写位)直接写进去了,导致地址匹配失败,从设备根本不响应。
注意:不同版本的 AXI_IIC IP 核对 ADR 寄存器的定义可能略有差异,务必对照你所用 Vivado 版本对应的 PG090 文档确认。
2.2 IIC 时序参数的计算与配置
IIC 总线的时序参数直接决定了通信能否成功。AXI_IIC IP 核把这些参数做成了可配置的寄存器,单位是 AXI 时钟周期数。这里必须做一次计算,不能凭感觉填。
假设 AXI 时钟频率为 100MHz,即周期 10ns。标准 IIC 在 100kHz 模式下,SCL 周期为 10us,高电平时间和低电平时间各约 5us。IP 核内部会对时钟进行分频,分频系数由这些时序寄存器共同决定。
以 TSUDAT(数据建立时间)为例,IIC 规范要求最小 250ns。在 100MHz 时钟下,250ns 对应 25 个时钟周期。所以 TSUDAT 寄存器至少填 25。但实际调试时我发现,填 25 偶尔会出错,填到 30 就非常稳定。原因是 FPGA 内部信号经过 AXI 总线同步后存在额外延迟,留一点余量是必要的。
同理,其他参数的计算方法:
- TSUSTA:起始条件建立时间,规范要求最小 4.7us,100MHz 下约 470 个周期,实际填 500
- THDSTA:起始条件保持时间,规范要求最小 4.0us,填 400 以上
- TSUSTO:停止条件建立时间,规范要求最小 4.0us,填 400 以上
- TSUACK:ACK 建立时间,规范要求最小 250ns,填 30 左右
- THDACK:ACK 保持时间,规范要求最小 4.0us,填 400 以上
这些值不是拍脑袋来的,每一个都有 IIC 规范依据。我建议你在配置时先把这些值算好,写成一个表格,调试时对照检查。
2.3 中断与轮询:两种工作模式的选择
AXI_IIC IP 核支持中断和轮询两种工作方式。我一开始用的是轮询,因为逻辑简单,不需要配置中断控制器。但轮询有一个致命问题:如果 IIC 总线因为某种原因卡住,轮询会陷入死循环,整个系统假死。
后来我改成了中断模式,配合超时计数器使用。具体做法是:
- 使能 GIE 寄存器的 bit0
- 在 IER 中使能需要的中断源,比如发送 FIFO 空、接收 FIFO 满、总线错误
- 在中断服务程序中读取 ISR,判断中断类型,执行相应操作
- 同时启动一个硬件定时器,如果超时未收到预期中断,强制复位 IIC 总线
中断模式的代码量比轮询大,但稳定性完全不是一个级别。特别是在 EEPROM 写周期(约 5ms)期间,轮询会白白占用 CPU,中断模式则可以让 CPU 去处理其他任务。
实操心得:即使你用中断模式,也一定要保留一个软件超时机制。我遇到过 EEPROM 因为写周期未完成而不响应的情况,如果没有超时,中断永远不会来,系统就挂在那里了。
3. 完整读写实操过程与关键环节
3.1 写操作:从 AXI 事务到 IIC 波形
写 EEPROM 的完整流程分为几个阶段,每个阶段都有需要特别注意的地方。
第一阶段:配置 IP 核
在发起任何读写之前,必须先完成 IP 核的基础配置。顺序很重要:
// 1. 软复位 XIic_WriteReg(BaseAddr, SOFTR_OFFSET, 0x01); // 等待复位完成,读SOFTR确认已清零 while (XIic_ReadReg(BaseAddr, SOFTR_OFFSET) & 0x01); // 2. 配置时序参数 XIic_WriteReg(BaseAddr, TSUSTA_OFFSET, 500); XIic_WriteReg(BaseAddr, THDSTA_OFFSET, 400); XIic_WriteReg(BaseAddr, TSUDAT_OFFSET, 30); XIic_WriteReg(BaseAddr, TSUACK_OFFSET, 30); XIic_WriteReg(BaseAddr, THDACK_OFFSET, 400); XIic_WriteReg(BaseAddr, TSUSTO_OFFSET, 400); // 3. 设置从设备地址 XIic_WriteReg(BaseAddr, ADR_OFFSET, 0x50); // 24C02的7位地址 // 4. 使能IIC XIic_WriteReg(BaseAddr, CR_OFFSET, 0x01);这段代码看起来简单,但每一步都有讲究。软复位后必须等待 SOFTR 自动清零,否则后续配置可能不生效。时序参数必须在使能 IIC 之前配置好,因为一旦 CR 的 bit0 置 1,IIC 状态机就开始运行了。
第二阶段:发送数据
写 EEPROM 时,数据格式是:从设备地址 + 写方向 + 字地址 + 数据。AXI_IIC IP 核会自动处理起始条件、地址发送和 ACK 检测,你只需要把数据按顺序写入 DTR 寄存器。
// 写入字地址(EEPROM内部地址) XIic_WriteReg(BaseAddr, DTR_OFFSET, word_addr); // 等待发送FIFO有空位 while (!(XIic_ReadReg(BaseAddr, ISR_OFFSET) & 0x01)); // 写入数据 XIic_WriteReg(BaseAddr, DTR_OFFSET, data); // 等待发送完成 while (XIic_ReadReg(BaseAddr, SR_OFFSET) & 0x01);这里的关键是等待发送 FIFO 有空位。如果你连续写入多个字节而不检查 FIFO 状态,数据会丢失。我一开始就是连续写了三个字节,结果只有第一个字节发出去了,后面两个被覆盖了。
第三阶段:等待 EEPROM 写周期
EEPROM 在收到写命令后,内部需要约 5ms 的时间完成实际写入。在这期间,EEPROM 不会响应任何 IIC 命令。如果你立刻发起下一次读写,会收到 NACK。
正确的做法是:发送完写命令后,延时 5ms 以上,或者用“ACK 轮询”方式——反复发送起始条件+从设备地址,直到收到 ACK 为止。
// 方式一:固定延时 usleep(6000); // 延时6ms,留余量 // 方式二:ACK轮询 while (1) { // 发送起始+地址 // 检查SR寄存器的ACK位 if (ack_received) break; usleep(100); }我推荐方式二,虽然代码复杂一点,但效率高,而且不依赖精确的延时。
3.2 读操作:随机读与顺序读的实现差异
读操作比写操作多了一个“写地址再读数据”的过程,这是 IIC 协议的特点。以随机读为例:
- 发送起始条件 + 从设备地址(写方向)
- 发送要读取的字地址
- 发送重复起始条件 + 从设备地址(读方向)
- 读取数据
- 发送 NACK + 停止条件
在 AXI_IIC IP 核中,这个流程需要分两次 AXI 事务完成。第一次是写事务,把字地址写进去;第二次是读事务,把数据读出来。
// 第一次:写事务,发送字地址 XIic_WriteReg(BaseAddr, CR_OFFSET, 0x01); // 方向=写 XIic_WriteReg(BaseAddr, DTR_OFFSET, word_addr); while (XIic_ReadReg(BaseAddr, SR_OFFSET) & 0x01); // 第二次:读事务 XIic_WriteReg(BaseAddr, CR_OFFSET, 0x03); // 方向=读 // 等待接收FIFO有数据 while (!(XIic_ReadReg(BaseAddr, ISR_OFFSET) & 0x02)); data = XIic_ReadReg(BaseAddr, DRR_OFFSET);这里有一个容易踩的坑:方向位切换后,必须等待总线空闲才能发起新的起始条件。如果第一次写事务的停止条件还没发完,你就切换方向,IP 核会产生一个异常的总线状态,读出来的数据全是 0xFF。
我的做法是在每次事务之间检查 SR 寄存器的 bit0(总线忙),确保总线空闲后再进行下一步。
3.3 波形验证:用 ILA 抓取真实时序
代码写得再对,最终还是要看波形。Vivado 的 ILA(集成逻辑分析仪)是调试 IIC 的利器。我把 SCL、SDA 以及 IP 核内部的几个关键状态信号都接到了 ILA 上。
抓取写操作波形时,我重点关注以下几个时间点:
- 起始条件:SCL 高电平期间,SDA 从高变低
- 地址发送:7 位地址 + 1 位读写位,共 8 个 SCL 周期
- ACK 检测:第 9 个 SCL 周期,SDA 被从设备拉低
- 数据发送:8 个 SCL 周期
- 停止条件:SCL 高电平期间,SDA 从低变高
第一次抓波形时,我发现地址发送后的第 9 个 SCL 周期,SDA 始终是高电平,说明从设备没有回 ACK。这就是前面提到的地址配置错误导致的。修正地址后,ACK 正常出现,数据也正确写入了。
读操作的波形更复杂一些,因为涉及重复起始条件。重复起始条件的特征是:SCL 保持高电平,SDA 先变高再变低,而不是像停止条件那样变高后保持。用 ILA 抓到这个细节,就能确认 IP 核是否正确产生了重复起始。
实操心得:ILA 的采样深度要设够,IIC 一次完整读写可能持续几十微秒,采样深度不够会抓不到完整的停止条件。我一般设 4096 或 8192 的深度,采样时钟用 AXI 时钟的 2 倍频。
4. 常见问题排查与避坑指南
4.1 读出来全是 0xFF 的三种可能原因
这是最典型的问题,我遇到过至少三次,每次原因都不一样:
原因一:从设备地址错误。这是最常见的。24C02 的 7 位地址是 0x50,但有些板子上的 EEPROM 地址引脚接了不同电平,实际地址可能是 0x51、0x52 等。用 IIC 扫描程序先确认地址,再写代码。
原因二:上拉电阻缺失或阻值不当。IIC 总线是开漏输出,必须有上拉电阻。我见过一块板子忘了焊上拉电阻,波形完全出不来。阻值方面,4.7kΩ 是常用值,但如果总线电容大,可能需要降到 2.2kΩ。阻值太大,上升沿变缓,高速通信会出错;阻值太小,功耗增加,驱动能力可能不够。
原因三:时序参数配置过快。特别是 TSUDAT 和 TSUACK,如果填得太小,数据建立时间不足,从设备采样错误。我一般先按标准值填,调通后再尝试优化。
4.2 写进去读不对:写周期与页边界问题
EEPROM 有两个特性容易导致“写进去读不对”:
写周期未完成。前面说过,EEPROM 写周期约 5ms,期间不响应任何命令。如果你写完立刻读,读到的可能是旧数据或者 0xFF。解决办法是延时或 ACK 轮询。
页边界跨越。24C02 的页大小是 8 字节。如果你从地址 0x06 开始连续写 4 个字节,会写到 0x06、0x07、0x00、0x01——后两个字节回卷到了页首,覆盖了原来的数据。这是 EEPROM 的内部机制,不是 bug。写跨页数据时,必须分多次写,每次不超过页边界。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读出全 0xFF | 地址错误 | IIC 扫描确认地址 |
| 读出全 0xFF | 上拉电阻问题 | 示波器看波形幅度 |
| 读出全 0xFF | 时序过快 | 增大 TSUDAT/TSUACK |
| 写后读不对 | 写周期未完成 | 延时 6ms 或 ACK 轮询 |
| 写后读不对 | 页边界跨越 | 检查写入地址范围 |
| 偶尔出错 | 总线干扰 | 缩短走线,加屏蔽 |
4.3 总线死锁的恢复机制
IIC 总线有一个经典问题:如果主机在发送过程中被复位,而从设备还在等待更多时钟,SDA 可能被从设备拉低,导致总线死锁。这时候主机发什么从设备都不理,因为 SCL 被 SDA 钳住了。
恢复方法是:手动发送 9 个 SCL 时钟脉冲,让从设备完成当前字节的接收,然后发送停止条件。在 FPGA 中,可以通过临时把 SCL 配置为 GPIO 输出来实现。
// 总线恢复逻辑(简化版) reg [3:0] recovery_cnt; always @(posedge clk) begin if (recovery_en) begin if (recovery_cnt < 9) begin scl_out <= ~scl_out; if (scl_out) recovery_cnt <= recovery_cnt + 1; end else begin sda_out <= 1'b1; // 发送停止条件 recovery_en <= 1'b0; end end end这段逻辑我实际用过,成功救活了一块“假死”的 EEPROM。后来我在每次上电初始化时都先执行一次总线恢复,确保总线处于空闲状态。
4.4 常见问题速查表
| 现象 | 优先检查 | 次要检查 | 终极手段 |
|---|---|---|---|
| 无波形 | CR 寄存器使能位 | 时钟是否接入 | ILA 抓 IP 核内部信号 |
| 有波形无 ACK | 从设备地址 | 上拉电阻 | 换一块 EEPROM |
| 数据错位 | 时序参数 | FIFO 状态检查 | 降低 SCL 频率 |
| 读操作失败 | 方向位切换 | 重复起始条件 | 改用写-延时-读 |
| 间歇性错误 | 电源纹波 | 总线电容 | 加总线缓冲器 |
5. 从失败到成功的关键经验总结
5.1 调试顺序比调试技巧更重要
回顾整个过程,我最大的教训是:不要跳步。我一开始急于写读写代码,跳过了 IP 核的基础配置验证,结果后面所有问题都混在一起,根本分不清是配置问题还是代码问题。
正确的调试顺序应该是:
- 先确认 IP 核能正常例化,AXI 读写寄存器正常
- 再确认 IIC 总线有空闲状态,SCL/SDA 都是高电平
- 然后发一个最简单的起始条件+地址,看有没有 ACK
- 有 ACK 后,再尝试写一个字节
- 写成功后,再尝试读
- 最后做连续读写和边界测试
每一步都确认无误后再进行下一步,这样出问题时,范围就缩小到了当前这一步,排查效率高很多。
5.2 软硬协同排查的思路
FPGA 调试的一个优势是软硬都可控。当 IIC 通信失败时,我通常按以下思路分段隔离:
- 软件层:打印寄存器值,确认 AXI 读写是否正确
- IP 核层:用 ILA 抓 IP 核的 AXI 接口信号,确认事务是否到达
- 物理层:用示波器或 ILA 抓 SCL/SDA,确认波形是否符合 IIC 规范
- 从设备层:换一块已知良好的 EEPROM,排除器件问题
这四层逐一排查,基本能定位到所有问题。我遇到的大部分问题都在 IP 核层和物理层之间,也就是寄存器配置和实际波形不一致。
5.3 可复用的代码框架
最后分享一个我整理的可复用框架,把 AXI_IIC 的读写封装成了几个函数,调用时只需要传地址和数据:
int iic_write_byte(u32 base, u8 dev_addr, u8 word_addr, u8 data) { // 配置地址 XIic_WriteReg(base, ADR_OFFSET, dev_addr); // 写事务 XIic_WriteReg(base, CR_OFFSET, 0x01); XIic_WriteReg(base, DTR_OFFSET, word_addr); while (!(XIic_ReadReg(base, ISR_OFFSET) & 0x01)); XIic_WriteReg(base, DTR_OFFSET, data); while (XIic_ReadReg(base, SR_OFFSET) & 0x01); // 等待写周期 usleep(6000); return 0; } int iic_read_byte(u32 base, u8 dev_addr, u8 word_addr, u8 *data) { XIic_WriteReg(base, ADR_OFFSET, dev_addr); // 写地址 XIic_WriteReg(base, CR_OFFSET, 0x01); XIic_WriteReg(base, DTR_OFFSET, word_addr); while (XIic_ReadReg(base, SR_OFFSET) & 0x01); // 读数据 XIic_WriteReg(base, CR_OFFSET, 0x03); while (!(XIic_ReadReg(base, ISR_OFFSET) & 0x02)); *data = XIic_ReadReg(base, DRR_OFFSET); return 0; }这两个函数在我后续的几个项目中直接复用,只改了设备地址和时序参数,都能正常工作。封装的好处是,下次再遇到 IIC 设备,不需要重新研究寄存器,直接调用即可。
5.4 几个容易被忽略的细节
还有一些细节,单独看都是小事,但组合起来就能让你调一整天:
- AXI 时钟和 IIC 时钟的关系:AXI_IIC IP 核的时序寄存器是以 AXI 时钟为基准的,如果你改了 AXI 时钟频率,所有时序参数都要重新计算。
- FIFO 深度:IP 核的发送和接收 FIFO 深度有限,连续读写多字节时要注意检查 FIFO 状态,避免溢出。
- 中断优先级:如果系统中有多个中断源,IIC 中断的优先级要合理设置,避免被其他中断长时间阻塞。
- 电源域:EEPROM 和 FPGA 如果不在同一个电源域,上电顺序可能影响 IIC 总线状态,必要时加电平转换或电源监控。
我在实际项目中把这些细节都整理成了一份检查清单,每次新板子调试时逐项核对,省下了大量反复排查的时间。IIC 本身不复杂,复杂的是它和 AXI 总线、FPGA 内部逻辑、外部器件的交互。把每一层的边界都搞清楚,问题自然就少了。