2. Linux 字符设备驱动框架:从结构体到模块加载

字符设备驱动,说白了就是 Linux 世界里最基础、最常用的一种驱动模型。你想想看,我们日常用的鼠标、键盘、串口,甚至编码器,本质上都是字符设备。它们的特点是数据按字节流传输,像水管里的水一样,一个一个字节地流过去。

我刚开始接触驱动开发时,总觉得这框架很抽象。后来亲手调了几个编码器驱动,才真正理解——这套框架其实就是内核和你的硬件之间的一纸契约。今天我们就来拆解这份契约。

2.1 file_operations:驱动与应用的接口契约

file_operations 结构体,是驱动开发者必须面对的第一个核心概念。它定义了用户程序通过系统调用(如 open、read、write)访问设备时,内核该调用你写的哪个函数。

核心理解: file_operations 就是一张函数跳转表。用户调用 read(),内核查这张表,找到你注册的 xxx_read() 函数,然后执行。

来看一个典型的编码器驱动中,我常用的 file_operations 定义:

static int encoder_open(struct inode *inode, struct file *filp)
{
    // 我习惯在这里使能编码器硬件时钟
    // 并初始化硬件寄存器
    return 0;
}

static int encoder_release(struct inode *inode, struct file *filp)
{
    // 关闭时释放资源,关闭时钟
    return 0;
}

static ssize_t encoder_read(struct file *filp, char __user *buf,
                            size_t count, loff_t *f_pos)
{
    // 读取编码器当前计数值
    // 注意要用 copy_to_user() 把数据从内核拷贝到用户空间
    return 0;
}

static ssize_t encoder_write(struct file *filp, const char __user *buf,
                             size_t count, loff_t *f_pos)
{
    // 写入控制参数,比如清零、设置预装载值
    return 0;
}

static long encoder_ioctl(struct file *filp, unsigned int cmd,
                          unsigned long arg)
{
    // 我经常用 ioctl 实现特殊控制,比如复位、配置模式
    return 0;
}

static struct file_operations encoder_fops = {
    .owner   = THIS_MODULE,
    .open    = encoder_open,
    .release = encoder_release,
    .read    = encoder_read,
    .write   = encoder_write,
    .unlocked_ioctl = encoder_ioctl,
};

我的经验: 早期我总忘记设置 .owner 字段。虽然不设也能跑,但模块计数会出问题,rmmod 时可能崩溃。后来我养成了习惯——定义 fops 时第一个就写 .owner = THIS_MODULE。

2.2 设备号分配:主设备号与次设备号

每个字符设备在内核里都有一个唯一的编号——设备号。它由两部分组成:

组成部分 位数 作用
主设备号 12 位 标识设备对应的驱动程序
次设备号 20 位 标识同一个驱动管理的不同设备实例

分配设备号有两种方式:

  1. 静态分配:手动指定一个主设备号。适合你知道这个号没被占用的场景。
  2. 动态分配:让内核自动分配。我强烈推荐这种方式,省心。

代码示例:

#define ENCODER_MAJOR 0   // 0 表示动态分配
#define ENCODER_MINOR 0
#define ENCODER_CNT   1    // 只创建一个设备节点

dev_t dev_num;

if (ENCODER_MAJOR) {
    // 静态分配
    dev_num = MKDEV(ENCODER_MAJOR, ENCODER_MINOR);
    ret = register_chrdev_region(dev_num, ENCODER_CNT, "encoder");
} else {
    // 动态分配 —— 我几乎只用这种方式
    ret = alloc_chrdev_region(&dev_num, ENCODER_MINOR, ENCODER_CNT, "encoder");
    printk("Allocated dev number: major=%d, minor=%d\n",
           MAJOR(dev_num), MINOR(dev_num));
}

我曾经踩过的坑: 有一次我用了静态分配的主设备号 200,结果和另一个模块冲突了。内核直接报错,设备注册失败。从那以后,我所有产品代码都改用动态分配。除非你有特殊理由,否则别用静态。

2.3 cdev 注册与注销:让设备活起来

设备号有了,file_operations 也定义好了,接下来就是把它们绑定到一起,注册到内核里。这一步靠的是 cdev 结构体。

注册流程我总结为三步:

  1. 初始化 cdev:调用 cdev_init(),绑定 file_operations
  2. 添加 cdev:调用 cdev_add(),告诉内核这个设备存在
  3. 创建设备节点:用 mknod 或 udev 自动创建 /dev/encoder0

