ARTICLE DETAIL

资讯详情

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

STM32硬件IIC驱动EEPROM:地址计算与页写实战

STM32硬件IIC驱动EEPROM:地址计算与页写实战 前段时间把项目里的 EEPROM 驱动从软件 IIC 换成了 STM32 的硬件 IIC折腾了差不多一个下午才把“任意地址、任意字节”这个听起来很基础的功能彻底跑顺。手头用的片子是 AT24C256环境是 STM32CubeMX 加 HAL 库目标就是不用再为模拟时序花心思直接调硬件外设把数据稳定写进 EEPROM、再完整读出来。网上聊 STM32 硬件 IIC 的文章不少但愿意把器件地址计算、页写边界、返回错误码、写周期等待这些细节讲透的真不多。很多人一遇到问题就甩锅给“F1 的硬件 IIC 有坑”或者直接退回软件模拟。实际上大部分问题出在地址位宽、上拉电阻、超时处理这些基础环节。这篇文章我把这次移植的完整思路、代码和排错过程整理出来项目里要接 AT24C01 到 AT24C256 的都可以参考。1. 为什么我这次坚决选择硬件IIC1.1 先说说软件IIC和硬件IIC的大实话很多老工程师一开始都习惯用软件模拟 IIC因为 GPIO 控制时序完全在自己手里哪里出问题都能用示波器慢慢量。软件 IIC 最大的优点不是“稳定”而是“可控”——SCL 拉高拉低、SDA 采样点、ACK 判断全部自己写想加延时、想打印调试都可以。缺点也很直接占用 CPU一个字节一个字节地翻转电平高速模式跑不动而且一旦有中断打断时序就非常容易出现半个字节的错位。硬件 IIC 正好反过来底层时序全部由外设模块搞定CPU 只需要发起一次传输、等待完成中断。即便跑 400kbps也可以不占用太多 CPU 时间去逐位翻转。缺点是很多人对硬件外设不熟一上来就碰到 HAL 库返回错误、总线锁死就赶紧放弃。其实稍微耐住性子把流程理清楚硬件 IIC 的可靠性做得比软件模拟好得多。我这次接 AT24C256需要反复写入和读取参数块长度从几个字节到几百字节都有。如果用软件模拟每页写入都要卡在 GPIO 翻转上加上中断影响隐患太大。换硬件 IIC 之后配合 HAL 库的 Memory 接口一段代码就能覆盖任意地址和任意长度维护成本低很多。1.2 HAL库下的硬件IIC到底能不能用提到 HAL 库的硬件 IIC总有人说 STM32F1 的 I2C 外设设计有缺陷、容易卡死调起来非常痛苦。这个说法有一定历史背景但不代表不能用。早期标准外设库时代I2C 事件处理比较复杂很多开发者没做好中断标志位的清理和超时保护才导致外设卡在忙状态。HAL 库本身把状态机包装过了用阻塞式接口的时候你在主循环里调用 HAL_I2C_Mem_Read、HAL_I2C_Mem_Write只要合理设置超时时间出现异常时外设也不至于彻底瘫掉。我更看重的一点是HAL 库提供了统一的 Memory 读接口。所谓 Memory 接口就是把 EEPROM 看成一块挂在 I2C 总线上的存储空间你告诉它设备地址、内存地址、长度它自动生成正确的起始条件和数据传输序列。对 AT24CXX 这种带内部地址寄存器的器件来说这个封装非常契合不需要自己操心“先发地址再切读方向”的细节。当然单片机和 I2C 外设的“可用边界”也要清楚。HAL 阻塞接口适合在任务里偶尔传几十字节数据不适合在中断服务函数里做长传输。对 EEPROM 这种一次写一两页、每页写完后还要等 5ms 的器件来说阻塞式接口完全够用。想要更高吞吐再考虑中断或 DMA 版本但那部分优化是后话我先用最直接的阻塞方式把功能跑通。2. AT24CXX看起来简单地址和页结构才是关键2.1 器件地址怎么拼出来的AT24CXX 是典型的 I2C EEPROM数据手册上会给一个器件地址比如 AT24C02 是 0xA0AT24C256 也是 0xA0。这里有个非常容易踩的坑0xA0 是包含了读写控制位的“8 位地址”而 STM32 HAL 库里的 DevAddress 参数要求的是“7 位地址”。HAL_I2C_Mem_Read 和 HAL_I2C_Mem_Write 在发送数据前会自己把 7 位地址左移一位再拼上读或写标志。所以你传进去的应该是 0x50不是 0xA0。有些代码里直接写 0xA0 也能碰巧工作因为最低位在部分硬件里被忽略了但一旦你做读操作或者换一个 HAL 版本就很容易变成“写正常、读超时”这种诡异现象。规范做法是统一把地址定义为 0x50。AT24CXX 的地址位并不只由芯片型号决定。像 AT24C02 有 A2、A1、A0 三个引脚可以接高接低来区分挂在同一条总线上的多片芯片。地址格式是 1010 A2 A1 A0比如三根引脚都接地7 位地址就是 0x50如果 A2 拉高地址就是 0x54。AT24C256 这类大容量芯片同样有 A2、A1、A0 引脚原理一致。在写代码前务必把硬件原理图打开确认 A0/A1/A2 实际接到什么电位然后算出正确的 7 位 I2C 地址。我遇到的很多 HAL_ERROR源头不是程序逻辑错而是地址少算了一位。2.2 页大小和内存地址位宽决定代码怎么写AT24CXX 内部的存储结构看起来简单其实有两个参数会直接影响代码内存地址位宽和页大小。小容量芯片比如 AT24C01、AT24C02容量只有 128 或 256 字节一条 8 位地址就能覆盖全部空间所以访问时应该用 I2C_MEMADD_SIZE_8BIT。大容量芯片比如 AT24C32 以上容量超过 4096 字节8 位地址不够用必须用 I2C_MEMADD_SIZE_16BIT发送内存地址时要先送高字节、再送低字节。AT24C04 到 AT24C16 中间还有一些“地址引脚兼任高位地址”的特殊情况设计时最好直接查数据手册的表格不要凭经验猜。页大小则决定了连续写入的上限。AT24C256 的页大小是 64 字节意味着在一个写周期内最多连续写入 64 字节而且这 64 字节不能跨页。如果你从地址 0x3F 开始写 4 个字节普通逻辑会觉得没问题但芯片内部写入时会在页边界回卷把后几个字节写到第 0 页去数据就乱了。这就是为什么“任意地址、任意字节”的写入接口必须自己做拆页处理。我把常见型号的参数列成一个对照表方便你快速确认型号容量内存地址位宽典型页大小备注AT24C022Kbit / 256B8 bit8 字节地址格式 1010 A2 A1 A0AT24C044Kbit / 512B8 bit16 字节部分地址位并入器件地址AT24C088Kbit / 1KB8 bit16 字节部分地址位并入器件地址AT24C1616Kbit / 2KB8 bit16 字节部分地址位并入器件地址AT24C32/6432Kbit / 4KB 以上16 bit32 字节需要 16 位内存地址AT24C128/256128Kbit / 256Kbit16 bit64 字节我的工程用的 AT24C256每次移植驱动先把容量、页大小、地址位宽三个值改对后面大概率不需要再动逻辑。3. 基于HAL库实现任意地址的读写接口3.1 STM32CubeMX配置IIC外设用 HAL 库开发第一步肯定是用 STM32CubeMX 生成基础工程。打开芯片的 I2C 外设比如 I2C1配置为 I2C 模式即可因为我们做主机不需要设置从机地址。速度模式根据硬件决定普通布线用 Standard Mode 100kHz 比较保守短距离、上拉电阻选得合适的话可以用 Fast Mode 400kHz。AT24C256 本身支持 400kHz但要注意总线上其他设备。CubeMX 里经常被忽略的一项是在 GPIO 设置页面检查 I2C1_SCL 和 I2C1_SDA 的引脚模式。硬件 IIC 的引脚会默认配置为开漏输出这没问题但千万不要在外部没有上拉电阻的情况下指望内部上拉能撑起来。I2C 信号线的正常逻辑高电平靠外部上拉电阻提供一般取 2.2k 到 10k 之间。速度越高、总线电容越大上拉电阻就要越小。调试时如果 SCL 或 SDA 波形上升沿太缓或者高电平不够高先从上拉电阻入手。生成代码后在 main.c 里会得到 MX_I2C1_Init 函数。真正干活的是用户自己的读写函数我习惯在 app 层单独建一个 at24cxx.c把所有 EEPROM 相关接口封装起来不想在 main 里堆一堆 HAL 调用。3.2 读取部分HAL_I2C_Mem_Read读操作是所有操作里最简单的因为不需要考虑页大小限制芯片内部会自动递增地址。HAL 的接口长这样HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout);其中的 DevAddress 填 7 位地址MemAddSize 根据芯片容量填 I2C_MEMADD_SIZE_8BIT 或 I2C_MEMADD_SIZE_16BITpData 是读取缓冲区Size 是想要读取的字节数。我封装出来的读取函数是这样#define AT24CXX_DEV_ADDR 0x50 #define AT24CXX_MEM_ADDR_SIZE I2C_MEMADD_SIZE_16BIT uint8_t AT24CXX_ReadBytes(uint16_t ReadAddr, uint8_t *pBuffer, uint16_t NumToRead) { if (HAL_I2C_Mem_Read(hi2c1, AT24CXX_DEV_ADDR, ReadAddr, AT24CXX_MEM_ADDR_SIZE, pBuffer, NumToRead, 100) ! HAL_OK) { return 1; } return 0; }这个函数对“任意地址”的支持来自 HAL 内部的组合帧主机先发设备地址加写标志再把内存地址高字节、低字节发过去然后重新发出起始条件切到读方向连续读取指定字节。过去用软件模拟要自己控制的步骤现在全被封装了。读取长度方面I2C 协议本身不考虑目标容量你传多少 Size 就尝试读多少但应用层还是要做边界检查。比如芯片只有 256 字节你不小心从地址 250 读 100 字节芯片会回卷到地址开头读出来的是你自己不期望的数据。所以调用这个接口前最好判断一下 ReadAddr NumToRead 是否超过芯片容量。3.3 写入部分拆页处理和写周期等待写入部分才是整个驱动的重点。如果直接拿 HAL_I2C_Mem_Write 写一大段跨页数据表面看 HAL 一次把数据发出去了但 AT24CXX 内部会在页边界回卷覆盖数据完全乱掉。所以必须在应用层把写入拆成多个不超过页大小的块。另外还有个写周期问题。AT24CXX 每完成一个页面写入内部需要大约 5ms 的编程时间在这段时间内芯片不响应任何 I2C 命令。如果不等待立刻执行下一次写入第一次写的数据可能还在内部编程第二笔操作已经发上总线结果就是部分数据丢失。常用办法有两个固定延时 5ms或者不断查询设备是否就绪。查询更可靠能够根据不同芯片实际写周期自动适配。我写的写入函数如下uint8_t AT24CXX_WaitReady(uint32_t timeout) { HAL_StatusTypeDef status HAL_ERROR; while (timeout--) { status HAL_I2C_IsDeviceReady(hi2c1, AT24CXX_DEV_ADDR, 1, 10); if (status HAL_OK) { return 0; } } return 1; } uint8_t AT24CXX_WriteBytes(uint16_t WriteAddr, uint8_t *pBuffer, uint16_t NumToWrite) { uint16_t page_size 64; // AT24C256 页大小 uint16_t chunk; while (NumToWrite 0) { // 计算当前页剩余空间 chunk page_size - (WriteAddr % page_size); if (chunk NumToWrite) { chunk NumToWrite; } if (HAL_I2C_Mem_Write(hi2c1, AT24CXX_DEV_ADDR, WriteAddr, AT24CXX_MEM_ADDR_SIZE, pBuffer, chunk, 100) ! HAL_OK) { return 1; } if (AT24CXX_WaitReady(100) ! 0) { return 2; } WriteAddr chunk; pBuffer chunk; NumToWrite - chunk; } return 0; }拆页逻辑的核心在chunk page_size - (WriteAddr % page_size)这一行。它能算出从当前地址到本页末尾还剩多少字节每次只写这么多写完再把数据指针和剩余长度做减法。这样哪怕从任意地址开始要写任意长度都不会触发页回卷。用这个函数写一个字节和写 200 字节本质上走的是同一段逻辑只是循环次数不同。调用方式也很直白uint8_t data[8] {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; AT24CXX_WriteBytes(0x100, data, 8); AT24CXX_ReadBytes(0x100, read_buf, 8);3.4 把“任意地址、任意字节”的边界做完整上面的读写函数看起来已经能处理任意地址但“任意”两个字背后还有几个边界问题。第一是地址对齐到页边界的情况当 WriteAddr 正好是页大小的整数倍时WriteAddr % page_size等于 0chunk 等于整页大小不会多写也不会少写逻辑依然正确。第二是内存地址位宽切换。AT24C02 这类小芯片容量有限如果沿用 16 位内存地址HAL 会多发一个高字节芯片内部读取时会把高字节当作数据造成地址偏移。所以小容量芯片要改成 I2C_MEMADD_SIZE_8BIT并把页大小改小。我的代码里把这两个值直接定义成了宏就是希望移植到其他 AT24CXX 型号时只需要改宏不需要动函数体。第三是写保护引脚。很多 AT24CXX 都有 WP 引脚拉高时整个芯片只读写入操作不会生效。如果写完调读接口发现返回全部是旧数据先检查 WP 是不是被外部电路拉高了。4. 调试实录硬件IIC常见翻车点4.1 一上来就返回HAL_ERROR先查这几项我第一次把工程烧进板子直接调 AT24CXX_WriteBytes结果返回 1也就是 HAL_I2C_Mem_Write 返回了 HAL_ERROR。当时第一反应是怀疑硬件 IIC 真的不靠谱后来用逻辑分析仪慢慢查发现问题全在初始设置上。第一项是设备地址。我确认过原理图A0/A1/A2 全部接地但代码里如果写成 0xA0HAL 在内部处理时不一定会按你想象的一样发送地址。把地址改成 0x50 之后写操作立刻正常。第二项是引脚模式。CubeMX 自动生成的代码一般没问题但如果你在别处重新初始化过 GPIO把开漏模式改成了推挽输出SDA 输出的低电平没问题高电平会被内部强行推高I2C 时序里的“线与”特性就被破坏了从机 ACK 也可能识别失败。第三项是上拉电阻。有些开发板上虽然丝印画了 I2C 接口但上拉电阻默认不焊或者焊了但阻值接近 100k。这种情况用示波器看波形会发现 SCL 上升沿非常缓慢高速模式下直接超时。上拉电阻选得好I2C 调试难度直接下降一半。4.2 总线被拉死之后怎么办调试中最烦人的是 I2C 总线被拉死表现为 SDA 一直为低所有读写操作都返回 HAL_BUSY。常见原因是传输过程中出现了异常中断比如代码在读写途中触发了某个硬错误或者外部干扰把从机状态机打乱。遇到这种情况最直接的办法是重新初始化 I2C 外设也就是先调用 HAL_I2C_DeInit再调用 HAL_I2C_Init。但这只能复位 MCU 内部的 I2C 控制器如果从机那边还卡在某种输出低电平的状态必须想办法让 SCL 产生几个脉冲把从机的内部状态机推出来。我习惯在初始化 I2C 之前临时把 SCL 和 SDA 引脚配置成开漏 GPIO然后手动给 SCL 翻转 9 个时钟周期再恢复成复用功能。这相当于给总线做一次软复位能解决大多数总线锁死问题。如果项目对可靠性要求高还可以在每次读写调用前判断一下总线的空闲状态如果 SDA 一直被拉低先做总线恢复再执行正常流程。代码逻辑不复杂但能省掉后续很多排查时间。4.3 问题排查速查表把这次调试中遇到的典型问题整理成表方便以后照着查故障现象最可能原因处理建议写接口返回 HAL_ERROR设备地址 7 位/8 位写错统一用 0x50 这样的 7 位地址读出来全是 0xFF芯片根本没被选中或地址越界核对器件地址、WP 引脚、芯片容量写完后部分数据丢失跨页写入没有拆页按页大小拆分chunk 不超过页剩余空间连续写入第二次超时没等写周期结束每次写完等待至少 5ms或查询设备就绪SCL/SDA 高电平不够高上拉电阻缺失或阻值过大使用 2.2k-10k 外部上拉总线卡死一直 HAL_BUSY从机状态机异常9 个 SCL 脉冲复位总线再重新初始化小容量芯片地址错乱内存地址位宽用错AT24C02 用 8bit 地址AT24C256 用 16bit这张表基本覆盖了新手最容易遇到的坑遇到问题先对照排查而不是直接怀疑“硬件 IIC 不行”。5. 给AT24CXX驱动加上可靠性细节5.1 写周期的等待逻辑可以更细腻前面代码里用的是 WaitReady 查询比固定延时靠谱但还可以再优化一点。内部写周期开始后芯片对外表现为无 ACK但是不同写入长度、不同电压下实际写周期长度并不固定。查询方式会自动越过这段不稳定的等待时间而且不会白白多等。对功耗敏感的项目查询方式也能让 MCU 在等待期间做其他事情比硬延时更省资源。如果一定要用固定延时建议至少留 10ms 而不是 5ms。温度低、电压偏低时芯片写周期可能略微变长留足余量才能避免偶发丢数据。我个人的工程里时间不敏感就直接 5ms 延时加一次 WaitReady双保险。5.2 面向长期使用的几个建议EEPROM 有擦写寿命限制通常号称 100 万次。如果程序里频繁地写入同一个存储单元比如每次上电都写计数器寿命很快会耗尽。设计上可以引入磨损均衡把写入位置分散到多个地址或者用一个逻辑块的概念先读、再改、最后写尽可能减少整页写入次数。如果写入的数据需要掉电保护建议设计一种简单的帧结构比如固定帧头、数据长度、校验字节。写入时先写一个“写入中”的标志全部数据写完后再把标志改成“完成”。下次上电后发现标志不是“完成”就认为上一次写入被中断回滚到上一帧。AT24CXX 本身只能保证单字节写入的原子性跨页的大数据块无法保证断电安全所以应用层必须兜底。还有一个容易被忽略的地方是缓冲区对齐。HAL 库的 I2C 发送和接收对数据缓冲区没有特殊对齐要求但如果你后面换 DMA 模式最好保证缓冲区是按照 4 字节对齐声明的。现在先把轮询接口跑通将来要想提升性能直接改成中断或 DMA 接口即可拆页逻辑完全不用动。最后分享一个小经验硬件 IIC 调试时不要一上来就开 400kHz。先把速度降到 100kHz确认读写全对再逐步提速。很多“随机丢数据”的问题其实是高速模式下 PCB 走线、上拉电阻、从机响应时间三者不匹配造成的。先把功能做稳定再追求速度这才是嵌入式开发的正确顺序。
返回列表