3、字符设备驱动框架:主次设备号、file_operations结构体、设备节点创建与访问

字符设备是Linux驱动开发中最基础、最常用的设备类型,其核心特征在于数据以字节流形式按顺序访问,不支持随机读写。RK3588作为瑞芯微旗舰级SoC,其内部集成了大量外设控制器(如UART、I2C、SPI、GPIO、PWM等),这些外设驱动绝大多数都以字符设备的形式呈现。理解字符设备驱动框架,是掌握RK3588外设驱动开发的基石。本章将围绕设备号管理、file_operations结构体、设备节点创建与访问三个核心维度,结合RK3588平台特性,深入剖析字符设备驱动的完整构建流程。

字符设备驱动框架总览(RK3588) 用户空间 应用程序 open/read/write/ioctl 通过设备节点访问 /dev/rk_uart0, /dev/rk_gpio 路径:/dev/xxx 内核空间 VFS(虚拟文件系统) ↓ file_operations 回调函数 驱动注册到内核 设备号管理 主设备号:驱动类型标识 次设备号:同类型设备区分 register_chrdev_region() alloc_chrdev_region() file_operations .open / .release .read / .write .unlocked_ioctl .llseek / .mmap 设备节点创建 mknod /dev/xxx c 主 次 udev/mdev 自动创建 class_create + device_create 设备树 compatible 匹配 系统调用 驱动加载与访问流程 ① 模块加载 ② 注册设备号 ③ cdev_add ④ 创建设备节点 ⑤ 用户访问 RK3588 平台:设备树节点定义 → 驱动 probe → 自动创建设备节点

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访问。

风险提示:静态分配设备号时,务必确认该主设备号未被系统其他驱动占用。在RK3588的SDK中,部分主设备号已被预留(如10、89等),建议优先使用动态分配方式。

3.2 file_operations结构体:驱动与内核的接口契约

3.2.1 核心原理

file_operations是字符设备驱动的核心结构体,定义了驱动向内核暴露的操作函数指针。当用户空间对设备节点执行open()、read()、write()、ioctl()等系统调用时,VFS会通过该结构体找到对应的驱动回调函数。对于RK3588外设驱动,常用的回调函数包括:

回调函数系统调用典型用途RK3588场景
.openopen()初始化设备、增加引用计数UART端口打开、GPIO请求
.releaseclose()释放资源、减少引用计数UART端口关闭、GPIO释放
.readread()从设备读取数据读取UART接收FIFO、读取ADC采样值
.writewrite()向设备写入数据发送UART数据、配置PWM占空比
.unlocked_ioctlioctl()设备控制命令设置UART波特率、GPIO方向控制
.llseeklseek()修改文件偏移一般字符设备不支持,置NULL
.mmapmmap()内存映射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,
};
关键经验:在RK3588驱动开发中,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实现(注意并发安全)、设备节点创建(自动创建+设备树匹配)。在实际项目中,建议遵循以下开发流程:

  1. 在设备树中定义外设节点,指定compatible属性。
  2. 编写驱动,实现probe()函数,在probe()中完成设备号分配、cdev注册、设备节点创建。
  3. 实现file_operations回调函数,注意使用内核提供的安全拷贝函数。
  4. 使用udevadm和strace验证设备节点创建和系统调用链路。
  5. 进行并发压力测试,确保驱动稳定性。
RK3588平台特别提醒:RK3588的TRM文档中详细列出了每个外设的中断号、寄存器基地址和时钟门控信息。在编写驱动时,务必查阅对应外设章节,正确配置clk_prepare_enable()和reset_control_assert(),否则外设无法正常工作。