4. 字符设备驱动基础:从框架到节点创建

字符设备驱动,是Linux驱动开发里最基础、也是最常打交道的一类。说白了,就是那些数据像水流一样,一个一个字节读写的外设——串口、GPIO、LED、按键,都属于这个范畴。

我记得刚入行那会儿,带我的师傅就说:“字符设备驱动你要是搞透了,后面那些块设备、网络设备,理解起来就顺了。” 嗯,今天我们就从最核心的框架开始,一步步把字符设备驱动跑通。

4.1 字符设备驱动框架:三要素

一个完整的字符设备驱动,其实就三个核心部分:

  • 设备号 —— 系统用来识别你的设备的“身份证”
  • file_operations —— 告诉内核,当用户程序打开、读写、关闭你的设备时,该调用哪个函数
  • 设备节点 —— 用户空间看到的那个“文件”,比如 /dev/mydevice

这三者缺一不可。我见过不少新手,驱动加载成功了,但 ls /dev 下啥也没有,就是因为忘了创建设备节点。

核心流程:

  1. 分配并注册设备号
  2. 初始化 file_operations 结构体
  3. 调用 cdev_init + cdev_add 注册字符设备
  4. 在 /dev 下创建设备节点(手动或自动)
字符设备驱动框架 用户程序 (open/read/write) VFS 虚拟文件系统 字符设备驱动 硬件外设 设备节点 /dev/xxx

4.2 file_operations 结构体:驱动的“函数表”

这个结构体,是驱动和内核之间的契约。你实现了哪些函数,就填哪些字段。没实现的,内核会用默认行为处理。

我个人的习惯是,先把最常用的几个成员列出来:

成员 函数原型 说明
.open int (*open)(struct inode *, struct file *) 打开设备时调用,做初始化
.release int (*release)(struct inode *, struct file *) 关闭设备时调用,释放资源
.read ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) 从设备读取数据
.write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 向设备写入数据
.unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 设备控制命令(不用加锁)

小技巧: 在 RK3568 上,建议优先使用 .unlocked_ioctl 而不是 .ioctl。因为新内核已经废弃了带 BKL(大内核锁)的 .ioctl 接口。我之前在移植一个老驱动时就踩过这个坑,编译直接报错。

来看一个最简单的实现:

// 打开设备
static int my_open(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "my_device opened\n");
    return 0;
}

// 关闭设备
static int my_release(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "my_device closed\n");
    return 0;
}

// 定义 file_operations
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = NULL,   // 暂时不实现
    .write   = NULL,   // 暂时不实现
};

你想想看,这个结构体其实就是一张函数跳转表。用户程序调用 open("/dev/mydev", O_RDWR) 时,内核 VFS 层就会找到 my_fops 里的 .open 函数来执行。

4.3 设备号:注册与注销

设备号分主设备号和次设备号。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。

在 RK3568 上,我们可以用动态分配,也可以手动指定。我个人更推荐动态分配——省心,不会跟其他驱动冲突。

#include <linux/fs.h>

#define DEVICE_NAME "my_device"
#define MINOR_NUM   0

dev_t dev_num;  // 存放设备号

// 模块加载时注册
static int __init my_init(void)
{
    int ret;

    // 动态分配设备号
    ret = alloc_chrdev_region(&dev_num, MINOR_NUM, 1, DEVICE_NAME);
    if (ret < 0) {
        printk(KERN_ERR "alloc_chrdev_region failed\n");
        return ret;
    }

    printk(KERN_INFO "Major number: %d, Minor number: %d\n",
           MAJOR(dev_num), MINOR(dev_num));
    return 0;
}

// 模块卸载时注销
static void __exit my_exit(void)
{
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "Device number unregistered\n");
}

注意: 注销设备号时,次设备号数量和注册时必须一致。我曾经因为注册了 2 个设备但注销时只传了 1,导致系统重启后设备号冲突,排查了半天才发现是这里的问题。

4.4 手动创建设备节点

设备号注册好了,驱动也加载了,但用户空间还看不到设备。为什么?因为 /dev 下还没有对应的节点文件。

手动创建的方式很简单,用 mknod 命令:

# 查看驱动打印的主设备号
dmesg | grep "Major number"

# 假设主设备号是 240
sudo mknod /dev/my_device c 240 0
sudo chmod 666 /dev/my_device

这里的 c 表示字符设备,240 是主设备号,0 是次设备号。

不过说实话,手动创建只适合调试阶段。产品上肯定要用自动创建——那就是 udev 或 devtmpfs 的事了,我们后面章节会专门讲。

4.5 完整示例:一个“会说话”的字符设备

我们把上面的代码串起来,写一个完整的驱动。这个驱动不操作硬件,只在内核日志里打印信息,但麻雀虽小五脏俱全。

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>

#define DEVICE_NAME "demo_device"

static dev_t dev_num;
static struct cdev demo_cdev;

static int demo_open(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "demo: open called\n");
    return 0;
}

static int demo_release(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "demo: release called\n");
    return 0;
}

static struct file_operations demo_fops = {
    .owner   = THIS_MODULE,
    .open    = demo_open,
    .release = demo_release,
};

static int __init demo_init(void)
{
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret) {
        printk(KERN_ERR "demo: alloc_chrdev_region failed\n");
        return ret;
    }

    // 2. 初始化 cdev
    cdev_init(&demo_cdev, &demo_fops);
    demo_cdev.owner = THIS_MODULE;

    // 3. 添加 cdev 到内核
    ret = cdev_add(&demo_cdev, dev_num, 1);
    if (ret) {
        printk(KERN_ERR "demo: cdev_add failed\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    printk(KERN_INFO "demo: loaded, major=%d\n", MAJOR(dev_num));
    return 0;
}

static void __exit demo_exit(void)
{
    cdev_del(&demo_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "demo: unloaded\n");
}

module_init(demo_init);
module_exit(demo_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device demo for RK3568");

编译加载后,执行:

sudo insmod demo.ko
dmesg | tail -5
# 看到 "demo: loaded, major=XXX"

# 手动创建节点
sudo mknod /dev/demo c XXX 0
sudo chmod 666 /dev/demo

# 测试
cat /dev/demo          # 触发 open 和 release
echo "hello" > /dev/demo  # 同样触发 open 和 release
dmesg | tail -10       # 查看内核日志

验证要点:

  • ls -l /dev/demo 能看到主次设备号
  • cat 和 echo 操作后,dmesg 里有对应的 open/release 日志
  • rmmod demo.ko 后,/dev/demo 节点还在,但操作会报错(因为驱动已卸载)

4.6 避坑指南

做字符设备驱动,有几个坑我当年都踩过,分享给你:

  • 设备号冲突: 手动指定主设备号时,一定要确认没被其他驱动占用。用动态分配最安全。
  • cdev_add 失败: 检查是否忘了调用 cdev_init,或者设备号范围参数传错了。
  • 节点权限: 默认创建的节点只有 root 能访问。调试时记得 chmod 666,不然普通用户程序打不开。
  • 卸载顺序: 先 cdev_del,再 unregister_chrdev_region。顺序反了可能导致内核崩溃。

嗯,字符设备驱动的基础框架就这些。你把这个流程跑通了,后面那些复杂的驱动,无非是在这个骨架上添砖加瓦。