RT-Thread 驱动开发指南
编制日期 2026-10-05
I/O 设备模型 · rt_device 生命周期 · 两套框架的分工 · 中断 DMA 环形缓冲 · 交付核查清单
一句话结论:RT-Thread 驱动开发的本质不是“操作寄存器”, 而是把一个硬件对象翻译成一个 rt_device 对象,并让它的 ops、ref_count、rx_indicate、自动初始化级别全部符合内核约定。 做到这一点,上层 90% 的代码可以换 MCU 而一行不改;做不到,驱动就退化成一个挂 rt_device 外壳的裸机 API。 这份指南按“先选框架 → 再选路线 → 填 ops → 接中断 → 交付核查”的顺序,把每一步的决定点和坑点摊开写。
这份文档解决什么
写 RT-Thread 驱动时,真正卡住人的通常不是某个寄存器怎么配,而是下面这些结构性判断:
我这颗芯片该走经典模型还是 DM?init 和 open 到底谁负责初始化硬件?
驱动注册早了总线还没起来怎么办?中断里能调哪些 API?环形缓冲满了该覆盖还是丢弃?
设备名起冲突了为什么注册悄悄失败?这些问题的答案散落在源码、示例和社区帖子里,
本指南把它们收拢成一套可照做的流程,并给出可直接编译的骨架代码与可逐条勾选的核查清单。
面向 RT-Thread 4.x / 5.x 主线。凡涉及具体结构体字段与 API 签名的写法,均以你所用版本的头文件
rtdef.h / drivers/*.h 为准。
1 · 驱动体系全景
本章解决“我在给谁写代码”:先把 RT-Thread 把驱动切成哪几层、每层负责什么说清楚,
然后看清 rt_device 这一个结构体为什么能让上层与硬件解耦,最后给出框架选型的两级决策。
1.1 四层模型:谁负责什么
RT-Thread 的驱动体系通常被画成三层(应用层 / 管理层 / 驱动层)。真正落地时「驱动层」还要再拆成 设备驱动框架层与硬件底层驱动两层,否则说不清「为什么我这颗 UART 只需要写 5 个函数」。 图 1 给出完整四层视图。
这四层的边界不是形式主义,而是变更成本的分界线:从上往下的调用是允许的, 一旦出现反向依赖(例如应用直接 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 反查回驱动实例的唯一正规通道)。其余字段由管理层维护,驱动不该自行改动。
| 字段 | 由谁写 | 作用与常见误区 |
|---|---|---|
parent | RT-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、资源受限设备、绝大多数 BSP | MPU / 复杂 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 把两级决策画成一棵树,
顺着走能直接落到本指南对应章节。
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 展开:
把这套语义翻译成驱动编写时的三条要求:
- init 幂等且只负责「让硬件可工作」:时钟使能、引脚复用、DMA 通道申请。内核保证它只在第一次 open 时被调用一次,所以不要在里面做多次累加的事情。
- open 负责使能申请者所需的能力:开中断、启动接收。多任务先后 open 同一设备时,open 只在计数为 0 时被回调一次;如果你的外设必须按「每个使用者一份资源」来开,请在自己的 priv 里再记一层计数。
- close 必须能接受被推迟:只有 ref_count 归零才会回调 close。任何一方忘了 close,硬件就一直开着,低功耗场景会被这个问题咬到。
应用层最常见的疏漏是只判 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 给出一次「查找 → 打开 → 中断通知 → 读取 → 关闭」的完整时序。
/* ---- 应用层:设置回调 ---- */
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();
}回调被执行时,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")① 必须写在大括号外面(它是变量定义,不是语句),放在函数体内会得到奇怪的同名局部符号;② 被它引出的函数签名必须是 int f(void);③ 函数里不能假定内核对象系统已完全就绪——在 INIT_BOARD_EXPORT 阶段创建信号量需要谨慎,因为此时调度器尚未启动,任何阻塞都会直接把系统挂死。
3.2 依赖顺序与踩坑
同一 Stage 内各函数的执行先后取决于链接顺序(本质上是编译单元被打包进镜像的顺序), 这在工程里几乎不可控。因此凡是存在依赖,就必须把它们放到不同阶段去。图 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.0 | INIT_CORE_EXPORT | platform 总线自身注册:rt_bus_register(&platform_bus) |
| 1.1 | INIT_SUBSYS_EXPORT | 必须早于 DT 扫描的早期驱动(如 pinctrl、部分 IRQ chip),手动调用 rt_platform_driver_register() |
| 1.2 | INIT_PLATFORM_EXPORT | 遍历设备树节点,生成 platform device 挂到总线上;若驱动已注册则立即匹配并 probe |
| 3 | INIT_DEVICE_EXPORT | RT_PLATFORM_DRIVER_EXPORT 在此注册驱动,随后对总线上所有未匹配设备逐一 probe |
在 DM 下,用 RT_PLATFORM_DRIVER_EXPORT 注册的驱动天然晚于设备树节点出现,所以不用担心顺序;反而是那些必须在 DT 扫描前就绪的底层(pinctrl、clock)必须走 INIT_SUBSYS_EXPORT 手动注册,否则它们后面驱动的 probe 会拿到未配置的引脚状态。详见第 9 章。
4 · 驱动开发的两条路线
本章解决“我这颗外设到底该怎么写”。选对路线,可能只需要填五个函数; 选错路线,可能在重复实现别人早就写好并经过量产验证的缓冲区逻辑。
4.1 决策依据
判断顺序建议如下,只要有一条回答「是」就走复用路线:
- 该外设是否属于某个已有设备框架(见 4.2 节清单)?
- 是否需要融进现有生态——FinSH 控制台、ulog、at 组件、sensor 框架、各种软件包?
- 是否需要跟别人写的应用代码对接,而对方只会用标准
rt_device_*API? - 未来是否可能换 MCU / 换同类型芯片?
四个问题都回答「否」,才有理由自建设备:通常是专用 ASIC、自定义 FPGA 逻辑、 或者需要一种框架没定义的流式/块式混合语义的器件。
4.2 内置设备框架清单
下表列出主线 components/drivers 里常见的框架。写驱动前先在这张表里找一遍,
能找到就用它的注册函数,不要另起炉灶。
| 类别 | 注册 / 创建入口 | 驱动要做的事 |
|---|---|---|
| 串口 serial | rt_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 七个钩子 |
| RTC | rt_hw_rtc_register(&rtc, name, flag, data) | 实现读写时间的 ops |
| 看门狗 WDT | rt_hw_watchdog_register(&wdt, name, flag, data) | 启动 / 超时 / 喂狗 ops |
| PWM / ADC / DAC | 各自框架的 rt_device_xxx_register | little need:通道配置与实际输出/采样 |
| HWTIMER / 编码器 | rt_device_hwtimer_register | 硬件定时器计数与超时回调 |
| CAN | rt_hw_can_register | 报文过滤、收发、波特率段 |
| 块设备 / MTD | rt_device_register + 类 Standard 类型 | 实现 RT_DEVICE_CTRL_BLK_* 系列命令(见 7.5 节) |
| 网络 netdev | rt_netdev_add 相关 | 实现链路层收发与 netdev_ops |
注意上表的分工:注册函数由框架提供,你提供的是 ops 里的那几个钩子函数与硬件相关的初始化。绝大多数情况下你不需要关心框架内部怎么组织缓冲区,这是 RT-Thread 相对裸机开发最大的省力点。
4.3 两条路线工作量对比
图 7 把两条路线各自要做的东西摊开。真正的差异不在代码行数,而在谁负责 infra。
4.4 从裸机驱动改造而来
很多项目手上有现成的裸机驱动:初始化函数、查询式收发、几个延时会要塑料??。 把它们改造成标准 RT-Thread 驱动,推荐按下面五步做,每一步都是可以单独验证的。
- 先跑通裸机:用逻辑分析仪或串口确认硬件 OK。带着硬件问题进 RTOS 会把问题复杂度翻倍。
- 抽设备私有结构体:把裸机里散落的全局变量集中到一个
struct xxx { struct rt_device parent; … }里。 - 把 gpio/init 拆成 init 钩子:原裸机初始化中「一次性的、全局的」部分进
init,「每次打开才需要的」进open。 - 查询式收发转中断 + 缓冲:裸机的轮询 while 必须移除(会阻塞整个线程),改成 ISR 收 + 上层 read,见第 8 章。
- 补 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,还会引入重入问题。