这两年我做嵌入式项目几乎每一块STM32F103的板子都要挂一颗AT24C02 EEPROM。设备参数、校准系数、掉电要保存的运行状态全往这颗2Kbit的小芯片里塞。I2C这个外设让我吃过不少苦头硬件I2C跑着跑着就卡死、示波器抓SCL/SDA波形怎么抓都正常但就是读不出数据、换个芯片又好了……后来我把这套逻辑彻底理顺干脆换成了软件模拟I2C自己控时序反而再也没出过幺蛾子。这篇文章我就按实战全流程来讲先解决一个绕不开的选型问题——硬件I2C还是模拟I2C然后把开漏、上拉电阻这些物理层基础讲透再把AT24C02的读写时序拆开揉碎最后给两套能直接跑的代码以及我实际排查过的故障套路。适合正在用F103做数据存储、被I2C通信折磨过的开发者也适合刚接触STM32想快速上手EEPROM的新手。1. 先回答一个老生常谈F103的硬件I2C和软件模拟I2C怎么选1.1 F103硬件I2C为什么总翻车STM32F1系列参考手册RM0008里硬件I2C的状态机事件写得清清楚楚起始条件后要等EV5、地址发送后等EV6、数据发送等EV8_1……流程图一套一套的代码写起来却完全是另一回事。最容易翻车的场景有三个一是总线上有设备突然掉电或者被拔掉BUSY位会一直锁在1你发什么它都不理你二是主机发送完数据等待NACK事件时从机正好出问题没返回ACK事件标志不置位程序就卡死在while循环里三是中断和查询混用事件标志被吃掉一次后状态机直接错乱。RM0008里确实写了BUSY位怎么判断、SWRST软件复位怎么用但实际恢复逻辑写起来比整个模拟I2C驱动都长。很多人最后都放弃了硬件I2C。1.2 软件模拟I2C到底香在哪模拟I2C本质上就是用GPIO按协议时序翻转电平配合延时函数产生时钟。它最大的优点是完全可控起始条件、停止条件、ACK、时钟频率全由你代码说了算总线卡死了大不了重新初始化GPIO再手动产生9个时钟把总线释放绝不会出现“硬件状态机不知道飞哪去了”的情况。第二个优点是引脚随意。硬件I2C1只能固定在PB6/PB7或PB8/PB9如果你这些引脚被其他功能占了就很麻烦。模拟I2C任意两个GPIO都能当SCL和SDA布线也灵活。第三个优点是可移植性。换芯片只需要改一下引脚宏和延时函数驱动代码原封不动搬走。我在CH32V307、GD32F103上都这么干过效果一样稳。缺点是占用CPU模拟I2C是忙等延时期间CPU干不了别的。但对于AT24C02这种工作在100kHz~400kHz的低速存储芯片传输一个字节也就几十微秒完全没有性能压力。1.3 硬件I2C和模拟I2C实测对比对比项硬件I2CHAL库软件模拟I2C开发难度事件标志多需理解状态机核心代码约50行逻辑直观总线死锁风险存在BUSY位卡死难恢复几乎为零可完全控制CPU占用低硬件自动传输中忙等延时最高速率400kHz取决于主频和延时100kHz轻松调试友好度黑盒靠标志位推测波形和代码一一对应引脚要求固定复用引脚任意GPIO1.4 我的选型建议从实测经验来看F103这种级别的芯片挂AT24C02这类低速设备默认就选软件模拟I2C。它能帮你把“协议”和“硬件外设”两个变量拆开——出问题时一是协议逻辑错二是硬件连接错不会有第三个“外设状态机异常”来干扰判断。如果项目确实需要硬件I2C跑400kHz、数据量又大也不是不能用但必须做好三件事所有等待事件加超时退出总线卡死后用SWRST复位并重新初始化每次通信结束后清BUSY标志。这套保护代码写下来比模拟I2C整体代码还多。所以我才说小项目模拟I2C保平安。2. 开漏、上拉电阻、电平转换I2C物理层的地基打不好代码再对也白搭2.1 为什么I2C必须是开漏而不是推挽很多新手在这里栽跟头为什么GPIO配置I2C时要选开漏输出Open-Drain而点亮LED、驱动蜂鸣器要用推挽输出Push-Pull推挽输出的高电平和低电平都是由芯片主动驱动输出能力强但这种方式不允许两个设备同时驱动同一条线。假设设备A输出高电平、设备B输出低电平两个推挽输出直接对撞轻则信号错乱重则烧引脚。I2C是真正的多设备总线一条总线上可能挂着好几个从机。开漏输出的高电平不是芯片推上去的而是通过外部上拉电阻拉高的。芯片只能把线拉低不能主动拉高。这样任何设备在任何时刻都有能力把总线拉低形成“线与”逻辑——谁拉低谁说话其余设备只是倾听。这也是为什么I2C协议里主机发送起始条件时要先确保SDA在SCL高电平期间被拉低。因为总线上所有设备都在看这根线的状态谁都可以发起通信。2.2 上拉电阻阻值怎么算4.7k为什么是黄金值上拉电阻是I2C电路的关键零件选大了信号上升沿太慢选小了灌电流超标。具体计算分两步下限由低电平时的灌电流限制I2C标准模式要求器件在VOL(max)0.4V时能灌入3mA电流。3.3V供电时Rp(min) (3.3 - 0.4) / 3mA ≈ 967Ω所以实际取1k以上。上限由上升时间限制标准模式允许的上升时间tr(max)1000ns总线电容Cb包括引脚电容、PCB走线、连接器电容的总和。RC充电到70%阈值附近公式是tr 0.8473 × Rp × Cb。假设总电容200pFRp(max) 1000 / (0.8473 × 200pF) ≈ 5.9kΩ。总电容较小100pF时10kΩ左右也能用但余量不大。两个条件一夹1k~5.9k之间都是合法区间4.7k刚好落在中间——这解释了为什么市面上几乎所有I2C模块板上都焊的是4.7k。如果总线走线长了、设备多了电容变大就把电阻降到2.2k。实测下来100kHz配合4.7k是最稳的组合杜邦线长度控制在20cm以内完全没问题。2.3 3.3V主控和5V供电的AT24C02混接必须补一课电平转换这个问题被问的频率很高“我的AT24C02供电接5VSTM32是3.3V能不能直接连”关键看输入高电平门槛。AT24C02的VIH是0.7×VCC如果VCC5VVIH3.5V。而STM32开漏引脚外接3.3V上拉高电平只有3.3V已经低于从机的判别门槛通信可能偶尔正常但抗干扰能力极差温度一高或者线缆一长就会报错。最省心的方案是AT24C02的工作电压本身是2.5V~5.5V你直接把它也接到3.3V供电和MCU共地共电源上拉到3.3VVIH0.7×3.3V≈2.31V稳稳妥妥。如果因为系统其他部分必须给EEPROM供5V那就得做电平转换。最简单的电路是用一个N沟道MOS管比如2N7002和两个上拉电阻搭建双向电平转换或者直接用PCA9306这类专用芯片。总之混接时千万别图省事把上拉电阻接3.3V糊弄过去。3. AT24C02读写时序把数据手册翻译成人话3.1 器件地址、页容量和引脚功夫AT24C02名字里的“02”代表容量2Kbit换算过来是256字节分32页每页8字节。这8字节的页结构在后面讲页写时特别关键。I2C设备寻址靠器件地址字节。AT24C02的地址字节格式是1010 A2 A1 A0 R/W。A2、A1、A0是硬件引脚一般直接接地地址就是0xA0写和0xA1读。如果总线上挂多个EEPROM可以通过接VCC和GND组合出8个不同地址。还有WP写保护引脚它必须接GND才能正常写入。别小看这个脚我见过有人WP飞线到3.3V结果所有写操作都失败读出来全是0xFF排查半天找不到原因。写保护接GND这个动作要刻进肌肉记忆。3.2 写操作字节写、页写以及“跨页回卷”这个阴坑字节写时序是START → 发送设备地址0xA0 → 发送字节地址 → 发送要写的数据 → STOP。每次写完芯片内部需要约5ms的写周期在这期间芯片不响应任何I2C命令。这里有个极其普遍的误解HAL库的I2C发送函数返回HAL_OK只代表数据已经送到总线上不代表EEPROM已经写完。你必须在每次写操作后等够5ms或者用下面说的ACK轮询。如果写连续数据可以用页写一次START后连续发送最多8个字节数据这8个字节必须落在同一页内。当写到页边界时内部地址计数器会回卷到本页页首把之前的数据覆盖掉而不是继续写下一页。比如从地址0x05开始连续写5个字节实际会写到0x05、0x06、0x07然后回卷覆盖0x00、0x01。这个坑非常经典数据错乱往往就是这样造成的。跨页批量写要么保证每次写入“页内剩余空间 本次写入长度 ≤ 8”要么拆分成多次页写每次加5ms延时。ACK轮询是一个节省时间的好技巧EEPROM在内部写周期内对主机发送的设备地址不返回ACK。你可以反复发送0xA0并等待ACK一旦收到ACK说明内部写周期已经结束可以立即进行下一次操作。这种方式比固定延时5ms效率高得多。3.3 读操作当前地址读、随机读、顺序读读操作有三种读模式发送流程适用场景当前地址读START → 发送0xA1 → 读数据 → NACK → STOP读上次操作后的地址随机读START → 发0xA0 → 发字节地址 → 重复START → 发0xA1 → 读数据 → NACK → STOP读任意指定地址顺序读随机读的起始部分然后连续读多个字节批量读取随机读里的重复STARTRestart很容易写错。它本质上是先“假写”设置内部地址指针然后用重复起始条件切换成读模式。模拟I2C实现时就是再调用一次IIC_Start函数不需要先发STOP。很多人在这里额外发了一个STOP读出来的数据不乱才怪。顺序读的最后一个字节主机必须返回NACK然后才发STOP。因为每收到一个ACKEEPROM会继续把内部地址加1并准备输出下一个数据。最后一个字节你回ACK芯片会误以为你还要继续读。3.4 ACK位是调试时最容易被忽略的表通信时每一步都要检查ACK发送设备地址后、发送字节地址后、每写完一个数据后。模拟I2C里IIC_WaitAck()返回值是0表示收到ACK是1表示从机没响应。如果某个环节返回1首先要怀疑的是地址对不对、从机供电有没有、WP有没有拉高、上拉电阻焊没焊。这比拿示波器乱抓一通有效得多。4. 代码实战CubeMX配置、HAL库和模拟I2C两套方案都能跑4.1 CubeMX配置细节硬件I2C和模拟I2C的区别用硬件I2C时CubeMX里开启I2C1SCL选PB6SDA选PB7模式选Standard Mode速率设100kHz。在GPIO设置中这两个引脚会被自动配成复用开漏模式AF_OD。时钟源注意是PCLK1APB1时钟如果是36MHz分频计算会自动完成。Keil环境下记得先安装STM32F1系列器件支持包否则工程可能识别不了芯片型号。如果用模拟I2CCubeMX里不需要开启I2C外设只需要把PB6、PB7配置成普通GPIO输出模式Output Open Drain速度选High。开漏模式下引脚既能输出低电平又能靠外部上拉实现高电平读SDA时直接读IDR寄存器就行不需要切换方向。这就是开漏的好处输入和输出在同一根线上自洽。4.2 HAL库API使用逻辑Mem_Write和Mem_Read的地址陷阱HAL库封装了很方便的库函数// 写 HAL_StatusTypeDef status HAL_I2C_Mem_Write(hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, pData, len, timeout); // 读 HAL_StatusTypeDef status HAL_I2C_Mem_Read(hi2c1, 0xA1, addr, I2C_MEMADD_SIZE_8BIT, pData, len, timeout);这里最大的坑是DevAddress参数。AT24C02的7位地址是0x50但HAL库里要传入左移一位后的8位格式。写操作传0xA0读操作传0xA1而不是0x50。很多人按7位地址传结果通信一直失败检查半天找不到原因。HAL的Mem_Write内部会先发送设备地址再发送字地址再发送数据顺序正好符合AT24C02的字节写时序。Mem_Read则会发送设备地址和字地址后用重复起始条件切换为读模式。但要注意HAL函数返回HAL_OK依然不表示EEPROM内部写完写周期还是要自己处理。4.3 模拟I2C完整驱动核心函数直接可用以下是我常用的模拟I2C驱动使用STM32 HAL库的GPIO操作引脚宏定义可以随时改#define IIC_SCL_PIN GPIO_PIN_6 #define IIC_SDA_PIN GPIO_PIN_7 #define IIC_PORT GPIOB #define IIC_SCL_H() HAL_GPIO_WritePin(IIC_PORT, IIC_SCL_PIN, GPIO_PIN_SET) #define IIC_SCL_L() HAL_GPIO_WritePin(IIC_PORT, IIC_SCL_PIN, GPIO_PIN_RESET) #define IIC_SDA_H() HAL_GPIO_WritePin(IIC_PORT, IIC_SDA_PIN, GPIO_PIN_SET) #define IIC_SDA_L() HAL_GPIO_WritePin(IIC_PORT, IIC_SDA_PIN, GPIO_PIN_RESET) #define IIC_SDA_READ() HAL_GPIO_ReadPin(IIC_PORT, IIC_SDA_PIN) static void IIC_Delay(void) { // 约5us72MHz对应100kHz左右 volatile uint8_t t 12; while (t--); } static void IIC_Start(void) { IIC_SDA_H(); IIC_SCL_H(); IIC_Delay(); IIC_SDA_L(); // SCL高电平期间SDA拉低 IIC_Delay(); IIC_SCL_L(); } static void IIC_Stop(void) { IIC_SDA_L(); IIC_SCL_H(); IIC_Delay(); IIC_SDA_H(); // SCL高电平期间SDA拉高 IIC_Delay(); } static uint8_t IIC_WaitAck(void) { uint8_t ack 0; IIC_SDA_H(); // 释放SDA让从机控制 IIC_Delay(); IIC_SCL_H(); IIC_Delay(); if (IIC_SDA_READ()) { ack 1; } IIC_SCL_L(); IIC_Delay(); return ack; // 0表示ACK1表示NACK } static void IIC_SendByte(uint8_t data) { for (uint8_t i 0; i 8; i) { if (data 0x80) IIC_SDA_H(); else IIC_SDA_L(); data 1; IIC_Delay(); IIC_SCL_H(); IIC_Delay(); IIC_SCL_L(); } } static uint8_t IIC_ReadByte(uint8_t ack) { uint8_t data 0; IIC_SDA_H(); // 释放总线 IIC_Delay(); for (uint8_t i 0; i 8; i) { data 1; IIC_SCL_H(); IIC_Delay(); if (IIC_SDA_READ()) data | 0x01; IIC_SCL_L(); IIC_Delay(); } if (ack) IIC_SDA_L(); // 非最后一字节回ACK else IIC_SDA_H(); // 最后一字节回NACK IIC_Delay(); IIC_SCL_H(); IIC_Delay(); IIC_SCL_L(); IIC_Delay(); IIC_SDA_H(); return data; }基于这套底层AT24C02的字节写、随机读、页写就可以组合出来。写操作每个字节后都要等待5ms写周期读操作注意最后一字节回NACK。4.4 自检回环测试把0到255写进去再读出来驱动写完第一件事就是跑全地址回环测试步骤1把递增数据0x00~0xFF写入EEPROM的连续地址步骤2延时等所有写周期完成步骤3从地址0开始连续读出全部256字节步骤4逐字节对比并返回第一个不一致的位置。这一步能一次性发现绝大多数问题地址错位、页写回卷、写周期不够、读时序错误。跑通这个测试再谈具体业务存储就心里有底了。5. 踩坑实录卡死、全0xFF、错位数据我的排查套路5.1 三类典型故障和排查顺序我做过的项目里I2C相关的故障基本归纳为三类故障现象可能原因排查优先级全地址读出全0xFF上拉电阻虚焊、WP拉高、从机没供电、器件地址错、读时序地址错先量硬件再看代码程序卡死在等待事件硬件I2C状态机异常、总线上有设备未释放总线检查BUSY位SWRST复位数据错位或某些地址对不上页写跨页、ACK/NACK判断反了、写周期不够重点查页写代码全0xFF这个现象最容易误判成EEPROM坏了。实际上我遇到过好几次“没坏但读全FF”的情况最后发现都是WP引脚被拉到高电平。排查顺序一定先拿万用表量量芯片供电、量WP对地电压、量SCL/SDA上拉电阻两端电压。硬件确认没问题的前提下再用逻辑分析仪看波形最后才怀疑芯片本身。硬件I2C卡死时不用急着断电。先把两个引脚从复用模式改回普通开漏GPIO然后手动产生9个SCL时钟让线上可能卡住的从机复位状态机。这个操作在模拟I2C里做起来很自然硬件I2C则需要先软件复位外设再重新初始化步骤繁琐。5.2 用逻辑分析仪抓波形的正确姿势一个几十块钱的逻辑分析仪是调试I2C最值的投资。抓I2C时采样率设1MHz就够抓100kHz的信号。抓波形不是只看有没有START、STOP要看这几件事地址字节的最后一位写操作应该是0读操作应该是1第9个时钟的ACK位低电平表示从机响应了数据字节呢和地址是否对齐STOP前后有没有多余的电平跳变。曾经有个客户的板子写EEPROM偶发失败我抓波形发现ACK位电平有毛刺降采样率后确认是从机在写周期内没有就绪。加了ACK轮询后问题彻底解决。这类问题如果只看代码永远找不出来。5.3 几个容易忽略的细节调试EEPROM时强烈建议并联一个串口打印口。每次读写之后打印返回值和读出数据配合逻辑分析仪能快速定位问题。串口DMA首发数据容易丢这个问题我也踩过调试阶段用阻塞式printf更省心。多个I2C设备挂同一条总线时每个设备的地址必须唯一AT24C02通过A0/A1/A2引脚区分。上拉电阻尽量放在离主控近的位置但更重要的是所有器件的引脚电容、走线长度要小。长线情况下4.7k要换成2.2k。不要带电插拔EEPROM模块。热插拔瞬间总线上会产生毛刺有可能让硬件I2C进入不可恢复的Busy状态。批量生产时留意EEPROM的批次差异个别厂商的24C02写周期会偏长固定延时5ms可能不够统一用ACK轮询最稳。我在实际项目中养成的一个习惯上电初始化时先读EEPROM里一个固定位置的“魔数”比如0x5A判断EEPROM是否可读。如果读不到或者不对第一时间在串口打印告警。这个习惯帮我迅速定位过好几块虚焊的板子也让我对新板卡首轮硬件调试有了基准判断。AT24C02本身不复杂但I2C是个典型的“细节决定成败”总线把物理层和时序都弄明白了这颗小芯片能陪你稳定跑很久。
阅读完成 · 觉得有帮助?