11、定时器与等待:内核定时器、等待对象、超时处理、高精度定时
驱动开发里,定时和等待是绕不开的话题。说白了,就是让代码“等一会儿”或者“等到某个条件满足”。我刚开始写驱动时,总觉得这很简单——不就是sleep吗?结果第一次做硬件轮询,就因为超时处理不当,直接把系统卡死了。嗯,从那以后我再也不敢小看定时器了。
这一章,咱们就聊聊Windows内核里的定时机制。我会把内核定时器、等待对象、超时处理和高精度定时这几个东西串起来讲。你想想看,驱动里最常见的场景是什么?无非就是:等硬件就绪、等某个事件发生、或者定期执行任务。这些全都要靠定时和等待机制。
11.1 内核定时器基础
内核定时器,说白了就是一个“闹钟”。你设定一个时间,到了时间它就会触发一个回调函数。Windows内核里最常用的定时器是KTIMER对象。
先看一个最简单的例子:
// 定义定时器对象和DPC
KTIMER g_Timer;
KDPC g_Dpc;
// 定时器回调函数
VOID TimerDpcRoutine(
_In_ struct _KDPC *Dpc,
_In_opt_ PVOID DeferredContext,
_In_opt_ PVOID SystemArgument1,
_In_opt_ PVOID SystemArgument2
)
{
// 这里就是定时器触发后执行的代码
DbgPrint("定时器触发了!\n");
}
// 初始化定时器
VOID InitMyTimer()
{
KeInitializeTimer(&g_Timer);
KeInitializeDpc(&g_Dpc, TimerDpcRoutine, NULL);
}
// 启动定时器,5秒后触发一次
VOID StartMyTimer()
{
LARGE_INTEGER dueTime;
dueTime.QuadPart = -5 * 1000 * 1000 * 10; // 相对时间,单位100纳秒
KeSetTimer(&g_Timer, dueTime, &g_Dpc);
}
这里有个坑,我一开始就踩过。dueTime的单位是100纳秒,不是毫秒。所以5秒要写成-5 * 1000 * 1000 * 10。负号表示相对时间,正号表示绝对时间。这个细节搞错了,定时器要么不触发,要么触发时间完全不对。
核心要点:
- 定时器需要配合DPC(延迟过程调用)使用
- DPC回调运行在DISPATCH_LEVEL级别,不能做太耗时的事
- 定时器精度受系统时钟影响,默认精度约10-15毫秒
11.2 等待对象与超时处理
等待对象是驱动里另一个重要机制。你想想看,驱动经常要等硬件完成某个操作,总不能一直死循环轮询吧?这时候就需要等待对象。
Windows内核提供了KEVENT对象,配合KeWaitForSingleObject使用:
KEVENT g_Event;
// 初始化事件
KeInitializeEvent(&g_Event, NotificationEvent, FALSE);
// 等待事件,最多等5秒
NTSTATUS WaitForEvent()
{
LARGE_INTEGER timeout;
timeout.QuadPart = -5 * 1000 * 1000 * 10; // 5秒超时
NTSTATUS status = KeWaitForSingleObject(
&g_Event,
Executive, // 等待原因
KernelMode, // 等待模式
FALSE, // 是否可被Alert打断
&timeout // 超时时间
);
if (status == STATUS_TIMEOUT) {
DbgPrint("等待超时了!\n");
return status;
}
DbgPrint("事件被触发了\n");
return STATUS_SUCCESS;
}
// 在另一个线程中触发事件
VOID SignalEvent()
{
KeSetEvent(&g_Event, IO_NO_INCREMENT, FALSE);
}
我个人习惯把超时时间设得比实际需要长一点。为什么?因为硬件响应有时候会有波动。我曾经遇到过一款USB设备,正常响应是100毫秒,但偶尔会到200毫秒。如果超时设得太死,就会误报错误。
注意:
- 等待时如果传NULL作为超时参数,表示无限等待——这很危险,可能导致线程永远挂起
- 在DISPATCH_LEVEL及以上级别不能调用等待函数
- 等待对象分为NotificationEvent和SynchronizationEvent两种,区别在于是否自动重置
11.3 高精度定时
默认的内核定时器精度不够,大概10-15毫秒。如果你需要微秒级的精度,就得用高精度定时器。
Windows提供了KeQueryPerformanceCounter和KeQueryPerformanceFrequency来实现高精度计时:
// 高精度延时函数
VOID HighPrecisionDelay(ULONG microseconds)
{
LARGE_INTEGER freq, start, current;
LONGLONG elapsed, target;
KeQueryPerformanceFrequency(&freq);
KeQueryPerformanceCounter(&start);
// 计算目标计数
target = start.QuadPart + (freq.QuadPart * microseconds) / 1000000;
do {
KeQueryPerformanceCounter(¤t);
elapsed = current.QuadPart - start.QuadPart;
// 如果已经超时,退出
if (current.QuadPart >= target) {
break;
}
// 短延时,让出CPU
KeStallExecutionProcessor(1); // 忙等1微秒
} while (TRUE);
}
不过说实话,高精度定时在驱动里用得不多。为什么?因为大部分硬件不需要那么高的精度。而且高精度定时通常要用忙等(busy wait),会占用CPU。我一般只在调试和性能分析时用。
经验之谈:
如果你需要周期性执行任务,可以考虑用KeSetTimerEx,它支持周期性触发。但要注意,回调函数里不能做太重的操作。我一般只在回调里设置一个标志位,然后在另一个线程里处理实际工作。
11.4 定时器与等待的配合使用
实际项目中,定时器和等待经常要配合使用。比如:等待硬件响应,同时设置一个超时定时器作为保护。
下面是一个典型的使用模式:
// 等待硬件响应,带超时保护
NTSTATUS WaitForHardwareWithTimeout(
PVOID HardwareContext,
ULONG TimeoutMs
)
{
KEVENT event;
KTIMER timer;
KDPC dpc;
LARGE_INTEGER dueTime;
NTSTATUS status;
// 初始化
KeInitializeEvent(&event, NotificationEvent, FALSE);
KeInitializeTimer(&timer);
KeInitializeDpc(&dpc, TimerDpcRoutine, &event);
// 设置超时定时器
dueTime.QuadPart = -(LONGLONG)TimeoutMs * 1000 * 10;
KeSetTimer(&timer, dueTime, &dpc);
// 启动硬件操作
StartHardwareOperation(HardwareContext);
// 等待事件或超时
status = KeWaitForSingleObject(
&event,
Executive,
KernelMode,
FALSE,
NULL // 无限等待,由定时器控制超时
);
// 取消定时器(如果已经触发也没关系)
KeCancelTimer(&timer);
return status;
}
// 定时器回调中触发事件
VOID TimerDpcRoutine(
_In_ struct _KDPC *Dpc,
_In_opt_ PVOID DeferredContext,
_In_opt_ PVOID SystemArgument1,
_In_opt_ PVOID SystemArgument2
)
{
PKEVENT event = (PKEVENT)DeferredContext;
KeSetEvent(event, IO_NO_INCREMENT, FALSE);
}
这个模式我用了很多年,很稳定。核心思路是:用定时器作为“最后防线”,防止硬件卡死导致驱动无限等待。
11.5 知识体系总览
下面这张图总结了本章的核心内容,你可以对照着梳理思路:
11.6 避坑指南
最后,我总结几个常见的坑,都是我曾经踩过的:
- 定时器回调里做内存分配:回调运行在DISPATCH_LEVEL,不能调用分页内存分配函数。我一开始不知道,结果系统蓝屏了好几次。
- 忘记取消定时器:如果设备被意外移除,定时器可能还在运行。一定要在清理函数里调用
KeCancelTimer。 - 超时时间设得太短:硬件响应有波动,建议留出余量。我一般设成预期时间的1.5倍。
- 在DISPATCH_LEVEL调用等待函数:这是致命错误,会导致系统崩溃。等待函数只能在PASSIVE_LEVEL调用。
嗯,定时器和等待这部分内容就这些了。记住一个原则:能用等待对象就别用忙等,能用定时器就别用死循环。这样写出来的驱动才稳定可靠。