☰
C# BLE GATT通信实战:扫描连接、Notify解析与断线重连
2026/10/26 20:54:47 网站建设 项目流程

简介:面向C#/.NET与物联网BLE开发者的蓝牙GATT通信示例包,针对Windows平台低功耗蓝牙设备扫描、连接、服务与特性查询、读写及通知等开发问题。资源共145个文件,以32个cs源码、56个baml界面资源及xaml、json配置、编译输出为主,约510KB,便于对照工程组织理解Windows.Devices.Bluetooth.GenericAttributeProfile调用。内容涵盖BluetoothLEDevice连接、GattServices遍历、Characteristic读写、DataWriter写入和ValueChanged订阅,并提示跨平台兼容与错误处理,适合搭建BLE原型或排查GATT交互细节时参考。已有978人学习,可作为C#蓝牙低功耗通信的学习与实现样例。

1. 从一台体脂秤说起:C# 里把 BLE GATT 通信跑通的完整链路

上个月帮朋友看一个健康管理上位机,需求一句话就能说完:人站上体脂秤,桌面端自动读数、落库、画曲线。他原本估了半天工作量,结果卡了三天。卡点不是“连不上”,恰恰相反,第一次连几乎总能成功,真正恶心的是第二次——设备休眠、地址轮换、系统缓存里那条陈旧记录,随便撞上一个都能让连接卡住十几秒然后返回空。这也是我这些年做 C# 蓝牙 GATT 通信最深的体会:难点从来不在握手,而在“稳定地握手第二次”。

这份 C# 蓝牙 GATT 通信项目就是围着这条链路写的:广播扫描、建立连接、服务与特征发现、Notify 订阅、特征读写、断线重连,外加一层把字节流拼成业务帧的解析器。它不依赖额外运行时,用的是 Windows 自带的 WinRT 蓝牙 API,适合做桌面上位机、设备配套工具、产线采集程序的开发者,也适合刚接手一个“别人写了一半、只能连一次”的蓝牙模块要救火的人。

2. 技术选型与工程骨架:WinRT API 边界、目标框架与抽象层设计

2.1 三套 API 的边界:WinRT、32feet 与 Linux 下的 BlueZ

在 C# 里做 BLE,绕不开的就那么几条路,选错了后面全是补丁。

Windows 桌面端最省事的是系统自带的Windows.Devices.Bluetooth系列(WinRT 投影,从 Win10 1803 之后 GATT 客户端这块基本稳定了)。它的好处是不用带任何第三方运行时,部署时少一个 DLL 就少一类“在我机器上好好的”事故;代价是只能跑 Windows,而且必须把目标框架指到带 Windows SDK 的 TFM 上,否则BluetoothLEDevice这个类型你根本引用不到。

老牌的 32feet 库历史更长,经典蓝牙 SPP 那套玩得很熟,如果项目里同时要连串口透传的老设备和 BLE 新设备,它是省事的;但它的 BLE 部分在 Win10 上也是转调系统栈,多一层封装多一层排查成本。

Linux 那边(工控机、边缘盒子)走的是 BlueZ 的 D-Bus 接口,用Tmds.DBus之类的绑定去调org.bluez,API 形状和 WinRT 完全不一样,代码没法直接复用。

方案依赖平台适用场景
WinRTWindows.Devices.Bluetooth系统自带,无 NuGetWindows 10 1809+桌面上位机、设备配套工具
32feet 一类封装库NuGet 包Windows 为主经典蓝牙 + BLE 混合的老项目
BlueZ D-Bus 绑定系统 bluez 服务Linux工控机、边缘采集盒

这份项目选的是第一条。理由很实际:读者拿到代码最想干的事是“双击跑起来看效果”,而不是先配一堆环境。后面所有代码也都基于 WinRT 这套 API 展开,Linux 端的思路可以照着搬,但接口名对不上。

2.2 工程骨架:目标框架、目录结构与运行前提

很多人第一次建项目就翻车,using Windows.Devices.Bluetooth;报“找不到类型”。不是代码问题,是 csproj 没配对。

