☰
C# 蓝牙 GATT 通信实战:从扫描到通知订阅的完整链路
2026/10/21 5:32:51 网站建设 项目流程

简介:这份资源面向具备一定C#基础的开发者,聚焦Windows平台下蓝牙低功耗(BLE)设备的GATT通信实现,可用于健康追踪器、智能家居等物联网场景的数据交互开发。内容围绕设备扫描连接、GATT服务与特性查询、特性值读写以及值变更通知订阅等核心环节展开,帮助读者理解GATT协议中服务、特性、描述符的结构关系,并掌握Windows.Devices.Bluetooth.GenericAttributeProfile命名空间的实际用法。资源包共145个文件,约510KB,以32个cs源码文件为主体,辅以56个baml界面资源、2个xaml布局文件、若干dll与pdb调试文件、json配置及csproj工程文件,构成一套可直接编译运行的完整示例工程。目前已有977人学习下载,适合希望快速上手BLE通信、对照代码理解读写与通知机制、并注意跨平台兼容与功耗管理的开发者参考。

1. 蓝牙 GATT 通信:为什么 C# 开发者绕不开这套协议栈

如果你做过 Windows 桌面端和低功耗蓝牙设备打交道的项目,大概率经历过这样的场景:设备明明在手机 App 上跑得好好的,换到 PC 端用 C# 写,要么扫描不到,要么连上了读不到特征值,要么订阅通知之后一个回调都收不到。这不是玄学,而是 GATT 协议栈在 Windows 上的行为跟移动端有实打实的差异。这个项目要解决的,就是在 C# 里把蓝牙 GATT 通信跑通——从设备扫描、服务发现、特征值读写到通知订阅,形成一条完整的链路。它适合两类人:一是需要在 Windows 上位机、工控面板、测试工具里集成低功耗蓝牙通信的开发者;二是已经用过Windows.Devices.Bluetooth命名空间但被各种异步回调和权限问题折腾过的熟手。下面按实际拆项目的节奏,从架构到代码到改造,一层层过。

2. 技术选型与架构拆解:WinRT API 还是第三方库

2.1 为什么首选 Windows.Devices.Bluetooth

在 C# 里做蓝牙 GATT 通信,摆在面前的路其实不多。第一条是微软自带的 WinRT API,也就是Windows.Devices.Bluetooth和Windows.Devices.Bluetooth.GenericAttributeProfile这两个命名空间;第二条是 32feet.NET 这类第三方库;第三条是走串口模拟或者厂商自己的 SDK。

32feet.NET 在经典蓝牙(SPP)时代确实好用,但到了 BLE GATT 这块,它的维护节奏明显跟不上 Windows 的更新,尤其是在通知订阅和连接稳定性上,踩坑概率不低。厂商 SDK 的问题更直接——换个模组就得重写一遍通信层,耦合太深。

所以这个项目的技术选型很明确:基于 WinRT 的Windows.Devices.Bluetooth系列 API 来构建。它的优势在于系统级支持,不需要额外引入 NuGet 包,跟 Windows 的蓝牙驱动栈直接对接,长期维护成本最低。代价是 API 全是异步的,而且部分操作对 UI 线程有依赖,需要开发者自己对异步流程做封装。

2.2 整体分层结构

项目按职责分成四层,每层只做自己该做的事:

层级职责关键类型
设备发现层扫描周边 BLE 设备,按名称或服务 UUID 过滤BluetoothLEAdvertisementWatcher
连接管理层建立 GATT 连接,维护连接状态,处理断连重连BluetoothLEDevice
服务操作层发现服务与特征值,执行读写和通知订阅GattDeviceService、GattCharacteristic
业务适配层把原始字节流按协议解析成业务数据,向上层暴露事件自定义DeviceClient类

这样分层的好处是,设备发现和连接管理可以独立测试,服务操作层的代码不依赖具体设备型号,业务适配层换一个设备协议只需要改解析逻辑。

2.3 核心异步模型

WinRT 的蓝牙 API 全部返回IAsyncOperation<T>,在 C# 里用await就能拿到结果。但有一个容易翻车的点:BluetoothLEAdvertisementWatcher的Received事件是在后台线程触发的,如果你直接在事件处理里更新 UI 或者访问 UI 线程持有的对象,就会遇到跨线程访问异常。常见做法是在事件处理里只做数据收集,把结果丢到一个ConcurrentQueue或者通过DispatcherQueue派发到 UI 线程再处理。

// 扫描回调:只做数据收集,不碰 UI private void OnAdvertisementReceived( BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs args) { // 按设备名称过滤,避免把周围所有 BLE 设备都收进来 var localName = args.Advertisement.LocalName; if (string.IsNullOrEmpty(localName) || !localName.StartsWith("MyDevice")) return; // 只入队,不做任何耗时操作 _discoveredDevices.Enqueue(new DeviceInfo { Address = args.BluetoothAddress, Name = localName, Rssi = args.RawSignalStrengthInDBm }); }

