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

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

1.1 为什么选 AT24C02 作为 I2C 入门练手对象

搞 STM32 的人迟早要跟 I2C 打交道,而 AT24C02 这颗 EEPROM 几乎是所有人入门 I2C 的首选靶子。原因很简单:它便宜、好买、时序标准、寄存器逻辑清晰,而且掉电不丢数据这个特性让它在实际产品里出场率极高——小到家电的参数存储,大到工业设备的校准系数,都能见到它的身影。

我手上这块 STM32F103C8T6 最小系统板,配上 AT24C02 模块,整个硬件成本不到二十块钱,但能把 I2C 的物理层、协议层、应用层全部跑通一遍。比起拿示波器干看时序图,直接读写一颗真实芯片的成就感要强得多。

这个项目的核心目标很明确:用 STM32F103 的硬件 I2C 外设,完成对 AT24C02 的字节写、字节读、页写、连续读四种基本操作,并且封装成可复用的驱动接口。适合刚学完 GPIO 和串口、想进阶外设驱动开发的嵌入式新手,也适合需要快速验证 I2C 硬件通路的工程师做参考。

1.2 硬件 I2C 还是软件模拟 I2C

这是绕不开的第一个决策点。STM32F103 的硬件 I2C 外设历史上出过不少幺蛾子,早期固件库的 I2C 状态机卡死问题让很多人直接转投软件模拟。但我的选择是:先用硬件 I2C 跑通,再补一套软件模拟作为备用方案。

理由有三条。第一,硬件 I2C 不占用 CPU 周期,在 400kHz 速率下数据搬运完全由外设完成,CPU 可以去处理其他任务,这在多任务场景下优势明显。第二,硬件 I2C 的时序由硬件保证,不会因为中断打断导致时序拉长,稳定性在干扰环境下更好。第三,ST 后来的 HAL 库对 I2C 状态机做了大量修补,配合超时机制基本不会出现死锁。

软件模拟 I2C 的价值在于引脚灵活,任何两个 GPIO 都能凑一对 SCL/SDA,而且时序完全可控,调试时用逻辑分析仪抓波形一目了然。我建议两个都实现,通过宏定义切换,这样遇到硬件 I2C 调不通的时候能快速定位是硬件问题还是协议问题。

1.3 整体软件架构分层

驱动代码我分成三层来写。最底层是 I2C 硬件抽象层,负责初始化 GPIO、配置 I2C 外设时钟和速率、封装起始条件、停止条件、发送字节、接收字节这些原子操作。中间层是 AT24C02 设备驱动层,实现字节写、页写、字节读、连续读四个核心函数,处理设备地址、内存地址、应答轮询这些设备特有的逻辑。最上层是应用层,用一个简单的测试函数把写入和读出串起来,通过串口打印结果验证。

这样分层的好处是,以后换一颗 24C16 或者 24C512,只需要改中间层的地址位宽和页大小参数,底层和上层几乎不用动。换一个 MCU 平台,也只需要重写底层 I2C 抽象层。

2. 硬件连接与 I2C 协议核心细节

2.1 最小系统板与 AT24C02 的接线要点

STM32F103C8T6 最小系统板引出的 I2C1 引脚是 PB6(SCL)和 PB7(SDA)。AT24C02 模块通常有四个引脚:VCC、GND、SCL、SDA。接线看起来简单,但有几个坑必须提前说清楚。

第一,上拉电阻。I2C 总线是开漏输出,SCL 和 SDA 都必须接上拉电阻才能输出高电平。很多 AT24C02 模块板上已经自带了 4.7k 或 10k 的上拉电阻,如果你的模块没有,必须在 STM32 侧补上。我实测下来,3.3V 供电时 4.7k 是比较稳妥的选择,阻值太小会增加功耗,太大则上升沿变缓,400kHz 速率下可能采不到高电平。

第二,电平匹配。STM32F103 的 IO 是 3.3V 电平,AT24C02 支持 1.8V 到 5.5V 宽电压供电。如果你用 5V 给 EEPROM 供电,SCL 和 SDA 上的高电平会接近 5V,长期下来可能损伤 STM32 的 IO。正确做法是 EEPROM 也用 3.3V 供电,或者加电平转换电路。我见过有人图省事直接 5V 供电,跑几天没事,但这是运气,不是设计。

