☰
蓝牙L2CAP发送调度深度解析:从l2c_link_check_send_pkts看吞吐量问题
2026/10/22 11:30:38 网站建设 项目流程

搞嵌入式这几年的朋友应该都有同感:查蓝牙协议栈的问题,十有八九最后都栽在“数据明明发出去了,但对方就是没收到”或者“链路没断,但吞吐量突然掉到零”这类诡异现象上。上个月我在一个穿戴设备项目的吞吐压测里又遇到了这种情况,设备连接正常、信号正常,但大流量传输每隔几秒就卡一次,抓包转储一看,问题最终指向L2CAP层的发送调度入口,也就是l2c_link_check_send_pkts这个函数。

这篇文章就围绕这个函数展开。我会从它所在的协议栈位置、触发链路、函数体主干逻辑一路讲到我在实际调试中踩过的坑和调优经验。内容主要基于Android开源蓝牙协议栈的L2CAP实现,源码路径大致在stack/l2cap目录下,核心文件是l2c_link.c。如果你经常跟L2CAP的队列、ERTM、重传、拥塞这些概念打交道,或者正在排查蓝牙吞吐上不去的玄学问题,那这篇文章应该能给你省下不少时间。

1. L2CAP发送这条路径,为什么偏偏是它为“闸门”

1.1 在追函数之前,先把链路和信道的关系捋清楚

蓝牙协议栈里,L2CAP位于HCI之上、GATT/RFCOMM/SMP之下,它的核心工作是做逻辑信道的复用与分用。这里有两个很容易搞混的控制块:tL2C_LCB代表一条ACL链路(Link),tL2C_CCB代表链路之上的信道(Channel)。一条物理ACL链路上可以挂多个逻辑信道,比如同时跑着RFCOMM、SMP、GATT。

理解l2c_link_check_send_pkts的第一步,是搞清它的操作对象。这个函数的第一个入参是tL2C_LCB *p_lcb,它管的是“某一条ACL链路上所有信道的发送”。它不会去管具体某个socket或者profile,它只关心这条链路的状态、队列和控制器发送窗口。

我说得直白点:上层应用觉得“我把数据写进socket就行”,但协议栈内部不是这么干的。数据进到L2CAP之后,会按信道拆分、排队、分片,然后等到底层HCI反馈“控制器能收包了”,才由这个函数带动整个发送流程往前走。没有它,上层缓冲再多,包也到不了空中。

1.2 名字里的“Check Send Pkts”:它不是发送函数,是名额分配器

很多人第一次看到l2c_link_check_send_pkts,会想当然认为它就是“把数据包发出去”的函数。实际上这个函数几乎不做真正的HCI发送,它做的是检查资格、搬运队列、分发配额。

我习惯把它类比成地铁站入口的安检闸机:上游(上层协议和应用)把乘客(数据包)塞进候车区(xmit队列),但能不能放进站台,要看闸机什么时候放行。l2c_link_check_send_pkts就是那个按下放行按钮的控制逻辑,不管具体检票动作,只负责判断“现在能不能放、放几个、先放哪批人”。

放行资格主要看三件事:链路状态是否已连接、控制器发送窗口是否还有余量、当前信道队列是否有pending包。三者同时满足,数据包才会真正被移交到HCI层。

1.3 连接状态机与放行资格:LST_CONNECTING和LST_CONNECTED的区别

这个函数第一个会检查的东西就是link_state。在协议栈里,LCB可能处于多种状态,但对发送调度有意义的只有两个:

  • LST_CONNECTING:链路正在建立中,此时不能发业务数据,但是可以处理一些协商信令。
  • LST_CONNECTED:链路已建立,所有正常的数据收发都走这里。

我的理解是:这个函数在LST_CONNECTING状态下也会被调进来,但它的处理策略是“只唤醒、不发包”,等到真正连上了再以LST_CONNECTED身份完成全部调度。这就像闸机虽然通电了,但卷帘门还没完全升起来,安检员不会放人进去。

在实际调试时,如果你发现链路一直停在LST_CONNECTING好几百毫秒,那通常不是发送函数的问题,而是底层建链流程卡住了。认清这一点,能避免在错误的方向上浪费大量时间。

2. 谁会在什么时机敲它的门:事件驱动的调用全景

这个函数不是被主循环周期调用的,它完全靠事件驱动。这是理解它行为的关键,也是很多人困惑的源头:明明队列里有数据,为什么不发?因为它没有被“正确的事件”唤醒。谁会在哪些时机调用它,我整理成了下面这张表。

