字符设备驱动基础:从零搭建你的第一个驱动
字符设备驱动,是嵌入式Linux驱动开发中最基础、也是最常打交道的一类。说白了,就是那些按字节流读写、不支持随机访问的设备——串口、GPIO、LED、按键,都属于这个范畴。
我记得刚入行那会儿,总觉得字符设备驱动很简单,不就是注册几个回调函数嘛。直到有一次,我在一个工业控制项目里,因为设备号分配冲突导致系统启动时驱动加载失败,现场排查了整整一个下午……嗯,从那以后,我再也不敢小看这些基础概念了。
1. file_operations:驱动与用户态的桥梁
用户程序调用 open()、read()、write() 时,内核怎么知道该执行哪段代码?答案就在 file_operations 结构体里。
这个结构体定义在 <linux/fs.h> 中,本质上是一堆函数指针的集合。每个成员对应一个系统调用。你只需要把想支持的操作填进去,内核就会自动帮你做映射。
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 *);
/* 还有更多... */
};
我个人习惯,至少实现 open、release、read、write 这四个。其他的按需添加,别一股脑全写上,容易出bug。
owner 字段上吃过亏。这个字段必须设为 THIS_MODULE,否则模块卸载时,如果还有进程持有文件描述符,内核会直接崩溃。别问我怎么知道的……
2. 主次设备号:设备的身份证
每个字符设备在内核里都有一个唯一的编号——主设备号 + 次设备号。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备实例。
| 概念 | 含义 | 示例 |
|---|---|---|
| 主设备号 | 标识驱动 | 89(I2C设备) |
| 次设备号 | 标识设备实例 | 0、1、2… |
分配设备号有两种方式:
- 静态分配:手动指定一个数字,用
register_chrdev_region()注册。适合你知道哪些号没被占用的场景。 - 动态分配:用
alloc_chrdev_region()让内核帮你挑一个。我强烈推荐这种方式——省心,不会冲突。
dev_t dev_num;
int major, minor;
/* 动态分配:主设备号由内核决定 */
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);
minor = MINOR(dev_num);
/* 或者静态分配:自己指定主设备号 */
// register_chrdev_region(dev_num, 1, "my_device");
/proc/devices 或者设备节点来获取。
3. cdev结构体:驱动的内核表示
cdev 是内核用来管理字符设备的核心结构体。它把 file_operations 和设备号绑定在一起,告诉内核:这个设备号对应的操作函数是什么。
初始化流程很简单,三步走:
- 分配
cdev结构体(静态或动态) - 调用
cdev_init()绑定file_operations - 调用
cdev_add()注册到内核
struct cdev my_cdev;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
if (cdev_add(&my_cdev, dev_num, 1) < 0) {
printk(KERN_ERR "Failed to add cdev\n");
unregister_chrdev_region(dev_num, 1);
return -1;
}
这里有个细节:cdev_add() 之后,设备就立刻对外可见了。所以一定要确保所有初始化都完成后再调用它,否则用户程序可能访问到一个半成品的设备。
4. 设备节点:用户态怎么找到你?
设备注册好了,用户程序怎么访问?通过设备节点——就是 /dev/ 目录下的那个文件。
创建设备节点有两种方式:
手动创建(不推荐)
用 mknod 命令手动创建:
mknod /dev/my_device c 240 0
缺点很明显:设备号变了就得重新创建,而且没法自动管理权限。
自动创建(推荐)
利用 udev 或 mdev 自动创建。驱动里只需要做两件事:
- 创建一个
class - 在
class下创建一个device
static struct class *my_class;
my_class = class_create(THIS_MODULE, "my_class");
device_create(my_class, NULL, dev_num, NULL, "my_device");
/* 卸载时记得销毁 */
device_destroy(my_class, dev_num);
class_destroy(my_class);
这样,模块加载后,/dev/my_device 就会自动出现。我个人觉得,这才是专业做法。
5. 完整示例:一个简单的字符设备驱动
说了这么多,不如直接看代码。下面是一个完整的字符设备驱动,实现了 open、release、read、write 四个操作。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_demo"
#define CLASS_NAME "demo_class"
static int major;
static struct class *demo_class;
static struct cdev demo_cdev;
static char kernel_buffer[256];
static int demo_open(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device opened\n");
return 0;
}
static int demo_release(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device closed\n");
return 0;
}
static ssize_t demo_read(struct file *filp, char __user *buf,
size_t len, loff_t *off) {
size_t available = strlen(kernel_buffer);
size_t to_copy = min(len, available);
if (copy_to_user(buf, kernel_buffer, to_copy)) {
return -EFAULT;
}
return to_copy;
}
static ssize_t demo_write(struct file *filp, const char __user *buf,
size_t len, loff_t *off) {
size_t to_copy = min(len, sizeof(kernel_buffer) - 1);
if (copy_from_user(kernel_buffer, buf, to_copy)) {
return -EFAULT;
}
kernel_buffer[to_copy] = '\0';
return to_copy;
}
static struct file_operations demo_fops = {
.owner = THIS_MODULE,
.open = demo_open,
.release = demo_release,
.read = demo_read,
.write = demo_write,
};
static int __init demo_init(void) {
dev_t dev_num;
/* 1. 分配设备号 */
if (alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME) < 0) {
return -1;
}
major = MAJOR(dev_num);
/* 2. 初始化并注册cdev */
cdev_init(&demo_cdev, &demo_fops);
demo_cdev.owner = THIS_MODULE;
if (cdev_add(&demo_cdev, dev_num, 1) < 0) {
unregister_chrdev_region(dev_num, 1);
return -1;
}
/* 3. 自动创建设备节点 */
demo_class = class_create(THIS_MODULE, CLASS_NAME);
device_create(demo_class, NULL, dev_num, NULL, DEVICE_NAME);
printk(KERN_INFO "Demo driver loaded, major=%d\n", major);
return 0;
}
static void __exit demo_exit(void) {
dev_t dev_num = MKDEV(major, 0);
device_destroy(demo_class, dev_num);
class_destroy(demo_class);
cdev_del(&demo_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "Demo driver unloaded\n");
}
module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver demo");
copy_to_user/copy_from_user是必须的,不能直接访问用户空间指针- 卸载顺序要和加载顺序相反:先销毁设备节点,再删除cdev,最后释放设备号
- Makefile 里记得加上
obj-m += demo.o
6. 知识体系总览
下面这张图,把字符设备驱动的核心流程串起来了。你写驱动时,就按这个顺序来,基本不会出错。
你想想看,整个流程其实就五步:分配设备号 → 注册cdev → 创建class → 创建设备节点 → 用户态访问。卸载时反过来就行。是不是很清晰?
echo 和 cat 测试读写,感受一下用户态和内核态的数据流动。等这个流程烂熟于心,再去做中断、DMA、休眠唤醒这些高级特性,会顺手很多。