4. 字符设备驱动基础:设备号、file_operations结构体、cdev注册与注销

字符设备是Linux驱动模型中最基础、最常用的设备类型,其特点是数据按字节流顺序访问,如串口、键盘、触摸屏等。本章将系统讲解字符设备驱动的核心要素:设备号的分配与管理、file_operations结构体的实现、cdev对象的生命周期管理,并最终完成一个完整的虚拟字符设备驱动。

学习目标:掌握字符设备驱动的标准开发流程,理解内核如何通过设备号与文件操作接口将用户空间的系统调用映射到驱动函数。

4.1 设备号:主设备号与次设备号

设备号是内核标识设备的唯一数字,由主设备号(major)和次设备号(minor)两部分组成,共32位。主设备号标识设备对应的驱动程序,次设备号标识由同一驱动管理的具体设备实例。

组成部分 位宽 作用 示例
主设备号(major) 12位(0~4095) 标识驱动程序,内核通过主设备号找到对应的驱动 4表示串口设备
次设备号(minor) 20位(0~1048575) 标识具体设备实例,驱动内部用于区分不同设备 0表示ttyS0,1表示ttyS1

内核使用dev_t类型(32位无符号整数)存储设备号,并提供宏操作:

#include <linux/types.h>

dev_t dev;                     // 设备号变量
int major = MAJOR(dev);        // 从dev_t提取主设备号
int minor = MINOR(dev);        // 从dev_t提取次设备号
dev = MKDEV(major, minor);     // 由主次设备号合成dev_t

4.1.1 设备号分配策略

设备号分配分为静态分配和动态分配两种方式:

  • 静态分配:调用register_chrdev_region(),需开发者手动指定主设备号。适用于已知且未被占用的设备号,但容易与已有驱动冲突。
  • 动态分配:调用alloc_chrdev_region(),由内核自动分配未使用的主设备号。推荐用于大多数场景,避免设备号冲突。
最佳实践:优先使用动态分配。若必须静态分配,请查阅Documentation/admin-guide/devices.txt确认设备号未被占用。
// 静态分配:注册0~3号次设备,共4个设备
if (register_chrdev_region(MKDEV(240, 0), 4, "my_device") < 0) {
    pr_err("Failed to register region\n");
    return -EIO;
}

// 动态分配:内核自动分配主设备号,从0号次设备开始,共2个设备
dev_t dev;
if (alloc_chrdev_region(&dev, 0, 2, "my_dynamic_device") < 0) {
    pr_err("Failed to alloc region\n");
    return -EIO;
}
pr_info("Allocated major=%d minor=%d\n", MAJOR(dev), MINOR(dev));

4.1.2 设备号注销

驱动卸载时,必须释放已分配的设备号:

void unregister_chrdev_region(dev_t from, unsigned count);

参数from为起始设备号,count为要注销的设备数量,必须与注册时一致。

设备号分配流程 分配策略选择 静态分配 动态分配 已知主设备号 未知/推荐 register_chrdev_region() alloc_chrdev_region() cdev_add() 注册设备

4.2 file_operations结构体

file_operations是字符设备驱动的核心接口,它将用户空间的系统调用(如open、read、write)映射到驱动实现的回调函数。该结构体定义在<linux/fs.h>中。

成员 函数原型 触发时机 说明
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() 从用户空间写入数据到设备
llseek loff_t (*llseek)(struct file *, loff_t, int) 用户调用lseek() 修改文件读写位置
unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 用户调用ioctl() 设备控制命令(无大内核锁)
注意:内核5.6之后移除了ioctl字段,必须使用unlocked_ioctl或compat_ioctl。所有数据在用户空间与内核空间之间传递时,必须使用copy_to_user()和copy_from_user(),禁止直接解引用用户空间指针。

典型实现示例:

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

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

static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) {
    char kernel_buf[128] = "Hello from kernel!\n";
    size_t len = strlen(kernel_buf);
    if (count < len) len = count;
    if (copy_to_user(buf, kernel_buf, len)) return -EFAULT;
    *f_pos += len;
    return len;
}

static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) {
    char kernel_buf[128];
    if (count > sizeof(kernel_buf)) count = sizeof(kernel_buf);
    if (copy_from_user(kernel_buf, buf, count)) return -EFAULT;
    pr_info("Received %zu bytes: %s\n", count, kernel_buf);
    *f_pos += count;
    return count;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};
用户空间系统调用 → file_operations 映射 用户空间 open("/dev/mydev") read(fd, buf, 128) write(fd, "data", 4) lseek(fd, 0, SEEK_SET) ioctl(fd, CMD, arg) close(fd) 内核VFS层 sys_open() sys_read() sys_write() sys_lseek() sys_ioctl() sys_close() 驱动层 my_open() my_read() my_write() my_llseek() my_ioctl() my_release() 系统调用 file_operations

4.3 cdev结构体:注册与注销

cdev是内核中表示字符设备的核心结构体,定义在<linux/cdev.h>。每个字符设备在内核中对应一个cdev实例,通过它将设备号与file_operations绑定。

4.3.1 cdev生命周期

步骤 函数/操作 说明
1. 分配 cdev_alloc() 或静态定义 分配cdev结构体内存
2. 初始化 cdev_init() 关联file_operations,设置owner
3. 添加 cdev_add() 将设备注册到内核,设备号生效
4. 删除 cdev_del() 从内核移除设备,释放cdev
#include <linux/cdev.h>

struct cdev my_cdev;          // 静态分配
dev_t dev_num;                // 设备号