<!-- BleGattDemo.csproj:关键是 TFM 必须带 Windows SDK 版本号 --> <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <!-- 只写 net8.0 是不够的,必须落到 net8.0-windows10.0.19041.0 才能拿到 WinRT 投影 --> <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework> <!-- 运行下限可以比编译版本低,但要 >= 10.0.17134.0,再低没有 GattSession --> <TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion> <UseWindowsForms>true</UseWindowsForms> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> </Project>

TargetFramework里的10.0.19041.0是编译时用的 SDK 版本,TargetPlatformMinVersion才是你允许用户跑的最低系统版本。这两个值配错了,表现是编译期没报错、运行期TypeLoadException,属于最难查的那类。

目录我一般拆成四层,避免所有逻辑堆进 Form 里:

BleGattDemo/ ├── Transport/ # 与协议无关的 BLE 通道 │ ├── BleScanner.cs # 广播扫描 │ ├── BleSession.cs # 连接、保活、重连 │ └── IBluetoothTransport.cs ├── Protocol/ # 与你家设备强相关的部分 │ ├── FrameParser.cs # 粘包/校验/状态机 │ ├── Crc16.cs │ └── DeviceCommands.cs # 使能、校准、读版本等命令帧 ├── Services/ │ └── SampleCollector.cs # 业务侧:收数、落库、去重 └── UI/ └── MainForm.cs # 只负责展示,不碰 GATT

Transport这一层的作用是:换一个设备厂商,只改Protocol和几个 UUID 常量,扫描和连接逻辑一行不动。这一点在做第二个蓝牙产品时价值巨大,血泪经验。

运行前提只有两条:机器有蓝牙适配器(BluetoothAdapter.GetDefaultAsync()返回非 null),以及设备没被别的程序独占——系统设置里已经配对过的设备,被厂商自家 App 连着的话,你的GattSession可能拿得到但读不到数据。

提示:打包成 MSIX 发布的 WinUI/WPF 应用,要在Package.appxmanifest里声明<DeviceCapability Name="bluetooth" />,否则扫描返回空列表,而且不报错。非打包的普通 exe 没这个约束。

2.3 抽象层:把 GATT 操作收进一个接口

UI 直接调GattCharacteristic是灾难的开始——回调线程、生命周期、重连状态全散在界面代码里。我习惯先定一个窄接口,把这堆东西收口。

// IBluetoothTransport.cs:只暴露业务关心的四个动作和两个事件 public interface IBluetoothTransport : IAsyncDisposable { event EventHandler<byte[]>? FrameReceived; // 已解析好的业务帧 event EventHandler<bool>? ConnectionChanged; // true=已连上 Task<bool> StartScanAsync(ushort companyId, TimeSpan timeout, CancellationToken ct); Task<bool> ConnectAsync(ulong address, CancellationToken ct); Task WriteAsync(byte[] frame, CancellationToken ct); // 整帧,内部分片 }

FrameReceived给的是byte[]业务帧而不是原始IBuffer,这样 UI 层完全不知道 GATT 的存在。WriteAsync收整帧、内部按 MTU 切片,调用方不用关心MaxPduSize。ConnectionChanged是给界面做状态灯用的,也是重连逻辑的触发器——断开事件里启动指数退避重连,第一次 500ms,之后翻倍,封顶 10s,别用固定间隔猛刷,那会把适配器刷死。

接口只有四个方法,实现类里再复杂也关得住。后面章节所有代码都是这个接口的具体实现。

3. 扫描与连接:从广播包到一条可用 GATT 会话

3.1 广播扫描:Watcher 的过滤参数与信号强度

扫描不是new Watcher().Start()就完事。默认配置在办公室环境里会把你淹没——几十台设备每秒广播好几次,回调像机关枪,UI 直接卡死。

