芯参谋(83):AliOS Things_驱动开发指南

报告总结

写 AliOS Things 驱动时,真正卡住人的往往不是「SPI 怎么发一个字节」,而是这些结构性问题: 为什么我的驱动 init 没被调用?为什么总线驱动还没起来外设就开始初始化? 我该用 LEVEL1 还是 LEVEL2?这个器件该不该挂 VFS? 内核态驱动和用户态驱动到底差在哪、怎么选?没有设备树,引脚和中断号写在哪? 为什么 open("/dev/xxx") 返回 -1?这些问题散落在源码、示例和零星文档里, 本指南把它们收拢成一套可照做的流程 ,配套可直接编译的骨架代码 与可逐条勾选的核查清单。

路径 1 · 新接一颗外设

只想尽快让设备跑起来

先读第 3 章搞清分级加载与 CSI,再按第 5 章的八步流程走一遍; SPI NAND / SPI NOR 直接抄第 5.3 节的存储驱动骨架,I2C 传感器抄传感器骨架。

关键输出:设备注册成功 + CLI 可见 + open/read/write 通

路径 2 · 驱动不稳定

能跑但偶发错数据、死机、唤醒失败

直奔第 6 章与第 8 章:加载等级依赖、中断上下文约束、并发加锁、 suspend/resume 时钟门控、VFS 设备名冲突。九成偶发问题在这一层的边界条件上。

关键输出:中断 / 线程 / 低功耗三者的边界被明确划线

路径 3 · 交付评审

把驱动交给别人或合入 SDK

按第 9 章做测试矩阵、第 12 章做目录命名与交付清单, 最后用附录 A/B 的配置模板与代码模板对齐,并用第 13 章 FAQ 覆盖常见质疑。

关键输出:可复现、可维护、可裁剪、可被他人接手的驱动组件

面向 AliOS Things V3.x 弹性微内核主线(同时标注 V2.x 差异)。凡涉及具体宏名、结构体字段与 API 签名, 均以你所用版本的 components/csi、components/drivers、core/osal 头文件为准。

1 · 文档概述

本章先回答「这份文档写给谁、基于哪个版本、涉及哪些硬件」,然后用一张图把 AliOS Things 的驱动模型与 Linux 的分层差异讲清楚。读完这一章,你应该能判断自己的驱动 将来会落在哪一层、要跟哪些层打交道。

1.1 文档目的、适用范围与读者

本指南用于规范 AliOS Things 平台下外设驱动的开发、调试、测试与交付 , 覆盖从「拿到一颗新器件」到「驱动作为组件合入 SDK」的完整流程。它不讲述具体芯片的寄存器细节 (那是芯片手册与 CSI 实现的事),而是聚焦驱动与内核之间的契约: 设备怎么注册、初始化顺序怎么保证、中断与并发怎么约束、怎么变成可裁剪组件、怎么验证。

角色 主要关心什么 建议优先阅读
BSP / 板级工程师 引脚复用、时钟、CSI 底层是否完备、板级资源表怎么填、无 DTS 时代怎么描述硬件 第 3.3、3.6 节,第 4 章全章,附录 A
驱动工程师 驱动类型判断、ops 实现、加载等级、VFS 挂载、中断与 DMA、内存策略 第 3.2、3.4、3.5 节,第 5 章全章,第 6 章全章,附录 B
应用 / 系统工程师 设备怎么被找到、POSIX 与 AOS 接口怎么选、并发与功耗行为、CLI 怎么验证 第 3.5 节,第 5.6 节,第 7 章,第 10 章
测试 / 交付负责人 测试矩阵、ROM/RAM 占用、回归范围、交付清单与 changelog 第 9 章,第 12 章,附录 C

1.2 版本基线:V2.x 与 V3.x 不是一回事

AliOS Things 的驱动模型在 V3.0 发生了一次结构性变化:内核从「宏内核 RTOS」演进为「弹性微内核」 , 驱动获得内核态 / 用户态两种运行形态,外设接口统一收敛到 CSI ,文件系统统一到 VFS 2.0 , 组件管理统一到 package.yaml + OCC(Online Component Center)。 这意味着 V2.x 时代「BSP 里随便写一写」的驱动,在 V3.x 上要么跑不起来,要么跑起来但失去可移植性与裁剪能力。图 2 给出演进脉络。

写驱动前先确认基线:V2.x 与 V3.x 的驱动模型不是一回事V1.x / V2.xRhino 实时内核V3.0弹性微内核V3.1+用户态驱动 / 多进程HaaS硬件即服务 + 云端内核形态宏内核 RTOS,驱动与应用同址弹性内核,内核态 / 用户态可切换多进程地址隔离,驱动可跑用户态HaaS 硬件 + 云端一体开发驱动接口HAL 风格,各家 BSP 自行定义CSI 统一外设接口标准CSI + VFS 2.0 双通道HaaS 组件化驱动硬件描述板级 .c 静态配置板级静态配置(无 DTS)板级静态配置 + 组件声明HaaS 板级配置模板文件系统VFS 1.0(早期)VFS 2.0,/dev 节点统一VFS 2.0 + 多文件系统挂载云端 OTA + 文件系统组件管理Makefile 手工裁剪package.yaml + OCC 组件仓库package.yaml + 依赖自动解析HaaS 组件市场选型建议:新项目一律从 V3.x 起步;老项目迁移先把驱动从「直操作寄存器」改成「调 CSI」,再谈内核态 / 用户态切换

图 1 · 版本基线演进:V2.x 与 V3.x 的驱动模型差异(新项目建议直接 V3.x)

动手前先做一次基线确认

请确认三件事:① SDK 版本号 (aos --version 或 package.yaml 顶部 version);② 目标 board 目录 下是否已有 CSI 实现(components/csi/<chip>/);③ 是否开启用户态驱动(影响你能否在驱动里用某些内核 API)。这三点决定了本指南里哪些写法适用、哪些要打折。

1.3 术语与缩写

下面的术语在全文中反复出现,先对齐一次。注意 AliOS Things 的术语与 Linux 有重叠但含义不同 (例如「设备」在 AOS 里是框架注册对象,不等同于 Linux 的 struct device)。

缩写 / 术语 全称 在本文中指什么
CSI Chip System Interface(芯片系统接口) 芯片厂商实现的底层硬件抽象层,向上提供 csi_spi/csi_gpio/csi_dma/csi_irq 等统一接口;驱动只调它,不碰寄存器
AOS API AliOS Things OS API 操作系统接口层:aos_task_new / aos_sem_wait / aos_malloc 等,也是驱动可用的内核能力入口
VFS Virtual File System 虚拟文件系统。驱动可注册为 /dev/xxx 节点,应用用 POSIX 的 open/read/write/ioctl 访问
package.yaml 组件描述文件 声明组件名、版本、依赖(depends)、编译配置;驱动就是一个组件,靠它实现按需裁剪
aos.mk 组件 Makefile 声明源文件、头文件路径、宏定义、依赖组件;与 package.yaml 共同决定一个驱动是否进入编译
组件 Component 可独立编译、可声明依赖的最小复用单元;驱动、协议栈、文件系统都是组件
驱动分级加载 Driver Init Level 用 XXX_DRIVER_ENTRY 宏把驱动的初始化函数放进特定启动阶段,解决「总线先于外设」这类依赖
HaaS Hardware as a Service 阿里云的 IoT 硬件即服务体系:HaaS 开发板 + HaaS Studio/IDE + 云端一体的开发调试体验
BSP Board Support Package 板级支持包:引脚复用、时钟、外设实例定义、板级初始化入口,AliOS 里对应 board/ 目录
OCC Online Component Center 在线组件仓库,aos-cube 拉取依赖组件的来源
uData / Sensor 传感器框架 把各类传感器抽象成统一的上层框架(V2.x 称 uData,V3.x 收敛为 sensor 组件),传感器驱动对接它而不是各自为政
FOTA / OTA Firmware Over-The-Air 空中升级。驱动需保证升级过程中器件状态可恢复,存储驱动要支持 A/B 分区与回滚

1.4 硬件平台范围

AliOS Things 面向 IoT MCU,典型主频 80 MHz ~ 400 MHz、RAM 几十 KB 到几 MB、 Flash 几百 KB 到几 MB。这与「跑 Linux 的 MPU」在驱动设计上是两种世界观:前者关心 ROM/RAM 占用与启动顺序, 后者关心内存管理与进程隔离。下表列出常见平台与本指南的适用方式。

平台 / 芯片 架构 典型场景 驱动开发要点
STM32(F1/F4/L4 等) Cortex-M 工业传感、网关子节点 CSI 实现多为厂商 HAL 封装;注意 L 系列的低功耗时钟门控与 Stop 模式唤醒
ESP32 / ESP8266 Xtensa WiFi 插座、灯控 WiFi/BLE 协议栈与驱动耦合紧,注意 RF 占用 CPU 时的时序抖动与中断延迟
BK7231 / BK7252 等 Cortex-M / RISC-V 智能家电、低成本 WiFi 模组 Flash 与 OTA 分区表要提前规划;注意 flash 擦写期间关中断带来的时序影响
RISC-V(C906 / E907 等) RISC-V 国产化替代、AIoT 中断控制器(PLIC/CLIC)与 Cortex-M NVIC 差异大,CSI 中断适配要格外小心优先级语义
HaaS 系列开发板 Cortex-M / RISC-V 快速验证、云端联调 板级配置已就绪,重点在于把驱动连上云端调试通道(在线日志、远程下发)

关于「没有 MMU」这件事

大部分 IoT MCU 没有 MMU,因此 V3.x 的「用户态驱动」在纯 MCU 上通常依赖 MPU 分区 或软件隔离(受限 API 集) 实现,而不是完整虚拟地址空间。在有 MMU 的芯片上才是真正的进程隔离。选择内核态还是用户态之前,先确认你的芯片支持哪一种隔离手段。

1.5 驱动模型总览

图 1 是本指南最重要的一张图,请记住它:自上而下是调用关系,自下而上是支撑关系。 一句话概括------SOC 厂商实现 CSI 底层 → 驱动基于 CSI 实现外设驱动 → 注册到 AOS 设备框架 → 可选挂载 VFS,应用用标准 POSIX 接口访问设备。

AliOS Things 驱动模型总览:SOC 厂商实现 CSI,驱动基于 CSI 实现,注册进 AOS 设备框架,可选挂 VFS① 应用层 Application / 阿里云 SDK业务任务、MQTT/HTTP、HaaS 小程序;只认设备名,不认芯片型号业务逻辑User Task连接与协议MQTT / HTTP文件系统LittleFS传感器应用uData / Sensor两条通路:AOS 原生接口 或 POSIX 接口aos_dev_open / aos_dev_readopen / read / write② VFS 虚拟文件系统(可选层)驱动注册成 /dev/xxx 节点,应用用 POSIX 访问;不挂 VFS 也能直接用 AOS 设备接口设备节点/dev/nand0文件节点/littlefs/a.txtposix 适配open/read/ioctlfd 表进程级管理③ AOS 设备驱动框架(内核)设备注册 / 查找 / 分级加载 / 内核态与用户态驱动统一托管,故障可隔离设备注册表aos_dev_xxx分级加载LEVELx_ENTRYops 分发open/read/ioctl内核态 / 用户态RPC 通道④ 外设驱动层 Driver(本指南的主战场)spi / i2c / uart / gpio / flash / sensor 驱动:只调 CSI,不碰寄存器总线驱动spi / i2c存储驱动spi nand / nor传感器驱动temp / acc通信驱动wifi / ble⑤ 硬件底层:CSI(csi_spi / csi_gpio / csi_dma / csi_irq)+ SOC 寄存器 ------ 由芯片厂商实现,驱动不得绕过驱动能看到的只有 CSI 提供的统一接口;换芯片时这一层整体替换,上层零改动

