【STM32】 I2C_EEPROM_test 工程(实战篇)


工程剖析:串口配置 → AT24C02 持久化 → LED 闪灯


1. 工程概览

1.1 功能一句话

通过串口交互配置"选哪几颗灯、闪几次、间隔多久、延时多久启动",把配置写入 AT24C02(I2C EEPROM)固化,上电后按保存的配置自动闪灯------典型的"参数掉电持久化"应用。

实际运行效果(串口控制台):

下一次上电选"2.使用现在的配置",读回 EEPROM 数据、校验通过后按同一套参数闪灯。


1.2 芯片与目录结构

项目 内容
芯片 STM32F103ZET6(LQFP144,512KB Flash / 64KB SRAM)
主频 8MHz HSE × PLL9 = 72MHz
外设 I2C2(400kHz)、USART1(115200)、GPIO PF8~PF11
EEPROM AT24C02(256 字节,8 字节/页,写周期最长 5ms)
工具链 CubeMX 生成 + Keil MDK-ARM(VSCode + Keil Assistant)
cpp 复制代码
I2C_EEPROM_test/                  ← 工程根目录
├── APP/                          # 应用层(用户代码)
│   ├── App.c / App.h             # 配置交互 + 执行逻辑
├── Core/                         # CubeMX 生成:HAL 初始化骨架
│   ├── Inc/                      # main.h / stm32f1xx_it.h ...
│   └── Src/                      # main.c / stm32f1xx_hal_msp.c / stm32f1xx_it.c
├── Drivers/                      # CubeMX 生成:HAL 库 + CMSIS
├── MDK-ARM/                      # Keil 工程(.uvprojx + .vscode/)
├── I2C_EEFORM_test.ioc           # CubeMX 工程文件(引脚/时钟配置)
└── .mxproject

D:\code_project\BSP_drivers/      ← 工程外的共享 BSP 驱动库
├── inc/   BSP_AT24C02.h  BSP_LED.h  BSP_Usart_Redir.h
└── src/   BSP_AT24C02.c  BSP_LED.c  BSP_Usart_Redir.c

注意:

BSP 驱动不在工程目录里,Keil 通过 ../../BSP_drivers/inc 引用头文件,Usart_Redir_test 等其他测试工程共用同一份驱动。这就是"驱动独立成库、多工程复用"的工程化做法。


2. 硬件设计

2.1 引脚总表

外设 引脚 GPIO 模式 速度/细节
I2C2_SCL PB10 复用开漏 AF_OD High speed,400kHz Fast 模式
I2C2_SDA PB11 复用开漏 AF_OD 同上(需外部上拉电阻)
USART1_TX PA9 复用推挽 AF_PP 115200
USART1_RX PA10 复用推挽 AF_PP 输入
LED1~LED4 PF8~PF11 通用开漏输出 OD 低电平点亮

2.2 LED 为什么是开漏输出

CubeMX 里 PF8~PF11 配的是 GPIO_MODE_OUTPUT_OD(开漏),代码里"点亮 = 写低电平":

cpp 复制代码
GPIO_PinState state = flag ? GPIO_PIN_RESET : GPIO_PIN_SET;  // ON → RESET(低)

开漏输出的本质:

引脚只能拉低(导通到地),不能主动输出高。对应硬件电路通常是:

复制代码
VCC ── LED ── 限流电阻 ── GPIO(PF8)

GPIO 输出低电平 → 电流灌进引脚 → LED 亮;

输出高阻/高电平时 LED 灭。

这样做的两个好处:

一是可以驱动高于 MCU 电平的负载(比如 5V 供电的 LED 灯条,开漏 + 外部上拉就实现了电平转换);

二是多个开漏输出可以直接并联(线与逻辑)。

缺点是必须靠外部上拉电阻,所以配置里 Pull = GPIO_NOPULL,上拉在板子上。


2.3 AT24C02 器件速览

AT24C02 是 Atmel/Microchip 的 2Kbit(256 字节)I2C EEPROM,本工程用到的关键特性:

特性 对代码的影响
容量 256 字节 地址 0x00~0xFF,8 位存储地址
页大小 8 字节 一次写周期最多写 8 字节且不能跨页
器件地址 1010 A2 A1 A0(0x50 基址) A2A1A0=000 时 7 位地址 0x50
写周期 tWR 最长 5ms 写完必须轮询等 ACK,否则读到旧数据
擦写寿命 100 万次 频繁写要留意磨损(本工程只在配置时写一次,无压力)

引脚上除了 SCL/SDA,还有 A0/A1/A2(地址选择,接地为 000 → 0x50)、WP(写保护,接地允许写)。同一 I2C 总线上最多可以挂 8 片 AT24C02,靠 A0~A2 区分地址。


2.4 时钟树

复制代码
HSE 8MHz ──×9(PLL)──→ SYSCLK 72MHz
                        ├─ AHB /1 → HCLK 72MHz(SysTick、GPIO 时钟域)
                        ├─ APB1 /2 → PCLK1 36MHz(I2C2、USART2/3、TIM 等)
                        └─ APB2 /1 → PCLK2 72MHz(USART1、GPIO、TIM1 等)

SystemClock_ConfigFLASH_LATENCY_2 对应 72MHz 需要 2 个 Flash 等待周期------这是 72MHz 超频/正常满频运行的必要配置。


3. I2C 协议与 AT24C02 基础(把底层讲透)

3.1 7 位地址 vs 8 位地址:为什么 0x50 << 1 = 0xA0

I2C 协议里,从机地址在总线上传输时是一个完整的 8 位字节:高 7 位是器件地址,最低位是读写标志位(0=写,1=读)

