1. 为什么在 Windows 上做 BLE 开发,我最终选了 Windows.Devices.Bluetooth
如果你之前用 C# 做过串口通信或者 TCP 通信,第一次接触 BLE 的时候大概率会有点懵。串口就是打开端口、读写字节,TCP 就是连上 IP 和端口、收发数据流,逻辑非常线性。但 BLE 完全不是这个套路——它有一套自己的"层级模型",你得先理解这套模型,才能把代码写对。
我在实际项目里做过不少无线温度监测、传感器数据采集这类需求,BLE 是绕不开的一环。早期也试过一些第三方的蓝牙库,有的封装太厚、出了问题根本不知道底层发生了什么,有的又太薄、连基本的连接管理都要自己写。后来我把目光收回到 Windows 自带的Windows.Devices.Bluetooth这一套 API 上,用下来发现它其实是 Windows 平台上做 BLE 最"正统"也最稳的方案。
这套 API 属于 WinRT(Windows 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。
要开启通知,你得先写 CCCD(UUID 是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这样的 TFM(Target Framework Moniker),可以直接用 WinRT API。 - .NET Framework 4.8:老项目常用,也能用,但需要引用
Windows.winmd,配置麻烦一些。 - UWP:原生支持,但如果你做的是上位机,一般不会选 UWP。
我实测下来,.NET 6 + WinForm/WPF是最舒服的组合。csproj 里这样写:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net6.0-windows10.0.19041.0</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <Nullable>enable</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 Include="Windows" />
但在 .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 Dictionary<ulong, 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 | 检查 CharacteristicProperties |
| ValueChanged 不触发 | 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 Action<byte[]>? DataReceived; public async Task ScanAndConnectAsync(Guid serviceUuid, Guid notifyUuid) { _watcher = new BluetoothLEAdvertisementWatcher { ScanningMode = BluetoothLEScanningMode.Active }; _watcher.AdvertisementFilter.Advertisement.ServiceUuids.Add(serviceUuid); var tcs = new TaskCompletionSource<ulong>(); _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 调试有时候会很磨人,一个连接问题可能查一整天。但一旦你把链路跑通,后面就是重复劳动了。耐心一点,把每一步都验证清楚,比急着写业务代码要划算得多。