在写外部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,头疼。

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

相关推荐
SY师弟1 小时前
台湾TEMI协会竞赛——1、龙舟机器人组装教学
c语言·单片机·嵌入式硬件·机器人·嵌入式·台湾temi协会
星宇CY1 小时前
STM32 定时器应用:从精准延时到智能控制的实战指南
stm32·单片机·嵌入式硬件
学习噢学个屁1 小时前
基于STM32音频频谱分析设计
c语言·stm32·单片机·嵌入式硬件·音视频
水水沝淼㵘3 小时前
嵌入式开发学习日志(数据库II && 网页制作)Day38
服务器·c语言·网络·数据结构·数据库·学习
Cyrus_柯4 小时前
网络编程(数据库:SQLite)
linux·c语言·数据库·sqlite
木木黄木木5 小时前
自定义鼠标效果 - 浏览器扩展使用教程
前端·经验分享·计算机外设
水饺编程5 小时前
MFC 第一章概述
c语言·c++·windows·mfc
Wangshanjie_985 小时前
【C语言】-指针01
c语言
半导体守望者6 小时前
Kyosan K5BMC ELECTRONIC INTERLOCKING MANUAL 电子联锁
经验分享·笔记·功能测试·自动化·制造
秃然想通6 小时前
C语言——深入解析字符串函数与其模拟实现
c语言·开发语言