3、字符设备驱动基础:设备号、file_operations、cdev 与设备节点

字符设备驱动是 Linux 驱动开发中最基础的驱动类型,也是网络驱动中控制面(如 MDIO、GPIO、中断注册)的底层支撑。本章聚焦字符设备驱动的四个核心要素:设备号(主/次)、file_operations 结构体、cdev 的注册与注销,以及设备节点的创建(手动与自动)。掌握这些基础,后续编写网络设备控制接口时将得心应手。

学习提示:字符设备驱动是理解 VFS(虚拟文件系统)与硬件交互的最佳入口。本章所有代码示例均基于 Linux 6.x 内核,API 向后兼容。

3.1 设备号:主设备号与次设备号

Linux 内核通过设备号唯一标识一个字符设备。设备号是一个 dev_t 类型(32 位无符号整数),其中:

  • 主设备号(major):高 12 位,标识设备对应的驱动程序。
  • 次设备号(minor):低 20 位,由驱动内部使用,通常用于区分同类型设备的不同实例或硬件通道。
概念 位数 作用 示例
主设备号 12 bit 内核根据主设备号找到对应的驱动 89(I2C 设备)
次设备号 20 bit 驱动内部区分不同设备或通道 0、1、2
设备号 32 bit 内核全局唯一标识 MKDEV(89, 0)

内核提供以下宏操作设备号:

#include <linux/types.h>
#include <linux/kdev_t.h>

dev_t devno = MKDEV(major, minor);   // 组合主次设备号
int major = MAJOR(devno);            // 提取主设备号
int minor = MINOR(devno);            // 提取次设备号
分配策略:
  • 静态分配:驱动指定主设备号(需确保不冲突),使用 register_chrdev_region()。
  • 动态分配:内核自动分配未使用的主设备号,使用 alloc_chrdev_region(),推荐用于可动态加载的驱动。
设备号结构示意图 32-bit dev_t 设备号 主设备号 (12 bit) 次设备号 (20 bit) 说明: • 主设备号:内核通过它查找驱动注册的 cdev,决定调用哪个驱动。 • 次设备号:驱动内部使用,例如区分网卡的不同队列或 SPI 片选通道。

3.2 file_operations 结构体

file_operations 是字符设备驱动的核心接口,定义了用户空间通过系统调用(open、read、write、ioctl 等)操作设备时的回调函数。内核 VFS 层在每次文件操作时,都会查找该结构体中的函数指针。

成员 系统调用 典型实现
.open open() 递增使用计数、初始化硬件
.release close() 递减使用计数、释放资源
.read read() 从硬件 FIFO 拷贝数据到用户空间
.write write() 从用户空间拷贝数据到硬件 FIFO
.unlocked_ioctl ioctl() 控制命令(如设置波特率、复位)
.mmap mmap() 将 DMA 缓冲区映射到用户空间

典型定义示例:

static int my_open(struct inode *inode, struct file *filp) {
    pr_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 const struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
    .unlocked_ioctl = my_ioctl,
};
注意事项:
  • .owner 必须设置为 THIS_MODULE,防止模块正在使用时被卸载。
  • 用户空间指针(__user)必须使用 copy_to_user/copy_from_user,不能直接解引用。
  • 网络驱动中,ioctl 常用于配置 MAC 地址、PHY 寄存器等控制面操作。

3.3 cdev 的注册与注销

cdev 是内核中表示字符设备的核心结构体。驱动必须将 cdev 注册到内核,才能将设备号与 file_operations 关联起来。

3.3.1 注册流程

  1. 分配设备号:alloc_chrdev_region() 或 register_chrdev_region()。
  2. 初始化 cdev:cdev_init(&cdev, &fops)。
  3. 添加 cdev 到内核:cdev_add(&cdev, devno, count)。
#include <linux/cdev.h>

static dev_t devno;
static struct cdev my_cdev;

static int __init my_init(void) {
    int ret;

    // 1. 动态分配设备号
    ret = alloc_chrdev_region(&devno, 0, 1, "my_device");
    if (ret < 0) {
        pr_err("Failed to allocate device number\n");
        return ret;
    }

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

    // 3. 注册 cdev
    ret = cdev_add(&my_cdev, devno, 1);
    if (ret) {
        pr_err("Failed to add cdev\n");
        unregister_chrdev_region(devno, 1);
        return ret;
    }

    pr_info("My device registered, major=%d minor=%d\n",
            MAJOR(devno), MINOR(devno));
    return 0;
}

3.3.2 注销流程

  1. 删除 cdev:cdev_del(&cdev)。
  2. 释放设备号:unregister_chrdev_region(devno, count)。
