4. BLE协议栈详解(下):L2CAP、SM、ATT

好,我们接着往下聊。上一节我们把HCI和LL层讲透了,这一节要啃的,是协议栈里真正跟「数据怎么用」相关的三个核心模块:L2CAP、SM和ATT。说白了,这三兄弟决定了你的智能牙刷能不能安全地、高效地把数据传给手机。

我个人习惯把BLE协议栈比作一个快递系统。LL层是快递车,负责在路上跑;L2CAP就是分拣中心,把大包裹拆成小件,或者把小件拼成大包;SM是保安,负责验明正身;ATT则是快递员,告诉你包裹里到底装了什么。

4.1 逻辑链路控制与适配协议(L2CAP)

L2CAP,全称Logical Link Control and Adaptation Protocol。名字很长,但功能很纯粹——它负责在BLE的基带之上,提供数据封装和服务复用。

核心功能一:协议复用

你想想看,BLE协议栈里跑的不止一种协议。有ATT、有SM、甚至可能有L2CAP自己的一些信令。这些数据都挤在同一条物理链路上,怎么区分?L2CAP通过一个叫通道ID(CID)的东西来搞定。

  • ATT协议:固定使用CID 0x0004
  • SM协议:固定使用CID 0x0006
  • L2CAP信令:固定使用CID 0x0005

嗯,这里要注意:这些是固定分配的。你在写代码时,不需要自己去创建这些通道,协议栈已经帮你做好了。

核心功能二:分段与重组

BLE的空中包最大只有251字节(实际有效载荷更小)。但你的应用层可能一次要发几百字节的数据,比如牙刷的固件升级包。这时候L2CAP就派上用场了。

它会自动把大包切成小段,每一段加上L2CAP头,然后交给LL层发送。接收端再把这些小段拼回去。整个过程对应用层是透明的。

重要参数:MTU

MTU(最大传输单元)决定了L2CAP层一次能处理的最大数据包大小。BLE默认的MTU是23字节(包含3字节的ATT头,实际有效数据只有20字节)。但通过MTU协商,可以提升到247字节甚至更大。

我在项目中遇到过一个问题:牙刷上报数据时,手机端总是收不全。查了半天,发现是MTU没协商,默认23字节,数据被截断了。后来在连接建立后主动发起MTU请求,问题就解决了。

实战建议:在智能牙刷项目中,建议将MTU协商到至少128字节。这样一次能传100字节以上的数据,足够传输一次完整的刷牙数据包(包含时间戳、区域编码、压力值等)。

4.2 安全管理器(SM)

SM,安全管理器。它的任务就一个:保证你的牙刷数据不会被别人偷听或篡改。

BLE的加密机制分为三个阶段:

  1. 配对(Pairing):建立共享密钥的过程
  2. 绑定(Bonding):把密钥存下来,下次重连不用再配
  3. 加密(Encryption):用密钥对数据进行加解密

配对方式有三种:

方式 说明 适用场景
Just Works 无用户交互,直接配对 智能牙刷(无屏幕)
Passkey Entry 一方显示数字,另一方输入 带显示屏的设备
Numeric Comparison 双方显示数字,用户确认一致 手机与手表等

对于智能牙刷这种没有屏幕、没有键盘的设备,只能用Just Works方式。但别担心,Just Works不等于不加密。它只是省略了用户确认的步骤,密钥交换和加密过程依然是安全的。

避坑指南:我曾经在一个项目中,因为忘记调用绑定存储函数,导致每次断开重连都要重新配对。用户反馈说「每次用牙刷都要重新连接,体验很差」。后来我加上了绑定标志位,在配对完成后调用 bonding_save(),问题就解决了。

记住:配对是一次性的,绑定是永久性的。智能牙刷一定要做绑定,否则每次使用都要重新配对,用户体验极差。

密钥分发

配对完成后,双方会交换一些密钥信息:

  • LTK(长期密钥):用于后续加密通信
  • IRK(身份解析密钥):用于私密地址解析
  • CSRK(连接签名解析密钥):用于数据签名

