☰
STM32F103硬件I2C驱动AT24C02实战:从时序到代码
2026/10/30 1:06:50 网站建设 项目流程

1. 项目缘起与整体设计思路

STM32F103 这颗芯片在嵌入式圈子里算是老面孔了,Cortex-M3 内核、72MHz 主频、丰富的外设资源,加上白菜价的最小系统板,几乎成了很多人入门 32 位单片机的第一站。而 AT24C02 这颗 2Kbit 的 EEPROM,I2C 接口、SOP-8 封装、几毛钱一颗,也是存储小数据量的经典选择。把这两个东西凑到一起做读写实验,看起来简单,实际上手却经常卡在时序、地址、应答这些细节上。我前后用这套组合做过好几个量产项目,从参数存储到掉电保护,踩过的坑不算少,今天就把整个流程从头到尾拆一遍。

这个项目的核心目标很明确:用 STM32F103 的硬件 I2C 外设,完成对 AT24C02 的字节读写、页写、连续读,并且保证数据在掉电后不丢失。适合谁看?如果你刚搭好最小系统板,点亮了 LED,串口也能打印了,下一步想接个存储芯片玩玩,那这篇内容就是给你准备的。如果你已经用过 I2C 但总是读不出数据,或者写进去读出来全是 0xFF,那也能在这里找到排查思路。

先说整体设计。STM32F103 的 I2C 外设支持标准模式 100kHz 和快速模式 400kHz,AT24C02 两者都兼容,所以速率上不用纠结,先用 100kHz 把功能跑通,再往上提。硬件连接上,SCL 和 SDA 各接一个 4.7k 到 10k 的上拉电阻到 3.3V,这个电阻不能省,也不能随便拿个 1k 或 100k 凑合。上拉电阻的作用是在总线空闲时把电平拉到高,如果阻值太小,灌电流太大会烧端口;阻值太大,上升沿变缓,高速通信时波形会塌。4.7k 在 3.3V 系统下是经过验证的稳妥值,我实测过 2.2k 到 10k 都能跑 100kHz,但 4.7k 的波形最干净。

软件层面,我选择用 HAL 库来操作,原因很简单:ST 的 HAL 库把 I2C 的底层时序封装好了,你只需要调用HAL_I2C_Mem_Write和HAL_I2C_Mem_Read就能完成读写,不用自己去翻参考手册算延时。当然,如果你想深入理解 I2C 时序,用寄存器或标准库自己写也不是不行,但那是另一个话题了。这里的目标是快速、稳定地把数据存进去读出来,HAL 库是效率最高的路径。

还有一个关键决策:AT24C02 的设备地址。7 位地址是1010加上 A2、A1、A0 三个引脚的电平。如果你把 A2、A1、A0 全接地,那地址就是1010000,即 0x50。HAL 库函数里传入的地址需要左移一位,变成 0xA0 作为写地址,0xA1 作为读地址。这个细节后面会详细说,因为很多人就是在这里搞混了。

2. 硬件连接与 I2C 时序核心细节

2.1 最小系统板与 AT24C02 的接线方案

先把手上的东西理清楚。STM32F103 最小系统板一般引出 PB6 和 PB7 作为 I2C1 的 SCL 和 SDA,当然你也可以重映射到 PB8 和 PB9。我习惯用 PB6/PB7,因为这两个引脚默认就是 I2C1 的功能,不用额外配置重映射。AT24C02 的引脚定义很标准:1 脚是 A0,2 脚是 A1,3 脚是 A2,4 脚是 GND,5 脚是 SDA,6 脚是 SCL,7 脚是 WP,8 脚是 VCC。

接线的时候注意几点。第一,WP 是写保护引脚,接高电平的时候芯片只能读不能写,接低电平或者悬空才能正常读写。我一般直接接地,省得调试的时候忘了这回事。第二,A0、A1、A2 决定设备地址,如果你只挂一颗 AT24C02,全接地就行。第三,VCC 接 3.3V,不要接 5V,虽然 AT24C02 有 5V 版本,但 STM32F103 的 IO 是 3.3V 电平,混用会出问题。