这段代码的关键在于:Received事件的触发频率可能很高,如果在这里做解析或者 UI 更新,轻则卡顿,重则丢包。入队之后由消费线程统一处理,是更稳妥的做法。BluetoothAddress是 ulong 类型,后续连接设备时需要用BluetoothLEDevice.FromBluetoothAddressAsync来换取设备对象。

3. 从扫描到连接:GATT 通信链路的关键代码

3.1 设备扫描与过滤策略

扫描看起来简单,实际上过滤策略决定了后续连接的成功率。BluetoothLEAdvertisementWatcher支持在AdvertisementFilter里预设条件,比如按服务 UUID 过滤,这样系统层面就帮你筛掉了一大批无关设备,比在回调里手动判断效率高得多。

// 创建扫描器并设置过滤条件 var watcher = new BluetoothLEAdvertisementWatcher { ScanningMode = BluetoothLEScanningMode.Active // Active 模式能拿到 ScanResponse }; // 按服务 UUID 过滤:只关心广播了目标服务的设备 var filter = new BluetoothLEAdvertisementFilter(); filter.Advertisement.ServiceUuids.Add( Guid.Parse("0000FFF0-0000-1000-8000-00805F9B34FB")); watcher.AdvertisementFilter = filter; watcher.Received += OnAdvertisementReceived; watcher.Stopped += OnWatcherStopped; watcher.Start();

ScanningMode.Active会主动发送扫描请求,能拿到设备的 ScanResponse 数据,里面通常包含完整的设备名称和额外的服务信息。代价是功耗略高,但在 PC 端这不是问题。过滤条件里的服务 UUID 要跟设备固件里广播的保持一致,如果设备广播的是 16 位短 UUID,需要补全成 128 位格式再传入。

3.2 建立 GATT 连接与断连处理

拿到BluetoothAddress之后,连接设备用BluetoothLEDevice.FromBluetoothAddressAsync。这里有一个血泪经验:连接成功后必须注册ConnectionStatusChanged事件,否则设备意外断开时你根本不知道,后续所有读写操作都会超时。

// 通过地址获取设备对象 var device = await BluetoothLEDevice.FromBluetoothAddressAsync(address); if (device == null) { // 设备可能已经离开范围或被其他主机占用 throw new InvalidOperationException("无法获取蓝牙设备对象"); } // 注册连接状态变化,这是断连感知的唯一可靠来源 device.ConnectionStatusChanged += (s, e) => { if (s.ConnectionStatus == BluetoothConnectionStatus.Disconnected) { // 触发重连逻辑或通知上层 OnDeviceDisconnected(s.DeviceId); } }; // 获取目标服务 var services = await device.GetGattServicesForUuidAsync( Guid.Parse("0000FFF0-0000-1000-8000-00805F9B34FB"), BluetoothCacheMode.Uncached); // Uncached 强制从设备重新发现 if (services.Status != GattCommunicationStatus.Success || services.Services.Count == 0) { throw new InvalidOperationException("目标服务未找到"); } var service = services.Services[0];

BluetoothCacheMode.Uncached这个参数值得单独说。Windows 会缓存 GATT 服务列表,如果设备固件更新了服务结构但缓存没刷新,你会拿到过期的数据。调试阶段建议一律用Uncached,正式发布后再根据场景决定是否用Cached来加速连接。

3.3 特征值读写与通知订阅

服务拿到之后,下一步是发现特征值。读操作相对直接,写操作要注意GattWriteOption的选择,而通知订阅是整条链路里最容易出问题的环节。

// 发现服务下的所有特征值 var characteristics = await service.GetCharacteristicsAsync( BluetoothCacheMode.Uncached); // 找到可读写的特征值 var writeChar = characteristics.Characteristics .FirstOrDefault(c => c.CharacteristicProperties.HasFlag( GattCharacteristicProperties.Write)); var notifyChar = characteristics.Characteristics .FirstOrDefault(c => c.CharacteristicProperties.HasFlag( GattCharacteristicProperties.Notify)); // 写入数据:WriteWithResponse 更可靠,但速度慢 if (writeChar != null) { var data = new byte[] { 0x01, 0x02, 0x03 }; var writer = new DataWriter(); writer.WriteBytes(data); var result = await writeChar.WriteValueWithResultAsync( writer.DetachBuffer(), GattWriteOption.WriteWithResponse); // result.Status 需要检查,不能假设一定成功 } // 订阅通知:先写 CCCD,再注册 ValueChanged if (notifyChar != null) { // 写入客户端特征配置描述符,开启通知 var cccdResult = await notifyChar.WriteClientCharacteristicConfigurationDescriptorWithResultAsync( GattClientCharacteristicConfigurationDescriptorValue.Notify); if (cccdResult.Status != GattCommunicationStatus.Success) { throw new InvalidOperationException("CCCD 写入失败,无法开启通知"); } notifyChar.ValueChanged += (s, e) => { var reader = DataReader.FromBuffer(e.CharacteristicValue); var bytes = new byte[e.CharacteristicValue.Length]; reader.ReadBytes(bytes); // 把原始字节交给业务层解析 OnNotificationReceived(bytes); }; }

