产品要验证 BT 侧的速率协商,用打流工具跑了一轮,每秒吞吐稳定在 83 Kbps 左右。后来查明,其中一大部分不是链路慢,是打流工具自己有病——发送循环里人为加了固定延时、统计把 990 字节的包记成 222 字节、逐包打印吃掉三分之一吞吐。三轮修完,83 → 804 → 1208 Kbps,15 倍。
(这段排障的完整过程在下一篇文章,本文先讲另一件事:那次能顺利定位问题,前提是手里有一个设计得足够干净的打流工具——统计口径可信、角色模型对称、不会自己成为瓶颈。)
这篇文章就把这个打流工具本身拆开讲清楚:为什么要认真设计一个"测试工具"?怎么分层?双向测试的角色怎么建模?它的适用面比看起来宽:任何需要验证 BT 链路吞吐的场景(打印机、音频串流、固件空中升级),这套设计都能直接搬走。
一、先想清楚:打流到底在测什么
写打流工具之前先回答一个问题:它测出来的数字,要用来做什么决策?
我们的答案有三层:
- 验证速率协商是否正常。经典蓝牙 BR/EDR 连接建立后,两端要协商包型与调制方式(1-DH5 / 2-DH5 / 3-DH5,EDR 逐级提速率)。协商是否成功、协商到了哪一级,直接决定链路上限——打流吞吐就是协商结果最直接的体检指标。协商失败或降级,吞吐立刻现形。
- 验证底层调度能力。协议栈内部的缓冲调度、credit 流控、线程切换效率,平时看不出好坏,满速压力下才见真章。
- 压力测试协议栈稳定性。长时间满速打流(数百上千包/秒),是对整个栈最便宜的稳定性测试——内存泄漏、缓冲堆积、异常分支,打半小时比点半天鼠标容易暴露得多。
这三层答案决定了后面的设计基调:工具自己不能成为瓶颈(你测不出比自己更快的链路——83 Kbps 那次教训的根源就是工具自己带了三处病),度量必须不失真(统计错误会让所有优化算错账),角色模型要对称(双向都要能测)。
二、分层:工具放在哪一层
我们的中间件是标准五层:应用层 → API 层 → 公共服务层 → 适配层 → 原厂协议栈。打流工具的代码怎么放,有两个方案:
- 堆进适配层(离协议栈最近的那层)——写起来顺手,但适配层的定位是"只做字节搬运",塞进打流状态机和统计逻辑后,这层就脏了
- 放公共服务层,适配层只留写路径的流控判断——主逻辑(状态机、统计、线程)集中在公共服务层,适配层只保留一个职责:判断"写失败是流控还是错误"(这个判断必须贴着协议栈做,后面讲)
我们选了后者。三个理由:业务集中可测——打流状态机、统计公式都在一个文件里;可复用——BLE 侧也有同款打流工具,workqueue 承载统计、线程自删这些模式直接继承;适配层保持纯粹——它只认识字节和句柄,不认识"打流"这个业务,换一个上层调用方它照样工作。
先看整体分层与打流代码的位置:
分层后的职责一览:
| 层 | 在打流中承担 |
|---|---|
| 应用层 | CLI 命令bt_iperf解析、下发 |
| API 层 | 对外唯一入口bt_iperf_cmd+ 回调注册bt_callback_register,签名稳定 |
| 公共服务层 | 核心:iperf 状态机、统计、1 秒定时、pump 线程、收发分发 |
| 适配层 | SPP 写spp_port_write(含流控判定)、SPP 收数据回调 |
| 协议栈 | RFCOMM/SPP、credit 流控、链路传输 |
两个跨层接口的细节单独说一下:
写路径spp_port_write(struct { connHandle, port, data*, length })——length用uint32_t。这不是随手写的:打流数据包 990 字节,如果哪层接口把长度参数声明窄了(比如uint8_t),990 就会被截断——真踩过这个坑,统计侧因此把吞吐低估了 4.46 倍(详见排障篇)。接口参数的宽度要跟数据包真实上限对齐,这是分层设计里最容易被忽略的一条契约。
收路径:协议栈回调 → 适配层 → 公共服务层入口回调 →转发进 workqueue 线程处理。协议栈回调的上下文不适合做重活(解析、统计、打印),收到即转发,回调本身尽快返回。
三、包设计:一次发多长
打流数据包固定长度,结构就三段(具体字段取值按自家协议约定即可,不展开):
帧头(2B) + 长度(2B) + 数据载荷 → 单包最大 990B990 是怎么定的:它不是蓝牙协议的规定,是本平台协议栈一次写入接口允许的最大长度。定了之后它要满足两个条件:
- 不超过最高速率包型的空口载荷上限。经典蓝牙 EDR 的最高速率包型 3-DH5,单包载荷上限1021 字节——990 < 1021,只要速率协商成功,一包上空口不用分片;协商不到最高速率时,协议栈自动按低级包型分片重组,应用层无感。链路协商到什么级别,吞吐天花板就摆在哪,包长取在最高包型之内,测出来的数字才反映协商结果本身。
- 贴住本平台栈的上限,让测量压着天花板跑。
注意这两条是通用原则,990 这个数是本平台的结果,不是普适值:有的栈一次写入就允许 1021 字节直接顶满 3-DH5,那就取 1021。关键动作是去查你所用栈的写接口最大长度 + 对应包型上限,按实际情况填你自己的"一次发送最大长度",照抄任何文章里的数字都没有意义。
控制命令帧bt_tp_cmd_t很小(8B),管启动/停止和统计上报,数据包管打流,两类帧同走一条 SPP 链路。伪代码示意(字段按自家协议自行定义):
struct bt_tp_cmd_t { /* 控制命令帧,共 8B */ uint16_t head; /* 帧头标识,用于和 990B 数据包区分 */ uint8_t op; /* 1=start 0=stop */ uint8_t mode; /* 打流模式 */ int8_t rssi; /* 统计上报时回填 */ uint16_t avg_tp; /* 平均吞吐,统计上报时回填 */ };四、命令模型与双向角色
bt_iperf <op> <handle> <port> op = 1: start-tx(本端为发送泵) 2: start-rx(本端为接收统计) 0: stop handle = 连接句柄 port = SPP 端口号前置流程:打流前必须先在 BT 连接上建好 SPP 链路——服务用固定服务 UUID 创建(spp_server_create),RFCOMM 通道由栈分配返回(spp_connect),拿到端口号后打流命令才有意义。完整前置链路见开头的流程图:BT 连接 → 配对绑定 → 建服务 → 连接 → 打流。
两端都能当发起端,这是设计里的一个务实决定。双向测试的本意是两个方向都要测:主机发给从机(tx)要测,从机发给主机(rx)也要测——但这两个方向靠主机一端的命令就能跑全:主机下发 start-tx 测主机→从机,下发 start-rx 测从机→主机,不存在"测不到"的问题。那为什么还要让从机也能发起?为的是操作位置可以不挑:
- 有些场景下主机侧串口不方便接 PC 输入命令,测试人员只能守在从机这边操作;
- 主机多连接的场景下(一拖多台从机),在从机上直接发起、就近验证自己那条链路,比去主机上区分下发方便得多。
我们的做法是用一个对称角色模型统一两端:
| 端 | role | 行为 |
|---|---|---|
| 发起端 | CLIENT | 只发 8B 命令帧,不建发送泵;等对端 echo(回执帧,就是"收到了"三个字的一句话答复)回来后才建泵 |
| 对端 | SERVER | 收到 START 帧走 server 分支,起统计定时器 + 每秒上报,并 echo 命令帧 |
echo(回执)这个设计解决了"发起方怎么知道对端准备好了"的问题:命令帧到达 → 对端起好统计 → echo 返回 → 发起方才开始 pump(pump 在这里指"发送泵":一个满速连发包的循环线程,像水泵一样一股劲往外灌数据)。一个 8 字节的握手,不引入任何新协议状态。而"echo 之后才建泵"这个顺序,避免了发起方单方面开跑、对端统计丢前几包的错账。
双向矩阵下谁能跑:
| 场景 | 谁是发送泵 | 谁统计上报 |
|---|---|---|
| 主机发起 | 主机 | 从机 |
| 从机发起 | 从机 | 主机 |
同一个工具、同一套代码,两个方向都能测。顺带一提,配套的bt_ping命令用的也是这套角色模型,机制完全对称——角色模型做对一次,其它工具直接继承。
一个必须说的坑:双向握手的前提是对端以 SERVER 态响应。如果对端残留着上次的 CLIENT role(上次测试没正常终止又没断链),会把你的 START 帧当"继续打流"处理而不 echo,发起方干等。所以角色残留的清理路径必须是完备的:正常终止走 STOP echo 归零,异常残留靠断链回调兜底清——这条链上任何一环缺失,双向测试就会偶发"打不起来"。(role 生命周期的完整表格和 STOP echo ping-pong 的实机教训,见排障篇。)
五、统计:1 秒窗口,软定时器只做轻活
统计窗口 1 秒。每秒输出一条:
[iperf tx]: 0-1 sec avg tp 1167 Kbps, rssi:-21设计点在定时器回调的职责切分。软定时器回调跑在系统的软定时器线程里,这个线程被所有定时器共享——回调里干重活会拖长中断关断窗口、拖住其它定时器的精度。原始版本把"统计平均、读 RSSI、写 TP 上报、打印"全串行塞在回调里,副作用是统计窗口自身漂移(打印慢一拍,sec_time就慢一拍)。
修法是回调瘦身到只剩两件轻量事:
软定时器回调(1s): 提交统计 workqueue + 无条件重武装定时器 ← 立即返回 workqueue 线程: 统计 avg / 读 RSSI / 上报 TP / 打印 ← 耗时活全在这无条件重武装的意思是:哪怕 workqueue 提交失败(未就绪等极端情况),定时器也继续走,最多丢一次统计,绝不中断采样节奏。
统计公式本身简单:avg = 窗口累计字节 × 1000 / 实际耗时(ms),换算 Kbps 时按×8/1000口径。口径不重要,重要的是全链路一个口径——打印的、上报的、平均的如果换算基准不一致,对比就没意义。
六、线程模型
打流跑起来时有这几条执行流:
- pump 线程:满速写 990B 包的发送循环,打流核心
- 上报线程:每秒把 TP 通知帧写给对端
- workqueue:统计、打印的承载
- 软定时器:只做触发器
两个设计约束:
- 线程退出自删。pump 线程是一次性的——停止即废弃,退出前
sys_thread_delete归还自身栈。打流是要反复启停的测试,不自删的话启停几十轮就是几十个泄漏的栈。 - 防重复启动。运行标志拦截重复 start(并发双泵会让计数翻倍)。栈大小、优先级统一宏管理,一处调整全局生效。
七、验收清单
设计完成后,验证过这些条目,作为"这个工具可以拿来测链路了"的门槛:
- 主/从双向打流均正常(CLIENT/SERVER 对称角色)
- 每秒统计周期稳定(软定时器瘦身后窗口不漂移)
- 反复启停无线程泄漏(自删 + 标志拦截)
- STOP 一次成功(先停泵再发 + 有限重试)
- 统计读数与真实包长一致(无截断失真)
工具就长这样。下一篇讲它投入使用后暴露的三个真实瓶颈——发送循环里的固定延时、uint8_t截断 990B、逐包打印吃掉串口 IO——也就是开头那个 83 Kbps 的完整来龙去脉。
系列文章: - 本文(第 1 篇):蓝牙SPP打流工具设计详解:分层架构、双向角色模型与990B包型 - 第 2 篇:蓝牙SPP吞吐从83Kbps到1.2Mbps:三个隐蔽瓶颈的定位与修复实录
相关标签:蓝牙SPPRFCOMMiperf吞吐测试嵌入式