字符设备驱动框架:从零搭建你的第一个驱动
字符设备驱动,是嵌入式Linux驱动里最基础、也最常用的一类。说白了,就是那些按字节流读写数据的设备——串口、GPIO、LED、按键,甚至某些传感器,底层都是字符设备驱动在撑着。
我刚开始学驱动时,觉得这玩意儿挺玄乎。后来写多了才发现,核心就四个东西:file_operations结构体、主次设备号、cdev的注册与注销、设备节点创建。把这四个搞明白,字符设备驱动的基本骨架就有了。
核心要点:字符设备驱动的本质,就是让用户空间的open/read/write/ioctl等系统调用,能正确找到内核里的对应函数。file_operations就是这座桥梁。
2.1 file_operations结构体:驱动与用户态的契约
每个字符设备驱动,都必须提供一个file_operations结构体。这个结构体定义了驱动能干什么——用户程序调用open、read、write、ioctl时,内核会查这个表,找到对应的驱动函数去执行。
我见过不少新手,上来就把所有函数指针都填上,其实没必要。你设备只支持读,那就只实现read,其他置NULL就行。内核会帮你处理默认行为,比如没实现write就返回-ENOTTY。
// 一个典型的file_operations定义
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.unlocked_ioctl = my_ioctl,
};
这里有个坑——.owner一定要赋值为THIS_MODULE。为什么?它用来防止模块正在被使用时被卸载。我曾经在调试一个USB转串口驱动时,忘了设这个字段,结果用户程序还在读数据,我手贱rmmod了一下,内核直接Oops。嗯,从那以后我再也没忘过。
| 函数指针 | 说明 | 常用场景 |
|---|---|---|
| open | 打开设备,做初始化 | 申请资源、自旋锁初始化 |
| release | 关闭设备,做清理 | 释放资源、关闭中断 |
| read | 从设备读取数据 | 拷贝数据到用户空间 |
| write | 向设备写入数据 | 从用户空间拷贝数据 |
| unlocked_ioctl | 设备控制命令 | 设置参数、获取状态 |
| llseek | 文件定位 | 修改文件读写位置 |
个人习惯:我写驱动时,会在file_operations里只实现真正需要的函数。多余的函数指针不填,既减少代码量,也避免引入不必要的bug。
2.2 主次设备号:设备的身份证
每个字符设备在内核里都有一个唯一的编号——主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。比如你写了一个I2C控制器驱动,主设备号是89,下面挂了两个从设备,次设备号分别是0和1。
设备号的分配有两种方式:
- 静态分配:调用
register_chrdev_region(),自己指定主设备号。适合你知道这个号没被占用的场景。 - 动态分配:调用
alloc_chrdev_region(),让内核自动分配。我强烈推荐这种方式,省心,不会冲突。
// 动态分配设备号
dev_t dev_num;
int ret;
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
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));
你想想看,如果每个驱动都硬编码主设备号,万一两个驱动用了同一个号,系统就乱了。动态分配就是让内核来当裁判,谁先申请谁先用。
避坑指南:我曾经在一个项目里,静态分配了主设备号240,结果另一个同事的模块也用了240。加载顺序不同,设备节点就指向了错误的驱动。从那以后,我所有新驱动都改用动态分配,除非有特殊理由。
2.3 cdev注册与注销:让内核认识你的设备
设备号有了,file_operations也有了,但内核还不知道它们俩是一对。这时候就需要cdev——字符设备的内核对象。
cdev的注册分三步:
- 分配cdev:用
cdev_alloc()或静态定义struct cdev。 - 初始化:调用
cdev_init(),把file_operations绑定上去。 - 添加:调用
cdev_add(),告诉内核这个设备号归这个cdev管。
struct cdev my_cdev;
dev_t dev_num;
// 1. 分配设备号(假设已用alloc_chrdev_region获取)
// 2. 初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 添加cdev到内核
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk(KERN_ERR "Failed to add cdev\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
注销时顺序反过来:先cdev_del(),再unregister_chrdev_region()。千万别搞反,否则设备号还在,但cdev已经没了,用户程序访问时会崩溃。
关键点:cdev_add()之后,内核随时可能调用你的file_operations函数。所以在这之前,一定要确保所有资源都已经初始化完毕。我习惯在cdev_add()之前先做好硬件初始化、内存分配、锁的初始化。
2.4 设备节点创建:用户态访问的入口
驱动注册好了,但用户程序怎么找到它?答案是设备节点——就是/dev/目录下的那个文件。比如/dev/ttyS0、/dev/i2c-1。
创建设备节点有两种方式:
- 手动创建:用
mknod /dev/mydevice c 240 0。调试时可以用,但产品里不现实。 - 自动创建:使用
udev或mdev,配合内核的device_create()和class_create()。这是生产环境的标准做法。
// 自动创建设备节点
static struct class *my_class;
// 在模块初始化函数中
my_class = class_create(THIS_MODULE, "my_device_class");
if (IS_ERR(my_class)) {
// 错误处理
}
device_create(my_class, NULL, dev_num, NULL, "my_device%d", 0);
// 在模块退出函数中
device_destroy(my_class, dev_num);
class_destroy(my_class);
这里有个细节:device_create()的最后一个参数是设备节点名,支持格式化字符串。比如"my_device%d", 0会生成/dev/my_device0。如果你有多个同类型设备,这个功能就很有用了。
我的经验:调试阶段我喜欢先用mknod手动创建节点,确认驱动基本功能正常后,再改成自动创建。这样能快速定位问题是出在驱动本身,还是udev规则配置不对。
2.5 完整框架流程图
下面这张图,把整个字符设备驱动的注册流程串起来了。从模块加载到设备节点出现,每一步都清晰可见。
2.6 完整代码骨架
把上面所有知识点串起来,就是一个完整的字符设备驱动骨架。我建议你把这个模板保存下来,以后写新驱动时直接改改就能用。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;
static int my_open(struct inode *inode, struct file *filp)
{
printk("Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp)
{
printk("Device closed\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off)
{
// 实现读操作
return 0;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t len, loff_t *off)
{
// 实现写操作
return len;
}
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)
{
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0)
return ret;
// 2. 初始化并添加cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0)
goto err_cdev;
// 3. 创建class和设备节点
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
ret = PTR_ERR(my_class);
goto err_class;
}
device_create(my_class, NULL, dev_num, NULL, "my_device");
printk("My device driver loaded, major=%d\n", MAJOR(dev_num));
return 0;
err_class:
cdev_del(&my_cdev);
err_cdev:
unregister_chrdev_region(dev_num, 1);
return ret;
}
static void __exit my_exit(void)
{
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk("My device driver unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple character device driver");
重要提醒:上面的代码只是骨架,实际项目中你需要在open/release里做资源管理,在read/write里做数据拷贝(copy_to_user/copy_from_user),还要考虑并发访问的保护。这些内容我们会在后续章节详细展开。
字符设备驱动框架,说白了就是这几步。你把这个模板练熟了,后面学平台驱动、设备树、中断处理,都会轻松很多。驱动开发没有捷径,但有一个好模板,能让你少走很多弯路。