图 2 · AliOS Things 驱动五层模型:应用、VFS、AOS 设备框架、外设驱动、CSI 与硬件

与 Linux 相比,这里有三处结构性差异,几乎决定了后面所有写法:

  • 没有设备树。 硬件资源(引脚号、外设基址、中断号、时钟源)不通过 DTS 描述后由内核解析, 而是在 board/ 下以静态 C 配置的方式写死,编译时确定。好处是零解析开销、启动快; 代价是换板子要改代码重新编译。
  • 驱动即组件。 驱动不是内核的一部分,也不是可加载的 .ko, 而是一个用 package.yaml 声明依赖的组件。没被依赖就不会被编译进固件, 这是 IoT 场景下控制 ROM/RAM 的核心手段。
  • 初始化顺序靠分级宏显式声明。 Linux 有 module_init 的等级与设备模型的 probe 匹配机制;AliOS 用 9 级 XXX_DRIVER_ENTRY,且没有自动的依赖关系推断------ 等级选错,你的驱动会在总线还没准备好时被初始化,然后失败。

一个判断驱动写得对不对的朴素标准

把你的驱动源文件里的所有 #include 扫一遍:如果出现芯片寄存器头(如 xxx_reg.h)或直接出现寄存器地址宏,说明它越界到了 CSI 层,换芯片一定改;如果只出现 csi_xxx.h、aos/xxx.h 与器件自己的 drv_xxx.h,那它就是合格的、可移植的外设驱动。能被多颗芯片复用的部分越往上放,未来的迁移成本越低。

2 · 开发环境搭建

这一章把「从一台空机器到能编译烧录并看到串口日志」的过程走一遍。 重点不是命令本身,而是理解工程结构与两份配置文件------后面所有驱动工作都在它们划定的框架内进行。

2.1 宿主机环境与依赖

AliOS Things 的构建链依赖 Python 与常规 GNU 工具,推荐 Ubuntu LTS(也可在 macOS / WSL 上工作, Windows 原生支持较弱)。下面是最小可用集:

复制代码
# 1) 系统依赖(Ubuntu / Debian)
sudo apt-get update
sudo apt-get install -y build-essential git python3 python3-pip \
     cmake ninja-build gcc-multilib libssl-dev libncurses5-dev \
     minicom picocom screen

# 2) Python 构建工具 aos-cube(AliOS Things 的命令行构建与烧录工具)
pip3 install aos-cube
aos --version                 # 验证安装

# 3) 交叉工具链(按目标架构选装)
sudo apt-get install -y gcc-arm-none-eabi        # Cortex-M
# RISC-V:      使用芯片厂商提供的 riscv64-unknown-elf / riscv32 工具链
# Xtensa(ESP): 使用乐鑫官方 xtensa-esp32-elf 工具链

# 4) 串口权限
sudo usermod -aG dialout $USER && newgrp dialout # 重新登录后生效

版本踩坑提示

① Python 3 与 pip 的对应关系 :确认 pip3 装的包能被构建脚本找到,多 Python 环境下常见「命令装上了但构建时提示找不到模块」;② 工具链路径不要带空格与中文 ,Makefile 对这类路径容忍度很低;③ WSL 下烧录需额外处理 USB 设备透传,建议烧录环节回到 Windows 原生工具。

2.2 SDK 源码获取与分支管理

SDK 即整个操作系统仓库。建议的分支策略是:以官方 release 分支为基线, 产品侧只做「驱动组件 + board 目录 + product 目录」的增量,避免直接改内核与公共组件。

复制代码
git clone https://github.com/alibaba/AliOS-Things.git
cd AliOS-Things
git checkout <release_tag>        # 明确切到一个发布版本,不要长期停在 master

# 分支策略建议
#   master / release   ------ 只读基线,只用 git fetch 跟进
#   dev/<product>      ------ 产品开发分支,只放 board / product / 自定义组件
#   feature/drv_xxx    ------ 单个驱动的开发分支,完成后合入 dev/<product>

# 提交前自检
git status                        # 确认没有误改公共组件
git diff --stat                   # 改动范围应集中在 components/drivers/<你的驱动> 与 board/

为什么强调「不要改公共组件」

IoT 项目周期长,SDK 迟早要升级。一旦你在 components/ 或 core/ 里动了别人的代码,升级时的合并冲突会呈指数增长。把改动收敛在「新增目录 + 声明依赖」内,升级 SDK 的成本就是重新编一次。

2.3 工具链与 aos-cube

aos-cube 是构建入口。它负责解析 package.yaml 的依赖树、 从 OCC 拉取缺失组件、生成编译配置并调用交叉工具链。最常用的几条命令如下。

复制代码
aos make <app>@<board>              # 编译某个产品方案(app 为 product 下的方案名)
aos make <app>@<board> -j8          # 并行编译
aos make <app>@<board> clean        # 清理构建产物
aos upload <app>@<board>            # 烧录(依赖板级支持,部分板子用厂商工具)

# 常见报错速记
#   "no such board"        → board 目录名拼写错误或未纳入 SDK
#   "component not found"  → package.yaml 的依赖没装,检查 OCC 网络或手动 aos install
#   "toolchain not found"  → 工具链未安装或 PATH 未配置

2.4 工程结构说明(重点)

图 3 给出 SDK 的四块地。驱动工程师 90% 的时间花在 components/ 与 board/ 上, 而决定固件最终形态的是 product/。

SDK 工程结构:驱动落在哪、板级配置落在哪、组件声明落在哪board/板级支持包:引脚复用、时钟、外设实例、板级初始化入口board/<board_name>/ # 板级目录├─ aos.mk # 板级编译配置├─ package.yaml # 板级组件依赖├─ board.c # 板级初始化、堆初始化└─ drivers/ # 板级专属驱动(可选)components/组件区:驱动、CSI、VFS、CLI 都在这里,每个目录一个组件components/├─ drivers/ # ④ 外设驱动:spi / i2c / uart / gpio / sensor / flash├─ csi/ # ⑤ CSI 硬件抽象层(按芯片分目录)├─ vfs/ # ② VFS 虚拟文件系统与 /dev 节点└─ cli/ # 串口命令行(调试第一工具)core / osal / kernel/内核与 OS 抽象层:任务、信号量、队列、中断、内存core/├─ osal/ # AOS API:aos_task / aos_sem / aos_malloc├─ rhino/ # Rhino 实时内核(V3 为弹性内核)└─ device/ # ③ 设备框架与分级加载宏定义product / app / solution/产品工程:一个具体产品的入口与配置,决定最终编进哪些组件product/└─ <solution>/ # 产品方案工程├─ package.yaml # 产品级依赖(决定固件里有什么)├─ aos.mk # 产品级编译配置└─ main.c # 应用入口口诀:加驱动动 components/drivers 与 package.yaml;换板子动 board;改裁剪动 product

图 3 · SDK 工程结构四块地:board(板级)、components(组件含 drivers/csi/vfs/cli)、core(内核)、product(产品)

复制代码
AliOS-Things/
├─ board/                          # ① 板级支持包
│  └─ <board_name>/
│     ├─ aos.mk                    #     板级编译配置(源文件、宏)
│     ├─ package.yaml              #     板级组件依赖
│     ├─ board.c                   #     板级初始化入口、堆初始化
│     └─ drivers/                  #     板级专属驱动(可选)
├─ components/                     # ② 组件区(每个子目录一个组件)
│  ├─ drivers/                     #     外设驱动:spi / i2c / uart / gpio / sensor / flash
│  ├─ csi/                         #     CSI 硬件抽象层(按芯片分目录)
│  ├─ vfs/                         #     VFS 虚拟文件系统与 /dev 节点
│  ├─ cli/                         #     串口命令行
│  └─ <其它组件>/                  #     网络、文件系统、安全、OTA 等
├─ core/                           # ③ 内核与 OS 抽象层
│  ├─ osal/                        #     AOS API:aos_task / aos_sem / aos_malloc
│  ├─ rhino/                       #     Rhino 实时内核 / 弹性内核
│  └─ device/                      #     设备框架与分级加载宏
├─ product/                        # ④ 产品方案工程
│  └─ <solution>/
│     ├─ package.yaml              #     产品级依赖(决定固件里有什么)
│     ├─ aos.mk
│     └─ main.c                    #     应用入口
└─ build/                          # 构建输出(编译产物、elf、bin)
目录 谁维护 放什么 驱动相关的动作
board/<board>/ BSP 工程师 引脚复用、时钟、外设实例、板级初始化 新板子在这里建目录;已有板子在这里加外设实例与引脚配置
components/drivers/ 驱动工程师 芯片无关的外设驱动(本指南的主战场) 新建 drv_xxx.c/.h + package.yaml + aos.mk
components/csi/ 芯片厂商 / BSP 芯片相关的底层硬件接口实现 只有在 SOC 无原生 CSI 支持时才介入;驱动层不应修改这里
components/vfs/ 系统组 VFS 与 /dev 节点管理 驱动只需调用注册接口,不实现 VFS 本身
product/<solution>/ 产品负责人 产品的依赖与入口 在 depends 里加上你的驱动组件,它才会被编进固件

2.5 编译配置:package.yaml 与 aos.mk

这两份文件决定「驱动会不会被编译」与「编译哪些源文件」。二者缺一不可: **package.yaml 决定组件是否被纳入构建,aos.mk 决定组件里的文件怎么编。**图 4 是完整构建流程。

构建流程:一条 aos make 命令背后发生了什么aos make <app>@<board>指定产品与板级解析 package.yaml收集组件与 dependsOCC 拉取缺失组件本地无则下载生成编译配置宏开关、头文件路径编译链接交叉工具链出 elf生成镜像bin / hex / ota 包三个决定成败的配置文件package.yaml 决定「有没有这个组件」;aos.mk 决定「这个组件编哪些源文件」;board/aos.mk 与板级 .c 决定「外设实例的引脚、时钟、中断号是什么」常见构建失败与原因• undefined reference ------ 源文件没进 aos.mk 的 SOURCES• 头文件找不到 ------ GLOBAL_INCLUDES 没加路径• 组件没编进去 ------ package.yaml 的 depends 没声明三条实用命令• aos make <app>@<board> -j8 编译• aos make clean 清构建产物• aos upload / aos burn 烧录(视板级支持)

图 4 · 构建流程与三类配置文件:aos make 从 package.yaml 解析依赖到生成可烧录镜像

复制代码
# ============================================================
# components/drivers/drv_spinand/package.yaml
# ============================================================
name: drv_spinand                 # 组件名:被 depends 引用时的标识
版本号 master
description: SPI NAND 驱动(基于 CSI,可挂 VFS)

depends:
  - drivers: master               # 设备驱动框架(提供注册与分级加载宏)
  - csi: master                   # CSI 硬件抽象层(提供 csi_spi_* 接口)
  - vfs: master                   # 可选:需要 /dev 节点时才依赖
  - cli: master                   # 可选:需要调试命令时才依赖

def_config:                       # 组件级默认宏(可被产品配置覆盖)
  CONFIG_DRV_SPINAND: 1
  CONFIG_DRV_SPINAND_DEBUG: 0

# ============================================================
# components/drivers/drv_spinand/aos.mk
# ============================================================
NAME := drv_spinand

$(NAME)_TYPE := kernel            # kernel:内核态组件
$(NAME)_SOURCES := drv_spinand.c  # 源文件(相对本目录)
$(NAME)_INCLUDES := .             # 对外头文件路径
GLOBAL_INCLUDES += .              # 暴露给其它组件的头文件路径
GLOBAL_DEFINES += CONFIG_DRV_SPINAND=1
$(NAME)_COMPONENTS += csi drivers # 编译期组件依赖(与 package.yaml 保持一致)

