RT-Thread 设备框架与驱动移植实战:从 FreeRTOS”各写各的 SPI”搬过来,我把 rt_device 这层统一套路补齐了
上期做完 RTOS 选型,项目落到了 RT-Thread 上。内核层替换其实没费多少劲,真正让我停下来重想一遍的,是驱动怎么组织这件事:在 FreeRTOS 上干了几年,大家习惯是”每个模块自己包 SPI,自己拉 CS,自己加锁”,三四个模块同时挂在一根 SPI 总线上之后,代码里能翻出三套互不兼容的收发封装。这次借着把一个老传感器驱动从 FreeRTOS 板子搬到 RT-Thread,我把 rt_device / SPI 设备框架从头过了一遍,把”驱动到底该怎么挂在 RTOS 上”这件事理顺了。这篇就是那次的完整记录。
先看老代码的问题:不是不能跑,而是没法长
先交代背景。原来的驱动是这样组织起来的(FreeRTOS + HAL):
/* 每个模块各写一份,风格还都不一样 */
uint8_t sensor_read_reg(uint8_t reg)
{
xSemaphoreTake(spi_lock, portMAX_DELAY);
HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET);
HAL_SPI_Transmit(&hspi1, ®, 1, 100);
HAL_SPI_Receive(&hspi1, &val, 1, 100);
HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);
xSemaphoreGive(spi_lock);
return val;
}
板子上还有 Flash 和一颗 ADC,各自又是一套:Flash 驱动自己定义了一把锁,ADC 驱动干脆没加锁(”反正现在只有一个任务用它”)。问题清单拉出来其实很典型:
- 总线锁不统一:三把锁管一根总线,锁的粒度和超时策略各不相同,最差的那个就等于没锁;
- CS 时序各管各的:有人整帧包住,有人逐字节拉,硬件走查时谁也说不清哪个是对的;
- 换板即重写:HAL 句柄、GPIO 宏全部散落在业务代码里,BSP 一换,驱动跟着重抄一遍;
- 没法统一调试:没有公共入口,想插一个”所有 SPI 传输都打点统计”的钩子,要改三个模块。
结论很明确:缺一层”设备抽象”。RT-Thread 的 rt_device 框架正好就是干这个的,那就顺势搬过去。
rt_device 框架全景:一个结构体、六个函数指针
RT-Thread 把所有对外访问的”东西”统一抽象成 rt_device,核心就是一个结构体加一张操作表:
struct rt_device
{
struct rt_object parent; /* 内核对象基类(名字、类型、链表) */
enum rt_device_class_type type; /* Char / Block / SPI / I2C ... */
rt_uint16_t open_flag;
rt_uint16_t ref_count; /* 打开计数,close 到 0 才真关 */
rt_uint16_t device_id;
/* 驱动需要实现的回调,框架统一调度 */
const struct rt_device_ops *ops;
/* 异步通知回调,由应用通过 rt_device_set_rx_indicate 挂上 */
rt_err_t (*rx_indicate)(rt_device_t dev, rt_size_t size);
rt_err_t (*tx_complete)(rt_device_t dev, void *buffer);
};
struct rt_device_ops
{
rt_err_t (*init) (rt_device_t dev);
rt_err_t (*open) (rt_device_t dev, rt_uint16_t oflag);
rt_err_t (*close) (rt_device_t dev);
rt_size_t (*read) (rt_device_t dev, rt_off_t pos, void *buf, rt_size_t size);
rt_size_t (*write) (rt_device_t dev, rt_off_t pos, const void *buf, rt_size_t size);
rt_err_t (*control)(rt_device_t dev, int cmd, void *args);
};
理解这套框架,抓住三点就够了:
- 注册:驱动在板级初始化阶段调用
rt_device_register()把自己挂进内核对象表,带一个全局唯一的名字; - 查找:应用用
rt_device_find("名字")拿到句柄,拿到的是接口,不是实现——应用代码从此不 include 任何芯片头文件; - 调度:open/close/read/write/control 全部走 ops 表,框架顺手管好 open 计数和引用保护。
这套东西在 UART 上是现成的(serial 框架),在 SPI 上则是”总线框架 + 设备”两层,下一节细说。
SPI 的两层结构:总线是一层,CS 是另一层
刚开始看文档时我栽过一个理解坑:以为”一个 SPI 外设 = 一个设备”。实际上 RT-Thread 的 SPI 框架是两层的:
| 层 | 对象 | 数量 | 归谁管 |
|---|---|---|---|
| 总线层 | struct rt_spi_bus |
每个 SPI 外设一个(spi1/spi2) | BSP 的 drv_spi.c 实现 |
| 设备层 | struct rt_spi_device |
每个挂载的片选一个(spi10/spi11) | rt_hw_spi_device_attach() 创建 |
spi10 这个名字读法是”spi1 总线的 0 号片选“。总线层负责时序、速率、DMA;设备层只管一件事——自己这根 CS 的选通范围。一根 spi1 上挂三个器件,就是 spi10、spi11、spi12 三个设备,互斥由框架的总线锁统一保证,这就是我前面那三把应用层锁的最终归宿。
挂载动作通常在板级初始化里做(BSP 的 main.c 或自定义板级文件):
#include <rtdevice.h>
#include "drv_spi.h"
static int board_spi_attach(void)
{
/* 把一颗 Flash 挂到 spi1 总线,CS 是 PA4 */
rt_hw_spi_device_attach("spi1", "spi10", GET_PIN(A, 4));
/* 同一根总线上再挂传感器,CS 是 PB0 */
rt_hw_spi_device_attach("spi1", "spi11", GET_PIN(B, 0));
return 0;
}
INIT_DEVICE_EXPORT(board_spi_attach);
注意这里用了 INIT_DEVICE_EXPORT 而不是 INIT_BOARD_EXPORT——挂 CS 设备依赖总线先就绪,放太早的自动初始化段里会 find 不到总线。我第一次就把它放进了 INIT_BOARD_EXPORT,启动日志里一句 attach failed,查了半天时序才发现是初始化段放早了。
配置速率与模式:rt_spi_configure 的正确时机
设备挂上后,传输前要配置。配置是每设备一份的,正好对应”总线上不同器件速率不同”的现实:
static struct rt_spi_device *spi_dev;
static int spi_dev_init(void)
{
spi_dev = (struct rt_spi_device *)rt_device_find("spi10");
RT_ASSERT(spi_dev != RT_NULL);
struct rt_spi_configuration cfg = {0};
cfg.data_width = 8;
cfg.mode = RT_SPI_MODE_0 | RT_SPI_MSB; /* CPOL=0 CPHA=0 */
cfg.max_hz = 20 * 1000 * 1000; /* 20MHz */
rt_spi_configure(spi_dev, &cfg);
return 0;
}
三个容易踩的点:
- configure 要在第一次传输之前完成。框架在每次 take_bus 时套用配置,传输中途改配置是允许的,但会让本次传输之后总线上多一次模式切换的开销,别在热路径里反复 configure;
- max_hz 写需求值,不是分频值。驱动层会按总线时钟自动分频取不超过 max_hz 的最近档位。但别指望”写 1Hz 它就给你 1Hz”——分频档位是离散的,实际速率只会低于等于请求值,对时序敏感的外设要拿逻辑分析仪确认实际波形;
- mode 宏直接用 RT_SPI_MODE_0~3 组合宏,别自己手拼 CPOL/CPHA 位。我就见过手拼时把 LSB 位一起带进去,导致收发全反的案例。
传输 API 选型:send_then_recv 是读寄存器的正解
SPI 设备层的传输 API 我整理成一张表,按场景对号入座:
| API | 适用场景 | CS 行为 |
|---|---|---|
rt_spi_send() |
只写:发命令、发数据 | 单次选通 |
rt_spi_recv() |
只读:连续读数据流 | 单次选通 |
rt_spi_transfer() |
全双工:一帧内读写同时发生 | 单次选通 |
rt_spi_send_then_recv() |
读寄存器:先发地址再收数据 | 两次传输间 CS 不释放 |
rt_spi_send_then_send() |
先命令后参数(如写使能+数据) | 同上,CS 不释放 |
老驱动里 sensor_read_reg 那种”发地址、收数据”,在裸 HAL 里必须手动保证 CS 中间不拉高,这就是最容易写错的地方。搬到框架后一行搞定:
static rt_err_t sensor_read_reg(struct rt_spi_device *dev,
uint8_t reg, uint8_t *val)
{
uint8_t cmd = reg | 0x80; /* 读标志位 */
return rt_spi_send_then_recv(dev, &cmd, 1, val, 1);
}
多字节连续读同理,把长度改成 N 就行,框架保证整段期间 CS 保持有效:
rt_err_t sensor_read_burst(struct rt_spi_device *dev,
uint8_t reg, uint8_t *buf, rt_size_t len)
{
uint8_t cmd = reg | 0x80;
return rt_spi_send_then_recv(dev, &cmd, 1, buf, len);
}
反过来,写寄存器加数据要用 send_then_send 而不是 send_then_recv——后者是”发完命令收数据”的语义,写场景用它会导致多一个读时钟尾巴。这套 API 语义差异是我给团队做代码评审时反复纠的点,现在直接固化成了写法约定。
互斥归属:把应用层的锁全删掉
搬移驱动时最纠结的问题:原来那三把锁要不要搬?答案是全删。框架在两个层面已经做了保护:
- 总线级:每次传输前框架会 take 总线互斥锁(对应释放动作在传输完成时),跨设备互斥自动成立;
- 消息级:
send_then_recv内部把两段传输作为一个原子消息组提交,中间不会被别的设备插队,CS 时序天然完整。
所以应用层原来 xSemaphoreTake(spi_lock) 那一坨直接删掉即可。唯一需要自己加锁的场景是驱动内部有非原子的状态序列(比如 Flash 的”擦除-编程-校验”三步之间不能被打断,不是为了保护总线而是保护流程),这时在驱动内部加 rt_mutex,粒度放在”业务流程”上,而不是”每次传输”上。
这个认知纠正了我一个老习惯:以前默认”每个驱动自带上锁才算严谨”,实际上锁加错层比不加锁更危险——两层锁嵌套后,超时策略一旦不一致就是死锁温床。
把传感器驱动改造成 rt_device:读接口统一成 read/write
SPI 框架层的挂载解决的是”怎么传”,驱动本身我进一步做成了标准 rt_device(char 型),让应用统一走 rt_device_read()。核心就是把 ops 表填上:
static rt_size_t sensor_read(rt_device_t dev, rt_off_t pos,
void *buf, rt_size_t size)
{
struct sensor_dev *s = (struct sensor_dev *)dev;
struct rt_spi_device *spi = s->spi;
/* pos 作为寄存器地址,size 为读取字节数 */
uint8_t cmd = (uint8_t)pos | 0x80;
if (rt_spi_send_then_recv(spi, &cmd, 1, buf, size) != RT_EOK)
return 0;
return size;
}
static rt_err_t sensor_control(rt_device_t dev, int cmd, void *args)
{
struct sensor_dev *s = (struct sensor_dev *)dev;
switch (cmd)
{
case SENSOR_CMD_SET_ODR:
s->odr = *(rt_uint16_t *)args;
return sensor_write_reg(s->spi, REG_ODR, s->odr);
default:
return -RT_ERROR;
}
}
const static struct rt_device_ops sensor_ops =
{
.init = sensor_init,
.open = RT_NULL,
.close = RT_NULL,
.read = sensor_read,
.write = sensor_write,
.control = sensor_control,
};
注册后,应用侧的访问就变成了这样:
rt_device_t dev = rt_device_find("sensor0");
rt_device_open(dev, RT_DEVICE_OFLAG_RDWR);
uint8_t raw[6];
rt_device_read(dev, REG_ACCEL_XOUT, raw, 6); /* 读 XYZ 六字节 */
rt_uint16_t odr = 200;
rt_device_control(dev, SENSOR_CMD_SET_ODR, &odr); /* 配置输出速率 */
这一步做完,收益是实打实的:应用代码里再无 SPI 细节,换平台只需重写 drv 层;control 的命令字成了驱动的”配置协议”,比以前散落的 set_xxx() 函数集合好评审得多。
中断与异步:rx_indicate 回调的正确用法
传感器有 DRDY 中断、数据到了才读的场景,就走 rx_indicate 这条异步路径:
/* 应用侧:挂回调 */
static rt_err_t on_rx_indicate(rt_device_t dev, rt_size_t size)
{
rt_sem_release(&data_sem); /* 或直接把 size 存到队列 */
return RT_EOK;
}
rt_device_set_rx_indicate(dev, on_rx_indicate);
/* 驱动侧:DRDY 中断服务里通知框架 */
void DRDY_IRQHandler(void)
{
rt_interrupt_enter();
if (dev->rx_indicate)
dev->rx_indicate(dev, 1);
rt_interrupt_leave();
}
两个纪律必须守住:
- 回调在中断上下文执行,里面只允许做”释放信号量/写队列”这类非阻塞动作,千万别在里面直接
rt_device_read——读操作走 SPI 传输,是要阻塞等总线的,在中断里做必挂; - 回调指针判空再调(框架惯例如上),应用没挂回调时驱动不能崩。
这套模式移植自 serial 框架,理解了 serial 的 INT_RX 用法,这套就零成本迁移。
验证三板斧:msh、list_device、逻辑分析仪
移植完怎么确认框架工作正常?我固定用三步:
第一步,msh 里 list_device,看设备注册没有、类型对不对:
msh> list_device
device type ref count
-------- -------------------- ----------
spi10 SPI Device 0
spi11 SPI Device 0
spi1 SPI Bus 0
sensor0 Character Device 0
uart1 Character Device 2
ref count 是现场排查利器:close 忘了调用、open 后异常路径没走 close,这里一眼就能看出来。
第二步,先用最短的读写各来一发,逻辑分析仪抓 CS/SCLK/MOSI/MISO 四线,核对三件事:CS 极性和时序、实际 SCLK 频率(对照 max_hz 的分频结果)、send_then_recv 期间 CS 是否保持。
第三步,两个任务并发读两个设备(spi10/spi11 各挂一个任务,各读各的寄存器),跑压力确认没有串扰。框架的总线锁是否真的兜住了互斥,这一步是唯一能给出确定答案的验证。
踩坑记录:五个印象最深的
- attach 放错初始化段。
INIT_BOARD_EXPORT太早,总线还没注册,attach 静默失败。放INIT_DEVICE_EXPORT才对(本文前面已提,这是花了我最久的一个); - 忘了使能 SPI 外设的 Kconfig。menuconfig 里只开了
RT_USING_SPI,没开 BSP 的BSP_USING_SPI1,结果 find 不到 spi1 总线,attach 报”bus not found”——先确认总线在,再挂设备,排查顺序别反; - CS 引脚被两边同时控制。老习惯顺手写了一句
rt_pin_write(cs, 0)想自己拉低,和框架的 CS 管理打架,时序全乱。结论:用框架管 CS 就一根手指都别碰; - send_then_recv 传成了 transfer。读寄存器场景误用全双工 transfer,第二段数据的时间窗口不对,器件状态机直接乱掉,寄存器读回来全是 0xFF;
- 两个设备速率冲突被忽略。spi10 20MHz、spi11 只能 2MHz,配置各写各的没报错,但业务上 Flash 大块读写把低速器件的实时性饿死了。后来在系统设计层加约束:同一总线上的器件按”最慢器件的中断延迟需求”规划访问模式,而不是各管各的速率。
迁移收益复盘:哪些账真的回本了
搬完之后我算了一笔账,哪些投入是值得的:
| 改造项 | 投入 | 回报 |
|---|---|---|
| 驱动统一 rt_device 接口 | 约 2 天 | 应用与 BSP 解耦,换板只改 drv 层 |
| 删应用层三把锁、交给框架总线锁 | 半天(含回归) | 消除锁嵌套死锁风险,评审少一类问题 |
| CS/时序交给框架管理 | 几乎零投入 | send_then_recv 语义固化,时序错误类 bug 清零 |
| control 命令字协议化 | 1 天 | 配置入口收敛,新增配置项不再改头文件 |
| 统一打点统计钩子 | 半天 | 所有 SPI 传输一处埋点,性能画像随时可看 |
一句话总结:rt_device 这层抽象的价值不在”多写了几十行注册代码”,而在于把”驱动怎么被访问”这件事从每个模块的自由发挥,收敛成了全项目一套约定。
自查清单
- ☐ SPI 总线已使能(RT_USING_SPI + BSP_USING_SPIn),list_device 能看到 spiX Bus;
- ☐ 每个 CS 独立 rt_hw_spi_device_attach,设备名遵循”spiX + 片选序号”;
- ☐ attach 放在 INIT_DEVICE_EXPORT,总线先于设备就绪;
- ☐ rt_spi_configure 在首次传输前完成,max_hz 写需求值并实测分频结果;
- ☐ 读寄存器用 send_then_recv、写寄存器用 send_then_send、全双工才用 transfer;
- ☐ 应用层不再持有任何总线锁,总线互斥完全交给框架;
- ☐ CS 引脚无任何手工拉高拉低代码;
- ☐ 驱动若做成 rt_device,ops 六项职责划分清晰(read 用 pos 传寄存器地址、control 用命令字);
- ☐ rx_indicate 回调内无阻塞调用,回调指针判空;
- ☐ DRDY 中断服务包含 rt_interrupt_enter/leave 配对;
- ☐ 并发双任务压测过跨设备互斥,ref count 无泄漏;
- ☐ 同总线多器件的速率与实时性需求做过统筹,不存在饿死组合。
下期预告:RT-Thread 这套搬顺了之后,接下来聊聊低功耗外设设计与传感器电源门控——表计设备上传感器挂在高电平域上天天漏电的问题,以及怎么用高边开关 + 框架级 suspend/resume 把静态功耗压下去。