GD32 I2C — I2C EEPROM 读写

GD32 GD32F30x_Firmware_Library V3.0.3 I2C I2C EEPROM 读写 示例教学

I2C — I2C EEPROM 读写

固件库: GD32F30x_Firmware_Library V3.0.3
芯片: GD32F30x(以 GD32F307 评估板为例)
源文件: GD32F30x_Firmware_Library_V3.0.3/Examples/I2C/I2C_EEPROM/main.c
配套文件: i2c.c / i2c.h(底层驱动)、at24cxx.c / at24cxx.h(EEPROM 读写逻辑)
评估板 EEPROM: AT24C02(I2C 接口,2Kbit = 256 字节)


功能简介

本示例演示用 I2C 硬件外设 读写一颗 AT24C02 EEPROM(电可擦可编程只读存储器)。EEPROM 最大的特点是掉电不丢数据,常用来存设备配置、校准参数、运行计数等。

整个流程一句话概括:

  1. 准备好 256 字节的测试数据 i2c_buffer_write[i] = i(即 0x00~0xFF)。
  2. 通过 I2C 把这批数据写入 AT24C02(从地址 0 开始)。
  3. 再从 AT24C02 读回同样 256 字节到 i2c_buffer_read[]
  4. 逐字节比较写入和读回是否一致,全部一致则测试通过(I2C_OK)。
  5. 测试通过 → LED2~LED5 循环流水闪烁;失败 → 4 颗 LED 全亮,表示读写出错。

本例的亮点(也是难点)是:I2C 收发全部用"状态机 + 超时"实现,每一步都等从机 ACK、带超时保护和总线复位,非常贴近真实工程的健壮写法。


硬件准备

1. 开发板 + EEPROM 模块

  • GD32F30x 评估板(例程按 GD32F307C EVAL 编写,LED2~LED5 由 gd_eval_led_init 初始化)。
  • AT24C02 模块(或板载 EEPROM)。本例 I2C 地址宏 I2CX_SLAVE_ADDRESS7 = 0xA0

2. 接线(I2C0:PB6/PB7)

信号 MCU 引脚 接 EEPROM
SCL PB6 EEPROM SCL
SDA PB7 EEPROM SDA

关键:I2C 的两根线必须接上拉电阻(通常 4.7kΩ 到 3.3V)。大多数 EEPROM 模块板载了上拉,直接连即可;若自己搭电路,别忘了加上拉,否则总线拉不高、通信必失败。

3. 串口(强烈建议)

例程用 printf 打印调试信息(“AT24C02 writing…"、“test passed!"、出错字节等)。把 PA9/PA10 接到 USB 转串口,波特率 115200(评估板 gd_eval_com_init 默认)即可在串口助手查看全过程。


完整代码(带中文注释)

由于本例代码分散在 main.ci2c.cat24cxx.c 三个文件,下面按"从顶到底"的顺序摘出最核心、最该读懂的部分,并配上中文注释。完整工程文件请直接查看源码目录。

1) 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <stdio.h>
#include "gd32f30x.h"
#include "gd32f307c_eval.h"
#include "systick.h"
#include "i2c.h"
#include "at24cxx.h"

uint8_t count = 0;

void led_turn_on(uint8_t led_number);
void led_config(void);

void led_config(void)
{
    gd_eval_led_init(LED2);
    gd_eval_led_init(LED3);
    gd_eval_led_init(LED4);
    gd_eval_led_init(LED5);
    gd_eval_led_off(LED2);
    gd_eval_led_off(LED3);
    gd_eval_led_off(LED4);
    gd_eval_led_off(LED5);
}

int main(void)
{
    /* 系统滴答定时器(提供 delay_1ms 时基) */
    systick_config();
    /* LED 初始化 */
    led_config();
    /* 串口初始化(用于 printf 打印) */
    gd_eval_com_init(EVAL_COM0);

    printf("I2C-24C02 configured....\n\r");

    /* GPIO 配置:把 PB6/PB7 设成 I2C 复用开漏 */
    gpio_config();
    /* I2C 外设配置:速率 100K、自身 7 位地址、使能、开启 ACK */
    i2c_config();
    /* 初始化 EEPROM 驱动(记录器件地址 0xA0) */
    i2c_eeprom_init();

    printf("\r\nThe I2C is hardware interface ");
    printf("\r\nThe speed is %d", I2C_SPEED);

    /* 执行读写自测 */
    if(I2C_OK == i2c_24c02_test()) {
        /* 测试通过:4 颗 LED 循环流水,每 500ms 切换 */
        while(1) {
            gd_eval_led_off(LED2);
            gd_eval_led_off(LED3);
            gd_eval_led_off(LED4);
            gd_eval_led_off(LED5);
            led_turn_on(count % 4);
            count++;
            if(count >= 4) count = 0;
            delay_1ms(500);
        }
    }
    /* 测试失败:4 颗 LED 全亮,停在这里 */
    gd_eval_led_on(LED2);
    gd_eval_led_on(LED3);
    gd_eval_led_on(LED4);
    gd_eval_led_on(LED5);
    while(1) {}
}

