2. 字符设备驱动基础:file_operations结构体、设备号注册与注销、cdev_init与cdev_add、简单的字符设备驱动示例

字符设备驱动是Linux驱动开发中最基础、最核心的模块之一。无论是GPIO控制、串口通信,还是后续将要学习的V4L2摄像头驱动,其底层都依赖于字符设备驱动框架。本章将深入剖析字符设备驱动的四大支柱:file_operations结构体、设备号管理、cdev初始化与添加,并通过一个完整的示例串联所有知识点。

2.1 file_operations结构体:驱动与用户空间的桥梁

file_operations结构体定义在<linux/fs.h>中,是驱动开发者向VFS(虚拟文件系统)注册的“操作函数表”。用户空间对设备文件执行open()、read()、write()、ioctl()等系统调用时,VFS通过该结构体找到驱动对应的处理函数。

核心概念: file_operations是一个函数指针集合,驱动只需实现需要的接口,未实现的指针置为NULL,VFS会返回对应错误码。
成员名称 函数原型 触发时机 典型用途
owner struct module *owner 模块引用计数 通常设为THIS_MODULE
open int (*open)(struct inode *, struct file *) 用户调用open() 设备初始化、权限检查
release int (*release)(struct inode *, struct file *) 用户调用close() 资源释放、计数器递减
read ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) 用户调用read() 从内核拷贝数据到用户空间
write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 用户调用write() 从用户空间接收数据到内核
unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 用户调用ioctl() 设备控制命令(V4L2大量使用)
mmap int (*mmap)(struct file *, struct vm_area_struct *) 用户调用mmap() 内存映射(摄像头帧缓冲)
poll __poll_t (*poll)(struct file *, struct poll_table_struct *) 用户调用poll()/select() 非阻塞I/O事件通知
V4L2关联: 摄像头驱动中,unlocked_ioctl是核心接口,用于处理VIDIOC_QUERYCAP、VIDIOC_S_FMT等控制命令;mmap用于将视频帧缓冲映射到用户空间;poll用于等待帧数据就绪。

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

Linux内核使用设备号唯一标识一个设备驱动。设备号是一个dev_t类型(32位无符号整数),其中高12位为主设备号,低20位为次设备号。

  • 主设备号:标识设备对应的驱动程序。内核通过主设备号找到cdev或file_operations。
  • 次设备号:由驱动内部使用,用于区分同类型的不同设备实例(如摄像头0、摄像头1)。
避坑提示: 主设备号是全局资源,静态分配需从Documentation/admin-guide/devices.txt中选取未使用的号码。动态分配(推荐)使用alloc_chrdev_region(),避免冲突。
设备号结构分解 dev_t (32位) 主设备号 (12位) 次设备号 (20位) 范围: 0~4095 范围: 0~1048575 MAJOR(dev) 提取主设备号 | MINOR(dev) 提取次设备号 | MKDEV(major, minor) 合成dev_t

2.3 设备号注册与注销

内核提供两组API用于设备号管理:静态注册(已知主设备号)和动态分配(由内核自动分配)。

函数 原型 说明
register_chrdev_region int register_chrdev_region(dev_t from, unsigned count, const char *name) 静态注册:指定起始设备号,适用于已知主设备号场景
alloc_chrdev_region int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name) 动态分配:内核自动分配主设备号,推荐使用
unregister_chrdev_region void unregister_chrdev_region(dev_t from, unsigned count) 注销设备号,在模块卸载时调用
最佳实践: 始终优先使用alloc_chrdev_region()动态分配主设备号。通过cat /proc/devices可以查看当前系统已分配的设备号。

2.4 cdev_init与cdev_add:将驱动注册到内核

cdev结构体是内核中字符设备的抽象。驱动需要完成以下步骤才能让设备文件与驱动关联:

  1. 分配cdev:静态定义或kmalloc动态分配。
  2. 初始化cdev:调用cdev_init(),绑定file_operations。
  3. 添加cdev:调用cdev_add(),将cdev注册到内核,关联设备号。
字符设备注册完整流程 步骤1 alloc_chrdev_region() 步骤2 cdev_init() 步骤3 cdev_add() 设备号分配 动态分配主设备号 指定次设备号起始 返回dev_t cdev初始化 绑定file_operations 设置owner模块 初始化内部链表 cdev注册 关联设备号范围 加入内核cdev_map 设备文件可访问 卸载流程(反向) cdev_del() → unregister_chrdev_region()

2.5 完整的字符设备驱动示例

下面是一个完整的“虚拟内存设备”驱动示例。该驱动在内存中维护一个缓冲区,用户可以通过read/write读写该缓冲区。代码展示了本章所有核心概念的综合运用。

2.5.1 驱动代码:chardev_demo.c

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

#define DEVICE_NAME "chardev_demo"
#define BUFFER_SIZE 1024

static dev_t dev_num;                // 设备号
static struct cdev *chardev_cdev;    // cdev对象
static char *kernel_buffer;          // 内核缓冲区

