字符设备驱动基础(上):设备号、cdev与file_operations
大家好,我是你们的老朋友。今天我们来聊聊字符设备驱动的基础部分。说实话,这部分内容在嵌入式Linux开发里,就像盖房子的地基一样重要。我当年刚入行时,就是被这几个概念绕得晕头转向——设备号、cdev结构体、file_operations……嗯,今天咱们一个一个把它掰扯清楚。
设备号的概念
先问大家一个问题:Linux系统里,应用程序怎么找到你的硬件设备?
答案就是——设备号。每个设备在系统里都有一个唯一的身份证,由两部分组成:主设备号和次设备号。
- 主设备号:标识设备对应的驱动程序。说白了,就是告诉内核“这个设备该由哪个驱动来管”。
- 次设备号:标识同一个驱动下的不同设备实例。比如你插了两个USB转串口,主设备号相同,次设备号不同。
在内核源码里,设备号用dev_t类型表示,它是一个32位的无符号整数。其中高12位是主设备号,低20位是次设备号。你可以用两个宏来操作它:
#include <linux/types.h>
dev_t dev = MKDEV(major, minor); // 合成设备号
int major = MAJOR(dev); // 提取主设备号
int minor = MINOR(dev); // 提取次设备号
重要提醒:主设备号范围是0~4095,次设备号范围是0~1048575。别搞反了,我见过有人把次设备号写到主设备号的位置上,结果驱动死活加载不了。
动态与静态分配设备号
设备号怎么来?有两种方式:静态分配和动态分配。
静态分配
静态分配就是你自己指定一个主设备号。比如:
#define MY_MAJOR 200
#define MY_MINOR 0
dev_t dev = MKDEV(MY_MAJOR, MY_MINOR);
register_chrdev_region(dev, 1, "my_device");
这种方式的好处是设备号固定,应用程序不用改。但坏处也很明显——万一别的驱动已经用了200这个号,你就冲突了。我在一个项目里就吃过这个亏,当时调试了两天才发现是设备号冲突。
动态分配
动态分配就聪明多了,让内核帮你选一个空闲的设备号:
dev_t dev;
alloc_chrdev_region(&dev, 0, 1, "my_device");
int major = MAJOR(dev); // 内核分配的主设备号
我个人习惯用动态分配。为什么呢?因为省心。你想想看,你写一个驱动要发布给别人用,静态分配的话,用户还得检查自己的系统里有没有冲突。动态分配就完全不用操心。
我的建议:产品开发阶段用动态分配,量产时如果为了固定设备节点方便,可以改成静态分配。但一定要在文档里注明你用了哪个主设备号。
cdev结构体
设备号有了,接下来怎么告诉内核“这个设备号对应哪些操作”?这就轮到cdev结构体登场了。
cdev是内核里描述字符设备的核心结构体。它的定义大概长这样:
struct cdev {
struct kobject kobj; // 内核对象,用于设备模型
struct module *owner; // 所属模块
const struct file_operations *ops; // 操作函数集
struct list_head list; // 链表节点
dev_t dev; // 设备号
unsigned int count; // 设备数量
};
使用cdev的流程很简单,三步走:
- 分配cdev:可以用静态定义,也可以用
cdev_alloc()动态分配。 - 初始化cdev:调用
cdev_init(),把file_operations挂上去。 - 添加cdev:调用
cdev_add(),把它注册到内核里。
看个例子:
struct cdev my_cdev;
// 初始化
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 添加设备
dev_t dev = MKDEV(major, 0);
cdev_add(&my_cdev, dev, 1);
注意:cdev_add()之后,设备就立即可用了。所以一定要确保所有初始化都完成了再调用它。我曾经在cdev_add()之后才去初始化硬件,结果用户空间一打开设备就崩溃了……嗯,血的教训。
file_operations结构体
好了,设备号有了,cdev也注册了。但应用程序调用open()、read()、write()时,内核怎么知道该执行什么代码?
答案就是file_operations结构体。它就像一个函数指针表,把系统调用和你的驱动函数一一对应起来。
常用的成员有这些:
| 成员 | 对应的系统调用 | 说明 |
|---|---|---|
open |
open() |
打开设备,做初始化工作 |
release |
close() |
关闭设备,释放资源 |
read |
read() |
从设备读取数据 |
write |
write() |
向设备写入数据 |
unlocked_ioctl |
ioctl() |
设备控制命令 |
定义方式是这样的:
struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_close,
.read = my_read,
.write = my_write,
};
关键点:.owner一定要赋值为THIS_MODULE。否则模块被卸载时,如果设备还在被使用,内核会直接崩溃。这个坑我踩过,真的会panic。
实现open/read/write/close
最后,我们来看看这四个函数具体怎么写。我以一个虚拟设备为例,它内部维护一个缓冲区。
open函数
static int my_open(struct inode *inode, struct file *filp)
{
// 可以在这里分配资源、初始化硬件
printk(KERN_INFO "my_device: device opened\n");
return 0;
}
这里要注意,inode参数包含了设备号等信息,filp是文件指针,你可以用filp->private_data来保存设备相关的数据结构。
close函数
static int my_close(struct inode *inode, struct file *filp)
{
// 释放资源、关闭硬件
printk(KERN_INFO "my_device: device closed\n");
return 0;
}
说白了,open和close就是一对儿,open里申请的东西,close里要记得释放。
read函数
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
// 从内核空间拷贝数据到用户空间
if (copy_to_user(buf, kernel_buffer, count)) {
return -EFAULT;
}
return count;
}
千万注意:绝对不能直接用memcpy往用户空间的buf里写数据!必须用copy_to_user()。否则轻则数据错误,重则内核崩溃。我见过有人图省事直接memcpy,结果系统直接死机。
write函数
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
// 从用户空间拷贝数据到内核空间
if (copy_from_user(kernel_buffer, buf, count)) {
return -EFAULT;
}
return count;
}
同理,copy_from_user()也是必须的。这两个函数会检查用户空间的地址是否合法,防止恶意程序搞破坏。
完整示例框架
把上面这些串起来,一个最简单的字符设备驱动框架就出来了:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_device"
#define BUFFER_SIZE 1024
static dev_t dev_num;
static struct cdev my_cdev;
static char kernel_buffer[BUFFER_SIZE];
static int my_open(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "Device opened\n");
return 0;
}
static int my_close(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 count, loff_t *f_pos)
{
if (copy_to_user(buf, kernel_buffer, count))
return -EFAULT;
return count;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
if (copy_from_user(kernel_buffer, buf, count))
return -EFAULT;
return count;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_close,
.read = my_read,
.write = my_write,
};
static int __init my_init(void)
{
// 动态分配设备号
alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
// 初始化并添加cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
cdev_add(&my_cdev, dev_num, 1);
printk(KERN_INFO "My device loaded, major=%d\n", MAJOR(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 unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
嗯,这个框架虽然简单,但五脏俱全。你把它编译加载后,用mknod /dev/mydev c 主设备号 0创建节点,就可以用echo和cat来测试了。
我的经验:刚开始学的时候,别急着写复杂的驱动。先把上面这个框架跑通,用dmesg看内核日志,确认open、read、write都被调用了。这一步走稳了,后面加硬件操作就水到渠成。
好了,今天的内容就到这里。字符设备驱动的基础——设备号、cdev、file_operations,说白了就是这三板斧。下节课我们继续深入,聊聊如何管理多个设备、如何用ioctl实现控制命令。到时候见!