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

字符设备驱动,是嵌入式Linux驱动开发中最基础、也是最常打交道的一类。说白了,就是那些按字节流读写、不支持随机访问的设备——串口、GPIO、LED、按键,都属于这个范畴。

我记得刚入行那会儿,总觉得字符设备驱动很简单,不就是注册几个回调函数嘛。直到有一次,我在一个工业控制项目里,因为设备号分配冲突导致系统启动时驱动加载失败,现场排查了整整一个下午……嗯,从那以后,我再也不敢小看这些基础概念了。

1. file_operations:驱动与用户态的桥梁

用户程序调用 open()、read()、write() 时,内核怎么知道该执行哪段代码?答案就在 file_operations 结构体里。

这个结构体定义在 <linux/fs.h> 中,本质上是一堆函数指针的集合。每个成员对应一个系统调用。你只需要把想支持的操作填进去,内核就会自动帮你做映射。

struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    /* 还有更多... */
};

我个人习惯,至少实现 open、release、read、write 这四个。其他的按需添加,别一股脑全写上,容易出bug。

避坑指南: 我曾经在 owner 字段上吃过亏。这个字段必须设为 THIS_MODULE,否则模块卸载时,如果还有进程持有文件描述符,内核会直接崩溃。别问我怎么知道的……

2. 主次设备号:设备的身份证

每个字符设备在内核里都有一个唯一的编号——主设备号 + 次设备号。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备实例。

概念 含义 示例
主设备号 标识驱动 89(I2C设备)
次设备号 标识设备实例 0、1、2…

分配设备号有两种方式:

  • 静态分配:手动指定一个数字,用 register_chrdev_region() 注册。适合你知道哪些号没被占用的场景。
  • 动态分配:用 alloc_chrdev_region() 让内核帮你挑一个。我强烈推荐这种方式——省心,不会冲突。
dev_t dev_num;
int major, minor;

/* 动态分配:主设备号由内核决定 */
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);
minor = MINOR(dev_num);

/* 或者静态分配:自己指定主设备号 */
// register_chrdev_region(dev_num, 1, "my_device");
注意: 动态分配的设备号,每次加载模块都可能不同。所以你的应用程序不能写死设备号,得通过 /proc/devices 或者设备节点来获取。

3. cdev结构体:驱动的内核表示

cdev 是内核用来管理字符设备的核心结构体。它把 file_operations 和设备号绑定在一起,告诉内核:这个设备号对应的操作函数是什么。

初始化流程很简单,三步走:

  1. 分配 cdev 结构体(静态或动态)
  2. 调用 cdev_init() 绑定 file_operations
  3. 调用 cdev_add() 注册到内核
struct cdev my_cdev;

cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

if (cdev_add(&my_cdev, dev_num, 1) < 0) {
    printk(KERN_ERR "Failed to add cdev\n");
    unregister_chrdev_region(dev_num, 1);
    return -1;
}

这里有个细节:cdev_add() 之后,设备就立刻对外可见了。所以一定要确保所有初始化都完成后再调用它,否则用户程序可能访问到一个半成品的设备。

4. 设备节点:用户态怎么找到你?

设备注册好了,用户程序怎么访问?通过设备节点——就是 /dev/ 目录下的那个文件。

创建设备节点有两种方式:

手动创建(不推荐)

用 mknod 命令手动创建:

mknod /dev/my_device c 240 0

缺点很明显:设备号变了就得重新创建,而且没法自动管理权限。

自动创建(推荐)

利用 udev 或 mdev 自动创建。驱动里只需要做两件事:

  • 创建一个 class
  • 在 class 下创建一个 device
static struct class *my_class;

my_class = class_create(THIS_MODULE, "my_class");
device_create(my_class, NULL, dev_num, NULL, "my_device");

/* 卸载时记得销毁 */
device_destroy(my_class, dev_num);
class_destroy(my_class);

这样,模块加载后,/dev/my_device 就会自动出现。我个人觉得,这才是专业做法。

