呼和浩特市网站建设_网站建设公司_SEO优化_seo优化
2026/1/1 6:45:10 网站建设 项目流程

从零搞懂虚拟串口驱动:PDO与FDO到底在忙什么?

你有没有遇到过这种情况——手头没有硬件设备,但程序非要连个“COM口”才能跑?或者你想测试两个串口工具之间的通信,却发现电脑只有1个物理串口?这时候,虚拟串口软件就成了救命稻草。

Virtual Serial Port Drivercom0com这类工具,能让你的系统凭空多出一对或多对 COM 端口,应用层读写数据就跟真的一样。可这背后的魔法是怎么实现的?尤其是那些文档里反复提到的PDOFDO,它们到底是干什么的?为什么非得有这两个东西?

今天我们就来撕开这层面纱,不讲玄学,只说人话。带你一步步看懂 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 管理器报告:“我发现了一个新设备!”

怎么报?靠三板斧:

  1. 调用IoCreateDevice创建设备对象
  2. 设置硬件 ID(Hardware ID),比如Root\VirtualSerial
  3. 上报给即插即用管理器,触发设备安装

一旦这一步成功,系统就会去注册表和 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')

背后发生了什么?

  1. CreateFile("COM5")→ I/O Manager 查符号链接\DosDevices\COM5→ 定位到设备对象
  2. 生成IRP_MJ_WRITE请求,目标是设备栈顶端的FDO
  3. FDO 的WriteDispatch被调用:
    - 检查波特率、流控状态
    - 将数据放入发送缓冲区
    - 模拟“发送完成”中断(立即或延时)
  4. 回复 IRP,用户态收到WriteFile成功
  5. 若配对端是另一个虚拟串口,则触发其接收事件,唤醒读取线程

整个过程中,PDO 基本“躺平”,只在设备启动时参与资源配置。真正忙前忙后的,是 FDO。


如何让别人觉得这就是个真串口?

要想让 LabVIEW、Modbus 工具、PLC 编程软件这些“老古董”也能正常使用你的虚拟串口,光能收发数据还不够,还得演全套

1. 符号链接不能少

必须创建\DosDevices\COMx链接,否则应用打不开。

UNICODE_STRING symLinkName; RtlInitUnicodeString(&symLinkName, L"\\DosDevices\\COM5"); IoSetSymbolicLink(&symLinkName, &deviceName); // deviceName: \Device\VirtualSerial5

2. 硬件 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 抓包分析。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询