AT24C02 的 7 位地址由"固定部分 1010" + "A2A1A0"组成:

复制代码
7 位地址:  1  0  1  0  A2 A1 A0        ← A2A1A0=000 → 0b1010000 = 0x50
8 位写地址: 1010 000 0  = 0xA0          ← 最低位 R/W=0
8 位读地址: 1010 000 1  = 0xA1          ← 最低位 R/W=1

所以 DEV_ADDRESS 0x50 是 7 位地址,at24c02.dev_address = 0x50 << 1 = 0xA0 是带上写位的 8 位地址------HAL 的 HAL_I2C_Mem_Read/Write 需要的正是这个 8 位值。

这是个经典易混点:数据手册上写的"0xA0"通常已经是 8 位写地址,而代码里习惯存 7 位再移位,两种写法都对,但要分清楚。


3.2 一次写事务/读事务在总线上的样子

写一字节到地址 0x00(HAL_I2C_Mem_Write 做的事):

复制代码
S | 0xA0(W) | ACK | 0x00(存储地址) | ACK | D0 | ACK | P
起始  从机地址  应答   内存地址       应答   数据  应答  停止

从地址 0x00 读 8 字节(HAL_I2C_Mem_Read 做的事,注意中间有 Repeated START):

复制代码
S | 0xA0(W) | ACK | 0x00 | ACK | Sr | 0xA1(R) | ACK | D0..D6 | ACK | D7 | NACK | P
起始  先写地址      应答          重复起始  切到读模式      读8字节     末字节不答  停止

为什么要"先写地址再切换读模式"?

**因为 EEPROM 是内存映射器件,必须先告诉它"从哪个地址开始",然后才能连续读出。**最后一个字节主机回 NACK 告诉从机"不要再发了",然后发停止位------这是 I2C 读操作的收尾规范。


3.3 为什么 I2C_MEMADD_SIZE_8BIT

AT24C02 只有 256 字节,存储地址 0255 用一个字节就能表示,所以 I2C_MEMADD_SIZE_8BIT

换大容量型号要改这里****AT24C04/08/16 地址超过 8 位(9~11 位),一部分地址位被并进器件地址字节里,I2C_MEMADD_SIZE_8BIT 就不够用了------这是移植时最容易漏的地方。


3.4 写周期 tWR:为什么写完还要轮询

EEPROM 收到数据后,内部要用电荷泵擦除+编程,这个过程需要时间(最长 5ms),期间器件不响应任何 ACK。所以驱动写完必须轮询探测:

cpp 复制代码
static bool BSP_AT24C02_Ready(void)
{
    return HAL_OK == HAL_I2C_IsDeviceReady(hi2c, dev_address, POLL_RETYIES, POLL_TIME_MS);
    // 重试10次 × 每次间隔2ms,器件恢复ACK即认为写周期结束
}

HAL_I2C_IsDeviceReady 发"起始+地址+等ACK+停止",EEPROM 没写完就不 ACK,函数返回超时。

漏掉这一步的典型症状:写完立刻读,读到的是旧数据


4. 分层架构

架构要点:

  1. 生成层只做初始化:main.c 全部是 CubeMX 生成的初始化代码,用户代码只出现在 USER CODE 注释区间------CubeMX 重新生成不会覆盖业务代码。
  2. BSP 独立共享:驱动库在工程外,被多个工程复用,业务层只依赖 BSP 接口,不直接碰寄存器。
  3. 单向依赖:APP → BSP → HAL → 硬件,每一层只调下一层的接口,没有反向依赖。这种结构让换硬件(比如换 I2C1)、换芯片都只动生成层。

5. 代码逐模块深读

5.1 main.c:启动流程逐行

cpp 复制代码
int main(void)
{
    HAL_Init();                // ① 复位所有外设、初始化 Flash 接口和 SysTick(1ms 节拍)
    SystemClock_Config();      // ② 72MHz 时钟树
    MX_GPIO_Init();            // ③ PF8~11 开漏输出,初始电平全部拉低(RESET)
    MX_I2C2_Init();            // ④ I2C2 400kHz,7 位地址模式
    MX_USART1_UART_Init();     // ⑤ USART1 115200

    /* USER CODE BEGIN 2 */    // ← 用户代码从这里开始
    BSP_Usart_Init(&huart1);   // ⑥ 把 UART 句柄交给 BSP(printf/scanf 立即可用)
    BSP_AT24C02_Init(&hi2c2);  // ⑦ 探测 EEPROM 是否在线
    /* USER CODE END 2 */

    while (1) {
        App_Init();            // ⑧ 串口交互:重新配置 or 读回现有配置
        App_Run();             // ⑨ 校验魔数 → 延时 → 按配置闪灯
    }
}

几个值得注意的点:

  • 句柄是"交出去"不是"包起来"****:BSP_Usart_Init(&huart1)huart1 指针存到 BSP 层。以后换串口(比如改用 USART2),只需要改 main.c 里这一行传参,BSP 代码零改动------这就是"依赖注入"思想在嵌入式里的朴素形态。

  • I2C2 初始化参数:400kHz、I2C_DUTYCYCLE_2(Fast 模式下高低电平 2:1 占空比)。HAL 会根据 PCLK1=36MHz 自动计算 CCR 分频和 TRISE 上升时间寄存器,不需要手算。

  • USART1 的特殊配置(重点):

    huart1.Init.BaudRate = 115200;
    huart1.Init.WordLength = UART_WORDLENGTH_9B; // 9 位字长(M=1)
    huart1.Init.Parity = UART_PARITY_ODD; // 奇校验使能

