☰
自研AGV调度通信协议:从帧设计到加密容错的三周实战复盘
2026/10/21 10:15:59 网站建设 项目流程

M公司那套AGV调度系统,老协议是上位机和车体控制器之间点对点的私有报文,靠串口和无线数传电台跑。结果M公司后来换了几代产品,协议没变,但文档越给越少,到最后连引脚定义都开始模糊。设备量翻倍之后,原来的轮询式一问一答彻底扛不住了,一帧指令下去得等几百毫秒才轮到下一台车,高峰期整条产线的AGV都在排队。这事说白了就一个字:憋屈。手里攥着整套上位机的源码,却被一层越来越不透明的协议卡着脖子,连加个心跳查询都得求对方排期。

所以最后我们做了一个决定:自己搞一套AGV通信协议,用C#从头写调度端,车体端联合控制器的固件工程师一起改。标题里写的"3周搞定",不是效率多高,而是产线那边只给了三周窗口期,第四周新车型就要进场联调,拖一天产线多停一天。时间紧、现场吵、设备杂,这套协议的每一行代码都是被真实工况逼出来的。

这篇文章就是把这三周的沟沟坎坎整体复盘一遍,从AGV调度帧怎么设计、粘包怎么处理、CRC和加密怎么做,到上线前压测踩到的坑。适合正在做或者准备做设备通信协议的工控上位机开发者看,也适合那些被供应商协议黑盒子坑过、想掌握主动权的人读。我会把当时测试记录里的一些数据也放出来,方便你做对比参考。

1. 为什么放着现成的Modbus、CANopen不用,非要自己定义调度帧

先说结论:不是标准协议不行,是AGV调度这个场景和标准协议的适用场景不完全匹配,硬套会很难受。

Modbus RTU和TCP在业内覆盖面极广,很多变频器、传感器、PLC都直接支持,上手也快。但它本质上是一个"寄存器读写"模型,适合周期性采集数据和单点控制。AGV调度不是这么回事。一台AGV在高位运行时需要接收路径序列,路径可能包含几十个拐点、速度区间、动作标志,Modbus读写保持寄存器的方式会把一条完整路径拆成几十次读写,中间任何一次超时、顺序错乱,车就停在一个谁也没想到的位置。CANopen的PDO/SDO模型更擅长设备状态和参数,但对"一段连续动作脚本"这种偏数据包的任务,表达能力仍需大量额外约定。

更关键的是加密和容错。新车型的调度要求是轨迹数据不能明文传输,防的是竞争对手在场内截包后逆推出产线布局。标准协议在应用层对加密的支持是零,得自己往上叠。既然加密链路和指令封装都绕不开自定义,那不如把整体帧格式一起定清楚。

1.1 自研协议的核心收益:不只是摆脱供应商,而是把调度可靠性拿回自己手里

自己定义协议的第一个直接收益,是把"通信失败"这件事变成可控的。原协议的行为是:请求发过去,设备断电或者RF掉线,上位机这边只能等超时,然后整条线路的调度队列卡死。新协议把每一条指令都设计成"带代号的请求-应答"模型,应答里除了数据本身还会带状态码、当前电量、位置校验,让上位机能区分"车没收到指令""车收到了但拒绝执行""车执行了但中途出错"三种情况。

第二个收益是消息队列可控。AGV调度最大的特点是"同时在线移动的多智能体之间需要共享一致性视图"。车的位置、路径、实时状态本质上是一个不断变化的状态流。私有协议可以自定义心跳间隔、位置上报频率、事件触发上报优先级,这些都是标准协议需要额外配置且难以统一控制的地方。

第三个收益则很实际:数据流可控。标准协议大多默认一帧尽量短、周期尽量稳。但AGV的路径脚本是突发性的、变长的。有的路径只有3个点,有的路径有30多个点。自定义协议可以在一帧里直接带上变长路径数据,传输效率和实时性比"固定寄存器地址慢慢磨"要好得多。

1.2 明确边界:哪些功能不放进自定义协议

