简介这份面向Windows平台C/C开发者的BLE蓝牙操作工程示例基于WinRT蓝牙API封装适合需要为桌面应用加入低功耗蓝牙扫描、设备连接及GATT服务与特征读写、通知订阅能力的初学者和中级开发者参考。压缩包共11个文件以4个.h头文件和4个.cpp源文件为核心另含Visual Studio工程文件及筛选器、用户配置整体约10KB体积轻便便于快速导入现有解决方案或独立编译验证。目前已有5966人学习下载说明其在Windows BLE入门与工程实践场景中具有一定参考价值。内容围绕BLEComm模块组织给出蓝牙句柄封装、WinRT头文件引入和工程结构示例读者可据此理解Windows下BLE调用链、工程依赖配置与模块划分方式并在此基础上扩展设备枚举、连接管理、特征值读写与通知回调等二次开发工作减少从零搭建环境与摸索API的成本。1. 从一台读不出数的温湿度模组说起Window BLE 蓝牙在 C/C 里怎么开口某开发者接了个产测工装的活产线上每块板子焊一颗 BLE 温湿度模组出厂前得读一次校准值写进数据库。手机 App 一扫就出数换到 Windows 上写个 C 上位机插上 USB 转串口却发现根本没有 COM 口——那颗模组是纯 BLE不走经典蓝牙 SPP设备管理器里自然看不到串口。这就是 Window BLE 蓝牙 C/C 开发最常见的起点设备明明在广播你的程序却不知道该怎么跟它说话。这份代码解决的就是这一段在 Windows 上用 C/C 完成 BLE 设备的广播扫描、连接建立、GATT 服务与特征值发现、读写和通知订阅跑通一条完整的采集链路。做工业采集上位机、测试工装、外设产测、给自家硬件写 PC 端配网工具的人都能直接拿来改。2. 选型与骨架C/WinRT 与 Win32 蓝牙接口的分工边界动手写第一行之前得先把 API 路线定下来。Windows 上操作 BLE 不是只有一条路选错了后面每一步都在还债。这一章先把选型的账算清楚再把工程骨架和线程模型搭起来后面三章的代码才有地方落。2.1 两条 API 路线谁负责发现谁负责传输第一条路是 WinRT 投影命名空间在Windows.Devices.Bluetooth和Windows.Devices.Bluetooth.GenericAttributeProfile下用 C/WinRT 调用。它把 GATT 客户端该有的一切都封好了服务发现、特征值读写、CCCD 配置、连接状态事件异步全部是IAsyncOperationC20 下直接co_await写起来接近同步代码的直觉。第二条路是老 Win32 那套头文件bthledef.h配bluetoothapis.h函数是BluetoothGATTGetServices、BluetoothGATTGetCharacteristics、BluetoothGATTWriteValue、BluetoothGATTRegisterEvent这一串。它是 C 风格句柄加事件回调Windows 8 就有了不依赖 WinRT 运行时理论上能塞进更老的环境。代价是文档薄、示例少、句柄生命周期全靠自己管服务发现要来回调好几次才能把服务、特征值、描述符三层拼齐。维度WinRTGenericAttributeProfileWin32bthledef.h最低系统Windows 10 起稳定可用Windows 8 起调用形态C/WinRT 投影 协程异步C 句柄 事件回调服务发现GetGattServicesAsync一次拿全三层多次枚举通知订阅写 CCCD ValueChanged事件BluetoothGATTRegisterEvent链接依赖windowsapp.lib无额外运行时适用场景新项目、上位机、产测工具老系统兼容、无 WinRT 环境新项目我一般直接走 WinRT。不是因为它先进而是因为 BLE 的坑本来就多再用一套半文档化的 C 接口去踩排查成本会翻倍。Win32 那条路留着给必须兼容旧系统的场景本文后面的代码全部基于 C/WinRT。2.2 工程骨架把 C/WinRT 塞进 CMake 控制台项目C/WinRT 的投影头文件跟着 Windows SDK 走不在编译器的默认搜索路径里CMake 工程得手动挂上去。另外 C20 下co_await是原生支持的不用再单独加/await开关这点比 C17 省事。cmake_minimum_required(VERSION 3.20) project(ble_probe LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) # C20 下协程原生可用省掉 /await set(CMAKE_CXX_STANDARD_REQUIRED ON) # SDK 自带的 cppwinrt 投影头不在默认 include 路径需要手动拼 if (NOT DEFINED ENV{WindowsSdkDir}) message(FATAL_ERROR 请在 VS 开发者命令提示符里执行 cmake) endif() string(REGEX REPLACE \\\\$ SDK_VER $ENV{WindowsSDKVersion}) add_executable(ble_probe src/main.cpp src/ble/ble_scanner.cpp src/ble/ble_session.cpp src/util/hex.cpp src/util/logger.cpp ) target_include_directories(ble_probe PRIVATE src $ENV{WindowsSdkDir}Include/${SDK_VER}/cppwinrt ) target_compile_options(ble_probe PRIVATE /EHsc /W4 /permissive-) target_link_libraries(ble_probe PRIVATE windowsapp) # WinRT 导入库windowsapp.lib是必须的不链它会在一堆RoGetActivationFactory符号上炸。/permissive-也别省C/WinRT 的协程模板推导在宽松模式下偶尔会给出莫名其妙的报错。WindowsSDKVersion环境变量结尾带反斜杠直接拼进路径会出现双斜杠所以上面用正则去了一次。入口函数里第一件事是初始化公寓。BLE 的回调来自系统线程池进程进 MTA 最省心别用 STA 然后指望回调还回到主线程。#include winrt/base.h #include winrt/Windows.Foundation.h #include chrono #include cstdio #include ble/ble_scanner.h int main() { // 必须最先调用BLE 回调来自线程池进程走 MTA winrt::init_apartment(winrt::apartment_type::multi_threaded); BleScanner scanner; scanner.Start([](BleDeviceInfo const info) { printf(%012llX rssi%4d name%s\n, static_castunsigned long long(info.address), info.rssi, winrt::to_string(info.name).c_str()); }); std::this_thread::sleep_for(std::chrono::seconds(10)); scanner.Stop(); return 0; }init_apartment一定要放在任何 WinRT 对象构造之前否则第一次BluetoothLEAdvertisementWatcher()就会抛RPC_E_CHANGED_MODE。scanner.Start传进去的是一个回调扫描线程拿到广播包后直接回调这里只做打印真做业务时应该把数据丢进队列而不是就地处理。2.3 目录结构扫描、会话、协议解析三层拆开BLE 代码最容易写成一坨扫描、连接、解析全塞在main.cpp里加第二款设备时就得推翻重来。按职责分三层后面改起来才不疼。src/ ├── main.cpp ├── ble/ │ ├── ble_types.h // BleDeviceInfo、连接状态、错误码 │ ├── ble_scanner.h/.cpp // 只负责发现不连接 │ ├── ble_session.h/.cpp // 一条 GATT 会话连接、发现、读写、订阅 │ └── gatt_profiles.h/.cpp // UUID → 业务字段的解析表 └── util/ ├── logger.h/.cpp // 带时间戳的线程安全日志 └── hex.h/.cpp // hex dump对空口抓包时救命扫描层只管“有哪些设备在广播”输出一个BleDeviceInfo结构体不持连接。会话层管“跟某一台设备把链路建起来并保持住”对外暴露读写订阅接口不关心数据是什么意思。协议解析层管“这串字节里第几个是温度”纯函数好测。hex.cpp看着不起眼但调试通知数据时没它基本没法干活——设备发过来的原始字节和手机端调试工具对不上第一反应都是先 dump 出来肉眼比。2.4 线程模型MTA 公寓、回调线程和串行队列这三层的线程边界必须先说清楚否则后面一定翻车。扫描回调、连接状态变化、特征值通知这三个事件都来自 WinRT 的线程池线程具体是哪一个不确定也不保证是同一个。你的主线程可能正在读特征值通知回调同时就在另一个线程上跑。所以规则定两条第一回调里只做最轻的事把数据拷贝出来丢进队列解析和写库交给业务线程第二所有发往设备的 GATT 请求串成一条队同一时刻只允许一个请求在空中。第二条不是洁癖GATT 客户端在协议层就不支持并发两个co_await同时飞出去返回ProtocolError的概率相当高。// 串行执行器保证同一时刻只有一个 GATT 请求在空中 class GattQueue { public: // Post 只能在同一个业务线程调用避免 m_busy 竞争 void Post(std::functionwinrt::Windows::Foundation::IAsyncAction() job) { m_pending.push(std::move(job)); if (!m_busy) { m_busy true; Pump(); } } private: winrt::fire_and_forget Pump() { while (!m_pending.empty()) { auto job std::move(m_pending.front()); m_pending.pop(); try { co_await job(); // 挂起期间 m_busy 保持 true } catch (winrt::hresult_error const e) { Logger::Warn(gatt job failed: 0x%08X, e.code().value); } } m_busy false; // 队列空了才放开 } std::queuestd::functionwinrt::Windows::Foundation::IAsyncAction() m_pending; bool m_busy false; };Pump是fire_and_forget协程第一次co_await挂起时函数返回但m_busy已经是true后续Post只入队不重入。队列排空后m_busy复位。这个实现的前提是Post只在单线程调用多线程投递的话得换成concurrency::unbounded_buffer或者加锁。参数上唯一要注意的是job必须是可拷贝构造的std::function捕获this时要保证对象活到任务执行完。3. 设备发现与连接从一条广播包到一条可用的 GATT 会话扫描和连接是两件独立的事很多代码把它们揉在一起结果就是连上之后扫描停不掉、断开之后重连又得重扫。这一章拆开讲怎么用广播包把目标设备筛出来怎么把链路建起来并且让它别断。3.1 广播扫描BluetoothLEAdvertisementWatcher 的过滤条件怎么写枚举 BLE 设备有两条路DeviceWatcher和BluetoothLEAdvertisementWatcher。前者枚举的是系统已知设备配对过或者被系统记录过的后者直接收空口广播包。产测场景下设备是全新的、没配过对只能走后者。void BleScanner::Start(std::functionvoid(BleDeviceInfo const) onFound) { m_onFound std::move(onFound); m_watcher BluetoothLEAdvertisementWatcher(); // Active 才会发 SCAN_REQ才能拿到 ScanResponse 里的完整名字 m_watcher.ScanningMode(BluetoothLEScanningMode::Active); m_watcher.Received([this](BluetoothLEAdvertisementWatcher const, BluetoothLEAdvertisementReceivedEventArgs const args) { // 只关心可连接广播信标类的非连接广播直接滤掉 auto type args.AdvertisementType(); if (type ! BluetoothLEAdvertisementType::ConnectableUndirected type ! BluetoothLEAdvertisementType::ConnectableDirected) { return; } BleDeviceInfo info{}; info.address args.BluetoothAddress(); // 48 位 MACuint64_t info.rssi args.RawSignalStrengthInDBm(); info.name args.Advertisement().LocalName(); // 按服务 UUID 过滤避免把整层楼的耳机手表全收进来 for (auto const uuid : args.Advertisement().ServiceUuids()) { if (uuid kTargetService) { info.hasTargetService true; break; } } if (!info.hasTargetService) { return; } m_onFound(info); }); m_watcher.Start(); }ScanningMode选Active还是Passive影响很大。被动扫描只收广播包很多设备的名字是放在 ScanResponse 里的被动模式下LocalName()拿到的就是空字符串主动扫描会多发一个 SCAN_REQ换来完整名字代价是功耗略高。产测工装插着电无所谓选 Active。kTargetService是目标服务 UUID16 位 UUID 要展开成 128 位// 16 位 UUID 0xFFE0 展开成 128 位后缀固定 static constexpr winrt::guid kTargetService{ 0x0000FFE0, 0x0000, 0x1000, { 0x80, 0x00, 0x00, 0x80, 0x5F, 0x9B, 0x34, 0xFB } };这个过滤条件很关键。不加过滤办公区里几十个 BLE 设备会一起涌进来回调被打满真正的目标设备反而被淹没。按ServiceUuids过滤比按名字过滤可靠名字可以被截断、可以重复UUID 不会。3.2 建立连接FromBluetoothAddressAsync 加超时看门狗拿到 MAC 地址就能连。BluetoothLEDevice::FromBluetoothAddressAsync返回一个IAsyncOperationBluetoothLEDevice设备在范围内会很快返回设备已经走远了则可能挂十几秒甚至更久这个接口本身不暴露超时参数。血泪经验是必须自己套看门狗。我的做法是把连接动作丢到一个独立线程上阻塞等待主流程只等固定时间// 连接是低频操作用线程池线程阻塞等待换一个可控的超时 std::futureBluetoothLEDevice fut std::async(std::launch::async, [address] { return BluetoothLEDevice::FromBluetoothAddressAsync(address).get(); }); if (fut.wait_for(std::chrono::seconds(8)) ! std::future_status::ready) { Logger::Warn(connect timeout, addr%012llX, address); // 超时后不要死等直接回扫描态重新找 return false; } m_device fut.get(); if (!m_device) { return false; // 系统层面就没找到这台设备 }IAsyncOperation::get()是阻塞语义只能在非 STA 线程上调用std::async拉起的线程池线程满足这个条件。代价是占一个线程八秒对连接这种低频动作完全可接受但千万别把这段照搬到读写上——高频读写每次都占线程几十路设备一起跑就直接炸了。3.3 服务与特征值发现Uncached 缓存模式的区别连上之后第一件事是拿服务列表。这里的BluetoothCacheMode是两个值Cached和Uncached区别大到能决定你排查一整天。Cached读的是系统维护的 GATT 缓存快但缓存可能是上一次连接时留下的。设备固件升级换了特征值 UUID或者你改了一版硬件缓存还是旧的GetGattServicesAsync返回的服务表跟实际对不上读出来的数据全是错的。Uncached强制走空中重新发现慢几百毫秒但拿到的是真相。IAsyncOperationbool BleSession::DiscoverAsync() { // 强制空中发现不吃系统缓存——固件 p a hrefhttps://download.csdn.net/download/u010816417/85705871 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p