第3章 字符设备驱动框架
字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种模型。我刚开始学驱动时,就是从字符设备入手的。你想想看,键盘、鼠标、串口,这些设备本质上都是字符设备——数据按字节流传输,顺序读写。这一章,我们就来彻底搞懂它的框架。
3.1 字符设备驱动模型
Linux把设备分成了三大类:字符设备、块设备和网络设备。字符设备的特点是啥?数据像流水一样,一个一个字节地传。没有缓冲区,不能随机访问。嗯,这里要注意,字符设备驱动其实就是一个软件层,它把硬件操作封装成标准的文件操作接口。
我在项目中遇到过一个问题:一个客户说他的POS机刷卡器不工作了。我排查了半天,发现是驱动没有正确注册到内核。说白了,驱动没挂上去,应用层根本找不到这个设备。所以理解驱动模型,是第一步。
字符设备驱动的核心结构是这样的:
应用层 (open/read/write/close)
↓
系统调用 (VFS层)
↓
字符设备驱动 (file_operations)
↓
硬件操作 (寄存器读写)
这个模型的好处是啥?应用开发者不需要懂硬件。他只需要用open、read、write这些标准函数,就能操作设备。这就是Linux的设计哲学——一切皆文件。
3.2 设备号:主设备号与次设备号
每个字符设备在内核里都有一个唯一的编号,叫设备号。设备号由两部分组成:主设备号和次设备号。
| 类型 | 位数 | 作用 |
|---|---|---|
| 主设备号 | 12位 (0~4095) | 标识设备对应的驱动程序 |
| 次设备号 | 20位 (0~1048575) | 标识同一个驱动下的不同设备实例 |
举个例子:你电脑上可能插了两个USB转串口设备。它们的主设备号相同(都是同一个驱动),但次设备号不同(ttyUSB0和ttyUSB1)。
我个人习惯用动态分配设备号,而不是静态指定。为什么?因为静态分配容易冲突。我曾经在调试时,手动指定了一个主设备号,结果跟系统里已有的驱动冲突了,内核直接报错。从那以后,我都是用alloc_chrdev_region()来动态分配。
设备号操作宏:
MAJOR(dev_t dev)— 从设备号中提取主设备号MINOR(dev_t dev)— 从设备号中提取次设备号MKDEV(int major, int minor)— 将主次设备号组合成dev_t
3.3 file_operations结构体
这个结构体,是字符设备驱动的灵魂。它定义了驱动能提供的所有操作函数。说白了,它就是驱动和应用之间的接口协议。
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, len)时,内核VFS层就会找到这个设备对应的file_operations,然后调用你实现的read函数。这就是整个驱动工作的核心机制。
我建议初学者先实现这四个基本操作:
- open — 打开设备,做初始化工作
- release — 关闭设备,释放资源
- read — 从设备读取数据
- write — 向设备写入数据
避坑指南:我曾经在写read函数时,忘了检查用户空间指针的有效性。结果应用层传了个空指针,内核直接Oops。记住,一定要用copy_to_user()和copy_from_user()来安全地传输数据。
3.4 设备节点的创建
设备节点是应用层访问驱动的入口。它位于/dev目录下,本质上就是一个文件。创建设备节点有两种方式:手动和自动。
3.4.1 手动创建
用mknod命令:
mknod /dev/mydevice c 240 0
参数说明:c表示字符设备,240是主设备号,0是次设备号。
手动创建有个问题:每次加载驱动都要手动敲命令,而且设备号变了还得改。所以我不推荐在生产环境用这种方式。
3.4.2 自动创建
自动创建设备节点,需要用到Linux的设备模型。核心是这两个步骤:
- 创建设备类(class)
- 在类下创建设备
// 创建类
struct class *my_class = class_create(THIS_MODULE, "my_device_class");
// 创建设备
device_create(my_class, NULL, devno, NULL, "mydevice");
这样,当驱动加载时,udev或mdev会自动在/dev下创建设备节点。卸载驱动时,节点也会自动删除。省心多了。
注意:自动创建设备节点需要内核配置了CONFIG_DEVTMPFS和CONFIG_UEVENT_HELPER。我在一个嵌入式板子上就遇到过,内核没开这些选项,结果设备节点死活不出来。查了半天才发现是内核配置的问题。
3.5 简单的字符设备驱动实现
好了,理论说完了,我们来写一个完整的字符设备驱动。这个驱动很简单:它维护一个缓冲区,应用层可以读写这个缓冲区。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "mychardev"
#define BUFFER_SIZE 1024
static int major;
static struct cdev my_cdev;
static struct class *my_class;
static char device_buffer[BUFFER_SIZE];
static int buffer_len = 0;
// open 函数
static int my_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "Device opened\n");
return 0;
}
// release 函数
static int my_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "Device closed\n");
return 0;
}
// read 函数
static ssize_t my_read(struct file *file, char __user *buf,
size_t len, loff_t *offset)
{
int bytes_to_read;
if (*offset >= buffer_len)
return 0;
bytes_to_read = min(len, (size_t)(buffer_len - *offset));
if (copy_to_user(buf, device_buffer + *offset, bytes_to_read))
return -EFAULT;
*offset += bytes_to_read;
return bytes_to_read;
}
// write 函数
static ssize_t my_write(struct file *file, const char __user *buf,
size_t len, loff_t *offset)
{
int bytes_to_write;
bytes_to_write = min(len, (size_t)BUFFER_SIZE);
if (copy_from_user(device_buffer, buf, bytes_to_write))
return -EFAULT;
buffer_len = bytes_to_write;
*offset = 0;
return bytes_to_write;
}
// file_operations 结构体
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
// 模块初始化
static int __init my_init(void)
{
dev_t devno;
// 动态分配设备号
alloc_chrdev_region(&devno, 0, 1, DEVICE_NAME);
major = MAJOR(devno);
// 初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 添加cdev到内核
cdev_add(&my_cdev, devno, 1);
// 自动创建设备节点
my_class = class_create(THIS_MODULE, "myclass");
device_create(my_class, NULL, devno, NULL, DEVICE_NAME);
printk(KERN_INFO "My char device loaded, major=%d\n", major);
return 0;
}
// 模块卸载
static void __exit my_exit(void)
{
dev_t devno = MKDEV(major, 0);
device_destroy(my_class, devno);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(devno, 1);
printk(KERN_INFO "My char device unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");
这个驱动虽然简单,但五脏俱全。它包含了:
- 设备号的动态分配
- file_operations的实现
- 设备节点的自动创建
- 安全的用户空间数据拷贝
测试方法:加载驱动后,用echo "hello" > /dev/mychardev写入数据,再用cat /dev/mychardev读取。如果能看到"hello",说明驱动工作正常。
嗯,这里要注意一点:这个驱动没有考虑并发访问的问题。如果多个进程同时读写,会出现数据混乱。不过作为入门示例,先理解框架更重要。并发控制我们后面章节会专门讲。
我记得刚开始写驱动时,总是忘记调用cdev_add,结果设备注册不上去。或者忘了copy_to_user,直接在内核空间操作用户指针。这些坑,踩过一次就记住了。
总结一下这一章的核心:字符设备驱动就是通过file_operations把硬件操作映射成文件操作。设备号是驱动的身份证,设备节点是应用层的入口。掌握了这些,你就迈出了Linux驱动开发的第一步。