6、定时器与DPC:定时器对象、DPC例程、高精度定时、周期性任务、超时处理

定时器和DPC,说白了就是驱动世界里「闹钟」和「跑腿小弟」的组合。你想想看,驱动跑在内核态,不能像用户态程序那样随便sleep,更不能搞个死循环等时间——那系统就卡死了。所以Windows内核给了我们一套精巧的机制:定时器对象负责「到点叫醒我」,DPC例程负责「到点了赶紧去干活」。

我个人习惯把定时器+DPC比作「厨房定时器+服务员」:定时器一响,服务员(DPC)立刻放下手里的活去处理。嗯,这个比喻虽然糙,但道理不糙。

6.1 定时器对象:内核里的闹钟

Windows内核提供了KTIMER对象,配合KeSetTimerKeCancelTimer这些API来使用。我刚开始写驱动时,总觉得定时器就是设个时间、等回调就行了——结果踩了不少坑。

先看一个最基础的定时器初始化:

KTIMER myTimer;
KDPC myDpc;

// 初始化DPC
KeInitializeDpc(&myDpc, MyDpcRoutine, NULL);

// 初始化定时器
KeInitializeTimer(&myTimer);

// 设置定时器,5秒后触发
LARGE_INTEGER dueTime;
dueTime.QuadPart = -5 * 1000 * 1000 * 10; // 相对时间,单位100纳秒
KeSetTimer(&myTimer, dueTime, &myDpc);

这里有个细节:dueTime如果是负数,表示相对时间;正数表示绝对时间。我见过不少新手在这里搞反,结果定时器要么立刻触发,要么永远不触发。

核心要点:定时器对象本身不干活,它只负责「到点通知」。真正干活的是DPC例程。

6.2 DPC例程:延迟过程调用

DPC的全称是Deferred Procedure Call,延迟过程调用。为什么叫「延迟」?因为它在中断请求级别(IRQL)为DISPATCH_LEVEL上执行,比普通代码的PASSIVE_LEVEL高,但比真正的硬件中断低。

说白了,DPC就是「我不急,但你也别让我等太久」的活儿。我在项目中遇到过一个问题:在DPC里直接操作分页内存,结果系统蓝屏了。为什么?因为DPC运行在DISPATCH_LEVEL,这个级别下不能访问分页内存,也不能等待事件或获取普通锁。

DPC例程的签名长这样:

VOID MyDpcRoutine(
    _In_ struct _KDPC *Dpc,
    _In_opt_ PVOID DeferredContext,
    _In_opt_ PVOID SystemArgument1,
    _In_opt_ PVOID SystemArgument2
)
{
    // 在这里干活
    // 注意:不能碰分页内存!
    // 注意:不能等待!
    // 注意:不能获取普通锁!
}

警告:DPC例程里不能做的事情太多了。我曾经因为图方便在DPC里调了ExAllocatePoolWithTag,结果系统在压力测试下直接崩了。后来查文档才发现,DPC里分配内存必须用NonPagedPool,而且不能指定NPAGED_LOOKASIDE以外的标志。

6.3 高精度定时:别指望毫秒级精度

很多从嵌入式转过来的朋友,上来就问:「Windows内核定时器精度能到微秒吗?」我的回答是:理论可以,实际别抱太大希望。

Windows不是实时操作系统。定时器的精度受限于系统时钟中断频率。默认情况下,时钟中断间隔是15.6ms(64Hz)。你设一个1ms的定时器,它可能15ms后才触发。

想要更高精度?可以用KeSetTimerEx配合HighResolution定时器:

// 高精度定时器示例
KTIMER highResTimer;
KDPC highResDpc;

KeInitializeTimerEx(&highResTimer, SynchronizationTimer);
KeInitializeDpc(&highResDpc, HighResDpcRoutine, NULL);

LARGE_INTEGER dueTime;
dueTime.QuadPart = -100; // 10微秒?别太天真
KeSetTimerEx(&highResTimer, dueTime, 0, &highResDpc);

但说实话,即使用了KeInitializeTimerEx,实际精度也受很多因素影响:系统负载、电源管理策略、其他高优先级中断。我做过测试,在普通Windows 10上,高精度定时器的抖动(jitter)通常在50-200微秒之间。

我的建议:如果你需要微秒级精度的定时,考虑用KeQueryPerformanceCounter做忙等待,或者用硬件定时器(如HPET)。但忙等待会吃掉CPU,别在生产环境这么干。

6.4 周期性任务:定时器+DPC的组合拳

很多驱动需要周期性执行任务,比如每秒采集一次传感器数据、每100ms检查一次设备状态。实现周期性任务最直接的方式是:在DPC例程里重新设置定时器。

看代码:

VOID PeriodicDpcRoutine(
    _In_ struct _KDPC *Dpc,
    _In_opt_ PVOID DeferredContext,
    _In_opt_ PVOID SystemArgument1,
    _In_opt_ PVOID SystemArgument2
)
{
    // 干周期性的活
    DoPeriodicWork();

    // 重新设置定时器,实现周期性
    LARGE_INTEGER dueTime;
    dueTime.QuadPart = -100 * 1000 * 10; // 100ms后再次触发
    KeSetTimer(
        (KTIMER*)DeferredContext,
        dueTime,
        Dpc
    );
}

