只读分享解决方案文档原理方案设计📅 2026-10-05🔖 Rev 1.4⏳ 26天有效(至 2026-11-05)
🔧解决方案&应用市场分析 -> 原理方案设计 -> 嵌入式驱动&系统开发 -> Zephyr_驱动开发指南

Zephyr_驱动开发指南

生成时间 2026-10-06 15:32:40 有效期至 2026-11-05 15:32:40(26天有效(至 2026-11-05))
同系列 · 原理方案设计

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 系统开发指南》

4 件套
一个驱动的最小文件集
binding.yaml + Kconfig + CMakeLists + 驱动源码
device_is_ready
不可省略的就绪检查
初始化等级错了,返回 false 而非崩溃
MTD + flash_map
Flash 驱动的落点
NVS / ZMS / LittleFS 都依赖它
SDMMC
块设备走另一条路
SD NAND 与 eMMC 不经过 MTD
3 处超时
写/擦除/命令必须有上限
无上限的忙等是死机的主要来源

一句话结论: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 系统开发指南》承接;器件的电气参数与硬件设计仍以各器件的电路设计指南为准。

路线 1 · 移植一个新存储器件
手册在手,从零到能读写
第 3 章完整走一遍 SPI Flash 移植 → 第 8 章补齐 MTD 接口全字段 → 第 19 章 XTX SPI NOR 全流程实战。
重点:binding 属性设计(3.2)、写擦超时(8.4)、必测项(16.2)
路线 2 · 设备不工作 / 读不到 ID
编译过、上板没反应
第 2.4 节初始化等级 → 第 18.1 节设备不出现类问题 → 第 5.6 节典型故障 → 第 20.4 节排查决策树。
重点:compatible 不匹配、status 未改、电源门控时序
路线 3 · 数据错 / 偶发失败
能读但内容不对
第 8.2 节页缓存 → 第 9 章坏块管理 → 第 14 章 DMA 与并发 → 第 18.2 节读得到但数据错。
重点:WIP 轮询、DMA 对齐、跨页边界、坏块未重映射
路线 4 · 做 NAND / SD NAND / eMMC
大容量存储与文件系统
第 9 章 NAND 坏块 → 第 10 章 SDMMC 与 eMMC → 第 11 章文件系统 → 第 6 章 NVS/ZMS。
重点:坏块表布局、RCA 分配、HS200 时序、EXT_CSD 读取
路线 5 · 提交到上游
自研驱动要合入主线
第 17.3 节 Kconfig 与 prj.conf 的组织 → 第 17.5 节提交前自审清单 → 附录 C 上游提交检查清单。
重点:checkpatch、DT 文档生成、一致性检查接口

使用约定

  • 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」这类误判。

Zephyr 驱动的四个层次,以及本文负责的范围④ 设备驱动层 device driver(本文主体)Flash 控制器flash_operations块设备disk_accessNAND坏块 + ECCGPIO / 中断 / PWM③ 设备模型与驱动框架层 driver wrapperMTD 子系统flash_map 分区文件系统层LittleFS / FatFs存储抽象NVS / ZMS / settings通用框架GPIO / SPI / I2C / UART② 总线控制器层 bus controllerspi.cSPI 控制器i2c.cI2C 控制器uart.cUART 控制器sdmc.cSDMMC 控制器① SoC / 板级层 SoC & board(不在本文范围)SoC pinctrl引脚复用SoC 时钟外设时钟门控SoC 寄存器中断路由板级 DTS器件实例与分区本文边界:③④ 两层是驱动作者的日常战场;② 只在需要新控制器时才动;① 通常由 SoC 支持包提供。
图 1 · 驱动分层与本文覆盖范围

本文主要负责 ④ 设备驱动层与③ 驱动框架层的对接:即「一个具体器件/控制器如何在框架里正确地暴露能力」。② 总线控制器层只在需要新控制器时改;① SoC 层通常由芯片原厂支持包提供,不在本文范围。

1.4 官方驱动可复用性判断

绝大多数新型号存储器件不需要写新驱动。Zephyr 官方驱动对同品类器件的覆盖通常是「指令集 + JEDEC ID 表」结构,新增一个型号往往只是往表里加一行。

