ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TCP选择响应帧协议:用单连接点名替代多设备轮询

TCP选择响应帧协议:用单连接点名替代多设备轮询 简介TCP-选择响应.zip 是一份面向计算机网络课程的大实验资料围绕 TCP 可靠传输中的选择响应Selective Repeat机制展开通过模拟滑动窗口与选择性重传过程适合正在学习传输层协议、需要完成 TCP 编程实验的本科生或自学者。压缩包共 24 个文件其中 6 个 Java 源码与 11 个编译后的 class 文件构成了完整的可运行程序另有 txt 记录文件、ini 配置、.project/.classpath 等工程文件方便在 Eclipse 等 IDE 中直接导入并复现实验环境源码目录与编译产物目录分离配合项目设置文件可快速恢复工程。包体仅 1.05MB轻量清晰。已有 141 人学习下载。资源包含了发送与接收端的核心实现、用于观察收包顺序的数据记录、以及运行日志可帮助初学者逐步理解选择性确认、乱序缓存、超时重传等关键过程也能作为课程设计或实验报告的代码基础具有较强的参考与复用价值。1. TCP-选择响应是什么上位机点名设备和普通的请求响应差在哪一台上位机要对着一排控制器发指令最常见的做法是给每台设备建一个 TCP 连接然后轮询。设备少没问题设备一多几十个连接同时挂着防火墙规则要开一片端口抓包时满屏都是无关流量哪台没响应还得逐个排查。TCP-选择响应这类方案要解决的正是这个场景所有设备共用同一个 TCP 服务端口上位机在下发的帧里写清“我选哪台设备、选哪个通道”被选中的设备才有资格回包。zip 只是分发形态核心是这一套选择与响应的帧协议和配套代码。这和我说的普通请求-响应有本质区别。普通请求-响应是“一对一私聊”选择和响应是“点名”一个连接里可以容纳几十台设备靠帧里的设备 ID 区分谁该说话。适合的人群很直接做上位机开发的、写嵌入式通信的、搭 IoT 网关的以及被多设备组网搞得焦头烂额又想少开端口的人。这套东西能帮你把连接数降一个数量级同时让抓包和排障都干净得多。2. 先把“选择-响应”的语义拆开TCP 只背字节选谁回答由应用层定2.1 为什么多设备通信偏偏用 TCP链路可靠了才谈得上“选择”TCP 这个传输层协议解决的是两个问题连接可靠、字节有序。三次握手建立链路四次挥手释放链路链路建立之后TCP 协议栈只保证一件事——对端收到的字节流和我发出去的完全一致、顺序不错。它不关心你发的是不是一条完整业务帧更不关心该哪台设备回应。这份“谁回答”的职责必须由应用层的帧协议来扛。这也是选择-响应模式选 TCP 而不是 UDP 的根本原因。UDP 也能广播点名一台发出去多台同时收到看起来更省事。但 UDP 不保证不丢、不保证有序更没有连接状态设备离线了你都不知道。产线或者机房里“没响应”和“响应丢了”是两种完全不同的故障TCP 帮你把后者基本消除剩下的就只是应用层自己的超时与重试。这里顺带提醒一句如果你在机器人调试场景搜“TCP 标定”那个 TCP 是 Tool Center Point工具中心点和传输控制协议是两码事。标题里这个 TCP 只要出现在通信上下文里指的就是传输控制协议。别拿机器人那套参数往这儿套。对比一下两种组网方式差别很直观对比项每设备一个 TCP 连接单连接 选择响应连接数随设备数线性增长固定 1 个防火墙端口要开一片端口段只开 1 个抓包排障几十路流量混在一起一份流按帧过滤新增设备要改上位机连接逻辑设备侧配个 ID 即可单点故障某路连接断了只影响一台连接断了全部失联需要重连机制第一列适合设备少、权限隔离要求高的场景如果你在做的是一台上位机对几十台设备第二列省下的维护成本非常可观。PLC 侧常见的 Modbus TCP 主站功能本质也是类似思路——功能码 0x10 写选择指令、0x03 读响应只是帧格式被标准定死了。自定义帧的好处是负载更紧凑能直接携带浮点和批量数据代价是拆包、校验、超时这些事全得自己写。2.2 一条选择帧长什么样帧头、选择字段、响应字段各管什么选择-响应模式里业务帧必须自己定义边界。TCP 是字节流协议它不会在你每次 send 的边界上帮你切帧所以帧头里必须有固定魔数和长度字段。下面这套帧格式是这类 zip 包里最常见的做法也是我在几个项目里一直在用的偏移字段长度说明0帧头 版本2 字节固定 0xAA 0x010x01 是协议版本2消息 ID2 字节发送方自增响应方原样带回4帧类型1 字节0x01 选择请求0x02 选择响应0x03 错误响应5设备 ID2 字节选哪台设备0xFFFF 表示组播7负载长度2 字节负载区字节数大端9负载N 字节通道号、配方号、数据段偏移等具体选择内容末尾CRC162 字节从帧头到负载结束的校验值为什么消息 ID 必须原样带回因为一个 TCP 连接里同时可能有多条选择指令在飞。上位机发出消息 ID1 的选择帧后收到响应时先看消息 ID如果是 2说明那是另一条请求的响应不该拿来匹配当前流程。设备 ID 是“选择谁”的核心字段单播时填具体设备号组播时填组号被选中的多台设备各自回包上位机用设备 ID 区分是谁回的。CRC16 这段别省。TCP 的校验和只能发现链路层的位错误但数据经过网卡、交换机和中间缓存应用层收到的字节仍然可能出错。CRC 虽然挡不住恶意篡改但能挡住绝大多数偶发错帧尤其是电磁环境不太干净的工业现场。算 CRC16 的代码每家写的都一样Modbus 那个 0xA001 查表法在 Python 里十几行就能实现别图省事拿总和校验糊弄。2.3 状态机与时序从“已选择”到“已响应”中间差一个超时帧格式定了时序就成了第二个关键。选择-响应模式在应用层是一个典型的状态机最少要四个状态状态触发条件后续动作空闲链路建立无未完成选择可下发选择帧已发送选择发出 0x01 帧启动超时定时器已收到响应收到 0x02 帧且 CRC 通过核对消息 ID处理负载转空闲超时定时器到点还没等到响应按策略重试或上报超时这个状态最容易写崩。TCP 能保证字节不丢但保证不了对端进程活着。设备断电、程序卡死、网线松动都会让响应永远不来。这时候如果上位机没有超时机制一个 recv 能把整个线程挂到天荒地老。所以状态机里必须给每次选择请求配一个超时定时器时间到了该重试重试该报错报错绝不能无限等。组播场景下状态机要更细一点发出组播选择后需要同时等待多台设备的响应这时候就不能简单地“收到一条就转空闲”而是得维护一张“待响应设备表”每回来一条就划掉一个设备号直到全部划掉或者整体超时。很多包里的示例代码只做了单播拿到组播需求时要自己扩展这张表这个坑后面还会提到。3. 解压到跑通只需要一次会话最小服务端与客户端代码3.1 拿到 zip 后先做三件事校验、看目录、确认环境这类 zip 包解压之前我习惯先做三个动作能省掉后面一半的排障时间。第一是校验哈希和发布方给的 SHA256 对上再解压防止传输过程被改坏第二是看目录结构先弄清楚哪些是源码、哪些是抓包样本、哪些是配置模板别一上来就改代码第三是确认运行环境Python 版本、C# 依赖库、LabVIEW 版本跟代码要求对不上就直接弃用这份包。# 先看包内结构不解压 unzip -l TCP-选择响应.zip # 校验哈希和发布方给的比对 sha256sum TCP-选择响应.zip # 解压到独立目录 unzip TCP-选择响应.zip -d select-demo # 确认文件类型、换行符和编码 file select-demo/*.py # 确认解释器版本 python3 --version第一行用unzip -l只列目录不落盘目的是先确认包内有没有脚本、文档、抓包样本三类内容以及文件层级是否合理。第三行把内容解压到独立目录避免和已有工程混在一起。第四行的file命令看着不起眼实际很关键从 Windows 那边传过来的 py 文件经常带 CRLF 换行符在 Linux 上跑起来报错格式千奇百怪先看一遍能提前发现。这里有一个常见的解压报错invalid zip archive: could not find eocd。EOCD 是 zip 格式的结尾记录找不到它通常意味着 zip 包在传输过程中被文本模式改过或者文件被截断。解决办法是让发送方用二进制模式重新传一遍如果文件已经损坏可以试试zip -FF damaged.zip --out repaired.zip让 zip 工具尝试修复损坏的中央目录救回来一部分文件也好过重新要包。如果 zip 带了密码我的习惯是把密码放到环境变量或者部署脚本里绝对不硬编码到源码。源码里写死密码这份代码一旦外发协议和凭据一起泄露后面想改都改不干净。环境变量在 CI 和现场部署里都能配置比改代码快得多。3.2 服务端代码监听 9000 端口按设备号回响应下面这份 Python 代码是这套帧里最简可运行的服务端单线程 accept每个连接开一个线程处理。线程模型在几十个连接以内完全够用别一上来就上 asyncio把协议逻辑先跑通比追求高并发重要。import socket import struct import threading FRAME_HEAD 0xAA01 # 帧头 版本号 def crc16(data: bytes) - int: crc 0xFFFF for b in data: crc ^ b for _ in range(8): crc (crc 1) ^ 0xA001 if (crc 1) else (crc 1) return crc 0xFFFF def build_response(msg_id: int, dev_id: int, payload: bytes) - bytes: # 响应帧 帧头 消息ID 类型0x02 设备ID 负载长度 负载 CRC body struct.pack(H B H H, msg_id, 0x02, dev_id, len(payload)) payload head struct.pack(H, FRAME_HEAD) return head body struct.pack(H, crc16(head body)) def handle(conn: socket.socket) - None: buf b conn.settimeout(5) while True: chunk conn.recv(4096) if not chunk: break buf chunk # 循环拆包一个 recv 里可能有多帧也可能只有半帧 while len(buf) 11: if struct.unpack(H, buf[:2])[0] ! FRAME_HEAD: buf buf[1:] # 帧头错位丢一个字节重新对齐 continue plen struct.unpack(H, buf[7:9])[0] total 9 plen 2 # 固定头部9字节 负载 CRC2字节 if len(buf) total: break # 半包等下一个 recv frame, buf buf[:total], buf[total:] msg_id, ftype, dev_id struct.unpack(H B H, frame[2:7]) if ftype 0x01: conn.sendall(build_response(msg_id, dev_id, bOK)) conn.close() srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((0.0.0.0, 9000)) srv.listen(20) print(selection server on tcp 9000) while True: conn, _ srv.accept() threading.Thread(targethandle, args(conn,), daemonTrue).start()代码里struct.pack(H B H H, ...)的大于号指定大端字节序这是网络字节序的标准做法千万不能省。SO_REUSEADDR这行必须在bind之前设置不然服务端重启时端口可能还在 TIME_WAIT 状态里直接报地址占用。buf[7:9]是负载长度字段的位置因为固定头部凑齐了 2 字节帧头、2 字节消息 ID、1 字节类型、2 字节设备 ID正好 7 字节长度字段从偏移 7 开始。这个版本故意把负载解析留成空操作只回一个bOK。实际项目里负载里装的是通道号或者配方号服务端要按这个号去查对应的数据再回填。先把链路和拆包跑通再加业务逻辑出问题时才知道是哪一层的问题。3.3 客户端代码发一条选择帧3 秒内等对应响应客户端的核心逻辑就三件事连接、发帧、等响应。我一般会先做一次 tcp 单连接实验确认链路、拆包、响应匹配这三个环节都对了再加多设备和并发逻辑。import socket import struct FRAME_HEAD 0xAA01 def crc16(data: bytes) - int: crc 0xFFFF for b in data: crc ^ b for _ in range(8): crc (crc 1) ^ 0xA001 if (crc 1) else (crc 1) return crc 0xFFFF def build_request(msg_id: int, dev_id: int) - bytes: # 请求帧类型0x01负载长度为0只做点名 body struct.pack(H B H H, msg_id, 0x01, dev_id, 0) head struct.pack(H, FRAME_HEAD) return head body struct.pack(H, crc16(head body)) def parse_response(frame: bytes): msg_id, ftype, dev_id struct.unpack(H B H, frame[2:7]) return msg_id, ftype, dev_id sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(3) # 响应超时3秒 sock.connect((127.0.0.1, 9000)) try: sock.sendall(build_request(msg_id1, dev_id0x0001)) resp sock.recv(1024) msg_id, ftype, dev_id parse_response(resp) if ftype 0x02: print(fdevice {dev_id:#06x} replied, msg_id{msg_id}) elif ftype 0x03: print(fdevice {dev_id:#06x} returned error) finally: sock.close()超时参数的取值很讲究。这里sock.settimeout(3)指 recv 最多等 3 秒3 秒的取值依据是局域网 RTT 通常在 1 毫秒级别设备端处理一条选择指令按最坏情况算也不超过 200 毫秒3 秒已经是非常保守的余量。如果你在跨公网或者走 4G 的场景这个值要放大到 8 到 10 秒但重试次数必须降下来不然一条请求会触发多次重发。最小示例里只recv一次是因为本机回环链路一次就能收全 13 字节的响应帧。真实环境里服务端返回的数据可能十几 KB一次 recv 只拿到一部分必须在客户端也做同样的缓存拆包逻辑。这一点偷懒的话后面就会被半包问题反复折磨。3.4 这套协议的关键参数怎么拍表格参数没有标准答案但有标准套路。下面这组是我在十几套设备组网的场景里调过好几轮的结果可以直接抄参数推荐值设定依据服务端口9000-9005 段避开 22、80、443、8080 等常用端口防止冲突消息 ID从 1 自增循环使用响应要靠它匹配是哪条请求的回答接收超时3-5 秒局域网 RTT 毫秒级3 秒足够跨网放大到 8-10 秒重试次数1 次重发一次还不行就报错别无限重试心跳间隔30-60 秒设备端异常断电时靠心跳才能发现连接假死接收缓冲区4KB-16KB步进按协议最大帧长度的 2 倍取单进程连接数threading 模型建议 50 以内超过 100 建议换 select 或 asyncio这条参数表里最容易被忽略的是心跳。TCP 连接在物理断开时如果双方都没有数据往来操作系统可能要几分钟才能发现链路断了。选择-响应是典型的主从模式从设备不会主动发数据全靠上位机心跳来维持“连接活着”这个认知。心跳可以复用选择帧本身选设备 ID 为 0 的虚拟设备负载里放时间戳设备端收到后只回一个空响应成本很低。如果服务端前面还挂了一层 nginx 做四层代理连接数上限就不是你代码里线程数决定的而是 nginx 的worker_connections和proxy_timeout两层叠加。我在一个项目里遇到服务端明明能扛 200 个连接nginx 默认配置只放了 512 个 worker 连接设备一多就开始丢连接。4. 换到 C#、LabVIEW 甚至单片机上四个最容易改歪的落地细节4.1 C# 客户端的异步 Socket 与拆包缓存Python 里写拆包很顺手换成 C# 就有人开始用同步阻塞的Receive一把梭。问题是选择-响应场景里上位机同时要跟几十台设备通信一个设备没响应同步 Receive 就把线程挂住了整条链路的调度全乱套。C# 这边我一般用异步回调加缓存列表来做拆包private byte[] _buffer new byte[4096]; private readonly Listbyte _cache new Listbyte(); private void OnReceive(IAsyncResult ar) { Socket sock (Socket)ar.AsyncState; int got sock.EndReceive(ar); if (got 0) { /* 对端关闭做清理 */ return; } for (int i 0; i got; i) _cache.Add(_buffer[i]); while (_cache.Count 11) { if (_cache[0] ! 0xAA || _cache[1] ! 0x01) { _cache.RemoveAt(0); // 帧头不对丢掉一个字节重新对齐 continue; } int plen (_cache[7] 8) | _cache[8]; int total 9 plen 2; if (_cache.Count total) break; // 半包等下一轮回调 byte[] frame _cache.Take(total).ToArray(); _cache.RemoveRange(0, total); // 在这里解析 frame做消息 ID 匹配和 CRC 校验 } sock.BeginReceive(_buffer, 0, _buffer.Length, SocketFlags.None, OnReceive, sock); }这段代码里最容易写错的是plen的取值。_cache[7] 8 | _cache[8]是手工拼大端序因为 C# 的BitConverter默认用本机字节序在 x86 上是小端直接读会得到反过来的数。要么像这样手工移位要么用BinaryPrimitives.ReadUInt16BigEndian显式指定大端就是不能直接BitConverter.ToInt16。拆包完成后同样的while结构在客户端和服务端都要有。TCP 是流协议一次BeginReceive可能收到半帧、一帧、甚至三帧不拆包后面所有解析逻辑都是在沙地上盖楼。这个是 C# 落地时翻车最多的地方没有之一。4.2 LabVIEW 上位机TCP 状态机加轮询别在读取函数里干等LabVIEW 里做选择-响应最大的问题是它默认的 TCP 读取 VI 是阻塞式的。很多人在循环里放一个“TCP Read”节点等着读取指定字节数设备没响应就直接把整个界面卡死。正确姿势是把通信逻辑做成状态机每个循环周期只做一次非阻塞尝试。常见做法是三层结构最外层是 50ms 的定时循环中间是选择-响应的状态机空闲、已发送、超时最内层是 TCP 读写节点。每次循环先检查状态如果处于“已发送选择”状态就尝试读一次缓冲区读不到就继续等等超过设定周期数就切到超时状态。这样即使一台设备没响应也只是它自己的状态机卡住其他设备的通信照常。LabVIEW 里拼帧要格外小心字符串和数值的转换。前面定义的帧格式里消息 ID 是 16 位大端整数在 LabVIEW 里要用“平移并合并”或者“类型转换”节点拼成 U16再转字符串。直接把 U16 数值转成字符串LabVIEW 默认的字符串编码和网络字节序对不上帧发出去就是反的。这个错误在波形图上看不出来只有用抓包工具才能发现。4.3 端序、浮点、符号位跨语言跨设备翻车最多的地方同一套协议Python 客户端、C# 上位机、LabVIEW 调试端三个一起上端序问题会立刻暴露出来。我的规矩就一条协议里所有多字节字段一律大端并且写死在文档里。Python 的H、C# 的BinaryPrimitives.ReadUInt16BigEndian、LabVIEW 的字符串转 U16 时勾选“大端”选项三家才能对上。浮点数更阴险。IEEE 754 浮点格式是各家通用的但 double 和 float 的字节长度不同跨语言传输必须写死统一用 float32 还是 float64载荷里再带一个字节的“类型标记”更保险。我在一个项目里吃过亏Python 端发的是 float64C# 端按 float32 解析数值直接变成天文数字查了整整一天才发现是字节长度不匹配。还有符号位。如果协议里约定设备 ID 是 U16那 0xFFFF 就是合法的组播地址改成有符号的 I160xFFFF 就变成 -1解析逻辑完全不同。这类问题在代码 review 里根本看不出来唯一的办法是在协议文档里把每个字段的“无符号/有符号、大端/小端、长度”三要素写全然后所有端的解析代码都对着文档逐字段核对。ESP01S 这类 MCU 也要凑热闹的话端序问题更得抠。MCU 上跑的是 C结构体直接映射到字节缓冲区时编译器会按本机端序排列字段跟网络大端对不上。我一般会在 MCU 端把协议字段逐个字节填进数组而不是把结构体指针直接扔进 socket宁可慢一点也别让编译器替你“优化”出错误字节序。4.4 多设备同时被选中批号字段把“群响应”盘活选了一台设备叫单播选了一批设备叫组播。组播场景下负载里除了通道号还要带一个批次号。上位机发出批次 47 的选择帧设备 1、3、7 各自回包响应帧里除了设备 ID 还带着批次 47。上位机收到后按批次号归类同一批次里收到的响应集合就是“本次被选设备的体检报告”。没有批次号会怎样你发了两轮组播第一轮的设备 3 响应慢第二轮的设备 5 响应快两条响应混在一起光靠消息 ID 已经对不上号了。批次号加消息 ID 双字段匹配才能把响应精确归位到某一次选择请求。这个字段在 zip 包里提供的示例代码里经常没有等你要做一选多的时候就明白它的必要性了。5. 避坑选择响应跑不通十有八九不是协议本身的问题5.1 首包总是半个帧TCP 粘包半包是流协议常态不是玄学现象服务端收到的第一条数据长度只有 4 个字节上位机却以为自己发了一条完整的选择帧。链路明明是通的数据也对但协议解析怎么都对不上。原因TCP 是字节流不是消息流。上层调一次send下层可能拆成两个 TCP 段发出去反过来调两次send也可能合并成一个段到达。所以recv返回的字节数和send时的长度没有任何对应关系。这不是玄学是 TCP 协议栈的基本行为。解决接收端必须做“缓存 按帧长拆包”。先攒到一个字节缓冲区里然后循环检查帧头对不对、长度字段够不够、缓冲区里有没有一整个帧。够就切出来处理不够就继续等下一个 recv。我前面给的服务端和 C# 代码里那个while len(buf) 11的结构就是这个逻辑的标准写法两边都要有少一边都会翻车。5.2 客户端重启就报“地址已在使用”TIME_WAIT 的存量包袱现象服务端正常客户端进程被杀掉后立刻重启connect报Address already in use。用 Java 写的客户端更常见报错和我们这个一模一样。原因TCP 四次挥手中主动关闭的一方会进入 TIME_WAIT 状态默认等 2 个 MSL最长报文段寿命Linux 上约 60 秒。这段时间内同一个“源 IP 源端口 目标 IP 目标端口”的四元组不能复用。客户端进程被强杀系统来不及发 FIN连接被动超时但 TIME_WAIT 的记录仍在。解决如果是服务端重启前面代码里的SO_REUSEADDR已经解决了端口占用问题。如果是客户端要么在设计上让服务端主动断开把 TIME_WAIT 丢给服务端要么客户端在重启时等待约 30 秒要么改用操作系统提供的 socket 复用选项。但多数情况下等 30 秒比改代码更快。重连逻辑里如果发现这个报错别立刻重试先退避不然就是重连风暴。5.3 设备离线后重连风暴超时参数互相打架现象一台设备断电上位机每隔 100 毫秒就尝试重连一次日志刷得飞快CPU 飙升其他设备的正常通信也被拖慢。更糟的是设备恢复后重连线程和正常线程同时在抢资源协议行为完全不可预测。原因connect 超时设得太短重试间隔设得太长两个参数没有配合好。连接失败后立即重试失败再立即重试这就是重连风暴。它消耗的不只是 CPU还占满了本地端口把后面所有连接的可用端口也吃光了。解决指数退避 随机抖动。第一次失败等 1 秒第二次 2 秒第三次 4 秒翻到 30 秒封顶每次再叠加 ±10% 的随机值防止多台设备同时恢复后同步重连造成突刺import random def next_delay(attempt: int) - float: base min(30.0, 2 ** attempt) # 1, 2, 4, 8, ..., 封顶30秒 return base * (1 random.uniform(-0.1, 0.1))这个退避参数要和前面的接收超时配套看。接收超时管的是“选择发出后多久算没响应”重连退避管的是“连接断开后多久再试一次连接”。两者各自独立但必须互相配合如果接收超时 3 秒、重试 1 次那一次完整的选择失败周期约 6 秒重连退避的最小值要大于这个周期不然会出现“连接都没建好上一次选择的超时回调又触发了”的交叉错乱。5.4 容器起不来“ports are not available”宿主机端口才是真凶现象Docker 启动时报error response from daemon: ports are not available: exposing port tcp 0.0.0.0:9000容器服务起不来。原因Docker 做端口映射时是宿主机上的 docker-proxy 进程监听这个端口然后把流量转发给容器。所以报错基本是两类宿主机 9000 端口被别的进程占了或者这个端口已经被映射给另一个容器了。很多人在容器里改配置改到天亮其实问题根本不在容器内部在宿主机。解决先在宿主机上查端口是谁在用ss -lntp | grep 9000 docker ps -a --format {{.Names}} {{.Ports}} | grep 9000第一行看宿主进程占用第二行看是不是别的容器已经占了映射关系。确认之后要么换一个端口重新映射要么把占用进程停掉。最后一招才考虑重启 docker 服务因为重启会把所有容器的网络栈重建一遍影响面太大。这背后还有一个容易忽略的点容器里的服务端口和宿主机映射端口是两回事。容器内监听 9000宿主机可以映射成 9001。上位机连接的是宿主机 IP 加映射端口不是容器 IP。排查时要先分清“协议监听端口”和“映射对外端口”不然抓包抓到怀疑人生。5.5 局域网 DUP ACK 导致选择指令重发响应幂等没做好现象抓包里看到连续的 DUP ACK随后选择指令被重发设备端把同一个动作执行了两次。比如设备解析到重复的选择指令后把同一段数据写了两遍数据错乱。原因TCP 的快速重传机制会在检测到乱序或丢包时通过 DUP ACK 让发送方重发数据。对 TCP 来说这是正常工作但你的应用层如果自己也做了一层“没收到响应就重发”的业务重试两层的重发叠加设备端就会收到重复帧。这就是为什么网卡上看很健康业务上却已经重复执行了。解决设备端必须做幂等处理。最简单有效的做法是维护一个“最近处理过的消息 ID”表收到选择帧后先查表如果这个消息 ID 已经处理过不再执行业务逻辑直接把上次的响应重新回一遍。表的大小按最大并发请求数取比如 64 条消息 ID 存一个循环数组够用又不占内存。这个幂等设计必须在协议层面就定下来而不是等出问题了再打补丁。因为一旦设备端执行了不可逆动作写参数、切换模式、下发控制量重复执行造成的影响是业务级的不是重传一次能挽回的。6. 验证这套方案值不值得用抓包、压连接、固化回归6.1 tcpdump 看全一次选择-响应代码写完了第一步永远是抓包验证别直接上业务。在服务端所在的机器上跑一条命令tcpdump -i lo -nn -XX -c 40 port 9000-i lo监听回环网卡因为客户端和服务端都在本机-XX同时打印十六进制和 ASCII方便对着帧格式逐字节核对-c 40只抓 40 个包就退出防止终端被刷爆。跑完看三个点第一抓包文件里有没有完整的 SYN、SYN-ACK、ACK 三次握手第二选择请求帧的 0xAA 0x01 帧头和长度字段对不对第三响应帧的消息 ID 是不是和请求帧完全一致。顺序对了、ID 对上了、CRC 没过才有资格谈业务逻辑。Wireshark 里过滤tcp.port 9000能看到同样的内容而且能直接在“Follow TCP Stream”视图里看到完整的字节流。这时候特别注意一个细节如果字节流视图里两帧之间没有间隙不要慌那是正常的TCP 本来就是流。6.2 500 个连接同时点名谁先崩谁就知道上限抓包确认协议对了下一步压连接数。我常干的一件事是开 500 个客户端同时往服务端发选择帧看服务端在第几个连接开始丢响应import socket import struct import threading def one_client(idx: int): s socket.create_connection((127.0.0.1, 9000), timeout5) s.sendall(b\xAA\x01 struct.pack(H B H H, idx, 0x01, 0x0001, 0)) try: s.recv(64) finally: s.close() threads [threading.Thread(targetone_client, args(i,)) for i in range(500)] for t in threads: t.start() for t in threads: t.join()压测时盯着ss -lntp看服务端的已建立连接数以及 TIME_WAIT 的数量。如果连接数到某个值之后响应开始延迟或者直接丢失先看是不是线程数到顶了、缓冲区不够了、还是文件描述符用完了。别在 Windows 上做大量 TCP 连接测试Windows 的动态端口范围和 TCP 自动调谐设置会影响结果得出的“上限”不是真实上限。测试机用 Linux准入门槛低数据也更可信。6.3 把好用例固化成回归脚本才是后悔药协议验证通过后最后一个动作是把用例固化成回归脚本。单播选择、组播选择、错误 CRC、超时重试、重复消息 ID 幂等这五个用例每个都要覆盖到。下次改协议、换语言、升版本跑一遍回归就知道有没有改坏。我自己的习惯是每次改了帧格式都先跑回归再下班跑挂了就当场修绝不抱着“改动小不用测”的心态。这套选择-响应方案值不值得投入取决于你是不是被多连接、多端口、抓包混乱折磨过。如果只是几台设备老老实实每台一个连接就行设备上了两位数一个连接加设备 ID 寻址这套东西省下的是防火墙规则和排障时间。选择-响应真正让我觉得“值”的时刻是现场多了一台新设备、我只需要在配置里加一个 ID 的时候。希望帮到你。提示文中所有代码按 Linux Python 3.8 以上环境验证换到 Windows 或嵌入式环境时端序和缓冲区的处理要按第四章的内容重新核对。本文还有配套的精品资源点击获取
返回列表