// open操作
static int chardev_open(struct inode *inode, struct file *file)
{
    pr_info("chardev: device opened\n");
    return 0;
}

// release操作
static int chardev_release(struct inode *inode, struct file *file)
{
    pr_info("chardev: device closed\n");
    return 0;
}

// read操作:从内核缓冲区拷贝数据到用户空间
static ssize_t chardev_read(struct file *file, char __user *buf,
                            size_t count, loff_t *ppos)
{
    size_t available = BUFFER_SIZE - *ppos;
    size_t to_read = count < available ? count : available;

    if (to_read == 0)
        return 0;

    if (copy_to_user(buf, kernel_buffer + *ppos, to_read))
        return -EFAULT;

    *ppos += to_read;
    pr_info("chardev: read %zu bytes from offset %lld\n", to_read, *ppos);
    return to_read;
}

// write操作:从用户空间接收数据到内核缓冲区
static ssize_t chardev_write(struct file *file, const char __user *buf,
                             size_t count, loff_t *ppos)
{
    size_t available = BUFFER_SIZE - *ppos;
    size_t to_write = count < available ? count : available;

    if (to_write == 0)
        return -ENOSPC;

    if (copy_from_user(kernel_buffer + *ppos, buf, to_write))
        return -EFAULT;

    *ppos += to_write;
    pr_info("chardev: wrote %zu bytes at offset %lld\n", to_write, *ppos);
    return to_write;
}

// file_operations结构体
static struct file_operations chardev_fops = {
    .owner   = THIS_MODULE,
    .open    = chardev_open,
    .release = chardev_release,
    .read    = chardev_read,
    .write   = chardev_write,
};

// 模块初始化
static int __init chardev_init(void)
{
    int ret;

    // 1. 动态分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("chardev: failed to allocate device number\n");
        return ret;
    }
    pr_info("chardev: allocated major=%d, minor=%d\n",
            MAJOR(dev_num), MINOR(dev_num));

    // 2. 分配cdev
    chardev_cdev = cdev_alloc();
    if (!chardev_cdev) {
        pr_err("chardev: failed to allocate cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return -ENOMEM;
    }

    // 3. 初始化cdev并绑定fops
    cdev_init(chardev_cdev, &chardev_fops);
    chardev_cdev->owner = THIS_MODULE;

    // 4. 添加cdev到内核
    ret = cdev_add(chardev_cdev, dev_num, 1);
    if (ret < 0) {
        pr_err("chardev: failed to add cdev\n");
        cdev_del(chardev_cdev);
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    // 5. 分配内核缓冲区
    kernel_buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
    if (!kernel_buffer) {
        pr_err("chardev: failed to allocate buffer\n");
        cdev_del(chardev_cdev);
        unregister_chrdev_region(dev_num, 1);
        return -ENOMEM;
    }
    memset(kernel_buffer, 0, BUFFER_SIZE);

    pr_info("chardev: module loaded successfully\n");
    return 0;
}

// 模块卸载
static void __exit chardev_exit(void)
{
    kfree(kernel_buffer);
    cdev_del(chardev_cdev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("chardev: module unloaded\n");
}

module_init(chardev_init);
module_exit(chardev_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("V4L2 Course Author");
MODULE_DESCRIPTION("Simple Character Device Driver Demo");

2.5.2 Makefile

obj-m := chardev_demo.o

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

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

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

2.5.3 测试步骤

  1. 编译模块:执行make,生成chardev_demo.ko。
  2. 加载模块:sudo insmod chardev_demo.ko,查看dmesg确认主设备号。
  3. 创建设备文件:sudo mknod /dev/chardev_demo c MAJOR 0(替换MAJOR为实际值)。
  4. 测试读写:
    • echo "Hello V4L2" > /dev/chardev_demo
    • cat /dev/chardev_demo
  5. 卸载模块:sudo rmmod chardev_demo,删除设备文件。
调试技巧: 使用cat /proc/devices | grep chardev查看设备号分配情况;使用strace跟踪用户态系统调用,观察驱动接口调用顺序。

2.6 本章知识结构总结

知识点 核心函数/结构体 关键作用 V4L2关联
file_operations struct file_operations 定义驱动操作接口 V4L2驱动注册video_device时绑定
设备号分配 alloc_chrdev_region() 动态获取主设备号 video_register_device内部调用
cdev初始化 cdev_init() 绑定fops到cdev V4L2框架封装在video_device中
cdev注册 cdev_add() 将设备加入内核 video_register_device内部调用
数据拷贝 copy_to_user/copy_from_user 内核-用户空间数据交换 视频帧数据传递
模块生命周期 module_init/module_exit 驱动加载/卸载入口 所有驱动必备
本章要点: 字符设备驱动的核心是“设备号 + cdev + file_operations”三位一体。设备号是内核查找驱动的“门牌号”,cdev是驱动在内核中的“登记表”,file_operations是用户空间与硬件交互的“操作手册”。掌握这三者的关系,就掌握了Linux驱动开发的基石。后续V4L2框架的所有复杂机制,都是在这个基础之上构建的。