做嵌入式这些年,I2C和EEPROM这对组合几乎每个项目里都能见到。STM32F103配AT24C02更是入门开发板上最经典的搭配:一颗AT24C02只有256字节,却能解决掉电保存参数、系统校准值记忆这类让人头疼的问题。我自己最早写I2C读写时也踩过不少坑,一直读到0xFF、总线锁死、串口打印乱码……这次就把从硬件连法、STM32CubeMX配置、HAL库代码到现场排查的全过程整理出来。目标是让准备接触I2C通信协议和EEPROM的新手少走弯路,也让已经能跑通例程、但遇到异常时不知道从哪下手的开发者有个排查思路。
1. 一块AT24C02和一排I2C引脚,为什么这对组合能流行这么多年
1.1 AT24C02能存什么、不能存什么
AT24C02标称2Kbit,也就是256字节。简单换算:存一个8位数组,最多256个;存一个int型变量需要2字节;存结构体数据,10到20个字节就能覆盖大部分设备参数。比如设备的开关机状态、定时器设定值、校准系数,这些需要掉电不丢失的数据,正好是EEPROM的强项。很多基于STM32F103的整机项目里,它的角色就是“小参数仓库”:上电时读出来,运行时改一改,掉电前写回去。
它不能替代大容量Flash存储,也不适合频繁写日志。这一点新手很容易忽略,以为接个EEPROM就可以随便存,实际上256字节非常小,且写入次数和写入时间都有讲究。应用场景上,温度控制器、小型家电、工控仪表里都能看到类似方案。拿一个简单设置保存功能来说:用户通过按键和旋钮修改参数后,系统要记住这些参数,下次上电还能恢复。直接存在MCU片内Flash也可以,但Flash按页擦写、寿命和中断时机不一定合适;外挂AT24C02的好处是接口标准、地址直观、单字节可写,代码写起来非常顺。
- 容量:2Kbit = 256字节
- 接口:I2C
- 写周期:约5ms(不同批次有一定差异)
- 寿命:标称约100万次擦写
AT24C02本身工作电压范围很宽,常见后缀支持1.8V到5.5V,这让它既能和3.3V的STM32F103直接搭配,又能兼容某些5V主控的旧电路。但兼容不等于可以随便接,后面硬件部分的电平转换就需要认真对待。
1.2 I2C报文:起始、停止、应答与地址帧的底层逻辑
I2C通信协议用两条线完成所有传输:SCL时钟线加SDA数据线。主机负责产生时钟,从机在时钟节拍下收数据或发数据。一次完整访问分四步:起始条件、地址帧、数据帧、停止条件。起始条件是SCL保持高电平时SDA从高变低;停止条件相反,SCL保持高电平时SDA从低变高。这两段边沿是所有I2C从机识别“开始/结束”的唯一依据,也是最容易在逻辑分析仪上判断的波形特征。
地址帧里,AT24C02的7位设备地址是固定的:高4位是1010,低3位由A0/A1/A2引脚的电平决定。如果三个引脚都接地,设备地址就是0x50。I2C协议在发送时会把7位地址左移一位,最后一位填入读写方向,0表示写、1表示读,所以访问地址字节其实写成0xA0(写)或0xA1(读)。HAL库里的HAL_I2C_Mem_Write正是接收这个含方向的8位地址,用起来比纯寄存器操作更直观。
应答机制也很关键。每个字节发送结束后,接收方要在第9个时钟拉低SDA,表示ACK;如果接收方没准备好或地址错误,它会保持SDA高电平,这就是NACK。读EEPROM时,读到最后一个字节后主机要回一个NACK,然后发停止条件,告诉从机“不要再发数据了”。这些细节如果只是在代码里看返回值,往往不会注意到;一旦用逻辑分析仪抓波形,就能很清楚看到ACK/NACK的位置。
2. 硬件连接里最容易翻车的三件事:上拉电阻、开漏模式、电平转换
2.1 推挽与开漏:为什么I2C引脚必须是开漏
很多STM32F103新手在初始化GPIO时习惯选推挽输出,结果I2C连不通。原因要从I2C总线仲裁机制说起。I2C允许一主多从,甚至多主机共存,所有设备在SDA和SCL上都是“线与”关系:任何一个设备输出低电平,整条线就是低。开漏模式正好匹配这种逻辑,设备只能主动拉低,释放后就靠上拉电阻回到高电平,因此不会出现两个设备一个拉高一个拉低导致引脚冲突的情况。推挽输出则会强行把引脚拉到高或低,两个设备一旦同时输出相反电平,轻则通信错乱,重则损伤引脚。
所以不管用STM32F103硬件I2C还是软件模拟I2C,SDA和SCL引脚都应该配置为复用开漏模式,并在外部接上拉电阻。常见阻值选4.7k到10k,总线速度快(400kHz)时倾向于4.7k或更小。很多现成的AT24C02模块上已经集成4.7k或10k上拉,接到开发板上直接用即可;如果自己飞线,一定要补上两颗上拉电阻到VCC,否则SDA释放不了,从机永远收不到正确电平。
2.2 3.3V与5V混接时的电平转换判断
“I2C需要电平转换吗”这个问题要看供电和逻辑电平。如果STM32F103以3.3V供电,AT24C02也以3.3V供电,两边直接连完全没问题,因为开漏加上拉到3.3V,高电平都在3.3V以内。如果AT24C02模块是5V供电,就需要谨慎一些:STM32F103的I2C引脚通常是5V容忍的,在开漏配置下可以承受5V上拉,但稳妥起见还是建议做电平转换。
常见做法是加一个双向电平转换电路,用MOS管加两个上拉电阻,SCL和SDA各一路,5V侧和3.3V侧分别上拉。这个电路成本不高,但能避免长期运行风险。另一种更简单的判断思路:如果高低电平的阈值满足要求,开漏结构本身就能“被动”适配。具体来说,只要从机识别高电平的阈值不高于3.3V,而且主机引脚能容忍外部上拉电压,那么5V供电的从机配合3.3V上拉仍然可能工作。但我不建议依赖这种临界方案,毕竟AT24C02不同后缀版本的VIH会有差异,真出问题排查起来很麻烦。
我在实际工程里统一做法是:3.3V的主控就买3.3V供电的EEPROM模块,或者自己设计时把VCC、上拉都做成3.3V。这样少一层转换电路,少一类潜在故障点。做5V转3.3V电路时也要注意上拉电阻接在哪一侧:如果上拉接在5V,SCL和SDA高电平就是5V,主机侧即使引脚容忍,也不一定是干净的数字高电平,最好还是加转换。
2.3 最小系统板上的I2C引脚选择与实测供电
STM32F103的I2C外设有I2C1和I2C2。I2C1默认在PB6/SCL、PB7/SDA,也可重映射到PB8/PB9;I2C2在PB10/PB11。从最小系统板引出排针时,优先确认这几个引脚有没有被其他外设占用。有些低引脚数封装或特定开发板把PB6/PB7和串口、LED复用了,选脚之前先看原理图。
CubeMX里分配引脚很直观:在Pinout视图里点击PB6选择I2C1_SCL,点击PB7选择I2C1_SDA。如果选成软件模拟I2C,就不需要在外设里配置I2C,而是把两个GPIO配置成开漏输出,用代码翻转时序。我实测过的最小系统板上,3.3V供电稳定、模块上拉电阻为4.7k时,100kHz和400kHz两种速率都能正常读写。供电方面要注意别在EEPROM和传感器共用一条细长杜邦线时把电压拉崩,实测中AT24C02写操作瞬间电流不大,但带上拉电阻和逻辑分析仪后,尽量单独走地线更稳妥。
3. 从CubeMX到HAL库:把工程跑起来之前先解决三个前置问题
3.1 Pack包下载与器件选择:找不到芯片时该怎么办
用STM32CubeMX打开新工程,第一步是选芯片型号。很多新手的CubeMX版本比较老,在MCU Selector里搜STM32F103C8T6时找不到,或者打开某型号后外设列表不全,这通常是STM32F1系列支持包(Pack)没装好。STM32CubeMX的Help -> Manage embedded software packages里能看到已安装的Pack列表,需要下载“STM32Cube FW_F1”这类支持包。如果下载很慢,可以直接用浏览器从服务器下载对应的Pack包文件,再在Manage窗口里点From Local安装。装完后重新打开软件,搜索型号就能正常显示。
工程生成后如果拿命令行或VSCode下载程序,常见做法是用OpenOCD加一个ST-Link或DAP-Link。这部分和I2C本身没关系,但调试EEPROM时离不开串口打印和反复烧录,所以建议一开始就把下载环境配好:VSCode里配置好Flash算法和.reset halt命令,能明显缩短试错周期。如果手头没有调试器,也可以把生成的HEX文件用串口ISP方式下载,但对I2C调试来说,有调试器能看寄存器状态和变量值,效率会高很多。
3.2 I2C外设初始化参数怎么填
在CubeMX里把I2C1模式选为I2C后,Parameter Settings有几个关键项:时钟速度、上升时间、自身地址。对于AT24C02,时钟速度一般填100000(标准模式)或400000(快速模式)。AT24C02标准模式支持100kHz,快速模式支持400kHz,但要注意总线上拉电阻和寄生电容能不能撑住400kHz,如果连线过长或上拉阻值偏大,高速模式会不稳定。我建议第一版先用100kHz跑通,再用400kHz做压力测试。
- I2C Speed Mode:Standard / Fast
- Clock Speed:100000
- Duty Cycle:Low Duty(100kHz时无所谓,400kHz时可配置)
- Own Address:主机可以保持默认,访问从机时用不到
生成代码后,I2C初始化函数里会调用HAL_I2C_MspInit,这里完成GPIO配置。如果CubeMX设置正确,PB6/PB7的模式应该是AF_OpenDrain(复用开漏),而不是Output Push-Pull。如果手改过GPIO配置,建议回到CubeMX重新生成,避免迷失在寄存器配置里。想了解主发送、主接收状态跳转的具体细节,可以直接查STM32F103系列参考手册(RM0008)里I2C外设相关章节,那是比任何博客都权威的资料来源。
3.3 硬件I2C和软件模拟I2C,第一次做该选谁
网上对STM32F103硬件I2C的吐槽不少,说它容易卡Busy、事件标志处理复杂。这确实是老一代芯片在工程圈子里留下的“历史包袱”。但其实在单主机、单从机这种最简单场景下,硬件I2C配合HAL库完全能用,我自己做过的项目里很少真正卡死。如果总在调试时遇到超时,多数是事件标志或重复起始条件处理不当,而不是芯片本身的问题。
第一次做建议先用HAL库的硬件I2C,因为代码量少,逻辑清晰:HAL_I2C_Mem_Write和HAL_I2C_Mem_Read两个函数就能覆盖AT24C02的大部分读写需求。如果遇到实在解决不了的总线锁死问题,再退回到软件模拟I2C。软件模拟的好处是每个时序都掌握在自己手里,出错时能用GPIO翻转代替SCL强制恢复,排障更直接。坏处是CPU占用高,多任务项目里要处理临界区。两者不是对立关系,我的习惯是硬件I2C做通用接口,软件模拟I2C作为特殊场景下的备用方案。
4. 读写AT24C02的完整代码走读:从单字节到页写入
4.1 写一个字节:HAL_I2C_Mem_Write的细节
以I2C1连接AT24C02、器件地址0x50为例,写一个字节到地址0x00的代码如下:
uint8_t write_data = 0x5A; HAL_StatusTypeDef status; status = HAL_I2C_Mem_Write(&hi2c1, (0x50 << 1) | 0, // 0xA0,写方向 0x00, // EEPROM内部字节地址 I2C_MEMADD_SIZE_8BIT, &write_data, 1, HAL_MAX_DELAY); if (status != HAL_OK) { printf("I2C write fail\r\n"); } else { HAL_Delay(6); // 等待内部写周期 }HAL函数的第三个参数MemAddress就是AT24C02内部的字节地址,因为AT24C02容量只有256字节,用8位地址宽度就够了。HAL_MAX_DELAY表示一直等待,超时机制关闭,调试时很方便。实际项目中我更推荐设置一个具体超时时间,比如100ms,避免I2C总线异常时程序死等。
写周期是AT24C02最容易忽略的点。每写完一字节或一页,芯片内部会把数据从缓存搬到非易失存储区,这段时间叫写周期,典型5ms。在这期间芯片不响应任何总线命令。所以写完必须延时或查询应答恢复信号,否则紧接着读操作会读到旧数据或返回NACK。我习惯在写完每个关键参数后延时6到10ms,宁可保守一点,也不去赌芯片速度。
4.2 读一个字节:地址的“伪写”机制
读AT24C02不能直接“给我地址0x03的数据”,而是要先执行一次“伪写”:把器件地址和内部字节地址发出去,让芯片内部的地址指针指向目标位置。接着是重复起始条件或停止后重新起始,再发送读方向的器件地址,从机才开始输出该地址的数据。
HAL_I2C_Mem_Read把这三段操作封装了:
uint8_t read_data = 0; HAL_StatusTypeDef status; status = HAL_I2C_Mem_Read(&hi2c1, (0x50 << 1) | 1, // 0xA1,读方向 0x00, I2C_MEMADD_SIZE_8BIT, &read_data, 1, HAL_MAX_DELAY); if (status == HAL_OK) { printf("read: 0x%02X\r\n", read_data); }这里从机地址参数填0xA1,HAL库会根据读写方向自动组织事件序列。如果只写一次地址就立刻调用Mem_Read,而中间没有延时,可能读到的是写周期结束前的旧数据,或者得到超时错误。这种问题在调试串口打印里看起来像是“读函数偶发失败”,实际上是EEPROM还没完成内部写入。
4.3 页写入的坑:跨页回卷问题
AT24C02的页大小为8字节。所谓页写入,是指一次连续写最多8字节,如果超过页边界,地址会回到当前页的起始位置,把已经写入的数据覆盖掉。这就是“跨页回卷”问题。举个例子:当前内部地址是7,连续写3字节,第二字节会写到地址7之后的“8”,但第三字节回到地址0,覆盖掉刚刚写进地址0的数据。
所以我写了一个按页边界切分的批量写函数:
uint8_t eeprom_write_bytes(uint16_t addr, uint8_t *buf, uint16_t len) { uint16_t offset = addr; uint16_t remain = len; while (remain > 0) { uint16_t page_end = (offset / 8 + 1) * 8; // 本页结束地址 uint16_t chunk = page_end - offset; if (chunk > remain) { chunk = remain; } if (HAL_I2C_Mem_Write(&hi2c1, 0xA0, offset, I2C_MEMADD_SIZE_8BIT, buf, chunk, 100) != HAL_OK) { return 1; } HAL_Delay(6); offset += chunk; buf += chunk; remain -= chunk; } return 0; }这个函数的核心是计算offset所在页的下一页边界,取本次能写的最大字节数。读操作不受页边界限制,可以连续读任意长度,只要不超出256字节空间范围。因此读参数时通常一次把所有参数读完,写参数时则要刻意按页切分。这是种典型的“看手册知道、不写代码想不到”的坑。寄存器或传感器里可能也有类似分块要求,提前规划数据结构能避免很多麻烦。
5. 实测中踩过的四个坑及完整排查链路
5.1 总线卡在低电平:复位和时钟恢复
遇到过最典型的I2C故障是:程序跑起来后第一次通信就超时,用示波器量SDA一直低电平。原因通常是从机处于某种等待状态:它以为总线事务还没结束,等主机继续发时钟,但这时主机侧可能因为异常退出而发出了停止条件,两边状态错位。从机抓住SDA不放,整条总线被拉低。
排查第一步不是改代码,而是确认硬件:把EEPROM先拆掉,只留上拉电阻,看SDA能不能恢复高电平。如果能恢复,问题基本在从机状态机;如果不能恢复,检查接线、上拉和供电。上拉电阻虚焊、杜邦线接触不良都会导致SDA持续低。
确认是从机锁死后的恢复方法很简单:手动把SCL线配置成GPIO输出,连续翻转9个时钟周期,让从机内部状态机跳出等待;跨越9个时钟还不行,就重新上下电。在STM32F103项目里,我还在异常处理分支里加了这样的恢复函数:
void i2c_bus_recover(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); delay_us(5); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); delay_us(5); } HAL_I2C_DeInit(&hi2c1); HAL_I2C_Init(&hi2c1); }每次进入I2C通信前调用HAL_I2C_GetState检查状态,如果已经卡在HAL_I2C_STATE_BUSY,就执行一次恢复函数。这个方法在多数锁死场景下有效,但根源还是要靠正确时序避免。
5.2 读到0xFF:应答与地址偏移
第二个高频现象是读EEPROM返回0xFF。我一开始遇到时怀疑芯片坏了,换了几颗还是一样。后来用逻辑分析仪抓波形才发现,主机发送的器件地址是0xA1,但AT24C02的A0引脚被模块默认拉高了,实际地址是0x51,对应地址字节0xA2/0xA3。也就是说,地址偏移不是代码问题,而是硬件引脚跳线问题。AT24C02地址的低3位由A0/A1/A2决定,不同模块可能有不同的默认跳线,有的模块把A0、A1、A2通过排针引出,不接时内部下拉到地,有的模块默认上拉。设备地址计算必须按实际电路来。
如果地址没错,读回0xFF还有一个常见原因:芯片处于写周期内。写完立即读,从机不响应主机,HAL返回超时或读到全1。解决方法就是写后延时5ms以上,或者在写完轮询ACK恢复。这里想提醒一点:AT24C02读操作每次执行很快,但写操作不能太频繁,即“读几乎无磨损,写有寿命约束”。
5.3 用逻辑分析仪看时序:怎么看ACK和NACK
调试I2C最有效的工具是逻辑分析仪,采样率24MHz就足够看100kHz和400kHz的波形。抓取时把SCL接通道0,SDA接通道1,地线共用。然后运行一次写一字节再读一字节的操作,分析仪就能解码出完整的“start、address、ACK、data、NACK、stop”。
看ACK/NACK的方法是找SCL的第9个上升沿,观察SDA在此时是低还是高。低就是ACK,高就是NACK。如果主机发完地址后看到NACK,首先怀疑器件地址;如果主机读数据时从机发出NACK,可能是在读尾时主机返回NACK导致,也可能是从机内部状态异常。软件里也能区分这些状态:HAL_I2C_Mem_Read返回HAL_ERROR时,我习惯把返回值和具体调用参数一起通过串口打印,再去对比逻辑分析仪波形,一次就能定位是地址、时序还是写周期问题。
5.4 串口打印与HAL库串口DMA接收首帧丢失
调试过程中用得最多的输出手段是串口。单纯用串口发送打印数据非常简单,但如果开了DMA接收,很多STM32F103用户会遇到“DMA接收首帧丢失”。这也是很多开发者在调试外设时会被带偏的问题:MCU上电后第一次收到上位机发来的数据,数组第一个字节丢了或者缓冲区错位。
我之前排查过一次:配置好UART的RX DMA和空闲中断后,第一次收到完整数据帧时首字节被覆盖成0或0xFF,后续帧正常。定位下来是因为上位机在MCU运行到UART_DMA_Start之前就已经有数据到达,DMA目标缓冲区还没有正确建立。更常见的变体是空闲中断标志处理顺序不对:中断里先判断IDLE再取剩余数据长度,但首帧被认为“刚好在启动边缘”,处理时拿到的长度偏大。解决方法是上电后主动清一次DMA中断标志,或者先启动一次空接收,再使能空闲中断。这其实和I2C关系不大,但串口打印是EEPROM调试的一部分,就顺手放到这里。实测中我把“首帧丢弃”当作握手信号来用:上位机先发一条命令,MCU收到后回一条Ready,之后的通信就都稳定了。
6. 让EEPROM在项目里更可靠:掉电保存、写寿命与扩展思路
6.1 掉电保存的典型应用设计与实测
EEPROM最常见的价值就是掉电保存。以一个小型家电场景为例:设备在运行期间根据按键调整目标温度,每次修改后把温度值写入AT24C02的地址0x00,上电启动时读取该地址恢复设置。代码结构和前面读写单字节的示例完全一致,但项目落地时要多做几件事:一是数据校验,二是版本标记。
只存一个裸数值是不可靠的。我习惯在EEPROM里划分一个参数区,开头放一个魔数(如0x5A),接下来是参数版本号、数据本身、校验字节。上电读出来先校验魔数和校验字节,不通过就恢复默认值。这样可以避免写一半断电导致参数损坏后,系统用脏数据启动。AT24C02容量小,但存几十个字节的参数区完全够。实测时我故意在写的过程中按下电源键,断电几十次后重新上电,系统都能正确识别参数无效并恢复默认值,没有出现“校验通过但参数混乱”的情况。
6.2 写次数限制与磨损均衡
AT24C02标称擦写寿命约100万次。听起来很多,但如果你用1秒写一次的频率,大约11天就会接近这个数字。所以设计上要尽量避免频繁写同一个地址。我总结了几条经验:只在参数变化时写,不要周期性写;把写操作放到按键释放或设置完成后,而不是放主循环里连续写;结构体数据最好整块写,避免一个字段一个字段地随机写。
磨损均衡的思路也比较简单:参数区划分成多个槽位,每次写入轮换使用不同槽位,并记录一个递增的序列号。上电时遍历所有槽位,取序列号最新且校验通过的那一份。这样可以把写压力分散到多个地址,显著延长整体寿命。对AT24C02这种256字节的小容量芯片,分4个槽位、每个槽位存几十字节的参数,仍然够用。这种方案在掉电保存需求比较高的设备里非常实用。
6.3 一颗不够用:I2C扩展和多片级联
如果256字节不够用,第一反应是换更大容量的EEPROM,比如AT24C04、AT24C16。它们指令和地址格式略有差异:AT24C04以上的器件地址里,会借用器件地址的最低一位或几位作为内部页地址扩展位,也就是说从机的“逻辑地址”不再完全由A0/A1/A2决定。换芯片前一定要重看手册的地址分配表,不能照搬AT24C02代码。
如果项目里已经有多个I2C从机(传感器、显示、EEPROM),还可能出现地址冲突或总线负载问题。AT24C02本身通过A0/A1/A2可以最多在一条I2C总线上挂8片,地址从0x50到0x57,这已经能满足很多需求。总线上挂了大量设备且地址不够分时,可以用I2C多路复用器,把总线分成多路,各路独立选通。这样扩展后的软件复杂度会有一定提升,每个外设都要先“选通道再通信”,但比硬编码多个总线的维护成本小。对STM32F103来说,只要时钟速度不超过从机限制,一条总线带多颗外设是没问题的。
我现在的习惯是把EEPROM读写封装成一个独立模块,所有设备参数统一用结构体描述,写函数内部处理跨页和延时,读函数上电时调用一次。这样不管后面换AT24C02、AT24C04还是增加多路复用器,应用层代码都不需要大改。I2C这套协议学了不亏,后面遇到OLED、传感器、时钟芯片,都是同一套起始、应答、地址、数据帧的套路,把这次踩坑的细节记牢了,其他外设上手会快很多。最后再分享一个小习惯:每次把EEPROM芯片焊到板子上之前,先用万用表量一遍A0、A1、A2的实际电平,再写死到代码注释里,能省掉后续大量“为什么读回来全是0xFF”的排查时间。