4. 字符设备驱动核心

字符设备驱动是Linux驱动开发中最基础、也最能体现“内核与用户空间交互”本质的一类驱动。它不依赖块设备的缓存与调度,而是以字节流方式直接响应read、write、ioctl等系统调用。一个可交付的字符设备驱动,通常要完成四件事:申请设备号、注册cdev、实现file_operations、打通用户空间读写接口。本章围绕这四步展开,并给出可复用的代码骨架与排障思路。

一、核心原理:从系统调用到驱动回调

用户空间执行open("/dev/mychar", O_RDWR)时,内核并不会直接调用驱动函数,而是经历一条清晰的路径:VFS根据路径找到inode,由inode->i_rdev取出设备号,再通过chrdevs[]哈希表定位到已注册的cdev,最终把file_operations中的函数指针挂到file->f_op上。此后read/write等调用才会进入驱动实现。

因此,设备号是“钥匙”,cdev是“登记簿”,file_operations是“操作手册”。三者缺一不可。设备号由主设备号(major)和次设备号(minor)组成:主设备号标识驱动类别,次设备号标识同一驱动下的不同实例。内核用dev_t(32位)表示,其中12位主设备号、20位次设备号,可通过MAJOR()、MKDEV()宏拆分与合成。

字符设备驱动核心链路:设备号 → cdev → file_operations → 用户接口 用户空间 open/read/write/ioctl /dev/mychar VFS 层 inode → i_rdev chrdevs[] 哈希查找 cdev 对象 dev_t + ops cdev_add 注册 file_operations open/read/write release/ioctl 设备号分配方式 静态:register_chrdev_region(dev_t, count, name) —— 需预先知道主设备号,易冲突 动态:alloc_chrdev_region(&dev, 0, count, name) —— 内核自动分配,推荐用于新驱动 关键数据结构与函数 struct cdev:包含 kobject、owner、ops、dev、count;cdev_init() 绑定 ops,cdev_add() 激活 struct file_operations:open、release、read、write、llseek、unlocked_ioctl、mmap 等回调

二、方法流程:四步完成一个可加载驱动

  1. 申请设备号:优先使用alloc_chrdev_region()动态分配,避免主设备号冲突。若需固定主设备号,再用register_chrdev_region()。
  2. 初始化并注册 cdev:用cdev_init()把file_operations绑定到cdev,再调用cdev_add()把设备加入内核。注意cdev_add()之后设备才真正可用。
  3. 实现 file_operations:至少实现open、release、read、write。其中read要把内核数据拷贝到用户空间,必须使用copy_to_user();write则用copy_from_user()。
  4. 创建 /dev 节点:可通过mknod /dev/mychar c 主设备号 次设备号手动创建,也可用class_create()+device_create()让udev自动生成。

最小驱动骨架:

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

static dev_t devno;
static struct cdev my_cdev;
static char kbuf[128] = "hello from kernel";

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

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t len, loff_t *off) {
    if (*off >= sizeof(kbuf)) return 0;
    if (len > sizeof(kbuf) - *off) len = sizeof(kbuf) - *off;
    if (copy_to_user(buf, kbuf + *off, len)) return -EFAULT;
    *off += len;
    return len;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t len, loff_t *off) {
    if (len > sizeof(kbuf)) len = sizeof(kbuf);
    if (copy_from_user(kbuf, buf, len)) return -EFAULT;
    return len;
}

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

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

static int __init my_init(void) {
    alloc_chrdev_region(&devno, 0, 1, "mychar");
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    cdev_add(&my_cdev, devno, 1);
    printk(KERN_INFO "mychar major=%d minor=%d\n",
           MAJOR(devno), MINOR(devno));
    return 0;
}

static void __exit my_exit(void) {
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");

三、案例场景:从加载到读写的完整验证

假设上述驱动编译为mychar.ko,在开发板上执行以下命令即可完成验证:

insmod mychar.ko
dmesg | tail -n 3
# 输出:mychar major=240 minor=0

mknod /dev/mychar c 240 0
chmod 666 /dev/mychar

echo "linux driver" > /dev/mychar
cat /dev/mychar
# 输出:linux driver

rmmod mychar

这个案例覆盖了字符设备驱动的完整闭环:模块加载时申请设备号并注册cdev,用户空间通过/dev/mychar触发open,write把数据写入内核缓冲区,read再把数据取回用户空间,最后卸载模块时释放资源。若cat输出为空或报错,应优先检查copy_to_user返回值、*off偏移量以及设备号是否与mknod一致。

四、误区与复盘:常见问题清单

误区/现象 根因 修正方法
insmod 成功但 /dev 下无节点 只注册了 cdev,未创建设备节点 手动 mknod,或用 class_create + device_create 自动生成
read 返回 -EFAULT 直接使用用户指针,未走 copy_to_user 所有用户空间数据交换必须用 copy_to_user/copy_from_user
第二次 read 读不到数据 未更新 loff_t *off,偏移量始终为 0 或越界 在 read/write 中正确维护 *off,并判断边界
rmmod 报 Device or resource busy cdev 未删除或模块引用计数未释放 在 exit 中先 cdev_del,再 unregister_chrdev_region
主设备号冲突 使用固定主设备号且已被占用 改用 alloc_chrdev_region 动态分配

交付建议:在综合项目中,字符设备驱动往往作为“控制通道”存在。建议把设备号、cdev、file_operations 的初始化封装成独立函数,并在 probe 或模块入口中按顺序调用;卸载时严格逆序释放。这样既能保证资源不泄漏,也便于后续把同一套骨架迁移到 platform 驱动或 misc 设备中。

注意:cdev_add() 成功后设备即对内核可见,此时若用户空间已打开节点,而驱动尚未完成初始化,可能触发空指针或竞态。正确做法是先完成所有内部数据结构初始化,再调用 cdev_add();卸载时先 cdev_del() 阻止新打开,再释放内部资源。

掌握设备号分配、cdev 注册、file_operations 实现和用户空间读写接口这四步,就掌握了字符设备驱动的主干。后续无论是 GPIO 控制、传感器读取还是自定义协议交互,都可以在这套骨架上扩展 ioctl、poll、mmap 等接口,形成完整的驱动交付能力。