☰
STM32硬件IIC驱动EEPROM:地址计算与页写实战
2026/10/3 8:04:06 网站建设 项目流程

前段时间把项目里的 EEPROM 驱动从软件 IIC 换成了 STM32 的硬件 IIC,折腾了差不多一个下午,才把“任意地址、任意字节”这个听起来很基础的功能彻底跑顺。手头用的片子是 AT24C256,环境是 STM32CubeMX 加 HAL 库,目标就是不用再为模拟时序花心思,直接调硬件外设把数据稳定写进 EEPROM、再完整读出来。

网上聊 STM32 硬件 IIC 的文章不少,但愿意把器件地址计算、页写边界、返回错误码、写周期等待这些细节讲透的真不多。很多人一遇到问题就甩锅给“F1 的硬件 IIC 有坑”,或者直接退回软件模拟。实际上,大部分问题出在地址位宽、上拉电阻、超时处理这些基础环节。这篇文章我把这次移植的完整思路、代码和排错过程整理出来,项目里要接 AT24C01 到 AT24C256 的都可以参考。

1. 为什么我这次坚决选择硬件IIC

1.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 是 0xA0,AT24C256 也是 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 A0
AT24C044Kbit / 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_16BIT,pData 是读取缓冲区,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等于 0,chunk 等于整页大小,不会多写也不会少写,逻辑依然正确。

第二是内存地址位宽切换。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 全部接地,但代码里如果写成 0xA0,HAL 在内部处理时不一定会按你想象的一样发送地址。把地址改成 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 走线、上拉电阻、从机响应时间三者不匹配造成的。先把功能做稳定,再追求速度,这才是嵌入式开发的正确顺序。

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

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

立即咨询