在写外部FLASH的应用时发现一些问题,在这里分享一下我的想法

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,头疼。

感谢野火哥的跨页写代码!!!

相关推荐
宝宝单机sop8 小时前
Ai 算法资源合集
经验分享
计算机小手8 小时前
一个带Web UI管理的轻量级高性能OpenAI模型代理网关,支持Docker快速部署
经验分享·docker·语言模型·开源软件
三水不滴9 小时前
Redis 过期删除与内存淘汰机制
数据库·经验分享·redis·笔记·后端·缓存
其古寺9 小时前
Spring事务嵌套异常处理深度解析
经验分享
梵刹古音9 小时前
【C语言】 函数基础与定义
c语言·开发语言·算法
梵刹古音10 小时前
【C语言】 结构化编程与选择结构
c语言·开发语言·嵌入式
爱编码的小八嘎10 小时前
C语言对话-22.想睡觉,偶然
c语言
小乔的编程内容分享站12 小时前
记录使用VSCode调试含scanf()的C语言程序出现的两个问题
c语言·开发语言·笔记·vscode
蓁蓁啊12 小时前
C/C++编译链接全解析——gcc/g++与ld链接器使用误区
java·c语言·开发语言·c++·物联网
中屹指纹浏览器13 小时前
2026年指纹浏览器技术迭代与风控对抗演进
经验分享·笔记