最容易踩的三个不一致

① package.yaml 写了依赖、aos.mk 没写 _COMPONENTS → 头文件能找到但链接时报 undefined reference;② aos.mk 的 SOURCES 漏文件 → 函数没进固件,运行时注册不到设备;③ product 的 package.yaml 没有 depends 你的驱动 → 驱动整份不编译,现象是「代码写完了但设备永远不出现」,且没有任何报错。

2.6 烧录、串口调试与 HaaS IDE

烧录方式取决于板级:常见有串口 ISP、J-Link/OpenOCD、厂商专用下载工具三类。 串口是 IoT 开发最可靠的调试通道,建议一开始就把它配好并确认日志能出。

手段 用途 配置要点
串口 UART 日志输出 + CLI 交互 + 基础烧录 常见波特率 115200 8N1;确认板级把 console 设备挂到正确 UART 与引脚;日志口不要与业务口冲突
J-Link / ST-Link + OpenOCD 断点调试、coredump 抓取、烧录 接好 SWD/JTAG;配好 OpenOCD 的 target 配置文件;gdb 连 localhost:3333
GDB 断点、单步、查看调用栈与变量 用 elf 文件(不是 bin)调试;优化等级高时变量可能被优化掉
HaaS Studio / IDE 工程创建、编译烧录一体化、云端联调 基于 VSCode 的插件体系;与阿里云 IoT 平台打通,可在线看日志、远程下发指令
阿里云 IoT 平台云端调试 设备在线日志、远程指令下发 设备需先完成三元组烧录与连云;适合真机在远端或不便接线时的验证

环境自检清单

① aos --version 有输出;② 能编译一个官方 example 并出 elf/bin;③ 串口能看到启动日志与 help 命令可用;④ 能烧录并复位后重复启动;⑤ tasklist / meminfo 等 CLI 命令有响应。这五条全绿再开始写驱动,否则后面所有问题的定位都会被环境噪声污染。

3 · AliOS Things 驱动基础原理

本章是与 Linux 驱动差异最集中的地方。请重点理解三件事: 分级加载(没有 probe 自动匹配)、CSI 隔离(禁止碰寄存器)、组件化(不被依赖就不编译)。 理解这三点,后面所有写法都顺理成章。

3.1 内核基础:任务、同步与中断模型

AliOS Things 的内核提供任务、信号量、互斥量、队列、事件与软件定时器。 驱动本身通常不创建任务,但在两种情况下需要:一是中断线程化(把耗时处理挪出 ISR), 二是器件本身的异步流程(如擦写完成通知)。图 5 给出可用原语与中断上下文约束。

内核基础:驱动能用什么、在哪里用任务与调度• 优先级抢占 + 时间片轮转• aos_task_new / aos_task_exit / aos_task_yield• 每个任务独立栈,栈大小静态指定• 驱动通常不建任务;需要线程化时建一个• aos_msleep 只能在任务上下文调用• 优先级反转:用带优先级继承的互斥量规避同步与通信• 信号量 aos_sem_new / wait / signal(可计数)• 互斥量 aos_mutex(带优先级继承,可嵌套)• 队列 aos_queue(定长消息,拷贝传递)• 事件 aos_event(按位等待,多条件组合)• 定时器 aos_timer(软件定时器,回调在任务上下文)• ISR 中只能用非阻塞版本或 ISR 安全接口中断模型① 注册:csi_irq_attach(irq_num, handler, arg) 或在 CSI 适配层挂接② 上下文:ISR 运行在独立中断栈上,栈极小(常见 256 B ~ 1 KB 级)③ 禁止:不能调用任何可能阻塞的 API(malloc、sem_wait、msleep、长临界区)④ 推荐:ISR 只做清中断 + 收/发一帧 + 发信号,业务逻辑交给线程(中断线程化)⑤ 优先级:Cortex-M 用 NVIC 优先级分组;RISC-V 用 PLIC/CLIC,语义不同需重新映射记住这条线:ISR 只做「必要且极短」的事,其余全部推给任务;这条线守不住,系统会以随机死机的方式报复你判断方法:把中断处理函数里每个调用问一遍「它可能阻塞吗」,只要有一个答案是「可能」,就该搬走

图 5 · 内核基础:任务调度、同步通信原语与中断模型的上下文约束

复制代码
/* 驱动里最常见的「中断下半部」形态 */
static aos_sem_t  g_spi_done;      /* 静态分配,避免启动时 malloc */
static csi_spi_t  g_spi;

static void spi_event_cb(csi_spi_t *spi, csi_spi_event_t event, void *arg)
{
    /* ① 这里仍是中断/回调上下文:只发信号,不做业务 */
    aos_sem_signal(&g_spi_done);   /* 必须是 ISR 安全版本;不可用 aos_sem_wait */
}

int drv_spinand_wait_ready(uint32_t timeout_ms)
{
    /* ② 任务上下文:可以阻塞等待 */
    return aos_sem_wait(&g_spi_done, timeout_ms);
}

中断上下文的三条硬约束

① 不能调用任何可能阻塞的 API (aos_malloc、aos_sem_wait、aos_msleep、普通互斥锁);② 中断栈极小 ,禁止在 ISR 里开大数组或深递归;③ 不要在 ISR 里做耗时操作(打印日志、SPI 轮询整页、解析协议),这些都会拉长关中断时间,直接恶化系统的实时性。

3.2 驱动分级加载机制(核心特色)

Linux 靠「总线 + 设备树 + probe 匹配」解决初始化顺序,AliOS Things 没有这套机制, 改为由驱动自己声明启动等级 :用 XXX_DRIVER_ENTRY 宏把初始化函数放进特定阶段, 启动时由框架按序调用。这就是分级加载。图 6 是完整时序。

驱动分级加载:谁先起来、谁后起来,全靠 XXX_DRIVER_ENTRY 声明1CORE_DRIVER_ENTRY内核核心设备内核运行必需的最底层设备(如中断控制器、时钟源)2BUS_DRIVER_ENTRY总线控制器SPI / I2C / UART 等总线控制器本体,外设驱动依赖它3EARLY_DRIVER_ENTRY早期设备需要在文件系统之前就绪的设备(如部分 GPIO、看门狗)4VFS_DRIVER_ENTRYVFS 与根设备VFS 初始化、根文件系统挂载、console 设备5LEVEL0_DRIVER_ENTRY基础外设不依赖其他驱动的外设(GPIO 扩展、LED、按键)6LEVEL1_DRIVER_ENTRY总线上的外设挂在 SPI / I2C 总线上的器件:Flash、NAND、传感器7LEVEL2_DRIVER_ENTRY依赖外设的上层设备基于其他设备构建的复合设备(如基于 Flash 的分区设备)8应用初始化业务任务与协议栈应用组件初始化、网络协议栈启动、云端连接9shell / cli调试与运维CLI 组件、调试命令注册,此时可用命令行验证驱动铁律:外设驱动的等级必须晚于它所依赖的总线驱动;依赖链每加一层,等级至少要往后挪一级典型组合:SPI 总线控制器 = BUS;挂在 SPI 上的 NAND/NOR = LEVEL1;基于 Flash 的分区设备 = LEVEL2排查技巧:在每个驱动 init 首尾加一条日志,看启动日志里两个驱动的先后顺序是否符合预期

图 6 · 驱动分级加载 9 级时序:从 CORE 到 shell,外设驱动必须晚于其依赖的总线驱动

复制代码
/* 用法:在驱动文件末尾声明「我的 init 属于哪一级」 */
static int spinand_drv_init(void)
{
    /* 1) 初始化 CSI 句柄(依赖 SPI 总线已就绪) */
    if (csi_spi_init(&g_spi, SPI_NAND_BUS_IDX) != CSI_OK)
        return -1;

    /* 2) 配置 SPI 模式与速率 */
    csi_spi_mode(&g_spi, SPI_MODE_0);
    csi_spi_baud(&g_spi, SPI_NAND_MAX_BAUD);

    /* 3) 读取器件 ID 并识别容量 */
    if (spinand_read_id(&g_id) != 0)
        return -1;

    /* 4) 注册到 AOS 设备框架 */
    aos_dev_register(&g_spinand_dev);

    /* 5) 可选:挂 VFS,生成 /dev/nand0 */
    vfs_register_driver("/dev/nand0", &spinand_fops, &g_spinand_dev);
    return 0;
}

/* 关键:SPI NAND 挂在 SPI 总线上,必须晚于总线 → 用 LEVEL1 */
LEVEL1_DRIVER_ENTRY(spinand_drv_init)
宏 适用对象 选错的后果 典型例子
CORE_DRIVER_ENTRY 内核运行必需的最底层设备 系统根本起不来 中断控制器、系统时钟源
BUS_DRIVER_ENTRY 总线控制器本体 所有该总线上的外设全部初始化失败 SPI 控制器、I2C 控制器
EARLY_DRIVER_ENTRY 需在文件系统之前就绪的设备 早期日志丢失、看门狗误复位 GPIO、看门狗、console 相关
VFS_DRIVER_ENTRY VFS 与根设备 设备节点注册失败、文件系统挂不上 VFS 初始化、根挂载
LEVEL0_DRIVER_ENTRY 不依赖其他驱动的基础外设 设备不可用 LED、按键、GPIO 扩展
LEVEL1_DRIVER_ENTRY 挂在总线上的外设 csi_xxx 返回 BUSY/ERROR,读写全错 SPI NAND / NOR、I2C 传感器
LEVEL2_DRIVER_ENTRY 依赖其他设备构建的复合设备 底层设备还没注册,初始化失败 基于 Flash 的分区设备、文件系统挂载

等级选错是最隐蔽的一类 bug

它不会编译报错,也不一定每次都失败:总线控制器刚好先起来就正常,一旦启动顺序或时序有抖动就偶发失败。排查时最快的办法是在每个驱动的 init 首尾各加一条日志,看启动日志里的实际顺序,再对照依赖链。

3.3 CSI 硬件抽象层

CSI 是芯片厂商实现的底层接口层,向上暴露统一签名的外设操作。 驱动只调用 csi_xxx,绝不直接读写寄存器,这是跨芯片可移植的唯一保障。 图 7 划清了两层的职责边界。

CSI 层与驱动层的职责边界:一条不能越的线上层:外设驱动(你写的)• 器件协议:命令字、时序、状态轮询• 器件状态机:忙等待、擦写流程、坏块管理• AOS 设备框架对接:ops、注册、VFS• 允许调用:csi_* 系列接口 + AOS API• 禁止:直接读写寄存器、直接操作 DMA 控制器• 禁止:假设具体芯片型号与时钟频率下层:CSI(芯片厂商实现)• 寄存器级操作:外设控制器配置与启动• 中断控制器挂接与优先级映射• DMA 通道申请、配置与释放• 引脚复用(pinctrl)与时钟门控• 对上暴露统一签名:csi_spi_xxx / csi_gpio_xxx• 不做:任何器件级业务语义csi_xxx 调用回调 / 事件通知CSI 统一接口形态(示意,具体签名以头文件为准)句柄式:csi_xxx_init(handle, idx) → csi_xxx_config(...) → csi_xxx_transfer(...) → csi_xxx_uninit(handle)返回值:csi_error_t(CSI_OK / CSI_ERROR / CSI_BUSY / CSI_TIMEOUT / CSI_UNSUPPORTED),必须逐个判断异步:csi_xxx_attach_callback(handle, cb, arg) 注册回调;不要靠忙等轮询吃掉整个 CPU索引号 idx:同一类外设的第几个实例(如 SPI0/SPI1),与板级配置中的实例编号一一对应

图 7 · CSI 与驱动层的职责边界:驱动只做器件语义,芯片细节全交给 CSI