上拉电阻的位置也有讲究。最好靠近 MCU 端放置,不要放在 EEPROM 那边。因为 I2C 是开漏输出,上升沿靠上拉电阻给总线电容充电,电阻离得远,走线电感会影响波形。4.7k 的电阻一端接 3.3V,另一端分别接 SCL 和 SDA。如果你手头只有 10k 的,也能用,但波形上升沿会慢一些,100kHz 下问题不大,400kHz 就悬了。

注意:有些最小系统板已经在 PB6/PB7 上焊了上拉电阻,这时候你就要确认一下阻值。如果板载的是 10k,而你又在外边并了一个 4.7k,等效阻值变成 3.2k 左右,虽然还能用,但灌电流会增大。最稳妥的办法是用万用表量一下,或者直接看原理图。

2.2 I2C 时序的关键参数与计算过程

I2C 总线说到底就是两根线在跳舞,SCL 负责打节拍,SDA 负责传数据。标准模式 100kHz 意味着每个时钟周期 10 微秒,其中高电平时间和低电平时间各占一半左右。但实际时序要求里,高电平最短 4 微秒,低电平最短 4.7 微秒,上升沿和下降沿也有上限。这些参数在 AT24C02 的数据手册里写得清清楚楚,翻到 AC Characteristics 那一页就能看到。

STM32F103 的 I2C 外设通过I2C_CR2寄存器里的 FREQ 位和I2C_CCR寄存器来配置时钟。FREQ 要填 APB1 总线的频率,STM32F103 的 I2C1 挂在 APB1 上,默认是 36MHz。CCR 的计算公式分标准模式和快速模式,标准模式下是CCR = PCLK1 / (2 * 目标频率)。代入 36MHz 和 100kHz,得到CCR = 36000000 / 200000 = 180。这个值直接写进 CCR 寄存器,再配上I2C_CCR的CCR_FS位清零表示标准模式,时钟就配好了。

用 HAL 库的话,这些计算在HAL_I2C_Init里自动完成,你只需要在I2C_InitTypeDef结构体里填ClockSpeed = 100000就行。但理解背后的计算过程很重要,因为当你遇到通信失败的时候,第一反应应该是拿示波器看波形,而不是瞎改代码。如果 SCL 频率明显不对,那就要检查 APB1 的时钟配置是不是和你以为的一样。

还有一个容易忽略的参数是上升时间。标准模式下上升时间最大 1000 纳秒,快速模式下最大 300 纳秒。4.7k 上拉电阻配合典型 100pF 的总线电容,上升时间大约是R * C = 4700 * 100e-12 = 470纳秒,满足标准模式要求。如果你挂了很多 I2C 设备,总线电容增大,上升时间会变长,这时候要么减小上拉电阻,要么降低通信速率。

2.3 AT24C02 的地址结构与寻址方式

AT24C02 的 2Kbit 容量等于 256 字节,地址范围从 0x00 到 0xFF。它的寻址分两层:第一层是设备地址,7 位,固定前四位是1010,后三位由 A2、A1、A0 引脚决定。第二层是字地址,8 位,直接对应 256 个字节中的某一个。

写操作的时候,主机先发设备地址加写位,收到应答后发字地址,再收到应答后发数据。读操作稍微绕一点,有两种方式:当前地址读和随机读。当前地址读是直接发设备地址加读位,芯片会从上次操作后的地址继续输出数据。随机读是先发设备地址加写位,再发你要读的字地址,然后重新发设备地址加读位,这时候芯片才会把指定地址的数据吐出来。

HAL 库的HAL_I2C_Mem_Read函数把随机读的流程封装好了,你只需要传入设备地址、字地址、数据缓冲区和长度。但要注意,设备地址参数要传左移后的 8 位地址,也就是 0xA0。如果你传了 0x50,HAL 库会把它当成 7 位地址再左移,结果就错了。这个坑我见过太多次,新手几乎必踩。

