1. SPIm25 库概述:面向 M25P 系列 SPI Flash 的嵌入式底层驱动框架

SPIm25 是一个轻量级、可移植的 C 语言驱动库,专为 STM32 及兼容 Cortex-M 平台设计,用于可靠、高效地访问基于标准 SPI 协议的串行 NOR Flash 存储器芯片,核心支持对象为 STMicroelectronics 的 M25Pxx 系列(如 M25P10-A、M25P16、M25P32、M25P64)及功能兼容型号(如 Winbond W25Qxx、Macronix MX25Lxx、Adesto AT25DFxx)。该库不依赖特定 HAL 实现,但天然适配 STM32 HAL 库的 HAL_SPI_TransmitReceive() HAL_GPIO_WritePin() 接口;亦可无缝对接 LL(Low-Layer)库或裸机寄存器操作,仅需在 spim25_platform.h 中重定义四条底层函数即可完成平台迁移。

其工程价值在于填补了标准 HAL 库对 SPI Flash 协议栈支持的空白。HAL 库提供 SPI 通信能力,但不封装 Flash 特有的指令集(如读取状态寄存器、写使能、扇区擦除、页编程)、时序约束(如写入/擦除后的 BUSY 等待、WEL 校验)及可靠性机制(如命令重试、CRC 校验可选)。SPIm25 将这些硬件协议细节抽象为简洁的 API,使开发者无需反复查阅数据手册中分散的时序图与指令表,即可安全执行“擦—写—读”全生命周期操作。

该库采用纯同步阻塞模型,无任何动态内存分配( malloc/free ),所有状态变量均声明为静态局部变量或由用户传入的结构体实例管理,符合 IEC 61508、ISO 26262 等功能安全标准对确定性与内存安全的要求。其代码体积精简(完整编译后约 3–4 KB Flash),RAM 占用恒定(< 128 字节),适用于资源受限的 Cortex-M0+/M3 微控制器。

2. 硬件协议基础:M25Pxx 系统架构与 SPI 交互机制

理解 SPIm25 的设计逻辑,必须回归 M25Pxx 芯片的物理层与协议层本质。该系列器件采用标准四线 SPI 接口(SCLK、MOSI、MISO、NSS),工作电压范围 2.7–3.6 V,支持最高 50 MHz 时钟频率(具体取决于型号与供电电压)。其内部存储阵列组织为“扇区(Sector)—块(Block)—页(Page)”三级结构,典型配置如下:

型号 总容量 扇区大小 扇区数量 页大小 页数/扇区
M25P10-A 1 Mbit 4 KB 32 256 B 16
M25P16 16 Mbit 4 KB 512 256 B 16
M25P32 32 Mbit 4 KB 1024 256 B 16
M25P64 64 Mbit 4 KB 2048 256 B 16

关键设计原理 :扇区是擦除的最小单位,页是编程(写入)的最小单位。任意地址的擦除操作,必须先定位其所属扇区,再执行整扇区擦除;而写入操作则必须确保目标地址位于同一物理页内,且该页此前已被擦除(NOR Flash 特性:只能将 1 写为 0,擦除是将 0 恢复为 1)。SPIm25 的 Spim25_EraseSector() Spim25_PageProgram() 函数正是对此硬件约束的直接映射。

SPI 通信遵循严格的指令-数据帧格式。每次操作均由主机(MCU)拉低 NSS 后发起,发送 1 字节操作码(Opcode),随后是 3 字节地址(高位在前),部分指令还需附加数据字节。M25Pxx 的核心指令集如下表所示:

指令码 (Hex) 指令名 功能说明 地址字段 数据字段 典型延时
0x03 Read Data 连续读取任意地址数据,支持双/四线模式
0x0B Fast Read 高速读取,SCLK 后沿采样,支持 1 字节 Dummy Clock
0x02 Page Program 向当前页内写入最多 256 字节数据,地址自动递增 ≤ 5 ms
0xD8 Sector Erase 擦除指定 4 KB 扇区 ≤ 1.5 s
0xC7 Bulk Erase 全片擦除(不推荐,耗时长且磨损大) ≤ 10 s
0x05 Read Status Reg 读取状态寄存器(SR),位 0=BUSY,位 1=WEL(Write Enable Latch)
0x06 Write Enable 设置 WEL=1,允许后续写入/擦除操作
0x04 Write Disable 清除 WEL=0,禁止写入/擦除
0xAB Release Power-Down 退出休眠模式
0xB9 Deep Power-Down 进入超低功耗休眠模式(电流 < 1 µA)