// 步骤1+2:初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

// 步骤3:添加cdev到内核
if (cdev_add(&my_cdev, dev_num, 1) < 0) {
    pr_err("Failed to add cdev\n");
    unregister_chrdev_region(dev_num, 1);
    return -EIO;
}

// 步骤4:模块卸载时删除cdev
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
关键点:cdev_add()之后设备立即生效,用户空间可以访问。因此必须在cdev_add()之前完成所有初始化工作,包括设备号注册。卸载时先cdev_del()再注销设备号。

4.4 完整字符设备驱动实现

下面实现一个完整的虚拟字符设备驱动,支持open、release、read、write操作,使用动态设备号分配。

4.4.1 驱动代码

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

#define DEVICE_NAME "vchard"
#define BUFFER_SIZE 1024

static struct cdev vchard_cdev;
static dev_t vchard_dev;
static char *vchard_buffer;
static int vchard_major;

static int vchard_open(struct inode *inode, struct file *filp) {
    pr_info("vchard: device opened\n");
    return 0;
}

static int vchard_release(struct inode *inode, struct file *filp) {
    pr_info("vchard: device closed\n");
    return 0;
}

static ssize_t vchard_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) {
    size_t available = strlen(vchard_buffer);
    if (*f_pos >= available) return 0;
    if (count > available - *f_pos) count = available - *f_pos;
    if (copy_to_user(buf, vchard_buffer + *f_pos, count)) return -EFAULT;
    *f_pos += count;
    return count;
}

static ssize_t vchard_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) {
    if (count > BUFFER_SIZE - 1) count = BUFFER_SIZE - 1;
    if (copy_from_user(vchard_buffer, buf, count)) return -EFAULT;
    vchard_buffer[count] = '\0';
    *f_pos += count;
    pr_info("vchard: wrote %zu bytes\n", count);
    return count;
}

static struct file_operations vchard_fops = {
    .owner   = THIS_MODULE,
    .open    = vchard_open,
    .release = vchard_release,
    .read    = vchard_read,
    .write   = vchard_write,
};

static int __init vchard_init(void) {
    // 1. 动态分配设备号
    if (alloc_chrdev_region(&vchard_dev, 0, 1, DEVICE_NAME) < 0) {
        pr_err("vchard: failed to alloc region\n");
        return -EIO;
    }
    vchard_major = MAJOR(vchard_dev);
    pr_info("vchard: major=%d\n", vchard_major);

    // 2. 分配缓冲区
    vchard_buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
    if (!vchard_buffer) {
        unregister_chrdev_region(vchard_dev, 1);
        return -ENOMEM;
    }
    memset(vchard_buffer, 0, BUFFER_SIZE);

    // 3. 初始化并添加cdev
    cdev_init(&vchard_cdev, &vchard_fops);
    vchard_cdev.owner = THIS_MODULE;
    if (cdev_add(&vchard_cdev, vchard_dev, 1) < 0) {
        pr_err("vchard: failed to add cdev\n");
        kfree(vchard_buffer);
        unregister_chrdev_region(vchard_dev, 1);
        return -EIO;
    }

    pr_info("vchard: driver loaded successfully\n");
    return 0;
}

static void __exit vchard_exit(void) {
    cdev_del(&vchard_cdev);
    kfree(vchard_buffer);
    unregister_chrdev_region(vchard_dev, 1);
    pr_info("vchard: driver unloaded\n");
}

module_init(vchard_init);
module_exit(vchard_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kernel Expert");
MODULE_DESCRIPTION("A simple virtual char device driver");

4.4.2 用户空间测试程序

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

int main() {
    char buf[256];
    int fd = open("/dev/vchard0", O_RDWR);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    // 写入数据
    write(fd, "Hello from userspace!", 21);

    // 读取数据
    lseek(fd, 0, SEEK_SET);
    memset(buf, 0, sizeof(buf));
    read(fd, buf, sizeof(buf));
    printf("Read: %s\n", buf);

    close(fd);
    return 0;
}
注意:测试前需要手动创建设备节点:mknod /dev/vchard0 c MAJOR 0,其中MAJOR为驱动加载时打印的主设备号。也可以使用udev自动创建。
字符设备驱动完整架构 用户空间 应用程序 open/read/write/close 内核空间 VFS层 虚拟文件系统 cdev对象 设备号+ops file_operations open/read/write 驱动 函数 设备号分配 → cdev_init → cdev_add → 设备可用 卸载时:cdev_del → 注销设备号 → 释放资源

4.5 常见问题与调试技巧

  • 设备节点无法打开:检查设备号是否匹配,使用cat /proc/devices查看已注册的设备号。
  • 读写返回-EFAULT:检查copy_to_user/copy_from_user的地址是否正确,用户空间指针不能直接解引用。
  • 模块卸载失败:确保没有进程持有设备文件描述符,使用lsof或fuser检查。
  • 设备号冲突:优先使用动态分配,避免手动指定已被占用的主设备号。
调试命令速查:
cat /proc/devices — 查看已注册的设备号
ls -l /dev/vchard* — 查看设备节点的主次设备号
dmesg | tail — 查看驱动打印的内核日志
insmod vchard.ko && mknod /dev/vchard0 c MAJOR 0 — 加载驱动并创建设备节点

本章完成了字符设备驱动的完整学习:从设备号分配、file_operations实现、cdev注册注销,到完整的驱动代码与测试。这是后续学习更复杂驱动(如并发控制、阻塞IO、poll机制)的基础。