Android 上搞 485 串口通信,我被 android-serialport-api 的两个深坑上了一课(附 Modbus 锁板可靠通信实战)

文章来源声明: 原文作者:Godikov; 来源站点:掘金; 原文链接:https://juejin.cn/post/7684800289061191706; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

面向安卓工控、智能柜开发的实战踩坑记。两大 native 坑的根因与解法讲得透彻,可靠性设计思路可直接复用;不适配高吞吐与强实时场景。

Android 上搞 485 串口通信,我被 android-serialport-api 的两个深坑上了一课(附 Modbus 锁板可靠通信实战) --------------------------------------------------------------------------

一台跑在 RK 主板上的智能柜识别终端:Android 应用负责虹膜/人脸/NFC 认证,认证通过后通过 RS485 总线给锁控板下发 Modbus 指令开箱门。本文记录在把 android-serialport-api 集成进项目时踩到的两个经典深坑(UnsatisfiedLinkError 和 SIGABRT),以及为了让"开门"这件小事在 7×24 小时运行的设备上不翻车,我们在指令层、重试层、轮询层做的可靠性设计。

背景:为什么是 485 而不是别的

智能柜类设备(快递柜、储物柜、智能钥匙柜)的锁控板几乎都是通过 RS485 + Modbus RTU 与上位机通信的。原因很朴素:485 差分信号抗干扰强、布线距离长,一条总线上可以挂几十块锁板;Modbus RTU 协议简单到锁板厂商用 51 单片机就能实现。

Android 这边,RK 方案的 BSP 通常会把 485 口直接暴露成 /dev/ttySx 串口节点,所以应用层不需要额外的 USB 转串口芯片,直接用经典的 android-serialport-api(Google 官方示例项目衍生出来的 SerialPort.java + libserial_port.so)就能读写。

听起来很美好:打开节点、拿流、read/write,完事。但真正把它搬进一个正经项目,我先后撞上了两个一查源码才能解释清楚的坑。

【配图建议:整体架构示意图 —— Android App → /dev/ttyS7 → 485 总线 → N 块锁控板 → 箱门锁/通风继电器】

坑一:改了 Java 包名,UnsatisfiedLinkError 当场教你做人

问题现象

项目是从一个参考工程演进来的,SerialPort.java 原来放在参考工程的包名 com.skyworth.splicing 下。整合进我们自己的代码结构时,我顺手把它挪到了自己的串口包 com.nlane.irisra.serial 下——Java 类嘛,挪个包名不是天经地义?

编译、安装、运行,调用 connect() 的瞬间直接崩:

java.lang.UnsatisfiedLinkError: No implementation found for java.io.FileDescriptor
    com.nlane.irisra.serial.SerialPort.open(java.lang.String, int, int)

根因分析

JNI 的默认函数绑定规则是编译期按 Java 类的全限定名生成符号名。libserial_port.so 里的 native 函数长这样:

// 符号名在编译 so 时就写死了
JNIEXPORT jobject JNICALL
Java_com_skyworth_splicing_SerialPort_open(JNIEnv *env, jobject thiz,
                                           jstring path, jint baudrate, jint flags);

也就是说,so 里的符号是 Java_com_skyworth_splicing_SerialPort_open。当你把 Java 类的包名改成 com.nlane.irisra.serial 后,JVM 按规则去找 Java_com_nlane_irisra_serial_SerialPort_open,自然找不到,UnsatisfiedLinkError。

而手头的 libserial_port.so 是参考项目给好的预编译产物(arm64-v8a / armeabi-v7a 等多个 ABI 的成品),没有对应的 .c 源码,改不了符号名。

解决方案

两条路:

  1. 拿到 C 源码重编 so(用 RegisterNatives 动态注册,或者保持静态绑定但改 C 里的函数名)——最干净,但前提是你有源码和 NDK 构建环境;
  2. Java 类放回原来的包名——零成本,一行代码不用改 so。