CSI 接口族 典型函数 驱动里的用途
csi_spi_* csi_spi_init / mode / baud / send / receive / transfer / attach_callback SPI 类器件:NOR/NAND Flash、LCD、RF 芯片
csi_i2c_* csi_i2c_init / master_send / master_receive / mem_write / mem_read I2C 传感器、EEPROM、PMIC、Codec
csi_gpio_* csi_gpio_init / mode / write / read / attach_callback 片选、复位、中断引脚、LED、按键
csi_uart_* csi_uart_init / send / receive / baud / attach_callback GPS、模组 AT 指令、调试口
csi_dma_* csi_dma_init / ch_alloc / ch_start / ch_stop / ch_free 大批量传输,降低 CPU 占用
csi_irq_* csi_irq_attach / detach / enable / disable 外部中断、器件中断引脚
csi_timer_* csi_timer_init / start / stop 超时判定、周期采样触发

返回值必须逐个判断

CSI 接口统一返回 csi_error_t,常见取值 CSI_OK / CSI_ERROR / CSI_BUSY / CSI_TIMEOUT / CSI_UNSUPPORTED。**CSI_BUSY 往往意味着上一次传输没结束或总线被别的驱动占用;CSI_TIMEOUT 常见于时钟未开、片选没拉对、器件没上电。**把返回值打进日志,排查效率能提升一个数量级。

3.4 AOS 设备模型

AOS 设备框架负责设备的注册、查找与生命周期管理 ,屏蔽了驱动的实现细节。 V3.x 的特色是驱动可以运行在内核态或用户态:内核态性能最好但崩溃会带走整个系统, 用户态通过 RPC 与内核交互、崩溃可隔离,代价是吞吐下降。图 8 给出取舍。

驱动运行在哪:内核态与用户态的两条路内核态驱动驱动代码与内核同址,直接调用内核与 CSI正:性能最好,无 RPC 开销,中断延迟最低正:可直接访问全部内核 API 与硬件资源正:适合高频、低延迟、中断密集的驱动负:一个野指针整系统死机(HardFault / panic)负:无法单独重启,故障影响面是整个设备负:驱动 bug 难以现场定位,只能靠 coredump典型选择:SPI 控制器、DMA、高频采样、网络协议栈底层用户态驱动驱动跑在独立用户进程,通过 RPC 与内核通信正:崩溃只死一个进程,内核与其他驱动存活正:可独立重启、独立升级与灰度正:适合复杂、第三方、易出错的设备逻辑负:每次硬件访问有 RPC 开销,吞吐下降负:中断不能直接进用户态,需要内核中转负:可用 API 受限,某些内核能力调不到典型选择:传感器融合、协议解析、Codec、复杂外设选型口诀:高频中断与时序苛刻的走内核态;逻辑复杂、易崩、可独立的走用户态;两者之间用 RPC 边界切分

图 8 · 内核态与用户态驱动的取舍:性能 vs 故障隔离

维度 内核态驱动 用户态驱动
运行位置 与内核同址,直接调用内核与 CSI 独立用户进程,通过 RPC 访问内核
性能 最高,无额外开销 有 RPC 开销,高频访问明显下降
中断处理 ISR 直接进驱动 需内核中转,延迟增加
故障影响 野指针导致整系统 panic 进程崩溃,内核与其他驱动存活
可用 API 全部内核 API 受限 API 集
升级 需整包升级 可独立升级 / 重启
适用 高频、低延迟、中断密集 逻辑复杂、第三方、易出错

3.5 VFS 虚拟文件系统

VFS 让驱动可以注册成 /dev/xxx 节点,应用用标准 POSIX 接口访问, 类似 Linux 字符设备,但没有设备树、没有 udev、节点由驱动显式注册。 图 9 是完整调用链。是否挂 VFS 由驱动自己决定,不是强制的。

VFS 通路:应用 open(/dev/xxx) 之后,调用是怎么走到驱动的应用fd = open("/dev/nand0")POSIX 层open / read / write / ioctlVFS路径查表 → inode → fd 分配file_ops驱动注册的 ops 结构体驱动实现csi_spi_transfer → 器件注册侧(驱动初始化时做一次)static file_ops_t nand_fops = { .open = ..., .read = ...,.write = ..., .ioctl = ..., .close = ... };vfs_register_driver("/dev/nand0", &nand_fops, priv);不挂 VFS 的另一条路驱动注册为 AOS 设备后,应用直接用 aos_dev_open /aos_dev_read 等 AOS 原生接口访问,省掉一层 VFS,代价是失去 POSIX 兼容与 shell 里 cat/echo 的便利什么时候该挂 VFS挂:字符流式设备、需要 shell 里直接测试、希望应用代码与 Linux 保持一致、需要被文件系统组件挂载不挂:超高频访问(层开销不可接受)、纯内部使用、资源极度紧张(VFS 本身占 ROM/RAM)注意:设备名必须全局唯一,重名注册会失败;建议在驱动里用常量统一管理命名

图 9 · VFS 通路:从 open(/dev/xxx) 经 POSIX → VFS inode → file_ops → 驱动实现的调用链

复制代码
/* 驱动侧:定义 file_ops 并注册(形态示意,签名以头文件为准) */
static int nand_open(inode_t *node, file_t *file)
{
    if (!g_spinand.inited) {
        LOGE("NAND", "device not inited");
        return -ENODEV;
    }
    return 0;                       /* 硬件初始化已在 init 里做过,这里只做状态检查 */
}

static ssize_t nand_read(file_t *file, void *buf, size_t nbytes)
{
    uint32_t page = file->offset / g_spinand.geo.page_size;

    aos_mutex_lock(&g_spinand.lock, AOS_WAIT_FOREVER);   /* 并发保护 */
    int ret = nand_page_read(page, buf, nbytes);
    aos_mutex_unlock(&g_spinand.lock);

    if (ret != 0) return ret;
    file->offset += nbytes;
    return nbytes;
}

static ssize_t nand_write(file_t *file, const void *buf, size_t nbytes)
{
    uint32_t page = file->offset / g_spinand.geo.page_size;

    aos_mutex_lock(&g_spinand.lock, AOS_WAIT_FOREVER);
    int ret = nand_page_program(page, buf, nbytes);   /* 内部分装:使能→load→exec→wait */
    aos_mutex_unlock(&g_spinand.lock);

    if (ret != 0) return ret;
    file->offset += nbytes;
    return nbytes;
}

static int nand_ioctl(file_t *file, int cmd, unsigned long arg)
{
    switch (cmd) {
    case NAND_IOC_ERASE_BLOCK:
        return nand_block_erase((uint32_t)arg);
    case NAND_IOC_GET_GEO:
        memcpy((void *)arg, &g_spinand.geo, sizeof(spinand_geo_t));
        return 0;
    case NAND_IOC_READ_STATUS:
        return nand_get_status((uint8_t *)arg);
    default:
        LOGW("NAND", "unknown ioctl cmd 0x%08X", cmd);
        return -EINVAL;              /* 未知命令必须返回错误,不能返回 0 */
    }
}

static file_ops_t nand_fops = {
    .open  = nand_open,
    .close = nand_close,
    .read  = nand_read,
    .write = nand_write,
    .ioctl = nand_ioctl,
};

/* 注册:路径全局唯一,重名会失败 */
vfs_register_driver("/dev/nand0", &nand_fops, &g_spinand);

/* 应用侧:POSIX 访问(与 Linux 应用写法几乎一致) */
int fd = open("/dev/nand0", O_RDWR);
if (fd < 0) {
    printf("open nand failed: %d\n", errno);
    return -1;
}

uint8_t buf[2048];
ssize_t n = read(fd, buf, sizeof(buf));

/* 擦除、读状态等控制类操作走 ioctl */
ioctl(fd, NAND_IOC_ERASE_BLOCK, block_no);
ioctl(fd, NAND_IOC_GET_INFO, &info);

close(fd);

VFS 不是万能的

① 高频访问有开销 :每次 read/write 都要经 POSIX → VFS → ops 三层,对采样率很高的场景不合适;② 设备名全局唯一 ,重名注册失败且容易被忽略返回值;③ VFS 本身占资源 ,极紧张的场景可以只用 AOS 原生设备接口;④ 存储类驱动通常还要再接一层分区管理与文件系统(LittleFS 等),文件系统是独立组件。

3.6 组件化管理

在 AliOS Things 里,驱动就是一个组件 :有独立的目录、package.yaml、aos.mk, 通过 depends 声明依赖,未被依赖就不参与编译。这是 IoT 场景控制 ROM/RAM 的核心机制, 也是与 Linux「编进内核 or .ko 模块」最本质的区别。图 10 是依赖链。

组件化:驱动是一个组件,靠 package.yaml 声明依赖,不被依赖就不编译product/<solution>package.yamldrv_spinandpackage.yamldrivers驱动框架csi芯片抽象层vfs可选:挂 /devcli可选:调试命令package.yaml 的三段关键信息name 组件名(被 depends 引用) | version 版本号(升级与冲突判定依据)depends 依赖列表(决定这条链上的组件是否进入编译,也决定能否链接到符号)裁剪收益未被引用的驱动整份源码不参与编译,ROM/RAM 零占用这是 IoT MCU 上最有效的资源控制手段常见错误依赖写了但 aos.mk 没加源文件 → 链接期 undefined reference版本写死过窄 → 组件升级时依赖解析失败

图 10 · 组件化依赖:product → 驱动组件 → drivers/csi/vfs/cli,未被依赖即不编译

复制代码
# product/<solution>/package.yaml ------ 决定固件里最终有什么
name: my_solution
version: master
depends:
  - drivers: master
  - csi: master
  - drv_spinand: master     # ← 不加这一行,你的驱动整份不会被编译
  - cli: master
  - vfs: master

组件化的三个好习惯

① 一个驱动一个目录 ,目录名与组件名一致;② 调试命令单独用宏隔离 (CONFIG_DRV_XXX_DEBUG),出厂默认关闭,避免带调试代码出货;③ 依赖写最小集:不需要 VFS 就别依赖 vfs,不要因为「方便」把整条链都拉进来。

4 · 板级硬件与 CSI 适配

这一章讲「驱动下面的那一层」。绝大多数「驱动跑不起来」的问题其实出在这里: 时钟没开、引脚复用没配、片选没拉对、CSI 底层根本没实现。 请把它当作驱动开发的前置检查项,而不是别人的事。

4.1 硬件原理图评审

驱动工程师必须参与原理图评审。原因很直接:硬件错误在软件层表现为「时序不对」「偶发失败」, 排查成本远高于改板前发现。图 13 给出四类必查项。

原理图评审:驱动工程师必须参与的四类检查SPI 链路• SCK / MOSI / MISO / CS 四线完整且无交叉• CS 必须为独占 GPIO(不用硬件 CS 共用)• 上拉/下拉按器件要求,避免浮空• WP# / HOLD# 需固定电平或接 GPIO 控制• 多器件共用总线时每个器件独立 CSI2C 链路• SDA / SCL 上拉阻值与总线电容匹配• 器件地址无冲突(7 位地址全表核对)• 总线挂 4 个以上器件时评估负载能力• 中断引脚 / 复位引脚是否引出• 电平转换:不同电压域需加电平转换器UART 链路• TX / RX 交叉连接(不是直连)• 波特率与双方时钟精度匹配• 流控引脚(RTS/CTS)按需引出• 调试口与业务口不要占用同一个 UART• 长距离或板间连接考虑 ESD 与共地电源与复位• 每颗芯片 VCC 就近 0.1 uF 去耦• 大电流路径的电源完整性与压降评估• 复位引脚的时序要求与上拉/复位电路• 上电时序:多电源域器件的先后约束• 掉电保护:存储器件写入期间供电稳定信号完整性要求• SPI 走线尽量等长、远离高频干扰源;速率越高越要控制反射与串扰(必要时串接 22~33 Ω 阻尼)• I2C 上拉阻值需按总线电容计算:速率越高、节点越多,上拉越小;过小的上拉会超出器件灌电流能力• 存储类器件擦写期间电流波动大,电源设计要留余量,否则会出现偶发写失败• 长线或板间连接的 SPI 建议在驱动侧降速验证:先低速跑通再提速,能快速区分时序问题与链路问题

