1. 项目背景与问题现象
作为一名FPGA开发者,I2C通信是我们最常打交道的低速串行协议之一。这次我在正点原子启明星开发板(Zynq XC7Z010)上进行EEPROM读写实验时,遇到了一个极具迷惑性的问题:FPGA与EEPROM芯片的I2C通信看似正常完成,但写入的数据却始终无法正确读取。
具体表现为:
- I2C通信指示灯(H15)正常亮起,表明所有ACK信号都正确接收
- 错误指示灯(L15)同时亮起,提示读取数据与预期不符
- 通过LED状态编码发现,读取到的数据始终为0xFF
这个现象非常反常,因为按照I2C协议规范,如果通信过程出现错误,通常会在ACK阶段就出现异常。而我的情况是握手过程完全正常,却在数据验证阶段失败,这种"假成功"的状态特别容易误导开发者。
2. 初步排查过程
2.1 写保护检查
我的第一反应是检查EEPROM的写保护功能。AT24系列芯片通常有一个WP(Write Protect)引脚,当该引脚接高电平时会禁止写入操作。但检查开发板原理图发现:
- WP引脚直接接地(GND)
- 硬件上并未启用写保护功能
- 测量WP引脚电压确认确实为低电平
2.2 通信速率调整
考虑到时序问题可能导致通信异常,我尝试降低I2C时钟频率:
- 初始设置为250kHz(标准模式)
- 逐步降低至100kHz、50kHz
- 甚至尝试了10kHz的低速模式
但无论怎么调整速率,现象依旧:通信指示灯正常,但读取数据始终为0xFF。
2.3 信号完整性检查
使用示波器观察I2C信号波形:
- SCL时钟信号干净,上升/下降时间符合规范
- SDA数据信号在采样点稳定,无明显振铃或过冲
- ACK响应信号位置正确,波形完整
3. 关键发现:芯片型号差异
3.1 原理图核查
在常规排查无果后,我决定重新审视硬件设计。查阅开发板原理图时,在U5位置发现了关键信息:
- 标注的芯片型号为AT24C64
- 而我使用的驱动代码是针对AT24C02编写的
3.2 容量与寻址差异
这两种芯片虽然都使用I2C接口,但在存储结构和寻址方式上有本质区别:
| 特性 | AT24C02 (2Kbit) | AT24C64 (64Kbit) |
|---|---|---|
| 存储容量 | 256字节 | 8192字节 |
| 地址位宽 | 8位(1字节) | 16位(2字节) |
| 页大小 | 8字节 | 32字节 |
| 设备地址 | 0xA0/0xA1 | 0xA0/0xA1 |
| 写时序 | DevAddr→Addr→Data | DevAddr→AddrHigh→AddrLow→Data |
3.3 通信过程解析
原来的驱动代码(针对24C02)发送的时序:
- 起始条件(START)
- 设备地址(0xA0 + W)
- 8位地址(0x20)
- 8位数据(0x34)
- 停止条件(STOP)
而24C64将其解释为:
- 起始条件(START)
- 设备地址(0xA0 + W)
- 地址高字节(0x20)
- 地址低字节(0x34)
- 停止条件(STOP)
结果就是芯片只是将内部指针移动到了0x2034位置,实际上并未执行数据写入操作。因此后续读取时,得到的是该地址的默认值0xFF。
4. 驱动代码重构
4.1 状态机改造
为适配24C64的双字节地址,需要重构I2C驱动状态机。关键修改包括:
verilog复制// 原状态定义(24C02)
localparam SEND_ADDR = 4'd4; // 发送8位地址
localparam ACK_ADDR = 4'd5;
// 新状态定义(24C64)
localparam SEND_ADDR_H = 4'd4; // 发送地址高8位
localparam ACK_ADDR_H = 4'd5;
localparam SEND_ADDR_L = 4'd6; // 发送地址低8位
localparam ACK_ADDR_L = 4'd7;
4.2 地址处理逻辑
在状态机中增加对16位地址的处理:
verilog复制// 发送地址高字节
SEND_ADDR_H: begin
if(bit_cnt < 7) begin
// 移位发送高8位地址
sda_out <= tx_buf[7-bit_cnt];
i2c_scl <= 1;
bit_cnt <= bit_cnt + 1;
end
else if(quad_cnt == 3) begin
state <= ACK_ADDR_H;
bit_cnt <= 0;
end
end
// 发送地址低字节
SEND_ADDR_L: begin
if(bit_cnt < 7) begin
// 移位发送低8位地址
sda_out <= tx_buf[7-bit_cnt];
i2c_scl <= 1;
bit_cnt <= bit_cnt + 1;
end
else if(quad_cnt == 3) begin
state <= ACK_ADDR_L;
bit_cnt <= 0;
end
end
4.3 顶层控制逻辑
修改后的读写流程控制:
verilog复制// 写操作流程
1: begin
i2c_exec <= 1'b1;
i2c_rh_wl <= 1'b0; // 写模式
addr <= 16'h0020;
wr_data <= 8'h34;
flow_state <= 2;
end
// 读操作流程
4: begin
i2c_exec <= 1'b1;
i2c_rh_wl <= 1'b1; // 读模式
addr <= 16'h0020;
flow_state <= 5;
end
5. 关键调试技巧
5.1 状态指示灯设计
在没有逻辑分析仪的情况下,合理利用板载LED进行状态指示:
verilog复制// 结果校验
if(rd_data == 8'h34)
r_led_success <= 1'b1; // 绿灯亮
else
r_led_fail <= 1'b1; // 红灯亮
这种设计可以快速区分:
- 通信失败(两个灯都不亮)
- 通信成功但数据错误(红灯亮)
- 完全成功(绿灯亮)
5.2 写等待时间
EEPROM内部写入需要时间,必须添加适当延时:
verilog复制// 关键:写等待 (20ms)
3: begin
if(wait_cnt < 20'd1_000_000)
wait_cnt <= wait_cnt + 1'b1;
else begin
wait_cnt <= 0;
flow_state <= 4;
end
end
6. 经验总结与避坑指南
-
硬件文档的重要性
- 开始开发前必须仔细阅读原理图和器件手册
- 确认实际使用的芯片型号与预期是否一致
- 检查关键引脚(如WP)的连接状态
-
I2C设备差异点
- 不同容量的EEPROM可能有不同的寻址方式
- 页写入大小也随容量变化(24C02为8字节,24C64为32字节)
- 高容量器件可能需要多字节地址
-
调试技巧
- 分层验证:先确认物理层通信正常,再检查协议层
- 合理利用板载资源(LED、串口等)进行状态指示
- 关键操作后添加适当延时
-
代码设计建议
- 状态机设计要清晰,每个状态只做一件事
- 重要参数(如地址位宽)应设计为可配置项
- 添加充分的注释和调试接口
7. 扩展思考
-
通用驱动设计
更完善的I2C驱动应该支持自动检测器件类型和容量,可以通过:- 读取器件ID(如果支持)
- 尝试不同地址模式并验证
- 通过参数化设计支持多种器件
-
性能优化
- 在确保功能正确后,可以逐步提高时钟频率
- 考虑使用DMA减少CPU干预
- 实现页写入功能提高批量写入效率
-
错误处理
- 添加超时机制防止总线挂死
- 实现自动重试功能
- 提供详细的错误状态返回
这个案例生动展示了硬件开发中"细节决定成败"的道理。看似简单的I2C通信,因为芯片型号的细微差别就导致了完全不同的行为。作为开发者,我们必须养成严谨的工作习惯,不放过任何一个技术细节,才能在复杂的硬件世界里游刃有余。
