☰
APP与PLC通过Modbus TCP通讯:从协议报文到地址映射的工程实践
2026/10/17 9:03:03 网站建设 项目流程

做工业现场项目的人,应该都有过这种经历:设备厂家留下一台控制器,现场工人说“手机上能看数据、能启停就好了”,于是这个听起来不大的需求就落到你头上。标题里“简单的APP与PLC通讯”,拆开看确实不复杂,但真做起来,从协议选型、地址映射到字节序处理,每一步都有讲究。我最近把一个温控设备的监控App和PLC通过以太网打通了,用的方案是Modbus TCP。这篇文章把从设计到联调的完整过程、踩过的坑都整理出来,适合只想做单机监控的开发者,也适合刚接触工业通讯的软件工程师参考。

1. 项目思路拆解:一开始就别选错方案

1.1 简单项目,为什么首选 Modbus TCP

接到“APP看PLC数据”这类需求时,先别急着打开IDE,而是把方案过一遍。我见过不少人上来就想用MQTT、OPC UA,甚至自研协议,结果把简单项目做成大工程。对单台设备、几十个点位的场景,Modbus TCP是投入产出比最高的方案,没有之一。

选择Modbus TCP有三个理由。第一,它是公开协议,资料多到看不完,抓包工具随便一抓就能看懂报文,不像某些厂商私有协议要靠猜。第二,绝大多数PLC都内置了Modbus从站功能,要么以太网口直接支持,要么加一个串口转以太网模块就能解决,成本很低。第三,APP侧实现简单,TCP Socket收发字节流就够,不需要引入复杂的SDK。如果PLC很老,只支持RS485串口,那就在PLC侧加一个串口转以太网模块,APP端依然走Modbus TCP,整体改动仍然可控。

MQTT也不是不能用,但它解决的是“多端订阅、跨网络、上云”的问题。如果只是手机和PLC在同一个局域网里点对点通讯,MQTT相当于绕了一圈,还要自己搭broker、管理主题,对简单项目来说纯属自找麻烦。

1.2 通讯链路与数据模型先想清楚

方案定了,接下来画通讯链路。最典型的结构是:手机App通过Wi-Fi连接路由器,路由器通过网线接到工业交换机,交换机再接到PLC的以太网口。如果PLC支持Wi-Fi模块,也可以直连,但工业现场更推荐有线方式,稳定性和抗干扰能力都好得多。

这里有个容易忽略的点:IP规划。PLC要设置成固定IP,比如192.168.1.10,子网掩码255.255.255.0;手机和电脑要跟PLC在同一网段,也就是192.168.1.x。如果不在同一网段,就算Wi-Fi信号满格,APP也连不上。真机调试时不要用移动数据,否则和PLC完全不在一个网络里,必然失败。

链路搞定后,先列数据模型。拿出纸笔,把设备上要读和要写的点位全部写出来,包括:

  • 温度、压力、流量等过程量:读,数值类型
  • 运行状态、报警状态:读,布尔类型
  • 启停控制、参数设定:写,布尔或数值类型

这一步看似繁琐,却能省掉后面80%的返工。点位清单定下来之后,才好规划寄存器地址和通讯轮询策略。

注意:涉及控制类的写操作,比如启动、停止、修改设定值,APP端必须加二次确认,通讯层只负责收发,业务层做权限校验。否则误触一次,设备可能当场停机。

1.3 地址映射表:最容易被忽略的源头

很多“通讯不上”的问题,最后查出来根本不是网络问题,而是地址映射表错了。所谓地址映射表,就是把PLC内部寄存器地址、通讯协议地址、APP侧变量名三者对应起来的表格。我建议在项目开始的第一天就建这张表,并用版本管理。

拿温控设备举例,映射表可以这样设计:

数据含义PLC侧地址通讯协议地址数据类型读写方向APP变量名
当前温度保持寄存器0地址0Float32(占两个寄存器)只读currentTemp
运行状态保持寄存器2地址2UInt16(0停止/1运行)只读runState
报警码保持寄存器3地址3UInt16只读alarmCode
启动指令线圈100地址100Bool只写startCmd
停止指令线圈101地址101Bool只写stopCmd

