Sniff 是经典蓝牙三个节能模式里用得最多的一个。TWS 耳机待机时能不能省电、A2DP 播放时会不会断音,都和 Sniff 参数直接相关。这篇拆解 Sniff 的三个核心参数,以及 TWS 场景下怎么调才平衡。
TL;DR
- Sniff 模式让从设备周期性"醒来"监听,其余时间休眠。三个参数:Interval(醒的周期)、Attempt(醒多久)、Timeout(额外等多久)。
- 参数调优的核心矛盾:Interval 越大越省电,但响应延迟越高;Attempt 越大越稳,但功耗越高。
- TWS 待机场景 Interval 可以拉到 500ms-1s,A2DP 播放场景要压到 40ms 左右,HFP 通话不能用 Sniff。
- Sniff Subrating 允许在不退出 Sniff 的情况下动态调整 Interval,实现"待机省电、有数据快速响应"。
目录
- [为什么要用 Sniff 模式](#为什么要用 Sniff 模式)
- [Sniff 的工作原理](#Sniff 的工作原理)
- 三个核心参数详解
- [Sniff Subrating 机制](#Sniff Subrating 机制)
- [HCI 命令与 LMP 交互](#HCI 命令与 LMP 交互)
- [TWS 场景参数调优](#TWS 场景参数调优)
- [抓包分析 Sniff](#抓包分析 Sniff)
- 常见问题排查
1. 为什么要用 Sniff 模式
经典蓝牙的 ACL 链路在 Active 状态下,从设备每个时隙都要监听主设备是否发数据------即使没有数据,RF 和基带也一直开着。这对于电池供电的设备(TWS 耳机、手表、传感器)是巨大的功耗浪费。
蓝牙定义了三个节能模式来降低功耗:
| 模式 | 功耗 | 响应延迟 | 适用场景 |
|---|---|---|---|
| Hold | 中 | 中 | 短时间挂起(如协商加密) |
| Sniff | 低 | 可调 | 长时间连接但数据稀疏(TWS 待机、传感器) |
| Park | 极低 | 高 | 已基本不用(BLE 取代) |
Sniff 是实际项目中最常用的------它保持 ACL 链路不断开,但从设备大部分时间在休眠,只在约定的时隙醒来监听。TWS 耳机待机时能撑好几天,核心就是 Sniff 在工作。
为什么不用 Hold?Hold 是"约定一段时间不通信",到期后自动恢复 Active。它适合临时挂起(比如切换加密模式时),不适合长期待机------因为 Hold 期间完全不能收数据,到期恢复 Active 后又回到全时监听。
为什么不用 Park?Park 几乎完全休眠,只保留 beacon 监听,但响应延迟太大(秒级),而且 Park 模式下设备没有 AM_ADDR,不能直接收发数据,必须先 Unpark。现在 BLE 已经取代了 Park 的大多数场景,经典蓝牙里 Park 基本不用了。
2. Sniff 的工作原理
Sniff 模式下,主从双方约定一个 Sniff Interval (嗅探间隔)。每隔这个间隔,从设备"醒来"一段窗口(Sniff Attempt),监听主设备是否发数据。如果在这段窗口里收到数据,从设备保持唤醒继续收发;如果没收到,从设备重新休眠,等下一个 Interval。
时间轴 →
主设备: ──[发数据]──────────────────────────[发数据]──
从设备: ▏醒▕─────────睡─────────▏醒▕─────────睡─────────▏醒▕
│←Attempt→│←Interval-Attempt→│←Attempt→│←Interval-Attempt→│
Active 模式对比:
从设备: ▏醒▕醒▕醒▕醒▕醒▕醒▕醒▕醒▕醒▕醒▕醒▕醒▕醒▕醒▕醒▕ ← 全时监听
关键点:主设备也必须在 Sniff 窗口才能给从设备发数据。Sniff 是双方约定的------主设备不能在从设备休眠时发数据,否则从设备收不到。这意味着 Sniff 参数影响的不只是从设备的功耗,还有链路的传输能力。
3. 三个核心参数详解
3.1 Sniff Interval(嗅探间隔)
从设备两次"醒来"之间的时间间隔。
- 取值范围:0x0004 - 0x4000,单位 0.625ms(即 2.5ms - 10.24s)
- 对功耗的影响:越大越省电(休眠时间越长)
- 对延迟的影响:越大延迟越高(主设备要等到下一个窗口才能发数据)
- 对吞吐的影响:越大有效带宽越低(传输机会少)
典型值:
| 场景 | Interval | 理由 |
|---|---|---|
| TWS 待机 | 500ms - 1s | 无数据传输,最大化省电 |
| A2DP 播放 | 40ms - 60ms | 匹配音频帧周期(SBC 约 20ms/帧),保证及时传输 |
| HID 键盘 | 100ms - 200ms | 按键响应延迟 < 200ms 用户可接受 |
| 传感器上报 | 1s - 4s | 数据稀疏,延迟可接受 |
3.2 Sniff Attempt(嗅探尝试窗口)
从设备每次醒来后,持续监听的时长。
- 取值范围:0x0001 - 0x7FFF,单位 0.625ms
- 对功耗的影响:越大越耗电(醒着的时间长)
- 对稳定性的影响:越大越稳(给链路更多时间重传/同步)
Attempt 不能为 0------至少要醒一个时隙才能判断主设备有没有发数据。Attempt 通常设置成能覆盖若干个跳频时隙(每个时隙 625μs),确保即使遇到跳频干扰也能收到。
典型值:
| 场景 | Attempt | 理由 |
|---|---|---|
| TWS 待机 | 4 - 8 时隙(2.5ms - 5ms) | 只需确认有无数据,短窗口够 |
| A2DP 播放 | 16 - 32 时隙(10ms - 20ms) | 要收完整个 SBC 帧包 |
| 弱信号环境 | 加大到 32 - 64 时隙 | 给重传留余量 |
3.3 Sniff Timeout(嗅探超时)
从设备在 Attempt 窗口内收到数据后,额外延长监听的时间。
- 取值范围:0x0000 - 0x7FFF,单位 0.625ms
- 作用:如果 Attempt 窗口里收到了数据,说明有通信需求,从设备继续醒着 Timeout 时长,看主设备是否还有后续数据
- Timeout=0:Attempt 结束立即休眠,不管有没有收到数据
这个参数解决的是"突发数据"场景------主设备可能连续发多个包,如果每个包都要等下一个 Interval,延迟会累积。Timeout 让从设备在收到数据后"多醒一会儿",避免频繁休眠-唤醒。
典型值:
| 场景 | Timeout | 理由 |
|---|---|---|
| TWS 待机 | 0 或 4 时隙 | 无突发数据,不需额外等待 |
| A2DP 播放 | 16 - 32 时隙 | 连续媒体包,需要延长接收窗口 |
| 大数据传输 | 64+ 时阶 | 临时退出 Sniff 更好(见下一节) |
3.4 三个参数的关系
│←───────────────── Sniff Interval ─────────────────→│
│ │
│ │← Attempt →│← Timeout →│ │
│ │ (仅收到数据时) │
│ ▏醒▕─────────睡──────────────────────────────────────
│ │
│ 下一个 Interval 开始
│ │← Attempt →│← Timeout →│
│ ▏醒▕─────────睡──────────────────────────────────────
三个参数的工程意义:
- Interval 决定省电程度(宏观)
- Attempt 决定基础可用性(微观)
- Timeout 决定突发处理能力(弹性)
调参的核心逻辑:先定 Interval(决定省电目标),再定 Attempt(保证基础通信),最后用 Timeout 优化突发场景。
4. Sniff Subrating 机制
4.1 问题:频繁进出 Sniff 的开销
TWS 耳机在"待机 - 来电 - 通话 - 挂断 - 待机"这种场景里,状态频繁切换。每次切换如果都要 Exit Sniff → Active → 重新 Enter Sniff,开销很大:
- LMP_unsniff + LMP_sniff 两次握手
- 状态机切换的时序约束
- 重新协商参数的延迟
4.2 Subrating 的解法
Sniff Subrating(opcode 0x54)允许在不退出 Sniff 的情况下,动态调整有效 Sniff Interval。原理是引入一个"基础 Interval"和"子速率"概念:
-
基础 Interval 保持较短(如 40ms),保证低延迟响应能力
-
实际运行时通过 Subrating 把有效 Interval 拉长到 N 倍(如 N=16,有效 Interval=640ms)
-
一旦有数据要传,立刻回到基础 Interval,无需重新协商
无 Subrating:
Interval=640ms ▏醒▕──────────────────────────────────────睡──────
↑ 来数据了,但要等 640ms 才能响应有 Subrating (base=40ms, subrate=16):
基础周期: ▏醒▕────▏醒▕────▏醒▕──── ... 共 16 次
↑ 第 1 次就收到数据,立即保持 40ms 的活跃 Interval
(无需 LMP 重新协商)无数据时:
有效周期: ▏醒▕────────────────────────────睡────── (640ms)
↑ 每 16 个基础周期才真正醒一次
4.3 Subrating 参数
HCI_Sniff_Subrating 命令(Opcode 0x0C11)的参数:
| 参数 | 含义 |
|---|---|
| Max_Latency | 最大允许的有效 Interval(×0.625ms),即子速率后的最大间隔 |
| Min_Remote_Timeout | 对端的最小 Sniff Timeout |
| Min_Local_Timeout | 本端的最小 Sniff Timeout |
Max_Latency 是核心参数------它告诉控制器"有效 Interval 可以拉到多大",但基础 Interval 保持不变。控制器根据链路活动自动决定实际 Interval:有数据时用基础 Interval,无数据时逐步拉长到 Max_Latency。
4.4 适用场景
Subrating 非常适合"间歇性活动"的设备:
- TWS 耳机:待机时拉长 Interval 省电,有来电/通知时立刻回到短 Interval 响应
- HID 设备:无输入时长 Interval,按键时短 Interval 低延迟
- 健康传感器:周期性上报数据,上报期间短 Interval,其余时间长 Interval
5. HCI 命令与 LMP 交互
5.1 进入 Sniff 的 HCI 命令
Host 通过 HCI_Sniff_Mode 命令(Opcode 0x0803)让 Controller 发起 Sniff:
c
// HCI_Sniff_Mode 命令参数
struct hci_sniff_mode_cmd {
uint16_t handle; // ACL 连接句柄
uint16_t sniff_interval; // 嗅探间隔 (×0.625ms)
uint16_t sniff_attempt; // 嗅探尝试 (×0.625ms)
uint16_t sniff_timeout; // 嗅探超时 (×0.625ms)
};
// 示例:进入 Sniff,Interval=100ms, Attempt=5ms, Timeout=0
struct hci_sniff_mode_cmd cmd = {
.handle = acl_handle,
.sniff_interval = 160, // 100ms / 0.625ms = 160
.sniff_attempt = 8, // 5ms / 0.625ms ≈ 8
.sniff_timeout = 0, // 不额外等待
};
hci_send_cmd(HCI_SNIFF_MODE, &cmd, sizeof(cmd));
5.2 底层 LMP 交互
Controller 收到 HCI 命令后,发起 LMP 交互:
Host (B) Controller B Controller A Host (A) Host (B) Controller B Controller A Host (A) #mermaid-svg-QgEEjbaS0meGCu5p{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QgEEjbaS0meGCu5p .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QgEEjbaS0meGCu5p .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QgEEjbaS0meGCu5p .error-icon{fill:#552222;}#mermaid-svg-QgEEjbaS0meGCu5p .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QgEEjbaS0meGCu5p .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QgEEjbaS0meGCu5p .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QgEEjbaS0meGCu5p .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QgEEjbaS0meGCu5p .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QgEEjbaS0meGCu5p .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QgEEjbaS0meGCu5p .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QgEEjbaS0meGCu5p .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QgEEjbaS0meGCu5p .marker.cross{stroke:#333333;}#mermaid-svg-QgEEjbaS0meGCu5p svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QgEEjbaS0meGCu5p p{margin:0;}#mermaid-svg-QgEEjbaS0meGCu5p .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QgEEjbaS0meGCu5p text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-QgEEjbaS0meGCu5p .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QgEEjbaS0meGCu5p .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-QgEEjbaS0meGCu5p .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-QgEEjbaS0meGCu5p .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-QgEEjbaS0meGCu5p #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-QgEEjbaS0meGCu5p .sequenceNumber{fill:white;}#mermaid-svg-QgEEjbaS0meGCu5p #sequencenumber{fill:#333;}#mermaid-svg-QgEEjbaS0meGCu5p #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-QgEEjbaS0meGCu5p .messageText{fill:#333;stroke:none;}#mermaid-svg-QgEEjbaS0meGCu5p .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QgEEjbaS0meGCu5p .labelText,#mermaid-svg-QgEEjbaS0meGCu5p .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-QgEEjbaS0meGCu5p .loopText,#mermaid-svg-QgEEjbaS0meGCu5p .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-QgEEjbaS0meGCu5p .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QgEEjbaS0meGCu5p .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-QgEEjbaS0meGCu5p .noteText,#mermaid-svg-QgEEjbaS0meGCu5p .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-QgEEjbaS0meGCu5p .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QgEEjbaS0meGCu5p .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QgEEjbaS0meGCu5p .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QgEEjbaS0meGCu5p .actorPopupMenu{position:absolute;}#mermaid-svg-QgEEjbaS0meGCu5p .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-QgEEjbaS0meGCu5p .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QgEEjbaS0meGCu5p .actor-man circle,#mermaid-svg-QgEEjbaS0meGCu5p line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-QgEEjbaS0meGCu5p :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 双方进入 Sniff 模式 HCI_Sniff_Mode(Interval, Attempt, Timeout) LMP_sniff(D_Sniff, T_Sniff, D_Sniff_Attempt, T_Sniff_Attempt) HCI_Sniff_Mode_Request 事件(通知 Host B) HCI_Accept_Sniff_Mode_Request(或拒绝) LMP_accepted(LMP_sniff) HCI_Command_Complete (Sniff_Mode) HCI_Mode_Change 事件
注意:LMP_sniff 是 Master 发起的。如果 Slave 想进入 Sniff,它发 HCI 命令给自己的 Controller,Controller 会通过 LMP 通知 Master,由 Master 决定是否发起 Sniff 协商。
5.3 退出 Sniff
退出 Sniff 用 HCI_Exit_Sniff_Mode(Opcode 0x0804),底层走 LMP_unsniff:
Controller Host Controller Host #mermaid-svg-K1OkGhcGT4feiI3O{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-K1OkGhcGT4feiI3O .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-K1OkGhcGT4feiI3O .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-K1OkGhcGT4feiI3O .error-icon{fill:#552222;}#mermaid-svg-K1OkGhcGT4feiI3O .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-K1OkGhcGT4feiI3O .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-K1OkGhcGT4feiI3O .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-K1OkGhcGT4feiI3O .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-K1OkGhcGT4feiI3O .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-K1OkGhcGT4feiI3O .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-K1OkGhcGT4feiI3O .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-K1OkGhcGT4feiI3O .marker{fill:#333333;stroke:#333333;}#mermaid-svg-K1OkGhcGT4feiI3O .marker.cross{stroke:#333333;}#mermaid-svg-K1OkGhcGT4feiI3O svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-K1OkGhcGT4feiI3O p{margin:0;}#mermaid-svg-K1OkGhcGT4feiI3O .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-K1OkGhcGT4feiI3O text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-K1OkGhcGT4feiI3O .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-K1OkGhcGT4feiI3O .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-K1OkGhcGT4feiI3O .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-K1OkGhcGT4feiI3O .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-K1OkGhcGT4feiI3O #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-K1OkGhcGT4feiI3O .sequenceNumber{fill:white;}#mermaid-svg-K1OkGhcGT4feiI3O #sequencenumber{fill:#333;}#mermaid-svg-K1OkGhcGT4feiI3O #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-K1OkGhcGT4feiI3O .messageText{fill:#333;stroke:none;}#mermaid-svg-K1OkGhcGT4feiI3O .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-K1OkGhcGT4feiI3O .labelText,#mermaid-svg-K1OkGhcGT4feiI3O .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-K1OkGhcGT4feiI3O .loopText,#mermaid-svg-K1OkGhcGT4feiI3O .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-K1OkGhcGT4feiI3O .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-K1OkGhcGT4feiI3O .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-K1OkGhcGT4feiI3O .noteText,#mermaid-svg-K1OkGhcGT4feiI3O .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-K1OkGhcGT4feiI3O .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-K1OkGhcGT4feiI3O .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-K1OkGhcGT4feiI3O .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-K1OkGhcGT4feiI3O .actorPopupMenu{position:absolute;}#mermaid-svg-K1OkGhcGT4feiI3O .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-K1OkGhcGT4feiI3O .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-K1OkGhcGT4feiI3O .actor-man circle,#mermaid-svg-K1OkGhcGT4feiI3O line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-K1OkGhcGT4feiI3O :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 对端回 LMP_accepted HCI_Exit_Sniff_Mode(handle) 发送 LMP_unsniff HCI_Mode_Change 事件 (Mode=Active)
5.4 Sniff Subrating 的 HCI 命令
c
// HCI_Sniff_Subrating 命令
struct hci_sniff_subrating_cmd {
uint16_t handle; // ACL 连接句柄
uint16_t max_latency; // 最大有效 Interval (×0.625ms)
uint16_t min_remote_timeout; // 对端最小 Timeout (×0.625ms)
uint16_t min_local_timeout; // 本端最小 Timeout (×0.625ms)
};
这个命令可以在 Sniff 模式下随时发,不触发状态切换。
6. TWS 场景参数调优
6.1 TWS 耳机的四种工作状态
| 状态 | 链路活动 | 推荐参数 |
|---|---|---|
| 待机(充电盒外) | 偶尔有通知 | Interval=500ms-1s, Attempt=8, Timeout=0 |
| A2DP 播放 | 持续媒体流 | Interval=40-60ms, Attempt=16, Timeout=16 |
| HFP 通话 | SCO/eSCO 占用 | 不能用 Sniff(见下文) |
| 来电响铃 | 突发信令 | 用 Subrating,base=40ms, max=200ms |
6.2 为什么 HFP 通话不能用 Sniff
HFP 通话走 SCO 或 eSCO 链路,SCO/eSCO 是同步链路,时隙是预留的。ACL 链路在通话期间仍然存在,但实际数据量很少(只有 HFP 控制信令)。
理论上 ACL 可以进 Sniff,但实际实现中:
- SCO/eSCO 已经占用了大量时隙,剩余的 ACL 时隙很少
- Sniff 协商的 Interval 可能和 SCO 时隙冲突
- 大多数芯片厂商在 SCO 激活时不允许 ACL 进 Sniff
所以 TWS 通话期间,ACL 保持 Active,靠 SCO 的预留时隙机制省电。通话结束后再切回 Sniff。
6.3 A2DP 播放时的 Sniff 参数
A2DP 播放时,媒体流通过 ACL 传输,Sniff 参数直接影响音频稳定性:
Interval 的选择:
SBC 帧周期约 20ms(48kHz × 16 blocks)。一个 RTP 包通常含 2-6 个 SBC 帧,即每 40-120ms 发一个 RTP 包。Interval 应该小于 RTP 包间隔,否则可能错过传输窗口。
推荐 Interval = 40-60ms,覆盖 1-2 个 RTP 包周期。
Attempt 的选择:
Attempt 要足够收完一个 RTP 包。RTP 包 + L2CAP 头约 700-1000 字节,EDR 2Mbps 下传输约 4ms。考虑重传,Attempt 设 16 时隙(10ms)比较稳妥。
Timeout 的选择:
A2DP 是连续流,一个 RTP 包后面可能紧跟着另一个。Timeout 设 16 时隙(10ms),让从设备在收完一个包后多醒一会儿,收下一个。
A2DP Sniff 参数推荐:
Interval = 40ms (64 × 0.625ms)
Attempt = 10ms (16 × 0.625ms)
Timeout = 10ms (16 × 0.625ms)
时间线:
│←── Interval 40ms ──→│←── Interval 40ms ──→│
│←Attempt+Timeout=20ms→│
▏醒▕────收RTP────────────▏醒▕────收RTP────────────
↑ 连续收完媒体包后休眠
6.4 待机时的 Sniff 参数
待机时没有持续数据,只需要偶尔收控制信令(如 AVRCP 按键):
待机 Sniff 参数推荐:
Interval = 800ms (1280 × 0.625ms)
Attempt = 2.5ms (4 × 0.625ms)
Timeout = 0 (不额外等待)
时间线:
│←────────── Interval 800ms ──────────→│
│←2.5ms→│
▏醒▕─────────────睡─────────────────────
↑ 确认无数据,立即休眠
待机时 Interval 可以拉得很大,但要考虑两个约束:
- 链路监督超时(Supervision Timeout):Interval 必须 < Supervision Timeout,否则链路会被判超时断开。通常 Supervision Timeout 设 5-10s,Interval 最大不超过它的一半。
- 应用响应延迟:如果用户按按键,响应延迟 ≈ Interval。Interval=800ms 意味着按键响应最多延迟 800ms------这对 AVRCP 不可接受。
解决响应延迟的方法:用 Sniff Subrating。基础 Interval=40ms(低延迟响应),Max_Latency=800ms(待机时拉长)。有按键时立刻回到 40ms,无数据时逐步拉到 800ms。
6.5 主从切换时的 Sniff 处理
TWS 耳机主从切换时,Sniff 参数需要重新协商:
- 主从切换前,先 Exit Sniff 回到 Active
- 执行角色切换(LMP_switch_req)
- 切换完成后,新主设备重新发起 Sniff 协商
如果直接在 Sniff 状态下做角色切换,可能因为 Sniff 窗口不对齐导致切换失败。稳妥的做法是先 Exit Sniff。
7. 抓包分析 Sniff
7.1 HCI 层抓包
用 btmon 或 hcidump 抓 HCI 层,能看到 Host 和 Controller 的交互:
bash
# btmon 抓包
sudo btmon -w sniff.pcap &
# 触发 Sniff 进入/退出
# ...
# 停止
sudo pkill btmon
Wireshark 过滤 Sniff 相关事件:
# Sniff 模式切换事件
bt HCI_EVT_Mode_Change
# Sniff 命令
btcmd.opcode == 0x0803 # HCI_Sniff_Mode
btcmd.opcode == 0x0804 # HCI_Exit_Sniff_Mode
btcmd.opcode == 0x0C11 # HCI_Sniff_Subrating
HCI 层抓包看到的是"Host 下命令 → Controller 确认 → Mode_Change 事件",看不到具体的 LMP 交互。
7.2 空口抓包看 LMP
要看 Sniff 的 LMP 交互,需要空口抓包仪(Ellisys / Frontline)。关键 LMP PDU:
| Opcode | 命令 | 抓包看什么 |
|---|---|---|
| 0x08 | LMP_sniff | 参数协商:D_Sniff, T_Sniff, D_Sniff_Attempt, T_Sniff_Attempt |
| 0x09 | LMP_unsniff | 退出 Sniff |
| 0x54 | LMP_sniff_subrating | 子速率协商 |
| 0x00 | LMP_accepted | 接受 Sniff 请求 |
空口抓包还能看到 Sniff 模式下的实际时隙占用------主设备只在 Sniff 窗口发数据,其余时隙静默。这是验证参数是否生效的最直接方法。
7.3 验证 Sniff 是否生效
抓包看三个信号:
- HCI_Mode_Change 事件:Mode 字段从 0x00(Active)变成 0x02(Sniff),说明进入 Sniff 成功
- ACL 数据包的时间分布:Sniff 模式下,ACL 包应该集中在 Sniff 窗口附近,非窗口期应该没有包
- 电流测试:用功耗分析仪测电流,Sniff 模式下应该看到周期性的电流脉冲(Attempt 窗口),其余时间电流极低
8. 常见问题排查
8.1 进入 Sniff 失败
现象 :发 HCI_Sniff_Mode 后收到 HCI_Command_Status (Status=ERROR) 或长时间无 Mode_Change 事件。
排查:
- 检查参数合法性:Interval ≥ 0x0004,Attempt ≥ 0x0001
- 检查连接状态:链路必须是 Active 才能进 Sniff
- 检查是否有 SCO 激活:SCO 激活时通常不允许进 Sniff
- 检查对端是否支持 Sniff:查 LMP features(Page 0 bit 5)
8.2 Sniff 模式下音频卡顿
现象:A2DP 播放时进入 Sniff 后,音频周期性卡顿。
原因:Interval 太大或 Attempt 太小,导致 RTP 包传输不及时。
调参方向:
- 缩小 Interval(如从 100ms 降到 40ms)
- 加大 Attempt(如从 8 时隙加到 16 时隙)
- 加大 Timeout(如从 0 加到 16 时隙)
8.3 Sniff 模式下链路断开
现象:Sniff 模式下链路突然断开,日志显示 Supervision Timeout。
原因:Sniff Interval > Supervision Timeout,或者信号质量差导致 Sniff 窗口里收不到数据,累计超时。
调参方向:
- 检查 Interval 是否 < Supervision Timeout / 2
- 弱信号环境下加大 Attempt,增加接收成功率
- 适当缩短 Interval,增加链路存活机会
8.4 待机功耗没降
现象:进了 Sniff,但整机功耗没明显下降。
排查:
- 确认 Sniff 真的生效(看 Mode_Change 事件)
- 测控制器电流:如果控制器电流没降,可能是 RF 没真正休眠(检查硬件设计)
- 检查其他耗电大户:传感器、LED、充电电路等可能抵消 Sniff 的省电效果
- Interval 是否足够大:Interval=100ms 省电效果不明显,至少 500ms 以上
8.5 Sniff Subrating 不生效
现象:发了 HCI_Sniff_Subrating,但有效 Interval 没变化。
排查:
- 确认双方都支持 Sniff Subrating(LMP features Page 0 bit 15)
- 检查 Max_Latency 是否 > 当前 Interval(否则没有拉长空间)
- 检查链路状态:必须在 Sniff 模式下才能用 Subrating
- 查控制器实现:某些低端芯片的 Subrating 实现有 bug,需要厂商确认