Android串口通信架构设计:请求-响应队列与超时容错

背景

在开发一款基于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回调)
  • sendOrdersynchronized 块中遍历/修改队列
  • 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、按键)靠事件驱动,状态查询(电量)靠轮询。两者共用同一个串口队列,互不干扰。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容