简介:本资源是一份面向Windows平台C/C++开发者的BLE(低功耗蓝牙)通信实践工程,适用于具备基础WinRT编程经验的中高级开发者,解决在桌面端调用系统原生API实现蓝牙设备扫描、连接、GATT服务发现与数据读写等核心场景问题。压缩包为10KB的ZIP格式,共含11个文件,包括4个头文件(.h)封装BLE操作接口与回调定义,4个源文件(.cpp)实现设备枚举、广告监听、特征值读写等关键逻辑,另含项目配置文件(.vcxproj)、过滤器(.filters)及用户设置(.user),结构完整、开箱即用。已有5965人学习下载,代码基于Windows SDK 10+,充分使用winrt/Windows.Devices.Bluetooth等命名空间,注释清晰,模块划分明确——BleHandle.h/.cpp专注通信抽象,BLEComm.h/.cpp负责主控调度,便于读者理解WinRT异步模型与蓝牙协议栈集成方式,并可快速迁移至实际工业控制、IoT设备调试等项目中。1. Windows 平台下用纯 C/C++ 直接操作 BLE 设备:不依赖 Qt、.NET 或 UWP,绕过 WinRT 黑匣子,连上手环/标签/传感器只要 300 行核心代码你有没有试过在 Windows 上用 C++ 写一个能扫描、连接、读写某款国产蓝牙温湿度标签的控制台程序,结果卡在BluetoothGATTGetServices返回ERROR_INVALID_HANDLE?或者发现用Windows.Devices.Bluetooth的 C++/CX 代码一编译就报winrt/base.h not found,而你压根不想装 VS 2019+、不想配 C++/WinRT 工具链、更不想把整个工程迁到 UWP 沙箱里?这个项目就是为这类场景写的——它用 Windows 10 1809+ 原生支持的 Bluetooth LE API(BluetoothAPIs.h+BthLEEnum.h),纯 Win32 C/C++ 实现完整 BLE 生命周期:设备发现 → GATT 服务枚举 → 特征值读写 → 通知订阅。没有 Qt 的QBluetoothDeviceDiscoveryAgent抽象层,没有 .NET 的BluetoothLEDevice.FromIdAsync异步黑盒,所有调用直通系统内核驱动。适合嵌入式网关、工业采集终端、自动化测试脚本等需要轻量、可控、无运行时依赖的场景。如果你手头有 Visual Studio 2017 或更高版本,5 分钟就能跑通ble_scan.exe和ble_read_temp.exe,后续改 UUID、换设备、加日志全在.c文件里动几行。2. 架构与技术栈:基于 Windows Bluetooth LE 原生 API 的三层结构设计这个项目不是封装库,而是一个可执行、可调试、可裁剪的最小可行 BLE 控制台工具集。它不引入任何第三方 SDK,完全基于 Windows SDK 自带头文件和静态链接库,编译产物是单个.exe,无 DLL 依赖。整个结构按职责划分为三层:底层驱动交互层(btapi_wrap.c)、GATT 协议逻辑层(gatt_client.c)、应用命令行胶水层(main.c)。这种分法不是为了炫技,而是为了让你在调试时能快速定位问题:比如扫描失败,先看btapi_wrap.c里BluetoothFindFirstRadio是否返回有效句柄;连接后读不到数据,就进gatt_client.c查BluetoothGATTGetCharacteristics的返回码和pCharValue缓冲区长度;命令行参数解析错了,直接改main.c的switch (argc)分支就行。所有模块都用标准 C99 编写,C++ 仅用于main.cpp中的少量字符串拼接(可轻松转回 C),确保你在老旧工控机或 CI 环境中也能用 MinGW-w64 或 Clang 编译通过。2.1 底层驱动交互层:btapi_wrap.c封装 Windows 蓝牙无线电句柄管理Windows 的 BLE 操作必须先获取本地蓝牙无线电(Radio)句柄,这是所有后续 API 的起点。btapi_wrap.c就干这一件事:安全打开、枚举、关闭 Radio,并提供线程安全的句柄缓存。关键不是“怎么写”,而是“怎么不出错”——很多开发者第一次调用BluetoothFindFirstRadio就失败,根本原因是没检查BLUETOOTH_FIND_RADIO_PARAMS结构体的dwSize字段是否初始化为sizeof(BLUETOOTH_FIND_RADIO_PARAMS)。这个字段是 Windows API 的经典陷阱:大小不对,整个结构体传参就错位,后续所有调用都返回ERROR_INVALID_PARAMETER。// btapi_wrap.c #include <windows.h> #include <bluetoothapis.h> #include <bthleenum.h> static HBLUETOOTH_RADIO_FIND s_hRadioFind = NULL; static BLUETOOTH_RADIO_INFO s_radioInfo = {0}; // 初始化并获取第一个可用蓝牙无线电句柄 HBLUETOOTH_RADIO_HANDLE bt_open_radio() { BLUETOOTH_FIND_RADIO_PARAMS findParams = {0}; findParams.dwSize = sizeof(BLUETOOTH_FIND_RADIO_PARAMS); // 必须显式赋值!玄学坑点1 s_hRadioFind = BluetoothFindFirstRadio(&findParams, &s_radioInfo); if (s_hRadioFind == NULL) { DWORD err = GetLastError(); printf("BluetoothFindFirstRadio failed: %lu\n", err); return NULL; } return s_radioFind; }这段代码里findParams.dwSize是硬编码的sizeof(...),不是宏定义也不是offsetof,因为 Windows API 文档明确要求该字段必须是结构体实际大小。我见过太多人把它写成0或sizeof(void*),结果在不同 Windows 版本上行为不一致——1809 可能容忍,22H2 就直接拒绝。另外注意s_hRadioFind是全局静态变量,避免多线程并发调用时句柄被覆盖。bt_open_radio()返回的是HBLUETOOTH_RADIO_HANDLE,不是HANDLE,不能用CloseHandle()关闭,必须用BluetoothFindRadioClose(),否则资源泄漏。这个细节在 MSDN 示例里藏得很深,但项目里已封装好bt_close_radio()来兜底。2.2 GATT 协议逻辑层:gatt_client.c实现服务发现与特征值操作BLE 的核心是 GATT(Generic Attribute Profile),所有通信都围绕 Service → Characteristic → Descriptor 展开。gatt_client.c不做抽象,直接调用BluetoothGATTGetServices、BluetoothGATTGetCharacteristics等原生函数,把每个步骤的错误码、缓冲区长度、内存分配逻辑全部暴露出来。比如服务发现,Windows 要求你先调用一次获取所需缓冲区大小,再分配内存重试——这不是设计缺陷,而是 Windows 内核驱动的内存管理策略。项目里用两阶段模式处理:// gatt_client.c #include <windows.h> #include <bluetoothapis.h> #include <bthleenum.h> // 获取指定设备的所有 GATT 服务 BOOL gatt_discover_services(HBLUETOOTH_RADIO_HANDLE hRadio, BLUETOOTH_DEVICE_ADDRESS addr, PBLUETOOTH_GATT_SERVICE *ppServices, PDWORD pNumServices) { DWORD dwSize = 0; DWORD dwError = 0; // 第一阶段:获取所需缓冲区大小 BluetoothGATTGetServices(hRadio, addr, 0, NULL, &dwSize, 0); dwError = GetLastError(); if (dwError != ERROR_MORE_DATA && dwError != ERROR_SUCCESS) { printf("First call to BluetoothGATTGetServices failed: %lu\n", dwError); return FALSE; } // 分配缓冲区(注意:Windows 要求按字节对齐,这里用 malloc 而非 HeapAlloc) *ppServices = (PBLUETOOTH_GATT_SERVICE)malloc(dwSize); if (!*ppServices) { printf("Failed to allocate %lu bytes for services\n", dwSize); return FALSE; } // 第二阶段:真正获取服务列表 if (!BluetoothGATTGetServices(hRadio, addr, dwSize, *ppServices, &dwSize, 0)) { dwError = GetLastError(); printf("Second call to BluetoothGATTGetServices failed: %lu\n", dwError); free(*ppServices); *ppServices = NULL; return FALSE; } *pNumServices = (*ppServices)->NumberOfCharacteristics; // 注意:这里存的是特征值数量,不是服务数 return TRUE; }重点看注释里的三处细节:dwSize必须先调用一次获取,不能硬写1024或4096,否则在服务多的设备(如智能手表)上直接截断;内存分配用malloc而非HeapAlloc,因为BluetoothGATTGetServices内部不关心堆类型,但malloc更易调试内存泄漏;NumberOfCharacteristics字段名有误导性——它实际表示该服务下特征值的数量,不是服务总数,项目里已加注释提醒,避免你误以为*pNumServices是服务个数。这个函数返回的是PBLUETOOTH_GATT_SERVICE链表,每个节点包含ServiceUuid(128 位 UUID)、StartHandle、EndHandle,后续读写特征值全靠这两个 handle 定位。没有自动 UUID 解析,你要自己比对memcmp(&service.ServiceUuid, &target_uuid, 16),但好处是零依赖、零反射、零运行时开销。2.3 应用胶水层:main.c提供可调试的命令行入口main.c是整个项目的“开关面板”。它不处理任何协议逻辑,只做三件事:解析命令行参数(-scan,-connect,-read,-notify)、调用对应模块函数、打印结构化输出。比如-scan模式,它会循环调用bt_scan_devices(),每发现一个设备就打印 MAC 地址、信号强度(RSSI)、本地名称(如果广播了的话):// main.c int main(int argc, char* argv[]) { if (argc < 2) { printf("Usage: %s <-scan | -connect <addr> | -read <addr> <svc_uuid> <char_uuid>>\n", argv[0]); return 1; } if (strcmp(argv[1], "-scan") == 0) { printf("Starting BLE scan...\n"); bt_scan_devices(); // 内部调用 bt_open_radio() + BluetoothFindFirstDevice() } else if (strcmp(argv[1], "-connect") == 0 && argc == 3) { BLUETOOTH_DEVICE_ADDRESS addr = parse_bt_address(argv[2]); // "AA:BB:CC:DD:EE:FF" → uint64_t if (addr == 0) { printf("Invalid address format\n"); return 1; } gatt_connect_device(addr); } // ... 其他分支 }这里parse_bt_address()是个实用小函数,把常见的AA:BB:CC:DD:EE:FF、AABBCCDDEEFF、aa-bb-cc-dd-ee-ff格式统一转成BLUETOOTH_DEVICE_ADDRESS(即uint64_t)。Windows API 要求地址是大端序的 6 字节整数,但用户输入习惯是十六进制字符串,这个转换逻辑如果漏掉大小端处理,连接永远失败。项目里已实现健壮解析,支持大小写、分隔符可选,且对非法字符(如G)直接返回 0 并提示,而不是静默截断。提示:main.c中所有printf都加了\n,避免 Windows 控制台缓冲导致日志不实时。这是血泪经验——某次在工控机上调试,发现printf("Connecting...")后没换行,程序卡住半天没输出,其实是缓冲区没刷,加\n立刻解决。3. 编译与运行:从零开始构建可执行文件的完整流程这个项目的目标是“开箱即用”,不是“配置半天”。它不依赖 CMakeLists.txt 的复杂规则,也不需要你手动设置 Windows SDK 版本号。整个编译流程基于 Visual Studio 的cl.exe命令行工具链,兼容 VS 2017/2019/2022,也支持 MinGW-w64(需额外链接BluetoothAPIs.lib)。下面是你真正需要做的三步,每一步都有明确命令和预期输出。3.1 环境准备:确认 Windows SDK 和编译器路径首先确认你的开发机已安装 Windows 10 SDK(1809 或更新版)。打开“Visual Studio Installer”,勾选 “Windows 10/11 SDK” 组件。然后以管理员身份运行 “x64 Native Tools Command Prompt for VS 2019”(或对应版本)。这个命令行环境会自动设置INCLUDE、LIB、PATH,让你无需手动配置头文件路径。验证是否就绪:# 在 VS 命令行中执行 cl /? # 应输出 cl.exe 版本信息,如 "Microsoft (R) C/C++ Optimizing Compiler Version 19.29.30137" echo %WindowsSdkDir% # 应输出类似 "C:\Program Files (x86)\Windows Kits\10\"如果cl命令未识别,请不要去网上搜“如何配置环境变量”,直接重装 VS Build Tools 或使用 VS Installer 修复。这是最省时间的方案。我曾帮某高校实验室排查过,他们花两天配环境变量,结果发现是 VS Installer 里 SDK 组件根本没装——重装 SDK 后 5 分钟搞定。3.2 编译命令:一条cl命令生成全部.exe项目源码共 4 个文件:btapi_wrap.c、gatt_client.c、uuid_utils.c(UUID 字符串解析)、main.c。编译只需一条命令,链接BluetoothAPIs.lib和Bthprops.lib(后者提供BluetoothFindFirstDevice等函数):cl /O2 /MT /Fe:ble_scan.exe main.c btapi_wrap.c gatt_client.c uuid_utils.c /link BluetoothAPIs.lib Bthprops.lib参数说明:/O2:最大优化,减小体积、提升性能;/MT:静态链接 CRT,避免目标机器缺少vcruntime140.dll;/Fe:ble_scan.exe:指定输出文件名;/link ...:链接蓝牙相关库,缺一不可;编译成功后,你会看到ble_scan.exe生成,大小约 120KB(静态链接后)。运行它:ble_scan.exe -scan预期输出(真实设备扫描结果):Starting BLE scan... Found device: AA:BB:CC:DD:EE:FF, RSSI: -62, Name: 'TempSensor-01' Found device: 11:22:33:44:55:66, RSSI: -78, Name: 'HeartRate-Band' Scan completed. Found 2 devices.如果没输出设备,请先确认:你的 Windows 蓝牙已开启且处于可被发现状态;扫描设备(如手环)确实在广播(有些设备默认关闭广播,需长按按钮触发);你不是在虚拟机里运行——Hyper-V 或 VMware 的蓝牙直通支持极差,必须在物理机上测试。3.3 运行时依赖与权限:为什么你的程序总在BluetoothFindFirstRadio失败?这是新手最高频的翻车点。BluetoothFindFirstRadio失败,90% 的原因是权限不足。Windows 蓝牙 API 要求调用进程拥有SE_LOAD_DRIVER_PRIVILEGE(加载驱动权限),而普通用户账户默认没有。解决方案只有两个:方案 A(推荐):以管理员身份运行命令行,再执行.exe;方案 B:在程序 manifest 文件中声明requireAdministrator,双击运行时自动提权;项目已附带app.manifest文件,内容如下:<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"> <security> <requestedPrivileges> <requestedExecutionLevel level="requireAdministrator" uiAccess="false"/> </requestedPrivileges> </security> </trustInfo> </assembly>编译时用/MANIFESTINPUT:app.manifest参数嵌入即可。如果你跳过这步,ble_scan.exe在非管理员命令行下必然失败,错误码是ERROR_ACCESS_DENIED(5)。这不是代码 bug,是 Windows 安全机制,必须正视。注意:某些企业域环境禁用了管理员提权,此时只能联系 IT 部门申请临时权限。不要尝试用CreateProcessAsUser或服务方式绕过——既违反安全策略,又增加复杂度,得不偿失。4. 常见问题排查:5 个真实踩坑记录与速查解决方案开发 Windows BLE 应用不是写 Hello World,很多问题现象诡异、原因隐蔽。以下是我在多个工业客户现场实测总结的 5 个高频问题,每个都按「现象 → 原因 → 解决」给出可立即执行的方案,不讲原理,只给答案。4.1 现象:BluetoothFindFirstRadio返回NULL,GetLastError()是5(ERROR_ACCESS_DENIED)原因:进程未以管理员权限运行,Windows 拒绝授予蓝牙无线电访问权。解决:右键点击命令行图标 → “以管理员身份运行”,再执行ble_scan.exe -scan。若需双击运行,确保已将app.manifest嵌入可执行文件(编译时加/MANIFESTINPUT:app.manifest)。4.2 现象:BluetoothFindFirstDevice扫描不到设备,GetLastError()是122(ERROR_INSUFFICIENT_BUFFER)原因:BLUETOOTH_DEVICE_SEARCH_PARAMS结构体的dwSize字段未初始化为sizeof(BLUETOOTH_DEVICE_SEARCH_PARAMS)。解决:打开btapi_wrap.c,找到BluetoothFindFirstDevice调用前的searchParams变量,确认其dwSize赋值语句存在且正确。删除所有= {0}初始化,改为显式searchParams.dwSize = sizeof(searchParams);。4.3 现象:连接设备后BluetoothGATTGetServices返回0个服务,GetLastError()是1168(ERROR_NOT_FOUND)原因:目标设备是经典蓝牙(BR/EDR)设备,不是 BLE 设备。Windows 的BluetoothGATT*系列 API 仅支持 BLE,对传统蓝牙无效。解决:用手机 App(如 nRF Connect)确认该设备是否广播 BLE 广告包。若广告数据中不含0x0201(Flags)或0x0303(16-bit Service UUIDs)等 BLE 特征,则不是 BLE 设备,此项目不适用。4.4 现象:BluetoothGATTReadCharacteristicValue读取特征值成功,但返回数据全是0x00原因:特征值 handle 错误。BluetoothGATTGetCharacteristics返回的pChar->CharacteristicHandle是属性句柄(Attribute Handle),而读取时需用pChar->ValueHandle(值句柄)。两者常被混淆。解决:在gatt_client.c的读取函数中,确认使用的是pChar->ValueHandle而非pChar->CharacteristicHandle。项目源码中已用注释标出:“// 注意:读取用 ValueHandle,不是 CharacteristicHandle”。4.5 现象:启用通知(BluetoothGATTRegisterForValueNotifications)后,回调函数从未触发原因:未向通知配置描述符(Client Characteristic Configuration Descriptor, CCCD)写入0x0001(启用通知)。Windows 不自动写 CCCD,必须手动调用BluetoothGATTWriteCharacteristicValue。解决:在注册通知前,先获取该特征值的 CCCD handle(通常为ValueHandle + 1),然后构造 2 字节数据0x01, 0x00写入。项目gatt_client.c中gatt_enable_notify()函数已实现此逻辑,检查是否被注释掉。5. 二次开发实战:如何快速适配你的 BLE 设备(以某款温湿度传感器为例)现在你已经能跑通扫描和连接,下一步是让程序读出真实数据。假设你手上有某款国产 BLE 温湿度传感器,它的 GATT 结构如下:Service UUID:0000181a-0000-1000-8000-00805f9b34fb(Environmental Sensing)Temperature Characteristic UUID:00002a6e-0000-1000-8000-00805f9b34fbData format:2 字节有符号整数(℃),需除以 100 得到实际值改造步骤分三步:改 UUID、加解析、验数据。全程修改不超过 20 行代码,无需重新编译整个项目。5.1 修改目标 UUID:替换main.c中的硬编码字符串打开main.c,找到-read命令的处理分支。原代码可能用占位 UUID,你需要替换成传感器的真实 UUID:// main.c 中 -read 分支 else if (strcmp(argv[1], "-read") == 0 && argc == 5) { BLUETOOTH_DEVICE_ADDRESS addr = parse_bt_address(argv[2]); // 替换这两行 ↓↓↓ GUID svc_uuid = {0xfb34fb9b5f800080, 0x00100000, 0x0010, {0x80,0,0,0,0,0,0,0x1a}}; // 181a GUID char_uuid = {0xfb34fb9b5f800080, 0x00100000, 0x0010, {0x80,0,0,0,0,0,0,0x6e}}; // 2a6e // ↑↑↑ 替换结束 gatt_read_characteristic(addr, &svc_uuid, &char_uuid); }注意:Windows 的GUID结构体是DWORD Data1; WORD Data2; WORD Data3; BYTE Data4[8];,所以0000181a-...要拆成Data1=0x0000181a,Data2=0x0000,Data3=0x1000,Data4={0x80,0x00,0x00,0x80,0x5f,0x9b,0x34,0xfb}。项目已提供uuid_from_string()辅助函数(在uuid_utils.c),但手动赋值更快,且避免字符串解析开销。5.2 添加数据解析逻辑:在gatt_client.c的读取回调中处理原始字节BluetoothGATTReadCharacteristicValue返回的是PBLUETOOTH_GATT_VALUE,其pValue字段指向原始字节数组。你需要根据传感器文档,把字节转成有意义的数值。在gatt_read_characteristic()函数末尾添加:// gatt_client.c void gatt_read_characteristic(BLUETOOTH_DEVICE_ADDRESS addr, GUID* pSvcUuid, GUID* pCharUuid) { // ... 前面是服务发现、特征值查找逻辑 ... PBLUETOOTH_GATT_VALUE pValue = NULL; DWORD dwValueSize = 0; if (BluetoothGATTReadCharacteristicValue(hRadio, addr, pChar->ValueHandle, &pValue, &dwValueSize, 0)) { if (dwValueSize == 2) { int16_t raw_temp = *(int16_t*)pValue->pValue; // 直接 reinterpret_cast float temp_c = (float)raw_temp / 100.0f; printf("Temperature: %.2f °C\n", temp_c); } else { printf("Unexpected value size: %lu bytes\n", dwValueSize); } free(pValue); // 必须释放,Windows 不负责 } }这里int16_t强制转换是安全的,因为 BLE 特征值传输是小端序,而 x86/x64 也是小端,无需字节序转换。如果传感器文档写明是大端,则用ntohs(*(uint16_t*)pValue->pValue)。5.3 验证与调试:用nRF Connect对照原始数据不要相信自己的解析逻辑,一定要用专业工具交叉验证。下载手机 App “nRF Connect”,连接同一台传感器,找到2a6e特征值,点击 “Read” 查看原始 hex 数据(如0x2c, 0x01)。转成小端整数:0x012c = 300,除以 100 得3.00°C。然后运行你的ble_read.exe,对比输出是否一致。如果不一致,检查:pValue->pValue是否为空指针(BluetoothGATTReadCharacteristicValue失败时不会分配内存);dwValueSize是否真等于 2(有些传感器返回 4 字节浮点数);除法是否用了整数除法(/ 100而非/ 100.0f)导致截断。从那以后我每次适配新设备,都强制走一遍 nRF Connect 对照流程——哪怕文档写得再清楚,硬件固件版本差异也会导致数据格式突变。这一步花 2 分钟,能省下 2 小时抓包分析时间。希望帮到你。本文还有配套的精品资源,点击获取
简介:本资源是一份面向Windows平台C/C++开发者的BLE(低功耗蓝牙)通信实践工程,适用于具备基础WinRT编程经验的中高级开发者,解决在桌面端调用系统原生API实现蓝牙设备扫描、连接、GATT服务发现与数据读写等核心场景问题。压缩包为10KB的ZIP格式,共含11个文件,包括4个头文件(.h)封装BLE操作接口与回调定义,4个源文件(.cpp)实现设备枚举、广告监听、特征值读写等关键逻辑,另含项目配置文件(.vcxproj)、过滤器(.filters)及用户设置(.user),结构完整、开箱即用。已有5965人学习下载,代码基于Windows SDK 10+,充分使用winrt/Windows.Devices.Bluetooth等命名空间,注释清晰,模块划分明确——BleHandle.h/.cpp专注通信抽象,BLEComm.h/.cpp负责主控调度,便于读者理解WinRT异步模型与蓝牙协议栈集成方式,并可快速迁移至实际工业控制、IoT设备调试等项目中。1. Windows 平台下用纯 C/C++ 直接操作 BLE 设备:不依赖 Qt、.NET 或 UWP,绕过 WinRT 黑匣子,连上手环/标签/传感器只要 300 行核心代码你有没有试过在 Windows 上用 C++ 写一个能扫描、连接、读写某款国产蓝牙温湿度标签的控制台程序,结果卡在BluetoothGATTGetServices返回ERROR_INVALID_HANDLE?或者发现用Windows.Devices.Bluetooth的 C++/CX 代码一编译就报winrt/base.h not found,而你压根不想装 VS 2019+、不想配 C++/WinRT 工具链、更不想把整个工程迁到 UWP 沙箱里?这个项目就是为这类场景写的——它用 Windows 10 1809+ 原生支持的 Bluetooth LE API(BluetoothAPIs.h+BthLEEnum.h),纯 Win32 C/C++ 实现完整 BLE 生命周期:设备发现 → GATT 服务枚举 → 特征值读写 → 通知订阅。没有 Qt 的QBluetoothDeviceDiscoveryAgent抽象层,没有 .NET 的BluetoothLEDevice.FromIdAsync异步黑盒,所有调用直通系统内核驱动。适合嵌入式网关、工业采集终端、自动化测试脚本等需要轻量、可控、无运行时依赖的场景。如果你手头有 Visual Studio 2017 或更高版本,5 分钟就能跑通ble_scan.exe和ble_read_temp.exe,后续改 UUID、换设备、加日志全在.c文件里动几行。2. 架构与技术栈:基于 Windows Bluetooth LE 原生 API 的三层结构设计这个项目不是封装库,而是一个可执行、可调试、可裁剪的最小可行 BLE 控制台工具集。它不引入任何第三方 SDK,完全基于 Windows SDK 自带头文件和静态链接库,编译产物是单个.exe,无 DLL 依赖。整个结构按职责划分为三层:底层驱动交互层(btapi_wrap.c)、GATT 协议逻辑层(gatt_client.c)、应用命令行胶水层(main.c)。这种分法不是为了炫技,而是为了让你在调试时能快速定位问题:比如扫描失败,先看btapi_wrap.c里BluetoothFindFirstRadio是否返回有效句柄;连接后读不到数据,就进gatt_client.c查BluetoothGATTGetCharacteristics的返回码和pCharValue缓冲区长度;命令行参数解析错了,直接改main.c的switch (argc)分支就行。所有模块都用标准 C99 编写,C++ 仅用于main.cpp中的少量字符串拼接(可轻松转回 C),确保你在老旧工控机或 CI 环境中也能用 MinGW-w64 或 Clang 编译通过。2.1 底层驱动交互层:btapi_wrap.c封装 Windows 蓝牙无线电句柄管理Windows 的 BLE 操作必须先获取本地蓝牙无线电(Radio)句柄,这是所有后续 API 的起点。btapi_wrap.c就干这一件事:安全打开、枚举、关闭 Radio,并提供线程安全的句柄缓存。关键不是“怎么写”,而是“怎么不出错”——很多开发者第一次调用BluetoothFindFirstRadio就失败,根本原因是没检查BLUETOOTH_FIND_RADIO_PARAMS结构体的dwSize字段是否初始化为sizeof(BLUETOOTH_FIND_RADIO_PARAMS)。这个字段是 Windows API 的经典陷阱:大小不对,整个结构体传参就错位,后续所有调用都返回ERROR_INVALID_PARAMETER。// btapi_wrap.c #include <windows.h> #include <bluetoothapis.h> #include <bthleenum.h> static HBLUETOOTH_RADIO_FIND s_hRadioFind = NULL; static BLUETOOTH_RADIO_INFO s_radioInfo = {0}; // 初始化并获取第一个可用蓝牙无线电句柄 HBLUETOOTH_RADIO_HANDLE bt_open_radio() { BLUETOOTH_FIND_RADIO_PARAMS findParams = {0}; findParams.dwSize = sizeof(BLUETOOTH_FIND_RADIO_PARAMS); // 必须显式赋值!玄学坑点1 s_hRadioFind = BluetoothFindFirstRadio(&findParams, &s_radioInfo); if (s_hRadioFind == NULL) { DWORD err = GetLastError(); printf("BluetoothFindFirstRadio failed: %lu\n", err); return NULL; } return s_radioFind; }这段代码里findParams.dwSize是硬编码的sizeof(...),不是宏定义也不是offsetof,因为 Windows API 文档明确要求该字段必须是结构体实际大小。我见过太多人把它写成0或sizeof(void*),结果在不同 Windows 版本上行为不一致——1809 可能容忍,22H2 就直接拒绝。另外注意s_hRadioFind是全局静态变量,避免多线程并发调用时句柄被覆盖。bt_open_radio()返回的是HBLUETOOTH_RADIO_HANDLE,不是HANDLE,不能用CloseHandle()关闭,必须用BluetoothFindRadioClose(),否则资源泄漏。这个细节在 MSDN 示例里藏得很深,但项目里已封装好bt_close_radio()来兜底。2.2 GATT 协议逻辑层:gatt_client.c实现服务发现与特征值操作BLE 的核心是 GATT(Generic Attribute Profile),所有通信都围绕 Service → Characteristic → Descriptor 展开。gatt_client.c不做抽象,直接调用BluetoothGATTGetServices、BluetoothGATTGetCharacteristics等原生函数,把每个步骤的错误码、缓冲区长度、内存分配逻辑全部暴露出来。比如服务发现,Windows 要求你先调用一次获取所需缓冲区大小,再分配内存重试——这不是设计缺陷,而是 Windows 内核驱动的内存管理策略。项目里用两阶段模式处理:// gatt_client.c #include <windows.h> #include <bluetoothapis.h> #include <bthleenum.h> // 获取指定设备的所有 GATT 服务 BOOL gatt_discover_services(HBLUETOOTH_RADIO_HANDLE hRadio, BLUETOOTH_DEVICE_ADDRESS addr, PBLUETOOTH_GATT_SERVICE *ppServices, PDWORD pNumServices) { DWORD dwSize = 0; DWORD dwError = 0; // 第一阶段:获取所需缓冲区大小 BluetoothGATTGetServices(hRadio, addr, 0, NULL, &dwSize, 0); dwError = GetLastError(); if (dwError != ERROR_MORE_DATA && dwError != ERROR_SUCCESS) { printf("First call to BluetoothGATTGetServices failed: %lu\n", dwError); return FALSE; } // 分配缓冲区(注意:Windows 要求按字节对齐,这里用 malloc 而非 HeapAlloc) *ppServices = (PBLUETOOTH_GATT_SERVICE)malloc(dwSize); if (!*ppServices) { printf("Failed to allocate %lu bytes for services\n", dwSize); return FALSE; } // 第二阶段:真正获取服务列表 if (!BluetoothGATTGetServices(hRadio, addr, dwSize, *ppServices, &dwSize, 0)) { dwError = GetLastError(); printf("Second call to BluetoothGATTGetServices failed: %lu\n", dwError); free(*ppServices); *ppServices = NULL; return FALSE; } *pNumServices = (*ppServices)->NumberOfCharacteristics; // 注意:这里存的是特征值数量,不是服务数 return TRUE; }重点看注释里的三处细节:dwSize必须先调用一次获取,不能硬写1024或4096,否则在服务多的设备(如智能手表)上直接截断;内存分配用malloc而非HeapAlloc,因为BluetoothGATTGetServices内部不关心堆类型,但malloc更易调试内存泄漏;NumberOfCharacteristics字段名有误导性——它实际表示该服务下特征值的数量,不是服务总数,项目里已加注释提醒,避免你误以为*pNumServices是服务个数。这个函数返回的是PBLUETOOTH_GATT_SERVICE链表,每个节点包含ServiceUuid(128 位 UUID)、StartHandle、EndHandle,后续读写特征值全靠这两个 handle 定位。没有自动 UUID 解析,你要自己比对memcmp(&service.ServiceUuid, &target_uuid, 16),但好处是零依赖、零反射、零运行时开销。2.3 应用胶水层:main.c提供可调试的命令行入口main.c是整个项目的“开关面板”。它不处理任何协议逻辑,只做三件事:解析命令行参数(-scan,-connect,-read,-notify)、调用对应模块函数、打印结构化输出。比如-scan模式,它会循环调用bt_scan_devices(),每发现一个设备就打印 MAC 地址、信号强度(RSSI)、本地名称(如果广播了的话):// main.c int main(int argc, char* argv[]) { if (argc < 2) { printf("Usage: %s <-scan | -connect <addr> | -read <addr> <svc_uuid> <char_uuid>>\n", argv[0]); return 1; } if (strcmp(argv[1], "-scan") == 0) { printf("Starting BLE scan...\n"); bt_scan_devices(); // 内部调用 bt_open_radio() + BluetoothFindFirstDevice() } else if (strcmp(argv[1], "-connect") == 0 && argc == 3) { BLUETOOTH_DEVICE_ADDRESS addr = parse_bt_address(argv[2]); // "AA:BB:CC:DD:EE:FF" → uint64_t if (addr == 0) { printf("Invalid address format\n"); return 1; } gatt_connect_device(addr); } // ... 其他分支 }这里parse_bt_address()是个实用小函数,把常见的AA:BB:CC:DD:EE:FF、AABBCCDDEEFF、aa-bb-cc-dd-ee-ff格式统一转成BLUETOOTH_DEVICE_ADDRESS(即uint64_t)。Windows API 要求地址是大端序的 6 字节整数,但用户输入习惯是十六进制字符串,这个转换逻辑如果漏掉大小端处理,连接永远失败。项目里已实现健壮解析,支持大小写、分隔符可选,且对非法字符(如G)直接返回 0 并提示,而不是静默截断。提示:main.c中所有printf都加了\n,避免 Windows 控制台缓冲导致日志不实时。这是血泪经验——某次在工控机上调试,发现printf("Connecting...")后没换行,程序卡住半天没输出,其实是缓冲区没刷,加\n立刻解决。3. 编译与运行:从零开始构建可执行文件的完整流程这个项目的目标是“开箱即用”,不是“配置半天”。它不依赖 CMakeLists.txt 的复杂规则,也不需要你手动设置 Windows SDK 版本号。整个编译流程基于 Visual Studio 的cl.exe命令行工具链,兼容 VS 2017/2019/2022,也支持 MinGW-w64(需额外链接BluetoothAPIs.lib)。下面是你真正需要做的三步,每一步都有明确命令和预期输出。3.1 环境准备:确认 Windows SDK 和编译器路径首先确认你的开发机已安装 Windows 10 SDK(1809 或更新版)。打开“Visual Studio Installer”,勾选 “Windows 10/11 SDK” 组件。然后以管理员身份运行 “x64 Native Tools Command Prompt for VS 2019”(或对应版本)。这个命令行环境会自动设置INCLUDE、LIB、PATH,让你无需手动配置头文件路径。验证是否就绪:# 在 VS 命令行中执行 cl /? # 应输出 cl.exe 版本信息,如 "Microsoft (R) C/C++ Optimizing Compiler Version 19.29.30137" echo %WindowsSdkDir% # 应输出类似 "C:\Program Files (x86)\Windows Kits\10\"如果cl命令未识别,请不要去网上搜“如何配置环境变量”,直接重装 VS Build Tools 或使用 VS Installer 修复。这是最省时间的方案。我曾帮某高校实验室排查过,他们花两天配环境变量,结果发现是 VS Installer 里 SDK 组件根本没装——重装 SDK 后 5 分钟搞定。3.2 编译命令:一条cl命令生成全部.exe项目源码共 4 个文件:btapi_wrap.c、gatt_client.c、uuid_utils.c(UUID 字符串解析)、main.c。编译只需一条命令,链接BluetoothAPIs.lib和Bthprops.lib(后者提供BluetoothFindFirstDevice等函数):cl /O2 /MT /Fe:ble_scan.exe main.c btapi_wrap.c gatt_client.c uuid_utils.c /link BluetoothAPIs.lib Bthprops.lib参数说明:/O2:最大优化,减小体积、提升性能;/MT:静态链接 CRT,避免目标机器缺少vcruntime140.dll;/Fe:ble_scan.exe:指定输出文件名;/link ...:链接蓝牙相关库,缺一不可;编译成功后,你会看到ble_scan.exe生成,大小约 120KB(静态链接后)。运行它:ble_scan.exe -scan预期输出(真实设备扫描结果):Starting BLE scan... Found device: AA:BB:CC:DD:EE:FF, RSSI: -62, Name: 'TempSensor-01' Found device: 11:22:33:44:55:66, RSSI: -78, Name: 'HeartRate-Band' Scan completed. Found 2 devices.如果没输出设备,请先确认:你的 Windows 蓝牙已开启且处于可被发现状态;扫描设备(如手环)确实在广播(有些设备默认关闭广播,需长按按钮触发);你不是在虚拟机里运行——Hyper-V 或 VMware 的蓝牙直通支持极差,必须在物理机上测试。3.3 运行时依赖与权限:为什么你的程序总在BluetoothFindFirstRadio失败?这是新手最高频的翻车点。BluetoothFindFirstRadio失败,90% 的原因是权限不足。Windows 蓝牙 API 要求调用进程拥有SE_LOAD_DRIVER_PRIVILEGE(加载驱动权限),而普通用户账户默认没有。解决方案只有两个:方案 A(推荐):以管理员身份运行命令行,再执行.exe;方案 B:在程序 manifest 文件中声明requireAdministrator,双击运行时自动提权;项目已附带app.manifest文件,内容如下:<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"> <security> <requestedPrivileges> <requestedExecutionLevel level="requireAdministrator" uiAccess="false"/> </requestedPrivileges> </security> </trustInfo> </assembly>编译时用/MANIFESTINPUT:app.manifest参数嵌入即可。如果你跳过这步,ble_scan.exe在非管理员命令行下必然失败,错误码是ERROR_ACCESS_DENIED(5)。这不是代码 bug,是 Windows 安全机制,必须正视。注意:某些企业域环境禁用了管理员提权,此时只能联系 IT 部门申请临时权限。不要尝试用CreateProcessAsUser或服务方式绕过——既违反安全策略,又增加复杂度,得不偿失。4. 常见问题排查:5 个真实踩坑记录与速查解决方案开发 Windows BLE 应用不是写 Hello World,很多问题现象诡异、原因隐蔽。以下是我在多个工业客户现场实测总结的 5 个高频问题,每个都按「现象 → 原因 → 解决」给出可立即执行的方案,不讲原理,只给答案。4.1 现象:BluetoothFindFirstRadio返回NULL,GetLastError()是5(ERROR_ACCESS_DENIED)原因:进程未以管理员权限运行,Windows 拒绝授予蓝牙无线电访问权。解决:右键点击命令行图标 → “以管理员身份运行”,再执行ble_scan.exe -scan。若需双击运行,确保已将app.manifest嵌入可执行文件(编译时加/MANIFESTINPUT:app.manifest)。4.2 现象:BluetoothFindFirstDevice扫描不到设备,GetLastError()是122(ERROR_INSUFFICIENT_BUFFER)原因:BLUETOOTH_DEVICE_SEARCH_PARAMS结构体的dwSize字段未初始化为sizeof(BLUETOOTH_DEVICE_SEARCH_PARAMS)。解决:打开btapi_wrap.c,找到BluetoothFindFirstDevice调用前的searchParams变量,确认其dwSize赋值语句存在且正确。删除所有= {0}初始化,改为显式searchParams.dwSize = sizeof(searchParams);。4.3 现象:连接设备后BluetoothGATTGetServices返回0个服务,GetLastError()是1168(ERROR_NOT_FOUND)原因:目标设备是经典蓝牙(BR/EDR)设备,不是 BLE 设备。Windows 的BluetoothGATT*系列 API 仅支持 BLE,对传统蓝牙无效。解决:用手机 App(如 nRF Connect)确认该设备是否广播 BLE 广告包。若广告数据中不含0x0201(Flags)或0x0303(16-bit Service UUIDs)等 BLE 特征,则不是 BLE 设备,此项目不适用。4.4 现象:BluetoothGATTReadCharacteristicValue读取特征值成功,但返回数据全是0x00原因:特征值 handle 错误。BluetoothGATTGetCharacteristics返回的pChar->CharacteristicHandle是属性句柄(Attribute Handle),而读取时需用pChar->ValueHandle(值句柄)。两者常被混淆。解决:在gatt_client.c的读取函数中,确认使用的是pChar->ValueHandle而非pChar->CharacteristicHandle。项目源码中已用注释标出:“// 注意:读取用 ValueHandle,不是 CharacteristicHandle”。4.5 现象:启用通知(BluetoothGATTRegisterForValueNotifications)后,回调函数从未触发原因:未向通知配置描述符(Client Characteristic Configuration Descriptor, CCCD)写入0x0001(启用通知)。Windows 不自动写 CCCD,必须手动调用BluetoothGATTWriteCharacteristicValue。解决:在注册通知前,先获取该特征值的 CCCD handle(通常为ValueHandle + 1),然后构造 2 字节数据0x01, 0x00写入。项目gatt_client.c中gatt_enable_notify()函数已实现此逻辑,检查是否被注释掉。5. 二次开发实战:如何快速适配你的 BLE 设备(以某款温湿度传感器为例)现在你已经能跑通扫描和连接,下一步是让程序读出真实数据。假设你手上有某款国产 BLE 温湿度传感器,它的 GATT 结构如下:Service UUID:0000181a-0000-1000-8000-00805f9b34fb(Environmental Sensing)Temperature Characteristic UUID:00002a6e-0000-1000-8000-00805f9b34fbData format:2 字节有符号整数(℃),需除以 100 得到实际值改造步骤分三步:改 UUID、加解析、验数据。全程修改不超过 20 行代码,无需重新编译整个项目。5.1 修改目标 UUID:替换main.c中的硬编码字符串打开main.c,找到-read命令的处理分支。原代码可能用占位 UUID,你需要替换成传感器的真实 UUID:// main.c 中 -read 分支 else if (strcmp(argv[1], "-read") == 0 && argc == 5) { BLUETOOTH_DEVICE_ADDRESS addr = parse_bt_address(argv[2]); // 替换这两行 ↓↓↓ GUID svc_uuid = {0xfb34fb9b5f800080, 0x00100000, 0x0010, {0x80,0,0,0,0,0,0,0x1a}}; // 181a GUID char_uuid = {0xfb34fb9b5f800080, 0x00100000, 0x0010, {0x80,0,0,0,0,0,0,0x6e}}; // 2a6e // ↑↑↑ 替换结束 gatt_read_characteristic(addr, &svc_uuid, &char_uuid); }注意:Windows 的GUID结构体是DWORD Data1; WORD Data2; WORD Data3; BYTE Data4[8];,所以0000181a-...要拆成Data1=0x0000181a,Data2=0x0000,Data3=0x1000,Data4={0x80,0x00,0x00,0x80,0x5f,0x9b,0x34,0xfb}。项目已提供uuid_from_string()辅助函数(在uuid_utils.c),但手动赋值更快,且避免字符串解析开销。5.2 添加数据解析逻辑:在gatt_client.c的读取回调中处理原始字节BluetoothGATTReadCharacteristicValue返回的是PBLUETOOTH_GATT_VALUE,其pValue字段指向原始字节数组。你需要根据传感器文档,把字节转成有意义的数值。在gatt_read_characteristic()函数末尾添加:// gatt_client.c void gatt_read_characteristic(BLUETOOTH_DEVICE_ADDRESS addr, GUID* pSvcUuid, GUID* pCharUuid) { // ... 前面是服务发现、特征值查找逻辑 ... PBLUETOOTH_GATT_VALUE pValue = NULL; DWORD dwValueSize = 0; if (BluetoothGATTReadCharacteristicValue(hRadio, addr, pChar->ValueHandle, &pValue, &dwValueSize, 0)) { if (dwValueSize == 2) { int16_t raw_temp = *(int16_t*)pValue->pValue; // 直接 reinterpret_cast float temp_c = (float)raw_temp / 100.0f; printf("Temperature: %.2f °C\n", temp_c); } else { printf("Unexpected value size: %lu bytes\n", dwValueSize); } free(pValue); // 必须释放,Windows 不负责 } }这里int16_t强制转换是安全的,因为 BLE 特征值传输是小端序,而 x86/x64 也是小端,无需字节序转换。如果传感器文档写明是大端,则用ntohs(*(uint16_t*)pValue->pValue)。5.3 验证与调试:用nRF Connect对照原始数据不要相信自己的解析逻辑,一定要用专业工具交叉验证。下载手机 App “nRF Connect”,连接同一台传感器,找到2a6e特征值,点击 “Read” 查看原始 hex 数据(如0x2c, 0x01)。转成小端整数:0x012c = 300,除以 100 得3.00°C。然后运行你的ble_read.exe,对比输出是否一致。如果不一致,检查:pValue->pValue是否为空指针(BluetoothGATTReadCharacteristicValue失败时不会分配内存);dwValueSize是否真等于 2(有些传感器返回 4 字节浮点数);除法是否用了整数除法(/ 100而非/ 100.0f)导致截断。从那以后我每次适配新设备,都强制走一遍 nRF Connect 对照流程——哪怕文档写得再清楚,硬件固件版本差异也会导致数据格式突变。这一步花 2 分钟,能省下 2 小时抓包分析时间。希望帮到你。本文还有配套的精品资源,点击获取