1. 字符设备驱动基础回顾:file_operations、主次设备号、cdev_init 与 cdev_add
好,咱们正式开始。这一章,我带你回顾字符设备驱动的几个核心概念。说白了,字符设备驱动是 Linux 驱动里最基础、最经典的一类。你想想看,从按键、LED 到串口、I2C 设备,底层都是字符设备那一套。我个人习惯把这一套东西叫做「驱动三板斧」——file_operations 结构体、设备号、cdev 注册。这三样搞明白了,后面迁移到杂项设备就顺理成章了。
1.1 file_operations 结构体:驱动与用户态的桥梁
先说说 file_operations。这个结构体,说白了就是驱动暴露给用户态的一组函数指针。用户程序调用 open、read、write、ioctl 时,内核会通过这个结构体找到你写的对应函数。
我在项目中遇到过不少新手,一上来就把所有函数都实现了,其实没必要。你只需要实现你的硬件真正需要的操作就行。比如一个简单的 LED 驱动,可能只需要 open 和 ioctl,read 和 write 根本用不上。
// 一个典型的 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,
};
嗯,这里要注意几个细节:
- .owner 一定要赋值为 THIS_MODULE,防止模块被卸载时还有进程在使用它。
- .open 和 .release 通常成对出现,一个做初始化,一个做清理。
- .unlocked_ioctl 是现在的主流,老版本用 .ioctl,但已经 deprecated 了。
我的经验: 写 file_operations 时,我习惯把不用的函数指针设为 NULL。内核会帮你做默认处理,比如 read 为 NULL 时,用户态 read 会返回 -EINVAL。这样代码更干净,也更容易排查问题。
1.2 主次设备号:驱动的身份证
设备号是什么?你可以把它想象成驱动的身份证。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。比如你的系统里有两个串口,主设备号都是 4,次设备号分别是 0 和 1。
我记得刚做驱动那会儿,最头疼的就是设备号冲突。你分配了一个主设备号,结果另一个驱动也用了同一个号,加载时就报错。后来我学乖了,要么用内核动态分配,要么去查 Documentation/devices.txt 确认号没被占用。
// 静态分配设备号
#define MY_MAJOR 240
#define MY_MINOR 0
// 动态分配设备号(推荐)
dev_t dev_num;
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
int major = MAJOR(dev_num);
int minor = MINOR(dev_num);
避坑指南: 我曾经在一个项目里用了静态主设备号 240,结果客户的内核里有个第三方驱动也用了 240。加载时直接报 "register_chrdev_region: device or resource busy"。从那以后,我所有新项目都改用 alloc_chrdev_region 动态分配,省心多了。
主次设备号在内核里用 dev_t 类型表示,32 位:高 12 位是主设备号,低 20 位是次设备号。你可以用 MAJOR() 和 MINOR() 宏来提取,也可以用 MKDEV() 来合成。
1.3 cdev_init 与 cdev_add:把驱动注册进内核
有了 file_operations 和设备号,下一步就是把它们绑定起来,告诉内核:「嘿,这个设备号归我管,用户操作它时调用我的函数。」这就是 cdev_init 和 cdev_add 干的事。
cdev_init 负责初始化一个 cdev 结构体,把 file_operations 挂上去。cdev_add 则把这个 cdev 注册到内核的设备表中,关联上设备号。
// 定义一个 cdev 结构体
static struct cdev my_cdev;
// 在模块初始化函数中
static int __init my_init(void)
{
dev_t dev;
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev, 0, 1, "my_device");
if (ret < 0) {
printk("alloc_chrdev_region failed\n");
return ret;
}
// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 添加 cdev 到内核
ret = cdev_add(&my_cdev, dev, 1);
if (ret < 0) {
printk("cdev_add failed\n");
unregister_chrdev_region(dev, 1);
return ret;
}
printk("my_device registered, major=%d minor=%d\n",
MAJOR(dev), MINOR(dev));
return 0;
}
// 在模块退出函数中
static void __exit my_exit(void)
{
dev_t dev = my_cdev.dev;
cdev_del(&my_cdev);
unregister_chrdev_region(dev, 1);
printk("my_device unregistered\n");
}
你看,整个流程其实就三步:分配设备号 → 初始化 cdev → 添加 cdev。退出时反过来:删除 cdev → 释放设备号。
我的习惯: 我通常会在 cdev_add 之后检查返回值,如果失败就回滚之前的所有操作。很多新手只检查 alloc_chrdev_region 的返回值,忽略了 cdev_add 也可能失败。嗯,内核编程里,每一步都要考虑「如果失败了怎么办」。
1.4 知识体系总览
为了让你更直观地理解这三者的关系,我画了一张图。你看一眼就明白了:
这张图把整个流程串起来了。用户态调用系统接口,经过 VFS 层,找到对应的 cdev,再通过 file_operations 调用到你写的驱动函数。设备号就是中间的「门牌号」,cdev 就是「门」,file_operations 就是「门后的服务清单」。
1.5 小结
好了,这一章的内容就这些。总结一下:
- file_operations:定义驱动能干什么,只实现需要的函数即可。
- 设备号:驱动的身份证,推荐动态分配避免冲突。
- cdev_init + cdev_add:把驱动注册进内核,三步走:分配号 → 初始化 → 添加。
这些基础打牢了,下一章我们就能轻松理解杂项设备是怎么「偷懒」的——它把很多重复的注册工作封装好了,让你少写不少代码。嗯,到时候你就知道为什么我推荐新项目优先用杂项设备了。
一句话记住: 字符设备驱动 = file_operations(功能) + 设备号(身份) + cdev(注册)。这三样缺一不可。