5. 完整示例:一个简单的字符设备驱动

说了这么多,不如直接看代码。下面是一个完整的字符设备驱动,实现了 open、release、read、write 四个操作。

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

#define DEVICE_NAME "my_demo"
#define CLASS_NAME  "demo_class"

static int major;
static struct class *demo_class;
static struct cdev demo_cdev;
static char kernel_buffer[256];

static int demo_open(struct inode *inode, struct file *filp) {
    printk(KERN_INFO "Device opened\n");
    return 0;
}

static int demo_release(struct inode *inode, struct file *filp) {
    printk(KERN_INFO "Device closed\n");
    return 0;
}

static ssize_t demo_read(struct file *filp, char __user *buf,
                         size_t len, loff_t *off) {
    size_t available = strlen(kernel_buffer);
    size_t to_copy = min(len, available);

    if (copy_to_user(buf, kernel_buffer, to_copy)) {
        return -EFAULT;
    }
    return to_copy;
}

static ssize_t demo_write(struct file *filp, const char __user *buf,
                          size_t len, loff_t *off) {
    size_t to_copy = min(len, sizeof(kernel_buffer) - 1);

    if (copy_from_user(kernel_buffer, buf, to_copy)) {
        return -EFAULT;
    }
    kernel_buffer[to_copy] = '\0';
    return to_copy;
}

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

static int __init demo_init(void) {
    dev_t dev_num;

    /* 1. 分配设备号 */
    if (alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME) < 0) {
        return -1;
    }
    major = MAJOR(dev_num);

    /* 2. 初始化并注册cdev */
    cdev_init(&demo_cdev, &demo_fops);
    demo_cdev.owner = THIS_MODULE;
    if (cdev_add(&demo_cdev, dev_num, 1) < 0) {
        unregister_chrdev_region(dev_num, 1);
        return -1;
    }

    /* 3. 自动创建设备节点 */
    demo_class = class_create(THIS_MODULE, CLASS_NAME);
    device_create(demo_class, NULL, dev_num, NULL, DEVICE_NAME);

    printk(KERN_INFO "Demo driver loaded, major=%d\n", major);
    return 0;
}

static void __exit demo_exit(void) {
    dev_t dev_num = MKDEV(major, 0);

    device_destroy(demo_class, dev_num);
    class_destroy(demo_class);
    cdev_del(&demo_cdev);
    unregister_chrdev_region(dev_num, 1);

    printk(KERN_INFO "Demo driver unloaded\n");
}

module_init(demo_init);
module_exit(demo_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver demo");
核心要点:
  • copy_to_user / copy_from_user 是必须的,不能直接访问用户空间指针
  • 卸载顺序要和加载顺序相反:先销毁设备节点,再删除cdev,最后释放设备号
  • Makefile 里记得加上 obj-m += demo.o

6. 知识体系总览

下面这张图,把字符设备驱动的核心流程串起来了。你写驱动时,就按这个顺序来,基本不会出错。

字符设备驱动注册与节点创建流程 1. 分配设备号 alloc_chrdev_region() 2. 初始化cdev cdev_init() + cdev_add() 3. 创建class class_create() 4. 创建设备节点 device_create() → /dev/xxx 5. 用户态访问 open/read/write → file_operations 关键数据结构 • dev_t:设备号类型 • struct cdev:内核设备对象 • struct file_operations:操作表 • struct class:设备类 卸载顺序(反向) 1. device_destroy() 2. class_destroy() 3. cdev_del() 4. unregister_chrdev_region()

你想想看,整个流程其实就五步:分配设备号 → 注册cdev → 创建class → 创建设备节点 → 用户态访问。卸载时反过来就行。是不是很清晰?

我的建议: 刚开始学的时候,别急着写复杂的驱动。先把上面这个demo跑通,用 echo 和 cat 测试读写,感受一下用户态和内核态的数据流动。等这个流程烂熟于心,再去做中断、DMA、休眠唤醒这些高级特性,会顺手很多。