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

RT-Thread_驱动开发指南

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

RT-Thread 驱动开发指南

编制日期 2026-10-05

I/O 设备模型 · rt_device 生命周期 · 两套框架的分工 · 中断 DMA 环形缓冲 · 交付核查清单

2 套
并行的驱动框架
经典 I/O 设备模型 / DM(设备树 + platform 总线)
6 级
自动初始化阶段
INIT_BOARD → INIT_PREV → INIT_DEVICE → … → INIT_APP
5 个
UART 底层钩子
configure / control / putc / getc / dma_transmit
1 套
API 打通全部外设
find / open / read / write / control / close

一句话结论:RT-Thread 驱动开发的本质不是“操作寄存器”, 而是把一个硬件对象翻译成一个 rt_device 对象,并让它的 ops、ref_count、rx_indicate、自动初始化级别全部符合内核约定。 做到这一点,上层 90% 的代码可以换 MCU 而一行不改;做不到,驱动就退化成一个挂 rt_device 外壳的裸机 API。 这份指南按“先选框架 → 再选路线 → 填 ops → 接中断 → 交付核查”的顺序,把每一步的决定点和坑点摊开写。

这份文档解决什么

写 RT-Thread 驱动时,真正卡住人的通常不是某个寄存器怎么配,而是下面这些结构性判断: 我这颗芯片该走经典模型还是 DM?init 和 open 到底谁负责初始化硬件? 驱动注册早了总线还没起来怎么办?中断里能调哪些 API?环形缓冲满了该覆盖还是丢弃? 设备名起冲突了为什么注册悄悄失败?这些问题的答案散落在源码、示例和社区帖子里, 本指南把它们收拢成一套可照做的流程,并给出可直接编译的骨架代码与可逐条勾选的核查清单。

路径 1 · 新接一颗外设
只想尽快把设备跑起来
先看第 1 章定框架,第 4 章定路线。若是标准外设(UART/SPI/I2C/PIN), 直接翻第 5、6 章抄骨架填 ops;若是自定义器件,翻第 7 章。
关键输出:rt_device_register 成功 + list_device 可见 + 应用能 read/write
路径 2 · 驱动不稳定 / 丢数据
能跑但有毛刺、丢字节、偶发死机
直奔第 8 章:ISR 约束、环形缓冲的两种实现与选满策略、临界区手段、 DMA 缓存一致性。九成偶发问题出在这一层的边界条件上。
关键输出:中断 / 线程 / DMA 三者的边界被明确划线
路径 3 · 交付评审
把驱动交给别人或合并进主线
按第 10 章做目录、命名、编译集成与静态内存整改,最后用第 12 章的核查清单 逐条打勾,并用第 11 章速查表覆盖常见故障处置。
关键输出:可复现、可维护、可被他人接手的驱动

面向 RT-Thread 4.x / 5.x 主线。凡涉及具体结构体字段与 API 签名的写法,均以你所用版本的头文件 rtdef.h / drivers/*.h 为准。

1 · 驱动体系全景

本章解决“我在给谁写代码”:先把 RT-Thread 把驱动切成哪几层、每层负责什么说清楚, 然后看清 rt_device 这一个结构体为什么能让上层与硬件解耦,最后给出框架选型的两级决策。

1.1 四层模型:谁负责什么

RT-Thread 的驱动体系通常被画成三层(应用层 / 管理层 / 驱动层)。真正落地时「驱动层」还要再拆成 设备驱动框架层与硬件底层驱动两层,否则说不清「为什么我这颗 UART 只需要写 5 个函数」。 图 1 给出完整四层视图。

RT-Thread 驱动分层全景:上层只用 rt_device 标准接口,下层可被整体替换① 应用层 Application / Packagert_device_find → open → read / write / control → close,与硬件型号无关通信协议包AT / MQTT文件系统DFS / Littlefs传感器框架Sensor业务逻辑User Task② I/O 设备管理层 Device Manager(内核)rt_object 对象容器 + 设备链表,负责注册 / 查找 / 引用计数 / open 去重设备链表rt_device_find引用计数ref_count打开标志open_flag异步回调rx_indicate③ 设备驱动框架层 Device Driver Framework(同类外设的共性抽象)serial / spi / i2c / pin / can / rtc / wdt 等等,定义该类外设的 ops 与数据结构serial 框架环形缓冲 + 事件spi 框架bus / device 分离i2c 框架msg 链表pin 框架引脚编号映射④ 硬件底层驱动 BSP Driver(唯一接触寄存器的地方)填充 ops、实现中断服务程序、配置 DMA / 时钟 / 引脚复用,然后注册到管理层ops 实现寄存器 / HALISR清中断 + 收发DMA通道配置clock / pinctrl时钟与复用关键约束:箭头只能向下调用,禁止反向依赖;换 MCU 时只重建 ④,①②③ 零改动这张图同时也是「出问题该查哪一层」的地图:应用层报错先确认是否注册,业务不稳先看框架层
图 1 · RT-Thread 驱动四层模型:应用层、设备管理层、驱动框架层、硬件底层

这四层的边界不是形式主义,而是变更成本的分界线:从上往下的调用是允许的, 一旦出现反向依赖(例如应用直接 include MCU 头文件操作寄存器),换平台的代价就从「重写 drv 文件」 变成「重构整个项目」。表格给出每层的产出物与明令禁止的事。

层级典型产出物负责什么禁止做什么
① 应用层业务线程、软件包调用用名字找设备 → 配置 → 读写;处理业务语义禁止包含 MCU 头文件、禁止直接读写寄存器、禁止假设设备名与芯片绑定
② I/O 设备管理层rt_device_find/open/read/write/control/close维护设备链表与对象容器、引用计数、打开去重、回调分发不关心任何硬件细节,也不因外设种类不同而分支
③ 设备驱动框架层serial.h / spi.h / i2c.h / pin.h 中的 ops 与结构体抽象同类外设的共性:缓冲、互斥、配置结构、中断事件定义不直接操作寄存器(那是第 ④ 层的事)
④ 硬件底层驱动drv_uart.c / drv_spi.c填 ops、写 ISR、配 DMA / 时钟 / 引脚复用、注册设备不包含业务逻辑,不创建业务线程,不决定应用层怎么用

1.2 rt_device:一个结构体撑起的抽象

整个抽象的支点是 struct rt_device。它的头部继承自 rt_object, 因此能被内核的对象容器统一管理(这也是 list_device 能把所有外设一起列出来的原因); 真正的行为则由一组函数指针决定。主线源码里存在两种等价形态,由 RT_USING_DEVICE_OPS 宏切换:

/* rtdef.h —— 设备控制块(字段顺序与注释按 4.x/5.x 主线整理) */
struct rt_device
{
    struct rt_object          parent;      /* 继承对象基类:名字、类型、链表节点 */
    enum rt_device_class_type type;        /* 设备类别:字符 / 块 / SPI 总线 / I2C 总线 … */
    rt_uint16_t               flag;        /* 设备特性:RDWR / INT_RX / DMA_RX / STREAM … */
    rt_uint16_t               open_flag;   /* 打开时的模式:只读 / 只写 / 非阻塞 … */
    rt_uint8_t                ref_count;   /* 引用计数:open 加 1,close 减 1 */
    rt_uint8_t                device_id;   /* 次设备号,通常保留为 0 */

