从零实现BLE协议栈(6-2)数据分片与重组

数据分片与重组

前提知识

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

  • 理解 LL Data PDU 的 Header 结构(第 6-1 篇有详细讲解):你至少需要知道 Data PDU 的 Header 包含 LLID、SN、NESN、MD、Length 这几个字段,以及 LLID 如何区分控制包和数据包。
  • 理解 SN/NESN 的 1-bit ARQ 机制(第 6-1 篇有详细讲解):本文不重复推导 ARQ 流程,但发送队列的 SN/NESN 填充逻辑直接依赖它。如果你对"SN 翻转 = ACK 确认"的含义不清楚,需要先回顾第 6-1 篇。
  • 理解连接事件的基本时序(第 4-2 篇有详细讲解):一个连接事件内,Master 先发、Slave 后回,每次只发一个 PDU。TX 队列中的分片是逐事件发送的,而非一口气灌出去。

不需要了解 L2CAP 的信令通道(Signaling Channel)或基于信用的流控模式(LE Credit Based Flow Control)。本文只关注最基础的 B-Frame(Basic L2CAP Frame)。

一、为什么链路层 PDU 不够用

在前面几章里,我们建立的连接已经能收发 LL Control PDU——比如 LL_VERSION_INDLL_FEATURE_REQ——这些控制包的 payload 通常不到 10 字节,一个 LL Data PDU 装得下。

但当 Host 需要向对端发送应用数据时,问题来了。一次 ATT Write Request 可能携带 244 字节的 payload,一次 OTA 升级包甚至更长。而 BLE 4.0/4.1 规定 LL Data PDU 的最大 payload 只有 27 字节

*注:Header 的 Length 字段在 BLE 4.0 中只有 5 bit,最大值 31;减去可选的 4 字节 MIC,留给 payload 的空间就是 27 字节。BLE 4.2 引入了 Data Length Extension(DLE),把 Length 字段扩大到 8 bit,单包最大 251 字节——但在本文中我们先按最保守的 27 字节实现。

27 字节连一个正经的 ATT 读写请求都可能装不下。如果不做分片,上层只能把每次传输的数据硬限制在 27 - 4(L2CAP header)= 23 字节以内——这正是 BLE 4.0 时代臭名昭著的 ATT_MTU = 23 的根源。
image

23 字节在很多场景下是不够的:

  • OTA 固件升级:每次只传 23 字节,100 KB 的固件需要 4500+ 个包,传输极慢
  • 传感器批量上传:一个加速度传感器的 FIFO 通常有 200+ 字节,拆成 10 个 ATT 操作会极大增加协议开销
  • 蓝牙 Mesh:Mesh Provisioning 的 PDU 最大 129 字节

解决这个问题的方式是在链路层和 Host 之间加一个 分片与重组(Fragmentation & Reassembly) 层——这就是 L2CAP 的核心职责之一。


二、L2CAP 帧格式

image

L2CAP(Logical Link Control and Adaptation Protocol)在 BLE 中的角色比经典蓝牙简单得多。对 BLE 而言,L2CAP 的核心功能只有两个:

  1. 多路复用:通过 Channel ID(CID)区分不同的上层协议——ATT、SMP、L2CAP Signaling 各用各的 CID。
  2. 分片与重组:把超过 27 字节的上层数据拆成多个 LL Data PDU,接收时再拼回来。

一个 L2CAP Basic Frame(B-Frame)的格式极其简单:
image

只有 4 字节 header, 所有多字节字段都是小端序(Little Endian)
BLE 规范预定义了三个 CID:

CID 用途
0x0004 ATT(Attribute Protocol)——属性读写
0x0005 L2CAP Signaling——协商连接参数等
0x0006 SMP(Security Manager Protocol)——配对加密

image

举一个具体的例子:Host 要通过 ATT 发送 20 字节的数据。L2CAP 帧如下:

字节偏移  值               说明
[0]      0x14             Length Low = 20
[1]      0x00             Length High = 0
[2]      0x04             CID Low = ATT
[3]      0x00             CID High = 0
[4..23]  <20 bytes data>  ATT payload

总共 24 字节(4 header + 20 payload)。24 < 27,一个 LL Data PDU 就装得下,无需分片。

但如果 ATT payload 是 50 字节呢?L2CAP 帧总长 54 字节,一个 PDU 装不下——必须分片。

三、LLID:分片的指挥棒

image

链路层通过 Data PDU Header 中的 LLID(Logical Link Identifier) 字段来告诉接收方"这个 PDU 在数据流中的位置"。LLID 占 2 bit,有以下取值:

LLID(二进制) 含义
01 Continuation(后续分片),或 Len=0 时为空包
10 Start(第一个分片,或完整的小包)
11 LL Control

发送方把一个完整的 L2CAP 帧拆成多个 PDU 时,规则很明确:

  1. 第一个 PDU:LLID = 10(Start),携带 L2CAP header + 尽可能多的 payload
  2. 后续 PDU:LLID = 01(Continuation),携带剩余 payload,直到发完

接收方的算法也很直观:

  1. 收到 LLID=10 的 PDU → 新开一个缓冲区,从 PDU payload 的前 2 字节读出 L2CAP Length,计算总长度
  2. 收到 LLID=01 的 PDU → 追加到当前缓冲区
  3. 已接收的字节数 ≥ L2CAP Length + 4(header)→ 重组完成,交付上层

来看一个分片的完整例子。假设 L2CAP 帧总长 54 字节(4 header + 50 payload),每个 PDU 最大 27 字节:

PDU #1 (LLID=10, len=27):
  [L2CAP Length=50, CID=0x0004] [前 23 字节 payload]

PDU #2 (LLID=01, len=27):
  [接下来 27 字节 payload]

所以 54 字节的 L2CAP 帧恰好需要 2 个满包就能发完。

如果是 55 字节的帧呢?

  • PDU #1:27 字节(4 header + 23 data)
  • PDU #2:27 字节(27 data)→ 已发 50
  • PDU #3:1 字节(最后 1 byte data)

三个分片,最后一个 PDU 只有 1 字节 payload。

四、实现:重组状态机

重组的核心是一个简单的缓冲区加状态机。我们在 ble_common.h 中定义了重组缓冲区结构:

struct l2cap_reassembly {
    uint8_t  buf[L2CAP_MAX_PAYLOAD + L2CAP_HDR_SIZE]; /* 516 字节 */
    uint16_t expected_len;  /* L2CAP Length + 4 (header) */
    uint16_t received_len;  /* 已接收字节数 */
    bool     in_progress;   /* 正在重组 */
};

为什么 buf 的大小是 L2CAP_MAX_PAYLOAD + L2CAP_HDR_SIZE(516 字节)?因为 L2CAP Length 字段是 16 bit,理论最大值 65535;但 BLE 规范建议实现者把最大 SDU 限制在合理范围内,我们取 512 + 4 = 516 字节作为上限。在实际的低功耗设备上,512 字节已经能覆盖绝大多数使用场景。

重组函数 l2cap_rx_fragment() 的完整实现:

bool l2cap_rx_fragment(uint8_t llid, const uint8_t *data, uint8_t len)
{
    if (llid == PDU_DATA_LLID_DATA_START) {
        /* 新 L2CAP 帧的第一个分片 */
        if (len < L2CAP_HDR_SIZE) {
            l2cap_reasm.in_progress = false;
            return false;
        }

        uint16_t l2cap_len = sys_get_le16(data);
        uint16_t total = l2cap_len + L2CAP_HDR_SIZE;

        if (total > sizeof(l2cap_reasm.buf)) {
            l2cap_reasm.in_progress = false;
            return false;
        }

        l2cap_reasm.expected_len = total;
        l2cap_reasm.received_len = 0;
        l2cap_reasm.in_progress = true;

        memcpy(l2cap_reasm.buf, data, len);
        l2cap_reasm.received_len = len;

        if (l2cap_reasm.received_len >= l2cap_reasm.expected_len) {
            l2cap_reasm.in_progress = false;
            return true;  /* 单包即完整 */
        }
        return false;
    }

    if (llid == PDU_DATA_LLID_DATA_CONTINUE) {
        if (!l2cap_reasm.in_progress) {
            return false;  /* 没有匹配的 Start — 丢弃 */
        }

        uint16_t remaining = l2cap_reasm.expected_len -
                             l2cap_reasm.received_len;
        uint16_t copy_len = (len < remaining) ? len : remaining;

        memcpy(l2cap_reasm.buf + l2cap_reasm.received_len,
               data, copy_len);
        l2cap_reasm.received_len += copy_len;

        if (l2cap_reasm.received_len >= l2cap_reasm.expected_len) {
            l2cap_reasm.in_progress = false;
            return true;  /* 重组完成 */
        }
        return false;
    }

    return false;
}

这段代码有几个值得注意的设计决策:

为什么收到 Start 时要检查 len < L2CAP_HDR_SIZE 因为第一个分片必须至少容纳 L2CAP 的 4 字节 header(Length + CID),否则我们连"这个 L2CAP 帧一共有多少字节"都无法知道。如果 Master 发来一个 Start PDU 只有 2 字节 payload,那要么是 Master 有 bug,要么是空口数据损坏——无论哪种情况都应该丢弃。

为什么收到 Continuation 时要检查 !in_progress 正常流程中,Continuation 一定跟在一个 Start 之后。如果收到 Continuation 时没有正在进行的重组,说明某个 Start 分片丢失了。缺了开头的 L2CAP header,后续数据全是无用的碎片,唯一合理的做法是等下一个 Start 到来重新开始。

为什么用 min(len, remaining) 而不是直接 memcpy(len) 防御性设计:如果 Master 发来的 Continuation 数据量超过预期的剩余长度,可能导致缓冲区溢出。取较小值可以保证写入不会越界。

五、实现:分片发送队列

发送方向的逻辑是重组的反向操作:把一个完整的 L2CAP 帧切成多个 ≤ 27 字节的 PDU 分片,放入一个环形队列,由连接事件调度器每次取一个分片发送。

先看队列的数据结构:

#define TX_QUEUE_SIZE  4

struct tx_frag {
    uint8_t  data[LL_DATA_MTU_DEFAULT]; /* 27 字节 */
    uint8_t  len;
    uint8_t  llid;  /* Start 或 Continuation */
};

struct tx_queue {
    struct tx_frag frags[TX_QUEUE_SIZE];
    uint8_t head;
    uint8_t tail;
    uint8_t count;
};

TX_QUEUE_SIZE = 4 意味着队列最多容纳 4 个分片。这是一个有意的限制:分片队列越大,占用的 RAM 越多(4 × 27 = 108 字节),而且过长的发送队列会让 MD 位持续为 1,导致连接事件无限延长(第 6-1 篇讲过这个问题)。4 个分片 × 27 字节 = 108 字节,足以覆盖一次常规的 ATT 操作。

分片入队函数:

int l2cap_tx_enqueue(const uint8_t *buf, uint16_t total_len)
{
    uint16_t offset = 0;
    int frag_count = 0;
    bool first = true;

    while (offset < total_len) {
        if (data_tx_q.count >= TX_QUEUE_SIZE) {
            return 0;  /* 队列满 */
        }

        struct tx_frag *f = &data_tx_q.frags[data_tx_q.tail];
        uint16_t chunk = total_len - offset;
        if (chunk > LL_DATA_MTU_DEFAULT) {
            chunk = LL_DATA_MTU_DEFAULT;
        }

        memcpy(f->data, buf + offset, chunk);
        f->len = (uint8_t)chunk;
        f->llid = first ? PDU_DATA_LLID_DATA_START
                        : PDU_DATA_LLID_DATA_CONTINUE;

        data_tx_q.tail = (data_tx_q.tail + 1) % TX_QUEUE_SIZE;
        data_tx_q.count++;

        offset += chunk;
        frag_count++;
        first = false;
    }
    return frag_count;
}