UART_WORDLENGTH_9B + 奇校验的组合,在线上实际是 1 起始位 + 8 数据位 + 1 奇校验位 + 1 停止位(校验位占据 9 位帧的最高位)。

**也就是说:串口助手要配成 8 位数据 + 奇校验(8-O-1)才能对上,配成常见的 8N1 会整个帧错位、乱码。**如果你上位机协议是标准 8N1,这里要改回 UART_WORDLENGTH_8B + UART_PARITY_NONE

  • Error_Handler:任何初始化失败都会进来,__disable_irq() 后死循环------嵌入式里最朴素的"卡死报错"方式,方便调试时一眼看出初始化没过。

5.2 BSP_Usart_Redir:printf/scanf 重定向的原理

这是整个工程最"妙"的一层,只有 40 行:

cpp 复制代码
UART_HandleTypeDef *ghusart = NULL;   // 全局串口句柄(单例)

void BSP_Usart_Init(UART_HandleTypeDef *husart) { ghusart = husart; }

int fputc(int ch, FILE *fp)           // printf("%d", x) 最终会调它
{
    UNUSED(fp);
    if (ghusart && HAL_UART_Transmit(ghusart, (uint8_t *)&ch, 1, HAL_MAX_DELAY) == HAL_OK)
        return ch;
    return EOF;
}

int fgetc(FILE *fp)                   // scanf("%d", &x) 最终会调它
{
    UNUSED(fp);
    uint8_t ch;
    while (1) {
        if (HAL_UART_Receive(ghusart, &ch, 1, HAL_MAX_DELAY) != HAL_OK)
            return EOF;
        if (ch != '\r')               // 过滤回车符 \r,只留有效字符
            return (int)ch;
    }
}

原理拆解:

C 标准库的 printf 内部把格式化结果逐字符写到 stdout 流,流的底层写函数就是 fputcscanf 反过来从 stdin 流逐字符读,底层是 fgetc

只要实现了这两个函数,把字节导向 UART,整个应用层就能用 printf/scanf 做串口交互------应用层完全不知道 UART 的存在。这正是"标准库重定向(retarget)"的经典做法(Keil 下通常配合 MicroLIB 使用)。

三个细节:

  1. HAL_MAX_DELAY = 阻塞等待发送完成,printf 是同步阻塞的------本工程串口只是调试交互,没问题;将来接实时任务调度(FreeRTOS)就要改成中断/DMA + 互斥锁。
  2. fgetcwhile(1) 阻塞等一个字节------scanf 没输入时程序就停在那等,这就是菜单能"卡住等用户"的原因。
  3. 过滤 '\r':串口助手发送时通常带 \r\n,如果不过滤,scanf("%d") 之后的残留 \r 会被当成下一个输入字符。这里只过滤了 \r\n 会留在缓冲区------但 scanf%d 会自动跳过空白字符(空格、\n\t),所以不影响。

请求函数的小细节:

复制代码
static int Request_Input(const char *message)
{
    int choice = 0;
    printf(">>%s\n", message);
    if (scanf("%d", &choice) == 1)   // scanf 返回值 = 成功转换的参数个数
        return choice;
    printf("选择错误!请重新输入!");
    return -1;                       // 输入了非数字 → 返回 -1
}

scanf 返回 0 表示"一个都没转出来"(比如输入了字母 abc),此时返回 -1。

而调用处 uint8_t choice = Request_Input(...) 把 -1 塞进 uint8_t 变成 255,255 != 1 && 255 != 2,走 else 分支打印错误------错误输入被"无意中"兜住了,但这属于隐式类型转换的巧合,见第 7 节。


5.3 BSP_LED:控制与闪灯

cpp 复制代码
void BSP_LED_Control(LED_t led, bool flag)
{
    GPIO_PinState state = flag ? GPIO_PIN_RESET : GPIO_PIN_SET;  // ON = 低电平
    switch (led) {
        case LED1: HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8,  state); break;
        case LED2: HAL_GPIO_WritePin(GPIOF, GPIO_PIN_9,  state); break;
        case LED3: HAL_GPIO_WritePin(GPIOF, GPIO_PIN_10, state); break;
        case LED4: HAL_GPIO_WritePin(GPIOF, GPIO_PIN_11, state); break;
        default:   break;
    }
}

flag ? GPIO_PIN_RESET : GPIO_PIN_SET 这行是极性反转:

逻辑层说"ON",物理层写低电平------把"低电平点亮"这个硬件事实封装在 BSP 内部,上层永远只说"亮/灭",不懂电学也能用。

以后电路改成高电平点亮,只改这一个函数。


闪灯函数 LED_Blink_Version2(led, cnt, delay) 分两路:

cpp 复制代码
if (led == 0) {          // 全选:4 颗灯一起闪
    LED_t leds[] = {LED1, LED2, LED3, LED4};
    int leds_num = sizeof(leds) / sizeof(leds[0]);   // = 4,数组元素个数
    do {
        // 全部点亮 → HAL_Delay(delay) → 全部熄灭 → HAL_Delay(delay)
    } while (cnt--);    
} else {                 // 单选:闪 cnt 次
    for (int i = 0; i < cnt; i++) { /* 亮 delay → 灭 delay */ }
}

5.4 BSP_AT24C02:EEPROM 驱动全解析(工程核心)

数据结构:一个"设备对象"
cpp 复制代码
typedef struct {
    I2C_HandleTypeDef *hi2c;   // 指向 I2C 句柄(哪条 I2C 总线)
    uint16_t dev_address;      // 8 位设备地址(0xA0)
    uint8_t page_size;         // 页大小 8
    bool is_init;              // 初始化是否成功(EEPROM 是否在线)
} eeprom_t;

