STM32F103硬件I2C驱动AT24C02实战:从时序到分页写入的避坑指南
1. 项目缘起与整体设计思路
STM32F103 搭配 AT24C02 这个组合,在嵌入式圈子里算是“老演员”了。但凡做过数据掉电保存、参数存储、设备序列号写入这类需求的人,大概率都绕不开这颗 2Kbit 的 I2C EEPROM。它便宜、好买、驱动简单,几乎成了入门 I2C 通信的标配外设。但就是这么个看似简单的东西,我在实际项目里见过太多人卡在“读出来全是 0xFF”“写进去读出来不对”“偶尔能通偶尔不通”这些坑上。所以这篇内容我不打算只贴一段能跑的代码,而是把整个读写链路从硬件到软件、从时序到状态机、从单字节到页写入全部拆开讲清楚。
先明确一下这个项目要解决的核心问题:让 STM32F103 通过硬件 I2C 接口,稳定可靠地对 AT24C02 进行字节读写、页写入和连续读取,并且处理好应答、超时、地址对齐这些容易出问题的细节。适合谁看?如果你正在用 STM32F103 最小系统板做数据存储相关的功能,或者你用的是 HAL 库但总觉得 I2C 用起来“心里没底”,再或者你打算从软件模拟 I2C 切换到硬件 I2C,那这篇内容基本可以当作一份实操手册来用。
为什么选硬件 I2C 而不是软件模拟?这是第一个要讲清楚的设计取舍。软件模拟 I2C 的好处是引脚随便选、时序完全可控、调试直观,但缺点也很明显:占用 CPU 时间、速率上不去、代码量不小。STM32F103 的硬件 I2C 外设虽然早年有一些“传说级”的坑,但在 F1 系列上只要配置正确、超时处理好,跑 100kHz 甚至 400kHz 都是稳的。我实测下来,用标准库或者 HAL 库的硬件 I2C,配合合理的超时机制,连续读写几千次没有出现丢数据的情况。所以这个项目的主线就是硬件 I2C。
再说 AT24C02 这颗芯片本身。2Kbit 的容量,换算过来是 256 字节,分成 32 页,每页 8 字节。这个“页”的概念非常关键,因为 AT24C02 支持页写入,一次最多写 8 个字节,超过一页就会“回卷”覆盖掉页首的数据。很多人写多字节的时候不处理分页,结果写 10 个字节发现最后两个把前面的覆盖了,这就是没搞懂页边界。所以整个项目的设计思路里,分页写入和地址对齐是必须提前考虑进去的。
从整体架构上看,这个项目分成三层:底层是 I2C 硬件初始化和基本的字节收发函数,中间层是 AT24C02 的设备驱动,包括字节写、页写、字节读、连续读、应答轮询,上层是应用层的测试逻辑,比如写入一组数据再读出来比对。这样的分层方式便于移植,换一个 I2C 从设备只需要改中间层。下面我就按这个结构,把每一层的实现细节和踩过的坑逐一展开。
2. 硬件连接与 I2C 基础配置
2.1 引脚连接与上拉电阻的选择
STM32F103 的硬件 I2C1 默认引脚是 PB6(SCL)和 PB7(SDA),I2C2 是 PB10 和 PB11。用哪个都行,我一般用 I2C1,因为引脚更常见,最小系统板上基本都引出来了。接线很简单:PB6 接 AT24C02 的 SCL,PB7 接 SDA,VCC 接 3.3V,GND 共地。但这里有一个必须注意的点:I2C 总线是开漏输出,必须接上拉电阻。
上拉电阻选多大?这个问题我被问过无数次。标准答案是 4.7kΩ 到 10kΩ 之间,具体要看总线速率和总线电容。100kHz 的标准模式下面,4.7kΩ 是最常用的值。如果你手头的模块已经带了上拉电阻,那就不用额外加;如果是从裸芯片自己搭电路,一定要加。我见过有人直接拿 STM32 的内部上拉来用,结果波形上升沿惨不忍睹,通信时好时坏。内部上拉大概在 40kΩ 左右,太弱了,驱动不了 I2C 总线的电容负载。
另外 AT24C02 的 A0、A1、A2 三个地址引脚决定了器件地址。全部接地时,7 位地址是 1010000,也就是 0x50。写操作地址是 0xA0,读操作地址是 0xA1。这个地址在后面代码里会反复用到,先记清楚。WP 引脚是写保护,接低电平允许写入,接高电平禁止写入。调试阶段建议直接接地,不然写不进去还以为是代码问题。
2.2 I2C 外设初始化参数计算
用 HAL 库初始化 I2C 的时候,CubeMX 会让我们填几个参数:Clock Speed、Duty Cycle、Own Address 等。Clock Speed 设成 100000 就是 100kHz,设成 400000 就是 400kHz。AT24C02 支持最高 400kHz,所以两个都可以。我一般先用 100kHz 调通,再尝试 400kHz。
这里有一个隐藏的参数需要算一下:I2C 的时钟源来自 APB1 总线。STM32F103 的 APB1 默认是 36MHz,I2C 外设通过分频和时钟控制寄存器来产生 SCL。以 100kHz 为例,分频系数大概是 36MHz / (2 * 100kHz) = 180,实际配置时 HAL 库会自动算,但如果你用标准库自己写,就要手动填 CCR 寄存器的值。标准模式下的公式是 CCR = PCLK1 / (2 * SCL),快速模式下降沿和上升沿还要分别设置。这些细节 HAL 库帮我们屏蔽了,但知道背后的计算逻辑,出问题的时候才能查。
还有一个容易忽略的点:I2C 的 GPIO 模式必须配置为复用开漏输出。在 CubeMX 里选 I2C 功能后,引脚会自动变成 AF Open Drain,但如果你手动写 GPIO 初始化代码,一定要设成GPIO_Mode_AF_OD,否则总线拉不高也拉不低,通信直接失败。这个坑我在早期用标准库的时候踩过,查了半天以为是时序问题,结果就是 GPIO 模式配错了。
2.3 超时机制的必要性
硬件 I2C 最让人头疼的就是“卡死”。如果从设备没有应答,或者总线被意外拉低,I2C 状态机可能一直等下去,整个程序就挂在那里。所以每一个 I2C 操作都必须带超时。HAL 库的HAL_I2C_Master_Transmit函数最后一个参数就是 Timeout,单位是毫秒。我一般设 100ms,对于 100kHz 的速率来说,传几个字节根本用不了这么久,设 100ms 已经非常宽松了。
但光有 HAL 的超时还不够。如果 I2C 总线真的被拉死了,HAL 的超时返回 HAL_TIMEOUT,这时候需要做总线恢复。恢复的方法是在 SCL 上手动发 9 个时钟脉冲,让从设备把数据线释放掉。具体做法是把 SCL 临时切成普通 GPIO 推挽输出,拉高拉低 9 次,然后再切回 I2C 功能。这个恢复流程我后面会在问题排查章节详细讲。
3. AT24C02 驱动实现与核心细节
3.1 单字节写入的完整流程
AT24C02 的单字节写入时序是这样的:发送起始条件,发送设备地址加写位(0xA0),等待应答,发送要写入的内存地址(0x00 到 0xFF),等待应答,发送要写入的数据字节,等待应答,发送停止条件。然后芯片内部会启动一个写周期,把数据真正写入 EEPROM 存储单元,这个写周期典型值是 5ms,最大 10ms。
这里的关键点是:发送完停止条件后,不能立刻进行下一次操作,必须等待写周期完成。怎么等?两种方式。一种是死延时 10ms,简单粗暴但浪费 CPU。另一种是应答轮询,也就是反复发送起始条件和设备地址,如果芯片返回应答,说明写周期结束了。应答轮询更高效,也是我推荐的方式。
用 HAL 库实现单字节写的代码大概长这样:
HAL_StatusTypeDef AT24C02_WriteByte(uint16_t memAddr, uint8_t data) { uint8_t buf[2]; buf[0] = (uint8_t)(memAddr & 0xFF); buf[1] = data; return HAL_I2C_Master_Transmit(&hi2c1, AT24C02_ADDR_WRITE, buf, 2, 100); }注意这里我把内存地址和数据拼成了一个 2 字节的缓冲区,一次性发出去。这样比先发地址再发数据少一次函数调用,效率更高。AT24C02 的内存地址是 8 位的,所以直接取低 8 位就行。如果是 AT24C32 或更大的芯片,地址是 16 位的,就要拆成高低两个字节。
3.2 页写入与地址回卷问题
前面说了,AT24C02 每页 8 字节,页写入时如果跨越页边界,地址会回卷到当前页的开头。举个例子,假设你从地址 0x06 开始写 4 个字节,0x06 和 0x07 是第 0 页的最后两个字节,0x08 和 0x09 属于第 1 页。但芯片不会自动跳到第 1 页,而是把 0x08 的数据写到第 0 页的 0x00,0x09 的数据写到 0x01。这就是回卷。
所以页写入函数必须做分页处理。我的做法是:先算出当前地址到页尾还有多少字节,如果剩余空间够,就一次写完;如果不够,就先写满当前页,然后从下一页开头继续写。代码逻辑如下:
HAL_StatusTypeDef AT24C02_WritePage(uint16_t memAddr, uint8_t *data, uint16_t len) { uint16_t pageRemain; HAL_StatusTypeDef status; while (len > 0) { pageRemain = AT24C02_PAGE_SIZE - (memAddr % AT24C02_PAGE_SIZE); uint16_t writeLen = (len < pageRemain) ? len : pageRemain; uint8_t buf[AT24C02_PAGE_SIZE + 1]; buf[0] = (uint8_t)(memAddr & 0xFF); memcpy(&buf[1], data, writeLen); status = HAL_I2C_Master_Transmit(&hi2c1, AT24C02_ADDR_WRITE, buf, writeLen + 1, 100); if (status != HAL_OK) return status; AT24C02_WaitStandby(); memAddr += writeLen; data += writeLen; len -= writeLen; } return HAL_OK; }这段代码里AT24C02_WaitStandby()就是应答轮询函数,每次写完一页都要等芯片内部写周期结束才能继续写下一页。如果不加这个等待,连续写多页的时候第二页的数据大概率会丢。
3.3 字节读取与连续读取
读取比写入简单一些,但也有细节。AT24C02 的读时序是:发送起始条件,发送设备地址加写位(0xA0),等待应答,发送要读取的内存地址,等待应答,重新发送起始条件,发送设备地址加读位(0xA1),等待应答,然后读取数据。如果是读多个字节,每读一个字节后主机要发送应答(ACK),最后一个字节发送非应答(NACK),然后发送停止条件。
用 HAL 库的话,HAL_I2C_Mem_Read这个函数可以直接完成上述流程,传入设备地址、内存地址、地址长度、数据缓冲区和长度就行。但要注意,HAL 库的HAL_I2C_Mem_Read在 STM32F1 系列上有一个已知的小问题:当读取长度超过一定值时,可能会因为中断优先级或者时序问题导致数据错位。我实测读 8 个字节以内没问题,读更多的话建议分多次读,每次不超过 8 字节。
连续读取的代码示例:
HAL_StatusTypeDef AT24C02_ReadBytes(uint16_t memAddr, uint8_t *buf, uint16_t len) { return HAL_I2C_Mem_Read(&hi2c1, AT24C02_ADDR_READ, memAddr, I2C_MEMADD_SIZE_8BIT, buf, len, 100); }这里I2C_MEMADD_SIZE_8BIT表示内存地址是 8 位的,AT24C02 用这个就对了。如果是 AT24C32 以上,要改成I2C_MEMADD_SIZE_16BIT。
3.4 应答轮询函数的实现
应答轮询是 AT24C02 驱动里最容易被忽略但最重要的部分。它的作用是检测芯片是否完成了内部写周期。实现方式是反复发送起始条件和设备地址,如果收到应答就说明芯片空闲了,可以继续操作。
HAL_StatusTypeDef AT24C02_WaitStandby(void) { uint32_t timeout = 0; while (HAL_I2C_Master_Transmit(&hi2c1, AT24C02_ADDR_WRITE, NULL, 0, 10) != HAL_OK) { timeout++; if (timeout > 100) return HAL_TIMEOUT; } return HAL_OK; }注意这里发送的数据长度是 0,也就是只发设备地址,不发数据。如果芯片还在写周期内,它不会应答,HAL 库返回错误;等写周期结束,芯片应答,函数返回成功。超时设 100 次,每次 10ms,总共 1 秒,足够覆盖最坏情况。
4. 完整读写测试与数据校验
4.1 测试用例设计
驱动写好了,怎么验证它真的可靠?我一般会设计三个测试用例。第一个是单字节读写测试:在地址 0x00 写入 0x5A,延时后读出来比对。第二个是页写入测试:从地址 0x00 开始写入 8 个字节的递增序列,读出来比对。第三个是跨页写入测试:从地址 0x05 开始写入 10 个字节,这个用例专门验证分页逻辑是否正确。
测试代码里我会加一个全局的错误计数器,每次比对失败就加一,最后打印出来。如果错误计数是 0,说明驱动稳定。我还会做循环测试,比如连续写读 1000 次,看看有没有偶发错误。这个循环测试特别重要,因为有些问题只在特定时序下出现,单次测试根本发现不了。
4.2 数据校验与错误处理
数据校验最简单的方式是逐字节比对。但如果是存储结构化数据,比如一个配置结构体,直接 memcmp 就行。更严谨的做法是加 CRC 校验,写入的时候算一个 CRC 存在末尾,读取的时候再算一遍比对。AT24C02 只有 256 字节,加一个字节的 CRC 开销可以接受。
错误处理方面,每个 I2C 操作都要检查返回值。如果返回 HAL_ERROR,可能是总线故障;返回 HAL_TIMEOUT,可能是从设备没应答;返回 HAL_BUSY,可能是上一次操作还没完成。针对不同的错误要有不同的处理策略。比如 HAL_TIMEOUT 可以重试几次,HAL_ERROR 就要考虑总线恢复了。
4.3 实测数据与性能分析
我在 100kHz 速率下实测的数据是:单字节写入(含等待写周期)大约 5.2ms,页写入 8 字节大约 5.5ms,连续读取 8 字节大约 0.8ms。可以看到写入的时间主要花在等待内部写周期上,实际 I2C 传输时间很短。所以如果你的应用对写入速度有要求,要尽量减少写入次数,能页写入就不要单字节写。
把速率提到 400kHz 后,I2C 传输时间缩短到原来的四分之一,但写周期还是 5ms 左右,所以整体写入时间改善有限。读取速度提升明显,8 字节读取降到 0.2ms 左右。所以 400kHz 对读多写少的场景更有意义。
5. 常见问题排查与避坑经验
5.1 读出来全是 0xFF 或 0x00
这是最常遇到的问题。0xFF 通常意味着读取操作根本没有得到应答,主机读到的只是总线空闲时的高电平。原因可能有几个:设备地址错了、引脚接反了、上拉电阻没接、芯片坏了。排查的时候先用示波器或者逻辑分析仪看波形,确认起始条件、地址字节、应答位是否正常。如果没有仪器,就用最笨的办法:把 I2C 速率降到 10kHz,用万用表测 SCL 和 SDA 的电压,正常空闲时都应该是高电平。
0x00 则可能是写保护引脚被拉高了,或者写入的地址不对。检查 WP 引脚的电平,确认是接地的。
5.2 写入后立刻读取数据不对
这个问题几乎都是因为没有等待写周期。AT24C02 在收到停止条件后需要 5ms 左右把数据写入存储单元,这期间芯片不响应任何操作。如果你写完立刻读,读到的还是旧数据或者随机值。解决办法就是加应答轮询或者至少延时 10ms。
我早期还遇到过一个更隐蔽的情况:写周期还没结束就发了下一个起始条件,导致芯片状态机混乱,后续所有操作都失败。所以应答轮询不仅是等写周期,也是让芯片状态复位。
5.3 多字节写入时数据错位
数据错位通常有两个原因。一个是分页逻辑没写对,跨页的时候地址回卷了。另一个是缓冲区溢出,比如你定义了 8 字节的数组但写了 10 个字节。排查的时候先把写入长度限制在 8 字节以内,确认单页写入没问题,再测试跨页。
还有一个不太常见但很坑的问题:HAL 库的HAL_I2C_Master_Transmit在传输过程中如果被高优先级中断打断,可能会导致时序错乱。解决办法是把 I2C 相关的中断优先级设高一点,或者在传输期间关中断。但关中断时间不能太长,否则影响系统实时性。
5.4 总线卡死与恢复方法
总线卡死表现为 SCL 或 SDA 被持续拉低,I2C 状态机一直处于 BUSY 状态。原因可能是从设备在传输过程中被复位,导致它还在等时钟脉冲。恢复方法前面提过:把 SCL 切成 GPIO 推挽输出,手动发 9 个时钟脉冲,然后发一个停止条件。具体代码:
void I2C_BusRecovery(void) { GPIO_InitTypeDef gpio; gpio.Pin = GPIO_PIN_6; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); } HAL_I2C_DeInit(&hi2c1); HAL_I2C_Init(&hi2c1); }恢复完成后重新初始化 I2C 外设,一般就能恢复正常。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 读出全 0xFF | 设备无应答 | 检查地址、接线、上拉 | 修正地址,补上拉电阻 |
| 读出全 0x00 | 写保护开启 | 测 WP 引脚电平 | WP 接地 |
| 写入后读不对 | 未等写周期 | 检查是否加延时 | 加应答轮询或延时 10ms |
| 多字节错位 | 跨页回卷 | 检查地址是否跨页 | 实现分页写入逻辑 |
| 总线卡死 | 从设备状态异常 | 测 SCL/SDA 电平 | 执行总线恢复流程 |
| 偶发失败 | 中断干扰 | 检查中断优先级 | 提高 I2C 中断优先级 |
6. 从单字节到结构化存储的扩展思路
6.1 参数存储的地址规划
AT24C02 只有 256 字节,如果用来存设备参数,必须提前规划好地址分配。我的习惯是把地址空间分成几个区域:0x00 到 0x0F 存设备 ID 和版本号,0x10 到 0x7F 存用户参数,0x80 到 0xEF 存运行日志,0xF0 到 0xFF 保留给 CRC 和标志位。这样规划的好处是各区域互不干扰,后期扩展也方便。
每个参数项用一个结构体定义,写入的时候把结构体首地址和长度传给页写入函数。读取的时候反过来。注意结构体可能会有字节对齐的问题,不同编译器对齐方式可能不一样,所以最好用__packed关键字或者手动序列化成字节数组再存。
6.2 数据版本管理与兼容性
产品固件升级后,参数结构可能变了,这时候读旧数据就会出错。解决办法是在存储区开头放一个版本号,读取的时候先检查版本号,如果不匹配就加载默认参数并重新写入。版本号可以是一个字节,每次参数结构变更就加一。这个机制在量产产品里非常有必要,能避免很多现场故障。
6.3 磨损均衡的简单实现
EEPROM 的擦写次数是有限的,AT24C02 标称 100 万次。如果某个地址频繁写入,比如每秒写一次,那不到 12 天就写坏了。简单的磨损均衡做法是:把同一个参数存到多个地址,每次写入轮换地址,读取的时候取最新的那个。比如用 4 个槽位轮流写,寿命就延长到 4 倍。更复杂的做法是加一个写入计数器,但 AT24C02 空间有限,简单轮换就够了。
我在一个数据记录仪项目里用过这个方案:每 10 秒记录一次温度,256 字节分成 32 个记录槽,每个槽 8 字节,循环覆盖。这样即使连续运行几个月,每个地址的写入次数也远低于极限。配合一个头指针记录当前写到哪个槽,读取的时候从头指针往前读就行。
7. 硬件 I2C 与软件模拟的取舍经验
7.1 什么时候该用硬件 I2C
硬件 I2C 的优势是省 CPU、速率高、代码简洁。如果你的项目里 I2C 操作不频繁,或者对实时性要求不高,硬件 I2C 完全够用。但如果你的 I2C 总线上挂了多个设备,而且有些设备时序比较特殊,硬件 I2C 的灵活性就不如软件模拟了。
我个人的经验是:单一从设备、标准时序、速率要求不高的情况下,优先用硬件 I2C。多设备、非标时序、需要精细控制的情况下,用软件模拟更省心。STM32F103 的硬件 I2C 在 F1 系列上确实有一些历史遗留问题,但在 HAL 库的封装下,大部分问题已经被屏蔽了。
7.2 软件模拟 I2C 的适用场景
软件模拟 I2C 最大的好处是引脚任意选、时序完全可控。比如你需要在某个特定引脚上模拟 I2C 时序去驱动一个非标设备,硬件 I2C 做不到,软件模拟就可以。另外,软件模拟 I2C 调试起来更直观,因为你可以随时在代码里打断点看波形。
但软件模拟的缺点也很明显:占用 CPU、速率低、代码量大。100kHz 的软件模拟 I2C 基本会占满 CPU,如果你还有其他任务要跑,就会受影响。所以我的建议是:能用硬件就用硬件,实在不行再考虑软件模拟。
7.3 两者混合使用的注意事项
有些项目里会同时用到硬件 I2C 和软件模拟 I2C,比如硬件 I2C 接 EEPROM,软件模拟接 OLED。这种情况下要注意引脚不能冲突,而且软件模拟的延时函数不能被打断太久,否则时序会乱。我一般会把软件模拟 I2C 的延时用 DWT 或者定时器来实现,比空循环更精准。
另外,如果硬件 I2C 和软件模拟 I2C 共用同一组引脚(分时复用),切换的时候要先把硬件 I2C 反初始化,把引脚切成 GPIO,再启动软件模拟。切换回来的时候反过来。这种用法比较少见,但确实有人这么干过。
8. 项目移植与代码组织建议
8.1 驱动层的接口设计
一个好的驱动层应该做到“换芯片不改上层”。我的做法是定义一组统一的 EEPROM 操作接口:初始化、字节写、页写、字节读、连续读、等待就绪。上层应用只调用这些接口,不关心底层是 AT24C02 还是 AT24C32。如果换芯片,只需要改驱动层的实现,上层代码一行不动。
接口定义可以放在一个头文件里,用函数指针或者宏来区分不同芯片。比如:
typedef struct { HAL_StatusTypeDef (*Init)(void); HAL_StatusTypeDef (*WriteByte)(uint16_t addr, uint8_t data); HAL_StatusTypeDef (*WritePage)(uint16_t addr, uint8_t *data, uint16_t len); HAL_StatusTypeDef (*ReadBytes)(uint16_t addr, uint8_t *buf, uint16_t len); HAL_StatusTypeDef (*WaitStandby)(void); } EEPROM_Driver_t;这样上层代码通过EEPROM_Driver_t指针来操作,换芯片的时候只需要换一个驱动实例。
8.2 在不同 STM32 系列上的移植
STM32F103 的 I2C 外设和 F4、F7、H7 系列有一些差异,主要是时钟配置和 HAL 库版本。移植的时候主要改三个地方:I2C 初始化结构体、时钟使能、引脚定义。HAL 库的 API 基本兼容,所以移植工作量不大。但要注意 F4 以上的系列 I2C 时序参数计算方式不同,CubeMX 会自动处理,手动写的话要查参考手册。
从 F103 移植到 F030 或者 G030 这类低端系列时,要注意 I2C 外设的版本可能更老,有些特性不支持。比如 F030 的 I2C 不支持 400kHz 快速模式,只能跑 100kHz。移植前先查一下目标芯片的参考手册,确认 I2C 外设的规格。
8.3 代码分层与目录结构
我一般的目录结构是这样的:bsp/放硬件抽象层,包括 I2C 初始化和 GPIO 配置;driver/放设备驱动,比如at24c02.c和at24c02.h;app/放应用逻辑,比如参数管理和测试代码。这样分层清晰,移植的时候只需要替换bsp/和driver/里的文件。
头文件里用#ifndef做防重复包含,所有对外接口加注释说明参数和返回值。驱动层不依赖任何应用层的头文件,保持独立。这样即使把驱动单独拿出来放到另一个项目里,也能直接编译通过。
9. 调试工具与实测技巧分享
9.1 逻辑分析仪的使用心得
调试 I2C 最有效的工具是逻辑分析仪。几十块钱的 8 通道逻辑分析仪就够用了,配合开源软件可以解码 I2C 协议,直接看到起始条件、地址、数据、应答位。我遇到的大部分 I2C 问题,用逻辑分析仪抓一次波形就能定位。
抓波形的时候要注意采样率。100kHz 的 I2C 至少要用 1MHz 以上的采样率,否则波形会失真。我一般设 4MHz 或 8MHz,这样上升沿和下降沿都能看清楚。触发条件设成 SCL 下降沿或者起始条件,这样能抓到完整的传输过程。
9.2 用 GPIO 翻转做简易调试
没有逻辑分析仪怎么办?可以用一个空闲的 GPIO 做调试指示。比如在 I2C 传输开始前拉高,结束后拉低,用示波器看这个 GPIO 的波形就能知道传输花了多长时间。或者在关键代码分支里翻转 GPIO,用 LED 显示程序走到了哪一步。这个方法虽然原始,但在没有专业仪器的时候非常管用。
我早期调试的时候,就是在应答轮询函数里翻转一个 GPIO,用 LED 看它闪不闪。如果一直不闪,说明芯片一直没应答,问题就在硬件或者地址上。如果闪了但数据不对,问题就在数据缓冲区或者时序上。这种二分法排查效率很高。
9.3 实测中的时序余量分析
I2C 的时序余量是指实际波形和协议要求之间的差距。比如协议要求 SCL 高电平时间至少 4μs,你实测是 4.5μs,那余量就是 0.5μs。余量太小的话,温度变化或者电压波动就可能导致通信失败。我一般要求余量至少在 20% 以上。
用逻辑分析仪测量 SCL 频率、高电平时间、低电平时间、起始条件的建立时间、停止条件的保持时间,和 AT24C02 数据手册里的参数对比。如果都在范围内且有余量,基本就稳了。如果某个参数贴着极限,就要调整 I2C 时钟配置或者上拉电阻。
10. 写在最后的实操体会
这个项目我从头到尾做过不下五遍,每次都能遇到新的小问题。最开始是地址搞错,后来是忘了等写周期,再后来是跨页写入没处理。每次踩坑都让我对 I2C 协议和 AT24C02 的理解更深一层。现在回头看,这个组合虽然简单,但把 I2C 的起始、停止、应答、地址、数据、时序、总线恢复这些概念全都覆盖到了,确实很适合用来练手。
如果你正在做这个项目,我的建议是:先用 100kHz 把单字节读写调通,再加页写入,再加连续读取,最后做循环测试。每一步都确认稳定了再往下走,不要一上来就写一堆代码然后一起调。另外,逻辑分析仪真的值得买一个,几十块钱能省你几十个小时的调试时间。
最后分享一个小技巧:在 AT24C02 的驱动里加一个全局的错误统计结构体,记录每种错误发生的次数。产品跑一段时间后把统计读出来,就能知道 I2C 通信的稳定性如何。如果某种错误频繁出现,就针对性地优化。这个习惯让我在很多项目里提前发现了潜在的硬件问题。