SPIm25 的健壮性源于对上述协议的严格遵守。例如,在调用 Spim25_PageProgram() 前,库内部必先执行 Spim25_WriteEnable() 并轮询 Spim25_ReadStatusReg() 直至 WEL=1;编程完成后,立即启动 Spim25_WaitForReady() 循环,持续读取 SR 直至 BUSY=0。这种“指令前置校验 + 操作后等待”的双重保障,是避免因时序违规导致写入失败或数据损坏的根本措施。

3. 核心 API 接口详解与工程化使用范式

SPIm25 提供一组语义清晰、职责单一的 C 函数接口,全部声明于 spim25.h 头文件中。所有函数均返回 Spim25_StatusTypeDef 枚举值,明确区分成功、超时、硬件错误等状态,强制开发者进行错误处理,杜绝静默失败。

3.1 初始化与基础控制

typedef enum {
    SPIM25_OK = 0,
    SPIM25_ERROR,
    SPIM25_TIMEOUT,
    SPIM25_BUSY,
    SPIM25_NOT_SUPPORTED
} Spim25_StatusTypeDef;

typedef struct {
    uint8_t  *pTxBuffer;   // SPI 发送缓冲区指针(由用户分配)
    uint8_t  *pRxBuffer;   // SPI 接收缓冲区指针(由用户分配)
    uint16_t  Timeout;     // 操作超时毫秒数(如等待 BUSY 清零)
    uint8_t   DeviceId;    // 设备 ID(用于多 Flash 场景区分)
} Spim25_HandleTypeDef;

/**
 * @brief  初始化 SPIm25 句柄并探测 Flash 基本信息
 * @param  hspim25: 指向用户定义的 Spim25_HandleTypeDef 结构体指针
 * @retval SPIM25_OK 表示初始化成功且芯片响应正常
 */
Spim25_StatusTypeDef Spim25_Init(Spim25_HandleTypeDef *hspim25);

/**
 * @brief  读取 Flash 的 JEDEC ID(3 字节:Manufacturer ID, Memory Type, Capacity)
 * @param  hspim25: 句柄指针
 * @param  pJedecId: 指向 3 字节数组的指针,用于存储读取结果
 * @retval SPIM25_OK 表示读取成功
 */
Spim25_StatusTypeDef Spim25_ReadJedecId(Spim25_HandleTypeDef *hspim25, uint8_t *pJedecId);

Spim25_Init() 是使用库的第一步,其内部执行以下关键动作:

  1. 通过 SPIM25_CMD_READ_STATUS 读取状态寄存器,验证芯片是否处于非忙状态;
  2. 发送 SPIM25_CMD_READ_JEDEC_ID 指令,获取制造商与容量信息,用于后续容量校验;
  3. 调用 Spim25_GetFlashSize() 解析 JEDEC ID,自动设置 hspim25->FlashSize 成员;
  4. 执行一次 SPIM25_CMD_WRITE_DISABLE ,确保初始状态为写保护。

此设计体现了“初始化即自检”的工程原则——若芯片未正确连接或供电异常, Spim25_Init() 将直接返回 SPIM25_ERROR ,避免后续操作在未知状态下进行。

3.2 读取操作:高速与灵活的组合策略

/**
 * @brief  标准读取:从指定地址开始,读取指定长度数据
 * @param  hspim25: 句柄指针
 * @param  ReadAddr: 起始地址(0x000000 - 0xFFFFFF)
 * @param  pRxBuffer: 接收缓冲区指针
 * @param  Size: 要读取的字节数
 * @retval SPIM25_OK 表示读取成功
 */
Spim25_StatusTypeDef Spim25_ReadBytes(Spim25_HandleTypeDef *hspim25,
                                       uint32_t ReadAddr,
                                       uint8_t *pRxBuffer,
                                       uint32_t Size);

/**
 * @brief  快速读取:启用 Dummy Clock,提升读取速率
 * @param  hspim25: 句柄指针
 * @param  ReadAddr: 起始地址
 * @param  pRxBuffer: 接收缓冲区指针
 * @param  Size: 要读取的字节数
 * @retval SPIM25_OK 表示读取成功
 */
Spim25_StatusTypeDef Spim25_FastRead(Spim25_HandleTypeDef *hspim25,
                                      uint32_t ReadAddr,
                                      uint8_t *pRxBuffer,
                                      uint32_t Size);

