STM32硬件I2C读写EEPROM:AT24C02驱动开发与调试全攻略
2026/9/7 5:36:27 网站建设 项目流程

简介:面向 STM32F407 嵌入式开发者的 HAL 库 I2C 读写 AT24C02 EEPROM 完整工程源码包。资源以实际可编译的 Keil MDK 工程为核心,覆盖 RCC/GPIO/I2C 初始化、主模式收发、AT24C02 分页读写与错误处理,适合需要快速掌握 HAL 库 I2C 驱动或移植存储模块的开发者。

压缩包共 1532 个文件,约 7.47 MB,包含 950 个 C 源码、270 个头文件、71 个启动汇编文件,以及链接脚本、工程配置、HEX 固件、ioc 图形化配置和调试辅助文件,目录结构完整。其中的 txt 说明和 map/axf 构建产物,可辅助理解编译链接过程、定位工程配置问题。已有 423 人学习下载,具备一定参考价值。

这套工程既提供了 STM32F407 HAL 库调用的完整例程,又展示了 AT24C02 的读写时序、寄存器地址与分页机制;通过阅读底层文件还能梳理时钟树、复用引脚配置以及 I2C 中断/DMA 设计思路,对入门 I2C 通信和 EEPROM 应用有直接帮助。 做STM32开发的人,大部分都会碰到一个需求:用I2C接口去读写EEPROM,最常见的就是AT24C02。这个需求几乎贯穿所有需要掉电保存参数的项目——不管是保存设备校准值、记录运行状态,还是临时缓存数据。我最近在STM32F407上用HAL库重写了这块驱动,顺手把整个流程整理出来,从CubeMX配置到最终调试,该踩的坑和该注意的细节都会提到,对刚接触HAL库I2C的人应该会很有帮助。

这篇文章我会重点聊几件事:为什么优先用硬件I2C而不用模拟I2C、AT24C02的地址格式和页写机制是怎么回事、HAL库的Mem_Write和Mem_Read到底怎么用、以及调试时最常见的几个“卡死”和“数据错乱”问题怎么解决。全程直接给代码、给参数、给步骤,不绕弯子。

1. 方案选型与整体设计思路

1.1 硬件I2C还是模拟I2C

先说大家最纠结的问题:I2C用硬件外设还是用GPIO模拟?我这次选择STM32F407的硬件I2C1外设,原因是实际项目里需要在读写EEPROM的同时还要驱动OLED和传感器,多个I2C设备挂同一条总线上,硬件I2C的时序稳定性更好,CPU占用也更低。

再说说两者的区别,方便你根据自己的项目情况取舍:

  • 硬件I2C:时序由芯片内部硬件产生,不占用CPU,配合中断或DMA可以做到高效收发。但F407的I2C外设老生常谈的“BUSY锁定”问题确实让人头疼,不过HAL库已经做了不少处理,只要配置正确,实际体验并不差。
  • 模拟I2C(GPIO翻转):用IO口手动拉高拉低模拟时序,胜在灵活、不易锁死,缺点就是CPU要一直参与,主频紧张、或者总线速率要求高的时候不太合适。

我这次用的是硬件I2C1,对应引脚PB6(SCL)和PB7(SDA),代码上配合HAL库的阻塞式API,简单直接,逻辑也清晰。如果你的板子上I2C引脚不是这两个,记得在CubeMX里对应改一下。

另外一个要提前想清楚的点是:总线上接几个设备、设备地址分别是什么。AT24C02的硬件地址由A0、A1、A2三个引脚决定,我的板子上这三个引脚全部接地,地址是0xA0(8位写地址)。要注意的是,HAL库的HAL_I2C_Mem_Write函数的DevAddress参数填的是“8位地址”,也就是0xA0,不是0x50,这个非常容易弄反,后文排查部分还会提到。

1.2 AT24C02芯片特性与I2C协议要点

AT24C02是一个2Kb(256字节)的串行EEPROM,内部按8字节一页组织,总共32页。I2C通信时支持单字节读写,也支持页写。写操作最麻烦的地方在于页写不能跨页,一次最多写8个字节,超过8字节就会回卷到当前页的起始位置,把已经写入的数据覆盖掉,这是最容易踩的坑。

再一个是写周期等待。AT24C02每次写操作完成后,内部会进行一轮编程(tWR),典型值为5ms,在这段时间内芯片不响应任何命令,你必须等它完成。代码里用轮询ACK的方式最稳妥,也就是写完后尝试发一个空的起始信号加地址字节,芯片有ACK应答就说明它空闲了。

