做上位机的人迟早会碰上这么一天:LabVIEW工程跑得好好的,产线上某台日系PLC换了型号,原来的串口屏驱动废了,现场又催着当天把数据送进MES。这时候翻通讯手册,最实用的一条路就是直接用MC协议从LabVIEW读写PLC里的bool、浮点、I32和字符串。这篇笔记就是围绕这个项目写的,源码整理完毕后丢到了某代码托管平台,链接在文末。项目本身不复杂,核心是一个TCP/IP客户端加一套MC协议的组包拆包VI,几十个小时踩坑换来的东西,希望你能少走一点弯路。适合刚被派去搞上位机对接、又不想上OPC的工程师,也适合想搞懂工业以太网帧结构的人。
这个项目我做的第一件事不是写代码,是把需求列清楚。现场要求读写的东西很杂:M点要当布尔用,D寄存器里有的要拼成32位整数,有的要按IEEE754当浮点读,还有一小段固定长度的字符串要按字拆开再重排。如果走OPC,这些问题会被封装掉一半,但部署那套东西同样烦人。于是干脆自己用MC协议实现,所有数据类型都在自己手里,想怎么解析就怎么解析。
1. 为什么我选择直接写MC协议,而不是省事上OPC
1.1 现场的真实需求:要读写的不只是寄存器
项目开始前,我把现场的数据点整理成了一张表,大概几十个信号:状态位用M点,温度用D区两个连续字拼成浮点,计数用D区两个字拼成I32,设备型号是D区里一段8字节的ASCII字符串。这类需求在工控项目里太典型了,几乎每个项目都逃不掉。
最初有人建议用OPC服务器中转,DP既视感很强,拉几个测试点位没问题,但涉及几十个点、要同时读写、还要带故障恢复时,OPC那套通讯状态管理并不省心,而且版本、兼容性、授权都是额外的坑。对我来说,用LabVIEW直接走TCP/IP套接字去发MC协议帧,从依赖上说只靠一个TCP节点,部署时不需要额外装服务,维护上反而清爽。
1.2 LabVIEW里常见方案对比
我简单列一下我们在项目里对比过的方案,方便你判断要不要自己写:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 官方通讯功能块 | 兼容性好,配置简单 | 需要对应通信模块或软件包 | 项目规模小、点位少 |
| OPC Server中转 | 点位管理方便,客户端通用 | 部署复杂,版本授权有成本 | 多上位机共享同一批数据 |
| 自写TCP加MC协议 | 依赖少,底层可控,学习价值高 | 需要熟悉帧结构,前期开发慢 | 要深入协议、追求可控性 |
做完这个对比我选择了第三个方案。回头看,最大收获不是省了钱,而是把MC协议彻底吃透了,后面遇到通讯异常,能直接在抓包工具里看帧,几秒钟判断是自己发错还是PLC回了错误码,这对现场排障太重要了。
2. 通讯之前先弄清MC协议的帧是怎么拼出来的
MC协议有好几种帧形式,串口常见ASCII帧,以太网可以走ASCII也可以走二进制。我们这个项目选的是TCP/IP加二进制帧,也就是常说的3E帧二进制模式。选二进制的原因很直白:帧短、不用做十六进制字符串转换、效率高。代价是数据不太直观,但只要按字节结构拼,稳定性和性能都很好。
2.1 二进制3E帧的固定头部
每个请求和响应报文,前面都有一段固定头部,用来告诉PLC“这笔请求来自哪个网络、哪个站、要发给哪个模块”。实际项目中不搞多CPU多网络时,头部大部分字段可以填固定值。我这里用一张表把字段顺序列清楚:
| 字段 | 长度 | 常用值 | 说明 |
|---|---|---|---|
| 帧子头 | 2字节 | D0 00 | 二进制帧标记,固定 |
| 网络编号 | 1字节 | 00 | 一般单网络填0 |
| PLC编号 | 1字节 | FF | 目标站号,配合PLC参数 |
| 目标模块IO编号 | 2字节 | 03 FF | 通常是默认模块地址 |
| 目标模块站号 | 1字节 | 00 | 单CPU基本填0 |
| 请求数据长度 | 2字节 | 可变 | 从命令字开始计算,小端发送 |
这里有个容易忽略的细节:请求数据长度是指“从命令字开始到报文结尾”的字节数,不包括前面这9个字节的头部。写VI计算长度时,很多新手会把整帧长度塞进去,结果PLC一直不回。我在第一版里就犯过这个错,后来抓包才看明白,这个长度字段是按协议内部数据区算的,别把头部带上。
2.2 命令字、子命令与软元件地址
头部之后是命令区。MC协议的命令字用2字节表示,例如读字单元和写字单元对应不同的命令码,二进制模式下发送时这2字节是反着发的,命令字0x0401在线路上实际是01 04。这一点我特意放前面说,因为它特别容易让人怀疑人生。每个命令后面还会带一个2字节的子命令,用来表示是批量读取、随机读取还是批量写入。
软元件地址在二进制帧里是固定格式:设备码1字节、起始地址2字节、位号1字节。设备码就是D区、M区、W区这些区域的编号,不同固件版本可能有差异,我把常用设备码做成了常量字典,放在一个独立VI里,改一处全局生效。比如D区设备码常见是A8,M区是90,W区是B4。这个值要以你手上的PLC手册为准,不要照抄网络上的某个序号。
设备地址部分要特别注意小端和位号的关系。比如D区100号,地址100转十六进制是0x0064,小端发送就是64 00,然后位号填0。如果是M点这类位区,位号通常填0,真正读写位时靠“读取起始地址+点数”来定位。做组包VI时,这三个字段一定要按顺序拼,不然PLC会回一个莫名其妙的软元件指定错误。
2.3 从D100读2个字,完整报文长这样
光说结构太抽象,我直接给一个例子。用二进制模式从D100开始读2个字,请求数据部分是:
- 命令字:读字 0x0401,发送时01 04
- 子命令:批量读取 0x0001,发送时01 00
- 软元件设备码:D区 A8
- 起始地址:D100 = 100 = 0x0064,发送64 00
- 位号:00
- 读取点数:2个字,发送02 00
合起来的请求数据区十六进制就是:01 04 01 00 A8 64 00 00 02 00
前面加上固定头部,再把请求数据长度设为后面这10个字节,也就是长度字段写0A 00,这样就是一整帧完整的读取请求。写VI的时候,把这一帧结构做成一个子VI,输入起始地址、点数、读写类型,输出一个U8数组,后面所有功能都复用这一个组包器。
3. LabVIEW侧TCP客户端的架构
我确定用LabVIEW实现这套协议后,第一步是把通讯引擎单独抽出来,不让界面和协议混在一起。原因很简单,现场调试时你会发现,协议层的问题和界面刷新问题是两个世界。VI如果几十个控件摆在同一个循环里,遇到通讯超时你都不知道是界面卡了还是PLC没回。
3.1 连接管理与看门狗
TCP通讯最基础也最重要的环节就是连接管理。LabVIEW自带的TCP Open Connection节点在断网或PLC断电时不会立刻报错,如果操作超时设得不合理,界面会像死机一样。我在项目里采用了一个独立的状态机,用枚举记录“未连接、连接中、在线、重连中”四个状态,每轮循环先检查当前连接状态,再决定要不要发起下一次握手。
看门狗也放在这个状态机里。每成功收发一帧,就刷新一个时间戳,界面线程只读这个时间戳,判断时间差超过2秒就把PLC标记为离线。这么做的目的不是省事,而是让读写的执行VI和显示无关。实时性要求不高的场景,界面哪怕卡了半秒,也不至于把全部通讯流程拖垮。
3.2 收发消息怎么处理粘包
这是写TCP通讯必踩的坑。MC协议基于TCP,TCP本身只保证字节流顺序,不保证一帧数据正好在一个读取调用中完整到达。PLC返回的响应长度不定,如果你做一次TCP Read就当成完整报文解析,等点位多、报文变长之后,必然遇到半包、粘包。表现就是偶尔读到正常数据,偶尔把两帧拧在一起。
我的解决办法是做一个“按期望长度收包”的子VI。先读固定头部,从长度字段解析出后续数据区的字节数,然后在循环里继续读,直到读满为止。缓冲区用移位寄存器积累,读出来的字节补进buffer,再判断长度是否满足。如果超过预期长度,说明粘包了,把多余字节留在缓冲区里,下一轮解析继续用。这套逻辑在LabVIEW里写起来也不难,就是一个状态判断加两个移位寄存器的事。
3.3 把协议组包/拆包做成独立VI
真正让项目变好维护的,是组包和拆包完全独立。组包VI只管“输入数据点信息,输出完整发送字节数组”,拆包VI只管“输入接收字节数组,输出解析好的数据和错误码”。业务循环不接触原始字节。这样即使以后换PLC系列、改协议版本,只需要替换这两个VI的内部实现,业务逻辑完全不用动。
LabVIEW里我习惯用簇传参数,一个簇包含设备码、起始地址、点数、数据类型、读写标志。组包VI内部把这个簇翻译成协议字节。拆包VI则逆操作,把响应里的数据区按读请求的类型还原成bool、I32、浮点或者字符串。数据类型在请求时就通过簇里的枚举定好,拆包时不会猜错,这是很多人的项目后期转型期才补的洞。
4. bool、浮点、I32、字符串四类数据的读写实现
这节是项目核心。通讯帧本身只是载体,真正让现场好用的是把这四类数据准确翻译出来。我逐个说。
4.1 BOOL:按位区读写M点
读写M点这类位元件,和字元件最大的区别是命令字不同。字读取用0x0401,位读取用0x0301;字写入用0x1401,位写入用0x1301。这个分类必须严格分开,你拿读字的命令去读M点,PLC会直接回错误响应。组包VI里我干脆定义了两个入口,一个走字通道,一个走位通道,防止业务层把命令搞混。
M点的地址解析相对简单,比如M0到M9这10个点位,软元件地址就是M区设备码加上起始地址0,点数填10。读回来的数据区是位打包后的字节,LabVIEW里可以直接把U8数组按位拆开。这里有一个经验:拆位时注意位的顺序,MC协议批量读位返回的数据,低位在前还是高位在前取决于PLC固件,建议拿到真机后用一组已知的M点状态做验证,而不是靠手册凭空猜。
写单点则更简单,写入位时数据区是一个字节,置1填01,置0填00。我在源码里还提供了一个按地址偏移连续写位的功能,用于整段控制字下发,这类需求在设备启停逻辑里非常常见。
4.2 I32:两个字的拼合与符号扩展
D寄存器是16位的,要表示32位有符号整数就得读两个连续字。假设D100存的是低16位,D101存的是高16位——这个顺序不同PLC程序师写法可能相反,所以源码里把“低字在前”和“高字在前”做成了配置项,默认低字在前。读取时先把两个U16还原出来,高字左移16位再和低字按位或,得到一个U32,最后再类型转换成I32,符号位自然保留。
我踩过的一个坑是:不能简单地把两个U16相加,觉得一个乘65536另一个加上就行。那样做虽然数学结果对,但在LabVIEW里处理负数时会出问题。原因是类型转换的符号扩展时机不对。正确做法是先转U32拼位,最后一次性翻成I32,这样编译器对补码的处理是完整的,负数不会被中间步骤截断成错误的大正数。
写入I32时是逆操作,先把I32转成U32,然后拆成高16位和低16位,再分别写入两个连续D寄存器。这里有一个实践中常见的坑:写了高字再写低字,或者反过来,中间瞬间PLC里的值会是不完整的。如果下游设备恰好在这个间隙读了这个寄存器,就会得到一个错误的中间值。更稳妥的做法是先写一个“数据有效”标志位,等两个子字都写完后再把标志位置1。
4.3 浮点:直接读32位IEEE754
浮点数是这四个类型里最容易让人摸不着头脑的,因为PLC里存的浮点字节序和PC端可能不一样。我项目的做法是读两个连续D寄存器以后,先还原成U32,在LabVIEW里用类型转换把U32转换成单精度浮点。只要底层字节没有被人为交换,这个转换就是准确的,LabVIEW的SGL格式本身就是IEEE754单精度。
麻烦的是字节序。有一部分PLC固件在以太网帧里对浮点做的是高字在前,而你本地拼的U32是低字在前,或者相反。遇到这种情况,单纯类型转换读出来的浮点会是一个天文数字,比如0.5变成45875200。排查方法很简单,读一个已知的浮点常量,用十六进制看U8数组,再对比理论值。如果发现高低两个字整体调换,就在LabVIEW里对U16数组调用一次反转。
最保险的做法是写一个“浮点读取”子VI,内部先把U16数组拼接成U32,再加上一个字节序配置布尔量,为真时先做字交换再做类型转换。这样现场遇到不同固件时,不需要改主程序,只改配置就能适配。这个设计我从上一个串口项目迁移过来,沿用到现在,帮我在多个现场省了大量时间。
4.4 字符串:字节序才是最大的坑
字符串本质上就是一堆字节,但通过D寄存器传输时,一个D寄存器能装两个ASCII字符,于是出现了两个棘手问题:一是两个字谁在前,二是结尾的空字符怎么处理。我遇到的项目里,PLC侧字符串程序大多把高字节放在第一个字符的低字节?这个词序每个项目都不一样,所以源码里做成了运行时模式。
我在LabVIEW的实现思路是:先按读取U16数组的方式拿到N个字,然后展开成字节数组。展开后的字节排列默认假设一个字的前半个是ASCII码顺序的前一个字符,后半个是下一个字符。如果现场对不上,就把每两个字节翻转一次。做完这步,再用“非空字符截取”把末尾的0去掉,得到最终的字符串。注意别直接对整个字节数组用Reverse,那样整行都反了,正确做法是逐字内部交换。
字符串写入时要补足长度。例如目标缓冲区是10个字,也就是20字节,你写入“abc”三个字符后,剩下的字节必须补0,而且要确认PLC侧读取时不会因为末尾补0而把长度算错。我在写入VI里建议做长度校验,超过缓冲区直接报错,避免把字符串溢出写到相邻寄存器里,那会是现场事故级别的bug。
5. 联调阶段踩过的坑和解决办法
帧结构写完后,你以为就通了吗?没有那么顺。我这块几乎每个现场都会冒出奇怪的通讯问题,最后发现大部分不是报文内容错,而是时序、字节序、生命周期管理上的问题。这里挑几个印象最深的写下来。
5.1 帧里的命令字为什么是反的
第一次用串口助手把组包VI输出的字节打出来,看命令字是01 04,我对手册上的0401,怎么看怎么别扭。网上很多例程里打印出来的又是04 01,到底哪个对?答案是:手册为了排版好读,按十六进制原样写0401,但协议线路上是低字节在前,所以实际发送时先从低字节开始,变成01 04。如果你抓包看到报文明明是01 04,不用担心,这是正常的,不是bug。
这个看似小的问题,实际会浪费不少时间。因为它只影响你对“正确报文”的判断,而不会产生明显的错误提示。建议调试阶段把收到的原始字节一律用十六进制数组显示出来,不要用字符串显示,否则不可见字符会让帧结构完全乱掉。我在源码里做了个调试面板,每次收发都会把原始字节记录到日志栏,这个功能在后来的现场定位中帮了大忙。
5.2 响应长度不够就动手解析
TCP通讯的另一个经典场景是,响应头只有几个字节,后续数据还没到,程序就开始解析。我第一次写拆包时没有做缓冲区积累,结果读到的响应时好时坏,有时候能解析出正常值,有时候数据只有一半。后来按前面说的“读满长度再解析”的方式实现,问题一下子就消失了。
除了等待长度,还要检查响应里的结束代码。MC协议响应数据区前2字节是结果代码,0表示正常,非0表示PLC侧检测到错误。常见的错误有软元件不存在、命令字不支持、地址越界。当读到错误代码时,不要把旧数据当有效数据用,我在业务层规定:任何一帧通讯失败,本次所有数据标记为无效。宁可显示问号也不显示一个错得离谱的数值,这是工业软件的底线意识。
5.3 断线重连和PLC重启恢复
产线设备免不了有人动网线、有人给PLC断电重启。我发现LabVIEW TCP连接在这些情况下反应很迟钝,有时要等十几秒甚至更久才能报错。如果上位机傻等,现场画面就会冻住。解决办法是加一个循环心跳,定时发送一个长度很短的状态读取请求,比如读SD寄存器的一个字,如果连续3次无响应,强制关闭连接并进入重连流程。
重连流程里要加点退避策略。如果PLC彻底停机,你1秒重连一次没有任何意义,只会刷满日志。我采用的是递增间隔:前几次1秒,然后2秒、5秒、10秒,封顶10秒。PLC上线后,由于我们每次都重新建立TCP连接,协议本身无需重新握手,只要第一帧读取成功,业务数据自动恢复。所有通讯状态都会实时写到一个状态簇里,供界面上的状态灯使用。
6. 开源代码的目录设计和使用方式
项目源码我整理成了一个LabVIEW项目库,结构尽可能让第一次打开代码的人也能快速找到入口。因为我现在写工程习惯一开始就把目录划分好,后面维护会轻松很多。这里简单说一下每个部分的作用。
6.1 VI组织与命名
顶层一个主VI,负责启动状态机、初始化配置、调用读写循环。目录下面分这么几组:协议核心、数据类型转换、通讯管理、配置工具、调试面板。协议核心里面只有组包、拆包、字节序转换这三个VI,是整套系统里最需要被静态审查的部分,保持函数式写法。数据类型转换是四类数据各自独立的VI,改动某个类型不会波及别处。配置工具负责从配置文件加载点位表,调试面板则展示收发日志和状态。
VI命名我统一用了“前缀_功能”的格式,拿过来就能感觉到一批人维护过。最忌讳的是把所有功能堆在一个巨大的VI里,逻辑再好也难读。哪怕你自己写的代码,一个月后回头看,也会感谢当时把功能拆开的自己。
6.2 如何配一个“PLC数据表”
源码里带了一个样例配置文件,格式是CSV,每一行是一个数据点信息:点名称、设备区、起始地址、点数、类型、读写方向、字节序标志。程序启动时一次性加载到内存里的数据簇数组,读写循环就按这份配置循环扫描。这样现场要新增一个温度信号,只需要在CSV里加一行,不需要改任何VI。
这个设计是项目能落地到不同设备的关键。因为现场的点位永远会调,如果每次都要打开VI重新编译,交付和运维成本都会上升。配置文件虽然简单,但配合上程序启动时的点位合法性检查,基本能挡住90%的错误输入。源码里还带了一个配置检查VI,会逐个点位比对设备码和地址范围,有问题直接弹窗,不会带病启动。
6.3 性能与后续扩展想法
单次通讯能读的点数有限制,不同CPU限制不同,有的批量读字上限是960字,位读取上限更高。我的源码里封装了一个自动分片功能,一个大段数据超过上限时,会拆成多帧请求发送,再拼回一个完整数组。这样业务层可以一次性要求读2000个字,底层自己处理分帧和合并。
后续可以扩展的方向也很多。比如在状态机里增加多个PLC的连接,每一路连接挂在独立的通讯任务里,点位表通过数据区编号区分。再比如把上下位机的时间同步、报警事件上送、配方管理这些业务塞进同一个通讯引擎。MC协议本身提供了丰富的帧类型,这套框架完全可以复用。我之后有时间会把这几个方向继续完善,到时候再更新到仓库里。
源码开源在半年前就开始了,地址我放在项目的说明文件顶部。如果你现在接到了一个类似的需求,建议你先把这个层级结构跑通一遍,再用真实PLC替换默认参数。等你用自己手里的PLC手册把设备码和默认IO编号改完后,这个通讯引擎就可以成为你以后所有项目都能复用的基础模块了。