第三,地址引脚。AT24C02 的 A0、A1、A2 三个引脚决定设备地址的低三位。模块上通常有跳线帽或者焊盘,默认全接地时,7 位设备地址是 1010000,即 0x50。写操作时地址字节是 0xA0,读操作时是 0xA1。这个细节后面代码里会反复用到。

2.2 I2C 时序的关键参数计算

I2C 标准模式 100kHz,快速模式 400kHz。STM32F103 的 I2C 时钟来自 APB1 总线,默认 36MHz。要得到 400kHz 的 SCL 频率,需要配置时钟控制寄存器 CCR。

计算公式是:CCR = PCLK1 / (2 × 目标频率)。代入 36MHz 和 400kHz,得到 CCR = 36000000 / 800000 = 45。如果目标频率是 100kHz,CCR = 180。这个值直接写进 I2C_CCR 寄存器即可。

另外还要配置上升时间寄存器 TRISE。标准模式最大上升时间 1000ns,快速模式 300ns。TRISE 的值等于 PCLK1 周期数加一。36MHz 下,一个周期约 27.8ns,快速模式 TRISE = 300 / 27.8 + 1 ≈ 11。这些参数在 HAL 库初始化结构体里都有对应字段,但理解计算过程能让你在通信失败时知道该调哪里。

2.3 AT24C02 的读写时序拆解

AT24C02 的字节写流程是这样的:主机发送起始条件,然后发送设备地址加写标志(0xA0),等待从机应答。接着发送一个字节的内存地址(0x00 到 0xFF),等待应答。再发送一个字节的数据,等待应答。最后发送停止条件。EEPROM 收到数据后并不会立刻写入存储单元,而是启动一个内部写周期,典型时间 5ms,最大 10ms。在这段时间内,芯片不会响应任何新的通信请求。

字节读稍微绕一点。先发送起始条件,发送设备地址加写标志(0xA0),等待应答,发送内存地址,等待应答。然后发送一个重复起始条件,发送设备地址加读标志(0xA1),等待应答,读取一个字节数据,主机发送非应答(NACK),最后发送停止条件。注意这里的重复起始条件很关键,它让主机在不释放总线的情况下切换方向。

页写是字节写的批量版本。AT24C02 的页大小是 8 字节。你可以一次发送最多 8 个字节的数据,它们会写入同一页内。但要注意,如果起始地址不是页对齐的,写到页边界后会回卷到该页开头,覆盖之前的数据。比如从地址 0x06 开始写 4 个字节,实际会写到 0x06、0x07、0x00、0x01。这个回卷特性是新手最容易踩的坑。

连续读则是利用 EEPROM 地址自动递增的特性。发送读命令后,主机可以连续读取多个字节,每读一个字节后发送应答(ACK),表示继续读,最后一个字节发送非应答(NACK)表示结束。地址会在芯片内部自动加一,到达 0xFF 后回卷到 0x00。

3. 代码实现与实操过程

3.1 硬件 I2C 初始化配置

我用的是 STM32CubeMX 生成基础工程,然后手动补充 I2C 配置。时钟树里 APB1 设为 36MHz,I2C1 选择 I2C 模式,速度设为快速模式 400kHz。生成代码后,HAL_I2C_Init 函数会自动计算 CCR 和 TRISE 的值。

如果你用标准外设库,初始化代码大概长这样:

void I2C_Configuration(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; I2C_InitStructure.I2C_OwnAddress1 = 0x00; I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_ClockSpeed = 400000; I2C_Init(I2C1, &I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }

注意 GPIO 模式必须设为复用开漏输出(AF_OD),不能设成推挽。推挽输出会导致总线冲突,因为 I2C 允许多个设备同时拉低总线,但只有一个设备能拉高。

3.2 AT24C02 字节写函数实现

字节写是基础中的基础,代码逻辑要严格遵循时序:

uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { uint32_t timeout = 0; I2C_GenerateSTART(I2C1, ENABLE); timeout = 0; while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)) { if(++timeout > 100000) return 1; } I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); timeout = 0; while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)) { if(++timeout > 100000) return 2; } I2C_SendData(I2C1, addr); timeout = 0; while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING)) { if(++timeout > 100000) return 3; } I2C_SendData(I2C1, data); timeout = 0; while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)) { if(++timeout > 100000) return 4; } I2C_GenerateSTOP(I2C1, ENABLE); return 0; }

