第2章:Flash硬件抽象层(HAL)——驱动模型、API接口与设备树绑定

好,我们进入存储子系统的核心层——Flash硬件抽象层。说白了,这一层就是Zephyr给上层文件系统和存储管理提供的「统一遥控器」。不管底下是NOR Flash、NAND Flash还是SPI Flash,上层调用的API接口都是一样的。我当年刚接触Zephyr时,最佩服的就是这套抽象设计——它让应用代码彻底跟硬件解耦了。

2.1 Flash设备驱动模型

Zephyr的Flash驱动模型,本质上是一个基于设备驱动模型(Device Driver Model)的标准化框架。每个Flash设备都对应一个struct device实例,并通过flash_driver_api结构体挂载操作函数指针。

核心数据结构:

struct flash_driver_api {
    int (*read)(const struct device *dev, off_t offset, void *data, size_t len);
    int (*write)(const struct device *dev, off_t offset, const void *data, size_t len);
    int (*erase)(const struct device *dev, off_t offset, size_t size);
    int (*lock)(const struct device *dev, off_t offset, size_t size);
    int (*unlock)(const struct device *dev, off_t offset, size_t size);
    int (*read_lock_status)(const struct device *dev, off_t offset);
    const struct flash_parameters *(*get_parameters)(const struct device *dev);
    int (*page_layout)(const struct device *dev, const struct flash_pages_layout **layout, size_t *layout_size);
};

嗯,这里要注意:lockunlock不是互斥锁,而是Flash的硬件保护机制。我在项目中遇到过有人把这两个接口当成线程锁来用,结果调试了一整天——千万别搞混。

驱动模型的注册流程是这样的:

  1. 定义设备实例:DEVICE_DT_DEFINE()
  2. 实现flash_driver_api中的所有回调函数
  3. 在初始化函数中配置硬件寄存器、设置时钟、初始化SPI或QSPI控制器
  4. 通过DEVICE_DT_INST_DEFINE()绑定设备树节点

我的习惯:写Flash驱动时,我会先实现get_parameterspage_layout这两个接口。为什么?因为上层文件系统(比如LittleFS)需要先知道页大小、擦除块大小这些参数,才能做后续的布局计算。顺序搞反了,后面全是坑。

2.2 API接口详解

Zephyr的Flash API分为两类:基础操作接口和辅助查询接口。我们逐个来看。

2.2.1 基础读写擦接口

函数 功能 注意事项
flash_read() 从指定偏移读取数据 对齐要求取决于硬件,一般无限制
flash_write() 写入数据到Flash 必须先擦除,且写入不能跨页
flash_erase() 擦除指定区域 擦除大小必须是扇区/块的整数倍
flash_flush() 刷新缓存(可选) 部分控制器需要显式flush

你想想看,flash_write()为什么要求先擦除?因为Flash存储单元的特性决定了它只能从1变成0,不能从0变成1。擦除操作就是把整个块重置成全1。我记得有一次调试,发现数据写进去读出来全是乱的,查了半天——原来是忘了先擦除。从那以后,我写任何Flash操作代码,第一行一定是检查擦除状态。

2.2.2 参数查询接口

这部分接口我个人觉得是Zephyr设计最贴心的部分:

const struct flash_parameters {
    size_t write_block_size;    // 写入块大小,通常等于页大小
    size_t erase_value;         // 擦除后的默认值,通常是0xFF
    size_t capacity;            // 总容量(字节)
};

还有flash_get_page_count()flash_get_page_info_by_offs()这两个辅助函数。它们能帮你快速定位某个偏移量属于哪个页、哪个扇区。我在做OTA升级分区管理时,就靠这两个函数做地址合法性校验——省了不少代码量。

避坑指南:我曾经在STM32平台上踩过一个坑——flash_write()len参数必须是write_block_size的整数倍。如果传入一个奇数长度,驱动会静默失败或者只写入部分数据。所以,建议每次写入前都做对齐检查:

