先从一个真实的问题说起。某天一个硬件群里的老哥发了段日志:他的运动手环模块跟手机 App 连接一切正常,心率数据每秒一条,但记录里每隔几十秒就会少一条,而且缺的位置完全随机。他怀疑是“BLE 链路参数”没调好,于是把 Connection Interval 从 7.5ms 改成 30ms,又从 30ms 改成 15ms,折腾了一晚上,丢帧依旧。这几乎是所有 BLE 初学者都会犯的错:把链路参数当成一个旋钮,以为调小就能提高可靠性,却完全没搞清 Connection Event、链路层自动重传和应用层“丢帧”这三者之间到底是什么关系。这篇就把这层窗户纸捅破,聊聊链路参数到底在干什么、自动重传为什么救不了一切,以及你看到的那个“丢帧”究竟是在哪一层丢的。不管你是写单片机固件、搞手机 App,还是负责产品测试,都可以把它当一份 BLE 空口通信的排障手册来读。
1. 链路参数是一份“双向定时合同”,不是调得越小越好
1.1 三个核心参数的职责和取值范围
BLE 链路层参数其实就三个:Connection Interval(连接间隔)、Slave Latency(从机延迟)、Supervision Timeout(监督超时)。很多人把它们当成三个性能开关,实际它们更像是一份合同,规定了两个无线电设备之间“多久见一次面、能不能请假、多久不联络就算分手”。
| 参数 | 单位 | 取值范围 | 一句话理解 |
|---|---|---|---|
| Connection Interval | 1.25ms 的倍数 | 7.5ms ~ 4s | 两个 Connection Event 起始点之间的时间间隔 |
| Slave Latency | 事件个数 | 0 ~ 499 | 从机允许连续缺席多少个 Connection Event |
| Supervision Timeout | 10ms 的倍数 | 100ms ~ 32s | 超过这个时间双方没有任何有效数据交换,连接直接判定丢失 |
这里最容易忽略的是三者之间的硬约束:Supervision Timeout 必须大于 2 × (1 + Slave Latency) × Connection Interval。为什么是两倍?因为链路层要留出足够余量应对时钟漂移。比如你设置连接间隔 100ms、从机延迟 5,那么超时值至少得大于 2 × 6 × 100ms = 1200ms,否则从机合法地睡完几个事件,超时窗口可能就提前触发了,连接会被莫名其妙地干掉。
1.2 参数由谁决定:别以为你改了就等于生效
很多工程师在固件里调了一版参数,就以为空口上跑的就是这个值,这是第二个误区。真实流程是:连接建立时,主设备在 CONNECT_IND 里给出初始参数;之后主设备可以主动更新,从机也可以发起请求——一种是通过链路层的 LL_CONNECTION_UPDATE_REQ,另一种是通过 L2CAP 固定通道 0x0005 发送 Connection Parameter Update Request。但最终拍板的是主设备。
落到手机上就更特殊:主流手机系统通常不会开放“直接设置连接间隔”的接口,App 只能表达一个“连接优先级”(比如低延迟、均衡、省电),具体参数由系统协议栈决定。经常出现的情况是:你的模块拼命请求 7.5ms,手机最后给了 30ms。所以排查问题时要做的第一件事,永远是打印出实际协商成功并被对端接受的参数,而不是你以为的那一组。这个习惯能省掉大量无效加班。
2. Connection Event:被绝大多数人忽略的最小时间骨架
2.1 一个 Connection Event 内部的“回合制”交换
Connection Event 是 BLE 连接状态下最基本的时间单元。每隔一个 Connection Interval,主设备就会在锚点(Anchor Point)发出一个包,从机必须在这个时间点附近处于接收状态。这个包可以是带数据的,也可以是一个空包——空包不携带用户数据,但携带了链路层的 SN/NESN 应答信息,作用是维持连接、确认上一条数据。
包与包的交换之间隔着一个 T_IFS,固定 150µs。你可以把它想象成乒乓球对拉:主设备发一球,从机回一球,一来一回才完成一次“传输机会”。一个 Connection Event 里可以有多轮这样的交换,只要双方都还有数据且事件时间没有耗尽。事件结束的条件有两个:要么主设备发了一个空包,从机也回了一个空包,双方都没有更多数据可发;要么到了控制器设定的最大事件时长。事件结束后,双方无线电进入待机,直到下一个锚点。
2.2 事件长度、多包交换和吞吐量的真实上限
这里有个关键概念必须分开:Connection Interval 是“多久开一次会”,事件长度是“每次会开多久”。很多控制器内部对一次事件能持续多久有独立限制,有些默认只允许很短的窗口,导致每个事件只能交换一两包数据。
举个具体的数字。假设连接间隔 30ms,事件长度只有 2ms。如果没开 DLE,链路层一个包最多 27 字节,空口耗时大约 0.3ms,那这一个事件还能塞下几个包;但如果开了 DLE,一个包最大 251 字节,在 1M PHY 下空口耗时接近 2ms,那一个事件就只够发一包。这就是为什么很多人开了 DLE 后吞吐量没提上去——瓶颈不在 MTU,而在事件长度。有些协议栈支持配置最大事件长度,比如设为 3 倍的连接间隔,不设置的话,高速传输根本跑不起来。
2.3 事件节奏如何决定延迟和功耗
数据从应用层写入到真正上天,理论上最坏要等一个完整的连接间隔:你在锚点刚过的那一刻写入,那就得等到下一个锚点。连接间隔 30ms,最坏延迟就是 30ms 起步;如果再碰上重传,每重试一次还要再叠加。而功耗方面,从机为了不错过锚点,即使没有数据也要醒来接收主设备的空包。连接间隔 7.5ms 意味着从机每秒要醒来 133 次。哪怕每次只醒 2ms,累积起来的电流开销也相当可观。所以“连接间隔调得越小越好”这句话在大功耗场景里就是灾难。
3. 自动重传的本质:停止等待 ARQ 与“隐形延迟”
3.1 链路层的停止等待机制:SN/NESN 位翻转
搞清楚自动重传,先得明白 BLE 链路层的应答方式。它用的是停止等待 ARQ:发送方一次只允许有一个未确认的数据包挂在空中,只有收到确认后才能发下一个新包。每个链路层数据包头里有两个关键位:SN(发送序号)和 NESN(期待收到的下一个序号)。接收方校验 CRC 通过后,把自己的 NESN 翻转一下作为应答,发回给发送方;发送方发现 NESN 翻转了,就知道上一包被收下了。
如果 CRC 校验失败,接收方根本不会送回任何有效应答,发送方等不到确认,就会在后续的 Connection Event 里重发这个包。有些实现允许在同一事件内补发,有些则只能等下一个事件。无论哪种,一次重试至少要付出一个连接间隔的时间代价。这非常重要,因为它决定了自动重传的本质:它把一次空口错误转化成了延迟,而不是直接转化为丢失。
3.2 重传风暴:连接还活着,数据却全“迟到”了
在一个 WiFi 密集、蓝牙设备扎堆的办公环境里,空口干扰是常态。假设连接间隔 30ms,某个包连续 5 个事件都没收到确认,那么它实际被确认的时间是 150ms 之后。对于心率这种 1 秒一条的数据,150ms 的延迟通常还能容忍;但对于实时控制、音频同步这类应用,150ms 已经超标了,应用层早就按“超时”处理了。于是连接没有断,数据也一个不少,但应用层看到的却是“丢帧、乱序、卡顿”。
这是最容易被误判的一类问题:抓包工具在空口上能看到每个包最终都发出去了,对端也确认了,于是你怀疑是应用逻辑的 bug,结果查了半天发现是无线环境导致的重传风暴。所以说,自动重传不是保险,它只是把问题从“丢”变成“晚”,而且这个“晚”对某些业务来说就等于“丢”。
3.3 链路层救不了的四件事
很多人默认“链路层有自动重传,所以数据不会丢”,这个认知至少错了一半。自动重传只在一个非常窄的范围内有效,它有四个明确的边界:
第一,监督超时触发。一旦超过 Supervision Timeout 双方没有任何有效包交换,连接被判定丢失,两端的控制器会静默清理所有未确认的发送队列和接收缓冲,这时候排队中的数据是真的没了,而且是不可恢复的没。第二,发送队列溢出。应用写入的速度超过链路层实际能排空的速度,写操作要么返回失败,要么被协议栈静默丢弃——GATT Write Without Response 尤其容易踩这个坑。第三,接收端处理不过来。数据到了对端控制器,但对端应用层消费速度跟不上,协议栈的接收缓冲区满,后续包也可能被丢。第四,高层协议状态被破坏。比如 L2CAP 分片还没拼完整连接就断了,半条消息直接作废。
还有一个必须强调的事实:BLE 链路层不会把重传次数上报给应用层。应用层看不到任何“重传”事件,只能从延迟变化、吞吐下降这些间接信号里去猜。这就让“自动重传到底发生了什么”成了一个很难直接观察的黑盒,也是很多排查工作陷入僵局的原因。
4. 一帧 Notify 的完整旅程:四个“看起来发出去了”的层
4.1 发送侧的每一层都在干什么
要从根上理解应用层丢帧,得跟着一帧数据走完全程。假设外设端应用调用 Notify,往特征值里写 20 字节数据,这一帧会经历这些步骤:
- 应用层调用写特征值,类型是通知(Notification)。
- GATT/ATT 层组包,生成一个 ATT Handle Value Notification 报文,包含属性句柄和数据内容。
- L2CAP 层给这个 ATT 报文加 4 字节头(长度加通道号),变成一个 L2CAP 报文。
- 链路层把这个 L2CAP 报文放进发送队列,等待下一个 Connection Event。
- 控制器在锚点发出这个包,等待对端链路层确认。
- 收到确认后,发送队列释放空间,应用层的写回调才真正完成。
注意第 4 步和第 6 步之间的本质区别:写回调返回成功,只代表本地协议栈把这个包放进了队列,不代表对端收到了,更不代表对端应用读到了。对于 Write Without Response 这种无应答写,协议栈甚至允许在队列满时直接丢弃数据而不产生任何错误。这就是第一个“看起来发出去了”。
4.2 接收侧的自动确认是个独立于应用的流程
再看接收侧。对端控制器收到数据后,链路层先做 CRC 校验和 MIC 解密,然后自动把应答包回给发送方。这个确认动作发生在控制器内部,在应用层任何代码运行之前。控制器确认之后,数据才沿着链路层、L2CAP、ATT、GATT 逐层往上递交,最后触发应用层的特征值变化回调。
所以空口抓包看到“对方 ACK 了”,只能说明对方的无线电控制器收到了那个包,并不能说明对方的 App 已经处理了这个通知。如果对方应用的主线程正卡在别的事务上,回调被延迟执行甚至被协议栈合并处理,应用层看到的现象就是“丢了”。这是第二个“看起来发出去了”。
4.3 四个坑的表格式总结
把上面前因后果整理成一个表,排查时对照着看会清晰很多。
| 现象 | 真实原因 | 结论 |
|---|---|---|
| 写特征值返回成功,但对端没反应 | 本地队列接受成功,不代表空口发送成功 | 无应答写的成功回调不可靠 |
| 空口抓包看到包已发出且被 ACK | 对端控制器收到了,但 App 消费速度跟不上 | ACK 只证明物理层收到了 |
| Notification 一条接一条发,App 有缺口 | Notify 没有端到端确认,发送方永远不知道对端是否处理 | 需要业务层自行加序号校验 |
| 写入超过 ATT MTU 的大数据经常失败 | 超过 MTU-3 的 Write Without Response 可能被直接拒绝 | 先协商 MTU,再考虑分片策略 |
第三坑尤其值得展开:Notify 在 GATT 层没有任何确认机制,发送方的协议栈只是把数据交给链路层就不管了。做产品设计时,如果业务要求“每条通知必须可靠送达”,靠 BLE 原生的 Notification 永远做不到,必须在应用数据里加序号、加校验,甚至在需要时改用 Indication(带确认)或者做应用层的重传协议。
5. 参数配置实战:心率、OTA、低功耗传感器的取舍与算例
5.1 吞吐量、延迟与功耗的三条约束
参数配置不是拍脑袋,而是在三条硬约束里找平衡点。下面的公式很重要:
- 理论吞吐量 ≈ (每个连接事件能承载的数据字节数)÷ 连接间隔
- 最坏延迟 ≈ 连接间隔 + 重传次数 × 连接间隔 + Slave Latency 跳过的间隔数
- 从机功耗 ≈ 每秒实际参与的事件数 × 单个事件的无线电耗电
每秒实际参与的事件数等于 1 除以连接间隔,再乘上一个系数:如果 Slave Latency 允许从机跳过 5 个事件,从机实际参与的事件数大约是每 6 个事件才参与 1 个。这就把三个参数和吞吐、延迟、功耗直接绑定了。调参数,本质上就是在这个三角形里选位置。
5.2 三种典型场景的参数建议
不同业务对三者的权重完全不同,先给一个参考表:
| 业务场景 | 推荐连接间隔 | Slave Latency | Supervision Timeout | 其他关键配置 |
|---|---|---|---|---|
| 实时心率/控制命令 | 7.5ms ~ 30ms | 0 ~ 2 | 1 ~ 2s | MTU 23 就够,无需 DLE |
| OTA 升级/文件传输 | 7.5ms ~ 15ms | 0 | 1 ~ 2s | 必须开 DLE,协商 MTU 247,事件长度拉满 |
| 电池传感器(几分钟上报一次) | 100ms ~ 1s | 4 ~ 20 | 5 ~ 10s | MTU 23 够,重点是降低事件参与频率 |
心率场景的要点是延迟和抖动:每条数据 1 秒一次,30ms 间隔能保证最坏延迟只有几十毫秒,显示曲线不会一顿一顿的。OTA 场景的要点是吞吐:间隔越小、每个事件的包越多,吞吐越高,同时要注意事件长度设置,以及手机侧通常能接受的下限。低功耗传感器场景的要点是省电:连接间隔拉到几百毫秒,Slave Latency 给到十几甚至几十,让从机绝大多数时间深度睡眠,但这时 Supervision Timeout 必须记得按第二节的公式重新核算,否则会出现“睡过头被判定掉线”的奇葩事故。
5.3 一个具体算例:50kB/s 的 OTA 目标和 20 字节心率的对比
先说 OTA。假设你要求下载速度达到 50kB/s,MTU 协商到 247,一条 Notification 的应用程序负载最多是 MTU 减 3 字节,也就是 244 字节。50kB/s 换算成每秒包数就是 51200 ÷ 244,约等于 210 包每秒。如果连接间隔 15ms,每秒有约 66.7 个事件,那么每个事件必须塞下约 3.15 个包。一个 251 字节的链路层包在 1M PHY 下空口大约要 2ms,加上确认往返,一个包大约占 2.4ms,4 个包约 9.6ms,离 15ms 还有富余,所以理论上可行。换成 2M PHY 会更轻松。这个算例的过程本身,就是排查吞吐不足问题的标准方法:把目标字节数除以单包负载得到包率,再除每秒事件数,看每个事件需要几包,然后看事件长度够不够。
再看心率。20 字节加上 ATT 头、L2CAP 头、链路层头,总共不到 30 字节,1M PHY 下空口只要 0.3ms 左右。哪怕连接间隔 100ms,一个事件也绰绰有余。这时决定功耗的不是数据量,而是唤醒次数。所以对这种业务,间隔选 30ms 还是 100ms 差别很大,选 7.5ms 则纯粹是浪费电池。
5.4 为什么不要迷信“最小化”
把连接间隔调到极限的 7.5ms 并不总是好事。除了功耗暴涨,它还会带来两个容易被忽视的问题:一是与同芯片上其他射频任务(广播、扫描)的调度冲突概率增大,二是手机端主控如果同时连接多个外设,7.5ms 的密集锚点会让调度变得非常紧张,一旦错过锚点,效果反而比 15ms 更差。经验上,能不用 7.5ms 就不要用,15ms 到 30ms 之间通常是延迟、功耗、可靠性的甜点区。
6. 应用层丢帧排查:从抓包到协议栈统计的完整路线
6.1 先把“丢”的定义定清楚
排查丢帧的第一步,不是看日志,而是统一口径。所谓“丢帧”至少有三种完全不同的情况:第一种是数据真的没到,比如序号有缺口;第二种是数据到了但晚了,比如 RSSI 差导致重传风暴,App 按照自己的超时逻辑把迟到的数据当成无效;第三种是数据全到了但被覆盖了,比如 App 回调里只保存“最新值”,两帧之间的中间值根本没被读取。
我强烈建议所有涉及 BLE 数据的业务,从第一天起就在负载里加一个自增序号。有了序号,丢还是没丢、丢在哪一段,一目了然。没有序号,所有的“好像少了”都只能靠猜,排查效率至少低一半。
6.2 按“信息量最大”排序的排查步骤
真按顺序来,我建议这样做:
- 抓空口。用 BLE 嗅探器在发送端和接收端附近抓包。先看发送端是否每个事件都把数据包发出来了,对端链路层是否 ACK 了;再看重传次数和 Supervision Timeout 事件。如果空口一切正常,问题大概率在上层。
- 打印实际协商参数。把连接间隔、Slave Latency、Supervision Timeout、MTU、事件长度全部打出来,和你预期的对比。这里经常会发现“我设了 7.5ms,实际是 30ms”。
- 统计协议栈内部事件。CRC 错误计数、错过锚点的次数、发送队列溢出次数、接收丢包计数。这些数据在多数协议栈里都有接口,只是很多人从来不看。
- 最小化复现。关掉其他设备,固定单连接、固定间隔、固定负载,跑 30 分钟。如果只在这种条件下复现,那多半是射频共存或者并发连接调度的问题。
- 应用层对照日志。接收端把每条通知的到达时间和序号记录下来,和发送端的日志对齐时间轴。
6.3 三个经典“伪丢帧”原因
排到最后,你会发现很多“丢帧”根本不是空口丢的,而是这三个经典原因之一。
一,MTU 没有协商。默认 ATT MTU 只有 23 字节,应用负载一次最多 20 字节。你要写个 200 字节的数据,就得拆成 10 个包,排队排到天荒地老,队列一满就丢。很多人把 MTU 协商漏了,然后被“大数据传输丢帧”折磨一星期。
二,事件长度被限制。前面说过,连接间隔和事件长度是两回事。控制器默认的事件长度往往很短,导致每个事件只能发一两包。症状是:明明间隔调得很小,吞吐却上不去,数据在队列里堆积,超时被丢。
三,单芯片多角色共用无线电。外设同时在跑广播和连接,或者在做周期性扫描,而芯片只有一个射频前端。扫描窗口或广播事件一旦和连接锚点冲突,连接事件就被缩短甚至错过。表现常常是周期性的丢帧缺口——观察抓包时间轴,几乎每个缺口都能对应上广播或扫描的活动。这种问题不是调链路参数能解决的,而是要安排射频任务的时序,让它们避开连接事件。手机端多个连接并存时也有类似的调度问题,只是你只能通过调小或者调大间隔来给主控留出调度余量。
6.4 一张症状对照表
| 症状 | 优先排查方向 |
|---|---|
| 通知序号有缺口 | 空口抓包 + 对端应用消费速度 |
| 写入大数据频繁失败或丢包 | MTU 未协商、事件长度不足、发送队列溢出 |
| 丢包呈现周期性 | 同芯片广播/扫描共存、主控多连接调度 |
| 偶发断连 | Supervision Timeout 配置、2.4GHz WiFi 干扰、天线匹配 |
说实话,调 BLE 链路参数这几年,我踩过最大的坑就是当初把 7.5ms 当成万能药,一遇到丢帧就往上调,结果问题没解决,功耗还翻了一倍。现在我的做法是:新项目第一天就在固件里把实际协商出的连接间隔、MTU、重传计数打成一条 debug 日志,出问题先看这条日志。很多所谓“丢帧”查到最后,根本不是无线空口的问题,而是协议栈配置、队列深度和应用消费速度的问题。把这个思路捋顺了,BLE 那些看似玄学的现象,其实大多都能在三十分钟内定位到具体那一层。