拿到一个新型号存储器件,按这条路走能省掉 80% 工作量① 同品类优先SPI NOR 看 jedec_spi_nor;SPI NAND 看 spi_nand;PPI NAND 看 mtd_nand_raw;块设备看 sdmc② 改 compatible改 DT 节点的 compatible 到你的厂商前缀,驱动自动实例化,无需改驱动源码③ 加 JEDEC ID 表在驱动的 id 数组里加一条:ID + 容量 + 页大小 + 时序参数,read_id 分支自动生效④ 只有不同时才写新驱动指令集差异大 / 有独有状态寄存器 / 坏块策略不同 / 电压或时序特殊,才新写驱动反过来要警惕:直接改 Zephyr 源码树里的官方驱动加自己的型号——west update 会全部冲掉。正确做法是自己写 overlay 与驱动(或 Zephyr module),把官方驱动当作参考读。
图 2 · 官方驱动可复用性判断路径
情形能否复用官方驱动需要做的改动
同品类、指令集兼容(如 NOR 容量档位扩展)可以,改 ID 表即可DTS compatible + 驱动的 id 表加一条
同品类但页大小/擦除粒度不同可以ID 表里带上页大小与擦除粒度字段
同品类但有独有状态寄存器或新指令部分加条件分支处理特有指令;或写 wrapper
电压/时序特殊(如需专用上电序列)部分在 pinctrl 与 init 序列里加特殊步骤
接口类型不同(NOR 改 NAND)不可以新写驱动,参考 spi_nand
坏块策略不同(如需 SLC 特殊处理)不可以新写驱动,参考 mtd_nand_raw
红线:不要直接改 Zephyr 源码树

在 zephyr/drivers/flash/ 里直接加自己的型号,west update 之后会被全部冲掉,且团队的代码无法进版本库。正确做法有三选一:① 改 DTS 与 binding(首选);② 写 Zephyr module(zephyr/module.yml + west.yml 的 projects 引入);③ Fork Zephyr 并在 manifest 里指向自己的 fork(改动大时才用)。

1.5 存储器件驱动的特殊性与本文重点

存储器件驱动与普通外设驱动有三个实质差别,也是本文重点围绕它们的原因:

同样是存储器件,走哪条路取决于接口类型,不是取决于容量路径 A · SPI NOR走 MTDjedec_spi_nor(可复用官方驱动)flash_map 分区NVS / ZMS / LittleFS容量:1~128 Mb典型:配置、固件镜像、日志、OTA 暂存路径 B · NAND走 MTD + 坏块管理spi_nand(SPI NAND)mtd_nand_raw(PPI)坏块表 + BBTMTD + 文件系统容量:128 Mb ~ 8 Gb典型:大容量数据、离线地图、固件包与资源包路径 C · 块设备走 disk + FatFssdmc(SD/eMMC 控制器)disk_access API扇区级读写FatFs / exFAT(PC 可识别)容量:2 Gb ~ 256 Gb典型:媒体文件、固件大包、需要 PC 互通的数据关键判断:接口类型(SPI x4/x8 vs SDMMC x1/x4)决定路径;容量只影响分区比例与器件选型。常见设计错误:把 SD NAND 当 MTD 设备用,或给 eMMC 配 flash_map 分区——两者模型不同,会在编译或运行时报错。
图 3 · XTX 存储器件在 Zephyr 中的三条落地路径

差别一:写与擦除是「耗时且不可中断」的操作。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 为例:

以 SPI NOR 驱动为例,四个文件各司其职,缺一个就不会被编译drivers/flash/驱动源码目录 jedec_spi_nor.cFlash 驱动实现(本节重点) Kconfig驱动 Kconfig,声明依赖与默认启用条件 CMakeLists.txt把源码加入 zephyr_library jedec_spi_nor.yamlbinding,描述 DT 节点契约dts/bindings/flash/binding 目录(也可放这里) jedec,spi-nor.yamlcompatible 与 reg 等属性定义boards/…/board.overlay板级:声明实际器件节点samples/drivers/flash/示例:jedec_spi_nor.c 的最小用法三处必须对齐的「契约字符串」compatible:binding 里定义 → 驱动 DT_DRV_COMPAT → DTS 节点 compatible,三处必须完全一致(含厂商前缀与标点)。漏改 binding → DTS 里写错也不报错;漏改驱动 → 驱动不编译;漏改 DTS → 驱动编译了但没有实例。
图 4 · 一个存储驱动的四个必备文件

如果是全新驱动而非修改现有驱动,建议直接用 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 等属性
binding 设计的两条经验

