字符设备驱动:设备号申请与注销、file_operations结构体实现、cdev注册与注销、用户态与内核态交互

字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种模型。你想想看,键盘、鼠标、串口、LED灯——这些按字节流读写的外设,几乎都用字符设备驱动来管理。我刚开始学驱动时,就是从字符设备入手的。今天咱们就把它的核心流程拆开揉碎了讲清楚。

设备号:驱动的身份证

每个字符设备在内核里都有一个唯一的编号,叫设备号。它分两部分:主设备号和次设备号。主设备号标识驱动类型,次设备号标识具体设备实例。

设备号结构:dev_t 类型,高12位是主设备号,低20位是次设备号。

申请设备号有两种方式:

  • 静态申请:你自己指定主设备号。适合你知道这个号没被占用的场景。
  • 动态申请:让内核帮你分配。我建议你优先用这种方式,省心。

代码示例:

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

dev_t dev_num;
int major;

/* 动态申请:让内核分配 */
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);

/* 静态申请:自己指定主设备号 */
register_chrdev_region(MKDEV(240, 0), 1, "my_device");

我的习惯:开发阶段用动态申请,调试时通过 cat /proc/devices 查看分配到的号。等产品定型了,再改成静态申请固定号。

注销设备号很简单:

unregister_chrdev_region(dev_num, 1);

我曾经踩过的坑:在模块卸载函数里忘了注销设备号,结果第二次 insmod 时直接报错“Device or resource busy”。后来我养成了习惯——每次在 exit 函数里第一行就写 unregister,确保不会漏。

file_operations:驱动与用户态的桥梁

用户程序调用 open、read、write、close 时,内核怎么知道该执行你驱动的哪段代码?答案就是 file_operations 结构体。它是一张函数指针表,把系统调用映射到你的驱动函数。

常用的操作:

成员 系统调用 说明
.open open() 打开设备,做初始化
.release close() 关闭设备,释放资源
.read read() 从设备读取数据
.write write() 向设备写入数据
.unlocked_ioctl ioctl() 设备控制命令

实现示例:

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

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos) {
    /* 从内核空间拷贝数据到用户空间 */
    if (copy_to_user(buf, kernel_buf, count))
        return -EFAULT;
    return count;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos) {
    /* 从用户空间拷贝数据到内核空间 */
    if (copy_from_user(kernel_buf, buf, count))
        return -EFAULT;
    return count;
}

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

关键点:read/write 里一定要用 copy_to_user 和 copy_from_user,不能直接解引用用户空间指针。内核态和用户态的地址空间是隔离的,直接访问会引发段错误。

cdev:字符设备的内核对象

设备号有了,操作函数表也有了,怎么把它们注册到内核里?靠 cdev 结构体。它是字符设备在内核中的核心表示。

注册流程分三步:

  1. 分配 cdev:用 cdev_alloc() 或静态定义。
  2. 初始化 cdev:调用 cdev_init(),绑定 file_operations。
  3. 添加 cdev:调用 cdev_add(),告诉内核这个设备号归你管。

代码示例:

static struct cdev my_cdev;

static int __init my_init(void) {
    dev_t dev;
    int ret;

    /* 1. 申请设备号 */
    alloc_chrdev_region(&dev, 0, 1, "my_device");

    /* 2. 初始化 cdev */
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    /* 3. 注册到内核 */
    ret = cdev_add(&my_cdev, dev, 1);
    if (ret) {
        printk(KERN_ERR "cdev_add failed\n");
        unregister_chrdev_region(dev, 1);
        return ret;
    }

    printk(KERN_INFO "Device registered, major=%d\n", MAJOR(dev));
    return 0;
}

static void __exit my_exit(void) {
    /* 先注销 cdev,再释放设备号 */
    cdev_del(&my_cdev);
    unregister_chrdev_region(my_cdev.dev, 1);
    printk(KERN_INFO "Device unregistered\n");
}

顺序很重要:卸载时先 cdev_del 再 unregister_chrdev_region。反过来会导致设备号释放了但 cdev 还在,内核访问野指针直接 panic。我调试时遇到过,屏幕一黑,重启吧。

用户态与内核态交互:数据怎么传

用户程序通过系统调用进入内核,驱动在 file_operations 里处理请求。数据传递靠两个关键函数:

  • copy_from_user(to, from, n):用户空间 → 内核空间
  • copy_to_user(to, from, n):内核空间 → 用户空间

用户态程序示例:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    int fd;
    char buf[64] = "Hello from userspace!";
    char read_buf[64] = {0};

    fd = open("/dev/my_device", O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    write(fd, buf, sizeof(buf));
    read(fd, read_buf, sizeof(read_buf));
    printf("Read from kernel: %s\n", read_buf);

    close(fd);
    return 0;
}

调试小技巧:用 printk 打印 current->pid 和 current->comm,可以知道是哪个用户进程在操作你的设备。我在排查多进程并发访问时经常用这招。

完整流程:一张图看懂

我把整个字符设备驱动的注册、交互、注销流程画成了一张图。你对照着看,思路会更清晰。

字符设备驱动完整流程 用户态 应用程序:open / read / write / close / ioctl 系统调用接口(VFS) 内核态 - 字符设备驱动 file_operations .open .read .write .release cdev 结构体 cdev_init / cdev_add 设备号 dev_t 主设备号 + 次设备号 硬件设备(寄存器 / 内存 / GPIO) 注册顺序:alloc_chrdev_region → cdev_init → cdev_add 注销顺序:cdev_del → unregister_chrdev_region

避坑指南:我踩过的几个雷

  • 设备号冲突:静态申请时用了已被占用的主设备号,insmod 直接失败。用 cat /proc/devices 先查一下。
  • cdev_add 失败不清理:如果 cdev_add 返回错误,别忘了 unregister_chrdev_region。否则设备号就泄漏了。
  • copy_to_user 返回值不检查:如果拷贝失败,要返回 -EFAULT。用户态会收到错误码,而不是读到乱数据。
  • 并发访问:多个进程同时 open 同一个设备,你的 open/release 里有没有计数保护?我早期没加,结果 release 里释放了还在用的资源,系统直接 hang 住。

我的建议:刚开始写字符驱动时,先搭一个最小框架——只有 open/release,打印点日志。确认注册注销流程没问题了,再往里加 read/write 逻辑。这样出问题了容易定位。

字符设备驱动是 Linux 驱动开发的基石。设备号、file_operations、cdev 这三样东西,你搞明白了,后面学块设备、网络设备都会轻松很多。嗯,今天就先聊到这儿,你先把代码跑起来试试。