RT-Thread 设备框架与驱动移植实战:从 FreeRTOS”各写各的 SPI”搬过来,我把 rt_device 这层统一套路补齐了

2026年10月1日 0 By admin

上期做完 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, &reg, 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 各挂一个任务,各读各的寄存器),跑压力确认没有串扰。框架的总线锁是否真的兜住了互斥,这一步是唯一能给出确定答案的验证。

踩坑记录:五个印象最深的

  1. attach 放错初始化段。INIT_BOARD_EXPORT 太早,总线还没注册,attach 静默失败。放 INIT_DEVICE_EXPORT 才对(本文前面已提,这是花了我最久的一个);
  2. 忘了使能 SPI 外设的 Kconfig。menuconfig 里只开了 RT_USING_SPI,没开 BSP 的 BSP_USING_SPI1,结果 find 不到 spi1 总线,attach 报”bus not found”——先确认总线在,再挂设备,排查顺序别反;
  3. CS 引脚被两边同时控制。老习惯顺手写了一句 rt_pin_write(cs, 0) 想自己拉低,和框架的 CS 管理打架,时序全乱。结论:用框架管 CS 就一根手指都别碰;
  4. send_then_recv 传成了 transfer。读寄存器场景误用全双工 transfer,第二段数据的时间窗口不对,器件状态机直接乱掉,寄存器读回来全是 0xFF;
  5. 两个设备速率冲突被忽略。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 把静态功耗压下去。