一台跑在 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 源码,改不了符号名。
解决方案
两条路:
- 拿到 C 源码重编 so(用
RegisterNatives动态注册,或者保持静态绑定但改 C 里的函数名)——最干净,但前提是你有源码和 NDK 构建环境; - 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 报告),原因链条是这样的:
SerialPort构造时调用 nativeopen()拿到一个FileDescriptor,然后用这个 同一个 fd 分别构造FileInputStream和FileOutputStream;- Java 层的
FileInputStream.close()/FileOutputStream.close()最终都会走到 native 的close(fd)—— 也就是说,关掉流,底层 fd 就已经被 close 了(两个流共享一个 fd,其中一个关闭后 fd 就失效了); - 此时再调用
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 有几个容易翻车的细节,都是实测踩出来/对照出来的:
- 多项式是
0xA001(0x8005 的反向),初值0xFFFF,右移运算——别和 CRC-CCITT 搞混; - 字节序:Modbus RTU 的 CRC 是低字节在前,和寄存器数据的高字节在前正好相反,抓包对不上时先怀疑这个;
- 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 库随便重构——它身上的每一个"别扭",背后都是前人踩过的坑。
面向安卓工控、智能柜开发的实战踩坑记。两大 native 坑的根因与解法讲得透彻,可靠性设计思路可直接复用;不适配高吞吐与强实时场景。