① 把「驱动需要的时序参数」做成属性(如 tpgm、terase),而不是写死在驱动里。这样换一颗时序不同的器件只需改 DTS,不需要改代码,也便于批量校准。② 继承 spi-controller.yaml 而不是自己重复定义 cs-gpios 等属性,这样驱动才能复用 Zephyr 的 SPI 框架(自动片选管理、传输助手 API)。

2.4 设备实例化与初始化等级

写完驱动后,如何让它在目标板上真正跑起来,取决于三件事的配合:DTS 里有没有节点且 status 为 okay、Kconfig 里有没有被打开、初始化等级与依赖关系是否正确。

同一个 compatible,在 DTS 写什么、在 Kconfig 写什么、最终得到什么① DTS 侧:决定「有没有实例」&xtx_nor: flash@0 { compatible = "xtx,spi-nor"; reg = <0x0 0x400000>; status = "okay"; };status 必须写 okay(缺省是 disabled)—— 这是「驱动编译了但设备不出现」最常见的原因。reg 的 size 必须填对:它决定 flash_map 的地址空间边界,填小了越界访问会落到别的分区。② Kconfig 侧:决定「编不编译」config FLASH_XTX_SPI_NOR default y # 量产建议显式 y,不要依赖隐式默认值 depends on SPI &amp;&amp; DT_HAS_XTX_SPI_NOR_ENABLED # 有节点就默认开③ 初始化等级:决定「能不能用」DEVICE_DT_INST_DEFINE(inst, init, NULL, data, &amp;api, POST_KERNEL, 90, cfg);等级必须晚于它依赖的总线控制器(spi0)。若 SPI 控制器在 PRE_KERNEL 而本驱动也在 PRE_KERNEL 同 prio,谁先执行取决于 inst 序号,不可控。→ 实用做法:存储类驱动一律 POST_KERNEL + prio 90。红色判据:如果 device_is_ready(dev) 返回 false,八成是等级/prio 问题,不是「器件坏了」。
图 5 · 驱动实例化与初始化等级的关系

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应用启动时依赖应用配置后才需要的设备
红线:驱动 init 里必须检查依赖设备的就绪状态

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 的惯例(部分架构上对齐影响性能或正确性)。

驱动向内核暴露什么,内核又给驱动提供什么驱动向上暴露:struct flash_operations(Flash 驱动的最小契约)内核向下提供:设备对象与框架服务flash_map分区地址与名字查询flash_erase扇区对齐检查与回调缓存与锁页缓存 + 互斥保护日志与 tracing诊断与时序观测三条接口纪律① 所有 op 都必须做入参校验:offset + len 不越界、len 对齐要求明确、失败返回负 errno 而非 0。② 错误必须向上传播:驱动不要吞错误(返回 0),否则上层会以为写入成功,数据静默丢失。③ status_sync 必须有超时:返回 -ETIMEDOUT 而不是死等;上层据此决定是否重试。
图 6 · 驱动 API 表与内核的对接面
规范项要求反例
入参校验每个 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 周期多少、四字节地址怎么进」各家仍有差异,这三项决定了后面所有实现。

从器件手册到能跑通 flash read/write 的九步主线1读手册摘出 JEDEC ID、容量、页大小、扇区大小、时序参数先确认官方驱动是否已支持,不支持再自研2 写 bindingcompatible 定为jedec,spi-nor 复用或自研 compatible属性用 enum 约束取值范围3 写 Kconfigdefault 依赖 SPI 与Flash 框架已启用,选 D1 级即可用 default y 避免一不小心多编译一份4 写驱动probe 里读 JEDEC ID识别厂商与容量只识别,不在这里做功能实现5 命令层cmd 发送 + wait轮询 WIP/WEL 带超时所有等待都必须有超时上限6 读路径读 ID → 读参数 →填 flash_parameterscapacity 与 size 不符要在 probe 报错7 写路径写使能 → 页编程按页边界拆包页尾写入会绕回,必须拆分8 擦除扇区擦除 + 超时擦除后校验 FF擦除前判断是否需要先擦9 联调shell 里 device list逐级验证再上文件系统先 flash probe 再 flash read顺序不可跳:命令层没写好就去调页编程,会把「命令没生效」误判成「页编程逻辑错」,浪费大量时间。每一步都留一条可观测的日志(LOG_INF / LOG_DBG),上板后能确认卡在哪一级。第 1 步的参数表没做完就不要动手写代码——后面每一处「读不到 ID」都会回到这张表上找原因。
图 7 · SPI Flash 驱动移植的九步主线

