4. 字符设备驱动基础:设备号注册与注销、file_operations 结构体、cdev_init 与 cdev_add
字符设备驱动,是 Linux 驱动开发里最基础、也最常用的一类。说白了,就是按字节流读写数据的设备——键盘、鼠标、串口,都属于这个范畴。我刚开始学驱动时,觉得字符设备挺简单,不就是 open、read、write 吗?真上手才发现,设备号怎么分配、cdev 怎么注册,这里头有不少门道。
这一章,我们就从最核心的三个点入手:设备号、file_operations、cdev 的注册与注销。把这几个搞明白,你就能写出一个能用的字符设备驱动了。
4.1 设备号:设备的“身份证”
每个字符设备在内核里都有一个唯一的编号——设备号。它由两部分组成:主设备号和次设备号。主设备号标识设备类型(比如硬盘是 8,串口是 4),次设备号标识同类设备中的不同实例。
在内核源码里,设备号用 dev_t 类型表示,它是一个 32 位的无符号整数。高 12 位是主设备号,低 20 位是次设备号。内核提供了两个宏来操作它:
#include <linux/types.h>
MAJOR(dev_t dev); // 提取主设备号
MINOR(dev_t dev); // 提取次设备号
MKDEV(int major, int minor); // 合成设备号
嗯,这里要注意:主设备号的范围是 0~4095,次设备号是 0~1048575。别超了。
4.1.1 静态分配 vs 动态分配
分配设备号有两种方式:静态和动态。我个人习惯用动态分配,省心。
| 分配方式 | 函数 | 适用场景 | 风险 |
|---|---|---|---|
| 静态分配 | register_chrdev_region() |
已知主设备号(如 240~255 预留范围) | 设备号冲突,需手动协调 |
| 动态分配 | alloc_chrdev_region() |
大多数场景,推荐使用 | 需读取 /proc/devices 确认分配结果 |
动态分配的代码示例:
#include <linux/fs.h>
dev_t dev_num;
int major, minor;
int ret;
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
printk(KERN_ERR "Failed to allocate chrdev region\n");
return ret;
}
major = MAJOR(dev_num);
minor = MINOR(dev_num);
printk(KERN_INFO "Allocated device number: major=%d, minor=%d\n", major, minor);
我曾经在一个项目里用了静态分配,结果跟另一个模块撞了主设备号,系统启动时直接崩溃。从那以后,我基本只用动态分配——除非有特殊要求。
4.1.2 注销设备号
模块卸载时,必须把设备号还回去。用 unregister_chrdev_region():
void unregister_chrdev_region(dev_t from, unsigned count);
参数 from 是起始设备号,count 是设备数量。记得跟分配时的参数保持一致。
4.2 file_operations:驱动与用户态的“接口协议”
用户态程序通过系统调用(open、read、write 等)访问设备,内核怎么知道该调用驱动的哪个函数?答案就是 file_operations 结构体。它是一张函数指针表,把系统调用映射到驱动实现上。
结构体定义在 <linux/fs.h> 里,成员非常多,但常用的就这几个:
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
// ... 还有几十个,不一一列举
};
你想想看,用户态调用 read(fd, buf, 1024),内核最终会调用你注册的 read 函数。这个函数里,你要把设备的数据拷贝到用户空间的 buf 里。
一个最简单的实现示例:
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, len=%zu\n", len);
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
};
.owner = THIS_MODULE 这行不能省。它告诉内核,这个 file_operations 属于哪个模块。如果省了,模块卸载时内核可能还在调用你的函数——又是 oops。
4.3 cdev_init 与 cdev_add:把驱动“挂”进内核
设备号有了,file_operations 也准备好了,接下来就是把它们组合成一个 cdev 结构体,注册到内核里。cdev 是内核用来管理字符设备的核心数据结构。
4.3.1 cdev_init:初始化 cdev
cdev_init() 把 file_operations 绑定到 cdev 上:
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
用法:
struct cdev my_cdev;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE; // 别忘了设置 owner
4.3.2 cdev_add:注册到内核
cdev_add() 把 cdev 和设备号关联起来,告诉内核:“这个设备号归我管了。”
int cdev_add(struct cdev *cdev, dev_t dev, unsigned count);
参数 count 是设备数量,通常为 1。如果注册多个次设备,可以传大于 1 的值。
完整流程:
static struct cdev my_cdev;
static int __init my_init(void) {
dev_t dev_num;
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;
}
printk(KERN_INFO "My device registered: major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
return 0;
}
4.3.3 cdev_del:卸载时的清理
模块卸载时,调用 cdev_del() 把 cdev 从内核里移除:
void cdev_del(struct cdev *cdev);
完整的卸载函数:
static void __exit my_exit(void) {
dev_t dev_num = MKDEV(major, 0); // 假设你保存了 major
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "My device unregistered\n");
}
- alloc_chrdev_region() → 分配设备号
- cdev_init() → 绑定 file_operations
- cdev_add() → 注册到内核
- 模块卸载:cdev_del() → unregister_chrdev_region()
4.4 知识结构图:字符设备驱动注册全流程
下面这张图,把整个流程串起来了。我建议你把它记在脑子里——每次写字符设备驱动,都是这个套路。
4.5 避坑指南:我踩过的几个坑
写字符设备驱动,有几个地方特别容易出问题。我把自己踩过的坑列出来,你遇到了可以少走弯路。
- cdev_add 失败后忘了释放设备号——我曾经犯过这个错,结果设备号被占用,模块再也加载不上了。解决办法:
cdev_add失败后立即调用unregister_chrdev_region。 - file_operations 的 owner 没设置——模块卸载后,内核还在调用你的函数,直接 oops。这个坑我见过不止一次。
- 设备号注销顺序搞反——先注销设备号再删 cdev,会导致 cdev_del 访问非法内存。正确顺序:先 cdev_del,再 unregister_chrdev_region。
- 用户态还在操作设备就卸载模块——这会导致内核 panic。解决方案:在模块卸载前,检查设备引用计数,或者用
try_module_get/module_put机制。
cat /proc/devices 查看设备号是否注册成功。用 mknod /dev/mydevice c major minor 创建设备文件,然后用 echo "test" > /dev/mydevice 测试读写。
好了,这一章的内容就到这里。设备号、file_operations、cdev 注册与注销,是字符设备驱动的三大基石。把这套流程练熟,后面的复杂驱动就水到渠成了。