    /* 数据收发回调:由应用层设置,驱动在事件发生处调用 */
    rt_err_t  (*rx_indicate)(rt_device_t dev, rt_size_t size);
    rt_err_t  (*tx_complete)(rt_device_t dev, void *buffer);

#ifdef RT_USING_DEVICE_OPS
    const struct rt_device_ops *ops;       /* 形态 A:ops 打包成一张表,结构体更小 */
#else
    rt_err_t   (*init)(rt_device_t dev);   /* 形态 B:函数指针直接内联在对象内 */
    rt_err_t   (*open)(rt_device_t dev, rt_uint16_t oflag);
    rt_err_t   (*close)(rt_device_t dev);
    rt_ssize_t (*read)(rt_device_t dev, rt_off_t pos, void *buffer, rt_size_t size);
    rt_ssize_t (*write)(rt_device_t dev, rt_off_t pos, const void *buffer, rt_size_t size);
    rt_err_t   (*control)(rt_device_t dev, int cmd, void *args);
#endif
    void                      *user_data;  /* 驱动私有数据:HAL 句柄、寄存器基址、配置指针 */
};
为什么要有两种形态

形态 A(RT_USING_DEVICE_OPS)把六个函数指针收进一张 const struct rt_device_ops 表并放在只读段,每个设备对象省下约 24 字节 RAM 且 ops 不可被误改写;形态 B 兼容性更好,老 BSP 里到处都是 dev->init = xxx 的写法。写新驱动时优先按形态 B 逐个赋值也能工作,但务必确认目标 BSP 是否打开了 RT_USING_DEVICE_OPS——两边写法不通用,混写会导致 ops 根本没挂上,open/read 全部返回错误。

字段虽多,日常真正要操心的只有三个:flag(声明设备能力,决定内核怎么对待它)、 ref_count(决定 init/open 是否重复执行)、user_data(承载私有数据, 是从 rt_device 反查回驱动实例的唯一正规通道)。其余字段由管理层维护,驱动不该自行改动。

字段由谁写作用与常见误区
parentRT-Thread 对象容器提供名字与链表节点。rt_device_register 时由内核填 type 并挂链表,驱动不要动
type框架层写决定 list_device 的显示类别,也决定某些框架能否识别该设备
flag驱动 / 框架在注册时写声明能力(读写、中断接收、DMA、流式)。标志写错不会报错,但会导致上层 read 行为诡异
open_flag应用层 rt_device_open 传入记录这次打开的模式,驱动在 read/write 里据此决定是否阻塞
ref_count内核维护open 加 1、close 减 1。驱动只读取,绝不自行修改
rx_indicate应用层设置、驱动调用接收回调。应用层没设置时为 NULL,驱动调用前必须判空
user_data驱动在注册前填写放私有结构体指针,通过 rt_container_of 反查(见 2.6 节)

1.3 两套框架:经典模型与 DM

RT-Thread 目前存在两套并行演进的驱动框架。若把「设备怎么被发现」当作区分点,两者差别一目了然:

对比维度经典 I/O 设备模型DM 设备驱动框架(RT_USING_DM)
适用场景MCU、资源受限设备、绝大多数 BSPMPU / 复杂 SoC、RT-Thread Smart
硬件如何描述写死在 C 代码里(哪个引脚、哪个 IRQ)设备树 DTS/DTB 描述 reg / interrupts / clocks
设备发现编译期已知,rt_hw_xxx_init() 里逐个注册遍历设备树节点生成 platform device
驱动与设备绑定驱动注册即完成,无匹配过程compatible 字符串匹配 → probe
pinctrl / clock驱动自己配,容易重复总线层在 probe 前统一应用 pinctrl-* 与默认时钟
移除 / 关机基本不支持有 remove / shutdown 回调
RAM / Flash 开销极小需 DTB 解析与层级对象,开销明显
是否互斥否,两者可在同一工程共存否,DM 常用于 SoC 级 IPR,经典模型用于板级外设
别把「新」当作「该用」

