从零实现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_IND、LL_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 的根源。

23 字节在很多场景下是不够的:
- OTA 固件升级:每次只传 23 字节,100 KB 的固件需要 4500+ 个包,传输极慢
- 传感器批量上传:一个加速度传感器的 FIFO 通常有 200+ 字节,拆成 10 个 ATT 操作会极大增加协议开销
- 蓝牙 Mesh:Mesh Provisioning 的 PDU 最大 129 字节
解决这个问题的方式是在链路层和 Host 之间加一个 分片与重组(Fragmentation & Reassembly) 层——这就是 L2CAP 的核心职责之一。
二、L2CAP 帧格式

L2CAP(Logical Link Control and Adaptation Protocol)在 BLE 中的角色比经典蓝牙简单得多。对 BLE 而言,L2CAP 的核心功能只有两个:
- 多路复用:通过 Channel ID(CID)区分不同的上层协议——ATT、SMP、L2CAP Signaling 各用各的 CID。
- 分片与重组:把超过 27 字节的上层数据拆成多个 LL Data PDU,接收时再拼回来。
一个 L2CAP Basic Frame(B-Frame)的格式极其简单:

只有 4 字节 header, 所有多字节字段都是小端序(Little Endian)。
BLE 规范预定义了三个 CID:
| CID | 用途 |
|---|---|
| 0x0004 | ATT(Attribute Protocol)——属性读写 |
| 0x0005 | L2CAP Signaling——协商连接参数等 |
| 0x0006 | SMP(Security Manager Protocol)——配对加密 |

举一个具体的例子: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:分片的指挥棒

链路层通过 Data PDU Header 中的 LLID(Logical Link Identifier) 字段来告诉接收方"这个 PDU 在数据流中的位置"。LLID 占 2 bit,有以下取值:
| LLID(二进制) | 含义 |
|---|---|
01 |
Continuation(后续分片),或 Len=0 时为空包 |
10 |
Start(第一个分片,或完整的小包) |
11 |
LL Control |
发送方把一个完整的 L2CAP 帧拆成多个 PDU 时,规则很明确:
- 第一个 PDU:LLID =
10(Start),携带 L2CAP header + 尽可能多的 payload - 后续 PDU:LLID =
01(Continuation),携带剩余 payload,直到发完
接收方的算法也很直观:
- 收到 LLID=
10的 PDU → 新开一个缓冲区,从 PDU payload 的前 2 字节读出 L2CAP Length,计算总长度 - 收到 LLID=
01的 PDU → 追加到当前缓冲区 - 已接收的字节数 ≥ 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 蓝牙分析仪
本文版权归作者:ixbwer所有,转载请注明原文链接:https://www.cnblogs.com/ixbwer/p/19810094,否则保留追究法律责任的权利。

浙公网安备 33010602011771号