当时没有源码,选了方案 2:SerialPort.java 永远待在 com.skyworth.splicing 包下,业务代码正常 import 使用:

// app/src/main/java/com/skyworth/splicing/SerialPort.java
package com.skyworth.splicing;   // 包名由 so 里的 JNI 符号决定,不能动

public class SerialPort {
    private native FileDescriptor open(String path, int baudrate, int flags);
    public native int close();

    static {
        System.loadLibrary("serial_port");
    }
}

经验:集成任何"Java 壳 + 预编译 so"的第三方库时,先 readelf -sW libxxx.so | grep Java_ 看一眼导出符号,确认 so 绑定的包名,再决定 Java 类放哪。如果要用自己的包名,要么重编 so,要么用 JNI_OnLoad + RegisterNatives 做动态注册摆脱包名束缚。

坑二:serialPort.close() 一调,Fatal signal 6 (SIGABRT)

问题现象

这是更阴的一个坑。最开始 disconnect() 写得非常"教科书":

fun disconnect() {
    inputStream?.close()
    outputStream?.close()
    readThread?.interrupt()
    serialPort?.close()   // ← 崩在这里:Fatal signal 6 (SIGABRT), code -6
}

日志里是经典的 native 崩溃:

A/libc: invalid address or address of corrupt block 0x... passed to dlfree
Fatal signal 6 (SIGABRT), code -6 in tid xxxx

根因分析

翻了 android-serialport-api 的 JNI 实现(以及网上大量的同类 crash 报告),原因链条是这样的:

  1. SerialPort 构造时调用 native open() 拿到一个 FileDescriptor,然后用这个 同一个 fd 分别构造 FileInputStream 和 FileOutputStream;
  2. Java 层的 FileInputStream.close() / FileOutputStream.close() 最终都会走到 native 的 close(fd) —— 也就是说,关掉流,底层 fd 就已经被 close 了(两个流共享一个 fd,其中一个关闭后 fd 就失效了);
  3. 此时再调用 SerialPort.close(),native 层拿着这个已经失效的 fd 又做了一次 close(),属于 double-close / 操作已回收内存,glibc 检测到堆破坏或非法地址,直接 abort() 抛 SIGABRT。

有意思的是,参考工程里的 MySerialUtil.disconnect() 同样从不调用 serialPort.close(),只关流和线程——前人显然是踩过这个坑的,只是没写注释。我踩完才补上。

最终实现

fun disconnect() {
    isConnected = false
    // 1. 关流(底层 fd 随之释放)
    try { inputStream?.close() } catch (_: Exception) {}
    inputStream = null
    try { outputStream?.close() } catch (_: Exception) {}
    outputStream = null
    // 2. 停读线程,最多等 500ms
    try {
        readThread?.interrupt()
        readThread?.join(500)
    } catch (_: Exception) {}
    readThread = null
    // 3. 注意:不调用 serialPort?.close()
    //    InputStream/OutputStream 关闭后底层 fd 已失效,再调 native close 会导致 SIGABRT。
    serialPort = null
}

经验:对 android-serialport-api 这类老库,正确姿势是"只关流,不 close SerialPort 对象"。serialPort = null 让对象被 GC 掉即可,fd 已经随流释放了。这个坑在网上有相当多的提问,但官方示例从没修过,属于典型的"人人踩、没人修"的历史遗留。

Modbus RTU 指令构造与 CRC16:手写不难,细节见真章

串口通了,下一步是和锁板"说上话"。锁板遵循 Modbus RTU,一条指令 8 字节:

[从机地址] [功能码] [寄存器高字节] [寄存器低字节] [数据高] [数据低] [CRC低] [CRC高]

开箱门是功能码 0x06(写单寄存器),查门状态是 0x03(读保持寄存器):

object GateCommandUtil {

