ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103硬件I2C实战:AT24C02读写全流程与避坑指南

STM32F103硬件I2C实战:AT24C02读写全流程与避坑指南 1. 为什么AT24C02是I2C入门的最佳陪练搞STM32的人几乎都绕不开I2C而AT24C02这颗2Kbit的EEPROM基本就是I2C实战的标准靶子。原因很实在它便宜、好买、时序规整、手册清晰而且掉电不丢数据这个特性让它在参数存储、设备序列号、校准值保存这些场景里一直有活干。你拿它练手练的不是一颗芯片而是整套I2C主机控制的通用能力——起始、停止、应答、时序、地址、页写、轮询全都能在这颗小芯片上跑一遍。STM32F103的硬件I2C外设江湖上名声比较复杂。有人说它稳有人说它容易卡死。我的实际体会是F103的硬件I2C能用但你必须理解它的状态机逻辑不能像用SPI那样发完就走。很多新手一上来就调库结果总线一挂就懵了根本不知道卡在哪一步。所以这篇我打算把AT24C02的读写全流程拆开讲从硬件连接到寄存器操作再到HAL库和寄存器两种写法最后把常见的坑一个个摆出来。这篇文章适合谁看如果你刚搭好STM32F103最小系统想找个外设练手AT24C02是很好的起点如果你已经会点灯、会串口打印但I2C总是调不通那这篇能帮你把时序和状态机彻底理清如果你在用HAL库但只会调现成函数不知道底层发生了什么我也会把寄存器层面的东西补上。读完你应该能做到不看例程自己从零把AT24C02读写跑通并且知道每一步为什么这么写。先说清楚一个前提AT24C02是2Kbit 256字节的容量按页组织每页8字节。这个页的概念非常关键后面讲页写的时候会反复用到。地址空间是0x00到0xFF一个字节的地址就能覆盖所以它的寻址比大容量EEPROM简单一档。2. 硬件连接与上拉电阻那些被忽略的细节2.1 引脚连接与地址配置AT24C02是8脚封装核心引脚就几个SDA、SCL、WP、A0/A1/A2加上VCC和GND。SDA和SCL接STM32F103的I2C引脚比如PB6SCL和PB7SDA这是I2C1的默认复用引脚。A0、A1、A2三个引脚决定器件地址的低3位全部接地时7位器件地址是1010000也就是0x50。写操作时地址字节是0xA0读操作是0xA1——注意这里说的是地址字节包含了读写位。WP是写保护引脚接高电平时芯片进入只读模式接低电平才能写。调试阶段我建议直接接地等确认读写都正常了再考虑要不要用GPIO控制它做写保护。很多人调不通写操作最后发现是WP悬空或者被拉高了这种低级错误其实很常见。2.2 上拉电阻到底该选多大I2C是开漏总线SDA和SCL都必须接上拉电阻这一点没有商量余地。STM32F103的I2C引脚要配置成开漏复用输出AF_OD不能配成推挽。为什么因为I2C允许多个设备共享总线任何设备都只能把线拉低不能主动拉高拉高靠的是上拉电阻。如果配成推挽两个设备一个想拉高一个想拉低直接短路芯片都可能烧。上拉电阻的取值是个权衡。典型值是4.7kΩ但这个值不是随便定的。电阻越小上升沿越陡能跑的速度越高但静态电流越大电阻越大功耗越低但上升沿变缓高速时可能来不及拉高就被采样了。粗略估算总线电容一般在100pF到200pF量级上升时间τ≈R×C。取R4.7kΩ、C100pFτ≈0.47μs标准模式100kHz下完全够用。如果你跑400kHz快速模式或者总线上挂了很多设备导致电容增大就得把电阻降到2.2kΩ甚至1.8kΩ。提示如果你用杜邦线在面包板上搭线长和接触电阻会让波形很难看。调试I2C时示波器或者逻辑分析仪几乎是必备的光靠能不能读到数据来判断效率太低。2.3 电平匹配问题STM32F103是3.3V供电AT24C02一般也是3.3V或5V都能工作。如果两者都接3.3V直接连就行不需要电平转换。但如果你用的是5V的AT24C02模块而STM32是3.3V那就要注意了AT24C02的SDA/SCL在5V供电时高电平接近5V直接接STM32的3.3V引脚可能超出其耐压范围。这种情况下要么把AT24C02也降到3.3V供电要么加电平转换电路。我个人的建议是统一用3.3V省事又安全。3. I2C时序的本质把状态机刻进脑子里3.1 起始、停止与应答的物理含义I2C的时序图看着复杂其实核心就三件事起始条件、数据传输、停止条件。起始条件是SCL为高时SDA从高变低停止条件是SCL为高时SDA从低变高。这两个条件必须由主机产生而且SDA的变化必须发生在SCL为低的时候否则会被误判成起始或停止。应答ACK机制是I2C可靠性的关键。每传输8位数据后接收方要把SDA拉低一个时钟周期表示我收到了。如果接收方不拉低NACK发送方就知道出问题了。AT24C02在写操作时会应答但在写周期内内部擦写不会响应任何命令这时候主机发地址会收到NACK这就是为什么写完之后要轮询等待。3.2 AT24C02的写周期与应答轮询AT24C02有个写周期时间tWR典型值5ms最大可能到10ms。在这段时间内芯片内部在把数据真正写进存储单元它不会响应总线上的任何命令。如果你写完立刻读大概率读到的是旧数据或者直接NACK。正确的做法是应答轮询Acknowledge Polling写完一页后主机反复发送起始条件加器件地址写方向如果收到ACK说明芯片忙完了如果收到NACK就继续等。这比死等5ms要高效得多尤其在连续写多页的时候。我见过不少人直接delay(10)了事能用但不够优雅而且如果芯片实际需要更长时间delay就不够了。3.3 页写与跨页问题AT24C02每页8字节。页写的意思是你给一个起始地址然后连续写最多8个字节芯片内部地址会自动递增但递增到页边界后会回卷到本页开头而不是进入下一页。这是个非常容易踩的坑。举个例子从地址0x07开始写4个字节。0x07是第0页的最后一个字节写完0x07后地址会回卷到0x00接下来的数据会覆盖0x00、0x01、0x02。你以为写到了0x08、0x09、0x0A实际上把前面的数据覆盖了。所以写跨页数据时必须手动分页每页单独发起一次写操作。操作类型最大连续字节地址行为注意事项字节写1指定地址最简单每次都要完整时序页写8页内递增到边界回卷必须按页对齐或手动分页连续读无限制全地址空间递增到0xFF回卷到0x00读完发NACK停止4. 寄存器级驱动从零手写I2C通信4.1 GPIO与I2C外设初始化先看寄存器层面的配置。以I2C1、PB6/PB7为例步骤是开GPIOB和I2C1的时钟配置PB6/PB7为复用开漏输出然后配置I2C的时钟控制寄存器。// 使能时钟 RCC-APB2ENR | RCC_APB2ENR_IOPBEN; RCC-APB1ENR | RCC_APB1ENR_I2C1EN; // PB6(SCL) PB7(SDA) 复用开漏 50MHz GPIOB-CRL ~(0xFF 24); GPIOB-CRL | (0xEE 24); // 复用开漏最大速度50MHz // I2C时钟PCLK136MHz目标100kHz // CCR 36MHz / (2 * 100kHz) 180 I2C1-CR2 36; // 输入时钟频率36MHz I2C1-CCR 180; // 标准模式 I2C1-TRISE 37; // 最大上升时间 361 I2C1-CR1 | I2C_CR1_PE; // 使能I2C这里CCR的计算要解释一下标准模式下CCR PCLK1 / (2 × 目标频率)。36MHz / (2 × 100kHz) 180。TRISE是最大上升时间标准模式规定1000ns对应36MHz下一个周期约27.8ns1000/27.8≈36加1就是37。这些值不是拍脑袋来的手册RM0008里有明确公式。4.2 完整的字节写函数寄存器操作I2C最麻烦的是状态机。每一步都要等对应的标志位然后检查状态码。下面是一个字节写的核心流程uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { // 1. 产生起始条件 I2C1-CR1 | I2C_CR1_START; while(!(I2C1-SR1 I2C_SR1_SB)); // 等SB置位 // 2. 发送器件地址写 I2C1-DR 0xA0; while(!(I2C1-SR1 I2C_SR1_ADDR)); // 等ADDR (void)I2C1-SR1; (void)I2C1-SR2; // 清ADDR // 3. 发送内存地址 while(!(I2C1-SR1 I2C_SR1_TXE)); I2C1-DR addr; // 4. 发送数据 while(!(I2C1-SR1 I2C_SR1_TXE)); I2C1-DR data; // 5. 等待传输完成 while(!(I2C1-SR1 I2C_SR1_BTF)); // 6. 停止条件 I2C1-CR1 | I2C_CR1_STOP; return 0; }注意第2步里读SR1再读SR2这个操作这是清ADDR标志的标准动作少读一个都会导致状态机卡住。这是F103硬件I2C最经典的坑之一。4.3 读操作的时序差异读比写多一个重启步骤。流程是起始→发写地址→发内存地址→重启→发读地址→读数据→发NACK→停止。最后读一个字节时要发NACK告诉从机我不再需要数据了然后主机产生停止条件。uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t data; // 起始 写地址 I2C1-CR1 | I2C_CR1_START; while(!(I2C1-SR1 I2C_SR1_SB)); I2C1-DR 0xA0; while(!(I2C1-SR1 I2C_SR1_ADDR)); (void)I2C1-SR1; (void)I2C1-SR2; // 发内存地址 while(!(I2C1-SR1 I2C_SR1_TXE)); I2C1-DR addr; while(!(I2C1-SR1 I2C_SR1_BTF)); // 重启 读地址 I2C1-CR1 | I2C_CR1_START; while(!(I2C1-SR1 I2C_SR1_SB)); I2C1-DR 0xA1; while(!(I2C1-SR1 I2C_SR1_ADDR)); (void)I2C1-SR1; (void)I2C1-SR2; // 准备NACK和停止 I2C1-CR1 ~I2C_CR1_ACK; I2C1-CR1 | I2C_CR1_STOP; // 读数据 while(!(I2C1-SR1 I2C_SR1_RXNE)); data I2C1-DR; I2C1-CR1 | I2C_CR1_ACK; // 恢复ACK return data; }这段代码里NACK和STOP的设置顺序很讲究。要在读DR之前就把ACK清掉、STOP置上这样读完最后一个字节后硬件会自动发NACK并产生停止条件。顺序反了就会多读一个字节或者总线卡住。5. HAL库写法与寄存器写法的取舍5.1 HAL库的便利与隐藏成本用CubeMX生成工程HAL库把I2C封装成了HAL_I2C_Mem_Write和HAL_I2C_Mem_Read一行就能读写EEPROMHAL_I2C_Mem_Write(hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100); HAL_Delay(5); HAL_I2C_Mem_Read(hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100);方便是真方便但代价是你不知道里面发生了什么。HAL库的超时机制、错误处理、状态机都在后台跑一旦总线出问题返回个HAL_ERROR或者HAL_TIMEOUT你很难定位到底卡在哪一步。而且HAL库的I2C实现里有一些已知的边界问题比如在特定时序下会进入死循环等待。我的建议是产品开发用HAL库提高效率但调试阶段一定要能看懂寄存器层面的东西。当HAL库返回错误时你能通过读SR1、SR2寄存器判断是总线忙、还是NACK、还是仲裁丢失这比盲目重试有用得多。5.2 两种写法的性能对比我实测过同一颗AT24C02寄存器写法和HAL库写法在单字节写上的耗时差异。寄存器写法从起始到停止大约几十微秒加上5ms写周期总时间主要被写周期占据。HAL库因为有多层函数调用和超时检查单次操作会多出几微秒到十几微秒但在EEPROM这种毫秒级写周期的场景下这点差异可以忽略。真正影响性能的是页写。如果你要写256字节按字节写需要256次写周期每次5ms总共1.28秒。按页写每页8字节需要32次写周期总共160ms。差了8倍。所以只要数据量超过1字节就应该用页写。写入方式写周期次数256字节总耗时按5ms/周期适用场景单字节写256约1280ms零星参数更新页写8字节32约160ms批量数据存储连续读0微秒级读取无限制5.3 页写函数的实现要点页写函数的关键是处理跨页。我的做法是先算出当前地址到页边界的剩余空间取剩余空间和待写长度的较小值作为本次写入量写完一页后重新计算下一页。void AT24C02_WritePage(uint8_t addr, uint8_t *buf, uint16_t len) { while(len 0) { uint8_t page_remain 8 - (addr % 8); // 本页剩余空间 uint8_t write_len (len page_remain) ? len : page_remain; // 发起一次页写 I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(addr); I2C_WaitAck(); for(uint8_t i 0; i write_len; i) { I2C_SendByte(buf[i]); I2C_WaitAck(); } I2C_Stop(); // 应答轮询等待写完成 AT24C02_WaitStandby(); addr write_len; buf write_len; len - write_len; } }这个8 - (addr % 8)就是页内剩余空间的计算。比如addr0x077%878-71本页只能再写1个字节。这个逻辑必须写对否则跨页数据就会错乱。6. 调试实录那些让I2C卡死的瞬间6.1 总线忙死锁与恢复F103的硬件I2C有个让人头疼的问题如果通信过程中出现异常比如从机突然断电、总线被干扰I2C外设可能进入BUSY状态而且怎么复位都不出来。这时候I2C1-SR2的BUSY位一直是1任何新的起始条件都发不出去。我遇到过一次是在热插拔AT24C02模块的时候。解决办法是手动模拟时序恢复总线把SCL和SDA临时切成普通GPIO发9个时钟脉冲让从机把残留的数据位移完然后手动产生一个停止条件再把引脚切回I2C复用模式。void I2C_BusRecover(void) { // 切为普通开漏输出 GPIOB-CRL ~(0xFF 24); GPIOB-CRL | (0x77 24); // 通用开漏 GPIOB-BSRR GPIO_Pin_6; // SCL高 GPIOB-BSRR GPIO_Pin_7; // SDA高 delay_us(5); for(int i 0; i 9; i) { GPIOB-BRR GPIO_Pin_6; // SCL低 delay_us(5); GPIOB-BSRR GPIO_Pin_6; // SCL高 delay_us(5); } // 手动停止条件SCL高时SDA由低变高 GPIOB-BRR GPIO_Pin_7; delay_us(5); GPIOB-BSRR GPIO_Pin_6; delay_us(5); GPIOB-BSRR GPIO_Pin_7; delay_us(5); // 切回复用开漏 GPIOB-CRL ~(0xFF 24); GPIOB-CRL | (0xEE 24); }这段恢复代码我建议直接放进你的驱动里在初始化时先调用一次能解决大部分上电就BUSY的问题。6.2 写保护引脚导致的写失败前面提过WP引脚这里再强调一次。我见过一个案例AT24C02的WP接了上拉电阻结果写操作全部失败读操作正常。因为读不受写保护影响所以现象很迷惑——能读不能写。排查了半天才发现是WP的问题。所以调试写操作时第一件事就是确认WP接地。6.3 地址计算错误AT24C02的地址是8位的0x00到0xFF。但如果你用的是AT24C32或更大容量的地址就是16位的需要发两个地址字节。有人拿AT24C02的代码去驱动AT24C32只发一个地址字节结果读写全乱。这个坑在换芯片时特别容易踩。判断方法很简单看手册里的地址字节数2Kbit及以下用8位地址4Kbit及以上用16位地址。6.4 上拉电阻缺失或阻值不当没有上拉电阻SDA和SCL永远是低电平总线直接死。阻值太大上升沿太慢高速下数据出错。我建议手头常备4.7kΩ和2.2kΩ两种标准模式用4.7kΩ快速模式或者总线设备多用2.2kΩ。用示波器看波形时重点看上升沿是否在规范时间内到达高电平如果是个缓慢的斜坡就是电阻太大了。7. 从AT24C02延伸到更实用的存储方案7.1 参数存储的结构化设计实际项目里我们很少直接按字节读写EEPROM而是把参数组织成结构体。比如一个设备配置typedef struct { uint16_t device_id; uint8_t baudrate_index; uint16_t calibration; uint8_t checksum; } DeviceConfig_t;存的时候把结构体整体写入读的时候整体读出最后校验checksum。这样比零散地读写单个字节可靠得多。但要注意结构体的字节对齐问题不同编译器可能插入填充字节导致存储布局不一致。稳妥的做法是手动序列化成字节数组再写。7.2 磨损均衡的朴素实现EEPROM有擦写寿命AT24C02标称100万次。如果你频繁写同一个地址比如每秒记录一次数据很快就会写坏。朴素的磨损均衡思路是把存储区分成多个块轮流写入用一个指针记录当前写到哪一块。这样每个块的擦写次数就被摊薄了。对于AT24C02这种小容量可以简单地把256字节分成16块每块16字节轮流使用。7.3 数据校验与掉电保护EEPROM写入过程中如果掉电可能写入一半数据导致内容损坏。常见的保护手段是双备份加校验同一份数据存两个副本每个副本带CRC校验。读取时如果主副本校验失败就用备份副本恢复。写入时先写备份再写主副本确保任何时候至少有一份完整数据。这个策略在工业设备里很常见虽然多占一倍空间但可靠性提升明显。8. 我踩过的坑和几条实在建议调试I2C这些年最大的体会是逻辑分析仪比万用表有用一百倍。I2C是时序协议用万用表只能看静态电平根本看不出起始条件、应答位、数据位。一个几十块的逻辑分析仪配合上位机软件能把整条总线的通信过程解码出来哪一步NACK、哪一步数据不对一目了然。这个投入绝对值得。第二条建议是先跑通单字节读写再上页写和连续读。很多人一上来就写复杂的批量读写函数结果出问题时分不清是时序问题还是逻辑问题。先用最简单的单字节写、单字节读验证硬件和基础时序确认无误后再逐步增加复杂度。这个调试顺序能帮你省下大量时间。第三条是关于HAL库的不要完全依赖HAL库的错误返回值。HAL库返回HAL_BUSY、HAL_ERROR、HAL_TIMEOUT但这些状态背后的原因需要你自己去读寄存器判断。我习惯在HAL库调用失败后手动读一下SR1和SR2把状态码打印到串口这样能快速定位是NACK、总线忙还是其他问题。最后说一个容易被忽略的点AT24C02的写周期内芯片不响应任何命令包括读。所以写完之后的应答轮询是必须的不能省。我见过有人写完立刻读读到旧数据以为是写失败其实是芯片还在忙。加上应答轮询这个问题就消失了。这套AT24C02的读写流程看起来简单但把每个细节都抠清楚其实涵盖了I2C通信的绝大部分知识点。把这颗芯片吃透再去驱动OLED、传感器、RTC这些I2C设备基本就是换个器件地址和寄存器地址的事。底层的东西是相通的这也是为什么我一直建议新手从AT24C02入手学I2C。
返回列表