注意 first 标志:第一个分片的 LLID 为 Start(10),后续为 Continuation(01)。这和我们在第三节介绍的规则完全对应。

还有一个关键设计:如果队列空间不足以装下所有分片,函数返回 0,一个分片都不入队。这是一个原子性保证——要么整个 L2CAP 帧的所有分片全部入队,要么一个都不入队,避免发送方只发出半截数据。

出队函数更加简单:

bool l2cap_tx_dequeue(struct pdu_data *pdu)
{
    if (data_tx_q.count == 0) {
        return false;
    }

    struct tx_frag *f = &data_tx_q.frags[data_tx_q.head];

    pdu->ll_id = f->llid;
    pdu->nesn  = rx_nesn;
    pdu->sn    = tx_sn;
    pdu->md    = (data_tx_q.count > 1) ? 1 : 0;
    pdu->len   = f->len;
    memcpy(pdu->lldata, f->data, f->len);

    data_tx_q.head = (data_tx_q.head + 1) % TX_QUEUE_SIZE;
    data_tx_q.count--;
    return true;
}

注意 md 位的设置:如果队列里还有其他分片(count > 1),就设 MD = 1,告诉 Master "我还有数据,别关射频"。这样 Master 会在同一个连接事件内继续发空包给我们,让我们有机会在下一个 T_IFS 周期内发出下一个分片,而不必等到下一个连接事件。


六、连接事件中的 TX 优先级

有了 L2CAP 分片队列后,连接事件中的 TX 侧需要决定"这次发什么"。我们实现了一个三级优先级策略:

/* 准备 TX 包 */
if (tx_pdu_pending) {
    /* 1. 最高优先: LL Control 回复 */
    tx_pdu_buf.nesn = rx_nesn;
    tx_pdu_buf.sn   = tx_sn;
    radio_pkt_tx_set(&tx_pdu_buf);
} else if (l2cap_tx_dequeue(&tx_pdu_buf)) {
    /* 2. 次优先: L2CAP 数据分片 */
    tx_pdu_buf.nesn = rx_nesn;
    tx_pdu_buf.sn   = tx_sn;
    tx_pdu_pending = true;
    radio_pkt_tx_set(&tx_pdu_buf);
} else {
    /* 3. 最低: 空包 (ACK only) */
    build_empty_pdu(&tx_pdu_buf);
    radio_pkt_tx_set(&tx_pdu_buf);
}

LL Control 回复为什么必须优先? 因为 BLE Core Spec 对 LL Control Procedure 有严格的超时限制(Procedure Response Timeout = 40 秒)。如果 Master 发来 LL_VERSION_IND,Slave 必须在下一个可用的连接事件内回复 LL_VERSION_IND,否则 Master 可能认为 Slave 不支持该 Procedure。如果数据分片排在 LL Control 前面,一个长 L2CAP 帧可能要好几个连接事件才能发完,期间 LL Control 回复被饿死。

空包不是浪费吗? 不是。即使 Slave 没有数据要发,也必须在收到 Master 的包之后回复一个 PDU——哪怕是 len=0 的空包。这个空包携带了 NESN(ACK)和 SN 信息,是 ARQ 协议运转的必要组成部分。不发空包等于不回 ACK,Master 会一直重传。


本系列教程同款硬件:👇
芯片: nRF 52832 开发板
工具: nRF 52840 BLE Dongle 蓝牙嗅探器
工具: 逻辑分析仪
工具: BPA low energy 蓝牙分析仪

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