从零实现BLE协议栈(8-1)LL Procedures:Instant 机制与参数热更新

LL Procedures:Instant 机制与参数热更新

前提知识

阅读本文需要具备以下基础:

  • 理解 LL Control PDU 的处理流程(第 6-1 篇有详细讲解):本文新增的两种 PDU(LL_CONNECTION_UPDATE_INDLL_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_mapchan_count,理解算法的运作方式才能明白为什么换 Channel Map 不需要换 hoplast_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 - connEventCounter \in [1, 32767] \]

也就是说,Instant 必须在当前事件之后、不超过半个 uint16_t 范围之后的某个未来事件。如果 Slave 收到一个 Instant 已经过去的 PDU(instant - counter > 0x7FFF,即负数的无符号表示),说明 Master 或传输出了问题——这是协议错误,Slave 应断开连接。

为什么是 32767 而不是 65535?因为 16-bit 计数器会回绕。当 counter = 65530instant = 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 收到后:

  1. 不回复——这个 PDU 是 Master 单向下发的,Slave 不需要发送任何响应 PDU
  2. 把新参数和 Instant 存入 proc_conn_update 结构体,标记 pending = true
  3. 继续用旧参数运行,直到 connEventCounter == instant
  4. 在 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),说明 instantcounter 之前——已经过去了。特别排除 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_ticksinterval_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_mapchan_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;
}

注意 hoplast_unmapped_chan 不需要改。CSA#1 的递推公式 unmapped = (last_unmapped + hop) % 37 不依赖 Channel Map——Channel Map 只在 remap 阶段起作用(当 unmapped 信道不在 map 中时,用 unmapped % chan_count 选择替代信道)。所以换 Channel Map 只需要换 chan_mapchan_count,信道序列的"骨架"不变,变的只是"哪些信道可用"。


七、Slave Latency 的特殊约束

这是本章最容易被忽略的一个规则:

当有 pending LL Procedure 且 Instant 还未到达时,Slave 不得使用 Slave Latency 跳过事件。

为什么?考虑以下场景:

  1. Slave 在事件 #100 收到 LL_CONNECTION_UPDATE_IND,Instant = 106
  2. Slave Latency = 4,于是跳过 #101~#104
  3. 事件 #105 唤醒,conn_event_counter 递推到 105
  4. 106 再次跳过?不行! 如果跳过了 #106,check_apply_conn_update() 就不会在 counter == 106 时被调用

  5. 等到 #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 逻辑需要在两个地方使用:

  1. 每次 chan_sel_1() 的常规调用
  2. 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_mapchan_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 蓝牙分析仪

posted @ 2026-04-02 10:19  ixbwer  阅读(17)  评论(0)    收藏  举报