ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F103硬件I2C驱动AT24C02 EEPROM读写实战与避坑指南

STM32F103硬件I2C驱动AT24C02 EEPROM读写实战与避坑指南 1. 项目缘起与整体设计思路STM32F103 配 AT24C02 这个组合在嵌入式圈子里算是“Hello World”级别的经典搭配了。但经典归经典真要把 I2C 读写 EEPROM 这条链路跑通、跑稳里面藏着不少细节。我见过太多人卡在“发送地址没应答”“写进去读出来全是 0xFF”“连续读写翻页出错”这些坑上。这篇内容就把整个流程从头到尾拆开把每一步为什么这么做、参数怎么算、坑在哪里全部讲清楚。先明确一下这个项目要解决的核心问题用 STM32F103 的硬件 I2C 外设对 AT24C02 这颗 2Kbit256 字节的 EEPROM 进行字节读写和页读写并且保证数据可靠。AT24C02 的容量不大256 个字节按页组织每页 8 字节一共 32 页。别看它小I2C 通信里该有的时序问题、应答机制、写周期等待它一个不少。为什么选 STM32F103 的硬件 I2C 而不是软件模拟这是第一个要做的方案选型。硬件 I2C 的优势在于时序由外设自动产生CPU 占用低速率稳定标准模式 100kHz、快速模式 400kHz 都能跑。但 STM32F103 的硬件 I2C 在圈子里有个“难用”的名声早期固件库确实有卡死的问题不过用 HAL 库配合 CubeMX 生成初始化代码配合正确的状态机处理实际跑下来是稳的。软件模拟 I2C 的好处是引脚随便选、时序完全可控、调试直观缺点是占 CPU、速率上不去。我的建议是如果你只是读写 EEPROM 这种低速外设硬件 I2C 完全够用如果你对时序有特殊要求或者引脚受限软件模拟更灵活。这篇主要走硬件 I2C 路线软件模拟的关键差异我会在对应位置点出来。整个项目的设计思路可以概括成三层硬件层负责电平匹配和上拉电阻驱动层负责 I2C 时序和 AT24C02 的读写协议应用层负责数据的组织与调用。很多人一上来就写应用层代码结果底层没通调半天找不到问题。正确的顺序是先把硬件确认好再用最简单的单字节读写验证通信最后才上页读写和复杂逻辑。提示AT24C02 的供电范围是 1.8V 到 5.5VSTM32F103 的 IO 是 3.3V 电平。如果 EEPROM 供 5VI2C 总线上需要电平转换如果 EEPROM 也供 3.3V那就可以直接连不需要额外转换电路。这一点在选型阶段就要定下来。2. 硬件连接与关键参数计算2.1 引脚连接与上拉电阻的取值STM32F103 的 I2C1 默认引脚是 PB6SCL和 PB7SDAI2C2 是 PB10SCL和 PB11SDA。用 CubeMX 配置的时候直接选 I2C1引脚会自动分配。AT24C02 这边A0、A1、A2 三个地址引脚全部接地表示器件地址的低三位是 000。WP 引脚是写保护接地表示允许写入接高电平则整个芯片只读。实际项目中如果不需要写保护直接接地就行。上拉电阻是 I2C 总线的命脉。I2C 的 SCL 和 SDA 都是开漏输出也就是说器件只能把线拉低拉高要靠上拉电阻。没有上拉电阻总线永远是低电平通信根本起不来。上拉电阻的取值需要权衡阻值太小功耗大低电平时灌电流大阻值太大上升沿变缓高速通信时波形来不及拉高。计算上拉电阻有个经验公式。I2C 标准里上升时间 tr 和总线电容 Cb 的关系是 tr ≈ 0.8473 × Rp × Cb。标准模式 100kHz 下 tr 最大 1000ns快速模式 400kHz 下 tr 最大 300ns。假设总线电容 Cb 是 100pF短线、少量器件的典型值标准模式下 Rp 最大约 11.8kΩ快速模式下约 3.5kΩ。同时还要考虑最小阻值VDD 除以最大灌电流通常 3mA3.3V / 3mA 1.1kΩ。所以标准模式选 4.7kΩ 到 10kΩ 都行快速模式建议 2.2kΩ 到 4.7kΩ。我一般直接用 4.7kΩ这个值在 3.3V 系统里兼容性最好100kHz 和 400kHz 都能跑。工作模式速率上升时间上限推荐上拉电阻Cb≈100pF标准模式100kHz1000ns4.7kΩ ~ 10kΩ快速模式400kHz300ns2.2kΩ ~ 4.7kΩ快速模式1MHz120ns1kΩ ~ 2.2kΩ2.2 电平匹配与电源设计STM32F103 的 IO 电压是 3.3VAT24C02 如果供 5V那 I2C 总线上的高电平是 5V直接连到 STM32 的 3.3V IO 上会超过绝对最大额定值长期跑会损坏 IO。解决办法有两个一是 AT24C02 也供 3.3V这样两边电平一致直接连二是用双向电平转换芯片比如常见的 MOS 管方案或者专用电平转换器。我个人的习惯是整个系统统一 3.3V 供电省掉电平转换的麻烦。AT24C02 在 3.3V 下工作完全正常读写时序和 5V 下没有本质区别。如果你手头的板子 EEPROM 已经固定 5V 供电了那就老老实实加电平转换别抱侥幸心理。电源去耦也不能省。AT24C02 的 VCC 引脚旁边放一个 0.1μF 的陶瓷电容位置尽量靠近芯片引脚。I2C 总线在切换的时候会有瞬态电流去耦电容能把这些毛刺滤掉避免误触发。这个细节很多人忽略但实际调试中电源噪声导致的通信偶发失败排查起来非常头疼。注意AT24C02 的 WP 引脚内部没有下拉悬空时状态不确定。如果悬空可能偶尔能写、偶尔不能写表现得很“玄学”。一定要明确接地或接 VCC不要悬空。3. AT24C02 读写协议深度拆解3.1 器件地址与写保护机制AT24C02 的 7 位器件地址是固定的高四位是 1010这是 Atmel 系列 EEPROM 的标志。低三位由 A2、A1、A0 引脚决定全部接地就是 000。所以完整的 7 位地址是 1010000即 0x50。在 I2C 传输中地址字节是 8 位最低位是读写位写操作是 0x50 1 | 0 0xA0读操作是 0x50 1 | 1 0xA1。这里有个容易混淆的点很多资料里直接说 AT24C02 的地址是 0xA0这其实是把读写位算进去之后的写地址。在 HAL 库的HAL_I2C_Mem_Write函数里传入的是 7 位地址左移一位后的值也就是 0xA0。如果你传 0x50HAL 库会把它当成 7 位地址再左移结果就错了。这个细节在调试时经常让人抓狂明明地址没错就是不应答。写保护方面WP 引脚高电平时芯片进入只读模式任何写操作都会被内部逻辑拒绝但读操作正常。WP 低电平时读写都正常。有些设计会在 WP 上接一个 GPIO需要写的时候拉低写完拉高防止误写。如果你的应用场景对数据安全性要求高这个做法值得考虑。3.2 字节写与页写的时序差异AT24C02 的写操作分两种字节写和页写。字节写就是一次写一个字节流程是起始条件 → 发送器件地址写→ 等待应答 → 发送字地址要写入的 EEPROM 内部地址0x00 到 0xFF→ 等待应答 → 发送数据字节 → 等待应答 → 停止条件。然后 EEPROM 进入内部写周期典型 5ms最大 5ms这段时间内芯片不响应任何命令。页写则是一次写一页AT24C02 的页大小是 8 字节。流程和字节写类似只是在发送完字地址后连续发送最多 8 个数据字节每发一个等一个应答。关键点在于页写不能跨页。如果你从地址 0x06 开始写 8 个字节写到 0x07 就到达第一页的边界了第 8 个字节会自动“回卷”到本页的起始地址 0x00覆盖掉原来的数据。这是 AT24C02 的内部地址指针机制决定的不是 bug是设计如此。所以页写的时候起始地址必须是 8 的倍数写入长度不能超过 8且不能跨越页边界。如果你要写 20 个字节从地址 0x00 开始那就分三次页写0x00 写 8 字节0x08 写 8 字节0x10 写 4 字节。每次页写之后都要等写周期完成才能进行下一次操作。操作类型起始地址要求单次最大长度跨页行为字节写任意1 字节不涉及页写页内任意8 字节回卷到本页起始覆盖数据3.3 读操作的三种模式读操作比写操作灵活一些分三种当前地址读、随机读、顺序读。当前地址读最简单起始条件 → 发送器件地址读→ 等待应答 → 接收数据 → 发送非应答 → 停止条件。它读取的是内部地址指针当前指向的位置读完之后指针自动加一。这个模式适合连续读取的场景但第一次使用前你并不知道指针在哪里所以通常配合随机读来设定起始地址。随机读是实际项目中最常用的先做一个“伪写”操作起始条件 → 发送器件地址写→ 等待应答 → 发送目标字地址 → 等待应答 → 重复起始条件 → 发送器件地址读→ 等待应答 → 接收数据 → 发送非应答 → 停止条件。注意这里的“重复起始条件”不是停止再起始而是直接发一个新的起始条件中间不释放总线。HAL 库的HAL_I2C_Mem_Read函数内部就是帮你做了这个伪写加重复起始的流程。顺序读是在随机读的基础上接收完第一个字节后主机发送应答ACK而不是非应答NACKEEPROM 就会继续输出下一个地址的数据直到主机发送 NACK 并停止。AT24C02 的地址指针在到达 0xFF 后会自动回卷到 0x00所以顺序读可以一直读下去但实际使用中要控制长度避免读到不需要的数据。提示读操作中主机接收最后一个字节时必须发送非应答NACK然后才能发停止条件。如果最后一个字节也发应答EEPROM 会继续等待下一个时钟总线可能挂住。这个细节在软件模拟 I2C 时尤其要注意。4. HAL 库实操全流程与代码拆解4.1 CubeMX 配置要点打开 CubeMX选好 STM32F103 的具体型号比如 C8T6。在 Pinout 视图里找到 I2C1把 SCL 设为 PB6SDA 设为 PB7。I2C 的参数配置里Speed Mode 选 Standard Mode 或 Fast Mode时钟频率根据你的上拉电阻和总线电容来定保守一点选 100kHz。Clock No Stretch Mode 保持 Disabled也就是允许时钟拉伸AT24C02 在写周期内会拉低 SCL 来拖延时间这个功能必须开。GPIO 的设置里I2C 引脚会自动配置成开漏复用模式不需要手动改。如果你用的是软件模拟 I2C那就把两个引脚设成开漏输出初始电平高然后在代码里手动翻转。时钟树配置里确保 I2C1 的时钟源是 PCLK1通常是 36MHz。CubeMX 会自动计算分频系数你只需要确认最终 I2C 速率是 100kHz 就行。生成代码的时候工具选 MDK-ARM 或者 STM32CubeIDE看你的开发环境。4.2 单字节读写函数实现先看写函数。用 HAL 库的话核心就是HAL_I2C_Mem_Write#define AT24C02_ADDR 0xA0 #define AT24C02_WRITE_TIMEOUT 100 HAL_StatusTypeDef AT24C02_WriteByte(uint16_t memAddr, uint8_t data) { return HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, data, 1, AT24C02_WRITE_TIMEOUT); }这里的memAddr是 EEPROM 内部地址0x00 到 0xFF。I2C_MEMADD_SIZE_8BIT表示字地址是 8 位AT24C02 确实是 8 位地址不要选 16 位。超时时间给 100ms 足够因为写周期最大 5ms加上 I2C 传输时间100ms 很宽裕。读函数HAL_StatusTypeDef AT24C02_ReadByte(uint16_t memAddr, uint8_t *data) { return HAL_I2C_Mem_Read(hi2c1, AT24C02_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, data, 1, AT24C02_WRITE_TIMEOUT); }注意读操作传入的地址还是 0xA0HAL 库内部会自动把最低位置 1 变成读操作。你不需要手动传 0xA1。写完一个字节后必须等写周期完成才能进行下一次操作。HAL 库的HAL_I2C_Mem_Write函数在发送停止条件后就返回了但 EEPROM 内部还在写。这时候如果你立刻发下一个命令EEPROM 不会应答。解决办法是轮询应答发一个起始条件加器件地址如果 EEPROM 应答了说明写周期结束如果没应答等一会儿再试。void AT24C02_WaitWriteComplete(void) { uint32_t tickstart HAL_GetTick(); while (HAL_I2C_IsDeviceReady(hi2c1, AT24C02_ADDR, 3, 5) ! HAL_OK) { if ((HAL_GetTick() - tickstart) 10) { break; } } }HAL_I2C_IsDeviceReady就是干这个的它发一个起始加地址看有没有应答。参数 3 是尝试次数5 是每次的超时毫秒数。实际用下来这个函数比固定延时 5ms 更高效因为写周期通常小于 5ms轮询能尽早继续。4.3 页写与连续读的实现页写函数需要处理跨页问题。我的做法是封装一个AT24C02_WritePage调用者保证起始地址和长度不跨页HAL_StatusTypeDef AT24C02_WritePage(uint8_t pageStartAddr, uint8_t *data, uint8_t len) { if (len 8 || (pageStartAddr % 8) len 8) { return HAL_ERROR; } HAL_StatusTypeDef status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR, pageStartAddr, I2C_MEMADD_SIZE_8BIT, data, len, 100); if (status HAL_OK) { AT24C02_WaitWriteComplete(); } return status; }连续读就简单了HAL_I2C_Mem_Read传入长度大于 1 就是顺序读HAL_StatusTypeDef AT24C02_ReadBuffer(uint8_t startAddr, uint8_t *buf, uint16_t len) { return HAL_I2C_Mem_Read(hi2c1, AT24C02_ADDR, startAddr, I2C_MEMADD_SIZE_8BIT, buf, len, 100); }读操作不需要等写周期因为读不会触发内部写。但要注意如果之前刚写过还是要先等写周期完成再读否则读到的可能是旧数据或者通信失败。4.4 完整测试用例写一个测试函数先写 8 个字节到地址 0x00再读回来对比void AT24C02_Test(void) { uint8_t writeBuf[8] {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; uint8_t readBuf[8] {0}; AT24C02_WritePage(0x00, writeBuf, 8); HAL_Delay(10); AT24C02_ReadBuffer(0x00, readBuf, 8); for (int i 0; i 8; i) { if (writeBuf[i] ! readBuf[i]) { printf(Mismatch at %d: wrote 0x%02X, read 0x%02X\r\n, i, writeBuf[i], readBuf[i]); return; } } printf(AT24C02 test passed\r\n); }这个测试覆盖了页写、写周期等待、连续读三个关键环节。如果这个能过基本通信就没问题了。5. 常见问题排查与避坑经验5.1 通信失败类问题速查现象可能原因排查方法完全无应答上拉电阻缺失或阻值过大用示波器看 SCL/SDA 是否有高电平地址应答但数据错字地址传错或读写位搞混确认传入的是 0xA0 而非 0x50写进去读出来是 0xFF写周期未等待或 WP 引脚高检查 WP 电平加写周期轮询偶发失败电源噪声或总线电容过大加去耦电容缩短走线页写数据错乱跨页回卷检查起始地址和长度是否跨页5.2 硬件 I2C 卡死的处理STM32F103 的硬件 I2C 在异常情况下可能进入死锁状态SCL 或 SDA 被拉低不放。这时候需要手动恢复把 I2C 外设复位重新初始化。HAL 库提供了HAL_I2C_DeInit和HAL_I2C_Init在检测到超时后调用这两个函数再重新配置。更彻底的办法是在初始化之前先手动翻转 SCL 引脚 9 个时钟把可能卡住的总线释放掉。具体做法是把 SCL 和 SDA 临时配成普通 GPIO 开漏输出发 9 个 SCL 脉冲然后重新配回 I2C 复用模式。这个技巧在调试阶段非常有用能解决大部分“莫名其妙就不通信了”的问题。void I2C_BusRecovery(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_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); } HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); MX_I2C1_Init(); }5.3 写周期等待的优化固定延时 5ms 虽然简单但效率低。AT24C02 的写周期典型值是 3.5ms 左右最大 5ms。用轮询应答的方式通常 3ms 到 4ms 就能继续省下来的时间在批量写入时很可观。我实测过写 256 个字节用固定延时需要 32 次页写乘以 5ms 等于 160ms用轮询大概 120ms 左右快了 25%。但轮询也有代价每次轮询都要发一个起始条件如果 EEPROM 还没写完这个起始会被忽略然后重试。重试次数太多会占用 CPU。我的做法是第一次等 2ms然后开始轮询每次间隔 1ms最多轮询 5 次。这样兼顾了效率和可靠性。注意有些批次的 AT24C02 写周期可能接近 5ms 上限如果你的应用对写入速度要求高建议换 AT24C02 的升级型号比如 AT24C08 或者 FRAM 芯片写周期能降到微秒级。但那是另一个话题了。5.4 软件模拟 I2C 的差异点如果你用软件模拟 I2C有几个地方和硬件 I2C 不同。第一时序完全由代码控制延时函数要精确100kHz 下每个位周期 10μs高电平和低电平各 5μs 左右。第二起始和停止条件的时序要手动保证SCL 高时 SDA 从高变低是起始SCL 高时 SDA 从低变高是停止。第三应答检测要手动读 SDA 电平主机发完 8 位后释放 SDA读第 9 个时钟的电平。软件模拟的好处是调试直观你可以用示波器看每个位的波形确认时序对不对。硬件 I2C 的波形是外设自动产生的出了问题只能看寄存器状态。所以我的建议是先用软件模拟把协议跑通理解每个环节再切到硬件 I2C 提升效率。这样学习曲线更平缓排查问题也更容易。6. 数据可靠性与扩展思考6.1 数据校验与冗余存储EEPROM 虽然是非易失的但也不是绝对可靠。长期使用、频繁写入、电源异常都可能造成数据损坏。对于关键数据我通常加一层校验。最简单的做法是每个数据块后面跟一个 CRC8 校验字节读出来先校验不通过就重读或者用备份数据。更稳妥的做法是双备份加版本号。把数据存两份每份带一个递增的版本号。读取的时候比较两个版本号取新的那个写入的时候先写备份区再写主区确保任何时候至少有一份完整数据。这个方案在工业控制里很常见代价是存储空间减半但 AT24C02 本来就只有 256 字节如果数据量不大完全可行。6.2 写入寿命与磨损均衡AT24C02 的写入寿命是 100 万次听起来很多但如果你的程序每秒钟写一次不到 12 天就写坏了。所以不要频繁写 EEPROM。如果确实需要频繁保存数据可以在 RAM 里缓存定期刷写或者只在数据变化时才写。磨损均衡是另一个思路不要每次都写同一个地址轮流使用不同的页。AT24C02 有 32 页你可以做一个简单的轮转表每次写下一页记录当前页号。这样写入次数均匀分布到整个芯片寿命能延长 32 倍。当然读取的时候要能找到最新数据所以需要一个页号索引这个索引本身也要考虑磨损。简单的做法是把页号存在固定的几个字节里每次更新时轮转。6.3 从 AT24C02 到其他 EEPROM 的迁移AT24C02 的读写逻辑和 AT24C04、AT24C08、AT24C16 基本兼容主要区别在地址位数和页大小。AT24C04 的地址是 9 位器件地址的低位用来扩展地址AT24C08 是 10 位AT24C16 是 11 位。页大小方面AT24C02 是 8 字节AT24C04/08/16 是 16 字节。如果你要迁移主要改两个地方字地址的位数和页写的长度限制。HAL 库的HAL_I2C_Mem_Write支持 16 位字地址传I2C_MEMADD_SIZE_16BIT就行。但要注意AT24C04 以上的型号器件地址的低位参与了地址扩展不能全部接地。具体怎么接看数据手册的地址表别想当然。我在实际项目中用过 AT24C02 做配置参数存储也用过 AT24C256 做日志记录。体会是小容量 EEPROM 适合存配置大容量适合存日志但都要注意写周期和寿命。如果数据量大、写入频繁FRAM 或者带电池的 SRAM 是更好的选择但成本也上去了。选型的时候把写入频率、数据量、寿命要求列出来算一下就知道该用哪个。最后分享一个调试小技巧在 I2C 的 SCL 和 SDA 上各挂一个 LED 加限流电阻通信的时候能看到闪烁虽然不能看出具体数据但能快速判断有没有在通信。这个土办法在没有示波器的时候特别管用我早期调试的时候全靠它。
返回列表