3. 底层驱动开发:SPI控制器初始化、读写单字节/多字节、扇区擦除、整片擦除、状态寄存器操作

好,咱们直接进入正题。这一章讲的是底层驱动,说白了就是让MCU和NOR Flash能“对上话”。很多新手移植文件系统失败,问题都出在这一层——SPI时序不对、状态寄存器没查、擦除操作没等完成就往下走了。嗯,这些坑我都踩过。

3.1 SPI控制器初始化——先让硬件“醒过来”

我个人习惯,写任何外设驱动之前,先看数据手册里的时钟树。SPI模块的时钟源是哪个?有没有使能位?我记得有一次在STM32F103上移植,折腾了半天SPI不工作,最后发现是RCC里忘了开SPI1的时钟。

初始化SPI控制器,通常需要做这几件事:

  • 使能时钟:RCC相关寄存器,打开SPI外设时钟和GPIO时钟
  • 配置GPIO:SCK、MOSI、MISO、CS(片选)引脚,设置为复用功能推挽输出/输入
  • 设置SPI模式:主模式、极性(CPOL)、相位(CPHA)、数据位宽(8位)、波特率
  • 使能SPI:设置SPI_CR1的SPE位

这里有个关键点——波特率不能太高。NOR Flash的SPI时钟频率通常有上限,比如W25Q64最大支持80MHz,但很多便宜的Flash只能跑到50MHz。我在项目中遇到过,为了追求速度把SPI时钟设到72MHz,结果读出来的数据全是0xFF。后来降频到36MHz,一切正常。

核心原则:SPI时钟频率 = 系统时钟 / 分频系数。分频系数选2、4、8、16等,确保最终频率 ≤ Flash支持的最大频率。

// 以STM32 HAL库为例,初始化SPI1
void SPI_Flash_Init(void)
{
    // 1. 使能时钟
    __HAL_RCC_SPI1_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();

    // 2. 配置GPIO
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; // SCK, MISO, MOSI
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 3. 配置SPI
    hspi1.Instance = SPI1;
    hspi1.Init.Mode = SPI_MODE_MASTER;
    hspi1.Init.Direction = SPI_DIRECTION_2LINES;
    hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
    hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;   // CPOL=0
    hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;       // CPHA=0
    hspi1.Init.NSS = SPI_NSS_SOFT;               // 软件控制CS
    hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // 分频4
    hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
    HAL_SPI_Init(&hspi1);
}

小技巧:CPOL和CPHA的组合,NOR Flash通常支持模式0(CPOL=0, CPHA=0)和模式3(CPOL=1, CPHA=1)。我习惯用模式0,兼容性最好。

3.2 读写单字节/多字节——最基础的数据搬运

SPI通信本质上是“主发从收”同时进行。你发一个字节,同时收到一个字节。读Flash的时候,主机要发送“假字节”来产生时钟,从机才会把数据送出来。

单字节读写很简单,但实际项目中很少用——效率太低。你想想看,读1KB数据要调用1024次SPI收发函数,每次都有函数调用开销。所以,多字节读写才是主力

// 单字节读写(底层函数)
uint8_t SPI_Flash_ReadWriteByte(uint8_t data)
{
    uint8_t rxdata;
    HAL_SPI_TransmitReceive(&hspi1, &data, &rxdata, 1, 100);
    return rxdata;
}

// 多字节读(连续读取)
void SPI_Flash_Read_Buffer(uint8_t *pBuffer, uint32_t ReadAddr, uint16_t NumByteToRead)
{
    // 1. 拉低CS
    CS_LOW();
    
    // 2. 发送读命令 0x03
    SPI_Flash_ReadWriteByte(0x03);
    
    // 3. 发送24位地址(高字节在前)
    SPI_Flash_ReadWriteByte((ReadAddr >> 16) & 0xFF);
    SPI_Flash_ReadWriteByte((ReadAddr >> 8) & 0xFF);
    SPI_Flash_ReadWriteByte(ReadAddr & 0xFF);
    
    // 4. 连续读取数据
    for(uint16_t i = 0; i < NumByteToRead; i++)
    {
        pBuffer[i] = SPI_Flash_ReadWriteByte(0xFF); // 发送0xFF产生时钟
    }
    
    // 5. 拉高CS
    CS_HIGH();
}

注意:多字节读的时候,地址会自动递增。但地址递增是循环的——读到芯片末尾会回卷到0地址。所以跨页读取时要小心,最好按页(256字节)为单位操作。

