从零实现BLE协议栈(8-1)LL Procedures:Instant 机制与参数热更新
LL Procedures:Instant 机制与参数热更新
前提知识
阅读本文需要具备以下基础:
- 理解 LL Control PDU 的处理流程(第 6-1 篇有详细讲解):本文新增的两种 PDU(
LL_CONNECTION_UPDATE_IND和LL_CHANNEL_MAP_IND)属于 LL Control 类别(LLID =11),共享同一套解析和分发框架。 - 理解 connEventCounter 的含义(第 4-2 篇有详细讲解):每个连接事件结束后
connEventCounter加 1,本文的 Instant 机制完全依赖这个计数器来决定"何时生效"。 - 理解 Slave Latency(第 7-1 篇有详细讲解):当有 Instant 尚未到达时,Slave Latency 必须被禁用——这是本章新增的一条关键约束。如果你跳过了第 7-1 篇直接看本文,需要知道
can_skip_event()是判断能否跳过事件的函数。 - 理解信道选择算法 CSA#1(第 4-4 篇有详细讲解):Channel Map 更新直接改变 CSA#1 的输入——
chan_map和chan_count,理解算法的运作方式才能明白为什么换 Channel Map 不需要换hop和last_unmapped_chan。
不需要了解 HCI 层。本文所有操作都发生在链路层内部,由空口 LL Control PDU 触发,不涉及 Host。
一、连接不是一成不变的
在前面几章里,连接参数来自 CONNECT_IND——Master 在建立连接时单方面决定了 Interval、Latency、Timeout 和 Channel Map,之后就一直用这组参数跑下去。
但在真实场景中,参数需要在连接生命周期内动态调整:
- OTA 升级:传输大量数据时希望缩短 Interval(如从 100 ms 降到 30 ms)来提高吞吐量,传完后再恢复
- 省电策略:设备空闲时拉长 Interval(如从 30 ms 延长到 500 ms)并增大 Latency
- 干扰规避:2.4 GHz 频段拥挤,Wi-Fi 或微波炉占用了某些信道。Master 通过 Channel Map Update 告诉 Slave"从现在起不要使用信道 6、7、8"
参数更新不能像改本地变量那样随手一改——两端必须在同一时刻切换到新参数,否则一端用新 Interval、另一端用旧 Interval,锚点瞬间错位,连接崩溃。
BLE 解决这个问题的方式叫做 Instant 机制:Master 发一个 LL Control PDU 说"从第 N 个连接事件开始,使用新参数"。Slave 收到后记下来,继续用旧参数运行,到了第 N 个事件的时刻一起切换。
二、Instant 的语义
Instant 是一个 16-bit 的 connEventCounter 值,包含在特定的 LL Control PDU 中。它的含义非常精确:
在
connEventCounter == instant的那个连接事件,新参数生效。
BLE Core Spec Vol 6, Part B, 5.1.1 还规定了有效范围:
也就是说,Instant 必须在当前事件之后、不超过半个 uint16_t 范围之后的某个未来事件。如果 Slave 收到一个 Instant 已经过去的 PDU(instant - counter > 0x7FFF,即负数的无符号表示),说明 Master 或传输出了问题——这是协议错误,Slave 应断开连接。
为什么是 32767 而不是 65535?因为 16-bit 计数器会回绕。当 counter = 65530、instant = 10 时,10 - 65530 = 15 还是 10 - 65530 = -65520?只有限制在半个范围内(32767),才能用无符号差值 >0x7FFF 可靠地判断"已过去"和"还没到"。
这和 TCP 的序列号比较用同样的技巧。
三、两种需要 Instant 的 LL Procedure
3.1 LL_CONNECTION_UPDATE_IND
当 Master 决定更新连接参数时,发送 LL_CONNECTION_UPDATE_IND(opcode 0x00),PDU 结构如下:
字节偏移 字段 长度 含义
[0] Opcode = 0x00 1 LL Control Opcode
[1] WinSize 1 新的传输窗口大小
[2..3] WinOffset 2 新的窗口偏移 (LE)
[4..5] Interval 2 新的连接间隔 (LE, x1.25ms)
[6..7] Latency 2 新的 Slave Latency (LE)
[8..9] Timeout 2 新的 Supervision Timeout (LE, x10ms)
[10..11] Instant 2 生效的连接事件编号 (LE)
Slave 收到后:
- 不回复——这个 PDU 是 Master 单向下发的,Slave 不需要发送任何响应 PDU
- 把新参数和 Instant 存入
proc_conn_update结构体,标记pending = true - 继续用旧参数运行,直到
connEventCounter == instant - 在 Instant 到达时一次性替换所有连接参数
3.2 LL_CHANNEL_MAP_IND
当 Master 要更新 Channel Map 时,发送 LL_CHANNEL_MAP_IND(opcode 0x01):
字节偏移 字段 长度 含义
[0] Opcode = 0x01 1 LL Control Opcode
[1..5] ChM 5 新的 Channel Map (37 bit)
[6..7] Instant 2 生效的连接事件编号 (LE)
处理逻辑完全类似:存下新 Channel Map 和 Instant,到时切换。
两者的共同点:
| 特征 | LL_CONNECTION_UPDATE_IND | LL_CHANNEL_MAP_IND |
|---|---|---|
| 方向 | Master → Slave only | Master → Slave only |
| Slave 回复 | 无 | 无 |
| 触发机制 | Instant | Instant |
| Slave 行为 | 记录 + 延迟生效 | 记录 + 延迟生效 |
| 失败条件 | Instant 已过 → 断连 | Instant 已过 → 断连 |
四、朴素方案:收到就立刻改
如果 Slave 收到 LL_CONNECTION_UPDATE_IND 后立刻替换连接参数,会发生什么?
假设 Master 在事件 #100 发出 PDU,Instant = 106:
- Master 在事件 #101~#105 仍使用旧 Interval 调度
- 如果 Slave 从 #101 开始就用新 Interval,锚点会和 Master 不同步
- 具体来说,假设旧 Interval = 24 (30ms)、新 Interval = 80 (100ms):
- Master 在 #101 的锚点 = T_100 + 30ms
- Slave 在 #101 的锚点 = T_100 + 100ms
- 两端差了 70ms——远超任何 WW 能覆盖的范围
- 连接立即断开
所以 Instant 不是可选的装饰,而是双端同步的刚性需求。在 Instant 之前的所有事件,双端都必须用旧参数;在 Instant 那个事件,同时切换。
五、实现:存储 Pending Procedure
解析 LL Control PDU 时,把新参数存入全局结构体:
case PDU_DATA_LLCTRL_TYPE_CONN_UPDATE_IND: {
const struct pdu_data_llctrl_conn_update_ind *cu =
&rx_pdu->llctrl.conn_update_ind;
proc_conn_update.win_size = cu->win_size;
proc_conn_update.win_offset = sys_le16_to_cpu(cu->win_offset);
proc_conn_update.interval = sys_le16_to_cpu(cu->interval);
proc_conn_update.latency = sys_le16_to_cpu(cu->latency);
proc_conn_update.timeout = sys_le16_to_cpu(cu->timeout);
proc_conn_update.instant = sys_le16_to_cpu(cu->instant);
proc_conn_update.pending = true;
/* Slave 不回复此 PDU */
return false;
}
return false 意味着不设置 tx_pdu_pending——不会向 Master 发送任何回复 PDU。这是这两种 PDU 和 LL_VERSION_IND(需要回复)的本质区别。
Channel Map 的存储逻辑完全对称:
case PDU_DATA_LLCTRL_TYPE_CHAN_MAP_IND: {
const struct pdu_data_llctrl_chan_map_ind *cm =
&rx_pdu->llctrl.chan_map_ind;
memcpy(proc_chan_map.chan_map, cm->chm, PDU_CHANNEL_MAP_SIZE);
proc_chan_map.instant = sys_le16_to_cpu(cm->instant);
proc_chan_map.pending = true;
return false;
}
六、实现:Instant 到达时的参数切换
在连接态主循环中,每个事件开始前检查 Instant 是否到达:
6.1 Instant 过期检查
首先需要一个函数判断 Instant 是否已经过去(协议错误检测):
static bool instant_passed(uint16_t instant)
{
uint16_t diff = instant - conn_event_counter;
return (diff > 0x7FFF) && (diff != 0);
}
这里的数学很精妙。diff = instant - counter 用的是 uint16_t 算术,自动处理回绕。如果 diff 的最高位为 1(即 diff > 0x7FFF),说明 instant 在 counter 之前——已经过去了。特别排除 diff == 0(正好是当前事件,不算过去)。
6.2 Connection Update 生效
static bool check_apply_conn_update(uint32_t *conn_interval_us,
uint32_t *interval_ticks,
uint32_t *interval_remainder_ps,
uint32_t *supervision_timeout_us,
uint32_t combined_sca_ppm)
{
if (!proc_conn_update.pending) {
return false;
}
if (conn_event_counter != proc_conn_update.instant) {
if (instant_passed(proc_conn_update.instant)) {
conn_terminated = true; /* 协议错误 → 断开 */
}
return false;
}
/* ★ Instant 到达! 替换参数 */
conn_params.interval = proc_conn_update.interval;
conn_params.latency = proc_conn_update.latency;
conn_params.timeout = proc_conn_update.timeout;
/* ... */
/* 重新计算 interval 相关的定时值 */
*conn_interval_us = (uint32_t)conn_params.interval * CONN_INT_UNIT_US;
*interval_ticks = HAL_TICKER_US_TO_TICKS(*conn_interval_us);
*interval_remainder_ps = HAL_TICKER_REMAINDER(*conn_interval_us);
*supervision_timeout_us = (uint32_t)conn_params.timeout * 10000;
/* 重新计算 WW 参数 */
anchor.ww_periodic_us = DIV_ROUND_UP(
combined_sca_ppm * (*conn_interval_us), 1000000);
anchor.ww_max_us = ((*conn_interval_us) >> 1) - T_IFS_US;
/* 更新 Slave Latency 配额 */
lat.latency_max = conn_params.latency;
lat.latency_used = 0;
proc_conn_update.pending = false;
return true;
}
参数替换后,必须立刻重新计算所有派生值。Interval 变了意味着:
interval_ticks和interval_remainder_ps要更新(锚点递推步长)ww_periodic_us要更新(每事件的 WW 增量与 Interval 成正比)ww_max_us要更新(WW 上限是 Interval/2 - T_IFS)supervision_timeout_us要更新latency_max要更新(新 Latency 值可能和旧的不同)
如果遗漏了其中任何一项,连接可能在 Instant 后立即出问题——最常见的是 WW 没更新导致 RX 窗口不匹配。
6.3 Channel Map Update 生效
Channel Map 的切换相对简单——只需要替换 chan_map 和 chan_count:
static bool check_apply_chan_map_update(void)
{
if (!proc_chan_map.pending) {
return false;
}
if (conn_event_counter != proc_chan_map.instant) {
if (instant_passed(proc_chan_map.instant)) {
conn_terminated = true;
}
return false;
}
uint8_t new_count = count_ones(proc_chan_map.chan_map,
PDU_CHANNEL_MAP_SIZE);
memcpy(conn_params.chan_map, proc_chan_map.chan_map,
PDU_CHANNEL_MAP_SIZE);
conn_params.chan_count = new_count;
proc_chan_map.pending = false;
return true;
}
注意 hop 和 last_unmapped_chan 不需要改。CSA#1 的递推公式 unmapped = (last_unmapped + hop) % 37 不依赖 Channel Map——Channel Map 只在 remap 阶段起作用(当 unmapped 信道不在 map 中时,用 unmapped % chan_count 选择替代信道)。所以换 Channel Map 只需要换 chan_map 和 chan_count,信道序列的"骨架"不变,变的只是"哪些信道可用"。
七、Slave Latency 的特殊约束
这是本章最容易被忽略的一个规则:
当有 pending LL Procedure 且 Instant 还未到达时,Slave 不得使用 Slave Latency 跳过事件。
为什么?考虑以下场景:
- Slave 在事件 #100 收到
LL_CONNECTION_UPDATE_IND,Instant = 106 - Slave Latency = 4,于是跳过 #101~#104
- 事件 #105 唤醒,
conn_event_counter递推到 105 -
106 再次跳过?不行! 如果跳过了 #106,
check_apply_conn_update()就不会在counter == 106时被调用 - 等到 #107 才唤醒时,
counter = 107,而instant = 106——已经过去,instant_passed()返回 true,连接被断开
即使不完全跳过 Instant 事件,跳过之前的事件也可能导致 WW 累积过大,增加在 Instant 事件上 RX 失败的概率。
我们的实现在 can_skip_event() 中加入了这个约束:
static bool can_skip_event(void)
{
if (lat.latency_used >= lat.latency_max) {
return false;
}
if (data_tx_q.count > 0 || tx_pdu_pending) {
return false;
}
/* ★ 有 pending procedure → 禁止跳过 */
if (proc_conn_update.pending || proc_chan_map.pending) {
return false;
}
return true;
}
新增的最后一个条件是整个函数最重要的修改。只要有任何 pending procedure,Slave 就老老实实每个事件都监听,直到 Instant 到达、参数切换完成。
八、remap_channel() 的提取
在实现 Channel Map Update 的过程中,我们注意到 CSA#1 的 remap 逻辑需要在两个地方使用:
- 每次
chan_sel_1()的常规调用 - Channel Map 切换后的第一次信道选择
因此我们将 remap 部分提取为独立函数:
uint8_t remap_channel(uint8_t unmapped, const uint8_t *chan_map,
uint8_t chan_count)
{
if (chan_map[unmapped >> 3] & (1U << (unmapped & 7))) {
return unmapped; /* 在 map 中,直接使用 */
}
/* 不在 map 中,remap */
uint8_t remap_index = unmapped % chan_count;
uint8_t count = 0;
for (uint8_t i = 0; i < 37; i++) {
if (chan_map[i >> 3] & (1U << (i & 7))) {
if (count == remap_index) {
return i;
}
count++;
}
}
return 0;
}
chan_sel_1() 现在变得非常简洁:
uint8_t chan_sel_1(void)
{
uint8_t unmapped = (last_unmapped_chan + conn_params.hop) % 37;
last_unmapped_chan = unmapped;
return remap_channel(unmapped, conn_params.chan_map,
conn_params.chan_count);
}
这个重构使得 Channel Map 切换变得透明——切换后下一次 chan_sel_1() 自动使用新的 chan_map 和 chan_count,不需要任何额外操作。
九、主循环中的检查时序
在连接态主循环中,Instant 检查的位置至关重要——必须在 conn_event() 之前执行:
while (!conn_terminated) {
/* ★ 先检查 Instant */
if (check_apply_conn_update(...)) {
/* Interval 等参数已更新 */
ww_event_us = 0;
win_size_event_us = anchor.win_size_event_us;
}
if (check_apply_chan_map_update()) {
/* Channel Map 已更新 */
}
/* 然后判断 Slave Latency */
bool skip_this_event = false;
if (lat.latency_max > 0 && can_skip_event()) {
/* ... 跳过逻辑 ... */
}
if (!skip_this_event) {
/* conn_event(): 使用已更新的参数 */
bool rx_ok = conn_event(next_event_rtc, ...);
/* ... */
}
/* 锚点递推 (使用可能已更新的 interval_ticks) */
next_event_rtc = (next_event_rtc + interval_ticks) & mask;
/* ... */
}
如果把 Instant 检查放在 conn_event() 之后,就会在 Instant 到达的那个事件上仍用旧参数收发包,然后才更新——白白浪费一个事件。对 Connection Update 来说影响不大,但对 Channel Map Update 来说,如果 Master 在 Instant 事件已经使用了新 map,而 Slave 还在用旧 map,两端可能选择了不同的信道——一次 RX 失败。
本系列教程同款硬件:👇
芯片: nRF 52832 开发板
工具: nRF 52840 BLE Dongle 蓝牙嗅探器
工具: 逻辑分析仪
工具: BPA low energy 蓝牙分析仪
本文版权归作者:ixbwer所有,转载请注明原文链接:https://www.cnblogs.com/ixbwer/p/19810136,否则保留追究法律责任的权利。

浙公网安备 33010602011771号