
简介面向希望在 Windows 平台用 C/C 直接操控低功耗蓝牙的开发者这套工程示例以 WinRT 接口为核心演示 BLE 设备枚举、广播扫描、服务与特征值读写、通知订阅等常见流程适合有一定 C 基础、需在桌面端集成蓝牙通信或排查连接问题的初中级工程师参考。压缩包共 11 个文件约 10KB含 4 个 .h 头文件、4 个 .cpp 源文件及 vcxproj 工程、filters 筛选配置和 .user 用户配置可直接用 Visual Studio 打开并按模块拆分学习头文件定义接口与数据结构源文件承担设备句柄管理与通信逻辑便于对照修改。目前已有 5966 人学习下载。借助这份可编译的工程骨架读者能理清 WinRT 异步调用与 GATT 交互的组织方式掌握扫描回调、特征值操作和异常处理思路并以此为起点扩展自己的 BLE 上位机或设备调试工具减少从零搭建工程的时间。1. 从一次“扫不到设备”的现场说起去年帮朋友排查一个产线工装场景很典型一个 BLE 温湿度传感器用手机 App 打开就出数据换成他们自己写的 Windows 桌面程序C/C那边调半天扫描列表一直是空的。换成别的 USB 转串口方案成本又上去了。问题不在硬件而在 Windows 这套BLE蓝牙 API 的分层广播扫描、连接、GATT 读写是三段独立的状态机任何一段的缓存或者生命周期没管对表现就是“什么都没有”。这份资源就是围绕这条链路做的可编译示例用C/WinRT拉起广播监听解析 AD 结构按地址连上设备枚举服务和特征写入指令并订阅 Notify 回包。它适合已经会用 C 但被 Windows 蓝牙折腾过的人也适合想把桌面端接到一个 BLE 模组上的嵌入式同事。往下我会按项目实际的目录和调用顺序拆不讲概念堆砌直接说这段代码为什么这么写、哪里最容易翻车。2. 技术选型与工程骨架三条 API 路径怎么选2.1 C/WinRT、bthledef、SetupAPI 的能力边界Windows 上做 BLE能走的路其实不止一条新人最容易在选型阶段就栽跟头。项目最终采用的是C/WinRT这一套也就是头文件winrt/Windows.Devices.Bluetooth.Advertisement.h、winrt/Windows.Devices.Bluetooth.GenericAttributeProfile.h这种投影方式。原因是它把广播包、服务、特征都做成了对象模型异步用co_await串起来可读性比裸结构体好太多。另外两条路也得知道存在不然遇到 WinRT 覆盖不到的场景会卡住。bthledef.h那套传统 Win32 蓝牙 API函数名是BluetoothGATTGetServices、BluetoothGATTGetCharacteristics这种全靠调用方预分配缓冲、传BTH_LE_GATT_SERVICE结构体数组代码量大但胜在可控某些老系统或者需要精细控制重叠 IO 的场合还得用它。SetupAPI配合GUID_BLUETOOTHLE_DEVICE_INTERFACE主要用于枚举设备接口和拿CreateFile的句柄配对和诊断有时候绕不开。能力C/WinRT 蓝牙 APIbthledef 传统 APISetupAPI 设备接口广播扫描BluetoothLEAdvertisementWatcher字段已解析需自己走 WSA 查询再解包不支持GATT 服务枚举对象模型co_await直接拿预分配缓冲 两段式调用不支持特征值通知ValueChanged事件注册事件 消息循环不支持配对管理DeviceInformation.PairingBluetoothAuthenticateDeviceEx部分可做回调线程线程池投递自己维护自己维护系统门槛Win10 1809 起较完整Win8 起一直有选型结论很直接日常的扫描 连接 读写一律先用C/WinRT只有当你要拿某些 WinRT 没暴露的底层字段或者需要在没有 C/WinRT 运行时的极简环境里跑才退回bthledef。项目里保留了一个win32_fallback.cpp的小文件就是给这种降级留的口子。2.2 目录结构与 CMake 构建配置工程结构照着职责切不按“头文件/源文件”切这样后面接业务协议时改动面小ble_demo/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp # 入口init_apartment 阻塞 │ ├── ble_scanner.h/.cpp # 广播监听与包解析 │ ├── ble_device.h/.cpp # 连接、断线重连、设备生命周期 │ ├── gatt_client.h/.cpp # 服务/特征枚举、读写、Notify │ ├── protocol_codec.h/.cpp # 业务帧编解码与 BLE 层解耦 │ └── log.h # 轻量日志带线程 id └── test/ └── codec_test.cpp # 纯逻辑单测不依赖硬件CMakeLists.txt里有两个关键点链接windowsapp.lib以及开启协程支持。C/WinRT的异步返回的是IAsyncOperation没有/await或者 C20 协程就co_await不了。cmake_minimum_required(VERSION 3.20) project(ble_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(ble_demo src/main.cpp src/ble_scanner.cpp src/ble_device.cpp src/gatt_client.cpp src/protocol_codec.cpp ) # windowsapp.lib 是 WinRT 投影的运行库缺了会在链接期报一堆 unresolved target_link_libraries(ble_demo PRIVATE windowsapp) # /await 用于协程和 /std:c20 同时开时若冲突退到 /std:clatest target_compile_options(ble_demo PRIVATE /await /EHsc /W4)这里有个血泪经验如果编译器版本偏旧/std:c20和/await会打架报的是语法错误而不是明确提示看着像代码写错了实际是开关组合问题。我一般先用/std:clatest验证能否编过再往下调标准版本。提示用vcpkg的话不需要额外装包C/WinRT的头文件随 Windows SDK 一起提供装好 SDK 就能#include winrt/...。2.3 最小可跑骨架初始化与进程存活main.cpp做的事情不多但每一行都不能少。winrt::init_apartment必须在任何 WinRT 调用之前执行一个线程只能调一次重复调用会抛异常。项目选的是multi_threadedMTA因为蓝牙回调本来就来自线程池强行绑 STA 反而要自己做消息泵。#include winrt/base.h #include winrt/Windows.Foundation.h #include iostream // 前向声明真正的会话逻辑在 ble_device.cpp 里 winrt::fire_and_forget run_session(std::string target_name); int main() { // 初始化 WinRT 运行时MTA 模式适配线程池回调 winrt::init_apartment(winrt::apartment_type::multi_threaded); run_session(TH-Sensor); // 协程是 fire_and_forget 的主线程必须活着否则进程退出协程被掐断 std::string line; std::getline(std::cin, line); return 0; }fire_and_forget表示这个协程不需要调用方等待结果异常也会被吞掉所以内部一定要自己 try/catch 并把错误写日志否则出问题时就是个黑匣子什么都看不到。最后用std::getline卡住主线程是最省事的保活方式如果是 GUI 程序换成消息循环即可道理一样。3. 广播扫描BluetoothLEAdvertisementWatcher 的解析链路3.1 AD 结构解析从原始段到可用字段广播包在 Windows 这层已经被拆成了DataSections()每一段带DataType()和Data()。但DataType是原始 AD Type 数值业务字段还是得自己按类型归位。项目里的解析函数统一输出一个扁平结构后面过滤和展示都基于它。struct BleAdvRecord { uint64_t address 0; int16_t rssi 0; std::string local_name; std::vectoruint8_t manufacturer; // AD type 0xFF std::vectoruint8_t service_data; // AD type 0x16 }; BleAdvRecord parse_adv(winrt::Windows::Devices::Bluetooth::Advertisement:: BluetoothLEAdvertisementReceivedEventArgs const args) { BleAdvRecord rec; rec.address args.BluetoothAddress(); // 48 位 MAC随机地址会变 rec.rssi args.RawSignalStrengthInDBm(); rec.local_name winrt::to_string(args.Advertisement().LocalName()); for (auto const sec : args.Advertisement().DataSections()) { auto type sec.DataType(); auto data sec.Data(); std::vectoruint8_t buf(data.begin(), data.end()); if (type 0xFF) { // 厂商自定义数据常见于传感器 rec.manufacturer std::move(buf); } else if (type 0x16) { // Service Data带 16 位 UUID 前缀 rec.service_data std::move(buf); } // 0x09 完整本地名、0x08 短名已由 LocalName() 处理不必重复解析 } return rec; }几个 AD Type 值得记住0x01是 Flags0x09完整本地名0x08短名0x03是 16 位 Service UUID 列表0x16是 Service Data0xFF是厂商自定义。很多国产模组把电量、版本号塞在0xFF里前两个字节是厂商 ID小端后面才是自定义内容——这个字节序如果搞反解析出来的数值会很离谱属于典型翻车点。args.BluetoothAddress()拿到的是uint64_t实际只有低 48 位有效。打印成AA:BB:CC:DD:EE:FF时要逐字节右移别直接std::to_string那个结果对不上任何工具。3.2 扫描参数Active 与 Passive 的取舍默认构造出来的BluetoothLEAdvertisementWatcher是 Passive 模式只收广播包不发扫描请求。如果设备的名称放在 Scan Response 里而不是主广播包里Passive 模式下LocalName()会是空的你会以为设备没名字其实是没问它。using namespace winrt::Windows::Devices::Bluetooth::Advertisement; BluetoothLEAdvertisementWatcher watcher; // Active 会发 SCAN_REQ能拿到 Scan Response 里的名字和额外字段 watcher.ScanningMode(BluetoothLEScanningMode::Active); // 信号强度过滤低于 -75dBm 的直接不投递减少回调压力 watcher.SignalStrengthFilter().InRangeThresholdInDBm(-75); watcher.SignalStrengthFilter().OutOfRangeTimeout(std::chrono::seconds(5)); watcher.SignalStrengthFilter().SamplingInterval(std::chrono::milliseconds(500)); watcher.Received([](BluetoothLEAdvertisementWatcher const, BluetoothLEAdvertisementReceivedEventArgs const args) { auto rec parse_adv(args); // 这里拿到的回调在线程池线程禁止直接碰界面 log_info(addr%012llX rssi%d name%s, rec.address, rec.rssi, rec.local_name.c_str()); }); watcher.Start();SamplingInterval控制的是信号强度采样的聚合窗口不是扫描周期名字容易误导。设得太小回调会很密集设得太大 RSSI 又滞后。500ms 是个折中值产线近距离识别场景可以再压到 200ms。如果设备支持扩展广播Extended Advertising在 Win10 2004 以后可以打开AllowExtendedAdvertisements(true)否则大包广播会被截断你只能收到前面一小段。这个开关打不开的时候不会报错只是数据不全非常隐蔽。3.3 从广播地址到连接对象扫描到地址只是第一步真正连上要调BluetoothLEDevice::FromBluetoothAddressAsync。这里有两个必须知道的限制一是地址必须是 48 位的传错位宽会直接抛异常二是随机地址设备休眠后会换地址你缓存下来的地址过一会儿就连不上了。winrt::fire_and_forget connect_by_address(uint64_t addr) { using namespace winrt::Windows::Devices::Bluetooth; auto device co_await BluetoothLEDevice::FromBluetoothAddressAsync(addr); if (!device) { log_error(connect failed, device is null); co_return; } // 刚连上时状态可能还是 Disconnected需要等一次 GATT 访问把它拉起来 log_info(connected%d, (int)device.ConnectionStatus()); // 用完必须 Close否则句柄泄漏同一设备第二次连会失败 device.Close(); }FromBluetoothAddressAsync返回的device为 null通常意味着设备已经离线或者地址是随机地址且已失效不是代码逻辑错。另一个容易忽略的点是ConnectionStatus()在刚拿到对象时常常还是Disconnected真正触发连接的是后面第一次 GATT 访问。这个设计导致很多人在这一步加了个“连接成功”的判断结果永远进不去分支。4. GATT 交互服务发现、特征读写与 Notify 订阅4.1 服务与特征枚举的缓存策略GetGattServicesAsync有个BluetoothCacheMode参数取值Cached和Uncached。第一次连上设备时如果用Cached很可能拿到空列表或者上一次的旧结构因为系统缓存还没建立。稳妥做法是首次强制Uncached之后可以走缓存提速。winrt::fire_and_forget enumerate(uint64_t addr) { using namespace winrt::Windows::Devices::Bluetooth; using namespace winrt::Windows::Devices::Bluetooth::GenericAttributeProfile; auto device co_await BluetoothLEDevice::FromBluetoothAddressAsync(addr); if (!device) co_return; // 首次必须 Uncached强制系统去设备上真实拉一遍服务表 auto svcResult co_await device.GetGattServicesAsync(BluetoothCacheMode::Uncached); if (svcResult.Status() ! GattCommunicationStatus::Success) { log_error(get services failed, status%d, (int)svcResult.Status()); device.Close(); co_return; } for (auto const svc : svcResult.Services()) { auto uuid winrt::to_string(winrt::to_hstring(svc.Uuid())); log_info(service %s, uuid.c_str()); auto charResult co_await svc.GetCharacteristicsAsync(BluetoothCacheMode::Uncached); for (auto const ch : charResult.Characteristics()) { auto props ch.CharacteristicProperties(); log_info( char %s read%d write%d notify%d, winrt::to_string(winrt::to_hstring(ch.Uuid())).c_str(), (int)props.HasFlag(GattCharacteristicProperties::Read), (int)props.HasFlag(GattCharacteristicProperties::Write), (int)props.HasFlag(GattCharacteristicProperties::Notify)); } } device.Close(); }代码里用CharacteristicProperties()先判断能力再操作比拿到特征就写要稳。有些特征的Write属性只支持WriteWithResponse你调了WriteWithoutResponse会返回Unreachable报错信息很难懂。判断属性再选写入方式能省掉一轮排查。4.2 读写与 CCCD 订阅订阅通知分两步注册ValueChanged回调然后往 CCCDClient Characteristic Configuration Descriptor写Notify。只注册回调不写描述符设备根本不会推数据这是初学者最常见的“订阅了没反应”。winrt::fire_and_forget subscribe(GattCharacteristic ch) { using namespace winrt::Windows::Devices::Bluetooth::GenericAttributeProfile; // 回调来自线程池不要在这里做耗时操作 auto token ch.ValueChanged([](GattCharacteristic const, GattValueChangedEventArgs const args) { auto buffer args.CharacteristicValue(); winrt::Windows::Storage::Streams::DataReader reader winrt::Windows::Storage::Streams::DataReader::FromBuffer(buffer); std::vectoruint8_t payload(reader.UnconsumedBufferLength()); reader.ReadBytes(payload); log_info(notify %zu bytes, payload.size()); // 交给业务层解帧别在这里解析协议 }); // 写 CCCD告诉设备开始推 auto status co_await ch.WriteClientCharacteristicConfigurationDescriptorAsync( GattClientCharacteristicConfigurationDescriptorValue::Notify); if (status ! GattCommunicationStatus::Success) { log_error(cccd write failed, status%d, (int)status); } }DataReader::FromBuffer拿到的数据长度受 MTU 限制默认 20 字节超过就得靠设备端分包。CCCD 写入失败最常见的原因是特征不支持 Notify只支持 Indicate这时候要换成Indicate枚举值并且每条消息都要回 ACK吞吐会明显下降。4.3 长包分包与 MTU 协商BLE 默认的 ATT MTU 是 23去掉 3 字节头就是 20 字节有效载荷。要传更长的数据得先协商 MTU再按协商结果分包。winrt::fire_and_forget negotiate_mtu(BluetoothLEDevice device) { using namespace winrt::Windows::Devices::Bluetooth::GenericAttributeProfile; auto session co_await GattSession::FromDeviceIdAsync(device.BluetoothDeviceId()); // MaxPduSize 是当前协商结果不是可设置值实际 MTU 要减去 3 log_info(max pdu size %d, payload %d, (int)session.MaxPduSize(), (int)session.MaxPduSize() - 3); session.Close(); }要注意MaxPduSize是只读的Windows 不提供主动发起 MTU 交换的接口实际值由设备和系统协商决定。所以分包逻辑不能写死 20要按运行时的值算。项目的protocol_codec.cpp里把分片和重组做成了纯函数不碰任何 WinRT 类型这样在 PC 上就能跑单测不用连硬件改协议时效率高很多。5. 避坑与常见问题排查只在真机上复现的那些坑5.1 扫描阶段的三类怪现象现象一手机能扫到桌面程序列表为空。原因八成是ScanningMode用了 Passive而设备把名称和关键字段放在 Scan Response 里。解决是把模式改成Active如果还是空再检查SignalStrengthFilter的InRangeThresholdInDBm是不是设得太高比如设成-40隔着一堵墙的设备全被过滤掉了。现象二设备明明在广播回调却几分钟才来一次。这是系统对广播监听的限流同一时刻注册的 watcher 太多会被降频。解决是全局只维护一个BluetoothLEAdvertisementWatcher实例把结果分发出去别每个模块各建一个。现象三地址每次扫描都不一样。设备用的是随机私有地址且没有做地址绑定。这类设备不能靠地址做唯一标识得从厂商数据段里取序列号或者从设备名里提取。硬用地址做 key重连逻辑必然失效。5.2 连接与 GATT 阶段的翻车点现象四第一次能连第二次FromBluetoothAddressAsync一直返回 null。原因是上一轮的BluetoothLEDevice对象没Close()底层句柄还占着。解决很机械所有BluetoothLEDevice、GattSession用完必须Close()用 RAII 包一层最省心。这个坑我踩过不止一次表现就是“运行一次好的再运行就不行”重启程序又恢复正常很有迷惑性。现象五服务列表拿到了特征列表是空的。多数是缓存问题用Cached模式读到了半成品。解决是首次访问强制Uncached或者在连接后先调一次GetGattServicesAsync(Uncached)把缓存刷出来再走后续逻辑。现象六ValueChanged回调里访问界面直接崩。回调线程是线程池线程不是创建 watcher 的线程。解决是用DispatcherQueue或者PostMessage把数据切回目标线程别在回调里直接改控件。这个错误在调试器里不一定崩Release 下才炸属于典型的玄学问题。注意打包成 MSIX 分发时清单里必须声明bluetooth能力否则 API 调用会静默失败不报权限错误只是拿不到数据。6. 二次开发把 BLE 层和业务协议彻底剥开接手这类代码做二次开发最容易犯的错是直接在ValueChanged回调里写协议解析。一开始只有三条指令还行等指令涨到二十条、还要加超时重试和状态机那坨代码就没人敢动了。我在这个项目里坚持的分层是gatt_client只负责“把字节送出去、把字节收回来”protocol_codec只负责字节和结构体之间的转换中间用一个请求队列串起来。请求队列的关键是给每条指令配一个序号和超时。发送时把序号写进帧头收到 Notify 后按序号匹配std::promise超时就置为失败。这样上层调用写起来是同步的底层还是异步的。struct PendingRequest { uint8_t seq; std::chrono::steady_clock::time_point deadline; std::promisestd::vectoruint8_t promise; }; // 发送侧分配序号、登记、写特征 std::futurestd::vectoruint8_t send_command(GattCharacteristic ch, std::vectoruint8_t frame) { auto req std::make_sharedPendingRequest(); req-seq next_seq(); req-deadline std::chrono::steady_clock::now() std::chrono::seconds(2); pending_[req-seq] req; frame[1] req-seq; // 帧头第二字节放序号双方约定 co_await ch.WriteValueWithResultAsync(to_buffer(frame)); return req-promise.get_future(); }对应地回调里按frame[1]找到pending_里的条目set_value之后擦除。超时检查放在一个独立的定时线程里扫表到点还没回的条目直接set_exception。这套结构加进来之后新增一条指令只需要改protocol_codec里的一个分支BLE 层一行不动。验证方法我一般分三层走先用codec_test.cpp在 PC 上跑编解码的单元测试不接硬件再用一个已知能工作的手机 App 对比同一设备返回的原始字节确认解析没错最后才上真机跑长稳至少挂两小时看有没有句柄泄漏和内存增长。跳过前两层直接上真机出问题时你分不清是协议错、BLE 层错还是设备固件错。改这套代码还有个小技巧把BluetoothCacheMode和超时时间做成配置项而不是写死。不同厂商的模组响应速度差异很大有的 200ms 就回有的要 1.5 秒写死 500ms 超时在某个型号上必然误报失败。做成配置后换模组只改一个数字。从那以后我每次接 BLE 项目都强制先把“扫描 → 连接 → 枚举 → 订阅 → 收发”这条链路写成最小可跑示例跑通了再接业务逻辑绝不把两件事混在一起调试。希望帮到你。本文还有配套的精品资源点击获取