Linux驱动开发核心概念详解 - 从入门到精通
本文适合Linux驱动开发初学者,采用通俗易懂的方式讲解设备驱动模型、进程线程、内存管理等核心概念。
📚 目录
一、Linux设备驱动模型
1.1 设备驱动模型的三大好处
Linux设备驱动模型的出现主要带来三个重要改进:
🔹 设备与驱动分离
设备和驱动不再紧密耦合,可以独立开发和维护。
🔹 总线结构抽象
以总线(bus)结构来表示设备和驱动的关系,层次清晰,一目了然。
🔹 热插拔支持
正是设备与驱动的分离,才使得热插拔机制成为可能。
1.2 设备与驱动的匹配机制
匹配过程
想象一下相亲的场景:
- 所有的**设备(device)**都挂在
input_dev_list
链表上 - 所有的**处理器(handler)**都挂在
input_handler_list
链表上 - 当新设备注册时,会遍历所有handler进行匹配
- 匹配成功后,调用handler的
connect()
函数建立连接
总线匹配规则
总线的匹配规则很简单:
当有新设备注册 → 总线被唤醒 → match()函数被调用
→ 遍历总线下所有驱动 → 比较名字 → 匹配成功调用probe()函数
注册顺序问题:
先注册设备还是先注册驱动都可以!因为无论哪个后注册,总线都会被唤醒进行匹配。
二、进程与线程
2.1 进程 vs 线程
什么时候用进程?什么时候用线程?
使用线程的场景:
- 需要频繁创建销毁时(开销小)
- 需要大量数据共享时
- 对实时性要求较高时
使用进程的场景:
- 对资源保护要求高时
- 对执行效率要求不是特别高时
- 需要更好的隔离性时
关键区别
特性 | 进程 | 线程 |
---|---|---|
资源占用 | 独立地址空间,开销大 | 共享地址空间,开销小 |
通信方式 | IPC(进程间通信) | 直接访问共享数据 |
创建销毁 | 慢 | 快 |
切换开销 | 大 | 小 |
2.2 进程的五种状态
1. 创建状态 → 进程刚被创建,正在获取系统资源
2. 就绪状态 → 已准备好运行,等待CPU调度
3. 运行状态 → 正在CPU上执行
4. 阻塞状态 → 因I/O等操作而暂停
5. 终止状态 → 进程执行完毕或被终止
状态转换图:
创建 → 就绪 ⇄ 运行 ⇄ 阻塞
↓
终止
2.3 内核线程 vs 用户线程
用户线程
- 不需要内核支持
- 在用户空间实现
- 切换速度快
- 操作系统内核不可见
- ❌ 缺点:一个线程阻塞会导致整个进程阻塞
内核线程
- 由操作系统内核创建和管理
- 内核可感知
- 一个线程阻塞不影响其他线程
- ✅ 优点:可在多处理器上并行运行
三、进程间通信(IPC)
3.1 常见IPC方式对比
方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
管道(Pipe) | 简单 | 速度慢,容量有限,只能父子进程 | 父子进程简单通信 |
有名管道(FIFO) | 任何进程可用 | 速度慢 | 无亲缘关系进程通信 |
消息队列 | 异步通信 | 容量受限 | 需要异步通信场景 |
信号量 | 进程同步 | 不传输数据 | 资源访问控制 |
共享内存 | 速度最快 | 需配合同步机制 | 大量数据交换 |
Socket | 网络通信 | 复杂度高 | 不同机器间通信 |
3.2 共享内存详解
什么是共享内存?
共享内存是映射一段能被多个进程访问的内存区域。
c
// 共享内存的特点
✅ 最快的IPC方式
✅ 多个进程可同时访问
⚠️ 需要配合信号量等同步机制
⚠️ 进程间需要协商访问协议
为什么最快?
因为数据不需要在内核和用户空间之间复制,直接在内存中交换信息。
四、内存管理
4.1 进程地址空间布局
高地址
│
├─── 栈(Stack) ───────→ 向下增长
│ 局部变量、函数参数
│
├─── 堆(Heap) ────────→ 向上增长
│ 动态分配(malloc)
│
├─── BSS段 ───────────→ 未初始化/初始化为0的全局变量
│
├─── 数据段(Data) ────→ 已初始化的全局变量
│
├─── 代码段(Text) ────→ 程序执行代码(只读)
│
低地址
4.2 各段详解
代码段(Text Segment)
- 存放程序执行代码
- 只读,不可修改
- 可被多个进程共享
数据段(Data Segment)
- 存放已初始化为非零的全局变量和静态变量
- 属于静态内存分配
- 程序运行前就已确定
BSS段
- 存放未初始化或初始化为0的全局变量
- Block Started by Symbol的缩写
- 本质上也是数据段,只是特殊处理
堆(Heap)
- 动态分配内存区域
- 使用
malloc()
、new
分配 - 使用
free()
、delete
释放 - 大小可动态变化
栈(Stack)
- 存放局部变量、函数参数、返回值
- 先进后出(LIFO)
- 自动管理,函数结束自动释放
4.3 示例代码
c
#include <stdlib.h>
int g_data = 100; // 数据段
int g_bss; // BSS段(未初始化)
static int s_zero = 0; // BSS段(初始化为0)
int main() {
int local = 10; // 栈
static int s_data = 5; // 数据段
char *p = malloc(100); // p在栈,malloc的内存在堆
const char *str = "hello"; // str在栈,"hello"在常量区
return 0;
}
4.4 为什么堆的空间不连续?
堆使用链表 来维护已用和空闲的内存块。频繁的分配和释放会产生堆碎片:
已用块 | 空闲 | 已用块 | 空闲 | 已用块
5KB | 2KB | 3KB | 1KB | 4KB
即使空闲块总和够,但不连续就无法分配大块内存!
解决方法:
当空闲块旁边的已用块被释放时,会自动合并成更大的空闲块。
4.5 用户栈 vs 内核栈
内核栈
- 属于操作系统空间
- 保存中断现场
- 用于系统调用
- 一般较小(如15层调用深度)
用户栈
- 属于用户空间
- 保存函数参数、局部变量、返回值
- 程序调用链使用
为什么不能共用?
- 系统需要在保护模式下运行
- 中断嵌套需要独立保存现场
- 用户程序调用层次可能很深
五、同步机制
5.1 自旋锁(Spinlock)
形象比喻:公共厕所
线程A进入厕所 → 锁门(获得锁)
线程B想进入 → 发现锁了 → 在门口等待(自旋)
线程A出来 → 开锁(释放锁)
线程B进入
特点:
- ✅ 适合短时间持有
- ❌ 长时间持有会浪费CPU(一直空转等待)
- 只有两个状态:锁定/解锁
使用场景:
- 保护的临界区代码很短
- 中断上下文
- 不能睡眠的场景
5.2 信号量(Semaphore)
形象比喻:停车场
停车场入口有显示牌:
"剩余车位: 5" → 信号量值
车进入 → 车位-1
车离开 → 车位+1
车位为0 → 后来的车等待(睡眠)
与自旋锁的区别:
特性 | 自旋锁 | 信号量 |
---|---|---|
等待方式 | 忙等待(自旋) | 睡眠等待 |
持有时间 | 短时间 | 可长时间 |
是否睡眠 | 不能睡眠 | 可以睡眠 |
性能开销 | 短期持有开销小 | 长期持有开销小 |
使用场景:
- 保护的临界区代码较长
- 可以睡眠的上下文
- 需要长时间持有的情况
5.3 线程同步的四种基本方法
1. 临界区(Critical Section)
- 同一时刻只允许一个线程访问
- 其他线程会被挂起
- 最快的同步方式
2. 互斥量(Mutex)
- 类似临界区,但可以跨进程
- 只有拥有互斥量的线程才能访问资源
- 需要显式获取和释放
3. 信号量(Semaphore)
- 允许多个线程同时访问(计数信号量)
- 控制有限数量的资源访问
4. 事件(Event)
- 用于通知线程某些事件已发生
- 适合启动后继任务
六、重要系统调用
6.1 fork() 家族
fork()
创建子进程,复制父进程的地址空间。
c
pid_t pid = fork();
if (pid == 0) {
// 子进程
} else if (pid > 0) {
// 父进程
}
vfork()
创建子进程,但不复制地址空间,与父进程共享。
注意事项:
- 子进程必须立即调用
exec()
或exit()
- 父进程会被挂起,直到子进程调用
exec()
或exit()
- 比
fork()
更快(避免了地址空间复制)
写时复制(Copy-On-Write, COW)
现代Linux使用COW优化 fork()
:
fork()调用 → 不立即复制内存
父子进程共享相同的物理页 → 页表标记为只读
任一进程尝试写入 → 触发缺页中断 → 此时才真正复制
好处:
- 如果进程只读数据,无需复制
- 如果
fork()
后立即exec()
,避免了无用的复制
6.2 文件操作
open()
c
int fd = open(const char *pathname, int flags, mode_t mode);
read()
c
ssize_t read(int fd, void *buf, size_t count);
注意:
- 如果数据未准备好,会阻塞
- 返回0表示文件结束
- 返回实际读取的字节数
write()
c
ssize_t write(int fd, const void *buf, size_t count);
注意:
- 如果缓冲区满,可能无法一次写完
- 多次调用时,文件位置指针会自动移动
七、进程状态特殊情况
7.1 僵尸进程
什么是僵尸进程?
子进程已经退出,但父进程没有调用 wait()
或 waitpid()
回收,导致子进程的进程描述符仍占用系统资源。
危害:
- 占用进程表项
- 系统进程数有上限,可能导致无法创建新进程
- 占用PID资源
如何避免?
c
// 方法1: 父进程及时调用wait()
wait(NULL);
// 方法2: 信号处理
signal(SIGCHLD, SIG_IGN); // 忽略子进程退出信号
// 方法3: 双fork
if (fork() == 0) {
if (fork() == 0) {
// 孙进程工作
}
exit(0); // 子进程立即退出
}
wait(NULL); // 回收子进程
7.2 Server端监听状态
问题: Server端监听端口但未有客户端连接,进程处于什么状态?
答案取决于模型:
- 阻塞模型: 进程处于阻塞状态(睡眠)
c
accept(); // 阻塞等待连接
- I/O多路复用(epoll/select): 进程处于运行状态
c
epoll_wait(); // 监听多个fd
八、实用技巧
8.1 查看进程
bash
# Linux查看进程
ps aux
# 查看进程树
pstree
# 实时监控
top
8.2 调试技巧
bash
# 使用strace跟踪系统调用
strace ./your_program
# 查看内存映射
cat /proc/[pid]/maps
# 查看进程状态
cat /proc/[pid]/status
8.3 性能优化建议
选择合适的同步机制:
- 短临界区 → 自旋锁
- 长临界区 → 信号量/互斥量
选择合适的IPC:
- 大量数据 → 共享内存
- 不同机器 → Socket
- 简单通信 → 管道
内存管理:
- 避免频繁小块分配(造成碎片)
- 使用内存池
- 及时释放不用的内存
📝 总结
本文涵盖了Linux驱动开发的核心概念:
✅ 设备驱动模型的架构和匹配机制
✅ 进程与线程的区别和使用场景
✅ 多种进程间通信方式的对比
✅ 进程地址空间的详细布局
✅ 自旋锁与信号量的区别和应用
✅ fork家族和写时复制技术
✅ 僵尸进程的产生和避免
希望这篇文章能帮助你建立起Linux驱动开发的知识体系!
参考资料:
- Linux内核设计与实现
- Unix环境高级编程
- Linux设备驱动程序
关于作者:
专注于Linux内核和驱动开发,欢迎交流讨论!
💡 如果觉得有帮助,欢迎点赞收藏!有问题欢迎评论区讨论~