eeprom_t at24c02;              // 全局单例实例

把"设备属性"打包成一个结构体、用一个全局实例持有,是嵌入式驱动最常见的形式。局限是单例不支持挂多片 EEPROM(改进见第 8 节)。


初始化:探测 + 记录属性
cpp 复制代码
bool BSP_AT24C02_Init(I2C_HandleTypeDef *hi2c)
{
    if (!hi2c) return false;
    at24c02.hi2c = hi2c;
    at24c02.dev_address = DEV_ADDRESS << 1;   // 0x50 → 0xA0
    at24c02.page_size = PAGE_SIZE;            // 8
    at24c02.is_init = false;
    if (HAL_OK == HAL_I2C_IsDeviceReady(at24c02.hi2c, at24c02.dev_address,
                                        SCAN_RETYIES, SCAN_TIME_MS)) {
        at24c02.is_init = true;               // 收到 ACK = 器件在线
    }
    return at24c02.is_init;
}

所有接口开头都检查 is_init------没初始化成功就拒绝读写,避免对不存在的器件空转超时。


读:边界检查 + Mem_Read
cpp 复制代码
bool BSP_AT24C02_Read(uint16_t start_address, void *data_out, uint8_t data_len)
{
    if (!at24c02.is_init || !data_out || data_len == 0) return false;
    if (start_address + data_len > DEV_TOTAL_SIZE) return false;   // 越界保护
    HAL_StatusTypeDef state = HAL_I2C_Mem_Read(
        at24c02.hi2c, at24c02.dev_address, start_address,
        I2C_MEMADD_SIZE_8BIT, data_out, data_len, READ_TIMEOUT_MS);
    return state == HAL_OK;
}

读操作没有页限制(EEPROM 可以跨页连续读),所以只有一个总容量越界检查。


写:单页写 + 自动翻页(重点算法)

为什么不能一把梭全写进去:

AT24C02 的写周期限制是"一次最多写一页(8 字节)且不能跨页"。

从地址 5 开始写 6 字节,会跨过地址 7 进入下一页------这种跨页写在器件内部会出错(要么被忽略,要么数据错乱)。所以驱动必须自己把大块数据拆成"每段不超过当前页剩余空间"的小段。


单页写:

cpp 复制代码
// 单⻚,⻚内写⼊
static bool BSP_AT24C02_Write_Page(uint16_t start_address, void *data_in,
                                   uint8_t data_len)
{
    if (!at24c02.is_init || !data_in || data_len == 0)
        return false;
    // 检查写⼊是否跨⻚
    // 当前写⼊到那⼀⻚
    // uint16_t page_num = start_address / at24c02.page_size; // 不重要
    uint16_t page_offset = start_address % at24c02.page_size; // 确定的⼀⻚,写⼊位置的开始下标!
    if (page_offset + data_len > at24c02.page_size)
    {
        return false;
    }
    HAL_StatusTypeDef state = HAL_I2C_Mem_Write(
        at24c02.hi2c,
        at24c02.dev_address,
        start_address,
        I2C_MEMADD_SIZE_8BIT,
        data_in,
        data_len,
        WRITE_TIMEOUT_MS);
    if (state != HAL_OK)
        return false;
    return BSP_AT24C02_Ready();
}

外层翻页循环:

cpp 复制代码
// ⽀持任意⻓度的写⼊,⾃动跨⻚
bool BSP_AT24C02_Write(uint16_t start_address, void *data_in, uint8_t data_len)
{
    if (!at24c02.is_init || !data_in || data_len == 0)
        return false;
    if (start_address + data_len > DEV_TOTAL_SIZE)//超过全部页的储量
        return false;
    uint16_t bytes_written = 0; // 已经写⼊了多少个字节

    while (bytes_written < data_len)//未完成写
    {
        // 数据还有多少没有发送
        uint16_t data_left = data_len - bytes_written;
        // 必须按照⻚来写,当前⻚剩余多少空间
        uint16_t new_start_address = start_address + bytes_written;//翻页逻辑
        // uint16_t current_page_num = new_start_address / at24c02.page_size;
        uint16_t page_offset = new_start_address % at24c02.page_size;//已经占用的空间
        uint16_t page_space = at24c02.page_size - page_offset;//剩余空间
        // 数据还有多少没有发送 -> 当前⻚剩余多少空间
        // ⼀定能写⼊当前⻚的剩余空间吗?不⼀定!!
        // 计算本次写⼊量
        // 1. 数据还有多少没有发送 <= 当前⻚剩余多少空间 = 数据还有多少
        // 2. 数据还有多少没有发送 >  当前⻚剩余多少空间 = 剩余多少空间
        uint16_t write_bytes = data_left;
        if (write_bytes > page_space)
        {
            write_bytes = page_space;//可以写且不跨页
        }
        if (!BSP_AT24C02_Write_Page(new_start_address,(uint8_t *)data_in + bytes_written, write_bytes))
        {
            return false;//单页写没成功
        }
        bytes_written += write_bytes;//把当页能写的加入,再次循环,翻页写
    }
    return true;
}

算法核心一句话:每次循环只写"当前页剩余空间"这么长,写完推进指针进入下一页,直到数据全部写完。调用方不需要知道页边界,驱动内部透明处理------这是 EEPROM 驱动设计的关键封装。


具体过程(以"从地址 5 写 10 字节"为例)见下图:

再给一张迭代跟踪表(start=5, len=10),把循环跑一遍:

