ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32内部FLASH读写实战:从原理到避坑指南

2026/8/8 3:51:26 拓冰建站 浏览量
STM32内部FLASH读写实战:从原理到避坑指南

1. 项目概述:为什么我们需要操作STM32的内部FLASH?

在嵌入式开发中,数据存储是一个绕不开的话题。无论是保存设备的运行参数、记录日志,还是实现固件的在线升级(IAP),都需要一个可靠的、非易失性的存储介质。很多开发者第一时间会想到外挂一个EEPROM或者SPI Flash芯片,这当然没问题。但很多时候,我们手头的STM32芯片内部就自带了一块FLASH存储器,除了存放程序代码,剩余的空间完全可以用来存储用户数据。直接使用内部FLASH,能省去一颗外置芯片,简化PCB布局,降低成本,对于数据量不大、但对成本和空间有严格要求的项目来说,是极具吸引力的方案。

然而,操作内部FLASH不像读写一个数组那么简单。它有自己的“脾气”:写入前必须先擦除,而且擦除以“扇区”(Sector)或“页”(Page)为单位;写入操作有特定的时序和锁机制;频繁擦写还需要考虑FLASH的寿命。STM32的HAL库为我们封装了底层硬件操作,但如果不理解其背后的原理和约束,直接调用API很容易掉进坑里,导致数据丢失、程序跑飞甚至芯片锁死。这个“简约版”实验的目的,就是拨开HAL库的封装,带你透彻理解STM32内部FLASH读写的核心机制,掌握从解锁、擦除、写入、读取到上锁的完整流程,并分享那些数据手册里不会写的实战经验和避坑指南。

2. 核心原理与HAL库驱动解析

2.1 STM32内部FLASH的物理结构与管理

STM32的内部FLASH可以看作一个巨大的、按地址寻址的只读存储器,但在特定条件下我们可以修改它的内容。它的管理单元从小到大依次是:字节(Byte) -> 半字(Half-Word, 16位) -> 字(Word, 32位) -> 页(Page) / 扇区(Sector)。

对于不同系列的STM32,FLASH的组织结构差异很大,这是第一个需要注意的点。例如:

  • STM32F1系列:通常以“页”为单位进行擦除,每页大小为1KB或2KB。
  • STM32F4/F7/H7系列:引入了“扇区”的概念,且扇区大小不均匀。比如STM32F407,扇区0到3是16KB,扇区4是64KB,后面的扇区是128KB。H7系列则更为复杂。
  • STM32G0/F0/L0等系列:页大小可能为2KB。

为什么这么设计?主要是为了兼顾代码存储效率和擦写管理的灵活性。大扇区适合存放核心固件,小扇区则适合频繁修改的参数区。在进行任何操作前,你必须查阅你所使用芯片的具体《参考手册》中的“Flash memory”章节,找到准确的扇区划分、起始地址和大小。错误地操作了存放程序代码的扇区,会导致程序崩溃。

HAL库通过一个关键的结构体FLASH_EraseInitTypeDef来抽象这些差异。你需要填充这个结构体来告诉库函数:要擦除哪个扇区、擦除多少个扇区。

2.2 HAL库FLASH驱动关键API剖析

HAL库提供了相对简洁的API,但每个函数背后都有其职责和限制。

  1. 解锁与上锁 (HAL_FLASH_Unlock/HAL_FLASH_Lock)FLASH控制器默认是锁定的,以防止误操作。任何写/擦除操作前必须解锁。解锁的本质是向特定的控制寄存器写入两个密钥值。上锁则是一个反向操作。务必配对使用,在操作结束后上锁,是一个好习惯。

  2. 擦除操作 (HAL_FLASHEx_Erase)这是最可能失败的操作。函数接受一个指向FLASH_EraseInitTypeDef的指针和一个保存擦除失败的扇区号的变量地址。

    HAL_StatusTypeDef HAL_FLASHEx_Erase(FLASH_EraseInitTypeDef *pEraseInit, uint32_t *SectorError);

    关键点:擦除会将整个扇区的所有位变为‘1’(状态为已擦除)。擦除时间较长,通常是毫秒级,期间CPU会被阻塞或需要等待操作完成标志。

  3. 编程操作 (HAL_FLASH_Program)擦除后(状态为‘1’),才能将‘0’写入相应的位。STM32的编程通常支持多种数据宽度:

    HAL_StatusTypeDef HAL_FLASH_Program(uint32_t TypeProgram, uint32_t Address, uint64_t Data);
    • TypeProgram: 编程类型,如FLASH_TYPEPROGRAM_BYTE(8位),FLASH_TYPEPROGRAM_HALFWORD(16位),FLASH_TYPEPROGRAM_WORD(32位)。对于F1,可能只支持半字编程;对于F4及以上,通常支持字节、半字、字和双字。必须根据芯片支持的类型和地址对齐要求来选择。例如,32位编程要求地址必须是4字节对齐的。
  4. 读取操作读取是最简单的,因为FLASH在地址映射后就像一个只读的常量数组,可以直接通过指针解引用或memcpy进行读取。

    uint32_t data = *(__IO uint32_t*)target_address;