    /** 开箱门指令(功能码 0x06) */
    fun openGate(group: Int, gateId: Int): ByteArray {
        val buff = ByteArray(8)
        buff[0] = (group and 0xff).toByte()   // 锁板地址
        buff[1] = 0x06                        // 写单寄存器
        buff[2] = 0x30                        // 寄存器高字节(厂商协议)
        buff[3] = (gateId and 0xff).toByte()  // 寄存器低字节 = 门号
        buff[4] = 0x00
        buff[5] = 0x01                        // 数据:1 = 开门
        val crc = getModbusCrc16(buff.copyOfRange(0, 6))
        buff[6] = crc[0]  // CRC 低字节在前
        buff[7] = crc[1]
        return buff
    }

    /** Modbus CRC16(多项式 0xA001,初值 0xFFFF) */
    fun getModbusCrc16(buffer: ByteArray): ByteArray {
        var crc = 0x0000ffff
        val polynomial = 0x0000a001
        for (b in buffer) {
            crc = crc xor (b.toInt() and 0x000000ff)
            repeat(8) {
                crc = if ((crc and 0x00000001) != 0) {
                    (crc shr 1) xor polynomial
                } else {
                    crc shr 1
                }
            }
        }
        val lo = crc and 0xFF
        val hi = (crc shr 8) and 0xFF
        return byteArrayOf(lo.toByte(), hi.toByte())  // Modbus RTU:低字节在前
    }
}

手写 CRC16 有几个容易翻车的细节,都是实测踩出来/对照出来的:

  1. 多项式是 0xA001(0x8005 的反向),初值 0xFFFF,右移运算——别和 CRC-CCITT 搞混;
  2. 字节序:Modbus RTU 的 CRC 是低字节在前,和寄存器数据的高字节在前正好相反,抓包对不上时先怀疑这个;
  3. Java/Kotlin 的 byte 有符号:参与运算前必须 and 0xFF 转无符号,否则 CRC 算出来完全是另一个数。

解析侧:总线上的数据很"脏",解析必须带过滤

485 是共享总线,读线程收到的不只是你查询的那一条响应——可能混着开门指令的回显、其他板子的应答、分片的数据。我们的读线程按"读空为界"合并分片,回调出一段 hex 数据,解析时做了严格过滤:

/**
 * 只解析功能码 0x03 的响应,过滤掉开门(0x06)等其它回显,避免误解析导致状态闪烁。
 * @return Triple<锁板地址, 门号, 状态>,state: 0=关 1=开 -1=解析失败
 */
fun parseGateState(hexStr: String): Triple<Int, Int, Int> {
    if (hexStr.length < 14) return Triple(-1, -1, -1)
    return try {
        if (hexStr.substring(2, 4) != "03") return Triple(-1, -1, -1)
        Triple(
            hexStr.substring(0, 2).toInt(16),   // 锁板地址
            hexStr.substring(6, 8).toInt(16),   // 门号
            hexStr.substring(8, 10).toInt(16)   // 状态
        )
    } catch (e: Exception) {
        Triple(-1, -1, -1)
    }
}

如果没加功能码过滤,开门瞬间总线上的 0x06 回显会被误当成门状态,UI 上门图标疯狂闪烁——这个 bug 现场复现过。

可靠性设计:通风指令的双索引下发与重试

开门指令丢一条,用户刷第二次卡就行了;但通风指令(定时开启柜内风扇)丢了没人会立刻发现,等柜内温度上来了才知道出事。所以通风走的是另一条更重的链路。

背景:一条指令变成了两条

最初通风继电器只对应锁板上索引 0x06 的寄存器。后来现场发现部分锁板的风扇实际接在索引 0x05 上,硬件改不了,只能软件兼容——同一条"开启通风"动作,需要同时下发到索引 6 和索引 5,哪一路真的接着风扇就由硬件自己决定。

最终实现

整个发送器(VentilationCommandSender)的可靠性和时序设计如下:

object VentilationCommandSender {
    private const val MAX_RETRY_COUNT = 2            // 最多重试 2 次(共 3 轮)
    private const val ACK_TIMEOUT_MS = 1_000L        // 单条指令回显超时
    private const val POLLING_IDLE_WAIT_MS = 300L    // 暂停轮询后的总线静默等待
    private const val INTER_COMMAND_INTERVAL_MS = 200L  // 两条指令间的发送间隔