速率方面,AT24C02支持标准模式100kHz和快速模式400kHz。我这次用的100kHz标准模式,信号裕量更充裕,调试时更容易排除速率过高带来的问题。如果你是批量高速场景,400kHz也行,但要确保总线上拉电阻和寄生电容不会让边沿太缓。

2. CubeMX配置与工程初始化

2.1 引脚分配与时钟树检查

打开STM32CubeMX,芯片型号选STM32F407VET6(按你自己的板子来),在Pinout & Configuration视图里找到I2C1,将Mode设为I2C(标准模式或快速模式都可以,取决于你的需求)。CubeMX会自动把PB6和PB7分配给I2C1_SCL和I2C1_SDA。

然后到Clock Configuration视图里确认APB1总线时钟。这一步很多人会忽略,但很关键——I2C1是挂在APB1上的,如果APB1时钟配置不对,I2C的实际通信速率会和你预期的不一致。F407的APB1上限是42MHz,我的工程里系统主频168MHz,APB1分频系数为4,APB1=42MHz,I2C外设时钟就是42MHz。

CubeMX会自动根据I2C外设时钟和目标速率计算分频寄存器值,你在I2C1的Parameter Settings里把Speed Mode设为Standard Mode(100kHz)即可,下面的Timing Register会自动生成,一般不需要手动改。如果改成Fast Mode,时钟源42MHz时400kHz也足够,但注意总线上挂多个设备时100kHz更不容易出错。

2.2 生成代码与初始化检查

配置完成后直接生成工程(选MDK-ARM或STM32CubeIDE都行)。初始化代码集中在main.c里,关键部分是:

static void MX_I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 100000; hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; hi2c1.Init.OwnAddress1 = 0; hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 = 0; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); } }

这里有几个参数要说明:ClockSpeed是目标速率,设为100000即100kHz;OwnAddress1是STM32自己的从机地址,我们没有让STM32做从机,填0就行;AddressingMode是7位地址模式,AT24C02也工作在7位地址模式,一致就可以了。

另外我习惯生成后检查一下GPIO初始化,确认PB6和PB7被配成了开漏输出并开启了上拉。HAL库的GPIO_Init结构体里会设置好GPIO_MODE_AF_OD(复用开漏模式),上拉电阻默认也打开了,这是I2C正常工作的基础。如果发现SDA和SCL没有配上拉,通信波形会很差,甚至完全不通。

3. AT24C02驱动核心代码与实现

3.1 单字节读写:HAL库Mem函数用法

直接上手写核心代码。HAL库为我们封装好了I2C内存操作的API,最常用的就是HAL_I2C_Mem_Write和HAL_I2C_Mem_Read,它们和我们之前用模拟I2C时手动拼地址、发数据的过程是对应的,只是HAL库把这一整套时序全部封装好了。

先看单字节读:

uint8_t AT24C02_ReadByte(uint8_t memAddr) { uint8_t data = 0; if (HAL_I2C_Mem_Read(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100) != HAL_OK) { /* 读失败处理 */ } return data; }
  • 第一个参数是I2C句柄,指向hi2c1。
  • 第二个参数是设备地址,注意这里是0xA0。
  • 第三个参数是EEPROM内部的内存地址,也就是0~255。
  • 第四个参数I2C_MEMADD_SIZE_8BIT表示内部地址是8位宽度,因为AT24C02一共就256字节,一个字节足够表达。如果你的EEPROM容量更大,比如AT24C256有32KB,那就要用16位地址模式。
  • 第五、第六个参数分别是要读的数据缓冲区和长度,这里读1字节。
  • 最后一个参数是超时时间,单位毫秒,阻塞模式下如果总线异常会卡在这里直到超时返回。

单字节写也非常类似:

uint8_t AT24C02_WriteByte(uint8_t memAddr, uint8_t data) { if (HAL_I2C_Mem_Write(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100) != HAL_OK) { return 1; } /* 等待写周期结束 */ AT24C02_WaitForWriteComplete(); return 0; }

注意点:Mem_Write发送的是“起始信号 + 设备地址 + 内存地址 + 数据”的完整帧,和Master_Transmit不一样。如果写成HAL_I2C_Master_Transmit,就把内存地址也当成数据发出去了,EEPROM会理解成“往某个内存地址写一个数”,但由于没有指定地址,数据会乱掉。所以这里必须用Mem系列函数。

3.2 页写与跨页处理逻辑

AT24C02一次最多写8字节,且必须在同一页内。让用户自己保证每次写入都在8字节范围内不现实,所以驱动里最好封装一个“跨页自动拆分”的写函数。