2.3 操作时序与中断安全

FLASH的擦写操作需要多个时钟周期,由芯片内部的FLASH控制器自动完成。HAL库的擦写函数是阻塞式的,即函数会一直等待本次操作完成或超时后才返回。这意味着在擦写期间,CPU无法执行其他代码。

这对你的系统设计有何影响?如果你的应用对实时性要求极高,或者擦写的扇区很大(如128KB),这段阻塞时间是不可接受的。此时,你需要考虑:

  • 使用中断或DMA?很遗憾,STM32内部FLASH的编程擦除操作通常不支持DMA,且中断处理也需谨慎。HAL库提供了__HAL_FLASH_ENABLE_IT()使能中断(如操作完成中断、错误中断),然后在中断服务程序里处理完成事件。但这增加了程序的复杂性,需要妥善管理状态机。
  • 分块操作:对于大数据存储,可以设计为分多次写入,每次写入一小块,在系统空闲时进行,避免长时间阻塞主循环。
  • 看门狗:如果使能了独立看门狗(IWDG),且擦写时间超过了看门狗复位时间,必须在操作前刷新看门狗,或者临时暂停看门狗(如果支持)。

注意:在擦写FLASH期间,不能从被操作的FLASH扇区取指执行代码。如果你的擦写函数本身或中断向量表位于正在被擦写的扇区,系统会立即崩溃。因此,通常的做法是将操作FLASH的代码(包括HAL库相关函数)复制到RAM中执行,或者确保这些代码位于不会被擦写的扇区(如Bootloader)。对于初学者,如果只是操作用户数据区(通常定义在程序末尾的特定扇区),则无需担心此问题。

3. 简约版实验设计与实现步骤

3.1 硬件与软件环境准备

硬件

  • 任意一款STM32开发板(如STM32F103C8T6、STM32F407VE等)。
  • ST-Link/V2等调试下载器。
  • 串口模块(用于打印调试信息,可选但强烈推荐)。

软件

  • STM32CubeIDE 或 Keil MDK。
  • STM32CubeMX(用于初始化生成代码)。
  • 串口调试助手(如Tera Term、SecureCRT)。

CubeMX配置

  1. 选择你的芯片型号。
  2. 配置系统时钟(SYSCLK),注意FLASH操作通常对时钟频率有要求,过高可能导致失败,需参考数据手册设置正确的延迟周期(Latency)。
  3. 配置一个串口(如USART1)用于打印,波特率115200。
  4. Project Manager中,选择正确的Toolchain/IDE,并确保HAL库被勾选。
  5. 生成代码。

3.2 定义用户数据存储区

这是最关键的一步,决定了你的数据存在哪里,以及会不会把程序自己给覆盖了。我们需要修改链接脚本(.ld文件 in GCC或scatter filein Keil),但使用CubeIDE和HAL库时,更简单的方法是:在代码中定义一个绝对地址的变量。

首先,在芯片的链接脚本或内存映射图中,找到程序FLASH的结束地址和最后一个扇区的起始地址。假设我们使用STM32F103C8T6(64KB FLASH),程序本身用了约30KB。我们可以选择从第63KB开始的位置(地址0x0800FC00)作为数据区。为了更规范,我们使用最后一个页(2KB)作为数据区。

