Unity安卓串口通信实战:JNI桥接、设备扫描与避坑指南 简介Unity安卓串口收发代码是一份面向Unity开发者的原生串口通信实现解决Unity应用与Arduino、传感器等硬件设备的数据交互问题。资源包内含基于JNI的Java层串口封装、C/C本地接口以及用于编译so库的Android.mk构建脚本可直接接入Unity C#脚本简化安卓平台串口打开、读写与权限处理流程。包内共30个文件以so动态库、java源码、c/h源码及mk构建配置为主同时保留svn版本记录整体压缩包仅124KB轻量易部署。已有1172人学习下载适合具备基础Unity开发经验、希望绕过现成付费插件自行实现硬件通信的开发者。通过本资源可掌握Unity调用安卓底层串口的关键链路理解JNI桥接、串口初始化、数据收发及AndroidManifest权限配置等核心细节为后续扩展智能硬件交互功能提供可复用模板。1. Unity安卓串口一张串口线让Unity变身硬件的上位机你拿Unity做了套可视化调试界面打包成APK装到一台安卓工业屏上结果要对接的硬件不是网口、不是蓝牙就是一根老式USB转串口线。这时候才发现Unity不会自己发现串口它得靠安卓系统去打开设备节点再跨语言把字节流递给你。本篇文章拆的就是这样一套Unity安卓串口收发代码从C#怎么调用Java层、怎么拿到并打开串口设备到独立线程读数据、回调抛给Unity主线程一路讲到底层链路。适合做Unity工业上位机、机器人控制台、物联网调试面板的开发者读完你可以把这份代码直接跑起来再改成自己的协议帧。2. 技术选型与架构Unity为什么绕不开JNI桥这一次2.1 为什么System.IO.Ports.SerialPort在安卓上不可用很多从Windows开发切过来的人第一反应是C#自带的System.IO.Ports.SerialPort不是能直接用吗在Windows平台确实能直接打开COM口但安卓不是WindowsUnity的Mono运行时在安卓上不会给你映射一堆COM接口。安卓下操作串口的唯一正统路径是访问Linux内核暴露的设备节点也就是/dev/ttyS0、/dev/ttyMT1这一类的文件路径读写本质上是对文件描述符做流操作这个能力Unity没有开放给C#层。这时候就轮到AndroidJavaClass和AndroidJavaObject出马了。Unity提供了一套从C#侧调用Java代码的桥接API你可以通过它拿到UnityPlayer的当前Context调用Java层封装好的串口工具类打开指定设备节点拿到InputStream和OutputStream再把字节流传回C#。整条链路就是一条JNI桥C# → Java → native层 - Linux内核。开头做项目结构设计时我建议把桥接层当成独立模块来写不要跟业务混在一起。桥接层的职责只有三件事找到设备节点、打开串口、传输字节流。至于协议解析、UI刷新、数据存储全部留在C#侧。这样做的直接好处是将来要换成蓝牙或Socket通信你只需要替换这个桥接层业务代码一行都不用动。2.2 通信选型对比串口、蓝牙、Socket各自的适用边界做硬件联调之前先要判断设备到底该走哪种通道。串口不是效率最高的选择它受波特率限制9600波特率下每秒也就传不到1K字节远不如4G模块的Socket带宽。但串口是工业现场最常见的存量接口很多传感器、PLC、单片机控制板出厂就只引出一根UART你没有选择空间。通信方式典型场景传输速率集成复杂度稳定度物理串口PLC、单片机、传感器9600~115200bps高需要JNI桥高有线不易干扰蓝牙近距离便携设备约20KB/s中Unity有插件中易受干扰Socket摄像头、网络模块百兆级低Unity原生支持高取决于网络如果设备同时支持串口和以太网我在实际项目中更倾向于以太网因为Unity的System.Net.Socket是完整可用的不用碰任何安卓底层。但现实是大量5V串口传感器、老式工控板只有UART输出那就得老老实实走串口桥。2.3 项目目录结构桥接层和业务层如何划分拆这份代码时我习惯先把目录结构打印出来看因为工程的组织方式直接反映设计意图。一个干净的Unity安卓串口工程应该长这样Assets/ ├── Plugins/ │ └── Android/ │ ├── AndroidManifest.xml │ └── serial-port-library/ # Java串口库与so库 ├── Scripts/ │ ├── Bridge/ │ │ └── SerialPortBridge.cs # C#侧桥接管理 │ ├── Core/ │ │ ├── SerialConfig.cs # 串口参数配置对象 │ │ ├── SerialDataReader.cs # 串口读取线程封装 │ │ └── SerialEventDispatcher.cs # 主线程事件分发 │ └── UI/ │ └── SerialConsoleView.cs # 调试面板 └── Scenes/ └── SerialTestScene.unity这里最关键的是Assets/Plugins/Android目录。Unity打包安卓时会把这个目录下的内容合并进最终APK的目录结构中所以Java源码、AndroidManifest.xml、so库都放在这里。我自己习惯把串口库的Java源代码直接丢在这个目录里而不是给jar包因为遇到设备节点扫描、流控参数这类需要改源码的场景直接能改到。SerialPortBridge.cs暴露给业务层的接口就四个Open、Close、Send、RegisterReceiver。不暴露设备路径、不暴露字节流缓冲区的细节所有设备连接状态通过事件向上通知。业务层看到的是一个抽象串口对象底层是JNI桥还是蓝牙通道都无所谓。3. 核心模块拆解一个串口请求从发送到回调的完整链路3.1 C#桥接层用AndroidJavaClass打开设备节点首先要明确C#侧不能直接new一个Java串口对象再随便调用所有跨语言调用都必须通过AndroidJavaObject包装。拿到当前UnityActivity的Context是第一步因为Java层打开设备节点时需要用到安卓应用的上下文信息判断权限状态。using UnityEngine; using System; public class SerialPortBridge { private AndroidJavaObject serialHelper; private AndroidJavaObject context; public bool Open(SerialConfig config) { // 获取Unity当前Activity用于后续Java调用 using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { context unityPlayer.GetStaticAndroidJavaObject(currentActivity); } // 创建Java层串口工具实例 using (AndroidJavaClass helperClass new AndroidJavaClass(com.example.serial.SerialPortHelper)) { serialHelper helperClass.CallStaticAndroidJavaObject( getInstance, context, config.DevicePath, config.BaudRate ); if (serialHelper null) return false; } return serialHelper.Callbool(isOpen); } public void Close() { if (serialHelper ! null) { serialHelper.Call(close); serialHelper.Dispose(); serialHelper null; } } public int Send(byte[] data) { if (serialHelper null) return -1; return serialHelper.Callint(write, data); } }这段代码里有几个容易翻车的点。第一AndroidJavaObject需要持有长生命周期引用千万不能在Open方法里用using包住然后释放掉否则Java层对象被Unity侧GC回收底层文件描述符也会跟着关闭。第二CallStatic方法可以直接调用Java类的静态工厂方法不用先new一个实例用这种方式拿到单例对象最稳妥。第三发送数据我直接传byte[]Java层会把它当成字节数组参数处理不需要做任何marshal操作。3.2 Java侧封装从设备节点到输入输出流Java层是整个串口链路的枢轴它负责调用底层native方法打开设备节点然后向上层提供输入输出流。注意这个设备节点不是随便打开的非root的普通应用直接open /dev/ttyS1会报Permission denied常见解决路径是使用USB转串口设备或者把应用签名成系统应用。package com.example.serial; import android.content.Context; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; public class SerialPortHelper { private static SerialPortHelper instance null; private FileDescriptor descriptor; private FileInputStream inputStream; private FileOutputStream outputStream; private boolean open false; static { System.loadLibrary(serial_port); } private native FileDescriptor nativeOpen(String path, int baudrate, int flags); private native void nativeClose(FileDescriptor fd); public static SerialPortHelper getInstance(Context ctx, String path, int baudrate) { if (instance null) { instance new SerialPortHelper(ctx, path, baudrate); } return instance; } private SerialPortHelper(Context ctx, String path, int baudrate) { try { descriptor nativeOpen(path, baudrate, 0); if (descriptor null) { throw new IOException(nativeOpen returned null, path: path); } inputStream new FileInputStream(descriptor); outputStream new FileOutputStream(descriptor); open true; } catch (IOException e) { open false; } } public int write(byte[] data) { if (!open || outputStream null) return -1; try { outputStream.write(data); outputStream.flush(); return data.length; } catch (IOException e) { return -1; } } public FileInputStream getInputStream() { return inputStream; } public boolean isOpen() { return open; } public void close() { if (!open) return; open false; try { if (inputStream ! null) inputStream.close(); if (outputStream ! null) outputStream.close(); if (descriptor ! null) nativeClose(descriptor); } catch (IOException e) { // 关闭失败一般出现在设备被拔出时忽略即可 } } }这块的参数说明值得写一下。nativeOpen的第一个参数是设备节点路径第二个是波特率第三个是flags通常传0表示默认阻塞模式。波特率直接传整数由客户端决定C#侧配置成9600还是115200完全由业务场景决定GPS模块用9600TTL电平调试用115200比较常见。值得注意的细节是FileInputStream可以直接接收一个FileDescriptor对象这是Java标准库的能力也是串口和普通文件读写共用的一条路径。3.3 读取线程设计阻塞读取加主线程分发串口数据是持续到达的你不可能在主线程里死等。常见设计是启动一个独立后台线程用InputStream.read阻塞等待新字节读到的原始数据先放入一个临时的累积缓冲区按帧解析后通过Unity主线程事件系统抛出。using System.Threading; using UnityEngine; public class SerialDataReader { private AndroidJavaObject serialHelper; private Thread readThread; private volatile bool running; private byte[] buffer new byte[1024]; private Queuebyte[] frameQueue new Queuebyte[](); public void Start(AndroidJavaObject helper) { serialHelper helper; running true; readThread new Thread(ReadLoop); readThread.IsBackground true; readThread.Start(); } private void ReadLoop() { try { AndroidJavaObject inputStream serialHelper.CallAndroidJavaObject(getInputStream); while (running) { int len inputStream.Callint(read, buffer); if (len 0) { byte[] frame new byte[len]; System.Buffer.BlockCopy(buffer, 0, frame, 0, len); lock (frameQueue) { frameQueue.Enqueue(frame); } } } } catch (System.Exception e) { Debug.LogError(串口读取异常: e.Message); } finally { running false; } } public void Dispatch(Actionbyte[] onFrame) { lock (frameQueue) { while (frameQueue.Count 0) { byte[] frame frameQueue.Dequeue(); onFrame?.Invoke(frame); } } } }这里的读线程用的是阻塞式read而不是自旋轮询。Symbolicallyread会一直阻塞直到有字节到达这比while(true)Sleep的方式效率高CPU占用率极低。Dispatch方法要从Unity主线程的Update循环里调用UnityAPI不能在子线程操作所以数据到了之后只进队列不碰任何Unity对象由主线程统一消费。参数方面buffer大小1024字节对于常见的串口帧足够如果是定长大数据帧可以改成4096但不要太小否则read返回的字节数会频繁触发多次解析。4. 避坑指南串口联调中五个反直觉的坑这一节把我在实机上踩过的坑按现象到原因到解决方式的格式列出来每条都是付过学费的。4.1 打开设备节点报Permission denied现象应用启动后调用nativeOpen直接抛IOException日志显示open failed: EACCES (Permission denied)。原因安卓系统的文件权限模型对/dev/ttyS0这类内核设备节点默认只授予root用户和system组普通第三方应用即使声明了所有权限也无法直接open。设备厂商在init.rc里定义的设备节点权限决定一切。解决两种方案任选。一是应用换成USB转串口硬件设备这类设备走USB权限模型应用可以正常申请USB权限后读写。二是把APK签名放进/system/app分区做成系统应用同时在AndroidManifest里声明android.permission.WRITE_EXTERNAL_STORAGE。项目联调时我强烈建议先用USB转串口模式省掉越权和签名流程的折腾。4.2 AndroidJavaObject被GC回收导致偶发断连现象串口用着用着突然失联日志无任何异常重新Open又能恢复且失联时间不固定。原因C#侧创建AndroidJavaObject后没有保存引用或者在某次方法调用完后调用DisposeUnity层的垃圾回收器把这些包装对象回收掉底层Java对象和文件描述符也随之释放。解决把serialHelper和inputStream的引用提升为成员字段持有绝不Dispose除非主动关闭串口。在C#层面AndroidJavaObject是一个包装句柄对它的生命周期管理要跟对真实串口一样认真每次用了就丢等于每次用完就拔线。4.3 读线程CPU占用率飙到100%现象打开串口后整机发烫查看CPU占用发现进程持续占满一个核心。原因读线程没有使用阻塞读取而是用while(true)不断调available再read拼数据available返回0时空转循环。Unity子线程优先级默认是Normal空转时毫无阻塞机制。解决改用InputStream.read(buffer)阻塞读取数据没到就让线程挂起内核在数据到达时唤醒。这样读线程空闲时间几乎不消耗CPU实测CPU占用率能压到0.5%以下。4.4 串口数据末尾总是多出\r\n现象设备发来的十六进制帧末尾凭空多出两个字节0x0D 0x0A导致帧校验失败。原因很多下位机固件串口打印用了print或者println函数println在末尾自动追加了回车换行。也有的是调试信息混淆进了数据帧打印日志同样走串口。解决处理数据时先按帧头帧尾做切片不要把整包读到的原始字节当完整帧。常见做法是维护累加缓冲区等凑齐帧头长度字段帧尾才抛给上层解析多余的行只需要在协议层剥离。4.5 同一份代码在A板上能用在B板上设备路径变了现象开发板A上配/dev/ttyS1跑得好好的换到开发板B或者某个工控屏上就打不开了日志提示No such file or directory。原因不同厂商的内核设备映射差异很大有的平台把串口映射成ttyS0有的映射成ttyMT0或者ttyUSB0USB转串口插入顺序不同还会让节点名漂移。解决把设备路径抽到配置项做一次启动时的可用设备扫描遍历/dev/tty和/dev/ttyUSB下所有节点逐个尝试打开。这份代码界面里留一个设备节点下拉框启动时自动扫描填充比写死路径可靠得多也算是个玄学问题用工程手段解决。5. 构建与验证从Unity工程到安卓真机联调的完整流程5.1 Unity构建安卓APK的配置细节Unity对安卓打包支持很成熟但串口工程有几个不能省的前提配置。打开Project Settings里的Player设置Other Settings下的Api Compatibility Level必须选.NET Standard 2.1或者ARMv7、ARM64都勾上。脚本后台值保持默认不用改成IL2CPPMono和IL2CPP在这份代码上都能跑区别不大。关键是Assets/Plugins/Android/AndroidManifest.xml。Unity默认生成的Manifest不会给你添加USB权限和串口设备权限声明需要手动加。基础权限声明一般长这样manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.yourcompany.serialtest uses-feature android:nameandroid.hardware.usb.host / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE / application activity android:namecom.unity3d.player.UnityPlayerActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity meta-data android:nameunityplayer.UnityActivity android:valuetrue / /application /manifestuses-feature声明usb.host是让设备告诉安卓系统你的APK需要USB宿主能力。挂载USB转串口时系统会弹授权对话框这时候Application的Activity需要处理onNewIntent回调Java侧在UnityPlayerActivity里重写这个方法授予USB设备权限。构建时把USB OTG线插上安卓设备Build And Run可以直接部署。敲一下打包命令也行/path/to/Unity -batchmode -buildTarget Android -projectPath ./SerialDemo -executeMethod BuildScript.BuildApk -quit5.2 真机联调从打开串口到收到第一包数据APK装好后连接硬件前先在Unity界面的设备下拉框里看看扫描结果确认识别到了ttyUSB0或ttyS1。选好波特率后点打开四个状态码要逐项确认Java层isOpen返回true、inputStream创建成功、读线程running为true、帧回调有事件触发。如果这四个状态没全绿对照上一节的避坑清单排查。我常用的排查思路是先拿到设备节点路径用adb shell ls -l /dev/ttyS*看看权限位是不是crw-rw----再确认当前应用跑在哪个uid下权限问题当场暴露。收到第一包数据后建议立刻做一次回环测试用一根杜邦线把串口的TXD和RXD短接发送端发出的字节会原样回到接收端。如果回环正常而接外部设备乱码那就是波特率或电平不匹配的问题跟代码无关了。5.3 模拟数据源没有硬件也能开发调试还有一个没硬件时逼疯开发者的场景有套简单办法写一个模拟串口脚本用Linux的伪终端pty生成一对虚拟设备App打开master端脚本往slave端写数据。socat -d -d pty,raw,echo0 pty,raw,echo0socat会输出两个设备路径比如/dev/pts/3和/dev/pts/4。把开发板或USB转串口指向其中一个路径用脚本写入Unity端打开另一个路径就能调试收数据逻辑非常实用。实测拿模拟源调试协议解析和UI更新跟真机效果没什么差别。6. 二次开发把收发Demo改成能用的协议层手里有了稳定的串口收发通道摆在面前的下一个问题就是怎么把裸字节流变成业务数据。如果直接按整包收发不同长度的帧会粘在一起握手协议、校验逻辑都没地方写。这一步我会先上一层心跳帧协议再谈多设备管理。6.1 自定义帧协议从裸字节到结构化消息做协议层的时候我会在数据外层加一个最简帧头结构帧头、长度、命令字、数据体、CRC校验。这样收端就能按长度字段切帧粘包和半包都在这一层解决。public class FrameProtocol { private Listbyte buffer new Listbyte(); public void Push(byte[] data) { buffer.AddRange(data); // 按长度字段循环切帧直到剩余数据不够一帧 while (buffer.Count 6) { if (buffer[0] ! 0xAA || buffer[1] ! 0x55) { buffer.RemoveAt(0); continue; } int len buffer[2]; if (buffer.Count len 5) break; if (Crc16(buffer, 0, len 3) ! (ushort)(buffer[len 3] | buffer[len 4] 8)) { buffer.RemoveRange(0, len 4); continue; } byte[] frame buffer.GetRange(0, len 5).ToArray(); buffer.RemoveRange(0, len 5); // 解析出命令字和payload投递给业务层 } } private ushort Crc16(byte[] data, int offset, int length) { // 标准CRC16算法实现 ushort crc 0xFFFF; for (int i offset; i offset length; i) { crc ^ data[i]; for (int bit 0; bit 8; bit) { if ((crc 0x0001) ! 0) crc (ushort)((crc 1) ^ 0xA001); else crc 1; } } return crc; } }这个实现的切帧逻辑很直接遍历缓冲区找帧头取出长度字段算出一帧完整长度校验CRC通过后整帧抛出否则继续往后找。参数上帧头选用AA 55是为了避开纯文本传感器可能输出的连续ASCII字节两个字节连续出现才认作帧头。同一份数据里如果有多个串口设备在汇报就在命令字里带上设备地址这样帧协议天然就支持一主多从的轮询模型。这类帧结构在旁人看来毫不起眼但在工业场景能兜住九成通信问题。6.2 多串口并发管理的改造思路如果项目要同时接两块传感器板子最容易踩的坑是干脆开两份SerialPortBridge然后忘了哪个设备回调对应哪个事件。我会先把设备抽象成SerialDevice类每个设备实例内聚自己的桥接、读线程、协议解析和错误计数。主控用Dictionarystring, SerialDevice按设备名管理比如gps对应/dev/ttyUSB0imu对应/dev/ttyUSB1。业务层发命令时按设备名找到实例收数据时一眼就能从回调参数里看到是哪个设备来的。这套改造做完加第三块板子只需要加一行配置不用改任何业务逻辑。串口这种老技术看着土但在成本敏感的项目里它就是能干活。让我记起教训最深的一次是在联调那天把波特率配错设置了9600但下位机实际跑115200界面上看一切正常就是数据乱码折腾半天才想起来核对波特率。从那以后我每次搭串口工程都强制把波特率、设备路径、校验位三项在启动时打印出来跟设备手册逐条核对才敢说进入联调。串口的坑大多不是难而是细。希望这篇拆解能帮你在项目的硬骨头里少试几轮错。本文还有配套的精品资源点击获取