简介:面向Android开发者的Modbus通信实现项目,基于Java版jamod库移植,支持串口、TCP、UDP主从通信,适合需要对接PLC、温控器、变频器等工业设备的移动应用开发者。压缩包共173个文件,以Java源码为主体,辅以png界面素材、apt与html格式的使用文档、配置文件、脚本及独立jar包,整体大小仅约869KB,结构清晰便于按需取用。目前已有278人参与学习下载。资料围绕Modbus协议核心展开,除基础协议解析与功能码、寄存器类型说明外,还针对Android虚拟机环境与串口访问进行了适配,并给出串口/网络主从设备的具体通信示例与进程映像处理思路,覆盖连接创建、请求收发、异常处理及线程模型等关键环节。对照这些示例与文档,开发者可快速掌握在Android平台上构建Modbus主站或从站的完整流程,再结合具体硬件文档即可完成定制化工业通信应用。
1. Android 上做 Modbus 主站:串口收发这件事比想象中麻烦
产线调试时最常遇到的需求之一,是一台安卓平板通过串口接上 Modbus 从站设备,又要当主站去读写寄存器。很多人以为就是把数据发出去、收回来,真正动手才发现串口打开方式、CRC 校验、超时重发、线程调度全是坑。这套 Android 上的 Modbus-master 源码包解决的就是这件事:它自带串口初始化、RTU 帧封装、CRC16 校验、事务状态管理和日志输出,适合做 HMI 看板、设备参数配置工具、产线数据采集终端的人直接拿去改。新手照着把串口参数和从站地址一填就能跑通闭环,熟手也能在既有协议栈上替换成自己的功能码。
2. 串口通信前置:波特率、设备节点与收发线程
2.1 串口参数为什么默认是 8N1
Modbus RTU 跑在串口上,用的最普遍的一组参数是:波特率 9600 或 115200、8 个数据位、1 个停止位、无校验,也就是常说的 8N1。不是每个设备都支持校验位,很多老仪表只认 8N1,一旦配成 8E1 或者 7E1,从站直接不响应。所以我一般先看从站设备铭牌上的波特率和校验设置,再在代码里把串口配置做成可配置项,而不是写死。
public SerialPort openSerialPort(File device, int baudRate) throws IOException { SerialPort serialPort = new SerialPort(device, baudRate, 8, 1, 'N', 0); return serialPort; }这段代码用的是常见的 Android 串口 JNI 方案,构造参数依次是设备文件、波特率、数据位、停止位、校验位和流控标志。校验位传'N'是 Modbus RTU 最稳的选择。流控标志一般传 0,不启用硬件流控,因为多数 RS485 设备不接 RTS/CTS 线,反而容易卡在读等待。
2.2 打开串口的代码骨架与权限处理
串口设备在 Android 上表现为/dev/ttyS0、/dev/ttyMT1这类节点,普通 App 没有直接访问权限,需要 root 或者通过chmod 666把设备节点权限放开。实际项目中,我一般把权限处理放到 open 方法里,避免每次启动都要手动改权限。
private File getSerialDevice() { File dev = new File("/dev/ttyS1"); if (!dev.exists()) { dev = new File("/dev/ttyMT1"); } return dev; } private void prepareDevicePermission(File device) { Process su = Runtime.getRuntime().exec("su"); su.getOutputStream().write(("chmod 666 " + device.getAbsolutePath() + "\n").getBytes()); su.getOutputStream().flush(); su.waitFor(); }这里prepareDevicePermission是典型的权限放开操作,设备节点路径是经常变的东西,换一台板子就要重新确认。我建议把所有候选设备节点放在一个数组里遍历查找,存在哪个用哪个,这样换硬件时只改配置数组。
2.3 读线程必须单独开,不能被 UI 拖死
一个容易翻车的地方是把串口读取直接放在主线程里。串口的 read 是阻塞的,主线程一旦被阻塞,整个界面就卡死,超时之后才能恢复。正确做法是单独起一个读线程,循环读取数据并丢给解析器处理。
private void startReadThread(SerialPort serialPort) { new Thread(() -> { byte[] buffer = new byte[256]; InputStream inputStream = serialPort.getInputStream(); while (!Thread.currentThread().isInterrupted()) { int len = inputStream.read(buffer); if (len > 0) { byte[] data = new byte[len]; System.arraycopy(buffer, 0, data, 0, len); handleReceivedData(data); } } }).start(); }核心点是read返回的长度不稳定,一次可能只读到半个帧,所以handleReceivedData里还需要做粘包拆包处理,按超时时间把不是完整 Modbus 帧的残包缓存起来,等后续字节到达再拼凑。读线程退出时记得interrupt()并关闭流,不然线程会永久卡在阻塞读上。
3. Modbus RTU 主站帧构造与 CRC 校验:命令从哪里来
3.1 RTU 帧结构拆解与应用场景
Modbus RTU 主站发的每一帧都有固定格式:从站地址 1 字节、功能码 1 字节、数据区 N 字节、CRC 校验 2 字节。以读保持寄存器为例,数据区包含起始寄存器地址 2 字节和寄存器数量 2 字节,全部是大端序。这是 Modbus 最基础也最重要的一点:地址和数量都是高字节在前,Java 里用ByteBuffer默认大端序刚好匹配,但用DataOutputStream时要小心默认是小端写入。
| 字段名 | 长度(字节) | 说明 |
|---|---|---|
| 从站地址 | 1 | 1~247,主站发命令时填目标从站地址 |
| 功能码 | 1 | 03 读保持寄存器、06 写单个寄存器、16 写多个寄存器等 |
| 数据区 | N | 随功能码变化,读写地址、数量、数据值 |
| CRC 低位 | 1 | CRC16 计算结果的低字节在前 |
| CRC 高位 | 1 | CRC16 计算结果的高字节在后 |
需要注意功能码 03 和 04 的差别:03 读保持寄存器,04 读输入寄存器,同一台设备上两种寄存器的地址空间可能重叠,读错功能码会拿到完全不同的数据。封装命令时一定要把功能码做成参数,方便上位机侧自由切换。
3.2 CRC16-Modbus 校验代码与字节序
CRC 算法是 RTU 帧正确性的关键。标准 CRC16-Modbus 的初始化值是0xFFFF,多项式是0xA001,计算完还要按低字节在前输出,很多人在这一步翻车。
public static int crc16Modbus(byte[] data) { int crc = 0xFFFF; for (byte b : data) { crc ^= (b & 0xFF); for (int i = 0; i < 8; i++) { if ((crc & 1) != 0) { crc = (crc >> 1) ^ 0xA001; } else { crc = crc >> 1; } } } return crc; }crc运算全程用无符号模式,b & 0xFF把负数变成 0~255 的正数。返回值的低字节要拼到帧末尾,高字节跟随其后。有个自测技巧:把完整帧(含 CRC)再丢进同一个函数,如果结果是0x0000,说明帧内的 CRC 是对的;商品级工具验证也都用这个办法。
3.3 常用功能码报文设计与封装
实际工程里最常用到的是读保持寄存器、写单个寄存器、写多个寄存器。以写单个寄存器为例,功能码是 06,数据区包含寄存器地址 2 字节、值 2 字节。封装这些命令时,我习惯用一个独立类。
public static byte[] buildWriteSingleReg(byte addr, int regIndex, int value) { ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); buffer.put(addr); buffer.put((byte) 0x06); buffer.putShort((short) regIndex); buffer.putShort((short) value); byte[] frameWithoutCrc = buffer.array(); int crc = crc16Modbus(frameWithoutCrc); ByteBuffer fullBuffer = ByteBuffer.allocate(9).order(ByteOrder.BIG_ENDIAN); fullBuffer.put(frameWithoutCrc); fullBuffer.put((byte) (crc & 0xFF)); fullBuffer.put((byte) ((crc >> 8) & 0xFF)); return fullBuffer.array(); }缓冲区分配大小是按帧长预计算的:8 字节数据加 1 字节 CRC 就是 9 字节。regIndex和value用short接收,天然符合大端格式。如果寄存器数量超过 125 个,Modbus 协议规定分多条命令发送,这个在封装处就要提前拦截,避免底层传参溢出。
4. 事务封装与多从站轮询:超时重发与数据解析
4.1 主站事务状态机:从发送到确认的完整闭环
一个合格的主站不是简单把命令丢到串口就完事,它要跟踪每个命令的状态:已发送、等待响应、超时、重发、完成。我常用一个轻量事务管理对象,每个事务持有命令、从站地址、期望功能码、超时次数和回调。
public class ModbusTransaction { private byte[] sendFrame; private byte deviceAddr; private int functionCode; private int retryTimes; private int maxRetry = 3; private long timeoutMs = 500; private long sentTime; public boolean isExpired() { return System.currentTimeMillis() - sentTime > timeoutMs; } }sentTime在发送成功那一刻记录,每次主循环检查isExpired。超过timeoutMs且重发次数小于maxRetry,把同一帧重新发出去。这里有个经验值:串口轮询间隔宁可慢一点,也不要设 200ms 的激进超时,现场设备响应慢是常态,500ms 起步比较稳。
4.2 多从站轮询调度:排队与间隔控制
实际项目里一条总线上通常挂着 5~10 台从站,轮询时得顺序发命令,不能并发往串口里塞数据。我用一个事务队列保存所有待发任务,主线程按固定时间片取出队首事务发送,然后进入等待状态。
public void pollLoop() { while (running) { ModbusTransaction tx = pollNext(); if (tx != null) { sendFrame(tx.sendFrame); tx.sentTime = System.currentTimeMillis(); waitForResponse(tx); } sleepInterruptibly(50); } }轮询间隔由两部分组成:一个从站的响应等待时间加上固定的 50ms 兜底延时。如果从站响应很慢,把timeoutMs调大即可。注意轮询循环不能因为一个从站永远无响应就卡死整条总线,遇到超时三次的从站要跳过它,继续轮询下一个,这在waitForResponse内部就要做判断。
4.3 响应解析与异常码识别:区分正常帧、异常帧和噪声
读回来的字节要先判断是不是完整帧,再从帧里拆出功能码和异常码。Modbus 有个规约细节:如果从站返回的功能码最高位置 1,比如请求 03 返回 0x83,说明从站拒绝了请求,真正的错误码在数据区的第一个字节。
public static int parseResponse(byte[] response, byte addr, int expectFunc) { if (response.length < 3) return RESP_INCOMPLETE; if (response[0] != addr) return RESP_WRONG_ADDR; int func = response[1] & 0xFF; if ((func & 0x80) != 0) { return response[2] & 0xFF; // 异常码 } if (func != expectFunc) return RESP_WRONG_FUNC; return RESP_OK; }正常帧和异常帧的标志很好认,但最常见的却是收到完全不期待的数据。比如总线上有噪声,读到的字节长度不对、CRC 校验不过,这时不能把垃圾数据当响应。所以解析前先做 CRC 再匹配地址和功能码,三个条件缺一个都丢弃。现场往往是噪声和正常响应混合出现,宁丢勿错,丢弃后等超时重发才可靠。
5. 避坑指南:从站无响应、CRC 错乱与串口抢占
5.1 从站无响应:先查波特率、从站地址和 RS485 方向
现象是主站把命令发出去了,日志里也能看到发送的十六进制帧,但从站就是没有任何回复。第一次遇到这种问题,我怀疑过是代码问题,后来查下来是波特率配错了,设备实际工作在 9600,主站配了 115200,两边鸡同鸭讲。最快的排查办法是用串口助手先直接发一帧标准的读命令,如果收发两端都正常,说明问题在主站逻辑上;如果串口助手也没响应,就要检查 RS485 的方向控制。很多 USB 转 RS485 线会自动切换收发方向,但某些板载 RS485 芯片需要 GPIO 拉高才能发送,发出后还要延时释放总线,否则从站的响应发不回来被自己吞掉。
解决:进入调试模式时,把波特率、设备地址、功能码、数据全部打印出来,先用 PC 工具验证;再检查 RS485 方向引脚的延时是否在发送完成后保留了 5~10ms。
5.2 CRC 校验总失败:整帧校验还是去校验和的问题
现象是响应帧长度正确、地址也对、功能码也对,但 CRC 校验一直失败。最经典的错误是把包含 CRC 的完整响应帧反复拿去跑 CRC 算法,然后和帧内 CRC 对比,得出失败结论。正确做法要么只用数据部分计算后和帧内 CRC 对比,要么把整帧代入算法判断结果是否为0x0000。另外很多从站响应帧前面会带一个前导零或换行符,如果没做粘包处理就把多余字节带进计算,CRC 必然失败。
解决:解析响应时先按帧结构剥离地址、功能码和数据区,再对这整段不带 CRC 的部分计算 CRC;帧头帧尾的噪声通过超时拆包机制隔离。
5.3 UI 卡顿和串口被其他进程抢占
现象是界面操作时偶尔卡住几秒,或者某一次发送命令后数据一直刷新不出来,过一会儿又恢复正常。常见原因有两个:某个地方把读线程或者在主线程上执行了串口写操作;另一个是同一串口节点同时被系统后台服务或其他 App 抢占,比如某应用在自动检测设备时占用了/dev/ttyS1,导致当前进程打开失败。
解决:把所有串口写操作统一放到发送线程,读操作只允许读线程执行;打开设备前用异常捕获,失败后重试指定次数,同时检查是否有其他进程占用节点,必要时在打开前强制 close。
5.4 读回来的浮点数量级不对:寄存器字序 AB/CD 还是 CD/AB
现象是读到的寄存器值本身没错,但用两个寄存器拼成 float 后数值明显不对,比如实际温度是 25.6,读出来却变成 6.8e-35。原因在于 Modbus 寄存器按 16 位存储,32 位 float 需要拼两个寄存器,不同厂家对高低字顺序定义不同。有设备高字在前、低字在后,也有反过来的,代码不做兼容就出现这种玄学错误。
解决:加一个字节序配置项,支持ABCD和CDAB两种拼接顺序。解析时先读两个 int16,再根据配置决定用哪个做高 16 位,最后合成 float。这个配置要放到和波特率同级的参数文件里,换设备时一目了然。
6. 验证技巧:用模拟从站和日志把主站跑稳
把主站功能写完,不能直接上真机就完事。我习惯先用 PC 端串口模拟工具做一个简单的从站回显,这样能快速验证主站发送的帧格式是否正确。模拟从站设置好波特率 9600、地址 1,把主站发来的命令原样打印出来,再手工组织一个正常响应帧发回去。这个过程能暴露 90% 的封帧问题,比如地址错位、CRC 字节顺序反了、寄存器数量超范围。
日志是另一个必需品。在主站的发送和接收两侧都加上十六进制日志,带毫秒时间戳,方便分析超时和重发的节奏。
[ send ] 2025-01-13 10:00:00.123 > 01 03 00 00 00 02 C4 0B [ recv ] 2025-01-13 10:00:00.612 < 01 03 04 00 01 00 02 85 B6看到上面这种日志格式,第一眼就能判断出来回时间差 489ms,说明超时阈值不能低于 500ms;响应帧长度 7 字节,数据区 4 字节,表示读到了两个寄存器。如果响应帧长度不符,也能直接看出是拆包逻辑漏了。从那以后,我每次联调新设备都强制走一遍同样的流程:先模拟从站验证封帧,再上真实设备跑半小时日志统计重发率,重发率超过 1% 就去查波特率和线缆质量。这套习惯救过不少次现场,希望帮到你。
本文还有配套的精品资源,点击获取