通信机制:DeviceIoControl、共享内存、事件通知、用户态与内核态通信
驱动开发里最绕不开的一个话题,就是用户态和内核态怎么“说话”。说白了,你的应用程序跑在Ring3,驱动跑在Ring0,中间隔着一堵墙。怎么把数据传过去?怎么把命令递进去?怎么让两边同步?这就是我们今天要聊的核心。
我个人习惯把通信机制分成三类:控制类通信、批量数据通信、事件通知类通信。这三类各有各的适用场景,也各有各的坑。咱们一个一个说。
1. DeviceIoControl:最常用的控制通道
DeviceIoControl是Windows提供的一套IO控制码机制。你想想看,应用程序发一个命令给驱动,驱动执行完再返回结果——这不就是最典型的“请求-响应”模式吗?
它的核心API长这样:
// 用户态调用
DeviceIoControl(
hDevice, // 设备句柄
IOCTL_MY_OPERATION, // 控制码
lpInBuffer, // 输入缓冲区
nInBufferSize, // 输入大小
lpOutBuffer, // 输出缓冲区
nOutBufferSize, // 输出大小
&lpBytesReturned, // 实际返回字节数
NULL // 重叠结构,同步模式传NULL
);
// 内核态处理
NTSTATUS MyDispatch(
PDEVICE_OBJECT DeviceObject,
PIRP Irp
) {
PIO_STACK_LOCATION irpStack = IoGetCurrentIrpStackLocation(Irp);
ULONG ioctlCode = irpStack->Parameters.DeviceIoControl.IoControlCode;
switch(ioctlCode) {
case IOCTL_MY_OPERATION:
// 处理逻辑
break;
}
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = bytesWritten;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
我的经验:控制码的定义一定要用CTL_CODE宏,别自己瞎编数字。我曾经见过有人直接写0x80002001这种魔数,结果跟系统预留的冲突了,蓝屏查了两天。
这里有个关键点:输入输出缓冲区怎么映射?Windows提供了三种方式:
- METHOD_BUFFERED:系统帮你拷贝数据,最安全,适合小数据量
- METHOD_IN_DIRECT / METHOD_OUT_DIRECT:用MDL映射,适合大数据量
- METHOD_NEITHER:直接传用户态指针,最快但也最危险
我个人建议:新手无脑用METHOD_BUFFERED。等你对内存管理有把握了,再考虑其他方式。
2. 共享内存:大数据量的高速公路
DeviceIoControl虽然好用,但每次调用都要进出内核,数据还要拷贝一次。如果你要传视频帧、音频流这种大数据量,性能就扛不住了。
这时候就需要共享内存。说白了,就是一块物理内存,用户态和内核态都能访问。
实现方式有两种:
| 方式 | 用户态创建 | 内核态创建 |
|---|---|---|
| 原理 | CreateFileMapping + MapViewOfFile | ZwCreateSection + MmMapViewInSystemSpace |
| 谁管理生命周期 | 应用程序 | 驱动程序 |
| 典型场景 | 视频采集、数据采集卡 | 内核需要主动推送数据的场景 |
我比较常用的是内核态创建Section的方式。流程大概是:
// 内核态创建共享内存
HANDLE hSection;
ZwCreateSection(&hSection,
SECTION_ALL_ACCESS,
NULL,
§ionSize,
PAGE_READWRITE,
SEC_COMMIT,
NULL);
// 映射到内核地址空间
PVOID kernelAddr;
MmMapViewInSystemSpace(hSection,
PsGetCurrentProcess(),
&kernelAddr);
// 通过DeviceIoControl把句柄传给用户态
// 用户态用ZwOpenSection + MapViewOfFile映射到用户空间
注意:共享内存最大的坑是同步问题。两边同时读写同一块内存,不加锁的话数据就乱套了。我一般配合事件通知来用——写完了发事件,读完了回事件。
3. 事件通知:让驱动主动找上门
前面两种都是“用户态主动发起”。但有些场景是驱动需要主动通知用户态——比如设备拔插、数据到达、错误发生。
事件通知的机制其实不复杂:
- 用户态创建一个Event对象,把句柄传给驱动
- 驱动保存这个句柄,等条件满足时触发
- 用户态用WaitForSingleObject等待事件
代码实现大概是:
// 用户态
HANDLE hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
// 通过DeviceIoControl把hEvent传给驱动
// 等待事件
WaitForSingleObject(hEvent, INFINITE);
// 内核态
// 在Dispatch中保存事件对象
ObReferenceObjectByHandle(
irpStack->Parameters.DeviceIoControl.Type3InputBuffer,
EVENT_MODIFY_STATE,
*ExEventObjectType,
KernelMode,
&pEventObj,
NULL
);
// 需要通知时触发
KeSetEvent(pEventObj, IO_NO_INCREMENT, FALSE);
核心要点:事件通知只传递“信号”,不传递数据。数据怎么拿?要么在事件触发前去共享内存里读,要么在事件触发后用DeviceIoControl去取。我习惯的做法是“事件通知 + 共享内存”组合拳——事件来了,去共享内存里拿最新数据。
4. 三种机制怎么选?一张图说清楚
我把这三种机制的核心逻辑画了张图,你看完应该就明白了:
从图上你能看出来:DeviceIoControl走控制流,共享内存走数据流,事件通知走信号流。三者各司其职,配合使用才是王道。
5. 避坑指南
做驱动通信这么多年,踩过的坑不少。挑几个典型的说说:
坑一:缓冲区大小不匹配。 用户态传了100字节,内核态只读了50字节,剩下的数据就丢了。我建议内核态永远检查输入缓冲区长度,不够的直接返回STATUS_BUFFER_TOO_SMALL。
坑二:事件对象引用计数。 用户态把事件句柄传给驱动后,如果用户态进程崩溃了,驱动里的事件对象就变成了悬空指针。一定要用ObReferenceObjectByHandle增加引用计数,并在驱动卸载时释放。
坑三:共享内存的同步。 我曾经有个项目,驱动每毫秒写一次共享内存,用户态每10毫秒读一次。不加锁的情况下,用户态读到一半数据被驱动覆盖了,画面全是撕裂的。后来加了自旋锁才解决。
6. 实际项目中的组合拳
我参与过一个高速数据采集项目,设备每秒产生200MB数据。当时的设计是这样的:
- DeviceIoControl:用来下发配置参数、启动/停止采集
- 共享内存:环形缓冲区,驱动往里写,用户态往外读
- 事件通知:每写完一帧数据,驱动触发事件,用户态收到信号后去共享内存取数据
这个方案跑了三年没出过问题。说白了,没有银弹,选对工具比会用工具更重要。
我的建议:刚开始做驱动通信,先用DeviceIoControl把功能跑通。性能不够了,再上共享内存。需要主动通知了,再加事件。别一上来就搞大而全的方案,容易把自己绕进去。
公众号:蓝海资料掘金营,微信deep3321