第4章 字符设备驱动基础:从零搭建你的第一个驱动框架

字符设备驱动,是Linux驱动世界里最基础、也是最核心的一块。说白了,就是那些按字节流读写、不支持随机访问的设备——串口、GPIO、LED、按键,都属于这一类。我刚开始接触驱动开发时,就是从字符设备入手的。你想想看,一个最简单的驱动,无非就是:注册设备、实现操作函数、挂载到内核。今天我们就把它拆开揉碎了讲清楚。

4.1 字符设备驱动框架:内核怎么找到你的驱动?

先看一张整体框架图,帮你建立全局认知。

字符设备驱动框架 用户空间 (User Space) 应用程序 (open/read/write/close) 系统调用接口 (VFS) 内核空间 (Kernel Space) 字符设备驱动 (cdev + file_operations) 硬件层 (Hardware) GPIO / UART / I2C / SPI 控制器 核心流程:应用 → VFS → 驱动 file_operations → 硬件

这张图其实就讲了一件事:用户空间的open/read/write,最终会通过VFS层,落到你驱动的file_operations结构体里。内核不关心你操作的是什么硬件,它只认这个结构体。

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

这个结构体,是字符设备驱动的灵魂。我习惯叫它“函数跳转表”——内核通过它,把系统调用映射到你的驱动函数。

来看一个最精简的定义:

struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

每个成员都是一个函数指针。你实现了哪个,内核就调用哪个。没实现的,内核会返回 -ENOSYS 或者用默认行为。

核心成员速查表

成员触发时机典型实现
.open应用调用 open()权限检查、硬件初始化、自旋锁/互斥锁获取
.release应用调用 close()资源释放、硬件复位、锁释放
.read应用调用 read()从硬件读取数据 → copy_to_user
.write应用调用 write()从用户空间拿数据 → 写入硬件
.unlocked_ioctl应用调用 ioctl()自定义控制命令(设置波特率、GPIO方向等)
.llseek应用调用 lseek()修改文件读写位置(字符设备通常返回 -ESPIPE)

这里有个细节:.owner 必须赋值为 THIS_MODULE。我曾经见过新手漏掉这一行,结果模块卸载时内核直接崩溃——因为内核不知道这个 fops 属于哪个模块,卸载时没人通知它。

4.3 cdev注册与注销:让内核认识你的设备

光有 file_operations 还不够。你得把它注册到内核的字符设备管理机制里。这个机制就是 cdev(character device)。

注册流程分三步走:

  1. 分配设备号——静态指定或动态申请
  2. 初始化 cdev——绑定 file_operations
  3. 添加 cdev 到内核——告诉内核:“这个设备号归我管”

注销流程反过来:先删除 cdev,再释放设备号。

直接上代码,这是我个人习惯的模板写法:

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

#define DEVICE_NAME "my_char_dev"
#define CLASS_NAME  "my_class"

static int major;
static struct class *my_class;
static struct device *my_device;
static struct cdev my_cdev;

/* 打开设备 */
static int my_open(struct inode *inode, struct file *filp)
{
    pr_info("my_char_dev: device opened\n");
    return 0;
}

/* 关闭设备 */
static int my_release(struct inode *inode, struct file *filp)
{
    pr_info("my_char_dev: device released\n");
    return 0;
}

/* 读操作 */
static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos)
{
    char kernel_buf[64] = "Hello from kernel!\n";
    size_t len = strlen(kernel_buf);

    if (count < len)
        return -EINVAL;

    if (copy_to_user(buf, kernel_buf, len))
        return -EFAULT;

    return len;
}

/* 写操作 */
static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos)
{
    char kernel_buf[128];

    if (count > sizeof(kernel_buf))
        return -EINVAL;

    if (copy_from_user(kernel_buf, buf, count))
        return -EFAULT;

    pr_info("my_char_dev: received %zu bytes\n", count);
    return count;
}

/* file_operations 结构体 */
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