这张表的作用不只是给你看,还要给PLC程序员和现场调试员看。凡是出现“数值不对”“读不到数据”的情况,第一件事就是检查映射表有没有同步。我吃过一次亏:PLC那边调整了寄存器地址,调试文档没更新,APP拿着旧地址去读,读回来一堆莫名其妙的数字,排查了两个小时才发现是地址错位。

编址的时候有个细节要特别注意:很多PLC软件界面里显示的“地址”是从1开始的,但Modbus报文里的寄存器偏移是从0开始的。比如界面显示“%MW1”,实际Modbus报文的起始地址是0。也就是说,映射表里必须区分“PLC侧界面地址”和“报文地址”,否则会统一差1,所有数据全部错位。

数据类型和量纲也要在映射表里写清楚。温度如果带一位小数,可以直接放大10倍用整数传,比如25.5度存成255;如果精度要求高,就用Float32占两个寄存器。量纲不统一,读回来的数据不是偏了10倍就是负数显示乱码。

2. 核心细节拆解:协议报文、功能码与数据格式

2.1 一条 Modbus TCP 报文到底长什么样

写APP的时候,大多数人会直接使用第三方库,不需要自己拼报文。但如果你不想被库的黑盒折腾,必须理解报文结构,否则出了问题连日志都看不懂。Modbus TCP报文由MBAP头和PDU两部分组成。

MBAP头一共7个字节:

字节范围字段名长度作用
0-1事务处理标识符2字节请求和响应配对用的流水号
2-3协议标识符2字节Modbus协议固定为0
4-5长度2字节后面数据的字节数
6单元标识符1字节从站地址,通常为1

PDU就是功能码加上数据区。比如要读从站地址0开始的2个保持寄存器,完整请求帧是:

00 01 00 00 00 06 01 03 00 00 00 02

逐字节拆解:00 01是事务ID,00 00是协议ID,00 06是长度,表示后面还有6个字节;01是单元标识符,03是功能码(读保持寄存器),00 00是起始地址,00 02是寄存器数量。长度为什么是6?因为从单元标识符开始数,01、03、00 00、00 02正好6个字节。

对应的响应帧可能是:

00 01 00 00 00 07 01 03 04 41 20 00 00

这里00 01是请求里的事务ID,APP侧靠这个把请求和响应对应起来。00 00 00 07是长度,01是单元标识符,03是功能码,04表示后面有4个字节数据,41 20 00 00就是两个寄存器拼出来的Float32值,按大端方式解析结果为10.0。

理解报文最大的价值在于排错。实际开发中你可以不用手写报文,但抓包或者看日志时,能一眼看出“请求发对了没有”“响应报了什么错”。

2.2 功能码就几个,但别用混

Modbus TCP的功能码看起来多,实际常用就8个:

功能码名称典型用途
01读线圈读取DO状态,比如继电器输出
02读离散输入读取DI状态,比如外部按钮、限位开关
03读保持寄存器读取模拟量、设定值、状态字,最常用
04读输入寄存器读取只读型模拟量,有些模拟量输入在这里
05写单个线圈控制单个DO,比如启动停止
06写单个寄存器写入单个设定值
0F写多个线圈批量控制DO
10写多个寄存器批量写入参数

最容易犯的错,是把模拟量数据默认放在保持寄存器里,用03功能码去读。有些PLC的AI模拟量通道只映射到输入寄存器区,这时必须用04功能码。读不到数据或者返回异常时,先确认数据到底在哪个区。

如果PLC返回异常码,常见几种:01表示不支持该功能码,02表示地址越界,03表示数据值不合法,04表示从站忙。看到异常码不要慌,拿映射表对着查,大概率是地址或功能码不匹配。

2.3 字节序、数据类型与地址偏移的坑

