STM32 I2C实战:AT24C02 EEPROM驱动从入门到精通 1. 为什么AT24C02是I2C入门的最佳陪练搞STM32的人几乎都绕不开I2C而AT24C02这颗2Kbit的EEPROM芯片基本就是I2C实战的标准靶子。原因很简单它协议规整、时序清晰、价格便宜、资料遍地而且容量小到你可以用逻辑分析仪把整个读写过程完整抓下来慢慢分析。我见过太多人学I2C的方式是背时序图SCL上升沿采样、SDA在SCL高电平期间必须稳定——背得滚瓜烂熟一上板子就抓瞎。问题出在哪因为I2C的坑从来不在知不知道时序而在起始条件到底怎么发、ACK到底谁来拉、写完一个字节为什么要等5ms这些工程细节上。AT24C02的读写流程恰好把这些细节全部暴露出来。它不像OLED那样有现成驱动库帮你封装好也不像某些传感器那样时序容错度高。你得老老实实处理起始信号、设备地址、字地址、数据、应答位、停止信号这一整套动作还得面对页写边界和写周期等待这两个经典问题。把这颗芯片吃透后面换任何I2C器件都是换汤不换药。这篇文章面向的是已经能点灯、能跑通串口、手里有STM32F103最小系统板的朋友。我会从硬件连接讲到寄存器配置从单字节读写讲到页写优化把每一步为什么这么做讲清楚。你不需要有I2C基础但需要能看懂C语言和基本的GPIO操作。读完之后你应该能独立写出稳定可靠的AT24C02驱动并且知道出问题时该往哪个方向排查。2. 硬件层上拉电阻、地址引脚与那颗容易被忽略的WP2.1 开漏输出与上拉电阻的真实作用I2C总线是开漏输出结构这意味着SCL和SDA两条线上主机和从机都只能把线拉低不能主动拉高。线要变高靠的是上拉电阻。很多人第一次画I2C电路时会问既然STM32的GPIO可以配置成推挽输出为什么不能直接推挽驱动答案是——如果两个设备同时推挽输出一个输出高一个输出低直接短路芯片烧给你看。开漏结构保证了线与逻辑只要有一个设备拉低线就是低所有设备都释放线才被上拉电阻拉高。上拉电阻的取值是个需要权衡的事。典型值4.7kΩ但这个值不是随便定的。电阻越小上升沿越陡高速通信越稳但功耗越大低电平时的灌电流也越大。电阻越大功耗小但上升沿变缓高速时可能还没到高电平阈值就被下一个下降沿打断了。标准模式100kHz下4.7k到10k都能用快速模式400kHz建议用2.2k到4.7k。我实测下来STM32F103配AT24C02跑100kHz4.7k是最省心的选择。注意有些开发板已经在SCL和SDA上焊了上拉电阻你外接AT24C02模块时如果模块上也有上拉两个电阻并联会导致阻值变小。并联后如果低于1k灌电流可能超过GPIO的承受能力。接之前先用万用表量一下。2.2 A0/A1/A2地址引脚一条总线挂多片的钥匙AT24C02的7位设备地址是1010开头后面三位由A2、A1、A0引脚决定。这三个引脚各自接GND或VCC就能得到8个不同的地址。也就是说一条I2C总线上最多挂8片AT24C02总容量16Kbit。这个设计在多参数存储场景下很实用比如你要分别存校准参数、用户配置、运行日志与其在一片里划分地址空间不如直接挂多片逻辑更清晰。实际接线时A0/A1/A2不要悬空。悬空的引脚电平不确定可能被干扰导致地址跳变出现有时候能读写有时候不能的玄学问题。老老实实接GND或VCC。如果只挂一片三个脚全接GND设备地址就是1010000即0x50。2.3 WP写保护引脚调试阶段的头号嫌疑人WP是Write Protect高电平时芯片进入写保护状态只能读不能写。这个引脚在调试阶段坑过无数人——现象是读数据一切正常写数据死活写不进去代码翻来覆去看不出问题最后发现WP接了VCC。我的建议是调试阶段WP直接接GND确保可写。产品化阶段如果确实需要写保护功能再用GPIO控制。另外要注意WP的状态切换需要在写操作之前完成写操作进行中切换WP可能导致数据损坏。2.4 供电与去耦AT24C02工作电压范围1.8V到5.5VSTM32F103是3.3V系统直接3.3V供电即可。VCC和GND之间并一颗0.1μF的陶瓷电容做去耦位置尽量靠近芯片电源引脚。这颗电容不是可选项EEPROM在写周期内电流会有波动没有去耦电容可能导致写失败或者数据出错。3. 用寄存器还是HAL库先看清I2C外设的工作方式3.1 STM32F103的I2C外设到底做了什么STM32F103的I2C外设是一个硬件状态机它帮你处理了起始条件生成、地址发送、ACK检测、数据移位这些底层动作。你只需要配置好时钟频率、使能外设然后往数据寄存器写数据、从数据寄存器读数据硬件会自动完成时序。这比软件模拟I2C省CPU但代价是状态机逻辑复杂一旦卡在某个状态排查起来比软件模拟麻烦。硬件I2C最让人头疼的是总线锁死问题。如果通信过程中主机复位而从机没有复位从机可能一直拉着SDA不放导致总线死锁。STM32F103的I2C外设在这种情况下可能卡在BUSY标志上出不来。解决办法是在初始化时检测BUSY标志如果异常就手动切换SCL/SDA为GPIO发送9个时钟脉冲把从机唤醒再重新配置I2C。3.2 软件模拟I2C的适用场景软件模拟I2C就是用两个普通GPIO通过精确控制电平翻转时间来模拟I2C时序。它的优势是引脚灵活任意两个GPIO都行、调试直观逻辑分析仪上看得清清楚楚、不会死锁因为每次操作都是你主动发起的。劣势是占用CPU时间高速通信时时序精度受中断影响。我的经验是学习阶段用软件模拟理解时序本质产品阶段用硬件I2C节省CPU资源。如果你用的是STM32F103C8T6这种资源紧张的芯片硬件I2C还能省出两个GPIO。但如果你遇到硬件I2C死活调不通的情况别死磕换软件模拟先跑通再回头分析硬件问题。3.3 时钟配置的计算过程I2C的时钟频率由I2C_CCR寄存器决定。以标准模式100kHz、APB1时钟36MHz为例标准模式下CCR APB1时钟 / (2 × 目标频率) 36,000,000 / (2 × 100,000) 180快速模式占空比Tlow/Thigh2时CCR APB1时钟 / (3 × 目标频率) 36,000,000 / (3 × 400,000) 30还要配置I2C_TRISE寄存器它决定SCL上升沿的最大允许时间。标准模式下的计算公式是TRISE (最大上升时间 / APB1周期) 1。100kHz时最大上升时间1000nsAPB1周期27.8nsTRISE 1000/27.8 1 ≈ 37。这些计算在HAL库的HAL_I2C_Init()里会自动完成但你必须知道背后的逻辑否则时钟不对时你连从哪查都不知道。4. 单字节读写把时序图翻译成可执行的代码4.1 写一个字节的完整动作分解AT24C02的单字节写操作拆开来看是这么一串动作主机发送起始条件SCL高电平时SDA从高变低主机发送设备地址写方向7位地址1010000第8位是0表示写合起来就是10100000即0xA0从机拉低SDA表示ACK主机发送字地址你要写入EEPROM内部哪个位置0x00到0xFF从机拉低SDA表示ACK主机发送数据字节从机拉低SDA表示ACK主机发送停止条件SCL高电平时SDA从低变高从机进入内部写周期此时不响应任何操作直到写完成第9步是新手最容易忽略的。AT24C02的写周期典型值5ms最大5ms。在这5ms内如果你发起新的读写请求芯片不会应答。所以每次写操作之后必须延时等待或者用应答查询的方式轮询直到芯片响应。// 软件模拟I2C写一个字节到AT24C02 void AT24C02_WriteByte(uint8_t wordAddr, uint8_t data) { I2C_Start(); I2C_SendByte(0xA0); // 设备地址写 I2C_WaitAck(); I2C_SendByte(wordAddr); // 字地址 I2C_WaitAck(); I2C_SendByte(data); // 数据 I2C_WaitAck(); I2C_Stop(); Delay_ms(5); // 等待写周期完成 }4.2 读一个字节为什么需要两次起始条件读操作比写操作多一个步骤。你不能直接发读地址然后收数据因为AT24C02需要你先告诉它我要读哪个地址。所以流程是发送起始条件发送设备地址写方向0xA0等待ACK发送要读的字地址等待ACK再次发送起始条件这叫重复起始条件Restart发送设备地址读方向0xA1等待ACK读取数据字节主机发送NACK告诉从机我读完了别再发了发送停止条件第10步的NACK很关键。主机在接收最后一个字节后必须发NACK否则从机会继续输出下一个地址的数据导致总线状态混乱。很多人的读函数能读出数据但后续操作异常就是漏了这个NACK。uint8_t AT24C02_ReadByte(uint8_t wordAddr) { uint8_t data; I2C_Start(); I2C_SendByte(0xA0); // 写方向为了发送字地址 I2C_WaitAck(); I2C_SendByte(wordAddr); // 字地址 I2C_WaitAck(); I2C_Start(); // 重复起始条件 I2C_SendByte(0xA1); // 读方向 I2C_WaitAck(); data I2C_ReadByte(); // 读取数据 I2C_SendNack(); // 主机发NACK I2C_Stop(); return data; }4.3 应答查询比死等5ms更聪明的做法固定延时5ms简单粗暴但效率低。如果你连续写多个字节每个都等5ms写满256字节就要1.28秒。应答查询的思路是发完停止条件后立即尝试发送起始条件设备地址写方向如果从机返回ACK说明写周期结束如果返回NACK说明还在忙继续等。void AT24C02_WaitStandby(void) { do { I2C_Start(); I2C_SendByte(0xA0); } while (I2C_WaitAck() ! 0); // 直到收到ACK I2C_Stop(); }实测下来单字节写的实际等待时间通常在3ms左右比固定5ms快不少。但要注意应答查询本身也有开销如果只写一两个字节固定延时反而更简单。5. 页写与跨页问题一次写8个字节的正确姿势5.1 页写的边界在哪里AT24C02内部按8字节一页组织共32页。页写模式下你可以一次性发送最多8个字节芯片内部地址会自动递增。但这里有个硬性限制写入不能跨越页边界。如果你从地址0x07开始写8个字节写到0x08时地址会回卷到0x00覆盖掉原来的数据。这个回卷行为是硬件设计决定的不是bug。所以页写函数必须处理跨页情况要么在跨页时拆分成两次写操作要么在调用层保证不跨页。void AT24C02_WritePage(uint8_t wordAddr, uint8_t *buf, uint8_t len) { uint8_t pageRemain 8 - (wordAddr % 8); // 当前页剩余空间 if (len pageRemain) { // 跨页先写当前页剩余部分 AT24C02_WritePage(wordAddr, buf, pageRemain); // 再写剩余部分到下一页 AT24C02_WritePage(wordAddr pageRemain, buf pageRemain, len - pageRemain); return; } // 同页内写入 I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(wordAddr); I2C_WaitAck(); for (uint8_t i 0; i len; i) { I2C_SendByte(buf[i]); I2C_WaitAck(); } I2C_Stop(); Delay_ms(5); }5.2 页写为什么比单字节写快单字节写每次都要经历完整的起始-地址-数据-停止流程加上5ms写周期写8个字节需要40ms以上。页写把8个字节打包在一次传输里只需要一个写周期总时间降到5ms左右。写满256字节单字节写需要1.28秒页写只需要160ms左右差了8倍。但页写也有代价如果传输过程中出错整页数据都可能受影响。所以页写适合批量初始化数据的场景单字节写适合频繁修改少量数据的场景。5.3 连续读地址自动递增的便利读操作没有页的概念你可以从任意地址开始连续读任意长度。AT24C02内部地址会自动递增读到0xFF后回卷到0x00。连续读的流程是发送字地址后重复起始条件发送读地址然后连续读取N个字节每读一个字节主机发ACK表示还要继续读最后一个字节发NACK表示读完。void AT24C02_ReadBuffer(uint8_t wordAddr, uint8_t *buf, uint16_t len) { I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(wordAddr); I2C_WaitAck(); I2C_Start(); I2C_SendByte(0xA1); I2C_WaitAck(); for (uint16_t i 0; i len; i) { buf[i] I2C_ReadByte(); if (i len - 1) I2C_SendAck(); // 还要继续读 else I2C_SendNack(); // 最后一个字节 } I2C_Stop(); }6. 那些让我熬夜的坑从现象到根因的排查链路6.1 读出来全是0xFF先查硬件再查代码现象读AT24C02任何地址都返回0xFF。0xFF意味着SDA线一直是高电平主机读到的全是1。排查链路量电压用万用表测SDA和SCL对地电压。正常空闲状态应该是3.3V。如果SDA是0V说明被某个设备拉死了。查上拉电阻如果SDA电压在1V左右徘徊可能是上拉电阻太大或者没焊。查设备地址用逻辑分析仪抓波形看主机发出的地址字节是不是0xA0。如果地址错了从机不会应答SDA保持高电平读回来就是0xFF。查WP引脚如果写不进去但读出来是0xFF先确认WP是不是接了高电平。查供电AT24C02的VCC如果没接或者虚焊芯片不工作SDA自然全是高。我遇到过一次最隐蔽的PCB上AT24C02的A0引脚走线经过一个过孔过孔没盖油和旁边的GND铺铜有微小短路。万用表量不出来但芯片地址变成了0x50和0x51之间跳变时好时坏。后来用热风枪吹下芯片发现焊盘下面有锡珠。6.2 写进去读出来不对写周期没等够现象写一个字节后立即读读出来的还是旧数据。延时10ms后再读数据正确。根因AT24C02的写周期最大5ms但这是在室温、标称电压下的值。如果你的供电电压偏低比如3.0V或者环境温度较低写周期可能延长到6ms甚至更长。固定延时5ms不够。解决方案改用应答查询或者把延时加到10ms。应答查询更可靠因为它不依赖固定的时间假设。6.3 连续写多个字节时后面的数据丢失现象写8个字节只有前几个写进去了后面的没写进去。根因跨页了。比如从地址0x06开始写8个字节写到0x08时地址回卷到0x00把前面的数据覆盖了。或者你的页写函数没有处理跨页一次性发了8个字节芯片只接受了当前页内的部分。解决方案在页写函数里判断wordAddr % 8 len 8如果跨页就拆分。或者更简单粗暴每次写之前确保起始地址是8的倍数。6.4 硬件I2C卡在BUSY标志现象程序运行一段时间后I2C通信全部失败调试发现I2C_SR2的BUSY位一直是1。根因通信过程中发生了异常比如从机被复位、总线被干扰导致状态机卡死。STM32F103的I2C外设在这种情况下不会自动恢复。解决方案在I2C初始化函数里加一段总线恢复逻辑void I2C_BusRecovery(void) { GPIO_InitTypeDef gpio; // 切换SCL和SDA为普通GPIO gpio.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_Out_OD; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, gpio); // 发送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); // 重新配置为I2C复用功能 // ... }这段代码的原理是如果从机因为收到不完整的数据而拉着SDA不放主机发送9个时钟脉冲可以让从机把剩余的数据位发完然后释放SDA。最后手动发一个停止条件让总线回到空闲状态。6.5 数据偶尔出错电源噪声在作怪现象大部分时候读写正常偶尔读出来的数据错一两个位。根因电源噪声。AT24C02在写周期内电流会突然增大如果去耦电容容量不够或者位置太远VCC上会出现电压跌落导致内部电荷泵工作异常写入的数据出错。解决方案VCC和GND之间并一颗0.1μF陶瓷电容再并一颗1μF钽电容。电容尽量靠近芯片引脚走线尽量短。如果条件允许在I2C总线上串联33Ω电阻可以抑制振铃。7. 从能用到好用驱动分层与错误处理7.1 把I2C底层和EEPROM逻辑分开很多人的驱动代码是一坨I2C起始、发送地址、读写数据、延时全混在一起。这样写能跑但换一个I2C器件就要重写一遍。更好的做法是分两层I2C底层只负责起始、停止、发送字节、接收字节、等待ACK。这一层不关心从机是什么。AT24C02驱动层调用I2C底层实现字节读写、页写、连续读。这一层不关心I2C是用硬件还是软件模拟。这样分层之后你把软件模拟换成硬件I2C只需要改底层上层代码一行不动。换一颗AT24C16也只需要改驱动层的地址和页大小。7.2 错误处理不要假装I2C不会出错I2C通信是可能失败的从机没应答、总线被拉死、数据校验错误。如果你的驱动函数返回void调用者根本不知道操作有没有成功。建议每个操作返回一个状态码typedef enum { EEPROM_OK 0, EEPROM_ERR_ACK, EEPROM_ERR_TIMEOUT, EEPROM_ERR_VERIFY } EEPROM_Status; EEPROM_Status AT24C02_WriteByte(uint8_t addr, uint8_t data) { // ... 写操作 ... if (I2C_WaitAck() ! 0) return EEPROM_ERR_ACK; // ... 等待写周期 ... // 回读验证 if (AT24C02_ReadByte(addr) ! data) return EEPROM_ERR_VERIFY; return EEPROM_OK; }回读验证会增加一次读操作的时间但对于关键数据比如校准参数是值得的。如果只是存日志可以省掉验证。7.3 数据校验CRC还是简单校验和EEPROM存储的数据可能因为各种原因损坏写周期中掉电、宇宙射线别笑高可靠性场景确实要考虑、芯片老化。对于重要数据建议加校验。最简单的方案是每个数据块后面跟一个字节的校验和uint8_t checksum(uint8_t *buf, uint16_t len) { uint8_t sum 0; for (uint16_t i 0; i len; i) sum buf[i]; return sum; }校验和只能发现单字节错误如果要更强的检错能力用CRC8。CRC8的计算稍微复杂一点但对于STM32F103来说完全不是负担。7.4 磨损均衡EEPROM的寿命问题AT24C02的擦写寿命是100万次。如果你每秒写一次同一个地址大约11天就会写坏那个字节。对于频繁写入的场景比如记录运行时间需要做磨损均衡不要每次都写同一个地址而是在一个区域内轮流写写满一圈再从头覆盖。一个简单的实现是分配16个字节作为日志区每个字节存一个计数每次写入时找到第一个0xFF的位置写入写满后擦除整页重新开始。这样每个字节的擦写次数被均摊寿命延长16倍。8. 用逻辑分析仪看波形调试I2C的终极手段8.1 抓包配置要点逻辑分析仪是调试I2C的利器。以常见的8通道24MHz采样率分析仪为例配置时注意采样率至少是I2C时钟频率的10倍。100kHz的I2C1MHz采样率就够但建议设到4MHz以上这样上升沿和下降沿的细节看得更清楚。触发条件设置SDA下降沿触发起始条件这样每次通信都能抓到。解码器大多数逻辑分析仪软件都自带I2C解码器配置好SCL和SDA通道它会自动把波形翻译成地址、数据、ACK/NACK。8.2 从波形里能看出什么抓一次单字节写的波形你应该能看到起始条件SCL高SDA下降地址字节0xA08个时钟脉冲第9个脉冲时SDA被从机拉低ACK字地址8个时钟脉冲第9个脉冲ACK数据字节8个时钟脉冲第9个脉冲ACK停止条件SCL高SDA上升如果第9个脉冲时SDA保持高电平说明从机没有应答。这时候要检查设备地址对不对、从机供电是否正常、WP引脚状态、上拉电阻是否合适。8.3 一个真实案例上升沿太缓导致数据错位有一次我抓波形发现SCL的上升沿非常缓从0.3VCC到0.7VCC用了将近2μs。在100kHz下SCL高电平时间只有5μs上升沿占了2μs留给数据稳定的时间只有3μs。虽然勉强能工作但偶尔会出错。根因是上拉电阻用了10k加上总线电容较大走线长、挂了多个器件RC时间常数太大。换成2.2k后上升沿降到0.5μs以内通信立刻稳定。这个案例说明上拉电阻的取值不是拍脑袋定的要根据总线电容和通信速率算。总线电容的估算方法是每个器件引脚约10pF每厘米走线约1pF。如果总电容是100pF上拉电阻2.2k上升时间约2.2k×100pF220ns远小于100kHz的5μs高电平时间安全。9. 从AT24C02延伸到其他I2C器件把AT24C02吃透之后你会发现其他I2C器件都是类似的套路。比如OLED屏它的通信流程也是起始-地址-命令/数据-停止只不过多了命令和数据的区分通过控制字节的Co位和D/C位。再比如温度传感器LM75它的寄存器地址是8位的读流程和AT24C02几乎一样。区别主要在于特性AT24C02OLED (SSD1306)LM75设备地址0x500x78/0x7A0x48-0x4F写周期5ms无无页边界8字节无无特殊引脚WP无OS/INT数据方向读写对称只写读写掌握了AT24C02的驱动框架换其他器件时只需要改设备地址、去掉写周期等待、调整数据格式。I2C底层的起始、停止、ACK处理完全复用。我在实际项目里通常会把I2C底层封装成一个结构体包含函数指针typedef struct { void (*start)(void); void (*stop)(void); uint8_t (*sendByte)(uint8_t); uint8_t (*readByte)(void); void (*sendAck)(void); void (*sendNack)(void); } I2C_Interface; I2C_Interface i2c_hw { I2C_HW_Start, I2C_HW_Stop, ... }; I2C_Interface i2c_sw { I2C_SW_Start, I2C_SW_Stop, ... };这样在初始化时选择用硬件还是软件上层驱动代码完全不用改。这个模式在需要兼容多种硬件的项目里特别有用。最后分享一个我踩过的坑AT24C02的写周期等待如果你用RTOS不要在写操作里用Delay_ms(5)这种阻塞延时它会把整个任务挂起。更好的做法是用应答查询或者把写操作放到一个低优先级任务里用信号量同步。我在一个FreeRTOS项目里最初用了阻塞延时结果写EEPROM时触摸屏响应卡顿后来改成应答查询加非阻塞状态机才解决。