☰
Android Modbus RTU主站开发实战:串口调试与源码解析
2026/10/26 20:24:29 网站建设 项目流程

简介:这份资源面向需要在 Android 平台上实现 Modbus 通信的开发者,尤其适合从事工业自动化设备对接、希望用手机或平板控制 PLC、温控器、变频器等硬件的工程师。项目以 Java 为主,将开源 Modbus 库 jamod 适配到 Android 环境,覆盖串口 RTU 与 TCP/IP 两种通信方式,涉及协议帧构建与解析、串口交互、后台线程管理及异常处理等关键环节,属于需要一定 Android 与 Java 基础的中阶实践素材。压缩包共 173 个文件,以 105 个 java 源码为核心,辅以 33 个 png 图示、14 个 apt 文档及 html、sh、txt 等说明与脚本文件,整体约 869KB,体积轻便、结构清晰。目前已有 279 人学习下载。读者可从中获得可参考的 Modbus 主站实现代码、协议与串口操作示例,以及面向不同设备的适配思路,便于快速理解通信流程并迁移到自己的项目中。

1. 串口屏调试卡了三天,最后靠一份 Modbus-master 源码翻盘

做 Android 工控或物联网网关的同行大概都遇到过这种局面:设备端是 PLC、电表、温控器,通信口是 RS485,协议是 Modbus RTU,而手上只有一块 Android 主板和一个 USB 转 485 模块。协议文档翻烂了,CRC 校验手算到怀疑人生,串口助手能收到字节但拼不出完整帧,三天过去还在纠结功能码 0x03 后面跟的到底是寄存器地址还是字节数。这份 Android 开发 Modbus-master 资源,就是把这个过程压缩成可复现的工程:它把串口打开、帧组装、CRC16 计算、主站请求与从站响应解析这几件事拆成独立模块,主站侧读写保持寄存器、线圈、输入寄存器的调用链是完整的,适合需要在自己的 Android 应用里嵌一套 Modbus 主站能力的开发者,也适合刚接触串口通信、想拿一份能跑通的代码对照协议手册的人。它不解决硬件接线问题,也不替代示波器,但能把“协议到底怎么落到字节流上”这件事讲清楚。

2. 拆开这份 Modbus-master:串口层与协议层怎么分工

2.1 先看清模块边界,别一上来就改代码

拿到一份串口通信工程,最容易犯的错是从 MainActivity 开始顺着读,读到一半被线程和回调绕晕。我的习惯是先看目录结构,把“跟硬件打交道的”和“跟协议打交道的”分开。这份 Modbus-master 大致可以切成三层:最底下是串口打开与字节收发,中间是 Modbus 帧的组装与解析,最上面是业务调用接口。串口层关心的是波特率、数据位、停止位、校验位和读写超时;协议层关心的是从站地址、功能码、起始地址、寄存器数量、CRC 校验。两层之间只传 byte 数组,这个边界守住了,后面换串口芯片或者换协议版本都不会牵一发动全身。

常见做法是把串口读写封装成一个带发送队列的类,因为 RS485 是半双工,同一时刻只能有一个方向在传。如果应用层两个线程同时调发送,帧就会在总线上撞车,从站收到的是一堆拼接错误的字节。所以看这份代码时,重点确认发送方法是不是加了同步锁、是不是等上一帧响应回来或者超时之后才允许下一帧发出。这个细节决定了工程能不能用在真实的多设备轮询场景里。

2.2 串口参数怎么设:一份能直接抄的配置表

串口参数设错是新手最常见的翻车点,而且现象很迷惑——能打开端口,但一个字节都收不到,或者收到乱码。下面这张表是我在工控现场反复验证过的常用组合,Modbus RTU 绝大多数设备落在这个范围里:

参数常用值说明
波特率9600 / 19200 / 38400 / 115200必须与从站一致,长距离布线优先降速
数据位8Modbus RTU 固定 8 位
停止位1 或 2多数设备 1 位,部分老设备要 2 位
校验位None / Even协议本身有 CRC,校验位常设 None
读超时300~1000 ms按轮询周期和从站响应速度调
帧间隔≥3.5 字符时间波特率越低,这个间隔越要留足

在 Android 上打开串口,底层通常走UsbManager拿到设备权限,再用UsbDeviceConnection.bulkTransfer做批量传输,或者用现成的串口库封装文件描述符。不管走哪条路,参数最终都要落到termios结构里。下面这段是设置串口属性的典型写法,逻辑是先把波特率、数据位、校验位、停止位逐项写进去,再刷新配置使其生效:

// 打开串口设备节点后,配置 termios 参数 int fd = open("/dev/ttyUSB0", O_RDWR | O_NOCTTY); Termios tio = new Termios(); tcgetattr(fd, tio); // 先读取当前配置,避免覆盖未知项 cfsetispeed(tio, B9600); // 输入波特率 cfsetospeed(tio, B9600); // 输出波特率 tio.c_cflag &= ~CSIZE; tio.c_cflag |= CS8; // 8 位数据位 tio.c_cflag &= ~PARENB; // 无校验 tio.c_cflag &= ~CSTOPB; // 1 位停止位 tio.c_cflag |= CLOCAL | CREAD; // 忽略调制解调器控制线,启用接收 tio.c_cc[VMIN] = 0; // 非阻塞读 tio.c_cc[VTIME] = 10; // 读超时 1 秒(单位 0.1 秒) tcsetattr(fd, TCSANOW, tio); // 立即生效

这里几个参数值得单独说。VMIN=0配合VTIME=10表示读操作最多等 1 秒,没数据就返回,这样上层轮询不会永久卡死。CLOCAL必须置位,否则没有载波信号时读会一直阻塞。CS8之前先清掉CSIZE掩码,这是很多人漏掉的一步,直接或上CS8在某些平台上会残留旧的数据位设置。波特率常量B9600是系统定义的宏,换成 115200 就是B115200,不要自己填数字。

2.3 帧结构拆解:一次 0x03 读寄存器到底发了什么

Modbus RTU 的请求帧结构是固定的:从站地址 1 字节、功能码 1 字节、数据域 N 字节、CRC 2 字节(低字节在前)。以读保持寄存器为例,功能码 0x03,数据域是起始寄存器地址 2 字节加寄存器数量 2 字节。假设从站地址 1,读起始地址 0x0000 的 2 个寄存器,完整帧是01 03 00 00 00 02 C4 0B。最后两字节就是 CRC16,低字节C4在前,高字节0B在后。

响应帧是:从站地址、功能码、字节数、数据、CRC。上面那次请求的正常响应形如01 03 04 XX XX YY YY CRC CRC,其中04表示后面跟 4 个数据字节,也就是两个 16 位寄存器。解析时先校验从站地址和功能码,再读字节数,按字节数取数据,最后算 CRC 比对。异常响应则是功能码最高位置 1,比如 0x83,后面跟一个异常码,常见的有 0x02(非法数据地址)和 0x03(非法数据值)。工程里如果只处理正常响应,遇到异常帧会把异常码当成数据解析,读出来的值完全不对,这是很隐蔽的坑。

CRC16 的计算是 Modbus 专用的多项式 0xA001(反向的 0x8005),初值 0xFFFF,逐字节异或后右移,最低位为 1 时异或多项式。下面这段实现可以直接用:

// Modbus RTU 专用 CRC16,多项式 0xA001,初值 0xFFFF public static int crc16(byte[] data, int len) { int crc = 0xFFFF; for (int i = 0; i < len; i++) { crc ^= (data[i] & 0xFF); // 与当前字节异或,注意屏蔽符号位 for (int j = 0; j < 8; j++) { if ((crc & 0x0001) != 0) { crc = (crc >> 1) ^ 0xA001; // 最低位为 1,右移后异或多项式 } else { crc >>= 1; } } } return crc & 0xFFFF; // 返回 16 位结果 }

data[i] & 0xFF这一步不能省,Java 的 byte 是有符号的,负数直接参与异或会把高位符号位带进来,算出的 CRC 永远对不上。返回值的低字节先发、高字节后发,组装帧时注意顺序。我见过有人把 CRC 高低字节写反,从站直接丢弃帧不响应,现象就是“发了没反应”,排查半天以为是接线问题。

3. 把主站跑起来:请求组装、轮询与响应解析

3.1 组装一帧请求的完整步骤

把上面的零件拼起来,一次读保持寄存器的流程是这样的:构造 6 字节的数据区(地址、功能码、起始地址高低字节、数量高低字节),算 CRC,把 CRC 低字节和高字节追加到末尾,得到 8 字节完整帧,写入串口,然后等响应。下面这段是可直接调用的组装方法:

// 组装读保持寄存器请求帧,slaveId 从站地址,startAddr 起始地址,count 寄存器数量 public static byte[] buildReadHolding(int slaveId, int startAddr, int count) { byte[] frame = new byte[8]; frame[0] = (byte) slaveId; frame[1] = 0x03; // 功能码:读保持寄存器 frame[2] = (byte) (startAddr >> 8); // 起始地址高字节 frame[3] = (byte) (startAddr & 0xFF); // 起始地址低字节 frame[4] = (byte) (count >> 8); // 寄存器数量高字节 frame[5] = (byte) (count & 0xFF); // 寄存器数量低字节 int crc = crc16(frame, 6); // 只对前 6 字节算 CRC frame[6] = (byte) (crc & 0xFF); // CRC 低字节在前 frame[7] = (byte) ((crc >> 8) & 0xFF); // CRC 高字节在后 return frame; }

参数上要注意count的范围,功能码 0x03 一次最多读 125 个寄存器,超过这个数从站会返回异常码 0x03。起始地址是 16 位的,实际设备手册里写的“40001”这类地址是带偏移的表示法,落到协议帧里要减 1 变成 0x0000,这个换算关系每家的手册写法不一样,务必对着手册确认,别想当然。

3.2 轮询多个从站:发送队列和超时重试

真实场景很少只读一个设备,一条 485 总线上挂七八个从站是常态。这时候必须串行轮询:发一帧、等响应或超时、再发下一帧。工程里通常用一个单线程的发送队列,把每个从站的请求排进去,逐个执行。超时时间设得太短,慢设备来不及响应就被判失败;设得太长,一个掉线的从站会把整轮轮询拖住。我的经验是按从站数量分配,8 个从站、轮询周期 2 秒的话,单帧超时控制在 200 毫秒左右比较合适,掉线设备连续失败 3 次就标记为离线,跳过它继续轮询,避免整条总线被一个坏节点拖死。

重试策略也要克制。Modbus 本身没有序号机制,重发同一帧如果从站其实已经处理了,会造成写操作重复执行。所以读操作可以重试,写线圈、写寄存器这类操作重试要谨慎,最好在应用层做幂等判断。这一点在控制类场景里是血泪经验,重复写一次继电器状态可能就把设备搞到异常工况。

3.3 响应解析:字节数、数据与 CRC 三重校验

收到响应后,先判断长度。正常读寄存器响应长度是 5 加字节数,字节数等于寄存器数量乘 2。如果收到的长度对不上,直接丢弃。然后校验从站地址是否匹配、功能码是否等于请求的功能码(异常响应是请求功能码加 0x80),再算 CRC 比对最后两字节。三步都过了,才按大端序把每两个字节拼成一个 16 位寄存器值。下面这段解析逻辑把校验和取值放在一起:

// 解析读保持寄存器响应,返回寄存器值数组,校验失败返回 null public static int[] parseReadHolding(byte[] resp, int expectSlave, int expectCount) { if (resp == null || resp.length < 5) return null; if ((resp[0] & 0xFF) != expectSlave) return null; // 从站地址不匹配 if ((resp[1] & 0xFF) == (0x03 | 0x80)) return null; // 异常响应,交给上层处理 if ((resp[1] & 0xFF) != 0x03) return null; // 功能码不符 int byteCount = resp[2] & 0xFF; if (byteCount != expectCount * 2) return null; // 字节数不符 if (resp.length != 5 + byteCount) return null; // 总长度不符 int crc = crc16(resp, resp.length - 2); int recvCrc = (resp[resp.length - 2] & 0xFF) | ((resp[resp.length - 1] & 0xFF) << 8); // 低字节在前 if (crc != recvCrc) return null; // CRC 校验失败 int[] values = new int[expectCount]; for (int i = 0; i < expectCount; i++) { int hi = resp[3 + i * 2] & 0xFF; int lo = resp[4 + i * 2] & 0xFF; values[i] = (hi << 8) | lo; // 大端序拼接 } return values; }

这里resp[1] & 0xFF的写法贯穿始终,原因和 CRC 那里一样,byte 转 int 时符号位会污染比较结果。异常响应单独判断,是因为异常帧的长度和正常帧不同,如果混在一起解析,长度校验会先把它拦掉,但你就拿不到异常码,排查时不知道是地址错了还是数量超了。把异常码透传到上层日志里,现场调试能省很多时间。

4. 避坑与排查:串口通信里那些让人怀疑人生的现象

4.1 能打开端口但收不到任何数据

