ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Windows BLE 开发实战:基于 Windows.Devices.Bluetooth 的 C# 上位机连接与 GATT 通信指南

Windows BLE 开发实战:基于 Windows.Devices.Bluetooth 的 C# 上位机连接与 GATT 通信指南 1. 为什么在 Windows 上做 BLE 开发我最终选了 Windows.Devices.Bluetooth如果你之前用 C# 做过串口通信或者 TCP 通信第一次接触 BLE 的时候大概率会有点懵。串口就是打开端口、读写字节TCP 就是连上 IP 和端口、收发数据流逻辑非常线性。但 BLE 完全不是这个套路——它有一套自己的层级模型你得先理解这套模型才能把代码写对。我在实际项目里做过不少无线温度监测、传感器数据采集这类需求BLE 是绕不开的一环。早期也试过一些第三方的蓝牙库有的封装太厚、出了问题根本不知道底层发生了什么有的又太薄、连基本的连接管理都要自己写。后来我把目光收回到 Windows 自带的Windows.Devices.Bluetooth这一套 API 上用下来发现它其实是 Windows 平台上做 BLE 最正统也最稳的方案。这套 API 属于 WinRTWindows Runtime体系C# 可以直接调用不需要额外装驱动或者第三方运行时。它的核心优势在于系统级支持、事件模型完整、和 Windows 的设备管理深度集成。你不需要自己去扫描 HCI 层的数据包系统帮你把广播、连接、GATT 服务发现这些都封装好了你只需要关注业务逻辑。但它的坑也很明显异步模型比较绕、对象生命周期管理容易出错、不同 Windows 版本的 API 行为有差异。我见过太多人卡在设备能扫到但连不上或者连上了但读不到数据这两个问题上。所以这篇内容我会把整个链路拆开讲——从设备发现、连接建立、服务枚举到特征值读写、通知订阅每一步都告诉你为什么这么做以及我踩过的坑。适合谁看如果你有 C# 基础做过 WinForm 或 WPF 上位机现在需要接入 BLE 设备比如蓝牙温湿度计、心率带、自定义的 BLE 模块那这篇就是给你写的。如果你完全没接触过 BLE也没关系我会把 BLE 的连接过程用生活化的方式讲清楚保证你能跟上。提示本文所有代码基于 .NET 6 Windows 10/11 环境验证使用 Windows.Devices.Bluetooth 命名空间。部分 API 在 Windows 8.1 上行为不同建议至少用 Windows 10 1809 以上版本。2. BLE 连接过程到底发生了什么从广播到 GATT 通信很多人写 BLE 代码时是照着示例抄能跑通就行但一旦出问题就完全不知道从哪查。根源在于不理解 BLE 的连接过程。我先把这个过程讲透后面写代码时你就能对上号了。2.1 广播、扫描、连接BLE 的三步走BLE 设备通信的第一步是广播Advertising。设备会周期性地在 2.4GHz 频段上发送广播包包里包含设备名、MAC 地址、服务 UUID 等信息。这就像一个人在广场上举着牌子喊我在这里我叫什么我能提供什么服务。你的电脑Central中心设备通过**扫描Scanning**来接收这些广播包。Windows.Devices.Bluetooth 里的BluetoothLEAdvertisementWatcher就是干这个的。扫描到设备后你拿到的是一个BluetoothLEDevice对象但这只是知道它存在还没建立真正的连接。连接Connection是第三步。当你调用BluetoothLEDevice.FromBluetoothAddressAsync或者对已扫描到的设备发起连接时系统会和设备建立一个物理链路。连接建立后你才能去枚举它提供的 GATT 服务。这里有个关键点很多人忽略BLE 的连接是按需建立的。Windows 为了省电不会一直保持连接。当你第一次访问某个 GATT 特征值时系统才会真正去建立链路。这就是为什么有时候你FromBluetoothAddressAsync返回了对象但读数据却超时——链路还没建好。2.2 GATT 模型Service、Characteristic、Descriptor 三层结构BLE 的数据组织是三层结构理解这个结构是写对代码的前提Service服务一个服务代表一类功能比如电池服务、心率服务。每个服务有一个 UUID 标识。Characteristic特征值服务下面的具体数据点。比如心率服务里有一个心率测量特征值它的值就是当前心率。特征值有属性Read、Write、Notify、Indicate。Descriptor描述符特征值的附加信息比如客户端特征配置描述符CCCD用来开启或关闭通知。用生活类比Service 就像一栋楼Characteristic 是楼里的房间Descriptor 是房间门上的说明牌。你要拿数据得先找到楼Service再找到房间Characteristic如果要订阅通知还得去改门上的说明牌CCCD。2.3 通知与指示数据收发的两种模式BLE 的数据收发有两种模式主动读取Read你主动去读特征值的当前值。适合变化不频繁的数据。通知Notify/ 指示Indicate设备主动推送数据给你。适合实时性要求高的场景比如心率、加速度。Notify 和 Indicate 的区别在于Notify 不需要接收方确认速度快但可能丢包Indicate 需要接收方确认可靠但速度慢。实际项目里传感器数据一般用 Notify关键配置用 Indicate。要开启通知你得先写 CCCDUUID 是00002902-0000-1000-8000-00805f9b34fb把它设成Notify或Indicate然后订阅ValueChanged事件。这一步是新手最容易漏的——不写 CCCD事件永远不会触发。3. 环境准备与项目搭建别在第一步就翻车3.1 项目类型和 .NET 版本选择Windows.Devices.Bluetooth 是 WinRT API在 C# 里调用需要目标框架支持。我的建议是.NET 6 或 .NET 8配合net6.0-windows10.0.19041.0这样的 TFMTarget Framework Moniker可以直接用 WinRT API。.NET Framework 4.8老项目常用也能用但需要引用Windows.winmd配置麻烦一些。UWP原生支持但如果你做的是上位机一般不会选 UWP。我实测下来.NET 6 WinForm/WPF是最舒服的组合。csproj 里这样写Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet6.0-windows10.0.19041.0/TargetFramework UseWindowsFormstrue/UseWindowsForms Nullableenable/Nullable /PropertyGroup /Project注意TargetFramework里的10.0.19041.0是 Windows SDK 版本决定了你能用哪些 API。19041 对应 Windows 10 2004基本够用。如果你要用更新的 API可以调到10.0.22621.0。3.2 权限声明容易被忽略的一步在 UWP 里你必须在Package.appxmanifest里声明蓝牙权限。但在 WinForm/WPF 里通常不需要额外声明因为桌面应用默认有蓝牙访问权限。不过有一种情况例外如果你的应用要以管理员权限运行某些蓝牙操作可能会被限制。另外Windows 11 对隐私控制更严格用户可以在设置 → 隐私和安全性 → 蓝牙里关闭应用的蓝牙访问。如果你的代码突然扫不到设备先检查这里。3.3 一个常见的编译错误找不到 Windows.Devices.Bluetooth如果你在 .NET Framework 项目里遇到命名空间 Windows.Devices.Bluetooth 不存在原因是没引用 WinRT 元数据。解决办法在项目里添加引用 → 浏览 → 找到C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.19041.0\Windows.winmd同时在 csproj 里加上Reference IncludeWindows /但在 .NET 6 里只要 TFM 写对了这些都不需要手动加。这也是我推荐新项目用 .NET 6 的原因之一。4. 设备扫描BluetoothLEAdvertisementWatcher 的正确用法4.1 创建 Watcher 并配置扫描参数扫描的核心类是BluetoothLEAdvertisementWatcher。它的基本用法var watcher new BluetoothLEAdvertisementWatcher { ScanningMode BluetoothLEScanningMode.Active }; watcher.Received OnAdvertisementReceived; watcher.Stopped OnWatcherStopped; watcher.Start();ScanningMode有两个值Active和Passive。区别在于 Active 会主动发送扫描请求能拿到扫描响应包里的额外数据比如设备名。如果你发现扫到的设备没有名字多半是用了 Passive 模式。Received事件里能拿到BluetoothLEAdvertisementReceivedEventArgs里面有BluetoothAddress设备 MAC 地址ulong 类型Advertisement广播内容包含 LocalName、ServiceUuids 等RawSignalStrengthInDBm信号强度可以用来估算距离4.2 按服务 UUID 过滤减少无效扫描如果你只关心特定服务比如自定义的温湿度服务可以在 Watcher 上设置过滤器watcher.AdvertisementFilter.Advertisement.ServiceUuids.Add( new Guid(0000fff0-0000-1000-8000-00805f9b34fb));这样只有广播里包含这个 UUID 的设备才会触发Received事件。实测下来过滤能显著降低 CPU 占用尤其是在设备密集的环境里。但要注意有些设备的广播包里不包含服务 UUID只在扫描响应里才有。这种情况下过滤器会漏掉设备。我的做法是先不过滤扫一遍看看目标设备的广播内容确认 UUID 在哪个包里再决定要不要加过滤。4.3 扫描到设备后的处理别急着连接Received事件触发很频繁同一个设备可能一秒触发好几次。所以你需要去重private readonly Dictionaryulong, DateTime _seenDevices new(); private void OnAdvertisementReceived( BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs args) { if (_seenDevices.ContainsKey(args.BluetoothAddress)) return; _seenDevices[args.BluetoothAddress] DateTime.Now; var name args.Advertisement.LocalName; // 更新 UI 列表 }这里有个坑不要在 Received 事件里直接做耗时操作比如连接设备。这个事件是在后台线程触发的而且频率很高做耗时操作会阻塞后续事件。正确做法是把设备信息存起来让用户点击后再连接。注意扫描会消耗电量也会占用蓝牙适配器。扫到目标设备后如果不需要持续扫描记得调用watcher.Stop()。我见过有人忘了停结果连接设备时一直失败——因为扫描和连接会抢适配器资源。5. 建立连接与枚举 GATT 服务从地址到可用特征值5.1 FromBluetoothAddressAsync 的返回值陷阱拿到设备地址后连接的第一步是var device await BluetoothLEDevice.FromBluetoothAddressAsync(address); if (device null) { // 设备不可用 return; }这里有个非常容易踩的坑FromBluetoothAddressAsync返回 null 不代表设备不存在。它返回 null 的常见原因有设备已经断开且不在广播系统蓝牙适配器被关闭设备地址是随机地址Random Address系统无法解析尤其是第三点很多 BLE 设备为了隐私会用随机 MAC 地址每次广播的地址都不一样。这种情况下你没法用地址去连接只能通过扫描时拿到的BluetoothLEDevice对象直接连。5.2 获取 GATT 服务GetGattServicesAsync 的两种缓存模式连接上设备后下一步是枚举服务var servicesResult await device.GetGattServicesAsync( BluetoothCacheMode.Uncached);BluetoothCacheMode有两个值Cached和Uncached。这是个大坑Cached从系统缓存读速度快但可能读到过时的数据。如果设备改了服务结构缓存不会更新。Uncached强制从设备重新读取慢但准确。我的经验是第一次连接用 Uncached后续用 Cached。如果你发现枚举出来的服务和设备文档对不上八成是缓存问题换成 Uncached 再试。5.3 找到目标特征值UUID 匹配与属性检查枚举到服务后遍历找目标特征值foreach (var service in servicesResult.Services) { if (service.Uuid ! targetServiceUuid) continue; var charResult await service.GetCharacteristicsAsync( BluetoothCacheMode.Uncached); foreach (var characteristic in charResult.Characteristics) { if (characteristic.Uuid targetCharUuid) { // 找到了 } } }这里要检查characteristic.CharacteristicProperties确认它支持你要的操作var props characteristic.CharacteristicProperties; bool canRead props.HasFlag(GattCharacteristicProperties.Read); bool canNotify props.HasFlag(GattCharacteristicProperties.Notify);如果属性里没有 Read你调ReadValueAsync会直接抛异常。我见过有人对着一个只支持 Notify 的特征值反复读读了一下午都没数据。6. 数据收发实战读取、写入与通知订阅6.1 读取特征值ReadValueAsync 与数据解析读取的代码很直接var readResult await characteristic.ReadValueAsync( BluetoothCacheMode.Uncached); if (readResult.Status GattCommunicationStatus.Success) { var reader DataReader.FromBuffer(readResult.Value); byte[] data new byte[readResult.Value.Length]; reader.ReadBytes(data); // 解析 data }关键在数据解析。BLE 特征值返回的是原始字节怎么解释取决于设备协议。比如一个温度特征值可能是 2 字节的小端整数单位是 0.01 摄氏度short raw BitConverter.ToInt16(data, 0); double temperature raw * 0.01;这里要注意字节序。BLE 协议一般用小端Little-Endian而BitConverter在 Windows 上也是小端所以直接转换通常没问题。但如果设备用大端你就得手动翻转。6.2 写入特征值WriteValueWithResultAsync 的选项写入有两种方式var writer new DataWriter(); writer.WriteBytes(new byte[] { 0x01, 0x02 }); var writeResult await characteristic.WriteValueWithResultAsync( writer.DetachBuffer(), GattWriteOption.WriteWithResponse);GattWriteOption有两个值WriteWithResponse需要设备确认可靠但慢。WriteWithoutResponse不需要确认快但可能丢。选哪个取决于特征值的属性。如果特征值只支持 WriteWithoutResponse你用 WriteWithResponse 会失败。反过来也一样。所以写之前一定要检查CharacteristicProperties。6.3 订阅通知CCCD 写入与 ValueChanged 事件这是 BLE 开发里最容易出错的一步。完整流程// 1. 找到 CCCD var cccd await characteristic.GetDescriptorsAsync(); var notifyDescriptor cccd.Descriptors.FirstOrDefault( d d.Uuid GattDescriptorUuids.ClientCharacteristicConfiguration); // 2. 写入 CCCD 开启通知 var writer new DataWriter(); writer.WriteBytes(GattClientCharacteristicConfigurationDescriptorValue .Notify.ToByteArray()); await notifyDescriptor.WriteValueAsync(writer.DetachBuffer()); // 3. 订阅事件 characteristic.ValueChanged OnCharacteristicValueChanged;ValueChanged事件的参数里有CharacteristicValue解析方式和 Read 一样。我踩过的一个坑CCCD 写入成功不代表通知马上就来。有些设备需要几百毫秒才发第一条数据。如果你写完 CCCD 就立刻检查数据可能会以为失败了。正确的做法是加个超时等待或者用状态机管理。另一个坑断开连接后CCCD 配置会失效。下次连接必须重新写 CCCD。所以你的连接流程里订阅通知应该是每次连接后都执行的一步不能只做一次。7. 连接管理与异常处理让程序稳定跑下去7.1 连接状态监控ConnectionStatusChanged 事件BLE 连接随时可能断——设备走远了、没电了、被其他设备抢占了。你必须监听连接状态device.ConnectionStatusChanged (s, e) { if (device.ConnectionStatus BluetoothConnectionStatus.Disconnected) { // 触发重连逻辑 } };注意这个事件触发时device对象可能已经不可用了。你需要重新走一遍扫描和连接流程。我的做法是维护一个状态机Disconnected → Scanning → Connecting → Connected → Subscribing每个状态有对应的处理逻辑。7.2 常见异常与排查表异常/现象可能原因解决办法FromBluetoothAddressAsync 返回 null设备不在广播、随机地址、适配器关闭改用扫描时拿到的对象连接GetGattServicesAsync 超时链路未建立、设备忙重试或先读一个简单特征值触发链路建立ReadValueAsync 返回 AccessDenied特征值不支持 Read检查 CharacteristicPropertiesValueChanged 不触发CCCD 未写、写错值确认 CCCD UUID 和写入值连接频繁断开信号弱、设备省电策略检查 RSSI考虑加心跳7.3 资源释放别让 BluetoothLEDevice 泄漏BluetoothLEDevice实现了IDisposable用完必须释放device.Dispose(); device null;如果不释放系统会保持连接导致设备无法被其他程序连接。我见过一个案例程序崩溃后没释放设备一直显示已连接重启电脑才恢复。另外GetGattServicesAsync返回的 Service 和 Characteristic 对象也建议在不用时释放。虽然它们不像 Device 那样占资源但在长时间运行的程序里累积起来也会有问题。8. 几个让我印象深刻的实战坑8.1 缓存导致的服务消失有一次我调试一个自定义 BLE 模块明明固件里定义了 3 个服务但代码枚举出来只有 2 个。查了半天最后发现是BluetoothCacheMode.Cached惹的祸——系统缓存了旧的 GATT 表。改成Uncached后3 个服务全出来了。这个坑的教训是调试阶段一律用 Uncached等稳定了再考虑用 Cached 优化性能。8.2 通知数据粘包BLE 的通知是一个特征值一次通知但有些设备会把多条数据合并到一个通知里发。比如一个加速度计一次通知里包含 3 个轴的 6 个字节。如果你按单轴解析数据就全乱了。解决办法是看设备协议文档确认每个通知包的结构。如果没有文档就用抓包工具比如 nRF Connect看一下原始数据反推结构。8.3 多设备并发连接如果你要同时连接多个 BLE 设备注意 Windows 的蓝牙适配器有并发限制。实测下来同时连 7 个左右就开始不稳定了。如果设备多建议排队连接或者用多个适配器。另外多设备场景下每个设备要有独立的BluetoothLEDevice对象和状态机不能共用一个。我见过有人用一个全局 device 变量结果设备 A 的数据跑到设备 B 上去了。8.4 断开重连的时机设备断开后不要立刻重连因为设备可能还在清理上一次连接。我的经验是等 2-3 秒再重连成功率明显提高。如果连续重连失败 3 次就等 10 秒再试避免把设备惹毛。9. 完整代码骨架把上面的东西串起来下面是一个可以直接参考的代码骨架把扫描、连接、订阅、收发都串起来了。你可以基于这个改public class BleClient : IDisposable { private BluetoothLEDevice? _device; private GattCharacteristic? _notifyChar; private GattCharacteristic? _writeChar; private BluetoothLEAdvertisementWatcher? _watcher; public event Actionbyte[]? DataReceived; public async Task ScanAndConnectAsync(Guid serviceUuid, Guid notifyUuid) { _watcher new BluetoothLEAdvertisementWatcher { ScanningMode BluetoothLEScanningMode.Active }; _watcher.AdvertisementFilter.Advertisement.ServiceUuids.Add(serviceUuid); var tcs new TaskCompletionSourceulong(); _watcher.Received (s, e) { tcs.TrySetResult(e.BluetoothAddress); }; _watcher.Start(); var address await tcs.Task; _watcher.Stop(); _device await BluetoothLEDevice.FromBluetoothAddressAsync(address); if (_device null) throw new Exception(设备连接失败); var services await _device.GetGattServicesAsync( BluetoothCacheMode.Uncached); foreach (var service in services.Services) { if (service.Uuid ! serviceUuid) continue; var chars await service.GetCharacteristicsAsync( BluetoothCacheMode.Uncached); foreach (var c in chars.Characteristics) { if (c.Uuid notifyUuid) { _notifyChar c; await EnableNotificationAsync(c); } } } } private async Task EnableNotificationAsync(GattCharacteristic c) { var descs await c.GetDescriptorsAsync(); var cccd descs.Descriptors.FirstOrDefault( d d.Uuid GattDescriptorUuids .ClientCharacteristicConfiguration); if (cccd null) return; var writer new DataWriter(); writer.WriteBytes(GattClientCharacteristicConfigurationDescriptorValue .Notify.ToByteArray()); await cccd.WriteValueAsync(writer.DetachBuffer()); c.ValueChanged (s, e) { var reader DataReader.FromBuffer(e.CharacteristicValue); var data new byte[e.CharacteristicValue.Length]; reader.ReadBytes(data); DataReceived?.Invoke(data); }; } public async Task WriteAsync(byte[] data) { if (_writeChar null) return; var writer new DataWriter(); writer.WriteBytes(data); await _writeChar.WriteValueWithResultAsync( writer.DetachBuffer(), GattWriteOption.WriteWithResponse); } public void Dispose() { _watcher?.Stop(); _device?.Dispose(); _device null; } }这个骨架里我省略了一些错误处理和状态管理实际项目里你需要补上。但核心流程就是这些扫描 → 连接 → 枚举服务 → 订阅通知 → 收发数据。10. 关于 BLE 开发的一点个人体会做 BLE 开发这几年我最大的感受是BLE 的复杂度不在代码而在协议和设备的脾气。同一套代码换个设备可能就跑不通因为每个厂商对 GATT 的实现都有自己的个性。有的设备 CCCD 写入后要等 500ms 才生效有的设备 Read 之前必须先 Write 一个使能命令有的设备通知包格式和文档完全对不上。所以我的建议是手边常备一个通用的 BLE 调试工具比如 nRF Connect 或者 LightBlue遇到问题先用工具验证设备行为确认是设备问题还是代码问题。工具能正常读写的代码一定能跑通工具都读不到的代码再怎么写也没用。另外日志一定要打全。BLE 的每一步操作扫描到谁、连接结果、服务枚举结果、CCCD 写入结果、每次通知的原始字节都记下来。出问题时这些日志就是你的黑匣子。我现在的项目里BLE 模块的日志级别默认就是 Debug宁可多打一点也不要出问题时抓瞎。最后说一个心态问题BLE 调试有时候会很磨人一个连接问题可能查一整天。但一旦你把链路跑通后面就是重复劳动了。耐心一点把每一步都验证清楚比急着写业务代码要划算得多。
返回列表