☰
Windows下C++通过虚拟串口连接蓝牙:SPP配对、COM号分配与收发实战
2026/10/1 12:12:14 网站建设 项目流程

简介:这是一份面向Windows平台C++开发者的蓝牙通信实战资料,聚焦通过虚拟串口完成与蓝牙设备的连接与数据收发,适合具备一定C++基础、希望深入理解Windows底层API与蓝牙协议栈的开发者参考。内容围绕Bluetooth API、RFCOMM协议、设备发现与连接、CreateFile创建虚拟串口、WriteFile/ReadFile双向传输、错误处理、多线程非阻塞读写以及资源管理等关键环节展开,并涉及蓝牙安全权限与Boost、Winsock等库的配合使用。资源包共94个文件,以h头文件、cpp源码、obj与pdb编译中间文件、tlog日志、manifest与res资源文件、sln与vcxproj工程文件等为主,整体约87.16MB,工程结构完整,可直接在Visual Studio中打开编译调试。目前已有401人学习下载,读者可借此获得一套可运行的蓝牙串口通信示例,理解从设备扫描到数据收发的完整链路,并掌握常见连接失败与传输异常的排查思路。

1. 从一根 USB 线到虚拟串口:Windows 下 C++ 连蓝牙到底在连什么

很多人第一次听到「C++ 通过虚拟串口连接蓝牙」,脑子里浮现的是拿 C++ 直接调蓝牙协议栈、配对、抓 HCI 包。真到工位上你会发现,绝大多数量产项目根本不这么干。你手上那块 HC-05、JDY-31、ESP32 蓝牙串口模块,出厂固件跑的就是 SPP(Serial Port Profile),它在 Windows 上配对成功后,系统会自动给你分配一个 COM 号,比如 COM5、COM7。于是「蓝牙通信」这件事,在代码层面就退化成了一件极其朴素的事:打开一个串口,读写字节。

这就是标题里「虚拟串口」四个字的真正含义。它不是让你去写蓝牙驱动,而是把蓝牙链路抽象成一条串口,让 C++ 用CreateFile、ReadFile、WriteFile这套 Windows 串口 API 去操作。好处是代码和有线串口几乎一模一样,坏处是蓝牙链路的建立、断开、延迟、丢包全被藏在这个 COM 口背后,出问题时你面对的是一个黑匣子。这篇笔记就按我实际做过的路径讲:先讲清虚拟串口和 SPP 的关系,再给一套能直接编译运行的 C++ 收发代码,然后重点讲参数怎么设、坑在哪、怎么排查。适合做上位机、工控采集、蓝牙模块调试的嵌入式同行,也适合刚学 C++ 想找个真实场景练手的同学。

2. 虚拟串口是怎么冒出来的:SPP 配对与 COM 号分配

2.1 蓝牙 SPP 在 Windows 上的工作链路

先把链路讲清楚,不然后面排查全靠猜。你的蓝牙模块(以 HC-05 为例)上电后处于从机模式,广播自己的名称和地址。Windows 这边用系统蓝牙设置或「添加设备」发起配对,配对时输入 PIN,常见默认是1234或0000。配对成功后,Windows 会为这个设备枚举出它的服务,其中 SPP 服务会被映射成两个 COM 口:一个「传入」、一个「传出」。你在设备管理器里展开「端口 (COM 和 LPT)」,会看到类似「Standard Serial over Bluetooth link (COM5)」和「(COM6)」两条。

关键点在于:你代码里要打开的是「传出」那个口,也就是你主动去连模块时用的口。很多人打开错了口,CreateFile直接失败或者打开后收不到任何数据,这是最高频的翻车点之一。判断方法很简单,在设备管理器里右键属性看「详细信息」,或者干脆两个口都试,能收到模块回显的那个就是对的。

链路建立后,数据流是这样的:C++ 写 COM 口 → Windows 蓝牙栈 → 射频 → 模块串口 → 你的 MCU。反向同理。整条链路上,C++ 只负责最前面那一段,后面任何一环出问题,表现出来都是「串口读不到数据」,所以排查必须分层。

2.2 配对、绑定与 COM 号固定

配对是一次性的,但 COM 号不一定稳定。血泪经验:同一台电脑,同一个模块,重新配对后 COM 号可能从 COM5 变成 COM8。如果你的程序把 COM 号写死,换台机器或重配对一次就跑不起来。常见做法有两种:一是程序里做端口枚举,按设备描述符匹配「Standard Serial over Bluetooth link」;二是让用户在配置里选端口,程序启动时列出所有可用 COM 口。

枚举端口用 Windows APISetupDiGetClassDevs配合GUID_DEVCLASS_PORTS,能拿到每个口的友好名称。下面这段是枚举逻辑的核心,后面完整代码会用到:

// 枚举系统所有串口,返回 "COMx" 与友好名称的对应关系 #include <windows.h> #include <setupapi.h> #include <devguid.h> #include <string> #include <vector> #pragma comment(lib, "setupapi.lib") struct PortInfo { std::string name; std::string friendly; }; std::vector<PortInfo> EnumSerialPorts() { std::vector<PortInfo> result; // 只取端口类设备 HDEVINFO hDev = SetupDiGetClassDevs(&GUID_DEVCLASS_PORTS, 0, 0, DIGCF_PRESENT); if (hDev == INVALID_HANDLE_VALUE) return result; SP_DEVINFO_DATA data; data.cbSize = sizeof(data); for (DWORD i = 0; SetupDiEnumDeviceInfo(hDev, i, &data); ++i) { char buf[256] = {0}; // 友好名称,例如 "Standard Serial over Bluetooth link (COM5)" if (SetupDiGetDeviceRegistryPropertyA(hDev, &data, SPDRP_FRIENDLYNAME, 0, (PBYTE)buf, sizeof(buf), 0)) { std::string friendly(buf); // 从括号里抠出 COM 号 auto l = friendly.find("(COM"); auto r = friendly.find(")", l); if (l != std::string::npos && r != std::string::npos) { std::string com = friendly.substr(l + 1, r - l - 1); result.push_back({com, friendly}); } } } SetupDiDestroyDeviceInfoList(hDev); return result; }

逻辑说明:SetupDiGetClassDevs拿到端口设备集合,SetupDiEnumDeviceInfo逐个遍历,SPDRP_FRIENDLYNAME取到带 COM 号的描述串,再用字符串截取把COM5这种名字抠出来。参数上DIGCF_PRESENT表示只要当前在位的设备,避免列出幽灵端口。拿到列表后,你可以按friendly里是否含Bluetooth来筛选蓝牙串口,也可以直接展示给用户选。

