4、Flash驱动开发:Flash物理特性、擦除/写入/读取操作、Flash磨损均衡策略
各位同学,今天我们来聊聊Bootloader开发中最基础、也最关键的一环——Flash驱动。说实话,很多新手工程师觉得Flash驱动就是调几个库函数,没什么技术含量。但我得告诉你,这个想法很危险。我在项目中见过太多因为Flash操作不当导致的“死机”、“变砖”案例,轻则返工重烧,重则直接报废芯片。
Flash驱动写得好不好,直接决定了你的Bootloader稳不稳。咱们今天就把这块硬骨头啃下来。
4.1 Flash物理特性——你得先了解它的脾气
Flash存储器,说白了就是一种非易失性存储介质。掉电不丢数据,这是它的核心优势。但它的物理特性决定了它有几个“怪脾气”,你得顺着它来。
核心要点:Flash的存储单元是基于浮栅晶体管(Floating Gate Transistor)实现的。写入数据时,需要施加高电压将电子注入浮栅;擦除数据时,则需要将电子从浮栅中拉出。这个过程本质上是一种“物理磨损”。
我刚开始做嵌入式开发时,总觉得Flash和RAM差不多,随便读写。结果有一次在项目中频繁擦写同一个扇区,不到一周芯片就罢工了。后来查手册才发现,Flash的擦写寿命是有限的。
这里有几个关键参数你必须记住:
| 参数名称 | 典型值 | 说明 |
|---|---|---|
| 擦写寿命(Endurance) | 10,000 ~ 100,000次 | 每个扇区可擦写的最大次数 |
| 数据保持时间(Retention) | 10 ~ 20年 | 写入后数据能保持的时间 |
| 擦除粒度 | 扇区(Sector)或页(Page) | 最小擦除单位,通常为4KB或64KB |
| 写入粒度 | 字节(Byte)或字(Word) | 最小写入单位,通常为1字节或2字节 |
| 擦除时间 | 几十毫秒到几百毫秒 | 擦除一个扇区所需的时间 |
| 写入时间 | 几微秒到几十微秒 | 写入一个字节或字所需的时间 |
你想想看,如果一个扇区只能擦写1万次,而你每秒钟都在更新数据,那这个扇区可能几天就报废了。这就是为什么我们需要磨损均衡策略。
警告:Flash在擦除操作时,如果突然掉电,可能会导致数据损坏甚至整个扇区失效。我曾经在一个工业项目中遇到过这种情况,设备在固件升级过程中突然断电,结果Bootloader和应用程序都丢了,只能返厂维修。所以,掉电保护机制是必须的。
4.2 擦除操作——最“暴力”的操作
Flash的擦除操作,说白了就是把一个扇区内的所有位都置为1(0xFF)。这个过程需要施加高电压,对存储单元进行“集体放电”。
我个人习惯在擦除前先检查一下扇区状态,避免对已经擦除的扇区重复操作。重复擦除不仅浪费时间,还会加速磨损。
下面是一个典型的Flash擦除函数示例:
/**
* @brief 擦除指定的Flash扇区
* @param sector_addr: 扇区起始地址
* @retval 0: 成功, -1: 失败
*/
int flash_erase_sector(uint32_t sector_addr)
{
// 1. 检查地址是否对齐到扇区边界
if (sector_addr % FLASH_SECTOR_SIZE != 0) {
return -1; // 地址不对齐,直接返回错误
}
// 2. 解锁Flash控制器(不同MCU操作不同)
flash_unlock();
// 3. 等待上一次操作完成
while (flash_is_busy());
// 4. 发送擦除命令
flash_send_command(FLASH_CMD_ERASE_SECTOR);
flash_write_address(sector_addr);
// 5. 等待擦除完成
while (flash_is_busy());
// 6. 锁定Flash控制器
flash_lock();
// 7. 验证擦除结果(可选,但我建议加上)
uint32_t *p = (uint32_t *)sector_addr;
for (int i = 0; i < FLASH_SECTOR_SIZE / 4; i++) {
if (p[i] != 0xFFFFFFFF) {
return -1; // 擦除失败
}
}
return 0;
}
提示:擦除操作是最耗时的Flash操作,通常需要几十到几百毫秒。如果你的系统有实时性要求,建议在擦除期间关闭中断,或者使用DMA方式执行擦除。我在一个电机控制项目中就吃过这个亏,擦除Flash时中断被延迟,导致电机控制出现了抖动。
4.3 写入操作——小心“写前擦除”原则
Flash的写入操作和RAM完全不同。RAM可以随时覆盖写入,但Flash不行。Flash的写入操作只能将1变为0,不能将0变为1。要把0变回1,只能通过擦除操作。
这就是为什么Flash编程有一个铁律:写入前必须先擦除。
我记得有一次,一个同事在调试Bootloader时,发现写入的数据总是错的。查了半天,原来是他忘了在写入前擦除扇区。结果新数据写进去,和旧数据混在一起,全是乱码。
来看一个标准的Flash写入函数:
/**
* @brief 向Flash写入数据(按字写入)
* @param addr: 目标地址
* @param data: 要写入的数据
* @retval 0: 成功, -1: 失败
*/
int flash_write_word(uint32_t addr, uint32_t data)
{
// 1. 检查地址是否对齐到字边界
if (addr % 4 != 0) {
return -1;
}
// 2. 检查目标地址是否已擦除(即内容为0xFFFFFFFF)
uint32_t *p = (uint32_t *)addr;
if (*p != 0xFFFFFFFF) {
return -1; // 未擦除,不能写入
}
// 3. 解锁Flash控制器
flash_unlock();
// 4. 等待上一次操作完成
while (flash_is_busy());
// 5. 发送写入命令
flash_send_command(FLASH_CMD_PROGRAM_WORD);
flash_write_address(addr);
flash_write_data(data);
// 6. 等待写入完成
while (flash_is_busy());
// 7. 锁定Flash控制器
flash_lock();
// 8. 验证写入结果
if (*p != data) {
return -1; // 写入失败
}
return 0;
}
重要提醒:写入操作虽然比擦除快得多,但也不能掉以轻心。写入过程中掉电同样会导致数据损坏。我建议在写入关键数据(如Bootloader配置参数)时,采用“双备份”策略——写两份数据,一份主数据,一份备份数据。如果主数据校验失败,就自动从备份恢复。
4.4 读取操作——最简单,但别大意
Flash的读取操作相对简单,本质上就是直接访问内存地址。大多数MCU的Flash都是映射到CPU的地址空间中的,所以读取Flash就像读取RAM一样。
但这里有一个坑:读取速度。Flash的读取速度通常比RAM慢,尤其是在高频时钟下,可能需要插入等待周期(Wait State)。
我曾经在一个项目中,把代码放在Flash里运行,结果发现程序跑得比预期慢很多。后来查手册才发现,Flash的读取需要2个等待周期。优化之后,把频繁调用的函数放到RAM里运行,性能才提上来。
读取操作示例:
/**
* @brief 从Flash读取数据
* @param addr: 源地址
* @param buf: 目标缓冲区
* @param len: 读取长度(字节)
*/
void flash_read(uint32_t addr, uint8_t *buf, uint32_t len)
{
uint8_t *src = (uint8_t *)addr;
for (uint32_t i = 0; i < len; i++) {
buf[i] = src[i];
}
}
嗯,就是这么简单。但要注意,读取时不要越界访问,否则可能会触发硬件异常。
4.5 Flash磨损均衡策略——延长Flash寿命的关键
好了,前面讲了Flash的物理特性和基本操作。现在我们来聊聊最核心的话题——磨损均衡。
你想想看,如果你的Bootloader需要频繁保存配置参数,每次都往同一个扇区写,那这个扇区很快就会报废。磨损均衡的目的,就是把擦写操作均匀地分布到整个Flash区域,避免某个扇区“过劳死”。
常见的磨损均衡策略有以下几种:
- 静态磨损均衡:在系统空闲时,将冷数据(不常修改的数据)迁移到磨损较重的区域,把磨损较轻的区域留给热数据(频繁修改的数据)。
- 动态磨损均衡:每次写入时,选择磨损次数最少的空闲块进行写入。这是最常用的策略。
- 日志结构(Log-Structured):把Flash当作一个循环日志来使用。每次写入都追加到日志末尾,擦除操作只在日志写满时进行。
我个人比较推荐在Bootloader中使用动态磨损均衡,因为它实现简单,效果也不错。
下面是一个简单的磨损均衡实现思路:
/**
* 磨损均衡管理器结构体
*/
typedef struct {
uint32_t sector_start; // 磨损均衡区域起始扇区
uint32_t sector_count; // 扇区数量
uint32_t current_sector; // 当前使用的扇区索引
uint32_t write_count; // 当前扇区已写入次数
} wear_leveling_t;
/**
* @brief 磨损均衡写入函数
* @param data: 要写入的数据
* @param len: 数据长度
* @retval 0: 成功, -1: 失败
*/
int wear_leveling_write(uint8_t *data, uint32_t len)
{
// 1. 检查当前扇区是否已写满
if (wl.write_count >= MAX_WRITES_PER_SECTOR) {
// 切换到下一个扇区
wl.current_sector = (wl.current_sector + 1) % wl.sector_count;
wl.write_count = 0;
// 擦除新扇区
uint32_t sector_addr = wl.sector_start + wl.current_sector * FLASH_SECTOR_SIZE;
if (flash_erase_sector(sector_addr) != 0) {
return -1;
}
}
// 2. 计算当前写入地址
uint32_t addr = wl.sector_start + wl.current_sector * FLASH_SECTOR_SIZE
+ wl.write_count * FLASH_PAGE_SIZE;
// 3. 写入数据
for (uint32_t i = 0; i < len; i += 4) {
uint32_t word = *(uint32_t *)(data + i);
if (flash_write_word(addr + i, word) != 0) {
return -1;
}
}
// 4. 更新写入计数
wl.write_count++;
return 0;
}
经验之谈:磨损均衡的扇区数量不是越多越好。扇区太多,管理开销会变大;扇区太少,均衡效果不明显。我一般建议使用4到8个扇区,每个扇区大小4KB或64KB,具体看你的MCU型号。另外,记得在Flash中保存一个磨损均衡的元数据表,记录每个扇区的擦写次数,这样系统重启后还能继续均衡。
4.6 避坑指南——我踩过的那些坑
最后,分享几个我在实际项目中踩过的坑,希望能帮你少走弯路。
- 坑一:擦除时掉电——我曾经在一个远程升级项目中,设备在擦除Flash时突然断电,结果Bootloader和应用程序都丢了。解决方案:在擦除前先备份关键数据,或者使用双Bank Flash架构。
- 坑二:写入前忘记擦除——这个错误太常见了。我建议在写入函数中加一个检查,如果目标地址不是0xFFFFFFFF,就自动执行擦除操作。
- 坑三:中断延迟导致擦除失败——擦除操作需要连续执行,如果被高优先级中断打断,可能会导致擦除失败。我建议在擦除期间关闭所有可屏蔽中断。
- 坑四:磨损均衡算法太复杂——有些工程师喜欢用复杂的算法,结果代码量大了,Bug也多了。其实对于Bootloader来说,简单的轮询策略就够用了。
好了,关于Flash驱动开发的内容就讲到这里。下一章我们会聊聊Bootloader的启动流程设计,到时候会用到今天讲的Flash操作知识。记得多动手写代码,光看不练是学不会的。