图 11 · 原理图评审要点:SPI / I2C / UART / 电源复位四类检查与信号完整性要求

最容易翻车的五个硬件细节

① CS 被多个器件共用 → 两个器件同时响应,读回数据乱;② WP# / HOLD# 浮空 → 存储器件偶发拒绝写入;③ I2C 地址冲突 → 两个同型号传感器挂在同一总线;④ UART TX/RX 直连未交叉 → 双方都收不到;⑤ 去耦电容离 VCC 引脚太远 → 擦写瞬间掉电,表现为随机写失败或器件锁死。

4.2 板级配置:引脚复用、时钟与 IO 初始化

AliOS Things 的板级配置集中在 board/<board>/ 下,通常包括 引脚复用(pinctrl)、外设时钟使能、外设实例定义、板级初始化入口 四件事。 与 Linux 不同,这些全部是编译期确定的静态配置(图 11)。

同样是描述「SPI0 用哪几个引脚、哪个中断号」,Linux 用 DTS,AliOS 用静态 C 配置Linux:设备树 DTS编译期:dts 转 dtb,与内核镜像分离启动期:内核解析 dtb,生成 platform_device匹配期:of_match_table 与驱动 compatible 匹配初始化:命中后调用 probe,资源由内核传入优点:换板不改代码,只换 dtb;内核与板级解耦优点:同一内核镜像可支持多种板子代价:需要解析器与运行时内存,启动变慢代价:dts 写错常表现为 probe 不调用,难定位典型文件:arch/arm/boot/dts/xxx.dtsdrivers/xxx.c 里的 of_device_id 表驱动拿资源:platform_get_resource / of_property_read依赖顺序:设备树父子关系 + 链接顺序共同决定调试手段:/proc/device-tree、dmesg 中的资源解析日志AliOS Things:板级静态 C 配置编译期:board 目录下用 C 宏或配置表写死资源启动期:无解析,配置表直接放在只读数据段匹配期:没有 compatible 匹配,靠实例索引号初始化:按 DRIVER_ENTRY 宏声明的等级调用优点:零解析开销,启动快、代码体积小优点:资源在编译期确定,部分错误编译期即可发现代价:换板要改代码重新编译,无二进制复用代价:资源写错靠运行现象发现,无统一查看入口典型位置:board/<board>/board.c、board.h、aos.mk驱动内引用板级配置头文件中的宏驱动拿资源:直接引用配置宏或板级导出的配置表依赖顺序:完全由分级加载宏决定,无自动推断调试手段:启动日志 + CLI 自定义命令打印配置结论:AliOS 用「编译期确定」换取「启动快、体积小」,代价是硬件变更必须重新编译,且依赖关系要人工保证

图 12 · 无 DTS:AliOS 板级静态 C 配置与 Linux 设备树描述硬件资源的对照

复制代码
/* board/my_board/board.h ------ 板级资源集中定义(无 DTS,全部静态声明) */
#ifndef BOARD_H
#define BOARD_H

/* ---- SPI 实例:SPI NAND 挂在 SPI0 上 ---- */
#define SPI_NAND_BUS_IDX        0          /* 对应 csi_spi_init 的 idx 参数 */
#define SPI_NAND_CS_GPIO_IDX    0          /* GPIO 端口索引 */
#define SPI_NAND_CS_PIN         12         /* 片选引脚号 */
#define SPI_NAND_MAX_BAUD       24000000   /* 24 MHz,上电先用低速,稳定后再提 */

/* ---- I2C 实例:温湿度传感器挂在 I2C1 ---- */
#define SENSOR_I2C_BUS_IDX      1
#define SENSOR_I2C_ADDR         0x44
#define SENSOR_INT_GPIO_IDX     1
#define SENSOR_INT_PIN          5
#define SENSOR_INT_IRQ_NUM      26         /* 中断号:查芯片手册中断向量表 */

/* ---- 调试串口 ---- */
#define CONSOLE_UART_IDX        0
#define CONSOLE_BAUD            115200

#endif

/* board/my_board/board.c ------ 板级初始化入口 */
#include <aos/aos.h>
#include "board.h"

static csi_gpio_t g_cs_gpio;

static void board_pinmux_init(void)
{
    /* 1) 引脚复用:SPI0 的 SCK/MOSI/MISO 切到外设功能 */
    csi_pinmux_config(SPI0_SCK_PIN,  SPI0_SCK_FUNC);
    csi_pinmux_config(SPI0_MOSI_PIN, SPI0_MOSI_FUNC);
    csi_pinmux_config(SPI0_MISO_PIN, SPI0_MISO_FUNC);

    /* 2) 片选配置为 GPIO 输出,初始拉高(未选中) */
    csi_gpio_init(&g_cs_gpio, SPI_NAND_CS_GPIO_IDX);
    csi_gpio_mode(&g_cs_gpio, SPI_NAND_CS_PIN, GPIO_MODE_PUSH_PULL);
    csi_gpio_write(&g_cs_gpio, SPI_NAND_CS_PIN, 1);
}

static void board_clock_init(void)
{
    /* 3) 外设时钟:漏掉这一句,读写全返回 0x00 / 0xFF */
    csi_clk_enable(CLK_SPI0);
    csi_clk_enable(CLK_I2C1);
    csi_clk_enable(CLK_GPIO);
}

void board_init(void)
{
    board_clock_init();      /* 先开时钟,再配引脚 */
    board_pinmux_init();
    /* 其它板级初始化:堆初始化、console 初始化等 */
}

时钟没开是最常见的「低级错误」

现象极具迷惑性:SPI 读回全 0x00 或全 0xFF、I2C 一直 NACK、csi_xxx 返回 CSI_TIMEOUT,而代码逻辑完全正确。排查第一件事:确认外设时钟已使能、引脚复用已切到外设功能(而不是 GPIO 模式)、器件已供电且未处于复位态。

4.3 CSI 底层接口适配

如果你的 SOC 已经有官方 CSI 实现,这一节可以跳过;如果没有(常见于新芯片或小众 RISC-V), 就需要自己补齐底层 HAL。图 12 是需要实现的四组接口与推进顺序。

SOC 没有原生 CSI 支持时,需要按这个清单把底层 HAL 补齐① 控制器初始化• 外设时钟使能与分频• 控制器软复位与寄存器默认值• 工作模式配置(主/从、速率)• 引脚复用与电气属性② 传输接口• 阻塞发送 / 接收• 收发一体 transfer• 异步 + 回调• 超时与错误处理③ 中断与 DMA• 中断号映射与优先级配置• ISR 清中断与事件上报• DMA 通道申请 / 配置 / 释放• 缓存一致性处理(如适用)④ 电源与低功耗• 时钟门控接口• suspend / resume 状态保存恢复• 唤醒源配置• 休眠期间的引脚状态适配顺序建议(按依赖推进,不要跳步)1) 先做 GPIO + 时钟:能点灯、能量到引脚电平,说明时钟与引脚复用正确2) 再做 UART 轮询收发:有串口日志,后续所有调试才有切入点3) 再做 SPI / I2C 阻塞收发:能读回器件 ID,说明时序与片选正确4) 最后补中断与 DMA:性能与异步能力在此阶段引入,此时已有手段验证

图 13 · CSI 适配工作分解:初始化、传输、中断 DMA、低功耗四组接口与推进顺序

复制代码
/* 以 csi_spi 为例:CSI 层需要提供的接口族(签名示意,以头文件为准) */

/* ① 初始化 / 去初始化 */
csi_error_t csi_spi_init(csi_spi_t *spi, uint32_t idx);
void        csi_spi_uninit(csi_spi_t *spi);

/* ② 参数配置 */
csi_error_t csi_spi_mode(csi_spi_t *spi, csi_spi_mode_t mode);   /* CPOL/CPHA */
csi_error_t csi_spi_baud(csi_spi_t *spi, uint32_t baud);
csi_error_t csi_spi_format(csi_spi_t *spi, uint32_t bits);       /* 数据位宽 */

/* ③ 数据传输(阻塞 / 异步) */
int32_t     csi_spi_send(csi_spi_t *spi, const void *data, uint32_t size, uint32_t timeout);
int32_t     csi_spi_receive(csi_spi_t *spi, void *data, uint32_t size, uint32_t timeout);
int32_t     csi_spi_transfer(csi_spi_t *spi, const void *tx, void *rx, uint32_t size, uint32_t timeout);
csi_error_t csi_spi_send_async(csi_spi_t *spi, const void *data, uint32_t size);

/* ④ 回调与中断 */
csi_error_t csi_spi_attach_callback(csi_spi_t *spi, void *callback, void *arg);
csi_error_t csi_spi_detach_callback(csi_spi_t *spi);

/* ⑤ 状态查询 */
csi_spi_state_t csi_spi_get_state(csi_spi_t *spi);

/* CSI 层实现示例(寄存器操作只允许出现在这里) */
csi_error_t csi_spi_init(csi_spi_t *spi, uint32_t idx)
{
    if (idx >= SPI_MAX_INSTANCE)  return CSI_ERROR;

    spi->idx  = idx;
    spi->base = (SPI_REG_T *)spi_base_addr[idx];   /* 寄存器基址表 */

    csi_clk_enable(clk_id[idx]);                   /* 时钟使能 */
    spi->base->CR1 &= ~SPI_CR1_SPE;                /* 先关外设再配置 */
    spi->base->CR1  = SPI_CR1_MSTR | SPI_CR1_SSI | SPI_CR1_SSM;
    spi->base->CR1 |= SPI_CR1_SPE;                 /* 使能 */

    csi_irq_attach(irq_num[idx], spi_irq_handler, spi);
    csi_irq_enable(irq_num[idx]);
    spi->state = SPI_STATE_READY;
    return CSI_OK;
}

/* ISR:清中断 + 上报事件,绝不做业务 */
static void spi_irq_handler(void *arg)
{
    csi_spi_t *spi = (csi_spi_t *)arg;
    uint32_t sr = spi->base->SR;

    if (sr & SPI_SR_RXNE) {                 /* 收到数据 */
        spi->rx_buf[spi->rx_cnt++] = spi->base->DR;
        if (spi->rx_cnt >= spi->rx_len) {
            spi->state = SPI_STATE_READY;
            if (spi->cb) spi->cb(spi, SPI_EVENT_RX_DONE, spi->cb_arg);
        }
    }
    if (sr & SPI_SR_OVR) {                  /* 溢出错误必须处理 */
        (void)spi->base->DR; (void)spi->base->SR;
        spi->state = SPI_STATE_ERROR;
    }
}

CSI 实现的三条质量线

① 返回值语义准确 :CSI_BUSY 表示资源被占用,CSI_TIMEOUT 表示超时,不要一律返回 CSI_ERROR,否则上层无法区分重试与放弃;② 状态机完整 :READY / BUSY / ERROR 三态要能自恢复,错误态不能被永久卡住;③ 可重入保护:同一外设被多个驱动访问时,CSI 层要有锁或在文档中声明非线程安全。

4.4 板级资源定义:没有 DTS,靠静态配置

这是与 Linux 最大的差异点:Linux 用 DTS 描述硬件、内核解析后传给 probe; AliOS Things 没有设备树,所有硬件资源以 C 宏或配置表的形式在 board 目录写死,编译期确定。 好处是零解析开销、启动快;代价是换板必须改代码重新编译。

