1. 项目缘起与整体设计思路
1.1 为什么选 AT24C02 作为 I2C 入门实战对象
搞嵌入式的人手里多少都攒着几块 STM32F103 的最小系统板,但很多人卡在“点完灯之后该练什么”这个阶段。GPIO 翻转太浅,SPI Flash 又偏复杂,而 I2C 接口的 AT24C02 这颗 EEPROM 恰好卡在一个黄金位置:协议够典型、时序够讲究、容量够小、成本够低,几乎每个电子市场一块钱就能抓一把。用它来打通 I2C 的完整链路,性价比极高。
AT24C02 是 2Kbit 容量的 I2C 串行 EEPROM,换算成字节就是 256 字节。别嫌小,存个设备序列号、校准参数、开机次数、用户配置这些零碎数据完全够用。它的页大小是 8 字节,这个数字后面会反复出现,因为写操作跨页是新手最容易翻车的地方。工作电压 1.8V 到 5.5V,跟 STM32F103 的 3.3V 系统天然兼容,不需要额外电平转换,硬件上省事不少。
从协议角度看,AT24C02 支持标准模式 100kHz 和快速模式 400kHz,STM32F103 的硬件 I2C 外设完全覆盖这个范围。它用 7 位从机地址,高四位固定为 1010,低三位由 A2/A1/A0 三个引脚决定,所以同一条总线上最多挂 8 片,地址范围从 0x50 到 0x57。这个地址分配机制很经典,理解了它,以后碰到其他 I2C 器件也能举一反三。
我选这个组合做实战拆解,核心原因是它能把 I2C 的三大核心问题一次性暴露出来:起始与停止条件的时序、应答位的处理、写周期的等待。这三个问题在纯软件模拟 I2C 时是纯时序问题,在硬件 I2C 时是寄存器配置和状态机问题,两条路都走一遍,对协议的理解会深刻得多。
1.2 硬件 I2C 与软件模拟 I2C 的选型权衡
这是绕不开的第一个决策点。STM32F103 有硬件 I2C 外设,但圈子里一直流传着“STM32 的硬件 I2C 有坑”的说法。这个说法有历史原因,早期标准库的 I2C 驱动确实存在一些状态机处理不完善的情况,加上 I2C 总线本身对时序敏感,一旦从机拉低时钟做时钟同步,或者出现总线仲裁,调试起来就比较头疼。
但我的实际经验是:硬件 I2C 在 F103 上是可用的,前提是你把状态机的错误处理写扎实。硬件 I2C 的优势在于 CPU 占用低,传输 256 字节时不需要 CPU 一直空转等待,而且时序由硬件保证,不受中断延迟影响。软件模拟 I2C 的优势在于引脚任意、调试直观、出问题容易定位,缺点是占用 CPU 且时序受中断影响。
我的建议是分场景:如果项目里 I2C 只是偶尔读写几个参数,软件模拟完全够用,代码可控性最强;如果要频繁读写、或者总线上挂多个器件、或者系统中断负载较重,那就老老实实上硬件 I2C,把错误处理写全。这篇拆解我会以硬件 I2C 为主线,同时在关键环节对比软件模拟的差异,这样两条路你都能掌握。
选型时还有一个容易被忽略的点:上拉电阻的取值。I2C 是开漏输出,必须外接上拉。标准模式 100kHz 下,4.7kΩ 是常见值;快速模式 400kHz 下,通常用 2.2kΩ 到 4.7kΩ。取值太大会导致上升沿变缓,波形变成“圆角”,高速下容易误判;取值太小则灌电流增大,增加功耗。总线上挂的器件越多、走线越长,上拉电阻就要相应减小。这个参数不是拍脑袋定的,后面我会给出计算方法。
1.3 整体数据流与分层设计
一个干净的 EEPROM 驱动应该分三层:底层 I2C 收发层、AT24C02 器件层、应用接口层。底层只负责“把若干字节发出去”和“把若干字节收回来”,不关心对面是谁;器件层负责拼装 AT24C02 需要的地址字节、处理页边界、等待写周期;应用层则提供类似save_config()、load_config()这样的语义化接口。
这样分层的好处是,哪天你把 AT24C02 换成 AT24C16 或者别的 I2C 器件,只需要改器件层,底层和应用层基本不动。很多新手把三层揉在一起写,结果换颗芯片就要重写一遍,这是很典型的教训。
数据流上,一次完整的写操作是这样的:应用层调用写接口 → 器件层判断是否跨页并拆分 → 底层发起起始条件、发送从机地址加写标志、发送字地址、发送数据、发起停止条件 → 器件层等待写周期完成(典型 5ms)→ 返回。读操作则是:底层发起起始、发送从机地址加写标志、发送字地址、重新发起起始、发送从机地址加读标志、接收数据并发送应答或非应答、发起停止。
这里有个关键细节:读操作里的“写地址”阶段用的是写标志,只有真正读数据时才切换成读标志。这个“伪写”步骤是 AT24C02 的随机读所必需的,用来设置内部地址指针。很多新手在这里搞混,直接发读标志,结果读出来全是 0xFF 或者错位数据。
2. 核心细节解析与实操要点
2.1 AT24C02 的地址机制与页写边界
先把地址这件事彻底讲透。AT24C02 的 7 位从机地址是1010 A2 A1 A0,假设三个地址脚都接地,那就是1010000,即 0x50。注意 I2C 传输时是“地址左移一位加读写位”,所以写操作发送的字节是0xA0,读操作是0xA1。这个左移操作是硬件 I2C 外设自动完成的,你只需要在寄存器里填 7 位地址即可,但用软件模拟时就得自己移位,别搞错。
器件内部的字地址是 8 位,范围 0x00 到 0xFF,正好对应 256 字节。写操作时,发送完从机地址后紧跟一个字节的字地址,然后才是数据。这个字地址会在每次写入后自动递增,所以连续写多个字节时不需要重复发地址。
页写边界是重中之重。AT24C02 的页大小是 8 字节,页的划分是固定的:0x00-0x07 是第一页,0x08-0x0F 是第二页,以此类推。当你从某个地址开始连续写数据,如果写到了页的末尾,地址指针会“回卷”到本页的开头,而不是进入下一页。举个例子,从 0x06 开始写 4 个字节,实际会写到 0x06、0x07、0x00、0x01,把开头两个字节覆盖掉。这个行为叫“页回卷”,是数据损坏的头号元凶。
所以器件层必须做跨页拆分。逻辑很简单:计算当前地址到本页末尾还剩多少字节,如果剩余空间不够本次要写的数据量,就先写满这一页,等写周期结束后,再从下一页开头继续写。这个拆分逻辑写对了,页写就稳了。
| 参数 | 数值 | 说明 |
|---|---|---|
| 总容量 | 256 字节 | 2Kbit |
| 页大小 | 8 字节 | 跨页必须拆分 |
| 从机地址 | 0x50-0x57 | 由 A2/A1/A0 决定 |
| 写周期 | 典型 5ms | 最大 10ms |
| 擦写寿命 | 100 万次 | 单字节级别 |
| 数据保持 | 100 年 | 常温下 |
2.2 写周期等待:轮询应答的两种实现
AT24C02 在收到停止条件后,内部会启动一个写周期,把数据真正烧进存储单元,这段时间典型 5ms,最大 10ms。在这期间,器件不会响应任何 I2C 请求,也就是发送从机地址后收不到应答。所以写完一页后不能立刻写下一页,必须等写周期结束。
等待方式有两种。第一种是固定延时,直接delay_ms(10),简单粗暴,但浪费时间,因为大部分情况下 5ms 就够了。第二种是应答轮询,也叫 ACK Polling:不断发起起始条件、发送从机地址加写标志,如果收到应答说明器件空闲了,可以继续;如果没收到应答就重试,直到超时。这种方式效率高,平均等待时间接近实际写周期。
应答轮询的实现要点是:每次轮询失败后要正确产生停止条件,否则总线会卡住。硬件 I2C 里,发送地址后如果收到 NACK,需要清除相关标志位再重新发起起始。软件模拟里则要保证 SDA 和 SCL 都回到高电平空闲态。我实测下来,轮询方式比固定延时平均能省 3 到 4ms,批量写 256 字节时差距很明显。
注意:应答轮询一定要设超时上限,比如 100 次重试。如果器件坏了或者总线短路,没有超时就会死循环,整个系统卡死。这个坑我踩过,现场调试时找了半天才发现是 EEPROM 虚焊导致轮询永不成功。
2.3 硬件 I2C 的关键寄存器与状态机
STM32F103 的硬件 I2C 用起来,核心是搞懂几个寄存器和事件。控制寄存器 CR1 里的 PE 位使能外设,START 位发起起始,ACK 位控制是否发送应答,STOP 位发起停止。状态寄存器 SR1 和 SR2 里则是一堆标志位,比如 SB(起始已发送)、ADDR(地址已发送且收到应答)、TXE(发送寄存器空)、RXNE(接收寄存器非空)、BTF(字节传输完成)。
事件驱动的写法是:发起起始后等 SB 置位,然后写数据寄存器 DR 发送地址,等 ADDR 置位,清除该标志后开始发数据。每发一个字节等 TXE,每收一个字节等 RXNE。这套流程用轮询实现最直观,用中断实现效率更高但代码复杂。
这里有个经典坑:ADDR 标志的清除方式。在 F103 上,清除 ADDR 需要先读 SR1 再读 SR2,顺序不能反。如果只读 SR1 不读 SR2,ADDR 清不掉,后续状态机会乱。这个细节在参考手册里有写,但很容易被忽略,导致程序卡在等待 ADDR 的循环里。
另一个坑是总线死锁。如果从机在传输中途掉电或者被复位,可能会一直拉低 SDA,导致总线无法产生起始条件。这时候需要手动恢复:把 SCL 配置成普通 GPIO,手动发送 9 个时钟脉冲,让从机把剩余数据吐完释放 SDA,然后再重新初始化 I2C 外设。这个恢复流程建议直接封装成一个函数,上电初始化和异常处理时都调用。
2.4 上拉电阻与总线电容的计算
前面提到上拉电阻不能拍脑袋定,这里给出计算方法。I2C 总线的上升时间受上拉电阻 Rp 和总线电容 Cb 影响,公式是tr ≈ 0.847 × Rp × Cb。标准模式要求上升时间不超过 1000ns,快速模式不超过 300ns。
假设总线电容 Cb 是 100pF(走线不长、挂两三个器件时的典型值),标准模式下Rp ≤ 1000ns / (0.847 × 100pF) ≈ 11.8kΩ,所以 4.7kΩ 绰绰有余。快速模式下Rp ≤ 300ns / (0.847 × 100pF) ≈ 3.5kΩ,这时候 4.7kΩ 就偏大了,得用 2.2kΩ 左右。
总线电容怎么估?每个器件的引脚电容大约 10pF,PCB 走线每厘米大约 1pF 到 2pF。挂 3 个器件、走线 10cm,大概就是 30pF 加 15pF 等于 45pF,取 100pF 是留了余量的保守估计。实际调试时最好用示波器看波形,如果上升沿明显变圆,就减小上拉电阻。
提示:上拉电阻是每条总线一组,不是每个器件一组。很多人第一次画板子给每个器件都配上拉,结果并联后阻值变得很小,灌电流超标。记住整条 SDA 和 SCL 各一个上拉就够了。
3. 实操过程与核心环节实现
3.1 硬件连接与初始化配置
先把硬件接起来。STM32F103 的 I2C1 默认引脚是 PB6(SCL)和 PB7(SDA),这两个脚配置成复用开漏输出。AT24C02 的 A2/A1/A0 全部接地,地址就是 0x50。SDA 和 SCL 各接一个 4.7kΩ 上拉到 3.3V。WP 引脚是写保护,接地表示允许写入,接高电平则整个芯片只读。调试阶段建议先接地,确认读写正常后再考虑是否启用写保护。
初始化配置分几步。第一,使能 GPIOB 和 I2C1 的时钟。第二,配置 PB6、PB7 为复用开漏输出,速度选 50MHz。第三,配置 I2C 的时钟控制寄存器 CCR,这个值决定了 SCL 频率。计算公式是CCR = PCLK1 / (2 × 目标频率)。假设 PCLK1 是 36MHz,目标 100kHz,那CCR = 36M / (2 × 100k) = 180。第四,配置上升时间寄存器 TRISE,标准模式下TRISE = PCLK1(MHz) + 1 = 37。第五,使能 ACK 和 PE 位。
// I2C1 初始化,标准模式 100kHz,PCLK1 = 36MHz void I2C1_Init(void) { GPIO_InitTypeDef gpio; I2C_InitTypeDef i2c; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_AF_OD; // 复用开漏 gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpio); i2c.I2C_Mode = I2C_Mode_I2C; i2c.I2C_DutyCycle = I2C_DutyCycle_2; i2c.I2C_OwnAddress1 = 0x00; // 主机模式随便填 i2c.I2C_Ack = I2C_Ack_Enable; i2c.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; i2c.I2C_ClockSpeed = 100000; // 100kHz I2C_Init(I2C1, &i2c); I2C_Cmd(I2C1, ENABLE); }这段代码用的是标准库,如果你用 HAL 库,逻辑一样,只是函数名不同。关键参数就是 ClockSpeed 和 DutyCycle,DutyCycle 只在快速模式下有意义,标准模式下填 2 就行。
3.2 单字节写入的完整时序实现
单字节写入是最基础的原子操作,把它写对了,页写和连续读都是它的组合。流程是:起始 → 发送从机地址加写标志 → 等应答 → 发送字地址 → 等应答 → 发送数据 → 等应答 → 停止 → 等待写周期。
// 向指定地址写一个字节,返回 0 成功,非 0 失败 uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { // 1. 发起起始条件 I2C_GenerateSTART(I2C1, ENABLE); if (!WaitFlag(I2C_SR1_SB, SET, TIMEOUT)) return 1; // 2. 发送从机地址 + 写标志 I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); if (!WaitFlag(I2C_SR1_ADDR, SET, TIMEOUT)) return 2; (void)I2C1->SR1; (void)I2C1->SR2; // 清 ADDR 标志 // 3. 发送字地址 I2C_SendData(I2C1, addr); if (!WaitFlag(I2C_SR1_TXE, SET, TIMEOUT)) return 3; // 4. 发送数据 I2C_SendData(I2C1, data); if (!WaitFlag(I2C_SR1_BTF, SET, TIMEOUT)) return 4; // 5. 停止条件 I2C_GenerateSTOP(I2C1, ENABLE); // 6. 等待写周期 return AT24C02_WaitStandby(); }WaitFlag是我自己封装的超时等待函数,轮询某个标志位直到置位或超时。超时值建议设成 10000 次循环左右,具体看主频。这里每一步都返回不同的错误码,调试时能快速定位卡在哪一步,比统一返回一个错误值有用得多。
AT24C02_WaitStandby就是前面说的应答轮询。它不断发起起始、发送 0xA0,收到应答就返回 0,超时返回错误。注意每次轮询失败后要发停止条件,否则总线状态不对。
3.3 页写与跨页拆分的实现
页写是在一次传输里连续发送多个字节,效率比逐字节写高得多,因为只需要一次起始、一次地址、一次停止。但必须处理跨页。实现思路是:先算出当前地址在本页内的剩余空间,取剩余空间和待写长度的较小值作为本次写入量,写完等写周期,再处理剩下的。
// 页写,自动处理跨页,返回 0 成功 uint8_t AT24C02_WritePage(uint8_t addr, const uint8_t *buf, uint16_t len) { while (len > 0) { // 计算本页剩余空间 uint8_t page_remain = 8 - (addr % 8); uint8_t chunk = (len < page_remain) ? len : page_remain; // 发起一次页写传输 I2C_GenerateSTART(I2C1, ENABLE); if (!WaitFlag(I2C_SR1_SB, SET, TIMEOUT)) return 1; I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); if (!WaitFlag(I2C_SR1_ADDR, SET, TIMEOUT)) return 2; (void)I2C1->SR1; (void)I2C1->SR2; I2C_SendData(I2C1, addr); if (!WaitFlag(I2C_SR1_TXE, SET, TIMEOUT)) return 3; for (uint8_t i = 0; i < chunk; i++) { I2C_SendData(I2C1, buf[i]); if (!WaitFlag(I2C_SR1_BTF, SET, TIMEOUT)) return 4; } I2C_GenerateSTOP(I2C1, ENABLE); // 等待写周期 if (AT24C02_WaitStandby() != 0) return 5; // 更新指针 addr += chunk; buf += chunk; len -= chunk; } return 0; }这段代码的核心就是那个page_remain的计算。addr % 8得到的是当前地址在页内的偏移,8 - 偏移就是本页还能写多少字节。比如 addr 是 0x06,偏移是 6,剩余 2 字节,如果 len 是 4,那第一次只写 2 字节,第二次从 0x08 开始写剩下 2 字节。这样永远不会触发页回卷。
实操心得:调试页写时,建议先用一个 256 字节的数组填满整个 EEPROM,再读回来逐字节比对。这个“全片写读校验”能一次性暴露跨页、地址递增、写周期等待的所有问题。我每次移植新驱动都先跑这个测试,比单点测试靠谱得多。
3.4 随机读与连续读的实现
读操作比写操作多一个“伪写”步骤。随机读的流程是:起始 → 发送从机地址加写标志 → 等应答 → 发送字地址 → 等应答 → 重新发起起始 → 发送从机地址加读标志 → 等应答 → 接收数据 → 发送非应答 → 停止。
连续读则是在接收数据时,除了最后一个字节发非应答,其余每个字节都发应答,这样器件会持续递增地址并输出数据。地址递增到 0xFF 后会回卷到 0x00,这是读操作的行为,和写操作的页回卷不同,读是整片回卷。
// 随机读,从 addr 开始读 len 个字节 uint8_t AT24C02_ReadBytes(uint8_t addr, uint8_t *buf, uint16_t len) { // 伪写:设置内部地址指针 I2C_GenerateSTART(I2C1, ENABLE); if (!WaitFlag(I2C_SR1_SB, SET, TIMEOUT)) return 1; I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); if (!WaitFlag(I2C_SR1_ADDR, SET, TIMEOUT)) return 2; (void)I2C1->SR1; (void)I2C1->SR2; I2C_SendData(I2C1, addr); if (!WaitFlag(I2C_SR1_BTF, SET, TIMEOUT)) return 3; // 重新发起起始,切换为读 I2C_GenerateSTART(I2C1, ENABLE); if (!WaitFlag(I2C_SR1_SB, SET, TIMEOUT)) return 4; I2C_Send7bitAddress(I2C1, 0xA1, I2C_Direction_Receiver); if (!WaitFlag(I2C_SR1_ADDR, SET, TIMEOUT)) return 5; // 接收数据 for (uint16_t i = 0; i < len; i++) { if (i == len - 1) { I2C_AcknowledgeConfig(I2C1, DISABLE); // 最后一个发 NACK } (void)I2C1->SR1; (void)I2C1->SR2; if (!WaitFlag(I2C_SR1_RXNE, SET, TIMEOUT)) return 6; buf[i] = I2C_ReceiveData(I2C1); } I2C_GenerateSTOP(I2C1, ENABLE); I2C_AcknowledgeConfig(I2C1, ENABLE); // 恢复 ACK return 0; }这里有个细节:最后一个字节之前要先关掉 ACK,再清 ADDR 标志,然后等 RXNE。顺序错了会导致最后一个字节收到 NACK 后器件提前释放总线,数据可能不完整。另外,读完之后一定要把 ACK 重新打开,否则下一次读操作会出问题。
3.5 完整测试用例与验证方法
驱动写完了,得验证。我一般分三步测。第一步,单字节读写:往 0x00 写 0x5A,读回来比对。第二步,全片写读:用 0 到 255 填充整个 EEPROM,再读回来逐字节比对,这一步验证跨页和地址递增。第三步,边界测试:从 0x06 写 4 字节,验证页回卷是否被正确规避;从 0xFF 读 4 字节,验证读回卷。
void AT24C02_Test(void) { uint8_t wbuf[256], rbuf[256]; for (int i = 0; i < 256; i++) wbuf[i] = i; // 全片写入 if (AT24C02_WritePage(0x00, wbuf, 256) != 0) { printf("write failed\r\n"); return; } // 全片读出 if (AT24C02_ReadBytes(0x00, rbuf, 256) != 0) { printf("read failed\r\n"); return; } // 比对 for (int i = 0; i < 256; i++) { if (wbuf[i] != rbuf[i]) { printf("mismatch at %d: w=%02X r=%02X\r\n", i, wbuf[i], rbuf[i]); return; } } printf("all pass\r\n"); }这个测试跑通,基本可以确认驱动没问题。如果中间有 mismatch,看错位的规律:如果是每 8 字节错一次,那是页边界问题;如果是固定偏移错,那是地址指针问题;如果全是 0xFF,那是根本没写进去或者读时序不对。
4. 常见问题与排查技巧实录
4.1 总线卡死与恢复流程
总线卡死是 I2C 调试里最烦人的问题,现象是程序卡在等待某个标志位的循环里,或者起始条件发不出去。原因通常是上一次传输异常中断,从机还在拉低 SDA 等待时钟。这时候硬件 I2C 外设可能已经进入错误状态,需要手动恢复。
恢复流程是:先把 I2C 外设关掉,把 SCL 和 SDA 配置成普通推挽输出,SCL 输出 9 个时钟脉冲,每个脉冲后检查 SDA 是否释放,然后手动产生一个停止条件(SDA 低时 SCL 高,然后 SDA 拉高),最后重新初始化 I2C 外设。这个流程封装成I2C_BusRecover(),上电时和检测到错误时都调用。
void I2C_BusRecover(void) { GPIO_InitTypeDef gpio; I2C_Cmd(I2C1, DISABLE); // 配置为普通推挽输出 gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpio); GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7); delay_us(10); // 发送 9 个时钟脉冲 for (int i = 0; i < 9; i++) { GPIO_ResetBits(GPIOB, GPIO_Pin_6); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(5); } // 手动产生停止条件 GPIO_ResetBits(GPIOB, GPIO_Pin_7); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_7); delay_us(5); I2C1_Init(); // 重新初始化 }注意:这个恢复函数在软件模拟 I2C 里同样适用,因为问题出在从机状态,跟主机是硬件还是软件无关。养成上电先调用一次恢复的习惯,能避免很多莫名其妙的初始化失败。
4.2 写不进去数据的排查思路
写完读出来不对,或者根本没写进去,排查顺序是这样的。第一,确认 WP 引脚电平,接高就是写保护,什么都写不进。第二,确认从机地址对不对,用示波器或者逻辑分析仪抓一下地址字节,看是不是 0xA0。第三,确认写周期等待有没有生效,如果写完立刻读,器件还在忙,读回来就是旧数据或者 0xFF。第四,确认上拉电阻有没有焊,很多人画板子忘了上拉,波形根本拉不高。
如果这些都对还是写不进,那可能是时序问题。用逻辑分析仪抓一次完整写操作的波形,对照数据手册看:起始条件是否标准(SCL 高时 SDA 下降),地址和数据是否在 SCL 高电平期间稳定,停止条件是否标准(SCL 高时 SDA 上升)。时序问题在示波器下一目了然,比盲猜快得多。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读回全 0xFF | 未写入、地址错、写周期未等 | 检查 WP、地址、加延时重试 |
| 每 8 字节错位 | 页回卷 | 检查跨页拆分逻辑 |
| 固定偏移错位 | 地址指针未递增 | 检查字地址发送 |
| 程序卡在等标志 | 总线死锁、ADDR 未清 | 调用总线恢复、检查 SR1/SR2 读取顺序 |
| 波形上升沿圆角 | 上拉电阻过大 | 减小上拉阻值或降低速率 |
| 偶发读写失败 | 中断干扰、时序临界 | 关中断测试、降低 SCL 频率 |
| 写后立即读失败 | 写周期未完成 | 启用应答轮询 |
4.4 提升可靠性的几个实战技巧
第一,关键参数存双份。EEPROM 虽然标称 100 万次擦写,但实际使用中如果频繁写同一地址,局部磨损会很快。重要参数存两份,读的时候比对,不一致就用备份恢复。第二,加校验和。每个配置块末尾加一个 CRC 或者简单累加和,读出来先校验,防止数据位翻转导致误用。第三,写前先读比对。如果新数据和旧数据一样,就跳过写入,能显著延长寿命。第四,降低写频率。参数变化时不要立刻写,攒一批或者定时写,减少擦写次数。
我在一个数据采集项目里,原本每秒写一次累计值,EEPROM 半年就出现坏块。后来改成每分钟写一次,并且加了写前比对,用了三年都没问题。这个教训说明,EEPROM 的寿命管理不是理论问题,是实打实会影响产品可靠性的。
4.5 软件模拟 I2C 的时序微调
如果你选择软件模拟,时序微调是必修课。标准模式 100kHz 意味着每个时钟周期 10us,高低电平各 5us。但实际实现时,由于 GPIO 翻转和函数调用有开销,延时函数要相应减小。我的做法是先按理论值写,然后用逻辑分析仪测实际频率,再调整延时参数。
软件模拟的关键是保证 SCL 高电平期间 SDA 稳定。发送数据时,先设置 SDA,再拉高 SCL,延时,拉低 SCL。接收数据时,先拉高 SCL,延时,读 SDA,再拉低 SCL。起始条件是 SCL 高时 SDA 从高变低,停止条件是 SCL 高时 SDA 从低变高。这些顺序不能乱。
提示:软件模拟 I2C 在中断频繁的系统里要小心,如果 SCL 高电平期间来了中断,延时被拉长,从机可能误判。解决办法是在关键时序段关中断,或者干脆用硬件 I2C。这也是我前面建议中断负载重时用硬件的原因。
5. 从 AT24C02 延伸到其他 I2C 器件的通用思路
把 AT24C02 这套流程吃透,你会发现大部分 I2C 器件都是类似的套路:起始、地址、寄存器地址、数据、停止。区别只在于地址格式、寄存器地址宽度、读写时序细节。比如某些传感器是 16 位寄存器地址,那就多发一个字节;某些器件读之前不需要伪写,直接读就行。
我的建议是,每接触一颗新 I2C 器件,先做三件事:确认从机地址、确认寄存器地址宽度、确认读写时序图。这三件事搞清楚了,剩下的就是套用已有的底层收发函数。AT24C02 作为练手对象,最大的价值就是它把这套通用流程用最简单的方式呈现出来了。
最后分享一个我常用的调试习惯:手边常备一个逻辑分析仪,几十块钱的那种就够用。I2C 的问题,看波形比看代码快十倍。波形对了,代码逻辑再查;波形不对,先修硬件和时序。这个习惯帮我省下了大量瞎猜的时间。