// 启动周期性任务
VOID StartPeriodicTask()
{
    KeInitializeTimer(&g_PeriodicTimer);
    KeInitializeDpc(&g_PeriodicDpc, PeriodicDpcRoutine, &g_PeriodicTimer);

    LARGE_INTEGER dueTime;
    dueTime.QuadPart = -100 * 1000 * 10; // 首次触发在100ms后
    KeSetTimer(&g_PeriodicTimer, dueTime, &g_PeriodicDpc);
}

这里有个坑:DPC例程里重新设置定时器时,要确保定时器对象没有被取消或删除。我曾经在驱动卸载时忘了取消定时器,结果DPC例程在一个已经释放的定时器对象上调用KeSetTimer——嗯,蓝屏了。

最佳实践:周期性任务一定要有「停止」的逻辑。在驱动卸载或设备移除时,先KeCancelTimer,再等所有待处理的DPC执行完毕(可以用KeFlushQueuedDpcs),最后才能释放资源。

6.5 超时处理:别让用户等太久

超时处理是驱动开发中最常见的场景之一。比如向硬件发送一个命令,如果500ms内没收到响应,就认为超时了。

我常用的模式是「标志位+定时器」:

// 超时处理结构体
typedef struct _TIMEOUT_CONTEXT {
    KTIMER Timer;
    KDPC Dpc;
    BOOLEAN TimedOut;
    KEVENT CompletionEvent;
} TIMEOUT_CONTEXT;

// 超时DPC例程
VOID TimeoutDpcRoutine(
    _In_ struct _KDPC *Dpc,
    _In_opt_ PVOID DeferredContext,
    _In_opt_ PVOID SystemArgument1,
    _In_opt_ PVOID SystemArgument2
)
{
    TIMEOUT_CONTEXT *ctx = (TIMEOUT_CONTEXT*)DeferredContext;
    ctx->TimedOut = TRUE;
    KeSetEvent(&ctx->CompletionEvent, IO_NO_INCREMENT, FALSE);
}

// 带超时的等待
NTSTATUS WaitWithTimeout(
    TIMEOUT_CONTEXT *ctx,
    LARGE_INTEGER timeout
)
{
    ctx->TimedOut = FALSE;
    KeInitializeEvent(&ctx->CompletionEvent, NotificationEvent, FALSE);
    KeInitializeDpc(&ctx->Dpc, TimeoutDpcRoutine, ctx);
    KeInitializeTimer(&ctx->Timer);

    KeSetTimer(&ctx->Timer, timeout, &ctx->Dpc);

    // 等待硬件响应或超时
    KeWaitForSingleObject(
        &ctx->CompletionEvent,
        Executive,
        KernelMode,
        FALSE,
        NULL
    );

    // 取消定时器(如果已经触发,取消也没事)
    KeCancelTimer(&ctx->Timer);

    return ctx->TimedOut ? STATUS_TIMEOUT : STATUS_SUCCESS;
}

这个模式我用了很多年,简单可靠。但要注意:KeWaitForSingleObject不能在DISPATCH_LEVEL以上调用。如果你在DPC里做超时等待,那就得换别的办法了。

我曾经踩过的坑:在超时DPC例程里直接调用KeWaitForSingleObject——死锁了。因为DPC运行在DISPATCH_LEVEL,而KeWaitForSingleObject需要降到PASSIVE_LEVEL。正确的做法是:DPC里只设置标志位和事件,让工作线程去处理后续逻辑。

6.6 知识体系总览

说了这么多,咱们用一张图把定时器和DPC的关系理清楚:

定时器与DPC 知识体系 定时器对象 KTIMER DPC例程 KDPC + 回调函数 高精度定时 KeInitializeTimerEx 触发 可选 周期性任务 DPC中重新KeSetTimer 超时处理 标志位 + 事件 + 定时器 组合使用 组合使用 ⚠ 注意事项 • DPC运行在DISPATCH_LEVEL,不能访问分页内存、不能等待、不能获取普通锁 • 高精度定时受系统负载影响,实际抖动约50-200μs • 卸载驱动前必须取消所有定时器并等待DPC完成

这张图把咱们今天讲的核心内容串起来了。定时器对象是起点,DPC例程是执行者,高精度定时是进阶玩法,周期性任务和超时处理是实际应用场景。每个模块之间都有联系,但各自又有独立的注意事项。

最后说一句:定时器和DPC是内核驱动开发的基础设施,几乎每个驱动都会用到。但越基础的东西,越容易在细节上出问题。我建议你写代码时,先把定时器取消和资源释放的逻辑想清楚,再动手写业务逻辑——顺序反了,后面全是坑。

一个小技巧:调试定时器相关问题时,可以用!timer!dpc这两个WinDbg命令查看系统中所有定时器和DPC的状态。我调试蓝屏问题时,经常先用这两个命令看看是不是定时器没取消导致的。


公众号:蓝海资料掘金营,微信deep3321