资源类型 Linux(DTS) AliOS Things(静态配置) 备注
引脚号与复用 pinctrl-0 = <&spi0_pins> csi_pinmux_config(pin, func) 或板级表 复用功能编号需查芯片手册,不同芯片语义不同
外设基址 reg = <0x40013000 0x400> CSI 层内部维护基址表,驱动不感知 驱动不应出现任何寄存器地址
中断号 interrupts = <0 26 4> #define SENSOR_INT_IRQ_NUM 26 RISC-V 与 Cortex-M 的中断号体系不同,移植时必查
时钟 clocks = <&rcc SPI1_CLK> csi_clk_enable(CLK_SPI0) AliOS 里时钟常由 CSI 内部处理,驱动不直接操作
DMA 通道 dmas = <&dma1 2> CSI 层分配,或板级指定通道号 通道冲突需人工协调,没有自动仲裁
器件私有参数 spi-max-frequency = <24000000> 驱动内宏定义或板级配置头 建议集中放在 board.h,避免散落在驱动里

把板级资源集中管理

强烈建议把所有引脚号、中断号、总线索引、速率上限集中到 board.h,驱动里只引用宏,不出现裸数字。这样换板只改一个文件,也能在评审时一眼看出资源是否冲突。散落在驱动里的魔法数字,是后期移植的主要成本来源。

4.5 硬件设计规范、电源与信号完整性

驱动无法弥补硬件缺陷。下面这些要求在原理图与 PCB 阶段就要落实, 否则软件侧只能降速、重试、加延时来「绕」,代价是性能与稳定性双输。

项目 要求 不满足时的软件侧表现
去耦电容 每颗芯片每个 VCC 引脚就近放置 0.1 uF,尽量靠近引脚、回路最短 擦写瞬间掉电 → 随机写失败、器件锁死、ID 读错
电源压降 按器件峰值电流(Flash 擦写可达数十 mA)评估电源与走线宽度 大电流操作时电压跌落 → 数据错误或复位
上拉/下拉 CS、WP#、HOLD#、RESET# 等关键控制线不允许浮空 器件偶发拒绝写入、上电状态不确定
SPI 走线 SCK/MOSI/MISO 尽量等长,远离 RF 与高速信号;高速时串接阻尼电阻 高速下读回数据错位、CRC 校验失败
I2C 上拉 按总线电容与速率计算上拉阻值,兼顾灌电流能力 高速模式波形不达标 → NACK、偶发丢字节
复位时序 满足器件要求的上电复位时间与复位脉宽 上电后首次访问失败,需软件延时规避
掉电保护 存储器件写入期间的供电稳定性;必要时加储能电容 意外掉电导致页/块数据损坏、坏块增加

给硬件工程师的一句话

如果某个问题在软件侧只能靠「加延时」「降速率」「多试几次」解决,那它本质上大概率是硬件问题。请回到原理图与电源设计上找原因,而不是把延时写进驱动当成修复------那只是把概率降低,不是消除。

5 · 驱动开发完整步骤

本章是全篇的核心。它以SPI NAND 存储驱动 为主线, 同时给出 I2C 传感器与 UART 的差异点,把从「拿到器件手册」到「驱动作为组件交付」的每一步摊开。 每一步都给出产出物 与常见错误,可以照着做。图 14 是总流程。

驱动开发八步:每一步都有明确的产出物,缺一步都会在后面某个环节爆炸① 需求分析定类型:总线外设 / 传感器定形态:是否需要 VFS 节点产出:驱动类型判定表② 依赖配置package.yaml 声明 dependsaos.mk 声明源文件与宏产出:两份配置文件③ 代码编写CSI 封装 → ops 实现→ 设备注册 → 可选 VFS 挂载产出:drv_xxx.c / .h④ 板级初始化board.h 定义资源宏board.c 完成时钟与引脚复用产出:板级改动⑤ 编译配置加入 product 的 depends确认裁剪与宏开关产出:固件含该驱动⑥ 基础测试注册是否成功 → CLI 是否可见读写是否正确 → 中断是否触发产出:功能验证记录⑦ 集成与压力长时间拷机、并发访问反复重启、休眠唤醒产出:稳定性结论⑧ 打包提交changelog、Known Issues测试用例与测试报告产出:可交付组件顺序不可颠倒的理由:③ 之前必须确定 ①(形态决定是否需要 ioctl);③ 之前必须完成 ④(否则引脚时钟没配,代码正确也读不到器件);⑤ 漏掉则驱动根本不进固件。实践建议:先跑通 ① 到 ⑥ 形成闭环,再补 ⑦ ⑧ ------ 早验证能省掉大量返工。

图 14 · 驱动开发八步流程与每步的产出物

5.1 第一步:需求分析与驱动分类

动手前先回答三个问题,它们决定了后面所有写法: ① 这是什么类型的设备?② 它挂在什么总线上?③ 是否需要 VFS 节点? 尤其是第三个------它决定你是否需要实现 file_ops 并处理 POSIX 语义。

驱动类型 典型器件 依赖总线 是否建议挂 VFS 加载等级建议
存储类 SPI NAND、SPI NOR Flash SPI / SDIO 建议挂(便于挂载文件系统、shell 直接测试) LEVEL1_DRIVER_ENTRY
传感器类 温湿度、加速度、光感、气压 I2C / SPI 通常不挂(走 sensor 框架更合适) LEVEL1_DRIVER_ENTRY
通信类 WiFi/BLE 模组、NB-IoT、LoRa UART / SDIO / SPI 按模组接口定(AT 指令常用 UART + VFS) LEVEL1 到 LEVEL2
人机交互 按键、LED、显示屏、Touch GPIO / SPI / I2C 一般不挂 LEVEL0_DRIVER_ENTRY
总线控制器 SPI 控制器、I2C 控制器 --- 不挂 BUS_DRIVER_ENTRY
复合设备 基于 Flash 的分区设备、虚拟设备 依赖底层设备 视情况 LEVEL2_DRIVER_ENTRY

一个判断是否需要 VFS 的经验法则

如果上层希望用文件的方式使用它 (读写流、需要挂载文件系统、希望在 shell 里 用 cat / echo 验证),就挂 VFS;如果上层是结构化调用(读一次采样值、设置一个参数、订阅一个事件),走 AOS 原生设备接口或 sensor 框架更合适,省掉一层查找与拷贝开销。

5.2 第二步:依赖组件配置

在 package.yaml 里声明依赖,在 aos.mk 里声明源文件。 依赖要写最小集:不挂 VFS 就别依赖 vfs,不做调试命令就别依赖 cli。

复制代码
# components/drivers/drv_spinand/package.yaml
name: drv_spinand
version: master
description: SPI NAND 驱动(基于 CSI,可挂 VFS)

depends:
  - drivers: master        # 必需:设备框架与分级加载宏
  - csi: master            # 必需:csi_spi_* / csi_gpio_* 接口
  - vfs: master            # 可选:仅当需要 /dev/nand0 时
  - cli: master            # 可选:仅当需要调试命令时

def_config:
  CONFIG_DRV_SPINAND: 1
  CONFIG_DRV_SPINAND_DEBUG: 0      # 出厂默认关闭调试
  CONFIG_DRV_SPINAND_TEST_CMD: 0   # 出厂默认关闭测试命令

# components/drivers/drv_spinand/aos.mk
NAME := drv_spinand

$(NAME)_TYPE := kernel
$(NAME)_SOURCES := drv_spinand.c
$(NAME)_INCLUDES := .
GLOBAL_INCLUDES += .
GLOBAL_DEFINES += CONFIG_DRV_SPINAND=1
$(NAME)_COMPONENTS += csi drivers

# 条件编译:调试与测试命令用宏隔离,避免带调试代码出货
ifeq ($(CONFIG_DRV_SPINAND_DEBUG),1)
GLOBAL_DEFINES += CONFIG_DRV_SPINAND_DEBUG=1
endif

5.3 第三步:驱动代码编写(核心)

一个驱动源文件建议按图 15 的五段组织。这样划分的好处是: 换芯片只重写第 2 段(CSI 封装),换器件只重写第 3 段(器件协议),换框架只改第 4 段。

drv_xxx.c 的内部组织:从上到下五段,每段职责单一第 1 段:配置与私有数据结构从 board.h 引用资源宏;定义设备私有结构体(句柄、缓冲、锁、状态)第 2 段:CSI 封装层把 csi_spi_transfer / csi_i2c_mem_read 包成器件级原语(发命令、读状态、等就绪)第 3 段:器件协议实现器件命令字、页/块读写流程、擦除、坏块管理、ECC 状态判断第 4 段:ops 与设备注册file_ops 或 aos 设备 ops 的填充;设备名、私有数据绑定第 5 段:初始化入口与等级声明init 函数 + DRIVER_ENTRY 宏;可选 CLI 测试命令配套文件:drv_xxx.h(对外暴露 ops 结构与 ioctl 命令码)、package.yaml(依赖)、aos.mk(源文件)第 2 段是关键隔离带:器件协议层(第 3 段)只认第 2 段的接口,因此换芯片时只需重写第 2 段

图 15 · 驱动源文件五段结构:配置、CSI 封装、器件协议、ops 与注册、初始化入口

5.3.1 第 1 段:配置与私有数据结构
复制代码
/* drv_spinand.c ------ 第 1 段:配置与私有数据结构 */
#include <aos/aos.h>
#include <csi/csi.h>
#include <vfs.h>
#include "board.h"          /* 板级资源宏:引脚、总线索引、速率 */
#include "drv_spinand.h"

/* ---- 器件命令字(来自器件 datasheet) ---- */
#define CMD_READ_ID          0x9F
#define CMD_PAGE_READ        0x13
#define CMD_GET_FEATURE      0x0F
#define CMD_SET_FEATURE      0x1F
#define CMD_PROGRAM_LOAD     0x02
#define CMD_PROGRAM_EXEC     0x10
#define CMD_BLOCK_ERASE      0xD8
#define CMD_WRITE_ENABLE     0x06
#define CMD_RESET            0xFF

/* ---- 状态寄存器位 ---- */
#define STATUS_OIP           (1 << 0)    /* 1 表示忙 */
#define STATUS_ECC_MASK      0x30
#define STATUS_ECC_NO_ERR    0x00

/* ---- 器件几何参数(按识别结果填充) ---- */
typedef struct {
    uint32_t page_size;       /* 页大小,典型 2048 */
    uint32_t spare_size;      /* 冗余区大小,典型 64 或 128 */
    uint32_t pages_per_block; /* 每块页数,典型 64 */
    uint32_t block_count;     /* 块数 */
    uint32_t max_baud;        /* 该器件支持的最高速率 */
} spinand_geo_t;

/* ---- 设备私有结构(静态分配,禁止在 init 里 malloc) ---- */
typedef struct {
    csi_spi_t   spi;          /* CSI SPI 句柄 */
    csi_gpio_t  cs_gpio;      /* 片选 GPIO 句柄 */
    uint8_t     cs_pin;
    spinand_geo_t geo;
    uint8_t    *page_buf;     /* 页缓冲:静态数组或 init 时一次性分配 */
    aos_mutex_t lock;         /* 并发保护 */
    uint32_t    busy_timeout_ms;
    int         inited;
} spinand_dev_t;

static spinand_dev_t g_spinand;             /* 单例:静态分配 */
static uint8_t g_page_buf[2048 + 128];      /* 页缓冲:静态分配 */

为什么强调静态分配

IoT MCU 的 RAM 通常只有几十 KB,运行期反复 malloc/free 会产生碎片 ,长时间运行后可能出现「总空闲内存够,但申请大块失败」。驱动里的缓冲区、锁、句柄一律静态分配 ,只在必要且一次性的场景使用 aos_malloc。