static void __exit my_exit(void) {
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
    pr_info("My device unregistered\n");
}
cdev 注册与注销完整流程 注册流程 (module_init) alloc_chrdev_region() cdev_init(&cdev, &fops) cdev_add(&cdev, devno, 1) 注销流程 (module_exit) cdev_del(&cdev) unregister_chrdev_region(devno, 1) 关键顺序: 注册:先分配设备号 → 再添加 cdev;注销:先删除 cdev → 再释放设备号。顺序颠倒会导致竞态或空指针。

3.4 设备节点创建:手动与自动

设备节点是用户空间访问字符设备的入口,位于 /dev 目录下。创建方式分为手动和自动两种。

3.4.1 手动创建(mknod)

驱动加载后,使用 mknod 命令手动创建设备节点:

# mknod /dev/my_device c 240 0
# 参数说明:c 表示字符设备,240 为主设备号,0 为次设备号
# 查看已创建的节点
# ls -l /dev/my_device
crw-r--r-- 1 root root 240, 0 Jan 15 10:00 /dev/my_device
手动创建的缺点:
  • 需要用户知道主设备号,不便于动态分配。
  • 设备节点在系统重启后消失,无法持久化。
  • 不适合热插拔场景(如 USB 设备)。

3.4.2 自动创建(udev / devtmpfs)

现代 Linux 系统使用 udev(用户空间设备管理器)或 devtmpfs 自动创建设备节点。驱动只需在初始化时创建 device 对象,并关联到 class,udev 会自动在 /dev 下生成节点。

自动创建步骤:

  1. 创建 class:class_create(THIS_MODULE, "my_class")。
  2. 创建设备:device_create(cls, NULL, devno, NULL, "my_device")。
  3. 注销时销毁:device_destroy(cls, devno) + class_destroy(cls)。
#include <linux/device.h>

static struct class *my_class;

static int __init my_init(void) {
    // ... 分配设备号、注册 cdev ...

    // 创建 class
    my_class = class_create(THIS_MODULE, "my_class");
    if (IS_ERR(my_class)) {
        pr_err("Failed to create class\n");
        // 回滚 cdev 和设备号
        return PTR_ERR(my_class);
    }

    // 创建设备节点 /dev/my_device
    device_create(my_class, NULL, devno, NULL, "my_device");

    return 0;
}

static void __exit my_exit(void) {
    device_destroy(my_class, devno);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
}
设备节点自动创建机制 内核空间 (Kernel) class_create("my_class") device_create(cls, devno, "my_device") sysfs 导出设备信息 用户空间 (Userspace) udev 监听 uevent mknod /dev/my_device /dev/my_device 可用 uevent

3.5 完整驱动框架模板

将上述要素整合,得到一个可编译的字符设备驱动模板:

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

#define DEVICE_NAME "my_device"
#define CLASS_NAME  "my_class"

static dev_t devno;
static struct cdev my_cdev;
static struct class *my_class;
static char kernel_buf[1024];

static int my_open(struct inode *inode, struct file *filp) {
    return 0;
}

static int my_release(struct inode *inode, struct file *filp) {
    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 const struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

static int __init my_init(void) {
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&devno, 0, 1, DEVICE_NAME);
    if (ret < 0) return ret;

    // 2. 初始化并注册 cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    ret = cdev_add(&my_cdev, devno, 1);
    if (ret) {
        unregister_chrdev_region(devno, 1);
        return ret;
    }

    // 3. 自动创建设备节点
    my_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(my_class)) {
        cdev_del(&my_cdev);
        unregister_chrdev_region(devno, 1);
        return PTR_ERR(my_class);
    }
    device_create(my_class, NULL, devno, NULL, DEVICE_NAME);

    pr_info("Device %s registered (major=%d minor=%d)\n",
            DEVICE_NAME, MAJOR(devno), MINOR(devno));
    return 0;
}

static void __exit my_exit(void) {
    device_destroy(my_class, devno);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
    pr_info("Device %s unregistered\n", DEVICE_NAME);
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Linux Network Driver Course");
MODULE_DESCRIPTION("Character device driver template");
编译与测试:
# Makefile 示例
obj-m += my_device.o
all:
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

# 加载与测试
sudo insmod my_device.ko
echo "Hello" > /dev/my_device
cat /dev/my_device
sudo rmmod my_device

3.6 本章小结

知识点 核心函数/宏 关键注意点
设备号分配 alloc_chrdev_region() 动态分配优于静态,避免冲突
file_operations .open、.read、.write、.unlocked_ioctl 用户指针必须用 copy_to/from_user
cdev 注册 cdev_init() + cdev_add() 先分配设备号,再添加 cdev
cdev 注销 cdev_del() + unregister_chrdev_region() 先删除 cdev,再释放设备号
自动创建节点 class_create() + device_create() 配合 udev 实现热插拔支持