从零搞懂虚拟串口驱动:PDO与FDO到底在忙什么?
你有没有遇到过这种情况——手头没有硬件设备,但程序非要连个“COM口”才能跑?或者你想测试两个串口工具之间的通信,却发现电脑只有1个物理串口?这时候,虚拟串口软件就成了救命稻草。
像Virtual Serial Port Driver、com0com这类工具,能让你的系统凭空多出一对或多对 COM 端口,应用层读写数据就跟真的一样。可这背后的魔法是怎么实现的?尤其是那些文档里反复提到的PDO和FDO,它们到底是干什么的?为什么非得有这两个东西?
今天我们就来撕开这层面纱,不讲玄学,只说人话。带你一步步看懂 Windows 虚拟串口驱动中,PDO 和 FDO 是如何分工协作、假装自己是个真实串口的。
没有芯片也能“插上”串口?先让系统信了才行
要理解虚拟串口,得先明白一件事:Windows 对待所有设备都有一套“标准流程”。不管你是 U盘、显卡还是串口,只要想被系统识别,就得走一遍这套“户口登记”制度。
这个制度的核心就是——设备栈(Device Stack)。
想象一下,当你把一个 USB 转串口插进电脑:
- 总线驱动(USB Bus Driver)发现新设备 → 创建 PDO
- 系统查 INF 文件,找到对应的串口功能驱动 → 在 PDO 上创建 FDO
- 最终形成一个“自下而上”的处理链:应用程序请求 → FDO 处理 → PDO 底层交互
而在虚拟串口的世界里,我们根本没有硬件。那怎么办?造假也要造得合法。
我们手动模拟整个过程:自己当“总线”,自己枚举设备,自己创建 PDO;然后加载标准串口驱动或自定义驱动,在上面挂 FDO。这样一来,操作系统就会认为:“哦,这儿真有个串口。” 第三方软件也就毫无察觉地照常工作了。
所以,PDO 是‘身份证明’,FDO 是‘干活的人’。两者缺一不可。
PDO:我不是物理的,但我很“正式”
别被名字骗了,“Physical”其实是历史遗留
PDO 全称是Physical Device Object,听着像是对应真实硬件。但其实它只是一个逻辑容器,用来告诉操作系统:“我这里有个设备,请按规矩办事。”
在虚拟串口场景下,我们的驱动本质上是一个“虚拟总线驱动”或“枚举驱动”,它的首要任务就是冒充总线,主动向 PnP 管理器报告:“我发现了一个新设备!”
怎么报?靠三板斧:
- 调用
IoCreateDevice创建设备对象 - 设置硬件 ID(Hardware ID),比如
Root\VirtualSerial - 上报给即插即用管理器,触发设备安装
一旦这一步成功,系统就会去注册表和 INF 文件里找谁来管这个设备——通常我们会引导它使用标准的serial.sys,或者是你自己写的兼容驱动。
📌 关键点:PDO 不处理任何读写操作,也不暴露 COM 口名字。它只负责“存在感”。
PDO 的四大职责
| 职责 | 说明 |
|---|---|
| ✅ 提供硬件标识 | 包括 HardwareID、CompatibleID,决定哪个驱动会被加载 |
| ✅ 支持即插即用 | 处理启动、停止、移除等 IRP,支持动态增删设备 |
| ✅ 声明虚拟资源 | 即使没有真正的 I/O 地址或中断,也可以声明“假资源”满足传统驱动需求 |
| ✅ 实例唯一性 | 每个虚拟设备必须有唯一的 Instance Path,避免冲突 |
你看,虽然没硬件,但我们该交的“材料”一样不少,完全符合 Windows 设备模型规范。
创建一个虚拟 PDO 长什么样?
NTSTATUS CreateVirtualSerialPDO(PDRIVER_OBJECT DriverObject) { PDEVICE_OBJECT pdo; NTSTATUS status; status = IoCreateDevice( DriverObject, sizeof(DEVICE_EXTENSION), NULL, // 不绑定名称 FILE_DEVICE_SERIAL_PORT, FILE_DEVICE_SECURE_OPEN, FALSE, &pdo ); if (!NT_SUCCESS(status)) return status; pdo->Flags |= DO_BUS_ENUMERATED_DEVICE; // 标记为总线枚举设备 PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION)pdo->DeviceExtension; pdx->IsPDO = TRUE; pdx->NextLowerDriver = NULL; g_PdoList[g_PdoCount++] = pdo; // 向PnP管理器报告新设备 ReportDeviceToPnpManager(pdo, L"VirtualSerial\\COM5"); return STATUS_SUCCESS; }注意几个细节:
- 第三个参数传NULL:PDO 自身不公开设备名。
-DO_BUS_ENUMERATED_DEVICE:告诉系统这是由总线枚举出来的,不是手工创建的。
-ReportDeviceToPnpManager():这是关键!你需要调用IoInvalidateDeviceRelations()或发送IRP_MN_QUERY_DEVICE_RELATIONS来触发系统重新扫描设备关系。
否则,系统根本不知道你“发现”了新设备。
FDO:真正的串口行为在这里上演
如果说 PDO 是“身份证”,那 FDO 就是“真人”。
FDO(Functional Device Object)是由功能驱动创建的对象,位于设备栈的上层。它是所有 I/O 请求的第一站,也是最终完成者。
当系统决定用某个驱动来管理你的虚拟串口时(比如serial.sys或你自己写的.sys),它会回调驱动中的AddDevice函数,并把 PDO 当作参数传进去。这时,你就该动手建 FDO 了。
AddDevice:驱动的“出生仪式”
NTSTATUS VirtualSerial_AddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT PhysicalDeviceObject) { PDEVICE_OBJECT fdo; NTSTATUS status; PDEVICE_EXTENSION fdx; status = IoCreateDevice( DriverObject, sizeof(DEVICE_EXTENSION), NULL, FILE_DEVICE_SERIAL_PORT, 0, FALSE, &fdo ); if (!NT_SUCCESS(status)) return status; fdx = (PDEVICE_EXTENSION)fdo->DeviceExtension; fdx->IsFDO = TRUE; fdx->AttachedToDeviceObject = PhysicalDeviceObject; // 把FDO挂在PDO之上 fdx->NextLowerDriver = IoAttachDeviceToDeviceStack(fdo, PhysicalDeviceObject); if (!fdx->NextLowerDriver) { IoDeleteDevice(fdo); return STATUS_NO_SUCH_DEVICE; } fdo->Flags &= ~DO_DEVICE_INITIALIZING; fdo->Flags |= DO_BUFFERED_IO; // 注册各种派遣函数 DriverObject->MajorFunction[IRP_MJ_READ] = ReadDispatch; DriverObject->MajorFunction[IRP_MJ_WRITE] = WriteDispatch; DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = IoControlDispatch; DriverObject->MajorFunction[IRP_MJ_PNP] = PnpDispatch; DriverObject->MajorFunction[IRP_MJ_CLOSE] = CloseDispatch; return STATUS_SUCCESS; }重点来了:
-IoAttachDeviceToDeviceStack()是构建设备栈的关键。它返回的是下层驱动的起点,也就是你要转发 IRP 的目标。
- 所有的 I/O 请求都会先到达 FDO。如果你不能处理(比如电源管理命令),就应该通过IoCallDriver(NextLowerDriver, Irp)转发下去。
- 如果你用了serial.sys作为功能驱动,那你甚至不需要写太多逻辑——它已经帮你实现了完整的串口语义。
但如果你想做双向转发(如 COM5 ↔ COM6),那就得自己接管Read/Write派遣函数。
数据是怎么流动的?一次写操作拆解
假设你在 Python 里写了这么一行代码:
import serial ser = serial.Serial('COM5', 9600) ser.write(b'Hello')背后发生了什么?
CreateFile("COM5")→ I/O Manager 查符号链接\DosDevices\COM5→ 定位到设备对象- 生成
IRP_MJ_WRITE请求,目标是设备栈顶端的FDO - FDO 的
WriteDispatch被调用:
- 检查波特率、流控状态
- 将数据放入发送缓冲区
- 模拟“发送完成”中断(立即或延时) - 回复 IRP,用户态收到
WriteFile成功 - 若配对端是另一个虚拟串口,则触发其接收事件,唤醒读取线程
整个过程中,PDO 基本“躺平”,只在设备启动时参与资源配置。真正忙前忙后的,是 FDO。
如何让别人觉得这就是个真串口?
要想让 LabVIEW、Modbus 工具、PLC 编程软件这些“老古董”也能正常使用你的虚拟串口,光能收发数据还不够,还得演全套。
1. 符号链接不能少
必须创建\DosDevices\COMx链接,否则应用打不开。
UNICODE_STRING symLinkName; RtlInitUnicodeString(&symLinkName, L"\\DosDevices\\COM5"); IoSetSymbolicLink(&symLinkName, &deviceName); // deviceName: \Device\VirtualSerial52. 硬件 ID 得像样
INF 文件中声明:
[MyVirtualDevice.HardwareIds] %ClassName%=MyDevice, VirtualSerial\COM5这样系统才知道要用哪个驱动加载。
3. 支持标准 IOCTL
很多程序会查询串口状态,例如:
EscapeCommFunction(hCom, SETRTS); GetCommModemStatus(hCom, &status);这些底层调用最终变成IOCTL_SERIAL_*类型的 IRP。如果你不做响应,程序可能直接报错退出。
解决方案:
- 自己实现部分常用 IOCTL(如 GET_COMMSTATUS、SET_XOFF)
- 或者直接基于serial.sys构建堆叠驱动,让它替你处理
4. 多实例隔离要做好
你可以同时创建 COM5、COM6、COM7……每个都要有自己的:
- 设备扩展(保存波特率、数据位等配置)
- 接收/发送缓冲区
- 状态标志(是否打开、DTR/RTS 状态)
建议用全局数组或哈希表管理:
PDEVICE_EXTENSION g_DeviceArray[MAX_PORTS] = {0};并通过设备名或扩展字段区分不同实例。
开发中常见的坑与避坑指南
❌ 坑1:忘了清理设备栈导致蓝屏
卸载驱动时,必须严格按照顺序:
1. 删除符号链接
2. 分离设备栈(IoDetachDevice())
3. 删除 FDO 和 PDO
4. 清空全局列表
否则下次加载时可能访问已释放内存,直接 BSOD。
❌ 坑2:IRP 没正确完成或转发
常见错误:
- 收到 IRP 后忘记调用IoCompleteRequest()
- 转发前没设置 completion routine 导致死锁
- 错误地完成了不属于自己的 IRP
记住原则:谁起始,谁终结。如果转发了,就不要擅自 Complete。
❌ 坑3:安全描述符缺失,权限失控
默认情况下,设备对象允许 SYSTEM 和 Administrators 访问。如果你希望普通用户也能打开串口,需要显式设置 SD:
SECURITY_DESCRIPTOR sd; SeInitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION); SeSetSecurityDescriptorDacl(&sd, TRUE, NULL, FALSE); IoCreateDeviceSecure(..., &sd, ...);否则CreateFile可能返回ACCESS_DENIED。
结语:从理解 PDO/FDO 开始,通往驱动开发的大门
虚拟串口看似简单,实则是一扇通向 Windows 内核世界的大门。通过亲手构建 PDO 与 FDO,你不仅学会了设备栈的组织方式,还掌握了以下核心能力:
- 如何参与即插即用机制
- 如何处理 I/O 请求包(IRP)
- 如何与用户态程序交互
- 如何模拟硬件行为而不依赖真实设备
这些技能不仅能用来做虚拟串口,还能延伸到过滤驱动、文件系统监控、调试代理、USB 模拟等各种高级场景。
所以别再说“驱动开发太难入门”了。从一个虚拟 COM 口开始,可能是最接地气的选择。
如果你正在尝试写自己的虚拟串口驱动,欢迎留言交流踩过的坑。下一期我们可以聊聊:如何实现一对可互连的虚拟串口(类似 com0com),并支持 Wireshark 抓包分析。