ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android 485串口通信实战:android-serialport-api的权限与read边界坑

Android 485串口通信实战:android-serialport-api的权限与read边界坑 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_NDELAYread是非阻塞的会立刻返回 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 功能码最高位置 11 异常码 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 666open 失败 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 和示波器把物理层确认了能省掉大量瞎猜的时间。锁板这东西协议简单但现场环境复杂把硬件底子打牢代码那点坑填起来就快了。
返回列表