这一点当时在团队里吵过。有人想把AGV的急停信号也做进自定义协议,我直接否了。急停这种安全链路上的信号,必须走硬接线,和通信协议完全隔离。自定义协议只承载调度指令、状态数据、位置信息、任务确认这类非安全等级的信息。退一万步讲,就算通信全挂了,AGV本身还能触发雷达、触边、急停按钮这些物理保护,不会因为协议崩溃直接出安全事故。这是底线,不能把通信协议设计成安全层。

2. 两套踩坑换来的调度帧结构:字段顺序是死磕出来的

帧结构这块,第一版只用了不到半天就写完了,当时觉得这么简单的东西有什么好纠结的。实际联调之后,第一版被杀得渣都不剩。问题不在编解码本身,而在"边界情况"——数据中途被截断、一帧里夹了两帧、设备刚开机发了半帧就断链。这些在测试环境里根本模拟不出来,一上现场全冒出来了。

2.1 最终采用的帧格式与字段取舍

我最终定下的帧结构是定长头部+变长数据体+校验尾的结构,头部固定12字节,具体如下:

字段长度(字节)说明
帧头标识2固定为0xAA 0x55,用于接收端定位
版本号1协议版本,首版为0x01
帧类型10x10=请求、0x20=响应、0x30=主动上报、0x40=心跳
命令字2如0x0001=下发路径、0x0002=查询状态、0x0003=急停确认
帧长度2从数据体第1字节到校验字段之前的总长度
流水号2发送端自增编号,用于请求响应配对和去重
数据体变长(0~1024)具体命令参数,按命令字定制结构
CRC162校验范围从版本号开始到数据体结束

为什么帧头要两字节0xAA 0x55而不是单字节?因为现场环境里设备上电瞬间会有大量随机电平抖动,单字节的0xAA很容易被误触发。0xAA 0x55连续出现概率低得多,而且接收端在未同步状态下必须连续匹配两个字节才认为"找到帧头",能有效过滤开机毛刺。

版本号是后来才加的。第一版没有这个字段,结果现场有两台车固件已经升级了,调度端还在按旧结构解析,出来的数据整个错位。版本号这个细节,我建议所有自定义协议都加上,成本只要1字节,收益是协议演进时不用推翻重来。

帧长度字段我特别强调一下:它必须包含数据体加校验字段的长度,但不包含帧头。这样接收端在解析时,读到"帧长度"之后可以直接算出"还需要收多少字节才构成一帧完整数据",对半包处理非常友好。用代码表达就是:

// 接收状态:已确认帧头,等待完整帧 int totalFrameLength = 12 + frameLength; // 头部固定12字节 + 数据体及校验 int remainingBytes = totalFrameLength - receivedSoFar; // 还差多少字节 if (remainingBytes <= 0) { // 完整帧已到齐,进入CRC校验和命令分发 }

CRC校验范围从"版本号"开始而不是从"帧头"开始,这是因为帧头只是一个定位符,不需要被校验。如果不小心把帧头也纳入CRC,一旦收发两端对帧头定义稍有偏差,整个校验就全部错乱,排查起来很痛苦。

2.2 大小端和BitConverter的坑,只有踩过才知道多大

C#的BitConverter默认用系统的小端序,但工业控制器那边的数据结构是按大端来的。任何多字节数值(长度、命令字、流水号、CRC)都是高位在前。这个问题在纯C#环境下完全看不出来,一旦和嵌入式控制器联调,立刻原形毕露。

当时车载控制器传回来的"路径点数量"字段是0x00 0x32,也就是十进制50。我们用BitConverter.ToUInt16直接转换,读出来变成0x3200也就是12800,于是控制器收到之后直接判定为非法数据,报了整整一轮的错误。这个坑的教训是:在C#里写协议编解码,任何多字节字段都不要直接用BitConverter,必须统一走自定义的端序转换函数。我最后封装了两个静态方法:

public static class EndianHelper { public static ushort ReadUInt16BigEndian(byte[] buffer, int offset) { return (ushort)((buffer[offset] << 8) | buffer[offset + 1]); } public static void WriteUInt16BigEndian(byte[] buffer, int offset, ushort value) { buffer[offset] = (byte)(value >> 8); buffer[offset + 1] = (byte)(value & 0xFF); } }