触发场景调用链大致走向说明
链路建链完成底层连接确认事件 → L2CAP CSM状态机处理 → 调用本函数链路从LST_CONNECTING进入LST_CONNECTED后的首次放行
HCI回执Number of Completed PacketsHCI事件回调 →l2c_link_process_num_of_completed_pkts→ 本函数控制器侧缓冲释放,本端“发送窗口”变大
上层数据入队profile通过L2CAP入队接口 → 进入CCB队列 → 调用本函数新业务数据到达,需要尽快排进发送队列
L2CAP信令收到/需重传信令处理函数或定时器回调 → 本函数重传或控制帧需要优先处理
断链或异常超时状态机处理相关事件 → 本函数通常只做收尾,让挂起队列进入清理流程

这张表说明了一个重要逻辑:触发点全是“链路有资格发送更多数据”的事件。HCI层的发送完成回执到来,意味着控制器里的缓冲空出来了,这时候协议栈有义务去hold队列里再捞几个包出来补位。上层数据入队,意味着有新的数据要被纳入调度。链路刚连接成功,意味着从建链期排队等着的包终于可以上路了。

2.1 建链完成后的第一次放行

链路建立完成后,CSM状态机会产生一个连接确认事件,处理完状态切换后会顺着代码逻辑调一次l2c_link_check_send_pkts。这次调用的意义在于:有些信道在建链阶段已经把数据压进了xmit_hold_q(等待发送队列),如果没人触发,这些包就会一直挂在内存里。

我排查过一个延迟抖动问题,现象是每次重连成功后第一个大包要等300ms才能发出去,后来发现就是不完整的触发逻辑导致hold队列没有被及时“唤醒”。这个细节不太容易从协议文档里看出来,但会在实际抓包时露出马脚。

2.2 对端“Completed”事件:窗口的续命开关

经典蓝牙里,Host发送ACL数据包到控制器后,控制器会在处理完一定数量的包之后,通过HCI_Number_of_Completed_Packets事件告诉Host:我这边的缓冲空出来了。Host侧收到这个事件后,会更新controller_xmit_window和controller_buffer两个水位变量,然后调用本函数,把等待队列里积压的包继续往控制器送。

这一步可以说是整个发送调度的“心脏”所在。如果这个事件因为固件问题或者HCI层解析问题迟迟不来,你就会看到链路连接正常,但吞吐量持续为零,而且队列长度越积越多。

2.3 上层入队和缓冲区释放的“催单”

除开HCI回执,上层一旦有新数据塞进信道队列,也会触发一次本函数的调用。这相当于在闸机口加塞一个乘客,虽然闸机本身没放行,但控制逻辑要重新评估一下窗口和队列状态,看看能不能趁机发掉几个包。

缓冲区释放也是类似的催单来源。协议栈内部有各种缓冲池,如果全局buffer紧张,某些包的发送会被延后;等到缓冲释放后,相关清理函数会回头调用本函数,把之前因为资源不足而压住的数据重新推上去。

2.4 异常路径上的兜底调用

断链、超时、重传超限这些异常路径也可能会调用它,但通常不是为了继续发数据,而是为了触发队列清空和状态回收。如果你在日志里看到异常路径也进入了本函数,别误以为链路还通着,这时候大概率是CSM状态机在做清理。

3. 函数体主干逐段拆解:状态门、优先级门、队列门

源码读多了你会发现,L2CAP的发送调度其实不是一大坨逻辑,而是一连串的“门禁”。每个门禁检查一项资格,全部通过才放行。l2c_link_check_send_pkts函数体本身不长,但它的骨架非常有代表性。

3.1 第一道门:空指针与链路状态检查

函数第一步一定是判空和状态门:

void l2c_link_check_send_pkts(tL2C_LCB *p_lcb) { if (p_lcb == NULL) return; if (p_lcb->link_state == LST_CONNECTING) { /* 链路还没完全建立,只拾取必要的处理,不发送业务数据 */ } if (p_lcb->link_state == LST_CONNECTED) { if (p_lcb->p_high_prio_ccb != NULL) l2c_link_check_send_high_pkts(p_lcb); l2c_link_check_send_low_pkts(p_lcb); } }

这里有个值得注意的设计:高优先级和低优先级被拆成了两个独立检查函数。这跟蓝牙的实时性要求有关——带内信令、部分固定信道数据需要更及时地抢发,而普通业务数据可以排队。

3.2 高优先级路径:固定信道与带内信令

p_lcb->p_high_prio_ccb指向的是需要高优先级处理的信道控制块。在大多数协议栈实现里,这个路径主要用于处理一些对延迟敏感的控制帧和固定信道数据。它的发送规则相对简单:不怎么看拥塞水位,直接尝试发送。

高优先级路径容易出问题的地方是“不受控制”。如果高优先级信道上频繁有请求-应答类流量,它就会持续抢占低优先级信道的发送机会,造成普通业务饿死。后面我会专门讲这个矛盾。

