1. 项目概述:为什么需要读写STM32的内部FLASH?
玩过STM32的朋友都知道,我们写的程序最终都烧录到了芯片内部的FLASH里。但除了存放代码,这片FLASH其实还有个大用处:当成一个掉电不丢失的“小硬盘”,用来保存一些关键数据,比如设备的校准参数、运行日志、用户配置,或者是一个简单的计数器。这比外挂一个EEPROM芯片要省成本、省空间。
这个项目,就是带你亲手操作STM32的内部FLASH,用HAL库实现数据的写入和读取。听起来简单,但里面门道不少。比如,FLASH写入前必须先擦除,而且一擦就是一大片(一个扇区);写入时必须按特定宽度(比如32位、64位)操作;不同型号的STM32,其FLASH大小、扇区划分、编程时间都可能不一样。直接操作寄存器太繁琐,而HAL库把这些底层细节封装了起来,让我们能更专注于业务逻辑。
我之所以选择HAL库来做这个实验,是因为它现在是ST主推的库,生态好,移植方便,而且CubeMX工具能一键生成初始化代码,大大降低了开发门槛。无论是STM32F1、F4还是最新的G0、H7系列,其HAL库操作FLASH的流程都大同小异,学会一个,就能举一反三。
2. 核心原理与硬件基础拆解
2.1 STM32内部FLASH的物理结构
STM32的内部FLASH和我们电脑上的U盘、固态硬盘原理类似,但结构更简单。你可以把它想象成一栋有很多层的大楼,每一层就是一个扇区(Sector),每一层里有很多个大小固定的房间,每个房间就是一个页(Page)或者说是最小可编程单元。对于大多数STM32来说,编程的最小单位是字(Word,32位)或双字(Double Word,64位),而擦除的最小单位是扇区。
以常见的STM32F103C8T6(中容量产品)为例,它的FLASH总容量是64KB,被划分为若干个扇区。前四个扇区(0-3)每个是1KB,后面的扇区(4及以后)每个是2KB。而像STM32F407这类大容量产品,扇区划分就更复杂,有16KB、64KB、128KB等不同大小的扇区。在进行任何操作前,你必须查阅对应型号的《参考手册》,找到“Flash memory”章节,确认其准确的扇区划分表。这是所有操作的基础,搞错了扇区地址,轻则数据写入失败,重则可能擦除掉你自己的程序代码,导致芯片“变砖”。
注意:操作FLASH时,务必避开存放你程序代码的扇区。通常,我们可以把用于存储数据的区域定义在FLASH的末尾部分。例如,对于64KB的FLASH,你的程序可能只用了前20KB,那么你就可以把数据存储区域定义在60KB开始的位置,并确保这个区域属于同一个或某几个完整的扇区。
2.2 FLASH操作的特殊性:擦除与编程
FLASH存储单元的特性决定了它的操作必须遵循严格的顺序:
- 解锁(Unlock):STM32的FLASH控制器默认是上锁的,防止误操作。进行擦写前,必须先向特定的密钥寄存器写入正确的序列来解锁。
- 擦除(Erase):FLASH的位只能从1变成0。如果想将已经写入0的位重新变成1,或者想写入新的数据,就必须先进行擦除操作,将整个扇区的所有位都恢复为1(状态通常是0xFF)。擦除是以扇区为单位的,耗时较长(几十毫秒级)。
- 编程(Program):擦除完成后,才能进行编程(写入)。编程是将特定的位从1变为0的过程。必须按照芯片要求的宽度(32位或64位)和地址对齐方式写入。
- 上锁(Lock):操作完成后,最好重新上锁FLASH,增加系统安全性。
HAL库提供了HAL_FLASH_Unlock(),HAL_FLASHEx_Erase(),HAL_FLASH_Program(),HAL_FLASH_Lock()这一系列函数,正是对应了这四个步骤。
2.3 HAL库的抽象层:简化但不简单
HAL库把上述底层寄存器的操作封装成了几个直观的函数。但“封装”不代表“无脑用”。你需要理解它封装了什么,以及它留给你的责任是什么。
例如,HAL_FLASHEx_Erase()函数需要一个FLASH_EraseInitTypeDef结构体作为参数,这个结构体里你要指定擦除类型(页擦除、扇区擦除、批量擦除)、要擦除的扇区编号、以及要擦除的扇区数量。HAL库会帮你配置好寄存器并启动擦除,但它不会帮你判断你传入的扇区编号是否合法,是否覆盖了代码区。这个判断逻辑需要你自己在应用层实现。
再比如,HAL_FLASH_Program()函数需要你指定编程类型(8位、16位、32位、64位)、目标地址和要写入的数据。HAL库会检查地址对齐(比如32位编程要求地址是4的倍数),并执行写入。但它不会帮你管理磨损均衡、坏块处理(内部FLASH一般没有坏块概念)或数据校验。这些高级功能,如果需要,也得你自己来设计。
3. 工程搭建与代码实现详解
3.1 开发环境与工程配置
我使用的硬件是STM32F103C8T6核心板,开发环境是STM32CubeIDE(它集成了CubeMX和IDE)。你也可以用Keil MDK、IAR等,原理相通。
首先,用STM32CubeMX新建一个工程,选择你的芯片型号。在Pinout & Configuration标签页,其实FLASH操作不需要配置任何外设引脚,因为它完全是芯片内部的操作。我们主要是在Project Manager标签页设置好工程名、路径和IDE。
关键一步是确定数据存储区的地址。我计划用最后两个扇区(Sector 2和Sector 3,每个1KB)来存数据。对于STM32F103C8T6,FLASH起始地址是0x0800 0000。每个扇区1KB,所以:
- Sector 0: 0x0800 0000 - 0x0800 03FF
- Sector 1: 0x0800 0400 - 0x0800 07FF
- Sector 2: 0x0800 0800 - 0x0800 0BFF (我用来存数据)
- Sector 3: 0x0800 0C00 - 0x0800 0FFF (我用来存数据)
因此,我定义数据起始地址为#define DATA_FLASH_ADDR 0x08000C00。务必确保这个地址加上你数据的大小,不会超出芯片的FLASH范围。
生成代码后,打开工程,我们主要在main.c的/* USER CODE BEGIN */和/* USER CODE END */之间添加我们的逻辑。
3.2 FLASH操作底层驱动函数封装
为了使用方便和代码复用,我通常会封装几个基本的FLASH操作函数。
1. 擦除指定扇区函数这个函数负责擦除一个或多个扇区。擦除是FLASH操作中最耗时也最“危险”的一步。
/** * @brief 擦除FLASH指定扇区 * @param start_sector: 起始扇区号 * @param nb_sectors: 要擦除的扇区数量 * @retval HAL status: HAL_OK 成功,其他为失败 */ uint32_t FLASH_Erase_Sectors(uint32_t start_sector, uint32_t nb_sectors) { HAL_StatusTypeDef status; FLASH_EraseInitTypeDef EraseInitStruct; uint32_t SectorError = 0; // 1. 解锁FLASH HAL_FLASH_Unlock(); // 2. 配置擦除参数 EraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; // STM32F1是页擦除,对于F1来说页=扇区 EraseInitStruct.Banks = FLASH_BANK_1; // 对于单BANK的芯片 EraseInitStruct.PageAddress = start_sector; // 起始扇区号 EraseInitStruct.NbPages = nb_sectors; // 扇区数量 // 3. 执行擦除 status = HAL_FLASHEx_Erase(&EraseInitStruct, &SectorError); if (status != HAL_OK) { // 擦除失败,SectorError包含了出错的扇区号,可用于调试 // 这里可以打印错误信息或做其他处理 } // 4. 上锁FLASH HAL_FLASH_Lock(); return status; }实操心得:擦除操作期间,芯片的CPU会被阻塞,直到擦除完成。这意味着你的程序会“卡住”几十毫秒。在实时性要求高的系统中,这可能是个问题。一种优化思路是,在系统空闲时(比如等待用户输入时)或启动初期进行擦除操作。对于更复杂的系统,可以考虑使用RTOS,将擦除任务放在低优先级线程中。
2. 写入数据函数(按32位写入)擦除后,FLASH里全是0xFF。我们可以按32位宽度写入数据。
/** * @brief 向指定FLASH地址写入一个32位数据 * @param address: 目标地址(必须4字节对齐) * @param data: 要写入的32位数据 * @retval HAL status */ uint32_t FLASH_Write_Word(uint32_t address, uint32_t data) { HAL_StatusTypeDef status; // 再次解锁(如果之前已锁) HAL_FLASH_Unlock(); // 执行编程 status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, address, data); // 操作完成后可以立即上锁,也可以等所有写入完成再统一上锁 // HAL_FLASH_Lock(); return status; }3. 写入数据函数(写入任意长度的数组)实际应用中,我们更常需要保存一个结构体或一段数组。
/** * @brief 向FLASH写入一段数据 * @param start_addr: 起始地址(建议4字节对齐) * @param pdata: 源数据缓冲区指针 * @param len: 数据长度(字节数) * @retval 0: 成功,其他: 失败 */ uint16_t FLASH_Write_Buffer(uint32_t start_addr, uint8_t *pdata, uint16_t len) { HAL_StatusTypeDef status = HAL_OK; uint16_t i; uint32_t *p_word; // 用于32位操作的指针 uint32_t write_data; uint16_t remain; // 检查地址对齐和长度 if (start_addr % 4 != 0) { return 1; // 地址未对齐错误 } HAL_FLASH_Unlock(); // 按32位(4字节)为单位进行写入 for (i = 0; i < len / 4; i++) { // 从字节数组组装成32位字 // 注意内存字节序:STM32是小端模式 write_data = (uint32_t)pdata[i*4 + 3] << 24 | (uint32_t)pdata[i*4 + 2] << 16 | (uint32_t)pdata[i*4 + 1] << 8 | (uint32_t)pdata[i*4]; status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, start_addr + i*4, write_data); if (status != HAL_OK) { HAL_FLASH_Lock(); return 2; // 写入过程中出错 } } // 处理剩余的不足4字节的数据(如果有) remain = len % 4; if (remain > 0) { write_data = 0xFFFFFFFF; // 先填充0xFF for (i = 0; i < remain; i++) { // 将剩余字节放入32位字的低位 write_data &= ~(0xFF << (i*8)); // 先清空对应字节位 write_data |= (uint32_t)pdata[len - remain + i] << (i*8); // 再写入数据 } status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, start_addr + (len/4)*4, write_data); } HAL_FLASH_Lock(); return (status == HAL_OK) ? 0 : 3; }注意事项:这个函数假设输入数据缓冲区
pdata在内存中是连续存放的。当你有一个struct需要保存时,直接传入(uint8_t*)&my_struct和sizeof(my_struct)即可。但务必注意结构体内部的对齐和填充(padding)问题,这可能导致sizeof计算的大小与实际数据大小不符。一个稳妥的做法是,使用#pragma pack(1)指令让结构体按1字节对齐,或者手动将结构体的每个成员单独序列化到字节数组中再写入。
4. 读取数据函数读取FLASH就简单多了,直接指针访问即可,因为FLASH映射到了内存地址空间。
/** * @brief 从FLASH读取一段数据 * @param start_addr: 起始地址 * @param pdata: 目标数据缓冲区指针 * @param len: 要读取的字节数 */ void FLASH_Read_Buffer(uint32_t start_addr, uint8_t *pdata, uint16_t len) { uint16_t i; for (i = 0; i < len; i++) { pdata[i] = *(__IO uint8_t*)(start_addr + i); } }3.3 主程序逻辑与应用示例
有了底层驱动,我们就可以在main函数里组织业务逻辑了。一个典型的流程是:上电后读取保存的数据,运行过程中更新数据,在需要时(如收到保存命令、定时或断电前)将数据写入FLASH。
// 定义一个要保存的数据结构 typedef struct { uint32_t boot_count; // 启动次数 float calibration_factor; // 校准系数 uint8_t device_id[8]; // 设备ID } SystemParams_t; SystemParams_t g_params; // 全局参数变量 const uint32_t FLASH_PARAMS_ADDR = 0x08000C00; // 参数存储地址 int main(void) { HAL_Init(); SystemClock_Config(); // ... 其他外设初始化(如串口用于打印调试信息) // 1. 从FLASH读取参数 FLASH_Read_Buffer(FLASH_PARAMS_ADDR, (uint8_t*)&g_params, sizeof(g_params)); // 2. 校验数据有效性(例如,通过魔数或CRC) // 首次使用或FLASH被擦除后,读出的数据可能是0xFF或随机值 // 这里用一个简单的魔数来检查 #define PARAMS_MAGIC_NUM 0xAA55CC33 if (g_params.magic != PARAMS_MAGIC_NUM) { // 数据无效,进行初始化 printf("First boot or data invalid, initializing params...\r\n"); memset(&g_params, 0, sizeof(g_params)); g_params.magic = PARAMS_MAGIC_NUM; g_params.boot_count = 0; g_params.calibration_factor = 1.0f; memcpy(g_params.device_id, "STM32F103", 9); // 初始化后立即保存一次 Save_Params_To_Flash(); } else { // 数据有效,正常启动 g_params.boot_count++; printf("System boot count: %lu\r\n", g_params.boot_count); // 可以在这里决定是否立即保存增加的boot_count,或者等下次一起存 } // 3. 主循环 while (1) { // ... 你的主要应用逻辑 // 示例:模拟一个需要保存参数的场景(如按键按下) if (/* 保存条件触发,例如一个按键被按下 */) { printf("Saving params to flash...\r\n"); if (Save_Params_To_Flash() == 0) { printf("Save successful!\r\n"); } else { printf("Save failed!\r\n"); } } HAL_Delay(100); } } /** * @brief 将全局参数保存到FLASH * @retval 0: 成功,其他: 失败 */ uint8_t Save_Params_To_Flash(void) { // 在写入新数据前,必须先擦除整个扇区 // 计算参数所在地址属于哪个扇区(这里需要根据具体芯片的扇区映射来计算) // 对于STM32F103C8T6,地址0x08000C00在Sector 3 uint32_t sector = FLASH_SECTOR_3; // 这个宏需要根据你的HAL库版本和芯片定义,可能是数字3 if (FLASH_Erase_Sectors(sector, 1) != HAL_OK) { return 1; // 擦除失败 } // 擦除成功后,写入新数据 if (FLASH_Write_Buffer(FLASH_PARAMS_ADDR, (uint8_t*)&g_params, sizeof(g_params)) != 0) { return 2; // 写入失败 } return 0; // 成功 }4. 关键问题深度剖析与避坑指南
4.1 地址对齐与数据宽度:最常见的错误来源
这是新手最容易栽跟头的地方。HAL_FLASH_Program函数对地址有严格的对齐要求:
FLASH_TYPEPROGRAM_BYTE(8位): 地址任意(但部分型号可能不支持字节编程)。FLASH_TYPEPROGRAM_HALFWORD(16位): 地址必须2字节对齐(地址 % 2 == 0)。FLASH_TYPEPROGRAM_WORD(32位): 地址必须4字节对齐(地址 % 4 == 0)。FLASH_TYPEPROGRAM_DOUBLEWORD(64位): 地址必须8字节对齐(地址 % 8 == 0)。
我的建议是,除非有特殊需求,统一使用32位编程。因为这是所有STM32都支持的最高效的模式。这就要求你的数据存储起始地址是4的倍数,数据长度最好是4的倍数。在定义存储结构体时,可以使用__attribute__((aligned(4)))或__ALIGNED(4)来确保结构体对齐。
4.2 擦写寿命与数据管理:让FLASH用得更久
STM32的内部FLASH通常有1万到10万次的擦写寿命。频繁地擦写同一个扇区会使其提前失效。这就需要引入数据管理策略。
1. 扇区轮询(简易磨损均衡)不要总是擦写同一个扇区。例如,你可以分配两个扇区A和B。第一次数据存在A,第二次就存B,同时把A标记为“旧数据”。第三次再存回A,擦除B。这样就把擦写次数分摊了。
2. 增加数据头信息在存储的数据前面,加上一个“数据头”,包含版本号、CRC校验码、时间戳、魔数等。每次读取时,先校验CRC和魔数,确保数据完整有效。这能防止因意外断电(擦写过程中)导致的数据损坏。
3. 减少不必要的保存不要在循环里疯狂调用保存函数。可以设置一个“脏数据”标志,当参数改变时置位,然后定时(比如每10分钟)或在系统空闲时检查这个标志,如果置位则执行保存操作并清除标志。在系统断电前(如果检测到断电),也应强制保存。
4.3 中断与操作冲突:稳定性保障
FLASH擦写期间,CPU会暂停执行指令(等待擦写完成)。这意味着:
- 禁止中断:在擦写操作的关键序列(解锁、擦除命令、编程命令)期间,必须禁止所有中断(包括SysTick),因为中断服务程序可能试图访问正在被操作的FLASH区域,导致硬件错误(HardFault)。HAL库函数内部通常已经处理了中断的禁用和恢复,但为了绝对安全,在调用
HAL_FLASHEx_Erase和HAL_FLASH_Program期间,最好确保系统处于一个稳定的状态。 - 代码在RAM中运行:极端情况下,如果你的擦写函数本身存放在FLASH中,而你要擦除的扇区正好包含这段代码,那将导致灾难性后果。一种高级技巧是将关键的擦写函数复制到RAM中执行。但对于大多数应用(操作非代码扇区),这不是必须的。
4.4 不同STM32系列的差异:移植注意事项
- F1系列:使用
FLASH_TYPEERASE_PAGES,扇区号就是页号。解锁密钥是FLASH_KEY1和FLASH_KEY2。 - F4/F7/H7系列:扇区(Sector)容量更大,且支持扇区擦除(
FLASH_TYPEERASE_SECTORS)和批量擦除(FLASH_TYPEERASE_MASSERASE)。解锁密钥是FLASH_KEY1和FLASH_KEY2,但寄存器地址和名称可能不同。 - G0系列等:可能使用不同的函数名或参数结构体,例如
HAL_FLASHEx_Erase的参数结构体字段名可能不同。
移植时,务必做三件事:
- 查《参考手册》,确认FLASH物理结构。
- 查HAL库头文件(如
stm32f1xx_hal_flash.h或stm32f4xx_hal_flash.h),找到正确的枚举定义和函数原型。 - 在CubeMX中,查看生成的
main.c里关于FLASH的初始化部分(如果有),作为参考。
5. 调试技巧与常见问题排查实录
5.1 调试输出是你的眼睛
在进行FLASH操作时,一定要利用好串口打印。在每个关键步骤(解锁成功/失败、擦除开始/结束、写入开始/结束、数据校验结果)都打印一条信息。这能让你清晰地知道程序执行到哪一步失败了。
5.2 常见错误代码与解决方法
HAL_ERROR/HAL_BUSY- 现象:调用
HAL_FLASH_Program或HAL_FLASHEx_Erase返回错误。 - 排查:
- 未解锁:确保在操作前调用了
HAL_FLASH_Unlock(),并且返回HAL_OK。 - 操作冲突:确保没有其他进程(如DMA、中断服务程序)正在访问FLASH。在操作前后关中断是个好习惯。
- 地址非法:检查目标地址是否在有效的FLASH地址范围内,并且没有指向代码区。
- 对齐错误:检查编程地址是否符合所选编程模式的对齐要求。
- 未解锁:确保在操作前调用了
- 现象:调用
数据读取错误
- 现象:写入后读出来的数据不对。
- 排查:
- 未擦除就写入:FLASH位只能从1变0。如果你要写入0xAA (10101010),而对应位置原本是0x55 (01010101),直接写入的结果会是0x00 (00000000),因为0&1=0, 1&0=0。务必确保写入前该区域已被擦除(全为0xFF)。
- 字节序问题:在
FLASH_Write_Buffer函数中,我们从字节数组组装32位字时,假设了内存是小端模式(STM32正是如此)。如果你从网络或大端机器传来数据,需要先进行字节序转换。 - 变量类型与指针转换:确保读取时使用的指针类型和写入时一致。用
*(uint32_t*)addr读取一个32位数,和用*(uint8_t*)addr读取第一个字节,结果不同。
程序跑飞或进入HardFault
- 现象:一执行FLASH操作程序就死机。
- 排查:
- 操作了代码区:这是最严重的情况。检查你的数据存储地址是否和程序代码、中断向量表有重叠。使用IDE的链接脚本(
.ld文件)或map文件来确认程序占用的空间。 - 在中断中操作FLASH:避免在中断服务程序里进行FLASH擦写。如果非做不可,必须进行非常严格的互斥保护。
- FLASH锁状态异常:确保每次解锁后,最终都有上锁。不配对的解锁/上锁可能导致状态机混乱。
- 操作了代码区:这是最严重的情况。检查你的数据存储地址是否和程序代码、中断向量表有重叠。使用IDE的链接脚本(
5.3 使用ST-LINK Utility或CubeProgrammer进行验证
当你的程序声称写入成功后,如何独立验证?可以借助ST官方的烧录工具。
- 暂停芯片运行(或复位后不运行你的程序)。
- 打开ST-LINK Utility或STM32CubeProgrammer,连接芯片。
- 跳转到你设定的数据存储地址(如0x08000C00)。
- 直接查看该地址区域的内存内容。你应该能看到你写入的数据。你也可以手动修改这些内存值,然后让芯片运行你的读取函数,看是否能读出来。这是一个极其强大的调试手段,能帮你确定问题是出在“写”还是“读”的逻辑上。
5.4 一个实用的调试函数:内存比较与打印
编写一个函数,比较两个内存区域的内容并打印出差异,在调试数据读写时非常有用。
void Compare_And_Print(uint8_t *buf1, uint8_t *buf2, uint16_t len, const char* name) { uint16_t i; uint8_t diff = 0; printf("\r\n--- Comparing %s ---\r\n", name); for (i = 0; i < len; i++) { if (buf1[i] != buf2[i]) { printf("Diff at byte %d: RAM=0x%02X, FLASH=0x%02X\r\n", i, buf1[i], buf2[i]); diff = 1; } } if (!diff) { printf("Data matches perfectly.\r\n"); } } // 用法: // uint8_t read_back_buffer[100]; // FLASH_Read_Buffer(DATA_FLASH_ADDR, read_back_buffer, 100); // Compare_And_Print((uint8_t*)&g_params, read_back_buffer, sizeof(g_params), "System Params");通过这个项目,你不仅能掌握STM32内部FLASH读写的基本技能,更能深入理解嵌入式存储管理的核心思想。从简单的参数保存,到复杂的磨损均衡、掉电保护,其底层逻辑都是相通的。在实际产品中,你可能会基于这些基础,搭建更健壮的文件系统或数据库层。希望这篇长文能帮你避开我当年踩过的那些坑,顺利地把芯片内部的这片空间利用起来。