工程剖析:串口配置 → 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)
cppI2C_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(开漏),代码里"点亮 = 写低电平":
cppGPIO_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_Config里FLASH_LATENCY_2对应 72MHz 需要 2 个 Flash 等待周期------这是 72MHz 超频/正常满频运行的必要配置。
3. I2C 协议与 AT24C02 基础(把底层讲透)
3.1 7 位地址 vs 8 位地址:为什么
0x50 << 1 = 0xA0I2C 协议里,从机地址在总线上传输时是一个完整的 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_8BITAT24C02 只有 256 字节,存储地址 0255 用一个字节就能表示,所以 I2C_MEMADD_SIZE_8BIT
换大容量型号要改这里****AT24C04/08/16 地址超过 8 位(9~11 位),一部分地址位被并进器件地址字节里,
I2C_MEMADD_SIZE_8BIT就不够用了------这是移植时最容易漏的地方。
3.4 写周期 tWR:为什么写完还要轮询
EEPROM 收到数据后,内部要用电荷泵擦除+编程,这个过程需要时间(最长 5ms),期间器件不响应任何 ACK。所以驱动写完必须轮询探测:
cppstatic 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. 分层架构
架构要点:
- 生成层只做初始化:
main.c全部是 CubeMX 生成的初始化代码,用户代码只出现在USER CODE注释区间------CubeMX 重新生成不会覆盖业务代码。- BSP 独立共享:驱动库在工程外,被多个工程复用,业务层只依赖 BSP 接口,不直接碰寄存器。
- 单向依赖:APP → BSP → HAL → 硬件,每一层只调下一层的接口,没有反向依赖。这种结构让换硬件(比如换 I2C1)、换芯片都只动生成层。
5. 代码逐模块深读
5.1 main.c:启动流程逐行
cppint 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 行:
cppUART_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流,流的底层写函数就是fputc;scanf反过来从stdin流逐字符读,底层是fgetc。只要实现了这两个函数,把字节导向 UART,整个应用层就能用
printf/scanf做串口交互------应用层完全不知道 UART 的存在。这正是"标准库重定向(retarget)"的经典做法(Keil 下通常配合 MicroLIB 使用)。三个细节:
HAL_MAX_DELAY= 阻塞等待发送完成,printf是同步阻塞的------本工程串口只是调试交互,没问题;将来接实时任务调度(FreeRTOS)就要改成中断/DMA + 互斥锁。fgetc里while(1)阻塞等一个字节------scanf没输入时程序就停在那等,这就是菜单能"卡住等用户"的原因。- 过滤
'\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:控制与闪灯
cppvoid 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)分两路:
cppif (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 驱动全解析(工程核心)
数据结构:一个"设备对象"
cpptypedef 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 节)。
初始化:探测 + 记录属性
cppbool 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
cppbool 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=0x00、len=8,恰好 8 字节正好一页------page_offset=0、page_space=8、write_bytes=8,循环一次就完成,连翻页都不需要。这也是把配置放在 0x00 地址的巧妙之处:刚好对齐页边界。
5.5 APP 层:配置对象、交互、魔数校验
config_t 的内存布局
cpptypedef 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:交互式配置
cppvoid 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("选择错误!请重新输入!"); } }注意
magic和version是写前先填好的------它不是用户输入项,是驱动层打的"数据标签"。用户只输入 4 个业务参数(灯、次数、间隔、延时)。
App_Run:魔数校验 + 执行
cppvoid 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,如果直接拿0xFF当repeat_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 */ }




