本文主要记录如何使用STM32片内的flash模拟EEPROM的操作。传统场景下,一般使用片外的EEPROM进行关键数据的掉电存储。我之前也写过这方面的笔记,例如:STM8S_库函数_IIC接口_IO软件模拟_硬件实现,但某些特殊场景下,可以使用单片机内部的flash实现同样的操作。
片外flash的寿命是很大的,例如:STM32CUBEMX_SPI接口操作片外FLASH-W25Q128。
但是有个问题,STM32片内的flash一次只能擦除一页,而一页的读写次数通常在一万次左右。因此,如果读写过于频繁,那么flash的寿命很快就用完了。
所以要考虑如何最大化的发挥flash的使用寿命,核心就是专业上常说的磨损均衡(Wear Leveling)机制。
原理也很简单,定义一个特定结构的数组,包含数据的有效标记、序号(写入的次数)、实际数据、校验数据等。使用flash的时候,擦一次,写入多次。
例如使用的flash,一页有2048字节,根据存入的数据定义一个64字节的数组。
2048/64=32
第一次使用前64个字节,第二次使用第65~128字节,第三次使用…,以此类推。所以一页flash可以写32次,假设一页可以擦除一万次,那么通过磨损均衡的机制,可以使用32万次。这个次数,绝大多数场景都够用了吧。
因此,设计时,需要考虑几个因素:
接下来看代码,首先是存入数据的结构,我这里使用结构体的方式定义:
- 根据要存储的数据量,定义相应的数组,尽量让一页flash的字节数能被数组元素个数整除;
- 如何找到最新写入的一组数据,通过数组中的序号判断;
- 找到下一个能写入的地址,并写入数据;
- 一页写满后,记得擦除。
typedef struct {
uint16_t magic; // 数据有效性标记
uint16_t index; // 数据索引
uint8_t data[60]; // 实际数据(60字节)
} FlashEmuBlock;
我这个结构体由三部分组成,数据有效性标记、数据索引和实际的数据。
有效标记使用固定的0x55aa或者0xaa55,表明这是一组数据开始的地方。
数据索引,也就是个序号。例如第一次写入的时候索引是0x01,第二次写入的时候,索引是0x02。这样的话,通过索引值,就能知道哪一组数据是最新的。
最后是实际数据,这个就简单了,不解释。
我这个结构体里面没有数据校验的部分,其实不太稳妥。可以把这一部分放在实际数据里,或者修改结构体,在实际数据后面增加这部分。
第二部分是查找最后一个有效数据块的地址,通过有效性标记和结构体的大小来实现,具体函数如下:
/**
* @brief 查找最后一个有效数据块的地址
* @retval 有效数据块地址,0表示未找到
*/
uint32_t FindLastValidBlock(void)
{
uint32_t addr = FLASH_START_ADDR + FLASH_EMU_PAGE * FLASH_PAGE_SIZE;
uint32_t end_addr = addr + FLASH_PAGE_SIZE;
FlashEmuBlock block;
uint32_t last_valid_addr = 0;
while(addr + sizeof(FlashEmuBlock) <= end_addr)
{
ST_Flash_Read(addr, (uint32_t*)&block, sizeof(FlashEmuBlock)/4);
if(block.magic == FLASH_EMU_MAGIC)
{
last_valid_addr = addr;
}
else
{
break;
}
addr += FLASH_EMU_BLOCK_SIZE;
}
return last_valid_addr;
}
第三部分是找到下一个能写入的地址,并写入数据。
其实第二部分找到了最新的数据起始地址,在此基础上加一个结构体的长度就是下一个能写入的地址,然后进行写操作即可。
/**
* @brief 保存数据到Flash(固定60字节)
* @param *data 要保存的数据(必须60字节)
* @retval 0成功,其他失败
*/
uint8_t SaveDataToFlash(uint8_t *data)
{
static uint16_t index = 0;
uint32_t next_addr;
FlashEmuBlock block;
// 获取最后一个有效块地址
uint32_t last_addr = FindLastValidBlock();
// 计算下一个写入地址
if(last_addr == 0)
{
next_addr = FLASH_START_ADDR + FLASH_EMU_PAGE * FLASH_PAGE_SIZE;
index = 0;
}
else
{
next_addr = last_addr + FLASH_EMU_BLOCK_SIZE;
ST_Flash_Read(last_addr, (uint32_t*)&block, sizeof(FlashEmuBlock)/4);
index = block.index + 1;
}
// 检查是否需要擦除
if(next_addr + FLASH_EMU_BLOCK_SIZE > FLASH_END_ADDR)
{
Erase_Flash(FLASH_EMU_PAGE, FLASH_EMU_PAGE);
next_addr = FLASH_START_ADDR + FLASH_EMU_PAGE * FLASH_PAGE_SIZE;
}
// 准备数据块
block.magic = FLASH_EMU_MAGIC;
block.index = index;
memcpy(block.data, data, 60); // 固定拷贝60字节
// 写入Flash
Write_Flash(FLASH_EMU_PAGE, next_addr,(uint64_t*)&block, sizeof(FlashEmuBlock)/8);
return 0;
}
这其中也包含了整页写满后,擦出的操作。
第四部分,这里就比较简单了,从flash中读出最新的一组数据,代码如下:
/**
* @brief 从Flash读取多个数据
* @param start_addr 起始地址
* @param *buf 读取数据缓冲区
* @param count 要读取的数据个数(32位字)
*/
void ST_Flash_Read(uint32_t start_addr, uint32_t *buf, uint16_t count)
{
uint16_t i;
// 检查地址范围是否有效
if(start_addr < FLASH_START_ADDR || (start_addr + count*4) > FLASH_END_ADDR)
{
return; // 地址不在范围内
}
for(i = 0; i < count; i++)
{
buf[i] = *(volatile uint32_t *)start_addr;
start_addr += 4; // 每次读取4字节(32位字)
}
}
/**
* @brief 从Flash读取最后保存的数据(固定读取60字节)
* @param *data 数据缓冲区(需保证至少60字节空间)
* @param *length 返回的数据长度(固定返回60)
* @retval 0成功,其他失败
*/
uint8_t ReadLastDataFromFlash(uint8_t *data, uint16_t *length)
{
uint32_t last_addr = FindLastValidBlock();
FlashEmuBlock block;
if(last_addr == 0)
{
return 1; // 没有有效数据
}
// 读取整个块结构体(假设FlashEmuBlock能容纳60字节数据)
ST_Flash_Read(last_addr, (uint32_t*)&block, sizeof(FlashEmuBlock)/4);
// 强制拷贝60字节,不考虑数据内容
memcpy(data, block.data, 60);
// 固定返回长度60
*length = 60;
return 0;
}
接下来可以进行上电测试,我提供的例程中自带一个测试函数,把它添加到程序开始位置即可。

程序优化等级选O1,编译后下载,重新给板子上下电,让程序运行一下。然后用STM32 ST-LINK Utility可以看到flash中的数据。

完整例程我放到了公众号后台,需要的同学回复关键词:032 或者“STM32磨损均衡”,即可获取下载链接。
我是单片机爱好者-MCU起航,打完收工!