迭代 bytes_written new_addr page_offset page_space data_left write_bytes 写入范围
1 0 5 5 3 10 3 地址 5~7
2 3 8 0 8 7 7 地址 8~14
--- 10 = data_len → 循环结束

而本工程实际调用 SAVE_CONFIG 时,CONFIG_ADDRESS=0x00len=8,恰好 8 字节正好一页------page_offset=0、page_space=8、write_bytes=8,循环一次就完成,连翻页都不需要。这也是把配置放在 0x00 地址的巧妙之处:刚好对齐页边界。


5.5 APP 层:配置对象、交互、魔数校验

config_t 的内存布局
cpp 复制代码
typedef struct _config_t {
    uint8_t  magic;       // 偏移 0:魔数 0x35
    uint8_t  version;     // 偏移 1:版本 0x01
    uint8_t  led_id;      // 偏移 2:灯序号(0=全选)
    uint8_t  repeat_cnt;  // 偏移 3:闪灯次数
    uint16_t delay_ms;    // 偏移 4:闪烁间隔
    uint16_t start_time;  // 偏移 6:启动延时
} config_t;               // sizeof = 8 字节,无填充字节

uint16_t 需要 2 字节对齐,编译器把它排到偏移 4 和 6,前 4 个 uint8_t 占 0~3------天然紧凑,正好 8 字节,恰好填满 AT24C02 的一页。

这个"结构体直接落盘"的方式依赖编译器不插填充字节,如果将来在中间加一个 uint8_t 字段,sizeof 可能变成 10,跨页逻辑就会触发------这是结构体直接持久化的一个隐患。


读写宏
cpp 复制代码
#define SAVE_CONFIG(conf_addr, conf_len) BSP_AT24C02_Write(CONFIG_ADDRESS, conf_addr, conf_len)
#define LOAD_CONFIG(conf_addr, conf_len) BSP_AT24C02_Read(CONFIG_ADDRESS, conf_addr, conf_len)
//   BSP_AT24C02_Write(0x00, &gconfig, 8)
//   BSP_AT24C02_Read(0x00, &gconfig, 8)

宏把"配置存储在地址 0x00"这个约定统一收口,业务代码里只写 SAVE_CONFIG(&gconfig, sizeof(gconfig)),改存储位置只动一处。


App_Init:交互式配置
cpp 复制代码
void App_Init(void)
{
    uint8_t choice = Request_Input("1.重新配置参数 2.使用现在的配置");
    if (choice == 1) {
        gconfig.magic      = APP_MAGIC;    // 0x35,写前就打上标签
        gconfig.version    = APP_VERSION;  // 0x01
        gconfig.led_id     = (uint8_t)Request_Input("step1:选择LED【1-4】,0表示全选>> ");
        gconfig.repeat_cnt = (uint8_t)Request_Input("step2:设置闪灯次数,【1-255】>> ");
        gconfig.delay_ms   = (uint16_t)Request_Input("step3:选择延迟间隔,单位ms>> ");
        gconfig.start_time = (uint16_t)Request_Input("step4:选择开始时间.单位ms>> ");
        Config_Display();
        SAVE_CONFIG(&gconfig, sizeof(gconfig));   // 写入 EEPROM
        printf(">> write config done <<\n");
    } else if (choice == 2) {
        LOAD_CONFIG(&gconfig, sizeof(gconfig));   // 读回 EEPROM
        printf(">> load config done <<\n");
    } else {
        printf("选择错误!请重新输入!");
    }
}

注意 magicversion 是写前先填好的------它不是用户输入项,是驱动层打的"数据标签"。用户只输入 4 个业务参数(灯、次数、间隔、延时)。


App_Run:魔数校验 + 执行
cpp 复制代码
void App_Run(void)
{
    if (gconfig.magic == APP_MAGIC && gconfig.version == APP_VERSION) {
        printf(">> %d ms之后, 开始闪灯\n", (int)gconfig.start_time);
        HAL_Delay(gconfig.start_time);                 // 延时启动(阻塞)
        printf(">> 闪灯中...\n");
        LED_Blink_Version2((LED_t)gconfig.led_id, gconfig.repeat_cnt, gconfig.delay_ms);
    }
    // 校验不过 → 什么都不做(安全降级)
}

魔数校验是 APP 的精华:全新 EEPROM 内容全是 0xFF,如果直接拿 0xFFrepeat_cnt(255 次)和 delay_ms(255ms)用,会出现"幽灵闪灯"。

magic == 0x35 校验把"存过有效配置"和"空片/数据损坏"区分开,校验不过就静默不闪------宁可什么都不做,也不要用错误数据驱动硬件。

version 是给将来格式升级留的兼容位。


6. 完整运行时序:把两条流程走一遍

场景 A:首次上电 + 配置并保存

cpp 复制代码
上电 → HAL/时钟/外设初始化 → EEPROM 探测成功
     → App_Init: 打印菜单 → 用户选 1
     → 依次 scanf 4 个参数 → 填 gconfig(magic=0x35)
     → SAVE_CONFIG: I2C2 写 8 字节到 0x00 → 轮询等 tWR 结束
     → App_Run: magic 校验通过 → 延时 start_time ms → 闪灯

场景 B:断电重启,使用保存的配置

复制代码
上电 → 初始化 → EEPROM 探测成功
     → App_Init: 打印菜单 → 用户选 2
     → LOAD_CONFIG: I2C2 读 8 字节 → gconfig
     → App_Run: magic==0x35 && version==0x01 → 延时 → 按参数闪灯

场景 C:EEPROM 是空片 / 从没写过