3.3 低优先级路径:遍历CCB,看hold队列和拥塞标记

主干逻辑在于l2c_link_check_send_low_pkts。这个函数会遍历该LCB下挂着的所有CCB,逐个检查:

  • 该信道xmit_hold_q是否有等待发送的数据;
  • 该信道是否处于拥塞状态;
  • 链路层的控制器发送窗口是否有余量;
  • 该信道是否处于ERTM等需要窗口管理的模式。

只有这些条件全部满足,数据包才会被从hold队列移动到真正的HCI发送入口。这也是为什么队列里有数据不代表会立即发出去——还必须等控制器窗口让路。

3.4 最后的出口:窗口计数与控制器水位

数据包真正离开L2CAP之前,还要过一次控制器水位检查。协议栈通过controller_xmit_window和controller_buffer等变量跟踪控制器侧还有多少缓冲。如果当前窗口已经耗尽,哪怕是高优先级数据也得等。

我画过一张简化的数据流,大致是这样:

上层数据 → 信道队列 → xmit_hold_q → l2c_link_check_send_pkts ↓ 控制器窗口有余量? ↓ HCI ACL数据包 → 控制器

l2c_link_check_send_pkts就夹在“排队”和“发送”之间。它可以被触发很多次,但每次真正送出的包数量受窗口和缓冲限制。

4. 发送调度里的三个隐藏难点

只看主流程的话,这个函数似乎不复杂,但实际使用中真正让人头大的都是细节。

4.1 xmit_hold_q与xmit_ack_q:两个队列的分工

L2CAP的ERTM模式下,每个CCB通常有两个队列:一个叫xmit_hold_q,保存尚未发出的数据帧;一个叫xmit_ack_q,保存已经发出但还在等待对端确认的帧。

两个队列的直接区别在于:hold队列是“排队准备出发”,ack队列是“出发了但没确认到货”。当收到对端ACK时,协议栈会把ack队列里的帧移除,腾出更多窗口空间,然后再次调用发送函数把hold队列中的数据补进来。

调试时最容易忽略的就是ack队列堆积:如果对端迟迟不回ACK,本端窗口会被已发送帧占满,此时hold队列哪怕再短,新数据也进不来。

4.2 ERTM窗口与credit:不是队列有包就能发

很多初次接触L2CAP的开发者会误以为发送是个FIFO流水线,队列头有包就能发。实际上ERTM模式引入了一个发送窗口概念:在收到对端确认之前,已发送但未确认的I帧数量不能超过窗口上限。

窗口大小的作用类似于水管阀门。窗口太小,吞吐上不去;窗口太大,一旦链路丢包,重传量会成倍飙升。这个参数由通道协商阶段确定,但实际操作中很多协议栈允许本地通过配置覆盖。

4.3 拥塞标记的全局影响与恢复路径

L2CAP层有一套自己的拥塞控制机制。当某个信道的hold队列积压到阈值时,协议栈会标记该信道为拥塞,并向上层协议暂停接收新数据。但这种拥塞判定有时会波及全局——如果全局buffer水位过高,整个L2CAP都会收紧发送。

我在调试中见过一种情况:一个信道上堆积了几个超大SDU,导致全局buffer告警,其它正常信道也跟着遭殃。从现象上看像链路层拥塞,实际上只是一个信道没及时处理完。这时候正确的做法是回到上层去限流,而不是盲目增大buffer。

4.4 高优先级抢占饿死普通通道:一个真实矛盾

高优先级路径的本意是保证信令及时性,但它天然会挤压普通数据路径。尤其当固定信道上有高频请求-应答流量时,每来一个事件,高优先级检查都会先抢走发送机会。

我曾经在一个模拟项目中把安全信道的确认机制和大数据通道的ERTM流同时跑,结果普通通道的吞吐量几乎被压成零。这是因为每次l2c_link_check_send_pkts被触发时,高优先级检查都会优先塞入控制帧,低优先级检查只能排后面,而控制帧应答频率又高,导致低优先级永远等不到窗口。

破局思路有两个:一是从源头降低高优先级流量频率,把多次确认合并成一次批量确认;二是在高优先级检查里加一个配额限制,比如每轮最多占用N个发送名额就转交低优先级。

5. 踩坑实录:定位“发送窗口假死”的完整排查链路

下面这段是我实际处理过的一个模拟问题,完整走一遍排查链路,希望能让读者朋友们看到这类问题的真实样貌。

5.1 症状与初判:连接正常,吞吐却周期性归零

当时的场景是一个通过蓝牙承载大量数据的测试项目,现象非常稳定:连接不会断,数据也能传,但每过几秒吞吐量会从几百Kbps瞬间掉到0,持续两三秒再恢复。