// BleScanner.cs —— 广播扫描:主动扫描 + 厂商数据过滤 + 去重 public sealed class BleScanner : IDisposable { private readonly BluetoothLEAdvertisementWatcher _watcher = new(); private readonly HashSet<ulong> _seen = new(); public event EventHandler<BluetoothLEAdvertisementReceivedEventArgs>? DeviceFound; public BleScanner(ushort companyId) { // Active:会额外发 SCAN_REQ 收回 ScanResponse,很多设备把完整名字放在这里 _watcher.ScanningMode = BluetoothLEScanningMode.Active; // 信号弱于 -85dBm 的直接在系统层丢掉,减少托管层回调次数 _watcher.SignalStrengthFilter.SignalStrength = -85; _watcher.SignalStrengthFilter.OutOfRangeTimeout = TimeSpan.FromSeconds(5); // 只放行指定厂商的广播,CompanyId 从设备协议文档里查 var filter = new BluetoothLEAdvertisementFilter(); filter.Advertisement.ManufacturerData.Add( new BluetoothLEManufacturerData { CompanyId = companyId }); _watcher.AdvertisementFilter = filter; _watcher.Received += OnReceived; } private void OnReceived(BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs args) { // 同一设备每秒广播多次,业务侧只需要第一次 if (!_seen.Add(args.BluetoothAddress)) return; DeviceFound?.Invoke(this, args); } public void Start() { _seen.Clear(); _watcher.Start(); } public void Stop() => _watcher.Stop(); public void Dispose() { _watcher.Received -= OnReceived; _watcher.Stop(); } }

ScanningMode选Active是常见做法:缺省被动扫描只能收到 ADV_IND 包,而不少设备把厂商自定义数据放在 ScanResponse 里,被动模式下永远拿不到。代价是多一轮请求响应,扫描延迟略高,但值得。

SignalStrengthFilter.SignalStrength是 dBm 阈值,-85大约对应隔一堵墙的距离。这个值调得太高(比如-60)会出现“设备就在旁边却扫不到”的玄学现象,因为广播功率本来就不高,衰减一点就掉出阈值了。

AdvertisementFilter里的CompanyId是蓝牙联盟分配的 16 位厂商 ID,硬件同事给你的协议文档里一定有。加这个过滤器的收益是把无关设备挡在托管层之外,扫描时的 CPU 占用能降一个数量级。

3.2 连接与保活:BluetoothLEDevice、GattSession 与超时

连接这段是坑最密集的地方。核心就三件事:拿设备对象、维持会话、用一次真实读操作验证链路。

// BleSession.cs —— 建立连接并保活 public async Task<bool> ConnectAsync(ulong address, CancellationToken ct) { // FromBluetoothAddressAsync 内部会查系统设备缓存, // 设备刚断电时它可能卡十几秒再返回一个"看起来成功"的对象 using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(8)); var device = await BluetoothLEDevice .FromBluetoothAddressAsync(address) .AsTask(timeout.Token); if (device is null) return false; device.ConnectionStatusChanged += OnConnectionStatusChanged; // MaintainConnection = true 才能留住链路, // 否则系统在空闲若干秒后会自己回收 _session = await GattSession.FromDeviceIdAsync(device.BluetoothDeviceId); _session.MaintainConnection = true; // 关键:设备对象拿到了不代表链路通, // 必须做一次真实的 GATT 交互来确认 var result = await device.GetGattServicesAsync(BluetoothCacheMode.Uncached); if (result.Status != GattCommunicationStatus.Success) { device.ConnectionStatusChanged -= OnConnectionStatusChanged; device.Dispose(); return false; } _device = device; _services = result.Services; ConnectionChanged?.Invoke(this, true); return true; }

三个参数值得单独说。

CancelAfter(8s)这个超时必须有。FromBluetoothAddressAsync在某些情况下会一直挂着不返回,尤其是地址已经失效的时候。加超时至少能让你知道“这次失败了”,而不是界面永远转圈。

MaintainConnection是保活开关。设为true后系统会持续维持链路,功耗略高,但省掉了“空闲 30 秒后第一次写入必失败”的问题。对于要持续收数据的采集场景,这个开关基本是必开的。

BluetoothCacheMode.Uncached是这次调用的灵魂。带缓存的枚举会直接返回上次的 GATT 表,设备固件升级过、特征变了,你拿到的还是旧表,然后一直读到Unreachable。用Uncached强制走一次真实交互,顺便就验证了链路。

