简介:本资源是一套面向Unity开发者、嵌入式交互项目实践者的安卓串口通信完整实现方案,解决Unity应用在Android平台与Arduino、树莓派等硬件设备进行双向串行通信的核心难题,适用于物联网控制、传感器数据采集、游戏外设联动等实际场景。压缩包共30个文件,含5个核心Java类(封装USB串口管理逻辑)、6个ARM/x86架构的.so动态库(提供底层JNI接口)、3个Makefile构建脚本及1个SerialPort.h头文件,配合C/C++源码与Shell编译脚本,构成从JNI桥接到Unity C#调用的全链路支持体系;整体仅124KB,轻量紧凑。已有1172人学习下载,资源结构清晰,包含标准Android JNI工程目录(src/com/android_serialport_api/、libs/armeabi-v7a/等),附带gen_SerialPort_h.sh自动生成头文件脚本与Application.mk构建配置,便于快速集成与二次开发,是理解Unity跨平台原生扩展机制的典型范例。
1. Unity 安卓串口收发代码:为什么你写的 C# 脚本在 Editor 里跑得飞起,一打包到手机就“收不到字、发不出数”?
这不是玄学,是安卓平台串口通信的硬性断层——Unity 的 C# 层根本不提供原生串口 API,System.IO.Ports.SerialPort在 Android 上直接被 .NET Runtime 禁用(编译能过,运行必抛PlatformNotSupportedException)。你看到的“Unity 串口”从来不是 Unity 自己写的,而是靠 Java/Kotlin 层桥接、JNI 调用系统底层android.hardware.usb.UsbManager和UsbSerialDriver实现的。这意味着:每行 C# 代码背后,至少要过 3 层胶水(C# → JNI → Java → Linux TTY)。常见翻车场景包括:USB 权限没弹窗就静默失败、设备插拔事件监听漏触发、接收缓冲区被 Java 层吞掉一半、发送时序错乱导致单片机只收到半个指令。适合正在对接 STM32/ESP32/PLC 等嵌入式设备的 Unity 开发者,尤其当你手头只有 USB 转串口线(CH340/CP2102/FTDI)且必须走物理串口(而非蓝牙/WiFi)时——这篇就是你绕不开的落地路径。
2. 用 Unity + Android Java 插件实现串口收发:从零搭起可调试的最小闭环
2.1 为什么不用第三方 Asset Store 插件?选型依据与成本权衡
Asset Store 上标榜“Unity 串口”的插件(如 SerialPort for Android、USB Serial for Unity)多数封装了usb-serial-for-android库,但存在三个致命短板:
- 权限黑盒化:自动申请
android.permission.USB_PERMISSION,但不暴露UsbManager.requestPermission()的回调入口,导致用户点“拒绝”后无感知; - 线程模型混乱:Java 层读写在子线程,C# 回调却在主线程,高频率收发(>10Hz)时
UnitySynchronizationContext队列溢出,丢包率飙升; - 设备枚举不可控:
UsbManager.getDeviceList()返回的UsbDevice缺少 VID/PID 过滤接口,同一台手机插多个 CH340 设备时无法精准绑定目标端口。
我一般会放弃 Asset Store 方案,手写精简版 Java 插件:只保留open()/write()/read()/close()四个核心方法,所有 USB 权限、设备枚举、线程调度逻辑由 C# 主动控制。这样虽多写 200 行 Java,但换来的是:
✅ 可在 C# 层精确判断UsbManager.hasPermission(device)状态;
✅ 接收数据用HandlerThread+Looper保序,避免 Unity 主线程阻塞;
✅ 发送支持byte[]直传,规避 String 编码转换(UTF-8 vs GBK)引发的乱码。
2.2 创建 Android Java 插件:UsbSerialHelper.java核心实现
在Assets/Plugins/Android/src/com/example/serial/下新建UsbSerialHelper.java(注意包名需与后续AndroidManifest.xml一致):
package com.example.serial; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.hardware.usb.UsbConstants; import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbDeviceConnection; import android.hardware.usb.UsbEndpoint; import android.hardware.usb.UsbInterface; import android.hardware.usb.UsbManager; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.util.Log; import java.io.IOException; import java.util.HashMap; import java.util.Iterator; public class UsbSerialHelper { private static final String TAG = "UsbSerialHelper"; private static final String ACTION_USB_PERMISSION = "com.example.serial.USB_PERMISSION"; private UsbManager usbManager; private UsbDeviceConnection connection; private UsbInterface usbInterface; private UsbEndpoint inEndpoint; private UsbEndpoint outEndpoint; private HandlerThread readThread; private Handler readHandler; private ReadCallback callback; // 初始化 USB 管理器(需传入 UnityActivity Context) public void init(Context context) { usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); // 注册广播接收器监听 USB 权限请求结果 IntentFilter filter = new IntentFilter(ACTION_USB_PERMISSION); context.registerReceiver(usbReceiver, filter); } // 枚举已连接设备(返回 VID:PID 字符串列表) public String[] getDeviceList() { HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList(); if (deviceList.isEmpty()) return new String[0]; String[] result = new String[deviceList.size()]; Iterator<UsbDevice> iterator = deviceList.values().iterator(); for (int i = 0; i < result.length; i++) { UsbDevice device = iterator.next(); result[i] = String.format("%04X:%04X", device.getVendorId(), device.getProductId()); } return result; } // 请求 USB 权限(关键!必须由 C# 触发) public void requestPermission(String vidPid) { UsbDevice targetDevice = findDeviceByVidPid(vidPid); if (targetDevice == null) { Log.e(TAG, "Device not found: " + vidPid); return; } PendingIntent permissionIntent = PendingIntent.getBroadcast( usbManager.getClass().getClassLoader().getResourceAsStream(""), 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT ); usbManager.requestPermission(targetDevice, permissionIntent); } // 打开串口(需先获权限) public boolean open(String vidPid, int baudRate) { UsbDevice device = findDeviceByVidPid(vidPid); if (device == null || !usbManager.hasPermission(device)) { Log.e(TAG, "No permission for device: " + vidPid); return false; } connection = usbManager.openDevice(device); if (connection == null) { Log.e(TAG, "Failed to open device"); return false; } // 查找 CDC ACM 接口(CH340/CP2102 均使用 Interface 0) usbInterface = device.getInterface(0); if (!connection.claimInterface(usbInterface, true)) { Log.e(TAG, "Cannot claim interface"); close(); return false; } // 获取端点(IN=接收,OUT=发送) inEndpoint = null; outEndpoint = null; for (int i = 0; i < usbInterface.getEndpointCount(); i++) { UsbEndpoint ep = usbInterface.getEndpoint(i); if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() == UsbConstants.USB_DIR_IN) { inEndpoint = ep; } else if (ep.getDirection() == UsbConstants.USB_DIR_OUT) { outEndpoint = ep; } } } if (inEndpoint == null || outEndpoint == null) { Log.e(TAG, "Missing IN or OUT endpoint"); close(); return false; } // 启动读取线程(HandlerThread 保证 Looper 不阻塞主线程) readThread = new HandlerThread("UsbReadThread"); readThread.start(); readHandler = new Handler(readThread.getLooper()); return true; } // 异步读取(非阻塞,通过 callback 回传) public void startReading(ReadCallback cb) { this.callback = cb; readHandler.post(readRunnable); } private final Runnable readRunnable = new Runnable() { @Override public void run() { if (connection == null || inEndpoint == null) return; byte[] buffer = new byte[1024]; int len = connection.bulkTransfer(inEndpoint, buffer, buffer.length, 100); if (len > 0 && callback != null) { callback.onDataReceived(buffer, len); } // 持续轮询(实际项目中建议用 wait/notify 优化,此处简化) readHandler.postDelayed(this, 10); } }; // 发送数据(同步阻塞,确保发送完成) public int write(byte[] data) { if (connection == null || outEndpoint == null) return -1; return connection.bulkTransfer(outEndpoint, data, data.length, 1000); } // 关闭连接 public void close() { if (readHandler != null) { readHandler.removeCallbacksAndMessages(null); } if (readThread != null) { readThread.quitSafely(); } if (connection != null) { connection.close(); connection = null; } if (usbInterface != null) { try { connection.releaseInterface(usbInterface); } catch (Exception ignored) {} } } // 辅助方法:按 VID:PID 查找设备 private UsbDevice findDeviceByVidPid(String vidPid) { String[] parts = vidPid.split(":"); if (parts.length != 2) return null; int vid = Integer.parseInt(parts[0], 16); int pid = Integer.parseInt(parts[1], 16); HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList(); for (UsbDevice device : deviceList.values()) { if (device.getVendorId() == vid && device.getProductId() == pid) { return device; } } return null; } // 广播接收器:处理权限请求结果 private final BroadcastReceiver usbReceiver = 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); if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { Log.d(TAG, "Permission granted for " + device); } else { Log.e(TAG, "Permission denied for " + device); } } } } }; // 回调接口(供 C# 实现) public interface ReadCallback { void onDataReceived(byte[] data, int length); } }逻辑说明:该类将 USB 权限申请、设备枚举、端点绑定、读写线程全部解耦为独立方法。关键设计点:
requestPermission()仅触发系统弹窗,不等待用户操作,权限结果通过广播ACTION_USB_PERMISSION异步通知;startReading()使用HandlerThread创建专属 Looper,避免UnityPlayer.currentActivity.runOnUiThread()频繁跨线程;write()方法采用同步阻塞(bulkTransfer第四参数设为 1000ms),确保指令完整发出,防止因 C# 层频繁调用导致 USB 控制器缓冲区溢出。参数说明:
baudRate在open()中未直接使用(因 CH340/CP2102 的波特率由硬件晶振决定,Android 层仅做协议适配),实际波特率需在单片机端配置匹配值(如 115200)。
2.3 编译插件并集成到 Unity:生成 AAR 包与 AndroidManifest 配置
导出 AAR:在 Android Studio 中新建 Module →
Android Library,将UsbSerialHelper.java放入src/main/java/com/example/serial/,build.gradle中添加依赖:dependencies { implementation 'com.github.mik3y:usb-serial-for-android:3.4.6' // 仅用于类型提示,实际未引用 }执行
./gradlew assembleRelease,生成build/outputs/aar/serial-release.aar。放入 Unity 工程:将
serial-release.aar复制到Assets/Plugins/Android/,删除src/文件夹(Unity 不识别源码,只认 AAR/JAR)。配置
AndroidManifest.xml:在Assets/Plugins/Android/AndroidManifest.xml中添加:<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <application> <!-- 声明广播接收器 --> <receiver android:name="com.example.serial.UsbSerialHelper$1" android:exported="true" /> </application> <!-- 必加权限 --> <uses-permission android:name="android.permission.USB_PERMISSION" /> <uses-feature android:name="android.hardware.usb.host" /> </manifest>注意:
android:exported="true"是 Android 12+ 强制要求,否则广播无法接收权限结果。
3. C# 层桥接与状态机管理:把 Java 插件变成可维护的 Unity 组件
3.1 创建AndroidUsbSerial.cs:JNI 调用封装与生命周期绑定
在Assets/Scripts/下新建AndroidUsbSerial.cs,实现 Java 类的反射调用:
using System; using System.Collections.Generic; using System.Text; using UnityEngine; public class AndroidUsbSerial : MonoBehaviour { private AndroidJavaObject usbHelper; private AndroidJavaClass unityPlayer; private AndroidJavaObject currentActivity; private string[] connectedDevices; private bool isPermissionGranted = false; private Action<byte[], int> onDataReceived; // 初始化:获取 Activity 并初始化 Java Helper public void Init() { if (Application.platform != RuntimePlatform.Android) { Debug.LogError("UsbSerial only works on Android!"); return; } unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"); usbHelper = new AndroidJavaObject("com.example.serial.UsbSerialHelper"); // 调用 Java init() 方法传入 Activity usbHelper.Call("init", currentActivity); Debug.Log("UsbSerialHelper initialized"); } // 枚举设备(返回 VID:PID 列表,如 ["1A86:7523", "10C4:EA60"]) public string[] GetDeviceList() { if (usbHelper == null) return new string[0]; try { connectedDevices = usbHelper.Call<string[]>("getDeviceList"); return connectedDevices; } catch (Exception e) { Debug.LogError("GetDeviceList failed: " + e.Message); return new string[0]; } } // 请求权限(传入 VID:PID,如 "1A86:7523") public void RequestPermission(string vidPid) { if (usbHelper == null) return; usbHelper.Call("requestPermission", vidPid); } // 检查权限状态(需在 RequestPermission 后调用) public bool HasPermission(string vidPid) { if (usbHelper == null) return false; // 此处需 Java 层补充 hasPermission 方法(见下文补丁) return isPermissionGranted; } // 打开串口 public bool Open(string vidPid, int baudRate = 115200) { if (usbHelper == null) return false; try { bool success = usbHelper.Call<bool>("open", vidPid, baudRate); if (success) { Debug.Log($"Serial opened: {vidPid} @ {baudRate}bps"); StartReading(); } return success; } catch (Exception e) { Debug.LogError("Open failed: " + e.Message); return false; } } // 启动读取(注册 C# 回调) public void SetDataReceivedCallback(Action<byte[], int> callback) { onDataReceived = callback; } private void StartReading() { if (usbHelper == null || onDataReceived == null) return; // 创建 Java 回调代理(关键!) var callbackProxy = new AndroidJavaProxy("com.example.serial.UsbSerialHelper$ReadCallback"); callbackProxy.RegisterMethod("onDataReceived", (object[] args) => { byte[] data = args[0] as byte[]; int length = (int)args[1]; if (data != null && length > 0) { // 复制有效数据段(避免 Java 数组残留垃圾) byte[] validData = new byte[length]; Array.Copy(data, 0, validData, 0, length); onDataReceived?.Invoke(validData, length); } }); usbHelper.Call("startReading", callbackProxy); } // 发送数据 public int Write(byte[] data) { if (usbHelper == null || data == null) return -1; try { return usbHelper.Call<int>("write", data); } catch (Exception e) { Debug.LogError("Write failed: " + e.Message); return -1; } } // 关闭串口 public void Close() { if (usbHelper != null) { usbHelper.Call("close"); Debug.Log("Serial closed"); } } // 清理资源(GameObject 销毁时调用) public void OnDestroy() { Close(); if (usbHelper != null) { usbHelper.Dispose(); usbHelper = null; } if (currentActivity != null) { currentActivity.Dispose(); currentActivity = null; } if (unityPlayer != null) { unityPlayer.Dispose(); unityPlayer = null; } } }逻辑说明:
AndroidJavaProxy是 Unity 提供的 JNI 回调机制,它将 C# 的Action封装为 Java 接口实现。重点在于:
onDataReceived回调中必须复制data数组的有效段(Array.Copy),否则 Java 层复用缓冲区会导致 C# 读到脏数据;Dispose()调用不可省略,否则 Java 对象长期驻留内存,多次开关串口后 OOM。参数说明:
baudRate参数在Open()中传递给 Java,但实际生效依赖单片机端配置——这是串口通信的黄金法则:两端波特率必须严格一致,且由硬件决定上限(CH340 最高支持 2Mbps,但 Android USB Host 控制器常限制在 115200)。
3.2 补全 Java 层hasPermission方法(修复 C# 权限检查漏洞)
在UsbSerialHelper.java中添加以下方法(插入到类末尾):
// 新增:供 C# 查询权限状态 public boolean hasPermission(String vidPid) { UsbDevice device = findDeviceByVidPid(vidPid); return device != null && usbManager.hasPermission(device); }并在 C# 的HasPermission()方法中调用:
public bool HasPermission(string vidPid) { if (usbHelper == null) return false; try { isPermissionGranted = usbHelper.Call<bool>("hasPermission", vidPid); return isPermissionGranted; } catch (Exception e) { Debug.LogError("HasPermission check failed: " + e.Message); return false; } }为什么必须补这个?
Asset Store 插件常把权限检查藏在open()内部,导致 C# 无法预判——用户点了“拒绝”后,Open()直接返回 false,但开发者不知道是权限问题还是设备不存在。补上hasPermission()后,可实现:if (!serial.HasPermission("1A86:7523")) { serial.RequestPermission("1A86:7523"); // 主动触发弹窗 return; // 等待广播回调后再重试 }
3.3 构建状态机:SerialManager.cs统一管控连接生命周期
创建SerialManager.cs管理从设备枚举→权限申请→打开→收发的全流程:
using System; using System.Collections; using System.Text; using UnityEngine; public class SerialManager : MonoBehaviour { public AndroidUsbSerial serial; public TextMeshProUGUI statusText; // 显示状态(可选 UI 组件) private enum ConnectionState { Idle, Enumerating, RequestingPermission, Opening, Connected, Closed } private ConnectionState currentState = ConnectionState.Idle; private string targetDevice = "1A86:7523"; // CH340 默认 VID:PID private float retryTimer = 0f; void Start() { serial.Init(); StartCoroutine(ConnectRoutine()); } IEnumerator ConnectRoutine() { while (true) { switch (currentState) { case ConnectionState.Idle: UpdateStatus("Idle"); yield return new WaitForSeconds(1f); currentState = ConnectionState.Enumerating; break; case ConnectionState.Enumerating: UpdateStatus("Scanning devices..."); string[] devices = serial.GetDeviceList(); if (devices.Length > 0) { Debug.Log($"Found devices: {string.Join(", ", devices)}"); if (Array.IndexOf(devices, targetDevice) >= 0) { currentState = ConnectionState.RequestingPermission; } else { UpdateStatus("Device not found"); yield return new WaitForSeconds(2f); } } else { UpdateStatus("No USB devices"); yield return new WaitForSeconds(2f); } break; case ConnectionState.RequestingPermission: UpdateStatus("Requesting USB permission..."); serial.RequestPermission(targetDevice); // 等待权限回调(通过广播,此处用定时轮询简化) retryTimer = 0f; currentState = ConnectionState.Opening; break; case ConnectionState.Opening: retryTimer += Time.deltaTime; if (retryTimer > 5f) // 超时则重试 { UpdateStatus("Permission timeout"); currentState = ConnectionState.Idle; continue; } if (serial.HasPermission(targetDevice)) { UpdateStatus("Permission granted"); if (serial.Open(targetDevice)) { serial.SetDataReceivedCallback(OnDataReceived); currentState = ConnectionState.Connected; } else { UpdateStatus("Open failed"); currentState = ConnectionState.Idle; } } yield return null; break; case ConnectionState.Connected: UpdateStatus("Connected ✅"); // 发送测试指令(如 AT\r\n) byte[] testCmd = Encoding.ASCII.GetBytes("AT\r\n"); int sent = serial.Write(testCmd); Debug.Log($"Sent {sent} bytes"); yield return new WaitForSeconds(1f); break; case ConnectionState.Closed: UpdateStatus("Disconnected"); yield return new WaitForSeconds(1f); currentState = ConnectionState.Idle; break; } } } void OnDataReceived(byte[] data, int length) { string received = Encoding.ASCII.GetString(data, 0, length); Debug.Log($"Received: {received}"); // 解析协议(如 Modbus/自定义帧) ParseProtocol(data, length); } void ParseProtocol(byte[] data, int length) { // 示例:简单帧头校验(0x55 0xAA + 数据长度 + 数据 + CRC) if (length >= 4 && data[0] == 0x55 && data[1] == 0xAA) { int payloadLen = data[2]; if (length >= 4 + payloadLen) { // 校验 CRC(此处省略具体算法) Debug.Log($"Valid frame: {payloadLen} bytes payload"); } } } void UpdateStatus(string text) { if (statusText != null) statusText.text = text; Debug.Log($"[Serial] {text}"); } void OnDestroy() { serial.Close(); } }状态机价值:将原本散落在
Start()/Update()中的串口逻辑收敛为可预测的状态流转。每个状态有明确的进入/退出条件,避免if (isConnected) { ... } else { ... }的面条代码。
血泪经验:RequestingPermission状态必须设超时(示例中 5 秒),因为用户可能:
- 点了“拒绝”但没关闭弹窗(系统仍占用焦点);
- 手机 USB 调试模式未开启,导致
UsbManager返回空设备列表;- 插线松动,设备反复插拔触发
BroadcastReceiver重复注册。
超时后回到Idle状态,让整个流程可自愈。
4. 避坑指南:安卓串口开发中 4 个高频翻车点与硬核解法
4.1 现象:UsbManager.getDeviceList()返回空数组,但手机确实插着 CH340 线
原因:Android 9+ 默认禁用 USB 主机模式(USB Host Mode),或 USB 调试未开启。UsbManager依赖android.hardware.usb.host硬件特性,若系统层未启用,getDeviceList()永远为空。
解决:
- 在手机设置中开启“开发者选项” → “USB 调试”(必须开启,否则 USB 设备不被识别);
- 检查
AndroidManifest.xml是否声明<uses-feature android:name="android.hardware.usb.host" />(已包含在 2.3 节); - 终极验证:用
adb shell执行ls /dev/tty*,若返回ttyUSB0或ttyACM0,证明内核已识别设备,问题在 Unity 层;若无输出,则是手机硬件/系统限制(部分厂商定制 ROM 屏蔽 USB Host)。
4.2 现象:requestPermission()后无弹窗,日志显示UsbManager.requestPermission: null device
原因:findDeviceByVidPid()返回 null,通常因getDeviceList()获取的 VID/PID 格式与requestPermission()传入的不一致。例如getDeviceList()返回"1a86:7523"(小写),而代码中写死"1A86:7523"(大写),字符串比对失败。
解决:
- 统一转为大写比较:在
findDeviceByVidPid()中添加vidPid = vidPid.ToUpperInvariant();; - 更可靠的做法:不依赖 VID/PID 字符串,改用
UsbDevice.getDeviceId()(唯一整数 ID),在getDeviceList()中返回device.getDeviceId(),requestPermission()传入该 ID,Java 层直接getDeviceList().get(deviceId)查找,彻底规避字符串格式问题。
4.3 现象:接收数据乱码,Encoding.ASCII.GetString()出现 符号
原因:单片机发送的是二进制协议(非纯 ASCII),而Encoding.ASCII强制将字节映射为 ASCII 字符,遇到非打印字符(如 0x00~0x1F)即显示为 。
解决:
- 永远不要用
GetString()直接解析协议!接收回调中应直接操作byte[]:void OnDataReceived(byte[] data, int length) { // 正确:按协议解析字节流 for (int i = 0; i < length; i++) { ProcessByte(data[i]); // 逐字节状态机解析 } } - 若需日志调试,用十六进制打印:
Debug.Log($"Hex: {BitConverter.ToString(data, 0, length)}");。
4.4 现象:发送指令后单片机无响应,但用串口助手测试正常
原因:bulkTransfer()发送的数据末尾缺少换行符\r\n,而单片机固件约定以\r\n作为命令结束符。Unity 的Write()发送的是裸字节数组,不会自动追加。
解决:
- 在 C# 发送前手动拼接:
string cmd = "AT+VERSION"; byte[] data = Encoding.ASCII.GetBytes(cmd + "\r\n"); // 关键! serial.Write(data); - 或在 Java 层
write()方法中自动追加(不推荐,破坏协议透明性)。
5. 协议解析实战:用状态机解包 Modbus RTU 帧,避免Read()阻塞导致丢包
5.1 为什么不能用Read()循环轮询?安卓 USB 的“假阻塞”陷阱
新手常写如下代码:
// ❌ 危险!在 Update() 中轮询 void Update() { if (serial.IsConnected) { byte[] buf = new byte[256]; int len = serial.Read(buf); // 假设存在 Read() 方法 if (len > 0) ProcessFrame(buf, len); } }问题根源:安卓UsbDeviceConnection.bulkTransfer()是同步阻塞,但Read()方法若封装为“等待数据”,会在 Java 层调用inEndpoint的bulkTransfer并设超时(如 100ms)。当单片机发送间隔 >100ms 时,每次Read()都超时返回 0,CPU 空转耗电;若设无限超时,则Update()卡死,Unity 主线程冻结。而我们已在 2.2 节用HandlerThread实现非阻塞轮询,C# 层只需专注解析,无需关心“有没有数据”。
5.2 Modbus RTU 帧结构与状态机解析逻辑
Modbus RTU 帧格式(以读保持寄存器为例):
| 字段 | 长度 | 说明 |
|---|---|---|
| Device Address | 1 byte | 从机地址(0x01~0xFF) |
| Function Code | 1 byte | 功能码(0x03=读保持寄存器) |
| Starting Address Hi | 1 byte | 起始地址高位 |
| Starting Address Lo | 1 byte | 起始地址低位 |
| Quantity of Registers Hi | 1 byte | 寄存器数量高位 |
| Quantity of Registers Lo | 1 byte | 寄存器数量低位 |
| CRC Lo | 1 byte | CRC 校验低位 |
| CRC Hi | 1 byte | CRC 校验高位 |
关键约束:RTU 帧要求两个字符间隔 <3.5 个字符时间,否则视为帧结束。安卓 USB 传输无此硬件定时,需用软件状态机模拟。
5.3 实现ModbusFrameParser.cs:基于字节流的状态机
using System; using System.Collections.Generic; using UnityEngine; public class ModbusFrameParser { private const int MAX_FRAME_LENGTH = 256; private byte[] frameBuffer = new byte[MAX_FRAME_LENGTH]; private int bufferIndex = 0; private DateTime lastByteTime = DateTime.MinValue; private const double BYTE_GAP_MS = 1.5; // 3.5 字符时间 ≈ 1.5ms @115200bps public List<ModbusFrame> ParseBytes(byte[] data, int length) { var frames = new List<ModbusFrame>(); for (int i = 0; i < length; i++) { byte b = data[i]; DateTime now = DateTime.Now; // 检测帧间隙:当前字节与上一字节时间差 > BYTE_GAP_MS,则上一帧结束 if ((now - lastByteTime).TotalMilliseconds > BYTE_GAP_MS && bufferIndex > 0) { if (IsValidModbusFrame(frameBuffer, bufferIndex)) { frames.Add(new ModbusFrame(frameBuffer, bufferIndex)); } bufferIndex = 0; // 重置缓冲区 } // 存入缓冲区 if (bufferIndex < MAX_FRAME_LENGTH) { frameBuffer[bufferIndex++] = b; } lastByteTime = now; } return frames; } private bool IsValidModbusFrame(byte[] frame, int len) { // 最小长度:地址(1) + 功能码(1) + CRC(2) = 4 if (len < 4) return false; // CRC 校验(简化版,实际需查表) ushort crc = CalculateCRC(frame, len - 2); return frame[len - 2] == (byte)(crc & 0xFF) && frame[len - 1] == (byte)(crc >> 8); } private ushort CalculateCRC(byte[] data, int len) { // 标准 Modbus CRC-16 算法(此处省略具体实现,可用现成库) // 推荐:https://github.com/robertvazan/crc16-csharp return 0; } } public struct ModbusFrame { public byte Address; public byte FunctionCode; public ushort StartingAddress; public ushort Quantity; public byte[] Data; // CRC 前的原始数据 public ModbusFrame(byte[] raw, int len) { Address = raw[0]; FunctionCode = raw[1]; StartingAddress = (ushort)((raw[2] << 8) | raw[3]); Quantity = (ushort)((raw[4] << 8) | raw[5]); Data = new byte[len - 6]; // 地址+功能码+起始地址+数量 = 6 字节 Array.Copy(raw, 6, Data, 0, len - 6); } }在SerialManager.OnDataReceived()中调用:
private ModbusFrameParser parser = new ModbusFrameParser(); void OnDataReceived(byte[] data, int length) { var frames = parser.ParseBytes(data, length); foreach (var frame in frames <p> <a href="https://download.csdn.net/download/glunoy/9637750" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>