复制代码
选 2 → 读回全 0xFF → App_Run 校验失败 → 不闪灯(安全)

7. 完整工程代码

BSP_AT24C02.h/c

cpp 复制代码
#ifndef __BSP_AT24C02_H
#define __BSP_AT24C02_H
#ifdef __cplusplus
extern "C"
{
#endif

#include <stdbool.h>
#include "stm32f1xx_hal.h"

    bool Read_Page_Data(void *data_out, uint8_t data_len);
    bool Write_Page_Data(void *data_in, uint8_t data_len);

    // 表⽰从设备
    typedef struct
    {
        I2C_HandleTypeDef *hi2c; // I2C2句柄指针
        uint16_t dev_address;    // 从设备地址
        uint8_t page_size;       // ⻚⼤⼩
        bool is_init;            // 是否被初始化
    } eeprom_t;

#define DEV_ADDRESS     0x50    // I2C从机设备地址
#define PAGE_SIZE       8       // EEPROM单页写入字节数
#define DEV_TOTAL_SIZE  256     // EEPROM总存储字节大小
#define SCAN_RETYIES    2       // I2C设备扫描重试次数
#define SCAN_TIME_MS    5       // 扫描重试间隔(ms)
#define POLL_RETYIES    10      // 写等待轮询重试次数
#define POLL_TIME_MS    2       // 轮询等待间隔(ms)
#define READ_TIMEOUT_MS 50      // I2C读取超时时间(ms)
#define WRITE_TIMEOUT_MS 50     // I2C写入超时时间(ms)


    bool BSP_AT24C02_Init(I2C_HandleTypeDef *hi2c);
    bool BSP_AT24C02_Read(uint16_t start_address, void *data_out, uint8_t data_len);
    bool BSP_AT24C02_Write(uint16_t start_address, void *data_in, uint8_t data_len);

#ifdef __cplusplus
}
#endif

#endif
------------------------------------------------------------------
#include "BSP_AT24C02.h"
eeprom_t at24c02;


//初始化逻辑
bool BSP_AT24C02_Init(I2C_HandleTypeDef *hi2c)

{
    if (!hi2c)
        return false;
    at24c02.hi2c = hi2c;
    at24c02.dev_address = DEV_ADDRESS << 1; // 左移1位(八位变七位)
    at24c02.page_size = PAGE_SIZE;
    at24c02.is_init = false;
    // 1. 产⽣起始
    // 2. 发送从设备地址(七位) + w(0) = 8字节数据
    // 3. ACK -> 从设备活着呢!
    // 4. STOP
    if (HAL_OK == HAL_I2C_IsDeviceReady(at24c02.hi2c, at24c02.dev_address,
                                        SCAN_RETYIES, SCAN_TIME_MS))
    {
        at24c02.is_init = true;
    }
    return at24c02.is_init;
}


// 按字节,按page,按任意读
bool BSP_AT24C02_Read(uint16_t start_address, void *data_out, uint8_t data_len)
{
    if (!at24c02.is_init || !data_out || data_len == 0)
        return false;
    if (start_address + data_len > DEV_TOTAL_SIZE)//超过页可读范围
    {
        return false;
    }
    HAL_StatusTypeDef state = HAL_I2C_Mem_Read(
        at24c02.hi2c, at24c02.dev_address,
        start_address,
        I2C_MEMADD_SIZE_8BIT,
        data_out,
        data_len,
        READ_TIMEOUT_MS);
    return state == HAL_OK;
}
static bool BSP_AT24C02_Ready()
{
    if (!at24c02.is_init)
        return false;
    return HAL_OK == HAL_I2C_IsDeviceReady(at24c02.hi2c, at24c02.dev_address,
                                           POLL_RETYIES, POLL_TIME_MS);
}


// 单⻚,⻚内写⼊
static bool BSP_AT24C02_Write_Page(uint16_t start_address, void *data_in,
                                   uint8_t data_len)
{
    if (!at24c02.is_init || !data_in || data_len == 0)
        return false;
    // 检查写⼊是否跨⻚
    // 当前写⼊到那⼀⻚
    // uint16_t page_num = start_address / at24c02.page_size; // 不重要
    uint16_t page_offset = start_address % at24c02.page_size; // 确定的⼀⻚,写⼊位置的开始下标!
    if (page_offset + data_len > at24c02.page_size)
    {
        return false;
    }
    HAL_StatusTypeDef state = HAL_I2C_Mem_Write(
        at24c02.hi2c,
        at24c02.dev_address,
        start_address,
        I2C_MEMADD_SIZE_8BIT,
        data_in,
        data_len,
        WRITE_TIMEOUT_MS);
    if (state != HAL_OK)
        return false;
    return BSP_AT24C02_Ready();
}


// ⽀持任意⻓度的写⼊,⾃动跨⻚
bool BSP_AT24C02_Write(uint16_t start_address, void *data_in, uint8_t data_len)
{
    if (!at24c02.is_init || !data_in || data_len == 0)
        return false;
    if (start_address + data_len > DEV_TOTAL_SIZE)//超过全部页的储量
        return false;
    uint16_t bytes_written = 0; // 已经写⼊了多少个字节

    while (bytes_written < data_len)//未完成写
    {
        // 数据还有多少没有发送
        uint16_t data_left = data_len - bytes_written;
        // 必须按照⻚来写,当前⻚剩余多少空间
        uint16_t new_start_address = start_address + bytes_written;//翻页逻辑
        // uint16_t current_page_num = new_start_address / at24c02.page_size;
        uint16_t page_offset = new_start_address % at24c02.page_size;//已经占用的空间
        uint16_t page_space = at24c02.page_size - page_offset;//剩余空间
        // 数据还有多少没有发送 -> 当前⻚剩余多少空间
        // ⼀定能写⼊当前⻚的剩余空间吗?不⼀定!!
        // 计算本次写⼊量
        // 1. 数据还有多少没有发送 <= 当前⻚剩余多少空间 = 数据还有多少
        // 2. 数据还有多少没有发送 >  当前⻚剩余多少空间 = 剩余多少空间
        uint16_t write_bytes = data_left;
        if (write_bytes > page_space)
        {
            write_bytes = page_space;//可以写且不跨页
        }
        if (!BSP_AT24C02_Write_Page(new_start_address,(uint8_t *)data_in + bytes_written, write_bytes))
        {
            return false;//单页写没成功
        }
        bytes_written += write_bytes;//把当页能写的加入,再次循环,翻页写
    }
    return true;
}

