尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Windows BLE开发实战:C++/WinRT与GATT协议栈全解析

Windows BLE开发实战:C++/WinRT与GATT协议栈全解析 简介面向使用C和C语言在Windows平台上开发低功耗蓝牙BLE应用的工程师这份资料基于WinRT接口提供了一套可直接集成的封装实现适合需要快速完成设备扫描、连接、服务发现、特征值读写等典型操作的桌面项目。压缩包共11个文件整体仅10KB代码结构精简清晰包含4个头文件和4个源文件分别负责蓝牙广播监听、设备枚举过滤、通用属性配置交互、动态链接库导出接口以及基础初始化逻辑并附带了Visual Studio工程配置可直接编译运行或按需修改。资料学习热度较高目前已有5951人浏览学习说明其覆盖场景具有普遍代表性。通过该封装开发者能够避开WinRT异步编程中繁琐的线程调度与对象生命周期管理将更多精力投入业务功能实现从而缩短BLE模块的集成周期尤其适合具备基础C/C功底、希望涉足Windows现代蓝牙开发的技术人员。1. 动手前先看清 Windows BLE 开发的路线选择1.1 三条路线别一上来就抄开源代码一聊到 Windows 上做 BLE 蓝牙操作很多人第一反应是去网上找现成的 C 蓝牙库结果翻到一堆 Linux 下的 BlueZ 代码。BlueZ 在 Windows 上跑不起来除非你用 WSL 加 USB 转发但那样延迟高、稳定性差做产品不现实。Windows 下做 BLE真正可行的路线大致就三条先看表再选型路线底层能力优点缺点适合场景WinRT APIWindows.Devices.Bluetooth系统自带 GATT 协议栈官方支持、功能完整、枚举/配对/读写都齐C 写起来偏啰嗦异步 API 多Windows 原生上位机、设备校准工具Win32 BTH.dll传统蓝牙 BR/EDR老项目会用对 BLE 支持很弱GATT 基本没有传统蓝牙耳机/SPP 类设备第三方跨平台库如 SimpleBLE内部封装 WinRT/BlueZ一套代码多平台遇到 Bug 要等更新抽象层会藏细节需要 Linux/macOS 同时跑的团队我自己做设备联调时优先用 WinRT 路线。原因后面展开一句话概括BLE 协议栈里最烦的广播解析、GATT 发现、MTU 协商、配对回调微软都已经封装好了你不需要写任何蓝牙底层协议的东西。1.2 为什么最终推荐 WinRT C/WinRTWinRT 是 Windows 系统给应用访问系统能力的一层 API蓝牙只是其中一部分。官方支持的 C 接入方式里C/WinRT 是目前推荐的它用纯标准 C17 实现不需要启用/ZWC/CX这种不符合标准语法的扩展。C/CX 是我最不建议跳的坑。它看起来写起来都方便但微软自己的项目都在逐渐淘汰网上很多老教程还停留在 C/CX复制多了代码里一堆^、ref new后面迁移成本非常高。C/WinRT 的做法是通过一套官方工具生成 WinRT 类型的 C 头文件然后你就可以直接using namespace Windows::Devices::Bluetooth;这样写了。编译产物体积小和标准 C 无缝衔接调试时也能看到真实调用栈。1.3 哪些场景不适合硬上 WinRTWinRT 不是万能钥匙。遇到下面几种情况我建议先停一下换思路。你的 BLE 模块厂商已经提供了免驱串口透传方案比如通过蓝牙模块虚拟出一个 COM 口。这时直接用 Win32 串口 APICreateFile / ReadFile / WriteFile就行完全不需要关心 GATT、UUID、MTU 这些。你只是在做 Windows 端的快速验证最终产品跑在手机或嵌入式上。那别在 Windows 下花太多时间手机上的调试助手 厂商 SDK 可能更快。你要求代码在 Windows、Linux、macOS 三端共用同一套 BLE 通信逻辑。那应该选 SimpleBLE 这类跨平台封装而不是自己用 WinRT 写完再给 Linux 重写一遍。一句话先确认你的“蓝牙操作”到底是要做哪一层再做技术选型能省掉大量返工。2. VSCode 环境下搭建 C/C BLE 开发环境2.1 工具链清单Windows 上做 BLE 的 C/C 开发最稳妥的组合是 VSCode MSVC 编译器 CMake vcpkg。如果你已经有 Visual Studio那更好但很多人只是写个小工具不想装那么重的 IDE。VSCode 配置 C/C 环境时网上教程经常推荐 MinGW-w64但做 WinRT 蓝牙开发我不建议用 MinGW。微软的 WinRT 头文件、SDK 路径和链接库都是围绕 MSVC 设计的MinGW 下编译 WinRT 代码会遇到一堆稀奇古怪的兼容问题踩坑成本远大于省下的那点安装时间。具体安装清单VSCode装上 C/C、CMake Tools 两个扩展。Visual Studio Build Tools 2022不装 IDE只装“使用 C 的桌面开发”这一项。CMake用 Build Tools 自带版本或独立安装都可以。vcpkg后面用来安装 C/WinRT 支持库。Windows 10/11 的 Windows SDK装 Build Tools 时会自动带上确认版本不低于 1803。装完可以用命令行验证cl能输出版本号cmake --version正常vcpkg 在 PATH 里。2.2 用 CMake 把 C/WinRT 接进来C/WinRT 有两种接入方式一种是用 Windows SDK 自带的cppwinrt.exe手动生成头文件另一种是直接用 vcpkg 或 NuGet 安装。我推荐后者省事还能自动处理依赖版本。vcpkg 安装命名是Microsoft.Windows.CppWinRT。装完之后在 CMakeLists.txt 里这样写cmake_minimum_required(VERSION 3.20) project(win_ble_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Microsoft.Windows.CppWinRT CONFIG REQUIRED) add_executable(ble_demo main.cpp) target_link_libraries(ble_demo PRIVATE Microsoft.Windows.CppWinRT)这里需要注意不同版本的 CppWinRT 包提供的 CMake target 名称可能有差异。安装完 vcpkg 包之后建议先看一眼vcpkg_installed/x64-windows/share/microsoft.windows.cppwinrt/目录下的.cmake文件确认实际的 target 名别直接照抄网上的代码。如果你的项目是 Visual Studio 工程更简单项目属性里打开“管理 NuGet 包”搜索并安装 Microsoft.Windows.CppWinRTSDK 会自动配置好头文件路径和链接库几乎零配置。2.3 写一个最小验证工程环境通不通先用一个最小工程验证。main.cpp 做成控制台程序初始化 WinRT 线程池并创建一个 BLE 广播监听器能跑起来就说明 C/WinRT 链路已经打通#include iostream #include winrt/Windows.Foundation.h #include winrt/Windows.Devices.Bluetooth.Advertisement.h using namespace winrt; using namespace Windows::Devices::Bluetooth::Advertisement; int main() { init_apartment(); std::cout WinRT BLE environment OK std::endl; BluetoothLEAdvertisementWatcher watcher; watcher.ScanningMode(BluetoothLEScanningMode::Active); watcher.Received([](auto const, BluetoothLEAdvertisementReceivedEventArgs const args) { auto name args.Advertisement().LocalName(); if (!name.empty()) { std::cout Device: winrt::to_string(name) | Address: args.BluetoothAddress() std::endl; } }); watcher.Start(); std::cout Scanning... Press Enter to stop. std::endl; std::cin.get(); watcher.Stop(); return 0; }编译运行后如果能看到周围广播中的 BLE 设备名称环境就算彻底通了。这个最小工程也是后面所有功能的骨架。3. 完整实操扫描、连接、读特征值3.1 扫描周边 BLE 设备并拿到设备 ID上面代码已经能扫描到设备但实际连接时需要设备 ID而不是地址。BLE 这层有个容易忽略的点通过广播包拿到的BluetoothAddress()是 64 位地址而BluetoothLEDevice::FromIdAsync()要的是一个字符串形式的设备 ID。扫描回调里应该这样处理watcher.Received([](auto const, BluetoothLEAdvertisementReceivedEventArgs const args) { auto name args.Advertisement().LocalName(); auto address args.BluetoothAddress(); auto signal args.RawSignalStrengthInDBm(); std::wcout Lname name.c_str() L addr address L rssi signal std::endl; });设备名可能为空因为 BLE 外设不一定广播 LocalName。这时只能靠地址识别或者先保持扫描状态等待设备主动发出可连接的广播。扫描模式这里我用Active因为主动扫描会向设备发送扫描请求能拿到更多广播数据。缺点是耗电但对电脑端工具无所谓。3.2 从设备 ID 连接并枚举 GATT 服务拿到设备 ID 之后连接和枚举服务的代码流程是这样的#include winrt/Windows.Devices.Bluetooth.h #include winrt/Windows.Devices.Bluetooth.GenericAttributeProfile.h using namespace Windows::Devices::Bluetooth; using namespace Windows::Devices::Bluetooth::GenericAttributeProfile; // deviceId 是扫描阶段保存的字符串 BluetoothLEDevice device BluetoothLEDevice::FromIdAsync(deviceId).get(); if (device nullptr) { std::cout Connect failed std::endl; return; } GattDeviceServicesResult servicesResult device.GetGattServicesAsync().get(); for (GattDeviceService service : servicesResult.Services()) { std::wcout LService: service.Uuid().c_str() std::endl; GattCharacteristicsResult charsResult service.GetCharacteristicsAsync().get(); for (GattCharacteristic ch : charsResult.Characteristics()) { std::wcout L Characteristic: ch.Uuid().c_str() std::endl; } }这段代码里.get()是同步等待IAsyncOperation完成在控制台程序里完全没问题。切记不要在 UI 线程里这么写会发生死锁后面单独讲。实际联调时很多设备不会一次把所有服务和特征都列出来特别是需要加密访问的服务。如果你枚举出来的服务比外设实际支持的少先去检查配对状态而不是怀疑 SDK 有问题。3.3 读写特征与订阅 Notify 通知拿到目标特征 UUID 之后主要就是三件事读、写、订阅通知。读取特征值GattReadResult readResult ch.ReadValueAsync().get(); if (readResult.Status() GattCommunicationStatus::Success) { auto buffer readResult.Value(); // buffer 是 IBuffer需要转成字节数组 }写入特征值#include winrt/Windows.Storage.Streams.h using namespace Windows::Storage::Streams; DataWriter writer; writer.WriteBytes(std::vectoruint8_t{0x01, 0x02, 0x03}); GattCommunicationStatus status ch.WriteValueAsync(writer.DetachBuffer()).get(); if (status GattCommunicationStatus::Success) { std::cout Write OK std::endl; }订阅通知ch.ValueChanged([](auto const, GattValueChangedEventArgs const args) { auto buffer args.CharacteristicValue(); // 这里处理从设备推送过来的数据 }); GattCommunicationStatus subStatus ch.WriteClientCharacteristicConfigurationDescriptorAsync( GattClientCharacteristicConfigurationDescriptorValue::Notify).get();这里有个经验写某个特征前一定先确认这个特征的属性。如果设备只支持WriteWithoutResponse你用默认的WriteWithResponse写部分设备会直接静默丢包表现为“写返回成功但设备没反应”。可以通过ch.CharacteristicProperties()提前判断。3.4 一个同步封装技巧C/WinRT 的异步 API 在控制台程序里用.get()很顺手但如果你要在一个后台线程里跑整套“扫描-连接-枚举”流程建议把关键步骤拆成函数每个函数内部用get()串行执行避免协程状态机把代码搞复杂。我个人习惯是做一个简单的包装类型templatetypename T T WaitFor(Windows::Foundation::IAsyncOperationT const op) { return op.get(); }这样主流程能保持同步风格可读性好很多。缺点是你无法在等待过程中响应取消但做调试工具和上位机足够用了。4. 我踩过的坑连接失败、UUID、MTU、绑定(bond)4.1 设备扫不到大概率不是代码问题遇到“扫描不到设备”时先别调代码。按这个顺序排查确认 Windows 蓝牙开关真的开了并且适配器支持 BLE。可以在“设置 - 蓝牙和其他设备”里看很多电脑的蓝牙适配器只支持传统蓝牙老 USB 棒尤其常见。打开“设置 - 隐私和安全性 - 蓝牙”确认“允许应用访问蓝牙”是打开的。这个权限没开时设备枚举是能跑但FromIdAsync会失败。确认外设正在广播而且广播类型是可连接的。有的设备默认只广播不可连接事件手机能看到名字但 Windows 连不上。如果设备之前被 Windows 配对过先去“蓝牙和其他设备”里删掉旧记录否则可能一直连到缓存地址。我曾经花了一下午调扫描逻辑最后发现是设备广播周期设成了 2 秒一次扫描窗口太短没碰上。真不是代码问题。4.2 UUID 大小端和 GATT 缓存BLE 协议里UUID 以小端序在线上传输但 Windows API 用的是标准 GUID 格式。两者不一致时会出现“我从设备端手册看到的是FFE0Windows 枚举出来却显示E0FF”。短 UUID 用官方辅助函数转换最稳#include winrt/Windows.Devices.Bluetooth.h GUID guid BluetoothUuidHelper::FromShortId(0xFFE0); std::wcout guid.c_str() std::endl;另外 Windows 会对 GATT 服务做缓存。设备固件升级后新增了服务或者你改了外设的 Service UUIDWindows 这边可能还拿到旧列表。处理办法是先关闭设备电源再重新扫描如果还不行把 Windows 蓝牙适配器禁用再启用一次强制清掉缓存。4.3 MTU、分包与长数据读写MTU 是 BLE 链路层单包最大传输单元。之前有个项目设备端一次最多发 200 字节Windows 这边MaxPduSize只有 23结果数据被拆成好多个通知包我在应用层拼包拼得很痛苦。Windows 的 GATT 栈会自动和设备协商 MTU你拿到的通知回调有可能是多包拼接后的完整数据也有可能是分包的这取决于设备和系统版本auto gattSession GattSession::FromDeviceIdAsync(device.BluetoothDeviceId()).get(); uint16_t maxPdu gattSession.MaxPduSize(); std::cout Max PDU size: maxPdu std::endl;通过这个值你至少能知道链路层分包上限。写数据时同理如果一次性写入超过 MTU 的大块数据WriteWithResponse在很多外设上会失败改成WriteWithoutResponse并自己做分包成功率会高很多。4.4 配对与绑定(bond)设备连上但找不到服务时先看这个“连接成功但枚举不到服务”是 BLE 开发最经典的问题之一。多半原因就是这个特征服务需要加密而你的设备还没配对。Windows 配对有几种模式最简单的是弹窗确认。代码里可以这样触发if (device.Pairing().Status() ! DevicePairingStatus::Paired) { auto pairStatus device.Pairing().PairAsync().get(); std::cout Pair status: static_castint(pairStatus) std::endl; }但这里有个大坑有些 BLE 外设设计成“主动连接后必须立即发起配对”如果先连接再枚举枚举就超过安全超时了。正确顺序是连接成功后立刻判断是否已配对未配对就先配对然后再枚举服务。还有一种更隐蔽的情况设备需要的不是普通配对而是结合授权码确认的绑定(bond)。比如某些 BLE 数字钥匙、健康设备Windows 会弹 PIN 输入框设备端显示屏上给一串数字你必须在 Windows 侧输入。如果设备端不支持输入需要把设备固件里的配对策略改成 Just Works无显示无输入否则 Windows 侧永远配对不成功。4.5 和其他模组联调要点如果你在 Windows 上连的是 ESP32 这类开发板有两个高频问题。一是 ESP32 的 BLE 广播名可能没设或超长Windows 列表里只显示 MAC 地址。先在广播数据里把name字段填好别只填 Manufacturer Data。二是连接参数。ESP32 默认连接间隔可能很密Windows 那边优化策略会经常断开。在 ESP32 的 BLE 初始化里把 connection interval 放宽一些比如 30ms 到 50ms稳定性会明显改善。如果是 BLE 转串口模块做 Modbus RTU 透传比如很多工业级透传模块GATT 服务的 UUID 基本都遵循 Nordic UART Service 的惯例一个 RX 特征一个 TX 特征一个通知特征。这类模块读写套路完全一致你只要把 service UUID 和特征 UUID 从模块手册里抄出来套用上面的代码就能通。5. 更省事的路子第三方库与免开发设备5.1 SimpleBLE 与 Qt Bluetooth如果只是做一个偶尔用用的小工具不想深入研究 WinRT 细节可以看下 SimpleBLE。它在 Windows 底层还是调用 WinRT但对外暴露的是统一 C 接口支持 Linux 和 macOS代码风格比 WinRT 清爽不少。SimpleBLE 的问题在于版本更新快API 变动大网上示例很多对不上旧版。如果你决定用它直接锁定一个 release 版本别跟着 main 分支跑。Qt Bluetooth 也提供 BLE 支持但 Windows 后端依赖 WinRT文档相对偏少调试起来不透明。我只有在项目本来就用 Qt 做 UI 的时候才会考虑顺手用 Qt 蓝牙否则不建议专门为 BLE 引入 Qt。5.2 串口透传模块这种情况不需要写 BLE最后再提醒一次如果你手里的 BLE 模块支持串口透传并且官方提供了 Windows 虚拟串口驱动那就别再自己实现 BLE 协议栈了。这类模块在系统里会以 COM 口的形式出现你用传统的CreateFile打开串口读写串口和调制解调器控制线就能完成所有数据交互。好处很明显联调更快代码更简单也不需要处理 MTU、分包、配对这一类问题。唯一要注意的是虚拟串口的数据通过 BLE 传输实际吞吐和丢包行为跟普通有线串口不一样给通信协议加上帧头和校验遇到异常重发即可。我在实际项目里见过太多团队明明设备支持虚拟串口却非要自己写一套 BLE 底层最后光调试就折腾了一两个月。工具为项目服务能少写就少写。还有个个人习惯分享下任何新的 BLE 外设我都会先拿官方调试助手在手机上把服务、特征、读写属性、配对模式全部摸清楚再回到 Windows 代码里做对接。手机调试时的操作手感比命令行直观得多能帮你提前避开很多“到底是设备问题还是电脑问题”的两难处境。Windows 端代码一旦写完后续出问题基本就锁定在系统权限、MTU 和配对这三个点上排查范围小很多。本文还有配套的精品资源点击获取
返回列表