
简介这份资源面向使用STM32F407进行嵌入式开发的工程师与学习者聚焦HAL库下I2C总线读写AT24C02 EEPROM的完整实现。内容涵盖RCC与GPIO时钟配置、I2C外设初始化、主模式收发函数调用、AT24C02页写与随机读时序以及超时与错误状态检查等关键环节适合需要掌握片上外设驱动与数据持久化存储的中级开发者参考。资源包共1532个文件以950个C源文件与270个头文件为主体另含启动汇编、链接脚本、工程配置与少量说明文本压缩包约7.47MB工程结构完整可直接导入调试。目前已有427人学习下载。通过该工程可快速理解I2C通信流程与EEPROM读写逻辑并在此基础上扩展DMA传输、多设备总线仲裁与稳定性优化为实际项目中的数据存储方案提供可复用的代码基础。1. 从一次“玄学”读写出错说起这套 AT24C02 驱动到底能解决什么如果你在用 STM32F407 的 HAL 库调 I2C 读写 AT24C02大概率遇到过这种情况单步调试一切正常全速跑起来读出来的数据偶尔错一位或者干脆卡在HAL_I2C_Mem_Read里等到超时。这不是你代码写错了而是 EEPROM 的页写时序、应答轮询和 HAL 库的超时机制没对齐。这套资源就是围绕 STM32F407 HAL 库 I2C 外设读写 AT24C02 这条链路把初始化、字节读写、页写、连续读、写保护处理全部拆开讲清楚。它适合正在做参数存储、掉电保存、设备序列号写入这类功能的嵌入式开发者尤其是从标准库转 HAL 库、对 I2C 时序还没完全吃透的人。你拿到的不只是一段能跑的代码而是一套能解释“为什么这么写”的配置逻辑。2. 硬件连接与 CubeMX 配置先把 I2C 外设的底层参数定下来2.1 AT24C02 的引脚与地址规则AT24C02 是 2Kbit 容量的 I2C EEPROM换算成字节就是 256 字节页大小 8 字节。它一共 8 个引脚实际接线的只有四个VCC、GND、SCL、SDA。A0、A1、A2 三个地址引脚决定器件在 I2C 总线上的 7 位从机地址。AT24C02 的固定前缀是1010后面三位由 A0~A2 的硬件电平决定。如果三个脚全部接地7 位地址就是1010000即 0x50。HAL 库函数里填的地址参数需要左移一位变成 0xA0 作为写地址、0xA1 作为读地址。很多人第一次调不通就是直接把 0x50 填进HAL_I2C_Mem_Read的 DevAddress 参数结果自然没有应答。WP 引脚是写保护接高电平时芯片进入只读状态接低电平才允许写入。如果你发现写入后读出来还是旧数据先拿万用表量一下 WP 脚的电平这个坑我见过太多次。SDA 和 SCL 上拉电阻一般取 4.7kΩ 到 10kΩSTM32F407 的 I2C 引脚是开漏输出没有外部上拉的话总线根本拉不高示波器上看就是一条爬不上去的斜线。2.2 CubeMX 里 I2C 参数怎么填在 CubeMX 里选 I2C1模式选I2C速度模式一般选Standard Mode或Fast Mode。AT24C02 支持 100kHz 和 400kHz但实际布线较长或者上拉电阻偏大时建议先用 100kHz 调通再往 400kHz 提。Clock Speed 这一栏填 100000 或 400000CubeMX 会自动算 CCR 和 TRISE 的值。下面这张表是我在 STM32F407 主频 168MHz 下常用的两组配置参数标准模式快速模式I2C Clock Speed100000 Hz400000 HzDuty Cycle2:12:1Analog FilterEnableEnableDigital Filter00上拉电阻4.7kΩ2.2kΩ生成代码后MX_I2C1_Init函数里会看到hi2c1.Init.ClockSpeed等字段被赋值。如果你后续要动态改速度直接改这个结构体再调HAL_I2C_Init就行但注意改完要重新使能外设。2.3 GPIO 模式与复用配置I2C 的 SCL 和 SDA 必须配置为复用开漏模式CubeMX 里选I2C1_SCL和I2C1_SDA的复用功能后GPIO 模式会自动设为GPIO_MODE_AF_OD上拉设为GPIO_PULLUP速度等级建议GPIO_SPEED_FREQ_HIGH。这里有个细节STM32F407 的内部上拉电阻大约 40kΩ做 I2C 上拉太弱所以外部上拉电阻不能省。我一般会在原理图里固定放两颗 4.7kΩ 电阻不管内部上拉开不开外部都焊上。/* I2C1 初始化片段由 CubeMX 生成后手动补充部分参数 */ hi2c1.Instance I2C1; hi2c1.Init.ClockSpeed 100000; /* 先用 100kHz 调通 */ hi2c1.Init.DutyCycle I2C_DUTYCYCLE_2; /* 占空比 2:1 */ hi2c1.Init.OwnAddress1 0; /* 主机模式不用管自身地址 */ 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设为DISABLE表示允许从机拉伸时钟。AT24C02 在写入周期内会拉低 SCL 告诉主机“我还在忙”如果禁止时钟拉伸主机就会在从机没准备好时强行发时钟读出来的数据必然出错。DutyCycle在标准模式下其实影响不大但快速模式下 2:1 和 16:9 的取舍会影响高低电平持续时间一般选 2:1 兼容性更好。3. HAL 库读写 AT24C02从字节写到页写的完整实现3.1 单字节写与写周期等待AT24C02 的单字节写流程是主机发启动条件发设备写地址发一个字节的内存地址发一个字节的数据发停止条件。HAL 库把这个过程封装成了HAL_I2C_Mem_Write。但关键点在于EEPROM 收到停止条件后并不会立刻完成内部写入而是进入一个约 5ms 的内部写周期这段时间内它不会应答任何总线请求。如果你紧接着发下一次读写就会因为收不到 ACK 而返回 HAL_ERROR 或 HAL_TIMEOUT。/** * brief 向 AT24C02 指定地址写入一个字节 * param pData: 待写入字节的指针 * param addr: EEPROM 内部字节地址范围 0x00~0xFF * retval HAL_OK 表示成功 */ HAL_StatusTypeDef AT24C02_WriteByte(I2C_HandleTypeDef *hi2c, uint8_t addr, uint8_t data) { HAL_StatusTypeDef status; uint32_t tickstart HAL_GetTick(); /* 等待上一次写周期结束最多等 10ms */ do { status HAL_I2C_IsDeviceReady(hi2c, 0xA0, 3, 10); if (status HAL_OK) break; } while ((HAL_GetTick() - tickstart) 10); if (status ! HAL_OK) return status; /* 发起单字节写 */ status HAL_I2C_Mem_Write(hi2c, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100); return status; }这段代码里HAL_I2C_IsDeviceReady是关键。它的作用是发一个空地址探测从机是否应答如果 EEPROM 还在写周期内就不会回 ACK函数返回 HAL_BUSY 或 HAL_ERROR。循环等待直到设备就绪或者超过 10ms 超时。0xA0是写地址I2C_MEMADD_SIZE_8BIT表示内存地址是 8 位AT24C02 的地址范围只有 256 字节所以用 8 位地址模式。最后一个参数100是超时时间单位毫秒对于单字节写来说足够。3.2 页写与跨页边界处理AT24C02 的页大小是 8 字节。页写的意思是你一次可以连续写最多 8 个字节但前提是这 8 个字节不能跨越页边界。页边界就是地址能被 8 整除的位置比如 0x00、0x08、0x10 等。如果你从地址 0x06 开始写 4 个字节写到 0x09 就跨页了EEPROM 内部会回卷到当前页的开头也就是 0x00把后面的数据覆盖掉。这个行为是芯片设计决定的不是 bug但如果你不知道就会丢数据。/** * brief 页写函数自动处理跨页拆分 * param addr: 起始地址 * param pData: 数据缓冲区 * param len: 数据长度 * retval HAL_OK 表示全部写入成功 */ HAL_StatusTypeDef AT24C02_WritePage(I2C_HandleTypeDef *hi2c, uint8_t addr, uint8_t *pData, uint16_t len) { HAL_StatusTypeDef status; uint16_t written 0; while (written len) { /* 计算当前页还能写几个字节 */ uint8_t page_remain 8 - (addr % 8); uint8_t chunk (len - written page_remain) ? (len - written) : page_remain; /* 等待写周期结束 */ uint32_t tickstart HAL_GetTick(); do { status HAL_I2C_IsDeviceReady(hi2c, 0xA0, 3, 10); if (status HAL_OK) break; } while ((HAL_GetTick() - tickstart) 10); if (status ! HAL_OK) return status; /* 写当前页内的数据 */ status HAL_I2C_Mem_Write(hi2c, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, pData[written], chunk, 100); if (status ! HAL_OK) return status; addr chunk; written chunk; } return HAL_OK; }这个函数的逻辑是每次循环先算当前地址距离页边界还剩几个字节然后只写这么多。写完一页后地址前进继续下一轮。addr % 8得到的是当前地址在页内的偏移8 - offset就是本页剩余空间。如果剩余数据长度小于页剩余空间就只写剩余长度。这样无论从哪个地址开始写、写多长都不会跨页回卷。注意每次写之前都要等写周期因为页写虽然一次写多个字节但内部写周期仍然是约 5ms。3.3 连续读与随机读读操作比写操作简单因为读不会触发内部写周期不需要等待。AT24C02 支持当前地址读、随机读和顺序读。HAL 库的HAL_I2C_Mem_Read可以直接实现随机读和连续读。/** * brief 从 AT24C02 连续读取多个字节 * param addr: 起始地址 * param pData: 接收缓冲区 * param len: 读取长度 * retval HAL_OK 表示成功 */ HAL_StatusTypeDef AT24C02_ReadBytes(I2C_HandleTypeDef *hi2c, uint8_t addr, uint8_t *pData, uint16_t len) { return HAL_I2C_Mem_Read(hi2c, 0xA1, addr, I2C_MEMADD_SIZE_8BIT, pData, len, 100); }这里设备地址填0xA1是 0x50 左移一位后加 1 得到的读地址。I2C_MEMADD_SIZE_8BIT同样表示 8 位内存地址。读取长度可以超过页大小因为读操作没有页边界限制EEPROM 会自动递增地址读到 0xFF 后回卷到 0x00。超时时间给 100ms 足够因为读操作不涉及内部写周期。3.4 写保护与数据校验WP 引脚拉高时芯片只读写入操作会返回 HAL_ERROR 或者看似成功但实际没写进去。我一般会在初始化时把 WP 拉低写入完成后如果需要保护再拉高。另外重要数据写入后建议立刻读回来比对确认写入成功。/** * brief 写入并校验一个字节 * retval HAL_OK 表示写入且校验通过 */ HAL_StatusTypeDef AT24C02_WriteVerify(I2C_HandleTypeDef *hi2c, uint8_t addr, uint8_t data) { uint8_t readback 0; HAL_StatusTypeDef status; status AT24C02_WriteByte(hi2c, addr, data); if (status ! HAL_OK) return status; status AT24C02_ReadBytes(hi2c, addr, readback, 1); if (status ! HAL_OK) return status; return (readback data) ? HAL_OK : HAL_ERROR; }这个校验函数在写入后立刻读回如果数据不一致就返回错误。对于存储设备序列号、校准参数这类不能出错的数据我建议都走一遍校验。代价是多一次读操作但换来的是确定性。4. 避坑与排查I2C 读写 EEPROM 最常见的五个翻车点4.1 现象HAL_I2C_Mem_Read一直返回 HAL_TIMEOUT原因通常有三个一是设备地址填错把 7 位地址 0x50 直接填进去而不是 0xA1二是 SDA 或 SCL 没有上拉电阻总线一直处于低电平三是 EEPROM 正在写周期内没有等待就发起读操作。排查时先用示波器看 SCL 和 SDA 有没有波形如果没有波形说明 GPIO 复用没配对或者外设没使能。如果有波形但地址字节后没有 ACK就是地址错了或者芯片没供电。如果地址有 ACK 但数据阶段超时检查是不是在写周期内发起了读。4.2 现象写入后读出来是旧数据先量 WP 引脚电平高电平就是写保护生效了。如果 WP 是低电平检查写入函数有没有等写周期。AT24C02 的写周期典型值 5ms最大 10ms如果你写完立刻读芯片还没完成内部写入读到的自然是旧数据。另外检查页写有没有跨页跨页会导致数据回卷覆盖看起来就像没写进去。4.3 现象连续写多个字节时只有第一个字节正确这是典型的页写跨页问题。比如从地址 0x07 开始写 4 个字节第一个字节写到 0x07第二个字节本应写到 0x08但 0x08 是下一页的起始地址EEPROM 内部地址计数器会回卷到 0x00把后面的数据写到 0x00 和 0x01。解决办法就是用 3.2 节的页写函数自动按页拆分。4.4 现象I2C 总线死锁SCL 被从机拉低不释放这种情况通常发生在主机复位后从机还在写周期内或者总线受到干扰导致状态机错乱。STM32F407 的 I2C 外设有总线恢复机制但 HAL 库没有直接提供。我一般会在初始化前手动模拟几个 SCL 脉冲把从机状态清掉把 SCL 配成普通 GPIO 输出发 9 个时钟脉冲再把 SDA 拉高发停止条件最后重新初始化 I2C 外设。这个操作在MX_I2C1_Init之前做能解决大部分死锁。4.5 现象CubeMX 生成的代码里 I2C 速度设为 400kHz 但实际通信失败400kHz 对总线上拉电阻和布线电容有要求。如果上拉电阻是 10kΩ上升沿会变缓400kHz 下可能采不到高电平。把上拉电阻换成 2.2kΩ 到 4.7kΩ 试试。另外检查DutyCycle设置快速模式下 2:1 和 16:9 会影响时序余量如果从机兼容性一般先用 100kHz 调通再提速。5. 进阶技巧用结构体映射和双备份提升参数存储可靠性5.1 把参数结构体直接映射到 EEPROM 地址实际项目里很少一个字节一个字节地存参数更常见的做法是定义一个结构体把整个结构体写到 EEPROM 的固定地址。AT24C02 只有 256 字节结构体不能太大但存一组校准参数、设备配置足够了。/* 参数结构体总大小 16 字节放在 EEPROM 0x10 起始地址 */ typedef struct { uint16_t device_id; /* 设备编号 */ uint16_t baudrate; /* 通信波特率 */ uint8_t calib_offset; /* 校准偏移 */ uint8_t work_mode; /* 工作模式 */ uint8_t reserved[10]; /* 预留扩展 */ } DeviceConfig_t; #define CONFIG_ADDR 0x10 /** * brief 保存配置到 EEPROM */ HAL_StatusTypeDef Config_Save(I2C_HandleTypeDef *hi2c, DeviceConfig_t *cfg) { return AT24C02_WritePage(hi2c, CONFIG_ADDR, (uint8_t *)cfg, sizeof(DeviceConfig_t)); } /** * brief 从 EEPROM 加载配置 */ HAL_StatusTypeDef Config_Load(I2C_HandleTypeDef *hi2c, DeviceConfig_t *cfg) { return AT24C02_ReadBytes(hi2c, CONFIG_ADDR, (uint8_t *)cfg, sizeof(DeviceConfig_t)); }这里用(uint8_t *)cfg把结构体指针强转成字节指针sizeof(DeviceConfig_t)得到结构体总字节数。注意结构体要做字节对齐否则不同编译器下大小可能不一致。我一般会在结构体里显式加reserved字段凑齐 4 字节对齐避免编译器插入填充字节导致读写长度对不上。5.2 双备份加校验字防止写入过程中掉电EEPROM 最大的风险是写入过程中掉电导致数据写了一半。解决办法是在 EEPROM 里存两份配置每份后面跟一个校验字。加载时先读第一份校验通过就用不通过再读第二份两份都不通过就加载默认值。区域起始地址长度内容备份 A0x1016 字节配置数据校验 A0x202 字节0x5A5A 表示有效备份 B0x3016 字节配置数据校验 B0x402 字节0x5A5A 表示有效写入时先写备份 B 和校验 B再写备份 A 和校验 A。这样即使写 A 的过程中掉电B 仍然是完整的。加载时先读 A 校验失败再读 B 校验。校验字用固定值 0x5A5A虽然简单但足够区分“写过”和“没写过”的状态。如果你需要更强的校验可以加 CRC16但 AT24C02 空间有限16 字节数据加 2 字节校验已经占了 36 字节再加 CRC 就要压缩数据结构了。5.3 用HAL_I2C_IsDeviceReady做写完成检测的细节前面代码里用了HAL_I2C_IsDeviceReady轮询等待写周期这个函数的参数含义值得说清楚。第一个参数是 I2C 句柄第二个是设备地址7 位地址左移后的值第三个Trials是尝试次数第四个Timeout是每次尝试的超时时间。我一般设Trials3、Timeout10意思是每次探测最多等 10ms最多试 3 次。如果 EEPROM 在 5ms 内完成写入第一次探测就返回 HAL_OK如果超过 10ms 还没就绪就再试一次。总等待时间不超过 30ms对大多数应用足够了。注意HAL_I2C_IsDeviceReady发的是空地址探测不会读写任何数据所以不会干扰 EEPROM 内部状态。但如果你在中断里调用它注意它内部有阻塞等待不要在高速中断里用。从那以后我每次调 I2C EEPROM都强制先跑一遍HAL_I2C_IsDeviceReady确认设备在线再走读写流程这个习惯帮我省掉了至少一半的“玄学”问题。希望帮到你。本文还有配套的精品资源点击获取