3.3 服务与特征发现:UUID 匹配、缓存模式与属性判断

拿到_services之后,按 UUID 找服务和特征。这块本身不难,难在属性判断——同一个 UUID 在不同固件版本上可能只支持 Write 不支持 WriteWithoutResponse,你按想当然的方式写就会收到GattCommunicationStatus.ProtocolError。

// 按 UUID 定位服务与特征,并记录其真实属性 private GattCharacteristic? FindCharacteristic( IReadOnlyList<GattDeviceService> services, Guid serviceUuid, Guid charUuid) { var service = services.FirstOrDefault(s => s.Uuid == serviceUuid); if (service is null) return null; // 这里同样用 Uncached,否则拿到的是缓存里的特征表 var chars = service.GetCharacteristicsAsync(BluetoothCacheMode.Uncached) .AsTask().GetAwaiter().GetResult(); if (chars.Status != GattCommunicationStatus.Success) return null; var ch = chars.Characteristics.FirstOrDefault(c => c.Uuid == charUuid); if (ch is null) return null; // 把属性打出来,后面分支全靠它 Debug.WriteLine($"[{charUuid}] props = {ch.CharacteristicProperties}"); return ch; }

生产代码里我不会用.GetAwaiter().GetResult(),这里是为了让片段能独立跑起来。实际实现里这一段是async整条链传下去的。

CharacteristicProperties是个[Flags]枚举,值决定后面所有行为:有Notify才能订阅,有Indicate只能按确认模式订阅(吞吐低但可靠),有Write才能用带响应写,有WriteWithoutResponse才有资格走高速通道。我一般会把这三类特征分开存:控制点(写)、数据点(Notify)、状态点(读)。分清楚之后,业务层的代码就变成“发一条命令、等一帧回复”的简单模型。

4. 数据通道拆解:Notify 订阅、分片写入与帧解析状态机

4.1 订阅通知:CCCD 写入与回调线程模型

订阅这段是新手翻车率最高的地方:代码没报错,ValueChanged就是不触发。

// 订阅数据通知:先判断属性,再写 CCCD,最后挂事件 public async Task<bool> SubscribeAsync(GattCharacteristic target) { var props = target.CharacteristicProperties; // Notify 和 Indicate 是两种模式,选设备实际支持的 GattClientCharacteristicConfigurationDescriptorValue mode; if (props.HasFlag(GattCharacteristicProperties.Notify)) mode = GattClientCharacteristicConfigurationDescriptorValue.Notify; else if (props.HasFlag(GattCharacteristicProperties.Indicate)) mode = GattClientCharacteristicConfigurationDescriptorValue.Indicate; else return false; // 两个都没有,说明这个特征压根不推数据 // 这一步就是写 CCCD(0x2902 描述符),不做它设备不会主动发 var status = await target.WriteClientCharacteristicConfigurationDescriptorAsync(mode); if (status != GattCommunicationStatus.Success) return false; target.ValueChanged += OnValueChanged; _notifyChar = target; return true; } private void OnValueChanged(GattCharacteristic sender, GattValueChangedEventArgs args) { var buffer = args.CharacteristicValue; var bytes = new byte[buffer.Length]; using (var reader = DataReader.FromBuffer(buffer)) reader.ReadBytes(bytes); // 回调跑在线程池线程,这里只做入队,绝不碰 UI _parser.Write(bytes); }

WriteClientCharacteristicConfigurationDescriptorAsync说白了就是往0x2902这个描述符里写一个值:Notify写0x0001,Indicate写0x0002。不写它,设备的 GATT 服务器根本不知道有人在监听,自然不会推。

mode的选择不能硬编码。有些电流检测模块的数据特征只支持Indicate,你按Notify写,部分协议栈会返回Success但一条数据都不发——这也是典型的玄学现场。

OnValueChanged的执行线程是线程池,不是 UI 线程。WinForms 的Control.Invoke、WPF 的Dispatcher.Invoke必须记住,不然就是随机崩溃。我一般在这里只往一个Channel<byte[]>或者解析器里塞数据,真正的处理放到消费端循环里做。