DM 引入设备树与现代 SoC 模型,看起来更先进,但它解决的是「一块 SoC 上几十上百个 MMIO 外设如何描述与复用」的问题。对一颗 64 KB RAM 的 Cortex-M4 而言,引入 DTB 解析得不偿失。资源受限 MCU 项目继续用经典 I/O 设备模型,是社区主流做法而非保守。

1.4 选型决策:什么时候走哪条路

框架选定之后还有第二级决策:是复用现成的设备框架,还是自建一个 rt_device。图 2 把两级决策画成一棵树, 顺着走能直接落到本指南对应章节。

先选框架,再选路线:RT-Thread 驱动开发的两级决策① 目标平台是 MCU 还是 MPU / SoC?MCU · 资源受限MPU / SoC走经典 I/O 设备模型走 DM 框架(RT_USING_DM)② 该类外设是否已有设备框架?platform bus + 设备树 compatible 匹配,第 9 章已有没有复用框架:只写底层 ops自建 rt_device:全套 ops典型需要自建的设备• 专用 ASIC / 自定义 FPGA 逻辑• 不被标准框架覆盖的流式外设• 需要特殊 control 命令的器件两类路线共用的底盘(这份指南的重点)rt_device 结构体 / 引用计数 / 自动初始化六阶段环形缓冲区 + rx_indicate 异步通知中断 DMA 上下文约束、故障速查与交付核查清单经验法则:能复用框架就一定复用——框架层已经替你处理了缓冲、互斥、命名与生态兼容
图 2 · 两级决策:先定「经典 I/O 模型 vs DM 框架」,再定「复用框架 vs 自建设备」

1.5 驱动在 BSP 工程中的落位

最后一个容易被忽略的问题是:驱动文件放哪里?放错位置带来的后果是别人根本编不到你的 C 文件, 或者同一个外设被两个 BSP 各维护一份。主线推荐的布局如下。

rt-thread/
├─ bsp/
│  └─ stm32/                          # 具体芯片系列 BSP
│     ├─ applications/                # 应用层:main.c、用户线程(不写驱动)
│     ├─ drivers/                     # ④ 硬件底层驱动:drv_uart.c / drv_spi.c / drv_gpio.c
│     ├─ board/
│     │  ├─ board.c                   # 板级初始化、堆初始化
│     │  └─ Kconfig                   # 板级配置项(在这里开出设备开关)
│     ├─ Libraries/
│     │  └─ HAL_Drivers/              # MCU 厂商 HAL 与 RT-Thread 的适配层
│     ├─ SConscript
│     └─ Kconfig
├─ components/
│  └─ drivers/
│     ├─ include/drivers/             # ③ 框架头文件:serial.h / spi.h / i2c.h / pin.h
│     ├─ serial/                      # ③ 串口框架实现
│     ├─ spi/                         # ③ SPI 框架实现
│     └─ …                            # ③ 其余框架
└─ include/rtdef.h                    # ② rt_device 定义所在

# 新加一个外设驱动时的最小改动集合
# 1) 新建          bsp/stm32/drivers/drv_xxx.c          芯片无关部分的框架对接
# 2) 硬件相关部分放进  Libraries/HAL_Drivers/drv_xxx.c    或第三方 HAL
# 3) 改            bsp/stm32/drivers/SConscript         让文件参与编译
# 4) 改            bsp/stm32/board/Kconfig              开出 RT_USING_XXX 开关
# 5) 不要在 applications/ 里注册设备
一条实用的分层自测

把你的驱动文件里的所有 #include 看一遍:如果出现了厂商 HAL 头(如 stm32f4xx_hal.h),那它属于 ④ bsp/drivers;如果只出现 rtdevice.h 和框架头,它可以上升到 HAL_Drivers 甚至 components/drivers。能被多个芯片复用的部分越往上放,未来的迁移成本越低。

2 · 设备对象的生命周期

本章解决“驱动对象是怎么被内核接管、被应用找到、被反复打开又归还的”。 理解 ref_count 与 open_flag 的语义,能一次性消灭「硬件被初始化两次」「close 之后还能 read」「多次 open 只 close 一次」这类经典故障。

2.1 注册与动态创建

注册有两件事:给设备起一个全局唯一的名字,并把对象挂进内核的对象容器。 绝大多数驱动用静态设备对象(编译期分配),少数场景才在运行时用 rt_device_create 动态申请。

/* 方式一:静态(推荐)—— 结构体通常是某个私有 struct 的第一个成员 */
static struct rt_device   my_dev;          /* 或某个 struct myxxxx 的 parent 成员 */

static int my_dev_register(void)
{
    my_dev.type      = RT_Device_Class_Char;
    my_dev.flag      = RT_DEVICE_FLAG_RDWR;
    my_dev.init      = my_init;
    my_dev.open      = my_open;
    my_dev.close     = my_close;
    my_dev.read      = my_read;
    my_dev.write     = my_write;
    my_dev.control   = my_control;
    /* user_data 可选,见 2.6 节 */

    return rt_device_register(&my_dev, "mydev", RT_DEVICE_FLAG_RDWR);
}
INIT_DEVICE_EXPORT(my_dev_register);