代码实现:

struct cdev encoder_cdev;

// 第一步:初始化
cdev_init(&encoder_cdev, &encoder_fops);
encoder_cdev.owner = THIS_MODULE;

// 第二步:注册到内核
ret = cdev_add(&encoder_cdev, dev_num, 1);
if (ret) {
    printk("Failed to add cdev\n");
    goto err_cdev_add;
}

// 第三步:自动创建设备节点(配合 udev/mdev)
device_create(encoder_class, NULL, dev_num, NULL, "encoder%d", 0);

注销时顺序反过来:

device_destroy(encoder_class, dev_num);
cdev_del(&encoder_cdev);
unregister_chrdev_region(dev_num, ENCODER_CNT);

我的习惯: 我总在模块初始化函数里用 goto 做错误处理。每个 goto 标签对应一个清理步骤,这样出错时能逐级回滚,不会漏掉资源释放。你可以在后面的完整代码里看到这个模式。

2.4 模块加载与卸载:驱动的生命周期

驱动最终以模块形式存在。加载时调用 module_init() 指定的函数,卸载时调用 module_exit() 指定的函数。

来看一个完整的编码器驱动骨架:

static int __init encoder_init(void)
{
    dev_t dev_num;
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "encoder");
    if (ret < 0) {
        printk("Failed to alloc dev number\n");
        return ret;
    }

    // 2. 创建类(用于自动创建设备节点)
    encoder_class = class_create(THIS_MODULE, "encoder_class");
    if (IS_ERR(encoder_class)) {
        ret = PTR_ERR(encoder_class);
        goto err_class;
    }

    // 3. 初始化并添加 cdev
    cdev_init(&encoder_cdev, &encoder_fops);
    encoder_cdev.owner = THIS_MODULE;
    ret = cdev_add(&encoder_cdev, dev_num, 1);
    if (ret) {
        goto err_cdev_add;
    }

    // 4. 创建设备节点
    device_create(encoder_class, NULL, dev_num, NULL, "encoder0");

    printk("Encoder driver loaded, major=%d\n", MAJOR(dev_num));
    return 0;

err_cdev_add:
    class_destroy(encoder_class);
err_class:
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

static void __exit encoder_exit(void)
{
    dev_t dev_num = MKDEV(encoder_major, 0);

    device_destroy(encoder_class, dev_num);
    cdev_del(&encoder_cdev);
    class_destroy(encoder_class);
    unregister_chrdev_region(dev_num, 1);

    printk("Encoder driver unloaded\n");
}

module_init(encoder_init);
module_exit(encoder_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Encoder character device driver");

2.5 知识体系总览

下面这张图把整个字符设备驱动框架串起来了。我建议你把它存下来,写驱动时对照着看:

Linux 字符设备驱动框架 用户空间 应用程序:open() / read() / write() / ioctl() 系统调用层:VFS 虚拟文件系统 file_operations 结构体 .open / .read / .write / .unlocked_ioctl / .release cdev 注册与设备号 cdev_init() → cdev_add() → device_create() 硬件层:编码器外设 GPIO / 定时器捕获 / SPI / I2C 接口 模块加载 → 注册 → 运行 → 卸载

2.6 调试小技巧

写驱动时,我常用这几个方法快速验证框架是否正确:

  • 查看设备号:cat /proc/devices | grep encoder,看主设备号是否注册成功
  • 查看设备节点:ls -l /dev/encoder*,确认节点存在且权限正确
  • 测试 open/close:写一个简单的测试程序,只调用 open() 和 close(),看 dmesg 里有没有打印
  • 测试 read/write:用 dd 命令或自己写测试程序,验证数据通路

核心要点回顾:

  • file_operations 是驱动与应用的接口契约,必须实现 open/release,其他按需实现
  • 设备号推荐动态分配,用 alloc_chrdev_region()
  • cdev 注册顺序:init → add → device_create;注销顺序相反
  • 模块加载/卸载函数中,用 goto 做错误处理,确保资源不泄漏

嗯,到这里字符设备驱动框架的核心内容就讲完了。这套框架我用了十年,从简单的 LED 驱动到复杂的编码器驱动,万变不离其宗。你只要把今天这几个结构体和流程吃透,后面写任何字符设备驱动都会很顺手。