4、多连接架构设计:连接调度算法、时间片分配、冲突避免策略
好,咱们进入正题。多连接架构,说白了就是让一个蓝牙芯片同时伺候好几个连接。听起来简单,做起来全是坑。我最早做多连接项目时,以为就是开几个连接句柄的事,结果一跑起来,丢包、断连、延迟爆炸,全来了。
为什么会这样?因为BLE的物理层只有一个射频通道。同一时刻,只能跟一个设备收发数据。你要同时跟三个设备通信,就得排队。怎么排?排多久?谁先谁后?这就是连接调度要解决的问题。
4.1 连接调度的核心矛盾
每个连接都有自己的连接间隔(Connection Interval)。比如连接A是30ms,连接B是50ms,连接C是100ms。主控芯片要在这些时间点之间来回切换。
我遇到过最头疼的情况:三个连接的连接间隔刚好错开,但每次切换时,射频状态还没稳定,下一个连接的事件就来了。结果就是——谁都没发成。
核心原则:调度算法的目标,就是让所有连接的事件窗口不重叠,或者重叠时能优雅地处理。
4.2 时间片分配策略
时间片分配,说白了就是给每个连接分一段独占的射频时间。我习惯用三种策略,根据场景选:
4.2.1 固定时间片轮转
最简单粗暴。把所有连接排个队,每个连接分固定长度的时间片,轮流来。
// 伪代码示例:固定时间片轮转调度
void scheduler_fixed_round_robin(void) {
for (int i = 0; i < num_connections; i++) {
// 每个连接分 2ms 时间片
uint32_t slot_start = current_time + i * 2;
schedule_connection_event(i, slot_start, 2);
}
}
优点:实现简单,确定性高。
缺点:浪费带宽。如果某个连接没数据要发,时间片就空转了。
我的经验:固定轮转适合连接数少(≤3个)、且每个连接数据量均匀的场景。比如三路传感器数据采集。
4.2.2 动态优先级调度
给每个连接分配一个优先级。高优先级的连接先发,发完了再给低优先级的。
// 伪代码示例:动态优先级调度
void scheduler_dynamic_priority(void) {
// 根据连接的数据量、延迟要求动态计算优先级
for (int i = 0; i < num_connections; i++) {
connections[i].priority = calculate_priority(i);
}
// 按优先级排序
sort_by_priority(connections);
// 依次调度
for (int i = 0; i < num_connections; i++) {
schedule_connection_event(connections[i].id);
}
}
我曾在某个可穿戴设备项目里用过这个策略。心率数据优先级最高,其次是加速度计,最后是环境温度。效果不错,心率延迟从15ms降到了5ms以内。
注意:动态优先级有个陷阱——优先级反转。低优先级连接一直抢不到时间片,导致缓冲区溢出,最终断连。我曾经被这个坑过,后来加了超时保护才解决。
4.2.3 自适应时间片分配
这是我最推荐的做法。根据每个连接的实时数据量,动态调整时间片长度。
// 伪代码示例:自适应时间片分配
void scheduler_adaptive(void) {
uint32_t total_data = 0;
for (int i = 0; i < num_connections; i++) {
total_data += connections[i].tx_pending + connections[i].rx_pending;
}
for (int i = 0; i < num_connections; i++) {
uint32_t data_ratio = (connections[i].tx_pending + connections[i].rx_pending) * 100 / total_data;
uint32_t slot_time = (data_ratio * TOTAL_SLOT_TIME) / 100;
schedule_connection_event(i, slot_time);
}
}
说白了,就是谁的数据多,谁就多占时间。但要注意,不能无限占,得设个上限。我一般设最大时间片不超过连接间隔的80%,留点余量给其他连接。
4.3 冲突避免策略
时间片分好了,但冲突还是会发生。为什么?因为连接事件不是你想什么时候开始就什么时候开始。BLE协议规定,连接事件必须在连接间隔的整数倍时刻开始。
举个例子:连接A的连接间隔是30ms,连接B是50ms。30和50的最小公倍数是150ms。每150ms,两个连接的事件起始时刻就会重合。
4.3.1 偏移量调整法
最简单的方法:给每个连接的事件起始时刻加一个偏移量。
// 连接A:偏移0ms
// 连接B:偏移15ms
// 连接C:偏移25ms
void setup_connection_offsets(void) {
// 连接A:连接间隔30ms,偏移0ms
set_connection_offset(conn_a, 0);
// 连接B:连接间隔50ms,偏移15ms
set_connection_offset(conn_b, 15);
// 连接C:连接间隔100ms,偏移25ms
set_connection_offset(conn_c, 25);
}
这样,即使连接间隔有公倍数,事件也不会完全重叠。我习惯把偏移量设为连接间隔的1/3到1/2之间,效果最好。
小技巧:偏移量不是随便设的。要考虑射频切换时间。我一般留至少150μs的切换间隔,否则射频状态不稳定,数据会丢。
4.3.2 事件窗口压缩法
如果两个连接的事件确实撞上了,那就压缩其中一个的事件窗口。
// 检测到冲突时,压缩低优先级连接的事件窗口
void handle_collision(uint8_t conn_id_high, uint8_t conn_id_low) {
// 高优先级连接保持完整窗口
uint32_t high_window = get_connection_window(conn_id_high);
// 低优先级连接压缩到最小窗口(只发必要数据)
uint32_t low_window = MIN_WINDOW_SIZE;
set_connection_window(conn_id_low, low_window);
}
我曾在某个项目里,用这个方法把冲突率从30%降到了5%以下。但要注意,压缩窗口意味着低优先级连接的数据可能发不完,得在下个周期补发。
4.3.3 延迟容忍调度
有些数据不怕延迟。比如温度传感器,一秒更新一次就够了。这类连接可以故意延迟,避开高峰。
// 延迟容忍调度:把低优先级连接的事件推迟到空闲时段
void lazy_schedule(uint8_t conn_id) {
uint32_t next_free_slot = find_next_free_slot();
if (next_free_slot < MAX_DELAY) {
schedule_connection_event(conn_id, next_free_slot);
} else {
// 超过最大延迟,强制调度
schedule_connection_event(conn_id, current_time);
}
}
嗯,这里要注意:延迟不能无限大。我一般设个最大延迟阈值,超过阈值就强制调度,哪怕冲突也要发。否则数据会过期。
4.4 实战中的避坑指南
说了这么多理论,来点实际的。我在多连接项目里踩过的坑,列出来给大家参考:
- 坑一:射频切换时间被忽略。 我曾经以为切换连接是瞬间完成的,结果发现每次切换需要100-200μs的稳定时间。后来我在调度算法里加了切换开销,问题解决。
- 坑二:连接间隔设得太小。 三个连接都设10ms间隔,结果射频根本忙不过来。我建议连接数每增加一个,连接间隔至少增加10ms。
- 坑三:没考虑广播事件。 多连接场景下,主控还要处理广播。广播事件会打断连接调度。我习惯把广播放在连接事件的间隙里,或者用独立的广播通道。
- 坑四:缓冲区溢出。 低优先级连接一直抢不到时间片,数据积压,最终溢出断连。我后来加了优先级老化机制——等待时间越长的连接,优先级自动提升。
总结一下:多连接调度没有银弹。固定轮转简单但浪费,动态优先级灵活但可能反转,自适应分配效果好但实现复杂。我的建议是:先根据场景选一种,跑起来看数据,再调参数。别想一步到位。
最后说一句:多连接架构设计,说白了就是在有限资源下做权衡。你不可能让所有连接都低延迟、高吞吐。想清楚哪些连接是关键的,哪些可以忍一忍,然后针对性地设计调度策略。这样,你的多连接系统才能稳定跑起来。