我是这样设计的:先算出当前页中剩余可写的字节数,再根据剩余要写的长度决定分几次写。比如从地址0x05开始写10个字节,第一页还剩3个字节(0x05、0x06、0x07),那第一次写3字节,第二次从新页(0x08)开始写7字节。

void AT24C02_WriteBytes(uint8_t memAddr, uint8_t *data, uint16_t len) { uint8_t pageSize = 8; uint16_t offset = 0; while (offset < len) { /* 当前这一页还能写多少字节 */ uint8_t remainInPage = pageSize - (memAddr % pageSize); /* 本批写入字节数 = min(剩余页空间, 剩余总长度) */ uint8_t writeLen = (len - offset) < remainInPage ? (len - offset) : remainInPage; if (HAL_I2C_Mem_Write(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, &data[offset], writeLen, 100) != HAL_OK) { /* 写失败处理 */ } AT24C02_WaitForWriteComplete(); memAddr += writeLen; offset += writeLen; } }

这个函数的重点就是remainInPage的计算:用当前地址对页大小8求余,余数表示已经用了多少字节,8减掉这个数就是页内剩余。注意边界情况,如果memAddr正好是8的倍数,remainInPage等于8,整页全写,也是正确的。

跨页处理是EEPROM驱动里最实用的一个函数,建议直接复制进你的项目里,以后不管写几字节,只要调这个函数就行,不用每次自己操心分页的事情。

3.3 写周期等待:轮询ACK方法

写完数据之后不能立刻发下一条I2C命令,因为AT24C02内部正在把缓存区数据写入非易失存储,这个时间大约5ms。在这段时间内芯片就像“死”了一样,对任何命令都不应答。如果强行读写,HAL_I2C_Mem_Write可能会一直等到超时,返回HAL_TIMEOUT,非常浪费时间。

最可靠的做法是轮询设备是否就绪:不断发送设备地址,直到芯片返回ACK。HAL库可以用HAL_I2C_IsDeviceReady来实现:

void AT24C02_WaitForWriteComplete(void) { HAL_I2C_IsDeviceReady(&hi2c1, AT24C02_DEV_ADDR, 1000, 10); }

HAL_I2C_IsDeviceReady的第三个参数是重试次数,第四个是每次重试的间隔时间。它会发送一个起始信号和地址字节,如果EEPROM忙,不会ACK,HAL库会一直重试,直到成功为止。这里的1000次重试对EEPROM的5ms写周期来说完全足够,实测不会卡很久。

如果不想轮询,也可以简单粗暴地HAL_Delay(10),但轮询ACK的好处是:一旦芯片提前就绪,能立刻继续执行,效率更高。批量写入大量数据时,能明显感觉到速度快一些。

3.4 多字节连续读取

读取不像写入有页限制,AT24C02支持从任意地址连续读任意长度,因为读操作不会触发内部编程,也就不存在覆盖风险。所以多字节读取可以一次性完成:

void AT24C02_ReadBytes(uint8_t memAddr, uint8_t *data, uint16_t len) { HAL_I2C_Mem_Read(&hi2c1, AT24C02_DEV_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, data, len, 100); }

注意HAL_I2C_Mem_Read的时序:起始信号 → 设备地址(写方向)→ 内存地址 → 重启信号 → 设备地址(读方向)→ 连续读数据 → 停止信号。HAL库会帮你处理好这些,不用手动操作。唯一要注意的是缓冲区长度要够,别把栈上一个小数组的地址传进去然后读一大堆数据,那就越界了。

4. 调试实测与常见问题排查

4.1 实测:读写的逻辑验证

驱动写完以后,先在main函数里做一个简单的回环测试。我把一个8字节的数组写进EEPROM,再从另一个地址读回来,用串口打印到调试助手确认。

第一轮实测就很顺利,数据全部读回一致。但有一个现象值得说一说:复位MCU之后,我直接调AT24C02_ReadByte去读上次写入的数据,发现前两次读回来的是0xFF,后面才正常。原因不在于EEPROM本身,而是板子刚上电时电源还在爬升,EEPROM内部电压不稳定,还没来得及进入正常状态。解决办法很简单,在读取前加一个至少10ms的延时,等电源稳定后再操作。如果你也遇到类似“上电立刻读EEPROM读不到”的问题,优先怀疑这个。

再一个是确认地址边界。我把写入起始地址设为0x05,写入长度10字节,通过逻辑分析仪观察I2C总线波形,可以看到总共发起了两次写操作,第一次3字节,第二次7字节,刚好符合跨页逻辑。如果你没有逻辑分析仪,也可以在page写函数里加一个计数变量,把写入分段情况通过串口打印出来,效果一样。

4.2 I2C BUSY锁定问题

