深入Windows内核:虚拟串口驱动中IRP请求的实战解析
你有没有遇到过这样的场景?一个老旧的工业控制软件,死死依赖于COM1、COM2这种“古董级”串口通信,而现代PC早已砍掉了物理RS-232接口。怎么办?总不能为了运行它再去买块PCI串口卡吧?
这时候,虚拟串口驱动(Virtual Serial Port Driver)就成了救星。它不靠硬件,纯靠软件模拟出一个“看起来和用起来都像真的一样”的串口设备。应用程序调用CreateFile("COM3")、ReadFile、WriteFile,一切照常,背后却是内存缓冲区、网络传输甚至另一台虚拟设备在默默工作。
但这一切是怎么实现的?核心答案就藏在Windows内核机制中的一个关键词——IRP(I/O Request Packet)。
今天,我们就来一次彻底的系统性拆解,从零开始讲清楚:在虚拟串口驱动里,IRP是如何被接收、处理并最终完成的。这不是简单的API搬运工教程,而是带你走进Windows I/O子系统的底层逻辑,理解每一个派遣函数背后的工程权衡与设计哲学。
什么是IRP?不只是数据包,更是Windows驱动的“心跳”
在用户态编程中,我们习惯同步思维:调用ReadFile()→ 等待 → 返回数据。但在内核世界,一切以异步为先。当你的程序执行ReadFile(hCom, buf, len, ...)时,Windows并不会立刻让驱动去“读”,而是做了一件事:
创建一个 IRP(I/O Request Packet),然后把它交给 I/O Manager,再由 I/O Manager 分发给对应的驱动。
你可以把IRP想象成一张工单。这张工单上写着:
- 要做什么事?(主功能码:IRP_MJ_READ)
- 参数是什么?(读多少字节?超时多久?)
- 数据放哪儿?(系统缓冲区地址)
- 完事后通知谁?(事件对象或APC)
驱动拿到这张“工单”后,可以选择:
- 马上办完,填好结果,交回去;
- 办不了现在,先存着,等条件满足了再办;
- 或者干脆拒绝:“这事我不接”。
这个过程完全异步,是WDM(Windows Driver Model)架构的灵魂所在。所有对设备的操作——打开、关闭、读写、控制、即插即用、电源管理——统统通过IRP传递。
所以,搞懂IRP,就是掌握了进入Windows驱动开发大门的钥匙。
虚拟串口怎么“装”得像真的?五大派遣函数全解析
要让一个虚拟设备骗过操作系统和应用层,关键在于:对每一种可能的I/O请求,都要有合理的响应。对于串口来说,最重要的五类IRP分别是:
IRP_MJ_CREATE—— “我要打开这个端口”IRP_MJ_CLOSE—— “我用完了,释放资源”IRP_MJ_READ—— “给我数据!”IRP_MJ_WRITE—— “我要发数据”IRP_MJ_DEVICE_CONTROL—— “设置波特率、查询状态”等复杂操作
下面我们逐个击破,看看每个派遣函数该怎么写,又有哪些坑要避开。
打开端口:IRP_MJ_CREATE的互斥控制艺术
当你调用CreateFile("\\\\.\\COM3")时,系统会生成一个IRP_MJ_CREATE请求发送给驱动。这不仅是“连接建立”的信号,更是资源分配的起点。
真实串口往往只允许一个进程独占访问(想想两个程序同时往同一个PLC发指令会怎样?)。我们的虚拟串口也得支持这种行为。
NTSTATUS DispatchCreate(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PDEVICE_EXTENSION devExt = (PDEVICE_EXTENSION)DeviceObject->DeviceExtension; NTSTATUS status = STATUS_SUCCESS; // 使用自旋锁保护共享状态 KLOCK_QUEUE_HANDLE lockHandle; KeAcquireInStackQueuedSpinLock(&devExt->StateLock, &lockHandle); if (devExt->IsOpen) { // 已被打开,检查是否允许多实例 if (!devExt->AllowShareAccess) { status = STATUS_ACCESS_DENIED; } else { devExt->OpenCount++; } } else { devExt->IsOpen = TRUE; devExt->OpenCount = 1; // 初始化默认串口参数 devExt->BaudRate = 9600; devExt->DataBits = 8; devExt->StopBits = 1; devExt->Parity = NOPARITY; } KeReleaseInStackQueuedSpinLock(&lockHandle); Irp->IoStatus.Status = status; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return status; }🔍重点解读:
- 我们用了KeAcquireInStackQueuedSpinLock来保护临界区,适合短时间操作。
-AllowShareAccess是设备扩展中的配置项,可由INF文件或注册表决定。
- 初次打开时初始化波特率等参数,确保每次重启后行为一致。
如果这里不做并发保护,多线程同时打开就会导致状态错乱——轻则返回错误,重则蓝屏。
关闭端口:别忘了清理“未完成的承诺”
CloseHandle()对应的是IRP_MJ_CLOSE。看似简单,实则责任重大。因为此时可能还有正在等待的读写操作没完成。
如果不妥善处理这些“悬挂”的IRP,它们将永远得不到回应,造成用户程序卡死、句柄泄漏,甚至引发系统资源耗尽。
NTSTATUS DispatchClose(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PDEVICE_EXTENSION devExt = (PDEVICE_EXTENSION)DeviceObject->DeviceExtension; // 取消所有挂起的读写操作 CancelAllPendingReads(devExt); CancelAllPendingWrites(devExt); // 停止定时器、DPC、工作线程等后台任务 if (devExt->TimeoutTimer) { KeCancelTimer(devExt->TimeoutTimer); } // 释放接收/发送缓冲区 ExFreePoolWithTag(devExt->RxBuffer, 'RXBF'); ExFreePoolWithTag(devExt->TxBuffer, 'TXBF'); // 标记为空闲 devExt->IsOpen = FALSE; Irp->IoStatus.Status = STATUS_SUCCESS; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; }💡经验之谈:
在CancelAllPendingReads()中,你需要遍历所有等待中的IRP,逐一调用IoSetCancelRoutine(irp, NULL)并设置其状态为STATUS_CANCELLED,最后调用IoCompleteRequest()完成它。这是防止“IRP泄漏”的黄金法则。
接收数据:读操作的两种模式如何统一处理?
IRP_MJ_READ是最复杂的派遣函数之一,因为它必须同时支持:
-阻塞读取:没数据就等着,直到来了或者超时;
-非阻塞读取:立即返回,有数据拿走,没数据也走人;
-部分读取:只要有一点数据,就先返回一部分;
-超时控制:遵循COMMTIMEOUTS设置的行为。
我们来看典型实现思路:
NTSTATUS DispatchRead(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PDEVICE_EXTENSION devExt = (PDEVICE_EXTENSION)DeviceObject->DeviceExtension; PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp); ULONG requestedLength = stack->Parameters.Read.Length; PUCHAR userBuffer = (PUCHAR)Irp->AssociatedIrp.SystemBuffer; ULONG bytesRead = 0; KLOCK_QUEUE_HANDLE lock; KeAcquireInStackQueuedSpinLock(&devExt->RxLock, &lock); // 1. 如果有数据,直接拷贝 while (bytesRead < requestedLength && RxQueueHasData(devExt)) { userBuffer[bytesRead++] = DequeueRxByte(devExt); } KeReleaseInStackQueuedSpinLock(&lock); if (bytesRead > 0) { // 成功读到一些数据,立即完成 Irp->IoStatus.Status = STATUS_SUCCESS; Irp->IoStatus.Information = bytesRead; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; } // 2. 没数据,看是不是允许等待 if (IsNonBlockingRead(stack)) { Irp->IoStatus.Status = STATUS_SUCCESS; // 注意:成功但信息为0 Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; } // 3. 阻塞模式:挂起IRP,等待数据到来 return PendReadIrp(Irp, devExt); // 返回 STATUS_PENDING }🧠关键设计点:
- 即使是非阻塞模式,也要返回STATUS_SUCCESS+Information=0,这是Win32 API期望的结果。
-PendReadIrp()会将IRP加入一个队列,并设置取消例程。当数据到达时(比如另一个线程调用了InjectDataToRxQueue()),驱动会唤醒该IRP并完成它。
- 超时由单独的DPC或定时器监控,避免无限等待。
发送数据:流控与延迟模拟的真实感营造
IRP_MJ_WRITE看似比读简单,其实不然。真正的串口通信要考虑:
- 波特率限制了最大吞吐量;
- CTS/DTR等硬件流控信号会影响能否发送;
- 写入操作本身也可以是异步的。
NTSTATUS DispatchWrite(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PDEVICE_EXTENSION devExt = (PDEVICE_EXTENSION)DeviceObject->DeviceExtension; PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp); ULONG length = stack->Parameters.Write.Length; PUCHAR buffer = (PUCHAR)Irp->AssociatedIrp.SystemBuffer; if (!devExt->CtsHolding) { // 硬件流控未就绪,排队等待 QueueWriteIrp(Irp); return STATUS_PENDING; } // 计算本次可写入的最大长度(受缓冲区限制) ULONG available = GetAvailableTxSpace(devExt); ULONG toSend = min(length, available); CopyMemoryToTxFifo(devExt, buffer, toSend); // 启动发送引擎(可能是DPC模拟波特率延迟) StartTransmitEngine(devExt); Irp->IoStatus.Status = STATUS_SUCCESS; Irp->IoStatus.Information = toSend; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; }⚙️进阶技巧:
你可以用一个定时DPC来逐字节“发射”数据,模拟真实串口按波特率逐位传输的过程。这样不仅更真实,还能自然地支持中断式的流量控制变化响应。
设备控制:IOCTL的大杂烩如何安全分发?
IRP_MJ_DEVICE_CONTROL是串口驱动中最复杂的部分,涵盖了几十种标准IOCTL(如IOCTL_SERIAL_SET_BAUD_RATE、IOCTL_SERIAL_CLR_DTR等),每一个都需要精准解析。
更重要的是:输入输出缓冲区的安全校验至关重要,否则极易引发内核缓冲区溢出。
NTSTATUS DispatchDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp); ULONG ctrlCode = stack->Parameters.DeviceIoControl.IoControlCode; PVOID inputBuffer = Irp->AssociatedIrp.SystemBuffer; SIZE_T inLen = stack->Parameters.DeviceIoControl.InputBufferLength; SIZE_T outLen = stack->Parameters.DeviceIoControl.OutputBufferLength; PDEVICE_EXTENSION devExt = (PDEVICE_EXTENSION)DeviceObject->DeviceExtension; NTSTATUS status = STATUS_SUCCESS; ULONG info = 0; switch (ctrlCode) { case IOCTL_SERIAL_SET_BAUD_RATE: { if (inLen < sizeof(ULONG)) { status = STATUS_BUFFER_TOO_SMALL; break; } ULONG newBaud = *(PULONG)inputBuffer; if (IsValidBaudRate(newBaud)) { devExt->BaudRate = newBaud; info = sizeof(ULONG); } else { status = STATUS_INVALID_PARAMETER; } break; } case IOCTL_SERIAL_GET_COMMSTATUS: { if (outLen < sizeof(SERIAL_COMMPROP)) { status = STATUS_BUFFER_TOO_SMALL; break; } PSERIAL_COMMPROP prop = (PSERIAL_COMMPROP)inputBuffer; FillCommProp(prop); // 填充当前状态 info = sizeof(SERIAL_COMMPROP); break; } default: status = STATUS_INVALID_DEVICE_REQUEST; break; } Irp->IoStatus.Status = status; Irp->IoStatus.Information = info; IoCompleteRequest(Irp, IO_NO_INCREMENT); return status; }✅安全守则:
- 所有输入长度必须严格检查;
- 输出结构体大小也要验证;
- 不信任任何来自用户态的数据指针,哪怕它是SystemBuffer;
- 使用静态分析工具(如Static Driver Verifier)检测潜在越界风险。
实战场景:构建一对相互连接的虚拟COM口
最常见的应用是虚拟COM对(Virtual COM Pair),比如 COM3 ↔ COM4 虚拟互联。两个独立驱动实例通过共享内存或命名FIFO相连。
App1 → WriteFile(COM3) ↓ Driver A (COM3) → 数据进入 Shared FIFO ← Driver B (COM4) ↓ App2 ← ReadFile(COM4)在这种架构下:
- 写入A的数据自动出现在B的接收队列中;
- B如果有挂起的读IRP,应立即被唤醒;
- 支持双向通信,形成全双工链路;
- 可扩展为“网络转串口”:一端本地虚拟串口,另一端TCP client/server。
这类设计广泛应用于:
- 工业网关协议调试;
- 医疗设备仿真测试;
- 物联网边缘计算桥接;
- 自动化测试框架中的设备模拟。
开发避坑指南:那些文档不会告诉你的秘密
❌ 常见错误1:忘记设置DO_BUFFERED_IO或DO_DIRECT_IO
如果你使用SystemBuffer,必须在设备对象中正确设置标志位,否则可能导致访问非法地址。
deviceObject->Flags |= DO_BUFFERED_IO; // 或 DO_DIRECT_IO(用于MDL映射)❌ 常见错误2:IRP未完成就返回
一旦你接手了一个IRP,就必须保证它最终被IoCompleteRequest()完成。否则用户程序永远卡住。
特别注意:当返回STATUS_PENDING时,必须先调用IoMarkIrpPending(Irp),否则系统会认为你没处理。
Irp->IoStatus.Status = STATUS_PENDING; IoMarkIrpPending(Irp); // 必须! return STATUS_PENDING;❌ 常见错误3:在Dispatch函数中做耗时操作
派遣函数应在短时间内返回。长时间操作(如等待网络响应)应交给DPC、工作项或系统线程处理,避免阻塞整个I/O路径。
结语:掌握IRP,才算真正入门Windows驱动
虚拟串口驱动看似小众,实则是学习WDM架构的绝佳切入点。它涵盖了IRP生命周期的所有典型场景:创建、销毁、数据流动、设备控制、异步等待、取消处理。
当你能熟练写出稳定可靠的派遣函数,并理解每一个IoCompleteRequest()背后的系统影响时,你就已经站在了高级驱动开发的门槛上。
下一步,你可以尝试:
- 迁移到KMDF(Kernel-Mode Driver Framework),用更简洁的模型替代手动IRP管理;
- 添加PNP 和 Power Management支持,让你的虚拟设备支持热插拔和休眠唤醒;
- 实现加密隧道模式,把两个虚拟串口之间的通信走TLS加密;
- 构建UI配置面板,允许用户动态增删虚拟端口、查看统计信息。
技术的深度,从来不是靠堆砌术语获得的。而是当你面对一个IRP时,能清晰地说出:“它从哪里来,要到哪里去,中间可能会发生什么。”
这才是真正的内核开发者思维。
如果你正在开发类似的驱动项目,或者遇到了IRP处理上的难题,欢迎在评论区交流探讨。我们一起把“黑盒”变成“透明”。