if (len % params->write_block_size != 0) {
    // 做填充或报错处理
}

2.3 设备树绑定

设备树绑定是Zephyr的「硬件描述语言」。对于Flash设备,我们需要在设备树中描述它的基地址、大小、页大小、SPI/QSPI配置等信息。

2.3.1 标准绑定属性

一个典型的NOR Flash设备树节点长这样:

&flash0 {
    compatible = "jedec,spi-nor";
    reg = <0x80000000 0x1000000>;  // 基地址 128MB
    spi-max-frequency = <104000000>;
    size = <0x1000000>;            // 16MB
    page-size = <256>;
    erase-block-size = <4096>;
    jedec-id = [c2 20 19];         // 厂商ID
};

嗯,这里compatible属性决定了Zephyr用哪个驱动来匹配这个设备。比如"jedec,spi-nor"对应的是通用的SPI NOR Flash驱动。如果你用的是国产的GD25Q系列,兼容性一般没问题,但最好还是确认一下JEDEC ID。

2.3.2 分区定义

在实际项目中,我们通常会把Flash划分成多个分区:

&flash0 {
    partitions {
        compatible = "fixed-partitions";
        #address-cells = <1>;
        #size-cells = <1>;

        boot_partition: partition@0 {
            label = "bootloader";
            reg = <0x00000000 0x00040000>;  // 256KB
            read-only;
        };
        storage_partition: partition@40000 {
            label = "storage";
            reg = <0x00040000 0x00100000>;  // 1MB
        };
        scratch_partition: partition@140000 {
            label = "scratch";
            reg = <0x00140000 0x000C0000>;  // 768KB
        };
    };
};

我的建议:分区大小最好对齐到擦除块大小的整数倍。否则,当你用flash_erase()擦除某个分区时,可能会误擦到相邻分区的数据。我曾经因为分区边界没对齐,导致bootloader分区被OTA升级分区擦掉了一半——板子直接变砖。从那以后,我每个分区定义都会手动算一遍对齐。

2.3.3 在代码中访问设备树信息

Zephyr提供了一组宏来从设备树中提取信息:

// 获取Flash设备实例
#define FLASH_DEVICE DT_LABEL(DT_ALIAS(flash_ctrl))

// 获取分区信息
#define STORAGE_PARTITION DT_NODELABEL(storage_partition)
#define STORAGE_OFFSET DT_REG_ADDR(STORAGE_PARTITION)
#define STORAGE_SIZE DT_REG_SIZE(STORAGE_PARTITION)

// 在代码中使用
const struct device *flash_dev = device_get_binding(FLASH_DEVICE);
flash_erase(flash_dev, STORAGE_OFFSET, STORAGE_SIZE);

你想想看,这样做的好处是什么?硬件布局变了,只需要改设备树文件,C代码一行都不用动。我在做产品迭代时,经常遇到Flash型号更换或者分区大小调整的情况——改个dts文件,重新编译,完事。这才是真正的「硬件抽象」。

2.4 实战要点总结

  • 驱动开发顺序:先实现get_parameters,再实现读写擦,最后做性能优化
  • 设备树命名规范:分区label用英文小写+下划线,不要用特殊字符
  • 错误处理:所有Flash API都返回负值错误码,一定要检查返回值
  • 性能考量:SPI Flash的读写速度受限于SPI时钟频率,QSPI可以提升4倍

一句话总结:Flash HAL层就是Zephyr给上层存储系统准备的「万能插座」——不管底下是什么Flash,插上去就能用。但前提是,你得把设备树绑对、驱动写对、分区对齐。这三步走稳了,后面的文件系统和存储管理就顺风顺水。

下一章,我们会深入文件系统层,看看Zephyr如何把LittleFS和FCB跑在这些Flash之上。到时候你会更深刻地理解,为什么这一层的抽象如此重要。