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()宏拆分与合成。
二、方法流程:四步完成一个可加载驱动
- 申请设备号:优先使用
alloc_chrdev_region()动态分配,避免主设备号冲突。若需固定主设备号,再用register_chrdev_region()。 - 初始化并注册 cdev:用
cdev_init()把file_operations绑定到cdev,再调用cdev_add()把设备加入内核。注意cdev_add()之后设备才真正可用。 - 实现 file_operations:至少实现
open、release、read、write。其中read要把内核数据拷贝到用户空间,必须使用copy_to_user();write则用copy_from_user()。 - 创建 /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 等接口,形成完整的驱动交付能力。