1、前言
原有的SPI-FLASH驱动就像野火哥那种,返回值都是空,能用的就是一个写的时候阻塞的查询那个状态位。当然跨页写的部分,我是直接复制野火哥的代码。附上网址
25. SPI---读写串行FLASH --- [野火]STM32库开发实战指南------基于野火霸道开发板 文档
这会遇到一个问题,假如我写错了怎么办,我怎么知道我写错了。没有一个返回值能告诉我,只能靠超时机制吗
cpp
/* WIP(busy)标志,FLASH内部正在写入 */
#define WIP_Flag 0x01
/**
* @brief 等待WIP(BUSY)标志被置0,即等待到FLASH内部数据写入完毕
* @param none
* @retval none
*/
void SPI_FLASH_WaitForWriteEnd(void)
{
u8 FLASH_Status = 0;
/* 选择 FLASH: CS 低 */
SPI_FLASH_CS_LOW();
/* 发送 读状态寄存器 命令 */
SPI_FLASH_SendByte(W25X_ReadStatusReg);
/* 若FLASH忙碌,则等待 */
do
{
/* 读取FLASH芯片的状态寄存器 */
FLASH_Status = SPI_FLASH_SendByte(Dummy_Byte);
}
while ((FLASH_Status & WIP_Flag) == SET); /* 正在写入标志 */
/* 停止信号 FLASH: CS 高 */
SPI_FLASH_CS_HIGH();
}
这部分代码简直就跟我工程里的原有的驱动代码一模一样。死等在这里没有超时退出机制。
我加了一个超时退出,这个时间我是当做一个经验值,平时都能写成功的一个值。
cpp
/**
* @brief 等待 Flash 操作完成(轮询状态寄存器 WIP 位)
*/
bool W25Q16_WaitProcessDone_timeout(void)
{
uint8_t u8Status = 0;
uint32_t timeoutCounter = 0; // 计数值跟自己的时钟频率有关,需要自己去尝试,或者直接查tick
do
{
W25Q16_CS_LOW();
soft_spi_transfer(W25Q16_CMD_READ_STATUS1);
u8Status = soft_spi_transfer(0Xff);
W25Q16_CS_HIGH();
timeoutCounter++;
if(timeoutCounter > 100000)
{
PRINT("W25Q16 timeout\r\n");
return false;
}
}
while ((u8Status & 0x01) == 1U);
return true;
}
超时退出机制有了,没有的话,如果等不到这个标志会不会程序就死在这里了。
下一个问题,就是按页写作为W25Q16等的写入大小。最大就写入一页。超了写不了,需要跨页地址写。如果跨扇区(未擦的情况下),还要擦除下一个扇区。所以跨页,野火哥给解决了,擦扇区需要放在应用方面手动去计算,是不是要擦下一个扇区。比如。
先放上跨页写的接口,当然,你想写多大都是没问题的。前提是后面的扇区都擦过了
cpp
/**
* @brief 对FLASH写入数据,调用本函数写入数据前需要先擦除扇区
* @param pBuffer,要写入数据的指针
* @param WriteAddr,写入地址
* @param NumByteToWrite,写入数据长度
* @retval 无
*/
void SPI_FLASH_BufferWrite(u8* pBuffer, u32 WriteAddr, u16 NumByteToWrite)
{
u8 NumOfPage = 0, NumOfSingle = 0, Addr = 0, count = 0, temp = 0;
/*mod运算求余,若writeAddr是SPI_FLASH_PageSize整数倍,
运算结果Addr值为0*/
Addr = WriteAddr % SPI_FLASH_PageSize;
/*差count个数据值,刚好可以对齐到页地址*/
count = SPI_FLASH_PageSize - Addr;
/*计算出要写多少整数页*/
NumOfPage = NumByteToWrite / SPI_FLASH_PageSize;
/*mod运算求余,计算出剩余不满一页的字节数*/
NumOfSingle = NumByteToWrite % SPI_FLASH_PageSize;
/* Addr=0,则WriteAddr 刚好按页对齐 aligned */
if (Addr == 0)
{
/* NumByteToWrite < SPI_FLASH_PageSize */
if (NumOfPage == 0)
{
SPI_FLASH_PageWrite(pBuffer, WriteAddr,
NumByteToWrite);
}
else /* NumByteToWrite > SPI_FLASH_PageSize */
{
/*先把整数页都写了*/
while (NumOfPage--)
{
SPI_FLASH_PageWrite(pBuffer, WriteAddr,
SPI_FLASH_PageSize);
WriteAddr += SPI_FLASH_PageSize;
pBuffer += SPI_FLASH_PageSize;
}
/*若有多余的不满一页的数据,把它写完*/
SPI_FLASH_PageWrite(pBuffer, WriteAddr,
NumOfSingle);
}
}
/* 若地址与 SPI_FLASH_PageSize 不对齐 */
else
{
/* NumByteToWrite < SPI_FLASH_PageSize */
if (NumOfPage == 0)
{
/*当前页剩余的count个位置比NumOfSingle小,一页写不完*/
if (NumOfSingle > count)
{
temp = NumOfSingle - count;
/*先写满当前页*/
SPI_FLASH_PageWrite(pBuffer, WriteAddr, count);
WriteAddr += count;
pBuffer += count;
/*再写剩余的数据*/
SPI_FLASH_PageWrite(pBuffer, WriteAddr, temp);
}
else /*当前页剩余的count个位置能写完NumOfSingle个数据*/
{
SPI_FLASH_PageWrite(pBuffer, WriteAddr,
NumByteToWrite);
}
}
else /* NumByteToWrite > SPI_FLASH_PageSize */
{
/*地址不对齐多出的count分开处理,不加入这个运算*/
NumByteToWrite -= count;
NumOfPage = NumByteToWrite / SPI_FLASH_PageSize;
NumOfSingle = NumByteToWrite % SPI_FLASH_PageSize;
/* 先写完count个数据,为的是让下一次要写的地址对齐 */
SPI_FLASH_PageWrite(pBuffer, WriteAddr, count);
/* 接下来就重复地址对齐的情况 */
WriteAddr += count;
pBuffer += count;
/*把整数页都写了*/
while (NumOfPage--)
{
SPI_FLASH_PageWrite(pBuffer, WriteAddr,
SPI_FLASH_PageSize);
WriteAddr += SPI_FLASH_PageSize;
pBuffer += SPI_FLASH_PageSize;
}
/*若有多余的不满一页的数据,把它写完*/
if (NumOfSingle != 0)
{
SPI_FLASH_PageWrite(pBuffer, WriteAddr,
NumOfSingle);
}
}
}
}
这里的关键问题,就是下一个扇区该不该擦。
这里就要用到一个计算,就是写入的地址开头与地址结尾进行特定操作后是否相等
cpp
(wr_addr_start & (~(FLASH_SECTOR_SIZE - 1))) != \
(wr_addr_end & (~(FLASH_SECTOR_SIZE - 1)))
这个与操作就是获得对应所在扇区的位置。
如果不相等就证明跨扇区了,需要擦下一个扇区。
这里还会遇到一个问题,如果你定义了一个地址范围,你的写入尾地址超出了,你限定存储的地址范围,此时就不是要擦下一个扇区,为了有效的存储,我们要回头擦第一个扇区,得到一个环形的存储空间。如果没有超出范围,就擦下一个扇区即可。
这里就可能会涉及到记录存储方面的内容,以后吃透了可能会讲讲。
下面就是擦,擦成功了吗?如何判定是否擦扇区成功,一般最小单位就是扇区,我们也是按最小单位来擦除的。
cpp
static bool W25Q16_VerifySectorErased(uint32_t addr) {
uint8_t buffer[FLASH_SECTOR_SIZE]; // 扇区大小4KB
uint32_t i;
W25Q16_Read(addr, buffer, FLASH_SECTOR_SIZE); // 读取扇区数据
// 遍历验证所有字节是否为0xFF
for (i = 0; i < FLASH_SECTOR_SIZE; i++) {
if (buffer[i] != 0xFF) {
return false; // 存在非0xFF字节,擦除失败
}
}
return true; // 全为0xFF,擦除成功
}
/**
* @brief 擦除扇区并验证(全为 0xFF)
* @param addr 扇区起始地址
* @return true: 擦除并验证成功;false: 擦除失败
*/
bool W25Q16_EraseSectorAndVerify(uint32_t addr) {
W25Q16_EraseSector(addr); // 擦除扇区
if (W25Q16_VerifySectorErased(addr) == false) {
// 擦除失败处理(如重试或报错)
printf("Sector erase failed at 0x%08X\n", addr);
return false;
}
return true;
}
我们都知道,SPI-FLASH擦完都是1,也就是每个字节都是0XFF,那么我们只需要把整个扇区的数据读回来,判断是不是0XFF即可。当然这都会造成一定的时间损耗,适合对准确性的要求大于效率性。擦除失败可以尝试重新擦除,这里就不再进行重擦,可以在写的地方,设置写失败,重试的次数,不然一直重擦,都嵌套了,太慢了。
2、擦除接口
当然我前面没做重试操作,在应用接口里做了重擦操作。这都是因为,我写记录打印的时候,有些时候写失败了,唉,不然我也不会去改这个接口。
cpp
/**
* @brief W25Q16 芯片的存储容量为 16Mbit(2MB),它将内部存储空间分为了 32 个块,每个块包含 16 个扇区,每个扇区大小为 4KB,因此可计算出每个块的大小为 4KB×16 = 64KB
*
*/
/**
* @brief 擦除指定地址所在的Flash扇区(带重试机制)
* @param addr 要擦除的起始地址(自动对齐到扇区边界)
* @return 擦除成功返回true,否则返回false
* @note 内部自动重试最多5次,适用于SPI Flash扇区擦除操作
*/
static bool RECORD_FLASH_ERASE(uint32_t addr)
{
uint8_t write_cnt = 0;
bool ret = true;
// 循环重试擦除操作,最多5次
do {
ret = W25Q16_EraseSectorAndVerify(addr);
write_cnt++;
} while (ret != true && write_cnt < 5);
// 返回最终擦除结果(成功或达到最大重试次数)
if(write_cnt >= 5) {
return ret; // 超时重试5次后仍失败
}
return ret; // 成功或重试后成功
}
3、写接口
就是写失败,头疼。改完之后,还会出现写失败,但是后面重试后成功了。写5次都失败的话,估计FLASH也可以扔了,换一个吧孩子。
在外面其实会判断是否需要擦下一个扇区,但是这里我就是为了保险,就这样吧,反正扇区那么大,也不是经常擦。记录也不是经常写。
第一次尝试写入,如果失败,就直接读出整个扇区的数据到RAM里,按理说没写过的地方都是0XFF,不用担心会整个扇区写入后导致后面的还得再擦。
如果没有跨扇区,把数据组合后,就调用跨页写接口,把这个扇区的内容全部写进去。
如果跨扇区了,为了保险,数据组合写入后,还是要擦下一个扇区,然后把剩余长度的数据写入下一个扇区。重试次数为5次。
cpp
/**
* @brief 向Flash写入数据(带失败恢复机制)
* @param addr 写入起始地址
* @param data 待写入数据缓冲区
* @param data_size 数据长度(字节)
* @return 写入成功返回true,失败返回false
* @note 若写入失败,会自动读取扇区、擦除、合并数据并重试
*/
static bool RECORD_FLASH_PROGRAM(uint32_t addr, uint8_t* data, uint16_t data_size)
{
uint8_t write_cnt = 0;
uint8_t read_buf[FLASH_SECTOR_SIZE]={0}; // 扇区数据缓冲区
uint32_t sector_addr = addr & (~(FLASH_SECTOR_SIZE - 1)); // 计算扇区起始地址
uint16_t sector_offset = addr % FLASH_SECTOR_SIZE; // 计算地址在扇区内的偏移
bool ret = false;
// 首次尝试直接写入
ret = W25Q16_Write_WithReadBack(addr, data, data_size);
if(ret == true)
return ret;
// 写失败,读取整个扇区数据
W25Q16_Read(sector_addr, read_buf, FLASH_SECTOR_SIZE);
// 进入重试恢复流程
do {
// 擦除当前扇区
W25Q16_EraseSector(sector_addr);
// 处理不跨扇区的情况(数据完全在当前扇区内)
if(sector_offset + data_size <= FLASH_SECTOR_SIZE){
// 合并新旧数据到缓冲区
tmos_memcpy(&read_buf[sector_offset], data, data_size);
// 写入整个扇区数据
ret = W25Q16_Write_WithReadBack(sector_addr, read_buf, FLASH_SECTOR_SIZE);
if(ret == true)
break;
}
// 处理跨扇区的情况
else {
// 跨扇区:分两部分处理
uint16_t first_part_size = FLASH_SECTOR_SIZE - sector_offset; // 当前扇区可写入的字节数
uint16_t second_part_size = data_size - first_part_size; // 需写入下一扇区的字节数
// 合并第一部分数据到缓冲区
tmos_memcpy(&read_buf[sector_offset], data, first_part_size);
// 擦除下一个扇区
W25Q16_EraseSector(sector_addr + FLASH_SECTOR_SIZE);
// 写入当前扇区的合并数据
ret = W25Q16_Write_WithReadBack(sector_addr , read_buf, FLASH_SECTOR_SIZE);
if(ret == false)
continue; // 当前扇区写入失败,继续重试
// 写入下一个扇区的剩余数据
ret = W25Q16_Write_WithReadBack(
sector_addr + FLASH_SECTOR_SIZE,
&data[first_part_size], // 指向剩余数据的起始位置
second_part_size // 剩余数据的长度
);
if(ret == true)
break;
}
write_cnt++;
} while (ret != true && write_cnt < 5);
// 返回最终写入结果
if(write_cnt >= 5) {
return ret; // 超时重试5次后仍失败
}
return ret; // 成功或重试后成功
}
4、当然还有一个重要的点,读数据,你怎么知道你读的是对的?
开始的时候,我想过在结构体内定义固定的标志位,比如0X55等。读的时候,先把第一位读出来,但是这样非常不保险,只能确定0x55是对的。我还想过连读两次,两次一样才判定成功,但是两次如果不一样,你怎么判定哪一次是错的?然后我还是想到了Modbus里的校验。结构体尾部加校验,存的时候把除去最后两字节的长度字节计算CRC16校验,读的时候,用读出的结果与计算的CRC校验比较即可。因为读接口是确定了,每次只读一个结构体大小的数据,而CRC又在结尾,这就不会导致,还要去找校验位在哪?
cpp
/**
* @brief 从Flash读取数据并校验(记录使用)
* @param addr 读取起始地址
* @param data 数据缓冲区
* @param data_size 读取数据长度(字节)
* @return 读取并校验成功返回true,失败返回false
* @note 内部使用带校验的读取函数,确保数据完整性
*/
static bool RECORD_FLASH_READ(uint32_t addr, uint8_t* data, uint16_t data_size)
{
uint8_t read_cnt = 0;
bool ret = false;
// 至少尝试读取一次
do {
// 1. 调用底层读取函数
if (W25Q16_Read_Verify(addr, data, data_size) == false) {
ret = false;
// 地址越界等严重错误,不重试
return false;
}
// 2. 提取存储的CRC值(低字节在前,高字节在后)
uint16_t stored_crc = (uint16_t)data[data_size - 2] | ((uint16_t)data[data_size - 1] << 8);
// 3. 计算数据部分的CRC(排除CRC字段本身)
uint16_t calculated_crc = ModbusCrc16(data, data_size - 2);
// 4. 比较CRC值
if (calculated_crc == stored_crc) {
return true; // CRC校验成功,返回true
}
read_cnt++;
} while (read_cnt < 5);
// 达到最大重试次数仍失败
return false;
}
这样应该能解决我的擦写失败问题了,我不信一直擦写失败,en。而且我也不会一直去擦写。存记录的话会存在一个问题,就是磨损平衡,和数据查找完整性问题。如果你只划一个扇区给记录,存完就擦,一下全部的数据都没了,你要开始存,那这些数据是要还是不要呢?当然,你都存了,数据肯定是重要的。
此时有两种思路:当然我是抄老哥的第二种思路。以后我自己写出来会分享。
第一种:搞个备份区,开始的时候主区和备份区同时存。存满后,擦主区,备份区还都是老数据,可以读1条新数据加地址偏移去读备份区的数据,这样数据存满了,可以保持一直是满的状态,旧数据被覆盖掉。
第二种,把数据区域搞大点,比如你最多存一个扇区,你就开3个扇区用来存数据,加上一个偏移量是大于数据记录最大数的,假如第一个扇区存满了,接着存第2,3扇区,偏移量在走,但是记录数不变,读取的时候就偏移往后-最大记录数为有效数据即可,这样就算偏移满了,回头擦第1扇区的时候也不会影响第3扇区存的旧纪录。好像就是土豪做法,反正有效数据确定是那么长了,也不会频繁擦。
最初我想的就是偏移最大等于最大记录数,这样偏移娆回头把我的老数据擦没了,读出来全是0XFF,头疼。
感谢野火哥的跨页写代码!!!