/* 方式二:动态 —— attach_size 会额外分配一段内存挂在对象尾部 */
rt_device_t dev = rt_device_create(RT_Device_Class_Char, sizeof(struct my_priv));
if (dev == RT_NULL) { LOG_E("create failed"); return -RT_ENOMEM; }
/* 通过 dev->user_data 使用那段附加内存 */
struct my_priv *priv = (struct my_priv *)dev->user_data;
rt_device_register(dev, "mydev", RT_DEVICE_FLAG_RDWR);
/* 不再使用时:rt_device_destroy(dev); 会_unregister 并释放内存 */
注册失败往往是无声的

rt_device_register 在名字重复或对象容器异常时返回 -RT_ERROR,如果你的注册函数直接写成 rt_device_register(...); return 0;,错误就被吞掉了,后续表现是「应用层 find 不到但驱动看起来跑得好好的」。

纪律:注册函数的返回值就是 rt_device_register 的返回值,被调用处必须检查并打日志。

2.2 查找、打开与引用计数

rt_device_find 返回的是对象指针,只代表设备已被注册,不代表硬件可用。 真正的可用性由 rt_device_open 保证,而它的行为完全围绕 ref_count 展开:

rt_device 对象视图:内核维护的字段 vs 驱动必须填的字段对象的内存布局(一个 ::parent 就把私有数据串起来)struct rt_device parent名字、 type、链表节点 —— 由内核对象容器维护list_device 能列出全部设备就是靠它struct my_device priv(你自己定义)• ops 函数表(init / open / read / write / control)• HAL 句柄或寄存器基址• 环形缓冲 / DMA 描述 / 互斥句柄• 波特率、模式等运行时配置注册时 rt_device_register(&priv->parent, name, flag)void *user_data指向 priv 自身,或留空改用 rt_container_of 反查两种做法二选一,不要重复保存同一份指针字段归属:哪些你能动,哪些你绝不能动parent / ref_count / open_flag归属:内核只读。改动会让引用计数与去重逻辑失效type / flag归属:注册时写一次flag 写错不报错,但会让上层读写行为异常rx_indicate / tx_complete归属:应用层设置驱动只调用不赋值;调用前必须判空ops 各函数指针归属:驱动写整套 I/O 的落点,未实现的入口置 NULLuser_data归属:驱动写反查私有数据的正规通道反查惯用法rt_device_t dev; struct my_device *p = rt_container_of(dev, struct my_device, parent);parent 必须是 struct my_device 的【第一个成员】,这是偏移为 0 的硬性前提最常见的多态 bug:忘记把 parent 放在结构体首位,rt_container_of 算出的地址偏移错位,驱动读写到完全无关的 RAM
图 3 · rt_device 对象的字段归属与私有数据反查惯用法

把这套语义翻译成驱动编写时的三条要求:

  1. init 幂等且只负责「让硬件可工作」:时钟使能、引脚复用、DMA 通道申请。内核保证它只在第一次 open 时被调用一次,所以不要在里面做多次累加的事情。
  2. open 负责使能申请者所需的能力:开中断、启动接收。多任务先后 open 同一设备时,open 只在计数为 0 时被回调一次;如果你的外设必须按「每个使用者一份资源」来开,请在自己的 priv 里再记一层计数。
  3. close 必须能接受被推迟:只有 ref_count 归零才会回调 close。任何一方忘了 close,硬件就一直开着,低功耗场景会被这个问题咬到。
open 返回值别忘了判

应用层最常见的疏漏是只判 find 不判 open:rt_device_find 拿到指针后直接 rt_device_write,一旦设备名存在但硬件初始化失败(电源没上、时钟没开),轻则返回错误码,重则在 NULL 的 HAL 句柄上跑飞。

正确姿势:dev = find(...); if (!dev) return; if (rt_device_open(dev, ...) != RT_EOK) return;

2.3 读写语义与阻塞策略

read / write 的返回值类型是 rt_ssize_t(有符号),这一点非常关键: 正数表示实际传输的字节数,0 通常表示无数据 / 结束,负数则是取反的错误码。 把 rt_ssize_t 当成 rt_size_t 来判断,会让 -RT_EIO 变成一个天文数字。

要素约定
返回值≥0 为实际字节数;-RT_EIO 硬件错、-RT_ENOMEM 内存不足、部分框架返回 -RT_EEMPTY 表示当前无数据(具体以框架头文件为准)
pos字节偏移。字符设备忽略它(传 -1 是常见写法);块设备与存储器件必须实现它,文件系统就靠它寻址
size期望长度。驱动可以自由返回更少的字节,上层必须按返回值推进缓冲区指针
阻塞默认阻塞。上层以 RT_DEVICE_OFLAG_NONBLOCKING 打开时禁止等待,驱动应立刻返回而不 pend 信号量
部分读取允许。流式外设(UART)一次 read 返回当前缓冲里的全部内容即可,不必凑满 size
线程安全同一个设备的并发 read/write 最终落到你的 ops 里的同一个缓冲,互斥由驱动负责(见 8.5 节)

2.4 control 命令码设计

control 是 ioctl 的角色,承载「不属于读写的一切」:波特率、采样率、通道选择、寄存器诊断、进入低功耗等。 它的自由度也是它最容易失控的地方——命令码一旦发散,驱动就再也无法被替换。建议按下述规则统一:

/* 驱动侧:命令码集中定义在一个头文件里,供驱动与应用共享 */
#define MY_CMD_BASE          0x80          /* 避开内核通用命令区段 */

#define MY_CMD_SET_SPEED     (MY_CMD_BASE + 0)   /* args: uint32_t*  期望速率 */
#define MY_CMD_GET_SPEED     (MY_CMD_BASE + 1)   /* args: uint32_t*  实际速率 */
#define MY_CMD_SET_MODE      (MY_CMD_BASE + 2)   /* args: struct my_mode* */
#define MY_CMD_DUMP_REG      (MY_CMD_BASE + 3)   /* args: NULL,仅打印调试信息 */

static rt_err_t my_control(rt_device_t dev, int cmd, void *args)
{
    switch (cmd)
    {
    case RT_DEVICE_CTRL_SUSPEND:            /* 通用命令,低功耗钩子(见 10.6 节) */
        my_hw_lp_enter();
        return RT_EOK;
    case RT_DEVICE_CTRL_RESUME:
        my_hw_lp_exit();
        return RT_EOK;
    case MY_CMD_SET_SPEED:
        if (args == RT_NULL) return -RT_EINVAL;
        return my_hw_set_speed(*(rt_uint32_t *)args);
    default:
        LOG_W("unknown cmd 0x%x", cmd);
        return -RT_ENOSYS;                  /* 不认识的命令绝不返回 RT_EOK */
    }
}
三条命令码纪律

① 未识别的命令返回 -RT_ENOSYS 而不是 RT_EOK——上层靠返回值判断命令是否真的生效;② args 永远视为可能 NULL,进来先判空;③ 命令码不要用裸数字,全部 #define 到一个共享头文件,方便后续替换驱动时保持二进制兼容的语义。

2.5 rx_indicate 与 tx_complete

这是 RT-Thread 驱动最经典的异步通知机制:应用层把回调挂到设备的回调位上,驱动在事件发生处调用它。 图 4 给出一次「查找 → 打开 → 中断通知 → 读取 → 关闭」的完整时序。

一次完整的设备访问时序:谁调用谁、谁负责什么副作用应用层线程I/O 设备管理层驱动 ops / ISRrt_device_find("uart2")遍历对象容器,找不到返回 RT_NULL返回 rt_device_t拿到的是指针,不代表设备可用rt_device_open(dev, RDWR|INT_RX)oflag 决定阻塞与否ref_count 为 0 → 先调 init()硬件时钟 / 引脚初始化再调 open(dev, oflag)开中断、使能 RXref_count 置 1,记录 open_flag重复 open 不再回调 init/openrx_indicate(dev, len) ← ISR 上下文中断上下文,ISR 里调用rt_device_read(dev, -1, buf, n)无数据时按 open_flag 决定等待或立即返回rt_device_close(dev)计数归零才回调 close虚线为泳道生命线;红字步骤发生在中断上下文,不是应用线程调用的
图 4 · 应用层、管理层与驱动 ops 之间的调用时序(含中断侧异步通知)
/* ---- 应用层:设置回调 ---- */
static rt_sem_t rx_sem = RT_NULL;

static rt_err_t uart_rx_ind(rt_device_t dev, rt_size_t size)
{
    /* 注意:这里可能运行在中断上下文 */
    if (size > 0)
        rt_sem_release(rx_sem);
    return RT_EOK;
}

void app_entry(void)
{
    rx_sem = rt_sem_create("rx", 0, RT_IPC_FLAG_FIFO);
    rt_device_t dev = rt_device_find("uart2");
    rt_device_set_rx_indicate(dev, uart_rx_ind);         /* 挂回调 */
    rt_device_open(dev, RT_DEVICE_FLAG_INT_RX);          /* 以中断接收方式打开 */

    while (1)
    {
        if (rt_sem_take(rx_sem, RT_WAITING_FOREVER) == RT_EOK)
        {
            rt_size_t len = rt_device_read(dev, -1, buf, sizeof(buf));
            if (len > 0) handle(buf, len);
        }
    }
}

/* ---- 驱动侧:事件发生处调用(通常在 ISR 尾部)---- */
static struct rt_device *g_dev;   /* 注册时保存 */

void USART2_IRQHandler(void)
{
    rt_interrupt_enter();
    /* 1) 清中断标志  2) 把字节搬进 ringbuffer */
    ...
    /* 3) 通知上层:一定判空 */
    if (g_dev->rx_indicate != RT_NULL)
        g_dev->rx_indicate(g_dev, bytes_in_fifo);
    rt_interrupt_leave();
}
rx_indicate 回调仍在中断上下文

回调被执行时,CPU 还停在 ISR 里。因此在回调里不能做数据解析、不能打印长日志、不能 rt_thread_mdelay、不能调用任何可能阻塞的 API。它的正确职责只有四个字:发出通知——释放信号量、通知邮箱、触发 completion,剩下的交给线程。

另外务必判空:应用层没设置回调时该指针为 NULL,直接调用等价于跳转到地址 0。

2.6 私有数据与容器反查

一个设备的全部状态(HAL 句柄、缓冲区、配置项)需要有一个落脚点。RT-Thread 的惯用做法是 让 rt_device 作为私有结构体的第一个成员,然后用 rt_container_of 从 rt_device_t 反查回来。

