1. 为什么拿 AT24C02 当 I2C 实战的入门器件,最合适不过
很多朋友学 STM32,点完流水灯、搞完串口打印,就开始想碰 I2C 这种通信协议。但直接上 OLED、传感器之类的,往往会被时序、寄存器配置、应答信号搞得一头雾水。我自己的经验是:先从一颗最简单的 EEPROM——AT24C02 入手,把 I2C 读写的每一步吃透,后面再碰任何 I2C 器件都会顺很多。
AT24C02 是一颗 2Kbit(也就是 256 字节)的串行 EEPROM,工作电压在 1.8V~5.5V,STM32F103 的 3.3V 供电完全兼容。它内部就是一个线性字节数组,没有复杂的寄存器、状态机、中断事件,你要做的事情只有两个:往某个地址写数据,或者从某个地址读数据。它就是为 I2C 通信而生的纯存储器件,非常适合拿来验证你对时序的理解。
1.1 AT24C02 的“性格”:256 字节能干什么
别看容量小,AT24C02 在很多嵌入式产品里是“刚需”般的存在。最典型的用途就是参数掉电保存。比如控制器上电后需要读取运行模式、校准系数、设备地址、用户设置等信息,这些数据放在 Flash 里擦写寿命有限(STM32F103 内部 Flash 虽然能擦写一万次,但如果你要频繁记录运行状态,用它显然不划算)。EEPROM 的擦写寿命普遍在 100 万次左右,而且按字节擦写,用起来更灵活。
AT24C02 的内部结构很简单:
- 容量 256 字节,每个字节对应一个 8 位地址,寻址范围是
0x00 ~ 0xFF。 - 支持页写(page write),每页 8 字节。也就是说,一次连续写最多能写 8 个字节,超过了就会在当前页内回卷,这一点很容易踩坑,后面我会专门讲。
- 支持随机读、顺序读、当前地址读三种读模式。
- 写一个字节后,器件内部需要大概 5ms 的自定时写周期,在这期间不响应任何外部指令。
所以,AT24C02 特别适合用来做“掉电记录仪”“运行参数保存”“系统配置存储”。我做过一个环境监测的小板子,每次上电前把传感器校准值从 AT24C02 里读出来,运行中每 5 分钟把累计数据写进去再关机,用了大半年没出过问题。
1.2 对比其他存储方案:为什么不用 SPI Flash 或者干脆用内部 Flash
很多人会有疑问:STM32F103 内部就有 Flash,为什么还要外挂 EEPROM?
先说内部 Flash。虽然它也能存数据,但问题是:擦写需要先按扇区擦除(F103 一个扇区 1KB),如果你想修改其中的 1 个字节,通常要先把整个扇区读到 RAM,再改写目标字节,再擦除整个扇区,最后写回去。这个过程不仅繁琐,而且一旦在擦写过程中断电,数据容易损坏。AT24C02 的 I2C 接口是逐字节操作的,你让它改哪一字节它就改哪一字节,内部自动完成擦写,外部不用管。
再说 SPI Flash(比如 W25Q16)。它容量大、速度快,但也是按扇区擦除,4KB 一个扇区,同样不适合频繁逐字节修改。而且 SPI 的引脚占用比 I2C 多:至少 MOSI、MISO、SCK、CS 四根线;I2C 只需要 SCL、SDA 两根线。对于只存几个配置参数的场景,AT24C02 无论是接口复杂度还是使用成本,都更合适。
还有一点很关键:AT24C02 是 I2C 总线上的经典器件,几乎所有 I2C 协议分析工具、逻辑分析仪、教程例子都以它为例。学会了它,等于掌握了 I2C 协议的八成要点,后面接 OLED(SSD1306)、温湿度传感器(SHT30)、气压计(BMP280)时,你会发现通信套路是通用的。
1.3 工具清单:别等焊完板子才发现缺东西
我做这个实验用的是一块 STM32F103C8T6 最小系统板,加一个 AT24C02 模块(或者直接买 DIP-8 封装的手焊到转接板上)。除了板子和芯片,你还需要准备:
- 杜邦线若干:至少 4 根(VCC、GND、SCL、SDA)。
- 两个 4.7kΩ 上拉电阻:AT24C02 模块上一般已经焊好了,如果是裸芯片,必须自己加,否则 I2C 通信不会稳定。
- 逻辑分析仪:强烈建议备一个。不要以为代码能跑就万事大吉,I2C 这种总线,波形上一眼能看出的问题,用调试器可能折腾一下午。某宝几十块的 8 通道逻辑分析仪配 PulseView 软件就够用。
- USB-TTL 串口模块:用来打印调试信息。
- 万用表:量电压、量通断,排查硬件连接问题必备。
如果你用的是 F103 系列,顺便提醒一句:在 Keil MDK 里搭建工程时,记得下载 STM32F1 系列的器件支持包(D FPACK),不然编译会报找不到芯片型号。网上现在有些教程还是老一套直接添加固件库文件,新手容易路径混乱,我更推荐直接从标准外设库(SPL)或者 HAL 库开始,配合 CubeMX 生成初始化代码,后面省心很多。
2. 硬件连接与电平匹配:别让 5V 和 3.3V 把你坑了
很多新手第一次接 AT24C02,直接用 5V 给模块供电,然后 SCL、SDA 也直接接在 STM32F103 的引脚上。这个接法其实很危险。
STM32F103 的绝大多数引脚是 5V 容忍的(FT 引脚),意味着它能直接接受 5V 电平输入。但 AT24C02 在 5V 供电时,SCL/SDA 的高电平输出是接近 5V 的,而 STM32 引脚即使能容忍 5V,它的内部上拉或者开漏配置如果没处理好,也可能出现电流倒灌或电平不确定的问题。最稳妥的设计是:AT24C02 和 STM32 都用 3.3V 供电,这样电平完全一致,无需转换。
2.1 最小系统与板载资源的注意点
我用的是最常见的 STM32F103C8T6 蓝色 pill 板,板上已经焊好了 8MHz 晶振、复位电路、USB 转串口芯片,外部只需要接一个 ST-Link 就可以下载调试。
接线方案很简单:
- AT24C02 的 VCC -> 3.3V
- AT24C02 的 GND -> GND
- AT24C02 的 SCL -> STM32F103 的 PB6(硬件 I2C1_SCL) 或者任意普通 GPIO(软件模拟时)
- AT24C02 的 SDA -> STM32F103 的 PB7(硬件 I2C1_SDA) 或者任意普通 GPIO
这里要说明一下:如果你打算直接用 STM32 的硬件 I2C 外设,SCL/SDA 必须接在对应的复用引脚上(I2C1 是 PB6/PB7,I2C2 是 PB10/PB11)。如果打算用软件模拟 I2C,那任意两个 GPIO 都行,代码里自己拉高拉低。我建议新手先从软件模拟开始,后面会解释为什么。
2.2 I2C 上拉电阻怎么选
I2C 总线是开漏结构,SCL 和 SDA 线上必须接上拉电阻到 VCC,才能产生高电平。这是新手最容易忽略的一件事——以为直接接上芯片就能通信,结果卡在寻址应答上。
上拉电阻的取值是有讲究的:
| 电容负载范围 | 推荐上拉电阻 |
|---|---|
| 100pF 以下 | 1kΩ ~ 2.2kΩ |
| 100pF ~ 400pF | 2.2kΩ ~ 4.7kΩ |
| 400pF ~ 2nF | 4.7kΩ ~ 10kΩ |
对于短距离、低速(100kHz 标准模式)的板内通信,4.7kΩ 是最稳妥的选择。如果电阻太小(比如 1kΩ),高电平时电流过大,会让总线上的驱动管承担过大的灌电流,虽然不至于烧毁,但可能影响低电平的判定。如果电阻太大(比如 100kΩ),总线电容充放电太慢,波形上升沿会变得平缓,通信速率一上来就容易出错。
顺便提一个很常见的现象:有人用杜邦线把模块接出来,上拉电阻用的是模块上预装的 10kΩ,然后发现距离超过 10cm 后通信不稳定。这时候把电阻换成 4.7kΩ,或者缩短杜邦线,问题往往就解决了。总线上挂多个设备时,等效电容会增大,上拉电阻也要相应调小。
2.3 AT24C02 的地址引脚与 WP 引脚处理
AT24C02 是 8 脚 DIP 封装(或 SOP-8),引脚定义如下:
- A0、A1、A2:地址选择引脚。这三个引脚的电平组合决定了器件的 7 位 I2C 地址。
- SCL、SDA:串行时钟、串行数据。
- WP:写保护引脚。WP 接高电平,整个芯片只读;接低电平(或悬空,内部默认下拉)可写。
- VCC、GND。
AT24C02 的 7 位 I2C 地址格式是1010 A2 A1 A0。比如你把 A0、A1、A2 全部接地,那 7 位地址就是1010000,也就是 0x50。这个地址是左移一个比特后,再加上读写位组成 8 位。很多人以为发送的地址字节是 0x50,实际上发送写地址时是0xA0(因为 0x50<<1 | 0),读地址时是0xA1(0x50<<1 | 1)。这个换算关系非常容易搞混,我当年就是卡在这,示波器抓到干净波形但就是不应答,后来发现是自己把地址算错了。
WP 引脚在正常读写时应接地,不要让它悬空。有些模块上 WP 引脚有下拉电阻,悬空默认低电平,但为了保险我还是建议手动接 GND。
2.4 5V 转 3.3V 电路的问题
如果你非要让 AT24C02 工作在 5V,那 STM32 这边就需要做电平转换。常见做法是:
- 用两个 MOS 管做双向电平转换,或用专用的电平转换芯片。
- 或者老老实实把 AT24C02 也接到 3.3V,反正它支持 1.8~5.5V,何必给自己添麻烦。
对于这个实验,直接 3.3V 供电是唯一推荐方案。有人问“STM32F103 引脚不是 5V 容忍吗,5V 直接接上去行不行?”——FT 引脚确实能承受 5V 输入,但要注意这是指 STM32 引脚作为输入时,外部逻辑输出 5V,引脚漏电流可控;如果 AT24C02 和 STM32 都加电,SDA 引脚在开漏模式下会被上拉到 5V,而 AT24C02 的 SDA 输出低电平能力没问题,但从 STM32 这一侧看,它内部的上拉二极管可能会把电流引向 3.3V 电源,时间久了有隐患。工程上不是不能用,而是没必要。用 3.3V 供电后,一切电平匹配问题都消失了,你可以把注意力集中在协议本身。
3. I2C 协议关键时序拆解:起始、停止、应答与时钟
I2C 协议说复杂也复杂,说简单也简单。你不需要背下整个标准规范,只需要搞清楚一条主线:主设备(STM32)控制时钟,从设备(AT24C02)在地址匹配时回应答,数据传输按位进行,每 8 位后面跟一个应答位。
3.1 从电工角度理解 I2C 总线
想象一下,SCL 是钟摆,SDA 是旗语。所有设备都挂在两根线上,开漏结构让每个设备都能拉低总线,但没人拉高——高电平时靠上拉电阻。所以总线空闲时,SCL 和 SDA 都是高。
传输的开始是:SCL 保持高,SDA 从高跳变到低,这个跳变就是起始条件(START)。结束时:SCL 保持高,SDA 从低跳变到高,这是停止条件(STOP)。这两个条件非常重要,因为它们是除了地址字节之外,让从设备识别通信边界的唯一依据。
时钟线 SCL 上的每个高电平脉冲,对应 SDA 上的一位数据。数据必须在 SCL 高电平期间保持稳定,只在 SCL 低电平期间改变。这就像拍照:“咔嚓”一声(上升沿),数据必须已经摆好姿势。
每传输 8 位数据后,接收方要拉低 SDA 一个时钟周期来回应答(ACK)。如果接收方没拉低,就代表 NACK,通常意味着地址没匹配、器件忙、或者读操作最后一个字节不需要应答。
3.2 为什么 STM32F103 的硬件 I2C 被称为“著名的坑”
STM32F103 的硬件 I2C 外设一直被人诟病。原因包括:外设状态机设计反人性、配置复杂,在总线异常时容易卡死,而且官方早期给的库函数有 bug。很多人照着例程写,明明时序听起来没问题,但代码一到I2C_CheckEvent()就死循环了。
我个人的建议是:如果你刚开始学,先用 GPIO 软件模拟 I2C。理由有三:
- 软件模拟能让你把时序完全掌握在自己手里,每一拍的拉高拉低都是你写的代码,出问题可以直接用示波器量和代码逐行对比。
- 规避掉原生硬件 I2C 在调试器介入、总线忙状态释放时的各种坑。
- 软件模拟代码可移植性极强,你把 GPIO 宏换一下,就能跑在 STM32 的任何一个引脚上,甚至换到 STM8、STC、Arduino 上都能用。
我在生产项目里,有时候用硬件 I2C(因为它不占 CPU),但调试阶段永远保留一份软件模拟驱动作为备份。下面这就是我常用的基础时序代码,前提是你已经用 GPIO 初始化把 SCL 和 SDA 都配成了开漏输出,SDA 在做输入时切换到输入模式。
// 以标准库为例,PB6->SCL, PB7->SDA #define I2C_SCL_H GPIOB->BSRR = GPIO_Pin_6 #define I2C_SCL_L GPIOB->BRR = GPIO_Pin_6 #define I2C_SDA_H GPIOB->BSRR = GPIO_Pin_7 #define I2C_SDA_L GPIOB->BRR = GPIO_Pin_7 #define I2C_SDA_READ ((GPIOB->IDR & GPIO_Pin_7) != 0) void i2c_delay(void) { // 约 5us,根据系统时钟调整;标准模式 SCL 频率约 100kHz volatile uint16_t i = 20; while (i--); } void i2c_start(void) { I2C_SDA_H; I2C_SCL_H; i2c_delay(); I2C_SDA_L; // SCL 高期间 SDA 拉低 => START i2c_delay(); I2C_SCL_L; // 拉低 SCL,准备传数据 } void i2c_stop(void) { I2C_SCL_L; I2C_SDA_L; I2C_SCL_H; i2c_delay(); I2C_SDA_H; // SCL 高期间 SDA 拉高 => STOP i2c_delay(); } void i2c_write_bit(uint8_t bit) { if (bit) I2C_SDA_H; else I2C_SDA_L; i2c_delay(); I2C_SCL_H; // 高电平期间数据稳定,从设备采样 i2c_delay(); I2C_SCL_L; // 拉低,准备下一位 } uint8_t i2c_read_bit(void) { uint8_t bit; I2C_SDA_H; // 释放 SDA,由从设备拉低/释放 i2c_delay(); I2C_SCL_H; i2c_delay(); bit = I2C_SDA_READ; I2C_SCL_L; i2c_delay(); return bit; }这段代码看起来简单,但它是整个 I2C 通信的原子操作。写字节就是循环 8 次写位,读字节就是循环 8 次读位并在第 9 个时钟主动控制 ACK/NACK。这些底层原语稳了,上层业务逻辑就稳了。
3.3 为什么理解 ACK/NACK 比记忆波形更重要
很多教程会给你画一个时序图,告诉你起始条件之后发送器件地址。但真正写代码时,你会在意的一个问题是:“我怎么知道 AT24C02 收到了我的地址?”
答案就是 ACK。在发送地址字节后,第 9 个时钟脉冲,主设备释放 SDA,从设备如果存在且地址匹配,就会拉低 SDA。这个 ACK 是硬件的“我在这里”信号。如果你发的地址没有设备应答,SDA 在第 9 个时钟高电平期间仍然保持高(NACK)。
我写软件模拟 I2C 时,会在每个字节发送后读一次 SDA 状态,作为应答检查。特别是写操作时,如果 ACK 没有出现,直接返回错误,而不是继续傻发。这样总线异常时你能第一时间知道。
4. AT24C02 读写全流程:从字节写到顺序读
AT24C02 的读写操作,本质上就是主设备用 I2C 总线的位流,按固定格式组织成几组“命令”。
4.1 器件寻址:7 位地址和 8 位地址的换算
AT24C02 的 7 位地址由固定部分1010和硬件引脚 A2/A1/A0 组成。当 A2=A1=A0=0 时,7 位地址是1010000b,即 0x50。在发送时,地址字节是 7 位地址左移一位,最低位是读写标志:
- 写:
0x50 << 1 | 0 = 0xA0 - 读:
0x50 << 1 | 1 = 0xA1
这一点必须烂熟于心。我在调试时见过很多人发送地址后从设备不应答,最后发现是地址字节错了一位——把 0x50 直接当成要发送的地址字节,那实际发送出去的是1010000 0再加后续位,自然对不上。正确写法是:地址字节 = (器件地址 << 1) | 方向位。
如果你的总线上还挂了其他 I2C 器件,比如 0x3C 的 OLED,那么 AT24C02 的 A2/A1/A0 就要通过外部引脚组合成避免冲突的地址。比如 A0=1,那 AT24C02 的地址就变成 0x51。别小看这个设置,总线挂多个设备时地址冲突是排查半天都发现不了的隐蔽问题。
4.2 写操作的三种模式:字节写、页写、写等待
AT24C02 写操作的标准流程:
- 发送起始条件。
- 发送器件写地址(0xA0),等待 ACK。
- 发送要写入的字节地址(8 位),等待 ACK。
- 发送要写入的数据字节,等待 ACK。
- 发送停止条件。
停止条件之后,芯片内部开始执行写操作。这个内部写周期(write cycle)典型时间是 5ms,期间芯片不会响应任何 I2C 事务。所以你写完一个字节后,如果想立刻写下一个,必须等待至少 5ms。等待的方法有两种:
- 固定延时 5ms~10ms,简单粗暴。
- 更优雅的做法是“轮询 ACK”:芯片在内部写周期结束后,才会重新参与 I2C 总线应答。你可以重复发送一个写地址(0xA0),如果得到 NACK,说明还在忙;得到 ACK,说明可以继续写。这个功能叫“写周期轮询”。
我推荐在设备掉电保存场景用第二种,既快又可靠。固定延时 5ms 太保守,而且如果芯片老化或温度低,可能 5ms 不够;轮询 ACK 则让主机自动等待芯片准备好,不会超时也不会过早发送。
页写则是一次写多个字节。AT24C02 以 8 字节为一页,页写时先发地址,再连续发多个数据字节,但不能超过 8 字节,且地址不能跨页。比如你从地址 0x06 开始写入 4 个字节(0x06、0x07、0x08、0x09),没问题;但如果你从 0x06 写入 4 个字节且数据长度超过页边界(0x06、0x07、0x08、0x09、0x0A),因为一页只有 8 字节(0x00~0x07 是第 0 页,0x08~0x0F 是第 1 页),写满了会回卷到页首,把 0x00、0x01 覆盖掉。这是 AT24C02 最经典的坑。
我写驱动的一般习惯是:写多字节时,先计算当前地址到页尾还剩多少字节,如果数据长度跨页,就分段写,每次不超过页内剩余字节数。代码里加一个边界判断,能省掉无数脑细胞。
4.3 读操作的三种模式:当前地址读、随机读、顺序读
读操作比写操作花样多一点。
- 当前地址读:直接发器件读地址(0xA1),不发内部地址,芯片会从当前内部地址指针指向的位置读一个字节。这个“当前地址”是上一次访问后自动递增留下的。这个功能适合快速轮询,但不常用来做正式读取。
- 随机读:标准做法是先发一个伪写操作(device address + 内部地址),然后重新发起始条件(注意是重复起始 START,而不是停止后再开始),再发器件读地址,然后读数据。也就是说,要先用写指令把内部地址指针指到你想读的位置,再切换为读模式。
- 顺序读:在随机读的基础上,连续读取多个字节。每读完一个字节,从设备会返回数据,主设备发送 ACK 表示“继续给下一个”,读最后一个字节时主设备发送 NACK,然后发停止条件。为什么最后一个字节不发 ACK?因为如果主设备发 ACK,从设备会认为你还想读,继续更新数据;发 NACK 才是告诉它“到此为止”。
随机读流程我列一下,方便对照代码:
- START
- 发送 0xA0(写地址),等 ACK
- 发送内部地址(例如 0x10),等 ACK
- 重新 START(重复起始,不是 STOP+START)
- 发送 0xA1(读地址),等 ACK
- 读取第一个字节,主设备发 ACK
- 读取第 N 个字节,最后一个字节主设备发 NACK
- STOP
这个重复起始条件,很多新手用软件模拟时容易写错:有人直接先 STOP 再 START,从设备的地址指针会丢失,导致读取数据不对。合法的重复起始应该是在不释放总线的情况下,再次在 SCL 高时把 SDA 拉低。上面代码里的i2c_start()可以直接复用,只要之前总线还处于忙状态(SCL 低、SDA 低),再次调用它即可。
4.4 完整驱动代码实现(含延时与应答处理)
下面贴一段我常用的软件模拟 I2C 配合 AT24C02 的驱动核心。为了简洁,这里用了标准外设库风格的 GPIO 操作,你如果用的是 HAL,思路完全一样,只是把寄存器操作换成HAL_GPIO_WritePin。
uint8_t i2c_write_byte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { i2c_write_bit((data & 0x80) != 0); data <<= 1; } // 第 9 个时钟:读取 ACK I2C_SDA_H; // 释放 SDA,让从设备拉低 I2C_SCL_H; i2c_delay(); uint8_t ack = (I2C_SDA_READ == 0); I2C_SCL_L; i2c_delay(); return ack; } uint8_t i2c_read_byte(uint8_t ack) { uint8_t i, data = 0; for (i = 0; i < 8; i++) { data = (data << 1) | i2c_read_bit(); } // 主机发送 ACK(0) 或 NACK(1) i2c_write_bit(ack ? 1 : 0); return data; } // AT24C02 写一字节 uint8_t at24c02_write_byte(uint8_t addr, uint8_t data) { i2c_start(); if (!i2c_write_byte(0xA0)) { i2c_stop(); return 1; } // 器件写地址 if (!i2c_write_byte(addr)) { i2c_stop(); return 2; } // 内部地址 if (!i2c_write_byte(data)) { i2c_stop(); return 3; } // 数据 i2c_stop(); // 等待内部写周期(轮询 ACK 方式) uint32_t timeout = 10000; while (timeout--) { i2c_start(); if (i2c_write_byte(0xA0)) { i2c_stop(); return 0; } i2c_stop(); delay_us(100); } return 4; // 超时 } // AT24C02 随机读一字节 uint8_t at24c02_read_byte(uint8_t addr, uint8_t *data) { i2c_start(); if (!i2c_write_byte(0xA0)) { i2c_stop(); return 1; } if (!i2c_write_byte(addr)) { i2c_stop(); return 2; } i2c_start(); // 重复起始条件 if (!i2c_write_byte(0xA1)) { i2c_stop(); return 3; } *data = i2c_read_byte(1); // 读一个字节,发 NACK i2c_stop(); return 0; } // AT24C02 页写,len <= 8,且不能跨页 uint8_t at24c02_write_page(uint8_t addr, uint8_t *buf, uint8_t len) { if (len == 0 || len > 8) return 5; if ((addr & 0x07) + len > 8) return 6; // 检查页边界 i2c_start(); if (!i2c_write_byte(0xA0)) { i2c_stop(); return 1; } if (!i2c_write_byte(addr)) { i2c_stop(); return 2; } for (uint8_t i = 0; i < len; i++) { if (!i2c_write_byte(buf[i])) { i2c_stop(); return 3; } } i2c_stop(); // 等待写周期 uint32_t timeout = 10000; while (timeout--) { i2c_start(); if (i2c_write_byte(0xA0)) { i2c_stop(); return 0; } i2c_stop(); delay_us(100); } return 4; }这段代码里的超时退出很重要。如果总线上根本没有 AT24C02(比如接线错误),i2c_start()后发送 0xA0 会一直得不到 ACK,如果代码里不判空直接继续发,会卡在某个循环里。每次操作都检查返回值,才是嵌入式代码该有的防御意识。
5. 实测踩坑:上电时序、写周期等待与总线锁死
代码写完,烧进去,串口打印一看——数据不对,或者程序卡死。以下这些坑我基本都踩过,而且都是非常典型的情况。
5.1 上电时序:为什么刚开机读到的全是 0xFF
如果你在系统上电后非常早地就去读 AT24C02,大概率会读到 0xFF 或者收不到 ACK。原因很简单:AT24C02 的内部上电复位需要时间,不同批次芯片大概在几百微秒到几毫秒之间;在这期间,芯片还没准备好响应 I2C 命令。
解决办法也很傻瓜:在初始化 I2C 之后,加一个 10ms 延时,再去访问 AT24C02。如果你在 STM32F103 的main()里,系统时钟配置完、GPIO 初始化完,delay_ms(10),然后才读 EEPROM,基本就不会出问题。同时,确保 3.3V 电源稳定后再让主设备开始通信。电源上升斜率太缓时,芯片工作不稳定,所以我在实验板上会并在电源引脚一个 100nF 和 10μF 电容。
5.2 写周期等待不能省:一次覆盖写入的惨痛教训
我最早写驱动时偷懒,连续写两个不连续的字节,代码是:
at24c02_write_byte(0x00, 0xAA); at24c02_write_byte(0x01, 0x55);然后读回来发现第二个地址还是 0xFF。查了半天,问题就出在第一个写操作结束后,内部写周期还没完成,立刻发起第二次写,芯片根本没响应。
后来我加了固定延时 5ms,发现偶尔还是不靠谱,就改成了上面的轮询 ACK 方式。实测下来,轮询 ACK 比固定延时的可靠性高很多,而且平均等待时间在 3ms 左右,比无脑 sleep 10ms 更快。如果你用的是硬件 I2C,注意在最后一个 STOP 后不要立刻发下一个 START,最好先判断总线状态。
5.3 总线锁死:SDA 被拉低,怎么都释放不了
这是 I2C 通信最让人头皮发麻的故障。现象是:程序正常运行,但某次通信后,SDA 引脚一直保持低电平,所有后续通信都失败。
原因多半是:某个时序错误导致从设备内部状态机停在输出低电平的状态,而主设备没意识到,继续操作总线。比如你发了起始条件之后,因为某段代码延时不对,导致 SCL 和 SDA 的电平组合不符合从设备预期,它就会卡住。
解决办法有三种:
- 复位从设备:给 AT24C02 断电再上电,或者把 WP 拉高再拉低(不一定有效)。最有效的当然是重新上电。
- 释放总线:I2C 标准里,如果主设备检测到 SDA 被异常拉低,可以连续切换 9 个 SCL 时钟脉冲,让从设备释放 SDA。这个“时钟恢复”技术我建议在驱动里写成一个小工具函数,手动调用。
- 从软件上避免:在每次发送起始条件前,先确保 SCL 和 SDA 都是高电平(空闲状态),再执行起始条件。如果你在代码里严格遵循这个检查,总线锁死的概率会小很多。
我在一个产品里遇到过一次,是硬件 I2C 外设的BUSY标志被置位,导致后续所有I2C_GenerateSTART()都失败。解决办法是在每次通信前检查I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY),如果忙就软件复位外设(I2C_DeInit再重新初始化)。软件模拟 I2C 就没有这个外设状态问题,因为总线上的电平完全由你的代码驱动,只要代码逻辑正确,理论上不会出现莫名其妙锁死。
5.4 示波器波形的读取技巧
我在调 I2C 时最依赖的逻辑分析仪,通常抓三根线:SCL、SDA、还有 STM32 的一个 GPIO 翻转作为代码执行标记。比如在发送起始条件前置高一个测试引脚,停止条件后拉低,这样逻辑分析仪上能清楚看到“代码走到了哪一步,总线发生了哪些电平变化”。
波形上你主要看这么几个信息:
- START 条件:SCL 高时,SDA 有下降沿。
- STOP 条件:SCL 高时,SDA 有上升沿。
- 地址字节后的第 9 个时钟:SDA 是低(ACK)还是高(NACK)。
- 每个字节 8 位数据的位序:如果数据波形完全反了,可能是代码里移位方向错了。
- 时钟频率:两个上升沿之间的时间是否太短,正常 100kHz 模式下 SCL 周期 10μs。
我第一次看到自己的 I2C 波形,发现 SCL 高电平期间 SDA 有跳变,说明代码里没有严格保持“SCL 高时 SDA 稳定”的规则。后来在写位函数里加了更长的延时,波形才干净。如果时序有问题,优先加延时而不要减延时,尤其是从设备对最小低电平时间有要求时。
6. 进一步优化:从驱动到应用层
读通 AT24C02 之后,你会发现 I2C 的基本功已经具备了。但真正做项目,还需要考虑驱动如何组织、如何给上层提供稳定的接口。
6.1 用 HAL 库还是寄存器操作?
标准外设库(SPL)在 F103 上已经算是官方钦点了,很多老工程都在用。HAL 库则更抽象,CubeMX 生成后代码量大,但胜在可读性好、跨芯片移植方便。我的建议:
- 学习协议阶段:用寄存器或标准库,至少你要知道 GPIO 的 CRL/CRH 配置、BSRR/BRR 的操作方式,这对理解 MCU 底层很有帮助。
- 做产品快原型:直接用 CubeMX + HAL,把 I2C 初始化交给工具,你只需要关心数据收发逻辑。HAL 库对软件模拟不友好,好在 AT24C02 用硬件 I2C 也能调通,只要处理好几处事件等待超时就好。
如果你坚持用硬件 I2C,请记住 HAL 里的两个常见坑:
HAL_I2C_Master_Transmit()返回HAL_BUSY时,多半是因为上次传输没完成就再次启动。解决办法是确保每次调用的地址和数据缓冲区生命周期满足要求,并检查返回值。- F103 的硬件 I2C 在总线忙标志置位时,向
I2C_CR1写SWRST复位外设即可清除,但注意不要把整个外设配置也清零了。
6.2 驱动抽象成可复用模块的思路
写 EEPROM 驱动时,不要把所有代码堆在main.c里。我至少会分三层:
- 底层:
i2c_hal.c/.h,只负责起始、停止、读写字节、读 ACK。这一层与具体芯片无关,可移植。 - 设备层:
at24c02.c/.h,调用底层接口,实现页写、随机读、写周期等待、地址边界检查。这一层与具体总线无关。 - 应用层:
param_store.c/.h,定义数据结构,比如一个结构体DeviceConfig,包含设备号、校准值、报警开关等字段,提供param_save()和param_load()接口。这一层直接面对业务逻辑。
这样的好处是:如果你以后换成了 AT24C04、AT24C128,只需要修改设备层“页大小”“地址字节数”等参数;如果你换成了 W25Q32 SPI Flash,只需要把设备层换成 SPI Flash 驱动,上面应用层代码可以不动。
6.3 应用实例:用 AT24C02 做掉电参数保存
我现在的一个小项目,每次上电要做这几件事:
- 初始化 I2C 后延时 20ms。
- 从 AT24C02 地址 0x00 读 32 字节配置,校验和放在末尾。
- 如果校验和通过,加载配置;如果失败,用默认配置并写回 EEPROM。
- 运行中用户修改参数后,调用
param_save(),把整个 32 字节配置一次性写入(分 4 次页写,每次 8 字节)。 - 写完后轮询 ACK 确认,再回读校验一次。
这个流程实际用下来非常稳。唯一的额外建议是:不要在写 EEPROM 的时候让系统断电。如果需要断电保存,可以在主电源检测到掉电时,利用大电容储能,通过 MCU 的掉电中断触发快速写操作。AT24C02 写一页的时间在 5ms 内,掉电检测通常能在几十毫秒内完成应急写入,来得及。
另外,跨页写的问题在保存大参数块时非常关键。我封装了一个at24c02_write_buffer(addr, buf, len),内部循环判断是否跨页,跨页就拆分成多次页写。代码逻辑大概是:
while (len > 0) { uint8_t page_left = 8 - (addr & 0x07); uint8_t chunk = (len < page_left) ? len : page_left; if (at24c02_write_page(addr, buf, chunk) != 0) return error; addr += chunk; buf += chunk; len -= chunk; // 等待写周期由 write_page 内部完成 }这个函数是 AT24C02 驱动的“高级接口”,业务侧用起来可以完全无视页的概念。
7. 最后一个容易被忽略的点:多设备共总线时的地址与时序问题
很多项目里,AT24C02 会和 OLED(SSD1306)、BMP280、SHT30 等共用一条 I2C 总线。这时候除了地址不能冲突之外,还有个问题:不同器件的最高通信速率不一样。
SSD1306 OLED 默认支持 100kHz~400kHz,BMP280 支持到 3.4MHz 高速模式,但 AT24C02 标准模式只有 100kHz,如果总线速率跑到了 400kHz,AT24C02 可能响应异常。稳妥做法是把 I2C 时钟配置成 100kHz,让所有设备都在舒适区工作。STM32F103 的 APB1 时钟是 36MHz,配置 I2C 时钟寄存器CCR为 0x28(对应 100kHz)和上升时间寄存器TRISE=0x23即可。
硬件 I2C 的使用者还要注意:当总线上多个设备时,某个设备处于“总线忙”状态可能会导致整个总线的起始条件失败。我在代码里每次通信前查一下I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY),如果长时间忙,就复位 I2C 外设。这个操作看似粗暴,但能避免很多卡死。
最后说说我自己的习惯:每次拿到一个新的 I2C 器件,我都会先在逻辑分析仪上录一段它上电后主动输出/响应查询的波形,确认它的地址、数据格式和行为。AT24C02 没什么主动行为,但读一次数据,把波形和手册时序图对照一遍,等于给自己做了一次“协议体检”。这种习惯救了我很多次,因为很多问题不是出在你的代码上,而是出在硬件连接或器件本身。
如果你现在正卡在 STM32F103 的 I2C 上,听我一句劝:别急着上硬件外设,先用软件模拟裸奔一遍,把时序打扎实;再回头用硬件 I2C,你会觉得那些“凭空消失的 ACK”“莫名卡死的 BUSY”都有了解释。AT24C02 这颗小芯片,就是最好的陪练。