现象是串口打开成功,发送也不报错,但读缓冲区永远是空的。原因通常有三个:一是收发方向控制没做对,RS485 需要切换 DE/RE 引脚,USB 转 485 模块一般自动切换,但如果用的是带方向控制的板载 485,代码里没拉高发送使能,数据根本没上总线;二是波特率或校验位和从站不一致,从站收到的是乱码,自然不会回应;三是接线把 A、B 接反了,这个最隐蔽,因为有些模块丝印和实际相反。解决顺序是先拿串口助手单独测从站,确认硬件和参数没问题,再回到代码里查方向控制和参数配置。

4.2 CRC 永远对不上

现象是帧发出去了,从站不响应,或者自己解析响应时 CRC 校验一直失败。原因多半是 CRC 计算时把符号位带进去了,或者高低字节顺序搞反,还有一种是计算范围错了——请求帧只对前 6 字节算,响应帧要对除最后两字节外的全部算。解决办法是拿一组已知正确的帧做单元测试,比如01 03 00 00 00 02的 CRC 应该是C4 0B,先把这个用例跑通再往下走。别在真实设备上盲调,效率太低。

4.3 多设备轮询时偶发数据错位

现象是单个设备测试正常,挂上多个从站后偶尔读到的值属于别的设备。原因是半双工总线上前一帧的响应还没收完,下一帧就发出去了,或者读缓冲区没清干净,上一帧的残留字节被当成新帧的开头。解决方法是每次发送前清空接收缓冲区,发送后按预期长度读取,读到的字节数不足就等超时丢弃整帧,不要拼接。发送队列加锁,确保一帧的完整收发周期结束才允许下一帧开始。

4.4 写操作重复执行导致设备状态异常

现象是写线圈或写寄存器偶尔被执行两次,继电器动作两次或者设定值被覆盖。原因是超时重试机制在写操作上被滥用,从站其实已经执行了,只是响应在总线上丢了,主站重发又执行一遍。解决办法是写操作不做自动重试,或者重试前先读回状态确认。对安全性要求高的场景,写操作之后跟一次读校验,确认写入生效再继续。

4.5 长时间运行后串口假死

现象是应用跑几个小时或几天后,串口突然不再响应,重启应用才恢复。原因可能是 USB 转串口芯片的驱动在异常情况下挂起,也可能是读线程被阻塞后没有超时退出,发送队列越积越多。解决办法是给读操作设硬超时,定期检查串口文件描述符是否还有效,必要时关闭重开。工程里加一个看门狗线程,连续 N 次收发失败就重建串口连接,这个习惯在无人值守的现场设备上很值。

5. 进阶:把 Modbus 主站封装成可复用的轮询引擎

代码能跑通只是起点,真正落地时你会发现业务层不想关心 CRC 和字节序,它只想说“读 1 号从站地址 0 开始的 4 个寄存器”。所以值得在这份源码基础上再包一层:定义一个从站配置对象,包含从站地址、功能码、起始地址、数量、轮询周期;引擎按周期调度,把结果通过回调抛给业务层。下面是一个简化的配置结构,用表驱动的方式管理轮询任务:

// 轮询任务配置:一个从站的一组寄存器读取 class PollTask { int slaveId; // 从站地址 int function; // 功能码,0x03 或 0x04 int startAddr; // 起始寄存器地址 int count; // 寄存器数量 long periodMs; // 轮询周期 long lastRun; // 上次执行时间戳 }

引擎主循环里遍历任务列表,到了周期就组装帧、发送、解析,结果放进一个以从站地址和寄存器地址为键的缓存。业务层读缓存就行,不用等串口。这个结构的好处是新增设备只加一条配置,不用改通信代码。参数上要注意轮询周期不能小于单帧收发时间乘以从站数量,否则任务会堆积。我一般留 30% 余量,8 个从站、单帧 50 毫秒的话,整轮至少 400 毫秒,周期设 600 毫秒以上比较稳。

验证这套引擎是否可靠,我的做法是接一个 Modbus 从站模拟器,把寄存器值设成递增序列,跑一整晚,第二天检查缓存里的值有没有跳变或断档。断档说明有帧丢失,跳变说明有错位。这个测试比在真实设备上盯半天有效得多。从那以后我每次把 Modbus 主站代码往新平台移植,都强制先跑一遍模拟器长稳测试再上真机,省下来的现场排查时间远超写测试的功夫。希望这套拆解和踩坑记录能帮到你,少走几天弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询