背景
在开发一款基于Android系统的服务机器人时,需要通过串口与MCU(微控制器)通信,实现头部转动控制、灯光颜色设置、体温测量、电量查询、SOS紧急呼叫、电源按键响应等功能。串口通信与网络通信不同,它是一种半双工的、基于帧的、没有内置ACK机制的底层通信方式,需要在应用层自行设计可靠性保障。
本文分享了串口通信管理类 RobotSerial 的架构设计,重点讲解请求-响应队列、超时容错、指令优先级等关键机制。
整体架构
核心数据结构
class RobotSerial(private val callback: RobotSerialInitCallback) {
// 指令队列(线程安全的CopyOnWriteArrayList)
private val orderList = CopyOnWriteArrayList<SerialOrderModel>()
// 当前等待响应的指令
private var curOrder: String? = null
// 串口打开状态
private var openStatus: SerialOpenStatus = SerialOpenStatus.None
// 超时定时器
private var timeoutTimer: Timer? = null
// 串口实际操作对象
private val serial: RobotSerialHelp = RobotSerialHelp()
}
指令模型
每条指令封装为 SerialOrderModel,有两个关键字段:
-
order:带CRC校验的HEX字符串 -
isReply:是否为"只发送不等待响应"的指令(如ACK回复)
fun add2Send(data: String, isReply: Boolean = false, insertFirst: Boolean = false) {
if (AgentUtil.isTopApDevice()) return // AP设备不走串口
val newOrder = SerialOrderModel(SerialProtocolHelper.createCrc(data), isReply = isReply)
if (insertFirst) {
orderList.add(0, newOrder) // 优先插入队首
} else {
orderList.add(newOrder) // 正常加入队尾
}
if (openStatus == SerialOpenStatus.Success) {
if (curOrder == null) {
sendOrder() // 如果当前没有等待响应的指令,立即发送
}
} else {
startCheckTimer() // 串口未就绪,启动状态检查定时器
}
}
关键设计点
1. 请求-响应队列:一次只发一条
串口是半双工通信,不能像网络请求那样并发。设计原则是:同一时刻只有一条指令在等待响应。
private fun sendOrder() {
synchronized(orderList) {
try {
if (orderList.isNotEmpty() && curOrder == null) {
val model = orderList.removeFirst()
if (model.isReply) {
// ACK类指令:只发送,不等待响应,不发下一条
serial.sendHex(model.order)
} else {
// 正常指令:记录curOrder,等待MCU响应
curOrder = model.order
checkSendOrder(curOrder!!) // 触发测温等回调
serial.sendHex(curOrder)
}
startTimeoutTimer() // 启动超时定时器
}
} catch (e: Exception) { }
}
}
curOrder 是关键的状态变量:
- 为
null:没有指令在等待,可以发下一条 - 不为
null:有指令在等待响应,新的指令只能排队
当MCU响应到达时,parseData 清空 curOrder 并触发下一条:
private fun parseData(data: String) {
stopTimeoutTimer()
val lowData = data.uppercase()
checkReceiveOrder(lowData, curOrder) // 解析响应
// ... SOS、电源、音量等事件处理
curOrder = null // 清空当前指令
if (orderList.isNotEmpty()) {
sendOrder() // 发送下一条
}
}
2. 超时容错:防止队列卡死
如果MCU没响应(比如指令丢失、MCU忙、串口干扰),curOrder 永远不会被清空,队列就卡死了。解决方案是 400ms 超时定时器:
private fun startTimeoutTimer() {
stopTimeoutTimer()
timeoutTimer = Timer()
timeoutTimer?.schedule(object : TimerTask() {
override fun run() {
curOrder = null // 强制清空当前指令
startCheckTimer(400) // 400ms后重试发送
}
}, 400) // 400ms超时
}
超时后的处理很巧妙:不是立即重发当前指令,而是清空 curOrder 并通过 startCheckTimer 触发 sendOrder,发送队列中的下一条指令。这样即使某条指令永久失败,也不会阻塞后续指令。
3. 指令优先级:插队机制
某些场景需要指令优先发送,比如紧急呼叫的ACK回复、健康检测中的测温指令。通过 insertFirst 参数实现插队:
// 优先插入队首
fun measureTemperature() {
add2Send("1581", true) // insertFirst=true
}
// 正常排队
fun setColor(isCool: Boolean, isWarn: Boolean) {
add2Send(data) // insertFirst=false
}
SOS场景更特殊——不仅要插队,还要先发送一个ACK确认:
private fun checkSOS(data: String): Boolean {
return if (data == "4355090102000801F8") {
callback.sos()
EmergencyCallManager.toCall()
// 优先响应紧急呼叫
orderList.add(0, SerialOrderModel(
SerialProtocolHelper.createCrc("00"), true
))
true
} else false
}
4. 串口初始化重试机制
串口打开可能失败(设备未就绪、权限问题等),设计了最多3次重试,每次间隔2秒:
private var curInitCount = 0
private val maxInitCount = 3
private val serialInitDelay = 2000L
private fun startSerialTimer() {
if (curInitCount < maxInitCount) {
serialOpenTimer = Timer()
serialOpenTimer?.schedule(object : TimerTask() {
override fun run() {
if (curInitCount < maxInitCount) {
curInitCount++
initSerial()
} else {
openStatus = SerialOpenStatus.Fail
callback?.initResult(false)
}
}
}, serialInitDelay)
}
}
5. 数据解析:一帧多义
checkReceiveOrder 需要处理多种响应格式,有些依赖 curOrder(当前发送的指令),有些不依赖:
private fun checkReceiveOrder(data: String, curOrder: String?) {
// 依赖curOrder:测温响应(只有发了测温指令才会收到)
if (curOrder?.toLowerCase() == "435509020100158070") {
temperatureStatusCallback?.statusCallback(if ("435509010200000151" == data) 1 else 0)
}
// 不依赖curOrder:电量主动上报(20秒轮询)
else if (data.startsWith("43550C010200", true) && data.length >= 23) {
val result = data.substring(14, 16)
val status = data.substring(16, 18)
val value = data.substring(18, 22)
if (result == "01") {
EventBus.getDefault().post(BatteryLevelEvent(
batteryLevel = SerialDataUtil.hex2Int(value),
charging = status == "01"
))
}
}
// ... 固件版本、移动限位等
}
串口初始化的线程亲和性问题
串口打开时遇到过一个隐蔽的坑:SerialPort.<clinit> 会通过JNI加载 .so 文件,这是同步阻塞I/O。如果放到IO线程执行,后续的 addDataListener 可能在错误的线程上注册回调,导致数据接收不稳定。
解决方案是用 StrictMode 兜底而非切换线程:
private fun initSerial() {
scope.launch(Dispatchers.Main) {
try {
// 必须在原线程(Main)执行才能保证文件描述符线程亲和性正确
android.os.StrictMode.setThreadPolicy(
android.os.StrictMode.ThreadPolicy.Builder()
.permitDiskReads()
.build()
)
status = serial?.open(BaseConfig.ROBOT_COM, 115200) ?: -1
android.os.StrictMode.setThreadPolicy(
android.os.StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.penaltyLog()
.build()
)
} catch (e: Exception) { }
if (status == 0) {
serial?.addDataListener { parseData(it) }
openStatus = SerialOpenStatus.Success
callback?.initResult(true)
sendOrder()
} else {
startSerialTimer() // 重试
}
}
}
收获与思考
1. 串口通信需要应用层可靠性保障
网络通信有TCP保证可靠性,但串口是"裸"通信——发出去的指令可能丢失,对方可能不响应。curOrder + 超时定时器的组合,本质上是在应用层实现了一个简易的ARQ(自动重传请求)机制。虽然是"简易版"(超时后不重发当前指令而是跳过),但对于控制类指令来说足够了——跳过一条转个头的指令,比卡死整个队列要好得多。
2. CopyOnWriteArrayList的选择
指令队列用 CopyOnWriteArrayList 而非普通的 MutableList,因为:
-
add2Send可能在任意线程调用(UI线程、协程、Timer回调) -
sendOrder在synchronized块中遍历/修改队列 -
parseData在串口数据回调线程中修改队列(如SOS时orderList.add(0, ...))
CopyOnWriteArrayList 的写时复制特性保证了读操作不需要加锁,适合"读多写少"且并发写的场景。虽然每次写操作会复制整个数组,但指令队列通常很短(几条到十几条),性能可以接受。
3. 事件驱动还是轮询?
checkReceiveOrder 本身是事件驱动的——串口收到数据才回调。但日志里看到它每20秒固定触发一次,是因为 MainViewModel 有个轮询循环每20秒查一次电量:
fun pollingBatteryLevel() {
pollingJob = viewModelScope.launch(Dispatchers.IO) {
while (true) {
PreferenceManager.getInstance().getRobotSerial().queryBatteryLevel()
delay(20_000)
}
}
}
这说明实际系统中是"事件驱动 + 定时轮询"的混合模式:硬件事件(SOS、按键)靠事件驱动,状态查询(电量)靠轮询。两者共用同一个串口队列,互不干扰。