注意:ValueChanged挂事件和写 CCCD 的顺序,理论上先写 CCCD 再挂事件会漏掉最早几帧。稳妥做法是先挂事件、再写 CCCD,最后按需清一次缓冲。

4.2 写入特征值:WriteOption、MTU 与分片

写数据分两种:带响应(Write)和不带响应(WriteWithoutResponse)。前者每包都要等设备 ACK,慢但稳;后者发完就走,快但丢了不知道。

// 按 MTU 分片写入,片间留间隔给设备处理 public async Task WriteAsync(byte[] frame, CancellationToken ct) { var ch = _writeChar ?? throw new InvalidOperationException("未找到可写特征"); // 设备支持无响应写就用它,吞吐能高一个数量级 var option = ch.CharacteristicProperties.HasFlag( GattCharacteristicProperties.WriteWithoutResponse) ? GattWriteOption.WriteWithoutResponse : GattWriteOption.WriteWithResponse; // ATT MTU 默认 23,减去 3 字节 ATT 头,业务负载只剩 20 int mtu = _session?.MaxPduSize ?? 23; int chunkSize = Math.Max(20, mtu - 3); for (int offset = 0; offset < frame.Length; offset += chunkSize) { ct.ThrowIfCancellationRequested(); int len = Math.Min(chunkSize, frame.Length - offset); var slice = frame.AsSpan(offset, len).ToArray(); var result = await ch.WriteValueWithResultAsync( CryptographicBuffer.CreateFromByteArray(slice), option); if (result.Status != GattCommunicationStatus.Success) throw new IOException($"写入失败:{result.Status}(offset={offset})"); // 无响应写之间必须有间隔,否则设备侧固件来不及处理会丢包 if (option == GattWriteOption.WriteWithoutResponse && offset + len < frame.Length) await Task.Delay(15, ct); } }

MaxPduSize是当前协商出来的 ATT MTU,-3是 ATT 头的开销(opcode 1 字节 + handle 2 字节)。默认 23 的情况下,单包只能塞 20 字节数据——这就是为什么很多人写 40 字节配置帧,设备只认前 20 字节。

Task.Delay(15)这个间隔不是拍脑袋。无响应写没有流控,连发十几包,固件里的接收缓冲一旦溢出就静默丢包,而且丢的是中间某几包,排查起来极其痛苦。15ms 对大多数设备是安全的,如果你的设备固件写得糙,可能要拉到 30ms。

4.3 帧解析:粘包、校验与状态机落地

BLE 的 Notify 是流式的:一次回调可能给你半帧,也可能给你两帧半。解析器必须能处理任意切分。

// FrameParser.cs —— 帧格式:A5 5A | len(2, 大端) | cmd(1) | payload(len) | crc16(2) public sealed class FrameParser { private readonly List<byte> _buf = new(); private const byte Head0 = 0xA5, Head1 = 0x5A; public event Action<byte, byte[]>? FrameReady; // (cmd, payload) public void Write(ReadOnlySpan<byte> data) { foreach (var b in data) _buf.Add(b); while (true) { // 1) 找帧头,找不到就整段丢弃,防止脏数据累积 int start = -1; for (int i = 0; i + 1 < _buf.Count; i++) { if (_buf[i] == Head0 && _buf[i + 1] == Head1) { start = i; break; } } if (start < 0) { _buf.Clear(); return; } if (start > 0) _buf.RemoveRange(0, start); // 2) 长度字段还没到齐 if (_buf.Count < 5) return; int len = (_buf[2] << 8) | _buf[3]; if (len > 512) { _buf.RemoveRange(0, 2); continue; } // 长度离谱,跳过这个假头 int total = 2 + 2 + 1 + len + 2; if (_buf.Count < total) return; // 半包,等下一次回调 var frame = _buf.GetRange(0, total).ToArray(); _buf.RemoveRange(0, total); // 3) 校验不过就丢掉,别抛异常把整条链路打断 if (!Crc16.Check(frame, total - 2)) continue; FrameReady?.Invoke(frame[4], frame[5..(5 + len)]); } } }

这里三个设计点值得说。

