Linux设备驱动基础:从模块到字符设备
大家好,我是老张。做嵌入式Linux驱动开发这么多年,我始终觉得——理解设备驱动的基础,就像盖楼打地基。地基不稳,后面全是坑。今天咱们就来聊聊Linux内核模块结构、字符设备驱动框架,还有那个绕不开的file_operations结构体。
1. Linux内核模块:驱动的最小单元
说白了,内核模块就是一段可以动态加载到内核的代码。你想想看,如果每次加个驱动都要重新编译整个内核,那得多痛苦?模块机制就是为了解决这个问题的。
一个典型的内核模块,至少包含两个函数:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init my_driver_init(void)
{
printk(KERN_INFO "Hello, driver loaded!\n");
return 0;
}
static void __exit my_driver_exit(void)
{
printk(KERN_INFO "Goodbye, driver unloaded!\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Zhang");
MODULE_DESCRIPTION("A simple demo driver");
嗯,这里要注意——__init和__exit这两个宏,很多人刚开始会忽略。它们的作用是把初始化代码放到特定段,模块加载完后内核可以回收这部分内存。我在项目中遇到过,有人没加这个宏,结果模块占用的内存一直释放不掉。
核心要点:
module_init():指定模块加载入口module_exit():指定模块卸载出口MODULE_LICENSE:必须声明,否则内核会报"tainted"
2. 字符设备驱动框架:最经典的驱动模型
字符设备,就是那些像串口、GPIO、I2C设备一样,按字节流读写的外设。Linux里大部分简单设备都属于这一类。
我个人习惯把字符设备驱动的开发流程总结为三步:
- 注册设备号——告诉内核"我要用这个设备号"
- 初始化cdev结构体——把操作函数绑定到设备
- 创建设备节点——让用户空间能访问到
来看一个完整的框架代码:
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/fs.h>
#define DEVICE_NAME "my_char_dev"
#define CLASS_NAME "my_class"
static int major_num;
static struct class *my_class;
static struct device *my_device;
static struct cdev my_cdev;
static int __init char_dev_init(void)
{
dev_t dev_num;
// 第一步:动态分配设备号
alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
major_num = MAJOR(dev_num);
// 第二步:初始化cdev
cdev_init(&my_cdev, &my_fops);
cdev_add(&my_cdev, dev_num, 1);
// 第三步:创建类和设备节点
my_class = class_create(THIS_MODULE, CLASS_NAME);
my_device = device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);
printk(KERN_INFO "Device registered, major=%d\n", major_num);
return 0;
}
我的经验:动态分配设备号(alloc_chrdev_region)比静态指定好得多。静态指定容易跟其他驱动冲突,我曾经在调试一个项目时,就因为设备号冲突,折腾了两天才找到原因。
3. file_operations结构体:驱动与用户空间的桥梁
这个结构体,说白了就是一张函数指针表。用户空间调用open()、read()、write()时,内核会通过这张表找到对应的驱动函数。
我见过很多新手,一上来就想把所有函数都实现。其实没必要——你只需要实现设备真正需要的操作就行。
static int my_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "Device closed\n");
return 0;
}
static ssize_t my_read(struct file *file, char __user *buf,
size_t len, loff_t *offset)
{
// 注意:这里要用copy_to_user,不能直接memcpy
printk(KERN_INFO "Read request, len=%zu\n", len);
return 0;
}
static ssize_t my_write(struct file *file, const char __user *buf,
size_t len, loff_t *offset)
{
printk(KERN_INFO "Write request, len=%zu\n", len);
return len;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
避坑指南:我曾经在read函数里直接用了memcpy往用户空间拷贝数据,结果内核直接崩溃。记住——用户空间和内核空间不能直接传指针,必须用copy_to_user和copy_from_user。这是新手最容易犯的错误之一。
4. 模块加载与卸载流程
加载和卸载,看起来就是insmod和rmmod两个命令的事。但背后的流程,你想想看,其实挺讲究的。
| 阶段 | 用户操作 | 内核行为 |
|---|---|---|
| 加载 | insmod my_driver.ko |
调用module_init指定的函数 |
| 使用 | open/read/write |
通过file_operations调用驱动函数 |
| 卸载 | rmmod my_driver |
调用module_exit指定的函数 |
卸载时要注意顺序——先删除设备节点,再销毁cdev,最后释放设备号。顺序反了,系统可能会崩溃。我建议你养成一个习惯:加载和卸载的代码写成对称的,这样不容易漏掉。
卸载函数的正确写法:
static void __exit char_dev_exit(void)
{
dev_t dev_num = MKDEV(major_num, 0);
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "Device unloaded\n");
}
5. 知识体系总览
为了让你更直观地理解整个框架,我画了一张图:
这张图清晰地展示了从用户空间到硬件驱动的完整调用链。你写驱动时,心里要始终装着这个框架——每个函数、每个结构体,都在这个链条上有自己的位置。
我的建议:刚开始学驱动开发,不要急着写复杂的逻辑。先把module_init、module_exit、file_operations这三个核心要素跑通。能加载、能卸载、能读写,你就已经掌握了80%的基础。
好了,这一章的内容就到这里。记住——驱动开发没有捷径,但有了扎实的基础,后面的路会越走越顺。
公众号:蓝海资料掘金营,微信deep3321