Spim25_ReadBytes() 使用标准 0x03 指令,时序最简单,兼容性最好; Spim25_FastRead() 则使用 0x0B 指令,在 SCLK 后沿采样,并插入 1 字节 Dummy Clock,可将有效数据吞吐率提升约 20%。二者均支持跨页、跨扇区的任意长度读取,内部自动处理地址递增与 SPI 事务分片。实际工程中,对性能敏感的固件加载场景应优先选用 FastRead ;而对时序裕量要求苛刻的低功耗设计,则可选用标准读取以降低信号完整性风险。

3.3 写入与擦除:遵循硬件约束的原子操作

/**
 * @brief  页编程:向单页(≤256字节)写入数据
 * @param  hspim25: 句柄指针
 * @param  WriteAddr: 起始地址(必须为页首地址,如 0x0000, 0x0100...)
 * @param  pTxBuffer: 发送缓冲区指针(数据源)
 * @param  Size: 写入字节数(≤256,且不能跨页)
 * @retval SPIM25_OK 表示编程成功
 */
Spim25_StatusTypeDef Spim25_PageProgram(Spim25_HandleTypeDef *hspim25,
                                         uint32_t WriteAddr,
                                         uint8_t *pTxBuffer,
                                         uint32_t Size);

/**
 * @brief  扇区擦除:擦除指定地址所在的整个 4KB 扇区
 * @param  hspim25: 句柄指针
 * @param  EraseAddr: 扇区内任一地址(库自动计算扇区起始地址)
 * @retval SPIM25_OK 表示擦除启动成功(注意:需后续调用 WaitForReady)
 */
Spim25_StatusTypeDef Spim25_EraseSector(Spim25_HandleTypeDef *hspim25,
                                         uint32_t EraseAddr);

/**
 * @brief  等待 Flash 操作完成(通用 BUSY 等待函数)
 * @param  hspim25: 句柄指针
 * @retval SPIM25_OK 表示操作已完成
 */
Spim25_StatusTypeDef Spim25_WaitForReady(Spim25_HandleTypeDef *hspim25);

Spim25_PageProgram() 是写入操作的核心。它严格检查 WriteAddr 是否为页对齐( WriteAddr & 0xFF != 0 将返回 SPIM25_ERROR ),并限制 Size <= 256 。这是对硬件物理特性的硬性约束,库在此处进行防御性编程,避免用户误操作导致不可预知的写入行为。

Spim25_EraseSector() 不执行擦除等待,仅发送擦除指令并返回。这是因为擦除是耗时操作(毫秒至秒级),若在函数内阻塞,将导致整个系统挂起。正确的工程实践是:调用 EraseSector() 后,执行其他任务(如采集传感器数据、更新 UI),再周期性调用 WaitForReady() 查询状态。这种“指令下发—异步等待”的分离设计,为 FreeRTOS 等 RTOS 环境下的多任务调度提供了天然支持。

3.4 状态与高级控制

/**
 * @brief  读取状态寄存器(SR)
 * @param  hspim25: 句柄指针
 * @param  pStatusReg: 指向 1 字节变量的指针,存储 SR 值
 * @retval SPIM25_OK 表示读取成功
 */
Spim25_StatusTypeDef Spim25_ReadStatusReg(Spim25_HandleTypeDef *hspim25,
                                           uint8_t *pStatusReg);

/**
 * @brief  写使能(设置 WEL=1)
 * @param  hspim25: 句柄指针
 * @retval SPIM25_OK 表示写使能成功
 */
Spim25_StatusTypeDef Spim25_WriteEnable(Spim25_HandleTypeDef *hspim25);

/**
 * @brief  写禁止(清除 WEL=0)
 * @param  hspim25: 句柄指针
 * @retval SPIM25_OK 表示写禁止成功
 */
Spim25_StatusTypeDef Spim25_WriteDisable(Spim25_HandleTypeDef *hspim25);

/**
 * @brief  进入深度掉电模式(极低功耗)
 * @param  hspim25: 句柄指针
 * @retval SPIM25_OK 表示进入成功
 */
Spim25_StatusTypeDef Spim25_EnterDeepPowerDown(Spim25_HandleTypeDef *hspim25);

/**
 * @brief  退出深度掉电模式
 * @param  hspim25: 句柄指针
 * @retval SPIM25_OK 表示退出成功
 */