/* 模块初始化 */
static int __init my_init(void)
{
    dev_t dev_num;

    /* 第一步:动态分配设备号 */
    if (alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME) < 0) {
        pr_err("Failed to allocate device number\n");
        return -1;
    }
    major = MAJOR(dev_num);
    pr_info("Allocated major number: %d\n", major);

    /* 第二步:初始化 cdev 并绑定 fops */
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    /* 第三步:添加 cdev 到内核 */
    if (cdev_add(&my_cdev, dev_num, 1) < 0) {
        pr_err("Failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return -1;
    }

    /* 第四步:创建 device class 和设备节点(自动生成 /dev/xxx) */
    my_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(my_class)) {
        pr_err("Failed to create class\n");
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_class);
    }

    my_device = device_create(my_class, NULL, dev_num,
                              NULL, DEVICE_NAME);
    if (IS_ERR(my_device)) {
        pr_err("Failed to create device\n");
        class_destroy(my_class);
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_device);
    }

    pr_info("my_char_dev: initialized successfully\n");
    return 0;
}

/* 模块退出 */
static void __exit my_exit(void)
{
    dev_t dev_num = MKDEV(major, 0);

    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);

    pr_info("my_char_dev: exited\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver example");

个人经验:我习惯在 init 函数里先做 alloc_chrdev_region,再做 cdev_init,最后 cdev_add。顺序不能乱——cdev_add 之后内核随时可能调用你的 open,如果此时设备号还没注册好,系统会直接 oops。

4.4 设备号管理:静态 vs 动态

设备号由主设备号(major)和次设备号(minor)组成。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。

方式函数优点缺点
静态注册register_chrdev_region()设备号固定,方便调试可能冲突,需要查 /proc/devices
动态注册alloc_chrdev_region()自动分配,无冲突设备号不固定,需手动 mknod 或依赖 udev

我个人强烈推荐动态注册。为什么?因为你在 Rockchip 平台上开发时,内核里可能已经注册了几十个驱动,静态指定主设备号很容易撞车。动态分配让内核帮你选一个空闲的,省心。

避坑指南:我曾经在 RK3588 上调试一个 SPI 字符设备驱动,静态指定了主设备号 200。结果加载时发现 200 已经被另一个模块占用了——系统直接报错“register_chrdev_region: device or resource busy”。从那以后,我所有新驱动都改用 alloc_chrdev_region。

4.5 设备节点的自动创建:udev 与 class/device 机制

早期 Linux 驱动需要手动 mknod /dev/mydev c 240 0 创建设备节点。现在有了 udev,配合内核的 class_create + device_create,设备节点会自动出现在 /dev 下。

流程很简单:

  • class_create() 在 /sys/class 下创建一个目录
  • device_create() 在该目录下创建一个设备文件
  • udev 监听到 uevent,自动在 /dev 下生成节点

你只需要确保系统里有 udev 或 mdev 在运行。Rockchip 的 Buildroot 或 Yocto 默认都带了。

4.6 编译与测试:跑起来看看

把上面的代码保存为 my_char_dev.c,写一个 Makefile:

obj-m += my_char_dev.o

KDIR := /lib/modules/$(shell uname -r)/build

all:
    make -C $(KDIR) M=$(PWD) modules

clean:
    make -C $(KDIR) M=$(PWD) clean

编译:

make

加载:

sudo insmod my_char_dev.ko
dmesg | tail -5
# 应该看到:my_char_dev: initialized successfully
# 以及:Allocated major number: 240(数字可能不同)

测试读写:

echo "hello" > /dev/my_char_dev
cat /dev/my_char_dev
# 输出:Hello from kernel!

卸载:

sudo rmmod my_char_dev
dmesg | tail -3
# 应该看到:my_char_dev: exited

关键检查点:

  • ls -l /dev/my_char_dev —— 确认设备节点存在,权限正确
  • cat /proc/devices | grep my_char_dev —— 确认主设备号已注册
  • dmesg —— 确认没有错误日志

4.7 本章小结

字符设备驱动的核心就三件事:实现 file_operations、注册 cdev、管理设备号。代码模板是固定的,你只需要替换硬件操作部分。在 Rockchip 平台上,这套框架适用于 GPIO、PWM、ADC、UART 等绝大多数外设。

嗯,这里要注意:cdev_add 之后,内核随时可能调用你的 open。所以 init 函数里,一定要在 cdev_add 之前把硬件初始化好,把锁准备好。否则并发访问一来,你的驱动可能还没准备好就被调用了——这种 bug 很难复现,但一旦出现就是内核崩溃。