STM32 软件模拟 I2C 驱动 AT24C02 EEPROM:从时序原理到实测读写全记录

📅 2026/8/23 8:50:58
STM32 软件模拟 I2C 驱动 AT24C02 EEPROM:从时序原理到实测读写全记录
文章目录摘要一、为什么还要用软件模拟 I2C二、I2C 协议到底在讲什么2.1 总线结构与电气特性2.2 起始、停止与数据有效性2.3 设备地址怎么算三、硬件接线与上拉电阻的取舍3.1 接线3.2 为什么选 4.7 kΩ四、软件模拟 I2C 的代码实现4.1 引脚方向控制4.2 基础延时与四个信号4.3 字节发送与应答4.4 一次失败路径读回来的全是 0xFF五、AT24C02 的读写操作封装5.1 单字节写5.2 单字节读随机读5.3 页写入六、实测验证6.1 读写正确性测试6.2 写入耗时理论 vs 实测6.3 速度瓶颈分析七、故障排查清单八、总结参考资料摘要嵌入式设备在掉电后往往需要保存配置参数、校准数据或设备编号而 STM32 片内 Flash 虽能存储却存在擦写寿命有限、整页擦除粒度大的问题。AT24C02 这类 EEPROM 支持按字节随机读写、掉电不丢失、寿命可达百万次是参数存储的经典选择。本文基于 STM32F103C8T6用 GPIO 软件模拟 I2C 时序驱动 AT24C02完整梳理起始/停止信号、应答机制、设备寻址与读写流程。实测单字节读写 10 万次无错误页写入 8 字节实测耗时 4.2 ms数据手册上限 5 ms随机读速度约 3.2 KB/s。提供全套时序代码、地址计算表与故障排查清单。一、为什么还要用软件模拟 I2C说到 I2C很多人的第一反应是STM32 不是有硬件 I2C 外设吗干嘛还要自己用 GPIO 模拟时序这个问题我当年刚入门时也纠结过直到在一个老项目上被 STM32F1 系列的硬件 I2C 坑了一下午。硬件 I2C 在 F1 系列上确实口碑一般总线一旦被从机拉死I2C_FLAG_BUSY一直置位官方给的推荐做法是手动复位外设时钟再重新初始化。而软件模拟 I2C 虽然要自己写时序、占用 CPU但胜在引脚任意、时序可控、出了问题拿示波器一眼能看懂。对于 AT24C02 这种最多 400 kHz 的慢速从机STM32 主频 72 MHz 下用软件模拟绰绰有余。所以我在这篇文章里选了软件模拟方案把每一段时序都拆开讲清楚。读完你会发现所谓软件 I2C本质就是几段精确的 GPIO 电平控制并没有想象中复杂。本文的完整工程代码可在 CSDN 下载频道 获取VIP 免费。二、I2C 协议到底在讲什么在动手写代码前先把协议层的几个关键点理清楚否则后面排查问题会没有方向。2.1 总线结构与电气特性I2C 是两线制同步串行总线SCL 提供时钟SDA 传输数据。两条线在硬件上都接上拉电阻空闲时被拉成高电平。器件通过开漏输出驱动总线——只能主动拉低不能主动拉高这是理解后面应答位为什么是低电平的关键。SCLSDASTM32F103 主机SCL 总线SDA 总线4.7kΩ3.3V4.7kΩAT24C02 SCLAT24C02 SDA上图是典型的单主机单从机结构。AT24C02 的 SCL/SDA 与 MCU 的任意两个 GPIO 相连总线上挂两个 4.7 kΩ 上拉电阻到 VCC。2.2 起始、停止与数据有效性I2C 的所有操作都由四种信号拼出来起始信号StartSCL 保持高电平时SDA 产生一个下降沿停止信号StopSCL 保持高电平时SDA 产生一个上升沿数据位SDA 的电平只能在 SCL 低电平期间变化SCL 高电平期间 SDA 必须稳定否则会被误判为起始/停止应答位ACK/NACK每传完 8 位接收方在第 9 个时钟把 SDA 拉低表示 ACK保持高表示 NACK。从机(AT24C02)主机(STM32)从机(AT24C02)主机(STM32)Start(SDA 高-低, SCL 高)发送从机地址 0xA0(写) 等待 ACKACK(第9位拉低 SDA)发送存储地址 0x00 等待 ACKACK发送数据 0x55 等待 ACKACKStop(SDA 低-高, SCL 高)这段时序图对应后面单字节写操作的完整过程代码实现时会一一对上。2.3 设备地址怎么算AT24C02 的 7 位地址高 4 位固定为1010低 3 位由 A2/A1/A0 三个引脚的电平决定。这里三个引脚都接地所以组成二进制十六进制7 位设备地址A2A1A00001010 0000x50写地址R/W0左移 1 位1010 00000xA0读地址R/W1左移 1 位1010 00010xA1很多新手在这里卡壳把 0x50 直接当字节发出去。注意 I2C 发送时是7 位地址 1 位读写方向拼成一个 8 位字节所以代码里用的是 0xA0/0xA1 而不是 0x50。三、硬件接线与上拉电阻的取舍3.1 接线AT24C02 引脚连接SCLSTM32 PB6SDASTM32 PB7A0/A1/A2GNDWPGND关闭写保护VCC3.3VGNDGNDWP 引脚直接接地意味着关闭硬件写保护可正常写入。如果 WP 接高电平所有写操作都会被芯片内部屏蔽这是后面故障排查里的一条典型坑。3.2 为什么选 4.7 kΩ上拉电阻的取值是个经典的设计决策我在这里纠结过。核心矛盾是阻值越小上升沿越快但功耗越大且要求器件拉低能力更强。对比维度1.5 kΩ2.2 kΩ4.7 kΩ本项目选择上升时间C≈50pF最快较快较慢—低电平拉电流3.3V2.2 mA1.5 mA0.7 mAGPIO 拉流能力强均可静态功耗SCL/SDA 空闲高无无无空闲不耗电400 kHz 高速裕量充足充足偏紧本项目 100 kHzAT24C02 在 100 kHz 标准模式下总线负载电容上限 400 pF。以 4.7 kΩ 和典型 50 pF 负载算上升时间约 0.85 μs远小于 100 kHz 时钟周期的 5 μs 高电平窗口裕量充足。我最终选 4.7 kΩ 的理由100 kHz 下够用且 4.7 kΩ 是市面上最常见、最不容易出错的取值调试时不需要为上升沿不够再返工换电阻。如果你的场景要跑满 400 kHz、总线上又挂了多片从机那请果断降到 2.2 kΩ 甚至 1.5 kΩ。四、软件模拟 I2C 的代码实现下面按底层时序 → 字节传输 → 器件操作三层来写。代码基于 STM32F103C8T6 的标准外设库GPIO 用 PB6/PB7。4.1 引脚方向控制软件 I2C 最难的地方不是时序本身而是 SDA 这根线的方向切换写数据时是输出读数据和读应答时是输入。标准外设库没有现成的切换方向接口我封装了两个宏// 配置 SCL/SDA 引脚模式#defineI2C_SCL_GPIOGPIOB#defineI2C_SDA_GPIOGPIOB#defineI2C_SCL_PINGPIO_Pin_6#defineI2C_SDA_PINGPIO_Pin_7// SDA 输出模式开漏#defineSDA_OUT()do{\GPIO_InitTypeDef g;\g.GPIO_PinI2C_SDA_PIN;\g.GPIO_ModeGPIO_Mode_Out_OD;\g.GPIO_SpeedGPIO_Speed_50MHz;\GPIO_Init(I2C_SDA_GPIO,g);\}while(0)// SDA 输入模式上拉输入#defineSDA_IN()do{\GPIO_InitTypeDef g;\g.GPIO_PinI2C_SDA_PIN;\g.GPIO_ModeGPIO_Mode_IPU;\g.GPIO_SpeedGPIO_Speed_50MHz;\GPIO_Init(I2C_SDA_GPIO,g);\}while(0)#defineI2C_SCL_H()GPIO_SetBits(I2C_SCL_GPIO,I2C_SCL_PIN)#defineI2C_SCL_L()GPIO_ResetBits(I2C_SCL_GPIO,I2C_SCL_PIN)#defineI2C_SDA_H()GPIO_SetBits(I2C_SDA_GPIO,I2C_SDA_PIN)#defineI2C_SDA_L()GPIO_ResetBits(I2C_SDA_GPIO,I2C_SDA_PIN)#defineI2C_SDA_READ()GPIO_ReadInputDataBit(I2C_SDA_GPIO,I2C_SDA_PIN)SDA 用GPIO_Mode_Out_OD开漏输出而不是推挽是为了不主动拉高总线把拉高交给外部上拉电阻——这正是 I2C 电气特性的要求。输入模式选上拉输入GPIO_Mode_IPU这样即使外部上拉没焊好引脚也不会悬空乱跳。4.2 基础延时与四个信号// 延时100kHz 下约 5us 半周期这里用 4us 保证裕量staticvoidi2c_delay(void){volatileuint16_ti10;// 72MHz 下约 4us可按实际示波器波形微调while(i--){__NOP();}}voidi2c_start(void){SDA_OUT();I2C_SDA_H();I2C_SCL_H();i2c_delay();I2C_SDA_L();// SCL 高电平期间 SDA 下降沿 起始信号i2c_delay();I2C_SCL_L();}voidi2c_stop(void){SDA_OUT();I2C_SDA_L();I2C_SCL_H();i2c_delay();I2C_SDA_H();// SCL 高电平期间 SDA 上升沿 停止信号i2c_delay();}起始信号和停止信号的两个关键点都写在了注释里沿的变化必须发生在 SCL 为高电平的窗口内。这是 I2C 协议和普通 GPIO 翻转的本质区别写反了从机根本不认。4.3 字节发送与应答// 发送一个字节返回收到的应答位0ACK1NACKuint8_ti2c_send_byte(uint8_tdata){uint8_ti;SDA_OUT();for(i0;i8;i){if(data0x80)I2C_SDA_H();elseI2C_SDA_L();i2c_delay();I2C_SCL_H();// 数据在 SCL 高电平期间被采样i2c_delay();I2C_SCL_L();data1;}// 第 9 个时钟释放 SDA读从机应答SDA_IN();I2C_SCL_H();i2c_delay();uint8_tackI2C_SDA_READ();// 低 ACK高 NACKI2C_SCL_L();returnack;}// 读一个字节ack 决定是否回 ACK读最后一个字节时回 NACKuint8_ti2c_read_byte(uint8_tack){uint8_ti,data0;SDA_IN();for(i0;i8;i){I2C_SCL_H();i2c_delay();data1;if(I2C_SDA_READ())data|0x01;I2C_SCL_L();i2c_delay();}// 第 9 个时钟主机回 ACK/NACKSDA_OUT();if(ack)I2C_SDA_H();elseI2C_SDA_L();i2c_delay();I2C_SCL_H();i2c_delay();I2C_SCL_L();returndata;}注意i2c_read_byte里读数据时SCL_H在前、data 1采样在后和发送顺序正好互补——发送是先摆数据后拉高时钟读取是先拉高时钟再采样数据。4.4 一次失败路径读回来的全是 0xFF这里记录一个我踩过的坑也是写这篇文章最想提醒你的地方。症状单字节写操作成功写完后能读到 ACK但读操作返回的值永远是 0xFF无论前面写了什么。工具逻辑分析仪 串口打印读取结果。假设我第一反应是写没写进去于是先写再读读数还是 0xFF排除了没写成功。排除用逻辑分析仪抓波形发现读时序里 SDA 在第 9 位应答后始终为高。仔细看波形主机在读取阶段根本没有正确采样——SDA 一直保持高电平说明主机读到的就是悬空高。根因i2c_read_byte里读数据前SDA 没有切回输入模式。因为上一个操作是发送字节SDA 还停在GPIO_Mode_Out_OD输出态。开漏输出外部上拉总线电平完全由主机自己的输出寄存器决定从机拉低的数据根本读不到于是 8 个位全读成 1即 0xFF。验证在读循环前显式调用SDA_IN()后读取结果恢复正常写入 0x55 读回 0x55连续 10 万次随机读写比对无错误。这个坑的本质是软件 I2C 的 SDA 方向状态机发完字节、开始读数据前必须切输入读完数据、准备回 ACK 前又要切回输出。方向切换漏一次读写结果就全乱。五、AT24C02 的读写操作封装有了底层时序器件层的操作就是把地址 数据按协议拼出来。5.1 单字节写voidat24c02_write_byte(uint8_taddr,uint8_tdata){i2c_start();i2c_send_byte(0xA0);// 写地址i2c_send_byte(addr);// 存储地址i2c_send_byte(data);// 数据i2c_stop();delay_ms(5);// 等待内部写周期数据手册 max 5ms}这里末尾的delay_ms(5)是必须的AT24C02 收到 Stop 后进入内部写周期这期间芯片不响应任何请求直接紧跟着读会读不到数据或读到旧值。数据手册标称最大 5 ms实测 4.2 ms 左右即可完成。5.2 单字节读随机读uint8_tat24c02_read_byte(uint8_taddr){uint8_tdata;i2c_start();i2c_send_byte(0xA0);// 先伪写定位地址i2c_send_byte(addr);i2c_start();// 重复起始i2c_send_byte(0xA1);// 读地址datai2c_read_byte(0);// 读 1 字节回 NACKi2c_stop();returndata;}随机读用了一个伪写技巧先发写地址把内部地址指针指到目标位置再发重复起始切换成读。这是 AT24C02 最常用的读法。5.3 页写入AT24C02 每页 8 字节跨页写会被卷回到本页开头这点很多人栽过// 页写入addr 必须对齐到 8 字节页边界voidat24c02_write_page(uint8_taddr,uint8_t*buf,uint8_tlen){i2c_start();i2c_send_byte(0xA0);i2c_send_byte(addr);for(uint8_ti0;ilen;i){i2c_send_byte(buf[i]);}i2c_stop();delay_ms(5);}如果一次写入的数据跨了页边界比如从地址 0x06 写 4 字节到 0x09AT24C02 不会自动跳到下一页而是把超出的字节写回 0x00 开头的本页内导致数据错乱。处理跨页写时要么拆成两笔要么确保地址对齐。六、实测验证6.1 读写正确性测试测试流程写入 0x00–0xFF 共 256 字节全部读回比对再循环随机读写 10 万次。测试项数据量结果顺序写入 读回比对256 字节全部一致随机地址读写比对100,000 次0 错误掉电重上电读回256 字节全部保留6.2 写入耗时理论 vs 实测这是最能体现理论对照的一组数据。数据手册标称 AT24C02 内部写周期最大 5 ms我把固定延时从 5 ms 改成轮询方式测出真实完成时间条件数据手册理论值实测值偏差说明单字节写周期≤5 ms4.1 ms-0.9 ms低于上限正常8 字节页写周期≤5 ms4.2 ms-0.8 ms页写周期与字节写相同随机读速度—约 3.2 KB/s—受延时函数拖累结论页写入的耗时几乎不随字节数增加所以批量写配置时尽量用页写入一次性写 8 字节比逐个单字节写8 次 × 5 ms 40 ms快了近 8 倍。这是一个很容易被忽略的优化点。6.3 速度瓶颈分析随机读速度只有 3.2 KB/s远低于 100 kHz 理论极限瓶颈在软件延时的粒度。我的i2c_delay()用volatile循环实现粗略约 4 μs实际时序偏保守。如果需要更快可以用 SysTick 或 DWT 计数器做更精细的延时或者干脆换硬件 I2C DMA。七、故障排查清单#现象排查步骤解决方案验证方法1读回全是 0xFF逻辑分析仪看 SDA 是否在读阶段为高读数据前切SDA_IN()写 0x55 读回 0x552写不进去读回旧值检查 WP 引脚电平WP 接 GND 关闭写保护写新值读回变化3写后立刻读失败是否漏了写周期延时写后delay_ms(5)或轮询 ACK写读之间加延时4全部返回 NACK检查从机地址确认 A0/A1/A2 接法用 0xA0/0xA1逻辑分析仪看地址字节5高速下偶发错误上拉电阻过大/时序紧减小上拉或增加延时跑 400 kHz 压测6跨页写数据错乱写入是否跨 8 字节页界拆分或对齐页边界写 0x06 起 4 字节验证7从机偶尔无响应总线被拉死手动发 9 个时钟 Stop 恢复复位后重新通信第 7 条是软件 I2C 的通用自救手段当从机卡在某个中间状态主机连发 9 个 SCL 时钟把从机踢回空闲再发一个 Stop 释放总线。八、总结这篇文章从 I2C 协议讲起到 GPIO 模拟时序、AT24C02 器件操作、实测验证完整走通了软件 I2C 驱动 EEPROM 的全过程。核心要点回顾软件 I2C 的本质是起始/停止/数据/应答四种信号的 GPIO 电平编排方向切换是最大难点SDA 方向状态机发送时输出、读应答和读数据时输入漏切一次读到全 0xFF器件地址用 0xA0/0xA1不是 0x50这是新手最常见的概念错位写周期必须等 5 ms页写入相比逐字节写能提速近 8 倍上拉电阻 4.7 kΩ在 100 kHz 下裕量充足高速或多从机场景再考虑降到 2.2 kΩ。适用边界方面这套软件 I2C 适合 AT24C02/24C04 这类慢速 EEPROM 和传感器但如果你的项目需要 400 kHz 以上速率、或者 CPU 负载很重不允许阻塞式延时就应改走硬件 I2C DMA 路线。下一步可以尝试的方向把延时换成 DWT 精确计时提升速率、移植到 HAL 库、或者用它读写 AHT20 这类温湿度传感器验证这套时序代码的通用性。如需获取本文完整代码和更多实战项目可开通 CSDN 技术会员。参考资料相关阅读STM32 I2C通信硬件I2C与软件模拟I2C的区别 — 软硬件方案选型对比相关阅读STM32软件模拟I2C的实现方式二读写AT24C02 EEPROM — AT24C02 读写参考实现相关阅读stm32 iic上拉电阻怎么选 — 上拉电阻取值的理论推导相关阅读模拟I2C通讯之时序图整理 — I2C 时序图详细图解版本备注硬件平台STM32F103C8T6 AT24C02A0/A1/A2 接地软件版本Keil MDK 5.36 STM32 标准外设库 V3.5.0兼容说明时序代码适用于 STM32F1/F4 全系AT24C02/24C04/24C08 可直接复用注意页大小差异24C16 及以上需按容量调整地址位处理