内核驱动框架与性能基础
驱动开发这事儿,说白了就是让内核认识你的硬件。我刚开始接触Linux驱动时,总觉得框架这东西太抽象,不如直接写寄存器来得痛快。后来被坑了几次才明白——框架不是束缚,是给你铺好的路。
这一章我们就把内核模块、字符设备、platform模型、设备树、并发锁这几个基础骨架过一遍。嗯,都是后面调优和定位故障的根基。
1. Linux内核模块加载机制
内核模块就是一段可以在运行时动态加载到内核的代码。你想想看,如果每次加个驱动都要重新编译整个内核,那开发效率得多低?
核心流程:
- module_init() — 模块入口,加载时调用
- module_exit() — 模块出口,卸载时调用
- MODULE_LICENSE() — 声明许可证,不写的话内核会报"tainted"
- MODULE_AUTHOR() / MODULE_DESCRIPTION() — 元信息
我见过不少新手在模块里直接操作硬件寄存器,结果加载时系统直接panic。为什么?因为模块加载时内存映射还没准备好,你得先让内核知道你的设备在哪。
// 一个最简单的内核模块
#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("Your Name");
MODULE_DESCRIPTION("A simple example module");
避坑指南:我曾经在模块初始化里申请了DMA缓冲区但忘了释放,结果卸载模块后系统内存泄漏。记住:init里申请的资源,exit里一定要还回去。
2. 字符设备驱动框架
字符设备是Linux驱动里最基础的一类。它按字节流读写数据,典型的像串口、GPIO、I2C设备都属于字符设备。
核心结构体就两个:struct cdev 和 struct file_operations。前者代表设备,后者代表操作函数表。
| file_operations成员 | 说明 | 典型场景 |
|---|---|---|
| .open | 打开设备 | 初始化硬件、申请资源 |
| .release | 关闭设备 | 释放资源、关闭硬件 |
| .read | 从设备读取数据 | 读取传感器值、接收数据 |
| .write | 向设备写入数据 | 发送命令、配置参数 |
| .unlocked_ioctl | 设备控制命令 | 设置波特率、切换模式 |
我个人习惯在注册字符设备时,先用 alloc_chrdev_region() 动态分配设备号,而不是写死主设备号。为什么?因为静态分配容易跟其他驱动冲突,动态分配省心多了。
// 字符设备注册核心步骤
dev_t dev_num;
struct cdev my_cdev;
// 1. 分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
// 2. 初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 添加设备到系统
cdev_add(&my_cdev, dev_num, 1);
// 4. 创建设备节点(通常由udev自动完成)
// class_create() + device_create()
注意:cdev_add()之后,内核随时可能调用你的操作函数。所以在add之前,必须确保所有资源都已就绪。我曾经在add之后才初始化自旋锁,结果并发访问直接死锁。
3. platform驱动模型
platform驱动是Linux设备模型里最常用的框架之一。它专门用于那些不挂在PCIe、USB、I2C等标准总线上的设备——说白了就是"片上设备"。
核心是两个结构体:struct platform_driver 和 struct platform_device。驱动和设备通过名字匹配,匹配成功后调用probe函数。
// platform驱动模板
static struct platform_driver my_pdrv = {
.probe = my_pdrv_probe,
.remove = my_pdrv_remove,
.driver = {
.name = "my_platform_device",
.of_match_table = my_of_match,
},
};
module_platform_driver(my_pdrv);
我在项目中遇到过一个问题:probe函数里读取设备树属性时,忘了检查返回值。结果设备树里漏配了一个属性,驱动直接访问了无效内存。从那以后,我每次读取设备树属性都会加一句:
if (of_property_read_u32(np, "reg", &base_addr)) {
dev_err(&pdev->dev, "missing reg property\n");
return -EINVAL;
}
经验之谈:probe函数里尽量只做资源申请和初始化,不要把业务逻辑塞进去。probe失败时,要确保所有已申请的资源都能正确回滚。我习惯用goto标签做统一错误处理,虽然老派但可靠。
4. 设备树基础
设备树(Device Tree)是ARM Linux里描述硬件信息的标准方式。它解决了以前arch/arm目录下大量板级文件混乱的问题。
设备树文件以.dts为后缀,编译后生成.dtb二进制文件。内核在启动时解析dtb,构建出设备树结构体。
// 一个简单的设备树节点
my_device: my-device@ff100000 {
compatible = "vendor,my-device";
reg = <0x0 0xff100000 0x0 0x1000>;
interrupts = <0 31 4>;
clocks = <&clkc 15>;
status = "okay";
};
你想想看,如果没有设备树,每次换一个板子都要改内核代码。有了设备树,只需要改dts文件,重新编译dtb就行。内核本身不用动。
| 设备树属性 | 含义 | 驱动中如何获取 |
|---|---|---|
| compatible | 设备兼容性字符串 | of_match_table匹配 |
| reg | 寄存器地址和大小 | platform_get_resource() |
| interrupts | 中断号、触发类型 | platform_get_irq() |
| clocks | 时钟源引用 | clk_get() / devm_clk_get() |
常见坑:设备树里的地址和大小都是无符号整数,别用int去读。我曾经因为用了int读取reg属性,结果地址高位被截断,驱动访问了错误的物理地址,系统直接挂掉。
5. 驱动中的并发与锁机制
驱动开发里,并发问题是最隐蔽、最难定位的。你想想看,一个驱动可能同时被多个进程打开,中断随时可能触发,SMP多核上两个CPU可能同时访问同一个变量。
Linux内核提供了多种锁机制,我按使用频率排个序:
- 自旋锁(spinlock) — 轻量级,适合短临界区,不能在持有锁时睡眠
- 互斥锁(mutex) — 重量级,允许睡眠,适合长临界区
- 读写锁(rwlock / rwsem) — 读多写少场景优化
- 原子操作(atomic_t) — 简单的计数器、标志位
// 自旋锁使用示例
spinlock_t my_lock;
spin_lock_init(&my_lock);
spin_lock(&my_lock);
// 临界区代码,不能睡眠
shared_data++;
spin_unlock(&my_lock);
// 互斥锁使用示例
struct mutex my_mutex;
mutex_init(&my_mutex);
mutex_lock(&my_mutex);
// 临界区代码,可以睡眠
copy_to_user(buf, &shared_data, sizeof(shared_data));
mutex_unlock(&my_mutex);
核心原则:
- 中断上下文只能用自旋锁,不能用互斥锁
- 持有自旋锁时不能调用任何可能睡眠的函数(kmalloc(GFP_KERNEL)、copy_to_user等)
- 锁的粒度要适中——太粗影响性能,太细容易死锁
我在项目中遇到过最典型的死锁场景:驱动A持有锁L1,然后调用驱动B的函数,驱动B又试图获取锁L1。两个驱动互相等待,系统卡死。解决方法是明确锁的层级关系,或者使用锁的嵌套顺序约定。
本章知识体系总览
下面这张图把本章的核心知识点串起来了。你可以把它当作一个思维导图,方便后面查阅。
嗯,这一章的内容就到这里。框架是骨架,性能调优是血肉,故障定位是经验。后面我们会一步步把这些基础用在实际的驱动开发中。