11.11. L2CAP 通道

GATT 是一种键/值模型。它提供的操作(读、写、通知、指示)每次只移动一个短小的值,它们能携带的最大单个载荷取决于协商出的 MTU——最多也就几百字节。这对于传感器读数、命令寄存器和状态标志很合适,但在面对数 KB 或数 MB 数据时就行不通了:把一大块数据拆成数百次小写入所付出的往返代价,远比无线电本身慢得多。

对于批量数据流——摄像头流式发送给手机的捕获帧、空中升级镜像、批量导出的测量数据——BLE 提供了另一条路径:逻辑链路控制与适配协议(Logical Link Control and Adaptation Protocol),即 L2CAP。L2CAP 位于链路层和 GATT 之间,让应用可以在同一条无线电链路之上申请自己的面向连接的通道。该通道是一条采用信用流控的字节路径,每个数据包的 MTU 大得多,并且中间没有 GATT 帧封装。

11.11.1. 何时使用 L2CAP

在以下情况下,L2CAP 通道是合适的工具:

  • 传输的数据超过几百字节。

  • 两端都知道将要使用某个 L2CAP 通道(它不会在广播载荷中公开;客户端必须通过带外方式获知该通道的协议/服务多路复用器,即 PSM 编号)。

  • 应用愿意放弃 GATT 提供的便利:没有按 UUID 内置寻址、无法通过标准应用进行客户端发现、没有通知。

在基于 aioble 的应用中,最常见的情形是在两段都了解 PSM 约定的软件之间移动二进制数据块——自定义的摄像头到手机协议、一对相互通信的 openmv 摄像头、外设 GATT 服务下的内部固件升级路径。

其他所有情况都应继续使用 GATT。短小的状态、控制寄存器、传感器读数——这些都应该放在特征中。

11.11.2. 建立通道

L2CAP 运行在一个既有的 aioble.DeviceConnection 之上,因此 GAP 侧的发现 / 广播 / 连接流程与 GATT 完全相同。一旦双方都持有连接,一侧在某个 PSM 上监听,另一侧连接过去。

PSM 只是一个小整数。Bluetooth SIG 将范围的低端保留用于标准化用途(0x0001-0x007F);对于应用专用的通道,请使用动态范围内的编号(固定 PSM 用 0x0080-0x00FF0x0040 及以上通常可自由用于自定义用途)。双方必须事先就该值达成一致。

L2CAP 通道上的 MTU 是任一侧在一次 send() 中所能交付的最大单个 SDU(服务数据单元,Service Data Unit)——不是 BLE 链路的 MTU。Aioble 会自动对更大的载荷进行分片。摄像头的 BLE 主机将 L2CAP MTU 上限设为 1017 字节;512 是一个合理的默认值,它在双方都留有余量的同时不会耗费过多 RAM。

在监听端(例如作为外设的摄像头):

async def serve_l2cap(connection, image_bytes):
    channel = await connection.l2cap_accept(psm=0x80, mtu=512)
    async with channel:
        # image_bytes is a bytearray -- e.g. csi0.snapshot().bytearray()
        # or a compressed JPEG buffer. send() fragments into MTU-sized
        # chunks automatically and awaits flow-control credits between.
        await channel.send(image_bytes)
        await channel.flush()

在连接端(例如手机或中心):

async def open_l2cap(connection, total_bytes):
    channel = await connection.l2cap_connect(psm=0x80, mtu=512)
    async with channel:
        image_bytes = bytearray(total_bytes)
        view = memoryview(image_bytes)
        received = 0
        while received < total_bytes:
            n = await channel.recvinto(view[received:])
            if n == 0:
                break
            received += n
        return image_bytes

l2cap_accept() 会阻塞,直到对端连接(或 timeout_ms 触发);l2cap_connect() 会阻塞,直到监听端接受(或失败)。两者都返回一个 aioble.L2CAPChannel——它本身是一个异步上下文管理器,在退出时关闭通道。

11.11.3. 发送和接收

通道上的两个主要操作是 send()(向对端写入字节)和 recvinto()(读入预分配的缓冲区)。两者都是协程。

  • send() 会把缓冲区分片成 MTU 大小的块,并在块之间等待链路层的流控信用。从应用的角度看,一次长发送只是一个 await;在内部它可能会排入许多数据包,并在对端的接收信用耗尽时暂停。

  • recvinto() 会用任何可用的数据(最多到通道的 MTU)填充传入的缓冲区,并返回字节数。如果没有可用数据则会等待。

  • available() 在有缓冲数据就绪时同步返回 True——便于在不挂起的情况下进行轮询。

  • flush() 会等待,直到任何未完成的发送都已完全传输给控制器。

L2CAP 通道在某种意义上类似流:字节按序到达且不丢失,但单次 send 的边界会被保留——每个 SDU 都从单次 recvinto 中取出。这与 TCP 不同,在 TCP 中一次 send() 的边界可能跨越多次 recv() 调用而被抹平。

11.11.4. 断开处理

通道会在三种情况下消失:任一侧调用 disconnect()、底层的 GAP 连接断开,或者 L2CAP 层的断开到来。活动中的操作会抛出 aioble.L2CAPDisconnectedError。与 GATT 侧一样,这会在正在等待的协程中以异常的形式浮现出来,而 async with channel 代码块会干净地退出。

如果通道因 GAP 层断开而变得不可达,应用会像处理 GATT 断开那样循环回到广播或扫描。

11.11.5. 内存开销

更大的 MTU 和更长的队列会在双方占用更多 RAM。512 字节的 MTU 加上每通道一个接收缓冲区,每个通道大约占 1 KB——如果同时打开多个通道,对于一台小型摄像头来说这并非毫无代价。请坚持每个连接只用一个通道,并选择与预期消息大小相匹配的 MTU;对大多数应用而言,每个 DeviceConnection 默认一个 L2CAPChannel 就够用了。

L2CAP 是 BLE 的安全阀。GATT 几乎是每个应用首先选用的方案,本节其余的中心 / 外设示例也都坚持使用 GATT。当应用超出了键/值模型的范畴时,通道风格的 API 就是答案。