3、字符设备驱动框架:主次设备号、file_operations结构体、设备节点创建与访问
字符设备是Linux驱动开发中最基础、最常用的设备类型,其核心特征在于数据以字节流形式按顺序访问,不支持随机读写。RK3588作为瑞芯微旗舰级SoC,其内部集成了大量外设控制器(如UART、I2C、SPI、GPIO、PWM等),这些外设驱动绝大多数都以字符设备的形式呈现。理解字符设备驱动框架,是掌握RK3588外设驱动开发的基石。本章将围绕设备号管理、file_operations结构体、设备节点创建与访问三个核心维度,结合RK3588平台特性,深入剖析字符设备驱动的完整构建流程。
3.1 设备号:主设备号与次设备号的分配机制
3.1.1 核心原理
设备号是Linux内核中标识字符设备的唯一数值,由主设备号(major)和次设备号(minor)两部分组成,共32位。主设备号占用高12位(0~4095),用于标识设备对应的驱动程序类型;次设备号占用低20位(0~1048575),用于区分由同一驱动程序管理的多个物理设备。例如,RK3588的多个UART控制器(UART0~UART9)可以共享同一个主设备号,通过不同的次设备号来区分。
在RK3588平台中,设备号的分配有两种方式:静态分配和动态分配。静态分配使用register_chrdev_region()函数,需要开发者手动指定主设备号,适用于已知且固定的设备类型;动态分配使用alloc_chrdev_region()函数,由内核自动分配未使用的主设备号,推荐用于大多数场景,以避免设备号冲突。
3.1.2 操作流程
// 动态分配设备号示例
dev_t dev_num;
int major, minor;
// 动态分配一个设备号,name为设备名称
ret = alloc_chrdev_region(&dev_num, 0, 1, "rk_uart");
if (ret < 0) {
printk("Failed to alloc chrdev region\n");
return ret;
}
major = MAJOR(dev_num); // 提取主设备号
minor = MINOR(dev_num); // 提取次设备号
printk("Allocated major=%d, minor=%d\n", major, minor);
在模块卸载时,必须释放设备号:
unregister_chrdev_region(dev_num, 1);
3.1.3 案例拆解:RK3588 UART设备号分配
假设我们需要为RK3588的UART0编写驱动。在设备树中,UART0节点定义如下:
&uart0 {
status = "okay";
pinctrl-names = "default";
pinctrl-0 = <&uart0_xfer>;
};
驱动中通过of_alias_get_id()获取UART编号,然后动态分配设备号:
static int rk_uart_probe(struct platform_device *pdev)
{
int uart_id = of_alias_get_id(pdev->dev.of_node, "serial");
dev_t dev_num;
// 每个UART分配一个次设备号
ret = alloc_chrdev_region(&dev_num, uart_id, 1, "rk_uart");
// ... 后续初始化
}
这样,UART0对应次设备号0,UART1对应次设备号1,以此类推,方便用户空间通过/dev/rk_uart0、/dev/rk_uart1访问。
3.2 file_operations结构体:驱动与内核的接口契约
3.2.1 核心原理
file_operations是字符设备驱动的核心结构体,定义了驱动向内核暴露的操作函数指针。当用户空间对设备节点执行open()、read()、write()、ioctl()等系统调用时,VFS会通过该结构体找到对应的驱动回调函数。对于RK3588外设驱动,常用的回调函数包括:
| 回调函数 | 系统调用 | 典型用途 | RK3588场景 |
|---|---|---|---|
| .open | open() | 初始化设备、增加引用计数 | UART端口打开、GPIO请求 |
| .release | close() | 释放资源、减少引用计数 | UART端口关闭、GPIO释放 |
| .read | read() | 从设备读取数据 | 读取UART接收FIFO、读取ADC采样值 |
| .write | write() | 向设备写入数据 | 发送UART数据、配置PWM占空比 |
| .unlocked_ioctl | ioctl() | 设备控制命令 | 设置UART波特率、GPIO方向控制 |
| .llseek | lseek() | 修改文件偏移 | 一般字符设备不支持,置NULL |
| .mmap | mmap() | 内存映射 | FrameBuffer、DMA缓冲区映射 |
3.2.2 实现要点
static int rk_uart_open(struct inode *inode, struct file *filp)
{
// 通过次设备号获取私有数据
struct rk_uart_dev *uart = container_of(inode->i_cdev, struct rk_uart_dev, cdev);
filp->private_data = uart;
// 使能UART时钟、配置引脚等
return 0;
}
static ssize_t rk_uart_read(struct file *filp, char __user *buf, size_t count, loff_t *pos)
{
struct rk_uart_dev *uart = filp->private_data;
// 从硬件FIFO读取数据,拷贝到用户空间
if (copy_to_user(buf, uart->rx_buf, count))
return -EFAULT;
return count;
}
static const struct file_operations rk_uart_fops = {
.owner = THIS_MODULE,
.open = rk_uart_open,
.read = rk_uart_read,
.write = rk_uart_write,
.unlocked_ioctl = rk_uart_ioctl,
.release = rk_uart_release,
};
unlocked_ioctl取代了旧的ioctl字段。由于内核在调用ioctl时不再持有大内核锁(BKL),驱动必须自行保证并发安全,建议使用mutex或spinlock保护共享数据。
3.3 设备节点创建:从内核到用户空间的桥梁
3.3.1 核心原理
设备节点是用户空间访问驱动的入口文件,位于/dev目录下。设备节点的创建有两种方式:手动创建(mknod命令)和自动创建(udev/mdev机制)。在RK3588的Linux系统中,推荐使用自动创建方式,通过内核的class_create和device_create接口,在驱动加载时自动生成设备节点。
3.3.2 自动创建设备节点流程
static struct class *rk_uart_class;
static int __init rk_uart_init(void)
{
// 1. 分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "rk_uart");
// 2. 初始化cdev并添加到内核
cdev_init(&uart_cdev, &rk_uart_fops);
cdev_add(&uart_cdev, dev_num, 1);
// 3. 创建设备类
rk_uart_class = class_create(THIS_MODULE, "rk_uart_class");
// 4. 创建设备节点 /dev/rk_uart0
device_create(rk_uart_class, NULL, dev_num, NULL, "rk_uart%d", 0);
return 0;
}
static void __exit rk_uart_exit(void)
{
device_destroy(rk_uart_class, dev_num);
class_destroy(rk_uart_class);
cdev_del(&uart_cdev);
unregister_chrdev_region(dev_num, 1);
}
3.3.3 设备树与设备节点的关联
在RK3588平台中,设备节点的创建通常与设备树绑定。驱动通过platform_driver_register()注册,当设备树中的节点与驱动的compatible属性匹配时,内核调用probe()函数,在probe()中完成设备节点创建:
static const struct of_device_id rk_uart_of_match[] = {
{ .compatible = "rockchip,rk3588-uart", },
{ /* sentinel */ }
};
static struct platform_driver rk_uart_driver = {
.probe = rk_uart_probe,
.remove = rk_uart_remove,
.driver = {
.name = "rk_uart",
.of_match_table = rk_uart_of_match,
},
};
module_platform_driver(rk_uart_driver);
3.4 常见误区与总结复盘
3.4.1 常见误区
- 设备号冲突:多个驱动使用相同主设备号且未正确区分次设备号,导致设备节点指向错误驱动。
- cdev_add失败:未检查
cdev_add()返回值,或设备号已被占用,导致驱动加载失败。 - 设备节点权限问题:默认创建的设备节点权限为
root:root 0660,普通用户无法访问。需在udev规则中设置权限,或使用device_create()的mode参数。 - 并发访问未保护:多个进程同时读写设备时,未使用锁机制,导致数据竞争。
- copy_to_user/from_user使用错误:直接访问用户空间指针,导致内核崩溃。必须使用
copy_to_user()和copy_from_user()。
3.4.2 调试技巧
# 查看设备号分配情况
cat /proc/devices | grep rk_uart
# 查看设备节点信息
ls -l /dev/rk_uart*
# 使用udevadm监控设备节点创建
udevadm monitor --kernel --property
# 使用strace跟踪系统调用
strace -e open,read,write,ioctl ./test_app
3.4.3 总结复盘
字符设备驱动框架是RK3588外设驱动开发的基础。开发者需要掌握三个核心环节:设备号分配(动态优先)、file_operations实现(注意并发安全)、设备节点创建(自动创建+设备树匹配)。在实际项目中,建议遵循以下开发流程:
- 在设备树中定义外设节点,指定
compatible属性。 - 编写驱动,实现
probe()函数,在probe()中完成设备号分配、cdev注册、设备节点创建。 - 实现file_operations回调函数,注意使用内核提供的安全拷贝函数。
- 使用
udevadm和strace验证设备节点创建和系统调用链路。 - 进行并发压力测试,确保驱动稳定性。
clk_prepare_enable()和reset_control_assert(),否则外设无法正常工作。