4、长数据收发(Long Read/Write):Prepare Write与Execute Write流程、MTU与PDU关系、分片重组机制
长数据收发,说白了就是怎么在蓝牙这条窄路上,把超过一包能装下的数据安全送过去。很多刚接触GATT的同事,一上来就发超过MTU大小的数据,结果发现对端要么收不全,要么直接断开连接。嗯,这里面的门道,我今天跟你好好聊聊。
4.1 MTU与PDU:先搞清楚这两个概念
MTU(Maximum Transmission Unit)和PDU(Protocol Data Unit)是长数据收发的基础。我见过不少开发者把这两个混为一谈,其实它们有本质区别。
| 概念 | 含义 | 典型值 | 谁决定的 |
|---|---|---|---|
| MTU | ATT层一次能发送的最大数据包大小 | 23字节(默认)~ 512字节(协商后) | Client和Server通过MTU Exchange协商 |
| PDU | ATT协议层的协议数据单元,包含操作码+参数 | 取决于MTU,但PDU ≤ MTU | 由ATT协议定义 |
你想想看,MTU是"路宽",PDU是"车"。车不能比路宽,这是基本常识。但很多人忽略了一点:PDU的头部也要占空间。
关键公式:
有效数据长度 = MTU - ATT头部长度
对于Write Request/Response:头部占3字节(操作码1字节 + 句柄2字节)
对于Prepare Write:头部占5字节(操作码1字节 + 句柄2字节 + 偏移量2字节)
举个例子,默认MTU=23时,一次Write Request最多只能发20字节的有效数据。我早期做项目时,就因为这个算错了,导致数据总是发不完整,排查了半天才发现是头部开销没算进去。
4.2 Prepare Write与Execute Write:分步提交的智慧
当你要发送的数据超过单次MTU能承载的范围时,就需要用到Prepare Write + Execute Write这套机制。这就像你去银行存一大笔现金,柜员不会一次性全收进去,而是先清点一部分,确认无误,再清点下一部分,最后统一入账。
4.2.1 流程拆解
- Prepare Write请求:Client发送数据片段,包含句柄、偏移量和数据内容。Server收到后暂存在缓冲区,不写入数据库。
- Server响应:Server回复Prepare Write Response,确认收到该片段。
- 重复步骤1-2:直到所有数据片段发送完毕。
- Execute Write请求:Client发送Execute Write,携带标志位(0x00=取消,0x01=立即写入)。
- Server执行:Server将所有暂存的数据按偏移量拼接,写入数据库,然后回复Execute Write Response。
我的经验:Prepare Write的偏移量必须连续且不重叠。我曾经遇到过一个Bug,因为上层应用传下来的偏移量计算有误,导致Server端拼接出来的数据全是乱码。后来我加了个校验:每次收到Prepare Write时,检查当前偏移量是否等于已接收数据的总长度。
4.2.2 代码示例:Prepare Write处理逻辑
// 伪代码:Server端处理Prepare Write
void onPrepareWrite(uint16_t handle, uint16_t offset, uint8_t* data, uint16_t len) {
// 检查偏移量是否合法
if (offset != current_prepare_len) {
sendErrorResponse(handle, ATT_ERROR_INVALID_OFFSET);
return;
}
// 检查总长度是否超过缓冲区上限
if (offset + len > MAX_PREPARE_BUFFER_SIZE) {
sendErrorResponse(handle, ATT_ERROR_INSUFFICIENT_RESOURCES);
return;
}
// 暂存数据
memcpy(prepare_buffer + offset, data, len);
current_prepare_len += len;
// 回复确认
sendPrepareWriteResponse(handle, offset, data, len);
}
// 伪代码:Server端处理Execute Write
void onExecuteWrite(uint8_t flags) {
if (flags == 0x01) {
// 写入数据库
writeToDatabase(attribute_handle, prepare_buffer, current_prepare_len);
}
// 清空缓冲区
memset(prepare_buffer, 0, MAX_PREPARE_BUFFER_SIZE);
current_prepare_len = 0;
sendExecuteWriteResponse();
}
4.3 分片重组机制:数据怎么拆,怎么拼
分片重组是长数据收发的核心。你发一个1KB的数据,MTU只有247,那就要拆成好几片发出去。对端收到后再按顺序拼回来。
4.3.1 分片策略
- 按MTU切分:每片大小 = MTU - ATT头部长度。对于Prepare Write,头部5字节,所以每片有效数据 = MTU - 5。
- 偏移量标记:每片数据都携带偏移量,告诉Server这段数据应该放在最终数据的哪个位置。
- 顺序发送:虽然蓝牙底层有重传机制,但建议按顺序发送分片,避免对端处理混乱。
4.3.2 重组逻辑
Server端收到所有分片后,按偏移量排序拼接。这里有个坑:分片可能乱序到达。虽然L2CAP层保证同一通道内的数据包按序交付,但ATT层如果同时处理多个Prepare Write请求,偏移量小的分片可能后到。
注意:不要假设分片按顺序到达。我见过一个案例,某厂商的蓝牙芯片在重传时会改变分片顺序,导致Server端拼接出错。解决方案是在重组前先按偏移量排序,再拼接。
4.4 长数据收发的完整流程
下面这张图展示了从MTU协商到Execute Write的完整流程。我习惯用这种图来跟团队沟通,一目了然。
4.5 常见问题与避坑指南
4.5.1 MTU协商失败
有些设备不支持MTU Exchange,或者协商后实际MTU比请求的小。我建议你在代码里加个超时机制:如果3秒内没收到MTU Response,就按默认MTU=23处理。
4.5.2 Prepare Write缓冲区溢出
Server端的缓冲区是有限的。如果Client一次性发太多分片,Server可能拒绝后续请求。解决办法是Client在发送前先查询Server的缓冲区大小(如果有这个服务的话),或者分批次发送,每批之间加个延迟。
4.5.3 Execute Write超时
如果Client发了Execute Write后长时间没收到Response,可能是Server在处理数据时卡住了。我曾经遇到过Server端数据库写入慢,导致超时。后来我在Server端加了异步写入机制,先回复Response,再慢慢写数据库。
我的习惯:在调试长数据收发时,我会在Client和Server两端都打印日志,记录每个分片的偏移量、长度和序号。这样一旦出问题,对比日志就能快速定位是哪个分片丢了或者顺序错了。
4.6 性能优化建议
- 合理选择MTU:不是越大越好。MTU越大,单次传输的数据越多,但重传代价也越大。我一般建议247或512,具体看你的应用场景。
- 控制分片数量:分片越多,出错的概率越大。如果可能,尽量提高MTU,减少分片数。
- 使用Write Command代替Write Request:如果不需要确认,可以用Write Command(无响应),减少交互次数。但要注意,Write Command不支持Prepare Write机制。
好了,长数据收发这块,核心就是搞清楚MTU和PDU的关系,用好Prepare Write + Execute Write这套机制,再注意分片重组的细节。你在实际项目中遇到什么问题,欢迎随时交流。