所有入站数据解析和出站数据封包都用这两个方法,配合单元测试把随机字节序列都跑一遍,确保和控制器端的C语言行为完全一致。字段顺序上,我的建议是"凡是跨平台共用的协议,永远默认大端序"。原因无它,网络字节序和绝大多数工控底层设备都遵循大端约定,C#这边做一次转换就够,少踩很多坑。

2.3 流水号不仅仅是"计数":请求响应对配、去重、日志追踪全靠它

最初设计流水号时,我把它理解为"一个会自增的数字"而已。实际用起来才发现它是整个调度链路里最重要的字段之一。调度端发一条指令出去之后,车端回应的包里必须回带相同的流水号,这样调度端才能把"对某条命令的响应"和"该命令本身"对应起来。如果车端5秒没回,调度端还能根据流水号做重发,而不会把同一个命令重复发两遍把车搞懵。

去重逻辑是这样的:重发命令到达车端,车端通过流水号发现"哦这条命令我处理过了",直接丢弃重复帧并重新回一个确认响应,而不是再次执行任务。这个机制在无线环境抖动大的场景至关重要。流水号的长度,我建议至少2字节。短了容易在快速收发时重号,长了浪费带宽。2字节在短时间内不够用的话可以做"发送端+流水号"的复合配对,比如用设备ID加流水号一起做唯一键。

3. AGV现场粘包与半包:解析器状态机才是协议的灵魂

帧结构是纸面工作,解析器是真正的血泪现场。AGV通信链路走的工业无线网络,信道占用、射频干扰、多车并发都会导致一个致命现象:上位机从Socket缓冲区里读出来的数据,经常不是"刚刚好的一帧",可能是一帧半、两帧、或者半帧。你要是写一个"先读固定字节再解析"的简单程序,那就等着被现场数据反复打脸吧。

3.1 粘包和半包到底是怎么产生的

粘包:调度端连续下发两条指令(比如先查询状态,再下发路径),TCP协议为了效率把两个小包合并成一个TCP段发出去。上位机一次Receive拿到了两帧完整数据。如果代码只按"一帧"去处理,那第二条指令还留在缓冲区里,会被误以为是错乱的半包。

半包:车端无线信号抖动,一帧300字节的数据可能第一次读出120字节,第二次读出100字节,第三次读出80字节。如果每次Receive之后都强行解析,就会得到一堆"解析失败"的垃圾日志。

解决办法就是状态机。我用了一个三层状态:初始态、待数据态、校验态。每收到一批字节,先和缓冲区里残留的字节拼在一起,然后反复尝试"从当前缓冲区开头提取一帧"。如果不够一帧,就返回"还缺数据",继续等待。如果够一帧,就解析、消费掉这帧对应长度的字节,然后循环判断缓冲区里还有没有剩余完整帧。

