STM32 I2C — 软件模拟读写 EEPROM(AT24C02)
开发板:野火 EBF STM32F103 Mini
固件库:HAL 库
对应例程:15-I2C-读写EEPROM
参考源码:User/main.c、User/i2c/bsp_i2c_gpio.h、User/i2c/bsp_i2c_gpio.c、User/i2c/bsp_i2c_ee.c
I2C(Inter-Integrated Circuit)是一种两线制、半双工、多主从的同步总线,只需要 SCL(时钟)和 SDA(数据)两根线就能挂多个设备。EEPROM 是 I2C 最典型的从机,用来保存掉电不丢的配置参数。
本文用软件模拟 I2C(GPIO 翻转时序),而非硬件 I2C 外设。原因是:软件 I2C 时序完全可见、易调试、跨平台可移植,野火例程特意用它来把 I2C 协议讲透。
一、功能简介
板上 AT24C02 是一颗 2Kbit(256 字节)的 I2C EEPROM。程序逻辑如下:
- 第一次运行(或未检测到数据标志):写入一个
0xCD 标志,并把 7 个 小数 和 7 个 整数 存进去;
- 之后每次复位:检测到标志,从 EEPROM 读出之前存的数据,打印到串口验证"掉电不丢"。
核心知识点:
- I2C 的起始/停止/应答(ACK/NACK)时序如何用 GPIO 拼出来;
- AT24C02 的设备地址与页写限制(每页 8 字节);
- 写后必须等待 EEPROM 内部写周期完成才能继续访问。
二、硬件准备
| 项目 |
说明 |
| 开发板 |
野火 EBF STM32F103 Mini |
| I2C 引脚 |
SCL → PA2,SDA → PA3(普通 GPIO,开漏输出) |
| 从机 |
AT24C02,设备地址 0xA0(写)/ 0xA1(读) |
| 调试输出 |
USART1(115200) |
AT24C02 地址 1 0 1 0 A2 A1 A0 R/W,板上 A2/A1/A0 接地 → 1010000,左移 1 位加 R/W 位 → 写 0xA0、读 0xA1。
三、完整代码(带中文注释)
1. bsp_i2c_gpio.h(引脚与宏)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
#ifndef _BSP_I2C_GPIO_H
#define _BSP_I2C_GPIO_H
#include <inttypes.h>
#define EEPROM_I2C_WR 0 /* 写控制 bit */
#define EEPROM_I2C_RD 1 /* 读控制 bit */
/* 只需改这 4 行,就能把 I2C 搬到任意 GPIO */
#define I2Cx_SDA_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
#define I2Cx_SCL_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE()
#define EEPROM_GPIO_PORT_I2C GPIOA
#define EEPROM_I2C_SCL_PIN GPIO_PIN_2 /* 时钟线 */
#define EEPROM_I2C_SDA_PIN GPIO_PIN_3 /* 数据线 */
/* 直接操作寄存器翻转 IO(比库函数快,满足 I2C 时序) */
#define EEPROM_I2C_SCL_1() EEPROM_GPIO_PORT_I2C->BSRR = EEPROM_I2C_SCL_PIN
#define EEPROM_I2C_SCL_0() EEPROM_GPIO_PORT_I2C->BRR = EEPROM_I2C_SCL_PIN
#define EEPROM_I2C_SDA_1() EEPROM_GPIO_PORT_I2C->BSRR = EEPROM_I2C_SDA_PIN
#define EEPROM_I2C_SDA_0() EEPROM_GPIO_PORT_I2C->BRR = EEPROM_I2C_SDA_PIN
#define EEPROM_I2C_SDA_READ() ((EEPROM_GPIO_PORT_I2C->IDR & EEPROM_I2C_SDA_PIN) != 0)
void i2c_CfgGpio(void);
void i2c_Start(void);
void i2c_Stop(void);
void i2c_SendByte(uint8_t _ucByte);
uint8_t i2c_ReadByte(void);
uint8_t i2c_WaitAck(void);
void i2c_Ack(void);
void i2c_NAck(void);
#endif
|
2. bsp_i2c_gpio.c(时序模拟核心,节选)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
|
#include "bsp_i2c_gpio.h"
#include "stm32f1xx.h"
/* 简单延时:循环 10 次约产生 200KHz 的 SCL(72MHz 主频下经验值) */
static void i2c_Delay(void)
{
uint8_t i;
for (i = 0; i < 10; i++);
}
/* 起始信号:SCL 高时,SDA 出现下降沿 */
void i2c_Start(void)
{
EEPROM_I2C_SDA_1();
EEPROM_I2C_SCL_1();
i2c_Delay();
EEPROM_I2C_SDA_0();
i2c_Delay();
EEPROM_I2C_SCL_0(); // 拉低 SCL,准备发数据
i2c_Delay();
}
/* 停止信号:SCL 高时,SDA 出现上升沿 */
void i2c_Stop(void)
{
EEPROM_I2C_SDA_0();
EEPROM_I2C_SCL_1();
i2c_Delay();
EEPROM_I2C_SDA_1();
}
/* 发送 1 字节(高位先发) */
void i2c_SendByte(uint8_t _ucByte)
{
uint8_t i;
for (i = 0; i < 8; i++)
{
if (_ucByte & 0x80)
EEPROM_I2C_SDA_1();
else
EEPROM_I2C_SDA_0();
i2c_Delay();
EEPROM_I2C_SCL_1(); // 上升沿锁存
i2c_Delay();
EEPROM_I2C_SCL_0();
if (i == 7)
EEPROM_I2C_SDA_1(); // 第 8 位发完释放 SDA 总线
_ucByte <<= 1;
i2c_Delay();
}
}
/* 读取 1 字节 */
uint8_t i2c_ReadByte(void)
{
uint8_t i, value = 0;
for (i = 0; i < 8; i++)
{
value <<= 1;
EEPROM_I2C_SCL_1();
i2c_Delay();
if (EEPROM_I2C_SDA_READ())
value++;
EEPROM_I2C_SCL_0();
i2c_Delay();
}
return value;
}
/* 等待从机应答:第 9 个时钟读 SDA,0=有应答 */
uint8_t i2c_WaitAck(void)
{
uint8_t re;
EEPROM_I2C_SDA_1(); // 释放 SDA,交由从机拉低
i2c_Delay();
EEPROM_I2C_SCL_1();
i2c_Delay();
if (EEPROM_I2C_SDA_READ())
re = 1; // 没拉低 => 无应答
else
re = 0;
EEPROM_I2C_SCL_0();
i2c_Delay();
return re;
}
/* 主机发 ACK(拉低 SDA 一个时钟) */
void i2c_Ack(void)
{
EEPROM_I2C_SDA_0();
i2c_Delay();
EEPROM_I2C_SCL_1();
i2c_Delay();
EEPROM_I2C_SCL_0();
i2c_Delay();
EEPROM_I2C_SDA_1();
}
/* 主机发 NACK(最后 1 字节后通知从机"别再发了") */
void i2c_NAck(void)
{
EEPROM_I2C_SDA_1();
i2c_Delay();
EEPROM_I2C_SCL_1();
i2c_Delay();
EEPROM_I2C_SCL_0();
i2c_Delay();
}
/* 配置 GPIO 为开漏输出,并先发一个停止信号复位总线 */
void i2c_CfgGpio(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
I2Cx_SCL_GPIO_CLK_ENABLE();
I2Cx_SDA_GPIO_CLK_ENABLE();
GPIO_InitStruct.Pin = EEPROM_I2C_SCL_PIN | EEPROM_I2C_SDA_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(EEPROM_GPIO_PORT_I2C, &GPIO_InitStruct);
i2c_Stop(); // 复位 I2C 总线上所有设备到待机态
}
|
3. bsp_i2c_ee.c(EEPROM 读写,节选)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
#include "bsp_i2c_ee.h"
#include "bsp_i2c_gpio.h"
#define EEPROM_DEV_ADDR 0xA0 /* 设备地址(写) */
#define EEPROM_PAGE_SIZE 8 /* 24C02 每页 8 字节 */
#define EEPROM_SIZE 256
/* 随机读取:先写地址,再重复起始读数据 */
uint8_t ee_ReadBytes(uint8_t *_pReadBuf, uint16_t _usAddress, uint16_t _usSize)
{
uint16_t i;
i2c_Start();
i2c_SendByte(EEPROM_DEV_ADDR | EEPROM_I2C_WR); // 写指令+地址
if (i2c_WaitAck() != 0) goto cmd_fail;
i2c_SendByte((uint8_t)_usAddress); // 发送存储地址
if (i2c_WaitAck() != 0) goto cmd_fail;
i2c_Start(); // 重复起始条件
i2c_SendByte(EEPROM_DEV_ADDR | EEPROM_I2C_RD); // 改为读指令
if (i2c_WaitAck() != 0) goto cmd_fail;
for (i = 0; i < _usSize; i++)
{
_pReadBuf[i] = i2c_ReadByte();
if (i != _usSize - 1)
i2c_Ack(); // 中间字节回 ACK,让从机继续发
else
i2c_NAck(); // 最后一字节回 NACK,结束
}
i2c_Stop();
return 1;
cmd_fail:
i2c_Stop();
return 0;
}
/* 页写:跨页时自动拆分,写后等待内部写周期结束 */
uint8_t ee_WriteBytes(uint8_t *_pWriteBuf, uint16_t _usAddress, uint16_t _usSize)
{
uint16_t i, m, usAddr = _usAddress;
for (i = 0; i < _usSize; i++)
{
/* 每页首地址或第 0 字节:重新起始 + 等上次写完成 */
if ((i == 0) || (usAddr & (EEPROM_PAGE_SIZE - 1)) == 0)
{
i2c_Stop();
for (m = 0; m < 1000; m++) // 轮询等待 EEPROM 就绪
{
i2c_Start();
i2c_SendByte(EEPROM_DEV_ADDR | EEPROM_I2C_WR);
if (i2c_WaitAck() == 0) break; // 有应答说明内部写已完成
}
if (m == 1000) goto cmd_fail;
i2c_SendByte((uint8_t)usAddr); // 发送当前存储地址
if (i2c_WaitAck() != 0) goto cmd_fail;
}
i2c_SendByte(_pWriteBuf[i]); // 写 1 字节
if (i2c_WaitAck() != 0) goto cmd_fail;
usAddr++;
}
i2c_Stop();
/* 等待最后一次 EEPROM 内部写操作结束 */
while (ee_CheckDevice(EEPROM_DEV_ADDR) != 0);
return 1;
cmd_fail:
i2c_Stop();
return 0;
}
|
4. main.c(掉电保存演示)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
#include "main.h"
#include "stm32f1xx.h"
#include "./usart/bsp_debug_usart.h"
#include "./led/bsp_led.h"
#include "./i2c/bsp_i2c_ee.h"
uint8_t cal_flag = 0;
int int_bufffer[7] = {0};
long double double_buffer[7] = {0};
#define DOUBLE_ADDR 10
#define LONGINT_ADDR 70
int main(void)
{
HAL_Init();
SystemClock_Config();
DEBUG_USART_Config();
printf("\r\n 这是一个EEPROM 读写小数和长整数实验 \r\n");
i2c_CfgGpio(); // 初始化 I2C GPIO(开漏)
ee_ReadBytes(&cal_flag, 0, 1); // 读 0 地址的标志位
if (cal_flag != 0xCD) // 没写过
{
printf("没有检测到数据标志,即将写入\r\n");
cal_flag = 0xCD;
ee_WriteBytes(&cal_flag, 0, 1);
for (int k = 0; k < 7; k++)
{
double_buffer[k] = k + 0.1;
int_bufffer[k] = k * 500 + 1;
}
ee_WriteBytes((void*)double_buffer, DOUBLE_ADDR, sizeof(double_buffer));
ee_WriteBytes((void*)int_bufffer, LONGINT_ADDR, sizeof(int_bufffer));
printf("已写入,请复位开发板读取验证\r\n");
}
else // 已写过
{
printf("检测到数据标志,读出数据:\r\n");
ee_ReadBytes((void*)double_buffer, DOUBLE_ADDR, sizeof(double_buffer));
ee_ReadBytes((void*)int_bufffer, LONGINT_ADDR, sizeof(int_bufffer));
for (int k = 0; k < 7; k++)
printf("小数=%LF 整数=%d\r\n", double_buffer[k], int_bufffer[k]);
}
while (1);
}
|
四、代码分段讲解
1. 为什么用软件 I2C?
STM32 的硬件 I2C 外设历史上"坑"较多,而软件模拟用两根 GPIO 翻转电平就能拼出时序,逻辑完全透明、易于用示波器对照学习。理解了软件 I2C,再看硬件 I2C 寄存器就轻松了。
2. 开漏输出 + 上拉电阻
I2C 总线要求 SDA/SCL 是**开漏(OD)**结构,并外接上拉电阻。开漏意味着引脚只能拉低或"释放"(靠上拉变高),这样多设备才能"线与"——谁拉低谁说了算,避免总线冲突。所以 i2c_CfgGpio 把引脚配成 GPIO_MODE_OUTPUT_OD。
3. 起始/停止信号的定义
- 起始:SCL 为高时,SDA 由高变低;
- 停止:SCL 为高时,SDA 由低变高。
- 数据位必须在 SCL 低电平时改变,在 SCL 高电平时保持稳定(主机在 SCL 上升沿采样)。这正是
i2c_SendByte 里"先摆数据、再拉高 SCL、再拉低"的由来。
4. ACK/NACK 应答
每传完 1 字节,接收方要在第 9 个时钟把 SDA 拉低表示"收到"(ACK)。读操作时,主机作为接收方:中间字节发 ACK 让从机继续发,最后一字节发 NACK 告诉从机"到此为止"。
5. AT24C02 的页写陷阱
24C02 每页只有 8 字节,且一次页写不能跨页(跨页会回绕覆盖)。所以 ee_WriteBytes 在遇到"页首地址"时重新发起始+地址,把数据按页切片写入。这是 I2C EEPROM 驱动最容易出错的地方。
6. 写后为什么要等?
主机把数据送到 EEPROM 后,EEPROM 要在内部把数据烧录进存储阵列(约几毫秒)。这段时间内它对总线寻址无应答。ee_WriteBytes 用 ee_CheckDevice 轮询应答,直到 EEPROM 响应,才认为写入完成——否则下一条命令会失败。
五、实验现象
- 第一次上电,串口打印"没有检测到数据标志,即将写入";写完后提示"请复位开发板读取验证";
- 按下复位键(不断电也可),串口打印出之前写入的 7 个小数和整数,证明数据已掉电保存;
- 即使断开电源再上电,数据依然存在(EEPROM 特性)。
六、常见问题
Q1:读写一直失败、检测不到设备?
- 确认 SDA/SCL 外接了上拉电阻(开发板一般已焊好),且 GPIO 配置为开漏;
- 确认设备地址
0xA0 与板上 A2/A1/A0 电平一致;
- 用示波器看 SCL 是否有波形,确认
i2c_Delay 延时合理(太快超出从机响应能力)。
Q2:写入后读出数据错乱?
- 多半是跨页没处理或写后没等忙;务必保留页拆分与
ee_CheckDevice 等待逻辑;
- 注意 AT24C02 容量仅 256 字节,地址别越界。
Q3:软件 I2C 和硬件 I2C 怎么选?
- 学习/移植/调试选软件 I2C;追求速率(400kHz/1MHz 快速模式)和 CPU 解放选硬件 I2C + DMA。
七、小结
- I2C 是两线、多设备、半双工总线,靠地址寻址区分从机;
- 软件 I2C 用 GPIO 翻转拼出起始/停止/应答时序,是理解协议的最佳路径;
- EEPROM 驱动要点:页写不跨页、写后等忙、读前发地址再重复起始。
八、拓展应用
- 保存用户配置:把屏幕亮度、通信参数等存进 EEPROM,上电自动恢复。
- 传感器标定系数:把零点/增益标定数据存 EEPROM,避免每次上电重新标定。
- 多从机总线:在同一条 I2C 上挂温湿度传感器(如 SHT30)、RTC(如 PCF8563)、OLED,用不同地址分时访问。
- 硬件 I2C + 中断/DMA:把软件模拟换成
HAL_I2C_Mem_Write/Read,提升速率并释放 CPU(参考后续硬件 I2C 进阶)。