5.3.2 第 2 段:CSI 封装层(隔离带)

这一段是唯一调用 csi_xxx 的地方,把通用总线操作包成器件级原语。 器件协议层只认这一段,从而实现跨芯片可移植。

复制代码
/* 第 2 段:CSI 封装 ------ 只有这里出现 csi_ 前缀 */

static void cs_select(int on)
{
    /* 片选:低有效。用 GPIO 软件控制,不依赖控制器硬件 CS */
    csi_gpio_write(&g_spinand.cs_gpio, g_spinand.cs_pin, on ? 0 : 1);
}

static int spi_xfer(const uint8_t *tx, uint8_t *rx, uint32_t len)
{
    int32_t ret = csi_spi_transfer(&g_spinand.spi, tx, rx, len, 1000 /* ms */);
    if (ret < 0) {
        LOGE("NAND", "spi xfer failed, ret=%d", ret);   /* 返回值必须打印 */
        return -EIO;
    }
    return 0;
}

/* 器件级原语:发一个命令 */
static int nand_cmd(uint8_t cmd)
{
    cs_select(1);
    int ret = spi_xfer(&cmd, NULL, 1);
    cs_select(0);
    return ret;
}

/* 器件级原语:读状态寄存器 */
static int nand_get_status(uint8_t *status)
{
    uint8_t tx[2] = { CMD_GET_FEATURE, 0xC0 };
    uint8_t rx[2] = { 0 };

    cs_select(1);
    int ret = spi_xfer(tx, rx, sizeof(tx));
    cs_select(0);
    if (ret != 0) return ret;

    *status = rx[1];
    return 0;
}

/* 器件级原语:等待器件空闲(必须有超时上限) */
static int nand_wait_ready(uint32_t timeout_ms)
{
    uint32_t start = aos_now_ms();
    uint8_t  st = 0xFF;

    while ((aos_now_ms() - start) < timeout_ms) {
        if (nand_get_status(&st) != 0)
            return -EIO;
        if ((st & STATUS_OIP) == 0)
            return 0;                       /* 空闲 */
        aos_msleep(1);                      /* 让出 CPU,不要死等 */
    }
    LOGE("NAND", "wait ready timeout, st=0x%02X", st);
    return -ETIMEDOUT;                      /* 超时必须返回,不能死循环 */
}
5.3.3 第 3 段:器件协议实现

这一段实现器件真正的协议:读 ID、页读、页编程、块擦除等。 它只调用第 2 段的原语,不出现任何 csi_ 调用。

复制代码
/* 第 3 段:器件协议 ------ 只调第 2 段的原语 */

/* 读 ID:拿不到 ID 说明时序/片选/供电有问题,是最有效的第一步验证 */
static int nand_read_id(uint8_t *id, uint32_t len)
{
    uint8_t tx[2] = { CMD_READ_ID, 0x00 };
    uint8_t rx[2 + 4] = { 0 };
    uint32_t n = (len > 4) ? 4 : len;

    cs_select(1);
    int ret = spi_xfer(tx, rx, 2 + n);
    cs_select(0);
    if (ret != 0) return ret;

    memcpy(id, &rx[2], len);
    LOGI("NAND", "id = %02X %02X", id[0], id[1]);
    return 0;
}

/* 页读:24 位行地址 → 等待器件把数据搬到 cache → 读回 cache */
static int nand_page_read(uint32_t page, uint8_t *buf, uint32_t len)
{
    uint8_t cmd[4];

    /* 1) 发页读命令 + 24 位行地址 */
    cmd[0] = CMD_PAGE_READ;
    cmd[1] = (page >> 16) & 0xFF;
    cmd[2] = (page >> 8)  & 0xFF;
    cmd[3] = (page >> 0)  & 0xFF;
    cs_select(1);
    int ret = spi_xfer(cmd, NULL, 4);
    cs_select(0);
    if (ret != 0) return ret;

    /* 2) 等待器件内部搬移完成(这一步最容易漏) */
    ret = nand_wait_ready(g_spinand.busy_timeout_ms);
    if (ret != 0) return ret;

    /* 3) 从 cache 读回数据 */
    uint8_t hdr[4] = { 0x03, 0x00, 0x00, 0x00 };  /* 读 cache 命令 + dummy + 列地址 */
    cs_select(1);
    ret = spi_xfer(hdr, NULL, 4);
    if (ret == 0)
        ret = spi_xfer(NULL, buf, len);
    cs_select(0);
    if (ret != 0) return ret;

    /* 4) ECC 状态判断:不要只看「有没有数据」,要看纠错结果 */
    uint8_t st = 0;
    nand_get_status(&st);
    if ((st & STATUS_ECC_MASK) != STATUS_ECC_NO_ERR) {
        LOGW("NAND", "ecc corrected/flagged, st=0x%02X", st);
    }
    return 0;
}

/* 块擦除:先写使能,再发擦除命令,再等待 */
static int nand_block_erase(uint32_t block)
{
    uint32_t page = block * g_spinand.geo.pages_per_block;
    uint8_t cmd[4];

    nand_cmd(CMD_WRITE_ENABLE);

    cmd[0] = CMD_BLOCK_ERASE;
    cmd[1] = (page >> 16) & 0xFF;
    cmd[2] = (page >> 8)  & 0xFF;
    cmd[3] = (page >> 0)  & 0xFF;
    cs_select(1);
    int ret = spi_xfer(cmd, NULL, 4);
    cs_select(0);
    if (ret != 0) return ret;

    return nand_wait_ready(g_spinand.busy_timeout_ms);
}

存储驱动最容易漏的三件事

① 发完页读命令后没有等待器件内部搬移 → 读回全 0xFF 或旧数据;② 没有判断 ECC 状态 → 数据已被纠错但错误被静默吞掉,长期运行后丢数据;③ 忙等待没有超时上限 → 器件异常时整个任务永久挂死,表现为「系统卡住」。

5.3.4 第 4 段:ops 与设备注册

这一段把器件能力包装成框架要求的接口。若挂 VFS 就填充 file_ops; 若走 AOS 原生设备就填充对应的设备 ops 并调用注册接口。图 16 是 SPI NAND 的完整分层。

实例:SPI NAND 驱动在整套模型里的位置与数据流向应用层open("/dev/nand0") → read / write / ioctlVFS + file_ops路径查表 → nand_fops.open / read / write / ioctl分区与磨损均衡(可选)分区划分、坏块表、逻辑块到物理块映射(也可由文件系统组件承担)器件协议层读 ID、页读、页编程、块擦除、状态寄存器轮询、ECC 判断CSI 封装层spi_transfer(命令+地址+dummy+数据)、CS 控制、速率配置CSI + 硬件csi_spi_transfer / csi_gpio_write → SPI 控制器 → 器件关键设计点• 页缓冲:静态分配一页,避免 malloc• 忙等待:轮询状态寄存器要有超时上限• 擦写期间:禁止被其他任务打断访问,需加锁与 Linux 实现的差异• 无 DTS:引脚与速率写死在 board.h• 分区与磨损均衡形态按组件而定• 加载顺序靠 LEVEL1_DRIVER_ENTRY 声明

图 16 · SPI NAND 驱动实例分层:从应用到 CSI 的完整数据流向与关键设计点

复制代码
/* 第 4 段:ops 与注册 */

/* ---- ioctl 命令码(放在 drv_spinand.h 对外暴露) ---- */
#define NAND_IOC_MAGIC          'N'
#define NAND_IOC_ERASE_BLOCK    _IOW(NAND_IOC_MAGIC, 1, uint32_t)
#define NAND_IOC_GET_GEO        _IOR(NAND_IOC_MAGIC, 2, spinand_geo_t)
#define NAND_IOC_READ_STATUS    _IOR(NAND_IOC_MAGIC, 3, uint8_t)

static int nand_open(inode_t *node, file_t *file)
{
    if (!g_spinand.inited) {
        LOGE("NAND", "device not inited");
        return -ENODEV;
    }
    return 0;                       /* 硬件初始化已在 init 里做过,这里只做状态检查 */
}

static ssize_t nand_read(file_t *file, void *buf, size_t nbytes)
{
    uint32_t page = file->offset / g_spinand.geo.page_size;

    aos_mutex_lock(&g_spinand.lock, AOS_WAIT_FOREVER);   /* 并发保护 */
    int ret = nand_page_read(page, buf, nbytes);
    aos_mutex_unlock(&g_spinand.lock);

    if (ret != 0) return ret;
    file->offset += nbytes;
    return nbytes;
}

static ssize_t nand_write(file_t *file, const void *buf, size_t nbytes)
{
    uint32_t page = file->offset / g_spinand.geo.page_size;

    aos_mutex_lock(&g_spinand.lock, AOS_WAIT_FOREVER);
    int ret = nand_page_program(page, buf, nbytes);  /* 使能 → load → exec → wait */
    aos_mutex_unlock(&g_spinand.lock);

    if (ret != 0) return ret;
    file->offset += nbytes;
    return nbytes;
}

static int nand_ioctl(file_t *file, int cmd, unsigned long arg)
{
    switch (cmd) {
    case NAND_IOC_ERASE_BLOCK:
        return nand_block_erase((uint32_t)arg);
    case NAND_IOC_GET_GEO:
        memcpy((void *)arg, &g_spinand.geo, sizeof(spinand_geo_t));
        return 0;
    case NAND_IOC_READ_STATUS:
        return nand_get_status((uint8_t *)arg);
    default:
        LOGW("NAND", "unknown ioctl cmd 0x%08X", cmd);
        return -EINVAL;              /* 未知命令必须返回错误,不能返回 0 */
    }
}

static file_ops_t nand_fops = {
    .open  = nand_open,
    .close = nand_close,
    .read  = nand_read,
    .write = nand_write,
    .ioctl = nand_ioctl,
};

ops 实现的四条纪律

① 返回值语义正确 :成功返回实际字节数或 0,失败返回负错误码;② ioctl 未知命令返回 -EINVAL ,返回 0 会让上层误以为执行成功;③ offset 由驱动维护 ,读写后要推进文件偏移;④ 所有硬件访问加锁,多任务并发访问同一器件必然出竞态。

5.3.5 第 5 段:初始化入口与等级声明

最后一步是把 init 函数挂到正确的启动等级上。等级选错是这个阶段最常见也最隐蔽的错误。

复制代码
/* 第 5 段:初始化入口与分级加载声明 */

static int spinand_drv_init(void)
{
    memset(&g_spinand, 0, sizeof(g_spinand));

    /* ① CSI 句柄初始化:依赖 SPI 总线控制器已就绪(BUS 级已加载) */
    if (csi_spi_init(&g_spinand.spi, SPI_NAND_BUS_IDX) != CSI_OK) {
        LOGE("NAND", "spi init failed");
        return -1;
    }

    /* ② 片选 GPIO:片选必须独占,不能与其他器件共用 */
    if (csi_gpio_init(&g_spinand.cs_gpio, SPI_NAND_CS_GPIO_IDX) != CSI_OK) {
        LOGE("NAND", "gpio init failed");
        return -1;
    }
    csi_gpio_mode(&g_spinand.cs_gpio, SPI_NAND_CS_PIN, GPIO_MODE_PUSH_PULL);
    csi_gpio_write(&g_spinand.cs_gpio, SPI_NAND_CS_PIN, 1);   /* 初始未选中 */
    g_spinand.cs_pin = SPI_NAND_CS_PIN;

    /* ③ 速率:先低速跑通,稳定后再提到器件上限 */
    csi_spi_mode(&g_spinand.spi, SPI_MODE_0);
    csi_spi_baud(&g_spinand.spi, SPI_NAND_MAX_BAUD);

    /* ④ 并发保护与参数 */
    aos_mutex_new(&g_spinand.lock);
    g_spinand.page_buf = g_page_buf;
    g_spinand.busy_timeout_ms = 3000;

    /* ⑤ 识别器件:读 ID 是验证硬件链路的第一道关 */
    uint8_t id[2] = { 0 };
    if (nand_read_id(id, 2) != 0 || id[0] == 0x00 || id[0] == 0xFF) {
        LOGE("NAND", "read id failed, id=%02X %02X", id[0], id[1]);
        return -1;                    /* 读不到 ID 就查时钟/片选/供电,不要往下走 */
    }
    spinand_geo_fill(id, &g_spinand.geo);   /* 按 ID 填几何参数 */

    /* ⑥ 注册到 AOS 设备框架 */
    if (aos_dev_register(&g_spinand_dev) != 0) {
        LOGE("NAND", "dev register failed");
        return -1;
    }

    /* ⑦ 可选:挂 VFS,生成 /dev/nand0 */
    if (vfs_register_driver("/dev/nand0", &nand_fops, &g_spinand) != 0) {
        LOGE("NAND", "vfs register failed");
        return -1;
    }

    g_spinand.inited = 1;
    LOGI("NAND", "inited: page=%d block=%d baud=%d",
         g_spinand.geo.page_size, g_spinand.geo.block_count, SPI_NAND_MAX_BAUD);
    return 0;
}

