4、字符设备驱动基础:cdev结构体、file_operations、设备号分配、miscdevice框架
好,咱们今天聊聊字符设备驱动。这是Linux驱动里最基础、也最常用的一类。说白了,你只要想跟硬件做点简单的数据交互——读个传感器、发个指令、控制个LED——大概率就得用字符设备驱动。
我个人习惯,学驱动先从框架入手。别急着看硬件手册,先把软件骨架搭明白。今天这节,咱们就把字符设备驱动的四个核心零件拆开揉碎:cdev结构体、file_operations、设备号分配,还有那个偷懒神器——miscdevice框架。
4.1 设备号:驱动的身份证
每个字符设备在内核里都有个唯一编号,叫设备号。它分两部分:主设备号和次设备号。主设备号标识驱动类型,次设备号标识具体设备。
你想想看,系统里那么多设备,内核怎么知道谁是谁?就是靠这个号。主设备号就像小区门牌号,次设备号就是单元号。
设备号的数据类型是 dev_t,一个32位无符号整数。高12位是主设备号,低20位是次设备号。
分配设备号有两种方式:
- 静态分配:调用
register_chrdev_region(),你得自己指定主设备号。适合你知道哪些号没被占用的场景。 - 动态分配:调用
alloc_chrdev_region(),内核自动给你分配一个空闲的主设备号。我强烈建议你用这个,省心。
我曾经在项目里吃过静态分配的亏。写驱动时随便选了个主设备号,结果跟系统里某个模块冲突了,加载驱动直接报错。从那以后,我再也不用静态分配了。
// 动态分配设备号示例
dev_t dev_num;
int ret;
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
if (ret < 0) {
printk(KERN_ERR "Failed to allocate device number\n");
return ret;
}
printk("Major: %d, Minor: %d\n", MAJOR(dev_num), MINOR(dev_num));
嗯,这里要注意:用完设备号一定要释放。调用 unregister_chrdev_region(),不然下次加载驱动时可能分配失败。
4.2 cdev结构体:驱动的内核化身
设备号是身份证,那cdev就是驱动在内核里的实体。每个字符设备在内核里都对应一个cdev结构体。
它的定义在 <linux/cdev.h> 里,核心成员就几个:
| 成员 | 说明 |
|---|---|
| owner | 所属模块,一般设为 THIS_MODULE |
| ops | 指向 file_operations 结构体的指针 |
| dev | 设备号(dev_t 类型) |
| count | 次设备号数量 |
初始化cdev的流程很简单:
struct cdev my_cdev;
cdev_init(&my_cdev, &my_fops); // 关联 file_operations
my_cdev.owner = THIS_MODULE;
cdev_add(&my_cdev, dev_num, 1); // 注册到内核
我个人习惯把cdev定义成全局变量或者动态分配。如果设备数量不确定,用 cdev_alloc() 动态分配更灵活。
注意:cdev_add() 之后,设备就对外可见了。所以一定要在硬件初始化完成后再调用,否则用户空间可能访问到未就绪的设备。
4.3 file_operations:驱动的操作手册
用户空间程序怎么跟你的驱动交互?通过系统调用,比如 open、read、write、ioctl。而驱动这边,就是通过 file_operations 结构体来响应这些调用。
说白了,这就是一张函数跳转表。用户调 open,内核就调你的 xxx_open 函数。
常用的操作有这些:
- open:打开设备,做初始化工作
- release:关闭设备,释放资源
- read:从设备读取数据
- write:向设备写入数据
- unlocked_ioctl:设备控制命令(注意,现在都用 unlocked_ioctl 了,老的 ioctl 已废弃)
- llseek:修改文件读写位置
来看个最简单的实现:
static int my_open(struct inode *inode, struct file *file) {
printk("Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *file) {
printk("Device closed\n");
return 0;
}
static ssize_t my_read(struct file *file, char __user *buf,
size_t len, loff_t *off) {
// 注意:要用 copy_to_user 把数据从内核拷贝到用户空间
return 0;
}
static ssize_t my_write(struct file *file, const char __user *buf,
size_t len, loff_t *off) {
// 注意:要用 copy_from_user 从用户空间拷贝数据
return len;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
避坑指南:我曾经在 read 函数里直接用了 memcpy 来拷贝数据,结果内核崩溃。记住,用户空间和内核空间不能直接传指针,必须用 copy_to_user / copy_from_user。这是新手最容易犯的错误。
4.4 miscdevice框架:偷懒的艺术
每次写字符设备驱动都要手动分配设备号、注册cdev、创建设备类……太麻烦了。对于简单的设备,Linux 提供了一个偷懒框架——miscdevice。
miscdevice 是「杂项设备」的意思。它自动帮你处理了设备号分配和cdev注册。你只需要填一个结构体:
struct miscdevice my_misc = {
.minor = MISC_DYNAMIC_MINOR, // 让内核自动分配次设备号
.name = "my_misc_device", // 设备名,会在 /dev 下创建
.fops = &my_fops, // 还是那个 file_operations
};
注册和注销也简单到离谱:
misc_register(&my_misc); // 注册
misc_deregister(&my_misc); // 注销
你想想看,原来要写四五步的工作,现在两步搞定。miscdevice 的主设备号固定是 10,所有杂项设备共享这个主设备号,通过次设备号区分。
我记得刚学驱动时,看到别人用 miscdevice 觉得太方便了。但要注意,它只适合简单的、数量少的设备。如果你要管理几十个同类设备,还是老老实实用标准字符设备框架。
miscdevice 的适用场景:
- 只有一个设备的驱动(比如一个LED、一个蜂鸣器)
- 不需要复杂的设备模型管理
- 快速原型验证
4.5 完整示例:一个最小字符设备驱动
把上面这些串起来,就是一个能用的驱动了。咱们用 miscdevice 框架,写个最简单的:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "demo_device"
static int demo_open(struct inode *inode, struct file *file) {
printk("demo: device opened\n");
return 0;
}
static int demo_release(struct inode *inode, struct file *file) {
printk("demo: device closed\n");
return 0;
}
static ssize_t demo_read(struct file *file, char __user *buf,
size_t len, loff_t *off) {
char data[] = "Hello from kernel!\n";
size_t data_len = strlen(data);
if (*off >= data_len)
return 0;
if (len > data_len - *off)
len = data_len - *off;
if (copy_to_user(buf, data + *off, len))
return -EFAULT;
*off += len;
return len;
}
static struct file_operations demo_fops = {
.owner = THIS_MODULE,
.open = demo_open,
.release = demo_release,
.read = demo_read,
};
static struct miscdevice demo_misc = {
.minor = MISC_DYNAMIC_MINOR,
.name = DEVICE_NAME,
.fops = &demo_fops,
};
static int __init demo_init(void) {
int ret;
ret = misc_register(&demo_misc);
if (ret) {
printk("demo: failed to register misc device\n");
return ret;
}
printk("demo: device registered\n");
return 0;
}
static void __exit demo_exit(void) {
misc_deregister(&demo_misc);
printk("demo: device unregistered\n");
}
module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple misc device driver");
编译加载后,你会在 /dev/demo_device 看到这个设备。用 cat /dev/demo_device 就能读到内核发来的消息。
嗯,这就是字符设备驱动最基础的样子。别看简单,所有复杂的驱动——摄像头、音频、GPU——底层都是这套机制。把今天这四个概念吃透,后面学 V4L2、ALSA 就轻松多了。
下一节咱们聊聊如何用 udev 自动创建设备节点,让驱动加载后设备自动出现,不用手动 mknod。