这些密钥需要安全地存储在非易失性存储器中。我建议存储在Flash的专用区域,并加上CRC校验,防止数据损坏。

4.3 属性协议(ATT)

ATT,属性协议。这是BLE应用层的基础。它定义了一种「客户端-服务器」的数据交互模型。

在BLE中,GATT服务器(通常是智能牙刷)提供数据,GATT客户端(通常是手机App)读取或写入数据。ATT就是它们之间通信的「语言」。

ATT的核心概念:属性

一个属性由三部分组成:

  • 属性句柄:一个16位的索引,用于唯一标识这个属性
  • 属性类型:用UUID表示,比如0x2A19表示电池电量
  • 属性值:实际的数据内容

ATT的操作类型

常用的操作就几种:

  • Read Request/Response:客户端读,服务器回
  • Write Request/Response:客户端写,服务器确认
  • Write Command:客户端写,不需要确认(速度快)
  • Notify/Indicate:服务器主动推数据给客户端

我个人习惯在智能牙刷中用Notify来上报刷牙数据。为什么呢?因为Notify不需要客户端确认,速度快,适合实时性要求高的场景。而Indicate需要客户端回复确认,虽然更可靠,但会降低吞吐量。

实战代码片段:发送Notify

// 假设我们有一个刷牙数据特征值,句柄为0x0030
// 数据缓冲区
uint8_t brushing_data[20] = {0};
// 填充数据:时间戳 + 区域编码 + 压力值
brushing_data[0] = 0x12;  // 时间戳高位
brushing_data[1] = 0x34;  // 时间戳低位
brushing_data[2] = 0x01;  // 区域编码(左上内侧)
brushing_data[3] = 0x50;  // 压力值(80克力)

// 发送Notify
// 注意:发送前要确保CCCD(客户端特征配置描述符)已使能通知
if (cccd_is_enabled(0x0030)) {
    att_notify(0x0030, brushing_data, 4);
}

ATT的PDU格式

每个ATT操作都对应一个PDU(协议数据单元)。结构很简单:

  • 操作码(1字节):告诉对方你要干什么(读、写、通知等)
  • 参数(可变长度):具体的数据内容

比如,一个Read Request的PDU就是:操作码0x0A + 属性句柄2字节。服务器收到后,回复操作码0x0B + 属性值。

嗯,这里有个细节要注意:ATT的PDU最大长度受MTU限制。如果你的MTU是23字节,那么ATT PDU最多只能有23字节,其中3字节是ATT头,剩下20字节是有效数据。这就是为什么我说要协商MTU的原因。

调试技巧:在开发智能牙刷时,我经常用抓包工具(比如Ellisys或nRF Sniffer)来查看ATT层的交互。你可以清楚地看到每一次Read、Write、Notify的PDU内容。有一次我发现手机端收不到Notify,抓包一看,原来是CCCD没有使能。这种问题光看代码很难发现,抓包一抓一个准。

4.4 三个协议的关系

最后,我用一张图来总结这三个协议在协议栈中的位置:

+-------------------+
|    应用层         |  ← 你的刷牙算法、UI逻辑
+-------------------+
|    GATT           |  ← 基于ATT的服务/特征值定义
+-------------------+
|    ATT            |  ← 属性读写、通知
+-------------------+
|    L2CAP          |  ← 数据分段、协议复用
+-------------------+
|    HCI + LL       |  ← 链路控制、物理传输
+-------------------+
|    射频层         |  ← 2.4GHz天线
+-------------------+

数据从应用层下来,先经过GATT封装成属性操作,然后交给ATT打包成PDU,再交给L2CAP加上通道标识和分段,最后通过HCI送到LL层发送出去。接收端则反过来。

你想想看,如果没有L2CAP,ATT的数据怎么知道该走哪个通道?如果没有SM,数据在空口被截获了怎么办?如果没有ATT,手机怎么知道牙刷的电池电量存在哪个地址?这三个协议各司其职,缺一不可。

好了,这一节的内容就到这里。下一节我们会进入GATT层,看看如何用服务和特征值来构建一个完整的智能牙刷数据模型。到时候我会分享一个我实际用过的服务定义模板,直接拿来就能用。