手把手教你实现 Flash 擦除:从原理到实战,彻底搞懂嵌入式存储底层操作
你有没有遇到过这样的情况:在做固件升级时,明明写入了新代码,设备却始终运行旧程序?或者尝试保存一个配置参数,结果读出来还是老值?这类“数据没更新”的诡异问题,90% 的根源都出在一个被很多人忽略的基础操作上——Flash 擦除(erase)。
今天我们就来揭开这个嵌入式开发中的“隐形门槛”。不讲空话,不堆术语,带你从零开始,亲手实现一个可靠的 Flash erase 功能,并深入理解它背后的机制与陷阱。
为什么不能直接往 Flash 写数据?
我们都知道 RAM 可以随意读写,但 Flash 不行。这不是设计缺陷,而是物理结构决定的。
Flash 存储单元基于浮栅晶体管,它的特性很特别:
✅只能将 1 改为 0,不能将 0 改为 1。
这意味着什么?
假设你有一块刚擦过的区域,所有位都是1(即字节值为0xFF)。这时你可以写入任意数据,比如把某些位改成0来表示0x5A。
但如果你想再改回来呢?比如从0x5A变成0xFF—— 这意味着要把一些0改成1,而这是硬件不允许的!
唯一的办法就是先执行erase,把整个扇区恢复成全1状态。这就像白板上的记号笔字迹,想修改就得先用板擦清空整片区域。
所以,在任何写入之前,必须先擦除。这是铁律。
Flash 擦除的本质是什么?
擦除不是软件层面的“清零”,而是一次高压物理操作。
控制器会在目标存储单元施加反向电压,迫使电子通过隧穿效应离开浮栅,从而使晶体管回到导通状态(逻辑 1)。这个过程耗时较长(通常几毫秒到几十毫秒),且会对材料造成微小损耗。
因此:
- 每个扇区有寿命限制(常见 1万~10万次擦写)
- 擦除单位远大于写入单位(最小常为 4KB 扇区)
- 一旦启动,不可中断(断电可能导致扇区损坏)
这些特性决定了我们必须谨慎对待每一次 erase 操作。
实战:在 STM32 上实现安全的扇区擦除
我们以 STM32F4 系列为例(ARM Cortex-M4 内核),使用 HAL 库完成一次完整的扇区擦除。这套流程适用于绝大多数现代 MCU,只是寄存器名略有差异。
第一步:包含头文件并初始化系统
#include "stm32f4xx_hal.h" #include "stm32f4xx_hal_flash.h" int main(void) { HAL_Init(); SystemClock_Config(); // 用户定义的时钟配置函数别忘了调用HAL_Init()初始化 HAL 层,否则后续操作可能失败。
第二步:确定你要擦的是哪一块
STM32F4 的 Flash 划分为多个扇区,不同型号大小不同。例如 STM32F407VG 拥有 1MB Flash,共 12 个扇区:
| 扇区 | 起始地址 | 大小 |
|---|---|---|
| 0 | 0x08000000 | 16 KB |
| 1 | 0x08004000 | 16 KB |
| 2 | 0x08008000 | 16 KB |
| … | … | … |
| 5 | 0x08020000 | 64 KB |
| … | … | … |
我们要擦除扇区 5,对应宏定义为FLASH_SECTOR_5。
uint32_t target_sector = FLASH_SECTOR_5;第三步:解锁 Flash 控制器
Flash 默认处于写保护状态,防止意外修改。要进行擦除或编程,必须先解锁。
HAL_FLASH_Unlock();这条指令会清除FLASH_CR寄存器中的LOCK位。如果不解锁就直接操作,硬件会拒绝并返回错误。
第四步:配置擦除参数
我们需要告诉控制器:“我要擦哪个扇区、擦几个、供电电压是多少”。
FLASH_EraseInitTypeDef erase_config; uint32_t error_code; erase_config.TypeErase = FLASH_TYPEERASE_SECTORS; // 类型:扇区擦除 erase_config.Sector = target_sector; // 目标扇区 erase_config.NbSectors = 1; // 擦 1 个 erase_config.VoltageRange = FLASH_VOLTAGE_RANGE_3; // 2.7V ~ 3.6V其中VoltageRange很关键。如果你的系统工作在 3.3V,选RANGE_3;如果是 1.8V 平台,则需选择对应范围。配错可能导致命令被忽略或误动作。
第五步:执行擦除并检查结果
调用库函数触发擦除:
if (HAL_FLASHEx_Erase(&erase_config, &error_code) != HAL_OK) { // 擦除失败!处理错误 Error_Handler(); }函数会自动轮询忙标志位(BSY),直到操作完成。如果中途发生错误(如地址非法、电压异常等),会通过返回值和error_code告知具体原因。
第六步:重新上锁,保护系统
操作完成后,务必重新加锁!
HAL_FLASH_Lock();这是很多初学者忽略的关键一步。不上锁的话,后续代码万一误触发写操作,可能会覆盖关键固件,导致系统崩溃。
封装成通用函数:可复用才是好代码
把上面逻辑打包成一个独立函数,方便以后调用:
/** * @brief 擦除指定 Flash 扇区 * @param sector: 扇区编号(如 FLASH_SECTOR_5) * @retval HAL_OK 成功,HAL_ERROR 失败 */ HAL_StatusTypeDef Flash_Erase_Sector(uint32_t sector) { FLASH_EraseInitTypeDef cfg; uint32_t error; HAL_FLASH_Unlock(); cfg.TypeErase = FLASH_TYPEERASE_SECTORS; cfg.Sector = sector; cfg.NbSectors = 1; cfg.VoltageRange = FLASH_VOLTAGE_RANGE_3; if (HAL_FLASHEx_Erase(&cfg, &error) != HAL_OK) { HAL_FLASH_Lock(); // 出错也要上锁 return HAL_ERROR; } HAL_FLASH_Lock(); return HAL_OK; }现在你可以在 OTA 升级、参数保存等场景中放心调用了:
if (Flash_Erase_Sector(FLASH_SECTOR_10) == HAL_OK) { printf("扇区擦除成功,准备写入新数据\n"); } else { printf("擦除失败,请检查电源或地址合法性\n"); }那些年踩过的坑:新手常见误区与应对策略
别以为写了代码就能高枕无忧。以下是实际项目中最容易翻车的几个点:
❌ 错误 1:擦了自己的代码
// 千万别这么干! Flash_Erase_Sector(FLASH_SECTOR_0); // 正在运行的代码就在这一区!CPU 正在从 Flash 取指执行,你却把它擦了……轻则 HardFault,重则变砖。
✅正确做法:只擦非当前运行区。OTA 升级时,通常保留两份应用空间(A/B 分区),交替擦写。
❌ 错误 2:电源不稳定还强行擦除
Flash 擦除需要稳定电压。若低于 2.7V 强行操作,可能造成部分单元未完全释放电荷,导致后续写入失败或数据混乱。
✅建议:
- 添加电压监测电路(PVD)
- 在低电压下禁止执行 erase
- 使用备份电源(超级电容)确保关键操作完成
❌ 错误 3:频繁擦写同一扇区,寿命耗尽
商用 Flash 寿命约 10,000 次。如果你每分钟记录一次日志到同一个扇区,不到一周就会报废。
✅解决方案:引入磨损均衡(Wear Leveling)
思路很简单:不要总盯着一个扇区写,换着来。
比如你有 4 个备用扇区,每次写日志前先找最“年轻”的那个(擦写次数最少)使用。
uint32_t best_sector = find_least_used_sector(log_sectors, 4); Flash_Erase_Sector(best_sector); write_log_to_sector(best_sector, log_data); update_usage_counter(best_sector);哪怕不用复杂算法,简单的轮询也能显著延长整体寿命。
❌ 错误 4:多任务环境下并发访问
在 FreeRTOS 或其他 RTOS 中,多个任务同时调用 Flash 操作?
后果不堪设想:擦一半又被另一个任务打断,状态寄存器错乱,甚至锁死控制器。
✅解决方法:加互斥锁
osMutexId_t flash_mutex; void safe_erase(uint32_t sector) { osMutexWait(flash_mutex, osWaitForever); Flash_Erase_Sector(sector); osMutexRelease(flash_mutex); }确保同一时间只有一个任务能操作 Flash。
如何验证擦除是否真的成功?
别信“返回 HAL_OK”就万事大吉。最好手动读回数据确认:
bool is_erased = true; uint32_t *addr = (uint32_t*)0x08020000; // 扇区起始地址 for (int i = 0; i < 1024; i++) { // 检查前 4KB if (addr[i] != 0xFFFFFFFF) { is_erased = false; break; } } if (is_erased) { printf("擦除验证通过 ✅\n"); } else { printf("警告:擦除不完整 ❌\n"); }注意:不需要检查整个扇区,抽样即可。毕竟全读一遍也挺耗时。
它到底用在哪?真实应用场景解析
掌握了基础技能,来看看它如何支撑核心功能。
场景一:无线固件升级(OTA)
流程如下:
- 下载新固件包 → 校验完整性
- 调用
Flash_Erase_Sector(APP_AREA)清空旧程序区 - 分页写入新固件(
HAL_FLASH_Program) - 更新启动标志 → 重启跳转
⚠️ 关键点:erase 必须在写入前完成。否则原有数据残留,新固件无法正确烧录。
场景二:参数存储(替代 EEPROM)
许多低成本 MCU 没有内置 EEPROM,开发者常用一小块 Flash 模拟。
典型做法:
- 划出 1~2 个扇区专用于存储配置
- SRAM 中缓存当前参数
- 修改后标记“脏”,定时批量刷入 Flash
- 每次刷入前先擦除该扇区
为了进一步提升可靠性,还可采用双缓冲机制:
- A 扇区正在用,B 扇区备用
- 修改时擦 B、写 B、切换指针
- 原 A 成为新备用区
这样即使断电,至少有一个副本是完整的。
场景三:运行日志记录
工业设备常需记录故障码、操作轨迹等信息。
挑战在于:日志不断增长,而 Flash 容量有限。
解决方案:
- 使用循环日志(Circular Log)
- 设定 N 个日志扇区
- 按顺序写,满后擦最早的那个继续写
结合前面提到的磨损均衡,可让每个扇区均匀分担负载。
更进一步的设计思考
当你已经能熟练实现基本功能,下一步该关注哪些工程细节?
✅ 合理规划 Flash 分区
建议在项目初期就明确划分:
| 区域 | 用途 | 是否允许擦除 |
|---|---|---|
| Bootloader | 启动代码 | 极少,仅升级时 |
| Application | 主程序 | OTA 时整体擦除 |
| Config | 配置参数 | 允许,频率较低 |
| Log | 日志 | 允许,需磨损均衡 |
| Backup | 关键数据备份 | 仅故障恢复时使用 |
避免混用,降低风险。
✅ 抽象出统一接口,便于移植
不要把 HAL 库调用散落在各处。封装一层驱动层:
typedef struct { int (*init)(void); int (*erase_sector)(uint32_t sector); int (*write_page)(uint32_t addr, const uint8_t *data); int (*read)(uint32_t addr, uint8_t *buf, size_t len); } flash_driver_t;将来换芯片或换 SDK,只需替换底层实现,上层逻辑不动。
✅ 加入 ECC 和校验机制(高端应用)
部分 MCU 支持 Flash ECC(错误纠正码),可在读取时自动修复单比特错误。开启后能显著提升长期数据可靠性。
此外,每次写入后计算 CRC 并保存,下次读取时校验,也是一种简单有效的防护手段。
写在最后:为什么每一个嵌入式工程师都要懂 Flash 擦除?
因为它不只是一个 API 调用,而是连接软件与硬件、理想与现实的桥梁。
你写的每一行代码,最终都要落在 Flash 上才能持久存在。而每一次成功的写入背后,都有一次沉默的擦除在默默铺路。
掌握它,你就掌握了:
- 固件升级的主动权
- 数据存储的可控性
- 系统稳定性的底线
更重要的是,你会开始用“物理视角”看待内存管理——不再认为“存个数而已有什么难的”,而是意识到每一次操作背后的代价与约束。
随着新型存储技术的发展,QLC NAND、HyperFlash、甚至 MRAM 逐渐进入嵌入式领域,但“先擦后写”的基本逻辑依然成立。今天你在 STM32 上练的手感,未来一样可以用在更复杂的平台上。
如果你正在做一个需要保存数据的项目,不妨停下来问问自己:
“我有没有在写入前正确地擦除目标区域?”
如果答案不确定,那就赶紧去补上这一课吧。
有任何疑问或实战经验分享,欢迎留言讨论 👇