struct my_device
{
    struct rt_device      parent;      /* ← 必须是第一个成员! */
    UART_HandleTypeDef   *huart;       /* HAL 句柄 */
    rt_uint32_t           irq_err_cnt; /* 统计信息 */
    struct rt_ringbuffer *rb;          /* 接收缓冲 */
    rt_uint32_t           baud;        /* 当前配置 */
};

static struct my_device g_devobj;      /* 静态分配,避免堆碎片 */

static rt_err_t my_open(rt_device_t dev, rt_uint16_t oflag)
{
    struct my_device *priv = (struct my_device *)dev->user_data;   /* 做法 A:用 user_data */
    /* 或者 */
    priv = rt_container_of(dev, struct my_device, parent);          /* 做法 B:容器反查 */
    ...
}

两种做法都常见,差别在于:user_data 是显式的、任何人可读的一枚指针; rt_container_of 则完全依赖结构体布局。建议遵循这条取舍: 私有结构体与 rt_device 同生共死时用 rt_container_of(语义最清晰,无需额外初始化); 只有当私有数据可能被替换、或需要与其他非设备上下文共享时才放到 user_data。 二者混用时一定要保证指向同一个地方,否则会出现「两处状态不同步」的隐蔽 bug。

3 · 自动初始化与启动时序

本章解决“谁在什么时候调用我的注册函数”。RT-Thread 不用你去 main 里逐个 init(), 而是把函数指针放进特定链接段由内核遍历执行。这份便利的代价是:一旦依赖关系摆错阶段,故障会以「设备不存在」的形式随机出现。

3.1 六阶段宏与 .rti_fn 段

所有自动初始化宏最终都收敛到同一个 INIT_EXPORT(fn, level),它把一个函数指针塞进名为 .rti_fn.<level> 的链接段。启动代码 rt_components_init() 按段名排序依次遍历这些函数—— 阶段其实就是链接段名字里的那一位数字。

/* rtdef.h —— 机制本身只有两行 */
typedef int (*init_fn_t)(void);

#define INIT_EXPORT(fn, level)                                              \\
    RT_USED static const init_fn_t __rt_init_##fn                           \\
    __attribute__((section(".rti_fn." level))) = fn

/* 由此派生的六个标准阶段 */
#define INIT_BOARD_EXPORT(fn)           INIT_EXPORT(fn, "1")
#define INIT_PREV_EXPORT(fn)            INIT_EXPORT(fn, "2")
#define INIT_DEVICE_EXPORT(fn)          INIT_EXPORT(fn, "3")
#define INIT_COMPONENT_EXPORT(fn)       INIT_EXPORT(fn, "4")
#define INIT_ENV_EXPORT(fn)             INIT_EXPORT(fn, "5")
#define INIT_APP_EXPORT(fn)             INIT_EXPORT(fn, "6")
启动阶段的六张多米诺:同一级内部的执行顺序由链接顺序决定,不要依赖1INIT_BOARD_EXPORT纯硬件:时钟树、GPIO 控制器本身、console 串口rt_hw_uart_init / rt_hw_pin_init2INIT_PREV_EXPORT与 CPU / 板卡密切的早期服务,需先于组件heap 初始化后的早期外设、DMA 复用3INIT_DEVICE_EXPORT绝大多数外设驱动注册:SPI 总线、I2C 总线、传感器rt_hw_spi_init / rt_hw_i2c_init4INIT_COMPONENT_EXPORT软件组件:文件系统、网络协议栈、ulog、FinSHdfs_init / lwip / lwip_thread5INIT_ENV_EXPORT运行环境:需要组件就绪后才能初始化的服务环境变量、某些需要网络的组件6INIT_APP_EXPORT用户应用:业务线程、RNNO 使能、启动脚本main / 业务逻辑线程DM 框架下还有 INIT_CORE(1.0) / INIT_SUBSYS(1.1) / INIT_PLATFORM(1.2),排在 1 之前
图 5 · RT-Thread 自动初始化的六个阶段与各自的典型用途
宏是启动时期执行的,写错就算编译通过也很难查

① 必须写在大括号外面(它是变量定义,不是语句),放在函数体内会得到奇怪的同名局部符号;② 被它引出的函数签名必须是 int f(void);③ 函数里不能假定内核对象系统已完全就绪——在 INIT_BOARD_EXPORT 阶段创建信号量需要谨慎,因为此时调度器尚未启动,任何阻塞都会直接把系统挂死。

3.2 依赖顺序与踩坑

同一 Stage 内各函数的执行先后取决于链接顺序(本质上是编译单元被打包进镜像的顺序), 这在工程里几乎不可控。因此凡是存在依赖,就必须把它们放到不同阶段去。图 6 给出最常见的翻车现场与修法。

注册顺序错了会怎样:同一个从设备,注册早一步就找不到总线✗ 错误:从设备与总线同在 INIT_DEVICE_EXPORTINIT_DEVICE_EXPORT(rt_hw_spi_init) /* 注册 spi0 总线 */INIT_DEVICE_EXPORT(sensor_init) /* 抢跑 */结果:rt_device_find("spi0") 返回 RT_NULLattach 失败 → 设备没注册 → 应用层再 find 也拿不到✓ 正确:按依赖分层,被依赖者放更早的阶段INIT_BOARD_EXPORT(rt_hw_spi_init) /* 总线先注册 */INIT_DEVICE_EXPORT(sensor_init) /* 后跑:bus 已存在 */结果:attach 成功,list_device 可见 spi0 与 sensor依赖链:总线 → 从设备 → 依赖该设备的服务三条保命规则1. 同一 Stage 内的执行顺序取决于链接先后——永远不要把依赖关系建立在同一级里2. 从设备注册函数里必须检查 bus/父句柄是否为 NULL,失败要打日志而不是静默返回 RT_EOK3. 拿不准就把总线放 INIT_BOARD_EXPORT、外设放 INIT_DEVICE_EXPORT、应用放 INIT_APP_EXPORT
图 6 · 注册顺序依赖:总线必须先于从设备、父设备必须先于子设备

