字符设备驱动基础:file_operations、主次设备号与cdev

字符设备驱动,说白了就是让应用程序能像读写文件一样,去操作你的硬件。Linux里“一切皆文件”,你的LED、按键、串口,在用户空间看来就是一个文件——/dev/xxx。用户调用open()、read()、write(),内核怎么知道该执行你驱动里的哪段代码?这就是本章要讲的核心机制。

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

每个字符设备驱动,都必须提供一个file_operations结构体。它就是个函数指针表,告诉内核:“当用户打开我的设备时,调用我这个函数;当用户读数据时,调用那个函数。”

我刚开始写驱动时,总觉得这结构体成员太多,记不住。其实常用的就那几个:

成员 触发时机 典型实现
.open 用户调用open("/dev/xxx") 设备初始化、计数加1
.release 用户调用close() 资源释放、计数减1
.read 用户调用read() 从硬件寄存器读取数据,拷贝到用户空间
.write 用户调用write() 从用户空间拷贝数据,写入硬件寄存器
.unlocked_ioctl 用户调用ioctl() 非标准控制命令,比如设置波特率、GPIO方向
.llseek 用户调用lseek() 调整文件读写位置,很多设备直接返回-ESPIPE

关键点:不是所有成员都必须实现。你的设备不支持定位?.llseek设为NULL,内核会自动返回-ESPIPE。只读设备?.write设为NULL,用户写的时候会收到-EINVAL。

来看一个最简单的实现模板:

// 驱动中定义一个file_operations实例
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
    .unlocked_ioctl = my_ioctl,
};

// 对应的函数实现
static int my_open(struct inode *inode, struct file *filp) {
    // 拿到设备结构体,挂到filp->private_data上
    struct my_dev *dev = container_of(inode->i_cdev, struct my_dev, cdev);
    filp->private_data = dev;
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos) {
    // 从硬件读数据,用copy_to_user()传给用户
    // ...
    return bytes_read;
}

注意那个.owner,它指向模块自身。内核靠它来防止你在驱动还在使用时被rmmod掉。我见过有人忘了设这个,结果模块卸载后,用户程序还在调用read()——直接内核崩溃。

主次设备号:设备的“身份证”

内核怎么区分不同的设备?靠设备号。设备号由两部分组成:

  • 主设备号:标识设备对应的驱动程序。比如1代表内存设备,4代表串口。
  • 次设备号:标识同一个驱动管理的不同设备实例。比如两个串口,主设备号都是4,次设备号分别是64和65。

分配设备号有两种方式:

方式 函数 适用场景
静态分配 register_chrdev_region() 你知道主设备号,且确保没被占用
动态分配 alloc_chrdev_region() 让内核自动分配,推荐用于新驱动

我的习惯:开发阶段用动态分配,省得跟别人冲突。产品定型后,如果设备号需要固定(比如为了udev规则),再改成静态分配。我曾经在项目里用静态分配选了240,结果跟另一个模块撞车,排查了半天。

动态分配的代码很简单:

dev_t dev_num;
int major, minor;

// 申请一个设备号,主设备号由内核分配
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);
minor = MINOR(dev_num);

// 用完记得释放
unregister_chrdev_region(dev_num, 1);

这里0是起始次设备号,1是连续设备数量。如果你要管理多个同类设备,比如4个LED,就传4。

cdev结构体:设备与驱动的“粘合剂”

有了file_operations和设备号,怎么把它们绑定起来?靠cdev。它是内核里代表字符设备的核心结构体。

注册一个字符设备,标准流程分三步:

  1. 分配并初始化cdev:cdev_alloc() + cdev_init()
  2. 将cdev添加到内核:cdev_add()
  3. 创建设备节点:手动mknod或通过device_create()自动生成

看代码:

struct my_dev {
    struct cdev cdev;    // 内嵌cdev
    // 其他设备相关数据...
};

static int __init my_init(void) {
    dev_t dev_num;
    struct my_dev *my_dev;

    // 1. 分配设备号
    alloc_chrdev_region(&dev_num, 0, 1, "my_device");

    // 2. 分配设备结构体
    my_dev = kzalloc(sizeof(*my_dev), GFP_KERNEL);

    // 3. 初始化cdev,绑定file_operations
    cdev_init(&my_dev->cdev, &my_fops);
    my_dev->cdev.owner = THIS_MODULE;

    // 4. 将cdev注册到内核
    cdev_add(&my_dev->cdev, dev_num, 1);

    // 5. 自动创建设备节点(需要配合class)
    // 这部分下一章细讲,先留个印象
    return 0;
}

static void __exit my_exit(void) {
    // 注销顺序与注册相反
    cdev_del(&my_dev->cdev);
    unregister_chrdev_region(dev_num, 1);
    kfree(my_dev);
}

注意:cdev_add()之后,你的设备就“活”了。用户空间随时可能打开它。所以cdev_add()之前,必须确保所有初始化工作已完成。我曾经在cdev_add()之后才分配缓冲区,结果用户一打开就访问空指针——嗯,教训深刻。

核心流程:一张图说清楚

下面这张图展示了字符设备驱动的完整注册流程,以及用户空间调用open()时,内核如何通过设备号找到你的file_operations:

字符设备驱动注册与打开流程 1. alloc_chrdev_region() 分配设备号 2. cdev_init() 初始化cdev,绑定fops 3. cdev_add() 将cdev加入内核链表 4. device_create() 创建设备节点 用户调用 open("/dev/my_device") VFS根据设备号查找cdev 调用 cdev->ops->open() 返回文件描述符,驱动开始工作 设备号 关键数据结构关系 • inode->i_rdev:存储设备号(主+次) • inode->i_cdev:指向注册的cdev结构体 • cdev->ops:指向你的file_operations

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

  • cdev_add()时机不对:一定要在所有硬件初始化完成之后再调用。否则用户一打开,你的驱动还没准备好。
  • 设备号冲突:静态分配前,先cat /proc/devices看看哪些号已经被用了。动态分配最省心。
  • file_operations没设.owner:后果就是模块被卸载后,函数指针变成悬空指针。内核直接Oops。
  • 忘记注销:模块退出时,cdev_del()和unregister_chrdev_region()一个都不能少。否则下次insmod会失败。

调试小技巧:注册成功后,ls -l /dev/my_device可以看到设备号。如果设备节点没自动生成,手动mknod /dev/my_device c major minor也能用。我早期调试时经常这么干,省得写udev规则。