STM32F407 HAL库 I2C 读写 AT24C02 EEPROM 实战指南 简介这份资源面向使用STM32F407进行嵌入式开发的初学者与进阶工程师聚焦HAL库下I2C总线读写AT24C02 EEPROM的完整实现帮助解决外设通信配置繁琐、时序调试困难等常见问题。压缩包共1532个文件以950个C源文件与270个头文件为核心辅以txt说明、汇编启动文件、链接脚本及工程配置文档整体约7.47MB结构完整便于直接导入或对照学习。资源围绕I2C时钟与GPIO复用配置、HAL_I2C_Init初始化、主模式收发函数调用、AT24C02页写与随机读时序、错误处理及DMA传输等关键环节展开读者可据此掌握从寄存器抽象到数据持久化的完整链路。目前已有427人学习下载适合需要快速搭建EEPROM读写验证环境、理解HAL库I2C驱动框架的开发者参考。1. 从一次读不出数据的现场说起STM32F407 的 I2C 到底难在哪焊好板子、接上 AT24C02、烧进程序结果串口打印出来的全是 0xFF或者第一次能读、复位后就卡死——这是很多人在 STM32F407 上用 HAL 库调 I2C 读写 EEPROM 时的真实开局。问题往往不在 EEPROM 本身而在 F407 的 I2C 外设时序、HAL 库的状态机、以及 AT24C02 的写周期等待这三件事叠在一起。这份资源围绕 STM32F407 HAL 库 I2C AT24C02 这条链路把初始化、字节读写、页写、地址对齐、超时处理完整走一遍适合刚上手 F4 系列、被 I2C 卡过、或者想找一份能直接对着改的参考实现的嵌入式开发者。下面按“先搞懂外设和器件、再动手写、最后排坑”的顺序展开每一步都落到能编译、能上板的程度。2. 先把 I2C 外设和 AT24C02 的时序对齐初始化参数怎么定2.1 F407 的 I2C 时钟源与 100kHz/400kHz 的换算STM32F407 的 I2C1/I2C2/I2C3 挂在 APB1 上APB1 的时钟默认来自系统时钟分频。以常见的 168MHz 主频为例APB1 通常配成 42MHz。HAL 库的HAL_I2C_Init里有一个ClockSpeed字段它并不是直接写分频寄存器而是由 HAL 根据你传入的I2C_InitTypeDef反算CCR和TRISE。很多人以为填了 100000 就一定是 100kHz其实还要看I2C_InitTypeDef里的DutyCycle和OwnAddress1是否合理。标准模式 100kHz 下CCR PCLK1 / (2 * 100000)快速模式 400kHz 下若DutyCycle I2C_DUTYCYCLE_2则CCR PCLK1 / (3 * 400000)。TRISE则对应最大上升时间标准模式 1000ns快速模式 300ns换算成 PCLK1 周期数再加 1。这些值 HAL 会替你算但前提是ClockSpeed和DutyCycle匹配。AT24C02 支持 400kHz但如果你用杜邦线飞线、上拉电阻偏大400kHz 很容易在示波器上看到上升沿塌肩这时候退回 100kHz 反而更稳。/* I2C1 初始化100kHz 标准模式7 位地址 */ hi2c1.Instance I2C1; hi2c1.Init.ClockSpeed 100000; /* 100kHzAT24C02 稳妥档 */ hi2c1.Init.DutyCycle I2C_DUTYCYCLE_2; /* 标准模式该字段无实际作用 */ hi2c1.Init.OwnAddress1 0x00; /* F407 作主机自身地址随意 */ hi2c1.Init.AddressingMode I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode I2C_DUALADDRESS_DISABLE; hi2c1.Init.GeneralCallMode I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode I2C_NOSTRETCH_DISABLE; /* 允许从机时钟拉伸 */ if (HAL_I2C_Init(hi2c1) ! HAL_OK) { Error_Handler(); }这段代码里最容易被忽略的是NoStretchMode。AT24C02 在内部写周期期间会拉低 SCL 做时钟拉伸如果把它设成I2C_NOSTRETCH_ENABLE主机不等从机读回来就是脏数据。另一个是OwnAddress1F407 作主机时这个地址不参与通信但 HAL 仍会写进 OAR1填 0 即可不要填成 AT24C02 的 0xA0否则容易在调试时看花眼。2.2 AT24C02 的 7 位地址、页边界与写周期AT24C02 容量 2Kbit即 256 字节内部按 8 字节一页组织共 32 页。它的 7 位从机地址是1010 A2 A1 A0其中 A2/A1/A0 由芯片的 1/2/3 脚电平决定。市面上多数模块把这三脚接地所以 7 位地址是0x50HAL 库要求传入的是左移一位后的 8 位形式即0xA0写、0xA1读。这里有个高频翻车点HAL 的HAL_I2C_Mem_Write第一个参数是DevAddress它内部会自己处理读写位所以你传0x50 1也就是0xA0而不是传0x50。页边界是第二个坑。AT24C02 的页写缓冲只有 8 字节如果你从地址 0x06 开始连续写 8 个字节写到第 3 个字节时就会回卷到本页开头 0x00把前面的数据覆盖掉。正确做法是按页拆分或者干脆用字节写。写周期tWR最大 5ms这期间芯片不响应任何命令如果你紧接着发下一次写就会 NACK。常见做法是每次写完后延时 5ms或者用应答轮询ACK polling判断芯片是否空闲。参数典型值说明容量256 字节2Kbit32 页页大小8 字节跨页写会回卷从机地址0x507 位HAL 传 0xA0写周期 tWR最大 5ms期间不响应最大时钟400kHz飞线建议 100kHz提示如果你手头的模块 A2/A1/A0 不是全接地先把地址算清楚再写代码否则会一直 NACK却误以为是时序问题。3. 用 HAL 库把字节读写和页写跑通代码逐段拆3.1 单字节写与写周期等待单字节写是最基础的入口用HAL_I2C_Mem_Write就能完成。它的参数依次是I2C 句柄、从机地址、内存地址EEPROM 内部偏移、内存地址长度、数据指针、数据长度、超时。AT24C02 的内部地址是 8 位所以MemAddressSize填I2C_MEMADD_SIZE_8BIT。写完后必须等tWR否则下一次操作直接失败。/* 向 AT24C02 指定地址写一个字节写完等待 5ms 写周期 */ HAL_StatusTypeDef AT24C02_WriteByte(I2C_HandleTypeDef *hi2c, uint8_t memAddr, uint8_t data) { HAL_StatusTypeDef st; st HAL_I2C_Mem_Write(hi2c, 0xA0, /* 7 位地址 0x50 左移一位 */ memAddr, /* EEPROM 内部偏移 0~255 */ I2C_MEMADD_SIZE_8BIT, /* AT24C02 地址 8 位 */ data, /* 待写数据 */ 1, /* 长度 1 字节 */ 100); /* 超时 100ms */ if (st ! HAL_OK) { return st; } HAL_Delay(5); /* 等内部写周期结束 */ return HAL_OK; }这里的HAL_Delay(5)是保守做法实际tWR最大 5ms留一点余量更稳。如果你在中断里调用不能用HAL_Delay得改成状态机或者用HAL_GetTick做非阻塞延时。超时参数 100ms 是给总线异常兜底的正常一次写不到 1ms但如果 SDA 被拉死100ms 后 HAL 会返回HAL_TIMEOUT避免程序卡死。3.2 单字节读与随机读时序读操作分两步先写内部地址dummy write再发起读。HAL 的HAL_I2C_Mem_Read把这两步封装在一起你只需要传同样的从机地址和内存地址。注意读的时候从机地址要传0xA1吗不需要HAL 内部会根据读操作自动把最低位置 1你统一传0xA0即可。/* 从 AT24C02 指定地址读一个字节 */ HAL_StatusTypeDef AT24C02_ReadByte(I2C_HandleTypeDef *hi2c, uint8_t memAddr, uint8_t *data) { return HAL_I2C_Mem_Read(hi2c, 0xA0, /* 统一传写地址HAL 自动处理读位 */ memAddr, I2C_MEMADD_SIZE_8BIT, data, 1, 100); }读操作没有写周期问题但有一个隐性依赖如果前一次写刚发出就立刻读芯片还在忙读会 NACK。所以读之前要么确保上一次写已经延时够要么在应用层做重试。我一般会在读函数里加最多 3 次重试每次间隔 2ms这样即使上层调用顺序不严谨也不至于直接返回错误。3.3 页写与跨页拆分页写能提高连续写入效率但必须处理页边界。AT24C02 页大小 8 字节页起始地址是 8 的倍数。写之前先算当前地址到本页末尾还剩多少字节取剩余长度和待写长度的较小值写完这一页再等 5ms然后继续下一页。/* 连续写自动按 8 字节页边界拆分每页写完等 5ms */ HAL_StatusTypeDef AT24C02_WriteBuffer(I2C_HandleTypeDef *hi2c, uint8_t memAddr, const uint8_t *buf, uint16_t len) { HAL_StatusTypeDef st; while (len 0) { uint8_t pageRemain 8 - (memAddr % 8); /* 本页剩余空间 */ uint8_t chunk (len pageRemain) ? len : pageRemain; st HAL_I2C_Mem_Write(hi2c, 0xA0, memAddr, I2C_MEMADD_SIZE_8BIT, (uint8_t *)buf, chunk, 100); if (st ! HAL_OK) { return st; } HAL_Delay(5); /* 每页写完等 tWR */ memAddr chunk; buf chunk; len - chunk; } return HAL_OK; }这段代码的关键在pageRemain的计算。假设memAddr 6pageRemain 2那么第一次只写 2 字节到地址 6、7然后地址跳到 8重新计算剩余 8 字节继续写。如果不做这个拆分从地址 6 写 8 字节芯片会在写到地址 7 后回卷到地址 0把 0、1 覆盖掉这种 bug 在调试时非常隐蔽因为读出来的数据“看起来有变化”但位置全错。注意HAL_I2C_Mem_Write的pData参数类型是uint8_t *传const指针时编译器可能报警加一个强制转换即可不要为了消警告把源数据改成非 const。4. 调 I2C 读写 EEPROM 时最容易翻车的几个点4.1 现象第一次能读复位后全 0xFF原因通常是上拉电阻缺失或阻值过大。F407 的 I2C 引脚是开漏输出必须有外部上拉。很多模块自带上拉但如果你用裸芯片飞线忘了接 4.7k 上拉总线在空闲时无法回到高电平第一次上电靠引脚寄生电容可能勉强通信复位后电容状态变了就彻底失败。解决方法是量 SDA/SCL 对 VCC 的电阻正常应在 4.7k 左右若开路就补上拉。4.2 现象写进去读出来是上一次的数据这是写周期没等够的典型表现。AT24C02 在tWR期间不响应任何命令如果你写完立刻读读操作会 NACKHAL 返回错误但有些人的代码忽略了返回值继续用旧缓冲区看起来就像“读到了上一次的数据”。解决方法是每次写后强制延时 5ms或者用 ACK 轮询循环发起写操作直到返回HAL_OK再继续。4.3 现象连续写 16 字节后 8 字节覆盖了前 8 字节页回卷。AT24C02 页写缓冲 8 字节跨页写不会自动进位到下一页而是回到本页开头。解决方法是按 8 字节边界拆分或者干脆全部用单字节写。单字节写虽然慢但不会踩这个坑适合对写入速度不敏感的场景。4.4 现象HAL_I2C_Mem_Write 返回 HAL_BUSY总线状态机卡在某个状态没释放。常见原因是上一次通信超时后没有正确复位 I2C 外设或者从机把 SDA 拉死。解决方法是调用HAL_I2C_DeInit再HAL_I2C_Init或者在初始化时加__HAL_I2C_DISABLE/__HAL_I2C_ENABLE强制复位。更彻底的做法是在HAL_I2C_ErrorCallback里做总线恢复把 SCL 配置成普通 GPIO 手动发 9 个时钟脉冲再重新初始化 I2C。4.5 现象地址 0x50 传进去一直 NACKHAL 库要求传 8 位地址即0x50 1 0xA0。如果你直接传0x50HAL 会把它当成 7 位地址再左移实际发出的地址变成0xA0没错但有些版本的 HAL 对DevAddress的处理是直接写入 DR 寄存器不额外移位这时候传0x50就会错。稳妥做法是统一传0xA0并在注释里写清楚这是左移后的结果。5. 进阶用 ACK 轮询替代死延时以及上板验证方法死等 5ms 在单任务裸机里没问题但如果你的系统里 I2C 操作频繁或者跑在 RTOS 下5ms 的阻塞会拖慢其他任务。更高效的做法是 ACK 轮询写完之后不延时而是循环发起一次“写 0 字节”或者“读当前地址”的操作如果芯片还在忙会返回 NACKHAL 返回错误继续重试一旦返回HAL_OK说明芯片空闲可以立即进行下一次读写。这样平均等待时间远小于 5ms因为实际tWR通常在 2~3ms 就完成了。/* ACK 轮询替代固定 5ms 延时芯片空闲立即返回 */ HAL_StatusTypeDef AT24C02_WaitReady(I2C_HandleTypeDef *hi2c, uint32_t timeoutMs) { uint32_t start HAL_GetTick(); while (HAL_I2C_IsDeviceReady(hi2c, 0xA0, 3, 10) ! HAL_OK) { if ((HAL_GetTick() - start) timeoutMs) { return HAL_TIMEOUT; } } return HAL_OK; }HAL_I2C_IsDeviceReady会向从机地址发一个起始条件加地址帧如果从机 ACK 就返回HAL_OK。它的第三个参数是试验次数第四个是每次试验的超时。这里设 3 次、每次 10ms配合外层timeoutMs做总超时。实际用的时候把AT24C02_WriteByte里的HAL_Delay(5)换成AT24C02_WaitReady(hi2c, 10)写入吞吐能提升不少。上板验证我一般分三步走。第一步不写数据只调HAL_I2C_IsDeviceReady确认地址和硬件连通这一步过了说明总线和地址没问题。第二步写一个字节到地址 0x00延时再读回来确认读写通路。第三步写 0x00 到 0xFF 共 256 字节再整体读回比对确认页边界和全地址范围都正常。第三步最容易暴露跨页问题如果比对失败先看失败位置是不是落在 8 的倍数附近是的话就是页拆分没做对。验证步骤操作通过标准地址探测HAL_I2C_IsDeviceReady返回 HAL_OK单字节回环写 0x00 再读读回值等于写入值全地址回环写 256 字节再读逐字节比对一致从那以后我每次调 I2C EEPROM都强制先跑一遍地址探测再跑全地址回环不跳过。因为页回卷和写周期这两个坑单字节测试根本测不出来只有全地址写读才能暴露。希望帮到你。本文还有配套的精品资源点击获取