2) i2c.h —— 关键宏定义(先记住这些)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#define I2C_SPEED               100000      /* 标准模式 100Kbps */
#define I2CX_SLAVE_ADDRESS7     0xA0        /* AT24C02 的 8 位地址写法(含读写位) */
#define I2C_PAGE_SIZE           8           /* AT24C02 每页 8 字节(页写上限) */

#define I2CX                    I2C0
#define RCU_GPIO_I2C            RCU_GPIOB
#define RCU_I2C                 RCU_I2C0
#define I2C_SCL_PORT            GPIOB
#define I2C_SDA_PORT            GPIOB
#define I2C_SCL_PIN             GPIO_PIN_6  /* PB6 = SCL */
#define I2C_SDA_PIN             GPIO_PIN_7  /* PB7 = SDA */

3) i2c.c —— GPIO 与 I2C 外设配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
void gpio_config(void)
{
    rcu_periph_clock_enable(RCU_GPIO_I2C);
    /* I2C 引脚必须是 复用开漏(AF_OD) + 外部上拉 */
    gpio_init(I2C_SCL_PORT, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, I2C_SCL_PIN);
    gpio_init(I2C_SDA_PORT, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, I2C_SDA_PIN);
}

void i2c_config(void)
{
    rcu_periph_clock_enable(RCU_I2C);
    /* 配置速率:100000 bps,占空比 2(快速模式才讲究,标准模式随意) */
    i2c_clock_config(I2CX, I2C_SPEED, I2C_DTCY_2);
    /* 配置 I2C 为主机模式、7 位地址格式、自身地址 0xA0 */
    i2c_mode_addr_config(I2CX, I2C_I2CMODE_ENABLE, I2C_ADDFORMAT_7BITS, I2CX_SLAVE_ADDRESS7);
    i2c_enable(I2CX);                 /* 使能 I2C 外设 */
    i2c_ack_config(I2CX, I2C_ACK_ENABLE);  /* 开启自动应答(读多字节时必须) */
}

4) at24cxx.c —— 最核心的"状态机写一字节”

下面这个函数 eeprom_byte_write_timeout() 把 I2C 写一字节的完整协议展示得最清楚。注意它是一个 switch 状态机,timeout 防止卡死:

  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
