做嵌入式这行,十次有八次调试BLE设备,流程都是固定的:手机装个nRF Connect,或者电脑上挂LightBlue、Wireshark抓包,配好适配器、翻GATT、读写特征值,一套下来半小时起步。工具确实专业,但很多时候我只是想确认“这个设备广播了没有”“电池服务能不能读”“某个特征值写进去设备有没有反应”——为这点事专门去装一套几百兆的工具链,真有种杀鸡用牛刀的感觉。
后来我偶然在Windows的PowerShell里调通了WinRT蓝牙API,才发现Windows自带蓝牙栈完全能解决大部分BLE设备调试需求:枚举设备、建立连接、读GATT服务、读写特征值、订阅通知、扫描周边广播包,全都能干。重点是全程不用装任何第三方软件,只要一台Windows 10或11的电脑就行。这篇文章就是我自己的完整实操路径,按“环境准备→原理拆解→脚本实操→实战案例→踩坑记录”的顺序往下写,适合嵌入式工程师、硬件测试、物联网开发,也适合刚接触BLE、手头只有一台Windows电脑的同学。
1. 动手之前:先确认你的Windows蓝牙能吃这碗饭
1.1 需要哪些基础条件
Windows自带的BLE调试,不是一个图形化的“蓝牙调试助手”,它倚仗的是系统内置的蓝牙协议栈加一套Windows.Devices.Bluetooth命名空间下的WinRT API。Windows 10 1809之后的版本,以及Windows 11,都把这套API老老实实放在了系统里,PowerShell可以直接调用,不需要装SDK,也不需要额外装蓝牙驱动。
硬件上,笔记本自带的蓝牙模块基本都能用,前提是蓝牙4.0以上,现在的机器基本都是5.0、5.2了。台式机如果主板没有无线网卡,插一个USB蓝牙适配器也行,只要系统能正常识别。我常年用的一台老笔记本,蓝牙芯片只有4.2,跑这套流程也完全没有压力。
这里要先划个重点:这篇文章针对的是BLE(低功耗蓝牙),也就是蓝牙4.0之后那套体系。像HC-05这种经典蓝牙模块走的是SPP串口协议,虽然也能在Windows下配对,但那是当串口设备用的,和今天聊的GATT读写不是一回事。如果你手里是HC-05连不上电脑,先确认你搞的是不是BLE,两边别混了。
1.2 打开蓝牙并完成设备初步配对
PowerShell不是万能的,它的前提是设备已经在系统层面被“看见”。所以第一步永远是先把Windows的蓝牙打开,流程很常规:
- 打开“设置 → 蓝牙和其他设备”,把蓝牙开关打开。这一步如果开关是灰的,先去设备管理器看蓝牙适配器有没有被识别,多半是驱动问题。
- 让目标设备进入广播状态。BLE设备一般上电后会自动进入可发现状态,有些需要特定按键操作,比如长按某个按键进入配对模式。
- 点击“添加设备 → 蓝牙”,等列表里出现设备名字,点击配对。BLE设备多数默认PIN是0000或直接确认,个别的会在屏幕上显示六位动态码。
配对成功后,设备会出现在“蓝牙和其他设备”页面里,状态显示“已配对”。这里多提一句,纯BLE设备并不都会显示在这个列表里,如果只是被扫描过而没有配对,系统可能根本不会列出来。所以等会调脚本的时候,我习惯直接用PowerShell来枚举,比系统设置页更可靠。
2. 原理先行:Windows凭什么能“自带”调试BLE
2.1 Windows蓝牙协议栈与WinRT API的关系
很多做硬件的朋友对Windows蓝牙的印象还停留在“只能连鼠标键盘、蓝牙音箱”,这其实是老黄历了。从Windows 8开始系统就内置了完整的BLE协议栈,到Windows 10,微软把BLE能力封装成了Windows.Devices.Bluetooth命名空间,里面有BluetoothLEDevice、GattDeviceService、GattCharacteristic这些类型,和手机上的CoreBluetooth、或者nRF Connect背后的抽象层是一个意思。
关键点是,这套API是WinRT组件,Windows PowerShell 5.1可以加载WinRT类型。也就是说,你能用C#写的UWP代码,绝大多数也能在PowerShell里写出来。对不想开Visual Studio、不想建工程的人来说,这就像一个天然可用的“命令行蓝牙调试器”。
2.2 为什么用PowerShell而不是写个C#程序
有人可能觉得,既然要写脚本,那还不如直接写个小程序。我的回答是:调试场景和开发场景完全不同。写个C#工程得建项目、引包、编译、部署,光环境就够折腾半天;而PowerShell脚本是即改即跑的,改一句存盘再执行,非常适合试错。
更重要的是,PowerShell可以直接做数据格式化。调完FindAllAsync返回的就是对象流,Select-Object、Where-Object、Format-Table随便用,这和命令行里用jq处理JSON一样顺手。图形工具给不了这种组合能力,尤其当你要批量测十台设备的时候,写个for循环就全搞定了。
2.3 能做到什么,做不到什么
我先把话说清楚,免得你满怀期待试完发现有些功能没有。用Windows自带方案能做到的是:枚举本机能看见的BLE设备、建立GATT连接、遍历服务和特征值、读取和写入特征值、订阅特征值通知、扫描周围的BLE广播包。
做不到的也有:一是抓链路层HCI日志做深度报文分析,这个得靠Wireshark加专用硬件或者系统ETW日志,属于另一个话题;二是Windows对同时处于活动连接的LE设备数量有限制,做不到像专业仪器那样挂几十个连接。不过在日常设备验证、固件联调、产线抽测这些场景下,这套方案已经非常好用了。
3. 核心实操:用PowerShell玩转GATT读写
这一节开始全是可以直接抄的脚本。我建议你现在就打开PowerShell,跟着跑一遍。
3.1 第一步:枚举已配对的BLE设备
打开PowerShell,推荐“以管理员身份运行”Windows PowerShell,就是那个蓝底的老版本,先加载WinRT的枚举类型:
[Windows.Devices.Enumeration.DeviceInformation, Windows.Devices.Enumeration, ContentType = WindowsRuntime] | Out-Null接着用AQS过滤器查所有BLE设备。{bb7bb05e-5972-42b5-94fc-76eaa7084d49}这个GUID是Windows定义的LE协议标识,注意整条过滤条件要用单引号包起来,双引号里写转义引号在PowerShell里会出错:
$aqs = '(System.Devices.Aep.ProtocolId:="{bb7bb05e-5972-42b5-94fc-76eaa7084d49}")' $devices = [Windows.Devices.Enumeration.DeviceInformation]::FindAllAsync($aqs, $null).GetAwaiter().GetResult() $devices | Where-Object { $_.Name } | Select-Object Name, Id, @{n='Paired';e={$_.Pairing.IsPaired}} | Format-Table -AutoSize跑完你会看到类似这样的输出:
Name Id Paired ---- -- ------ MySensor BluetoothLE#BluetoothLE70:07:9a:xx:xx:xx-70:07:9a:xx:xx:xx True这里有个坑:FindAllAsync返回的不只是已配对设备,还包括本机之前“见过”但没配对过的设备。我加了两层过滤,第一层去掉没有名字的,第二层用Pairing.IsPaired把配对状态标出来。如果你要调试的目标设备没出现在列表里,最常见的是两种情况:设备没进入广播状态,或者距离太远。把它拿到电脑旁边,重新上电,再跑一次。
3.2 第二步:连接并列出GATT服务
拿到Id之后,用BluetoothLEDevice.FromIdAsync建立连接。这个调用表面是“从ID创建设备对象”,实际上Windows会同时启动一个LE连接:
[Windows.Devices.Bluetooth.BluetoothLEDevice, Windows.Devices.Bluetooth, ContentType = WindowsRuntime] | Out-Null $deviceId = $devices | Where-Object { $_.Name -eq 'MySensor' } | Select-Object -First 1 -ExpandProperty Id $ble = [Windows.Devices.Bluetooth.BluetoothLEDevice]::FromIdAsync($deviceId).GetAwaiter().GetResult() if (!$ble) { Write-Host '设备连接失败,请检查设备电量和距离'; exit } $servicesResult = $ble.GetGattServicesAsync().GetAwaiter().GetResult() if ($servicesResult.Status.ToString() -ne 'Success') { Write-Host ('获取服务失败: ' + $servicesResult.Status); exit } $servicesResult.Services | ForEach-Object { $short = '0x' + $_.Uuid.ToString().Substring(4, 4) "$short ($($_.Uuid))" }这段代码会列出设备公布的所有GATT服务。比如一个常见的BLE温湿度传感器,通常会有这几个标准服务:0x1800(Generic Access)、0x1801(Generic Attribute)、0x180A(Device Information)、0x180F(Battery Service),以及厂商自定义服务,UUID要么是0000xxxx-0000-1000-8000-00805f9b34fb这种带短UUID的格式,要么是完全随机的128位UUID。
看到服务列表,就说明GATT连接已经打通,接下来的读写都是在这个连接上操作。
3.3 第三步:读取特征值
拿“电池服务”做例子。服务UUID是0000180f-0000-1000-8000-00805f9b34fb,电池电量特征值UUID是00002a19-0000-1000-8000-00805f9b34fb,返回单字节,直接代表百分比:
[Windows.Storage.Streams.DataReader, Windows.Storage.Streams, ContentType = WindowsRuntime] | Out-Null $batteryService = $servicesResult.Services | Where-Object { $_.Uuid.ToString() -eq '0000180f-0000-1000-8000-00805f9b34fb' } if ($batteryService) { $charResult = $batteryService.GetCharacteristicsAsync().GetAwaiter().GetResult() $charResult.Characteristics | ForEach-Object { "特征值: $($_.Uuid) 属性: $($_.CharacteristicProperties)" } $batteryChar = $charResult.Characteristics | Where-Object { $_.Uuid.ToString() -eq '00002a19-0000-1000-8000-00805f9b34fb' } $readResult = $batteryChar.ReadValueAsync([Windows.Devices.Bluetooth.BluetoothCacheMode]::Uncached).GetAwaiter().GetResult() $reader = [Windows.Storage.Streams.DataReader]::FromBuffer($readResult.Value) $level = $reader.ReadByte() Write-Host "电池电量: $level %" }这里有个细节我必须专门说:ReadValueAsync里我传了BluetoothCacheMode.Uncached,意思是强制从设备上读实时值,而不是拿Windows缓存的结果。很多人第一次在这里踩坑,读出来的永远是上一次的值,就是因为没加这个参数。但也要注意,有些BLE设备对频繁读取有功耗或频率限制,别拿这个接口去轮询几百次,设备会不高兴的。
如果是读取字符串类型的特征值,比如设备名称(0x2A00)或者序列号(0x2A25),用ReadString更直接:
$reader = [Windows.Storage.Streams.DataReader]::FromBuffer($readResult.Value) $text = $reader.ReadString($reader.UnconsumedBufferLength) Write-Host "读取到字符串: $text"3.4 第四步:写入特征值
写入比读取多一点讲究。你在特征值列表里会看到属性里有Write或者WriteWithoutResponse,这两个对应GATT的两种写操作。选哪个由设备固件决定,比如LED控制类设备一般用带响应的写,数据采集类传感器更常用不响应写,图的是快。
[Windows.Storage.Streams.DataWriter, Windows.Storage.Streams, ContentType = WindowsRuntime] | Out-Null $writer = [Windows.Storage.Streams.DataWriter]::new() $writer.WriteBytes([byte[]](0x01, 0x02, 0x00, 0xFF)) # 按设备协议组包 $writeResult = $someChar.WriteValueAsync($writer.DetachBuffer()).GetAwaiter().GetResult() if ($writeResult.ToString() -eq 'Success') { Write-Host '写入成功' } else { Write-Host ('写入失败: ' + $writeResult) }$someChar指的是上一步从特征值列表里拿到的目标特征值对象。写入可能返回的错误包括Unreachable(设备失联)和ProtocolError(设备端拒绝了这次写入),看到这些先怀疑协议组包,别急着怀疑电脑。如果特征值属性里压根没有写权限,调用会直接报错,这也是正常的,先检查一下设备那个特征值到底支不支持写。
3.5 第五步:订阅通知,实时收数据
进到调试阶段,实时看数据才是最爽的。BLE的通知机制是:设备主动往特征值里推数据,主机端先开启CCCD描述符里的通知位,就能收到ValueChanged事件。CCCD就是那个UUID为0x2902的客户端特征配置描述符。
[Windows.Devices.Bluetooth.GenericAttributeProfile.GattClientCharacteristicConfigurationDescriptorValue, Windows.Devices.Bluetooth.GenericAttributeProfile, ContentType = WindowsRuntime] | Out-Null # 打开通知开关 $status = $someChar.WriteClientCharacteristicConfigurationDescriptorAsync( [Windows.Devices.Bluetooth.GenericAttributeProfile.GattClientCharacteristicConfigurationDescriptorValue]::Notify ).GetAwaiter().GetResult() if ($status.ToString() -ne 'Success') { Write-Host '开启通知失败'; exit } # 注册事件 Register-ObjectEvent $someChar ValueChanged -Action { $reader = [Windows.Storage.Streams.DataReader]::FromBuffer($event.SourceEventArgs.CharacteristicValue) $bytes = New-Object byte[] $reader.UnconsumedBufferLength $reader.ReadBytes($bytes) Write-Host ("收到: " + [System.BitConverter]::ToString($bytes)) }执行后,设备每次上报数据,事件里就会输出一行hex数据。这里有个细节:Register-ObjectEvent注册的事件是绑定在当前PowerShell进程上的,窗口一关就没了,所以调试时窗口别关。如果你需要长时间采集,最好把脚本写成循环读取,并落盘保存。
4. 进阶技能:不配对也能扫描周边广播
上面的流程有个前提:你得先让Windows和你的设备完成配对。但有时我们拿到的设备根本没有烧录正确的广播名,或者固件有问题连不上,这时候就需要直接看广播包。
4.1 用AdvertisementWatcher监听广播包
Windows.Devices.Bluetooth.Advertisement命名空间里有个BluetoothLEAdvertisementWatcher,作用是扫描空中的BLE广播报文,完全不需要配对。脚本也很短:
[Windows.Devices.Bluetooth.Advertisement.BluetoothLEAdvertisementWatcher, Windows.Devices.Bluetooth.Advertisement, ContentType = WindowsRuntime] | Out-Null $watcher = New-Object Windows.Devices.Bluetooth.Advertisement.BluetoothLEAdvertisementWatcher Register-ObjectEvent $watcher Received -Action { $adv = $event.SourceEventArgs.Advertisement $mac = $event.SourceEventArgs.BluetoothAddress.ToString('X12') $name = $adv.LocalName $rssi = $event.SourceEventArgs.RawSignalStrengthInDBm if ($name) { Write-Host "$mac RSSI=$rssi $name" } } $watcher.Start()脚本跑起来之后,附近的BLE广播就会不断刷屏。BluetoothAddress是64位整数,格式化时要拼够12位十六进制并补前导0,这就是我们常说的MAC地址。RawSignalStrengthInDBm是当前信号强度,可以用来粗略判断设备离电脑多远。想让它定时结束,可以在后面加一句Start-Sleep -Seconds 10,或者干脆开着窗口手动Ctrl+C。
4.2 从广播包里捞有用信息
广播包不只是“我在线”,里面还藏着不少信息。Advertisement对象有LocalName(本地名)、ManufacturerData(厂商自定义数据)、ServiceUuids(服务UUID列表)这些字段。
比如很多蓝牙信标、手环会把自定义状态塞在ManufacturerData里,厂商ID能告诉你这是哪家的设备;服务UUID列表则能帮你判断用途——看到0x180D就知道是心率计,看到0x180F就知道设备带电池服务。在设备连不上的时候,这些信息就是排查“到底有没有在广播”“广播内容对不对”的关键证据。
我之前用这个功能排查过一次典型问题:厂家设备的固件改版后,广播名显示全是问号,怎么也连不上工具。靠AdvertisementWatcher一看,发现广播里LocalName是空的,但制造商数据一直在正常变化,立刻就定位到是设备端广播名配置漏了,跟协议栈半毛钱关系都没有。
5. 实战复盘:给一个BLE温湿度节点做体检
原理讲了一堆,你可能还是觉得“每个函数都懂,但串起来不知道长什么样”。这一章我用一个虚拟的BLE温湿度节点,完整走一遍调试流程。
5.1 目标设备与调试需求
设备是个温湿度节点,固件是新刷的,需求就三个:确认设备能正常建立GATT连接;确认温湿度特征值能读到有效数据;确认周期上报的通知能稳定收包。设备已经通过Windows设置页配对好了,名字叫TempNode-A1。
5.2 完整调试过程记录
先把枚举脚本跑一遍,确认能看到设备:
$aqs = '(System.Devices.Aep.ProtocolId:="{bb7bb05e-5972-42b5-94fc-76eaa7084d49}")' $devices = [Windows.Devices.Enumeration.DeviceInformation]::FindAllAsync($aqs, $null).GetAwaiter().GetResult() $devices | Where-Object { $_.Name -like '*TempNode*' } | Select-Object Name, Id输出只有一行:
Name Id ---- -- TempNode-A1 BluetoothLE#BluetoothLEa4:c1:38:xx:xx:xx-a4:c1:38:xx:xx:xx接着连接、遍历服务。得到的结果里,除了Generic Access、Generic Attribute、Device Information这几个标准服务,还有一个厂商自定义服务0000fef4-0000-1000-8000-00805f9b34fb,基本就是温湿度数据所在的专有服务。
进入这个自定义服务,列出特征值:
$service = $servicesResult.Services | Where-Object { $_.Uuid.ToString() -eq '0000fef4-0000-1000-8000-00805f9b34fb' } $charResult = $service.GetCharacteristicsAsync().GetAwaiter().GetResult() $charResult.Characteristics | Select-Object Uuid, CharacteristicProperties | Format-Table -AutoSize特征值有三个:
Uuid CharacteristicProperties ---- ------------------------ 0000aaa1-0000-1000-8000-00805f9b34fb Read 0000aaa2-0000-1000-8000-00805f9b34fb Notify 0000aaa3-0000-1000-8000-00805f9b34fb Write这里信息量很大:0xaaa1支持读,0xaaa2支持通知,0xaaa3支持写。对这类传感器,通常0xaaa1是读当前值,0xaaa2是周期上报,0xaaa3是配置命令通道。下一步就是读一次当前值,再开通知看上报频率。
5.3 调试结果分析
读取0xaaa1,得到四个字节:0x21 0x01 0x82 0x01。按设备协议解析,前两个字节是温度,后两个是湿度。用BitConverter.ToUInt16按小端序转换,算出大概是0x0121 = 289,除以10就是28.9度;湿度0x0182 = 386,除以10是38.6%,数值合理。
接着给0xaaa2开通知,按3.5节的脚本走。一分钟后,PowerShell里刷了大约12行数据,换算后都在合理范围内。到这里,三个验证需求全部满足,整个调试过程不到五分钟,中途还顺带确认了设备没有异常掉线。
这一步几乎就是我日常工作的缩影。很多时候硬件联调问题,80%靠这五个基础操作就能定位。
6. 常见问题与排查技巧
6.1 一张表对齐八种翻车现场
我把这一路踩过的坑整理成了速查表,你可以直接对着找答案:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 设置里蓝牙开关是灰的 | 蓝牙适配器驱动异常或被禁用 | 设备管理器里卸载蓝牙设备后重新扫描硬件,重装驱动 |
| 设备搜不到 | 设备没进广播模式,或距离太远 | 重新上电设备,长按配对键,靠近电脑再试 |
| 配对总失败 | PIN码不对或配对模式超时 | 确认识别配对方式,BLE常用0000或动态码,快速重试 |
| 枚举不到已配对设备 | AQS过滤器写错,或设备没名字 | 核对GUID,去掉Name过滤条件再跑一次 |
| 枚举得到但连接失败 | 设备已休眠或连接已被占用 | 重新上电,关掉其他占用蓝牙的设备,重试 |
| GATT服务获取失败 | Windows缓存了旧的GATT表 | 删除设备重新配对,或禁用再启用蓝牙适配器 |
| 读特征值报Access Denied | 系统隐私权限没开 | 设置 → 隐私和安全性 → 蓝牙,允许应用访问蓝牙设备 |
| 收不到通知 | 没写CCCD,或用了缓存模式 | 先写0x2902通知位;用Uncached模式重跑 |
6.2 几个提高效率的小习惯
第一个:把第3章的脚本整理成一份Debug-BLE.ps1,每次拿到新设备,第一件事跑“枚举+遍历服务”,把结果保存成文本,相当于给设备建了份“体检档案”。后面固件改版,对比两次GATT表,一眼就能看出服务结构动了哪儿,省得拿图形工具一个个点。
第二个:PowerShell脚本里所有WinRT异步方法都要跟着.GetAwaiter().GetResult(),这是WinRT和PowerShell之间最常见的转换写法。忘了写,你拿到的会是一个IAsyncOperation对象,而不是真正结果。如果输出里出现一大堆Windows.Foundation.IAsyncOperation,第一反应就是这里漏了。
第三个:Windows PowerShell 5.1和PowerShell 7在WinRT支持上有差异,7.0之后很多WinRT类型加载方式变了,还容易报缺类型。我的建议是做BLE调试就用系统自带的Windows PowerShell 5.1,右键管理员运行,别折腾新版本。等以后想把这些脚本集成到CI里,再考虑用兼容层慢慢调。
第四个:如果怀疑是Windows缓存了陈旧的GATT信息,在“设置 → 蓝牙和其他设备”里把设备删掉重新配对,通常能解决一大半“服务获取失败”“刚改完固件读出来的还是旧服务”的怪问题。改固件改了GATT表之后,Windows并不会自动感知,这个坑我踩了好几次才反应过来。
我个人实际操作下来的体会是,这套方案最好的使用场景是“快速验证”和“批量联调”。比如今天要测五个不同版本的固件,每个都要读一遍电池电量、确认一次设备信息,拿图形工具你得点半天,换成PowerShell脚本,循环五遍,记录全部落盘,干净利落。当然,如果你要做复杂到需要抓HCI包的深度分析,Windows自带方案确实不够,Wireshark加专用抓包器还是得备着。但日常生活里,一套系统自带工具能解决的调试问题,真的没有必要先装个几百兆的软件再说。