Zephyr 驱动开发指南
编制日期 2026-10-05
面向 Cortex-M / RISC-V 的 Zephyr 外设与存储驱动移植规范 · 覆盖 binding YAML、设备实例化、SPI/I2C/UART/GPIO/中断、Flash 控制器与 MTD、NAND 坏块管理、SDMMC 与 eMMC、LittleFS/FatFs、NVS/ZMS、PM、ISR-DMA 并发安全、测试与上游提交 · 系统层的线程调度与构建见配套《Zephyr 系统开发指南》
一句话结论:Zephyr 驱动开发的核心不是写寄存器,而是「用 binding 声明硬件契约、用 Kconfig 声明功能依赖、用设备模型声明初始化顺序」——三份声明写对了,驱动的骨架代码就对了;剩下的才是各家厂商的时序差异。
线上问题最集中的四处:binding 的 compatible 与 DTS 不匹配导致驱动根本不编译、初始化等级高于依赖它的设备导致 device_is_ready 返回 false、写/擦除忙等没有超时上限、缓冲区对齐与 DMA 缓存一致性问题。管住这四处,存储器件驱动的稳定性解决八成。
这份文档解决什么
本文是Zephyr 驱动开发文档:从 binding YAML 与驱动骨架、SPI/I2C/UART/GPIO 通用外设,到 Flash 控制器与 MTD 子系统、NAND 坏块管理、SDMMC 与 eMMC 块设备、LittleFS/FatFs、NVS/ZMS 键值存储,再到 PM 落地、ISR-DMA 并发安全、错误处理与测试,最后给出一份完整的 XTX SPI NOR 移植实战与可勾选的核查清单。 线程调度、工作队列、日志诊断、构建系统与 MCUboot OTA 等系统层议题由配套的《Zephyr 系统开发指南》承接;器件的电气参数与硬件设计仍以各器件的电路设计指南为准。
使用约定
- API、目录与配置项以 Zephyr 3.7 LTS / 4.x 为主线;驱动 API 在 4.x 有少量签名变化,落地前以工程内源码为准。目标平台以 Cortex-M3/M4/M33、RISC-V 为主。
- 代码示例以 SPI NOR(jedec_spi_nor 风格) 为主线,因为它是 Zephyr 中最完整、最有参考价值的存储驱动;NAND 与 SDMMC 路径在第 9、10 章单独展开。
- 寄存器与 SoC 相关操作一律写成
/* SoC 层提供 */,本文聚焦驱动层(device driver)的写法,不重复硬件手册内容。 - 标 红线 为强制约束,标 警惕 为高频踩坑点,标 建议 为经验推荐值。
- 涉及器件容量、时序、电压、寿命等参数,一律以该器件的数据手册为唯一依据;本文出现的数值仅为示例量级。
1 · 驱动开发总论
这一章解决:驱动该写在哪一层、哪些工作可以复用官方代码、什么情况下必须自己写。对存储器件项目来说,这个问题问对了能省掉数周的移植时间。
1.1 编写目标、适用范围与读者
本文的目标是让读者能够独立完成一个新存储器件在 Zephyr 上的移植与量产落地,包括:从器件手册提取关键参数、编写 binding 与 DTS overlay、补齐驱动代码接口、通过 ztest 验证、完成掉电与寿命测试,并具备向上游提交的质量。
适合的读者:需要在 Zephyr 上接入 SPI NOR / SPI NAND / PPI NAND / SD NAND / eMMC 的嵌入式驱动与 BSP 工程师;以及做存储器件 FAE 时需要理解 Zephyr 侧实现细节的应用工程师。本文假定读者已具备裸机驱动开发经验(会看手册、会读时序图、写过 SPI 收发),但不假定熟悉 Zephyr 内核 API——那部分由《Zephyr 系统开发指南》承接。
1.2 版本基线与源码位置约定
本文以 Zephyr 3.7 LTS 为基线(4.x 主线在驱动 API 层面基本一致,新增配置项以源码为准)。文中提到的源码路径均相对于 zephyr/ 目录:
| 路径 | 内容 | 驱动作者的关联度 |
|---|---|---|
dts/bindings/ | binding YAML,描述节点与驱动的契约 | ★★★★★ 写新驱动必读 |
drivers/flash/ | Flash 控制器驱动(jedec_spi_nor、spi_nand 等) | ★★★★★ 主战场 |
drivers/mtd/ | MTD 通用层与 NAND 原始驱动 | ★★★★ 读 NAND 必看 |
drivers/sdhost/ | SDMMC 控制器驱动 | ★★★★ SD NAND / eMMC 必看 |
subsys/fsm/ | 文件系统(NVS、ZMS、LittleFS、FCB) | ★★★★ |
drivers/gpio/ | GPIO 框架 | ★★★ |
drivers/spi/ | SPI 框架与控制器 | ★★★ |
include/zephyr/device.h | 设备模型核心头文件 | ★★★ |
include/zephyr/dt-bindings/ | DT 属性宏定义(gpio.h、spi.h 等) | ★★★★ |
soc/<vendor>/ | SoC 层驱动与 pinctrl | ★★ |
boards/<vendor>/ | 板级 DTS 与 defconfig | ★★ |
注意 include/zephyr/dt-bindings/ 这个目录:所有 DT 属性宏(GPIO_ACTIVE_HIGH、SPI_MODE_CPOL、SPI_WORD_SET 等)都在这里。写 binding 时引用的属性名必须来自这里或自己新增,否则 DTS 里写了但没人校验,拼写错误也无人报错。
1.3 驱动分层与本文覆盖范围
Zephyr 的驱动分四层。分清层次能避免「明明是控制器问题却在器件驱动里找 bug」这类误判。
本文主要负责 ④ 设备驱动层与③ 驱动框架层的对接:即「一个具体器件/控制器如何在框架里正确地暴露能力」。② 总线控制器层只在需要新控制器时改;① SoC 层通常由芯片原厂支持包提供,不在本文范围。
1.4 官方驱动可复用性判断
绝大多数新型号存储器件不需要写新驱动。Zephyr 官方驱动对同品类器件的覆盖通常是「指令集 + JEDEC ID 表」结构,新增一个型号往往只是往表里加一行。
| 情形 | 能否复用官方驱动 | 需要做的改动 |
|---|---|---|
| 同品类、指令集兼容(如 NOR 容量档位扩展) | 可以,改 ID 表即可 | DTS compatible + 驱动的 id 表加一条 |
| 同品类但页大小/擦除粒度不同 | 可以 | ID 表里带上页大小与擦除粒度字段 |
| 同品类但有独有状态寄存器或新指令 | 部分 | 加条件分支处理特有指令;或写 wrapper |
| 电压/时序特殊(如需专用上电序列) | 部分 | 在 pinctrl 与 init 序列里加特殊步骤 |
| 接口类型不同(NOR 改 NAND) | 不可以 | 新写驱动,参考 spi_nand |
| 坏块策略不同(如需 SLC 特殊处理) | 不可以 | 新写驱动,参考 mtd_nand_raw |
在 zephyr/drivers/flash/ 里直接加自己的型号,west update 之后会被全部冲掉,且团队的代码无法进版本库。正确做法有三选一:① 改 DTS 与 binding(首选);② 写 Zephyr module(zephyr/module.yml + west.yml 的 projects 引入);③ Fork Zephyr 并在 manifest 里指向自己的 fork(改动大时才用)。
1.5 存储器件驱动的特殊性与本文重点
存储器件驱动与普通外设驱动有三个实质差别,也是本文重点围绕它们的原因:
差别一:写与擦除是「耗时且不可中断」的操作。Flash 的页编程(典型 0.3~3 ms)与块擦除(典型 100~700 ms)期间不能被读打断,忙等必须有超时上限,否则器件异常时会永久卡死整个系统。这是驱动里最重要的一处防御。
差别二:擦除后数据不可逆,且写入只能从 1 变 0。这决定了 NAND 设备必须做坏块管理(出厂坏块 + 使用中新坏块),也决定了 NAND 上的文件系统必须做磨损均衡,否则几百万次擦写就会把介质写死。
差别三:接口类型决定走哪条软件栈。SPI NOR 走 MTD → flash_map → NVS/LittleFS;NAND 走 MTD + 坏块管理;SD NAND 与 eMMC 是块设备,走 SDMMC → disk → FatFs,完全不经过 MTD。选错路径会在编译期或运行期报错。
第 2~8 章按「通用骨架 → 通用总线 → Flash 控制器」顺序展开,是驱动的通用能力;第 9~12 章按存储品类分支(NAND / 块设备 / 文件系统 / 键值存储)展开;第 13~16 章是工程化要求(PM、并发、错误处理、测试);第 17~20 章是质量与交付(可移植性、FAQ、实战、速查与核查清单)。第一次移植器件时,建议按第 3 章走完整流程,再回头按需查 9~12 章。
2 · 驱动骨架:目录、构建与设备模型
这一章解决:一个驱动需要哪些文件、每个文件写什么、设备如何被实例化并正确初始化。读完这一章,你应该能独立搭出一个能编译、能被 DTS 实例化、能通过 device_is_ready 的空驱动骨架。
2.1 驱动源码的标准目录结构
Zephyr 的驱动遵循「就近放置 + 四件套」惯例:驱动源码、Kconfig、CMakeLists.txt、binding YAML 放在一起(binding 也可单独放 dts/bindings/)。以 SPI NOR 为例:
如果是全新驱动而非修改现有驱动,建议直接用 Zephyr 的驱动脚手架生成骨架,避免遗漏:
# 在 zephyr 源码树内执行,会生成最小可用骨架
./scripts/scaffold.py -s spi # 按子系统类型创建目录结构
# 生成后至少补齐:
# 1. binding YAML(compatible + 属性)
# 2. Kconfig(default 条件 + depends on)
# 3. CMakeLists.txt(zephyr_library + 源文件)
# 4. 驱动的 DT_DRV_COMPAT 与 DEVICE_DT_INST_DEFINE
2.2 CMakeLists 与 Kconfig 配套文件
驱动目录下的 CMakeLists.txt 把源文件加入内核库(非应用时是 zephyr_library,应用侧是 zephyr_library_named + target_sources):
# drivers/flash/CMakeLists.txt(节选)
zephyr_library()
zephyr_library_sources_ifdef(CONFIG_FLASH_JEDEC_SPI_NOR
jedec_spi_nor.c
)
zephyr_library_sources_ifdef 的意义是「仅当该 Kconfig 打开时才编译这个文件」。这既省 Flash(关掉后源码完全不占体积),也避免在配置未启用时因缺少依赖头文件而编译失败。
驱动目录下的 Kconfig 声明配置项与依赖,是「哪些功能被裁剪」的唯一入口。写法要点见 2.4 与第 20.3 节。
2.3 binding YAML:驱动的契约
binding 是驱动与设备树之间的唯一正式契约。它定义了 compatible 字符串、必选与可选属性、属性的数据类型与取值范围。设备树编译器会依据 binding 校验 DTS——拼错属性名、类型不匹配、缺少必填属性,都会在编译期报错,而不是等到上板才发现。
| binding 字段 | 作用 | 不写的后果 |
|---|---|---|
| compatible | 与驱动的 DT_DRV_COMPAT 匹配的唯一依据 | 驱动不实例化,设备不出现 |
| include | 引入 dt-bindings 宏(gpio.h / spi.h 等) | 无法使用 <&gpio_0> 等控制器引用 |
| properties | 声明每个属性的类型与说明 | DTS 里写错不报错 |
| required | 列出必填属性(如 reg) | 缺 reg 也能编译,运行期地址为 0 |
| additionalProperties | 是否允许未声明属性 | 误写的属性被静默接受 |
| description / title | 生成 DT 文档,供用户查阅 | 文档缺失,评审无从下手 |
# dts/bindings/flash/xtx,spi-nor.yaml
description: |
XTX SPI NOR Flash(单线 SPI 接口的 NOR 型存储器)
适用于 XTX 系列 1~128 Mb SPI NOR,采用标准 JEDEC 命令集
(0x9F 读 ID / 0x03 页编程 / 0x06 写使能 / 0xD8 块擦除)。
compatible: "xtx,spi-nor"
include: [spi-controller.yaml] # 继承 SPI 控制器属性(cs-gpios 等)
properties:
reg:
maxItems: 2
required: true
description: |
reg[0]: 存储总线的从设备号(通常是 0)
reg[1]: Flash 的线性地址空间大小(必须等于器件实际容量)
size-in-bytes:
type: int
required: true
description: 器件实际容量(字节)。与 reg[1] 一致,用于自检。
jedec-id:
type: int
required: true
description: 器件的 JEDEC ID(0x9F 返回的 3 字节中前 2 字节)
tpgm:
type: int
default: 300000
description: 页编程典型耗时(纳秒),用于忙等超时初值
terase:
type: int
default: 400000000
description: 块擦除典型耗时(纳秒),用于忙等超时初值
# 继承 spi-controller.yaml 提供的 cs-gpios / spi-max-frequency 等属性
① 把「驱动需要的时序参数」做成属性(如 tpgm、terase),而不是写死在驱动里。这样换一颗时序不同的器件只需改 DTS,不需要改代码,也便于批量校准。② 继承 spi-controller.yaml 而不是自己重复定义 cs-gpios 等属性,这样驱动才能复用 Zephyr 的 SPI 框架(自动片选管理、传输助手 API)。
2.4 设备实例化与初始化等级
写完驱动后,如何让它在目标板上真正跑起来,取决于三件事的配合:DTS 里有没有节点且 status 为 okay、Kconfig 里有没有被打开、初始化等级与依赖关系是否正确。
DEVICE_DT_INST_DEFINE 的完整形态与各参数含义:
/* 参数依次为:
* inst 实例序号(0 起),多实例时逐个展开
* init 初始化函数,签名 int (*)(const struct device *dev)
* pm 电源管理回调,NULL 表示不支持(见第 13 章)
* data 静态数据(配置结构体),可为 NULL
* api 指向驱动 API 表(struct flash_operations)
* level 初始化等级
* prio 同等级内的优先级(数字小者先执行)
* cfg 驱动配置结构体,用于 CONFIG_<DRIVER>_INIT_PRIORITY 覆盖 prio
*/
#define DEVICE_DT_INST_DEFINE(inst, init, pm, data, api, level, prio, cfg) \
DEVICE_DT_INST_DEFINE_ORDT_INIT(inst, init, pm, data, api, level, prio, cfg, ORDT_INIT_DEPTH)
/* 存储类 Flash 驱动的推荐参数组合 */
DEVICE_DT_INST_DEFINE(0, jedec_spi_nor_init, NULL, &cfg, &flash_xtx_api,
POST_KERNEL, 90, &cfg);
| 初始化等级 | 执行时机 | 适合的驱动类型 |
|---|---|---|
PRE_KERNEL_1 | 内核启动最早,锁与基础内存可用前 | 极少数(SoC 早期初始化) |
PRE_KERNEL_2 | 内核基础功能就绪后 | 中断、GPIO 控制器等基础外设 |
POST_KERNEL | 内核就绪、应用 main 运行前 | 存储类、总线类、通信类驱动 |
APPLICATION | 应用启动时 | 依赖应用配置后才需要的设备 |
Flash 驱动依赖 SPI 控制器已就绪。init 里第一件事应该是:
static int jedec_spi_nor_init(const struct device *dev)
{
const struct xtx_nor_config *cfg = dev->config;
int ret;
/* 依赖检查:总线控制器必须先就绪(存储类驱动的必写代码) */
if (!device_is_ready(cfg->bus)) {
LOG_ERR("SPI controller not ready");
return -ENODEV;
}
/* 若用 pinctrl,同样要检查 */
if (cfg->pinctrl && !pinctrl_is_ready(cfg->pinctrl)) {
LOG_ERR("pinctrl not ready");
return -ENODEV;
}
ret = spi_configure_dt(cfg->bus, &cfg->spi_cfg);
if (ret) {
LOG_ERR("spi_configure failed (%d)", ret);
return ret;
}
/* 读 JEDEC ID 确认器件在场 */
ret = jedec_spi_nor_read_id(dev);
if (ret) {
LOG_ERR("no JEDEC ID readback (%d)", ret);
return ret;
}
return 0;
}
返回 -ENODEV 后,device_is_ready(dev) 会返回 false,上层能感知;比崩溃或静默返回 0 好得多。
2.5 设备 API 表与编码规范
驱动通过一个 API 结构体向框架暴露能力。以 Flash 为例是 struct flash_operations;块设备是 struct disk_operations;GPIO、I2C 等各有各的结构体。把 API 表声明为 static const 并配 __aligned 到 cache line 是 Zephyr 的惯例(部分架构上对齐影响性能或正确性)。
| 规范项 | 要求 | 反例 |
|---|---|---|
| 入参校验 | 每个 op 先查 offset/len 边界、对齐、ptr 非空 | 直接访问不校验,可能越界 |
| 错误返回 | 失败返回负 errno(-EINVAL / -EIO / -ETIMEDOUT / -ENODEV) | 失败返回 0,错误被吞 |
| 字节序 | Flash 缓冲区内统一使用小端(Zephyr 约定) | 混用大端,读出数据颠倒 |
| 日志前缀 | 用 LOG_MODULE_REGISTER 声明模块,级别按需 | 直接 printf,阻塞且不可分级 |
| 注释 | 解释「为什么这么写」,不复述代码 | 无注释,后人不敢改 |
| 对齐 | DMA 缓冲区用 BUILD_ASSERT 校验对齐 | 栈上数组未对齐,DMA 数据错位 |
Zephyr 的 Flash API 里,缓冲区里的多字节数据一律按小端排列(与 Linux 的 mtdcore 一致)。这对存储器件尤其重要:如果你的器件手册规定数据是 MSB first(例如某些 NAND 的页内排列),必须在驱动里做字节序转换,并在注释里写明。这是「读得出数据但数值全错」类问题的常见根因之一。
骨架写完后、进入功能实现前,先确认五件事:① 能在 menuconfig 里看到并打开该驱动的 Kconfig 项;② binding 能通过 dtc 校验(west build 不报 binding 错误);③ DTS 里节点 status 是 okay 且 reg 的 size 与器件容量一致;④ 启动日志里能看到该设备出现(device 命令或 init 的 LOG_ERR 都会显示);⑤ device_is_ready() 返回 true。这五步全绿之后,再开始写读/写/擦逻辑,能省掉大量「功能写了但根本调不到」的调试时间。
3 · 从零移植一个 SPI Flash 驱动
这一章是本文的主线:用一个 SPI NOR 器件走完「读手册 → 定 binding → 写命令层 → 读路径 → 写路径 → 擦除 → 上板联调」的全过程。读完你应该能独立把一个手册没被官方支持过的 SPI NOR 器件点亮,并解释清楚为什么能点亮。
3.1 需求确认与器件手册关键参数
移植的第一步不是写代码,是把手册读透并填一张参数表。SPI NOR 的厂商(JEDEC)已经把指令集标准化了,但「哪些指令支持、dummy 周期多少、四字节地址怎么进」各家仍有差异,这三项决定了后面所有实现。
参数表不用做得复杂,但下面 12 项必须一项不漏地摘出来。缺任何一项,后面的调试都会退化成「猜」:
| 参数 | 典型值 | 拿不到时的退路 |
|---|---|---|
| JEDEC ID | EF 40 98(Winbond 128 Mbit) | 用 flash probe 工具实际读,读到什么就是什么 |
| 容量 | 128 Mbit = 16 MB | 按 ID 表查;查不到就先用 16 MB 试,再按读 ID 结果反推 |
| 页大小 | 256 B(个别 128 B 或 512 B) | 试 256;写坏的现象是页首被覆盖 |
| 扇区大小 | 4 KB(也有 32 KB / 64 KB) | 从 0x20 是否可用推断;若不可用走 0xD8 |
| 擦除指令集 | 0x20 小扇区 / 0xD8 大块 | 两者都试;都不行说明这颗器件需要特定指令 |
| WIP / WEL 位 | SR1 的 bit0 / bit1 | 读一次 SR1,看哪些位在擦写期间变化 |
| tPP / tSE / tBE | 0.7 ms / 45 ms / 400 ms | 手册必给;取 3 倍作为超时上限 |
| 是否需要四字节地址 | 容量 > 128 Mbit 才需要 | 超过阈值必须切,且要写清进出顺序 |
| 四字节指令集 | 0x13 读 / 0x12 写 / 0x21 擦 | 部分器件没有,必须走 4B 指令 + 4B 地址模式 |
| dummy 周期档位 | 8 档(1~8 个 dummy) | 从 1 档试到 8 档,返回 0xFF 的那档就是错的 |
| 封装与 IO 电平 | SOP8 / USON8,2.7~3.6 V | 直接查料号表;封装错了板子做不出来 |
| WP# 与 HOLD# | 是否需要上拉 | 这两个脚悬空会导致读数据随机出错 |
参数表里最容易被跳过、代价也最大的是 「4 字节地址的进出顺序」。进四字节模式一般是:写 SR2 的 QE 位 → 退出三字节模式(写 SR3 的 ADS=0),顺序反过来器件会进入一种既不是三字节也不是四字节的怪状态,之后所有地址都错。退出时顺序相反:先清 QE,再清 ADS。
把上面这张表原样写成驱动的头注释,标明数据来源是哪个手册的哪一版。三个月后有人改 dummy 周期,至少能查到依据。这条对向上游提交也是硬性要求:Zephyr 的评审会直接问参数出处。
3.2 binding 与 DTS overlay
如果这颗器件已经被官方支持(CONFIG_SPI_NOR 里能找到),binding 与 overlay 直接复用,驱动完全不用写——只需要在板级 DTS 里加一个节点。这一步先确认,能省掉后面 80% 的工作:
# 在 zephyr 源码树里查官方是否已支持
grep -r "jedec,spi-nor" drivers/flash/*.yaml
grep -rn "0xef40\|0x2040\|0x9541" drivers/flash/jedec_spi_nor.c
# 用探测工具确认(接上硬件后)
west build -b <your_board> samples/sflash
# 启动后
device list # 看有没有 flash@0
flash probe # 打印 JEDEC ID 与容量
未被支持时,有两条路:
| 方案 | 做法 | 适用场景 | 代价 |
|---|---|---|---|
| 复用 compatible | compatible 仍写 jedec,spi-nor,只在 Kconfig 里加 SPINOR_HAS_<厂商型号> 宏 | 同厂商同系列的新容量档位 (GD25Q128 与 GD25Q64 指令一致) | 最小。上游也愿意收这种补丁 |
| 新增 compatible | binding 里加 compatible = "vendor,part",驱动里加独立的 ID 匹配表 | 指令集有实质差异的 新型号或新厂商 | 较大,但隔离清晰 |
| 板级自定义 | 完全自研 compatible 并只在自有板使用 | 一次性项目、不打算开源 | 小,但无法享受上游维护 |
复用 compatible 时最容易犯的错是「只加了 ID 却没加参数差异」。比如某厂商的 512 Mbit 型号需要进四字节模式才能访问上半区,而同系列 64 Mbit 不需要——如果只在 ID 表里加一行而没加「是否四字节」的分支,上半区就是一片 0xFF。
写命令层时把这四个原语分成两个文件:jedec_spi_nor.h 放命令字定义与状态位定义,jedec_spi_nor.c 放实现。这样移植新型号时只需加一个 static const struct spi_nor_cmd 表项,不用碰逻辑。
3.3 驱动 Kconfig 与 CMakeLists
Kconfig 这一步的唯一目的是「不要在不需要的时候把代码编进固件」。SPI NOR 驱动的写法是:
# drivers/flash/Kconfig
config SPI_NOR
bool "SPI NOR flash"
default y
depends on SPI
# 板上确实有 flash 才默认打开
default y if FLASH_JEDEC_SPI_NOR
help
SPI NOR flash 统一驱动。若芯片型号已包含在支持列表中,
建议保持开启以获得统一的 MTD 与分区支持。
config FLASH_JEDEC_SPI_NOR
bool "JEDEC SPI NOR flash support"
default SPI_NOR
depends on FLASH
help
对应 drivers/flash/jedec_spi_nor.c。
关闭后该文件的全部代码不参与编译,节省 Flash 空间。
config FLASH_SIMULATOR
bool "Flash simulator"
depends on FLASH
default n if !FLASH_JEDEC_SPI_NOR
help
纯内存 flash 仿真,不依赖真实硬件。
调试文件系统层时非常有用,建议每个板子都打开。
CMakeLists 用 zephyr_library_sources_ifdef 与 Kconfig 对齐即可:
# drivers/flash/CMakeLists.txt(节选)
zephyr_library()
zephyr_library_sources_ifdef(CONFIG_FLASH_JEDEC_SPI_NOR jedec_spi_nor.c)
zephyr_library_sources_ifdef(CONFIG_FLASH_SIMULATOR simulator.c)
驱动源码的组织建议按「参数表 / 命令原语 / 路径实现 / 厂商表」四段,而不是按函数堆在一起。厂商表(ID 到容量与指令集的映射)单独一个文件,移植新型号时只改这一个文件。
3.4 硬件抽象层:命令与状态轮询
命令层要做的只有四件事:发命令、读数据、写数据、等状态。真正需要小心的是「等状态」和「命令之间的间隔」。
/* 等待 WIP 清零,带超时。这是整个驱动里最不能省的一个函数。 */
static int spi_nor_wait_ready(const struct spi_nor *nor)
{
int64_t start = k_uptime_get();
/* 超时取手册 tBE(块擦除)典型值的 3 倍,至少 500 ms */
int64_t timeout = MAX(nor->t_max_ms * 3, 500);
while (true) {
uint8_t sr = spi_nor_read_sr1(nor); /* 0x05,读状态寄存器 */
if ((sr & SPI_NOR_SR1_WIP) == 0U) {
break;
}
int64_t elapsed = k_uptime_get() - start;
if (elapsed > timeout) {
LOG_ERR("wait ready timeout after %lld ms, sr=0x%02x",
elapsed, sr);
/* 记录现场:地址、命令字、耗时,之后不要再往这个块写 */
return -ETIMEDOUT;
}
/* 让出 CPU,否则高优先级线程会被独占了 400 ms 的擦除 */
k_sleep(K_MSEC(1));
}
return 0;
}
/* 发命令前的写使能,并确认 WEL 真置位 */
static int spi_nor_write_enable(const struct spi_nor *nor)
{
int ret = spi_nor_cmd_write_enable(nor); /* 0x06 */
if (ret) {
return ret;
}
uint8_t sr = spi_nor_read_sr1(nor);
if ((sr & SPI_NOR_SR1_WEL) == 0U) {
/* 有些器件 WEL 窗口极短,这里直接判失败比盲目往下走好 */
LOG_ERR("WEL not set");
return -EIO;
}
return 0;
}
上面这段里有两个「看起来多余」但必须保留的动作:确认 WEL 置位与轮询里 k_sleep(1ms) 让出 CPU。前者能把「命令没生效」和「写逻辑错」区分开,后者避免一次 400 ms 的块擦除把整个系统冻住。都是「出问题时省两天」的成本。
有些教程用 k_busy_wait(t_be * 1000) 做等待,这在 Zephyr 上是错误的:它不让出 CPU,擦除期间系统假死、看门狗可能复位、蓝牙等周期任务全部超时。正确做法是循环里 k_sleep(K_MSEC(1)) 轮询状态位——既让出 CPU,又比固定等 tBE 更快返回。
3.5 读路径实现
读路径是驱动里最容易写对也最容易写「看起来对但有隐患」的部分。核心风险有三个:传输方式选择、Cache 一致性、多线模式的 dummy 周期。
读 ID 与读参数是启动阶段的第一条通路,也是唯一不依赖器件内部状态管理的通路。任何驱动移植都从它开始:
static int spi_nor_read_id(const struct spi_nor *nor, uint8_t *id)
{
uint8_t cmd = SPI_NOR_CMD_RDID; /* 0x9F */
/* 结构体事务:tx 缓冲区含命令,rx 缓冲区收 3 字节 ID */
const struct spi_buf tx = { .buf = &cmd, .len = sizeof(cmd) };
struct spi_buf rx = { .buf = id, .len = SPI_NOR_ID_LEN }; /* 3 */
const struct spi_buf_set tx_set = { .buffers = &tx, .count = 1U };
const struct spi_buf_set rx_set = { .buffers = &rx, .count = 1U };
const struct spi_transport_dt dt = SPI_TRANSPORT_DT(nor->bus, FLASH0_CONFIG_BIAS, 100);
int ret = spi_transmit_dt(nor->bus, &tx_set, &rx_set);
if (ret) {
LOG_ERR("read id failed (%d)", ret);
return ret;
}
/* 厂商 ID 校验:0xFF 说明根本没读到(接线、模式、供电问题) */
if (id[0] == 0xFFU || id[0] == 0x00U) {
LOG_ERR("invalid JEDEC id %02x %02x %02x", id[0], id[1], id[2]);
return -ENODEV;
}
return 0;
}
这里有两个值得强调的点。其一,读 ID 的 spi_transmit_dt 里操作频率参数(第三个参数)要按「能稳定读到」的低频填,比如 1 MHz,确认通路后再提速——用 50 MHz 读不到 ID 时无法区分是时钟太快还是接线错误。其二,判断「根本没读到」要用「读到 0xFF 或 0x00」这个特征值,而不是「读到的 ID 不在表里」;后者可能是时序问题,前者是电气或模式问题。
3.6 写路径与页编程
写路径里唯一真正需要动脑的是页边界。其余部分——写使能、命令发数据、等忙——和擦除完全一样。
拆分逻辑写成一个独立的辅助函数,让主流程保持干净:
/* 计算从 offset 起、页内剩余还能写多少字节 */
static inline size_t spi_nor_chunk_size(const struct spi_nor *nor,
uint32_t offset, size_t len)
{
uint32_t page = nor->parameters.page_size;
uint32_t in_page = offset % page;
return MIN(len, (size_t)(page - in_page));
}
static int spi_nor_do_write(const struct spi_nor *nor, uint32_t offset,
const uint8_t *data, size_t len)
{
int ret = 0;
while (len > 0U) {
size_t chunk = spi_nor_chunk_size(nor, offset, len);
ret = spi_nor_write_enable(nor);
if (ret) {
return ret;
}
ret = spi_nor_page_program(nor, offset, data, chunk);
if (ret) {
return ret;
}
ret = spi_nor_wait_ready(nor);
if (ret) {
return ret;
}
offset += (uint32_t)chunk;
data += chunk;
len -= chunk;
}
return 0;
}
页边界越界的写入行为是「静默覆盖」:驱动返回 0,上层认为成功,但页首数据已经被后面的内容冲掉。表现通常是「刚写完读回是对的,但重启后再读,某个偏移处的数据变成了另一段的内容」。验证方法是:写完立刻读回整个涉及的扇区,而不只是刚写的那一段。
3.7 擦除与忙等管理
擦除逻辑本身很短,难点全在「什么时候需要擦」和「等多久」。
static int spi_nor_do_erase(const struct spi_nor *nor, uint32_t offset,
uint32_t size)
{
int ret;
/* 扇区对齐检查:这是 API 契约的一部分,必须显式校验 */
if (offset % nor->parameters.erase_size != 0U) {
LOG_ERR("erase offset 0x%08x not sector aligned", offset);
return -EINVAL;
}
if (size % nor->parameters.erase_size != 0U) {
LOG_ERR("erase size 0x%08x not sector multiple", size);
return -EINVAL;
}
/* 按擦除粒度逐块发命令。小扇区指令不支持时退化为大块擦除。 */
uint32_t sector = nor->parameters.erase_size;
while (size > 0U) {
ret = spi_nor_write_enable(nor);
if (ret) {
return ret;
}
if (nor->flags & SPI_NOR_HAS_ERASE_4K) {
ret = spi_nor_cmd_erase_4k(nor, offset); /* 0x20 */
} else {
ret = spi_nor_cmd_erase_block(nor, offset); /* 0xD8 */
}
if (ret) {
return ret;
}
ret = spi_nor_wait_ready(nor);
if (ret) {
/* 超时:不要继续往这个块写,把失败地址上报 */
LOG_ERR("erase timeout at 0x%08x", offset);
return ret;
}
offset += sector;
size -= sector;
}
return 0;
}
「什么时候需要擦除」这个决策通常不由 Flash 驱动做,而由上层的 Flash 控制器(flash_operations 之上)决定:Zephyr 的 flash_map 与分区层会先查目标区域是否已是 0xFF(flash_area_is_empty 或驱动自己实现的 isErased),非空才发起擦除。把「是否需要擦」的判断放在驱动里重复实现,是常见的性能问题来源——每次写都擦一遍,写放大可能到 10 倍以上。
3.8 上板联调顺序
联调顺序本身就是排障策略。从最底层往上走,每一步都留下可观测证据:
# 第 0 步:确认硬件通路(不依赖驱动)
# 示波器/逻辑分析仪量 CLK/MOSI/MISO,确认有波形、片选能拉低
# 万用表量 VCC,确认在 2.7~3.6 V 之间
# 第 1 步:确认设备树被解析
west build -b <board> samples/sflash
# 构建日志里应出现 "devicetree: 'spi@0' found ... flash@0 (jedec,spi-nor)"
# 若没有,检查 DTS 节点是否 status="okay"、是否被 chosen 的 zephyr,sram 分区挡住
# 第 2 步:确认设备被实例化
device list | grep flash
# 应看到 DEVICE_DT_NAME(FLASH0_NODE) 或 DEVICE_DT_GET(FLASH0_NODE)
# 没有就查 Kconfig 是否打开、DT_DRV_COMPAT 是否与 compatible 一致
# 第 3 步:确认能读到 ID
flash probe
# 打印 JEDEC id: ef 40 98 (Winbond, 128 Mbit)
# 读不到:查 CPOL/CPHA、接线、供电、片选极性
# 第 4 步:确认单字节读写
flash read flash-test-sector0 0x0 16
# 应看到烧录工具写入的校验值
# 第 5 步:确认擦除
flash erase flash-test-sector0 0 4096
flash read flash-test-sector0 0x0 16
# 应全是 0xff
# 第 6 步:确认写入与读回
flash write flash-test-sector0 0x0 bin/16b.bin
flash read flash-test-sector0 0x0 16
# 与源文件逐字节比对
# 第 7 步:全片读写校验(最耗时,但必须做)
flash write flash-test-sector0 0x0 bin/large.bin
flash read flash-test-sector0 0x0 <len>
# 用 cmp 与源文件比对,验证页拆分与跨扇区处理
小容量(16 B)读写成功不代表全片正常——页拆分、扇区边界、跨 4 KB 边界的问题只有在大范围读写时才暴露。全片读写校验是移植完成的标准,也是唯一能证明「所有页边界都处理对了」的方法。
4 · GPIO 与中断驱动
这一章补齐驱动里最基础也最容易写错的两块:GPIO 的取用方式与中断的注册约束。存储驱动本身用不到多少 GPIO,但片选、READY/Busy 脚、以及所有带中断的传感器都依赖它。
4.1 GPIO 设备树声明与 DT 宏
Zephyr 里 GPIO 有两套历史接口:旧的 gpio_pin_configure() 与新的 gpio_dt_spec。新代码一律用 gpio_dt_spec,因为它把「哪个控制器、哪个引脚、什么极性」都从设备树带出来,不需要在驱动里硬编码引脚号。
三处最容易出错的地方:
| 错误写法 | 后果 | 正确写法 |
|---|---|---|
GPIO_DT_GET(node, cs_gpios) | 丢失 GPIO_ACTIVE_LOW 极性标志,片选极性反了 | GPIO_DT_SPECIAL_GET(node, cs_gpios, gpios) |
检查 gpio_pin_configure_dt() 的返回值忽略 | 引脚被别的驱动占用时静默失败,后续所有访问都不通 | 必须判返回值,非 0 时返回 -ENODEV 或 -EBUSY |
| 在 DTS 里写 GPIO 的实际引脚号 | 换 SoC 或换板子就全错 | DTS 只写「片选是哪个控制器的哪个脚」,驱动不关心具体编号 |
还有一个细节:cs-gpios 这类带「-gpios」后缀的属性,标准 GPIO_DT_GET 与 GPIO_DT_SPECIAL_GET 的行为不同。前者返回引脚号(丢标志位),后者返回带标志位 32 位值。带「-gpios」的属性(cs-gpios、reset-gpios、ready-gpios)一律用 SPECIAL_GET。
4.2 GPIO API 与一次性初始化
GPIO 的初始化应当在 probe 里做一次,之后只改值。反复配置引脚方向是常见的浪费,尤其在中断频繁的场合。
/* probe 里一次性配置 */
static int sensor_gpio_init(const struct sensor_dev_config *cfg)
{
int ret;
if (!gpio_is_ready_dt(&cfg->int_gpio)) {
LOG_ERR("int gpio not ready");
return -ENODEV;
}
/* 配置为输入、带上拉(中断脚常用) */
ret = gpio_pin_configure_dt(&cfg->int_gpio, GPIO_INPUT);
if (ret) {
LOG_ERR("int gpio config failed (%d)", ret);
return ret;
}
/* 配置为输出并置高(复位脚常用:低有效) */
ret = gpio_pin_configure_dt(&cfg->reset_gpio, GPIO_OUTPUT_ACTIVE);
if (ret) {
return ret;
}
return 0;
}
/* 使用时只改值,不改方向 */
gpio_pin_set_dt(&cfg->reset_gpio, 0); /* 释放复位 */
gpio_pin_set_dt(&cfg->reset_gpio, 1); /* 拉低复位 */
/* 读回(注意要读 _dt 版本,它处理了逻辑极性) */
int level = gpio_pin_get_dt(&cfg->int_gpio);
if (level < 0) {
return level;
}
gpio_pin_get_dt() 返回的是逻辑电平(已按 DT 里的极性取反),而 gpio_pin_get_raw() 返回物理电平。判断器件状态一律用 _dt 版本,否则 DT 里写了 GPIO_ACTIVE_LOW 之后所有判断都会反。
4.3 中断的 DT 声明与 IRQ_CONNECT
Zephyr 的中断统一走 irq_connect()(旧名 irq_handler),不再直接操作 SoC 的中断控制器寄存器。声明和注册都很短,但「寄存器在哪个中断控制器上」这件事必须算对。
DT_IRQ(node) 返回的是「中断号」,它由 devicetree 生成的宏按 interrupt-parent 计算出来。计算规则是:控制器基址编号 × 每控制器最大中断数 + 引脚号。所以 A/B/C/D 四个控制器的中断号区间是不同的,写死一个中断号在不同板子上必然错。
/* 中断号与 IRQ 类型都从 DT 推导,不硬编码 */
#define SENSOR_NODE DT_NODELABEL(sensor_als)
#define SENSOR_INT DT_SPECIAL_GET(SENSOR_NODE, interrupt_parent, ...)
static void sensor_isr(const struct device *port, void *arg)
{
struct sensor_data *data = (struct sensor_data *)arg;
int ret = gpio_pin_get_dt(&data->int_gpio);
if (ret > 0) {
k_sem_give(&data->data_ready);
}
}
/* 在 init 里注册(不是 probe,因为可能需要先启动器件) */
static int sensor_init(const struct device *dev)
{
struct sensor_data *data = dev->data;
k_sem_init(&data->data_ready, 0, 1);
gpio_pin_configure_dt(&data->int_gpio, GPIO_INPUT);
gpio_pin_interrupt_configure_dt(&data->int_gpio, GPIO_INT_EDGE_TO_LOW);
irq_connect(data->int_gpio.port, data->int_gpio.pin,
sensor_isr, data, 0);
irq_enable(data->int_gpio.pin);
return 0;
}
ISR 的参数里第一个是「端口设备」(即中断控制器本身),多数驱动里用不到;最后一个是自己传的上下文指针。传 dev->data 而不是 dev 可以省掉 ISR 里的一次解引用。
4.4 ISR 的约束与 _from_isr 系列
ISR 运行在中断上下文,有一批 API 不能用。这是「能编译但运行时挂死」的高发区:
| 不能做 | 原因 | 应该用 |
|---|---|---|
k_sleep() / 忙等 | 中断上下文不能阻塞,会死等 | k_sleep(K_USEC(...)) 都不行;只能 k_spin_lock 或立刻返回 |
k_malloc() | 堆可能被锁住,或中断打断持有堆锁的代码 | K_HEAP_DEFINE 的静态对象;或 ISR 里只做标记 |
printk() / LOG_ERR() | 日志用锁保护,在中断里拿锁会与持锁者互等 | LOG_ERR 在多数配置下可行但会拖长中断;调试期用 LOG_INF 且控制量 |
mutex_lock() | 互斥锁可能睡眠 | spinlock(k_spinlock_key_t) |
k_sem_give() 同名混淆 | k_sem_give 可以,k_sem_take 不行 | ISR 里 give,线程里 take |
mutex_lock 的 _from_isr 版本 | Zephyr 的 mutex 不提供 from_isr 版本 | struct k_spinlock + k_spin_lock |
_from_isr 系列(xxx_from_isr)在 Zephyr 里主要见于 work queue 与 k_sem 相关 API。使用原则很简单:ISR 里只做「记录事件」和「通知等待者」两件事,耗时的处理一律交给 work queue 或线程。这是 ISR 里最重要的一条设计原则,比记住哪个 API 能不能调更根本。
/* 正确模式:ISR 只发信号,work 处理 */
static void sensor_isr(const struct device *port, void *arg)
{
struct sensor_data *data = arg;
/* ISR 里最长的操作就是这行:往信号量给一个信号 */
k_sem_give(&data->data_ready);
}
static void sensor_work_handler(struct k_work *work)
{
struct sensor_data *data = CONTAINER_OF(work, struct sensor_data, work);
/* 耗时的读寄存器、发消息都在这里,可以睡眠 */
uint16_t raw = sensor_read_raw(data->dev);
struct sensor_value val;
sensor_raw_to_milli(data->dev, raw, &val);
if (data->data_ready_cb) {
data->data_ready_cb(data->dev, &val);
}
}
/* 用 work queue 而不是 k_thread 的原因:work 的执行上下文有栈,
* 且可以用 k_work_q_configure 绑到指定队列(如系统队列)。 */
k_work_init(&data->work, sensor_work_handler);
4.5 常见错误:去抖、长中断与电平触发
五类错误里,「重复中断」最难查:现象是系统负载莫名升高、消息队列积压、日志刷屏,但 ISR 里的业务逻辑又是对的。定位方法是先在 ISR 入口加一个静态计数器,如果计数器增长速度远超物理事件频率,就是中断没被正确清掉。
去抖有三种做法,按优先级:硬件 RC 滤波(最可靠,成本是一个电阻一个电容);器件端去抖(如果器件有专门的 debounce 寄存器或滑动平均);软件去抖窗口(ISR 里记录时间戳,主循环里丢弃窗口内的重复事件)。前两种优于第三种。
中断脚一定要有外部上拉(常见 10 kΩ 到 47 kΩ)。很多 SoC 的中断脚是「无上拉的开漏」,悬空状态下每次读都可能是低电平,导致上电瞬间误触发一次中断。如果现象是「一上电就收到一次数据就绪」,先怀疑这里。