uint8_t eeprom_byte_write_timeout(uint8_t *p_buffer, uint8_t write_address)
{
    uint8_t  state = I2C_START;
    uint16_t timeout = 0;
    uint8_t  i2c_timeout_flag = 0;

    i2c_ack_config(I2CX, I2C_ACK_ENABLE);

    while(!(i2c_timeout_flag)) {
        switch(state) {
        case I2C_START:
            /* ① 等总线空闲(不忙)才发 START */
            while(i2c_flag_get(I2CX, I2C_FLAG_I2CBSY) && (timeout < I2C_TIME_OUT)) {
                timeout++;
            }
            if(timeout < I2C_TIME_OUT) {
                i2c_start_on_bus(I2CX);      /* 产生 START 条件 */
                timeout = 0;
                state = I2C_SEND_ADDRESS;
            } else {
                timeout = 0;
                state = I2C_START;           /* 超时:重来 */
                printf("i2c bus is busy in WRITE BYTE!\n");
            }
            break;

        case I2C_SEND_ADDRESS:
            /* ② 等 START 已发出(SBSEND) */
            while((! i2c_flag_get(I2CX, I2C_FLAG_SBSEND)) && (timeout < I2C_TIME_OUT)) {
                timeout++;
            }
            if(timeout < I2C_TIME_OUT) {
                /* 发送从机地址 + 写方向(库会自动拼读写位) */
                i2c_master_addressing(I2CX, eeprom_address, I2C_TRANSMITTER);
                timeout = 0;
                state = I2C_CLEAR_ADDRESS_FLAG;
            } else {
                timeout = 0;
                state = I2C_START;
            }
            break;

        case I2C_CLEAR_ADDRESS_FLAG:
            /* ③ 等从机回应地址 ACK(ADDSEND),然后必须清除该标志 */
            while((! i2c_flag_get(I2CX, I2C_FLAG_ADDSEND)) && (timeout < I2C_TIME_OUT)) {
                timeout++;
            }
            if(timeout < I2C_TIME_OUT) {
                i2c_flag_clear(I2CX, I2C_FLAG_ADDSEND);
                timeout = 0;
                state = I2C_TRANSMIT_DATA;
            } else {
                timeout = 0;
                state = I2C_START;
            }
            break;

        case I2C_TRANSMIT_DATA:
            /* ④ 等发送缓冲空(TBE),先发 EEPROM 内部地址(写哪个存储单元) */
            while((! i2c_flag_get(I2CX, I2C_FLAG_TBE)) && (timeout < I2C_TIME_OUT)) {
                timeout++;
            }
            if(timeout < I2C_TIME_OUT) {
                i2c_data_transmit(I2CX, write_address);  /* EEPROM 内地址 */
                timeout = 0;
            }
            /* 等字节传完(BTC),再发真正的数据字节 */
            while((!i2c_flag_get(I2CX, I2C_FLAG_BTC)) && (timeout < I2C_TIME_OUT)) {
                timeout++;
            }
            if(timeout < I2C_TIME_OUT) {
                i2c_data_transmit(I2CX, *p_buffer);      /* 实际要写的数据 */
                timeout = 0;
            }
            /* 再等 BTC,准备结束 */
            while((!i2c_flag_get(I2CX, I2C_FLAG_BTC)) && (timeout < I2C_TIME_OUT)) {
                timeout++;
            }
            if(timeout < I2C_TIME_OUT) {
                state = I2C_STOP;
                timeout = 0;
            }
            break;

        case I2C_STOP:
            /* ⑤ 发 STOP 条件,结束本次通信 */
            i2c_stop_on_bus(I2CX);
            while((I2C_CTL0(I2CX) & I2C_CTL0_STOP) && (timeout < I2C_TIME_OUT)) {
                timeout++;
            }
            if(timeout < I2C_TIME_OUT) {
                timeout = 0;
                state = I2C_END;
                i2c_timeout_flag = I2C_OK;
            }
            break;

        default:
            state = I2C_START;
            i2c_timeout_flag = I2C_OK;
            timeout = 0;
            break;
        }
    }
    return I2C_END;
}

5) at24cxx.c —— 自测函数 i2c_24c02_test()

 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
uint8_t i2c_24c02_test(void)
{
    uint16_t i;
    uint8_t i2c_buffer_write[BUFFER_SIZE];   /* BUFFER_SIZE = 256 */
    uint8_t i2c_buffer_read[BUFFER_SIZE];

    printf("\r\nAT24C02 writing...\r\n");
    /* 准备测试数据:0,1,2,...,255 */
    for(i = 0; i < BUFFER_SIZE; i++) {
        i2c_buffer_write[i] = i;
    }
    /* 整片写入(内部做了分页写) */
    eeprom_buffer_write_timeout(i2c_buffer_write, EEP_FIRST_PAGE, BUFFER_SIZE);

    printf("AT24C02 reading...\r\n");
    /* 整片读回 */
    eeprom_buffer_read_timeout(i2c_buffer_read, EEP_FIRST_PAGE, BUFFER_SIZE);

    /* 逐字节比对 */
    for(i = 0; i < BUFFER_SIZE; i++) {
        if(i2c_buffer_read[i] != i2c_buffer_write[i]) {
            printf("Err:data read and write aren't matching.\n\r");
            return I2C_FAIL;     /* 任一字节不一致 → 失败 */
        }
    }
    printf("I2C-AT24C02 test passed!\n\r");
    return I2C_OK;               /* 全部一致 → 成功 */
}

其余 eeprom_page_write_timeout()(页写)、eeprom_buffer_write_timeout()(跨页拆分)、eeprom_buffer_read_timeout()(含"复合格式"读:先写地址再发重复 START 切到读方向)、eeprom_wait_standby_state_timeout()(等 EEPROM 写完就绪)都是在这套状态机骨架上的扩展。它们的核心套路一致:START → 地址 → 等 ACK → 数据 → STOP,只是数据段多循环几次、或读之前多发一次"重复起始(Repeated START)"。


代码分段讲解

1. I2C 引脚为什么是"复用开漏”?

1
2
gpio_init(I2C_SCL_PORT, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, I2C_SCL_PIN);
gpio_init(I2C_SDA_PORT, GPIO_MODE_AF_OD, GPIO_OSPEED_50MHZ, I2C_SDA_PIN);