3.3 扇区擦除与整片擦除——擦干净才能写

NOR Flash的特性是:写之前必须先擦除。而且擦除的最小单位是扇区(通常4KB),不能只擦一个字节。整片擦除就是把整个芯片清成0xFF。

擦除操作需要发送命令序列,并且要等待内部操作完成。怎么知道擦完了?查状态寄存器。

// 扇区擦除(4KB)
void SPI_Flash_Erase_Sector(uint32_t SectorAddr)
{
    // 1. 写使能
    SPI_Flash_Write_Enable();
    
    // 2. 拉低CS
    CS_LOW();
    
    // 3. 发送扇区擦除命令 0x20
    SPI_Flash_ReadWriteByte(0x20);
    
    // 4. 发送24位地址
    SPI_Flash_ReadWriteByte((SectorAddr >> 16) & 0xFF);
    SPI_Flash_ReadWriteByte((SectorAddr >> 8) & 0xFF);
    SPI_Flash_ReadWriteByte(SectorAddr & 0xFF);
    
    // 5. 拉高CS
    CS_HIGH();
    
    // 6. 等待擦除完成
    SPI_Flash_Wait_Busy();
}

// 整片擦除
void SPI_Flash_Erase_Chip(void)
{
    SPI_Flash_Write_Enable();
    
    CS_LOW();
    SPI_Flash_ReadWriteByte(0xC7); // 整片擦除命令
    CS_HIGH();
    
    SPI_Flash_Wait_Busy(); // 整片擦除耗时较长,可能几秒
}

避坑指南:我曾经在整片擦除时没有等待完成就断电了,结果芯片里数据全乱套。后来加了个超时机制——如果等待超过5秒还没完成,就报错并重试。工业产品里,超时处理是必须的。

3.4 状态寄存器操作——Flash的“健康检查”

状态寄存器(Status Register)是Flash的“仪表盘”。最常用的是忙标志位(BUSY)写使能锁存位(WEL)

名称 说明
BIT0 BUSY 1=忙(正在擦除/写入),0=空闲
BIT1 WEL 1=写使能已锁存,0=未使能
BIT2 BP0~BP2 块保护位,用于写保护
// 读取状态寄存器
uint8_t SPI_Flash_ReadSR(void)
{
    uint8_t byte;
    CS_LOW();
    SPI_Flash_ReadWriteByte(0x05); // 读状态寄存器命令
    byte = SPI_Flash_ReadWriteByte(0xFF);
    CS_HIGH();
    return byte;
}

// 写使能
void SPI_Flash_Write_Enable(void)
{
    CS_LOW();
    SPI_Flash_ReadWriteByte(0x06); // 写使能命令
    CS_HIGH();
}

// 等待忙状态结束
void SPI_Flash_Wait_Busy(void)
{
    uint32_t timeout = 0;
    while((SPI_Flash_ReadSR() & 0x01) == 0x01) // BUSY位为1
    {
        timeout++;
        if(timeout > 0xFFFFFF) // 超时处理
        {
            printf("Flash busy timeout!\r\n");
            break;
        }
    }
}

经验之谈:每次写操作(页编程、擦除)之前,一定要先发写使能命令(0x06)。否则Flash会忽略后续操作。我见过有人忘了这一步,调试了一整天。

3.5 本章知识体系

下面这张图总结了底层驱动的核心流程,你可以把它当作“操作手册”来用:

NOR Flash SPI底层驱动核心流程 SPI控制器初始化 时钟使能 → GPIO配置 模式设置 → 使能SPI 选择操作类型 读 / 写 / 擦除 读操作 发送0x03 + 24位地址 连续读取N字节 无需等待忙状态 写操作(页编程) 写使能 → 发送0x02 发送地址+数据 等待忙状态结束 擦除操作 写使能 → 发送0x20/0xC7 扇区擦除/整片擦除 等待忙状态结束 状态寄存器检查 读0x05 → 检查BUSY位

这张图把整个流程串起来了。你从初始化开始,根据需求选择读、写或擦除,最后都要检查状态寄存器。记住:写和擦除必须等忙状态结束,读操作不需要等。

好了,底层驱动这块就讲到这里。代码虽然不多,但每个细节都关系到系统稳定性。下一章我们会把这些驱动封装成文件系统需要的接口函数。


公众号:蓝海资料掘金营,微信deep3321