提示:AT24C02 的页大小是 8 字节。页写的时候,如果你从地址 0x07 开始写 8 个字节,写到 0x08 的时候会翻转到 0x00,覆盖掉原来的数据。所以页写一定要对齐,或者自己分段处理。HAL 库的HAL_I2C_Mem_Write不帮你处理翻转,它只是把数据一股脑发出去,芯片自己会回卷。

3. 实操过程与核心代码实现

3.1 CubeMX 配置与工程搭建

我习惯用 CubeMX 来生成初始化代码,省得手动配时钟树和引脚。打开 CubeMX,选好芯片型号 STM32F103C8T6,第一步配 RCC,把 HSE 设成 Crystal/Ceramic Resonator。然后进 Clock Configuration,把 HSE 选上,PLL 倍频到 72MHz,APB1 分频系数设为 2,这样 APB1 就是 36MHz。这一步很关键,因为 I2C 的时钟源就是 APB1,如果 APB1 频率不对,I2C 速率也会偏。

接着配 I2C1。在 Connectivity 里选 I2C1,Mode 选 I2C,Speed Mode 选 Standard Mode,Clock Speed 填 100000。引脚会自动分配到 PB6 和 PB7,检查一下有没有冲突。然后配 USART1 用于打印调试信息,波特率 115200,8 位数据位,1 位停止位,无校验。最后配一个 GPIO 输出接 LED,方便看程序运行状态。

生成代码的时候,Toolchain 选 MDK-ARM,也就是 Keil。如果你用 STM32CubeIDE 也行,流程差不多。生成之后打开工程,先编译一遍,确认没有错误。然后接上 ST-Link,配置调试器为 SWD 模式,下载程序试试 LED 能不能闪。这一步是基础,如果 LED 都不闪,后面的 I2C 就不用谈了。

注意:CubeMX 生成的HAL_I2C_MspInit函数里会自动配置 GPIO 为开漏复用模式,并且使能 I2C 时钟。但上拉电阻它不会帮你加,那是硬件的事。如果你在 CubeMX 里看到 GPIO 配置有 Pull-up 选项,那个是内部上拉,阻值大概 40k 左右,太弱了,不能替代外部 4.7k 上拉。

3.2 字节读写函数的封装与调用

HAL 库提供了两个核心函数:HAL_I2C_Mem_Write和HAL_I2C_Mem_Read。它们的原型是这样的:

HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout); 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是设备地址,要传 0xA0。MemAddress是 AT24C02 内部的字地址,0x00 到 0xFF。MemAddSize填I2C_MEMADD_SIZE_8BIT,因为 AT24C02 的字地址是 8 位。pData是数据指针,Size是字节数,Timeout是超时时间,填 1000 毫秒足够。

我一般会再封装一层,让调用更顺手:

#define AT24C02_ADDR 0xA0 HAL_StatusTypeDef AT24C02_WriteByte(uint16_t addr, uint8_t data) { return HAL_I2C_Mem_Write(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, &data, 1, 1000); } HAL_StatusTypeDef AT24C02_ReadByte(uint16_t addr, uint8_t *data) { return HAL_I2C_Mem_Read(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 1000); }

写完之后必须等一段时间,让芯片完成内部写周期。AT24C02 的写周期最大 5 毫秒,这段时间内它不会应答任何请求。如果你写完立刻读,读回来的可能是旧数据或者 0xFF。我通常用HAL_Delay(5)来等,虽然有点浪费,但最省事。如果你追求效率,可以用应答查询的方式,反复发设备地址直到收到应答,但代码会复杂一些。

3.3 页写与连续读的实现细节

页写是 AT24C02 的批量写入方式,一次最多写 8 个字节。如果你要写 16 个字节,就得分成两次页写。HAL 库的HAL_I2C_Mem_Write其实支持任意长度,但芯片内部会按页处理,跨页的时候会回卷。所以正确的做法是自己算好页边界,分段调用。