I2C 总线是**线与(wired-AND)**结构:任何设备都能把线拉低,但只能靠上拉电阻拉高。如果用推挽输出,两个设备一个拉高一个拉低就会"短路"打架。开漏输出恰好满足"谁想拉低谁拉,否则释放由电阻拉高",配合外部上拉电阻,这就是 I2C 能多主多从、还能仲裁的硬件基础。漏了上拉电阻 = 通信必失败,这是 I2C 头号新手坑。

2. 速率、地址、应答三件套

1
2
3
i2c_clock_config(I2CX, I2C_SPEED, I2C_DTCY_2);          // 速率 100K
i2c_mode_addr_config(I2CX, ..., I2C_ADDFORMAT_7BITS, 0xA0);  // 7 位地址
i2c_ack_config(I2CX, I2C_ACK_ENABLE);                   // 开启自动应答
  • I2C_SPEED = 100000:标准模式。想提速改 400000(快速模式);EEPROM 通常都支持。
  • 0xA0:AT24C02 的 8 位地址俗写(含读写位)。i2c_master_addressing() 会按 7 位地址格式自动处理,所以这里传 0xA0 没问题。
  • ACK 必须开启:读多字节时,主机每收一个字节都要回 ACK 告诉从机"继续发",最后一个字节才回 NACK 表示"到此为止"。

3. 状态机:每一步都在"等 ACK + 防卡死"

观察 eeprom_byte_write_timeout(),整段逻辑被拆成 5 个状态:I2C_START → I2C_SEND_ADDRESS → I2C_CLEAR_ADDRESS_FLAG → I2C_TRANSMIT_DATA → I2C_STOP。每个状态里都是 while(标志未到 && timeout < I2C_TIME_OUT) 这种带超时的等待

  • I2C_FLAG_I2CBSY 为 0 → 总线空闲才能发 START。
  • I2C_FLAG_SBSEND → START 已发出。
  • I2C_FLAG_ADDSEND → 从机回了地址 ACK;必须 i2c_flag_clear() 清掉它,不清会卡死。
  • I2C_FLAG_TBE / BTC → 数据发出去、且收到从机对数据的 ACK。

超时就 state = I2C_START 重来,必要时 i2c_bus_reset() 把总线"抖"回正常。这种写法比"裸等标志"健壮得多,是真实工程里 I2C 的标配。

为什么要"先发 EEPROM 内地址、再发数据"? AT24C02 这类 EEPROM 在收到器件地址后,还要再收一个"你想写哪个存储单元"的字地址(0x00~0xFF),之后才是真正的数据。所以一帧里主机连续发:器件地址(写) → 字地址 → 数据。读操作更复杂:先以"写方向"发器件地址+字地址告诉它"我要读的位置",再发一次重复 START以"读方向"访问,然后开始收数据。

4. 页写与"等待就绪"

AT24C02 内部按"页"组织,每页 8 字节I2C_PAGE_SIZE)。一次 I2C 写操作最多连续写一页,跨页必须拆成多次(每次都要重新 START+地址)。eeprom_buffer_write_timeout() 就是做这个"按 8 字节分页、跨页拆分"的活。

而且EEPROM 写慢——发完 STOP 后它要花几毫秒把数据固化到存储阵列,这段时间里它不响应任何访问。所以每次写完后都要 eeprom_wait_standby_state_timeout():不断发 START+地址,直到从机回 ACK 表示"我忙完了、可以再聊了"。如果你写完立刻读,读到的会是旧数据或 NACK。

5. 读回来的数据怎么验证?

i2c_24c02_test()i2c_buffer_write[i]=i 造了 0~255 的递增序列,写进去再读回来逐字节比。全对返回 I2C_OK,main 里就让 LED 流水;有错立刻 I2C_FAIL、LED 全亮。串口还会把出错的具体字节打印出来,方便排查。


实验现象

  1. 串口输出(115200 波特):
    1
    2
    3
    4
    5
    6
    
    I2C-24C02 configured....
    The I2C is hardware interface
    The speed is 100000
    AT24C02 writing...
    AT24C02 reading...
    I2C-AT24C02 test passed!
    
  2. LED 行为
    • 测试通过 → LED2、LED3、LED4、LED5 以 500ms 间隔循环流水闪烁
    • 测试失败(读写不一致)→ 4 颗 LED 全部常亮,程序停在末尾死循环。
  3. 若把接线拔掉或地址写错,串口会打出 i2c bus is busy ... / 各种 timeout 信息,最终触发失败分支。