每个等待事件都加了超时计数,这是防止 I2C 死锁的关键。我早期版本没加超时,一旦从机没应答,程序就卡在 while 里出不来,只能按复位键。加上超时后,函数会返回错误码,上层可以决定重试还是报错。

3.3 应答轮询与写周期等待

写完一个字节后不能立刻进行下一次写操作,必须等待 EEPROM 内部写周期完成。有两种处理方式:固定延时和应答轮询。

固定延时最简单,直接 delay_ms(10) 覆盖最大写周期。但这样效率低,如果连续写 256 个字节,光等待就 2.5 秒。应答轮询则主动检测芯片是否就绪:发送起始条件加设备地址,如果收到应答说明写周期结束,可以继续;如果没应答就重复尝试。

void AT24C02_WaitStandby(void) { uint32_t timeout = 0; do { I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); timeout++; if(timeout > 10000) break; } while(I2C_GetFlagStatus(I2C1, I2C_FLAG_AF) != RESET); I2C_ClearFlag(I2C1, I2C_FLAG_AF); I2C_GenerateSTOP(I2C1, ENABLE); }

实测下来,应答轮询通常 3 到 5 毫秒就能检测到就绪,比固定延时快不少。但要注意,每次检测失败会产生一个应答失败标志 AF,必须清除,否则会影响后续通信。

3.4 页写与连续读的边界处理

页写函数在字节写基础上加一层循环,但必须处理页边界。我的做法是先计算当前地址到页尾还有多少空间,然后取剩余空间和待写长度的较小值作为本次写入量。

uint8_t AT24C02_WritePage(uint8_t addr, uint8_t *buf, uint8_t len) { uint8_t pageRemain = 8 - (addr % 8); uint8_t writeLen = (len < pageRemain) ? len : pageRemain; I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING)); for(uint8_t i = 0; i < writeLen; i++) { I2C_SendData(I2C1, buf[i]); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING)); } I2C_GenerateSTOP(I2C1, ENABLE); AT24C02_WaitStandby(); return writeLen; }

连续读函数则利用地址自动递增,一次读多个字节:

void AT24C02_ReadBuffer(uint8_t addr, uint8_t *buf, uint8_t len) { I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA0, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0xA1, I2C_Direction_Receiver); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); for(uint8_t i = 0; i < len; i++) { if(i == len - 1) { I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); } while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); buf[i] = I2C_ReceiveData(I2C1); } I2C_AcknowledgeConfig(I2C1, ENABLE); }

注意最后一个字节前要关闭应答并发送停止条件,否则 EEPROM 会继续等待下一个读请求,总线无法释放。

4. 常见问题排查与避坑经验

4.1 通信失败排查速查表

现象可能原因排查方法
起始条件后无应答设备地址错误用逻辑分析仪抓波形,确认地址字节是 0xA0
写数据后读回全 0xFF写周期未等待检查是否调用了 WaitStandby 或延时不足
读数据错位重复起始条件时序不对确认读操作前发送了重复起始条件
总线一直忙SDA 被拉低无法释放检查从机是否掉电,或加总线恢复逻辑
偶尔通信失败上拉电阻过大换 4.7k 或更小阻值,缩短走线
页写数据覆盖地址未页对齐计算页边界,分多次写入

4.2 逻辑分析仪的实际使用技巧

调试 I2C 最有效的工具是逻辑分析仪,几十块钱的 8 通道 USB 分析仪就够用。接线时把通道 0 接 SCL,通道 1 接 SDA,采样率设 1MHz 以上。抓到的波形用上位机软件解码成 I2C 协议,能直接看到地址、数据、应答位。

我踩过的一个坑是采样率设太低,400kHz 速率下波形失真,解码出来全是错误。后来把采样率提到 4MHz,波形清晰,解码准确。另外触发条件设成 SCL 下降沿或者起始条件,能抓到完整的通信过程。

4.3 写周期等待的两种策略对比

固定延时和应答轮询各有适用场景。固定延时代码简单,不依赖 I2C 通信,适合在中断上下文或者 I2C 总线可能被其他设备占用的场景。应答轮询效率高,但需要独占总线,适合单设备或者总线仲裁可控的场景。

