单链路的 Sniff 入门不难,难的是一条设备上同时跑多条 Sniff 链路——多路窗口怎么协商、怎么错峰、进入 deep 省电后怎么保证窗口不漂、不重叠。这是 Multi-Central / Multi-Peripheral 场景下,调度器设计的核心难题。本文拆解:Sniff 协商的完整流程、建立用的全套参数、以及 deep 模式下多路窗口对齐的设计思路。
TL;DR
- 多路 Sniff 的本质:一个设备(Master 或 Slave)同时维护 N 条 ACL 链路,每条链路有独立的 D_SNIFF / T_SNIFF 锚点,调度器必须让 N 个窗口"错峰"且"不漂"。
- 协商流程:
LMP_sniff协商基础参数(D_SNIFF / T_SNIFF / Attempt / Timeout),LMP_sniff_subrating协商 deep 模式的子速率(Max Latency / Min Timeout)。 - Deep 模式的窗口对齐精髓:锚点 D_SNIFF 永远不变,deep 只是把实际唤醒周期放大为 T_SNIFF × N,双方用同一个锚点独立推算,天然对齐。
- 多路错峰的关键:把多条链路的 T_SNIFF 设计成倍数关系,让窗口在时域上天然不重叠,配合调度器的优先级仲裁兜底。
- 时钟漂移是最大敌人:deep 模式下休眠久、漂移累积大,必须靠每次收包更新 clk_offset 持续校准。
目录
- 多路 Sniff 的场景与挑战
- Sniff 协商全流程
- 建立用的全套参数
- Deep 模式:子速率与窗口对齐
- 多路窗口对齐的调度设计
- 实战坑与调优
- 抓包验证
- 总结
1. 多路 Sniff 的场景与挑战
1.1 什么是"多路 Sniff"
单路 Sniff 是一条 ACL 链路进入省电模式。多路 Sniff 是同一个设备同时维护多条进入 Sniff 的 ACL 链路。典型场景:
| 场景 | 拓扑 | 多路压力 |
|---|---|---|
| Multi-Central | 一个中央设备连 3 个外围,3 条 ACL 都进 Sniff | 中央设备作为 Master,要错峰调度 3 个 Sniff 窗口 |
| Central + Peripheral | 设备同时是链路 A 的 Master、链路 B 的 Slave | 既要推算自己的 Slave 窗口,又要给别人的 Slave 排窗口 |
| TWS 主耳 | 主耳对手机是 Slave(Sniff),对副耳是 Master(TPT) | 两条链路的 Sniff 节奏必须对齐,否则副耳监听丢包 |
1.2 多路带来的三个新问题
单路 Sniff 只要"对齐一条链路的窗口",多路则多了三个维度的问题:
问题 1:窗口重叠(调度冲突) 链路 A: ┃窗┃──────┃窗┃──────┃窗┃ 链路 B: ──────┃窗┃──────┃窗┃── ↑ 如果 A/B 窗口撞在一起,同一个 RF 前端无法同时服务两条链路 问题 2:功耗峰值 三条链路同时唤醒 → 瞬时电流叠加 → 电源纹波 → 影响 RF 性能 问题 3:deep 模式下漂移放大 T_SNIFF 拉大后,休眠时间变长,clk_offset 漂移累积, 窗口对不上的概率随休眠时长指数上升核心结论:多路 Sniff 的难点不在"协商"(协议已定义),而在调度器如何把 N 个窗口在时域上排开,并让它们在 deep 模式下依然对齐。
2. Sniff 协商全流程
2.1 协商的参与者与方向
Sniff 由Master 发起协商(LMP 规范里LMP_sniff方向是 M→S),但从设备可以主动请求(HCI 层的HCI_Sniff_Mode命令,Controller 内部再转成 LMP_sniff)。
HCI 层: Host → Controller: HCI_Sniff_Mode(handle, max_interval, min_interval, attempt, timeout) LMP 层: Master → Slave: LMP_sniff(D_sniff, T_sniff, attempt, timeout) Slave → Master: LMP_sniff_accepted / LMP_not_accepted2.2 LMP_sniff 协商时序
主设备(Master) 从设备(Slave) │ │ │ ① 决定进 Sniff │ ├── LMP_sniff ──────────────────────────→ │ │ D_sniff = 1000(主设备 CLKN 锚点) │ │ T_sniff = 32(周期,时隙) │ │ Attempt = 4(强制监听时隙) │ │ Timeout = 16(延伸监听时隙) │ │ │ │ ② 从设备评估参数是否可接受 │ │ ←────────────── LMP_sniff_accepted ──────┤ │ (或 LMP_not_accepted 拒绝) │ │ │ │ ③ 双方在 D_sniff 时隙开始 Sniff │ │ 窗口 k = D_sniff + k·T_sniff │ │ │2.3 D_sniff 是怎么算出来的
D_sniff 是主设备 CLKN 的一个未来时隙号,主设备选取时要保证从设备有足够时间处理 LMP 命令:
D_sniff = 当前主设备 CLKN + 安全余量(通常 8~16 时隙) 主设备当前 CLKN = 1000 D_sniff = 1000 + 16 = 1016 从设备本地换算: D_sniff_local = 1016 - clk_offset关键:D_sniff 必须是未来的时隙,否则协商完成后窗口已经"过期"。余量要覆盖 LMP 传输 + 从设备处理的时隙数。
2.4 退出 Sniff:LMP_unsniff
需要回到 Active 时(如开始大流量传输),Master 发LMP_unsniff:
LMP_unsniff 与 LMP_sniff 不同——它不需要协商新参数, 只需要双方约定一个"退出时刻"(Instant),到期回到 Active 全时监听。 退出 Sniff 后,从设备恢复每个时隙监听,功耗回升。3. 建立用的全套参数
3.1 基础 Sniff 四参数(LMP_sniff)
| 参数 | 字段 | 含义 | 范围 | 对功耗/延迟的影响 |
|---|---|---|---|---|
| D_sniff | D_sniff | Sniff 锚点(主设备 CLKN 时隙号) | 未来时隙 | 决定窗口的"绝对位置",用于多路对齐 |
| T_sniff | T_sniff | 嗅探周期(时隙数) | 0x00040x4000(2.5ms10.24s) | 越大越省电、延迟越高 |
| Sniff_Attempt | Attempt | 强制监听时隙数 | 1~255 | 越大越稳、越耗电 |
| Sniff_Timeout | Timeout | 延伸监听时隙数 | 0~255 | 越大有数据时越久才休眠 |
3.2 时钟偏移 clk_offset
虽然不是 LMP_sniff 的参数,但是窗口对齐的隐含前提:
| 参数 | 来源 | 作用 |
|---|---|---|
| clk_offset | LMP_clk_offset_req/rsp(连接建立时) | 从设备换算主设备 CLKN 到本地时钟 |
从设备本地时钟 = 主设备 CLKN - clk_offset 窗口 k 的从设备本地时刻 = (D_sniff + k·T_sniff) - clk_offset3.3 Sniff Subrating 三参数(deep 模式)
Deep 省电通过LMP_sniff_subrating_req/rsp协商,核心参数是:
| 参数 | 字段 | 含义 |
|---|---|---|
| Max Latency | Max_Latency | 最大可容忍延迟(时隙),从设备据此计算子速率 N |
| Min Remote Timeout | Min_Remote_Timeout | 最小远端(对端)Timeout |
| Min Local Timeout | Min_Local_Timeout | 最小本地 Timeout |
子速率 N 的计算:从设备根据 Max Latency 和基础 T_sniff,算出实际唤醒周期 = T_sniff × N,使实际周期不超过 Max Latency:
实际周期 = T_sniff × N ≤ Max_Latency N = floor(Max_Latency / T_sniff) 示例: T_sniff = 8 时隙 (5ms), Max_Latency = 800 时隙 (500ms) N = floor(800 / 8) = 100 实际周期 = 8 × 100 = 800 时隙 (500ms)3.4 参数全景表(一张表记住所有参数)
┌─────────────────────────────────────────────────────────┐ │ Sniff 参数全景 │ ├─────────────────────────────────────────────────────────┤ │ 基础 Sniff(LMP_sniff): │ │ D_sniff —— 锚点(绝对位置,多路对齐的基准) │ │ T_sniff —— 周期(唤醒频率) │ │ Attempt —— 强制监听窗口宽度 │ │ Timeout —— 延伸监听窗口宽度 │ │ │ │ 隐含前提: │ │ clk_offset —— 主从时钟偏移(窗口换算的前提) │ │ │ │ Deep 模式(LMP_sniff_subrating): │ │ Max_Latency —— 最大容忍延迟 → 决定子速率 N │ │ Min_Remote_Timeout —— 最小对端 Timeout │ │ Min_Local_Timeout —— 最小本地 Timeout │ └─────────────────────────────────────────────────────────┘4. Deep 模式:子速率与窗口对齐
4.1 什么是 deep 模式
Deep 模式 = 深度省电 = 实际唤醒周期被大幅拉长。有两种方式:
| 方式 | 机制 | 特点 |
|---|---|---|
| 大 T_sniff | 直接协商很大的 T_sniff(如 1s) | 简单,但改参数要重新协商 |
| Sniff Subrating | 保持 T_sniff 不变,用 N 放大实际周期 | 推荐,切换快、锚点不变 |
实际项目里,deep 模式几乎都用Subrating——因为它保留了基础 T_sniff,只是"跳过"部分窗口,锚点不动,切换瞬时完成。
4.2 Deep 模式窗口对齐的精髓:锚点不变
Subrating 最精妙的设计是:deep 模式不改变 D_sniff 和 T_sniff,只引入 N 跳过窗口。
基础 Sniff (D_sniff=100, T_sniff=8): 窗口: 100 108 116 124 132 140 148 156 ... │←8→│←8→│←8→│←8→│←8→│←8→│←8→│ Deep 模式 (N=4, 实际周期 = 8×4 = 32): 窗口: 100 132 164 ... │←────── 32 ──────→│←────── 32 ──────→│ 每 4 个基础窗口才唤醒一次为什么锚点不变就能对齐:
- 双方都从同一个 D_sniff 开始推算
- 实际窗口 = D_sniff + k·(T_sniff × N),k = 0,1,2…
- 因为 D_sniff 是同一个值,双方的推算结果天然一致
- 不需要任何额外的同步信令——这是 Subrating 的核心价值
4.3 从设备侧的 deep 窗口推算
typedefstruct{uint32_td_sniff;// 基础 Sniff 锚点(主设备 CLKN)uint32_tt_sniff;// 基础周期(时隙)uint32_tattempt;// 窗口宽度uint32_tsubrating_n;// 子速率 N(deep 模式下 N > 1)int32_tclk_offset;// 主从时钟偏移}sniff_deep_state_t;// 计算下一个实际唤醒窗口(deep 模式)uint32_tcalc_next_deep_window(sniff_deep_state_t*s,uint32_tcur_local_clk){uint32_td_sniff_local=s->d_sniff-s->clk_offset;uint32_tactual_period=s->t_sniff*s->subrating_n;// 实际周期uint32_telapsed=cur_local_clk-d_sniff_local;uint32_tactual_idx=elapsed/actual_period;// 下一个实际窗口 = 锚点 + (idx+1) × 实际周期returnd_sniff_local+(actual_idx+1)*actual_period;}对比:普通 Sniff 的周期是
t_sniff,deep 模式的周期是t_sniff * subrating_n。锚点d_sniff始终不变——这是 deep 模式窗口对齐的根本。
4.4 Deep 模式下的时钟漂移问题
deep 模式休眠时间变长,clk_offset 漂移累积更大。这是 deep 模式窗口对齐最大的坑:
| 模式 | 实际周期 | ±20ppm 晶振漂移 |
|---|---|---|
| 普通 Sniff | 25ms | 1μs(可忽略) |
| Deep (N=40) | 1s | 40μs(需留余量) |
| Deep (N=400) | 10s | 400μs(接近 1 时隙,必须校准) |
应对策略:
- 每次收包更新 clk_offset:只要通信不断,偏移持续校准
- 扩大 Attempt 窗口:deep 模式下 Attempt 要覆盖漂移累积
- 粗时钟唤醒 + 精时钟锁定:用低功耗粗时钟提前唤醒,用精确时钟锁定窗口
推荐 Attempt 时隙数 = ceil(2 · 实际周期秒数 · ppm · 1e-6 / 625e-6) + 1 deep 模式实际周期 = 1s, ppm = 20: Attempt = ceil(2 · 1 · 20e-6 / 625e-6) + 1 = ceil(0.064) + 1 = 2 时隙5. 多路窗口对齐的调度设计
5.1 调度器要解决的核心问题
多路 Sniff 下,调度器(Planner/Slice)要保证:
| 目标 | 说明 |
|---|---|
| 窗口不重叠 | N 条链路的 Sniff 窗口在时域上错开,RF 前端不同时服务两个窗口 |
| 优先级仲裁 | 窗口意外重叠时,高优先级链路(如 eSCO 通话)优先 |
| 功耗平滑 | 避免多条链路同时唤醒造成电流尖峰 |
| deep 模式对齐 | 进入 deep 后,多路窗口依然错峰 |
5.2 核心手段一:T_sniff 倍数关系
最有效的错峰手段是让多条链路的 T_sniff 成倍数关系,窗口天然不重叠:
链路 A: T_sniff = 8 → 窗口: 100, 108, 116, 124, ... 链路 B: T_sniff = 16 → 窗口: 104, 120, 136, ... 链路 C: T_sniff = 32 → 窗口: 112, 144, ... 错峰效果(每 32 时隙一个大周期): 100 104 108 112 116 120 124 ... A B A C A B A ...设计原则:
- 各链路 T_sniff 都是最小 T_sniff 的整数倍
- D_sniff 错开一个"最小窗口宽度"的偏移
- 这样窗口在时域上天然排开,调度器只需处理意外重叠
5.3 核心手段二:Planner + Slice 两级调度
对应简历里的 Schedule 调度系统,两级规划:
┌─────────────────────────────────────────────┐ │ Planner(长周期规划,ms~s 级) │ │ - 规划各链路 Sniff 窗口的"宏观节奏" │ │ - 处理 deep 模式的子速率 N │ │ - 计算各链路下一个窗口时刻 │ └──────────────────┬──────────────────────────┘ │ 下发时隙计划 ┌──────────────────▼──────────────────────────┐ │ Slice(短周期切片,时隙级) │ │ - 每个时隙决定"这个时隙服务哪条链路" │ │ - 窗口重叠时按优先级仲裁 │ │ - eSCO > ACL > 无连接广播 │ └─────────────────────────────────────────────┘// Slice 层:每时隙的仲裁逻辑typedefstruct{uint8_tlink_id;uint8_tpriority;// eSCO=3, ACL高=2, ACL低=1bool is_sniff_window;// 是否处于该链路的 Sniff 窗口}slice_decision_t;slice_decision_tslice_arbitrate(scheduler_t*sched,uint32_tcur_clk){slice_decision_tbest={.priority=0,.is_sniff_window=false};for(inti=0;i<sched->link_count;i++){link_t*link=&sched->links[i];// 判断当前时隙是否是该链路的 Sniff 窗口if(in_sniff_window(link,cur_clk)){if(link->priority>best.priority){best.link_id=i;best.priority=link->priority;best.is_sniff_window=true;}}}returnbest;// 返回最高优先级的链路}5.4 核心手段三:多路 deep 模式的锚点规划
进入 deep 模式后,多路窗口的错峰依然靠锚点规划:
原则 1:所有链路的 D_sniff 保持"相对错峰" 链路 A 的 D_sniff = 100 链路 B 的 D_sniff = 100 + 4(错开一个最小窗口) 链路 C 的 D_sniff = 100 + 8 原则 2:deep 模式的 N 也成倍数关系 链路 A: T=8, N=4 → 实际周期 32 链路 B: T=16, N=4 → 实际周期 64 链路 C: T=32, N=4 → 实际周期 128 原则 3:锚点不变是前提 无论 N 怎么变,D_sniff 永远不动,错峰关系永不破坏5.5 多路对齐的完整时序示例
三条链路进入 deep 模式后的对齐效果: 链路 A (T=8, N=4, 实际周期 32): ┃窗┃───────────────────────────────┃窗┃─────────── 100 132 链路 B (T=16, N=4, 实际周期 64): ──────┃窗┃─────────────────────────────────────── 104 链路 C (T=32, N=4, 实际周期 128): ────────────┃窗┃───────────────────────────────── 112 总览(128 时隙 = 80ms 一个超级周期): 100 104 112 A B C ┃窗┃ ┃窗┃ ┃窗┃ ...三个窗口完美错峰,无重叠6. 实战坑与调优
6.1 坑 1:D_sniff 设得太近导致协商后窗口过期
症状:协商成功后第一个窗口就丢包 根因:D_sniff 余量不足,LMP 传输 + 从设备处理耗时超过了余量 修复:D_sniff = 当前 CLKN + 至少 16 时隙(10ms)6.2 坑 2:deep 模式切换后多路窗口突然重叠
症状:从普通 Sniff 切到 deep 模式后,两条链路窗口撞在一起 根因:切换 N 时没有重新检查多路错峰关系 修复:切换 N 前,先验证新实际周期下各链路窗口是否仍错峰; 若会重叠,优先调整 N 而非 D_sniff(锚点不动是铁律)6.3 坑 3:deep 模式长休眠后窗口漂移失准
症状:deep 模式跑几分钟后,从设备偶尔收不到主设备的包 根因:长时间无通信,clk_offset 漂移累积,窗口偏移超过 Attempt 覆盖范围 修复:① 扩大 Attempt ② 主设备周期性发 POLL 包强制校准 ③ 用粗时钟提前唤醒6.4 多路 Sniff 调优清单
| 场景 | T_sniff | N | Attempt | 说明 |
|---|---|---|---|---|
| 多路待机(中央连 3 外设) | 8 时隙 | 40 | 2 | 实际周期 200ms,窗口错峰 |
| 多路 + 一路活跃 | 活跃路 T=8/N=1,其余 T=16/N=8 | — | 2 | 活跃路快响应,休眠路 deep |
| TWS 主耳 | 手机链路 T=40/N=40,TPT 自主调度 | — | 4 | 副耳监听必须对齐手机链路 |
7. 抓包验证
用 HCI snoop 抓包验证多路 Sniff 对齐:
7.1 看协商
抓包过滤 LMP 命令: LMP_sniff → 看 D_sniff / T_sniff / Attempt / Timeout LMP_sniff_subrating_req → 看 Max_Latency(推断 N) LMP_sniff_subrating_rsp → 确认协商结果7.2 看对齐
关键验证点: 1. 多条链路的 ACL 数据包是否在时间轴上"错峰"出现 2. deep 切换后,数据包间隔是否 = T_sniff × N 3. 是否有连续的 POLL/NULL 包(说明窗口失准,在互相探测)7.3 看漂移
长时间抓包,观察数据包的实际间隔 vs 协商周期: 间隔 = 协商周期 ± 漂移量 漂移量持续增大 → clk_offset 未更新,窗口在漂8. 总结
多路 Sniff 的设计可以浓缩成三句话:
- 协商:
LMP_sniff定锚点和周期,LMP_sniff_subrating定 deep 模式的子速率,锚点 D_sniff 永远是窗口对齐的基准。 - 对齐:deep 模式不改变 D_sniff,只放大实际周期为 T_sniff × N,双方从同一锚点独立推算,天然对齐,无需额外同步。
- 多路错峰:让 T_sniff 成倍数关系 + D_sniff 错开最小窗口,配合调度器的优先级仲裁,保证 N 个窗口在时域上不重叠、不漂。
最核心的一句话:锚点不变,是 deep 模式窗口对齐的根;倍数错峰,是多路 Sniff 不冲突的根。