参数表不用做得复杂,但下面 12 项必须一项不漏地摘出来。缺任何一项,后面的调试都会退化成「猜」:

器件手册里必须摘出来的 12 项参数与典型值参数含义典型值填错的后果JEDEC ID厂商与器件型号的三字节身份EF 40 98probe 匹配不上,设备不出现容量总容量,按 2 的幂128 Mbit = 16 MBflash get_size 报错或地址越界页大小Page Program 的最大字节数256 B一次写超过页长会绕回覆盖扇区大小Sector Erase 的最小粒度4 KB擦除越界或烧掉相邻数据擦除指令集支持的擦除指令及各自粒度20/52/D8小扇区擦不掉,坏块无法回收WIP 轮询方式状态寄存器哪一位表示忙SR1 的 WIP / WEL读不到状态,超时或死等tPP / tSE页编程与扇区擦除典型耗时0.7 ms / 45 ms超时值取太小,正常写入被误判失败四字节地址容量是否大于128 Mbit是,需 4B 模式访问上半区地址错位QE 位位置四字节地址模式控制位所在寄存器SR2 的 bit 1切换 4B 后读出全 0DTR 时序支持的最大时钟与 dummy 周期104 MHz / 8 档高速读返回错误数据封装与引脚封装类型、WP# 与HOLD# 是否独立SOP8 / WSON8封装对不上,焊不上去工作电压范围VCC 允许区间与 IO 电平2.7~3.6 VVCC 超规格导致数据错或寿命骤降
图 8 · SPI NOR 器件手册必须摘出的参数清单
参数典型值拿不到时的退路
JEDEC IDEF 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 / tBE0.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 与容量

未被支持时,有两条路:

方案做法适用场景代价
复用 compatiblecompatible 仍写 jedec,spi-nor,只在 Kconfig 里加 SPINOR_HAS_<厂商型号> 宏同厂商同系列的新容量档位 (GD25Q128 与 GD25Q64 指令一致)最小。上游也愿意收这种补丁
新增 compatiblebinding 里加 compatible = "vendor,part",驱动里加独立的 ID 匹配表指令集有实质差异的 新型号或新厂商较大,但隔离清晰
板级自定义完全自研 compatible 并只在自有板使用一次性项目、不打算开源小,但无法享受上游维护

复用 compatible 时最容易犯的错是「只加了 ID 却没加参数差异」。比如某厂商的 512 Mbit 型号需要进四字节模式才能访问上半区,而同系列 64 Mbit 不需要——如果只在 ID 表里加一行而没加「是否四字节」的分支,上半区就是一片 0xFF。

命令层的四个原语:读 ID、写使能、状态同步、批量传输cmd只发命令字,不等忙、不读数据read发命令 + 地址 +读固定长度数据write发命令 + 地址 +发数据(DMA)status_sync轮询 WIP 直到清零必须有超时以 Sector Erase (0x20) 为例:三个原语怎么串起来spi_nor_cmd_write_enable()发 0x06,并校验 SR1.WEL 置位spi_nor_cmd_erase()发 0x20 + 3/4 字节地址spi_nor_wait_ready()轮询 SR1.WIP,上限 60 sspi_nor_read_status()读 SR1 确认擦除成功缺一不可的校验点:① WEL 发完必须回读确认。有些器件在极短时间窗内会自动清 WEL,紧接着发擦除会静默失败。② WIP 轮询必须有超时上限。超时后要返回 -ETIMEDOUT,不能死等——Flash 挂了会把整个系统卡在 boot 阶段。③ 擦除后建议抽读首字节确认是 0xFF。器件坏或地址越界时,WIP 也会正常结束但内容没变。三类「命令发了但没生效」的场景
图 9 · SPI Flash 命令层的四个操作原语与三类静默失效场景

写命令层时把这四个原语分成两个文件: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 整段时间

有些教程用 k_busy_wait(t_be * 1000) 做等待,这在 Zephyr 上是错误的:它不让出 CPU,擦除期间系统假死、看门狗可能复位、蓝牙等周期任务全部超时。正确做法是循环里 k_sleep(K_MSEC(1)) 轮询状态位——既让出 CPU,又比固定等 tBE 更快返回。

3.5 读路径实现