    private const val FAN_INDEX_PRIMARY = 0x06
    private const val FAN_INDEX_SECONDARY = 0x05

    fun sendStartFan(boardAddress: Int): Boolean {
        return sendFanCommand(
            boardAddress,
            commands = listOf(
                GateCommandUtil.startFan(boardAddress, FAN_INDEX_PRIMARY),
                GateCommandUtil.startFan(boardAddress, FAN_INDEX_SECONDARY)
            ),
            actionName = "开启通风"
        )
    }
}

核心流程:

private fun sendFanCommand(boardAddress: Int, commands: List<ByteArray>, actionName: String): Boolean {
    pollingManager.pause()          // 1. 暂停门状态轮询,独占总线
    try {
        if (!ensureSerialConnected()) return false
        Thread.sleep(POLLING_IDLE_WAIT_MS)   // 2. 等总线静默,避免和轮询包撞车

        repeat(MAX_RETRY_COUNT + 1) { attemptIndex ->
            if (sendAllCommands(commands)) return true   // 3. 整体成功才返回
            if (attemptIndex < MAX_RETRY_COUNT) Thread.sleep(150L)  // 整组重试
        }
        return false
    } finally {
        pollingManager.resume()     // 4. 无论成败,恢复轮询
    }
}

/** 依次发多条指令,指令间隔 200ms;全部收到有效回显才算成功 */
private fun sendAllCommands(commands: List<ByteArray>): Boolean {
    commands.forEachIndexed { index, command ->
        if (!sendOnceAndWaitEcho(command)) return false
        if (index < commands.lastIndex) {
            Thread.sleep(INTER_COMMAND_INTERVAL_MS)
        }
    }
    return true
}

单条指令的"回显确认"用 CountDownLatch 实现——发出去后临时挂一个数据监听,1 秒内收到包含原指令 hex 的回显才算成功:

private fun sendOnceAndWaitEcho(command: ByteArray): Boolean {
    val expectedHex = command.toHexString()
    val matched = AtomicBoolean(false)
    val latch = CountDownLatch(1)
    val listener = object : Rs485SerialManager.OnDataReceiveListener {
        override fun onDataReceive(bufferList: List<ByteArray>) {
            val actualHex = bufferList.joinToString("") { it.toHexString() }
            if (actualHex.contains(expectedHex, ignoreCase = true)) {
                matched.set(true)
                latch.countDown()
            }
        }
    }
    serialManager.addListener(listener)
    return try {
        val writeSuccess = serialManager.sendBufferOneTime(command)
        writeSuccess && latch.await(ACK_TIMEOUT_MS, TimeUnit.MILLISECONDS) && matched.get()
    } finally {
        serialManager.removeListener(listener)
    }
}

几个设计决策的理由:

  • 为什么暂停轮询? 门状态轮询每 200ms 一发,和通风指令抢总线会导致双方回显交织,回显匹配容易误判。pause() 用计数器实现,支持嵌套调用(多路并发暂停时不会提前恢复)。
  • 为什么指令间隔 200ms? 锁板单片机处理一条写寄存器指令需要时间,连着发第二条可能被丢。200ms 是实测稳定值。
  • 为什么"两条都收到回显才算成功,否则整组重试"? 部分成功(6 成功 5 失败)的状态没法上报后台,也不值得单独补偿,整组重来最简单可靠。
  • 为什么用回显而不是锁板的正常应答做确认? 485 的 A/B 线发送的同时会自收到发出的数据(取决于硬件接法),实际项目里回显是最稳定可得的确认信号,语义等价于"数据确实上了总线"。

轮询管理器:一个"够用就好"的架构样本

最后是常驻的门状态轮询(GatePollingManager),负责定时查所有箱门开关状态并同步给 UI 和云端。整体结构:

Handler(主线程) 递归调度
    └→ 单线程 Executor 执行一轮轮询
         ├→ 遍历所有箱门:发送 checkGateState → sleep(门间间隔) → 检查超时
         └→ 一轮结束 sleep(200ms) → handler.post(下一轮)

Rs485SerialManager 读线程收到响应
    └→ dataListener 解析 → updateGateState()
         ├→ 状态有变化 → 更新 StateFlow + 写数据库 + 上报 MQTT
         └→ 状态无变化 → 什么都不做

几个关键点:

1. 调度用 Handler 递归 post,不用 scheduleAtFixedRate。 递归式调度天然保证"上一轮跑完才开始下一轮计时",不会出现任务堆积;而且一轮轮询的耗时本身随门数量变化(每扇门之间还要 sleep 一个可配置间隔),固定周期反而别扭。

2. 暂停/恢复用计数器,不用布尔位。

fun pause()  { synchronized(pauseLock) { pauseCount++; pauseState = true } }
fun resume() {
    synchronized(pauseLock) {
        pauseCount = (pauseCount - 1).coerceAtLeast(0)
        if (pauseCount == 0) pauseState = false
    }
}

开门流程和通风流程可能并发要求暂停轮询,布尔位会被先返回的一方提前复位,计数器则不会。

3. 状态不变,坚决不做事。 这是 2GB 内存设备上的生存法则:

private fun updateGateState(groupId: Int, gateId: Int, state: Int) {
    // ...定位到 gate 实体...
    val newState = if (state == 0) GateState.CLOSED else GateState.OPEN
    missingStatusSinceAt.remove(gate.id)

    val oldState = _gateStates.value[gate.id]
    if (oldState != newState) {          // 只有真正变化才更新
        lockBoardRepo.updateGateState(gate.id, newState)   // 写库
        val current = _gateStates.value.toMutableMap()
        current[gate.id] = newState
        _gateStates.value = current                          // 触发 UI
        DoorStatusReporter.getInstance().onGateLockStatusChanged(gate.id, newState)  // 上报
    }
}

门的状态 99.99% 的时间是不变的。如果每次轮询都写一次 SQLite、重建一次 StateFlow 的 Map、刷新一次 RecyclerView,一天下来就是几十万次无意义的 IO 和对象分配,在低配设备上迟早出问题。

4. 离线超时检测。 用 ConcurrentHashMap 记录每扇门"从何时起没收到状态",超过 60 秒标记为 ABNORMAL 并同样走变化上报链路——锁板断电/485 断线时,运维端能看到异常而不是停留在"关闭"的假状态。

【配图建议:轮询时序图 —— 遍历 N 扇门逐条下发查询指令 → 读线程异步收响应 → 状态机更新】

总结与适用场景

回顾一下这篇文章的几个沉淀:

坑/设计一句话结论
JNI 包名绑定so 的 native 符号按编译时 Java 全限定名生成,改包名前先看 `readelf` 导出符号
`SerialPort.close()` SIGABRT流关闭即释放 fd,再调 native close 是 double-close;正确姿势是只关流和线程
CRC16 手写多项式 0xA001、初值 0xFFFF、低字节在前、byte 转无符号,四个点缺一不可
回显确认不可靠总线上,"发出去"不等于"到了",应用层必须有自己的确认与重试
双索引下发硬件批次差异用软件冗余兜底,整组成功才算成功,整组重试
轮询架构递归调度防堆积、计数器式暂停、状态不变不做事

这套方案的适用范围其实很明确:Android 主机(尤其 RK/全志方案工控板)直连 485 总线控制低速外设的场景——智能柜锁板、工业 IO 板、传感器采集。它不适合高吞吐、多主或对实时性有硬要求的场景,那种场合应该让 MCU 干 MCU 的活。

最后一句肺腑之言:android-serialport-api 这个库快十年没人维护了,但它在工控 Android 领域的存量代码里无处不在。遇到它的时候,别把它当普通 Java 库随便重构——它身上的每一个"别扭",背后都是前人踩过的坑。