C# Winform 通过 TCP 与 OpenProtocol 控制拧紧枪实战
简介:这份资源面向从事工业自动化上位机开发的C#程序员,聚焦如何用Winform客户端通过TCP/IP与拧紧枪设备通信,并借助OpenProtocol协议完成控制指令下发与状态监测,适合已具备一定Socket编程基础、希望切入拧紧控制场景的中级开发者。压缩包共49个文件,约324KB,以18个cs源码文件为核心,辅以config配置、resx与resources界面资源、sln与csproj工程文件及少量dll、exe运行组件,构成可直接打开调试的完整示例工程。目前已有1542人学习下载。资源围绕Atlas拧紧控制示例展开,涵盖Socket连接建立、OpenProtocol消息的构建与解析、启动拧紧与扭矩参数设置、结果读取及CRC校验等关键环节,并演示async/await异步收发以避免阻塞UI线程,读者可据此理解协议命令结构、复用通信封装思路,并对照自身设备快速搭建可运行的拧紧控制客户端。
1. 拧紧枪联机这件事,卡住新人的从来不是协议本身
车间里一台拧紧枪孤零零地跑单机模式,操作工手动按启动、看灯判断 OK/NG、拿纸单记扭矩——这种场景在中小装配线上太常见了。想把数据接进 MES、想让 PLC 自动触发拧紧、想按工位追溯每颗螺栓的扭矩曲线,第一步就得让上位机通过 TCP/IP 跟拧紧枪说上话。而工业拧紧枪这个圈子里,OpenProtocol 几乎是绕不开的通信标准:它规定了报文长什么样、控制器怎么应答、订阅和结果上报怎么走。这份资源就是围绕「C# Winform + TCP + OpenProtocol」搭的一套上位机控制拧紧枪的完整实现,从连接建立、报文组包解析,到拧紧结果订阅、异常重连都覆盖到了。适合两类人:一类是刚接手产线数据采集、被协议文档里一堆 MID 编号绕晕的 C# 开发者;另一类是想把拧紧工位从手动模式改成自动触发、需要一份能跑起来的参考代码的自动化工程师。下面按我拆这套东西的顺序讲,先立住协议模型,再落到代码,最后把踩过的坑摊开。
2. OpenProtocol 报文模型:先把 MID、长度头和应答机制吃透
2.1 为什么不能上来就写 Socket
很多人拿到需求第一反应是TcpClient.Connect然后stream.Read,结果读到的是一堆粘在一起的字节,解析全靠猜。OpenProtocol 是典型的「长度前缀 + 定长头 + 变长体」结构,每条报文开头 4 个字节是总长度(ASCII 数字,含这 4 字节本身),紧接着 4 个字节是 MID(Message ID,也是 ASCII 数字),后面才是数据字段。控制器和上位机之间是请求-应答模型:上位机发一条请求,控制器回一条同 MID 的应答,再异步推送订阅类消息。不理解这个模型,Socket 写得再顺也是白搭。
我一般会先把报文结构画成一张表贴在显示器边上,比翻 PDF 快得多:
| 字段位置 | 长度 | 含义 | 示例 |
|---|---|---|---|
| 0–3 | 4 字节 | 报文总长度(ASCII) | 0058 |
| 4–7 | 4 字节 | MID 消息号(ASCII) | 0001 |
| 8–9 | 2 字节 | 修订号 Revision | 01 |
| 10–… | 变长 | 数据字段 | 依 MID 而定 |
| 末尾 | 1 字节 | 结束符 NUL(0x00) | — |
注意那个结束符,很多实现里长度字段算的是「不含 NUL」还是「含 NUL」会差一位,这是后面解析错位的头号元凶,先记住。
2.2 组包与拆包的核心代码
组包的本质就是「拼字符串 + 算长度」。下面这段是我从这套资源里提炼出来的组包方法,逻辑很直白:先把 MID、Revision、数据体拼起来,再在最前面补上总长度。
// 组装一条 OpenProtocol 请求报文 public static byte[] BuildMessage(string mid, string revision, string data) { // 数据体 = MID(4) + Revision(2) + Data string body = mid.PadLeft(4, '0') + revision.PadLeft(2, '0') + data; // 总长度 = 长度头(4) + body + 结束符(1) int totalLen = 4 + body.Length + 1; string header = totalLen.ToString().PadLeft(4, '0'); string full = header + body + "\0"; return Encoding.ASCII.GetBytes(full); }参数说明:mid传"0001"这类四位字符串,revision一般传"01",data是各 MID 定义的数据段(比如订阅结果时是空串,发参数时是具体字段拼接)。这里totalLen把结束符算进去了,跟控制器侧对齐——如果对端不认,就改成不含 NUL 再试,这是第一个要现场验证的点。
拆包比组包麻烦,因为 TCP 是流式的,一次Read可能读到半条、也可能读到两条半。稳妥做法是维护一个接收缓冲区,循环判断「缓冲区长度 ≥ 4」时先解析长度头,够一整条就切出来处理,不够就继续等。
private List<string> _recvBuffer = new List<string>(); // 从字节流中切出完整报文 private void OnDataReceived(byte[] chunk) { _recvBuffer.Add(Encoding.ASCII.GetString(chunk)); string all = string.Concat(_recvBuffer); while (all.Length >= 4) { // 前 4 位是长度头,解析失败说明流已错位 if (!int.TryParse(all.Substring(0, 4), out int msgLen) || msgLen < 8) { all = string.Empty; // 丢弃脏数据,触发重连 break; } if (all.Length < msgLen) break; // 不足一条,等下一批 string one = all.Substring(0, msgLen); all = all.Substring(msgLen); Dispatch(one); // 按 MID 分发处理 } _recvBuffer.Clear(); if (all.Length > 0) _recvBuffer.Add(all); }这段的关键在msgLen < 8这个兜底判断:正常报文最短也有长度头加 MID 共 8 字节,一旦解析出小于 8 的值,基本可以断定流已经错位(多半是上一轮长度算错了一位),此时清空缓冲、走重连比硬解析靠谱。Dispatch里按one.Substring(4, 4)取 MID 做 switch 分发即可。
2.3 连接建立与心跳保活
控制器一般监听固定端口(常见 4545,具体看设备手册),上位机作为客户端主动连。连上之后不是就完事了,OpenProtocol 有 keep-alive 机制,长时间不发报文控制器会主动断开。我一般起一个定时器,每 5 秒发一条 MID 9999 或者任意轻量查询,既保活又能顺带探测链路是否还通。
// 心跳定时器,5 秒一次 private void HeartbeatTick(object sender, EventArgs e) { if (_client == null || !_client.Connected) { Reconnect(); return; } try { var msg = BuildMessage("9999", "01", ""); // 轻量保活 _stream.Write(msg, 0, msg.Length); } catch (IOException) { Reconnect(); // 写失败即视为断链 } }Reconnect里要做退避,别一失败就死循环重连把 CPU 打满,常见做法是 1s、2s、4s 递增,封顶 30s。这套资源里重连逻辑是单独抽出来的,值得借鉴的地方在于它把「连接状态」和「业务状态」分开了——链路断了不影响已缓存的拧紧结果,重连后补发订阅即可。
3. 拧紧业务落地:订阅结果、解析扭矩、联动 Winform
3.1 订阅拧紧结果的标准流程
协议通了只是第一步,真正要的是拧紧数据。OpenProtocol 里拿结果有两条路:一是上位机发 MID 0018 订阅,控制器在每次拧紧完成后主动推 MID 0061(拧紧结果)或 MID 0062(拧紧曲线);二是上位机轮询 MID 0016 查最后一条结果。产线追溯场景我强烈建议走订阅,轮询会丢中间结果,尤其是节拍快的工位。
订阅的报文很简单,MID 0018 数据段填订阅序号和要订阅的 MID 列表:
// 订阅拧紧结果 MID 0061,订阅号 01 var sub = BuildMessage("0018", "01", "01" + "0061"); _stream.Write(sub, 0, sub.Length);发出去后控制器回一条 MID 0018 应答,data里带订阅状态。这里有个容易忽略的点:订阅号(第一个01)是上位机自己定的标识,后续推送的 0061 报文里会带回这个号,多工位共用一个连接时靠它区分来源。单工位随便填,多工位一定要规划好。
3.2 解析 0061 报文里的扭矩与状态
MID 0061 的数据段字段多且定长,最常取的是拧紧状态、扭矩值、角度值、螺栓号。字段是定长 ASCII,按偏移切就行。下面这段解析方法把关键字段抠出来:
// 解析 MID 0061 拧紧结果 public class TighteningResult { public string Status; // 01=OK 02=NG 03=未定义 public double Torque; // 扭矩,单位依控制器配置 public double Angle; // 角度 public string BoltNo; // 螺栓号 } public static TighteningResult Parse0061(string msg) { // msg 已含长度头和 MID,数据从第 8 位开始 string d = msg.Substring(8); var r = new TighteningResult(); r.Status = d.Substring(0, 2); // 拧紧状态 r.Torque = double.Parse(d.Substring(2, 6)) / 100.0; // 扭矩,两位小数 r.Angle = double.Parse(d.Substring(8, 5)) / 10.0; // 角度,一位小数 r.BoltNo = d.Substring(13, 4).Trim(); // 螺栓号 return r; }参数说明:偏移量和缩放系数(/100.0、/10.0)必须对照你手上控制器的协议手册核对,不同厂商对同一 MID 的字段排布可能有细微差异,这是最容易翻车的地方——代码跑通了但扭矩差 100 倍,八成就是缩放系数没对上。Status拿到02时要在界面上立刻标红,别等操作工自己发现。
3.3 Winform 界面与后台通信的线程隔离
Winform 里最常见的翻车是「在接收线程里直接改控件」。Socket 的DataReceived回调跑在后台线程,直接label.Text = ...会抛跨线程异常,或者界面卡死。正确做法是Invoke回 UI 线程,或者用BeginInvoke异步投递。
// 在接收回调里安全更新界面 private void UpdateUi(TighteningResult r) { if (this.InvokeRequired) { this.BeginInvoke(new Action<TighteningResult>(UpdateUi), r); return; } lblStatus.Text = r.Status == "01" ? "OK" : "NG"; lblStatus.ForeColor = r.Status == "01" ? Color.Green : Color.Red; lblTorque.Text = r.Torque.ToString("F2"); dgvHistory.Rows.Insert(0, DateTime.Now.ToString("HH:mm:ss"), r.BoltNo, r.Torque, r.Status); }用BeginInvoke而不是Invoke是为了不阻塞接收线程——拧紧结果推送频率高时,同步Invoke会让接收队列堆积,严重时表现为「界面越用越卡、数据延迟越来越大」。这套资源里历史记录用的是DataGridView插行,注意行数要设上限(比如保留最近 500 条),否则跑一天内存就上去了。
3.4 自动触发拧紧的联动
除了被动收结果,很多工位要上位机主动触发拧紧,对应 MID 0019(启动拧紧)或 MID 0020(启动并下发参数)。触发前要确认控制器处于可启动状态,否则会收到 NG 应答。我一般把「触发」和「结果」做成状态机:触发后界面进入等待态,收到 0061 再落回就绪态,超时(比如 10 秒没结果)就报警。
// 触发一次拧紧 public void StartTightening(string boltNo) { var msg = BuildMessage("0019", "01", boltNo.PadLeft(4, '0')); _stream.Write(msg, 0, msg.Length); _waitTimer.Start(); // 启动超时计时 }boltNo补零到四位是协议要求,别传中文或超长字符串,控制器会直接拒。超时计时器到点还没收到 0061,就在界面弹提示并记录一条「触发无响应」,这条日志在排查现场问题时特别值钱。
4. 避坑与排查:那些让联调多花两天的细节
4.1 现象:报文发出去控制器没反应,抓包看字节完全正确
原因多半是结束符或长度头差一位。有的控制器要求长度字段不含 NUL,有的要求含;还有的要求报文末尾必须是\0而不是\r\n。解决:先用抓包工具看控制器自己发出的报文,逐字节比对长度头和结尾,以对端为准改BuildMessage里的totalLen计算。别信文档,信抓包。
4.2 现象:跑一段时间后解析全乱,扭矩值变成天文数字
原因是我前面说的流错位——某次Read只读到半条,缓冲区拼接时把长度头切错了,之后整条流就再也对不齐。解决:在OnDataReceived里加msgLen < 8的兜底,一旦发现异常立即清空缓冲并重连,宁可断一次也别带着脏数据跑。重连后重新订阅,业务侧无感。
4.3 现象:界面偶发卡死,日志显示接收线程阻塞
原因是接收回调里用了同步Invoke更新界面,UI 线程忙时反压到接收线程,Socket 缓冲区满后控制器判定超时断开。解决:全部改BeginInvoke,并且界面更新做节流——高频结果不必每条都刷界面,攒 100ms 批量刷一次,历史表格只保留最近若干行。
4.4 现象:多工位共用一个连接,结果张冠李戴
原因是订阅号没规划,或者解析 0061 时没按订阅号分流。解决:每个工位分配唯一订阅号,Dispatch里先取订阅号再路由到对应工位的数据模型;触发时也带上工位标识,形成闭环。单连接多工位是能做的,但订阅号、触发号、结果路由三处必须一一对应,少一处就串。
4.5 现象:扭矩数值对不上,和控制器面板显示差一个数量级
原因是缩放系数或单位没对齐。控制器可能按 N·m 存、按两位小数放大成整数传输,也可能按 N·cm。解决:拿控制器面板显示值和报文原始值做一次标定,反推缩放系数,写进配置而不是硬编码。换型号时只改配置不改代码,这是血泪经验。
5. 进阶:把通信层抽成可复用组件并做链路自检
这套资源跑通之后,我做的第一件事是把通信层从 Winform 里剥出来,做成独立的TighteningClient类,对外只暴露Connect、Subscribe、StartTightening、ResultReceived事件。界面只订阅事件,不碰 Socket。这样同一套通信代码能同时给 Winform、控制台服务、甚至后续的 Web 后端复用,换界面不用重写协议。
抽离时我加了一个「链路自检」方法,上线前跑一遍,比人工点界面靠谱:
// 上线前链路自检:连接 -> 订阅 -> 触发 -> 等结果 public bool SelfTest(out string report) { report = ""; if (!Connect()) { report = "连接失败"; return false; } if (!Subscribe("0061")) { report = "订阅失败"; return false; } StartTightening("0001"); if (!_resultEvent.WaitOne(10000)) { report = "触发后 10s 无结果"; return false; } report = $"自检通过,扭矩={_lastResult.Torque:F2}"; return true; }WaitOne的超时要按工位节拍设,别照抄 10 秒;_resultEvent在收到 0061 时Set()。自检通过只代表链路通,扭矩值是否合理还得人工看一眼——我一般要求自检日志里把扭矩、角度、状态都打出来,存档备查。
还有一个容易被忽略的进阶点:断线重连后的「状态补偿」。链路断开期间控制器可能已经完成了若干次拧紧,重连后这些结果不会补推。如果业务要求不丢数据,重连后要主动用 MID 0016 拉一次最后结果,或者干脆在控制器侧开启结果缓存(部分型号支持)。这个取舍要看追溯精度要求,别默认订阅就万事大吉。
从那以后我每次接新控制器,都强制先跑一遍抓包比对 + 链路自检,再动界面代码——顺序反了,后面全是返工。希望帮到你。
本文还有配套的精品资源,点击获取