读路径是驱动里最容易写对也最容易写「看起来对但有隐患」的部分。核心风险有三个:传输方式选择、Cache 一致性、多线模式的 dummy 周期。

读路径从 flash_read() 到 DMA 完成,共经过六段① 参数校验offset + len 不越界len 可跨页跨扇区② 选传输方式len 大于阈值走 DMA小包走 CPU 逐字读③ 查缓存页缓存命中直接memcpy 返回④ 读数据线0x03 或 0x0B/0x0C三字节或四字节地址⑤ 边界补齐页尾按页长拆分末字节单独处理⑥ 回填校验cache invalidate再交给上层读路径最容易出的三类错误读路径的正确性判据:flash read 0x0 64 后,前 16 字节应与烧录工具写入的校验值逐字节一致。若只「读出来了」但内容是 0xFF,说明命令或地址错;若内容长度对但中间一段错,说明是 dummy 周期或 Cache 问题。阈值怎么定DMA 启动开销大约 5~20 μs(半双工模式更高),CPU 读一个字约 0.3~1 μs。经验阈值:len 小于 32 B 用 CPU 读,长于 64 B 用 DMA,中间的差异不大但要实测。阈值应写成 Kconfig 或 devicetree 属性(如 ddr-transfer-size),不要硬编码在驱动里。半双工 SPI(如多数 MCU 的硬件 SPI)DMA 不一定有优势,必须实测后再定。
图 10 · SPI Flash 读路径的六段结构与传输阈值

读 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 写路径与页编程

写路径里唯一真正需要动脑的是页边界。其余部分——写使能、命令发数据、等忙——和擦除完全一样。

一次跨越 300 字节、页大小 256 的写入必须拆成两段错误做法:整包发出去写入 offset=0x100、len=300、页大小 256:offset 0x100写入 156 B写入 144 B绕回页首下一页 144 B结果:页 0 的前 144 字节被覆盖成页 1 的内容,且驱动不会报错。这类错误在「写完立刻读回」时完全查不出来,只有读回更早的位置才发现数据被冲掉。正确做法:按 min(剩余长度, 页大小 - 页内偏移) 循环拆分页 0可写 156 B写 156 B落在页 0 内页 1可写 256 B写 144 B落在页 1 内两段各自独立执行「写使能 → 页编程 → 等忙完成」,任何一段失败立即返回错误并报告实际完成长度。擦除粒度远大于页大小,写之前必须先判断是否需要擦除扇区 4 KB = 16 页。连续写同一扇区内的多个页,只需擦一次;跨扇区则必须逐扇区擦。若上层的写请求是非对齐且不连续,驱动的策略应是「读-改-写」,这会带来显著的写放大。驱动只负责按页拆分,擦除决策由上层 flash_map 完成,不要在驱动里重复实现。
图 11 · 写路径的页边界拆分:一次 300 字节写入如何切成两段

拆分逻辑写成一个独立的辅助函数,让主流程保持干净:

/* 计算从 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 倍以上。

擦写命令的等待状态机:轮询、超时与失败后的现场处理发命令等 WIP=1轮询 WIPWIP=0超时 → 记录现场标记块不可信并上报等待循环的三条工程要求超时之后必须做的四件事记录现场把状态寄存器的值、目标地址、命令字、耗时全部打日志尝试恢复读一次状态位判断器件实际状态;必要时发 Reset Enable (0x66) + Reset (0x99)标记不可信该块写超时的概率是硬件级问题,必须在元数据里标记,后续别再往里写上报上层返回 -ETIMEDOUT,让文件系统或应用层决定是否换块、重试或告警
图 12 · 擦写等待状态机与超时后的四项处理

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 与源文件比对,验证页拆分与跨扇区处理
第 7 步不可省略

小容量(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 声明与驱动侧的三步取用第一步 · DTS 里声明flash0: flash@0 { compatible = "jedec,spi-nor"; reg = <0x0>; spi-max-frequency = <20000000>; cs-gpios = <&gpio0 5 GPIO_ACTIVE_LOW>;};第二步 · 驱动里生成 devicetree 宏#define FLASH0_NODE DT_NODELABEL(flash0)#define FLASH0_CS GPIO_DT_SPECIAL_GET(FLASH0_NODE, cs_gpios, gpios)#define FLASH0_BUS DT_BUS(FLASH0_NODE)注意用 SPECIAL_GET 而不是普通 GET:cs-gpios 带了 GPIO_ACTIVE_LOW 标志位,普通 GET 会丢这个极性。第三步 · 取句柄与释放if (!gpio_is_ready_dt(&FLASH0_CS)) { LOG_ERR("cs gpio not ready"); return -ENODEV; }ret = gpio_pin_configure_dt(&FLASH0_CS, GPIO_OUTPUT_INACTIVE);卸载路径必须 gpio_pin_configure_dt(&FLASH0_CS, GPIO_OUTPUT_INACTIVE),把片选拉高。
图 13 · GPIO 设备树声明与驱动侧三步取用

三处最容易出错的地方:

错误写法后果正确写法
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 的中断控制器寄存器。声明和注册都很短,但「寄存器在哪个中断控制器上」这件事必须算对。

从 DTS 声明中断到注册 ISR,再到 ISR 的六条硬约束DTS 侧:中断要写全三要素sensor: sensor@40 { compatible = "vendor,als-4010"; reg = <0x40>; interrupt-parent = <&gpio0>; interrupts = <5 GPIO_ACTIVE_LOW IRQ_TYPE_EDGE_TO_LOW>;};驱动侧:用中断号宏 + IRQ_CONNECT,四行完成注册#define SENSOR_IRQ DT_IRQ(DT_NODELABEL(sensor))static void sensor_isr(const struct device *port, void *arg) { ... }if (IS_ENABLED(CONFIG_SENSOR_ALS)) { gpio_pin_interrupt_configure_dt(&sensor_int, GPIO_INT_EDGE_TO_LOW); irq_connect(sensor_int.port, sensor_pin, sensor_isr, dev, 0); irq_enable(sensor_int.pin);}ISR 的六条硬约束触发类型的四个坑:电平触发在 ISR 里不清标志会反复进;上升沿 + 器件内部上拉弱时会漏触发;机械抖动需在硬件加 RC 或在驱动做软件去抖窗口;ISR 里调 gpio_pin_set 在某些 SoC 上会与父 IRQ 冲突。写 ISR 时的自查:这里如果出现 k_sleep,编译能过但运行必挂。
图 14 · 中断的 DT 声明、ISR 注册与六条硬约束

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 常见错误:去抖、长中断与电平触发

五类最常见的 GPIO / 中断现场错误与定位方法片选一直是低片选未配置为输出,or 初始化时序早于 GPIO控制器就绪定位方法示波器看 CS# 引脚;gpio_pin_configure_dt 的返回值必须检查片选极性反了DT 里没写 GPIO_ACTIVE_LOW,驱动又用 SPECIAL_GET拿到全 0 标志位定位方法用 DT_FOREACH_STATUS_OKAY_NODE打印 gpios 标志位核对中断不进中断号宏取错节点;或 pin 在 A/B 控制器但用了 C/D 的号定位方法printk 打出 DT_IRQ 的实际数值,与 SoC 手册对照中断进但数据旧ISR 里没读 FIFO 就返回;或读了但没清中断标志定位方法在 ISR 首行加计数打印,确认每次中断都走到大量重复中断电平触发且 ISR 内没清中断源定位方法改边沿触发;或在 ISR 里先清源再处理
图 15 · GPIO 与中断的五类典型错误与定位方法

五类错误里,「重复中断」最难查:现象是系统负载莫名升高、消息队列积压、日志刷屏,但 ISR 里的业务逻辑又是对的。定位方法是先在 ISR 入口加一个静态计数器,如果计数器增长速度远超物理事件频率,就是中断没被正确清掉。

去抖有三种做法,按优先级:硬件 RC 滤波(最可靠,成本是一个电阻一个电容);器件端去抖(如果器件有专门的 debounce 寄存器或滑动平均);软件去抖窗口(ISR 里记录时间戳,主循环里丢弃窗口内的重复事件)。前两种优于第三种。

中断脚上拉电阻的取值

中断脚一定要有外部上拉(常见 10 kΩ 到 47 kΩ)。很多 SoC 的中断脚是「无上拉的开漏」,悬空状态下每次读都可能是低电平,导致上电瞬间误触发一次中断。如果现象是「一上电就收到一次数据就绪」,先怀疑这里。

扫码打开本页
微信扫码 · 展会可扫

再分享给同事

同事可直接打开这份资料《Zephyr_驱动开发指南》,在线阅读;完整章节请下载芯参谋。