FPGA实战:AXI_IIC IP核驱动EEPROM读写调试与避坑指南
2026/9/19 8:42:54 网站建设 项目流程

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 核寄存器不少,但真正高频使用的就那么几个。我把它们整理成一张表,方便你调试时快速查阅:

寄存器偏移名称关键位说明
0x00GIEbit0 全局中断使能
0x04ISRbit0 发送FIFO空,bit1 接收FIFO满,bit2 总线忙
0x08IER中断使能,按需配置
0x0CSOFTRbit0 软复位,写1后自动清零
0x10CRbit0 使能IIC,bit1 方向(0写1读)
0x14SRbit0 总线忙,bit1 地址已发送,bit2 收到ACK
0x18DTR发送数据写入此寄存器
0x1CDRR接收数据从此寄存器读出
0x20ADR从设备地址(7位,左移1位存放)
0x24TO超时计数器
0x28TSUSTA起始条件保持时间
0x2CTSUSTO停止条件保持时间
0x30THDSTA起始条件保持时间(重复起始)
0x34TSUDAT数据建立时间
0x38TSUACKACK建立时间
0x3CTHDACKACK保持时间
0x40TSUSTO停止条件建立时间

第一次调试时,我犯了一个低级错误:把从设备地址直接写成了 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 总线因为某种原因卡住,轮询会陷入死循环,整个系统假死

后来我改成了中断模式,配合超时计数器使用。具体做法是:

  1. 使能 GIE 寄存器的 bit0
  2. 在 IER 中使能需要的中断源,比如发送 FIFO 空、接收 FIFO 满、总线错误
  3. 在中断服务程序中读取 ISR,判断中断类型,执行相应操作
  4. 同时启动一个硬件定时器,如果超时未收到预期中断,强制复位 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 协议的特点。以随机读为例:

  1. 发送起始条件 + 从设备地址(写方向)
  2. 发送要读取的字地址
  3. 发送重复起始条件 + 从设备地址(读方向)
  4. 读取数据
  5. 发送 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 核的基础配置验证,结果后面所有问题都混在一起,根本分不清是配置问题还是代码问题。

正确的调试顺序应该是:

  1. 先确认 IP 核能正常例化,AXI 读写寄存器正常
  2. 再确认 IIC 总线有空闲状态,SCL/SDA 都是高电平
  3. 然后发一个最简单的起始条件+地址,看有没有 ACK
  4. 有 ACK 后,再尝试写一个字节
  5. 写成功后,再尝试读
  6. 最后做连续读写和边界测试

每一步都确认无误后再进行下一步,这样出问题时,范围就缩小到了当前这一步,排查效率高很多。

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 内部逻辑、外部器件的交互。把每一层的边界都搞清楚,问题自然就少了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询