首页 / 资讯中心 / 文章详情

自研AGV调度通信协议:从帧设计到加密容错的三周实战复盘

自研AGV调度通信协议:从帧设计到加密容错的三周实战复盘 ★ FEATURED ARTICLE
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 ListAgvFrame TryExtractFrames(ref byte[] buffer) { var result new ListAgvFrame(); 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调度场景里最怕的是车辆断线后重新上线但调度端不知道它现在在哪、在干什么。如果直接又发一条路径车端可能正在执行旧路径新旧指令冲突车就走位错乱。所以断线重连之后必须经过状态同步阶段车端重新上线后不等待指令主动上报当前位置、当前任务编号、当前执行进度、剩余路径点。调度端收到状态上报后判断此时应该让车继续执行旧任务还是下发新任务。如果车在执行任务途中掉线又上线调度端根据当前进度决定继续走剩余路径还是重新下发完整路径。这一步看似简单实际上包含了最关键的一致性决策逻辑。说实话很多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上线时报出自己能支持的版本、加密算法列表、最大数据帧长调度端根据能力自动选择最优交互模式。这个机制放在现在的版本里做也算兼容扩展通过版本号加保留字段即可平滑过渡。最后说一个心得协议开发最忌讳闭门造车式设计。第一版协议我是对着标准文档写的想法是字段齐全、类型丰富、逻辑完善结果到了现场被真实数据揍了两轮才明白协议是为抗恶劣环境而设计的不是为功能丰富而设计。精简、抗干扰、可诊断这三点的优先级远高于看起来很完整。如果你也在做类似的工控通信协议我的建议永远是先定义数据丢了怎么办和数据坏帧怎么办再回头填字段顺序反了后面所有调试都是还债。
阅读完成 · 觉得有帮助?
咨询建站