这段代码里有三个关键点。第一,WriteWithResponse会等待设备回复确认,可靠性高但吞吐量低;如果传的是高频传感器数据,可以换成WriteWithoutResponse,但要确认设备固件支持。第二,订阅通知之前必须先写 CCCD 描述符,这一步经常被忽略,导致ValueChanged永远不触发。第三,ValueChanged回调里的CharacteristicValue是一个IBuffer,用完必须及时读取,否则可能被后续数据覆盖。

4. 避坑与排查:蓝牙 GATT 开发中的常见翻车现场

4.1 扫描不到设备

现象:BluetoothLEAdvertisementWatcher启动了,但Received事件一次都不触发,或者只收到部分设备。

原因通常有三个:一是 Windows 的蓝牙适配器被其他程序独占,比如手机助手类软件;二是AdvertisementFilter设置得太严格,设备广播的 UUID 跟过滤条件不匹配;三是系统蓝牙服务异常,需要重启Bluetooth Support Service。

解决步骤:先把过滤器清空,确认能否扫到任意 BLE 设备。如果能扫到但过滤后没有,就检查设备实际广播的 UUID。如果清空后也扫不到,去设备管理器确认蓝牙适配器状态,必要时在服务管理器里重启蓝牙相关服务。

4.2 连接成功但服务发现失败

现象:FromBluetoothAddressAsync返回了非空对象,但GetGattServicesForUuidAsync返回的Status不是Success,或者服务列表为空。

原因多半是缓存问题。Windows 会缓存之前发现过的 GATT 服务,如果设备固件更新了服务结构,缓存里的旧数据就会导致发现失败。另一个可能是设备在连接建立后需要一段时间才能响应服务发现请求,立即调用会超时。

解决办法:强制使用BluetoothCacheMode.Uncached,并且在连接成功后加一个短暂的延迟再发起服务发现。如果仍然失败,尝试先调用device.GetGattServicesAsync()获取全部服务,再从中筛选目标服务。

4.3 通知订阅后收不到数据

现象:CCCD 写入返回成功,ValueChanged也注册了,但设备发送数据时回调不触发。

这个问题的排查要分两步。第一步,确认设备端确实在发送通知。可以用手机上的通用 BLE 调试工具验证,如果手机端能收到,说明设备没问题。第二步,检查 CCCD 写入的值是否正确。有些设备只支持Indicate而不支持Notify,这时候需要写GattClientCharacteristicConfigurationDescriptorValue.Indicate。另外,部分设备的通知数据是通过ValueChanged之外的机制上报的,需要查阅设备的具体协议文档。

4.4 多设备并发连接时的资源竞争

现象:同时连接多个 BLE 设备时,部分设备的操作开始超时或返回GattCommunicationStatus.Unreachable。

原因是 Windows 的蓝牙协议栈对并发连接数有限制,具体数量取决于适配器型号和驱动版本。超过限制后,新的连接请求会被排队或直接拒绝。

解决办法:在应用层做连接池管理,限制同时活跃的连接数。对于不需要保持长连接的操作,可以在读写完成后主动断开,释放资源。另外,BluetoothLEDevice对象实现了IDisposable,不用的时候一定要Dispose,否则底层句柄不会释放。

4.5 写入大数据包时失败

现象:写入超过 20 字节的数据时,WriteValueWithResultAsync返回失败或者设备无响应。

BLE 的默认 MTU 是 23 字节,去掉协议头之后有效载荷只有 20 字节。虽然可以通过协商提高 MTU,但 Windows 的 API 对 MTU 协商的支持有限,实际可用值取决于设备端。

解决办法:在应用层做分包处理,把大数据拆成 20 字节以内的小包依次发送。如果设备支持长写(Long Write),可以用GattWriteOption.WriteWithResponse配合偏移量分多次写入。更稳妥的做法是在协议设计阶段就把单包数据控制在 20 字节以内。

5. 二次开发改造:从能跑到好用的几个关键调整

5.1 封装可复用的 DeviceClient

原始 API 用起来啰嗦,每次操作都要检查GattCommunicationStatus,还要处理各种异步回调。我一般会封装一个DeviceClient类,把连接、读写、订阅都包成事件驱动或者Task返回的形式,上层业务代码不需要直接接触 WinRT 类型。

