简介:这份 Unity 安卓串口收发代码面向需要在 Unity 应用中连接 Arduino、树莓派等串行设备的移动端开发者,重点解决 Android 平台下 C# 无法直接访问串口、需经 Java 与 JNI 桥接的问题。压缩包共 30 个文件,约 124KB,包含 5 个 Java 源文件、6 个 so 库、C 与头文件、Android.mk 及 Application.mk 构建脚本,另有编译中间产物和版本控制缓存,能够较完整地呈现从 Java 接口、JNI 本地实现到 Unity 调用的工程结构。已有 1175 人学习下载,适合具备一定 Unity 与 Android 基础、希望打通硬件通信链路的读者参考。其中的串口打开、数据收发、权限声明与构建配置可作为排错和二次开发的对照材料,帮助理解 AndroidJavaObject 调用 Java 层、再由本地 so 访问串口设备的实现路径。
1. 为什么 Unity 安卓串口要先绕开 System.IO.Ports
“Unity 安卓串口收发代码”这几个字听起来像一段百来行的小脚本,真放到一体机、工控屏或者带扫码枪的产线上,它其实是一条完整链路:场景里点一下按钮,字节要穿过 C# 运行时、JNI、Android 的 UART 或 USB 驱动,最后落到/dev/ttyS3这样的设备节点上再原路返回。很多开发者第一次写会习惯性using System.IO.Ports,编辑器里跑得好好的,一打包到真机就收到PlatformNotSupportedException,这是最典型的第一个坑。
这份代码面向的是做安卓串口外设联调的开发者:扫码枪、PLC、电子秤、小票打印机、传感器模块,协议大多是十六进制帧。目标不复杂——在 Unity 里能把口打开、把指令发出去、把回包稳定收上来,并且协议层能按项目改。下面按工程结构、核心收发、安卓适配、排查、二次开发五段拆。
2. 工程结构与运行链路:从场景按钮到 /dev/ttyS* 的一次往返
一套能上真机的 Unity 串口实现,目录不会只有一个脚本。它至少要分四层:接口层、平台实现层、Unity 生命周期层、协议工具层。分层的目的不是好看,而是让 Editor 里能用虚拟串口调试,Android 上能切到 JNI 实现,业务代码不用改。
2.1 目录与脚本分工
常见的目录组织是这样,放在Assets/Scripts/SerialPort下:
Assets/ ├── Scripts/ │ └── SerialPort/ │ ├── ISerialPort.cs # 串口行为接口 │ ├── SerialConfig.cs # 波特率、数据位等参数结构 │ ├── SerialPortFactory.cs # 按平台创建实现 │ ├── EditorSerialPort.cs # 编辑器/Windows 下用 System.IO.Ports │ ├── AndroidSerialPort.cs # Android 下通过 JNI 调 Java 层 │ ├── SerialPortManager.cs # MonoBehaviour,挂在场景对象上 │ ├── FrameParser.cs # 粘包/半包解析 │ └── HexUtil.cs # 十六进制与 byte[] 互转 ├── Plugins/ │ └── Android/ │ ├── AndroidSerialPort.jar # Java 层编译产物 │ └── AndroidManifest.xml # USB Host 权限声明 └── Scenes/ └── SerialDemo.unity接口层只描述“能干什么”,不关心底下是 Windows 的 COM3 还是安卓的/dev/ttyS3。平台实现层用条件编译隔开,#if UNITY_EDITOR || UNITY_STANDALONE走System.IO.Ports,#if UNITY_ANDROID && !UNITY_EDITOR走 JNI。这样在编辑器里接一个 USB 转串口模块就能把协议逻辑调完,不必每次都打包到真机,省掉大量来回。
SerialPortFactory只做一件事:根据当前平台返回对应的ISerialPort实例。业务层拿到接口后调用Open、Write、Read,不会出现if (Application.platform == RuntimePlatform.Android)散落在各个业务脚本里的情况。这条约束在后期接第二个串口设备时特别值钱。
2.2 场景对象与串口管理器的挂载方式
SerialPortManager继承MonoBehaviour,挂在一个空物体上, Inspector 里暴露串口名、波特率、超时时间。打开时机放在Start而不是Awake,因为Awake阶段场景还没完全初始化,某些安卓机型上 JNI 环境拿不到稳定的 Activity。关闭放在OnDestroy和OnApplicationPause(true)两处,安卓切后台时系统可能回收文件描述符,不主动关会出现“回到前台口打不开”的玄学问题。
using System; using System.Collections.Generic; using UnityEngine; public class SerialPortManager : MonoBehaviour { [Header("串口参数")] public string portName = "/dev/ttyS3"; public int baudRate = 115200; public int dataBits = 8; public int readTimeoutMs = 20; [Header("调试")] public bool showSendLog = true; public bool showReceiveLog = true; private ISerialPort _port; private readonly List<byte[]> _frames = new List<byte[]>(); private FrameParser _parser; private readonly object _lock = new object(); // 业务侧订阅收到完整帧 public event Action<byte[]> OnFrameReceived; private void Start() { var config = new SerialConfig { portName = portName, baudRate = baudRate, dataBits = dataBits, stopBits = StopBits.One, parity = Parity.None }; _port = SerialPortFactory.Create(); if (!_port.Open(config)) { Debug.LogError($"串口打开失败: {portName}"); return; } _parser = new FrameParser(); Debug.Log($"串口已打开: {portName} @ {baudRate}"); } private void Update() { if (_port == null || !_port.IsOpen) return; // 每帧最多读一次,避免主线程被读操作拖住 var buffer = new byte[512]; int len = _port.Read(buffer, 0, buffer.Length); if (len <= 0) return; if (showReceiveLog) Debug.Log("RX: " + HexUtil.ToHex(buffer, len)); lock (_lock) { _parser.Push(buffer, len); var list = _parser.Extract(); if (list.Count > 0) _frames.AddRange(list); } // 回调放到主线程,业务侧可以安全操作 UI for (int i = 0; i < _frames.Count; i++) OnFrameReceived?.Invoke(_frames[i]); _frames.Clear(); } public void Send(byte[] data) { if (_port == null || !_port.IsOpen) return; int n = _port.Write(data); if (showSendLog) Debug.Log($"TX({n}): " + HexUtil.ToHex(data, data.Length)); } private void OnApplicationPause(bool pause) { if (pause) _port?.Close(); else if (_port != null && !_port.IsOpen) _port.Open(new SerialConfig { portName = portName, baudRate = baudRate, dataBits = dataBits }); } private void OnDestroy() { _port?.Close(); _port = null; } }这段代码里几个参数值得说明。readTimeoutMs不是所有平台都支持,Windows 的SerialPort.ReadTimeout可以设,安卓的FileInputStream没有超时概念,实际靠“非阻塞读 + 主线程轮询”实现,所以这个值在安卓实现里只作为日志参考。buffer每帧 new 一次是偷懒写法,真上产线建议复用成员数组,避免 GC 在长时间运行时抖动。OnFrameReceived在主线程触发,业务侧可以直接改 Text 组件;如果解析量大,建议改成独立线程解析、主线程只消费结果队列。
2.3 Android 端权限与设备节点扫描
安卓侧要分两条路线看。第一条是“设备节点路线”:系统已经把 UART 映射成/dev/ttyS*,应用只要有读写权限就能用FileInputStream/FileOutputStream打开。第二条是“USB Host 路线”:外接 CH340、CP210x 这类 USB 转串口芯片,走UsbManager申请权限,再按芯片协议做 bulk 传输。两条路线的权限模型完全不同,混在一起写会互相干扰。
节点路线需要在AndroidManifest.xml里声明 USB Host 特性,方便应用在插入设备时被系统识别;真正的设备节点权限通常由系统镜像的ueventd.rc或init.rc预置,普通应用没有 root 改不了。
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.demo.serial"> <uses-feature android:name="android.hardware.usb.host" android:required="true" /> <uses-permission android:name="android.permission.USB_PERMISSION" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <application> <activity android:name="com.unity3d.player.UnityPlayerActivity" android:exported="true"> <intent-filter> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> </intent-filter> <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter" /> </activity> </application> </manifest>device_filter.xml里按 VID/PID 列出要识别的芯片,系统插拔时会弹“是否允许访问该 USB 设备”。这一步不做,UsbManager.getDeviceList()里能看到设备,但openDevice()会抛权限异常。节点路线则不需要这个弹窗,它依赖的是文件系统权限,表现出来就是open failed: EACCES (Permission denied),和 USB 权限不是一回事,排查时要先分清。
扫描节点可以在 Java 层做一次遍历,把/dev/ttyS0到/dev/ttyS4、/dev/ttyUSB0、/dev/ttyACM0逐个canRead()判断,结果回传给 C# 做下拉框。这一步不做也能跑,但换一台主板节点编号变了就得改代码,现场调试很被动。
3. 收发核心拆解:打开、读线程、写指令与粘包解析
串口收发真正的难点不在“打开”,在“稳定地收到完整帧”。安卓的 UART 驱动一次read返回多少字节是不确定的,可能一次给你整帧,也可能先给 3 个字节、隔 10 毫秒再给剩下的。业务层如果直接拿每次read的结果当一帧解析,就会出现“偶尔少一个字节”“CRC 校验不过”这类让人抓狂的问题。
3.1 串口参数配置与打开逻辑
C# 侧先定义接口和参数结构,字段和 POSIX 的termios一一对应,方便后面 JNI 传参。
public enum StopBits { One = 1, Two = 2 } public enum Parity { None = 0, Odd = 1, Even = 2 } [Serializable] public struct SerialConfig { public string portName; // 安卓:/dev/ttyS3;编辑器:COM3 public int baudRate; // 9600 / 19200 / 115200 public int dataBits; // 通常是 8 public StopBits stopBits; public Parity parity; } public interface ISerialPort { bool Open(SerialConfig config); void Close(); int Read(byte[] buffer, int offset, int count); int Write(byte[] data); bool IsOpen { get; } }编辑器实现直接包System.IO.Ports.SerialPort,注意构造函数里把ReadTimeout和WriteTimeout都设上,否则读操作会无限阻塞,Unity 主线程直接卡死。
#if UNITY_EDITOR || UNITY_STANDALONE using System.IO.Ports; public class EditorSerialPort : ISerialPort { private SerialPort _sp; public bool IsOpen => _sp != null && _sp.IsOpen; public bool Open(SerialConfig config) { try { _sp = new SerialPort(config.portName, config.baudRate, (Parity)config.parity, config.dataBits, (StopBits)config.stopBits); _sp.ReadTimeout = 20; // 读超时,避免主线程阻塞 _sp.WriteTimeout = 500; _sp.Open(); return true; } catch (Exception e) { UnityEngine.Debug.LogError("打开失败: " + e.Message); return false; } } public void Close() { _sp?.Close(); _sp = null; } public int Read(byte[] buffer, int offset, int count) { try { return _sp.Read(buffer, offset, count); } catch (TimeoutException) { return 0; } } public int Write(byte[] data) { _sp.Write(data, 0, data.Length); return data.Length; } } #endif安卓实现走 JNI,C# 侧只负责调方法,真正的FileInputStream在 Java 层。这里有一个必须说清的点:FileInputStream只能读写数据,不能设置波特率。安卓 Java 层没有公开的termiosAPI,常见做法有三种——系统镜像里由驱动预配置好波特率;用 native 层 JNI 调tcsetattr;或者设备有 root 权限时用stty命令。这份代码按“节点已由系统配置”处理,Java 层只做打开和读写,波特率参数传进去仅用于日志记录。
package com.demo.serial; import android.util.Log; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; public class AndroidSerialPort { private static final String TAG = "AndroidSerialPort"; private FileInputStream mInput; private FileOutputStream mOutput; private volatile boolean mIsOpen = false; public boolean open(String devicePath, int baudRate, int dataBits, int stopBits, int parity) { try { File device = new File(devicePath); if (!device.exists() || !device.canRead() || !device.canWrite()) { Log.e(TAG, "node not ready: " + devicePath); return false; } // 注意:这里不设置 termios,参数由系统镜像或 native 层预置 mInput = new FileInputStream(device); mOutput = new FileOutputStream(device); mIsOpen = true; Log.i(TAG, "opened " + devicePath + " baud=" + baudRate); return true; } catch (IOException e) { Log.e(TAG, "open failed", e); return false; } } public int read(byte[] buffer) { if (!mIsOpen) return -1; try { int available = mInput.available(); if (available <= 0) return 0; // 没有数据直接返回,交给上层轮询 int len = Math.min(available, buffer.length); return mInput.read(buffer, 0, len); } catch (IOException e) { Log.e(TAG, "read failed", e); return -1; } } public int write(byte[] data) { if (!mIsOpen) return -1; try { mOutput.write(data); mOutput.flush(); return data.length; } catch (IOException e) { Log.e(TAG, "write failed", e); return -1; } } public void close() { mIsOpen = false; try { if (mInput != null) mInput.close(); } catch (IOException ignored) {} try { if (mOutput != null) mOutput.close(); } catch (IOException ignored) {} mInput = null; mOutput = null; } public boolean isOpen() { return mIsOpen; } }available()在普通文件上返回剩余字节数,在字符设备节点上行为依赖驱动实现,有的驱动返回 0 直到缓冲区里真有数据,有的返回一个估算值。所以 C# 侧不能假设“一次 read 就是一帧”,必须有下面的粘包解析兜底。
3.2 读线程与字节缓冲
读操作放在哪很关键。放在Update里每帧读一次,实现简单,但帧率波动时会漏读;放在独立线程里循环读,实时性好,但要把数据搬回主线程。这份代码选择“主线程轮询 + 非阻塞读”,原因是串口数据量通常不大(波特率 115200 下每秒最多 11KB),每帧读一次足够,且不用处理跨线程回调的麻烦。如果接的是高速采集设备,建议换成独立读线程 +ConcurrentQueue<byte[]>。
C# 侧 Android 实现的读方法通过AndroidJavaObject调 Java:
#if UNITY_ANDROID && !UNITY_EDITOR using UnityEngine; public class AndroidSerialPort : ISerialPort { private AndroidJavaObject _jo; private byte[] _javaBuffer = new byte[1024]; public bool IsOpen => _jo != null && _jo.Call<bool>("isOpen"); public bool Open(SerialConfig config) { // Java 类需提前编译成 jar 放进 Plugins/Android _jo = new AndroidJavaObject("com.demo.serial.AndroidSerialPort"); return _jo.Call<bool>("open", config.portName, config.baudRate, config.dataBits, (int)config.stopBits, (int)config.parity); } public int Read(byte[] buffer, int offset, int count) { if (_jo == null) return 0; // Java 层读到自己维护的 byte[],再拷回 C#,避免频繁 JNI 分配 int len = _jo.Call<int>("read", _javaBuffer); if (len <= 0) return 0; int copy = Mathf.Min(len, count); System.Array.Copy(_javaBuffer, 0, buffer, offset, copy); return copy; } public int Write(byte[] data) => _jo?.Call<int>("write", data) ?? -1; public void Close() { _jo?.Call("close"); _jo?.Dispose(); _jo = null; } } #endif这里_javaBuffer做成成员数组是为了减少 JNI 边界上的对象分配。每次Read都 new 一个byte[]传给 Java,短时间跑没问题,跑几个小时 GC 压力就上来了。AndroidJavaObject的Call有反射开销,频率高时可以把read合并成“一次取一批”,而不是每帧调一次。
3.3 写指令与十六进制收发
业务侧通常拿到的是十六进制字符串,比如AA 55 04 01 00 00 00 00 5C,需要转成byte[]再写。转换工具要处理空格、0x前缀、换行,长度必须是偶数,否则说明配置写错了,直接抛异常比静默截断好排查。
using System; using System.Text; public static class HexUtil { public static byte[] ToBytes(string hex) { if (string.IsNullOrEmpty(hex)) return Array.Empty<byte>(); hex = hex.Replace(" ", "") .Replace("0x", "") .Replace("\r", "") .Replace("\n", "") .Replace("\t", ""); if (hex.Length % 2 != 0) throw new ArgumentException("十六进制长度必须是偶数: " + hex); var bytes = new byte[hex.Length / 2]; for (int i = 0; i < bytes.Length; i++) bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); return bytes; } public static string ToHex(byte[] data, int len) { var sb = new StringBuilder(len * 3); for (int i = 0; i < len; i++) { sb.Append(data[i].ToString("X2")); if (i < len - 1) sb.Append(' '); } return sb.ToString(); } }Convert.ToByte(..., 16)遇到非法字符会抛FormatException,这比自定义解析器更省事,报错信息也够定位。日志里打TX和RX是现场排查最有效的手段——很多“设备没反应”最后发现是发出去的帧少了一个字节,或者 CRC 高低位反了,有原始十六进制日志一眼就能看出来。
3.4 粘包与半包处理
协议帧格式要在项目开始时定死,这里假设一种常见的结构:AA 55 | LEN | CMD | PAYLOAD | CRC16,LEN表示CMD + PAYLOAD的字节数,整帧长度是LEN + 5。解析器维护一个List<byte>缓存,每次把新数据追加进去,然后循环找帧头、判断长度、校验 CRC。
using System; using System.Collections.Generic; public class FrameParser { private readonly List<byte> _cache = new List<byte>(512); public void Push(byte[] data, int length) { for (int i = 0; i < length; i++) _cache.Add(data[i]); // 防止异常数据把内存撑爆,超过 4KB 丢弃最早的数据 if (_cache.Count > 4096) _cache.RemoveRange(0, _cache.Count - 4096); } public List<byte[]> Extract() { var frames = new List<byte[]>(); while (_cache.Count >= 5) { int head = FindHead(); if (head < 0) { _cache.Clear(); break; } if (head > 0) _cache.RemoveRange(0, head); if (_cache.Count < 3) break; int len = _cache[2]; int total = len + 5; if (_cache.Count < total) break; // 半包,等下一次数据 var frame = _cache.GetRange(0, total).ToArray(); if (CheckCrc(frame)) { frames.Add(frame); _cache.RemoveRange(0, total); } else { // CRC 不过,丢掉一个字节重新找头,避免死循环 _cache.RemoveAt(0); } } return frames; } private int FindHead() { for (int i = 0; i < _cache.Count - 1; i++) if (_cache[i] == 0xAA && _cache[i + 1] == 0x55) return i; return -1; } private bool CheckCrc(byte[] frame) { // 示例用累加和,真实项目按协议换成 CRC16/MODBUS byte sum = 0; for (int i = 2; i < frame.Length - 2; i++) sum += frame[i]; return sum == frame[frame.Length - 2]; } }FindHead找不到帧头时直接清空缓存,这是“宁可丢数据也不让脏数据滚雪球”的策略。CheckCrc里用累加和只是占位,接 Modbus 设备时要换成标准 CRC16,注意多项式和高低字节顺序,这两处写反是现场最常见的翻车点。total = len + 5这个 5 是帧头 2 字节 + 长度 1 字节 + CRC 2 字节,协议一改这个常量就要跟着改,建议抽成const int FrameOverhead = 5。
4. 安卓端适配:JNI 桥接、USB 权限与设备节点差异
Unity 在安卓上跑的是 Mono 或 IL2CPP,System.IO.Ports这套托管实现依赖 Windows 的串口 API,安卓上没有对应物,所以必须借 Java 或 native 层。JNI 桥接是最容易落地的方式,代价是要维护一份 Java 源码和编译产物。
4.1 JNI 桥接方式
C# 调 Java 有两条路:AndroidJavaObject和AndroidJNI。前者是高层封装,写起来像普通对象方法调用,适合业务逻辑;后者是底层函数指针,适合高频调用。串口读写频率不高,用AndroidJavaObject足够,代码可读性也好。
Java 类编译成 jar 后放进Assets/Plugins/Android,Unity 打包时会自动合并到 APK 的classes.dex。这里有个细节:如果项目里同时接了其他安卓插件,jar 里的package名要保证唯一,否则会出现类冲突,报ClassNotFoundException或NoClassDefFoundError。我一般把包名定成com.<项目代号>.serial,避免和通用库撞车。
C# 侧创建对象时,new AndroidJavaObject("com.demo.serial.AndroidSerialPort")会走 Java 的无参构造函数。如果 Java 类只有带参构造,要在 Java 里补一个无参构造,或者用AndroidJavaObject的另一个重载传参。这个错误在编辑器里看不出来,只有真机运行时才抛,所以第一次联调一定要先跑一个最小 Demo,确认 JNI 能通,再往上叠业务。
4.2 USB 转串口芯片的权限申请
走 USB Host 路线时,UsbManager的权限是动态的。应用第一次插入设备,系统弹窗询问,用户同意后权限只在当前会话有效;设备重新插拔或应用重启,要重新申请。常见做法是注册一个BroadcastReceiver监听自定义 action,在onReceive里判断EXTRA_PERMISSION_GRANTED,拿到权限后再枚举接口、找 bulk endpoint。
private static final String ACTION_USB_PERMISSION = "com.demo.serial.USB_PERMISSION"; private final BroadcastReceiver mReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (ACTION_USB_PERMISSION.equals(action)) { synchronized (this) { UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); boolean granted = intent.getBooleanExtra( UsbManager.EXTRA_PERMISSION_GRANTED, false); if (granted && device != null) { // 权限到手,后续按芯片型号打开接口、claim interface Log.i(TAG, "usb permission granted: " + device.getDeviceName()); } else { Log.w(TAG, "usb permission denied"); } } } } };注册 receiver 时要用ContextCompat.registerReceiver或registerReceiver带RECEIVER_NOT_EXPORTED标志,高版本安卓对隐式广播限制很严,漏了这个标志会直接抛SecurityException。另外,PendingIntent的 flag 要设FLAG_MUTABLE,否则部分机型上权限弹窗根本不出现,表现就是“代码没报错但一直没权限”,这种问题不看 logcat 很难定位。
节点路线不需要这套弹窗,但它要求系统镜像给/dev/ttyS*配好属主和权限。普通应用装上去只能读不能写,或者连读都读不了,这时候要么让系统厂商在ueventd.rc里加一行chmod 0666,要么应用拿系统签名。现场遇到EACCES,先adb shell ls -l /dev/ttyS*看权限位,再决定往哪个方向推,比盲改代码有效。
4.3 不同设备节点命名差异
同样是串口,不同主板、不同芯片方案给出的节点名完全不一样。下面这张表是现场对过的常见情况,换设备时先对照看一眼,能省掉很多“代码没动怎么就跑不起来”的困惑。
| 节点名 | 常见来源 | 注意事项 |
|---|---|---|
/dev/ttyS0~/dev/ttyS4 | SoC 原生 UART | 编号和硬件丝印不一定对应,要以实测为准 |
/dev/ttyUSB0 | USB 转串口芯片 | 插拔后编号可能变成ttyUSB1,不能写死 |
/dev/ttyACM0 | CDC-ACM 类设备 | 部分开发板、通信模块走这类节点 |
/dev/ttyHS0 | 高通平台高速串口 | 波特率上限高,但驱动配置因主板而异 |
/dev/ttyGS0 | Gadget 串口 | 通常用于设备作为从机被电脑访问,不是外设口 |
节点编号变化是产线最头疼的问题之一。设备上插两个 USB 转串口,先插的是ttyUSB0,后插的是ttyUSB1,重启后顺序可能反过来。稳妥做法是通过udev规则按序列号绑定固定名字,或者启动时遍历/sys/class/tty/*/device读 VID/PID 做匹配,而不是在代码里写死ttyUSB0。这一步多花半小时,能省掉后面无数次现场远程支持。
5. 避坑与常见问题排查:从打不开节点到收不到回包
串口这类和硬件打交道的问题,现象往往很统一——“没反应”,但原因可能分布在权限、参数、线程、协议四层。下面几条是实际项目里反复出现的,按“现象 → 原因 → 解决”记,遇到时按顺序过一遍。
5.1 打开就报 EACCES,日志里看不到任何设备数据
现象:Open返回 false,logcat 里是java.io.FileNotFoundException: /dev/ttyS3: open failed: EACCES (Permission denied),应用没有崩溃,但串口功能完全不工作。
原因:应用进程没有该设备节点的读写权限。安卓的 SELinux 和文件权限是两道门,节点属主通常是root:root或system:system,普通应用装上去只有rw-------甚至完全不可见。另一种情况是节点存在但被 SELinux 策略拦了,ls -l看权限是够的,open仍然失败。
解决:先用adb shell ls -l /dev/ttyS*确认节点存在和权限位;再用adb shell dumpsys package <包名>看应用是不是系统签名。如果只是调试,可以临时adb shell su -c chmod 666 /dev/ttyS3验证链路是否通;要上产线,必须让系统镜像在ueventd.rc里给这个节点配固定权限,或者把应用签成系统应用。不要指望在 Java 层用Runtime.exec("su")绕过,普通商用设备不会给 root。
5.2 能发不能收,或者收到的全是乱码
现象:Write返回成功,逻辑分析仪上也能看到波形,但设备不回应;或者回应了,读上来是FF FE 00这类明显不对的字节。
原因:波特率、数据位、停止位、校验位四项里至少一项和设备不匹配。最常见的是设备文档写 9600,代码里图省事写了 115200;其次是校验位,设备用偶校验,代码里默认None。安卓节点路线还有个隐藏问题:Java 层没有设termios,如果系统镜像预置的波特率和代码里传的不一致,代码里的参数其实没生效。
解决:先确认参数设置到底有没有落到驱动上。节点路线可以用adb shell stty -F /dev/ttyS3 -a看当前配置;USB 路线要看芯片驱动有没有按UsbDeviceConnection.controlTransfer设 line coding。参数确认后再看数据本身,用HexUtil.ToHex把原始字节打出来,和设备文档的应答帧逐字节对,先排除协议层问题。
5.3 点一下按钮界面卡两秒,然后 ANR
现象:发送指令后界面冻结,安卓弹“应用无响应”,logcat 里主线程堆栈停在read或write上。
原因:串口读写放在了Update或按钮回调里同步执行,而设备节点在没数据时read可能阻塞,或者write遇到流控被挂起。Unity 的主线程同时承担渲染和逻辑,一旦被阻塞超过 5 秒就是 ANR。
解决:读操作改成非阻塞轮询,available()为 0 就直接返回;写操作如果数据量大,放到独立线程或ThreadPool里。AndroidJavaObject.Call本身是同步的,Java 层方法里不要再做Thread.sleep之类的等待。另外,OnApplicationPause里关串口时如果正好有读线程在跑,要先置一个volatile bool停止标志,再close,否则容易出现Stream Closed异常刷屏。
5.4 帧偶尔少一个字节,CRC 隔一段时间就错一次
现象:大部分帧正常,跑几分钟到几十分钟出现一次 CRC 校验失败,或者解析器把半帧当整帧处理,业务侧收到一条长度不对的指令。
原因:读缓冲太小,一次read只取了部分数据,剩下的留在驱动缓冲区里;或者解析器在“找不到帧头”时直接清空缓存,把下一帧的开头也丢了。还有一种情况是List<byte>缓存在多线程下被同时读写,没有加锁。
解决:读缓冲开到 1024 以上,单次读不完就下一轮继续读,不要假设一次读完。解析器“找不到帧头”时只丢第一个字节再重新找,不要整段清空。跨线程访问缓存必须加锁,或者用ConcurrentQueue<byte>替代List<byte>。如果协议里有长度字段,优先按长度切帧,帧头只作为辅助校验,这样即使丢了一个帧头字节也能靠下一个帧头恢复。
6. 二次开发改造:把串口收发封装成可复用组件
第一版能跑通之后,接下来要考虑的是“换一个设备、换一条协议,代码改多少”。我的做法是把串口收发放到独立程序集里,业务层只依赖一个ISerialDevice接口和事件,不直接碰AndroidJavaObject或SerialPort。协议解析做成可替换的IFrameCodec,Modbus、自定义帧、ASCII 指令各自实现一份,SerialPortManager只负责搬运字节。
public interface ISerialDevice { event Action<byte[]> OnFrame; event Action<string> OnError; bool Open(SerialConfig config); void Send(byte[] frame); void Close(); } // 业务侧只依赖这个接口,换平台、换协议都不用改 public class ScannerService { private readonly ISerialDevice _device; public ScannerService(ISerialDevice device) { _device = device; } public void Start() { _device.OnFrame += frame => { // 扫码枪回包:AA 55 开头,第 4 字节是条码长度 if (frame.Length > 5 && frame[0] == 0xAA && frame[1] == 0x55) { int len = frame[3]; string code = System.Text.Encoding.ASCII.GetString(frame, 4, len); UnityEngine.Debug.Log("条码: " + code); } }; _device.Open(new SerialConfig { portName = "/dev/ttyS3", baudRate = 9600, dataBits = 8 }); } }改造时优先动这三处。第一,把节点名、波特率、协议帧头做成ScriptableObject配置,现场换设备只改资源文件,不用重新出包。第二,给ISerialDevice加一个“回环实现”,把发出去的帧原样回灌给解析器,这样不接硬件也能验证协议层和 UI 逻辑,编辑器里就能跑。第三,日志分级,TX/RX原始字节默认关闭,出问题时通过一个开关打开,避免产线上日志把存储写满。
验证方法上,我会准备一张检查表:编辑器回环跑通、真机节点权限确认、拔插三次后能重新打开、连续收发 30 分钟不崩、CRC 错误率低于万分之一。这五项过了再交给现场。有一回偷懒省掉“拔插重开”这一项,设备在展台上跑了一天没事,到客户现场插拔一次就再也打不开,最后查出来是OnApplicationPause里关了串口但没置空引用。从那以后我每次交付前都强制走一遍拔插和切后台的流程,不再只看“当前能跑”。希望帮到你。
本文还有配套的精品资源,点击获取