App.h\c

cpp 复制代码
#ifndef __APP_H
#define __APP_H

#ifdef __cplusplus
extern "C"
{
#endif

#include <stdint.h>
#include "BSP_AT24C02.h"
#include "BSP_Usart_Redir.h"
#include "BSP_LED.h"
#define APP_MAGIC 0x35
#define APP_VERSION 0x01

    typedef struct _config_t
    {
        uint8_t magic;       // 魔数
        uint8_t version;     // 版本
        uint8_t led_id;      // 灯序号
        uint8_t repeat_cnt;  // 重复次数
        uint16_t delay_ms;   // 延迟间隔
        uint16_t start_time; // 开始时间
    } config_t;

#define CONFIG_ADDRESS 0x0  // 配置数据在AT24C02中的存储起始地址
#define SAVE_CONFIG(conf_addr, conf_len) BSP_AT24C02_Write(CONFIG_ADDRESS,conf_addr, conf_len)
#define LOAD_CONFIG(conf_addr, conf_len) BSP_AT24C02_Read(CONFIG_ADDRESS,conf_addr, conf_len)

void App_Init(void);
void App_Run(void);

#ifdef __cplusplus
}
#endif

#endif
----------------------------------------------------------------
#include "App.h"

config_t gconfig;
static void Config_Display(void)
{
    printf("-----------配置清单----------\n");
    printf("config.magic:0x%x\n", gconfig.magic);
    printf("config.version:0x%x\n", gconfig.version);
    printf("config.led_id:%d\n", gconfig.led_id);
    printf("config.repeat_cnt:%d\n", gconfig.repeat_cnt);
    printf("config.delay_ms:%d\n", gconfig.delay_ms);
    printf("config.start_time:%d\n", gconfig.start_time);
}
static int Request_Input(const char *message)
{
    int choice = 0;
    printf(">>%s\n", message);
    if (scanf("%d", &choice) == 1)
    {
        return choice;
    }

    printf("选择错误!请重新输入!");
    return -1;
}
void App_Init(void)
{
    uint8_t choice = Request_Input("1.重新配置参数 2.使用现在的配置");
    if (choice == 1)
    {
        gconfig.magic = APP_MAGIC;
        gconfig.version = APP_VERSION;
        gconfig.led_id = (uint8_t)Request_Input("step1:选择LED【1-4】,0表示全选>> ");
        gconfig.repeat_cnt = (uint8_t)Request_Input("step2:设置闪灯次数,【1-255】>> ");
        gconfig.delay_ms = (uint16_t)Request_Input("step3:选择延迟间隔,单位ms>> ");
        gconfig.start_time = (uint16_t)Request_Input("step4:选择开始时间.单位ms>> ");

        Config_Display();
        // 写⼊到EEPROM
        SAVE_CONFIG(&gconfig, sizeof(gconfig)); // AT24C02写⼊
        printf(">> write config done <<\n");
    }
    else if (choice == 2)
    {
        // 读取EEPROM
        LOAD_CONFIG(&gconfig, sizeof(gconfig));
        printf(">> load config done <<\n");
    }
    else
    {
        printf("选择错误!请重新输入!");
    }
}
void App_Run()
{
    if (gconfig.magic == APP_MAGIC && gconfig.version == APP_VERSION)
    {
        printf(">> %d ms之后, 开始闪灯\n", (int)(gconfig.start_time));
        HAL_Delay(gconfig.start_time);
        printf(">> 闪灯中...\n");
        LED_Blink_Version2((LED_t)(gconfig.led_id), gconfig.repeat_cnt,
                           gconfig.delay_ms);
    }
}

main.h\c

cpp 复制代码
#ifndef __MAIN_H
#define __MAIN_H

#ifdef __cplusplus
extern "C" {
#endif

/* Includes ------------------------------------------------------------------*/
#include "stm32f1xx_hal.h"

/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include <stdbool.h>
#include "BSP_AT24C02.h"
#include "BSP_Usart_Redir.h"
#include "BSP_LED.h"
#include "App.h"


/* Exported functions prototypes ---------------------------------------------*/
void Error_Handler(void);


#ifdef __cplusplus
}
#endif

#endif /* __MAIN_H */

--------------------------------------------------------

#include "main.h"

/* Private variables ---------------------------------------------------------*/
I2C_HandleTypeDef hi2c2;

UART_HandleTypeDef huart1;

/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_I2C2_Init(void);
static void MX_USART1_UART_Init(void);

int main(void)
{

  MX_GPIO_Init();
  MX_I2C2_Init();
  MX_USART1_UART_Init();

  /* USER CODE BEGIN 2 */
  BSP_Usart_Init(&huart1);
  BSP_AT24C02_Init(&hi2c2);
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
    App_Init();
    App_Run();
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }
  /* USER CODE END 3 */
}

