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提供了KeQueryPerformanceCounterKeQueryPerformanceFrequency来实现高精度计时:

// 高精度延时函数
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(&current);
        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 知识体系总览

下面这张图总结了本章的核心内容,你可以对照着梳理思路:

内核定时与等待机制总览 内核定时与等待机制 内核定时器 等待对象 高精度定时 具体内容 • KTIMER + KDPC 配合使用 • KeSetTimer / KeSetTimerEx • 相对时间 vs 绝对时间 • 默认精度约10-15ms 具体内容 • KEVENT 对象 • KeWaitForSingleObject • 超时处理 (STATUS_TIMEOUT) • Notification vs Synchronization 具体内容 • KeQueryPerformanceCounter • KeQueryPerformanceFrequency • 微秒级精度 • 忙等 (busy wait) 实践要点 • 定时器回调运行在DISPATCH_LEVEL,不能做耗时操作 • 等待时务必设置超时,避免无限等待 • 高精度定时尽量少用,只在必要时采用

11.6 避坑指南

最后,我总结几个常见的坑,都是我曾经踩过的:

  1. 定时器回调里做内存分配:回调运行在DISPATCH_LEVEL,不能调用分页内存分配函数。我一开始不知道,结果系统蓝屏了好几次。
  2. 忘记取消定时器:如果设备被意外移除,定时器可能还在运行。一定要在清理函数里调用KeCancelTimer
  3. 超时时间设得太短:硬件响应有波动,建议留出余量。我一般设成预期时间的1.5倍。
  4. 在DISPATCH_LEVEL调用等待函数:这是致命错误,会导致系统崩溃。等待函数只能在PASSIVE_LEVEL调用。

嗯,定时器和等待这部分内容就这些了。记住一个原则:能用等待对象就别用忙等,能用定时器就别用死循环。这样写出来的驱动才稳定可靠。