STM32 I2C实战:AT24C02 EEPROM驱动从时序到代码 1. 为什么AT24C02是I2C入门的最佳练手对象搞STM32的朋友大概率都经历过这个阶段GPIO点灯玩腻了串口打印也调通了想找个外设练手结果一看SPI、CAN、USB这些协议寄存器多到头皮发麻。这时候I2C就是最理想的突破口而AT24C02又是I2C设备里最经典、最便宜、最好买的那颗料。AT24C02是一颗2Kbit容量的EEPROM换算过来就是256字节。别看容量小得可怜它几乎涵盖了I2C通信的所有核心知识点7位从机地址、读写位、ACK/NACK应答、字节写、页写、随机读、顺序读甚至连时序里的起始条件、停止条件、时钟拉伸这些细节都能在它身上练到。更关键的是它便宜一块钱能买好几颗焊坏了不心疼逻辑分析仪抓波形也不怕设备太复杂看不懂。我个人的看法是如果你能把STM32F103的硬件I2C驱动AT24C02跑通并且能稳定读写不出错那后面换任何I2C传感器比如OLED、温湿度、气压计都是换汤不换药的事。这篇文章我就按实际调试的顺序把整个流程从硬件到软件、从时序到代码、从踩坑到排查完整拆一遍。提示本文基于STM32F103C8T6最小系统板和AT24C02DSOP-8封装实测使用HAL库和CubeMX配置逻辑分析仪为Saleae兼容款。不同批次的AT24C02在页写时序上可能有细微差异以数据手册为准。2. 硬件层最小系统、电平与上拉的三个关键决策2.1 最小系统板选型与供电确认STM32F103最小系统板市面上主要有两种一种是C8T6核心板另一种是带CH340串口和ST-Link接口的“开发板”。我建议用前者因为引脚全部引出接线自由度高而且便宜。供电方面最小系统板通常通过Micro USB或者排针输入5V板载的AMS1117-3.3会把5V降到3.3V给MCU供电。这里有个细节要注意AT24C02的工作电压范围是1.8V到5.5V看起来3.3V和5V都能用。但STM32F103的GPIO是3.3V电平如果你把AT24C02接到5V供电虽然I2C总线上的上拉电阻可以拉到3.3V但AT24C02的I2C引脚耐压是VCC0.3V5V供电时它的输入高电平阈值会变高3.3V可能识别不稳。所以最稳妥的方案是AT24C02也用3.3V供电和MCU同电平省去电平转换的麻烦。2.2 I2C上拉电阻的计算与选择I2C总线是开漏输出必须加上拉电阻才能产生高电平。很多新手直接拿两个10kΩ电阻往上一焊结果通信时好时坏用逻辑分析仪一看波形上升沿圆得像个馒头。上拉电阻的取值不是拍脑袋定的它和总线电容、通信速率直接相关。标准模式100kHz下上升时间tr的最大值是1000ns。总线电容Cb包括PCB走线电容、引脚电容和器件电容一般估算在100pF到200pF之间。上升时间公式是tr ≈ 0.847 × R × Cb。代入tr1000ns、Cb200pF算出来R最大约5.9kΩ。所以10kΩ在标准模式下勉强能用但如果你把速率提到400kHz快速模式tr最大只有300ns10kΩ就完全不够了得降到2.2kΩ到4.7kΩ。我实测下来3.3V供电、总线长度10cm以内、100kHz速率用4.7kΩ上拉最稳。如果你板子上已经有其他I2C设备带了上拉记得算并联后的等效阻值别重复加上拉导致阻值过低灌电流超过3mA会烧引脚。通信速率最大上升时间推荐上拉阻值Cb≈200pF实测稳定性100kHz1000ns4.7kΩ~10kΩ4.7kΩ最稳400kHz300ns1.8kΩ~4.7kΩ2.2kΩ较稳50kHz以下无严格要求10kΩ也可可用但波形差2.3 引脚连接与地址配置AT24C02的SOP-8引脚定义很清晰1脚A0、2脚A1、3脚A2、4脚GND、5脚SDA、6脚SCL、7脚WP、8脚VCC。A0/A1/A2是地址选择引脚接GND为0接VCC为1。AT24C02的7位从机地址固定前四位是1010后三位由A2/A1/A0决定。所以如果三个都接地7位地址就是1010000即0x50。写操作时地址字节是0xA0读操作是0xA1。WP是写保护引脚接GND允许正常读写接VCC则禁止写入。调试阶段建议直接接GND等代码稳定了再考虑要不要加写保护逻辑。我一般会在WP和GND之间留一个跳线帽方便切换。STM32F103C8T6的I2C1默认引脚是PB6SCL和PB7SDA这两个引脚需要配置为开漏复用模式。注意STM32的I2C引脚必须用开漏输出不能用推挽因为推挽会导致总线冲突时烧毁引脚。CubeMX里配置I2C时它会自动帮你设成开漏但如果你用寄存器直接写一定要手动设置GPIO_MODE_AF_OD。3. 软件架构CubeMX配置与HAL库的取舍3.1 CubeMX工程搭建的关键参数打开CubeMX选STM32F103C8T6第一步配置RCC把HSE设为Crystal/Ceramic Resonator这样系统时钟可以跑到72MHz。然后配置I2C1模式选I2C速度模式选Standard Mode或者Fast Mode我一般先用Standard Mode 100kHz调通再尝试400kHz。I2C的参数里有几个容易忽略的Clock No Stretch Mode要Disable因为AT24C02在写周期内会拉低SCL做时钟拉伸如果禁止了拉伸通信会失败。General Call Address Detection也Disable用不上。Own Address1随便填一个不冲突的值就行主机模式下其实用不到。时钟树配置里APB1的时钟是36MHzI2C挂在APB1上CubeMX会自动计算分频系数。你可以在Clock Configuration里看到I2C的时钟频率确保它和你要设置的通信速率匹配。3.2 HAL库I2C函数的选用逻辑HAL库提供了两套I2C API阻塞式的HAL_I2C_Mem_Write/Read和中断/DMA式的HAL_I2C_Mem_Write_IT/Read_IT。对于AT24C02这种低速设备阻塞式完全够用代码也简单。但要注意HAL_I2C_Mem_Write在写EEPROM时最后一个参数Timeout要设够因为EEPROM的写周期典型值是5ms最大可能到10ms。如果你设成100ms一般没问题。HAL_I2C_Mem_Write的函数原型是HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout);DevAddress是8位地址即0xA0。MemAddress是EEPROM内部字节地址0到255。MemAddSize填I2C_MEMADD_SIZE_8BIT因为AT24C02的地址是8位的。Size是要写入的字节数注意页写时不能跨页。3.3 为什么我最终选了软件I2C做对比硬件I2C在STM32F103上有个历史遗留问题早期批次的芯片I2C外设有bug在某些错误状态下会锁死总线需要重新初始化才能恢复。虽然HAL库做了不少容错但在强干扰环境下还是可能卡住。所以我建议你在调通硬件I2C之后再写一套软件I2C作为备用方案。软件I2C用两个GPIO模拟时序虽然速度慢但胜在可控出问题容易排查。软件I2C的代码结构很简单起始条件、停止条件、发送字节、接收字节、发送ACK、发送NACK这六个函数就能拼出所有操作。关键是延时函数要准100kHz下每个位周期10us高电平时间和低电平时间各5us左右。用DWT或者TIM做微秒延时比空循环靠谱。4. AT24C02读写时序的逐条拆解4.1 字节写与页写的区别AT24C02支持两种写模式字节写和页写。字节写就是每次写一个字节写完等5ms再写下一个。页写是一次性写最多8个字节一页这8个字节必须在同一页内即起始地址的低3位加上数据长度不能超过8。页写的优势是快8个字节只需要一个写周期。但如果你跨页了比如从地址0x07开始写4个字节地址会回卷到0x00把原来的数据覆盖掉。这是新手最容易踩的坑。我一般会在代码里加一个判断如果起始地址的低3位加上长度大于8就拆成两次页写。字节写的流程是起始条件 → 发送0xA0 → 等ACK → 发送内存地址 → 等ACK → 发送数据 → 等ACK → 停止条件 → 延时5ms。页写就是把数据部分连续发多个字节每发一个等一个ACK最后发停止条件。4.2 随机读与顺序读的实现读操作比写操作多一个步骤需要先写内存地址再重启总线发送读地址。随机读的流程是起始条件 → 发送0xA0 → 等ACK → 发送内存地址 → 等ACK → 起始条件重复起始→ 发送0xA1 → 等ACK → 接收数据 → 发送NACK → 停止条件。注意最后一个数据字节接收完后要发NACK告诉从机“我不需要更多数据了”然后主机发停止条件。如果你发的是ACK从机会继续输出下一个地址的数据形成顺序读。顺序读可以用来连续读取整个EEPROM的内容但要注意地址到255之后会回卷到0。HAL_I2C_Mem_Read函数内部已经帮你处理了重复起始条件你只需要调用它就行。但如果你想用软件I2C自己实现就要严格按上面的流程写。4.3 写周期与应答轮询AT24C02在收到写命令后内部会启动一个写周期把数据写入存储阵列。这个周期典型5ms最大10ms。在这期间AT24C02不会响应任何I2C命令包括它自己的地址。所以如果你在写周期内发起始条件加地址它会返回NACK。应答轮询就是利用这个特性写完之后不断发送起始条件加0xA0如果收到ACK说明写周期结束可以发下一个命令如果收到NACK就继续等。这样比固定延时5ms更高效因为实际写周期可能只有3ms。HAL库没有直接提供应答轮询函数需要自己写。void AT24C02_WaitReady(I2C_HandleTypeDef *hi2c) { uint32_t timeout 1000; while (HAL_I2C_IsDeviceReady(hi2c, 0xA0, 1, 10) ! HAL_OK) { if (--timeout 0) break; } }HAL_I2C_IsDeviceReady就是专门做这个的它发送地址并检查ACK返回HAL_OK说明设备就绪。5. 完整代码实现与逐行注释5.1 头文件与宏定义#include main.h #include i2c.h #define AT24C02_ADDR_WRITE 0xA0 #define AT24C02_ADDR_READ 0xA1 #define AT24C02_PAGE_SIZE 8 #define AT24C02_WRITE_TIMEOUT 100地址宏定义要区分读写虽然HAL_I2C_Mem_Write内部会自动处理读写位但软件I2C时需要手动用。5.2 字节写函数HAL_StatusTypeDef AT24C02_WriteByte(uint16_t addr, uint8_t data) { HAL_StatusTypeDef status; status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR_WRITE, addr, I2C_MEMADD_SIZE_8BIT, data, 1, AT24C02_WRITE_TIMEOUT); if (status ! HAL_OK) return status; AT24C02_WaitReady(hi2c1); return HAL_OK; }注意addr参数是uint16_t但AT24C02只有256字节实际只用低8位。传参时如果超过255HAL库会截断但最好在调用前自己检查。5.3 页写函数带跨页保护HAL_StatusTypeDef AT24C02_WritePage(uint16_t addr, uint8_t *data, uint16_t len) { uint16_t page_remain AT24C02_PAGE_SIZE - (addr % AT24C02_PAGE_SIZE); if (len page_remain) { len page_remain; // 截断到本页末尾 } HAL_StatusTypeDef status; status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR_WRITE, addr, I2C_MEMADD_SIZE_8BIT, data, len, AT24C02_WRITE_TIMEOUT); if (status ! HAL_OK) return status; AT24C02_WaitReady(hi2c1); return HAL_OK; }这个函数只写一页如果数据超过一页调用者需要自己循环。我一般会再包一层AT24C02_WriteBuffer自动处理跨页拆分。5.4 随机读函数HAL_StatusTypeDef AT24C02_ReadByte(uint16_t addr, uint8_t *data) { return HAL_I2C_Mem_Read(hi2c1, AT24C02_ADDR_READ, addr, I2C_MEMADD_SIZE_8BIT, data, 1, AT24C02_WRITE_TIMEOUT); }读操作不需要等写周期因为读不会触发内部写。但如果你刚写完立刻读还是要先等写周期结束。5.5 顺序读函数HAL_StatusTypeDef AT24C02_ReadBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { return HAL_I2C_Mem_Read(hi2c1, AT24C02_ADDR_READ, addr, I2C_MEMADD_SIZE_8BIT, buf, len, AT24C02_WRITE_TIMEOUT); }顺序读时地址会自动递增到255后回卷到0。如果你不想回卷读之前算好长度。6. 调试实录从波形到代码的排查思路6.1 逻辑分析仪抓不到ACK的三种可能第一次调试时我发完地址字节后逻辑分析仪显示第9个时钟周期是高电平说明从机没拉低SDA也就是没回ACK。这种情况一般有三个原因一是地址不对检查A0/A1/A2引脚的电平确认7位地址确实是0x50二是SDA/SCL接反了这个低级错误我犯过不止一次三是上拉电阻没接或者阻值太大导致高电平建立不起来。排查顺序建议从硬件开始先用万用表量SDA和SCL对VCC的电阻正常应该是上拉阻值。然后量AT24C02的VCC引脚确认有3.3V。最后用逻辑分析仪看波形如果起始条件都出不来那就是GPIO配置问题。6.2 写进去读出来不一样的原因有时候写数据返回HAL_OK但读出来是0xFF或者旧数据。最常见的原因是写周期没等够。AT24C02的写周期最大10ms如果你写完立刻读它还在忙读出来就是错的。解决办法就是用应答轮询或者干脆延时10ms。另一个原因是页写跨页了数据被回卷覆盖。比如你从地址0x06写4个字节实际只写进了0x06和0x07两个字节后两个字节跑到了0x00和0x01。这个用逻辑分析仪看地址字节就能发现。还有一种可能是WP引脚接了VCC写保护生效了。这时候写操作会返回HAL_OK但数据没写进去因为AT24C02内部忽略了写命令。量一下WP引脚电压就能确认。6.3 总线锁死的恢复方法STM32的硬件I2C在从机拉低SCL做时钟拉伸时如果主机正好复位SCL可能一直被拉低导致总线锁死。这时候HAL_I2C_Mem_Write会返回HAL_BUSY或者HAL_TIMEOUT。恢复方法是在初始化I2C之前把SCL配置为普通推挽输出手动发送9个时钟脉冲让从机释放总线。void I2C_BusRecovery(void) { GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_6; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, gpio); for (int i 0; i 9; i) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); } // 重新配置为I2C复用开漏 gpio.Mode GPIO_MODE_AF_OD; HAL_GPIO_Init(GPIOB, gpio); }这个函数在I2C初始化失败时调用能解决大部分总线锁死问题。6.4 常见问题速查表现象可能原因排查方法解决措施无ACK地址错误量A0/A1/A2电平修正地址或引脚无ACK上拉缺失量SDA/SCL对VCC电阻加4.7kΩ上拉写入后读出0xFF写周期未等逻辑分析仪看时序加应答轮询或延时数据错位页写跨页检查起始地址低3位拆分页写写不进WP接高量WP引脚电压WP接GND总线忙时钟拉伸锁死量SCL是否被拉低执行总线恢复波形上升沿缓上拉阻值过大逻辑分析仪看上升时间换4.7kΩ或更小高速下出错上拉不够计算tr与Cb降低速率或减小阻值7. 几个容易被忽略的实操细节7.1 电源去耦与PCB布局AT24C02的VCC引脚旁边一定要放一个0.1uF的陶瓷电容越近越好。EEPROM在写周期内电流会有波动没有去耦电容可能导致写失败。PCB走线时SDA和SCL尽量等长远离高频信号线如果板子上有DC-DC或者电机驱动I2C线最好走内层或者加地线屏蔽。7.2 写平衡与寿命管理AT24C02的擦写寿命是100万次看起来很多但如果你在同一个地址反复写比如每秒写一次不到12天就写废了。实际项目中要避免固定地址频繁写入可以用轮询的方式把数据分散到不同地址或者加一个缓存机制只在数据变化时写入。7.3 数据校验的必要性EEPROM虽然可靠但在强干扰环境下还是可能位翻转。我一般会在数据后面加一个CRC8校验读出来先校验再使用。如果校验失败就重读一次还失败就报错。这个习惯在工业项目里能省很多事。uint8_t CRC8_Calc(uint8_t *data, uint16_t len) { uint8_t crc 0x00; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x80) crc (crc 1) ^ 0x07; else crc 1; } } return crc; }这个CRC8多项式是0x07和很多传感器用的是一致的代码量小跑起来快。7.4 软件I2C的延时精度如果你用软件I2C延时函数别用HAL_Delay那个是毫秒级的太粗。用DWT的CYCCNT寄存器做微秒延时最准void DelayUs(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }DWT初始化时要先使能TRCENA位否则CYCCNT不走。这个延时精度在72MHz下能到14ns左右足够I2C用了。8. 从AT24C02延伸到其他I2C设备的思路AT24C02调通之后你会发现I2C的套路基本就这些起始、地址、ACK、数据、停止。换一个OLED屏无非是命令字节和数据字节的区别换一个温湿度传感器无非是寄存器地址和数据格式的区别。我建议你在AT24C02的代码基础上把读写函数抽象成通用的I2C_Mem_Write和I2C_Mem_Read把设备地址和寄存器地址作为参数传入这样后面加新设备只需要改地址和寄存器定义。另外如果你要接多个I2C设备注意地址冲突问题。AT24C02的地址可以通过A0/A1/A2改但有些传感器的地址是固定的比如SSD1306的地址通常是0x78或0x7A。如果冲突了要么用I2C多路复用器要么换一个I2C接口。STM32F103C8T6有两个I2CI2C1在PB6/PB7I2C2在PB10/PB11可以分担一下。最后说一个我自己的习惯每次调通一个I2C设备我都会把逻辑分析仪的波形截图保存下来标注好地址、数据、ACK的位置。下次遇到问题拿出来对比一下一眼就能看出哪里不对。这个习惯帮我省了无数个抓头发的夜晚。