/* 关键:SPI NAND 挂在 SPI 总线上 → 必须晚于总线 → LEVEL1 */
LEVEL1_DRIVER_ENTRY(spinand_drv_init)

init 函数的正确姿势

① 每步失败都要打印并返回 ,不留「半初始化」状态;② 读 ID 作为硬件链路自检 ,读不到就别往下走(省掉后面大量无效排查);③ 先低速再提速 ,一次把速率拉满会让时序问题与逻辑问题混淆;④ 结尾打印关键参数,让启动日志成为第一手的调试证据。

5.3.6 I2C 传感器驱动的差异点

传感器驱动通常不挂 VFS ,而是对接 sensor 框架或只提供 AOS 设备接口。 核心差异在于:用 csi_i2c_* 而非 csi_spi_*,且多为「寄存器读写」模型。

复制代码
/* I2C 传感器:寄存器读写模型 */
static csi_i2c_t g_i2c;

static int sensor_read_reg(uint8_t reg, uint8_t *val)
{
    int32_t ret = csi_i2c_mem_read(&g_i2c, SENSOR_I2C_ADDR, reg,
                                   CSI_I2C_MEM_ADDR_8BIT, val, 1, 100);
    if (ret != 0) {
        LOGE("SENSOR", "i2c read reg 0x%02X failed, ret=%d", reg, ret);
        return -EIO;
    }
    return 0;
}

static int sensor_drv_init(void)
{
    if (csi_i2c_init(&g_i2c, SENSOR_I2C_BUS_IDX) != CSI_OK)
        return -1;
    csi_i2c_mode(&g_i2c, CSI_I2C_MODE_MASTER);
    csi_i2c_speed(&g_i2c, CSI_I2C_BUS_SPEED_STANDARD);   /* 100k 起步 */

    uint8_t who = 0;
    if (sensor_read_reg(REG_WHO_AM_I, &who) != 0 || who != EXPECTED_ID) {
        LOGE("SENSOR", "who am i mismatch: 0x%02X", who);   /* 地址错或总线错 */
        return -1;
    }

    /* 中断引脚:配置为输入 + 上升沿触发 */
    csi_gpio_init(&g_int_gpio, SENSOR_INT_GPIO_IDX);
    csi_gpio_mode(&g_int_gpio, SENSOR_INT_PIN, GPIO_MODE_PULL_UP);
    csi_gpio_attach_callback(&g_int_gpio, sensor_isr, &g_int_gpio);
    csi_irq_enable(SENSOR_INT_IRQ_NUM);

    aos_dev_register(&g_sensor_dev);      /* 不挂 VFS */
    return 0;
}
LEVEL1_DRIVER_ENTRY(sensor_drv_init)     /* 同样挂在 I2C 总线之后 */

I2C 的两个高频坑

① 地址是 7 位还是 8 位 :datasheet 常给 8 位(含读写位),CSI 接口通常要 7 位地址,忘记右移一位会一直 NACK;② 重复起始条件 :读寄存器通常是「写寄存器地址 + 重复起始 + 读数据」,用 mem_read 这类封装接口比自己拼两段传输更可靠。

5.4 第四步:板级初始化

驱动代码里只引用 board.h 的宏,真正的引脚复用、时钟使能写在 board.c。 这样换板只改 board 目录,驱动零改动。完整写法见 4.2 节。

复制代码
/* board/my_board/board.h ------ 新增 SPI NAND 的资源定义 */
#define SPI_NAND_BUS_IDX        0
#define SPI_NAND_CS_GPIO_IDX    0
#define SPI_NAND_CS_PIN         12
#define SPI_NAND_MAX_BAUD       24000000

/* board/my_board/board.c ------ 在 board_init 中完成时钟与引脚 */
void board_init(void)
{
    board_clock_init();        /* ① 先开时钟 */
    board_pinmux_init();       /* ② 再配引脚复用 */
    /* ③ 片选初始拉高由驱动 init 里再做一次,双保险 */
}

5.5 第五步:编译配置与裁剪

最后确认三处一致:驱动组件被 product 依赖、源文件进入 aos.mk、调试宏按需开关。

复制代码
# product/<solution>/package.yaml ------ 别漏这一行
depends:
  - drv_spinand: master

# 编译
aos make my_solution@my_board -j8

# 编译后确认驱动确实进了固件(三选一)
# ① 看 map 里是否有 drv_spinand 的符号
# ② 看启动日志是否有 NAND inited
# ③ CLI 里看 /dev 节点是否存在

「代码写完了但设备不出现」的三种原因

① product 的 depends 没加 → 整份驱动没编译,且无任何报错;② 驱动 init 返回了 -1 但日志没开 → 注册失败被静默吞掉;③ 加载等级早于总线 → CSI 初始化失败,同样返回 -1。排查顺序:先看有没有编进去(map 或符号),再看有没有被调用(日志),最后看返回值。

5.6 第六步:基础功能测试

用 CLI 做最小闭环验证。建议每个驱动都自带一条测试命令(用宏隔离,出厂关闭), 它能在没有应用层的情况下证明驱动可用。

复制代码
/* 驱动自带的测试命令(CONFIG_DRV_SPINAND_TEST_CMD=1 时编译) */
static void nand_test_cmd(char *pwbuf, int blen, int argc, char **argv)
{
    uint8_t id[2] = { 0 };
    spinand_geo_t geo;

    if (nand_read_id(id, 2) == 0) {
        printf("ID: %02X %02X\n", id[0], id[1]);
    } else {
        printf("read id FAILED\n");
        return;
    }

    nand_ioctl(NULL, NAND_IOC_GET_GEO, (unsigned long)&geo);
    printf("page=%d spare=%d pages/blk=%d blocks=%d\n",
           geo.page_size, geo.spare_size, geo.pages_per_block, geo.block_count);

    /* 擦 → 写 → 读 → 比对 */
    uint8_t pattern[256];
    memset(pattern, 0xA5, sizeof(pattern));
    if (nand_block_erase(0) == 0 &&
        nand_page_program(0, pattern, sizeof(pattern)) == 0) {
        uint8_t rb[256] = { 0 };
        if (nand_page_read(0, rb, sizeof(rb)) == 0) {
            printf("verify: %s\n", memcmp(pattern, rb, sizeof(rb)) == 0 ? "PASS" : "FAIL");
        }
    }
}

/* 注册到 CLI */
ALIOS_CLI_CMD_REGISTER(nand_test, nand_test_cmd, "spi nand basic test");
验证项 方法 通过标准
驱动已加载 启动日志 / CLI 命令 出现设备初始化成功日志,无 error
设备已注册 CLI 列出设备或查看 /dev 能看到 /dev/nand0 或对应设备名
读 ID 正确 测试命令或应用层 ioctl ID 与 datasheet 一致,不是 0x00 / 0xFF
读写一致 写 pattern 后读回比对 多次循环全部 PASS
擦除有效 擦除后读回 全 0xFF(或器件定义的擦除值)
中断正常 触发事件后看计数 ISR 被调用,且没有触发看门狗
并发安全 两个任务同时读写 无数据错乱,无死锁

5.7 第七步:集成测试与压力测试

功能通过不等于可用。集成与压力测试要覆盖长时间、并发、异常三类场景, 具体矩阵见第 9 章。这里只强调三条最容易暴露问题的:

  • 循环读写:连续读写数万次,观察是否出现偶发错误(暴露时序与忙等待问题)。
  • 并发访问:两个以上任务同时访问同一器件(暴露锁缺失)。
  • 反复重启:反复上电或复位上百次(暴露初始化竞态与未初始化变量)。

5.8 第八步:驱动组件打包与提交

作为组件提交时,目录结构与交付物要规范,便于他人复用与裁剪。

复制代码
components/drivers/drv_spinand/
├─ drv_spinand.c        # 驱动主体:五段结构
├─ drv_spinand.h        # 对外头文件:ioctl 命令码、geo 结构、对外 API
├─ package.yaml         # 组件名、版本、依赖、默认宏
├─ aos.mk               # 源文件、头文件路径、宏
├─ README.md            # 用法说明、板级配置要求、已知限制
└─ test/
   ├─ test_drv_spinand.c # 测试用例(可编译为独立测试固件)
   └─ test_report.md      # 测试报告:环境、用例、结果、ROM/RAM 占用

提交前的最后一道自检

① 是否还有直操作寄存器 的代码(应全部在 CSI 层)?② 是否还有裸数字 (引脚号、中断号应来自 board.h)?③ 调试代码是否被宏隔离 ?④ 依赖是否最小化 ?⑤ README 是否说明了板级要求(别人照着能不能配通)?

后续章节请下载「芯参谋」查看

......(篇幅原因, 还有14章· 详见芯参谋→方案设计)

打开路径:🔧解决方案&应用市场分析 -> 原理方案设计 -> 嵌入式驱动&系统开发 -> AliOS Things_驱动开发指南

📥 下载芯参谋,免费查看完整报告

相关报告 · 1

相关报告 · 2

相关推荐
万物智能信息科技1 小时前
指纹识别传感器驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·华为·开源·harmonyos·鸿蒙
女神下凡1 小时前
芯参谋(84):Linux eMMC 驱动开发指南
linux·运维·arm开发·驱动开发·单片机·嵌入式硬件
鬼手点金1 小时前
Skills:领域能力的可组合单元
java·人工智能·笔记·自动化·1024程序员节
总要学点什么10 小时前
第89篇:三维特效-螺旋圆效果——让螺旋臂卷起一圈圈外扩的涟漪
开发语言·javascript·ecmascript·webgl·cesium·空间分析·1024程序员节
喵了几个咪12 小时前
AI 时代的危与机:当潮水退去,技术人如何自处
1024程序员节
szxinmai主板定制专家13 小时前
X86 工控机 vs RK3588 异构主控:AMR 控制器 BOM、散热、长期运维对比
运维·stm32·单片机·嵌入式硬件·fpga开发·am62x
LMT流氓兔14 小时前
第五单元——第十六课:给书店助手加上网页界面
java·开发语言·jvm·人工智能·oracle·langchain·1024程序员节
HQB202615 小时前
屹晶微电子 EG1130|2‑24V 输入 / 最高 28V 输出 内置 MOS 异步升压 DC‑DC|SOT23‑6|摄像头 / 车充 / 电池充电电源
嵌入式硬件·硬件工程
开开心心就好16 小时前
音频降噪剪辑工具,音量裁剪一键处理
运维·服务器·前端·spring·ocr·音视频·1024程序员节