第3章 字符设备驱动框架:设备号申请与注销、file_operations结构体、cdev结构体、设备节点创建
字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种驱动模型。你想想看,我们平时用的键盘、鼠标、串口,甚至一些传感器,本质上都是字符设备。它们的特点是数据按字节流传输,顺序读写,不像块设备那样可以随机访问。
这一章,我带你手把手搭建一个完整的字符设备驱动框架。从设备号怎么申请,到file_operations结构体怎么填,再到cdev结构体怎么注册,最后创建设备节点让用户空间能访问。嗯,这一套流程走下来,你就能写出自己的第一个字符设备驱动了。
3.1 设备号:驱动的身份证
每个字符设备在Linux内核里都有一个唯一的标识,这就是设备号。设备号由两部分组成:主设备号和次设备号。
- 主设备号:标识设备对应的驱动程序。比如,同一个驱动可以管理多个同类型的设备。
- 次设备号:由驱动自己管理,用来区分同一个驱动下的不同设备实例。
在内核源码中,设备号用dev_t类型表示,它是一个32位的无符号整数。高12位是主设备号,低20位是次设备号。内核提供了几个宏来操作它:
#include <linux/types.h>
MAJOR(dev_t dev); // 从设备号中提取主设备号
MINOR(dev_t dev); // 从设备号中提取次设备号
MKDEV(int major, int minor); // 将主次设备号组合成一个dev_t
我个人习惯在写驱动时,先用MKDEV把主次设备号拼起来,再去做注册。这样代码看起来更清晰。
3.2 设备号申请:静态与动态
申请设备号有两种方式:静态申请和动态申请。
3.2.1 静态申请
静态申请就是你自己指定一个主设备号。内核提供了register_chrdev_region函数:
int register_chrdev_region(dev_t from, unsigned count, const char *name);
from:起始设备号count:连续设备号的个数name:设备名称,会在/proc/devices中显示
返回值:成功返回0,失败返回负的错误码。
/proc/devices来确认哪些主设备号已经被占用了。我曾经在项目里直接用了254这个号,结果跟系统里某个驱动冲突了,调试了半天才发现。嗯,从那以后我基本都用动态申请了。
3.2.2 动态申请
动态申请由内核自动分配一个可用的主设备号。用alloc_chrdev_region函数:
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
dev:输出参数,返回分配到的设备号baseminor:起始次设备号count:连续设备号的个数name:设备名称
我个人强烈建议你在开发阶段使用动态申请。为什么?因为省心啊!你不用去查哪些号被占了,内核会帮你搞定。等产品定型了,再考虑要不要用静态的固定号。
3.3 设备号注销
有申请就有释放。在驱动卸载时,一定要记得把设备号还回去。用unregister_chrdev_region:
void unregister_chrdev_region(dev_t from, unsigned count);
参数跟申请时一样。不释放的话,下次加载驱动时可能就申请不到设备号了。我见过不少新手写的驱动,加载一次后卸载再加载就报错,十有八九是忘了这步。
3.4 file_operations结构体:驱动与用户空间的桥梁
用户程序通过系统调用(如open、read、write)来操作设备文件。内核怎么知道这些系统调用该调用驱动里的哪个函数呢?答案就是file_operations结构体。
这个结构体定义在<linux/fs.h>中,里面全是函数指针。我们只需要把我们实现的函数填进去就行。常用的成员有:
| 成员 | 说明 |
|---|---|
owner |
通常设为THIS_MODULE,防止模块被卸载时还有操作在进行 |
open |
打开设备时调用 |
release |
关闭设备时调用 |
read |
从设备读取数据 |
write |
向设备写入数据 |
unlocked_ioctl |
设备控制命令(无大内核锁) |
llseek |
修改文件读写位置 |
来看一个最简单的实现:
static int my_open(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "device closed\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off)
{
printk(KERN_INFO "read called\n");
return 0;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t len, loff_t *off)
{
printk(KERN_INFO "write called\n");
return len;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
read和write函数中,用户空间的缓冲区指针是__user类型的。千万不要直接解引用!要用copy_to_user和copy_from_user来安全地拷贝数据。这是内核开发的基本常识,搞错了会导致内核崩溃。
3.5 cdev结构体:内核中的设备对象
有了设备号和file_operations,接下来就要把它们绑定到一起。这个绑定的载体就是cdev结构体。
cdev定义在<linux/cdev.h>中。使用它需要三步:
- 分配cdev:可以用静态分配,也可以用
cdev_alloc动态分配 - 初始化cdev:调用
cdev_init,关联file_operations - 添加cdev到系统:调用
cdev_add,注册到内核
代码示例:
#include <linux/cdev.h>
static dev_t dev_num;
static struct cdev my_cdev;
static int __init my_init(void)
{
int ret;
// 1. 申请设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
printk(KERN_ERR "failed to alloc chrdev region\n");
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) {
printk(KERN_ERR "failed to add cdev\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
printk(KERN_INFO "my device initialized, major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
return 0;
}
static void __exit my_exit(void)
{
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "my device exited\n");
}
module_init(my_init);
module_exit(my_exit);
这里要注意顺序:先申请设备号,再初始化cdev,最后添加cdev。如果cdev_add失败,要记得把之前申请的设备号释放掉。我在项目中见过有人把顺序搞反了,结果cdev_add时设备号还没申请,内核直接报错。
3.6 设备节点创建:让用户空间能访问
驱动加载成功后,内核里已经有了这个设备。但用户空间怎么访问它呢?需要有一个设备文件,也就是设备节点。通常放在/dev目录下。
创建设备节点有两种方式:
3.6.1 手动创建(调试用)
用mknod命令:
mknod /dev/my_device c 240 0
其中c表示字符设备,240是主设备号,0是次设备号。这种方式适合调试,但不适合产品发布。
3.6.2 自动创建(推荐)
利用内核的udev机制,驱动加载时自动创建设备节点。需要用到class和device:
#include <linux/device.h>
static struct class *my_class;
static struct device *my_device;
static int __init my_init(void)
{
// ... 前面的设备号申请和cdev_add代码 ...
// 创建类
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
ret = PTR_ERR(my_class);
goto err_class;
}
// 创建设备节点
my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
if (IS_ERR(my_device)) {
ret = PTR_ERR(my_device);
goto err_device;
}
return 0;
err_device:
class_destroy(my_class);
err_class:
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
return ret;
}
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);
}
这样,驱动加载后,/dev/my_device就会自动出现。用户程序直接open("/dev/my_device", O_RDWR)就能操作了。
3.7 完整示例:一个什么都不做的字符设备
把上面的代码拼起来,就是一个完整的字符设备驱动。它什么都不做,只是注册了设备,创建了节点。你可以用它来验证整个框架是否跑通。
编译加载后,用ls -l /dev/my_device看看设备节点有没有创建。然后用cat /dev/my_device试试,应该能看到内核打印的read called信息。
嗯,这一章的内容就到这里。下一章我们会在这个框架基础上,加入真正的硬件操作,让驱动能干点实事。