C#蓝牙GATT通信实战:从扫描、连接到Notify订阅与心率数据解析

📅 发布时间:2026/10/17 13:02:52
C#蓝牙GATT通信实战:从扫描、连接到Notify订阅与心率数据解析
简介面向C#开发者的蓝牙GATT通信示例包专门演示Windows平台下与低功耗蓝牙设备的数据交换。资源以可运行的工程形式提供从设备扫描、连接到服务与特性发现再到数据读取、写入和值变更通知完整走通一条低功耗蓝牙应用链路贴合健康追踪、智能家居等物联网开发场景也适合课程实验或毕业设计参考代码将蓝牙协议栈的常用操作梳理为清晰步骤目录划分直观方便二次开发。包内共145个文件以32个C#源码、56个界面资源、3个可执行程序为主并配有项目配置、设置文件和说明文档整体仅510KB可直接打开调试。已有977人学习下载数据表明不少开发者将其作为入门参考。借助这套示例可以理解GATT服务、特性、描述符与C#接口的对应关系掌握特征值读写及通知事件的注册处理方式还能借鉴其中错误处理与功耗管理思路规避配对失败、读取超时等常见问题为后续接入具体设备打下扎实基础。1. 蓝牙GATT通信在C#里的落地姿势从DeviceWatcher到数据回调很多C#工程师踩进蓝牙GATT通信第一反应都是先找库、再拉代码然后被一个接一个的“权限”“服务发现为空”“回调线程崩溃”磨掉耐心。老实说GATT本身并不复杂——它的骨架就是设备、服务、特征、描述符四层嵌套复杂的是那些封装把底层细节藏得太深导致你看着API不明所以一跑就翻车。下面我按一条主线来拆GATT的最小概念、四套常见库怎么选、扫描连接全流程、一个心率传感器实时读取的完整案例以及我踩过的五个坑。不管你要连的是体脂秤、智能灯还是传感器标签这条链路都能直接套用。2. GATT协议与C#的模型映射先搞懂UUID再写扫描代码2.1 GATT四层结构和UUID换算蓝牙低功耗BLE的GATT协议从协议栈往下看是一整套规范但从应用层看它把设备的能力组织成一颗树。最顶层是设备Device一个设备里有多个服务Service每个服务下挂着若干特征值Characteristic特征值还能带描述符Descriptor。你在C#里见到的那些核心类——BluetoothLEDevice、GattDeviceService、GattCharacteristic——就是照着这棵树设计的。服务用UUID标识。平时你看到的“心率服务0x180D”里的180D不是完整GUID它是蓝牙组织分配的16位短UUID换算成128位GUID要拼上固定前缀0000180D-0000-1000-8000-00805F9B34FB。C#里的Guid类原生支持这个格式。特征值也一样心率数据特征0x2A37就对应00002A37-0000-1000-8000-00805F9B34FB。这个换算在过滤和比对时特别容易出错很多人拿短UUID直接new Guid(180D)编译过不了还找不到原因。特征值的读写方式决定你怎么调API。有的特征支持Read直接读值有的支持Write写入指令更多传感器类的特征走Notify或Indicate——设备主动往你这边推数据。开放Notify要往该特征的0x2902描述符CCCD里写指令C#里对应的就是WriteClientCharacteristicConfigurationDescriptorAsync这个方法。你可以把这三个性质记成读是拉写是推Notify是服务器主动塞给你。2.2 C#下四套GATT方案的取舍在C#里做BLE GATT市面主流就四个方向。我按实际体验排个序先看表格再解释。方案平台覆盖GATT完整度上手难度适用场景Windows官方APIWindows.Devices.Bluetooth仅Windows高中桌面工具、调试上位机社区跨平台库基于官方API封装Windows、Android、iOS中高低跨平台原型、移动端老牌串口类库Windows为主低BR/EDR强中兼容经典蓝牙串口模块自写P/Invoke封装按平台自定义极高成本高平台定制需求Windows官方API最大的优势是稳定。它在Windows 10之后的系统版本里支持得比较完整GATT服务发现、特征值读写、Notify订阅都有现成封装缺点是异步API铺得到处都是而且配对的权限弹窗在无人值守时容易卡住。社区跨平台库胜在API风格统一把各平台的差异封装了一层但遇到厂商特殊的广播和私有服务调试成本反而上去了。老牌串口类库处理经典蓝牙BR/EDR很顺手对BLE GATT的支持相对吃力——如果你要连的是老式蓝牙串口模块它合适对付BLE外设就别勉强。我自己习惯这样分流只做Windows工具就选官方API要上手机端就在跨平台库和官方API之间做一层抽象把平台相关的蓝牙代码全部收敛在接口后面方便以后换实现。你先别急着铺业务代码把平台跑通、把目标设备的Service和Characteristic枚举出来再定架构比一开始就定死某个库要稳得多。2.3 Central和Peripheral的角色分工GATT通信里有两个角色Central中心设备和Peripheral外围设备。手机、电脑、上位机通常做Central负责扫描、发起连接、读数据传感器、智能秤、手环做Peripheral广播自己的存在等Central来连。在C#里你绝大多数时候写的是Central逻辑但偶尔也会用模拟设备来做开发——比如在电脑上模拟一个GATT服务用手机去连它。Peripheral的核心是Advertisement广播包。广播包里通常有两种信息Flags表示设备是否可连接Service UUID列表声明自己支持哪些服务。扫描端就靠这些广播包快速判断“这台设备值不值得连”。C#里有个专门的BluetoothLEAdvertisementWatcher它的Received事件里会给你广播地址、RSSI信号强度、广播里的Service UUID列表。注意一点广播包里的服务不一定完整有些设备为了省电只会在广播里露一个Service剩下要等连接建立后通过GetGattServicesAsync去枚举。所以你在扫的时候过滤条件别写太死否则大概率漏掉目标设备。3. 扫描、配对、连接、发现服务GATT通信的四步链路3.1 用BluetoothLEAdvertisementWatcher扫描和过滤扫描有两种模式Passive只被动听广播包不主动响应扫描请求省电但信息少Active会回应设备发来的扫描请求设备会补充更多广播数据。你要过滤服务UUID、看设备是否可连接就用Active。示例代码如下var watcher new BluetoothLEAdvertisementWatcher(); watcher.ScanningMode BluetoothLEScanningMode.Active; // 只关注可连接的广播类型忽略不可连接的Beacon watcher.AdvertisementFilter.AdvertisementType BluetoothLEAdvertisementType.ConnectableUndirected; // 用128位UUID过滤心率服务 Guid heartRateServiceGuid Guid.Parse(0000180d-0000-1000-8000-00805f9b34fb); watcher.AdvertisementFilter.Advertisement.ServiceUuids.Add(heartRateServiceGuid); watcher.Received OnAdvertisementReceived; watcher.Start();这段代码的核心是三个配置点ScanningMode决定主动程度AdvertisementType只接收ConnectableUndirected把只广播不能连的信标设备过滤掉ServiceUuids把广播包里声明了心率服务的设备筛出来。需要提醒的是ServiceUuids过滤器在不同平台上的匹配逻辑不完全一样Windows上是“广播里包含该UUID即可”多条件过滤时要在回调里自己判断别依赖框架帮你做“或”逻辑。在Received事件里拿到广播地址和RSSI先做个信号阈值判断再继续private void OnAdvertisementReceived(BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs args) { // -80dBm以下的信号基本不可靠 if (args.RawSignalStrengthInDBm -80) return; ulong address args.BluetoothAddress; var serviceUuids args.Advertisement.ServiceUuids; foreach (var uuid in serviceUuids) { if (uuid heartRateServiceGuid) { // 记住这个地址稍后用来连接 candidateDeviceAddress address; sender.Stop(); break; } } }这里有个细节我吃过亏args.BluetoothAddress返回的是设备的蓝牙地址ulong类型不是GUID后面调BluetoothLEDevice.FromBluetoothAddressAsync传的就是它。还有Windows上同一台设备可能同时广播BR/EDR和LE如果你在回调里看到同一个地址多次触发用args.AdvertisementType过滤一下避免重复连接。3.2 配对不是必须的但坑往往是这里GATT连接本身不强制配对。很多设备打开可连接之后Central直接就能连上并访问服务只有涉及加密特征值才需要配对。Windows下配对由系统弹窗引导你在代码里能做的是提前请求访问权限BluetoothLEDevice device null; try { device await BluetoothLEDevice.FromBluetoothAddressAsync(candidateDeviceAddress); } catch (Exception ex) { // 地址非法、设备断电等都会往这跳 return; } DeviceAccessStatus accessStatus await device.RequestAccessAsync(); if (accessStatus ! DeviceAccessStatus.Allowed) { // 弹窗被拦截或用户在系统设置里关了蓝牙权限。 // 常见做法是提示用户去系统设置里打开“附近设备”权限。 return; }RequestAccessAsync才是决定成败的一步。它在Windows 11上会触发权限弹窗弹窗没被点允许后续GetGattServicesAsync就会遇到拒绝。如果你在开发调试阶段发现代码一直卡在权限这一步先到系统设置确认“蓝牙和其他设备”权限是打开的再检查是否有隐藏弹窗没被点掉。3.3 GetGattServicesAsync三层嵌套枚举连接成功后标准姿势是先拿服务列表再拿特征值最后做读写或订阅。官方API这层封装得很规整就是三层异步调用GattDeviceServicesResult servicesResult await device.GetGattServicesAsync(BluetoothCacheMode.Uncached); if (servicesResult.Status ! GattCommunicationStatus.Success) { Console.WriteLine($服务发现失败:{servicesResult.Status}); return; } foreach (var service in servicesResult.Services) { if (service.Uuid ! heartRateServiceGuid) continue; GattCharacteristicsResult characteristicsResult await service.GetCharacteristicsAsync(BluetoothCacheMode.Uncached); if (characteristicsResult.Status ! GattCommunicationStatus.Success) continue; foreach (var characteristic in characteristicsResult.Characteristics) { if (characteristic.Uuid heartRateCharacteristicGuid) { // 这就是我们要读的特征值节点 } } }BluetoothCacheMode有两个值Cached使用系统缓存的GATT表速度快但可能是旧的Uncached强制重新发现一次。第一次连接务必用Uncached避免拿到设备旧固件的服务定义。还有一点GattCommunicationStatus除了Success之外还有Unreachable这个状态通常是设备一连上立刻就断开先别急着重试看下是不是配对弹窗把连接抢走了。3.4 读取和写入注意特征值的属性拿到特征值后读取顺着GattCharacteristic的ReadValueAsync走写用WriteValueAsync。但前提是特征值本身支持读或写属性这个在设备数据手册里会写明白代码里可以直接检查CharacteristicPropertiesvar readResult await characteristic.ReadValueAsync(BluetoothCacheMode.Cached); if (readResult.Status GattCommunicationStatus.Success) { // readResult.Value是IBuffer需要DataReader转byte[] using var reader DataReader.FromBuffer(readResult.Value); byte[] data new byte[reader.UnconsumedBufferLength]; reader.ReadBytes(data); }写数据和读类似区别在于你要把数据打包成IBuffer再给WriteValueAsync。这里有个容易被忽略的参数WriteValueOption。它有两个值WriteWithResponse和WriteWithoutResponse。前者要求设备回ACK适合需要确认可靠性的场景后者发完就走适合传感器控制等不要求毫秒级确认的场景。选错的话前者会拖慢吞吐后者可能丢指令。我建议写过之后做一步反馈校验别假设每条指令都成功。4. 完整案例用C#读取心率设备并实时解析4.1 模拟端准备没有真实设备的场景我用手机上的一款BLE调试工具模拟一个心率外设。工具里新建一个仿真设备声明心率服务UUID 0x180D特征值0x2A37设为Notify属性然后在工具里周期性发送心率数据帧。这样就能在电脑上完整跑通整个链路。你要注意模拟工具的广播包里必须带上心率服务UUID否则第3章的过滤条件直接把你筛掉。4.2 订阅Notify并把数据带回UI线程真实设备在连接后不会主动发数据你写完CCCD才会开始推。这一步对应代码里的WriteClientCharacteristicConfigurationDescriptorAsyncGattCharacteristic heartRateCharacteristic null; // 前面枚举出来的 heartRateCharacteristic.ValueChanged OnHeartRateValueChanged; var status await heartRateCharacteristic.WriteClientCharacteristicConfigurationDescriptorAsync( GattClientCharacteristicConfigurationDescriptorValue.Notify); if (status ! GattCommunicationStatus.Success) { // 有些低功耗模块这里会失败多半是设备端Notify属性没配好 return; }订阅成功之后设备会在这个特征值上产生Notification事件。ValueChanged回调运行在后台线程如果想刷新UI必须用Dispatcher转发private async void OnHeartRateValueChanged(GattCharacteristic sender, GattValueChangedEventArgs args) { byte[] data GetBytesFromBuffer(args.CharacteristicValue); int heartRate ParseHeartRate(data); await Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal, () textBox.Text ${heartRate} BPM); }这一步是为了防止跨线程访问UI控件引发的异常。很多人最先遇到的是“Dispatcher对象在Console应用里拿不到”的问题。Console应用没有Dispatcher你直接在事件回调里写Console.WriteLine就行要是WPF用Application.Current.Dispatcher要是WinForms用Control.BeginInvoke。别图省事直接访问控件跨线程崩溃属于那种早不来晚不来、偏偏正式运行才来的坑。4.3 按心率协议解析原始字节心率数据帧的第一字节是Flags它用位标记告诉解析逻辑数据怎么读。最常见的心率格式是uint8也就是单字节一个值但也有心率带支持uint16。解析时必须按规则来不是简单读一个byte就完事private static int ParseHeartRate(byte[] data) { if (data.Length 2) return 0; byte flags data[0]; // bit0: 0表示心率是uint81表示uint16 bool isUInt16 (flags 0x01) ! 0; int heartRate; if (isUInt16) { heartRate BitConverter.ToUInt16(data, 1); } else { heartRate data[1]; } return heartRate; }这个解析有两个隐藏细节。第一心率值的偏移是1而不是0因为第0字节是Flags第二uint16时是低位在前Little EndianBitConverter在Windows平台默认就是小端所以直接用它就能对上号。如果你是解析别的传感器协议体脂、温度、步数也先看看厂家数据手册里有没有类似的Flags位别想当然地跳过首字节。4.4 缓存策略与采样频率的取舍讲完案例回到一个很多人会问的问题“为什么我订阅了Notify数据还是一跳一跳的”这通常是通知事件频繁触发、回调处理跟不上。ValueChanged事件是设备每发一帧就触发一次如果你在回调里做了耗时操作写日志、刷新UI布局丢帧是必然的。常规做法是回调里极简处理后把数据塞进队列由后台消费者线程统一处理前端再按展示需求降采样——比如UI每秒刷新一次但数据仍然全量接收。上面这段案例代码为了演示串行写UI是刻意简化的。生产环境里建议事件回调只负责把字节拷贝进ConcurrentQueue然后由独立的定时器每200ms批量刷新一次。这样不管是低功耗设备连续推数据还是高频率的心率采样都不会卡UI。5. 避坑实录GATT通信里最常见的五个坑5.1 服务列表为空GetGattServicesAsync返回空Service现象连接成功了Device对象也有了但GetGattServicesAsync枚举出来0个服务。原因最常见的两种一是缓存模式用错了第一次连就用了Cached模式Windows拿旧缓存返回空二是设备本身广播里没带服务信息连接后GATT表还没准备好。解决第一次枚举强制用Uncached模式等待设备连接状态稳定后再查。我一般会在连接状态变为Connected后Delay 300ms再获取服务给低功耗设备醒过来的时间。如果还是空大概率设备在固件里没把服务表配置好换另一台设备验证一下就知道是不是代码问题。关键代码可以写成这样GattDeviceServicesResult result await device.GetGattServicesAsync(BluetoothCacheMode.Uncached); if (result.Services.Count 0) { await Task.Delay(500); result await device.GetGattServicesAsync(BluetoothCacheMode.Uncached); }5.2 读特征值一直超时现象ReadValueAsync返回Unreachable或者等了很久没回。原因特征值本身不支持Read属性你看着有值就想读或者缓存模式用错导致拿不到或者你在订阅Notify之后又去Read设备固件没实现同时响应。解决先看CharacteristicProperties是不是包含Read标志不包含就别读。如果只是想拿当前值优先用Notify的缓存值固件层面很多低功耗设备的读只支持连接后直接读不要再叠加缓存。这种问题在国产低功耗模块上经常出现最好连上后用抓包工具看看是否存在指令冲突。判断代码如下if ((characteristic.CharacteristicProperties GattCharacteristicProperties.Read) 0) { // 不支持Read直接走Notify订阅 }5.3 配对的弹窗就是不弹出来现象调用了RequestAccessAsync但系统既不报错也不弹窗卡死在那。原因Windows的配对弹窗经常被全屏窗口或XAML框架的模态弹窗遮挡或者当前进程没有前台权限被系统静默拦截。另外应用在未获得“蓝牙”系统权限时RequestAccessAsync会直接Denied不会走弹窗流程。解决先把系统设置里的“蓝牙和其他设备”权限打开再检查当前进程有没有前台窗口。调试阶段用WinForms或WPF测试尽量别用纯Console去测这步拿不到可见弹窗很容易误以为卡死。界面侧的常见做法是加一个“配对中”的加载态用户点击后主动发起配对请求避免系统里多个弹窗堆叠。5.4 ValueChanged回调里刷UI直接崩现象回调里给TextBox赋值运行时抛跨线程异常。原因ValueChanged执行在后台线程UI控件只能从主线程访问。经典问题几乎每个蓝牙上位机都会踩一次。解决前面案例给了Dispatcher的标准写法WPF用Application.Current.DispatcherWinForms用Control.Invoke。本质上是一个SynchronizationContext的切换你可以在回调入口处记录主线程的SynchronizationContext后续统一用context.Post这样代码不会散落得到处都是Dispatcher调用。5.5 扫描不到新固件设备现象同一型号的设备换了新固件广播里带了新的私有Service UUID但你的代码扫描不到。原因Watcher启动时用了缓存或者广播过滤条件写死设备广播更新后Watcher仍按旧规则匹配有时候是Windows缓存了设备的旧广播地址。解决扫描结束之后把Watcher实例释放并重新创建别复用同一个实例做二次扫描。每次打开扫描界面都new一个新Watcher并将缓存模式设为Uncached。如果设备固件更新后在广播里换了UUID那么过滤条件也得跟着更新——固件升级干的就是这事别忽略。6. 进阶断线重连与多设备并发6.1 用ConnectionStatusChanged做断线业务连接状态变化要靠BluetoothLEDevice的ConnectionStatusChanged事件来跟踪。断线时先判断断因设备主动断开、信号丢失、系统节能休眠三种情况的重试策略不同。我的做法是维护一个状态机Connected、Disconnected、Reconnecting。断线后先等2秒再尝试重连如果连续失败3次提醒用户检查设备电量或是否进入休眠。device.ConnectionStatusChanged (s, e) { var status (s as BluetoothLEDevice).ConnectionStatus; if (status BluetoothConnectionStatus.Disconnected) { // 走重连状态机 _ TryReconnectAsync(device); } };重连时的地址还是用之前记住的蓝牙地址不要重新走扫描流程省掉广播发现这一步能快不少。重连成功后要重新枚举服务并重新订阅Notify因为旧的GattCharacteristic对象已经失效了这是最容易漏掉的一步。6.2 多设备并发别共享Watcher同时管理多个传感器时一个Watcher能收到所有广播但连接之后每个设备各占一个BluetoothLEDevice实例它们的ValueChanged事件也互不相通。我的习惯是建立设备会话Session的概念每个Session里包含设备对象、特征对象、状态机和数据缓冲队列这样断开、重连、销毁都好管理。从那次模拟项目之后我每次做蓝牙上位机都强制先写整链路连通测试从扫描到Notify全流程跑通再写业务逻辑。以前图省事跳过这步结果到项目中期才发现问题埋在服务发现那层排查起来极其痛苦。基础链路跑通一天后续开发省一周这个顺序值得坚持。希望这篇拆解对你有帮助。本文还有配套的精品资源点击获取