F407硬件I2C最出名的问题就是总线忙锁死。表现是程序跑到某个I2C操作后一直卡住,用调试器暂停查看HAL状态,会看到hi2c1->State不是READY,或者说总线上检测到BUSY,但实际没有设备在通信。

我在调试过程中也遇到过这个情况,原因是EEPROM写周期还没结束的时候,我手动用调试器暂停过程序,然后又强行单步,导致I2C状态机和实际总线状态错位。修复方法分两种:

  1. 软件复位I2C外设:调用HAL_I2C_DeInit再重新HAL_I2C_Init,相当于把外设恢复到初始状态。
  2. 硬件复位总线:把SCL引脚配置成普通GPIO输出,手动翻转9个时钟周期,再恢复正常复用模式,把挂在总线上的从机状态机复位到空闲态。

考虑到实际项目中I2C总线上可能挂着多颗设备,一次异常就软复位整个外设,可能影响其他设备。所以我更常用第二种方法,把下面的函数封装起来,遇到BUSY卡住直接调用:

void I2C_Bus_Reset(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; HAL_I2C_DeInit(&hi2c1); GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6 | GPIO_PIN_7, GPIO_PIN_SET); 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); } MX_I2C1_Init(); }

这里SCL翻转9次,是为了让总线上可能处于半传输状态的从机完整接收一个错误帧并恢复空闲。翻转节奏不要太快,我加了1ms延时,确保从机时钟极值都能识别到。

4.3 常见问题速查表

我在调试过程中把所有可能遇到的问题整理成了这张表,供你对照排查:

现象可能原因解决办法
读回数据全是0xFF上次没写进去;写周期没等够;WP引脚被拉高写保护检查写等待函数是否调用;确认WP引脚接GND
写操作返回HAL_TIMEOUT设备地址错误;总线上拉电阻缺失;I2C被锁死确认地址是0xA0;检查上拉电阻;执行总线复位函数
写入数据乱序/交叉跨页未处理,越过8字节回卷覆盖用跨页拆分函数替代直接Mem_Write
读地址错误使用的是Master_Transmit而非Mem_Transmit改成HAL_I2C_Mem_Read
上电后前几次读是0xFFEEPROM上电未稳定初始化后加10~50ms延时
SDA/SCL波形高电平很低上拉电阻过大或总线电容太大检查上拉电阻,常用2.2kΩ~4.7kΩ

这些坑我一个一个踩过来,其中最隐蔽的就是跨页回卷,它不会报任何错误,就是悄悄把你之前写的字节覆盖掉,如果有日志记录类的需求,排查起来会有点绕。

4.4 扩展:从EEPROM到其他I2C设备

把AT24C02调通了之后,你会发现HAL库I2C操作的套路基本一样,换其他I2C设备都能很快上手。比如我后来在同一个工程里挂了OLED(SSD1306)和温湿度传感器(SHT30),核心区别就是设备地址不同、寄存器映射不同,但读写的骨架还是那套:HAL_I2C_Mem_Write、HAL_I2C_Mem_Read,或者有些传感器需要先写寄存器地址再读数据,用HAL_I2C_Master_Transmit加HAL_I2C_Master_Receive组合也能完成。

有一个小技巧想分享给刚开始用HAL库的朋友:遇到I2C设备通信不正常,先写一个I2C地址扫描函数,遍历0x01~0x7F所有地址,看哪些地址有ACK响应,能很快确认设备和MCU之间有没有建立起基本的物理层通信。下面这段代码我用过很多次,直接放在工程里备用:

void I2C_Scan(void) { for (uint8_t addr = 1; addr < 128; addr++) { uint8_t devAddr = addr << 1; if (HAL_I2C_IsDeviceReady(&hi2c1, devAddr, 1, 5) == HAL_OK) { printf("Found I2C device at 0x%02X\r\n", devAddr); } } }

这套调试思路对所有I2C从机都适用:先扫地址,确认物理层通了;再用逻辑分析仪抓时序,确认数据帧正确;最后才深入分析寄存器层面的问题。按照这个顺序,绝大部分I2C问题都能在半小时内定位到原因。

最后再分享一个这次调试中的体会:HAL库的确把I2C时序封装得很简洁,但正因为封装得好,反而容易让人忽略时序细节。我在调试跨页写入时,一度以为HAL_I2C_Mem_Write自己会处理分页,结果仔细看芯片手册才发现页边界是硬件定的,软件必须主动规避。所以不管用标准外设库还是HAL库,I2C时序和EEPROM页结构这两个基础概念始终是绕不开的,把它们吃透了,用任何库都不会被“黑盒”坑到。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询