/**
  * @brief System Clock Configuration
  * @retval None
  */
void SystemClock_Config(void)
{
  RCC_OscInitTypeDef RCC_OscInitStruct = {0};
  RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};

  /** Initializes the RCC Oscillators according to the specified parameters
  * in the RCC_OscInitTypeDef structure.
  */
  RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
  RCC_OscInitStruct.HSEState = RCC_HSE_ON;
  RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1;
  RCC_OscInitStruct.HSIState = RCC_HSI_ON;
  RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
  RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
  RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;
  if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
  {
    Error_Handler();
  }

  /** Initializes the CPU, AHB and APB buses clocks
  */
  RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
                              |RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
  RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
  RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
  RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;
  RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;

  if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK)
  {
    Error_Handler();
  }
}

/**
  * @brief I2C2 Initialization Function
  * @param None
  * @retval None
  */
static void MX_I2C2_Init(void)
{

  /* USER CODE BEGIN I2C2_Init 0 */

  /* USER CODE END I2C2_Init 0 */

  /* USER CODE BEGIN I2C2_Init 1 */

  /* USER CODE END I2C2_Init 1 */
  hi2c2.Instance = I2C2;
  hi2c2.Init.ClockSpeed = 400000;
  hi2c2.Init.DutyCycle = I2C_DUTYCYCLE_2;
  hi2c2.Init.OwnAddress1 = 0;
  hi2c2.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
  hi2c2.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
  hi2c2.Init.OwnAddress2 = 0;
  hi2c2.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
  hi2c2.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
  if (HAL_I2C_Init(&hi2c2) != HAL_OK)
  {
    Error_Handler();
  }
  /* USER CODE BEGIN I2C2_Init 2 */

  /* USER CODE END I2C2_Init 2 */

}

/**
  * @brief USART1 Initialization Function
  * @param None
  * @retval None
  */
static void MX_USART1_UART_Init(void)
{

  /* USER CODE BEGIN USART1_Init 0 */

  /* USER CODE END USART1_Init 0 */

  /* USER CODE BEGIN USART1_Init 1 */

  /* USER CODE END USART1_Init 1 */
  huart1.Instance = USART1;
  huart1.Init.BaudRate = 115200;
  huart1.Init.WordLength = UART_WORDLENGTH_9B;
  huart1.Init.StopBits = UART_STOPBITS_1;
  huart1.Init.Parity = UART_PARITY_ODD;
  huart1.Init.Mode = UART_MODE_TX_RX;
  huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
  huart1.Init.OverSampling = UART_OVERSAMPLING_16;
  if (HAL_UART_Init(&huart1) != HAL_OK)
  {
    Error_Handler();
  }
  /* USER CODE BEGIN USART1_Init 2 */

  /* USER CODE END USART1_Init 2 */

}

/**
  * @brief GPIO Initialization Function
  * @param None
  * @retval None
  */
static void MX_GPIO_Init(void)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};
  /* USER CODE BEGIN MX_GPIO_Init_1 */

  /* USER CODE END MX_GPIO_Init_1 */

  /* GPIO Ports Clock Enable */
  __HAL_RCC_GPIOF_CLK_ENABLE();
  __HAL_RCC_GPIOB_CLK_ENABLE();
  __HAL_RCC_GPIOA_CLK_ENABLE();

  /*Configure GPIO pin Output Level */
  HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8|GPIO_PIN_9|GPIO_PIN_10|GPIO_PIN_11, GPIO_PIN_RESET);

  /*Configure GPIO pins : PF8 PF9 PF10 PF11 */
  GPIO_InitStruct.Pin = GPIO_PIN_8|GPIO_PIN_9|GPIO_PIN_10|GPIO_PIN_11;
  GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;
  GPIO_InitStruct.Pull = GPIO_NOPULL;
  GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
  HAL_GPIO_Init(GPIOF, &GPIO_InitStruct);

  /* USER CODE BEGIN MX_GPIO_Init_2 */

  /* USER CODE END MX_GPIO_Init_2 */
}

相关推荐
清水白石00839 分钟前
Python 异步编程深度解析:Cancellation 到底是异常还是控制信号?从 asyncio 取消机制到企业级事务设计最佳实践
开发语言·python
en.en..40 分钟前
UDP套接字编程完整标准流程(服务端+客户端)
单片机·嵌入式硬件
kolyle1 小时前
万级 QPS 下的 Token 分发系统架构:从 0 到 1 跑通 AI 时代的“水电煤“
开发语言·人工智能·系统架构·token·qps·极智词元·大模型私有化部署
caoerzhong1 小时前
JeeWMS 开源仓库管理系统 GPL-3.0 合规指南:Java WMS 二次开发前必须弄清的授权边界
java·开发语言·开源·vue
Patrick在香港1 小时前
Python 审计香港开放数据目录:两个端点差 10 倍,只有 9.3% 的资源标了「最后修改时间」
开发语言·数据库·python·数据分析·api·数据治理·开放数据
斑马1391 小时前
单片机学习笔记(一)——STC89C52的GPIO、LED、数码管、定时器和中断
单片机·stc89c52
安河桥。1 小时前
SOME/IP 技术说明文档
嵌入式硬件·车载系统·信息与通信
Sylvia33.1 小时前
火星数据体育API|一站式接入足球篮球电竞等18+项目实时数据
java·开发语言·python·websocket·游戏
youcans_2 小时前
【嵌入式软件AI编程】09. 使用VS Code调试STM32程序
stm32·mcu·ai编程·嵌入式软件·claude code