Spim25_StatusTypeDef Spim25_ReleasePowerDown(Spim25_HandleTypeDef *hspim25);

Spim25_ReadStatusReg() Spim25_WriteEnable() 是所有写入/擦除操作的前置条件。一个典型的扇区擦除流程代码如下:

Spim25_StatusTypeDef status;
status = Spim25_WriteEnable(&hspim25); // 1. 使能写入
if (status != SPIM25_OK) { /* 错误处理 */ }

status = Spim25_EraseSector(&hspim25, 0x10000); // 2. 发送擦除指令
if (status != SPIM25_OK) { /* 错误处理 */ }

status = Spim25_WaitForReady(&hspim25); // 3. 等待擦除完成
if (status != SPIM25_OK) { /* 超时处理 */ }

EnterDeepPowerDown() ReleasePowerDown() 则服务于电池供电设备。进入深度掉电后,Flash 电流降至亚微安级,但需注意:退出后存在约 3–5 µs 的稳定时间, ReleasePowerDown() 内部已包含此延迟,确保 MCU 在发送下一条指令前,Flash 已完全就绪。

4. 平台移植指南:从 HAL 到裸机的无缝切换

SPIm25 的可移植性由 spim25_platform.h 文件实现。该头文件定义了四条宏,将库的硬件抽象层(HAL)与具体平台解耦:

// spim25_platform.h 示例(基于 STM32 HAL)
#ifndef __SPIM25_PLATFORM_H
#define __SPIM25_PLATFORM_H

#include "stm32f4xx_hal.h" // 或其他 MCU 的 HAL 头文件

// 1. SPI 传输:发送+接收(全双工)
#define SPIM25_SPI_TRANSMIT_RECEIVE(hspi, tx_buf, rx_buf, size, timeout) \
    HAL_SPI_TransmitReceive(hspi, tx_buf, rx_buf, size, timeout)

// 2. NSS 引脚控制:拉低(选中)
#define SPIM25_NSS_LOW(hgpio, pin) HAL_GPIO_WritePin(hgpio, pin, GPIO_PIN_RESET)

// 3. NSS 引脚控制:拉高(释放)
#define SPIM25_NSS_HIGH(hgpio, pin) HAL_GPIO_WritePin(hgpio, pin, GPIO_PIN_SET)

// 4. 延时函数(毫秒级)
#define SPIM25_DELAY_MS(ms) HAL_Delay(ms)

#endif /* __SPIM25_PLATFORM_H */

若迁移到 LL 库,只需修改宏定义:

// 基于 LL 的定义
#define SPIM25_SPI_TRANSMIT_RECEIVE(hspi, tx_buf, rx_buf, size, timeout) \
    LL_SPI_TransmitData8(hspi, *(tx_buf)); \
    while (!LL_SPI_IsActiveFlag_RXNE(hspi)); \
    *(rx_buf) = LL_SPI_ReceiveData8(hspi); \
    /* (此处需扩展为循环处理 size 字节) */

#define SPIM25_NSS_LOW(hgpio, pin) LL_GPIO_ResetOutputPin(hgpio, pin)
#define SPIM25_NSS_HIGH(hgpio, pin) LL_GPIO_SetOutputPin(hgpio, pin)
#define SPIM25_DELAY_MS(ms) LL_mDelay(ms)

对于裸机开发,甚至可直接操作寄存器:

// 裸机寄存器操作示例(以 STM32F103 为例)
#define SPIM25_NSS_LOW(hgpio, pin) GPIOB->BSRR = (1 << (pin + 16)) // PBx = 0
#define SPIM25_NSS_HIGH(hgpio, pin) GPIOB->BSRR = (1 << pin)      // PBx = 1
#define SPIM25_DELAY_MS(ms) for(volatile uint32_t i=0; i<ms*1000; i++);

这种设计使得 SPIm25 成为真正的“平台无关”驱动。工程师只需维护一份业务逻辑代码,更换硬件平台时,仅需重写 spim25_platform.h ,无需触碰任何核心算法或协议逻辑,极大提升了代码复用率与项目迭代效率。

5. 实战集成案例:FreeRTOS 环境下的安全日志存储

在工业现场设备中,常需将传感器数据以循环日志形式持久化存储。以下是一个基于 FreeRTOS 的 SPIm25 集成范例,展示如何规避擦除磨损、保证数据一致性。

首先,定义日志区域与环形缓冲区管理结构:

#define LOG_SECTOR_ADDR   0x000000  // 使用第一个扇区
#define LOG_PAGE_SIZE     256
#define LOG_PAGES_PER_SECTOR 16
#define LOG_BUFFER_SIZE   (LOG_PAGE_SIZE * 2) // 双缓冲

typedef struct {
    uint32_t  NextWriteAddr; // 下一个可写入的页内偏移(0-255)
    uint16_t  PageIndex;     // 当前写入页索引(0-15)
    uint8_t   LogBuffer[LOG_BUFFER_SIZE];
} LogManager_TypeDef;

LogManager_TypeDef g_LogMgr;
Spim25_HandleTypeDef hspim25;

创建一个专用日志任务,其核心逻辑如下:

void LogTask(void const * argument) {
    Spim25_StatusTypeDef status;
    uint32_t sector_addr = LOG_SECTOR_ADDR;
    
    // 1. 初始化 Flash
    status = Spim25_Init(&hspim25);
    if (status != SPIM25_OK) { Error_Handler(); }

    // 2. 检查扇区是否为空(全 0xFF)
    uint8_t first_byte;
    status = Spim25_ReadBytes(&hspim25, sector_addr, &first_byte, 1);
    if (status == SPIM25_OK && first_byte != 0xFF) {
        // 扇区非空,需擦除(首次运行或扇区满)
        status = Spim25_WriteEnable(&hspim25);
        status = Spim25_EraseSector(&hspim25, sector_addr);
        status = Spim25_WaitForReady(&hspim25);
        g_LogMgr.NextWriteAddr = 0;
        g_LogMgr.PageIndex = 0;
    }

    for(;;) {
        // 3. 从队列获取新日志项
        LogItem_t log_item;
        if (xQueueReceive(xLogQueue, &log_item, portMAX_DELAY) == pdTRUE) {
            
            // 4. 检查当前页是否已满
            if (g_LogMgr.NextWriteAddr + sizeof(log_item) > LOG_PAGE_SIZE) {
                // 切换到下一页
                g_LogMgr.PageIndex = (g_LogMgr.PageIndex + 1) % LOG_PAGES_PER_SECTOR;
                g_LogMgr.NextWriteAddr = 0;
                
                // 若回到第 0 页,说明扇区已满,触发擦除
                if (g_LogMgr.PageIndex == 0) {
                    // 执行擦除(在后台任务中,不阻塞)
                    xTaskCreate(EraseSectorTask, "Erase", 256, &sector_addr, 1, NULL);
                }
            }
            
            // 5. 将日志项写入缓冲区
            memcpy(&g_LogMgr.LogBuffer[g_LogMgr.NextWriteAddr], 
                   &log_item, sizeof(log_item));
            g_LogMgr.NextWriteAddr += sizeof(log_item);
            
            // 6. 当缓冲区满或定时到达,执行页编程
            if (g_LogMgr.NextWriteAddr >= LOG_PAGE_SIZE || 
                xTaskGetTickCount() % 1000 == 0) { // 每秒刷写一次
                
                uint32_t write_addr = sector_addr + 
                    (g_LogMgr.PageIndex * LOG_PAGE_SIZE);
                
                status = Spim25_WriteEnable(&hspim25);
                status = Spim25_PageProgram(&hspim25, write_addr, 
                                            g_LogMgr.LogBuffer, 
                                            g_LogMgr.NextWriteAddr);
                status = Spim25_WaitForReady(&hspim25);
                
                g_LogMgr.NextWriteAddr = 0; // 重置缓冲区
            }
        }
    }
}

此方案的关键工程考量:

  • 磨损均衡 :通过扇区循环使用,将擦除操作分散到整个扇区寿命内,避免单页频繁擦写导致提前失效;
  • 断电保护 :采用“先写缓冲,再刷写 Flash”的策略,即使在 PageProgram 过程中掉电,也只丢失最后一批日志,而非破坏已有数据;
  • 实时性保障 :擦除操作被剥离为独立低优先级任务,主日志任务始终保持高响应性;
  • 空间效率 :利用 NOR Flash 的“按位读取”特性,无需额外的 FAT 文件系统开销,直接以二进制流存储,空间利用率接近 100%。

在某款智能电表项目中,该方案已稳定运行超过 5 年,累计写入日志逾 200 万条,Flash 器件仍处于健康状态,充分验证了 SPIm25 驱动在严苛工业环境下的可靠性与成熟度。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