HAL_StatusTypeDef AT24C02_WritePage(uint16_t addr, uint8_t *data, uint16_t len) { uint16_t page_start = addr & 0xF8; // 每页 8 字节,低 3 位清零 uint16_t page_offset = addr & 0x07; if (page_offset + len > 8) { return HAL_ERROR; // 跨页了,需要分段 } return HAL_I2C_Mem_Write(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data, len, 1000); }

连续读就简单多了,AT24C02 支持顺序读,你给个起始地址,然后一直读,它会自动递增地址,读到 0xFF 之后回卷到 0x00。HAL 库的HAL_I2C_Mem_Read传入长度大于 1 的时候,就是连续读。

HAL_StatusTypeDef AT24C02_ReadBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { return HAL_I2C_Mem_Read(&hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, buf, len, 1000); }

实测下来,连续读 256 个字节完全没问题,速度也很快。但要注意,读的时候不要跨页写,读没有页限制,整个 256 字节可以一次读完。

3.4 完整测试代码与串口验证

我写了一个简单的测试流程:先往地址 0x00 写 0x55,读回来对比;再往地址 0x10 开始写 8 个字节的数组,读回来对比;最后做一次全片读写测试,写 256 个字节的递增数据,再读回来校验。串口打印每一步的结果。

void AT24C02_Test(void) { uint8_t data = 0x55; uint8_t readback = 0; uint8_t buf[8] = {1,2,3,4,5,6,7,8}; uint8_t rbuf[8] = {0}; uint8_t full[256], rfull[256]; printf("AT24C02 Test Start\r\n"); AT24C02_WriteByte(0x00, data); HAL_Delay(5); AT24C02_ReadByte(0x00, &readback); printf("Byte Test: write=0x%02X read=0x%02X %s\r\n", data, readback, (data==readback)?"PASS":"FAIL"); AT24C02_WritePage(0x10, buf, 8); HAL_Delay(5); AT24C02_ReadBuffer(0x10, rbuf, 8); printf("Page Test: "); for(int i=0;i<8;i++) printf("%d ", rbuf[i]); printf("\r\n"); for(int i=0;i<256;i++) full[i] = i; for(int i=0;i<256;i+=8) { AT24C02_WritePage(i, &full[i], 8); HAL_Delay(5); } AT24C02_ReadBuffer(0x00, rfull, 256); int err = 0; for(int i=0;i<256;i++) { if(full[i] != rfull[i]) err++; } printf("Full Test: %d errors\r\n", err); }

烧进去之后打开串口助手,波特率 115200,应该能看到三行结果。如果 Byte Test 就 FAIL 了,那说明硬件连接或者地址有问题,先别往下看,回头检查上拉电阻和接线。如果 Byte Test 过了但 Page Test 挂了,那可能是页写跨页了。如果 Full Test 有错误,大概率是写周期没等够,把HAL_Delay(5)改成HAL_Delay(10)试试。

4. 常见问题与排查技巧实录

4.1 读写失败问题速查表

现象可能原因排查方法解决方案
读回来全是 0xFF设备没应答用示波器看 SDA 第 9 个时钟检查上拉电阻、地址、供电
读回来全是 0x00写保护生效量 WP 引脚电平WP 接地或悬空
写进去读出来不对写周期没等够增加延时到 10ms用应答查询替代固定延时
偶尔成功偶尔失败时序临界降低速率到 50kHz检查上拉电阻和走线
页写数据错乱跨页回卷检查起始地址和长度分段写,每段不跨页
HAL 返回 HAL_ERROR总线忙或超时看HAL_I2C_GetError复位 I2C 外设重新初始化

这个表是我自己调试的时候总结的,基本上覆盖了九成以上的问题。其中“读回来全是 0xFF”是最常见的,新手看到这个结果往往以为是代码问题,其实多半是硬件没接好。AT24C02 如果没应答,主机读到的就是总线空闲时的高电平,也就是 0xFF。

4.2 示波器抓波形:定位问题的终极手段

代码调不出来的时候,别死磕,拿示波器看波形。两通道分别接 SCL 和 SDA,触发设在 SCL 下降沿,单次触发。正常的写时序应该是:起始条件(SCL 高时 SDA 拉低),然后 7 位设备地址加 1 位写标志,第 9 个时钟芯片拉低 SDA 表示应答,接着 8 位字地址,再应答,然后 8 位数据,再应答,最后停止条件(SCL 高时 SDA 拉高)。

如果你看到第 9 个时钟 SDA 还是高,说明芯片没应答。这时候先量 AT24C02 的 VCC 是不是 3.3V,再量 WP 是不是低,再量 A0/A1/A2 是不是接地。如果这些都正常,那可能是上拉电阻没焊好,或者 SDA/SCL 接反了。我遇到过最离谱的一次是 PCB 上 SDA 和 SCL 走线交叉了,飞线解决。

还有一种情况是波形上升沿太缓,看起来像锯齿波。这说明上拉电阻太大或者总线电容太大。把 10k 换成 4.7k,或者缩短走线,问题就解决了。如果波形上有明显的振铃,那是走线电感太大,可以在 SDA 和 SCL 上串一个小电阻,22 欧姆左右,能有效抑制振铃。

4.3 写周期与应答查询的优化

固定延时 5 毫秒虽然简单,但在频繁写入的场景下很浪费。AT24C02 的写周期典型值是 3.5 毫秒,最大 5 毫秒。如果你一次写很多页,每页都等 5 毫秒,总时间就很可观。这时候可以用应答查询:写完一页后,反复发设备地址加写位,如果芯片返回应答,说明内部写周期结束了。

void AT24C02_WaitReady(void) { uint32_t timeout = 0; while(HAL_I2C_IsDeviceReady(&hi2c1, AT24C02_ADDR, 1, 100) != HAL_OK) { timeout++; if(timeout > 100) break; // 超时退出 } }

HAL_I2C_IsDeviceReady就是干这个的,它发一个空操作,只看芯片应不应答。实测下来,写周期通常在 3 到 4 毫秒之间,比固定延时 5 毫秒省了一点时间。但如果你对时间不敏感,固定延时更省心,不容易出幺蛾子。

提示:AT24C02 在写周期内不会应答任何请求,包括读请求。所以如果你写完立刻读,读操作会失败,HAL 库返回 HAL_ERROR。这不是代码问题,是芯片的正常行为。加上等待就好了。

4.4 多设备挂载与地址冲突

I2C 总线可以挂多个设备,只要地址不冲突。AT24C02 通过 A2/A1/A0 可以配出 8 个地址,从 0x50 到 0x57。如果你挂了两颗 AT24C02,一颗 A0 接地,一颗 A0 接 VCC,那地址就是 0x50 和 0x51,左移后分别是 0xA0 和 0xA2。调用的时候传对应的地址就行。

但要注意,总线上挂的设备越多,总线电容越大,上升沿越缓。如果挂了四五颗芯片,可能 100kHz 都跑不稳。这时候要么减小上拉电阻到 2.2k,要么降低速率到 50kHz。我一般会在总线上串一个 100 欧姆的电阻,把总线电容和主机的驱动能力隔开,效果不错。

还有一点,不同厂家的 AT24C02 兼容性有差异。有些国产替代品在 3.3V 下工作正常,有些在 2.5V 下就不行了。如果你买的是拆机件或者来路不明的芯片,先拿一颗确认能读写,再批量用。我吃过一次亏,一批芯片里有两颗是坏的,排查了半天以为是代码问题。

5. 进阶扩展与项目落地建议

5.1 数据掉电保护与双备份策略

AT24C02 最大的价值就是掉电不丢数据,但如果你在写的时候突然断电,数据可能写了一半,导致整个结构体损坏。我的做法是双备份加校验。把数据分成两份,存在两个不同的页里,每份数据后面跟一个 CRC 校验字节。读的时候先读第一份,校验通过就用;不通过就读第二份;两份都不通过就恢复默认值。

typedef struct { uint16_t param1; uint16_t param2; uint8_t crc; } Config_t; void SaveConfig(Config_t *cfg) { cfg->crc = CalculateCRC(cfg, sizeof(Config_t)-1); AT24C02_WritePage(0x00, (uint8_t*)cfg, sizeof(Config_t)); HAL_Delay(5); AT24C02_WritePage(0x10, (uint8_t*)cfg, sizeof(Config_t)); HAL_Delay(5); }

这样即使写第一份的时候断电,第二份还是完整的。虽然多占了一倍空间,但 AT24C02 有 256 字节,存点参数绰绰有余。如果你要存的数据超过 128 字节,那就得换 AT24C04 或者 AT24C08 了,地址引脚和页大小都不一样,但 I2C 时序是一样的。

5.2 从 AT24C02 迁移到其他 EEPROM 的注意事项

如果你以后要换更大的 EEPROM,比如 AT24C32 或 AT24C256,有几个地方要改。第一,字地址变成 16 位,MemAddSize要填I2C_MEMADD_SIZE_16BIT。第二,页大小变了,AT24C32 是 32 字节一页,AT24C256 是 64 字节一页,页写的时候边界要重新算。第三,设备地址的高四位还是1010,但 A2/A1/A0 的用法可能不同,具体看数据手册。

HAL 库的函数是通用的,只要把MemAddSize和地址改对,代码基本不用动。但写周期时间会变长,AT24C256 的最大写周期是 10 毫秒,延时得加够。我一般会在换芯片之后先跑一遍全片读写测试,确认没问题再上业务逻辑。

5.3 实际项目中的经验教训

最后分享几个我在实际项目中踩过的坑。第一个是关于电源的。有一次我用 5V 给最小系统板供电,但 AT24C02 接的是 3.3V,结果 I2C 通信时好时坏。后来发现是 5V 转 3.3V 的 LDO 输出电容太小,负载一变电压就波动。换了个 100uF 的电容就好了。所以如果你用 5V 供电,一定要确保 3.3V 稳定,最好用示波器看一下纹波。

第二个是关于 PCB 走线的。I2C 的 SCL 和 SDA 最好走等长线,并且远离高频信号线,比如 SPI 时钟或者 PWM 输出。我有个项目把 I2C 走线跟电机驱动线平行走了 5 厘米,结果电机一转,EEPROM 就读写失败。后来把 I2C 走线改到板子另一层,问题解决。如果实在避不开,可以在 I2C 线上加共模电感,或者降低速率到 10kHz。

第三个是关于代码的。HAL 库的 I2C 函数在超时之后会返回 HAL_TIMEOUT,但外设状态可能卡在 BUSY。这时候需要调用HAL_I2C_DeInit和HAL_I2C_Init重新初始化,否则后续所有操作都会失败。我一般会在错误处理里加上这个逻辑,保证总线能恢复。

if(HAL_I2C_Mem_Write(...) != HAL_OK) { HAL_I2C_DeInit(&hi2c1); HAL_I2C_Init(&hi2c1); }

这个操作看起来粗暴,但实测很有效。I2C 总线卡死是嵌入式开发中的常见问题,尤其是总线上有干扰或者某个设备异常拉低 SDA 的时候。重新初始化能解决大部分软件层面的卡死,如果是硬件问题,那就得断电重启了。

这套 STM32F103 加 AT24C02 的组合,虽然简单,但把 I2C 的时序、地址、页写、应答这些核心概念都串起来了。你把这套跑通,以后换任何 I2C 设备,比如 OLED 屏、温湿度传感器、EEPROM,都是同样的套路。区别只在于设备地址和寄存器地址不同,底层逻辑是一样的。我在实际使用中发现,把这一套吃透之后,再看其他 I2C 器件的数据手册,基本能直接上手写驱动,不用再从头啃时序图了。

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

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

立即咨询