6. 同步与并发:IRQL级别、自旋锁、互斥体、信号量、内核事件对象
驱动开发里,并发问题是个绕不开的坎儿。你想想看,一个驱动可能同时被多个线程调用,中断随时可能发生,CPU 也可能在多核上并行执行你的代码。如果不做同步,数据乱掉、蓝屏崩溃,那都是家常便饭。
我个人习惯,在写任何驱动之前,先想清楚两个问题:这段代码会在什么 IRQL 下运行? 以及 多个执行路径会不会同时访问同一块数据? 想明白了,再选合适的同步机制。
6.1 IRQL 级别:驱动世界的优先级
IRQL(Interrupt Request Level)是 Windows 内核里用来管理中断优先级的一套机制。说白了,它决定了当前代码能不能被其他中断打断。
常见的 IRQL 级别有这么几个:
| IRQL 级别 | 名称 | 说明 |
|---|---|---|
| PASSIVE_LEVEL | 0 | 普通线程执行级别,可被任何中断打断 |
| APC_LEVEL | 1 | 异步过程调用级别,比普通线程高一点 |
| DISPATCH_LEVEL | 2 | 调度级别,线程调度和 DPC 在此运行 |
| DIRQL | 3~26 | 设备中断级别,硬件中断处理程序在此运行 |
| HIGH_LEVEL | 31 | 最高级别,几乎不可打断 |
嗯,这里要注意:IRQL 越高,能做的事情越少。比如在 DISPATCH_LEVEL 及以上,你就不能访问分页内存,也不能等待用户模式的对象。我曾经在项目里踩过这个坑——在 DPC 例程里试图等待一个事件,结果系统直接蓝屏。从那以后,我写代码前都会先检查当前 IRQL。
核心原则:IRQL 决定了你能用什么同步机制。PASSIVE_LEVEL 下几乎什么都能用,DISPATCH_LEVEL 下只能用自旋锁,再往上就只能用中断屏蔽了。
6.2 自旋锁:轻量级的守护者
自旋锁是最基础的同步原语。它的原理很简单:当一个 CPU 核心想要获取锁时,如果锁被占用了,它就在那里不停地循环检查(也就是「自旋」),直到锁被释放。
为什么叫「自旋」?因为线程不会睡眠,而是忙等。这在多核系统上效率很高,因为上下文切换的开销可能比自旋几圈还大。但缺点也很明显——浪费 CPU 时间。
使用自旋锁的典型场景:
- 保护短小的临界区(几十条指令以内)
- 在 DISPATCH_LEVEL 级别下保护共享数据
- 多核之间同步对全局变量的访问
来看一个例子:
// 初始化自旋锁
KSPIN_LOCK mySpinLock;
KeInitializeSpinLock(&mySpinLock);
// 获取锁(会提升 IRQL 到 DISPATCH_LEVEL)
KIRQL oldIrql;
KeAcquireSpinLock(&mySpinLock, &oldIrql);
// 临界区代码
mySharedCounter++;
// 释放锁(恢复原来的 IRQL)
KeReleaseSpinLock(&mySpinLock, oldIrql);
我个人习惯,在获取自旋锁后尽量少做事。锁的持有时间越短,其他 CPU 等待的时间就越短。我曾经见过一个同事在自旋锁保护的区域里做了内存分配,结果系统性能直接崩了——因为内存分配可能触发缺页中断,而自旋锁下是不允许的。
警告:自旋锁保护的区域里,绝对不能做以下事情:
- 访问分页内存(可能触发缺页)
- 调用可能阻塞的函数(如 KeWaitForSingleObject)
- 执行耗时操作(如大量计算或 I/O)
6.3 互斥体:线程间的排他访问
互斥体(Mutex)和自旋锁不同,它允许线程在等待时进入睡眠状态。这意味着它只能在 PASSIVE_LEVEL 下使用,但好处是不浪费 CPU 时间。
互斥体的特点:
- 支持递归获取(同一个线程可以多次获取同一个互斥体)
- 支持超时等待
- 可以跨进程使用(命名互斥体)
- 等待时会降低 IRQL 到 PASSIVE_LEVEL
代码示例:
KMUTEX myMutex;
KeInitializeMutex(&myMutex, 0); // 第二个参数 0 表示不递归
// 等待互斥体
NTSTATUS status = KeWaitForSingleObject(
&myMutex,
Executive, // 等待原因
KernelMode, // 等待模式
FALSE, // 是否可被 APC 打断
NULL // 无限等待
);
if (NT_SUCCESS(status)) {
// 临界区代码
// ...
// 释放互斥体
KeReleaseMutex(&myMutex, FALSE);
}
我记得有一次调试一个文件系统驱动,发现某个线程卡住了。查了半天,原来是互斥体被一个线程获取后,另一个线程在等待,但获取锁的线程因为异常退出了,导致互斥体永远无法释放。从那以后,我写代码都会加上超时机制,避免死锁。
小技巧:互斥体适合保护那些需要长时间持有的资源,比如设备上下文或全局状态。如果临界区很短,用自旋锁更合适。
6.4 信号量:控制并发数量
信号量(Semaphore)和互斥体有点像,但它不是排他的。信号量维护一个计数器,允许多个线程同时访问资源,只要不超过设定的最大值。
典型的应用场景:
- 限制同时访问某个硬件的线程数量
- 管理有限数量的资源池(比如缓冲区)
- 生产者-消费者模式
信号量的使用:
KSEMAPHORE mySemaphore;
KeInitializeSemaphore(&mySemaphore, 5, 10); // 初始5个,最大10个
// 等待信号量(减少计数)
KeWaitForSingleObject(&mySemaphore, Executive, KernelMode, FALSE, NULL);
// 使用资源
// ...
// 释放信号量(增加计数)
KeReleaseSemaphore(&mySemaphore, IO_NO_INCREMENT, 1, FALSE);
嗯,这里有个细节:KeReleaseSemaphore 的第二个参数是优先级提升量。如果你希望等待的线程尽快运行,可以设一个非零值。但一般情况下,设 IO_NO_INCREMENT 就够了,避免不必要的优先级反转。
6.5 内核事件对象:线程间的信号通知
事件对象(Event)是最简单的同步原语之一。它只有两种状态:有信号(Signaled)和无信号(Non-Signaled)。线程可以等待一个事件变成有信号状态,然后继续执行。
事件有两种类型:
- 自动重置事件:当一个线程等待成功后,事件自动变回无信号状态
- 手动重置事件:需要手动调用
KeResetEvent或KeSetEvent来改变状态
来看一个典型用法:
KEVENT myEvent;
KeInitializeEvent(&myEvent, NotificationEvent, FALSE); // 手动重置,初始无信号
// 线程 A:等待事件
KeWaitForSingleObject(&myEvent, Executive, KernelMode, FALSE, NULL);
// 线程 B:触发事件
KeSetEvent(&myEvent, IO_NO_INCREMENT, FALSE);
事件对象在驱动里用得特别多。比如,一个线程发起 I/O 请求后,可以等待一个事件,等硬件中断处理程序完成操作后设置事件,唤醒等待的线程。这种模式在驱动里叫「异步完成通知」。
我曾经写一个网络驱动时,用事件来同步数据包的接收和发送。一开始用的是自旋锁,结果发现 CPU 占用率居高不下。换成事件后,等待的线程可以睡眠,CPU 占用率一下就降下来了。
总结一下:
- 自旋锁:轻量、忙等、适合短临界区、DISPATCH_LEVEL 可用
- 互斥体:重量、睡眠等待、支持递归、仅 PASSIVE_LEVEL
- 信号量:控制并发数量、适合资源池管理
- 事件:信号通知机制、适合异步完成场景
选择哪种同步机制,取决于你的 IRQL 级别、临界区长度、以及是否需要睡眠等待。没有银弹,只有最适合当前场景的方案。
这张图是我自己总结的决策流程。每次写同步代码前,先走一遍这个流程,基本不会出错。当然,实际项目中可能还有更多细节要考虑,比如优先级反转、死锁检测等,但掌握了这些基础,你已经能应对 90% 的场景了。