1. 项目缘起:一块锁板背后的串口通信需求
去年接手了一个智能储物柜的项目,主控是一块跑 Android 11 的工控板,柜体里挂了一串锁板,每块锁板负责 12 路电磁锁,锁板之间用 RS485 总线手拉手串起来,主控通过一路 USB 转 485 模块跟整条总线对话,协议走的是标准 Modbus RTU。听起来是不是特别常规?我一开始也这么想,觉得无非就是打开串口、拼帧、发出去、收回来,半天搞定。结果这一搞就是两周,中间被android-serialport-api这个老库结结实实上了两课,一课关于权限,一课关于数据读取的边界处理,每一课都足够让程序在实验室跑得好好的、一到现场就翻车。
这篇东西不是教程,是我踩完坑之后回头整理的一份实战记录。核心关键词就几个:Android、485 串口通信、android-serialport-api、Modbus。我会把整个链路的选型逻辑、库的两个深坑、Modbus 锁板通信的完整实现、以及现场排查用到的工具和方法全部摊开讲。适合谁看?适合正在 Android 上做工业串口通信的兄弟,尤其是用 485 总线接 Modbus 从站设备的场景,不管你是接锁板、接电表、接 PLC 还是接变频器,底层逻辑是通的。如果你只是想在 Android 上点个灯、读个传感器,那可能用不上这么重的方案,但坑是一样的坑,看一眼没坏处。
先说清楚整个系统的物理链路,不然后面讲坑的时候容易懵。工控板的 USB 口插一个 USB 转 485 模块,模块的 A/B 两根线接到锁板的 485 总线,总线两端各挂一个 120 欧姆终端电阻,锁板地址从 1 开始依次排下去。Android 这边通过 USB 转串口芯片(我用的是 CH340 方案,也有用 CP2102 和 FT232 的)暴露出一个/dev/ttyUSB0或者/dev/ttyACM0的设备节点,android-serialport-api干的事就是帮你打开这个节点、配置波特率数据位停止位、然后读写字节流。Modbus RTU 的帧格式是地址加功能码加数据加 CRC16,帧与帧之间靠 3.5 个字符时间的静默间隔来区分。这些背景知识后面会反复用到,先埋在这儿。
2. 为什么是 android-serialport-api,而不是别的方案
2.1 选型时的几个候选和淘汰理由
Android 上做串口,能走的路其实不多,我当初列了四个候选:一是android-serialport-api,二是usb-serial-for-android,三是直接用 Android 的 USB Host API 自己撸,四是走 JNI 调 Linux 的 termios 自己封装。下面这张表是我当时的对比,也是我最后选android-serialport-api的直接依据。
| 方案 | 优点 | 致命缺点 | 适用场景 |
|---|---|---|---|
| android-serialport-api | 轻量、直接操作设备节点、代码少 | 需要 root 或系统签名权限、读数据有坑 | 工控板、定制 ROM、有系统权限 |
| usb-serial-for-android | 免 root、走 USB 权限、支持芯片多 | 依赖 USB Host、对某些国产芯片支持一般 | 普通手机、平板外接 |
| USB Host API 自撸 | 完全可控 | 工作量大、要处理 USB 协议栈 | 特殊需求 |
| JNI + termios | 性能最好、控制最细 | 要写 C、要处理 JNI 边界 | 高频大数据量 |
我为什么没选usb-serial-for-android?因为工控板上的 USB 转 485 模块是焊死在板子上的,走的是内部 USB 通道,Android 的 USB Host 枚举有时候认不到,而且这个库对波特率和流控的细粒度控制不如直接操作设备节点来得直接。工控板本身是定制 ROM,有系统权限,/dev/ttyUSB0的读写权限可以给到应用,所以android-serialport-api的权限门槛对我来说不是问题。如果你的场景是普通手机外接,那我建议你认真考虑usb-serial-for-android,别硬上这个库。
2.2 这个库到底做了什么
android-serialport-api的核心其实就一个类SerialPort,它内部通过 JNI 调用了 Linux 的open()、tcsetattr()、read()、write()这几个系统调用。打开设备节点的本质就是open("/dev/ttyUSB0", O_RDWR | O_NOCTTY | O_NDELAY),配置波特率就是往termios结构体里填c_cflag,然后tcsetattr生效。读数据就是阻塞或非阻塞地read()一个字节数组。它把这一套封装成了 Java 层能调的方法,省得你自己写 JNI。理解这一点很关键,因为后面两个坑的根源都在这里——它封装得不完整,把一些本该处理的边界情况留给了调用者。
提示:这个库在 GitHub 上已经很久没更新了,很多分支是各路人马自己 fork 改的。你拿到的版本可能跟我的不完全一样,但核心逻辑一致,坑也一致。
2.3 权限这一课:第一坑
第一个坑就是权限。我在 Android Studio 里把代码写完,SerialPort构造函数一调,直接抛SecurityException: open failed: EACCES (Permission denied)。这个好理解,/dev/ttyUSB0默认属主是 root,权限是crw-rw----,普通应用根本读不了。解决办法有几种,我按推荐程度排一下。
第一种,改设备节点的权限。在工控板的 init.rc 或者开机脚本里加一句chmod 666 /dev/ttyUSB0,或者用 udev 规则根据 USB 设备的 VID/PID 自动设权限。这是最干净的,但需要你能改系统。第二种,把应用放到system分区或者用平台签名,让它有权限访问。第三种,用su提权,但工控板不一定有 su,而且这么做不优雅。我最后用的是 udev 规则,因为工控板的 USB 转串口芯片 VID/PID 是固定的,写一条规则一劳永逸。
这里有个细节很多人会忽略:设备节点不是插上就立刻出现的。USB 转串口芯片枚举需要时间,如果你的应用在开机时抢跑,/dev/ttyUSB0可能还不存在,open直接失败。我的做法是在打开串口前加一个轮询等待,每隔 200 毫秒检查一次节点是否存在,最多等 10 秒。这个等待逻辑后来救了我好几次,因为现场有几块板子枚举特别慢。
private boolean waitForDevice(String path, int timeoutMs) { long start = System.currentTimeMillis(); File dev = new File(path); while (!dev.exists()) { if (System.currentTimeMillis() - start > timeoutMs) { return false; } try { Thread.sleep(200); } catch (InterruptedException e) { } } return true; }3. 第二坑:read 的边界处理,Modbus 帧被拦腰截断
3.1 坑是怎么暴露的
权限搞定之后,单条 Modbus 指令测试通过,读锁状态、开锁都正常,我以为大功告成。结果一上现场,12 块锁板轮询,问题来了:偶尔会读到半截帧。具体表现是 CRC 校验失败,或者解析出来的数据莫名其妙。抓包一看,本该一次收到的 7 字节回复(地址 1 字节 + 功能码 1 字节 + 字节数 1 字节 + 数据 2 字节 + CRC 2 字节),有时候只收到 3 个字节,剩下的 4 个字节在下一次 read 里才出来。
这就是android-serialport-api的第二个坑:它的read()方法不保证一次读满你想要的字节数。底层read()系统调用返回的是当前缓冲区里已有的字节,可能少于你请求的长度。串口是流式设备,数据是一个字节一个字节到的,尤其波特率低的时候(我用的是 9600),字节之间的间隔在毫秒级,read很容易在帧中间返回。库本身没有做"读满 N 字节"的封装,你得自己处理。
3.2 为什么 Modbus 对这个特别敏感
Modbus RTU 是基于时间间隔分帧的,不是基于长度分帧的。协议规定帧与帧之间至少 3.5 个字符时间的静默。9600 波特率下,一个字符(含起始位、8 数据位、无校验、停止位,共 10 位)是 10/9600 秒,约 1.04 毫秒,3.5 个字符就是约 3.65 毫秒。也就是说,如果两个字节之间的间隔超过 3.65 毫秒,接收方就应该认为一帧结束了。问题在于,Android 的调度不是实时的,read返回的时机受系统调度影响,你没法保证在 3.65 毫秒内把整帧读完。所以正确的做法不是靠时间分帧,而是靠长度分帧——Modbus 的回复帧长度是可以根据功能码推算出来的,或者干脆用"读满预期长度"的策略。
3.3 我的解决方案:带超时的读满循环
我写了一个readFully方法,核心思路是循环调用read,把读到的字节累加到一个缓冲区,直到读满预期长度或者超时。超时时间设多少?Modbus 从站回复通常在几十毫秒内,我设了 500 毫秒的总超时,单次read之间不额外等待,因为read本身是阻塞的(取决于你打开时的 flag)。这里要注意,android-serialport-api打开串口时如果用了O_NDELAY,read是非阻塞的,会立刻返回 0 或已有字节,那你就得自己加 sleep。我建议打开时不要用O_NDELAY,让它阻塞,配合超时控制。
public byte[] readFully(InputStream in, int expectedLen, int timeoutMs) throws IOException { byte[] buffer = new byte[expectedLen]; int offset = 0; long deadline = System.currentTimeMillis() + timeoutMs; while (offset < expectedLen) { int remain = (int) (deadline - System.currentTimeMillis()); if (remain <= 0) { throw new IOException("read timeout, got " + offset + "/" + expectedLen); } int n = in.read(buffer, offset, expectedLen - offset); if (n > 0) { offset += n; } } return buffer; }这个方法的精髓在于预期长度 expectedLen 必须准确。Modbus 回复的长度怎么算?读保持寄存器(功能码 0x03)的回复是:地址 1 + 功能码 1 + 字节数 1 + 数据 N + CRC 2,其中字节数 = 寄存器数 × 2。读线圈(0x01)的回复是:地址 1 + 功能码 1 + 字节数 1 + 数据 M + CRC 2,字节数 = ceil(线圈数 / 8)。异常回复是固定的 5 字节:地址 1 + 功能码(最高位置 1)1 + 异常码 1 + CRC 2。所以你在发请求之前就能算出回复长度,readFully就有了目标。这个思路比"读到静默为止"可靠得多,因为静默判断依赖精确计时,Android 上做不到。
注意:如果你的从站设备回复长度不固定,比如某些自定义协议,那就得用"读到静默"或者"读到特定结束符"的策略,但 Modbus 标准场景下,长度预判是最稳的。
4. Modbus 锁板通信的完整实现
4.1 帧的组装与 CRC16 计算
Modbus RTU 的帧结构前面提过,这里把组装过程写清楚。以读锁板 1 的 12 路锁状态为例,功能码用 0x01(读线圈),起始地址 0x0000,数量 0x000C。请求帧是:01 01 00 00 00 0C,后面跟 CRC16。CRC16 用的是 Modbus 标准多项式 0xA001(反向的 0x8005),初始值 0xFFFF,计算时每个字节先跟 CRC 低字节异或,然后右移 8 次,每次如果最低位是 1 就异或 0xA001。算出来的 CRC 低字节在前,高字节在后,这是 Modbus RTU 的规定,别搞反了。
public static byte[] 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; } else { crc >>= 1; } } } return new byte[]{(byte) (crc & 0xFF), (byte) ((crc >> 8) & 0xFF)}; }组装请求的时候,先把地址、功能码、数据拼好,再算 CRC 追加到末尾。发送前记得清空接收缓冲区,因为总线上可能有上一帧的残留数据。android-serialport-api没有提供清空缓冲区的直接方法,我的做法是发送前先非阻塞地读几次,把已有数据丢掉,或者干脆在打开串口后先读一次丢弃。
4.2 开锁指令与状态回读
开锁用的是功能码 0x05(写单个线圈),地址对应锁的编号,值是 0xFF00 表示开,0x0000 表示关。比如开锁板 1 的第 3 路锁,地址是 0x0002,帧是01 05 00 02 FF 00加 CRC。回复是原样返回请求帧(地址、功能码、地址、值、CRC),长度固定 8 字节。这里有个实战经验:电磁锁的开锁时间通常要控制,不能一直通电,否则线圈发热甚至烧毁。我的做法是发开锁指令后,延时 500 毫秒到 1 秒,再发关锁指令。这个延时不能太短,因为锁舌机械动作需要时间;也不能太长,发热是实打实的。具体多少,得看你用的锁的规格书,我用的这款标称最长通电 2 秒,我取 800 毫秒,留足余量。
状态回读就是前面说的读线圈,一次读 12 路,回复 3 个字节的数据(12 位,2 字节够,但 Modbus 按字节对齐,实际是 2 字节,字节数域写 2)。解析的时候按位取,第 0 位对应第 1 路锁,第 11 位对应第 12 路锁。这里要注意位序,Modbus 规定第一个线圈在数据字节的最低位(LSB),别按 MSB 解,否则状态全反。
4.3 轮询策略与总线冲突避免
485 是半双工总线,同一时刻只能有一个设备发送。主控轮询 12 块锁板,必须一问一答,发完请求等回复,收到回复或者超时了再问下一块。绝对不能同时发多个请求,否则总线上的数据会撞车,谁都收不全。我的轮询周期是这样设计的:每块锁板读一次状态,12 块轮完一遍,加上每块之间的间隔,大概 200 到 300 毫秒。这个周期对锁状态监控足够了,锁状态变化不是毫秒级的事。
帧间间隔怎么保证?发完一帧后,等收到回复,再等至少 3.5 个字符时间(9600 下约 3.65 毫秒)才能发下一帧。我实际用的是 10 毫秒的固定间隔,比理论值大,留足余量。这个间隔宁大勿小,因为 Android 的调度抖动可能让实际间隔比你以为的小。如果轮询频率要求高,可以适当压缩,但别低于 5 毫秒。
提示:如果某块锁板连续多次超时,不要死等,直接跳过,标记为离线,下一轮再试。否则一块板子掉线会拖垮整个轮询周期。我设的是连续 3 次超时标记离线,离线后每 10 轮重试一次。
5. 现场排查:工具、方法和那些坑
5.1 手边必备的调试工具
现场排查 485 问题,光靠看代码是没用的,得有工具。我包里常备这几样:一个 USB 转 485 模块接笔记本,用 Modbus Poll 或者类似的调试软件模拟主站,先确认锁板本身是好的;一个示波器或者逻辑分析仪,看 A/B 线上的差分波形,判断有没有信号、波特率对不对、有没有干扰;一个万用表,量终端电阻和总线电压。软件层面,Android 这边我会在关键路径打日志,把发送的帧和接收的帧都按十六进制打出来,跟 Modbus Poll 抓到的对比,一眼就能看出是发的问题还是收的问题。
这里说个细节,Modbus Poll 这类工具在 Windows 上跑,需要配合 USB 转 485 模块的驱动。CH340 的驱动装好之后,设备管理器里会出现 COM 口,Modbus Poll 里选对应的口,波特率 9600、8 数据位、无校验、1 停止位,跟 Android 端保持一致。如果 Modbus Poll 能正常读到锁板,那问题就在 Android 端;如果 Modbus Poll 也读不到,那问题在锁板或者接线。这个二分法能省大量时间。
5.2 常见问题速查表
下面这张表是我这两周踩过的坑和对应的排查思路,按现象分类,现场直接查。
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| open 失败 EACCES | 设备节点权限不足 | ls -l /dev/ttyUSB0看权限 | udev 规则或 chmod 666 |
| open 失败 ENOENT | 节点还没枚举出来 | 检查节点是否存在 | 加轮询等待 |
| 发出去没回复 | A/B 接反、波特率不对、地址错 | 示波器看波形、Modbus Poll 验证 | 对调 A/B、核对参数 |
| 回复 CRC 错 | 收到半截帧、干扰 | 打印原始字节、看长度 | readFully 读满、加终端电阻 |
| 偶尔丢帧 | 总线冲突、间隔不够 | 看轮询逻辑 | 加帧间间隔、一问一答 |
| 全部超时 | 终端电阻缺失、总线断 | 万用表量通断 | 两端各加 120 欧 |
| 开锁无动作 | 锁地址错、通电时间不对 | 单独测该路锁 | 核对地址、调整延时 |
5.3 几个容易被忽略的硬件细节
485 总线看着简单,两根线,但硬件上的坑一点不少。终端电阻必须加,而且只在总线两端加,中间设备不加。我一开始没加,短距离测试没问题,线一拉长到十几米就开始丢帧。加了两个 120 欧姆电阻之后,波形干净多了。A/B 线的定义不同厂家可能相反,有的标 A 是正、B 是负,有的反过来,接之前一定看手册,接反了就是收不到。隔离这块,如果锁板和主控距离远、或者现场有电机之类的干扰源,建议用带隔离的 485 模块,光耦隔离能挡掉不少共模干扰。我现场有一台设备旁边就是变频器,没隔离的时候误码率明显高,换了隔离模块之后稳定了。
还有一点,485 芯片的收发使能。有些模块是自动收发的,有些需要一根 DE/RE 引脚控制方向。USB 转 485 模块一般是自动的,不用管。但如果你是自己在板子上做 485 电路,那根使能引脚的控制时序很关键,发完最后一个字节要等数据完全移出去(等 TXE 和 TC 标志)才能切回接收,否则最后一个字节会丢。这个坑我在 STM32 上踩过,Android 这边用现成模块就绕过了。
6. 代码结构与我个人的封装习惯
6.1 分层:协议层和传输层分开
我的代码分两层,传输层只管打开串口、发字节、收字节,协议层管 Modbus 帧的组装解析。这样分开的好处是,哪天换了个串口库,或者从 485 换成 TCP,协议层不用动。传输层的接口就三个方法:open、send、receive。receive就是前面那个readFully。协议层拿到字节数组,校验 CRC,解析功能码,返回业务对象。
public interface Transport { void open() throws IOException; void send(byte[] data) throws IOException; byte[] receive(int expectedLen, int timeoutMs) throws IOException; void close(); }6.2 超时和重试的边界
超时设多少,重试几次,这个没有标准答案,得看你的实时性要求。我的设置是单次请求超时 500 毫秒,重试 2 次,也就是最多 1.5 秒还没回复就放弃。为什么是 500 毫秒?因为 9600 波特率下,一帧最长也就几十毫秒,500 毫秒足够覆盖从站的处理时间和总线延迟,再长就是设备真有问题了。重试 2 次是为了应对偶发的干扰导致的丢帧,但重试次数不能多,否则一块坏板子会拖慢整个轮询。重试的时候记得重新发完整的请求帧,包括 CRC,别只发数据部分。
6.3 线程模型
串口读写必须放在独立线程,不能放主线程,否则 Android 会直接 ANR。我的做法是一个专门的串口线程,用一个阻塞队列接收上层下发的请求,线程循环从队列取请求、发送、接收、回调结果。这样上层业务逻辑跟串口操作解耦,也不会阻塞 UI。队列的好处是天然串行化,不会出现两个请求同时发的情况,正好符合 485 一问一答的要求。
注意:回调结果的时候要切回主线程或者业务线程,别在串口线程里直接更新 UI。我一般用 Handler 或者 LiveData 把结果抛出去。
7. 关于 android-serialport-api 的取舍建议
这个库我用了,也踩了坑,但我不劝你无脑用或者无脑弃。它的定位很明确:有系统权限、需要直接操作设备节点、场景相对固定的工控环境。在这个定位下,它够用,代码量小,可控。但你要清楚它的两个短板:权限要自己解决,读数据要自己封装readFully。把这两个补上,它就是个合格的传输层。
如果你的场景是普通消费级设备,没有系统权限,那我建议你走usb-serial-for-android,虽然它对某些芯片的支持和参数控制不如直接操作节点,但免 root 这一条就值了。如果你的场景对性能要求极高,比如高频采集,那 JNI 自己封装 termios 是正路,android-serialport-api的 Java 层封装会有额外开销。
最后说个我自己的体会:串口通信的稳定性,代码只占一半,另一半在硬件和现场环境。我这两周里,真正花在改代码上的时间可能只有三分之一,剩下三分之二都在查线、量电阻、看波形、对比抓包。所以别一遇到问题就怀疑代码,先拿 Modbus Poll 和示波器把物理层确认了,能省掉大量瞎猜的时间。锁板这东西,协议简单,但现场环境复杂,把硬件底子打牢,代码那点坑填起来就快了。