第一,找帧头失败时直接_buf.Clear()。有人喜欢只丢一个字节然后继续找,理论上更稳,实际上遇到持续的噪声数据会陷入 O(n²) 的循环,一两秒就把 CPU 打满。整段丢弃是最省事的做法。

第二,长度字段要做上界检查。len > 512直接判定为假帧头,RemoveRange(0, 2)跳过它继续找下一个。不加这个限制,一帧脏数据能让你等一个永远凑不齐的“巨帧”,缓冲区持续膨胀。

第三,校验失败不抛异常。BLE 链路上偶发一两个错包是正常的,抛异常会让调用栈层层展开,重连逻辑可能被误触发。丢掉、继续等下一帧,这是更符合现场的做法。

5. 避坑与排查:五个只在真机上才出现的问题

模拟器和实验室环境永远正常,一到客户现场各种毛病。下面这五个是我被折腾次数最多的。

5.1 现象:第一次连得上,设备断电重连必失败

现象:程序启动后连接正常,数据也能收。设备断电再上电,代码调用FromBluetoothAddressAsync卡十几秒,然后返回null,或者返回一个对象但后续GetGattServicesAsync一直Unreachable。

原因:Windows 的 BLE 设备对象带系统级缓存,靠蓝牙地址取设备时会先查系统设备表。如果设备用的是可解析私有地址(RPA,现在大部分低功耗设备都默认开),每次重连地址都会变,你手里那个旧地址在系统表里已经失效,但缓存记录还在,于是内部等待超时。另外,旧的BluetoothLEDevice对象没Dispose的话,系统里会残留一条失效引用,同样会拖慢新连接。

解决:三件事一起做。一是别长期缓存地址,每次重连都重新扫描拿最新的BluetoothAddress;二是连接失败时务必device.Dispose(),包括ConnectionStatusChanged的事件解绑;三是给连接整条链路加超时(我一般 8 秒),超时后强制走重扫流程,而不是原地重试。

5.2 现象:CCCD 写成功返回 Success,但一条数据都不推

现象:日志里WriteClientCharacteristicConfigurationDescriptorAsync返回Success,ValueChanged事件也挂上了,就是收不到任何回调。换成厂商自家 App 一切正常。

原因:这大概率是两件事。一是这个特征实际只支持Indicate,你按Notify写,部分协议栈会“接受但忽略”;二是设备的 Notify 有前置条件——需要先往控制点特征写一条使能命令(比如0x01),设备才开始采样并推送,光订阅 CCCD 不够。

解决:先打日志把CharacteristicProperties完整输出,按实际支持的模式选Notify还是Indicate。订阅完成后立刻发一条使能帧,然后等 200ms 再判断有没有数据。排查阶段用通用调试工具连一次做对照,如果那边也收不到,问题在设备侧而不是代码侧,别在代码里瞎改。

5.3 现象:写入 40 字节的配置帧,设备只认前 20 字节

现象:上位机下发一条四十多字节的参数配置,设备回的是参数错误,或者干脆没反应。抓日志看写入返回的都是Success。

原因:默认 ATT MTU 是 23,减去 3 字节 ATT 头,单包有效负载只有 20 字节。用WriteWithoutResponse写超长数据时,超出部分在链路层就被截断,API 层面依然返回成功——这就是最坑的地方,成功不等于送达。

解决:按GattSession.MaxPduSize - 3切片,片间加 15~30ms 间隔。如果设备固件支持 MTU 协商,连接后主动请求更大的 MTU,能把分片数降下来。判断是否真的送达,别信 API 返回值,要看设备的应答帧。

5.4 现象:ValueChanged里更新界面,程序偶发崩溃或卡死

现象:界面跑着跑着抛InvalidOperationException(跨线程操作控件),或者设备推数据频率一高,整个窗口就卡住不动。

原因:ValueChanged回调在线程池线程上执行,WinForms 和 WPF 都要求控件只能在 UI 线程访问。如果在回调里直接textBox.Text = ...,轻则异常重则死锁。另外回调里做耗时操作(写文件、算 CRC、更新图表),会阻塞后续通知的投递。