这是“简单通讯”项目里翻车率最高的地方。Modbus寄存器是16位宽的,一个32位Float32要占两个寄存器。两个寄存器怎么拼成浮点数,不同设备、不同库的默认方式不一样。

假设读回两个寄存器,高位字寄存器值是0x4120,低位字寄存器值是0x0000,那么标准大端解析是把这两个16位数拼成0x41200000,按IEEE 754浮点数解析得到10.0。代码可以这样写:

fun twoRegistersToFloat(regHi: Int, regLo: Int): Float { val intBits = ((regHi and 0xFFFF) shl 16) or (regLo and 0xFFFF) return Float.fromBits(intBits) }

但问题来了:有的PLC默认是“中端”模式,也就是两个寄存器的顺序是反的,先低字后高字;有的网关还会做字节交换。表现就是读回来的温度变成一个离谱的大数,比如几千、几万,有时还忽大忽小。解决办法是在通讯层加一个字节序配置项,支持大小端切换,用一套参数试三个组合,总有一个是正确的。

还有个常被忽视的坑是单次读取寄存器数量的上限。Modbus TCP一次最多读125个寄存器,超过这个数量PLC会返回异常或直接截断。如果点位很多,要分批读取,比如每批100个寄存器,轮询多次。

3. 实操过程:从联调打通到做出APP Demo

3.1 环境准备:模拟器优先,真机随后

做设备通讯,我的习惯是先在PC上用模拟器把整个流程跑通,再去现场碰真机。千万不要一上来就抱着笔记本电脑和设备对接,现场环境复杂,地址、端口、设备配置都可能是变量,在PC上多花半小时,现场能少花半天。

需要准备的环境很简单:

  • 一台能跑PLC模拟器的电脑,模拟器里配好Modbus TCP从站,开放端口502
  • 一个支持Modbus TCP调试的通用工具,我这里用的是一个免费的开源调试器,主要用它的读写测试功能
  • 一部手机,和电脑连到同一个Wi-Fi网络

IP规划建议这样:电脑固定IP为192.168.1.100,PLC模拟器设置为192.168.1.10,手机用192.168.1.101之类的地址。以上只是示例,只要同一网段即可。如果系统防火墙开了,需要放行TCP 502端口,否则PC工具可以连、手机App连不上,问题会非常迷惑。

3.2 用PC调试工具先打通PLC侧

打开调试工具,填上关键的通讯参数:协议选Modbus TCP,IP填PLC的地址,端口填502,单元标识符填1,功能码先用03读保持寄存器,起始地址填0,寄存器数量填2,然后点读取。

第一次读取如果有返回数据,说明物理链路和PLC从站基本正常。接着再试一下写功能:用06功能码往一个无关紧要的寄存器写一个测试值,读回来确认是否成功。这一步是为了验证写通道,控制类设备到现场再验证写逻辑。

用PC工具还有一个重要作用:观察寄存器原始值。比如温度值在PLC界面显示25.5,调试工具读出来是255,说明PLC侧是放大10倍存储的;如果读出来是0.0但有两个寄存器的原始数据,就要手动拼一下浮点,看看字节序。这些信息记录下来,写APP时直接按确认过的格式解析,少走弯路。

3.3 APP端连接管理与读写封装

通讯层的核心思路是:打开一个TCP Socket,发送请求报文,读取响应报文,解析数据。不要在主线程里做这些操作,否则界面一卡,用户直接给你差评。我在项目里封装了一个简单的客户端类,核心方法包括连接、读寄存器、写寄存器。

连接部分用Kotlin伪代码示意:

class PlcClient(private val host: String, private val port: Int = 502) { private var socket: Socket? = null private var transactionId = 0 fun connect(timeoutMs: Int = 3000) { socket = Socket() socket?.connect(InetSocketAddress(host, port), timeoutMs) socket?.soTimeout = timeoutMs } fun disconnect() { socket?.close() socket = null } }

读取保持寄存器的请求构造可以这样做:

fun readHoldingRegisters(slaveId: Int, startAddr: Int, quantity: Int): List<Int> { val txId = transactionId++ and 0xFFFF val request = ByteArray(12) request[0] = (txId ushr 8).toByte() request[1] = (txId and 0xFF).toByte() request[2] = 0 request[3] = 0 request[4] = 0 request[5] = 6 request[6] = slaveId.toByte() request[7] = 0x03 request[8] = (startAddr ushr 8).toByte() request[9] = (startAddr and 0xFF).toByte() request[10] = (quantity ushr 8).toByte() request[11] = (quantity and 0xFF).toByte() socket?.getOutputStream()?.apply { write(request) flush() } // 读取响应:先读6字节MBAP头,再根据长度字段读取后续数据 // 校验事务ID、功能码,最后按字节解析出寄存器列表 }

这里把发送端写清楚了,接收端代码省略,但核心逻辑就是先读6字节头,长度字段告诉你后面还有多少字节,然后按功能码解析PDU,最终拿到寄存器数组。实际项目里我还会加一个解析方法,把两字节拼成Int16、无符号Int16、Float32等等。

封装的要点有四个:一是连接超时要设置,否则PLC掉线后APP会卡住好几十秒;二是Socket关闭要放在finally块,避免资源泄漏;三是每次请求的transactionId要递增,这样能对应响应;四是重连逻辑要做延迟,不要无脑死循环重连。

轮询周期不建议太快,500毫秒以下对PLC是不必要的负担。我做的是1秒循环,用一个TimerTask在子线程里周期读数据,解析完成后通过回调刷新UI。

3.4 一个完整Demo:温度读取与启停控制

设备点位按之前映射表来,我做一个最小可跑通的Demo:读温度、运行状态、报警码,以及控制启停。

点位规划:

报文地址类型含义操作
0-1Float32当前温度只读
2UInt16运行状态(0停止,1运行)只读
3UInt16报警码只读
100Bool启动指令写
101Bool停止指令写

页面打开后,在子线程里定时读取0到3号寄存器,解析出温度和状态:

Timer().schedule(object : TimerTask() { override fun run() { val registers = client.readHoldingRegisters(1, 0, 4) val temp = twoRegistersToFloat(registers[0], registers[1]) val state = registers[2] val alarm = registers[3] runOnUiThread { updateUI(temp, state, alarm) } } }, 0, 1000)

点击“启动”按钮时,发送写线圈请求,功能码05,写入地址100:

client.writeSingleCoil(1, 100, true)

写线圈返回后不要急着提示成功,最好再读一次运行状态确认设备确实启动了。我在实际联调中就遇到一次:线圈写成功了,但设备因为保护条件没满足根本没有启动,App却提示“启动成功”,后来改成读状态确认,才把用户体验做对。

模拟器环境跑通之后,我把手机拿到现场测试,结果连不上PLC。排查了一圈发现,现场PLC的IP是192.168.10.x,而手机连的Wi-Fi是192.168.1.x,根本不在一个网段。后来把路由器的DHCP网段改成和PLC一致,重启后秒连。这种问题只要卡住过一次,下一回就长记性了。

4. 常见问题与排查技巧实录

4.1 问题速查表

问题现象可能原因排查思路
App一直显示连接失败IP地址错误、不在同一网段、502端口被防火墙拦截ping PLC地址,telnet IP 502看端口通不通
能建立TCP连接但读不到数据单元标识符不对、功能码错误、PLC从站服务未启用用PC调试工具逐项对比参数
读到的数值特别大或乱码字节序不对、数据类型解析错、寄存器顺序颠倒先看原始寄存器值,再切换大小端组合
数值固定不变寄存器地址不对、读的是别的设备区域核对映射表和PLC组态
App点击按钮后卡死网络操作放在了主线程、Socket未设置超时把网络请求移到子线程,设置connect和read超时
断线后重连不上Socket未关闭、端口处于TIME_WAIT状态每次关闭流和Socket,重连前先disconnect
PLC返回异常码02起始地址越界、数量超过上限检查地址是否在PLC允许范围内,单次读不超过125个寄存器
模拟器正常真机不正常手机和PLC不在同一网段、设备访问控制列表限制检查手机IP、网关、现场交换机VLAN配置

4.2 三个值得说透的排查经验

第一个经验:抓包看报文比自己猜快得多。有一次读温度读到负几千,我怀疑是字节序问题,但换了三种组合都不对。后来抓包一看,请求帧发过去的起始地址是0,但PLC那边实际数据在地址100,根本读错了区。报文里什么问题都藏不住,与其猜,不如直接看。

第二个经验:地址映射表一定要版本管理。我在项目里用表格维护,每次改动备注修改时间和原因。现场调试时,多方人员拿着不同版本的表,结果两边都对不上。后来强制执行一条规矩:地址表以现场PLC组态文件导出的标签名为准,任何人改动必须同步更新,再没有出现过因映射不一致导致的“灵异现象”。

第三个经验:模拟器验证通过不代表现场没有坑。模拟器通常把寄存器都配好了,但现场真机可能从站服务没开、寄存器掉电不保持、模拟量区在输入寄存器而不是保持寄存器。所以模拟器只是排错工具,不是验收标准,必须到现场走一遍完整读写。

4.3 “简单通讯”翻车重灾区:从站服务没开

这句话值得单独说。很多PLC默认配置并不开放Modbus TCP服务,需要先在PLC设置里激活从站功能。如果服务没开,你的TCP连接可以建立成功,但PLC不会响应任何Modbus请求,现象就是“连得上但没反应”。

我遇到过一次很典型的案例:现场人员信誓旦旦说PLC网络正常,我用调试工具读保持寄存器,一直超时。后来登录PLC组态界面,发现Modbus TCP使能选项没勾。打开之后,再点读取,数据秒回。

这个坑在模拟器里很难复现,因为模拟器默认已经开了服务。所以项目启动后,第一件事就是确认PLC侧已经把Modbus从站服务打开,而不是先改了一百遍APP代码。

5. 写在最后:我的实践体会

5.1 几条立竿见影的实操建议

如果你马上要开始一个类似的APP与PLC通讯项目,我的建议总结下来是五点。

第一,协议层面先统一:用什么协议、什么端口、什么单元标识符,开工前写在文档里,不要口头约定。

第二,所有通讯参数可在App里配置:IP、端口、从站地址、轮询周期都做成可配置项,不要写死在代码里。现场改参数时,你总不见得为了改个IP重新打包发布一次。

第三,一定要做日志:每次请求的报文、响应的报文、解析出的数值都记录到本地文件。没有日志,出了问题只能靠猜,有了日志,半小时内定位问题。

第四,控制类操作要设计安全机制:启动、停止这类操作要有二次确认,最好在界面区分只读模式和操作模式,避免误触。

第五,通讯层和业务层解耦:把读寄存器、写线圈、解析数据封装成独立模块,界面只和业务数据打交到。这样以后换协议、加设备,改动都在通讯层,不影响界面。

5.2 后续还能怎么扩展

这个Demo做完之后,扩展空间很大。如果设备数量增加,可以做多客户端管理,用连接池或者一个Socket管理一台设备,按需读写。如果要把数据上云,可以加一个数据上报模块,把读到的温度、状态转换成JSON,推给MQTT服务,这样Web端和小程序也能看到。如果想让用户自己配置点位,不写代码,可以加一个“地址配置”页面,用映射表驱动,后台存储配置,界面动态生成控件。

我在实际项目里还遇到过想做权限管理的需求,那就需要把用户体系、操作审计加进来。通讯本身依然很简单,复杂的是业务。

回过头看,“简单的APP与PLC通讯”这个项目,真正的难点从来不是把Socket里的字节读出来,而是先想清楚数据从哪里来、到哪里去、中间经过哪些地址和格式转换。把物理链路、地址映射、字节序这三件事理顺,剩下的就是把代码像搭积木一样拼起来。这个理理顺了,后面每次联调都会顺利很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询