简介:围绕 C# 蓝牙 GATT 通信的示例工程与配套源码包,面向具备一定 C# 基础、希望把 BLE 设备接入桌面应用的开发者,可用于理解服务与特征值读写、通知订阅等典型流程。压缩包共 145 个文件,约 510KB,以 56 个 baml、32 个 cs 源码和 21 个 cache 编译缓存为主,并包含 xaml 界面、csproj 工程文件以及 exe、dll、pdb 等运行调试产物,便于直接查看项目结构与对照编译结果。已有 978 人学习,说明其作为入门与排错参考具有一定认可度。内容围绕 Windows 平台下 GenericAttributeProfile 的使用展开,读者可据此梳理设备扫描、连接、服务发现、特性读写和值变更通知的实现思路,同时留意跨平台兼容与错误处理;对需要复用 BLE 通信代码、完善物联网桌面端功能或排查连接问题的开发者,这份材料能提供较具体的工程参照。
1. 从一条读不到的特征值说起:C# 蓝牙 GATT 通信资源拆解
C# 网络编程里,蓝牙 GATT 常被当成一条普通数据通道,真接上设备才会发现读不到特征值、通知时有时无、断线后重连卡住。这份围绕 C# 蓝牙 GATT 通信整理的示例工程,把广播扫描、设备连接、服务发现、特征读写、Notify/Indicate 订阅、MTU 分片和资源释放串成一条可复现的路径,不是零散 API 摘抄。它适合正在做设备配置工具、测试台、产线工装或桌面接入程序的 C# 开发者,尤其是已经能把设备连上、却卡在“服务列表为空”“写命令没回包”“通知收不到”的人。代码按可替换参数组织,设备名、服务 UUID、特征 UUID 改掉就能对着手头硬件跑;如果你要从零搭一条稳定的 BLE 数据通道,这份资源能省掉反复试 API 的时间。
2. GATT 模型与 C# 接入方式:先选对 API 再谈连接
蓝牙 GATT 的难点不在“调一个方法”,而在对象生命周期和协议边界。设备广播里只带少量信息,真正读写要走“设备—服务—特征—描述符”这条链路,任何一层对象被提前释放、缓存模式选错、线程切错,表现都像“设备不稳定”。这份工程的第一层价值,是把这些对象收进一个可释放的上下文里,让排错时能明确知道问题出在扫描、连接、服务发现还是特征操作。
2.1 GATT 的四个对象与一次读写的完整链路
BLE 设备在 GATT 里通常扮演服务端,主机侧是客户端。客户端拿到的是BluetoothLEDevice,它代表一个远端设备;设备下挂GattDeviceService,服务下挂GattCharacteristic,特征下还可以有GattDescriptor。真正承载业务数据的是特征值,读写和通知都落在特征上。很多新手一上来就找Read,忽略了特征的CharacteristicProperties,结果对只支持通知的特征发读请求,状态直接返回不支持。常见做法是:发现特征后先打印属性,确认Read、Write、WriteWithoutResponse、Notify、Indicate哪些可用,再决定调用哪条路径。
一次完整的读取链路大致是:扫描拿到蓝牙地址,BluetoothLEDevice.FromBluetoothAddressAsync拿到设备对象,首次 GATT 访问触发底层连接,GetGattServicesForUuidAsync找到目标服务,GetCharacteristicsForUuidAsync找到目标特征,ReadValueAsync返回IBuffer,再用DataReader转成字节数组。写入则是把字节数组用DataWriter装进IBuffer,再走WriteValueWithResultAsync。通知更绕一步:除了给ValueChanged挂事件,还要往 CCCD 描述符写Notify或Indicate,设备端才会开始推送。UUID 有 16 位短 UUID 和 128 位长 UUID,工程里最好统一用Guid常量,不要一边写字符串一边写Guid,否则调试时很容易对不上。
还有一个容易忽略的点:GattDeviceService和GattCharacteristic不是纯数据对象,它们背后持有原生资源。临时变量里的服务对象被回收后,特征可能还能读一次,随后就报“元素未找到”或“不可达”。我一般会把服务对象和特征对象都放进长生命周期字段里,连接上下文不销毁,它们就不销毁。这个习惯比事后加重试更管用,血泪经验是:BLE 的很多“玄学断连”,其实是对象生命周期没管住。
2.2 WinRT 原生 API 与社区库的取舍
C# 接 BLE 常见两条路:一条是 Windows 平台自带的 WinRT 蓝牙 API,命名空间以Windows.Devices.Bluetooth开头;另一条是社区跨平台库,把扫描、连接、读写包装成更简单的接口。两条路都能跑通,但排错能力差别很大。WinRT 原生 API 的优点是 GATT 控制粒度细,能直接拿到GattSession、MaxPduSize、BluetoothCacheMode、GattCommunicationStatus,出问题时状态码和对象状态都看得见;缺点是 Windows 限定,目标框架要带平台标识,部分桌面运行方式还涉及应用能力和权限配置。社区库的优点是跨平台、接口短,适合业务层不想碰平台差异的项目;缺点是抽象层可能把缓存模式、配对状态、MTU 和 CCCD 细节藏起来,一旦设备固件有脾气,排查会变成黑匣子。
| 维度 | WinRT 原生 API | 社区跨平台库 |
|---|---|---|
| 平台范围 | 主要面向 Windows | 通常覆盖多平台 |
| GATT 控制粒度 | 细,可直接控制缓存、会话、PDU | 取决于库的封装程度 |
| 依赖与配置 | 需要平台目标框架和蓝牙访问能力 | 依赖库自身适配层 |
| 排错可见性 | 状态码、对象状态、事件较完整 | 抽象后日志可能不够细 |
| 适合场景 | 桌面设备工具、测试台、产线工装 | 业务需要跨平台复用 |
这张表不是让谁替代谁,而是决定“第一版用什么”。如果资源目标是先把 GATT 链路跑通,我建议先用原生 API 把扫描、连接、服务发现、读写、通知各写一遍,知道每个状态码长什么样,再考虑是否用社区库封装。反过来的顺序也能做,但一旦遇到“服务列表为空”或“通知不触发”,抽象层会消耗大量时间。工程里把平台相关代码收在BleGattClient一类里,业务层只依赖接口,后面换库时改动面会小很多。注意,换库不代表能绕过 GATT 协议本身,UUID、CCCD、MTU、配对这些概念仍然要懂。
2.3 工程骨架:把设备、会话、服务、特征分层
示例工程没有把扫描、连接、读写全塞进一个事件回调,而是先定义一个连接上下文,把BluetoothLEDevice、GattSession、GattDeviceService、GattCharacteristic收在一起。这样重连时释放旧上下文,订阅通知时保留特征引用,业务层拿到的始终是同一个入口。下面这段是骨架的核心部分,省略了日志和 UI 通知,保留资源释放和特征查找。
using System; using System.Threading.Tasks; using Windows.Devices.Bluetooth; using Windows.Devices.Bluetooth.GenericAttributeProfile; public sealed class GattLink : IAsyncDisposable { private BluetoothLEDevice? _device; private GattSession? _session; private GattDeviceService? _service; private GattCharacteristic? _characteristic; public GattCharacteristic Characteristic => _characteristic ?? throw new InvalidOperationException("特征未就绪"); public async Task ConnectAsync( ulong address, Guid serviceUuid, Guid characteristicUuid) { _device = await BluetoothLEDevice.FromBluetoothAddressAsync(address); if (_device is null) throw new InvalidOperationException("设备不可用"); _session = await GattSession.FromDeviceIdAsync(_device.BluetoothDeviceId); _session.MaintainConnection = true; var services = await _device.GetGattServicesForUuidAsync( serviceUuid, BluetoothCacheMode.Uncached); if (services.Status != GattCommunicationStatus.Success || services.Services.Count == 0) throw new InvalidOperationException($"服务发现失败: {services.Status}"); _service = services.Services[0]; var chars = await _service.GetCharacteristicsForUuidAsync( characteristicUuid, BluetoothCacheMode.Uncached); if (chars.Status != GattCommunicationStatus.Success || chars.Characteristics.Count == 0) throw new InvalidOperationException($"特征发现失败: {chars.Status}"); _characteristic = chars.Characteristics[0]; } public ValueTask DisposeAsync() { _characteristic = null; _service?.Dispose(); _session?.Dispose(); _device?.Dispose(); return ValueTask.CompletedTask; } }这段代码的逻辑是先拿设备对象,再建GattSession并设置MaintainConnection,然后按 UUID 直接找目标服务和特征,避免全量枚举带来的干扰。address是扫描事件里的 48 位蓝牙地址,类型是ulong;serviceUuid和characteristicUuid必须和固件文档一致,不能只凭名字猜。BluetoothCacheMode.Uncached用于首次发现,绕开系统缓存,代价是慢一点;连接稳定后可以改Cached提升速度。MaintainConnection = true让系统尽量保持连接,减少设备休眠导致的假断连,但它不是万能药,设备主动断开时仍要监听状态变化。DisposeAsync里先清特征引用,再释放服务、会话、设备,顺序反了容易出现释放后仍被回调访问的情况。这个骨架不负责重试,重试逻辑放在外层更合适,因为不同设备对重试间隔的容忍度不一样。
3. 扫描、连接与服务发现:从广播包到目标 Characteristic
扫描阶段最容易被低估。很多人以为Watcher一开就能看到设备,实际广播包分主广播和扫描响应,服务 UUID 可能只出现在扫描响应里;设备可能已经被另一台主机连着,广播里看得到但连不上;系统蓝牙开关、无线电状态、应用权限也会让扫描结果为空。第 3 章把扫描、连接、服务发现拆开写,是因为这三步的失败现象很像,但排查手段完全不同。先把广播包看清,再谈连接,最后才做服务发现,顺序不要反。
3.1 用 BluetoothLEAdvertisementWatcher 做可控扫描
扫描器不要一直开着。常见做法是进入配网或设备选择页时开启,拿到目标地址后立刻停止,连接成功后再按需短时扫描。BluetoothLEScanningMode.Active会请求扫描响应,能拿到更多广播数据,适合调试;Passive更省电,但信息少。过滤条件也不要一上来就卡死服务 UUID,因为有些设备的服务 UUID 只在扫描响应里,或者广播包被裁剪。工程里可以先按服务 UUID 过滤,同时保留一个“调试模式”打印全部字段,避免现场设备一换就抓瞎。
using System; using System.Collections.Generic; using Windows.Devices.Bluetooth.Advertisement; public sealed class BleScanner : IDisposable { private readonly BluetoothLEAdvertisementWatcher _watcher; private readonly Dictionary<ulong, DateTimeOffset> _seen = new(); private readonly Guid _targetService; public event Action<ulong, string, short>? DeviceFound; public BleScanner(Guid targetService) { _targetService = targetService; _watcher = new BluetoothLEAdvertisementWatcher { ScanningMode = BluetoothLEScanningMode.Active }; _watcher.Received += OnReceived; _watcher.Stopped += OnStopped; } public void Start() => _watcher.Start(); public void Stop() => _watcher.Stop(); private void OnReceived( BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs args) { if (!args.Advertisement.ServiceUuids.Contains(_targetService)) return; var now = DateTimeOffset.UtcNow; if (_seen.TryGetValue(args.BluetoothAddress, out var last) && (now - last).TotalSeconds < 3) return; _seen[args.BluetoothAddress] = now; DeviceFound?.Invoke( args.BluetoothAddress, args.Advertisement.LocalName ?? string.Empty, args.RawSignalStrengthInDBm); } private void OnStopped( BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementWatcherStoppedEventArgs args) { // 扫描被系统停止时记录原因,常见于无线电关闭或权限变化 } public void Dispose() { _watcher.Received -= OnReceived; _watcher.Stopped -= OnStopped; _watcher.Stop(); } }这段扫描器的关键参数有三个:ScanningMode决定是否请求扫描响应,调试阶段用Active更容易看到完整服务 UUID;_targetService是过滤条件,现场可以先传一个已知服务 UUID,再在调试模式里打印LocalName、ServiceUuids、ManufacturerData做比对;_seen做三秒去重,避免同一设备刷屏。args.RawSignalStrengthInDBm是信号强度,只能做粗筛,不能拿来判断设备是否可连接。DeviceFound回调运行在后台线程,如果直接更新界面控件,需要切回 UI 线程。Stopped事件要记录原因,扫描被系统停掉时,先看系统蓝牙开关和无线电状态,不要一味重试代码。
3.2 连接设备与保持 GattSession
BluetoothLEDevice.FromBluetoothAddressAsync返回对象,不等于底层链路已经连好。首次 GATT 访问才会真正触发连接,所以有些代码刚拿到设备就立刻读特征,会收到“不可达”或“未找到”。工程里可以在拿到设备后监听ConnectionStatusChanged,给一个短超时,再继续服务发现。超时不是协议规定值,是工程经验值,桌面设备工具里五秒左右比较常见,现场环境差可以适当放宽,但不要无限等。
public async Task<BluetoothLEDevice> ConnectAsync( ulong address, CancellationToken ct) { var device = await BluetoothLEDevice.FromBluetoothAddressAsync(address); if (device is null) throw new InvalidOperationException("设备不存在或不可达"); if (device.ConnectionStatus != BluetoothConnectionStatus.Connected) { var tcs = new TaskCompletionSource<bool>( TaskCreationOptions.RunContinuationsAsynchronously); void OnStatus(BluetoothLEDevice sender, object args) { if (sender.ConnectionStatus == BluetoothConnectionStatus.Connected) tcs.TrySetResult(true); } device.ConnectionStatusChanged += OnStatus; using var reg = ct.Register(() => tcs.TrySetCanceled(ct)); var completed = await Task.WhenAny(tcs.Task, Task.Delay(5000, ct)); device.ConnectionStatusChanged -= OnStatus; if (completed != tcs.Task || !tcs.Task.IsCompletedSuccessfully) throw new TimeoutException("连接超时"); } return device; }这段代码里,address来自扫描事件,ct用来在用户取消时及时退出。Task.Delay(5000, ct)是等待上限,不是连接参数;真正连接由 GATT 栈触发,代码只是等状态变化。ConnectionStatusChanged在后台线程触发,TaskCompletionSource用RunContinuationsAsynchronously避免续体阻塞事件线程。连接成功后要把device存进上下文,不要用局部变量持有,否则后续服务发现可能拿到已释放对象。如果设备已被其他主机连接,FromBluetoothAddressAsync可能仍返回对象,但 GATT 操作会失败,这时提示用户断开其他主机,比反复重连有效。
3.3 服务发现:缓存模式、重试与特征筛选
服务发现失败是高频问题。系统会缓存 GATT 表,固件升级后 UUID 变了,缓存里还是旧表;设备刚上电,服务还没准备好;配对状态不对,访问受保护特征被拒绝。工程里的做法是首次用Uncached,失败后做有限重试,重试间隔不要固定为零。重试三次、间隔三百毫秒是一个保守起点,现场设备启动慢可以加长,但不要把重试写成死循环。服务发现成功后,服务对象要保留,不能只留特征对象。
public async Task<GattCharacteristic> FindCharacteristicAsync( BluetoothLEDevice device, Guid serviceUuid, Guid characteristicUuid) { GattDeviceServicesResult? services = null; for (var i = 0; i < 3; i++) { services = await device.GetGattServicesForUuidAsync( serviceUuid, BluetoothCacheMode.Uncached); if (services.Status == GattCommunicationStatus.Success) break; await Task.Delay(300); } if (services is null || services.Status != GattCommunicationStatus.Success || services.Services.Count == 0) throw new InvalidOperationException( $"服务不可用: {services?.Status}"); var service = services.Services[0]; var chars = await service.GetCharacteristicsForUuidAsync( characteristicUuid, BluetoothCacheMode.Uncached); if (chars.Status != GattCommunicationStatus.Success || chars.Characteristics.Count == 0) throw new InvalidOperationException($"特征不可用: {chars.Status}"); return chars.Characteristics[0]; }这里serviceUuid和characteristicUuid是目标设备的固定标识,调试时可以先全量枚举服务和特征,把 UUID 打印出来再回填。BluetoothCacheMode.Uncached用于绕过系统缓存,连续失败时不要只加Cached碰运气,先确认设备是否已连接、是否已配对、是否被其他主机占用。services.Status不是Success时,错误可能来自连接断开、协议错误或访问被拒,分别对应不同的处理:断连要重连,协议错误要看固件,访问被拒要检查配对和权限。service对象必须保存在上下文字段里,代码里如果只把chars.Characteristics[0]返回出去,服务对象被回收后特征可能失效,这是很多“读一次就坏”的根因。
4. 读写、通知与 MTU:把数据通道跑通
特征读写看起来只是两个方法,实际要处理四件事:特征属性是否支持、缓存模式是否合适、写模式是否有响应、数据长度是否超过当前 PDU。通知还多一层 CCC
本文还有配套的精品资源,点击获取