1. 为什么这类需求最后都选了ModbusTCP
1.1 需求场景:机器人坐标实时上送
好几年前,我做一个汽车零部件焊装线的项目,ABB机器人要把当前TCP的笛卡尔坐标和姿态数据实时发给PLC,让中控大屏显示焊接轨迹。设备是西门子S7-1200,机器人是ABB 6700,中间没有Profinet授权,客户又要求方案必须通用、可维护、后续换品牌PLC也不能推翻重来。试了一圈下来,落地最快、跨品牌兼容性最好、又不需要额外买软件授权的,就是ModbusTCP。但真正动手之后才发现,传几个16位整数、开关量都很简单,唯独Float这件事,模块文档里没写清楚,网上的资料也东一句西一句,最后全靠自己抓包、对照字节序表才调通。
这篇东西就是把我当时的完整思路和代码整理出来,给后面做ABB机器人和PLC数据交互的兄弟们一个可以直接参考的底子。不管你是刚接触机器人通信的电气工程师,还是被Float字节序折磨过的集成商调试人员,这篇都能帮你少走弯路。
1.2 ModbusTCP相比Profinet等方案的优势
先说结论:ModbusTCP不一定是最好的协议,但它是"最不坏"的协议。
Profinet和EtherNet/IP固然性能强、诊断丰富,但有个现实问题——机器人这侧要装对应的通信板卡或授权,PLC那侧要导入GSD文件或EDS文件,两侧任意一侧没配好就白搭,而且这些授权和板卡的价格不便宜。DeviceNet、CC-Link这类现场总线更是要看机器人控制柜里有没有对应的选装硬件。
ModbusTCP的优势在于它是个开放标准,几乎所有PLC都内置支持。ABB的RobotWare里也有现成的Socket指令,不需要额外授权。而且ModbusTCP的报文格式非常简单,一个请求头加功能码加数据,抓包看几遍就能完全掌握。对工程师来说,"能看懂每一个字节"本身就是最大的安全感。
代价就是它确实比较"原始"——只定义寄存器和线圈,数据宽度是16位,没有现成的Float类型。你传输Float就要自己在应用层做编码解码,这就引出这篇文章要重点讲的事情。
2. 通信架构选型:内置IO配置还是Socket手写
2.1 内置ModbusTCP IO配置方案
ABB较新版本的RobotWare里,可以在RobotStudio的IO System中添加ModbusTCP设备,然后定义GI、GO这类Group信号。听起来很方便对吧?配置完,机器人程序里直接用GetSignal和SetSignal就能读写。
但实际用起来有几个限制。第一,配置方式不够灵活,每个信号地址、长度、字节序都要在配置界面里一项项填,批量定义几十个点非常痛苦。第二,Float没有现成的信号类型,你还是要把它拆成两个Group信号(每个16位),然后在PLC侧拼回32位浮点数,字节序谁对齐谁不对齐还是得自己调。第三,排查问题太费劲——一旦数据不对,你面对的是一个自动封装的IO模块,报文细节看不到,只能瞎猜是配置错了还是PLC侧映射错了。
所以内置配置适合什么场景呢?开关量、整数监控、高低速简单的DI/DO交换,比如机器人给PLC发个"循环完成"、PLC给机器人发个"启动允许",这种场景用它非常省事。但凡是涉及批量Float、自定义功能码、多台PLC对接,我建议直接走下一种方案。
2.2 Socket手写方案
Socket方案说白了就是用RAPID的SocketCreate、SocketConnect、SocketSend、SocketReceive这些指令,自己拼ModbusTCP报文。报文长什么样、发到哪个地址、怎么解析响应,全都在代码里,完全透明可控。
这套路听起来"原始",但恰恰是它最稳。ModbusTCP报文格式就这么几个字段:MBAP头7个字节,加功能码加数据区。你自己拼,就彻底绕开了ABB IO配置那一层黑匣子。Float传错了在哪个字节,Wireshark一抓包立刻就能定位。而且这套代码拷贝到任何一台ABB机器人上都能用,不依赖RobotStudio版本,也不依赖授权等级。
2.3 怎么选
我个人的判断标准很简单:传输点少且长期固定、全整数、只做状态交换,选内置配置。涉及浮点数、点数超过20个、以后可能要加协议转换,选Socket手写。我这篇文章的代码主线走Socket方案,这样Flexible,后期想怎么改寄存器映射都行。
3. Float过关记:编码、拆分与字节序判定
3.1 Float为什么不能直接塞进Modbus
先普及一个基础认知:在PLC里,西门子叫REAL,三菱叫Float,很多国产组态软件也叫Float,其实都是同一个东西——IEEE 754标准的32位单精度浮点数。一个Float占用4个字节,正好等于2个Modbus寄存器(每个寄存器16位)。
问题在于,Modbus寄存器是16位一个单位,它本身不知道你这2个寄存器合在一起是一个Float。所以你能做的就是:把32位Float拆成两个16位,写到连续的两个寄存器里;读的时候反过来,把两个16位拼回32位。这个拆分逻辑本身不复杂,难在字节序。
3.2 字节序的四种错法
IEEE 754里,1.0这个数存成32位是0x3F800000,也就是4个字节3F 80 00 00。现在假设你把这4个字节通过两个寄存器发出去,PLC侧把这些字节组合成32位,就有四种可能:
| PLC组合结果 | 寄存器1 | 寄存器2 | 字节序情况 | 是否等于1.0 |
|---|---|---|---|---|
| 3F 80 00 00 | 0x3F80 | 0x0000 | 字内、字间全部大端 | 是 |
| 00 00 3F 80 | 0x0000 | 0x3F80 | 字序反了 | 否 |
| 80 3F 00 00 | 0x803F | 0x0000 | 字内字节反了 | 否 |
| 00 00 80 3F | 0x0000 | 0x803F | 字内字间都反 | 否 |
ABBRobot控制器的CPU是x86架构,本机字节序是小端。你用PackRawBytes把一个Float打包出4个字节,得到的顺序通常是00 00 80 3F。如果你直接把这4个字节当成两个寄存器数据发出去,PLC按大端的正常习惯组合出来就是第四种情况00 00 80 3F,浮点数变成了一个很小的非规格化数,或者干脆显示成NaN。
Modbus协议本身规定,线上传输时16位寄存器内部是高字节在前(大端)。所以你的责任就是让最终PLC收到的两个寄存器分别是0x3F80和0x0000,这样PLC按大端组合就正好是1.0。
3.3 判定与修正方法
现场第一步永远是"发一个已知浮点数测量"。我在调试时习惯先让机器人反复发送1.0、2.0这种特征明显的值,然后在PLC里看收到的那两个寄存器十六进制值,对照上面那个表就能判断属于哪种错乱。
修正方法有两个层次。如果这侧发的值是直接通过\Float4 \Net打包的,RAPID会按网络字节序生成4个字节,那结果天然就是3F 80 00 00,直接对接PLC就行。如果RobotWare版本不支持\Net参数(有些老版本确实没有),或者你走的方案本来就是逐字节拼帧,那就在代码里手动做一次字节反转:PackRawBytes本机打包得到00 00 80 3F,发出去的时候按3F 80 00 00的顺序逐字节填进帧里就行。我在后面的代码里写的就是手动反转这个版本,因为它对系统版本没要求,兼容性最好。
这里还要专门提醒一个细节:字节序问题不只是发生在Float上。就算你传的是16位整数,只要机器人按小端Packed,PLC按大端解析,一样会错。区别在于Integer错了你一眼能看出来(比如发了1收到256),Float错了常常显示成天文数字或者NaN,没有经验的工程师会在那边查半天通信配置,其实根因就是字节序。
4. RAPID完整代码实现:连接、帧构造与Float读写
4.1 总体代码结构
代码按功能分成四块:通信参数定义、连接管理、帧构造、业务读写。我写代码的习惯是把连接和帧处理做成通用函数,业务层只负责"我要写哪个Float到哪个地址"这种逻辑,这样后面无论传坐标还是传姿态,都只是换个参数调用的事。
!-------------------------------------------------- ! 模块入口 !-------------------------------------------------- MODULE ModbusTCPFloat ! 通信参数 CONST string PLC_IP := "192.168.1.10"; CONST num PLC_PORT := 502; CONST num UNIT_ID := 1; VAR socketdev plc_socket; VAR bool comm_ok := FALSE; VAR num trans_id := 0;4.2 连接管理
RAPID里的SocketConnect是阻塞式调用,带\Time参数可以设置超时。我一般封装一个带重试的连接函数,每次失败后关闭socket,等2秒再试,避免socket资源泄漏。
!-------------------------------------------------- ! 建立连接(带重试) !-------------------------------------------------- PROC ConnectToPLC() SocketClose plc_socket \SkipError; SocketCreate plc_socket; SocketConnect plc_socket, PLC_IP, PLC_PORT \Time:=2; IF ERRNO <> ERR_OK THEN comm_ok := FALSE; SocketClose plc_socket \SkipError; ELSE comm_ok := TRUE; ENDIF ENDPROC这里有个非常容易踩的坑:SocketClose如果不加\SkipError,在socket还没创建或者已经断开的场景下会直接报错中断程序。所以统一都加上,宁可多关一次,不能漏关一次。
4.3 帧构造
发Float到PLC,用的是Modbus功能码16(0x10)写多个寄存器。帧结构从第1个字节开始排:
| 字节位置 | 内容 | 说明 |
|---|---|---|
| 1-2 | Transaction ID | 每次请求递增,用来把响应和请求对上 |
| 3-4 | Protocol ID | 固定0x0000 |
| 5-6 | Length | 从Unit ID开始的字节数 |
| 7 | Unit ID | 一般填1,要和PLC侧配置一致 |
| 8 | 功能码 | 0x10 |
| 9-10 | 起始地址 | 填0就对应PLC的40001 |
| 11-12 | 寄存器数量 | 这里传Float就是2 |
| 13 | 字节数 | 这里传Float就是4 |
| 14-17 | Float的4字节 | 按大端排列 |
!-------------------------------------------------- ! 写一个Float到PLC(写2个保持寄存器,FC=16) !-------------------------------------------------- PROC WriteFloatToPLC(num val, num start_addr) VAR rawbytes frame; VAR rawbytes float_bytes; VAR num b1, b2, b3, b4; trans_id := trans_id + 1; ! 1. MBAP Header(7字节) PackRawBytes trans_id, frame, 1, \Int2; PackRawBytes 0, frame, 3, \Int2; PackRawBytes 11, frame, 5, \Int2; PackRawBytes UNIT_ID, frame, 7, \Int1; ! 2. PDU:功能码+地址+数量+字节数 PackRawBytes 16, frame, 8, \Int1; PackRawBytes start_addr, frame, 9, \Int2; PackRawBytes 2, frame, 11, \Int2; PackRawBytes 4, frame, 13, \Int1; ! 3. 把Float拆成4个字节,按大端组装进帧 PackRawBytes val, float_bytes, 1, \Float4; UnpackRawBytes float_bytes, 1, b1, \Int1; UnpackRawBytes float_bytes, 2, b2, \Int1; UnpackRawBytes float_bytes, 3, b3, \Int1; UnpackRawBytes float_bytes, 4, b4, \Int1; ! 本机小端时b1是低字节,Modbus线上要高字节在前,所以反转写入 PackRawBytes b4, frame, 14, \Int1; PackRawBytes b3, frame, 15, \Int1; PackRawBytes b2, frame, 16, \Int1; PackRawBytes b1, frame, 17, \Int1; SendFrame frame; ENDPROC如果现场确认PLC组合下来还是不对,优先检查浮点数的4个字节在打包时是不是小端顺序(b1最低字节)。ABB控制器基本都是小端,但工业环境里什么稀奇古怪的扩展卡都有,验证方法简单,发个1.0,看PLC收到的寄存器值是不是0x3F80和0x0000。
4.4 应用层解析
从PLC读Float,用的功能码是03读保持寄存器。响应帧的结构是MBAP头加功能码加字节数加数据区,数据从第10个字节开始(从1计数的RAPID位置)。
!-------------------------------------------------- ! 从PLC读一个Float(读2个保持寄存器,FC=03) !-------------------------------------------------- PROC ReadFloatFromPLC(VAR num val, num start_addr) VAR rawbytes frame; VAR rawbytes resp; VAR rawbytes fbytes; VAR num b1, b2, b3, b4; trans_id := trans_id + 1; ! 构建读请求:MBAP + FC03 + 地址 + 数量 PackRawBytes trans_id, frame, 1, \Int2; PackRawBytes 0, frame, 3, \Int2; PackRawBytes 6, frame, 5, \Int2; PackRawBytes UNIT_ID, frame, 7, \Int1; PackRawBytes 3, frame, 8, \Int1; PackRawBytes start_addr, frame, 9, \Int2; PackRawBytes 2, frame, 11, \Int2; SendFrame frame; ! 等待20ms后收响应(Modbus响应通常毫秒级到达) WaitTime 0.02; SocketReceive plc_socket \RawData:=resp \Time:=1; IF RawBytesLen(resp) < 13 THEN val := 0; RETURN; ENDIF ! 数据区是第10到第13字节,按大端顺序组装 UnpackRawBytes resp, 10, b1, \Int1; UnpackRawBytes resp, 11, b2, \Int1; UnpackRawBytes resp, 12, b3, \Int1; UnpackRawBytes resp, 13, b4, \Int1; ! 按原顺序回填,交给本机解析 PackRawBytes b1, fbytes, 1, \Int1; PackRawBytes b2, fbytes, 2, \Int1; PackRawBytes b3, fbytes, 3, \Int1; PackRawBytes b4, fbytes, 4, \Int1; UnpackRawBytes fbytes, 1, val, \Float4; ENDPROC这个WaitTime 0.02是我踩过坑之后特意加的。最开始我写完请求立刻去Receive,结果经常收到半截帧或者空缓冲区。原因是TCP数据还没从协议栈到socket缓冲区。延20ms再收,对日常100ms周期的点位监控完全够用。如果你的应用要求极高频,那要做的事就是循环接收并按MBAP头里的Length字段截帧,工作量会大不少,我建议先评估是不是真有必要。
主程序里的循环我习惯这样写:
PROC main() SocketClose plc_socket \SkipError; WHILE TRUE DO IF comm_ok = FALSE THEN ConnectToPLC; ENDIF IF comm_ok = TRUE THEN WriteFloatToPLC tcp_x, 0; WriteFloatToPLC tcp_y, 2; WriteFloatToPLC tcp_z, 4; ReadFloatFromPLC target_x, 10; ENDIF WaitTime 0.1; ENDWHILE ERROR IF ERRNO = ERR_SOCK_TIMEOUT OR ERRNO = ERR_SOCK_CONNCLOSED THEN comm_ok := FALSE; SocketClose plc_socket \SkipError; WaitTime 2; ENDIF RETRY; ENDPROCtcp_x这些数在真实项目里是取自CPos(\Tool:=tool_gripper \WObj:=wobj_station)的坐标值,你可以根据实际场景替换。错误处理那段很重要——一旦TCP连接异常关闭,RAPID程序会在对应指令处报错,如果不拦截,整个任务会停在那里,产线直接罢工。有了这个错误陷阱,程序会自动断开重连,不会卡死。
5. 对接不同PLC时的地址映射与参数陷阱
5.1 西门子:MB_SERVER与8180错误
西门子S7-1200/1500侧做ModbusTCP Server,需要在TIA Portal里调用MB_SERVER指令,并把保持寄存器区映射到一组DB块。调试时最常见的错误就是MB_SERVER块的RET_VAL输出报8180。
8180这个错误的字面含义是"本地连接未激活"或者"连接未建立",但它实际指向的问题通常很简单:要么你没在CPU属性的连接机制里勾选"允许来自远程对象的PUT/GET通信访问"(S7-1500有这个选项),要么你MB_SERVER的UNIT_ID参数和机器人侧报文里的Unit ID对不上,要么你CONNECT引脚指向的TCON_IP_v4连接描述没有正确配置端口号502。
排查思路我建议按"先网络层、再协议层、最后应用层"来。先用电脑ping机器人IP,通了再接一条临时Modbus调试工具测试PLC侧是否响应,最后再跑机器人的代码。千万不要一上来就怀疑机器人程序,很多时候PLC侧MB_SERVER根本没激活。
5.2 三菱/汇川:D区与地址偏置
三菱和汇川的很多型号,ModbusTCP Server功能默认映射到D寄存器区。这里最大的坑是地址偏置:ModbusTCP报文里的起始地址0,对应的是PLC侧的40001,但三菱的文档里可能会写成D0对应40001,也可能D1对应40001,不同系列不一样。你在机器人侧写地址0,PLC侧到底看D0还是D1,必须实测确认。
另外三菱和汇川的浮点数存储默认也是小端,也就是说两个连续寄存器里,低字在前。这和西门子默认大端的习惯正好相反。所以如果你调试的是三菱PLC,收到Float不对时,先考虑在PLC侧做字交换,或者回过来在机器人侧发帧时把字序反转。哪种改着方便取决于现场维护习惯,我个人喜欢在PLC侧统一处理,因为后续可能还有其他设备(比如HMI、上位机)要读同一块地址区。
5.3 欧姆龙等其他PLC
欧姆龙的ModbusTCP地址映射一般为40001往后的保持寄存器区对应CIO区,具体映射因PLC型号差异很大。倍福、菲尼克斯这类基于PC的PLC,Modbus寄存器映射到DB结构体基本可以自定义,反而灵活。
我的建议是:接任何一台没见过的PLC,都先在它的手册里找三张表——Modbus功能码支持表、寄存器地址映射表、浮点数存储字节序说明。这三张表确认了,通信基本就成了一半。如果手册里没写字节序,那就用我第3节说的"发1.0看现象"大法,比什么文档都靠谱。
6. 现场实测优化:刷新周期、断线重连与故障处理
6.1 实测刷新周期与丢帧情况
我实测过不同的刷新周期:10ms、20ms、50ms、100ms。
- 10ms:不稳定,偶尔出现连续两次读请求还没收到响应就发下一条,导致SocketReceive拿到的是上一次的响应,看起来就是数据"偶尔跳变"。
- 20ms:虽然多数时候没问题,但一赶上PLC扫描周期紧张或者以太网有广播风暴,依然会偶尔错帧。
- 50ms:稳定多了,基本不丢。
- 100ms:长期稳定运行,CPU负载也可以忽略不计。
实际项目里,如果不是高速追踪这种特殊需求,100ms足够满足人机界面和中控系统显示。如果必须要20ms以下,建议换Profinet或者EtherCAT方案,硬拿ModbusTCP做高速传输是跟自己过不去。
6.2 断线重连的细节
工业现场有个常见情况:PLC重启了、交换机掉电了、网线被叉车压坏了,恢复之后机器人这侧如果还是盯着已经断开的socket不放,那就再也连不上了。
解决办法就是错误陷阱加状态机。我在第4节主循环里已经写了一个简化版本,它的核心逻辑是:任何socket级别的错误都把comm_ok置为FALSE,然后在主循环里发现FALSE就重新走连接流程。实测中还要注意一点:重连前必须SocketClose,有的人只执行SocketCreate和SocketConnect,连续几次失败后系统会报"超出socket配额"之类的错误,因为每次失败的连接并没有被正确释放。
6.3 用抓包验证通信问题
遇到通信疑难杂症,我最推荐的做法不是在PLC侧反复改地址重试,而是直接在机器人控制柜的网口上做镜像抓包,用Wireshark过滤tcp.port == 502,一帧一帧看报文。
抓包能看出很多东西:请求有没有发出去、PLC有没有响应、响应回来的是异常码还是正常数据、数据内容的字节排列长什么样。比如PLC返回的异常码02表示非法数据地址、03表示非法数据值——看到这个你就知道是寄存器地址越界或写入值超范围了,都不用猜。
我见过太多工程师在通讯不上时反复重启机器人、重启PLC,其实抓包10秒就定位了:要么是PLC侧MB_SERVER没起来,要么是Unit ID对不上。工具就摆在那里,学会用抓包分析ModbusTCP,调试效率能翻一倍。
7. 批量Float传输的进阶玩法:寄存器规划和帧合并
7.1 寄存器规划
点位多了以后,最忌讳的就是在程序里一个个写Float读写调用,那样一帧只传一个Float,效率低还容易乱。科学的做法是:提前规划一个寄存器地图,把要交换的数据全部排布在连续地址区域,然后用一次批量读写全部搞定。
我给你一个我常用的分配例子:
| 地址区间 | 方向 | 内容 |
|---|---|---|
| 0-1 | 机器人→PLC | TCP的X坐标Float |
| 2-3 | 机器人→PLC | TCP的Y坐标Float |
| 4-5 | 机器人→PLC | TCP的Z坐标Float |
| 6-7 | 机器人→PLC | 姿态四元数qx |
| 8-9 | 机器人→PLC | 姿态四元数qy |
| 10-19 | PLC→机器人 | 目标坐标、速度倍率、启停命令等 |
这个表就是你和PLC工程师之间的"契约"。双方按这张表做映射,机器人侧维护机器人侧代码,PLC侧维护PLC侧映射,谁有问题对照表格排查,效率非常高。
7.2 批量读写的帧格式
批量写的功能码还是16,区别是一次写入多个Float,数据区变长。比如一次写3个Float(占用6个寄存器),请求帧的数据区从第14字节开始,依次排列6个寄存器的值。批量读功能码03也一样,一次读10个寄存器,响应数据区就是20个字节,解析时循环取两步一个Float即可。
Modbus规范里单次PDU最多125个寄存器,我建议实际使用控制在100个以内,因为PLC侧的处理能力差异很大,留点余量比追求极限重要。寄存器规划好了,数据帧变长对代码的影响很小——写请求时把每个Float都叠加到同一个rawbytes里,读响应时用一个FOR循环批量解析,整体代码量并不会比单点读写多多少。
我实际落地时的体会是:做了寄存器规划之后,这台ABB机器人和PLC之间的数据交换就像操作一个共享内存区一样简单。后续加一个点位,只需要修改寄存器地图、加一个变量、在解析循环里多取一个Float,程序骨架完全不用动。这个思路同样适用于机器人和PC上位机、机器人和视觉系统之间的通信,算是从ModbusTCP通信里沉淀出来的通用方法论了。