字符设备驱动基础:file_operations、主次设备号与cdev
字符设备驱动,说白了就是让应用程序能像读写文件一样,去操作你的硬件。Linux里“一切皆文件”,你的LED、按键、串口,在用户空间看来就是一个文件——/dev/xxx。用户调用open()、read()、write(),内核怎么知道该执行你驱动里的哪段代码?这就是本章要讲的核心机制。
file_operations结构体:驱动的“函数跳转表”
每个字符设备驱动,都必须提供一个file_operations结构体。它就是个函数指针表,告诉内核:“当用户打开我的设备时,调用我这个函数;当用户读数据时,调用那个函数。”
我刚开始写驱动时,总觉得这结构体成员太多,记不住。其实常用的就那几个:
| 成员 | 触发时机 | 典型实现 |
|---|---|---|
.open |
用户调用open("/dev/xxx") |
设备初始化、计数加1 |
.release |
用户调用close() |
资源释放、计数减1 |
.read |
用户调用read() |
从硬件寄存器读取数据,拷贝到用户空间 |
.write |
用户调用write() |
从用户空间拷贝数据,写入硬件寄存器 |
.unlocked_ioctl |
用户调用ioctl() |
非标准控制命令,比如设置波特率、GPIO方向 |
.llseek |
用户调用lseek() |
调整文件读写位置,很多设备直接返回-ESPIPE |
关键点:不是所有成员都必须实现。你的设备不支持定位?.llseek设为NULL,内核会自动返回-ESPIPE。只读设备?.write设为NULL,用户写的时候会收到-EINVAL。
来看一个最简单的实现模板:
// 驱动中定义一个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,
};
// 对应的函数实现
static int my_open(struct inode *inode, struct file *filp) {
// 拿到设备结构体,挂到filp->private_data上
struct my_dev *dev = container_of(inode->i_cdev, struct my_dev, cdev);
filp->private_data = dev;
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos) {
// 从硬件读数据,用copy_to_user()传给用户
// ...
return bytes_read;
}
注意那个.owner,它指向模块自身。内核靠它来防止你在驱动还在使用时被rmmod掉。我见过有人忘了设这个,结果模块卸载后,用户程序还在调用read()——直接内核崩溃。
主次设备号:设备的“身份证”
内核怎么区分不同的设备?靠设备号。设备号由两部分组成:
- 主设备号:标识设备对应的驱动程序。比如
1代表内存设备,4代表串口。 - 次设备号:标识同一个驱动管理的不同设备实例。比如两个串口,主设备号都是
4,次设备号分别是64和65。
分配设备号有两种方式:
| 方式 | 函数 | 适用场景 |
|---|---|---|
| 静态分配 | register_chrdev_region() |
你知道主设备号,且确保没被占用 |
| 动态分配 | alloc_chrdev_region() |
让内核自动分配,推荐用于新驱动 |
我的习惯:开发阶段用动态分配,省得跟别人冲突。产品定型后,如果设备号需要固定(比如为了udev规则),再改成静态分配。我曾经在项目里用静态分配选了240,结果跟另一个模块撞车,排查了半天。
动态分配的代码很简单:
dev_t dev_num;
int major, minor;
// 申请一个设备号,主设备号由内核分配
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);
minor = MINOR(dev_num);
// 用完记得释放
unregister_chrdev_region(dev_num, 1);
这里0是起始次设备号,1是连续设备数量。如果你要管理多个同类设备,比如4个LED,就传4。
cdev结构体:设备与驱动的“粘合剂”
有了file_operations和设备号,怎么把它们绑定起来?靠cdev。它是内核里代表字符设备的核心结构体。
注册一个字符设备,标准流程分三步:
- 分配并初始化cdev:
cdev_alloc()+cdev_init() - 将cdev添加到内核:
cdev_add() - 创建设备节点:手动
mknod或通过device_create()自动生成
看代码:
struct my_dev {
struct cdev cdev; // 内嵌cdev
// 其他设备相关数据...
};
static int __init my_init(void) {
dev_t dev_num;
struct my_dev *my_dev;
// 1. 分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
// 2. 分配设备结构体
my_dev = kzalloc(sizeof(*my_dev), GFP_KERNEL);
// 3. 初始化cdev,绑定file_operations
cdev_init(&my_dev->cdev, &my_fops);
my_dev->cdev.owner = THIS_MODULE;
// 4. 将cdev注册到内核
cdev_add(&my_dev->cdev, dev_num, 1);
// 5. 自动创建设备节点(需要配合class)
// 这部分下一章细讲,先留个印象
return 0;
}
static void __exit my_exit(void) {
// 注销顺序与注册相反
cdev_del(&my_dev->cdev);
unregister_chrdev_region(dev_num, 1);
kfree(my_dev);
}
注意:cdev_add()之后,你的设备就“活”了。用户空间随时可能打开它。所以cdev_add()之前,必须确保所有初始化工作已完成。我曾经在cdev_add()之后才分配缓冲区,结果用户一打开就访问空指针——嗯,教训深刻。
核心流程:一张图说清楚
下面这张图展示了字符设备驱动的完整注册流程,以及用户空间调用open()时,内核如何通过设备号找到你的file_operations:
避坑指南:我踩过的几个坑
- cdev_add()时机不对:一定要在所有硬件初始化完成之后再调用。否则用户一打开,你的驱动还没准备好。
- 设备号冲突:静态分配前,先
cat /proc/devices看看哪些号已经被用了。动态分配最省心。 - file_operations没设.owner:后果就是模块被卸载后,函数指针变成悬空指针。内核直接Oops。
- 忘记注销:模块退出时,
cdev_del()和unregister_chrdev_region()一个都不能少。否则下次insmod会失败。
调试小技巧:注册成功后,ls -l /dev/my_device可以看到设备号。如果设备节点没自动生成,手动mknod /dev/my_device c major minor也能用。我早期调试时经常这么干,省得写udev规则。