除了「从设备早于总线」之外,还有四种顺序问题在真实项目里高频出现:

现象根因处置
list_device 里看不到某个设备注册函数没被任何导出宏引出,或所在 C 文件没进编译检查 SConscript 是否包含该文件、宏是否写了 (void) 之外的名字
应用跑起来了但设备指针为 NULL应用在 INIT_APP_EXPORT 之前或同级被调用把应用放 INIT_APP_EXPORT,或改用显式函数调用而非自动初始化
总线上的设备时而注册成功时而失败总线与从设备在同一 Stage,链接顺序不稳定按图 6 右侧把总线前移到 INIT_BOARD_EXPORT
注册函数里调用阻塞 API 后系统停在启动期board 阶段调度器未启动,rt_thread_mdelay / 信号量 take 无法返回该阶段只允许纯硬件操作;需要等待的初始化放到线程里做
同一个设备注册两次,第二次失败名字重复(手工注册 + 自动初始化双重触发)只在一条路径上注册;检查是否被 packages 里的同名驱动抢占

3.3 自定义初始化级别

六个阶段不够细怎么办?INIT_EXPORT 的 level 是字符串,可以直接给出小数点后的段位, 例如 "3.5" 会排在 "3" 之后、"4" 之前。这比调整链接顺序可靠得多。

/* 自定义:总线之后、普通外设之前 */
#define MY_BUS_EXPORT(fn)  INIT_EXPORT(fn, "3.1")
#define MY_DEV_EXPORT(fn)  INIT_EXPORT(fn, "3.5")

MY_BUS_EXPORT(rt_hw_spi_bus_init);   /* 注册 spi0 / spi1 总线 */
MY_DEV_EXPORT(rt_hw_spi_dev_init);   /* 挂载 spi 从设备,此时总线一定在场 */

/* 注意:命名建议带上项目前缀,避免与主线后续新增的宏冲突 */

3.4 DM 框架下的启动顺序

开启 RT_USING_DM 后,阶段表多出三项,顺序大致如下(具体名称与版本相关,以 rtdef.h 为准):

阶段宏做什么
1.0INIT_CORE_EXPORTplatform 总线自身注册:rt_bus_register(&platform_bus)
1.1INIT_SUBSYS_EXPORT必须早于 DT 扫描的早期驱动(如 pinctrl、部分 IRQ chip),手动调用 rt_platform_driver_register()
1.2INIT_PLATFORM_EXPORT遍历设备树节点,生成 platform device 挂到总线上;若驱动已注册则立即匹配并 probe
3INIT_DEVICE_EXPORTRT_PLATFORM_DRIVER_EXPORT 在此注册驱动,随后对总线上所有未匹配设备逐一 probe
由此得到的一条实践结论

在 DM 下,用 RT_PLATFORM_DRIVER_EXPORT 注册的驱动天然晚于设备树节点出现,所以不用担心顺序;反而是那些必须在 DT 扫描前就绪的底层(pinctrl、clock)必须走 INIT_SUBSYS_EXPORT 手动注册,否则它们后面驱动的 probe 会拿到未配置的引脚状态。详见第 9 章。

4 · 驱动开发的两条路线

本章解决“我这颗外设到底该怎么写”。选对路线,可能只需要填五个函数; 选错路线,可能在重复实现别人早就写好并经过量产验证的缓冲区逻辑。

4.1 决策依据

判断顺序建议如下,只要有一条回答「是」就走复用路线:

  1. 该外设是否属于某个已有设备框架(见 4.2 节清单)?
  2. 是否需要融进现有生态——FinSH 控制台、ulog、at 组件、sensor 框架、各种软件包?
  3. 是否需要跟别人写的应用代码对接,而对方只会用标准 rt_device_* API?
  4. 未来是否可能换 MCU / 换同类型芯片?

四个问题都回答「否」,才有理由自建设备:通常是专用 ASIC、自定义 FPGA 逻辑、 或者需要一种框架没定义的流式/块式混合语义的器件。

4.2 内置设备框架清单

下表列出主线 components/drivers 里常见的框架。写驱动前先在这张表里找一遍, 能找到就用它的注册函数,不要另起炉灶。