我的建议是:初始化阶段用固定延时确保稳定,批量写入时用应答轮询提升速度。另外注意,AT24C02 在高温环境下写周期会变长,如果产品工作温度范围宽,固定延时最好留足余量,比如设 15ms。

4.4 数据掉电保护的实际考量

EEPROM 虽然掉电不丢数据,但写入过程中掉电可能导致数据损坏。如果存储的是关键参数,建议加一个校验机制。我的做法是在数据区前面加两个字节的校验和,读取时先验证校验和,不匹配则使用默认值。

另外,AT24C02 的擦写寿命是 100 万次,虽然看起来很多,但如果程序里频繁写同一个地址,比如每次开机都写一次状态标志,几年下来也可能耗尽。优化方法是只在数据真正变化时才写入,或者用多个地址轮换写入,分散磨损。

4.5 软件模拟 I2C 的备用方案

硬件 I2C 调不通的时候,软件模拟是快速验证硬件通路的好办法。用两个 GPIO 分别控制 SCL 和 SDA,按照时序图手动翻转电平。关键是延时函数的精度,100kHz 下每个位周期 10us,用 __NOP 或者简单的循环延时就能满足。

软件模拟的代码我一般只用来做调试,确认 EEPROM 本身没问题后,再切回硬件 I2C 排查配置问题。这样能快速区分是硬件故障还是软件配置错误。

5. 驱动封装与项目扩展方向

5.1 面向对象的驱动接口设计

把 AT24C02 的操作封装成结构体加函数指针的形式,方便以后替换其他 EEPROM 型号:

typedef struct { uint8_t (*init)(void); uint8_t (*write)(uint8_t addr, uint8_t *buf, uint16_t len); uint8_t (*read)(uint8_t addr, uint8_t *buf, uint16_t len); uint8_t deviceAddr; uint16_t pageSize; uint16_t totalSize; } EEPROM_Driver;

这样上层应用只依赖接口,不依赖具体芯片。换 24C512 的时候,只需要改 deviceAddr、pageSize、totalSize 三个参数,读写函数逻辑完全复用。

5.2 参数存储模块的实战应用

在实际产品里,EEPROM 通常用来存配置参数。我一般定义一个参数结构体,加上版本号和校验和,整体读写:

typedef struct { uint16_t version; uint16_t checksum; float calibration; uint32_t serialNumber; uint8_t workMode; } DeviceConfig;

写入时先算校验和,再整体写入 EEPROM 的固定区域。读取时先读出来,验证校验和,不通过则加载默认参数并重新写入。这样即使 EEPROM 数据损坏,设备也能正常启动。

5.3 从 AT24C02 到其他 I2C 设备的迁移思路

跑通 AT24C02 之后,I2C 总线上挂其他设备就轻车熟路了。OLED 屏用的是 SSD1306 控制器,通信逻辑类似,只是命令和数据用控制字节区分。温度传感器 LM75 直接读寄存器,比 EEPROM 还简单。RTC 芯片 PCF8563 稍微复杂一点,涉及 BCD 码转换,但底层 I2C 读写完全一样。

我的经验是,把 I2C 底层抽象层写好,后面每加一个设备就是查数据手册、确认设备地址、封装读写函数三步。AT24C02 作为第一个练手对象,性价比极高。

5.4 调试过程中的个人体会

这个项目我前前后后调了三天,大部分时间花在排查一个低级问题上:上拉电阻没接。当时用逻辑分析仪看到 SCL 有波形但 SDA 一直是低电平,以为是芯片坏了,换了一块还是同样现象。后来拿万用表量 SDA 对 VCC 的电阻,发现无穷大,才意识到模块上的上拉电阻是空的,需要自己补。

另一个体会是,超时机制一定要加。I2C 总线挂死是常态,没有超时保护的程序在生产环境里就是定时炸弹。我现在写任何 I2C 操作都会带超时返回,上层根据返回值决定重试次数和降级策略。

最后分享一个小技巧:如果手头没有逻辑分析仪,可以用 STM32 的另一个定时器做一个简单的总线监控,记录 SCL 和 SDA 的跳变次数,通过串口打印出来。虽然不如专业工具直观,但能判断总线是否有活动,快速缩小问题范围。

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

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

立即咨询