提示:枚举出来的名字在不同 Windows 语言版本下可能不同,中文系统里可能是「蓝牙链路上的标准串行」之类,所以匹配关键字别只写英文,稳妥做法是匹配(COM这个结构本身。

3. 用 C++ 打开蓝牙串口并收发:一份能直接编译的代码

3.1 打开串口与配置 DCB 参数

Windows 串口操作的核心是CreateFile打开\\.\COMx,注意 COM 号超过 9 时必须加\\.\前缀,否则CreateFile会失败,这是另一个高频坑。打开后拿DCB结构配置波特率、数据位、停止位、校验位,再用SetCommTimeouts设置读写超时。蓝牙 SPP 的波特率其实是个「协商值」,模块和 Windows 之间实际速率由蓝牙链路决定,但 DCB 里仍要填一个值,通常填模块串口侧配置的波特率,比如 9600 或 115200。

#include <windows.h> #include <string> #include <iostream> HANDLE OpenBthSerial(const std::string& com, DWORD baud) { // COM10 及以上必须加 \\.\ 前缀 std::string path = "\\\\.\\" + com; HANDLE h = CreateFileA(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, // 串口必须独占 NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (h == INVALID_HANDLE_VALUE) { std::cerr << "open failed, err=" << GetLastError() << std::endl; return INVALID_HANDLE_VALUE; } DCB dcb = {0}; dcb.DCBlength = sizeof(dcb); if (!GetCommState(h, &dcb)) { CloseHandle(h); return INVALID_HANDLE_VALUE; } dcb.BaudRate = baud; // 如 CBR_9600 / CBR_115200 dcb.ByteSize = 8; // 数据位 dcb.Parity = NOPARITY; // 无校验 dcb.StopBits = ONESTOPBIT;// 1 位停止位 dcb.fBinary = TRUE; dcb.fDtrControl = DTR_CONTROL_ENABLE; // 有些模块靠 DTR 判断主机在线 if (!SetCommState(h, &dcb)) { CloseHandle(h); return INVALID_HANDLE_VALUE; } // 超时:读操作最多等 100ms 返回,避免线程卡死 COMMTIMEOUTS to = {0}; to.ReadIntervalTimeout = 50; to.ReadTotalTimeoutConstant = 100; to.ReadTotalTimeoutMultiplier = 0; to.WriteTotalTimeoutConstant = 200; SetCommTimeouts(h, &to); return h; }

逻辑说明:CreateFileA的第三个参数0表示独占,串口不能被两个进程同时打开,调试时如果串口助手还开着,你的程序必然打不开,报错通常是「拒绝访问」。DCB里fDtrControl设成DTR_CONTROL_ENABLE很关键,部分蓝牙模块(尤其 HC-05 某些固件)会检测 DTR 信号,不拉高就不进入数据透传状态,表现为连上了但发不出数据。超时设置里ReadIntervalTimeout是字节间最大间隔,ReadTotalTimeoutConstant是单次读的总等待,这两个值决定了你的读线程多久返回一次,设太小会频繁空转,设太大响应迟钝。

3.2 读写循环与线程收数据

串口读是阻塞式的,放在主线程会卡界面,标准做法是开一个独立线程循环ReadFile,读到数据丢进队列或回调。写操作相对简单,WriteFile即可,但要注意蓝牙链路有 MTU 限制,一次别写太大,常见做法是分包,每包不超过 512 字节,包间留几毫秒间隔。

#include <thread> #include <atomic> #include <functional> std::atomic<bool> g_running{true}; void ReadLoop(HANDLE h, std::function<void(const char*, DWORD)> onData) { char buf[1024]; DWORD read = 0; while (g_running) { if (ReadFile(h, buf, sizeof(buf), &read, NULL) && read > 0) { onData(buf, read); // 交给上层处理,别在这里做耗时操作 } else { DWORD err = GetLastError(); if (err != ERROR_SUCCESS && err != ERROR_TIMEOUT) { std::cerr << "read err=" << err << std::endl; break; // 链路断开,退出循环 } } } } bool SendData(HANDLE h, const char* data, DWORD len) { DWORD written = 0; // 分包发送,避免超过蓝牙单包上限 const DWORD CHUNK = 256; for (DWORD off = 0; off < len; off += CHUNK) { DWORD n = (len - off > CHUNK) ? CHUNK : (len - off); if (!WriteFile(h, data + off, n, &written, NULL) || written != n) { return false; } Sleep(5); // 包间小间隔,给蓝牙栈缓冲时间 } return true; }

逻辑说明:ReadFile在超时设置下会周期性返回,read == 0且错误码是ERROR_TIMEOUT属于正常空读,不要当成断线。真正断线时错误码通常是ERROR_OPERATION_ABORTED或ERROR_BAD_COMMAND,这时退出循环并触发重连逻辑。SendData里CHUNK取 256 是保守值,实测 115200 波特率下 512 也能跑,但模块缓冲区小的会丢包,宁可小一点。Sleep(5)是给蓝牙协议栈的喘息时间,去掉后连续大包发送容易触发缓冲溢出。

注意:onData回调里不要直接操作 UI 控件,跨线程更新界面要PostMessage或投递到主线程队列,否则会出现随机崩溃,这类 bug 极难复现。

4. 参数怎么设、模块怎么配:HC-05 与 ESP32 的实操差异

4.1 波特率、AT 模式与透传模式切换

蓝牙串口模块分两种状态:AT 命令模式和透传模式。HC-05 上电时如果 KEY 脚(EN 脚)拉高,进入 AT 模式,此时波特率固定 38400(部分固件是 9600),你发AT能收到OK;KEY 脚悬空则进入透传模式,按你之前用 AT 命令设的波特率工作。很多人连不上就是因为在透传模式下发 AT 命令,当然没反应。ESP32 用蓝牙串口(BluetoothSerial 库)时没有这个物理切换,它上电就跑透传,配置靠代码里的begin("设备名")。

配置 HC-05 的典型 AT 序列,用串口助手手动发一遍确认:

命令作用期望返回
AT测试连通OK
AT+NAME=MyDev设设备名OK
AT+PSWD=1234设配对码OK
AT+UART=115200,0,0设波特率 115200,1 停止位,无校验OK
AT+ROLE=0设为从机OK

参数说明:AT+UART三个参数依次是波特率、停止位(0=1位,1=2位)、校验(0=无)。设完要断电重启才生效。AT+ROLE=0从机模式是让电脑主动连它,如果你要模块主动连电脑,得设ROLE=1并配AT+BIND绑定电脑蓝牙地址,这种场景少,多数项目电脑做主。

4.2 电脑端配对与端口确认

Windows 10/11 的蓝牙配对入口在「设置 → 蓝牙和其他设备 → 添加设备 → 蓝牙」,搜到模块名点配对,输 PIN。配对成功后不会自动弹 COM 号,要去「更多蓝牙选项 → COM 端口」里看,或者设备管理器。这里有个玄学现象:有时候配对成功但 COM 端口列表里没有新增,原因是 SPP 服务没被正确枚举。解决办法是删掉设备重新配对,或者用「添加 → 传入/传出」手动添加。我一般会先确认设备管理器里出现了「Standard Serial over Bluetooth link」,再去代码里枚举。

ESP32 这边,配对后同样会生成 COM 口,但 ESP32 的蓝牙串口默认设备名是ESP32SPP之类,配对码通常不需要或固定。它的波特率在代码里SerialBT.begin("name")时并不指定,实际链路速率自适应,DCB 里填 115200 即可。

5. 避坑与排查:连不上、收不到、断连的 5 个真实记录

5.1 现象:CreateFile 返回 INVALID_HANDLE_VALUE,错误码 5

原因:串口被占用,最常见是串口助手、另一个调试程序还开着这个 COM 口,或者你打开的是「传入」口而系统把它占给了别的服务。解决:关掉所有可能占用串口的程序,任务管理器里确认没有残留进程;换「传出」口试;用Handle或 Process Explorer 搜哪个进程持有该句柄。

5.2 现象:打开成功,WriteFile 也成功,但模块毫无反应

原因:DTR/RTS 信号没拉高,模块认为主机不在线,不转发数据;或者模块还在 AT 模式。解决:DCB 里设fDtrControl = DTR_CONTROL_ENABLE、fRtsControl = RTS_CONTROL_ENABLE;确认模块 KEY 脚状态,透传模式下 KEY 必须悬空或拉低。

5.3 现象:能收到数据但全是乱码

原因:波特率不匹配。蓝牙 SPP 虽然链路速率自适应,但模块串口侧到 MCU 那一段的波特率必须和 MCU 一致,而 Windows 侧 DCB 的波特率如果和模块 AT 配置的不一致,也会乱。解决:统一三处波特率——模块 AT 配置、Windows DCB、MCU 代码。用示波器或逻辑分析仪量模块 TX 脚最准。

5.4 现象:跑几分钟后 ReadFile 报错,链路断开

原因:蓝牙链路空闲超时被系统回收,或信号弱导致断连。Windows 蓝牙栈对空闲 SPP 连接有省电策略。解决:程序里定时发心跳包(比如每 5 秒发一个字节),保持链路活跃;在设备管理器蓝牙适配器属性里关掉「允许计算机关闭此设备以节约电源」;缩短设备与电脑距离,避开 2.4G 干扰源。

5.5 现象:换台电脑或重配对后程序找不到 COM 口

原因:COM 号变了,程序写死了旧号。解决:用第 2 章的枚举逻辑动态获取,按友好名称里的Bluetooth关键字筛选,取第一个匹配项;或者做成配置文件让用户选。别偷懒写死,这是后期维护成本最高的小坑。

6. 让链路更稳:心跳保活、断线重连与一个验证技巧

做到能收发只是及格线,真正上线要解决「长时间稳定运行」。我一般会加两层保护。第一层是心跳:起一个定时器,每 3 到 5 秒通过SendData发一个约定字节(比如0x00),模块侧收到后回一个0x00,上位机在onData里更新「最后收到时间」。如果超过 15 秒没收到任何数据,判定链路异常,主动CloseHandle然后重连。第二层是重连:把「枚举端口 → 打开 → 起读线程」封装成一个Connect()函数,断线后延时 2 秒重试,重试间隔做退避,避免疯狂重连把系统蓝牙栈搞崩。

// 心跳与超时判定,放在读线程或独立定时线程里 #include <chrono> std::chrono::steady_clock::time_point g_lastRecv; void HeartbeatTick(HANDLE h) { auto now = std::chrono::steady_clock::now(); auto idle = std::chrono::duration_cast<std::chrono::seconds>(now - g_lastRecv).count(); if (idle > 15) { std::cerr << "link timeout, reconnect..." << std::endl; g_running = false; // 通知读线程退出 CloseHandle(h); // 外层循环负责重新 Connect() return; } char ping = 0x00; SendData(h, &ping, 1); // 心跳包 }

逻辑说明:g_lastRecv在每次onData里更新,心跳线程只读它做判断。idle > 15的阈值按你的业务定,实时性要求高的可以缩到 5 秒,但太短会误判——蓝牙偶尔有几百毫秒的抖动是正常的。重连时一定要先CloseHandle再重新CreateFile,不关句柄直接重开必然失败。

验证链路是否真的稳,我有个笨但有效的技巧:写一个回环测试。让模块侧 MCU 收到什么就原样发回,上位机发 1 万个递增序号,统计收到多少、丢了多少、最大延迟多少。跑一晚上,如果丢包率低于万分之一、没有断连,这条链路才算能上生产。这个测试能暴露 90% 的隐藏问题,比盯着代码看有用得多。

最后说个我自己的习惯:每次换新模块或新电脑,先别写代码,用串口助手手动配对、打开端口、发几个字节确认链路通了,再把这套参数搬进 C++。跳过这步直接上代码,出问题时你分不清是链路问题还是代码问题,白白多花几小时。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询