手把手教你开发Windows下的USB转串口驱动:从零到上线的实战指南
你有没有遇到过这样的场景?手头一块基于STM32或ESP32-S2的开发板,想通过USB连上PC调试,却发现系统识别不了COM口;又或者你的工业设备需要接入老旧PLC,但主机只有USB接口——这时候,“USB转串口”就成了绕不开的技术桥头堡。
而在Windows平台上做这件事,并不像Linux那样“插上就能用”。你需要深入内核、理解PnP机制、搞定驱动签名,甚至和URB(USB请求块)这种底层结构打交道。听起来复杂?别急,本文就带你一步步走完这条“硬核之路”,让你不仅能写出能跑的驱动,还能搞懂它为什么能跑。
为什么我们还需要自己写USB转串口驱动?
你说,不是有FT232、CH340这些现成芯片吗?确实,它们自带官方驱动,插上就出COM口,省心省力。但现实往往更复杂:
- 你想用MCU自带USB实现虚拟串口,节省BOM成本;
- 你要做定制化功能,比如在传输数据的同时进行固件升级;
- 你的产品属于军工、医疗等特殊领域,不允许使用第三方驱动;
- 你希望完全掌控通信流程,避免黑盒带来的安全隐患。
这时候,自研驱动就成了必选项。而Windows作为企业级主力操作系统,其驱动生态既严格又成熟——只要迈过门槛,回报极高。
USB转串口的本质:让电脑“以为”接了个老式串口
先抛开代码和协议,我们来想想:什么是“USB转串口”?
简单说,就是让一个USB设备,在Windows眼里看起来像一个标准的COM端口。应用程序调用CreateFile("\\\\.\\COM4")、ReadFile、SetCommState时,根本不知道背后是USB线缆而不是RS-232电缆。
要实现这一点,核心在于三层协同:
[用户程序] → Win32串口API ↓ [系统层] → Serial API (kernel32.dll) + serial.sys / usbser.sys ↓ [驱动层] → 自定义KMDF驱动 或 usbser.sys + INF配置 ↓ [硬件层] → MCU运行CDC ACM固件,收发UART帧也就是说,驱动的任务,就是把上层对“串口”的操作,翻译成对“USB设备”的控制与数据传输。
技术选型第一关:用WDM还是KMDF?建议新手直接上KMDF
早年写Windows驱动,基本靠WDM(Windows Driver Model),纯手工管理IRP、即插即用状态机、电源策略……稍有不慎就会蓝屏。
现在?微软早就推荐使用KMDF(Kernel-Mode Driver Framework)——它是WDF框架的一部分,专为功能性设备驱动设计,极大简化了开发难度。
KMDF到底强在哪?
| 对比项 | WDM | KMDF |
|---|---|---|
| 对象生命周期 | 手动分配/释放 | 引用计数自动管理 |
| PnP处理 | 需手动分发IRP_MN_* | 注册回调函数即可 |
| I/O队列 | 自行实现 | 内建顺序/并行队列支持 |
| 同步机制 | KeWaitForSingleObject等原语 | 框架自动处理并发访问 |
| 调试体验 | DbgPrint为主 | 支持WPP跟踪 + WinDbg源码级调试 |
一句话总结:KMDF让你专注业务逻辑,而不是和内核调度打架。
所以,如果你不是为了研究旧架构,直接上KMDF是最优解。
CDC ACM:免驱之王,首选协议
既然目标是模拟串口,那就不能随便定义协议。好在USB-IF组织早已制定了标准类规范——CDC ACM(Communication Device Class - Abstract Control Model)。
它的最大优势是什么?免驱!
从Windows XP SP2开始,系统内置usbser.sys驱动,只要你的设备符合CDC ACM规范,插入后会自动分配COM端口号,无需安装任何额外驱动。
CDC ACM设备长什么样?
一个典型的CDC ACM设备包含两个接口:
Control Interface(类码0x02)
- 用于发送控制命令:设置波特率、DTR/RTS信号、流控等
- 端点0默认用于控制传输(Control Endpoint)Data Interface(类码0x0A)
- 用于实际数据收发
- 包含一对批量端点:BULK IN 和 BULK OUT
当PC上的程序调用SetCommState(hCom, &dcb)设置波特率为115200时,系统会向Control Endpoint发送一条SET_LINE_CODING类请求,里面包含了完整的串口参数。
设备端收到后,解析并配置内部UART模块,完成映射。
关键参数结构体:_USB_CDC_LINE_CODING
这是主机传下来的“串口配置单”,长这样:
typedef struct _USB_CDC_LINE_CODING { ULONG dwDTERate; // 波特率,如115200 UCHAR bCharFormat; // 停止位:0=1, 1=1.5, 2=2 UCHAR bParityType; // 校验:0=无,1=奇,2=偶,3=标记,4=空格 UCHAR bDataBits; // 数据位:5~8 } USB_CDC_LINE_CODING, *PUSB_CDC_LINE_CODING;你的驱动必须正确响应GET_LINE_CODING和SET_LINE_CODING请求,否则上位机无法配置串口。
实战第一步:搭建开发环境
工欲善其事,必先利其器。以下是必备工具链:
- Visual Studio(建议2022 Community版)
- WDK(Windows Driver Kit)—— 提供头文件、库、编译规则
- SDK(Windows SDK)
- WinDbg Preview—— 调试神器
- Inf2Cat / SignTool—— 驱动签名工具
- (可选)USB协议分析仪(如Total Phase Beagle USB 480)
安装时注意选择“Driver Development”工作负载,VS会自动集成WDK模板。
编写第一个KMDF驱动:从DriverEntry开始
所有KMDF驱动都始于DriverEntry函数。它相当于C程序的main(),是驱动加载时的第一个入口。
// DriverEntry.c #include <ntddk.h> #include <wdf.h> WDFDEVICE g_Device = NULL; WDFDRIVER g_WdfDriver = NULL; NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { WDF_DRIVER_CONFIG config; NTSTATUS status; // 初始化驱动配置 WDF_DRIVER_CONFIG_INIT(&config, EvtDeviceAdd); config.EvtDriverUnload = EvtDriverUnload; // 创建驱动对象 status = WdfDriverCreate(DriverObject, RegistryPath, WDF_NO_OBJECT_ATTRIBUTES, &config, &g_WdfDriver); if (!NT_SUCCESS(status)) { KdPrint(("WdfDriverCreate failed: 0x%x\n", status)); } return status; }这里的关键是注册EvtDeviceAdd回调,当系统检测到匹配设备时,这个函数会被调用,用来创建设备对象。
设备初始化:PrepareHardware中完成USB枚举
接下来是在EvtDevicePrepareHardware中完成真正的硬件准备动作。
NTSTATUS OnPrepareHardware(WDFDEVICE hDevice, WDFCMRESLIST ResourcesRaw, WDFCMRESLIST ResourcesTranslated) { WDF_USB_DEVICE_SELECT_CONFIG_PARAMS configParams; USBD_INTERFACE_LIST_ENTRY interfaceListEntry; PURB urb; NTSTATUS status; // 创建USB设备句柄 status = WdfUsbTargetDeviceCreateWithParameters( hDevice, WDF_NO_OBJECT_ATTRIBUTES, NULL, &m_hUsbDevice); if (!NT_SUCCESS(status)) return status; // 初始化多接口配置参数(Control + Data) WDF_USB_DEVICE_SELECT_CONFIG_PARAMS_INIT_MULTIPLE_INTERFACES( &configParams, 2, &interfaceListEntry); // 分配URB用于SELECT_CONFIGURATION urb = (PURB)ExAllocatePool2(NonPagedPoolNx, sizeof(struct _URB_SELECT_CONFIGURATION), 'urb'); if (!urb) return STATUS_INSUFFICIENT_RESOURCES; UsbBuildSelectConfigurationRequest( urb, sizeof(struct _URB_SELECT_CONFIGURATION), m_ConfigDescriptor, interfaceListEntry.Interfaces); // 同步提交URB status = WdfUsbTargetDeviceSendUrbSynchronously(m_hUsbDevice, NULL, NULL, urb); ExFreePool(urb); if (!NT_SUCCESS(status)) { KdPrint(("Failed to select configuration: 0x%x\n", status)); return status; } // 获取批量读写管道 m_BulkReadPipe = WdfUsbTargetPipeWdmGetPipeHandle( WdfUsbInterfaceGetConfiguredPipe(m_UsbInterface, 1, WdfUsbTargetPipeTypeBulk)); m_BulkWritePipe = WdfUsbTargetPipeWdmGetPipeHandle( WdfUsbInterfaceGetConfiguredPipe(m_UsbInterface, 0, WdfUsbTargetPipeTypeBulk)); return STATUS_SUCCESS; }这段代码完成了:
- 枚举USB设备
- 选择包含两个接口的配置(CDC ACM典型结构)
- 获取BULK IN/OUT管道句柄,为后续读写做准备
如何暴露COM端口?IoRegisterDeviceInterface是关键
很多人写了驱动却看不到COM口,问题就出在这里:没有注册设备接口!
你需要调用IoRegisterDeviceInterface并启用它:
status = IoRegisterDeviceInterface( physicalDeviceObject, &GUID_DEVINTERFACE_COMPORT, // 表示这是一个串口设备 NULL, &symbolicLinkName); if (NT_SUCCESS(status)) { status = IoSetDeviceInterfaceState(&symbolicLinkName, TRUE); // 启用接口 }一旦启用,系统会在设备管理器中显示“USB Serial Port (COMx)”,并且用户可以用标准API打开它。
数据读写怎么搞?拆成URB发出去!
应用程序调用WriteFile()时,系统会生成一个IRP_MJ_WRITE请求,由你的驱动处理。
由于USB批量传输有最大包大小限制(通常64字节全速,512字节高速),大块数据必须拆分成多个URB。
void HandleWriteRequest(WDFQUEUE queue, WDFREQUEST request, size_t length) { PUCHAR buffer; WDFMEMORY mem; WDFUSBPIPE pipe = m_BulkWritePipe; // 获取用户缓冲区 WdfRequestRetrieveOutputMemory(request, &mem); WdfMemoryGetBuffer(mem, NULL); // 提交URB写入 WdfUsbTargetPipeFormatRequestForWrite(pipe, request, mem, NULL, NULL); if (WdfRequestSend(request, WdfUsbTargetPipeGetIoTarget(pipe), NULL)) { return; // 异步等待完成 } else { WdfRequestCompleteWithInformation(request, STATUS_IO_ERROR, 0); } }读操作类似,通常采用“预提多个URB”的方式保持接收流畅,避免丢包。
常见坑点与避坑指南
❌ 问题1:插上没反应,设备管理器里是“未知设备”
- 可能原因:VID/PID未被识别,INF文件没写对
- 解决方法:
inf [DeviceList.NTamd64] "My Custom UART" = MYDRIVER_Device, USB\VID_1234&PID_5678
❌ 问题2:能识别但打不开COM口,提示“拒绝访问”
- 原因:权限不足或已有进程占用
- 检查点:确保没有其他串口工具(如PuTTY)开着
❌ 问题3:波特率设置无效
- 排查步骤:
1. 用USB协议分析仪抓包,看是否收到SET_LINE_CODING
2. 检查URB类型是否为URB_FUNCTION_CLASS_INTERFACE
3. 确认Request Code为0x20(SET_LINE_CODING)
❌ 问题4:读取延迟高,数据“憋”着不出来
- 优化方案:
- 使用中断IN端点触发唤醒
- 设置短包自动提交:
WdfUsbTargetPipeSetNoMaximumPacketSizeCheck(TRUE) - 采用Completion Routine主动重提下一个读请求
驱动签名:上线前的最后一道坎
从Windows 10 v1607起,x64系统强制要求驱动签名。否则即使测试模式也不让加载。
解决方案两条路:
测试签名模式(开发阶段)
bash # 启用测试签名 bcdedit /set testsigning on
然后用Test Certificate签名驱动(WDK自带工具)。正式发布:购买EV代码签名证书
- 向DigiCert、Sectigo等CA机构申请EV证书
- 使用SignTool签名:bash signtool sign /v /s MY /n "Your Company Name" /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 MyDriver.sys
- 提交至Microsoft Partner Center进行WHQL认证(可选,提升信任度)
INF文件:驱动的“身份证”
别小看INF文件,它是系统识别并加载驱动的关键依据。
[Version] Signature="$Windows NT$" Class=Ports ClassGuid={4d36e978-e325-11ce-bfc1-08002be10318} Provider=%ManufacturerName% CatalogFile=MyDriver.cat DriverVer=01/01/2024,1.0.0.0 [Manufacturer] %ManufacturerName% = DeviceList,NTamd64 [DeviceList.NTamd64] "My USB UART Bridge" = MYUSB_SER_Device, USB\VID_1234&PID_5678 [MYUSB_SER_Device] Include=mdmcpq.inf Needs=MDMCPQ.InfHW [MYUSB_SER_Device.Services] Include=mdmcpq.inf Needs=MDMCPQ.InfHW.Services⚠️ 注意:引用
mdmcpq.inf可让系统使用内置usbser.sys作为服务驱动,实现真正免驱!
调试技巧:WinDbg才是终极武器
光靠KdPrint不够。真出问题,还得上WinDbg。
快速入门三步法:
目标机开启内核调试:
bash bcdedit /debug on bcdedit /dbgsettings serial debugport:1 baudrate:115200主机运行WinDbg,连接串口或网络调试
加载符号文件 + 源码路径,实现断点调试
当你看到蓝屏时,执行!analyze -v,往往能直接定位到出错的驱动模块和行号。
结语:掌握这项技能,你已经超越80%的嵌入式工程师
看到这里,你应该已经明白:
- USB转串口不是简单的“转换芯片+驱动安装”,而是一套完整的软硬协同体系;
- KMDF + CDC ACM 是当前最主流、最稳定的实现路径;
- 掌握驱动开发能力,意味着你能打造真正自主可控的通信链路。
无论是做工业网关、调试适配器,还是构建专用设备,这套技术都能为你提供坚实的底层支撑。
下一步你可以尝试:
- 在STM32上实现CDC ACM固件(可用STM32CubeMX快速生成)
- 结合用户态服务实现固件在线升级
- 添加自定义IOCTL扩展功能(如读取设备温度、重启MCU等)
如果你正在开发这类产品,欢迎留言交流具体场景,我可以帮你分析架构设计是否合理。
毕竟,能把驱动写明白的人,才是真正懂系统的开发者。