STM32 I2C — 软件模拟读写 EEPROM(AT24C02)

野火 EBF STM32F103 Mini HAL 库:用普通 GPIO 模拟 I2C 时序,读写 AT24C02 串行 EEPROM,保存小数与长整型数据。

STM32 I2C — 软件模拟读写 EEPROM(AT24C02)

开发板:野火 EBF STM32F103 Mini
固件库:HAL 库
对应例程15-I2C-读写EEPROM
参考源码User/main.cUser/i2c/bsp_i2c_gpio.hUser/i2c/bsp_i2c_gpio.cUser/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 读出之前存的数据,打印到串口验证"掉电不丢"。

核心知识点:

  1. I2C 的起始/停止/应答(ACK/NACK)时序如何用 GPIO 拼出来;
  2. AT24C02 的设备地址与页写限制(每页 8 字节);
  3. 写后必须等待 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_WriteBytesee_CheckDevice 轮询应答,直到 EEPROM 响应,才认为写入完成——否则下一条命令会失败。


五、实验现象

  1. 第一次上电,串口打印"没有检测到数据标志,即将写入";写完后提示"请复位开发板读取验证";
  2. 按下复位键(不断电也可),串口打印出之前写入的 7 个小数和整数,证明数据已掉电保存;
  3. 即使断开电源再上电,数据依然存在(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 驱动要点:页写不跨页、写后等忙、读前发地址再重复起始

八、拓展应用

  1. 保存用户配置:把屏幕亮度、通信参数等存进 EEPROM,上电自动恢复。
  2. 传感器标定系数:把零点/增益标定数据存 EEPROM,避免每次上电重新标定。
  3. 多从机总线:在同一条 I2C 上挂温湿度传感器(如 SHT30)、RTC(如 PCF8563)、OLED,用不同地址分时访问。
  4. 硬件 I2C + 中断/DMA:把软件模拟换成 HAL_I2C_Mem_Write/Read,提升速率并释放 CPU(参考后续硬件 I2C 进阶)。
世界是你们
使用 Hugo 构建
主题 StackJimmy 设计