public class BleDeviceClient : IDisposable { private BluetoothLEDevice _device; private GattDeviceService _service; private GattCharacteristic _notifyChar; // 连接并自动发现目标服务 public async Task ConnectAsync(ulong address, Guid serviceUuid) { _device = await BluetoothLEDevice.FromBluetoothAddressAsync(address); _device.ConnectionStatusChanged += OnConnectionStatusChanged; var services = await _device.GetGattServicesForUuidAsync( serviceUuid, BluetoothCacheMode.Uncached); if (services.Status != GattCommunicationStatus.Success) throw new BleException($"服务发现失败: {services.Status}"); _service = services.Services[0]; } // 订阅通知,把原始字节通过事件抛出 public async Task SubscribeAsync(Guid characteristicUuid) { var chars = await _service.GetCharacteristicsForUuidAsync( characteristicUuid, BluetoothCacheMode.Uncached); _notifyChar = chars.Characteristics[0]; var cccd = await _notifyChar .WriteClientCharacteristicConfigurationDescriptorWithResultAsync( GattClientCharacteristicConfigurationDescriptorValue.Notify); if (cccd.Status != GattCommunicationStatus.Success) throw new BleException("CCCD 写入失败"); _notifyChar.ValueChanged += (s, e) => { var reader = DataReader.FromBuffer(e.CharacteristicValue); var bytes = new byte[e.CharacteristicValue.Length]; reader.ReadBytes(bytes); DataReceived?.Invoke(this, bytes); }; } public event EventHandler<byte[]> DataReceived; public void Dispose() { _notifyChar = null; _service?.Dispose(); _device?.Dispose(); } }

这个封装的核心思路是:把 WinRT 的IAsyncOperation转成标准的Task,把ValueChanged转成 .NET 事件,把状态检查统一成异常。上层业务代码只需要await client.ConnectAsync(...)然后订阅DataReceived事件就行,不需要关心底层是 WinRT 还是别的实现。

5.2 断连重连的自动化处理

蓝牙设备断连是常态,尤其是低功耗设备为了省电会主动断开。手动重连不现实,需要在ConnectionStatusChanged里触发自动重连逻辑。我的做法是维护一个重连状态机:断连后先等待一个退避时间(比如 1 秒、2 秒、4 秒递增),然后尝试重新连接,连续失败超过阈值后通知上层。

private async void OnConnectionStatusChanged( BluetoothLEDevice sender, object args) { if (sender.ConnectionStatus == BluetoothConnectionStatus.Disconnected) { var retryCount = 0; while (retryCount < MaxRetry) { await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retryCount))); try { await ReconnectAsync(); return; // 重连成功,退出 } catch { retryCount++; } } // 超过重试上限,通知上层处理 ConnectionLost?.Invoke(this, EventArgs.Empty); } }

退避重连的关键是不要立即重试,给设备一点时间恢复广播。指数退避能避免在设备暂时离线时疯狂重试消耗资源。

5.3 数据解析层的可插拔设计

不同设备的 GATT 协议不一样,有的用固定长度帧,有的用 TLV 格式,有的还带校验和。如果把解析逻辑硬编码在DeviceClient里,换一个设备就得改一遍。更好的做法是定义一个IDataParser接口,把解析逻辑抽出来。

public interface IDataParser { // 把原始字节解析成业务对象 object Parse(byte[] rawData); // 把业务对象序列化成可写入的字节 byte[] Serialize(object data); } // 使用示例:在 DeviceClient 里注入解析器 public class DeviceClient { private readonly IDataParser _parser; public DeviceClient(IDataParser parser) { _parser = parser; } private void OnDataReceived(byte[] raw) { var businessData = _parser.Parse(raw); BusinessDataReceived?.Invoke(this, businessData); } }

这样换设备的时候只需要实现一个新的IDataParser,DeviceClient本身不用动。测试的时候也可以用一个 Mock 解析器,不依赖真实设备就能验证上层逻辑。

5.4 调试与验证的实用技巧

蓝牙开发最头疼的是没有直观的调试手段。我的习惯是:第一,在关键路径上加日志,尤其是连接状态变化、服务发现结果、CCCD 写入状态这几个点;第二,准备一个手机端的通用 BLE 调试工具作为对照,当 PC 端行为异常时,先用手机验证设备本身是否正常;第三,对于通知订阅,可以在 CCCD 写入成功后主动读一次特征值,确认通道是通的。

还有一个容易被忽略的点:Windows 的蓝牙缓存有时候会顽固到重启应用都不管用,需要到「设置 → 蓝牙和其他设备」里把已配对的设备删掉,再重新扫描连接。从那以后我每次调试新设备都强制走一遍「删除配对 → 重新扫描 → Uncached 发现服务」的流程,省得在缓存问题上浪费时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询