我第一个疑点是控制器拥塞或RF环境干扰,但换了屏蔽室环境和多个控制器之后问题依旧。此时我开始怀疑协议栈内部的调度逻辑,而不是无线链路本身。

5.2 抓包与日志对照:btsnoop只看到入口,看不到原因

完整抓了一遍btsnoop,发现一个有趣的现象:问题发生时,HCI层几乎不再有新的ACL Data Packet发出,但也没有断链事件。也就是说,协议栈“自己停手了”。这与无线干扰的表现完全不同——干扰时通常是重传暴增,而不是彻底停发。

随后我在协议栈日志里加了几行临时打印,重点观察controller_xmit_window、l2cb.controller_buffer和各信道的hold队列长度。很快发现,问题发生时controller_xmit_window长期维持在很小的值,而hold队列在持续积压。这基本锁定了问题出在“控制器窗口没有恢复”上。

5.3 根因:回执事件与本地窗口计数错位

继续追HCI_Number_of_Completed_Packets事件,发现事件本身是有的,但数量异常:控制器每次上报完成的包数远小于实际已经发包数,本端的窗口计数逐渐被“吃掉”,最终退化为0,于是发送调度被彻底卡住。

这属于典型的事件处理错位问题。有可能是控制器固件上报口径与协议栈预期的“按包计数”不一致,也可能是本端在特定配置下没有正确累计回执。修复方式是调整控制器侧的缓冲上报策略,并在协议栈侧对窗口下限做钳制,保证窗口计数不会因为个别异常事件退化成0。

5.4 修复与验证:三处改动让吞吐恢复稳定

改动集中在三块:

  1. 控制器侧的上报策略调整,确保已发送缓冲能更准确地回收;
  2. 协议栈侧增加窗口下限保护,窗口计数低于1时按1处理,避免调度彻底卡死;
  3. 高优先级路径增加配额限制,防止固定信道数据饿死普通通道。

改完之后重新跑压测,吞吐曲线恢复正常,周期性归零现象消失。这个案例说明了一个核心观点:发送卡死未必是新数据来没来的问题,而是“窗口回执”断供的问题。遇到类似情况,优先检查控制器窗口是如何恢复的,比死磕队列逻辑更效率。

6. 调试与调优:把发送调度当信号灯用

6.1 上线前必须观察的三个指标

如果你正在做蓝牙相关的性能调优,我建议在协议栈日志里至少盯住三个指标:

  • xmit_hold_q长度:反映等待发送的积压情况;
  • xmit_ack_q长度:反映已发送但未确认的数据量;
  • controller_xmit_window和controller_buffer:反映控制器侧的缓冲余量。

这三个指标对应“排队”“在途”“可发”三个环节。只要其中一个长期高水位,发送调度的瓶颈就一目了然。

6.2 三组权衡参数:窗口、缓冲阈值、优先级

调优时要记住三组参数:

参数方向调小的效果调大的效果
ERTM发送窗口重传风暴风险小,但吞吐受限吞吐更高,但窗口耗尽后ACK迟延影响大
缓冲水位阈值更早触发拥塞,保护全局容忍突发流量,但可能挤占公共资源
高优先级配额普通数据更公平固定信道更及时

实际项目里没有“一套参数走天下”的做法。我的习惯是先跑一轮基线压测,再针对性地调一组参数,每次只改一个变量,避免互相干扰。

6.3 移植与版本差异注意点

不同版本的开源协议栈,以及不同厂商的vendor patch,都会在L2CAP调度细节上存在差异。有些版本会在本函数前面增加额外的ACL优先级判断,有些版本把高优先级检查合并进了主流程。

跨版本移植时建议把函数体内所有link_state分支列出来对比,确认有没有额外状态参与调度判断。尤其是从老版本升级到新版本时,函数的调用时机可能已经变了,不能只看函数名一样就默认行为等价。

另外,不同控制器对Number of Completed Packets事件的上报口径也可能不一致。有的按HCI包计数,有的按L2CAP SDU计数,有的会受固件节能策略影响而延迟上报。遇到吞吐莫名拉胯的问题,先把控制器事件上报行为摸清楚,比直接投更多buffer进去更有效。

我在实际项目里还有一个习惯:遇到这类“连接没断但应用层感觉卡顿”的问题,不会急着调参数,而是先抓一轮带HCI事件对齐的完整日志,把链路状态、队列长度和窗口计数放在同一时间轴上比对。这个习惯帮我省掉了大量无头苍蝇式的排查时间。如果你也在和L2CAP的吞吐、重传、窗口打交道,希望这篇文章能让你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询