6、定时器与DPC:定时器对象、DPC例程、高精度定时、周期性任务、超时处理
定时器和DPC,说白了就是驱动世界里「闹钟」和「跑腿小弟」的组合。你想想看,驱动跑在内核态,不能像用户态程序那样随便sleep,更不能搞个死循环等时间——那系统就卡死了。所以Windows内核给了我们一套精巧的机制:定时器对象负责「到点叫醒我」,DPC例程负责「到点了赶紧去干活」。
我个人习惯把定时器+DPC比作「厨房定时器+服务员」:定时器一响,服务员(DPC)立刻放下手里的活去处理。嗯,这个比喻虽然糙,但道理不糙。
6.1 定时器对象:内核里的闹钟
Windows内核提供了KTIMER对象,配合KeSetTimer、KeCancelTimer这些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例程是执行者,高精度定时是进阶玩法,周期性任务和超时处理是实际应用场景。每个模块之间都有联系,但各自又有独立的注意事项。
最后说一句:定时器和DPC是内核驱动开发的基础设施,几乎每个驱动都会用到。但越基础的东西,越容易在细节上出问题。我建议你写代码时,先把定时器取消和资源释放的逻辑想清楚,再动手写业务逻辑——顺序反了,后面全是坑。
一个小技巧:调试定时器相关问题时,可以用!timer和!dpc这两个WinDbg命令查看系统中所有定时器和DPC的状态。我调试蓝屏问题时,经常先用这两个命令看看是不是定时器没取消导致的。
公众号:蓝海资料掘金营,微信deep3321