用 UDP 做一条“可靠“的控制通道

文章目录

  • [用 UDP 做一条"可靠"的控制通道](#用 UDP 做一条"可靠"的控制通道)
    • 一、先说结论
    • [二、UDP 什么都不保证](#二、UDP 什么都不保证)
    • 三、六种机制
      • [3.1 TTL:让陈旧指令自己失效](#3.1 TTL:让陈旧指令自己失效)
      • [3.2 序号 + 防重放:拒绝重复和伪造](#3.2 序号 + 防重放:拒绝重复和伪造)
      • [3.3 MAC:拒绝篡改](#3.3 MAC:拒绝篡改)
      • [3.4 双向心跳 + 超时:判断"对方还在吗"](#3.4 双向心跳 + 超时:判断"对方还在吗")
      • [3.5 租约:判断"谁有资格控制"](#3.5 租约:判断"谁有资格控制")
      • [3.6 请求-应答 + 重发:让离散命令有结果](#3.6 请求-应答 + 重发:让离散命令有结果)
    • 四、三层状态机
      • [4.1 连接状态](#4.1 连接状态)
      • [4.2 授权状态](#4.2 授权状态)
      • [4.3 恢复流程](#4.3 恢复流程)
    • 五、四条工程纪律
      • [纪律 1:所有在途操作必须有出口](#纪律 1:所有在途操作必须有出口)
      • [纪律 2:超时用时间戳,不数次数](#纪律 2:超时用时间戳,不数次数)
      • [纪律 3:状态变化才通知](#纪律 3:状态变化才通知)
      • [纪律 4:区分"状态"和"事实"](#纪律 4:区分"状态"和"事实")
    • [六、为什么不用 TCP](#六、为什么不用 TCP)
    • 七、这套东西的成本
    • 八、小结

用 UDP 做一条"可靠"的控制通道

一、先说结论

用 UDP 做控制通道,目标不是"每一帧都不丢",而是:

任何一次操作,最终都必须有一个确定的结果------成功、被拒、或者超时失败。

这两件事看起来像,其实完全不同。TCP 追求"不丢",UDP 控制通道追求"不留悬案"。

因为对实时控制来说,一个迟到的成功,等于失败。TCP 的重传会把 200ms 前的指令补送到对端,而那时现场情况已经变了。

所以行业里的做法是:用 UDP,然后用应用层把"可靠性"重新定义成安全性和确定性。


二、UDP 什么都不保证

保证 UDP
送达 ❌
顺序 ❌
不重复 ❌
完整(不篡改) ❌
对端在线 ❌
对端是本人 ❌

每一条都会在控制场景里变成具体的事故:

  • 丢了 → 指令没执行,但你不知道
  • 乱序 → 旧指令后到,覆盖了新指令
  • 重复 → 同一条指令执行两次
  • 篡改 → 收到伪造指令
  • 对端不在 → 你还在发,以为在控

下面六种机制,一一对上这六个问题。


三、六种机制

3.1 TTL:让陈旧指令自己失效

每帧指令带一个"有效期"(比如 200ms)。对端收到后只在有效期内执行,过期就忽略。

解决乱序:晚到 3 秒的旧指令不再有效,不会覆盖当前值。

同时带来一个副作用(也是好处) :发送方必须持续发。你不发,对端就自动进入安全状态。"不发电=停"比"发停止帧=停"更可靠------因为后者也可能丢。

3.2 序号 + 防重放:拒绝重复和伪造

每帧带一个单调递增的序号。对端记录"见过的最大序号",小于等于它的一律丢弃。

解决重复和重放攻击。

如果两端都维护会话序号,还要处理"发送方重启后序号归零"------通常用会话密钥重新协商来解决。

3.3 MAC:拒绝篡改

每帧附一个完整性标签(MAC),用共享密钥计算。

解决篡改。 没有密钥的人可以伪造包,但对端一验就丢。

这也意味着:协议本身不需要 CRC。CRC 只能防传输误码,防不了恶意构造;MAC 两者都能防。很多新协议因此把 CRC 去掉了。

3.4 双向心跳 + 超时:判断"对方还在吗"

两个方向都要有:

  • 设备 → 上位机:上位机知道设备活着,可以显示"已连接"
  • 上位机 → 设备:设备知道上位机活着,否则进入失效保护

只有单向心跳是不够的。 典型的坑是:上位机崩溃了,设备还在等指令;或者设备重启了,上位机还以为连着。

3.5 租约:判断"谁有资格控制"

如果系统可能被多个来源控制(多台上位机、机上自动驾驶、遥控器),光有"连上了"不够,还要知道"现在谁说了算"。

租约是一个限期授权:

复制代码
上位机:我要控制权,租 1000ms
设备:  批准(1000ms 内有效)
上位机:每 500ms 续租一次
设备:  你没续 → 到期收回 → 拒绝你的控制帧

为什么用租约而不是"登录/登出" :因为登出消息会丢。上位机崩溃时不可能发登出,租约到期自动失效是唯一不依赖对端配合的回收方式。

3.6 请求-应答 + 重发:让离散命令有结果

连续指令(速度、位置)可以靠 TTL 自愈。但离散命令(上电、切换模式、返航)不能丢------丢一次就是没执行。

做法:

复制代码
发送 → 带 request_id → 等应答 → 收到或超时重发(同一个 id)→ 超过次数判失败

三个要点:

  1. request_id 必须沿用,不能每次重发都换新号,否则迟到的应答对不上
  2. 过期应答要能识别:id 不匹配就丢,不能当成新命令的应答
  3. 超时必须报失败,不能静默------操作员点了按钮,必须看到结果

四、三层状态机

把上面这些机制组织起来,需要一个状态机(通常不止一个)。

4.1 连接状态

复制代码
正在连接 ──收到数据──→ 已连接 ──超时──→ 中断
    │                                    │
    └────超时────→ 中断                   └──收到数据──→ 已连接

注意最后一条:中断能自动恢复。因为中断只是"界面上的判断",socket 并没有真的断开------数据一回来就该恢复。

实现上的关键就一行:

cpp 复制代码
if (state != Live) {        // 而不是 if (state == Connecting)
    state = Live;
}

写 != 就顺带支持了重连,写 == 就只支持首次连接。 一个符号的差别。

4.2 授权状态

复制代码
不可用 ──心跳就绪──→ 空闲 ──有操作──→ 申请中 ──批准──→ 已授权
                        ↑                    │            │
                        └──冷却+松杆──────── 被拒 ←────────┘
                                               租约到期

"被拒后必须松杆 + 冷却" 这条限制很重要。否则操作员一直推着杆,系统就会无限重发申请,既刷爆网络,又可能去抢别人的控制权。

4.3 恢复流程

设备重启后,上一次的远程控制状态可能还留在设备端。所以新连接第一件事是"复位"------先把设备拉回中立状态,再申请控制权。

这个"先复位再接管"的流程,是很多人第一次做远程控制会漏掉的。


五、四条工程纪律

机制是死的,纪律是活的。这四条决定了实现是"看起来对"还是"真的对"。

纪律 1:所有在途操作必须有出口

一个请求发出去,最终只有四种结局:

复制代码
接受 / 拒绝 / 本地超时 / 会话复位

每一种都要通知上层。

最容易漏的是后两种。代码里常见的写法:

cpp 复制代码
if (timeout) {
    // 清掉状态,继续
}

状态清了,但 UI 上那个"启动中..."永远不消失。操作员只能重连。

纪律 2:超时用时间戳,不数次数

cpp 复制代码
// ✅
if (now - lastSent >= 800ms) { resend(); lastSent = now; }

// ❌
if (++counter % 40 == 0) { resend(); }

定时器从来不准。40 次 × 21ms = 840ms,误差会累积。而且某次定时器被拖了 100ms,计数器完全不知道。

用时间戳的好处:抖动不累积,卡顿能自动追上。

纪律 3:状态变化才通知

50Hz 的节拍器每轮都可能"检查"到同一件事。如果每次都通知 UI,界面就废了。

cpp 复制代码
if (phase == lastPhase) return;
lastPhase = phase;
notify(phase);

纪律 4:区分"状态"和"事实"

本地状态可能滞后于真实世界。

例:本地认为"已授权",但设备那边租约已经过期了。

所以释放资源时,看的是"我可能还持有"这个事实标志,而不是"我现在是不是已授权"状态:

cpp 复制代码
if (leaseOutstanding) {      // 事实:我可能还持有
    sendRelease();
    leaseOutstanding = false;
}

否则会出现"设备端租约还没过期,本地已经复位成未授权,于是不发释放帧,设备要空等一个租期"。


六、为什么不用 TCP

维度 TCP UDP + 应用层
丢包 无限重传,延迟不可控 丢弃,等下一帧
队头阻塞 有一个包丢了,后面的都等 无
连接状态 内核维护,断线感知慢 自己控制,心跳判活快
多播/广播 不支持 支持
迟到的数据 照常送达 可以直接丢
实现复杂度 低 高(这是唯一劣势)

核心问题:TCP 的可靠性语义是"最终送达",而控制需要的是"及时有效"。这两个目标在丢包时直接冲突------TCP 选择补送,控制需要放弃。


七、这套东西的成本

诚实地说,用 UDP 做控制通道,成本主要在状态管理上:

  • 三到四个状态机
  • 二十多个"在途/待确认/待重发"标志
  • 每个都要处理超时、复位、被取代、被拒绝

一个成熟的实现通常在 1000~2000 行左右,而且边界条件比主流程长。

判断值不值得:如果丢一帧的后果是"设备继续按旧指令运行",那就值得。 如果只是"界面数字跳一下",用 TCP 更省事。


八、小结

用 UDP 保证"稳定",不是让它不丢包,而是让它:

  1. 丢包时安全 ------ TTL 让旧指令失效
  2. 有结果 ------ 请求-应答 + 超时失败
  3. 能判活 ------ 双向心跳
  4. 不打架 ------ 租约
  5. 不被利用 ------ 序号 + MAC
  6. 人出事也能停 ------ 输入超时失效保护

一句话:不是"保证送达",是"保证不留悬案"。

相关推荐
十五年专注C++开发1 小时前
FlatBuffers: 一个高效的零拷贝二进制序列化库
c++·序列化·反序列化·flatbuffers
跨境数据猎手1 小时前
HTTP 响应体编码与压缩:识别、解码
网络协议·http·ios
xys_6782 小时前
深挖C++:C++入门基础
开发语言·c++
高冷小鱼殿下2 小时前
C 语言指针与输入输出
c++
路溪非溪3 小时前
C++标准库之智能指针
linux·开发语言·c++
|_⊙3 小时前
传输层协议UDP和TCP
网络·tcp/ip·udp
hetao17338373 小时前
2026-11-02 hetao1733837 的刷题记录
c++·算法
Tairitsu_H3 小时前
[C++高阶数据结构·图论下] 最短路径:为什么“挑最小的”一定正确?
数据结构·c++·图论
未力忘4 小时前
S7-200 SMART Modbus TCP 通信实践
网络·网络协议·tcp/ip