一台跑在 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 <span>for</span> java.io.FileDescriptor
com.nlane.irisra.serial.SerialPort.<span>open</span>(java.lang.<span>String</span>, <span>int</span>, <span>int</span>)
根因分析
JNI 的默认函数绑定规则是编译期按 Java 类的全限定名生成符号名。libserial_port.so 里的 native 函数长这样:
<span>// 符号名在编译 so 时就写死了</span>
JNIEXPORT jobject JNICALL
<span>Java_com_skyworth_splicing_SerialPort_open</span><span>(JNIEnv *env, jobject thiz,
jstring path, jint baudrate, jint flags)</span>;
也就是说,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 使用:
<span>// app/src/main/java/com/skyworth/splicing/SerialPort.java</span>
<span>package</span> com.skyworth.splicing; <span>// 包名由 so 里的 JNI 符号决定,不能动</span>
<span>public</span> <span>class</span> <span>SerialPort</span> {
<span>private</span> <span>native</span> FileDescriptor <span>open</span><span>(String path, <span>int</span> baudrate, <span>int</span> flags)</span>;
<span>public</span> <span>native</span> <span>int</span> <span>close</span><span>()</span>;
<span>static</span> {
System.loadLibrary(<span>"serial_port"</span>);
}
}
经验:集成任何"Java 壳 + 预编译 so"的第三方库时,先 readelf -sW libxxx.so | grep Java_ 看一眼导出符号,确认 so 绑定的包名,再决定 Java 类放哪。如果要用自己的包名,要么重编 so,要么用 JNI_OnLoad + RegisterNatives 做动态注册摆脱包名束缚。
坑二:serialPort.close() 一调,Fatal signal 6 (SIGABRT)
问题现象
这是更阴的一个坑。最开始 disconnect() 写得非常"教科书":
<span><span>fun</span> <span>disconnect</span><span>()</span></span> {
inputStream?.close()
outputStream?.close()
readThread?.interrupt()
serialPort?.close() <span>// ← 崩在这里:Fatal signal 6 (SIGABRT), code -6</span>
}
日志里是经典的 native 崩溃:
<span>A</span>/<span>libc</span>: <span>invalid</span> <span>address</span> <span>or</span> <span>address</span> <span>of</span> <span>corrupt</span> <span>block</span> <span>0</span><span>x</span>... <span>passed</span> <span>to</span> <span>dlfree</span>
<span>Fatal</span> <span>signal</span> <span>6</span> (SIGABRT), <span>code</span> <span>-6</span> <span>in</span> <span>tid</span> <span>xxxx</span>
根因分析
翻了 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(),只关流和线程——前人显然是踩过这个坑的,只是没写注释。我踩完才补上。
最终实现
<span><span>fun</span> <span>disconnect</span><span>()</span></span> {
isConnected = <span>false</span>
<span>// 1. 关流(底层 fd 随之释放)</span>
<span>try</span> { inputStream?.close() } <span>catch</span> (_: Exception) {}
inputStream = <span>null</span>
<span>try</span> { outputStream?.close() } <span>catch</span> (_: Exception) {}
outputStream = <span>null</span>
<span>// 2. 停读线程,最多等 500ms</span>
<span>try</span> {
readThread?.interrupt()
readThread?.join(<span>500</span>)
} <span>catch</span> (_: Exception) {}
readThread = <span>null</span>
<span>// 3. 注意:不调用 serialPort?.close()</span>
<span>// InputStream/OutputStream 关闭后底层 fd 已失效,再调 native close 会导致 SIGABRT。</span>
serialPort = <span>null</span>
}
经验:对 android-serialport-api 这类老库,正确姿势是"只关流,不 close SerialPort 对象"。serialPort = null 让对象被 GC 掉即可,fd 已经随流释放了。这个坑在网上有相当多的提问,但官方示例从没修过,属于典型的"人人踩、没人修"的历史遗留。
Modbus RTU 指令构造与 CRC16:手写不难,细节见真章
串口通了,下一步是和锁板"说上话"。锁板遵循 Modbus RTU,一条指令 8 字节:
<span>[从机地址]</span> <span>[功能码]</span> <span>[寄存器高字节]</span> <span>[寄存器低字节]</span> <span>[数据高]</span> <span>[数据低]</span> <span>[CRC低]</span> <span>[CRC高]</span>
开箱门是功能码 0x06(写单寄存器),查门状态是 0x03(读保持寄存器):
<span>object</span> GateCommandUtil {
<span>/** 开箱门指令(功能码 0x06) */</span>
<span><span>fun</span> <span>openGate</span><span>(group: <span>Int</span>, gateId: <span>Int</span>)</span></span>: ByteArray {
<span>val</span> buff = ByteArray(<span>8</span>)
buff[<span>0</span>] = (group and <span>0xff</span>).toByte() <span>// 锁板地址</span>
buff[<span>1</span>] = <span>0x06</span> <span>// 写单寄存器</span>
buff[<span>2</span>] = <span>0x30</span> <span>// 寄存器高字节(厂商协议)</span>
buff[<span>3</span>] = (gateId and <span>0xff</span>).toByte() <span>// 寄存器低字节 = 门号</span>
buff[<span>4</span>] = <span>0x00</span>
buff[<span>5</span>] = <span>0x01</span> <span>// 数据:1 = 开门</span>
<span>val</span> crc = getModbusCrc16(buff.copyOfRange(<span>0</span>, <span>6</span>))
buff[<span>6</span>] = crc[<span>0</span>] <span>// CRC 低字节在前</span>
buff[<span>7</span>] = crc[<span>1</span>]
<span>return</span> buff
}
<span>/** Modbus CRC16(多项式 0xA001,初值 0xFFFF) */</span>
<span><span>fun</span> <span>getModbusCrc16</span><span>(buffer: <span>ByteArray</span>)</span></span>: ByteArray {
<span>var</span> crc = <span>0x0000ffff</span>
<span>val</span> polynomial = <span>0x0000a001</span>
<span>for</span> (b <span>in</span> buffer) {
crc = crc xor (b.toInt() and <span>0x000000ff</span>)
repeat(<span>8</span>) {
crc = <span>if</span> ((crc and <span>0x00000001</span>) != <span>0</span>) {
(crc shr <span>1</span>) xor polynomial
} <span>else</span> {
crc shr <span>1</span>
}
}
}
<span>val</span> lo = crc and <span>0xFF</span>
<span>val</span> hi = (crc shr <span>8</span>) and <span>0xFF</span>
<span>return</span> byteArrayOf(lo.toByte(), hi.toByte()) <span>// Modbus RTU:低字节在前</span>
}
}
手写 CRC16 有几个容易翻车的细节,都是实测踩出来/对照出来的:
- 多项式是
0xA001(0x8005 的反向),初值0xFFFF,右移运算——别和 CRC-CCITT 搞混; - 字节序:Modbus RTU 的 CRC 是低字节在前,和寄存器数据的高字节在前正好相反,抓包对不上时先怀疑这个;
- Java/Kotlin 的 byte 有符号:参与运算前必须
and 0xFF转无符号,否则 CRC 算出来完全是另一个数。
解析侧:总线上的数据很"脏",解析必须带过滤
485 是共享总线,读线程收到的不只是你查询的那一条响应——可能混着开门指令的回显、其他板子的应答、分片的数据。我们的读线程按"读空为界"合并分片,回调出一段 hex 数据,解析时做了严格过滤:
<span>/**
* 只解析功能码 0x03 的响应,过滤掉开门(0x06)等其它回显,避免误解析导致状态闪烁。
* <span>@return</span> Triple<锁板地址, 门号, 状态>,state: 0=关 1=开 -1=解析失败
*/</span>
<span><span>fun</span> <span>parseGateState</span><span>(hexStr: <span>String</span>)</span></span>: Triple<<span>Int</span>, <span>Int</span>, <span>Int</span>> {
<span>if</span> (hexStr.length < <span>14</span>) <span>return</span> Triple(-<span>1</span>, -<span>1</span>, -<span>1</span>)
<span>return</span> <span>try</span> {
<span>if</span> (hexStr.substring(<span>2</span>, <span>4</span>) != <span>"03"</span>) <span>return</span> Triple(-<span>1</span>, -<span>1</span>, -<span>1</span>)
Triple(
hexStr.substring(<span>0</span>, <span>2</span>).toInt(<span>16</span>), <span>// 锁板地址</span>
hexStr.substring(<span>6</span>, <span>8</span>).toInt(<span>16</span>), <span>// 门号</span>
hexStr.substring(<span>8</span>, <span>10</span>).toInt(<span>16</span>) <span>// 状态</span>
)
} <span>catch</span> (e: Exception) {
Triple(-<span>1</span>, -<span>1</span>, -<span>1</span>)
}
}
如果没加功能码过滤,开门瞬间总线上的 0x06 回显会被误当成门状态,UI 上门图标疯狂闪烁——这个 bug 现场复现过。
可靠性设计:通风指令的双索引下发与重试
开门指令丢一条,用户刷第二次卡就行了;但通风指令(定时开启柜内风扇)丢了没人会立刻发现,等柜内温度上来了才知道出事。所以通风走的是另一条更重的链路。
背景:一条指令变成了两条
最初通风继电器只对应锁板上索引 0x06 的寄存器。后来现场发现部分锁板的风扇实际接在索引 0x05 上,硬件改不了,只能软件兼容——同一条"开启通风"动作,需要同时下发到索引 6 和索引 5,哪一路真的接着风扇就由硬件自己决定。
最终实现
整个发送器(VentilationCommandSender)的可靠性和时序设计如下:
<span>object</span> VentilationCommandSender {
<span>private</span> <span>const</span> <span>val</span> MAX_RETRY_COUNT = <span>2</span> <span>// 最多重试 2 次(共 3 轮)</span>
<span>private</span> <span>const</span> <span>val</span> ACK_TIMEOUT_MS = <span>1_000L</span> <span>// 单条指令回显超时</span>
<span>private</span> <span>const</span> <span>val</span> POLLING_IDLE_WAIT_MS = <span>300L</span> <span>// 暂停轮询后的总线静默等待</span>
<span>private</span> <span>const</span> <span>val</span> INTER_COMMAND_INTERVAL_MS = <span>200L</span> <span>// 两条指令间的发送间隔</span>
<span>private</span> <span>const</span> <span>val</span> FAN_INDEX_PRIMARY = <span>0x06</span>
<span>private</span> <span>const</span> <span>val</span> FAN_INDEX_SECONDARY = <span>0x05</span>
<span><span>fun</span> <span>sendStartFan</span><span>(boardAddress: <span>Int</span>)</span></span>: <span>Boolean</span> {
<span>return</span> sendFanCommand(
boardAddress,
commands = listOf(
GateCommandUtil.startFan(boardAddress, FAN_INDEX_PRIMARY),
GateCommandUtil.startFan(boardAddress, FAN_INDEX_SECONDARY)
),
actionName = <span>"开启通风"</span>
)
}
}
核心流程:
<span>private</span> <span><span>fun</span> <span>sendFanCommand</span><span>(boardAddress: <span>Int</span>, commands: <span>List</span><<span>ByteArray</span>>, actionName: <span>String</span>)</span></span>: <span>Boolean</span> {
pollingManager.pause() <span>// 1. 暂停门状态轮询,独占总线</span>
<span>try</span> {
<span>if</span> (!ensureSerialConnected()) <span>return</span> <span>false</span>
Thread.sleep(POLLING_IDLE_WAIT_MS) <span>// 2. 等总线静默,避免和轮询包撞车</span>
repeat(MAX_RETRY_COUNT + <span>1</span>) { attemptIndex ->
<span>if</span> (sendAllCommands(commands)) <span>return</span> <span>true</span> <span>// 3. 整体成功才返回</span>
<span>if</span> (attemptIndex < MAX_RETRY_COUNT) Thread.sleep(<span>150L</span>) <span>// 整组重试</span>
}
<span>return</span> <span>false</span>
} <span>finally</span> {
pollingManager.resume() <span>// 4. 无论成败,恢复轮询</span>
}
}
<span>/** 依次发多条指令,指令间隔 200ms;全部收到有效回显才算成功 */</span>
<span>private</span> <span><span>fun</span> <span>sendAllCommands</span><span>(commands: <span>List</span><<span>ByteArray</span>>)</span></span>: <span>Boolean</span> {
commands.forEachIndexed { index, command ->
<span>if</span> (!sendOnceAndWaitEcho(command)) <span>return</span> <span>false</span>
<span>if</span> (index < commands.lastIndex) {
Thread.sleep(INTER_COMMAND_INTERVAL_MS)
}
}
<span>return</span> <span>true</span>
}
单条指令的"回显确认"用 CountDownLatch 实现——发出去后临时挂一个数据监听,1 秒内收到包含原指令 hex 的回显才算成功:
<span>private</span> <span><span>fun</span> <span>sendOnceAndWaitEcho</span><span>(command: <span>ByteArray</span>)</span></span>: <span>Boolean</span> {
<span>val</span> expectedHex = command.toHexString()
<span>val</span> matched = AtomicBoolean(<span>false</span>)
<span>val</span> latch = CountDownLatch(<span>1</span>)
<span>val</span> listener = <span>object</span> : Rs485SerialManager.OnDataReceiveListener {
<span>override</span> <span><span>fun</span> <span>onDataReceive</span><span>(bufferList: <span>List</span><<span>ByteArray</span>>)</span></span> {
<span>val</span> actualHex = bufferList.joinToString(<span>""</span>) { it.toHexString() }
<span>if</span> (actualHex.contains(expectedHex, ignoreCase = <span>true</span>)) {
matched.<span>set</span>(<span>true</span>)
latch.countDown()
}
}
}
serialManager.addListener(listener)
<span>return</span> <span>try</span> {
<span>val</span> writeSuccess = serialManager.sendBufferOneTime(command)
writeSuccess && latch.await(ACK_TIMEOUT_MS, TimeUnit.MILLISECONDS) && matched.<span>get</span>()
} <span>finally</span> {
serialManager.removeListener(listener)
}
}
几个设计决策的理由:
- 为什么暂停轮询? 门状态轮询每 200ms 一发,和通风指令抢总线会导致双方回显交织,回显匹配容易误判。
pause()用计数器实现,支持嵌套调用(多路并发暂停时不会提前恢复)。 - 为什么指令间隔 200ms? 锁板单片机处理一条写寄存器指令需要时间,连着发第二条可能被丢。200ms 是实测稳定值。
- 为什么"两条都收到回显才算成功,否则整组重试"? 部分成功(6 成功 5 失败)的状态没法上报后台,也不值得单独补偿,整组重来最简单可靠。
- 为什么用回显而不是锁板的正常应答做确认? 485 的 A/B 线发送的同时会自收到发出的数据(取决于硬件接法),实际项目里回显是最稳定可得的确认信号,语义等价于"数据确实上了总线"。
轮询管理器:一个"够用就好"的架构样本
最后是常驻的门状态轮询(GatePollingManager),负责定时查所有箱门开关状态并同步给 UI 和云端。整体结构:
<span>Handler</span>(主线程) 递归调度
└→ 单线程 Executor 执行一轮轮询
├→ 遍历所有箱门:发送 checkGateState → <span>sleep</span>(门间间隔) → 检查超时
└→ 一轮结束 <span>sleep</span>(<span>200ms</span>) → handler<span>.post</span>(下一轮)
Rs485SerialManager 读线程收到响应
└→ dataListener 解析 → <span>updateGateState</span>()
├→ 状态有变化 → 更新 StateFlow + 写数据库 + 上报 MQTT
└→ 状态无变化 → 什么都不做
几个关键点:
1. 调度用 Handler 递归 post,不用 scheduleAtFixedRate。 递归式调度天然保证"上一轮跑完才开始下一轮计时",不会出现任务堆积;而且一轮轮询的耗时本身随门数量变化(每扇门之间还要 sleep 一个可配置间隔),固定周期反而别扭。
2. 暂停/恢复用计数器,不用布尔位。
<span><span>fun</span> <span>pause</span><span>()</span></span> { synchronized(pauseLock) { pauseCount++; pauseState = <span>true</span> } }
<span><span>fun</span> <span>resume</span><span>()</span></span> {
synchronized(pauseLock) {
pauseCount = (pauseCount - <span>1</span>).coerceAtLeast(<span>0</span>)
<span>if</span> (pauseCount == <span>0</span>) pauseState = <span>false</span>
}
}
开门流程和通风流程可能并发要求暂停轮询,布尔位会被先返回的一方提前复位,计数器则不会。
3. 状态不变,坚决不做事。 这是 2GB 内存设备上的生存法则:
<span>private</span> <span><span>fun</span> <span>updateGateState</span><span>(groupId: <span>Int</span>, gateId: <span>Int</span>, state: <span>Int</span>)</span></span> {
<span>// ...定位到 gate 实体...</span>
<span>val</span> newState = <span>if</span> (state == <span>0</span>) GateState.CLOSED <span>else</span> GateState.OPEN
missingStatusSinceAt.remove(gate.id)
<span>val</span> oldState = _gateStates.value[gate.id]
<span>if</span> (oldState != newState) { <span>// 只有真正变化才更新</span>
lockBoardRepo.updateGateState(gate.id, newState) <span>// 写库</span>
<span>val</span> current = _gateStates.value.toMutableMap()
current[gate.id] = newState
_gateStates.value = current <span>// 触发 UI</span>
DoorStatusReporter.getInstance().onGateLockStatusChanged(gate.id, newState) <span>// 上报</span>
}
}
门的状态 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 坑的根因与解法讲得透彻,可靠性设计思路可直接复用;不适配高吞吐与强实时场景。