简介:工业设备数据采集是数字化车间的关键环节,尤其涉及关键扭矩数据的记录与追溯时,稳定高效的采集方案直接决定MES系统的可靠性。在装配场景中,设备通信常基于TCP等工业以太网技术,而Atlas拧紧枪控制器普遍支持OpenProtocol报文协议,其中PET模式可实现拧紧结果的主动推送,显著降低接收端轮询开销与延迟。C# .NET作为成熟的工程开发栈,适合搭建长连接采集服务,处理粘包、断包、多工位并发及批量落库等问题,并能与MES和质量报表体系无缝对接。掌握这类采集方法,可以帮助工程师快速落地产线数字化项目,实现每一颗螺丝的扭矩、角度数据可追溯。本文围绕Atlas拧紧枪数据采集,梳理PET模式原理与C# .NET实现要点,为装配产线的数字化改造提供参考。
1. Atlas拧紧枪数据采集:C# .NET搭配PET模式能解决产线数字化中最棘手的一环
装配产线上“最后一颗螺丝”的拧紧结果,直接决定产品能否通过安全检测。Atlas拧紧枪加上配套控制器,是目前制造工厂最常见的装配设备,每打一枪都会产生扭矩、角度、程序号等完整数据,但多数工厂至今靠手抄报表或拍屏留存,出了问题根本没法追溯到具体工位。控制器自带以太网与串口数据出口,PET模式就是其中面向第三方上位机开放数据的通信模式,而C# .NET又是产线IT环境中现成的开发栈,把数据接进MES、追溯系统和质量报表的速度,明显快过任何Excel手工流程。
这篇笔记从控制器通信原理讲起,重点记录用C# .NET搭建采集服务的全过程,包括PET模式下报文帧怎么切、多工位并发怎么管理、部署后有哪些高频坑。目标读者是刚接产线数字化项目的软件工程师,以及想搞清楚控制器数据接口怎么用的设备工程师。按下面步骤走,半天能把第一台控制器的数据读到本地库并展示在界面上。
2. 先看懂控制器数据出口:通信选型、报文结构与PET模式的工作方式
采集代码好不好写,七成取决于通信模型理解得对不对。这一章不写代码,只把控制器侧最常见的数据出口讲清楚,能少走很多弯路。
2.1 控制器数据出口选型:为什么优先走TCP而不是串口
Atlas拧紧枪系统的硬件层级通常是这样的:拧紧枪本体通过专用电缆连接一台控制器,控制器里跑电机驱动与拧紧算法,正面有屏幕和按键,背面至少有一个以太网口和一个外接I/O端子排,部分型号还带一个RS-232串口。想采集数据,出口基本有三种:
| 出口方式 | 连接数量 | 稳定性 | 实际体验 |
|---|---|---|---|
| 以太网TCP/IP | 多客户端可同时连(部分型号有上限) | 高,干扰少 | 只需IP和端口,调试成本低 |
| RS-232串口 | 一条线只能一个客户端 | 受波特率/校验位影响大 | 参数互相扯皮,易乱码 |
| 现场总线(Profibus/DeviceNet) | 多主站有限制 | 高 | 多是IO信号,拧紧结果不一定在总线上 |
我的选择习惯是:只要确认过控制器背面有网口,一律走TCP。原因不只是稳定,而是串口那套参数——9600还是115200、奇校验还是偶校验——在产线现场经常出现“设备工程师口头说设置好了,采集端收到的却是乱码”的情况,调试一次就耗掉半天。TCP只有一个IP和端口,出问题也好定位。比如连不上抓包看SYN有没有回,数据乱码看应用层有没有按ASCII解析,排查路径非常直白。
如果担心同一段网线上有其他TCP客户端抢占连接,可以在控制器通信设置里把允许会话数调小,或单独划分一个采集VLAN,这个后面第6章会展开。
2.2 报文帧结构:长度、MID与数据区
TCP通道打开后,控制器和上位机之间“聊天”用的语言,最常见的是OpenProtocol——一套面向数据交换的ASCII报文协议。报文结构很固定,一帧由三部分构成:
- 帧长度:固定两位ASCII数字,表示“MID+数据区”的字符总数;
- MID(Message ID):四位ASCII数字,表示报文类型。0002是命令接受应答,0003是命令拒绝,0060是拧紧结果上报,0061是拧紧结果请求;
- 数据区:按MID定义逐字段排列的ASCII字符串,字段间用空格或分号分隔。
一帧拧紧结果的可视形态大概是这样(以MID0060为例,字段值仅为示意):
20 0060 02 01 A 01 010001 0001 12540 2750 35 ...排在最前面的20是总长,0060是MID,后面依次是序列号、程序号、扭矩、角度等。不同控制器固件版本下,长度位和分隔符细节会有出入,动手前务必以控制器手册里《报文定义》章节为准,这里给的是最常见形态。
字段具体落在哪个位置,不同型号差别很大。所以写解析代码前,我强烈建议先开一个TCP调试工具连上去抓几包真实报文,以真实报文为准写映射,而不是直接套网上老帖的偏移量。版本不同导致解析错位的血泪案例实在太多,这一条记住能省一整天。
2.3 PET模式是什么:与普通问答模式的三个关键差异
PET模式,这个在控制器通信配置菜单里经常出现的选项,本质上是一种通信会话模式。和“一问一答”的普通命令模式最大的区别是:连接建立后,控制器在满足使能条件的前提下,会主动把每一枪的拧紧结果向客户端推送,不需要客户端先发“请给我数据”的请求帧。
我把和普通模式的差异总结成三个核心点:
- 数据获取方式:问答模式要上位机不停发“取数据”命令,PET模式是“有枪就推”,消费方只负责接和解析;
- 会话状态:PET模式要求客户端与控制器维持长连接,并有周期心跳约定,连接断开则停止推送;
- 帧确认:部分固件下,PET推送帧要求客户端回Ack确认,否则控制器会认为上位机没收到,后续数据排队积压甚至重推。
如果设备工程师已经在控制器菜单里把通信模式切到了PET,上位机这边就不用再写“轮询取数”逻辑,这会显著降低CPU消耗,也让时延更小——枪打停后,通常几百毫秒内推送就到。代价是接收端必须把粘包和断包处理好,否则后面全是乱码。
提示:PET模式适合“长时间在线、数据流大、要求低延迟”的采集场景。如果只是偶尔连上去拉一次报表,普通问答模式反而更省事,不占长连接资源。
3. C# .NET采集端实现:Socket连接、报文切帧与数据映射
这一章开始写代码。按一个可运行的.NET 6/8控制台项目组织,代码精简到核心路径,可以逐步粘贴到你的工程里。
3.1 TCP长连接与断线重连框架
用C#做TCP客户端,第一反应是TcpClient,但产线网络环境和办公室差别很大:交换机重启、网线松动、控制器固件偶发“半开连接”都会导致Socket假死。所以我不用简单的一次性连接,而是做一个带重连循环的后台线程。
public sealed class PetClient : IDisposable { private readonly string _ip; private readonly int _port; private TcpClient _tcp; private Thread _loop; private volatile bool _running; private readonly Action<byte[], int> _onData; private readonly Action<string> _onLog; public PetClient(string ip, int port, Action<byte[], int> onData, Action<string> onLog) { _ip = ip; _port = port; _onData = onData; _onLog = onLog; } public void Start() { _running = true; _loop = new Thread(WorkLoop) { IsBackground = true }; _loop.Start(); } private void WorkLoop() { while (_running) { try { using (var tcp = new TcpClient()) { var task = tcp.ConnectAsync(_ip, _port); if (!task.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("connect timeout"); _tcp = tcp; _onLog?.Invoke("connected: " + _ip); var stream = tcp.GetStream(); var buffer = new byte[8192]; int read; while (_running && (read = stream.Read(buffer, 0, buffer.Length)) > 0) { _onData(buffer, read); // 每次读到字节就回调 } } } catch (Exception ex) { _onLog?.Invoke("error: " + ex.Message); } // 间隔3秒重连,用30次x100毫秒实现,避免疯狂抢连 for (int i = 0; _running && i < 30; i++) Thread.Sleep(100); } } public void Dispose() { _running = false; _tcp?.Close(); _loop?.Join(1000); } }逻辑说明:这个类是采集通道的底层基础。Start启动一个后台线程,线程内尝试ConnectAsync,5秒连不上直接抛超时;连接成功后进入stream.Read的阻塞循环,每读一段就回调_onData。Read返回0表示对端主动关闭,代码会退出using块把Socket关掉,进入重连等待。
参数说明:buffer大小8192对OpenProtocol这类小报文已经足够,一帧通常几十到几百字节,8K缓冲能覆盖一帧加残留粘包。如果采集的是大批量历史数据上传、单帧可能超过8K,这里要加大或者做二次拼包,否则会出现“帧被截断”的解析错误。
3.2 收到字节流后如何正确切帧:粘包与断包处理
上位机收到的是连续字节流:控制器推来的报文可能一次推一帧,也可能半帧或者两三帧粘在一起。没有“帧结束符”时,只能靠报文头里的长度字段切帧。OpenProtocol报文头两个字节是ASCII字符的帧长度,我用一个环形缓冲区做切帧:
public sealed class FrameParser { private readonly byte[] _buffer = new byte[1024 * 64]; private int _offset; public void Push(byte[] data, int count, Action<byte[]> onFrame) { // 先追加到缓冲区尾部 Buffer.BlockCopy(data, 0, _buffer, _offset, count); _offset += count; while (_offset >= 4) // 至少能读到2字节长度+2字节MID首部 { int frameLen = TryParseLength(_buffer, 0); if (frameLen <= 0) { _offset--; continue; } // 滑1字节重新找帧头 int total = 2 + frameLen; // 2字节长度头 + MID与数据区 if (_offset < total) break; // 整帧未到齐,等下一次Push onFrame(_buffer.Take(total).ToArray()); // 消费掉这一帧,剩余数据前移 Buffer.BlockCopy(_buffer, total, _buffer, 0, _offset - total); _offset -= total; } } private int TryParseLength(byte[] buf, int start) { if (start + 1 >= _offset) return 0; return (buf[start] - '0') * 10 + (buf[start + 1] - '0'); } }逻辑说明:Push每次把新字节追加进缓冲区尾部,随后在一个while循环里反复检查缓冲区头部能否构成一帧。先读前两个ASCII字符得到帧长,再检查当前缓冲是否已凑够“MID+数据区+长度头”的总长;不够就退出等下一包,够了就切出整帧交给回调,再用BlockCopy把剩余数据搬到头部。这样无论网络层怎么粘包、断包,最终交给业务层的都是完整帧。
参数说明:这段代码假设帧长度一定在头两个ASCII字符里,这是OpenProtocol的标准设计。如果控制器固件用了4字节长度或带起始位(比如百分号开头),请先按手册调整TryParseLength和起始判断。这个差异踩到就是几小时的排查时间,属于必看细节。
3.3 拧紧结果映射:从ASCII报文到强类型对象
切出来的帧先转成字符串,再根据MID分发。MID0060是主动推送的拧紧结果,我给出一个常见的字段排列方式:
public class TighteningResult { public string SerialNumber { get; set; } public string ProgramName { get; set; } public int Torque { get; set; } // 原始整数,单位换算按scale处理 public int Angle { get; set; } public bool OkFlag { get; set; } public DateTime Timestamp { get; set; } } public static TighteningResult ParseMid0060(string frame) { // 帧格式: 2位长度 + 4位MID + 数据区,这里直接对数据区做Split var body = frame.Substring(6); var parts = body.Split(' '); // 常见分隔是空格,也可能是分号 return new TighteningResult { SerialNumber = parts[0], // 实际偏移以手册和抓包为准 ProgramName = parts[1], Torque = int.Parse(parts[2]), Angle = int.Parse(parts[3]), OkFlag = parts[4] == "1", Timestamp = DateTime.Now }; }逻辑说明:这是把一帧报文“翻译”成能进数据库的对象。先剥掉“2+4”字符的帧头,把数据区按分隔符Split,再按字段顺序填充属性。字段顺序这里用的是极简版本,实战中必须以本控制器打印出来的实际帧为准,而不是照抄示例——我在多个项目里见过同型号控制器在两个固件版本之间字段顺序互换的情况。
提示:扭矩字段在OpenProtocol里通常传的是整数,比如3125表示31.25 Nm。不做单位换算直接入库,后面报表会错出天际,具体换算放到第5章避坑部分说明。
4. 采集工程化:多控制器并发、队列缓冲与配置管理
单台控制器验证跑通只是Demo,真正的产线是几十台控制器同时采集。这一章把单一脚本提升成一个可部署的采集服务。
4.1 多控制器并发:独立接收线程还是事件驱动
常见做法是每台控制器建一个PetClient实例、对应一个接收线程。线程数在几十个以内时,C#完全扛得住,不需要引入复杂的事件驱动框架。每个PetClient把收到的数据回调给同一个解析器实例,解析后推入共享队列:
var client = new PetClient(ip, port, (raw, count) => { _parser.Push(raw, count, frame => { var text = Encoding.ASCII.GetString(frame); if (text.Substring(2, 4) == "0060") // 只关心拧紧结果 { var result = ParseMid0060(text); _queue.Enqueue(result); } }); }, Console.WriteLine);要点有两个。一是所有控制器的Push操作都在各自线程里执行,FrameParser内部缓冲区是共享的,必须加锁;如上代码未显示锁,实际使用时建议在Push入口加lock (_lockObject),防止两个线程同时写内部buffer导致错乱。二是队列要线程安全,用ConcurrentQueue而不是普通Queue。
参数说明:ConcurrentQueue 的入队出队都是原子操作,适合“一收一存”的模式。如果产线规模更大、数据量到每秒几百帧,更建议换成System.Threading.Channels里的Channel ,它内置背压控制,队列满了可以阻塞生产者,避免内存被无限拉高。
4.2 落库解耦:内存队列与批量写入
采集服务最忌讳每收到一帧就打开数据库连接写一条。我的常规做法是:接收线程只负责入队,后台有一个独立的批量消费线程,每5秒或攒够500条就批量写一次:
private async Task BulkSaveLoop(CancellationToken ct) { var batch = new List<TighteningResult>(500); while (!ct.IsCancellationRequested) { if (_queue.TryDequeue(out var item)) batch.Add(item); if (batch.Count >= 500) { await SaveBatch(batch, ct); batch.Clear(); } else { await Task.Delay(1000, ct); // 1秒凑一批;有数据时靠count触发 } } // 退出前把剩余批次flush掉,防止丢最后几条 if (batch.Count > 0) await SaveBatch(batch, ct); }逻辑说明:TryDequeue取不到数据就短暂Delay再试;批量达到500条立即写库,否则等1秒。SaveBatch内部可以用Dapper的批量INSERT或SQL Server表值参数,写库吞吐比逐条插入高一个量级。
避坑点:批量写必须带flush逻辑。最典型的翻车场景是服务在系统停止时把队列里剩余数据留在了内存,结果“采集一整天正常,数据库却少最后几百条”。我每次在停止服务时都会强制调用一次flush,这个习惯救过很多次场。
4.3 配置管理:IP列表、端口与解析参数
把固定值抽到appsettings.json里,生产换一条线就不用改代码重新编译:
{ "Controllers": [ { "Name": "工位A-01", "Ip": "192.168.1.31", "Port": 4545, "Enabled": true }, { "Name": "工位A-02", "Ip": "192.168.1.32", "Port": 4545, "Enabled": true } ], "Parser": { "SplitChar": " ", "AcceptMids": ["0060", "0061"], "TorqueScale": 100 }, "Database": { "Provider": "sqlserver", "ConnString": "Server=.;Database=TorqueDB;Trusted_Connection=True;" } }说明:TorqueScale=100表示报文整数值=实际Nm×100,后面换算直接用这个系数;AcceptMids用于过滤不需要的报文,减小解析压力。调试初期可以只开0060,等稳定了再按需加,能少处理很多无用数据。
提示:启用配置前先检查控制器端口。不同型号的默认采集端口可能不一样,和端口写错相比,反而是最容易被忽视的一处配置混淆。
5. 避坑记录:PET模式采集翻车的五个高频原因
这里记录多个项目里实际踩过的坑,按“现象→原因→解决”顺序梳理。产线环境问题千奇百怪,下面五条出现频率最高,值得逐条对比排查。
5.1 连上PET模式却收不到主动推送
现象:以太网连接正常,客户端也能收到一些握手帧,但打了一枪之后,拧紧结果迟迟不来。
原因:控制器端PET模式的“主动推送”有前置条件。通常在菜单里除了切换PET,还要把“自动发送拧紧结果”或“立即发送MID0060”的开关打开。设备工程师经常只改了模式,没开推送。另一个高频原因是客户端没有按约定回复Ack帧,控制器认为上位机不在线。
解决:进控制器菜单的通信设置,确认两项同时开启:PET模式、结果自动上报。同时在客户端抓包看是否有Ack请求帧,有就实现Ack自动回复,返回包格式是MID0002加原MID值。这个Ack不能省,省了就是“连上了但不出数”的玄学问题。
5.2 断线重连后数据不再上报
现象:采集服务重启或网络抖动后,客户端重连成功,但控制器不再推送数据,直到重启服务甚至重启控制器。
原因:这是典型的TCP半开连接问题。控制器侧认为旧会话还存活,不释放会话资源;新连接虽建立,但被控制器识别为“第二会话”,而该型号只允许一个会话占用主动推送通道。
解决:通信建立后,客户端按固定周期(比如10秒)发送一次心跳请求,让控制器感知会话活着;如果怀疑是半开连接,用超时机制主动断开再重连。同时,把控制器的“断开超时时间”调短,可以让僵尸会话更快被回收。这个坑在产线网络不稳定的环境中几乎必现,心跳不能省。
5.3 粘包与断包导致解析全是乱码
现象:刚开始接入时,打印出来的帧一段长一段短,有时候一帧被切成两半显示。
原因:TCP是字节流协议,没有天然的消息边界。PET模式推送是高频连续发,很可能一帧被拆成多次发送,也可能几帧粘在一起到达。我第一版代码就栽在这里——没做缓冲区,直接按一次Read的字节数当一帧解析。
解决:用第3.2节的FrameParser做缓冲切帧,核心是靠报文头长度字段切分,切不齐就先缓存。注意不要把“网络包大小”当“帧大小”,这是新手最高频的误解。调试时先打印原始十六进制,确认长度字段解析正确,再谈业务字段。
5.4 扭矩值和显示屏差了一个数量级
现象:控制器屏幕上显示40.5 Nm,数据库里却是4050。
原因:OpenProtocol里扭矩值通常按Nm×100的整数传输,部分固件用更小的解析度,控制器界面做了显示换算,原始报文是纯整数。把整形报文直接入库,报表看起来就是放大了100倍。
解决:在配置里增加TorqueScale统一换算,入库前除以当前控制器对应的缩放系数,Angle也按类似方式核对。建议在测试环境打几次已知力矩值的螺丝,用标准扳手复核验证换算系数后再上线。单位换算这种问题,查半天都不如实测一次来得快。
5.5 同一把枪重复上报导致重复报警
现象:MES收到两条完全相同的拧紧结果,防错系统因此报了两次警。
原因:PET模式下如果以太网闪断,控制器可能重传上一次未确认的报文;消费者重连后收到旧报文;或者Ack确认逻辑写得不严谨,控制器按协议重发。
解决:在业务层做去重,以“序列号+时间戳+结果帧MD5”做唯一过滤。对于重发的历史帧,根据时间戳是否在允许时间窗内决定是否丢弃。这里去重不能只靠数据库主键,一定要在内存或入库前单独判断,否则高频推送时照样能秒级重复入库。
6. 进阶:离线补发、MES对接与采集完整性自检
采集跑通只是开始,真正让产线信任这套数据的,是“补发”和“完整性自检”这两个动作。
6.1 离线补发与队列容灾
产线网络只靠“在线数据推送”保障追溯是远远不够的。我通常在采集主流程之外启动一个补发任务:对每台控制器周期性查询一次漏发的记录,把在线推送漏掉的数据补回来。
// 每台控制器每天零点对前一天做一次补拉 var request = BuildMid0061(start: DateTime.Today.AddDays(-1), end: DateTime.Today); client.SendFrame(request);PET模式本身有主动推送,再主动发查询会引入重复数据,因此补发查到的数据必须沿用5.5的去重方案,关键字段匹配上了就不落。补发频率建议一天一次或每次交接班一次,不要设成每分钟,避免和主动推送互相干扰。
6.2 MES对接的三种形态
- 实时推送:解析结果直接通过HTTP或消息队列推给MES,适合防错联动;
- 文件交换:每小时生成CSV或JSON快照,适合MES那边有定期导数流程的场景;
- 中间库表:采集服务写库,MES通过视图读取,耦合最低。
我最近一个项目用的是中间库表,MES系统那边接口封闭,给视图反而比推消息简单。关键是两个系统要提前约定“无重复、无遗漏”的判定规则,否则对同一枪数据口径不一致,后面扯皮很消耗精力。
6.3 完整性自检:用计数器字段防丢数据
不少控制器在每帧里带一个自增的“消息编号”或结果计数器。我养成的习惯是把该字段单独存一列,每次巡检时拉出这个字段的相邻差值做检查——差值大于1说明中间丢包,需要补发。用SQL就能查出来:
SELECT SerialNumber, MsgCounter, LAG(MsgCounter) OVER (ORDER BY Timestamp) AS PrevCounter, MsgCounter - 1 - LAG(MsgCounter) OVER (ORDER BY Timestamp) AS LostCount FROM TighteningLog WHERE ControllerId = 'A-01' ORDER BY Timestamp;逻辑说明:窗口函数LAG取上一行的计数器值,当前值减去上一行值再减1,就是中间丢掉的帧数。结果表里LostCount大于0的行就是漏数据点,可以直接触发补发任务。
从那以后,每次上新产线的采集项目,我都强制把“计数器差值检查”写进上线清单,不管MES那边催得多急,这条不跑完不交付。数据完整性这种事,靠自觉不如靠机制,希望帮到你。
本文还有配套的精品资源,点击获取