Windows下用C++/WinRT自研BLE调试工具:从扫描到GATT读写全解析
简介针对Windows平台Visual Studio C环境下的蓝牙BLE客户端开发需求这份完整源码示例面向需要调试ESP32等硬件设备的嵌入式工程师提供了一套可直接运行的解决方案。示例代码对设备扫描、蓝牙连接、服务发现、特征值读写与通知订阅、错误处理等核心环节均有清晰实现模块划分合理便于开发者快速定位关键逻辑并加以修改。通过实际运行这一示例可以掌握Windows 10 UWP蓝牙API在C项目中的调用方法理解GATT服务与特征值的交互流程也能为自研BLE调试工具或ESP32等硬件固件的功能验证提供可复用的基础框架。全部源码共14个文件以5个cpp实现、4个头文件声明以及2个txt说明文档为主体并附带了Visual Studio工程配置文件压缩包整体仅8KB轻量且便于部署。目前已有1834人学习下载源码具备较好的稳定性与可读性适合C/BLE初学者和物联网开发者作为参考模板快速上手。 做嵌入式蓝牙开发这几年我一直没找到一个完全顺手的 Windows 桌面 BLE 调试工具。手机上的 nRF Connect 确实好用但碰上自动化测试、日志留存、和上位机协议对接这些场景就缺东西了。最后我干脆用 Visual Studio C基于 Windows 官方的 BLE 接口写了一套自己的桌面客户端扫描、连接、服务发现、特征值读写、通知订阅都齐了代码结构也不算复杂核心部分拉下来改改就能当工具用。这篇文章把整个项目的思路、源码逻辑和踩过坑的地方拆开讲从环境配置写到关键代码适合在做蓝牙外设固件、BLE 网关或者刚接触 C/WinRT 的同学参考。1. 为什么要在Windows上用C自研BLE调试客户端1.1 现成工具在什么地方不够用很多人在刚开始做 BLE 调试时手机上的 nRF Connect、LightBlue 这类 App 已经覆盖了大部分需求。但是对经常要联调的开发者来说这些工具的问题很快会暴露出来它们只能做手工点按没法把一段固定的测试序列自动跑起来也没法把每次读写的时间戳、原始字节、信号强度完整地记录成文件。我遇到的实际场景是做量产前的通信稳定性验证需要在 5 分钟内反复连接、写入几百包数据并统计丢包率手机上手工操作根本不现实。PC 端的场景其实更刚需第一是自动化测试脚本或命令行工具可控第二是日志管理蓝牙数据可以直接落盘第三是协议层调试上位机往往要同时处理串口、网络和 BLE 数据这时候一个可编程的 C 客户端就能和现有协议栈无缝集成。所以我选择自己写而不是继续找现成软件。1.2 Windows平台做BLE的三条路线怎么选在 Windows 上写 C BLE 程序我试下来最多人纠结的其实是技术路线常用的有这么几条方案BLE GATT 支持程度开发成本适合场景传统 Win32 Bluetooth API基本只覆盖 BR/EDR对 LE GATT 支持很弱低SPP 蓝牙串口WinRT API C/WinRT完整支持扫描、连接、GATT中桌面控制台/工具类应用第三方跨平台库SimpleBLE、NimBLE-PC 等取决于封装程度低到中快速验证、跨平台传统 Win32 的 Bluetooth API 主要是为经典蓝牙设计的虽然能枚举设备但要看 BLE 的广播数据、GATT 服务、特征值读写就很吃力基本可以放弃。第三方跨平台库上手快不过封装了一层后对广播数据的原始解析、连接参数的精细控制会受限。Windows 官方推荐的 WinRT 接口通过 C/WinRT 暴露出来既贴近系统底层又保持了现代 C 的写法所以我最终选了这条路。2. 环境与项目配置把 Visual Studio 变成 BLE 开发环境2.1 版本和组件要求我用的环境是 Visual Studio 2022需要安装“使用 C 的桌面开发”工作负载Windows SDK 版本推荐 10.0.19041.0 以上。操作系统建议 Windows 10 2004 或更高版本因为新版 SDK 里 BLE 相关的 WinRT API 更完整尤其是 GattSession 这类新接口。开发机自己的蓝牙适配器基本都支持 BLE 4.0 以上笔记本一般没问题台式机如果用的老式蓝牙适配器建议换一个 USB BLE 4.0 的否则扫描距离和稳定性都让人头疼。另外要注意 Windows 设置里的蓝牙开关这个看起来是废话但我在控制台程序里排查过半天“扫描没结果”最后发现是适配器被系统禁用了这种低级错误在频繁切换设备的测试环境里经常出现。2.2 创建 C/WinRT 项目的两种方式新建项目最快的方式是在 Visual Studio 里直接搜“Windows Console Application (C/WinRT)”模板创建后自动带好了 C/WinRT 的环境。如果你手头已经有一个老式 Win32 或 MFC 项目不想换模板可以在 NuGet 包管理器里安装Microsoft.Windows.CppWinRT安装后项目会自动生成 cppwinrt.exe 工具链把 WinRT 元数据转成 C/WinRT 头文件并用起来。无论哪种方式项目配置里注意两个点C 语言标准建议 C17 或 C20确保协程可用如果使用 NuGet 方式需要检查“生成事件”里 cppwinrt 的导入步骤是否正常执行。我看到不少人在这一步卡住报错基本都是找不到winrt/Windows.Devices.Bluetooth.h大概率是 C/WinRT 环境没生效。2.3 控制台程序里如何稳定等待异步调用控制台程序跑 WinRT 异步操作有一个容易被忽视的问题主线程如果直接退出进程就没了扫描事件自然也不会触发。我最初的写法是在 main 里简单睡几秒但这样既不可控事件回调也经常不执行。推荐的做法是用事件对象保持进程存活#include winrt/base.h #include Windows.h int main() { winrt::init_apartment(winrt::apartment_type::multi_threaded); winrt::handle eventHandle{ ::CreateEvent(nullptr, FALSE, FALSE, nullptr) }; // 在这里启动扫描或连接逻辑 ::WaitForSingleObject(eventHandle.get(), INFINITE); return 0; }init_apartment的参数我建议直接用multi_threaded。如果项目是 MFC/Win32 对话框程序异步回调里更新界面时不能直接操作控件需要借助DispatcherQueue或传统的PostMessage切到 UI 线程否则界面会闪退或卡死。3. 核心代码逻辑从扫描到 GATT 读写的完整链路3.1 扫描用 BluetoothLEAdvertisementWatcher 抓设备BLE 扫描用的是BluetoothLEAdvertisementWatcher它是最简单的一个入口。创建后设置扫描模式然后订阅 Received 事件即可#include winrt/Windows.Devices.Bluetooth.Advertisement.h #include winrt/Windows.Devices.Bluetooth.h #include winrt/Windows.Devices.Bluetooth.GenericAttributeProfile.h #include winrt/Windows.Storage.Streams.h using namespace winrt; using namespace Windows::Devices::Bluetooth; using namespace Windows::Devices::Bluetooth::Advertisement; using namespace Windows::Devices::Bluetooth::GenericAttributeProfile; void StartScan() { BluetoothLEAdvertisementWatcher watcher; watcher.ScanningMode(BluetoothLEScanningMode::Active); watcher.Received([](BluetoothLEAdvertisementWatcher const, BluetoothLEAdvertisementReceivedEventArgs const args) { uint64_t address args.BluetoothAddress(); auto name args.Advertisement().LocalName(); int16_t rssi args.RawSignalStrengthInDBm(); // 过滤或者保存到设备列表 if (name.empty()) name Lunknown; printf(Addr: %012llX, Name: %ls, RSSI: %d\n, address, name.c_str(), (int)rssi); }); watcher.Start(); }这里要解释一下Active和Passive的区别。Active 模式会主动向周边设备请求扫描响应包所以更容易拿到设备名Passive 模式只听广播省电但对设备名的获取不稳定。调试工具建议用 Active。接收到的BluetoothAddress()是一个 48 位 MAC 地址之后连接设备就靠它。3.2 连接之后如何发现服务和特征拿到地址后通过BluetoothLEDevice::FromBluetoothAddressAsync获取设备对象然后调用GetGattServicesAsync做 GATT 连接和服务发现。Windows 的这个调用是阻塞式的异步等待内部会完成链路连接不需要自己等ConnectionStatus变化这一点比 Linux BlueZ 那套省心很多winrt::fire_and_forget ConnectAndDiscover(uint64_t address) { BluetoothLEDevice device co_await BluetoothLEDevice::FromBluetoothAddressAsync(address); if (device nullptr) { printf(Device unavailable\n); co_return; } GattDeviceServicesResult serviceResult co_await device.GetGattServicesAsync(BluetoothCacheMode::Uncached); if (serviceResult.Status() ! GattCommunicationStatus::Success) { printf(Service discovery failed\n); co_return; } for (GattDeviceService service : serviceResult.Services()) { printf(Service: %ls\n, winrt::to_hstring(service.Uuid()).c_str()); GattCharacteristicsResult charResult co_await service.GetCharacteristicsAsync(BluetoothCacheMode::Uncached); for (GattCharacteristic characteristic : charResult.Characteristics()) { printf( Characteristic: %ls\n, winrt::to_hstring(characteristic.Uuid()).c_str()); } } }关键点是要把服务发现的结果状态先判断一下。我第一次写的时候没检查Status()直接遍历Services()结果设备和手环断开后程序直接崩溃加上状态判断之后才稳定。3.3 特征值的读写需要哪些接口GATT 读写是整个调试工具的核心。写操作我用Windows::Storage::Streams::DataWriter把字节组装好然后DetachBuffer()转成IBuffer传给WriteValueAsyncwinrt::fire_and_forget WriteCharacteristic(GattCharacteristic characteristic) { DataWriter writer; writer.WriteByte(0x01); writer.WriteUInt16LE(0x1234); GattCommunicationStatus status co_await characteristic.WriteValueAsync(writer.DetachBuffer()); if (status GattCommunicationStatus::Success) printf(Write OK\n); else printf(Write failed: %d\n, (int)status); }读操作则直接调ReadValueAsync拿到结果后通过DataReader转成字节数组winrt::fire_and_forget ReadCharacteristic(GattCharacteristic characteristic) { GattReadResult readResult co_await characteristic.ReadValueAsync(); if (readResult.Status() ! GattCommunicationStatus::Success) { printf(Read failed\n); co_return; } DataReader reader{ readResult.Value() }; uint32_t len reader.UnconsumedBufferLength(); std::vectoruint8_t bytes(len); reader.ReadBytes(bytes); for (uint8_t b : bytes) printf(%02X , b); printf(\n); }注意字节序BLE 协议中多字节数值大多是小端序DataWriter提供了WriteUInt16LE这类方法写端和读端要保持一致。我在协议联调时踩过一个大坑写入的数据和固件端解析的字节序反了单片机那边死活不响应后来才发现是主机端用错了接口。3.4 订阅 Notify 最关键的一步是写 CCCD要接收设备的主动通知只挂ValueChanged事件是不够的。BLE 规范里通知功能需要通过客户端特征配置描述符CCCD来使能在 Windows API 里对应一个现成的调用characteristic.ValueChanged([](GattCharacteristic const, GattValueChangedEventArgs const args) { DataReader reader{ args.CharacteristicValue() }; uint32_t len reader.UnconsumedBufferLength(); std::vectoruint8_t bytes(len); reader.ReadBytes(bytes); // 处理通知数据 }); GattCommunicationStatus cccdStatus co_await characteristic.WriteClientCharacteristicConfigurationDescriptorAsync( GattClientCharacteristicConfigurationDescriptorValue::Notify); if (cccdStatus ! GattCommunicationStatus::Success) printf(Enable notify failed\n);这里特别提醒先注册ValueChanged再写 CCCD。如果顺序反了设备可能在你注册事件之前就把通知发出来第一条数据就丢了。另外有些特征值同时支持 Notify 和 Indicate选择要和服务端实际实现一致否则通知不会到。3.5 长数据与 MTU 的边界问题BLE 的 MTU 决定了单次 ATT 数据包能传多少字节。早期 BLE 4.0/4.1 默认 MTU 是 23 字节扣除 ATT 头和相关字段后实际有效载荷只有 20 字节左右。Windows 的WriteValueAsync会依据当前链路的 MTU 来决定单次写入是否可行数据超过限制时写操作会失败而不是自动帮你分片。如果你和固件端需要传稍大的数据包一般两个思路固定 MTU 协商Windows 会自动参与 MTU 协商最终结果可以从BluetoothLEDevice::GetGattSession()后的GattSession::MaxPduSize属性查看或者应用层自定义分包协议。我在实际工具里用的是后者超过 20 字节时按固定长度切片每片加序号接收端重组。这样即使换了一个 MTU 更大的设备协议依然兼容——毕竟调试工具要面对的设备五花八门。4. 调试阶段最消磨耐心的几个坑如果你按前面的代码搭出来大概率不会一次就顺利跑通。下面这五个问题是我在多个项目里反复遇到的每一个都对应一段真实的排查链路。4.1 watcher 事件不触发先从三个方向排查症状是Start()后Received没有任何输出。我的排查顺序是先用手机上的 nRF Connect 确认设备确实在广播排除是环境问题然后检查watcher是不是局部变量——如果Start之后函数返回watcher 被析构事件自然不会再触发这个变量必须保持生命周期最后检查init_apartment是否设置成了multi_threaded。这个坑的隐蔽之处在于不是每次都能复现。有的机器上 STA 也能收到事件但在使用协程等待异步结果的代码路径上STA 会因为没有消息泵导致co_await永远不返回程序看起来就像“卡死”了。所以我的建议是控制台工具一律用 MTA桌面程序则把 BLE 操作放到独立工作线程避免和 UI 消息循环互相影响。4.2 写入总是失败根因往往是特征属性不对现象是WriteValueAsync返回的错误码不是 Success但服务端的手机工具却能正常写。这个时候先去读GattCharacteristic.CharacteristicProperties()看特征值到底支持什么操作。有些特征只支持WriteWithoutResponse你如果用了默认的WriteWithResponse去怼有些设备直接拒绝反过来也一样。另一个高频原因是数据长度超过 MTU。调试时可以先写一个单字节验证链路再慢慢加长快速定位是“属性不支持”还是“超长被拒”。如果特征支持WriteWithoutResponse还可以在调用时指定第二个参数GattWriteOption::WriteWithoutResponse成功率会更高但代价是你不知道固件到底收到了没有适合固件端有日志的场景。4.3 服务列表是空的大概率是缓存问题GetGattServicesAsync返回的集合为空但这个设备在手机工具里明明能看到完整 GATT 表。这是因为系统缓存了旧的连接信息。解决办法是在调用时传BluetoothCacheMode::Uncached强制重新发现。如果重新发现后还是空就去 Windows“设置 - 蓝牙和其他设备”里把这个设备删掉重新配对一次。我遇到过最离谱的一次是设备固件升级后 GATT 表变了但缓存里还是旧的服务列表导致工具始终连不到新特征。从那以后我把所有服务发现调用都写成Uncached代价是连接耗时稍长但换来的正确性值得。4.4 设备名是空的或乱码别只依赖 LocalName广播包里的设备名不是一定有的。BluetoothLEAdvertisement::LocalName()只有在广播数据或扫描响应里明确携带了设备名时才非空很多低功耗设备为了省电广播包里只放了服务 UUID设备名要连接后去读 GATT 的 Device Name 特征0x2A00才能拿到。所以完整的调试工具应该有两条获取设备名的路径扫描阶段用LocalName()只是显示用真要稳定辨识设备要么用 MAC 地址要么连接后主动读 0x2A00 特征。乱码问题则多数是编码不匹配BLE 设备名一般是 UTF-8Windows 的LocalName()返回的是 UTF-16 字符串如果设备端塞了 GBK 编码的字节进去显示出来就是乱码这个只能靠设备端配合修正。4.5 回调里的异步链和对象生命周期这是 C/WinRT 里最经典的坑。你在ValueChanged回调里捕获了this但如果当前对象在通知到达前被析构了回调里再访问成员变量就是访问已释放内存轻则随机崩溃重则查几天都查不出来。解决办法是在对象内部发给事件时用get_weak()机制class DeviceHandler { public: void Subscribe(GattCharacteristic characteristic) { auto weakThis get_weak(); characteristic.ValueChanged( [weakThis](GattCharacteristic const, GattValueChangedEventArgs const args) { if (auto strongThis weakThis.get()) { strongThis-OnValueChanged(args.CharacteristicValue()); } }); } private: void OnValueChanged(IBuffer const value) { // 处理通知数据 } };这样对象销毁后回调会自动跳过不会触发非法访问。我见过很多人把这个问题归咎于“WinRT 事件太难用了”其实只要理解回调触发是异步的调用方的生命周期管理就要跟上。5. 一套可跑的源码怎么组织以及实测验证方式5.1 建议的模块划分与关键类职责完整的调试工具不应该把所有逻辑堆在 main.cpp 里。我建议按下面的结构拆分这也是这套源码的模块划分BleDebugTool/ ├─ BleDebugTool.sln ├─ src/ │ ├─ main.cpp // 入口命令行交互或简单UI │ ├─ BleScanner.h/.cpp // 扫描设备维护设备列表 │ ├─ BleConnection.h/.cpp // 连接、服务发现、特征值操作 │ ├─ GattView.h/.cpp // 把 GATT 表转换成可读的树形结构 │ ├─ HexUtil.h/.cpp // 十六进制字符串与字节数组互转 │ └─ Logger.h/.cpp // 带时间戳的日志输出可落盘 └─ README.md // 示例和说明BleScanner只负责扫描把设备地址、名称、RSSI 保存到一个 vector 里。BleConnection负责连接设备和 GATT 操作对外暴露ConnectAsync、ReadCharacteristicAsync、WriteCharacteristicAsync等接口。Logger是调试工具很容易忽略的一块所有蓝牙收发数据都打上时间戳后联调时定位时序问题会轻松非常多。把职责拆开的主要原因是BLE 联调时你和固件工程师经常需要分头验证同一个问题一个负责看设备端日志一个看主机端工具日志。如果工具把所有逻辑都揉在一起日志一多就分不清到底是扫描问题、连接问题还是数据问题。5.2 配合开发板的实测流程我用这套工具配合一块 nRF52840 DK 开发板做过完整验证流程可以作为大家拿到源码后的参考第一步开发板广播一个自定义服务包含一个 Device Name 特征、一个 LED 控制特征、一个温度通知特征。第二步打开工具扫描确认设备名和 MAC 地址显示正常。第三步连接设备打印出完整的 GATT 表确认服务 UUID 和特征 UUID 与固件定义一致。第四步写 0x01 到 LED 控制特征开发板上的 LED 点亮读回温度特征能拿到数值。第五步订阅温度通知特征用手触摸开发板上的温度传感器日志窗口能看到周期性的温度值刷新。这套流程能一次性覆盖扫描、连接、服务发现、读写、通知订阅五个核心功能任何一个环节有问题都能快速定位。5.3 扩展方向别停留在“能读能写”就结束如果你只是把工具跑通其实只完成了一半。我后来在这个基础上做了几个重要扩展每一个都直接解决了实际痛点。第一个是自动化测试模式。写一个简单的脚本文本每行一个操作指令比如connect address、write uuid 01020304、wait 500、read uuid工具顺序执行并记录 PASS/FAIL。量产前的稳定性测试就交给这个脚本反复跑比手工点按可靠得多。第二个是把 BLE 数据转发到串口或网络。很多 BLE 网关项目本质上就是一个“BLE 到 TCP/MQTT”的桥调试工具加一个转发层后可以直接模拟网关行为验证设备端和云端协议的一致性。第三个是结合 ModbusRTU 和 BLE 透传的场景。很多工业设备用 BLE 透传 Modbus 协议我在工具里加了 Modbus CRC 校验和报文解析功能收到原始字节后直接显示解析后的寄存器地址和值联调效率提高了一个档次。BLE Mesh 的调试也是类似思路重点在于抓广播包和分析分段消息这部分 Windows 的广播接口已经能覆盖大部分需求。到最后你会发现自己写的这个调试工具慢慢长成了最适合自己工作的形态。这也是我建议自己动手写一个的根本原因现成工具给的是通用能力自己写的工具才真正贴合要调试的那套硬件和协议。本文还有配套的精品资源点击获取