4. 字符设备驱动框架:从零搭建你的第一个驱动骨架
字符设备驱动,说白了就是 Linux 驱动世界里最基础、最经典的一种模型。你想想看,键盘、鼠标、串口、LED 灯——这些设备本质上都是“按字节流读写”的,它们都属于字符设备。我刚开始学驱动时,第一个上手的也是字符设备。今天我们就把它拆开揉碎了讲清楚。
4.1 file_operations:驱动与内核的“接口契约”
每个字符设备驱动,核心就是一张函数跳转表——file_operations 结构体。内核不知道你的设备怎么读写,它只认这个结构体里的函数指针。
核心认知: file_operations 是驱动写给内核的“操作手册”。用户调用 open/read/write/ioctl 时,内核就翻这本手册,找到对应的驱动函数去执行。
我见过不少新手,上来就写 .read = my_read,结果忘了初始化其他成员。嗯,这里要注意:未赋值的成员必须显式设为 NULL,内核会帮你做默认处理。千万别用未初始化的局部变量去赋值,那是坑。
// 一个典型的 file_operations 实例
static struct file_operations my_fops = {
.owner = THIS_MODULE, // 模块引用计数,必须填
.open = my_open, // 打开设备
.release = my_release, // 关闭设备
.read = my_read, // 从设备读数据
.write = my_write, // 向设备写数据
.unlocked_ioctl = my_ioctl, // 控制命令(新内核用这个)
.llseek = my_llseek, // 文件定位
};
这里有个细节:.owner 为什么要设成 THIS_MODULE?我在项目中遇到过,有人忘了填这个字段,结果模块被 rmmod 卸载后,用户态程序还在调用驱动——直接内核 panic。这个字段就是防止模块在被使用时被卸载的。
| 成员 | 作用 | 避坑要点 |
|---|---|---|
| .owner | 模块引用计数 | 必须设为 THIS_MODULE,否则 rmmod 可能崩溃 |
| .open | 设备打开时的初始化 | 可做自增计数、分配私有数据 |
| .release | 设备关闭时的清理 | 别忘了释放 open 里申请的资源 |
| .read/.write | 数据读写入口 | 注意用户态指针要用 copy_to/from_user |
| .unlocked_ioctl | 自定义控制命令 | 新内核已废弃 ioctl,改用 unlocked_ioctl |
4.2 主次设备号:设备的“身份证号”
设备号分两部分:主设备号标识驱动类型,次设备号标识同类型下的不同设备。比如你插两个串口,主设备号都是 4,次设备号分别是 0 和 1。
分配方式有两种:
- 静态分配:用
register_chrdev_region(),提前知道设备号。适合固定硬件、嵌入式板级支持包。 - 动态分配:用
alloc_chrdev_region(),让内核自动分配。我建议你优先用动态分配——避免设备号冲突,也方便模块化。
我的习惯: 开发阶段用动态分配,通过 cat /proc/devices 查看分配结果。产品定型后,如果设备号需要固定,再改成静态分配。
dev_t dev_num; // 设备号(主+次)
int major, minor;
// 方式一:动态分配
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
printk("alloc_chrdev_region failed\n");
return ret;
}
major = MAJOR(dev_num);
minor = MINOR(dev_num);
// 方式二:静态分配(已知主设备号 240)
dev_num = MKDEV(240, 0);
ret = register_chrdev_region(dev_num, 1, "my_device");
我曾经犯过一个错:动态分配后直接用了 dev_num,没提取主次号就去注册 cdev。结果 cdev 注册成功,但 mknod 时设备号对不上——驱动死活不工作。记住:alloc_chrdev_region 返回的 dev_t 要拆开用,或者直接用 MKDEV 重新组装。
4.3 cdev 注册与注销:让内核认识你的设备
设备号有了,操作函数表有了,接下来就是把它们绑定到一起——用 cdev 结构体。这步做不好,内核根本不知道你的驱动在哪儿。
struct cdev my_cdev;
// 1. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 2. 注册 cdev 到内核
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk("cdev_add failed\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
这里有个顺序问题:必须先分配设备号,再注册 cdev。反过来会怎样?cdev_add 需要一个有效的 dev_t,你还没分配就传个 0 进去——内核不会报错,但用户态永远找不到你的设备。
我曾经踩过的坑: 在模块卸载函数里,先调了 unregister_chrdev_region(),再调 cdev_del()。结果设备号先被回收,cdev_del 时内核尝试访问已释放的设备号——Oops 报错。正确顺序:先 cdev_del,再 unregister_chrdev_region。
注销代码的标准写法:
static void __exit my_exit(void)
{
cdev_del(&my_cdev); // 先删除 cdev
unregister_chrdev_region(dev_num, 1); // 再释放设备号
}
4.4 设备节点创建:用户态怎么找到你?
驱动注册完了,内核知道你了,但用户态怎么访问?答案是设备节点——就是 /dev/ 目录下的那个文件。有两种方式:
- 手动 mknod:适合调试阶段。命令:
mknod /dev/mydev c 240 0(c 表示字符设备,240 是主设备号,0 是次设备号)。 - 自动创建:用
class_create+device_create,内核自动在/dev/下生成节点。我建议产品代码都用这种方式。
static struct class *my_class;
static struct device *my_device;
// 在模块初始化函数中
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
// 错误处理
}
my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
if (IS_ERR(my_device)) {
class_destroy(my_class);
// 错误处理
}
自动创建的好处很明显:设备号变了,节点自动更新;模块卸载时,节点自动删除。你想想看,如果用手动 mknod,每次 insmod 后还得去查设备号、敲命令——多麻烦。
调试小技巧: 注册成功后,用 ls -l /dev/my_device 查看节点信息。如果显示的是 c 240 0 就对了。如果显示问号或设备号不对,说明 cdev_add 或 device_create 有问题。
4.5 完整框架图:一张图看懂字符设备驱动
下面这张图把整个流程串起来了。我建议你把它打印出来贴在工位上——每次写驱动前看一眼,顺序就不会乱。
4.6 完整代码骨架:拿来就能用
下面是一个最小化的字符设备驱动框架。我每次写新驱动都从这开始改——省得每次都敲那些模板代码。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;
static struct device *my_device;
static int my_open(struct inode *inode, struct file *filp)
{
printk("device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp)
{
printk("device closed\n");
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
};
static int __init my_init(void)
{
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) return ret;
// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 注册 cdev
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
unregister_chrdev_region(dev_num, 1);
return ret;
}
// 4. 自动创建设备节点
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
return PTR_ERR(my_class);
}
my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
if (IS_ERR(my_device)) {
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
return PTR_ERR(my_device);
}
printk("my_device registered, major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
return 0;
}
static void __exit my_exit(void)
{
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk("my_device unregistered\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A minimal char device driver");
重要提醒: 上面的代码只实现了 open/release,没有 read/write。如果你加载后去 cat /dev/my_device,会返回什么?嗯,什么都不会返回——因为 read 函数是 NULL,内核直接返回 -EINVAL。下一章我们会补上真正的读写逻辑。
好了,字符设备驱动的骨架你已经有了。记住这个四步走:分配设备号 → 初始化 cdev → 注册 cdev → 创建节点。注销时反向操作。把这个流程刻在脑子里,以后写任何字符设备驱动都不会迷路。