1.背景
为什么选 LoRa + 跳频:LoRa 本身靠扩频(SF)+ 前向纠错(CR)获得抗干扰增益,但单频点长时间驻留时,持续的同频/邻频干扰(尤其是同 LoRa 参数的异网设备)仍然能把链路压垮。跳频把"避让"从物理层的被动纠错,提升到信道级的主动脱离——这是本项目要解决的核心问题。
2.系统架构
2.1 硬件基线
- 射频模块: E22-400M22S,主控芯片 Semtech SX1268,SPI 接口
- 频段:410~493 MHz(典型 433/470/490),信道号
0..83,实际频率 =410 + channelMHz - 调制:LoRa(SF7 / BW500kHz / CR 4/5,8 字节载荷空中时间约 7ms)
- 关键控制:
BUSY(任何 SPI 事务前必须等低)、DIO1(中断,映射 TxDone/RxDone/CrcErr/HeaderErr)、NSS/SPI
2.2 角色模型
两端烧完全相同的固件,通过编译期宏 DEVICE_ROLE 区分 ROLE_SENDER(leader)和 ROLE_RECEIVER(follower):
- leader 主导每个收发回合:周期性发 DATA 帧、做跳频决策、主导预通知握手;
- follower 常态 RX,收到 DATA 后回 ACK,跟随 leader 的跳频。
这种"主发-从回"的乒乓模式天然避碰撞——两块板绝不会同时发射。
3. 跳频序列
使用mcu出厂ID作为随机数种子 + Fisher-Yates 洗牌生成确定性序列
/* Numerical Recipes LCG,种子来自 HOP_SEED */
static uint32_t rng_next(void) {
s_rng = s_rng * 1664525u + 1013904223u;
return s_rng;
}
static void build_sequence(uint32_t seed) {
uint8_t i, n = 0;
s_rng = seed;
for (i = 0; i <= HOP_CHANNEL_MAX; i++) {
if (i == HOP_CHANNEL_SETTING) continue; /* 跳过同步频道(独立于序列) */
if (is_blacklisted(i)) continue; /* 跳过黑名单频道 */
s_seq[n++] = i;
}
s_seq_len = n;
/* Fisher-Yates 洗牌,按实际长度 */
for (i = (uint8_t)(s_seq_len - 1); i > 0; i--) {
uint32_t j = rng_next() % (uint32_t)(i + 1);
uint8_t tmp = s_seq[i];
s_seq[i] = s_seq[j];
s_seq[j] = tmp;
}
}关键点:
- LCG 是确定性伪随机:
HOP_SEED相同 → 随机数序列相同 → 洗牌结果相同 → 两端序列表逐字节一致。这是双方能对齐的根本前提,也是最常见的"为什么对不上"的坑——seed 差一位就永远同步不了。 - 洗牌前先排除同步频道和黑名单频道,剩余频道顺序填入再洗牌。这样序列里每个槽位都是"可用频道",跳频时
(current_index + 1) % seq_len直接循环递增即可,不需要在运行时再判断跳过哪些槽。 HOP_SEED是编译期常量(0x74DA26CA),异组网络用不同 seed,序列天然不同,撞频也互不干扰。
3.1 三类频道的分工
| 频道 | 用途 | 是否进序列 |
|---|---|---|
sequence[0] | 日常通讯的"家",开机/恢复后驻留 | 是 |
sequence[1..N-1] | 避让频道,干扰时依次跳到这里 | 是 |
HOP_CHANNEL_SETTING(同步频道) | 失步会合点,独立于序列、不参与洗牌 | 否 |
| 黑名单频道 | 已知干扰/法规禁用频段,永不被选中 | 否 |
同步频道单独拎出来不进序列,是个重要设计:失步时双方都退到这里守候,逻辑简单、不会和日常跳频路径互相干扰。
4. 干扰评估模型:加权累积 + 衰减 + SNR 软预判(全文核心)
4.1 朴素方案为什么不行
最直观的干扰检测是"滑动窗口计数":最近 N ms 内非法包数 ≥ 阈值就跳。但它有两个问题:
- 窗口边界抖动:干扰恰好在窗口边界,计数忽上忽下,容易在阈值附近反复触发/取消。
- 无法区分"偶发噪声"和"持续干扰":一个雷击、一次微波炉启动,都可能让窗口瞬间冲到阈值,导致误跳。
本项目换成了加权累积 + 衰减的连续模型,把"干扰压力"建模成一个有惯性的标量 s_interference,而不是一个会清零的窗口计数。
4.2 三档更新模型
每发生一次事件,压力按规则更新:
| 事件 | 压力变化 | 代码 |
|---|---|---|
| 硬失败(CRC 错 / 包头错 / ACK 超时 / 异组非命中帧) | +FAIL_STEP (+5) | hop_on_interference() |
| 合法包且 SNR ≥ FLOOR(0dB) | -OK_DECAY (-1,地板 0) | hop_on_valid_packet() |
| 合法包但 SNR < FLOOR(边际链路) | +SOFT_STEP (+2) | hop_on_valid_packet() |
压力 ≥ THRESH(20) | → 触发跳频 | hop_poll() |
void hop_on_interference(void) {
if (s_interference < HOP_INTERF_CAP) {
uint16_t v = (uint16_t)s_interference + HOP_INTERF_FAIL_STEP; /* +5 */
s_interference = (v > HOP_INTERF_CAP) ? HOP_INTERF_CAP : (uint8_t)v;
}
}
void hop_on_valid_packet(int8_t snr) {
s_last_valid_ticks = HAL_GetTick();
if (snr < (int8_t)HOP_SNR_SOFT_FLOOR_DB) {
/* 边际链路:温和预警 +2 */
s_interference = MIN(s_interference + HOP_INTERF_SOFT_STEP, HOP_INTERF_CAP);
} else {
/* 健康链路:衰减 -1(地板 0) */
s_interference = (s_interference >= HOP_INTERF_OK_DECAY)
? (s_interference - HOP_INTERF_OK_DECAY) : 0;
}
}HOP_INTERF_CAP(60)是饱和上限,防止极端情况下压力溢出 uint8。
4.3 数学边界:丢包率到多少才会触发跳频
这套模型最妙的地方在于它有一个解析的临界丢包率。设丢包率为 p(硬失败比例),合法包比例为 1-p(先不考虑 SNR 分档)。每个包带来的期望压力变化:
ΔE = p · FAIL_STEP − (1−p) · OK_DECAY压力要"净累积"(ΔE > 0)才会往阈值爬,解出临界丢包率:
p · FAIL > (1−p) · DECAY
p · (FAIL + DECAY) > DECAY
p > DECAY / (FAIL + DECAY)代入当前参数 FAIL_STEP=5、OK_DECAY=1:
p_crit = 1 / (5+1) ≈ 16.7%结论:丢包率低于 ~17% 时,压力永远不会净累积到阈值,系统绝不误跳。 这正是工业遥控器要的——偶发背景噪声、零星丢包被健康包的衰减对冲掉,只有持续性干扰(丢包率 > 17%)才会被判定为需要避让。
4.4 SNR 软预判:不等硬丢包就提前预警
光靠 CRC 错判定干扰有个滞后:链路已经恶化(SNR 跌到负值)但还没开始丢包时,系统感知不到。SNR 软预判解决这个——合法包但 SNR 低于 HOP_SNR_SOFT_FLOOR_DB(0dB)视为边际链路,+SOFT_STEP(+2)温和加压。
同样可以推出纯低 SNR(不丢包)场景下的临界边际率:
ΔE = q · SOFT_STEP − (1−q) · OK_DECAY > 0
q > DECAY / (SOFT + DECAY) = 1 / (2+1) ≈ 33%即:纯靠 SNR 软预判,要超过约 1/3 的包处于边际 SNR,压力才会净累积。零星几个负 SNR 包会被正 SNR 包的衰减对冲掉,不会误跳;只有链路整体恶化时才慢慢爬到阈值——这是"快跳"和"不误跳"之间的一个温和缓冲。
4.5 为什么这个模型比滑动窗口好
- 有惯性、无边界抖动:压力是连续标量,不受"窗口恰好翻页"影响。
- 临界可解析:丢包率/SNR 边界能算出来,调参时心里有数,而不是靠拍脑袋。
- 天然抗偶发:阈值以下的小波动被衰减机制抹平。
- 零额外空中开销:不像 CAD/RSSI 主动探测要额外占用信道,完全复用解码失败的副产物。
5. 跳频握手:为什么"发完即切"是错的
决定跳频后,怎么让 leader 和 follower 同时切到新频道而不失步,是最容易出 bug 的环节。
5.1 错误做法:发完预通知立即切
直觉做法:leader 算出 next_index,在下一条心跳里填 next_index 发出(预通知),发完立刻切到新频道。
问题在于无线链路不可靠:预通知包可能丢。如果 leader 切了、follower 没收到预通知还停在旧频道,双方立刻失步,而且谁都不知道对方在哪——这比不跳还糟。
5.2 正确做法:预通知 → ACK 确认 → 才落地
本项目的握手分三步:
leader: 干扰达阈值 → 置 hop_pending,算 next_index
│
旧频道 ─► 发"预通知"DATA,HOP_INDEX 字段填 next_index ★必须在旧频道发
│ (follower 此刻还在旧频道听,才能收到)
│ 发完【不切】,继续等 ACK
│
follower 收到预通知 → 对齐 current_index=next_index
│ 仍在【旧频道】回 ACK(因为它还没切)
│ 下一个周期再切到新频道守候
│
leader 收到 ACK → 调 hop_on_ack_received() → apply_pending_hop()
│ 【现在才真正切到新频道】
│
新频道 ─► 双方恢复乒乓收发关键代码:
/* 仅返回"本条心跳是否为预通知"。不再发完即切——切频道延迟到收到 ACK 确认 */
bool hop_notify_heartbeat_sent(void) {
return s_hop_pending;
}
/* ACK 确认 与 超时强跳 共用此函数 */
static void apply_pending_hop(void) {
s_current_index = s_next_index;
s_hop_pending = false;
s_channel_changed_pending = true;
s_interference = 0; /* 新频道从 0 重新评估 */
s_peer_interference = 0;
}注意 hop_notify_heartbeat_sent() 里有个容易被忽略但很重要的细节:它不刷新 s_last_valid_ticks。也就是说,leader 发出预通知后如果一直收不到 ACK,它的掉线计时不会因为"自己发了包"而重置——只有真正收到 ACK 才算"在线"。这保证了 leader 在握手失败时也会按掉线超时去同步频道赴约,不会因为一直在发预通知而自我感觉良好。
5.3 ACK 超时怎么办:重试,用尽则强跳跟随
预通知在途但连续收不到 ACK 时:
void hop_on_ack_timeout(void) {
if (s_hop_pending) {
if (++s_hop_retry >= HOP_HOP_MAX_RETRIES) {
/* 连续 N 次预通知都收不到 ACK:follower 大概率已收到某一条并跳走(只是它的 ACK 丢了)。
* leader 直接跟跳到 next_index——命中则下条心跳立即恢复;
* 万一 follower 没跳(小概率 ≈ p^N),双方各自掉线超时后到同步频道赴约。 */
apply_pending_hop();
} else {
/* 重发预通知 */
}
} else {
hop_on_interference(); /* 普通心跳 ACK 超时 = 一次干扰 */
}
}这里有个关键的失步概率分析。为什么用尽重试后选择"强跳跟随 follower"而不是"放弃、留在旧频道"?
- 若 follower 已跳走(预通知收到了,只是 ACK 丢):leader 强跳到
next_index→ 命中,下一条心跳立即恢复。✅ - 若 follower 没跳(预通知也丢了):概率 ≈
p^N(N 次预通知全丢)。此时 leader 跳了、follower 没跳 → 失步,但双方都会在掉线超时后到同步频道重新会合。
反过来,如果此时选择"留在旧频道",而 follower 其实已经跳走 → leader 单方滞留旧频道,follower 在新频道等不到人,失步且无人发现,恢复要等掉线超时,反而更慢、更糟。
所以"强跳跟随"在两种子情况下都不比留旧频道更差,在大概率子情况下明显更好——这是个经过权衡的决策,值得在博客里讲清楚。
5.4 follower 侧对齐的小细节
follower 收到 DATA 时,如果携带的 index 和自己当前不一致,就对齐并切频道,且不对这条预通知回 ACK 之外的特殊处理:
bool hop_set_peer_index(uint8_t idx) {
if (s_state == HOP_STATE_SYNC_WAIT) {
return false; /* SYNC_WAIT 期间对端的 index 是失联前的陈旧值,无意义,忽略 */
}
if (idx < s_seq_len && idx != s_current_index) {
s_current_index = idx;
s_channel_changed_pending = true;
s_interference = 0;
return true;
}
return false;
}SYNC_WAIT 期间忽略对端 index 是个防呆:失联期间对端心跳里夹带的 index 是它掉线前的休眠值,跟着对齐反而会错。这时只认 hop_on_valid_packet() 完成的重同步。
6. 失步恢复:一个双向兜底、绝不死锁的状态机
无论握手多周密,极端情况(双方同时判定、重启、长时强干扰)仍可能失步。失步恢复要做到:自动重新会合 + 任何状态都有退路。
6.1 状态机:NORMAL ↔ SYNC_WAIT
收到对方有效包(resync)
┌────────────────────────────────┐
▼ │
┌──────────┐ 掉线超时(5s 无合法包) ┌──────────┐
│ NORMAL │ ────────────────────────► │ SYNC_WAIT│
│ seq[idx] │ │ 同步频道 │
└──────────┘ ◄──────────────────────── └──────────┘
▲ 收到对方包→resync 回 NORMAL │
│ │ 驻留超时(8s 等不到对方)
└──────────────回 seq[0] 重试─────────┘核心逻辑在 hop_poll():
/* 掉线:NORMAL 下超过 OFFLINE_TIMEOUT 无合法包 → 进 SYNC_WAIT 去同步频道赴约 */
if (s_state == HOP_STATE_NORMAL &&
(now - s_last_valid_ticks) >= HOP_OFFLINE_TIMEOUT_MS) {
s_state = HOP_STATE_SYNC_WAIT;
...
}
/* SYNC_WAIT 驻留超时 → 回 NORMAL(seq[0])重试,防同步频道本身不可用 / 单方等待死锁 */
if (s_state == HOP_STATE_SYNC_WAIT &&
(now - s_sync_enter_ticks) >= HOP_SYNC_WAIT_TIMEOUT_MS) {
s_state = HOP_STATE_NORMAL;
s_current_index = 0;
...
}6.2 为什么不会死锁
这是状态机设计上最要紧的属性,靠两条互为退路的转换保证:
- NORMAL 卡住(链路彻底不通)→ 5 秒掉线超时 → 进 SYNC_WAIT;
- SYNC_WAIT 卡住(同步频道也被干扰、或单方在那干等)→ 8 秒驻留超时 → 回 NORMAL 的
seq[0]重试。
两个状态互为对方的退路,任何一方单独等待都有超时兜底,所以系统永远在 NORMAL ↔ SYNC_WAIT 之间循环重试,活性有保证。HOP_SYNC_WAIT_TIMEOUT_MS(8s)刻意取大于 HOP_OFFLINE_TIMEOUT_MS(5s),确保对端有足够时间到达同步频道。
HOP_OFFLINE_TIMEOUT_MS 取 5 秒(而非心跳周期的 2~3 倍)是有意为之:工业遥控器宁可忍受 5 秒的"完全失联恢复窗口",也不要因为几百毫秒没收到包就频繁进 SYNC_WAIT 来回折腾——这是"快跳快恢复"和"不要瞎折腾"之间的折中。真正的"快"由第 7 节的反向链路快路径负责,掉线恢复只兜最坏情况。
7. 反向链路快路径:区分"偶发抖动"与"确定性故障"
第 4 节的加权模型适合处理"正向链路的渐进性干扰",但有一种故障它反应太慢:反向链路(ACK 方向)持续断。
leader 周期发 DATA,follower 每 3 条回一个保活 ACK。如果 follower 那一侧出了问题(比如 follower 被强干扰压住发不出 ACK),leader 侧表现就是"一直收不到 ACK"。按第 4 节的慢累积,要好几秒才爬到阈值——对工业遥控器太慢。
所以专门开了一条快路径:
/* 反向链路持续断(连续软超时无 ACK):直接拉到阈值,下次 hop_poll 立即触发预通知跳频。
* 与 hop_on_interference 的 +STEP 慢累积不同——持续无响应是确定性链路故障,
* 应快速反应,不必和偶发 CRC 错共用慢累积器(否则要数秒才到阈值)。 */
void hop_on_link_lost(void) {
s_interference = HOP_INTERFERENCE_THRESH;
}配合 app.c 里的两级判定(单次软超时按常规 +5 加压、连续 N 次软超时才走快路径),把"偶发抖动"和"确定性故障"区分开:
if ((now - last_ack_ticks) >= HOP_ACK_LOST_TIMEOUT_MS) { /* 600ms */
last_ack_ticks = now;
if (++ack_lost_seq >= HOP_ACK_LOST_HOP_TRIG) { /* 连续 2 次 */
ack_lost_seq = 0;
hop_on_link_lost(); /* 拉满压力 → 下次 hop_poll 立即预通知跳频 */
} else {
hop_on_interference(); /* 单次:常规 +5 加压(可被后续 ACK 衰减对冲) */
}
}按当前参数 HOP_ACK_LOST_TIMEOUT_MS = 2×100×3 = 600ms、HOP_ACK_LOST_HOP_TRIG = 2,反向链路持续断约 1.2 秒即触发跳频决策——这就是标题里"1.2 秒"的由来。单次 600ms 没收到 ACK(可能只是相位错开)只 +5 加压,后续收到 ACK 就被衰减对冲,不误跳;连续两次(1.2s)才认定是确定性故障,直接快跳。这套两级设计同时拿到了"不误跳"和"故障快恢复"。
8. 协议帧设计
8.1 帧格式与校验
┌──────────┬─────┬─────┬─────┬──────┬──────────┬──────────────┬────────┬
│ 帧头 2B │ net │ dst │ src │ func │ len(大端)│ payload N B │ xor16 │
│ 0xAA 0x55│ 1B │ 1B │ 1B │ 1B │ 2B │ │ 2B │
└──────────┴─────┴─────┴─────┴──────┴──────────┴──────────────┴────────┘- 帧头
0xAA 0x55用于字节对齐; net/dst/src做网络隔离与单播寻址;异组或非本机帧虽能合法解码,但占用信道,计为干扰(开关HOP_COUNT_NON_HIT_AS_INTERF);- 校验用 2 字节异或(偶数位异或到高字节、奇数位异或到低字节),比 CRC 弱但实现极简、对单字节错能检出,够用。
8.2 DATA 帧合并心跳:一次 refactor 的取舍
早期版本有独立的 HEARTBEAT 帧(300ms 一发)和 DATA 帧。后来发现心跳帧其实不承载业务数据,只是个"在线 + 跳频信息"的载体——完全可以和 DATA 合并:
DATA 帧既承载业务数据,又携带跳频信息[hop_index][interf_cnt]。发送周期统一为DATA_TX_INTERVAL_MS(100ms)。
合并后两个直接收益:
- 跳频信息传播更快:从 300ms 缩到 100ms,预通知和干扰回报的响应都快了 3 倍;
- 省一类帧、简化主循环:leader 只发 DATA,follower 只回 ACK,两类帧走同一套载荷结构。
代价是要重新平衡 ACK 节奏(见下)。
8.3 ACK 稀疏化:反向链路省 airtime
leader 每 100ms 发一帧,如果 follower 每帧都回 ACK,反向信道占用太高。所以 ACK 做了稀疏化:
- 预通知 DATA:follower 收到立即回 ACK(确认跳频,不能漏);
- 普通 DATA:follower 每
HOP_ACK_KEEPALIVE_EVERY_N(3)条回一个保活 ACK(携带本机干扰计数)。
这样反向信道占用降到 1/3,同时跳频握手的即时性不受影响。
8.4 异组帧计干扰:物理层网络隔离的补充
LoRa 物理层有 Sync Word 做网络隔离,但同频段、同 LoRa 参数的不同网络(比如别人的 433MHz LoRa 设备)仍会在物理层互相"听到",只是 net 地址不同。把这类"能解码但非本网"的帧计为干扰,让系统能避让同参数的异网干扰源——这是单纯靠 Sync Word 做不到的。