字符设备驱动基础:从模型到实战
字符设备驱动,说白了就是 Linux 驱动里最基础、最常用的一类。你想想看,键盘、鼠标、串口、LED 灯——这些设备本质上都是按字节流来读写数据的。我刚开始学驱动时,第一个练手的项目就是字符设备驱动。嗯,今天我们就把它彻底讲透。
字符设备驱动模型
Linux 把设备抽象成三类:字符设备、块设备和网络设备。字符设备的特点是:数据按顺序读写,没有缓冲区,不支持随机访问。比如你按下一个按键,驱动就上报一个键值,就这么简单。
整个模型的核心就三个角色:
- 应用程序:通过 open/read/write/close 等系统调用操作设备
- VFS(虚拟文件系统):统一接口层,屏蔽底层差异
- 设备驱动:真正和硬件打交道的代码
我画了一张图,帮你理清它们之间的关系:
核心要点:应用程序不直接调用驱动函数,而是通过 VFS 这个中间层。驱动只需要注册好 file_operations,剩下的交给内核去串联。
file_operations 结构体
这个结构体,是驱动和 VFS 之间的契约。你实现了哪些函数,VFS 就能调用哪些。说白了,它就是一张函数指针表。
我挑几个最常用的成员讲讲:
| 成员 | 函数原型 | 作用 |
|---|---|---|
| open | int (*open)(struct inode *, struct file *) | 打开设备,做初始化 |
| release | int (*release)(struct inode *, struct file *) | 关闭设备,释放资源 |
| read | ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) | 从设备读取数据 |
| write | ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) | 向设备写入数据 |
| unlocked_ioctl | long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) | 设备控制命令 |
| llseek | loff_t (*llseek)(struct file *, loff_t, int) | 修改文件读写位置 |
我的习惯:写驱动时,我一般先定义一个 file_operations 变量,把需要的函数指针填进去。没实现的成员直接设为 NULL,内核会帮你处理默认行为。
来看一个实际例子:
// 定义一个简单的字符设备驱动操作集
static int my_open(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "my_device: open() called\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp)
{
printk(KERN_INFO "my_device: release() called\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
// 这里实现从设备读取数据的逻辑
printk(KERN_INFO "my_device: read() called, count=%zu\n", count);
return 0;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
// 这里实现向设备写入数据的逻辑
printk(KERN_INFO "my_device: write() called, count=%zu\n", count);
return count;
}
// 定义 file_operations 结构体
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
注意:read 和 write 函数的 buf 参数带有 __user 标记,说明它指向用户空间。千万不要在内核空间直接解引用!必须用 copy_to_user 和 copy_from_user 来安全传输数据。我曾经见过新手直接 memcpy,结果内核直接 oops。
设备号注册与注销
每个字符设备在内核里都有一个唯一的编号——设备号。它由主设备号和次设备号组成:
- 主设备号:标识设备类型,比如 1 代表内存设备,4 代表 tty 设备
- 次设备号:标识同类设备中的不同实例
设备号用 dev_t 类型表示,内核提供了宏来操作它:
// 从主次设备号合成 dev_t
dev_t devno = MKDEV(major, minor);
// 从 dev_t 提取主设备号
int major = MAJOR(devno);
// 从 dev_t 提取次设备号
int minor = MINOR(devno);
注册设备号有两种方式:
| 方式 | 函数 | 适用场景 |
|---|---|---|
| 静态分配 | register_chrdev_region(dev_t from, unsigned count, const char *name) | 你知道要用的主设备号,且确保没被占用 |
| 动态分配 | alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name) | 让内核自动分配一个可用的主设备号 |
我的建议:除非你有特殊需求(比如要兼容旧版驱动),否则一律用动态分配。内核比你更清楚哪些设备号是空闲的。我早期做项目时硬编码了主设备号,结果在另一台机器上冲突了,排查了半天才发现是设备号被占用了。
注销设备号很简单,用这一个函数:
void unregister_chrdev_region(dev_t from, unsigned count);
完整的注册流程是这样的:
dev_t devno;
int major;
// 第一步:动态分配设备号
if (alloc_chrdev_region(&devno, 0, 1, "my_device") < 0) {
printk(KERN_ERR "Failed to allocate device number\n");
return -EIO;
}
major = MAJOR(devno);
printk(KERN_INFO "Allocated major number: %d\n", major);
// ... 后续还要做 cdev 初始化、添加等操作 ...
// 模块卸载时注销
unregister_chrdev_region(devno, 1);
cdev 结构体
cdev 是内核用来管理字符设备的核心结构体。它把设备号和 file_operations 绑定在一起。说白了,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()注册到内核
来看完整代码:
static struct cdev my_cdev;
static int __init my_init(void)
{
dev_t devno;
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&devno, 0, 1, "my_device");
if (ret < 0) {
printk(KERN_ERR "alloc_chrdev_region failed\n");
return ret;
}
// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 添加 cdev 到内核
ret = cdev_add(&my_cdev, devno, 1);
if (ret < 0) {
printk(KERN_ERR "cdev_add failed\n");
unregister_chrdev_region(devno, 1);
return ret;
}
printk(KERN_INFO "my_device initialized, major=%d\n", MAJOR(devno));
return 0;
}
static void __exit my_exit(void)
{
dev_t devno = my_cdev.dev;
// 1. 删除 cdev
cdev_del(&my_cdev);
// 2. 注销设备号
unregister_chrdev_region(devno, 1);
printk(KERN_INFO "my_device unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
避坑指南:我曾经在项目里犯过一个低级错误——先调了 cdev_add,然后才去创建设备文件。结果用户空间程序在设备文件还没创建时就打开了设备,导致内核 panic。正确的顺序是:先注册设备号 → 添加 cdev → 创建设备文件。卸载时顺序反过来。
完整驱动框架一览
把上面所有知识点串起来,一个完整的字符设备驱动框架就是这样的:
// 1. 定义 file_operations
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
// 2. 模块初始化
static int __init my_init(void)
{
// 分配设备号 → 初始化 cdev → 添加 cdev
}
// 3. 模块卸载
static void __exit my_exit(void)
{
// 删除 cdev → 注销设备号
}
module_init(my_init);
module_exit(my_exit);
我的经验:刚开始写驱动时,建议先用动态设备号 + 简单 printk 调试。等把整个框架跑通了,再往里填真正的硬件操作代码。这样出问题时,你能快速定位是框架问题还是硬件问题。
字符设备驱动是 Linux 驱动开发的基石。你把这个模型吃透了,后面学平台驱动、I2C 驱动、SPI 驱动都会轻松很多。嗯,今天就到这里,动手写一个你自己的字符设备驱动试试吧。