private List<AgvFrame> TryExtractFrames(ref byte[] buffer) { var result = new List<AgvFrame>(); int offset = 0; while (offset + 12 <= buffer.Length) // 至少能看头部 { if (!(buffer[offset] == 0xAA && buffer[offset + 1] == 0x55)) { offset++; // 逐字节滑动找帧头 continue; } int frameLen = EndianHelper.ReadUInt16BigEndian(buffer, offset + 5); int totalLen = 12 + frameLen; if (offset + totalLen > buffer.Length) break; // 帧还没到齐,等待后续数据 var frameBytes = new byte[totalLen]; Array.Copy(buffer, offset, frameBytes, 0, totalLen); if (VerifyCrc16(frameBytes)) result.Add(DecodeFrame(frameBytes)); else LogError(frameBytes); // 这里绝对不能静默丢弃 offset += totalLen; } if (offset > 0) buffer = buffer.Skip(offset).ToArray(); // 消费掉已处理字节 return result; }

那个"逐字节找帧头"的处理,是当时被坑出来的。有些控制器上电时会在帧头前补几个填充字节,如果不做滑窗处理,第一帧直接白丢。别小看这个细节,产线上一台车偶尔丢一帧状态查询,整个调度端可能就会认为它离线,触发不必要的停车等待,很伤生产节拍。

3.2 CRC16查表法实现与效率控制

CRC在整个帧里的作用,是防止无线传输过程中字节被翻转、夹带、篡改。AGV路径数据的任何一bit错了都可能让车跑到完全错误的方向,所以校验不能省。工业界最常用的是CRC16-MODBUS,多项式0x8005,初始值0xFFFF。C#实现我直接用的查表法,预先算好256个表项,运行时每个字节只查一次表加一次异或,一帧300字节的数据体在普通工控机上用时不到0.1毫秒,可以忽略不计。

有个容易踩的细节:CRC计算要不要包含"帧头"?我在2.1里说过不包含。但还有一种坑出现在校验字段本身的字节序上。部分控制器发CRC时低位在前、高位在后,C#接收端如果用大端去解析就会永远校验失败。当时我在车辆控制器联调阶段被这个问题卡了整整一天,最后是抓了一帧原始数据用Python手工算了一遍CRCat才定位到。所以调试的时候务必先抓原始字节、手工验证CRC,再怀疑代码逻辑。

3.3 畸形帧处理原则:宁可错杀,不可静默

联调期间通讯解析器曾遇到一种情况:一帧数据长度字段因为受干扰变成1024,实际根本没有那么多后续字节。如果不做限制,接收缓冲区会被这个"虚高长度"无限占着,后面真正能用的数据全都进不来,整个通信链路一致性直接被卡死。所以我给"帧长度"加了一个限制:数据体加校验长度超过2048的帧直接判定为畸形帧,缓冲区立即丢弃当前数据并重新寻找下一帧头。这个设计看上去粗暴,实际在工业现场非常有用——把异常果断隔离,比尝试去"修复"一个已经错乱的帧安全得多。

所有畸形帧的处理,绝不能静默删除。我当时在每个丢弃点都打了一条包含十六进制转储的警告日志。配合一个简易日志观察工具,能够把2秒内的通信波形回放出来,排查链路问题极高效。现场调试那会儿,"抓日志看十六进制"是定位问题最快的手段,比看任何高维度的时序图都靠谱。

4. 工业级加密容错:长度受限怎么把安全做进去

协议加密这件事,纯技术讨论是一回事,放到AGV调度场景做是另一回事。AGV的控制周期非常短,路径下发、位置回传这些消息要求毫秒级完成,加完密如果多出几十毫秒延迟,产线上的车辆就跟不上节拍。更麻烦的是,车载控制器的资源有限,跑不了太重型的加解密算法。最后我们采用的是AES-128-CTR模式加会话密钥轮换,中间踩过的坑比想象中多得多。

4.1 CTR模式为什么比CBC适合AGV场景

CBC模式的解密必须从第一个密文分组开始,任何一个分组的丢失、错位都会导致整个解密结果崩溃。这在AGV无线场景是致命的——你根本不知道哪一帧会在空中被切碎、重传、抖动。CTR模式的核心思路是"把分组密码当流密码用":用一个计数器当作初始向量,和密钥一起生成密钥流,然后密钥流和数据体逐字节异或。每个字节的加密解密都是独立的,一帧数据中间坏掉几个字节,顶多那几个字节解析出错,不会波及其他位置。这种容错能力在无线环境下非常宝贵。

而且CTR模式天然可以对齐到任意字节数,不需要padding。CBC模式对不足16字节的尾部数据要做补齐,补齐逻辑稍有差错,接收端就解不出一模一样的原文。CTR没这个负担,加解密前后的数据长度是一样的,帧结构的"长度字段"不用为了密文长度再改一遍。这一点在现场联调时很省心。

4.2 会话密钥轮换与防重放机制

工业通信里做密钥管理,最忌讳的是"一把密钥用半年"。设备长期运行在无人工维护的环境里,密钥如果泄露且没有轮换机制,整个调度系统形同虚设。我们采用的做法是:调度端和每台AGV预置一个根密钥,在AGV上线时通过根密钥生成会话密钥,然后调度端和车端都缓存这个会话密钥。每24小时或者每累计加密多少帧之后强制轮换一次会话密钥,轮换过程本身也是一个带加密的密钥协商命令。

重放攻击在工控里一直容易被忽略。攻击者把之前录制的一帧"路径下发指令"原封不动地再发一次,调度端如果只做解密校验,会以为新指令又来了,车可能因此驶向一条已经废弃的路径。防重放我用了两层:一是时间戳,帧里带一个32位Unix时间戳(精确到秒),接收端判断和本地时间偏差超过正负5秒就直接拒绝;二是流水号,接收端维护一个"最近处理过的流水号集合",相同流水号重复到达直接丢弃或只回确认不执行。两个机制配合起来,重放攻击基本堵死。

4.3 加密对实时性的实测影响

测试数据是在模拟环境里用一台普通工控机加一台车载控制器跑出来的。未加密从封包到解析的完整往返时间约8ms,加上AES-CTR加解密之后约12ms,多出的4ms完全在AGV调度允许范围之内(车体本身的运动控制周期一般是10ms级)。加密带来的代价远没有想象中那么大,关键不在于算法本身,而是在于"别用错误模式导致padding重传"或者"不要每帧都新建会话密钥矩阵"。会话密钥创建一次复用一天,加解密开销就是纯数据异或,连硬件加密芯片都不需要。

5. 容错机制设计:从"通信成功"到"通信故障也能继续工作"

协议设计的最高标准不是我发出去的数据对方一定收得到——这在工业无线环境下根本不可能保证。真正的标准是:数据丢了、坏了、迟到了,系统整体还是安全的、可恢复的。

5.1 心跳与超时判定:细节决定有没有"假死"

心跳是AGV调度端感知车辆在线状态的基础。我见过很多系统简单地把心跳设成"固定秒数没收到就判定离线",这样的判断在弱网下全是误杀。当时我把心跳分成两级:在线心跳和活跃心跳。在线心跳是正常的周期上报,500ms一次;活跃心跳则是指调度端有命令交互时,车端回包本身就起着心跳作用。

超时判定用滑动窗口:连续3次期望收到心跳但未收到,才进入"疑似离线"状态,此时再触发主动拉取。主动拉取也不是只发一次查询,而是2秒内发3次,3次都无响应才判定离线。这一个细节,把现场无线抖动导致的误判率从原来的20%降到了3%以内。所谓"3秒判定离线"听起来简单,实际把误判率压下来要考虑真实工况而不是拍脑袋。

5.2 重传、去重与命令队列

AGV通信链路不是"有去有回"这么简单。调度端下发一条路径后,很可能会因为网络抖动丢掉响应帧。如果调度端不做重传,这辆车就停在原地等指令;如果无脑重传,又可能重复执行。解决方式是把"重传"和"去重"组合起来:每个命令带流水号,发送端在超时后携带相同流水号重发;接收端用流水号去重。同时命令不是"发一条丢一条",而是在调度端维护一个"待确认队列",最多缓存10条未确认指令,超过上限就暂停下发并告警。这相当于通信层的滑动窗口,既保证了吞吐,又不会把链路打爆。

车端呢,维护一个"最近执行的命令流水号缓存",长度就是最近100条。每收到一条命令,先查缓存,如果已存在,直接返回上一轮的响应帧(本地缓存了响应内容),不做任务执行。这个设计在弱网环境下极大地减少了重复任务造成的车辆走位重叠。

5.3 断线重连与状态同步:不只是"重连成功"

我们的AGV调度场景里,最怕的是车辆断线后重新上线,但调度端不知道它现在在哪、在干什么。如果直接又发一条路径,车端可能正在执行旧路径,新旧指令冲突,车就走位错乱。所以断线重连之后必须经过"状态同步阶段":

  1. 车端重新上线后,不等待指令,主动上报当前位置、当前任务编号、当前执行进度、剩余路径点。
  2. 调度端收到状态上报后,判断此时应该让车"继续执行旧任务"还是"下发新任务"。
  3. 如果车在执行任务途中掉线又上线,调度端根据当前进度决定"继续走剩余路径"还是"重新下发完整路径"。

这一步看似简单,实际上包含了最关键的一致性决策逻辑。说实话,很多AGV系统厂商公开资料里都不会写这么细,因为这是现场维护时最珍贵的经验之一。通信容错绝不是"我发了你收到了"这个单向视角,而是"发方知道收方处于什么状态、收方也让发方能推导出自己的状态"这个双向校验的闭环。

6. 三周内的突发问题排查链路实录

三周时间线看起来短,实际每天都过得很紧凑。我挑两个最典型的排查过程讲一下,因为踩坑链路反映了从帧设计到上线压测的最真实状况。

6.1 联调现场冒出的"诡异CRC错误"

第一周和第二周的单元测试全部跑通,进出几百帧都完全正常。到现场联调,发现车端连上调度端之后,CRC校验一直失败,偶发成功但概率极低。当时第一反应是"协议栈的CRC表算错了",但纯软件模拟又是对的。花了大半天抓数据分析,发现失败帧的字节序和成功帧的字节序整体发生了错位,错误集中在数据体中间段。最后把原始数据用十六进制逐个对照,才发现车端控制器在无线模块收到数据后,会把前两个字节吞掉,导致整个帧全部错位。根因是车端无线驱动初始化时多开了一个调试口,数据被调试口吃了两字节。不是协议问题,是设备驱动问题。这个案例告诉我:协议调试如果长时间出现"无法解释"的错帧,先怀疑底层驱动和无线模块缓存,不要急着改协议栈。

6.2 弱网环境下心跳参数引发的连锁误判

第二周晚些时候,我们在车间角落模拟弱网测试,发现一台车明明在线,调度端却频繁判定离线并触发急停。查日志发现车端确实在发心跳,但无线抖动导致心跳帧到达调度端的时间间隔从平均30ms变成400~800ms。最初设计的心跳超时是1秒没收到就判定离线,结果网络一抖动直接误杀。后来我把超时窗口改成"连续3次超时且2次主动拉取无响应",同时把心跳帧的发送优先级提到最高,由车端独立线程发送、不参与命令队列排队。这样即使在变频器干扰严重的角落,车端的活跃准确性也从65%直接升到98%以上。

6.3 72小时压测暴露的累积性问题

上线前我们做了72小时持续运行压测,中间暴露了两个问题:一是内存泄漏,调度端在分析接收缓冲区时,每帧数据都用byte[]拷贝一次,高频位置上报时GC压力巨大,运行24小时内存占用涨了1.8GB。换成复用缓冲区加偏移量访问之后,内存在第6个小时后稳定在720MB附近。二是长时间运行后"流水号"字段含义模糊,原计划里流水号是全局自增的,但重连后新建立的会话又从0开始计数,结果老会话重传的帧和新会话的帧流水号冲突。最后把流水号改成"会话ID+自增序号"的复合字段,从根上杜绝了跨会话混淆。

7. 如果让我再做一次,协议设计的保守与激进

协议开发结束到现在已有几个月,回头审视当初的决定,有做得对的,也有过度设计的地方。

做得对的:版本号字段从一开始就预留了,后面的字段调整全靠它兜底;CRC和加密独立成模块,改任何一个都不影响主解析逻辑;状态机解析器没有走简化路线,保证了弱网环境下的稳定性。

过度设计:第一版我塞进了太多种帧类型,比如专门的"电量查询帧""故障查询帧""位置查询帧"。实际用下来,一个"通用状态上报帧"足以覆盖所有周期数据,独立的查询帧大部分场景根本用不上,纯属增加代码量。精简协议比扩充协议难,也更考验设计者对场景的理解。

后续演进方向:如果未来要接入更多车型,我可能会在协议里增加一个"能力协商"阶段,让AGV上线时报出自己能支持的版本、加密算法列表、最大数据帧长,调度端根据能力自动选择最优交互模式。这个机制放在现在的版本里做也算兼容扩展,通过版本号加保留字段即可平滑过渡。

最后说一个心得:协议开发最忌讳"闭门造车式设计"。第一版协议我是对着标准文档写的,想法是"字段齐全、类型丰富、逻辑完善",结果到了现场被真实数据揍了两轮,才明白协议是"为抗恶劣环境而设计"的,不是"为功能丰富而设计"。精简、抗干扰、可诊断,这三点的优先级,远高于"看起来很完整"。如果你也在做类似的工控通信协议,我的建议永远是先定义"数据丢了怎么办"和"数据坏帧怎么办",再回头填字段,顺序反了,后面所有调试都是还债。

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

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

立即咨询