解决:回调里只做入队。用Channel<byte[]>或者ConcurrentQueue把数据攒起来,UI 侧用定时器(100ms 一次)批量取出来渲染。这样既解决了跨线程问题,也顺带解决了高频刷新导致的界面卡顿。

5.5 现象:设备休眠后连接状态还是 Connected,写数据全部超时

现象:BluetoothLEDevice.ConnectionStatus一直显示Connected,但写特征值返回Unreachable,或者写成功但设备毫无反应。

原因:BLE 的“连接”是链路层概念,和“对端应用还活着”完全是两码事。设备进入深度休眠后,链路层可能还维持着连接句柄,但 GATT 服务已经不再响应。系统层面的状态更新有延迟,靠ConnectionStatusChanged判断存活并不可靠。

解决:加应用层心跳。每 5~10 秒读一次状态特征(只读操作成本很低),连续三次失败就判定为假连接,主动Dispose设备对象并走重连流程。别指望系统帮你判断,这块只能自己兜。

6. 二次开发:把通用 Demo 改成你自家设备的协议适配层

拿到这份代码之后,绝大多数人要做的第一件事就是换设备。改造点其实很集中,我列一下换一个全新设备要动的地方:

改造点位置说明
厂商 IDBleScanner构造参数从蓝牙联盟分配的 16 位 CompanyId
三个 UUIDProtocol/DeviceProfile.cs服务 UUID、数据点、控制点,协议文档里能查到
帧格式FrameParser帧头、长度字段宽度、校验算法,各家都不一样
命令表DeviceCommands使能、校准、读版本,每条命令一组字节
心跳特征BleSession挑一个只读、开销小的特征

最关键的是FrameParser,因为每家的帧格式都不一样。我一般把它抽成一个可替换的接口,让Protocol层可以整体换掉:

// 协议适配层:换设备只换实现,Transport 一行不动 public interface IFrameCodec { // 喂原始字节,吐出完整业务帧 void Feed(ReadOnlySpan<byte> raw); event Action<byte, byte[]>? FrameReady; // 把命令 + 参数打包成一帧 byte[] Encode(byte cmd, byte[] payload); } // 以 A5 5A 帧头、大端长度、CRC16 校验的协议为例 public sealed class A5FrameCodec : IFrameCodec { public event Action<byte, byte[]>? FrameReady; public void Feed(ReadOnlySpan<byte> raw) { /* 第 4.3 节的解析逻辑 */ } public byte[] Encode(byte cmd, byte[] payload) { var frame = new byte[2 + 2 + 1 + payload.Length + 2]; frame[0] = 0xA5; frame[1] = 0x5A; frame[2] = (byte)(payload.Length >> 8); frame[3] = (byte)(payload.Length & 0xFF); frame[4] = cmd; payload.CopyTo(frame, 5); var crc = Crc16.Compute(frame, frame.Length - 2); frame[^2] = (byte)(crc >> 8); frame[^1] = (byte)(crc & 0xFF); return frame; } }

改造顺序我建议这样走:先把DeviceProfile里的 UUID 全换掉,用扫描 + 枚举把设备的服务和特征列出来打日志,确认三个关键特征都找得到;再替换FrameCodec,用一条最简单的读版本命令验证提包和解析能闭环;最后才接业务逻辑。反过来做,一上来就写业务,出问题时根本分不清是协议错了还是业务错了。

验证方法别只靠自己的程序。设备侧的问题,用通用 BLE 调试工具连一次做对照,那边能收到数据说明链路和订阅都没问题,问题在解析;那边也收不到,就往设备固件和使能命令上查。这个二分法能省掉大量猜测。

最后说个教训。以前我做第一个蓝牙项目,图快,把扫描、连接、解析全写在一个 Form 里,跑通了就交付。三个月后换了个型号的设备,我花了整整两天才把那坨代码理清楚哪行是扫描哪行是订阅。从那以后我每次开蓝牙相关的工程,都强制先写IBluetoothTransport和IFrameCodec这两个接口,哪怕当前只有一个设备型号——这层抽象看着是多写的代码,实际上是给未来的自己留后悔药。希望帮到你。

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

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

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

立即咨询