一、前言
前面我们学了一大堆用C语言实现面向对象的编程,全都是为了这一部分铺垫,RTThread在使用UART、tim、ADC等外设时,采用的是设备模型框架的形式,所有代码都和继承、封装、多态相关。
刚学 RTT 设备模型的时候,我一直很懵: 同样都是 STM32 外设,为什么 UART 用rt_device_read/write收发? ADC 有单独的rt_adc_read,不用rt_device_read? PWM 有rt_pwm_set,底层却是调用rt_device_control? 都是rt_device派生出来的设备,为什么写法差别这么大?
二、设备模型框架
1.是什么
设备模型框架,简单来说就是把所有的外设(UART、ADC、TIM等)抽象成一个父类device,用一个统一的结构体,对外设进行初始化,打开、关闭、读设备、写设备等操作,这一个框架就是设备模型框架。
2.为什么需要
我们在写代码时很容易遇到串口 1 改成串口 2、或者换成 USB 虚拟串口,**所有调用的地方都要改代码。**或者你可能会遇到同一个项目,从STM32换到GD32,因为硬件函数变了,业务代码几乎全部从新写,而且串口、USB、spi、ADC等外设各有各的函数,明明很多功能类似,比如输出日志,写入数据等操作,却要调用各自对应的函数,就会很复杂。
RTThread的设备模型框架就负责解决这些问题,所有外设全部用同一套函数来进行初始化、打开、关闭等操作,并且应用层只认「设备名」和「通用读写接口」,完全不用管底层是什么硬件、哪个厂家的芯片。
设备模型就是用面向对象思想,给所有硬件套了一层统一的「壳」,我们实际写代码时,只和壳打交道就能完成全部设计。
3.对比理解
参考上一期讲的动物-猫-狗模型,我们能完全进行对比理解。
| 面向对象概念 | 动物-猫-狗模型 | RT-Thread 设备模型 |
|---|---|---|
| 父类(抽象接口) | struct animal |
struct rt_device(通用设备结构体) |
| 子类(具体实现) | struct cat、struct dog |
struct rt_serial_device(串口)、struct rt_adc_device(ADC) |
| 继承 | 子类第一个成员放父类 | 具体设备结构体第一个成员放 rt_device parent |
| 多态 | 父类函数指针,子类赋值不同实现 | rt_device里的read/write函数指针,不同驱动挂不同的硬件函数 |
| 向上转型 | (animal_t *)&cat |
(rt_device_t)&uart_dev 注册设备 |
| 向下转型 | cat_speak里转回cat_t* |
驱动函数里转回具体设备指针 |
4.源码详解模型结构框架
4.1父类结构体rt_device
我们先看看我们的父类结构体------也就是device这个大的结构体包含什么内容。
我们能看到里面有init初始化函数,open打开设备,read读取设备,write写入设备,close关闭设备,这些函数指针。所有设备都有这几个操作,所以抽象成统一接口。不同驱动给这些指针挂上自己的硬件实现,应用层用父类指针调用时,自动执行对应驱动的代码。
cpp
struct rt_device
{
struct rt_object parent; /* 内核基础对象(自带设备名、链表节点) */
enum rt_device_class_type type; /* 设备类型:字符设备/块设备等 */
rt_uint16_t flag; /* 设备参数标志 */
rt_uint16_t open_flag; /* 设备是否打开的标记 */
rt_uint8_t ref_count; /* 设备被引用的次数 */
/* 通用收发回调(所有设备都可以有) */
rt_err_t (*rx_indicate)(rt_device_t dev, rt_size_t size);
rt_err_t (*tx_complete)(rt_device_t dev, void *buffer);
const struct rt_device_ops *ops; /* 【核心】统一设备操作函数集 */
void *user_data; /* 用户自定义私有数据 */
};
typedef struct rt_device *rt_device_t;
struct rt_device_ops
{
rt_err_t (*init) (rt_device_t dev); // 初始化
rt_err_t (*open) (rt_device_t dev); // 打开
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); // 控制
};
4.2串口子类rt_serial_device
我们可以看到,第一个是父类结构体,为的是实现继承,然后串口自己特有的与其他设备不一样的东西就放在这个子类结构体里面。
cpp
struct rt_serial_device
{
struct rt_device parent; /* 【继承】父类,必须放在第一个位置 */
/* 下面全是串口自己独有的东西 */
const struct rt_uart_ops *ops; // 串口底层硬件操作函数集(操作寄存器用)
struct serial_configure config; // 串口参数:波特率、数据位、停止位、校验位
void *serial_rx; // 串口接收缓冲区
void *serial_tx; // 串口发送缓冲区
};
typedef struct rt_serial_device *rt_serial_t;
struct rt_uart_ops
{
rt_err_t (*configure)(struct rt_serial_device *serial, struct serial_configure *cfg);
int (*putc)(struct rt_serial_device *serial, char c); // 底层发一个字节
int (*getc)(struct rt_serial_device *serial); // 底层收一个字节
};
4.3ADC子类rt_adc_device
和串口完全一样的设计思路:父类打头,后面跟自己独有的硬件参数和操作函数,其他的外设也都是这种思路的
cpp
struct rt_adc_device
{
struct rt_device parent; /* 【继承】父类,必须放在第一个位置 */
/* 下面全是ADC自己独有的东西 */
const struct rt_adc_ops *ops; // ADC底层硬件操作函数集
rt_uint32_t resolution; // ADC分辨率(12位/16位)
rt_uint32_t vref; // ADC参考电压
};
struct rt_adc_ops
{
rt_err_t (*enabled)(struct rt_adc_device *adc, rt_uint32_t channel, rt_bool_t enabled);
rt_err_t (*read)(struct rt_adc_device *adc, rt_uint32_t channel, rt_uint32_t *value);
};
4.3pwm子类rt_device_pwm
PWM 子类和 UART、ADC 思路不一样。PWM 和 UART 相同点:都要绑定基础的init/open/close等,接入设备模型。但是其特殊的操作,比如设置周期,设置占空比等操作,因为都是修改配置的操作,RTThread就改成了把所有的修改配置的操作的函数,都用rt_device_control(rt_device_t dev, int cmd, void *arg)来实现,根据不同的cmd,来调用不同的修改配置的函数。
cpp
struct rt_pwm_ops
{
rt_err_t (*control)(struct rt_device_pwm *device, int cmd, void *arg);
};
struct rt_device_pwm
{
struct rt_device parent;
const struct rt_pwm_ops *ops;
};
rt_err_t rt_device_pwm_register(struct rt_device_pwm *device, const char *name, const struct rt_pwm_ops *ops, const void *user_data);
//每个修改配置的函数都对应一个cmd,用于在rt_device_control里面调用
rt_err_t rt_pwm_enable(struct rt_device_pwm *device, int channel);
rt_err_t rt_pwm_disable(struct rt_device_pwm *device, int channel);
rt_err_t rt_pwm_set(struct rt_device_pwm *device, int channel, rt_uint32_t period, rt_uint32_t pulse);
rt_err_t rt_pwm_set_period(struct rt_device_pwm *device, int channel, rt_uint32_t period);
rt_err_t rt_pwm_set_pulse(struct rt_device_pwm *device, int channel, rt_uint32_t pulse);
rt_err_t rt_pwm_set_dead_time(struct rt_device_pwm *device, int channel, rt_uint32_t dead_time);
rt_err_t rt_pwm_set_phase(struct rt_device_pwm *device, int channel, rt_uint32_t phase);
5.实例讲解使用方法
首先我们要明白所有的框架遵循的一个调用方式,所有设备的调用逻辑完全统一,都是**「找设备 → 打开 → 读写 / 控制 → 关闭」** 四步走,本质是用父类指针rt_device_t调用统一接口,内部通过函数指针自动跳转到对应硬件驱动函数,完成各种功能。
| 步骤 | 函数 | 作用 | 对应面向对象逻辑 |
|---|---|---|---|
| 1 | rt_device_find("设备名") |
按名字从系统设备链表中找到设备,返回父类指针 | 按名字取出父类指针,向上转型完成 |
| 2 | rt_device_open(dev, 模式) |
打开设备,执行驱动的init+open函数 |
调用父类函数指针,底层硬件初始化 |
| 3 | rt_device_read/write/control |
读写数据、发送控制命令 | 核心多态调用,自动执行对应驱动的函数 |
| 4 | rt_device_close(dev) |
关闭设备,释放资源 | 调用驱动的close函数 |
并且UART、ADC、SPI、PWM 等外设,Cubemx 中需要开启外设、配置引脚与外设时钟;board.h 开启 RT-Thread 对应的宏。硬件底层引脚 / 时钟交给 Cubemx,而外设工作参数(波特率、PWM 周期、ADC 通道)这类业务配置,交给 RT-Thread 驱动完成。
5.1串口实际应用分析
以官方的代码为例,因为UART需要实现的功能(打开关闭初始化、收和发、设置参数)这些操作都能用设备模型的通用接口实现,所以UART几乎没有单独封装的专属API,也是新手最好入门的模型之一。
使用UART时,记得在cubemx在硬件层面使能串口,设置好时钟之类的,至于软件层面的波特率等等,由RTThread管理。也要记得在board.h #denfine你要用到的引脚,如果要用到DMA也要记得打开,反正就是根据官方提示去做
cpp
#include <rtthread.h>
#define SAMPLE_UART_NAME "uart2"
/* 用于接收消息的信号量 */
static struct rt_semaphore rx_sem;
static rt_device_t serial;
/* 接收数据回调函数 */
static rt_err_t uart_input(rt_device_t dev, rt_size_t size)
{
/* 串口接收到数据后产生中断,调用此回调函数,然后发送接收信号量 */
rt_sem_release(&rx_sem);
return RT_EOK;
}
static void serial_thread_entry(void *parameter)
{
char ch;
while (1)
{
/* 从串口读取一个字节的数据,没有读取到则等待接收信号量 */
while (rt_device_read(serial, -1, &ch, 1) != 1)
{
/* 阻塞等待接收信号量,等到信号量后再次读取数据 */
rt_sem_take(&rx_sem, RT_WAITING_FOREVER);
}
/* 读取到的数据通过串口错位输出 */
ch = ch + 1;
rt_device_write(serial, 0, &ch, 1);
}
}
static int uart_sample(int argc, char *argv[])
{
rt_err_t ret = RT_EOK;
char uart_name[RT_NAME_MAX];
char str[] = "hello RT-Thread!\r\n";
if (argc == 2)
{
rt_strncpy(uart_name, argv[1], RT_NAME_MAX);
}
else
{
rt_strncpy(uart_name, SAMPLE_UART_NAME, RT_NAME_MAX);
}
/* 查找系统中的串口设备 */
serial = rt_device_find(uart_name);
if (!serial)
{
rt_kprintf("find %s failed!\n", uart_name);
return RT_ERROR;
}
/* 初始化信号量 */
rt_sem_init(&rx_sem, "rx_sem", 0, RT_IPC_FLAG_FIFO);
/* 以中断接收及轮询发送模式打开串口设备 */
rt_device_open(serial, RT_DEVICE_FLAG_INT_RX);
/* 设置接收回调函数 */
rt_device_set_rx_indicate(serial, uart_input);
/* 发送字符串 */
rt_device_write(serial, 0, str, (sizeof(str) - 1));
/* 创建 serial 线程 */
rt_thread_t thread = rt_thread_create("serial", serial_thread_entry, RT_NULL, 1024, 25, 10);
/* 创建成功则启动线程 */
if (thread != RT_NULL)
{
rt_thread_startup(thread);
}
else
{
ret = RT_ERROR;
}
return ret;
}
/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(uart_sample, uart device sample);
很多人会疑问,我调用一个rt_device_write(serial, 0, str, (sizeof(str) - 1));怎么就实现发送了?下面拆解一下运作原理。
我们看下面的rt_device_write的源码,我们要知道,rt_device_write只是一个函数外壳,它不进行发送这个动作,只是根据传进来的参数(这里是serial),去找到这个参数绑定在rt_device_write的函数而已。又因为,UART绑定在rt_device_write()的函数是串口发送函数,所以当我们调用rt_device_write()时,就会间接的去调用串口发送函数。
rt_device_write只是一个通用函数外壳,本身不做实际发送动作。在传进来的参数里,使用传入的设备句柄serial,通过这个句柄找到这个设备结构体里 ops 操作集,调用 ops 中,早就提前绑定好的 write 函数指针。UART 设备在初始化注册时,已经把 自己结构体里的 ops->write 绑定成串口底层发送函数。
简单来说,就是当rt_device_write传入的设备模型是串口时,调用这个函数等同于调用串口发送函数。所以调用 rt_device_write,就会间接执行串口驱动里真正的发送逻辑。
rt_device_read同理,会间接执行串口驱动里的收取数据的函数。
而对于其他设备比如ADC,那么rt_device_write根据设备模型的不同,实际调用的又是ADC绑定在自己结构体里的ops->write的函数,实现和串口不同的功能。
cpp
rt_ssize_t rt_device_write(rt_device_t dev,
rt_off_t pos,
const void *buffer,
rt_size_t size)
{
/* parameter check */
RT_ASSERT(dev != RT_NULL);
RT_ASSERT(rt_object_get_type(&dev->parent) == RT_Object_Class_Device);
if (dev->ref_count == 0)
{
rt_set_errno(-RT_ERROR);
return 0;
}
/* call device_write interface */
if (device_write != RT_NULL)
{
return device_write(dev, pos, buffer, size);
}
/* set error code */
rt_set_errno(-RT_ENOSYS);
return 0;
}
5.2ADC实际应用分析
ADC外设,比UART多了专属API,放在自己的结构体里,有以下函数是直接调用的ADC的专属API(也就是通用设备模型的那几个函数满足不了的)。
并且在ADC设备模型中,rt_device_write和rt_device_read是没有绑定有关ADC的函数的,因为内核设计者认为:ADC 不是 "流设备"(不像串口,源源不断收发字节流)。ADC 是多通道采样设备,每次读要指定通道号,所以单独设计了rt_adc_read()去获取ADC值。
但是ADC设备模型还是会用到open和close函数的,这2个函数只负责"打开"和"关闭"ADC,注意只是控制ADC上电,并没有初始化ADC及其引脚,所以就设计了rt_adc_enable和rt_adc_disable函数来「打开某一路ADC通道」,但是示例代码里没有使用open和close函数,是因为rt_adc_enable和rt_adc_disable函数如果检测到你没有打开或者关闭ADC设备,会自己在内部调用open或者close函数来打开/关闭ADC。
rt_err_t rt_hw_adc_register(rt_adc_device_t adc,const char *name, const struct rt_adc_ops *ops, const void *user_data);
rt_uint32_t rt_adc_read(rt_adc_device_t dev, rt_int8_t channel);
rt_err_t rt_adc_enable(rt_adc_device_t dev, rt_int8_t channel);
rt_err_t rt_adc_disable(rt_adc_device_t dev, rt_int8_t channel);
rt_int16_t rt_adc_voltage(rt_adc_device_t dev, rt_int8_t channel);
cpp
#include <rtthread.h>
#include <rtdevice.h>
#define ADC_DEV_NAME "adc1" /* ADC 设备名称 */
#define ADC_DEV_CHANNEL 5 /* ADC 通道 */
#define REFER_VOLTAGE 330 /* 参考电压 3.3V,数据精度乘以100保留2位小数*/
#define CONVERT_BITS (1 << 12) /* 转换位数为12位 */
static int adc_vol_sample(int argc, char *argv[])
{
rt_adc_device_t adc_dev;
rt_uint32_t value, vol;
rt_err_t ret = RT_EOK;
/* 查找设备 */
adc_dev = (rt_adc_device_t)rt_device_find(ADC_DEV_NAME);
if (adc_dev == RT_NULL)
{
rt_kprintf("adc sample run failed! can't find %s device!\n", ADC_DEV_NAME);
return RT_ERROR;
}
/* 使能设备 */
ret = rt_adc_enable(adc_dev, ADC_DEV_CHANNEL);
/* 读取采样值 */
value = rt_adc_read(adc_dev, ADC_DEV_CHANNEL);
rt_kprintf("the value is :%d \n", value);
/* 转换为对应电压值 */
vol = value * REFER_VOLTAGE / CONVERT_BITS;
rt_kprintf("the voltage is :%d.%02d \n", vol / 100, vol % 100);
/* 关闭通道 */
ret = rt_adc_disable(adc_dev, ADC_DEV_CHANNEL);
return ret;
}
/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(adc_vol_sample, adc voltage convert sample);
5.3pwm实际应用分析
pwm相对于普通的设备模型又比较的特殊了,它也会正常使用init、open等通用函数指针,但是因为它的操作都是设置一些参数,和通用函数指针中的rt_device_control类似,所以设计者就发动小巧思了。
RTT 框架做了封装:上层提供 rt_pwm_set、rt_pwm_enable 这类 API,调用时会把传入的周期、脉冲等参数打包进配置结构体,再调用 rt_device_control,同时传入对应的 CMD 命令码(如PWM_CMD_SET);底层驱动收到 control 调用,判断 CMD,取出结构体里的参数,完成硬件寄存器配置。
cpp
rt_err_t rt_pwm_set(struct rt_device_pwm *device, int channel, rt_uint32_t period, rt_uint32_t pulse)
{
rt_err_t result = RT_EOK;
struct rt_pwm_configuration configuration = {0};
if (!device)
{
return -RT_EIO;
}
configuration.channel = (channel > 0) ? (channel) : (-channel);
configuration.period = period;
configuration.pulse = pulse;
result = rt_device_control(&device->parent, PWM_CMD_SET, &configuration);
return result;
}
官方示例代码如下。
cpp
#include <rtthread.h>
#include <rtdevice.h>
#define PWM_DEV_NAME "pwm3" /* PWM设备名称 */
#define PWM_DEV_CHANNEL 4 /* PWM通道 */
struct rt_device_pwm *pwm_dev; /* PWM设备句柄 */
static int pwm_led_sample(int argc, char *argv[])
{
rt_uint32_t period, pulse, dir;
period = 500000; /* 周期为0.5ms,单位为纳秒ns */
dir = 1; /* PWM脉冲宽度值的增减方向 */
pulse = 0; /* PWM脉冲宽度值,单位为纳秒ns */
/* 查找设备 */
pwm_dev = (struct rt_device_pwm *)rt_device_find(PWM_DEV_NAME);
if (pwm_dev == RT_NULL)
{
rt_kprintf("pwm sample run failed! can't find %s device!\n", PWM_DEV_NAME);
return RT_ERROR;
}
/* 设置PWM周期和脉冲宽度默认值 */
rt_pwm_set(pwm_dev, PWM_DEV_CHANNEL, period, pulse);
/* 使能设备 */
rt_pwm_enable(pwm_dev, PWM_DEV_CHANNEL);
while (1)
{
rt_thread_mdelay(10);
if (dir)
{
pulse += 5000; /* 从0值开始每次增加5000ns */
}
else
{
pulse -= 5000; /* 从最大值开始每次减少5000ns */
}
if (pulse >= period)
{
dir = 0;
}
if (0 == pulse)
{
dir = 1;
}
/* 设置PWM周期和脉冲宽度 */
rt_pwm_set(pwm_dev, PWM_DEV_CHANNEL, period, pulse);
}
}
/* 导出到 msh 命令列表中 */
MSH_CMD_EXPORT(pwm_led_sample, pwm sample);
三、小结
RT-Thread 设备模型本质是一套面向对象的 C 语言抽象框架 :把所有外设共同的行为(打开、关闭、读写、控制)抽出来做成父类rt_device与通用ops函数指针表,所有外设都继承这个基础结构;而外设之间独有的差异行为,框架提供了两种灵活实现方式:一类像 ADC、PIN,单独定义一套子类 ops 结构体,封装专属 API 直接调用;另一类像 PWM,不新增子类 ops,上层 API 只是封装壳,内部打包参数、命令码复用父类的rt_device_control来完成配置。
理解和使用设备模型的核心视角,就是分清 "通用能力" 和 "外设特有能力":遇到外设先区分它是数据流设备(UART,直接复用 read/write)还是纯配置类设备(PWM、ADC);不要死记 API,而是顺着函数指针去找底层绑定的驱动函数,看懂 API 最终调用的是哪一层 ops,这样就能理解为什么不同外设的调用方式不一样,写代码时也能选对接口、避开空函数指针带来的问题。