在代码中,我们可以这样定义:

// 定义用户数据区的起始地址。假设使用STM32F103,最后一页起始地址为0x0800F800 #define USER_FLASH_START_ADDR ((uint32_t)0x0800F800) #define USER_FLASH_SECTOR FLASH_SECTOR_XX // 需要根据实际芯片定义扇区号,F1是页号 #define USER_DATA_SIZE 512 // 计划存储512字节数据 // 将一个指针指向该地址,用于读写 uint32_t* pUserFlash = (uint32_t*)USER_FLASH_START_ADDR;

更工程化的做法是,在main.c前声明一个特殊的段:

// 在变量定义时,将其定位到特定地址(编译器相关) __attribute__((section(".user_data"))) const uint8_t userFlashData[USER_DATA_SIZE] = {0}; // 然后在链接脚本中,将`.user_data`段放到FLASH末尾的特定地址。

对于初学者,直接使用绝对地址指针更直观。

3.3 核心功能函数实现

我们将封装三个核心函数:擦除扇区、写入数据、读取数据。

1. 擦除指定扇区函数

/** * @brief 擦除指定的FLASH扇区 * @param sector: 扇区号(或页号) * @retval HAL status: HAL_OK, HAL_ERROR 等 */ uint8_t USER_FLASH_EraseSector(uint32_t sector) { HAL_StatusTypeDef status; FLASH_EraseInitTypeDef eraseInit; uint32_t sectorError = 0; // 1. 解锁FLASH HAL_FLASH_Unlock(); // 2. 配置擦除参数 eraseInit.TypeErase = FLASH_TYPEERASE_PAGES; // F1系列是页擦除,F4是SECTORS eraseInit.Banks = FLASH_BANK_1; // 对于单Bank芯片 eraseInit.Page = sector; // 对于F1,这是页号 eraseInit.NbPages = 1; // 擦除1页 // 对于F4,使用 Sector 和 NbSectors // eraseInit.Sector = sector; // eraseInit.NbSectors = 1; eraseInit.VoltageRange = FLASH_VOLTAGE_RANGE_3; // 电压范围,需根据芯片工作电压选择 // 3. 执行擦除 status = HAL_FLASHEx_Erase(&eraseInit, §orError); if (status != HAL_OK) { // 擦除失败,可以通过 sectorError 查看哪个扇区出错 printf("Flash erase failed! SectorError: %lu\r\n", sectorError); } // 4. 上锁FLASH HAL_FLASH_Lock(); return (status == HAL_OK) ? 0 : 1; }

2. 写入数据函数(以32位为单位写入)

/** * @brief 向指定地址写入一段数据(按32位对齐写入) * @param addr: 起始地址(必须32位对齐) * @param pData: 源数据指针 * @param len: 数据长度(以32位字为单位) * @retval HAL status */ uint8_t USER_FLASH_Write(uint32_t addr, uint32_t *pData, uint32_t len) { HAL_StatusTypeDef status = HAL_OK; uint32_t i; // 检查地址对齐(如果是字编程) if (addr % 4 != 0) { printf("Address not aligned to 4 bytes!\r\n"); return 1; } HAL_FLASH_Unlock(); for (i = 0; i < len; i++) { // 使用字编程模式 status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i*4, pData[i]); if (status != HAL_OK) { printf("Flash write failed at addr: 0x%08lx\r\n", addr + i*4); break; } } HAL_FLASH_Lock(); return (status == HAL_OK) ? 0 : 1; }

3. 读取数据函数

/** * @brief 从指定地址读取一段数据 * @param addr: 起始地址 * @param pBuffer: 目标缓冲区指针 * @param len: 数据长度(字节数) */ void USER_FLASH_Read(uint32_t addr, uint8_t *pBuffer, uint32_t len) { memcpy(pBuffer, (uint8_t*)addr, len); }

3.4 主程序逻辑与测试

main函数的初始化部分(HAL_Init()SystemClock_Config()之后),添加测试代码。

int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); // 初始化串口 uint8_t status = 0; uint32_t writeData[4] = {0x12345678, 0xABCDEF90, 0xA5A5A5A5, 0x5A5A5A5A}; uint32_t readData[4] = {0}; printf("\r\n--- STM32 Internal Flash Test Start ---\r\n"); // 1. 读取原始数据 printf("Reading original data from 0x%08lx:\r\n", USER_FLASH_START_ADDR); USER_FLASH_Read(USER_FLASH_START_ADDR, (uint8_t*)readData, sizeof(readData)); for(int i=0; i<4; i++) { printf("[0x%08lx]: 0x%08lx\r\n", USER_FLASH_START_ADDR + i*4, readData[i]); } // 2. 擦除目标扇区 printf("\r\nErasing Flash Sector...\r\n"); status = USER_FLASH_EraseSector(USER_FLASH_SECTOR); if(status == 0) { printf("Erase OK.\r\n"); } else { printf("Erase FAILED.\r\n"); while(1); } // 3. 再次读取,应全为0xFFFFFFFF(已擦除状态) printf("\r\nReading after erase:\r\n"); USER_FLASH_Read(USER_FLASH_START_ADDR, (uint8_t*)readData, sizeof(readData)); for(int i=0; i<4; i++) { printf("[0x%08lx]: 0x%08lx\r\n", USER_FLASH_START_ADDR + i*4, readData[i]); if(readData[i] != 0xFFFFFFFF) { printf("Error: Not fully erased!\r\n"); } } // 4. 写入测试数据 printf("\r\nWriting test data...\r\n"); status = USER_FLASH_Write(USER_FLASH_START_ADDR, writeData, 4); if(status == 0) { printf("Write OK.\r\n"); } else { printf("Write FAILED.\r\n"); } // 5. 验证写入的数据 printf("\r\nVerifying written data:\r\n"); USER_FLASH_Read(USER_FLASH_START_ADDR, (uint8_t*)readData, sizeof(readData)); for(int i=0; i<4; i++) { printf("[0x%08lx]: 0x%08lx", USER_FLASH_START_ADDR + i*4, readData[i]); if(readData[i] == writeData[i]) { printf(" [OK]\r\n"); } else { printf(" [MISMATCH, expected: 0x%08lx]\r\n", writeData[i]); } } printf("\r\n--- Test Finished ---\r\n"); while (1) { // 主循环 } }

通过串口调试助手,你可以清晰地看到整个擦除、写入、验证的过程。

4. 深度避坑指南与高级技巧

4.1 常见错误与问题排查

  1. HAL_FLASH_Program返回HAL_ERROR

    • 地址未对齐:确保编程地址满足所选编程模式的对齐要求(字编程需4字节对齐)。
    • 目标地址未擦除:FLASH只能将‘1’写成‘0’,不能将‘0’写成‘1’。写入前必须确保该区域已被擦除(全为0xFF)。如果你尝试在已有数据(非0xFF)的位置写入,会失败。
    • 写保护:某些扇区可能被设置了写保护(如用于存放Bootloader的扇区)。检查选项字节(Option Bytes)配置。
    • 电压/时钟问题:FLASH操作对电源电压和内核时钟频率敏感。确保工作电压在允许范围内,并且根据时钟频率正确配置了FLASH的等待周期(Latency)。在SystemClock_Config()中,CubeMX通常会帮你设置好。
  2. HAL_FLASHEx_Erase失败

    • 扇区号错误:这是最常见的原因。F1的页号和F4的扇区号都是从0开始,但对应不同的物理地址范围。务必核对《参考手册》的Flash memory organization表格。
    • 操作期间产生中断:在擦写期间,如果发生了中断,且中断服务程序或相关代码位于正在被操作的FLASH扇区,会导致取指失败,程序死锁。确保中断向量表和关键中断服务程序不在目标扇区。
  3. 数据读出错误或系统异常

    • 内存越界:写入的数据长度超出了你定义的存储区范围,可能破坏了相邻的程序代码或数据。
    • 指针操作错误:确保你的地址指针计算正确,没有发生意外的指针偏移。
    • Cache问题(F7/H7系列特有):对于带有指令缓存(I-Cache)和数据缓存(D-Cache)的高性能系列,在写入FLASH后,如果CPU通过Cache读取旧数据,会得到错误结果。需要在写入后执行Cache清理(clean)或无效化(invalidate)操作。HAL库提供了SCB_CleanDCache()SCB_InvalidateDCache()等函数。

4.2 数据管理与磨损均衡策略

内部FLASH的擦写次数是有限的,通常是1万到10万次。如果频繁地在同一个扇区擦写,该扇区会率先损坏。

  • 追加写入:对于日志型数据,可以设计为追加写入。每次写入新的数据块,并更新一个索引指针。只有当扇区写满时,才擦除整个扇区并从头开始。这能将擦除次数减少到原来的1/N(N为扇区可容纳的数据块数)。
  • 双扇区/乒乓操作:使用两个扇区A和B。先写A扇区,写满后写B扇区,同时将A扇区的有效数据搬移到B扇区,然后擦除A扇区。下次写满B扇区时,再写回A扇区。如此循环,将擦写磨损平均到两个扇区。
  • 状态标记:在存储数据时,不仅存储数据本身,还存储一个“有效”标记或版本号。读取时,只读取标记为最新的数据。这可以避免因意外断电导致的数据中间状态问题。

4.3 实战心得:可靠性与效率的权衡

  • 写入前检查:在调用HAL_FLASH_Program前,可以先读取目标地址的值。如果已经是你要写入的值,可以跳过,节省时间。如果非0xFF,则说明需要先擦除。
  • 减少擦除次数:擦除是最耗时的操作。尽量组织你的数据,使一次擦除后能进行多次写入。
  • 电源稳定性:FLASH擦写期间对电源电压波动极其敏感。在电池供电或电源质量较差的应用中,在擦写FLASH前,应确保电源电压充足且稳定。有条件可以增加大电容或使用电源监控芯片,在电压低于阈值时禁止FLASH操作。
  • 错误恢复机制:重要的数据应该存储两份(双备份),并加上CRC校验。读取时,先校验第一份,如果失败则读取备份份。这能有效防止因单比特翻转(虽然概率低)或局部损坏导致的数据完全丢失。
  • 仿真调试注意:在调试器(如ST-Link)连接下进行FLASH擦写,有时会因为调试器自身的访问冲突导致操作失败。如果遇到奇怪的失败,可以尝试断开调试器,让芯片独立运行一次测试流程。

5. 项目扩展与进阶思考

掌握了基础的读写操作后,你可以将这个“简约版”实验扩展成更实用的模块。

1. 封装成通用存储层设计一个简单的键值对(KV)存储API,例如:

int8_t FlashKV_Write(const char* key, void* value, uint16_t size); int8_t FlashKV_Read(const char* key, void* buffer, uint16_t buffer_size); int8_t FlashKV_Delete(const char* key);

内部实现需要管理存储布局、处理数据序列化、以及实现上述的磨损均衡策略。

2. 实现IAP(在应用编程)这是内部FLASH最经典的高级应用。核心思想是:

  • 将FLASH划分为两个区域:Bootloader区(通常放在起始扇区)和用户应用程序区。
  • Bootloader负责通过串口、USB、CAN、以太网等接口接收新的用户程序固件(bin文件)。
  • Bootloader将接收到的固件数据写入到用户应用程序区的FLASH中。
  • 校验完成后,Bootloader跳转到用户应用程序的起始地址执行。
  • 在这个过程中,Bootloader需要擦写存放用户程序的FLASH扇区,这正是本实验技术的直接应用。跳转前需要正确配置中断向量表偏移(通过SCB->VTOR寄存器)。

3. 结合文件系统对于数据量稍大的应用,可以在内部FLASH上移植一个轻量级文件系统,如LittleFS或SPIFFS。这些文件系统已经内置了坏块管理、磨损均衡和掉电保护等高级特性,让你像在SD卡上一样方便地管理文件,而无需关心底层FLASH的操作细节。你需要为文件系统提供底层的擦除、写入、读取驱动接口,这正是本实验所实现的功能。

通过这个“简约版”实验,你不仅学会了调用几个HAL函数,更重要的是理解了嵌入式存储的核心概念和约束。在实际项目中,根据数据的重要性、更新频率和容量需求,在外部存储芯片和内部FLASH之间做出合理的选择,并设计出稳健可靠的数据存储方案,这才是这项技能的价值所在。