字符设备驱动基础:从框架到实战
大家好,我是你们的嵌入式Linux驱动讲师。今天我们来聊聊字符设备驱动的基础。说实话,字符设备驱动是Linux驱动开发里最基础、也是最核心的一块。我刚开始学驱动时,就是从这里入门的。你想想看,键盘、鼠标、串口,这些统统都是字符设备。搞懂了它,后面学块设备、网络设备就会轻松很多。
字符设备驱动框架:一个简单的模型
字符设备驱动,说白了就是让应用程序能通过文件操作的方式来访问硬件。比如你在用户空间写open("/dev/mydev", O_RDWR),内核就能找到对应的驱动,然后执行你预先写好的打开函数。
这个框架其实不复杂。我画个简单的图给你看:
应用程序 (open/read/write/close)
↓
系统调用接口
↓
VFS (虚拟文件系统)
↓
字符设备驱动 (file_operations)
↓
硬件设备
嗯,这里要注意一点:VFS是Linux的虚拟文件系统层。它把各种设备都抽象成了文件。所以你在用户空间操作设备,感觉就像在读写一个普通文件。这个设计真的很巧妙,我个人非常佩服。
file_operations结构体:驱动的灵魂
每个字符设备驱动都必须实现一个file_operations结构体。这个结构体定义了驱动能提供的所有操作。我把它叫做驱动的「菜单」——应用程序点哪个菜,驱动就执行哪个函数。
来看看这个结构体的核心成员:
| 成员 | 说明 | 我常用的场景 |
|---|---|---|
| .owner | 模块所有者,一般设为THIS_MODULE | 防止模块被卸载时还在使用 |
| .open | 打开设备时调用 | 初始化硬件、分配资源 |
| .release | 关闭设备时调用 | 释放资源、关闭硬件 |
| .read | 从设备读取数据 | 从硬件寄存器读数据 |
| .write | 向设备写入数据 | 向硬件寄存器写数据 |
| .unlocked_ioctl | 设备控制命令 | 设置波特率、切换模式等 |
我在项目中遇到过一个问题:有个同事忘了设置.owner字段,结果模块被卸载后,应用程序还在调用驱动,直接导致内核崩溃。所以这个字段虽然小,但千万别漏掉。
设备号:驱动的身份证
每个字符设备在内核里都有一个唯一的编号,叫做设备号。它由两部分组成:
- 主设备号:标识设备类型。比如串口设备通常是4,tty设备通常是5。
- 次设备号:标识同类设备中的不同实例。比如tty0、tty1。
设备号在内核里用dev_t类型表示。你可以用MAJOR()和MINOR()宏来提取主次设备号。
重要提示:主设备号可以静态申请,也可以动态分配。我个人习惯用动态分配,省得跟别人冲突。你想想看,要是你硬编码了一个主设备号,结果别人的驱动也用了这个号,那不就打架了吗?
设备号注册与注销
注册设备号有两种方式:
// 方式一:静态申请(不推荐)
int register_chrdev_region(dev_t from, unsigned count, const char *name);
// 方式二:动态分配(推荐)
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
注销就更简单了:
void unregister_chrdev_region(dev_t from, unsigned count);
我曾经犯过一个低级错误:在模块卸载时忘了注销设备号。结果下次加载模块时,设备号已经被占用了。嗯,这种错误犯一次就够了,后来我每次写驱动都会在退出函数里仔细检查。
cdev结构体:内核里的设备对象
cdev是内核用来描述字符设备的结构体。它把设备号和file_operations绑定在一起。你可以把它理解成驱动的「户口本」。
使用cdev的典型流程:
// 1. 分配cdev结构体
struct cdev *my_cdev = cdev_alloc();
// 2. 初始化cdev
cdev_init(my_cdev, &my_fops);
my_cdev->owner = THIS_MODULE;
// 3. 添加cdev到内核
cdev_add(my_cdev, devno, 1);
// 4. 卸载时删除cdev
cdev_del(my_cdev);
小技巧:我习惯把cdev结构体嵌入到自己的设备结构体中,而不是单独分配。这样管理起来更方便,也减少了内存泄漏的风险。
简单的字符设备驱动实现
好了,理论说完了,我们来写一个最简单的字符设备驱动。这个驱动只做一件事:记录应用程序写入的数据,然后读出来。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "mydemo"
#define BUFFER_SIZE 1024
static dev_t devno;
static struct cdev *my_cdev;
static char buffer[BUFFER_SIZE];
static int buffer_len = 0;
static int my_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "mydemo: device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "mydemo: device closed\n");
return 0;
}
static ssize_t my_read(struct file *file, char __user *buf,
size_t count, loff_t *ppos)
{
int ret;
if (*ppos >= buffer_len)
return 0;
if (count > buffer_len - *ppos)
count = buffer_len - *ppos;
ret = copy_to_user(buf, buffer + *ppos, count);
if (ret)
return -EFAULT;
*ppos += count;
return count;
}
static ssize_t my_write(struct file *file, const char __user *buf,
size_t count, loff_t *ppos)
{
if (count > BUFFER_SIZE)
count = BUFFER_SIZE;
if (copy_from_user(buffer, buf, count))
return -EFAULT;
buffer_len = count;
*ppos = count;
printk(KERN_INFO "mydemo: wrote %zu bytes\n", count);
return count;
}
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)
{
// 动态分配设备号
if (alloc_chrdev_region(&devno, 0, 1, DEVICE_NAME) < 0) {
printk(KERN_ERR "mydemo: failed to alloc devno\n");
return -1;
}
// 分配并初始化cdev
my_cdev = cdev_alloc();
if (!my_cdev) {
unregister_chrdev_region(devno, 1);
return -ENOMEM;
}
cdev_init(my_cdev, &my_fops);
my_cdev->owner = THIS_MODULE;
// 添加cdev到内核
if (cdev_add(my_cdev, devno, 1) < 0) {
cdev_del(my_cdev);
unregister_chrdev_region(devno, 1);
return -1;
}
printk(KERN_INFO "mydemo: loaded, major=%d, minor=%d\n",
MAJOR(devno), MINOR(devno));
return 0;
}
static void __exit my_exit(void)
{
cdev_del(my_cdev);
unregister_chrdev_region(devno, 1);
printk(KERN_INFO "mydemo: unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver demo");
避坑指南:我曾经在copy_to_user和copy_from_user上栽过跟头。记住,内核空间不能直接访问用户空间的内存,必须用这两个函数。直接使用memcpy会导致内核崩溃,别问我怎么知道的...
测试驱动
驱动编译加载后,你需要手动创建设备节点:
# 查看设备号(从dmesg获取)
cat /proc/devices | grep mydemo
# 创建设备节点
mknod /dev/mydemo c 主设备号 0
# 测试读写
echo "Hello Driver" > /dev/mydemo
cat /dev/mydemo
如果一切正常,你应该能看到输出"Hello Driver"。嗯,这就是你的第一个字符设备驱动了。
总结
字符设备驱动的核心就这几步:
- 实现
file_operations结构体 - 申请设备号(推荐动态分配)
- 初始化并添加
cdev结构体 - 在模块退出时做好清理工作
说实话,这个框架我写了不下上百次。每次写新驱动,都是从这个模板开始改。你把这个基础打牢了,后面学复杂驱动就会轻松很多。下一章我们会讲如何用udev自动创建设备节点,以及如何实现更复杂的ioctl控制命令。到时候见!