☰
Windows BLE 开发实战:基于 Windows.Devices.Bluetooth 的 C# 上位机连接与 GATT 通信指南
2026/10/7 13:53:25 网站建设 项目流程

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 元数据。解决办法:

  1. 在项目里添加引用 → 浏览 → 找到C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.19041.0\Windows.winmd
  2. 同时在 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 调试有时候会很磨人,一个连接问题可能查一整天。但一旦你把链路跑通,后面就是重复劳动了。耐心一点,把每一步都验证清楚,比急着写业务代码要划算得多。

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

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

立即咨询