最近做智能硬件项目时,碰上一个看起来人畜无害、做起来全是细节的功能:设备端把扫描到的WiFi列表,通过BLE完整传到手机App上。最初的想法很简单,扫描结果拼个字符串,开个Notify特征直接发过去不就完了?结果从MTU协商、分包重组、中文SSID编码到连接参数调整,前前后后填了不知道多少坑。如果你正在做智能家居配网、设备调试通道或者WiFi信息同步类的功能,这篇文章把我会踩的和已经踩过的坑都整理出来,应该能帮你省下几个通宵。
先说清楚“BLE传输WiFi列表”到底指什么。它通常有两个方向:一个方向是设备端把扫描结果上报给手机,另一个方向是手机端把已保存的WiFi列表(SSID、密码、优先级)下发到设备端,用于自动联网或者可信网络同步。别看都是“传列表”,这两者对协议设计、安全要求、传输可靠性的要求完全不一样。文章里我会把两个方向都覆盖到,重点讲那些最容易出问题的地方。
1. 先搞清楚需求:什么场景需要BLE传WiFi列表
1.1 经典场景:智能设备配网与可信WiFi同步
先说最常见的场景,就是智能设备配网。设备没有屏幕没有键盘,想让它连上家里的WiFi,最通用的做法就是手机通过BLE把WiFi的SSID和密码传过去,设备拿到后用这套凭据去连接路由器。这里传的不是“列表”,而是单条WiFi信息,但协议设计上要按列表来考虑,因为你不知道用户会选哪个网络,也不知道会不会包含5G频段或者企业级加密网络。
另一种常见场景是设备端自己扫描周围的WiFi环境,把扫描结果通过BLE上报给手机端。这种功能常见于网络诊断、信号覆盖测试、或者某个“智能音箱配网时帮用户找路由器”的引导流程。设备扫描一次可能返回几十个AP,每个AP有SSID、BSSID、RSSI、加密方式、信道等字段,加起来很容易超过1KB甚至2KB,这时候就真的要考虑列表传输了。
还有一种场景是可信WiFi同步。用户更换手机或者重置设备时,希望把原来保存的所有WiFi信息一次性同步到新设备上。这种场景往往还涉及密码,安全性要求就比较高,单纯明文传输肯定不行。
我把文章讨论的边界限定在这三类场景里,重点分析第二和第三种,因为第一种单条WiFi传输本质上就是列表长度为1的特殊情况,很多问题列表场景才会暴露。
1.2 动手设计前,先想清楚的三个问题
很多同学一上来就写代码,其实在动手之前有三个问题没想清楚,后面返工的概率非常高。
第一个问题:传输方向是谁到谁?如果你的列表是从设备端到手机端(上报扫描结果),那么协议主体应该是Notify特征,手机订阅通知后设备主动推;如果是手机端到设备端(下发WiFi配置),那主要用Write特征或者Write Without Response。方向不同,可靠性策略也完全不同。Notify模式下设备只管发,手机收到多少算多少,所以必须考虑丢包重传;而Write模式是有应答的,可靠性天然高一些,但要面临写长包时的MTU限制。
第二个问题:列表里有没有密码字段?没有密码的扫描结果理论上可以放宽安全性设计,但一旦包含WiFi密码,整个链路就要考虑加密。BLE本身支持配对和加密,但配对流程对用户体验是个大伤害,很多项目最后采用的是传输层加密(比如用固定密钥做AES-GCM或者对数据做会话密钥协商)。这个决策会影响你后面所有数据格式设计。
第三个问题:数据规模到底多大?不要拍脑袋说“没几条”。一个城市公寓里扫到十几个AP很正常,商场里扫到几十个也不稀奇。每条AP信息如果包含SSID(最长32字节)、BSSID(6字节)、RSSI、信道、加密方式,一条轻松破50字节,几十条就是2KB以上。搞清楚这个数据量,你才知道分包重组是必须设计的,不是可选的。
我当时就是没把这三个问题想清楚,结果方案从“直接JSON字符串发过去”改成“自定义二进制帧格式”,整个底层通信模块差点推翻重写。所以这句话放在最前面说:先设计协议,再写业务代码。
2. 传输链路层的核心问题盘点
2.1 MTU才是第一个拦路虎
BLE传输WiFi列表时第一个撞上的坑,就是MTU。BLE 4.0/4.1的默认MTU是23字节,扣掉ATT协议头的3字节,单次实际传输用户数据的空间只有20字节。20字节是什么概念?一个普通的WiFi SSID“Home_WiFi_5G”就已经12字节了,加上BSSID和几个属性字段,一条AP信息就能撑爆。很多开发者在Android端不调用requestMtu(),然后拿着默认的23字节MTU去写一个几百字节的包,收到错误回调后整个人都懵了。
现代BLE设备普遍支持MTU协商,Android端在连接成功后会通过BluetoothGatt.requestMtu(247)申请更大的MTU,协商成功后单次可以传输244字节的用户数据。iOS端的行为不太一样,它不允许App主动设置MTU,而是系统根据对端能力自动决定,不过通常也能协商到185字节以上,够用就好。
这里要补充一个很多人忽略的细节:MTU协商是双向生效的,但实际生效的值是双方能力里较小的那一个。比如你的设备端报文接收缓冲只有100字节,即使手机协商到247也没用,协议栈实际只会按100字节给对端发。还有,MTU协商必须在连接建立后尽早完成,理论上连接后任何时间都可以发起Exchange MTU Request,但有些对端协议栈在连接初期连接参数还没稳定时,对MTU协商请求的响应会变慢甚至失败。
实操建议:设备端和手机端都支持动态查询当前连接MTU,不要把MTU写死在一个头文件常量里。协议设计上无论MTU是多少都能正常工作——也就是永远按“一块数据可能被拆成多包”来做,MTU大小只是影响分包数量的优化参数,而不是正确性依赖的参数。
2.2 分包、重组与粘包问题
即使协商到MTU 247,一个2KB的WiFi列表还是要分成9到10包。这里就涉及两个问题:分包和重组。
分包说起来简单:把数据切成N块,每一块加上序号,按顺序发出去。但实际做起来全是细节。首先是包格式设计,我做过的项目里常用的一种帧格式是:1字节版本号 + 1字节帧类型 + 2字节总长度 + 1字节当前包序号 + 1字节总包数 + N字节payload + 2字节CRC16。总共是8字节开销,对于MTU 244的情况,每包实际payload为236字节。
然后是重组逻辑。接收端绝对不能假设“一次回调就是一个完整包”。Android的onCharacteristicChanged回调有时候把一个完整包分成两次回调,有时候又把两个包合成一次回调,完全取决于底层控制器和系统蓝牙栈的实现。几年前我调试某款手机的蓝牙协议栈时,就遇到过通知数据被任意切割的情况,当时以为是自己分包逻辑有问题,折腾了好久才发现是协议栈把数据合并了。
处理这种问题最稳妥的做法是实现一个基于状态机的接收缓冲区:每次收到数据先追加到缓冲区,然后循环从缓冲区头部按帧格式解析,解析出一帧就交给上层处理,解析不完整就继续等下一批数据。这其实就是串口通信里处理“粘包/半包”的标准思路,搬到BLE上同样适用。
我用一个生活类比来解释一下:想象你在收快递,快递可能不是一个箱子一车拉过来,可能被拆成多个包裹分批到,也可能几个包裹用一个大袋子装着一起到。你不能指望“每来一辆车就是一个完整包裹”,正确做法是先把所有包裹堆在仓库里,然后按照箱子上的编号一个个找出来。
另外,分包发送时还要注意发送节奏。BLE的Notify是异步的,如果上层代码在一个循环里连续调用notifyValueChanged(),数据会进入协议栈的发送队列,但底层一次只能发一个包,发完一个才能发下一个。如果你的发送循环没有任何流控,几十个包一瞬间塞进去,很容易造成底层ACL丢包。比较好的做法是等待每个Notify的发送完成回调后再发下一包,或者在发送前做一次队列深度的检查。
2.3 GATT服务设计的几个关键点
GATT服务设计看起来简单,一个Service下面挂几个Characteristic而已,但有几个细节直接决定你的传输能不能成。
第一,Notify特征和CCCD的时序问题。很多同学会在开启Notify之后立刻开始发数据,但其实CCCD(Client Characteristic Configuration Descriptor)的写入是异步的,Android端setCharacteristicNotification()返回true只代表本地准备好了,不代表设备端已经知道你订阅了。如果设备端收到数据时还没处理完CCCD写入,这包数据就白发了。正确做法是先写CCCD,等待设备端确认(或者等待一段时间)之后再启动数据传输。更严谨的做法是在GATT服务里单独加一个“客户端就绪”特征,手机写一个字节表示“我开始订阅了,你可以发了”,设备端收到这个信号后再开始推数据。
第二,服务端并发写和跨特征状态的问题。如果你的数据分块写需要跨多个Characteristic,一定要保证写操作的先后顺序,或者干脆只用一个特征来传数据。多特征并发写会引入额外的状态同步问题,对端协议栈实现五花八门,很容易出现数据错乱。
第三,连接参数的影响。BLE的连接间隔(Connection Interval)决定了两个设备之间多久交换一次数据。默认连接间隔可能是30ms甚至50ms,对于WiFi列表这种几百字节到几KB的传输来说,这会直接导致传输时间翻倍。可以在连接建立后主动请求更短的连接间隔(比如7.5ms到15ms),但Android/iOS对连接参数更新的限制各不相同,有些系统会拒绝过于激进的参数请求。这里可以妥协一下:用中等水平的连接间隔,比如20ms,既不会有太高的功耗,传输速度也基本能接受。
3. 数据内容层的问题:远不止“把字串发过去”
3.1 WiFi列表的格式选择:JSON、二进制还是TLV
技术选型阶段几乎一定会讨论这个问题。JSON看起来最方便,调试友好,解析简单,但它有两个硬伤:一是字节开销大,一条AP的JSON表示加上键名、引号、花括号,差不多是二进制的两到三倍;二是特殊字符处理麻烦,SSID里如果出现引号或者反斜杠,JSON序列化和反序列化都得特别小心。
二进制格式或者TLV格式是更稳妥的方向。每个AP用固定结构描述,SSID的部分用长度前缀而不是字符串结尾符。我比较推荐一种类似TLV的设计:
AP Type (1 byte) + Length (2 bytes) + ValueType表示这条记录的属性是SSID、BSSID还是RSSI,多个属性组合成一条AP记录,多条AP记录组合成整个列表。这样做的好处是解析逻辑完全确定,不存在长度歧义,未来加字段也不用改协议版本。
如果你图省事一定要用JSON,那就得做好两件事:第一,整个JSON构造成一个完整的字符串后再分包传输,不要在JSON中间分包;第二,接收端先拼出完整字符串再交给JSON解析库,不要收到半个JSON就去解析。这个“先拼完整再解析”的原则在BLE这种分包传输场景下非常重要,因为大多数JSON解析库遇到截断字符串只会报一个没法用的错误。
3.2 中文SSID乱码与特殊字符转义的解决思路
这个坑我敢说几乎所有做过WiFi列表上报的人都碰到过。手机端扫描到的WiFi列表里有中文SSID非常正常,比如“隔壁老王的WiFi”,问题在于两端的编码不一致。
手机端Android获取到的SSID本身是UTF-8编码的String,如果设备端的协议栈或者中间层把它当成GBK或者Latin-1来解读,显示出来就是一堆乱码。我在调试某个项目时就亲眼见过App上显示“鎴戝_2019”这种让人摸不着头脑的内容,一查才知道是嵌入式端把UTF-8字节流按GBK解码了。
解决思路有两层:第一层是协议层面明确编码,写在协议文档里“所有字符串统一UTF-8编码”,发送端和接收端都按这个约定处理;第二层是实现层面,如果两边某个环节无法控制编码(比如第三方闭源SDK强制用某种编码),那就只能在协议层加“编码标识”字段,标识当前数据是什么编码,接收端根据标识动态解码。
除了编码,SSID里还有一类特殊字符问题。IEEE 802.11标准里SSID是任意字节序列,但很多路由器允许用户输入空格、引号、反斜杠、甚至不可见字符。当你把SSID拼接到一个自定义分隔符协议里时,这些字符就会造成解析错乱。比如你用逗号分隔多个字段,结果SSID里有个逗号,整条数据就废了。规避手段就是前面说的长度前缀方案,不要用分隔符,不要依赖转义,直接用长度告诉对端这块数据有多长。
BSSID的表示方式也要注意。MAC地址用冒号分隔的字符串表示虽然可读性好,但是占字节多,解析也费劲。协议内部建议直接用6字节二进制数据,到UI层再做格式化。
3.3 去重、排序与截断策略
设备端扫描到的WiFi列表如果不做任何处理直接上报,手机端收到的数据会非常难看。同一个SSID可能对应多个BSSID(比如一个路由器开了多个AP、启用了双频合一),信号强度各不相同。不做去重的话,用户看到的列表里同一个WiFi名字出现五六次,体验很差。
去重策略可以按SSID去重,保留RSSI最强的那条;也可以按BSSID完全保留,把去重的决定权交给手机端。我的建议是设备端做一次“轻去重”:按SSID+加密方式组合去重,保留RSSI最强的记录,因为用户关心的是“能不能连上这个WiFi”,而不是“这个WiFi有几个AP”。但如果你的应用场景是专业信号覆盖测试,那就要保留全部BSSID信息,这种情况就另当别论了。
排序上一般按RSSI降序,也就是信号从强到弱排列。注意RSSI是负数,数值越大信号越强,排序时千万别写反了。
截断策略也是需要考虑的。列表太长时不能无限传,协议里最好定义最大条目数,比如50条或者100条。超出部分要么丢弃,要么用分页查询的方式分批获取。分页查询虽然协议复杂一点,但用户体验更好:手机端显示20条,用户往上滑再去取下一页,比一次性传200条更实际。
4. 实测问题排查实录
4.1 问题一:写数据时返回错误码,传输失败
现象:Android端调用writeCharacteristic()写一条WiFi配置数据,回调返回错误码,常见的是133或者257。iOS端写数据也报错,但错误信息比较含糊。
定位过程:先看是不是MTU问题。日志里打印当前mtu值,如果是23,那大概率就是单次写入的数据量超过了ATT payload。再检查写的数据长度,如果一次写了100字节而MTU只有23,协议栈直接拒绝。
解决方案:一是连接成功后主动请求更大的MTU,Android端用requestMtu(247),不要用默认值。二是在代码里拆分写入数据,每次写入不超过当前MTU可承载的payload长度,并且等待上一次写入回调成功后再发下一次。这两条都要做,只做一条都不保险。有的项目只改了MTU,但某个功能模块还留着一段超过MTU的整包写入代码,换个手机就复现了问题。
我的经验是:无论MTU协商成什么样,所有写入操作都要先查一次当前MTU再决定分包策略,不要用一个全局常量假设所有设备都一样。
4.2 问题二:多包数据乱序、重包,对端解出来是乱码
现象:设备端发了几十包数据,手机端收到的顺序偶尔会错乱,或者同一个包收到两次,导致拼接出来的WiFi列表错位,解析完全失败。
定位过程:这个问题的隐蔽性很强,因为BLE底层本身有重传机制,但不能保证高层的包顺序。如果你的分包没有序号,或者没有处理重复包的逻辑,接收端只能按到达顺序拼接,一旦协议栈层面出现重传优化或者链路层乱序,拼接就错了。
解决方案:分包帧里必须有序号和总数,接收端维护一个接收位图或者一个简单的缓存数组,按照序号存储。等所有包都到达之后再统一拼接。对于重复包,根据序号直接丢弃。对于丢包,则要设计超时重传机制——这一步很多人觉得麻烦就省略了,但一旦发生了就是灾难。
补充一个细节:有些厂商的BLE协议栈为了吞吐量会启用“L2CAP的Enhanced Credit-Based Flow Control”,这种模式下数据到达顺序可能和发送顺序不完全一致,所以“顺序保证”不能盲目依赖底层。
4.3 问题三:中文SSID显示乱码
现象:手机端App显示的WiFi名称是“鎴戝_2024”或者一堆“???”。
定位过程:先判断是设备端扫描时就存错了,还是传输过程中编码转换错了。我在设备端打印原始SSID的十六进制,再对比手机端收到的十六进制,发现设备端原始数据是正确的UTF-8字节,但手机端解析成了GBK,导致显示乱码。
解决方案:协议中明确约定UTF-8编码,两端严格按这个规范处理。设备端扫描到的原始字节如果不是UTF-8,需要在协议栈层做一次转码;手机端解析BLE数据时不要用系统默认编码去new String(bytes),必须显式指定StandardCharsets.UTF_8。代码审查时重点检查所有涉及字节转字符串的地方,看看有没有漏网之鱼。
另外,iOS端读取数据时也要注意,NSData转NSString如果没指定编码同样会出问题,而且iOS端默认失败返回nil,调试时看起来像数据丢了,其实是编码导致的。
4.4 问题四:全列表传输耗时过长,UART缓冲区溢出
现象:设备端把整个2KB的WiFi列表分包发出,传输总耗时超过5秒,期间如果设备端的串口日志也在跑,还会出现UART缓冲区溢出导致数据丢失的情况。
定位过程:抓包看每一包之间的时间间隔,发现连接间隔默认是30ms到50ms,而每个连接事件最多传2到3包,算下来2KB数据要几百毫秒到1秒以上。这还不算最严重的,如果设备端的WiFi扫描线程和BLE发送线程同时在操作串口打印日志,UART FIFO被日志挤占,BLE数据来不及搬运就丢了。
解决方案:传输大列表前先做连接参数更新,把连接间隔从默认值缩短到20ms以内。关闭不必要的调试日志,给UART的DMA缓冲区适当增大。还有一个取巧的做法:把WiFi列表按“信噪比从高到低”排序后优先传前30条,用户看列表时最关心的是前几个信号最好的WiFi,后面的慢慢传,用户体验上会好很多。
我后来做了一件让整个传输更稳的事:给日志模块加了一个“传输期间静默”的开关,传列表时把普通日志全部降级为缓存,传输结束后再统一刷出来。这个改动虽然简单,但直接把因为串口干扰导致的传输失败率降到了接近零。
4.5 典型问题速查表
| 现象 | 定位思路 | 解决方案 |
|---|---|---|
| 写入BLE特征报错(错误码133/257) | 检查MTU协商结果,检查单次写入长度 | 连接后主动请求MTU 247,写入前按当前MTU分包 |
| 接收端拼接数据错乱、解析失败 | 检查分包帧是否有序号,是否处理重复/乱序 | 帧头添加seq/total字段,接收端按序号重组,重复包丢弃 |
| 中文SSID显示乱码 | 打印两端数据的十六进制字节,对比编码 | 协议统一UTF-8,两端显式指定UTF-8编码解析 |
| 传输速度慢、偶发超时 | 抓包查看连接间隔,查看每次Notify间隔 | 更新连接参数为更短间隔,优化发送流程,减少日志干扰 |
| 设备端发送几十包后不再响应 | 检查协议栈发送队列、流控 | 发送回调确认后再发下一包,增加队列深度检查 |
| 对端接收时收到半包/粘包 | 查看底层回调数据的切分行为 | 接收缓存加状态机解析,每次数据到达后循环提取完整帧 |
5. 一点调优经验
5.1 善用新蓝牙特性,但必须保留降级方案
如果芯片方案支持BLE 4.2以上的Data Length Extension(DLE)或者BLE 5.0的2M PHY,WiFi列表的传输体验会有明显提升。DLE能把单包数据从20字节提升到251字节,2M PHY让无线速率翻倍,两者叠加之后传2KB数据只需要不到1秒。
但现实情况是,你的用户可能拿着一个好几年前的手机,系统蓝牙协议栈对DLE和2M PHY支持不完整。我调试过一个老手机上跑同样的功能,协商MTU倒是成功到了185,但2M PHY启动失败后连接参数没正确回退,导致传输速度还不如之前的1M PHY。
所以我的做法是:协议栈能力分层。固件启动时默认使用BLE 4.2兼容模式,基本功能保证稳定运行;检测到对端支持更高能力时,再动态启用DLE和2M PHY。所有关于能力的协商结果都要回传给上层,上层根据实际协商结果决定分包大小和分包策略,而不是假设某个特性一定可用。
5.2 日志与抓包工具双管齐下
排查这类问题,只看代码是远远不够的。蓝牙协议栈分层太深,应用层的逻辑错误只是冰山一角,更多问题是发生在L2CAP、ATT、LL这些层面的。
我有两个习惯。第一个是Android开发者在开发者选项里打开“Bluetooth HCI Snoop Log”,抓一份完整的HCI日志,用Wireshark打开看。Wireshark里能直观看到每一层的包交换过程,比如MTU协商请求和响应、连接参数更新请求是否被拒绝、数据包有没有重传,这些信息比应用层日志可靠得多。
第二个是给应用层的收发逻辑加详细打点。发送时打印“包序号、长度、时间戳”,接收时打印同样信息,两边一对比,哪个包丢了、哪个包乱了立刻就能看出来。别小看这种打点日志,很多问题在代码review时看不出来,实际跑一遍数据,问题自己就暴露了。
5.3 传输协议里那些不起眼但救命的设计
说几个我在协议设计上踩过坑之后总结出来的小经验。
协议里一定要有版本号。哪怕你的WiFi列表功能只有手机App和你的设备端在用,也要在协议头里加1字节版本号。因为设备固件可能OTA升级,App可能强制更新,两边不兼容时版本号能帮你快速定位是谁的问题,而不是看着一堆乱码干瞪眼。
帧格式里要有总长度字段,不只是当前包长度。接收端拿到最后一包后,要根据总长度判断数据是否完整。如果只是依赖“包序号凑齐了就当完整”,一旦中间有包重复,序号虽然齐了但数据其实多了一段,对端解析就会出问题。
如果列表里包含WiFi密码,强烈建议做加密后再传。BLE的加密配对流程可以解决一部分问题,但很多人嫌配对流程繁琐就不做。一个折中且常见的做法是:连接后先做一次临时密钥协商(ECDH或类似方案),后续所有包含密码的数据用这个密钥加密传输。这个方案不需要交互配对,又能保证密码字段安全。密码字段传输完成后,最好能主动断开连接或者重新协商密钥,避免密钥长期复用被嗅探。
最后一个经验是:传输结束后记得主动关掉Notify或断开连接。BLE连接挂着不关,设备端的功耗一直掉不下去,而且会占用连接资源影响其他功能。传完列表后主动断开,设备回到可广播状态,整个产品体验会明显更“利落”。
说实话,这个功能做下来最大的体会是:BLE看似简单,但“传输类功能”和别人家SDK直接给你封装好的“数据发送”完全是两回事。你永远不知道对端手机是什么MTU、什么系统版本、蓝牙协议栈对DLE和连接参数更新的支持程度如何。唯一能让你稳住的,就是先把帧格式、分包策略、编码约定这些最底层的东西定死。协议定清楚了,业务代码写起来就是按部就班的事;协议稀里糊涂,后面每一个字节都在还技术债。先设计再动手,做WiFi列表也好、做别的BLE数据传输也好,都能少踩很多坑。