类别注册 / 创建入口驱动要做的事
串口 serialrt_hw_serial_register(&serial, name, flag, data)实现 rt_uart_ops 五个钩子 + 一个 ISR(第 5 章)
SPI 总线rt_spi_bus_register(&bus, name, &ops)实现 rt_spi_ops 的 configure / xfer
SPI 从设备rt_spi_bus_attach_device_cspin(dev, name, bus_name, cs, data)无需写 ops,应用层直接调 rt_spi_transfer_message
I2C 总线rt_i2c_bus_device_register(&bus, bus_name)实现 rt_i2c_bus_device_ops 的 master_xfer
PIN(GPIO)rt_device_pin_register(name, &ops, data)实现引脚编号映射表 + rt_pin_ops 七个钩子
RTCrt_hw_rtc_register(&rtc, name, flag, data)实现读写时间的 ops
看门狗 WDTrt_hw_watchdog_register(&wdt, name, flag, data)启动 / 超时 / 喂狗 ops
PWM / ADC / DAC各自框架的 rt_device_xxx_registerlittle need:通道配置与实际输出/采样
HWTIMER / 编码器rt_device_hwtimer_register硬件定时器计数与超时回调
CANrt_hw_can_register报文过滤、收发、波特率段
块设备 / MTDrt_device_register + 类 Standard 类型实现 RT_DEVICE_CTRL_BLK_* 系列命令(见 7.5 节)
网络 netdevrt_netdev_add 相关实现链路层收发与 netdev_ops
「注册」与「框架」的边界

注意上表的分工:注册函数由框架提供,你提供的是 ops 里的那几个钩子函数与硬件相关的初始化。绝大多数情况下你不需要关心框架内部怎么组织缓冲区,这是 RT-Thread 相对裸机开发最大的省力点。

4.3 两条路线工作量对比

图 7 把两条路线各自要做的东西摊开。真正的差异不在代码行数,而在谁负责 infra。

路线 A「复用框架」与路线 B「自建设备」的工作量分布路线 A · 复用已有设备框架适用:UART / SPI / I2C / PIN / CAN / RTC / WDT / PWM 等标准外设要你写的底层 ops(UART 只需 5 个函数)+ 注册调用框架替你做环形缓冲、读写互斥、配置结构、中断事件定义自动获得生态兼容:FinSH、ulog、at_device、各种软件包可直接用风险点必须严格按框架约定命名与填字段,否则上层行为异常路线 B · 自建 rt_device适用:专用 ASIC、自定义外设、框架未覆盖的器件要你写的init / open / close / read / write / control 全套 + 注册自己补 infra缓冲策略、并发保护、阻塞与非阻塞语义、错误码需设计control 命令码体系、设备类别、命名(见 2.4 节)收益完全自主:可以为特殊器件定制语义与数据流判断口诀:框架覆盖得到的类别一律走 A;走 B 之前,先确认社区没有等价框架(packages 里往往有惊喜)
图 7 · 两条开发路线的工作量分布与各自的收益、风险

4.4 从裸机驱动改造而来

很多项目手上有现成的裸机驱动:初始化函数、查询式收发、几个延时会要塑料??。 把它们改造成标准 RT-Thread 驱动,推荐按下面五步做,每一步都是可以单独验证的。

  1. 先跑通裸机:用逻辑分析仪或串口确认硬件 OK。带着硬件问题进 RTOS 会把问题复杂度翻倍。
  2. 抽设备私有结构体:把裸机里散落的全局变量集中到一个 struct xxx { struct rt_device parent; … } 里。
  3. 把 gpio/init 拆成 init 钩子:原裸机初始化中「一次性的、全局的」部分进 init,「每次打开才需要的」进 open。
  4. 查询式收发转中断 + 缓冲:裸机的轮询 while 必须移除(会阻塞整个线程),改成 ISR 收 + 上层 read,见第 8 章。
  5. 补 control 与收尾:把裸机里那些配置宏换算成 control 命令,确认 close 能让硬件进入可重入状态。
/* 改造前后对照 —— 一个典型的报错根源 */

/* 【裸机思维】下面这段搬到 RTOS 里一定会出问题 */
void uart_send(uint8_t *buf, uint16_t len)
{
    for (uint16_t i = 0; i < len; i++)
    {
        while (!(USART2->SR & USART_SR_TXE));   /* ✗ 忙等:把整个线程卡死 */
        USART2->DR = buf[i];
    }
    while (!(USART2->SR & USART_SR_TC));        /* ✗ 同上 */
}

/* 【RT-Thread 写法】写侧可以是阻塞的(框架用完成量同步),
   但阻塞必须是「线程挂起」而不是「while 轮询」 */
static rt_ssize_t my_write(rt_device_t dev, rt_off_t pos, const void *buffer, rt_size_t size)
{
    /* 交给框架:由框架的完成量 or ISR 驱动后续字节 */
    rt_size_t n = rt_hw_serial_write((struct rt_serial_device *)dev, pos, buffer, size);
    return (rt_ssize_t)n;
}
/* read 侧:拿不到数据就让出 CPU */
static rt_ssize_t my_read(rt_device_t dev, rt_off_t pos, void *buffer, rt_size_t size)
{
    rt_size_t len = rt_hw_serial_read((struct rt_serial_device *)dev, pos, buffer, size);
    if (len == 0 && !(dev->open_flag & RT_DEVICE_OFLAG_NONBLOCKING))
    {
        /* 等待 rx_indicate 释放的信号量等 —— 由上层负责等待,
           驱动内部只负责在有数据时返回字节数 */
    }
    return (rt_ssize_t)len;
}
轮询习惯是最难改的一件事

裸机转 RTOS 最常见的残留:while(!flag);。它在单任务里是「等待」,在多任务里是「独占 CPU」。识别方法很简单——任何没有 rt_thread_yield / 信号量 / 延时参与的 while,都是忙等,必须改成挂起等待或至少带超时的让出。另外忙等期间中断仍在触发,若 ISR 修改了同一个 flag,还会引入重入问题。

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

再分享给同事

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