常见问题

Q1:串口没任何输出 / 一直打 “i2c bus is busy”?
99% 是物理接线或上拉问题。检查:① PB6/PB7 是否真正接到了 EEPROM 的 SCL/SDA;② 总线上是否接了上拉电阻(4.7kΩ 到 3.3V);③ EEPROM 供电是否正常。总线拉不高,I2C 永远判"忙"。

Q2:为什么 I2C 地址写成 0xA0 而不是 0x50?
0xA0 是 8 位写法(7 位地址 0x50 左移 1 位 = 0xA0,最低位是读写方向位)。AT24C02 的数据手册里器件地址常直接给成 0xA0(写)/ 0xA1(读)这种"含读写位"的写法。GD32 库函数 i2c_master_addressing() 会按你给的格式自动拼方向位,传 0xA0 即可,不必自己再移位。

Q3:为什么写完要等那么久才能读?
EEPROM 内部是"浮栅"工艺,写/擦要毫秒级时间。写完后它处于"忙"状态,期间不响应 I2C 访问。例程里的 eeprom_wait_standby_state_timeout() 就是反复探测它"忙完没有"。如果你自己写代码,记得写后延时 5~10ms 或轮询 ACK 再读。

Q4:可以一次写超过 8 字节吗?
不能在一次 I2C 连续写里超过一页(AT24C02 是 8 字节/页)。超过会从页首"回卷"覆盖。例程的 eeprom_buffer_write_timeout() 已替你按 8 字节分页、跨页拆分处理,所以传 256 字节也没问题。

Q5:为什么代码里那么多 timeout 和 i2c_bus_reset?
因为 I2C 是带应答的协议,从机没接好 / 总线被拉死时若"裸等标志"会永久卡住。带超时 + 总线复位是工业级写法,保证"卡了能恢复"。初学可先忽略 timeout 细节,重点理解状态机的 5 步顺序。

Q6:我想换成 AT24C04 / AT24C128 等更大容量怎么办?
主要改两点:① 器件地址(大容量页结构不同,字地址可能是 2 字节);② I2C_PAGE_SIZE(页大小随型号变,如 AT24C64 是 32 字节)。地址格式(7 位/10 位)和页写逻辑要对照对应手册调整。


小结

本示例把 I2C 最核心的"主机写从机"协议完整走了一遍,记住这条主线:

  1. 开时钟 + 配 GPIO(AF_OD + 上拉):SCL/PB6、SDA/PB7,千万别忘上拉电阻。
  2. 配 I2C 外设:速率、7 位地址、使能、开启 ACK。
  3. 通信五步状态机START → 发地址(等 ACK) → 发字地址 → 发数据 → STOP,每步都等标志 + 防超时。
  4. EEPROM 特有两件事:按页写(≤8 字节/页,跨页拆分)、写后等"就绪"再读。
  5. 验证:写进去什么、读回来什么,逐字节比对,用 LED / 串口给出结果。

I2C 比 SPI “省线但啰嗦”,吃透了 EEPROM 这一例,你基本就掌握了所有 I2C 从器件的读写套路。


拓展应用

掌握了 I2C EEPROM 读写后,可以延伸至大量 I2C 从设备:

  • 存配置参数:把设备的校准值、用户设置写入 EEPROM,掉电不丢,上电读取恢复。
  • 其它 I2C 传感器:如温湿度 SHT30、气压 BMP280、IMU MPU6050、光照 BH1750——它们的读写同样是"START → 地址 → 寄存器地址 → 数据/读回"这套协议,只是寄存器定义不同。
  • RTC 时钟芯片(PCF8563 / DS3231):I2C 读时间、设闹钟。
  • I/O 扩展芯片(PCF8574 / MCP23017):用 I2C 扩展 GPIO 口,省 MCU 引脚。
  • OLED 显示屏(SSD1306 的 I2C 版本):用 I2C 刷小屏。
  • 升级为 DMA / 中断:数据量大时改用同目录 I2C_EEPROM_dma / I2C_EEPROM_interrput,把轮询等待换成 DMA 或中断,释放 CPU。
  • 多从机挂载:同一组 PB6/PB7 上可并挂多个 I2C 器件(EEPROM + 传感器 + RTC),靠各自不同地址区分,体现 I2C “两根线挂一片设备"的优势。

想看更简洁的纯主机收发骨架,可阅读同目录的 Master_transmitter / Master_receiver 例程。

世界是你们
使用 Hugo 构建
主题 StackJimmy 设计