ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Unity安卓串口通信实战:USB Host桥接实现手机直连单片机

Unity安卓串口通信实战:USB Host桥接实现手机直连单片机 简介一份面向 Unity 开发者的安卓串口收发代码包用于解决 Unity 应用与 Arduino、树莓派等外部硬件设备的数据交互问题。压缩包共 30 个文件大小约 124KB包含 JNI 桥接相关的 C/C 源文件.c/.h、Android.mk/Application.mk 构建配置、Java 串口接口封装、多架构 .so 动态库及 SVN 元数据等。通过阅读源码可了解 JNI 层如何封装打开、关闭、发送与接收串口数据的逻辑并查看 armeabi-v7a、armeabi、x86 三种 CPU 架构的库文件便于在真机调试时替换对应 so 库对于有一定 Android 原生基础、想摆脱现成插件限制的 Unity 开发者是可直接参考的实现样本。已有 1172 人学习下载适合作为复刻串口收发功能的起点。1. 一条手机直连单片机的数据链路Unity做界面安卓原生USB口负责收发有一次接产线数据采集面板的活儿甲方拿了一台现场安卓平板要求直接插USB转串口线读取下方RS485传感器数据同时在一套Unity做的三维工控界面里实时刷新数值。大部分人听到“Unity串口”第一反应是用C#的System.IO.Ports但这个库在安卓上直接失效连打开设备都会抛异常原因在于安卓的串口权限模型和Windows差了一个时代。这篇文章拆一套能跑起来的Unity安卓串口收发方案核心思路是把底层USB串口读写交给安卓原生层C#通过AndroidJavaObject桥接调用Unity只负责协议解析和UI渲染。适合在Unity里做设备上位机、需要手机直连单片机或传感器的开发者读完照着配权限、写代码基本能在几分钟内跑通数据回路。2. 技术选型拆解为什么绕开C#串口API把宝押在安卓USB Host上2.1 安卓串口的三条路有两条走不通先看现状。在Windows上写串口System.IO.Ports拿过来就用打开COM3读数据几行代码搞定但这个体验在安卓平台上彻底失效。Unity的Mono运行时在安卓上没有串口设备文件的访问权限SerialPort类实际调用会直接失败或者打开后没有任何数据返回。原因是安卓应用层的设备访问受SELinux和权限模型双重控制工厂设备里常见的/dev/ttyS0、/dev/ttyMTK节点普通应用连列目录的权限都没有。第二条路是直接用FileStream读/dev/ttyS*设备节点这也是很多老项目在网上流传的做法。这个方案依赖root权限需要把App装在root过的设备上再在授权下执行chmod 666。实际跑起来会发现另一个问题不同SOC平台的tty节点命名完全不同高通平台叫ttyHS联发科部分平台叫ttyMT展锐的节点又是另一套。今天能在这台机器跑通的代码换一批设备就全灭维护成本高到失控。所以真正可复现的路径只剩第三条USB Host外接串口转接芯片。安卓手机和平板普遍支持OTG系统把外接的CH340、FT232、CP210x这类转接芯片枚举成USB设备应用层通过UsbManager获得设备句柄。这里不像PC那样由内核自动加载串口驱动安卓不会把USB转串口芯片变成/dev/ttyUSB0需要开发者在应用层实现对应芯片的协议。绕这一大圈本质是用USB的批量传输端点模拟异步串口的收发。2.2 自写芯片驱动不划算选现成库省掉八成工作量USB转串口芯片的协议并不是统一标准。拿市面上最常见的两种芯片举例CH340的控制传输命令和FT232的格式完全不是一回事波特率配置、流控开关、数据位设置的寄存器偏移都对不上。这意味着不借助外部库需要拿到每颗芯片的数据手册逐个寄存器实现配置命令再自己处理厂商芯片的识别逻辑。我见过有人在这个环节翻车——花两个星期照着CH340手册写了一个能用的驱动换一批FT232的转接板控制命令全部失效又要重新读FT232手册。这种纯应用层开发去啃芯片协议性价比实在低。工程上推荐直接引入现成的USB串口开源库它把设备枚举、VID/PID匹配、芯片差异、波特率寄存器配置全部封装在底层上层唯一需要关心的就是打开、读、写三个动作。实际测过CH340和FT232两种转接板同一份代码几乎不用改就能切换芯片。2.3 工程目录逐文件拆解每个文件管哪一段拿一个可运行的Unity工程示例串口相关资源集中放在两个目录下Unity侧的C#脚本和Android插件。Assets/ ├── Plugins/ │ └── Android/ │ ├── AndroidManifest.xml │ ├── libs/ │ │ └── usb-serial-lib.aar │ └── java/ │ └── com/devdemo/serial/ │ ├── SerialPortBridge.java │ └── SerialCallback.java ├── Scripts/ │ ├── Serial/ │ │ ├── SerialPortConfig.cs │ │ ├── SerialPortService.cs │ │ └── SerialFrame.cs │ └── Demo/ │ └── SerialDemoController.cs └── Prefabs/ └── SerialManager.prefabPlugins/Android下的AndroidManifest.xml不是Unity工程默认那个而是构建APK时给安卓打包用的合并清单负责声明USB Host特性、权限过滤规则把USB设备枚举的Intent-Filter挂到主Activity上。libs目录放串口库的aarjava目录放我们自己的桥接类SerialPortBridge和回调接口SerialCallback。Scripts/Serial里SerialPortConfig.cs承载当前串口的参数集合波特率、数据位、停止位、校验位都在这定义并通过Inspector露出SerialPortService.cs是全局唯一的串口服务入口控制打开、关闭、读写SerialFrame.cs是协议解析工具负责把收到的原始字节流按自定义帧格式拆解。Demo目录挂一个演示控制器把串口事件转发到UI显示文本。Prefabs/SerialManager.prefab是一个内含SerialPortService组件的空物体场景启动时挂上即可。这种组织方式的好处是协议、底层、UI三层隔离。后期协议从Modbus改成私有文本协议时只需要动SerialFrame其余代码基本不动。2.4 USB通信模型底料endpoint、批量传输和控制传输串口库封装得再深底层还是在跟USB的endpoint打交道。UsbDevice暴露接口后需要拿到UsbDeviceConnection再找到对应接口的In/Out两个endpoint。endpoint就是USB设备上数据传输的通道一个负责往设备发数据一个负责收数据。批量传输不保证实时性但适合收发一帧一帧的串口数据。控制传输则用来做设备配置比如切换波特率、设置数据位、请求设备描述符都走控制端点。UsbSerialPort的setParameters方法内部就是拼好控制命令发到控制端点再由转接芯片改写内部寄存器。理解了这一层就能明白自写芯片协议的工作量在哪里——每颗芯片的控制字格式都不同做一次就得重新折腾一遍寄存器映射。提示权限是一个绕不开的环节。UsbManager是系统服务App要用设备必须得到用户授权。授权弹窗呼出后如果没有对应的BroadcastReceiver处理返回结果后续openDevice调用会一直失败。这部分在下一章代码里能看到具体落法。3. 核心模块拆解从USB设备枚举到串口打开的完整链路3.1 AndroidManifest配置让系统把转接板识别成可用USB设备Unity导出的APK原本带一个自动生成的AndroidManifest.xml里面只有Unity入口Activity。外接USB转串口设备时需要额外声明USB Host特性并且给Activity加上USB设备插拔的Intent-Filter否则设备插上去系统没有任何反应UsbManager.getDeviceList()拿到的列表永远是空的。?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android uses-feature android:nameandroid.hardware.usb.host android:requiredtrue / application activity android:namecom.unity3d.player.UnityPlayerActivity intent-filter action android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED / /intent-filter meta-data android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED android:resourcexml/usb_device_filter / /activity /application /manifest这段配置做了两件事。uses-feature声明应用依赖USB Host硬件系统在一开始就知道这个App需要OTG能力。intent-filter和meta-data组合起来把USB设备插拔事件路由到UnityPlayerActivity同时由usb_device_filter.xml文件做设备过滤。设备过滤文件通常按厂商VID和产品PID来放行只让特定芯片触发打开流程?xml version1.0 encodingutf-8? resources usb-device vendor-id6790 product-id29987 / usb-device vendor-id1027 product-id24577 / /resources这两个ID是我实际调试中用到的分别对应CH340和FT232两种常见芯片的组合识别码。不同厂商转接板的VID/PID五花八门如果担心漏设备也可以把过滤文件写成空列表让系统对所有USB设备广播在Java代码里再靠芯片协议探测来判断是不是串口。我的习惯是过滤写严一点只放已经验证过的板子防止车间现场弹出些莫名其妙的权限请求干扰作业。3.2 SerialPortBridge.java设备枚举、鉴权、连接一次说清Java侧的核心类叫SerialPortBridge负责把UsbManager的系统服务能力包装成Unity方便调用的接口。先看设备枚举public class SerialPortBridge { private static final String ACTION_USB_PERMISSION com.devdemo.serial.USB_PERMISSION; private Context context; private UsbManager usbManager; private PendingIntent usbPermissionIntent; private UsbSerialPort serialPort; private UsbDeviceConnection connection; public SerialPortBridge(Context context, Activity activity) { this.context context; this.usbManager (UsbManager) context.getSystemService(Context.USB_SERVICE); this.usbPermissionIntent PendingIntent.getBroadcast( context, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_MUTABLE); } public String[] enumDevices() { HashMapString, UsbDevice devices usbManager.getDeviceList(); ListString names new ArrayList(); for (UsbDevice device : devices.values()) { names.add(device.getDeviceName()); } return names.toArray(new String[0]); } }枚举方法把UsbManager内的所有USB设备名取出来返回给Unity侧。一个安卓设备会挂多个USB设备比如触控屏、键鼠、数据线通通都会出现在这里。光拿名字不够打开前还要通过UsbSerialProber探测哪一个是能被串口芯片协议识别的设备。请求权限的代码public void requestPermission(String deviceName) { UsbDevice device findDeviceByName(deviceName); if (device null) return; if (!usbManager.hasPermission(device)) { usbManager.requestPermission(device, usbPermissionIntent); } }这里的回调不是同步返回而是通过ACTION_USB_PERMISSION广播回到注册好的BroadcastReceiver。我在构造方法里动态注册Receiver到应用Context上收到允许结果后马上执行openDevice和UsbSerialPort初始化。不熟悉这个模式的开发者把requestPermission当同步调用紧接着就调openDevice必然每次失败这个坑后面避坑章节会详细展开。打开串口的核心方法public int open(String deviceName, int baudRate) { UsbDevice device findDeviceByName(deviceName); if (!usbManager.hasPermission(device)) { return -2; } connection usbManager.openDevice(device); if (connection null) { return -3; } UsbSerialPort port UsbSerialProber .getDefaultProber() .probeDevice(connection, device); if (port null) { return -4; } serialPort port; serialPort.open(connection); serialPort.setParameters( baudRate, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE); return 0; }几个返回值对应不同失败原因-2是没授权-3是系统把设备占了-4是芯片协议不匹配。Unity侧拿到返回值就能直接给用户一个明确的提示文案。setParameters里的参数分别对应波特率、数据位、停止位和校验位和Windows串口编程里DCB结构体的内容一一对应这里配置错了后面读到的数据全部是乱码。3.3 C#侧桥接Unity把调用传到Java层的标准姿势Unity侧并不直接持有UsbManager所有安卓系统服务调用都必须经过AndroidJavaObject。SerialPortService以单例形式存在构造时同时创建Java桥接对象和回调代理public class SerialPortService : AndroidJavaProxy { public delegate void DataReceivedHandler(byte[] data); public event DataReceivedHandler DataReceived; private AndroidJavaObject bridge; private readonly object lockObj new object(); public SerialPortService() : base(com.devdemo.serial.SerialCallback) { AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer); AndroidJavaObject activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); bridge new AndroidJavaObject( com.devdemo.serial.SerialPortBridge, activity, this); } public string[] GetDeviceList() { return bridge.Callstring[](enumDevices); } public int Open(string deviceName, int baudRate) { return bridge.Callint(open, deviceName, baudRate); } public void Close() { bridge.Call(close); } }AndroidJavaProxy是Unity提供的动态代理机制C#对象可以作为Java接口回调的实参传进Java层。Java的SerialCallback接口定义一个onData(byte[])方法C#类通过重写OnData方法接收底层数据这样Unity侧不用开额外线程就能拿到串口字节。调用过程有两个注意点。AndroidJavaObject.Call传参时bool要转成Java booleanstring直接用C#的string即可返回值是数组的Unity能直接转成对应类型的C#数组。另外在Linux编辑器模式下调试会报找不到UnityPlayer类所以串口服务初始化要包在#if UNITY_ANDROID宏里等真机打包后再调用。4. 数据收发实战从读取线程到帧解析完整链路一帧不丢4.1 Java侧读取线程别把USB读操作塞到主线程USB串口库提供的read操作是阻塞式的。如果直接在Unity下帧循环里调用桥接去读每一帧都卡在USB读取上界面明显掉帧。数据流量一上来主线程和渲染线程互相等整个应用就“假死”了。正确做法是在Java层起一条独立线程循环调用read把读到的数据通过回调抛给Unityprivate Thread readThread; private volatile boolean running; public void startReading() { running true; readThread new Thread(() - { byte[] buffer new byte[512]; while (running serialPort ! null) { try { int len serialPort.read(buffer, 300); if (len 0) { byte[] data new byte[len]; System.arraycopy(buffer, 0, data, 0, len); callback.onData(data); } } catch (Exception e) { running false; } } }); readThread.start(); } public void stopReading() { running false; if (readThread ! null) { readThread.interrupt(); } }read的第二个参数是超时毫秒数300毫秒内没有新数据就返回0或负值。进入循环后如果串口断开read会抛异常这里捕获后把running置false线程自然退出避免出现僵尸线程。每次从buffer截取实际读到的长度再一次性提交回调。不直接复用整个512字节数组是因为Unity侧会把这个数组当完整数据做帧解析如果混入多余空字节会判断错帧长。4.2 Unity侧数据接收队列把Java回调转成主线程亲和的事件流Java回调到C#侧时OnData方法运行在子线程而Unity大部分API不允许子线程访问。处理方法是在C#侧把收到的字节放入线程安全的队列然后在Update里统一取出处理private readonly ConcurrentQueuebyte[] pendingData new ConcurrentQueuebyte[](); public void OnData(byte[] data) { pendingData.Enqueue(data); } private void Update() { byte[] data; while (pendingData.TryDequeue(out data)) { ProcessSerialData(data); } }注意几个细节OnData回调必须保证线程安全如果队列不加锁回调并发进来就可能坏数据ConcurrentQueue的TryDequeue保证出队原子性但ProcessSerialData内部不能做耗时操作否则队列会越堆越长。ProcessSerialData内部流程是先把原始字节拼进一个分组缓冲区再按帧头帧尾切分。不能每收到一次回调就算一帧因为串口的read可能一帧分两次读到也可能一次读到半帧。常见做法是积累式缓冲新字节append到尾部再循环从头查找完整帧。4.3 发送链路指令组包、异步写入和失败降级发送接口同样走AndroidJavaObject.Call回到Java层。write是同步的在主线程调用可能卡顿所以发送时我一般放在Unity协程里或Task.Run包一层public bool Write(byte[] payload) { if (bridge null) return false; return bridge.Callbool(write, payload); }Java侧对write封装一层重试逻辑public boolean write(byte[] data) { if (serialPort null) return false; try { int written serialPort.write(data, 300); return written data.length; } catch (Exception e) { return false; } }写入时序有一个容易翻车的点两个指令间隔太短转接芯片的发送缓冲区还没把上一帧发完下一帧就缀上去。应用层要保证每次发送之间至少有一个帧的间隔不建议硬编码毫秒级delay。更稳妥的做法是维护一个发送队列上一帧write返回后再发下一帧。对实时性要求不高的工业采集场景发送频率控制在20帧每秒以内稳定性会好很多。4.4 协议帧封装帧头、长度、命令字和CRC16的通用实现单片机那边几乎不会用裸字节当业务数据至少带一个简单帧格式。下面是一个可以直接拿去改的组包实现public static class SerialFrame { public static byte[] BuildFrame(byte cmd, byte[] payload) { int length 1 1 payload.Length 1 2; byte[] frame new byte[length]; frame[0] 0xAA; frame[1] 0x55; frame[2] (byte)(payload.Length 1); frame[3] cmd; System.Buffer.BlockCopy(payload, 0, frame, 4, payload.Length); ushort crc Crc16.Compute(frame, frame.Length - 2); frame[frame.Length - 2] (byte)(crc 0xFF); frame[frame.Length - 1] (byte)((crc 8) 0xFF); return frame; } }帧结构是两字节帧头0xAA55一字节长度字段一字节命令字然后是负载最后两字节CRC16校验尾。接收端解析先找帧头读长度字段判断帧长计算CRC比对校验尾。这个格式很通用能应付大多数单片机通信。CRC16用查表法实现代码不复杂。组包时注意CRC计算范围是除校验尾以外的所有字节两个校验字节的顺序由单片机固件决定。调试时如果CRC一直对不上先把这两字节交换一下再看。这类“对不上校验”的排查点后面避坑章节会专门提。注意帧头、长度字段和CRC的字节序一定要先跟设备端固件确认。很多项目联调翻车都是协议看起来一致实际高低字节顺序完全相反。5. 串口调试避坑五类高频问题从现象到根因的排查路线5.1 设备列表为空USB转串口板插上没反应现象Unity界面上点“刷新设备”枚举数组为空OTG线已插好转接板指示灯也亮了。原因AndroidManifest没声明USB Host特性或Activity没有接收USB_DEVICE_ATTACHED广播。系统层面可能压根没把设备加入UsbManager设备列表。另一种情况是部分平板厂商对USB设备做了白名单限制。解决先确认清单里有uses-feature android.hardware.usb.host再检查Intent-Filter是否齐全。排除后在电脑上用adb shell执行dumpsys usb查看系统枚举结果如果这里都没有就是硬件接触或供电问题。带RS485隔离模块的转接板常吃电流单靠平板USB口供电经常带不动这时得外接供电。5.2 权限弹窗出现但点确定之后没有任何回调现象第一次插入设备系统弹出USB设备授权对话框点了允许但串口就是打不开甚至反复弹窗。原因请求权限用的PendingIntent在Android 12以上需要显式指定可变性标志如果配了FLAG_MUTABLE或FLAG_IMMUTABLE不当权限回调广播就发不出来。另外BroadcastReceiver如果用Activity作为context动态注册Activity销毁后收不到任何事件。解决PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)Receiver注册改用context.getApplicationContext()这样即使页面重建也不会丢广播。还要区分手机系统设置里的“USB调试”弹窗那是开发者选项和应用层USB权限是两回事。5.3 串口打开正常但收到的字节全是乱码现象read能收到数据打印出来是\u00ff\u00fe之类或者高位字节全部丢失。原因最常见是波特率不匹配。单片机端配置9600安卓侧按115200打开肯定乱码。另一类是数据位、停止位、校验位有一项不一致比如设备端是8N1代码里写成了7E1导致字节错位。解决先查看设备说明书确认串口参数再把setParameters里四个参数对齐。还乱码就换一根更短的连接线工业现场长线传输高波特率很容易受干扰。试到2400或4800许多老传感器在低速下反而稳定。5.4 数据能收但丢帧严重帧与帧之间断流现象偶尔能收到完整数据帧但数据不连续中间缺字节报文头尾经常对不上。原因读取线程buffer太小或读取间隔太长。USB批量传输一次最大有效载荷不过64字节单次读取后没及时清空下一个包就可能覆盖前一个。另一头可能是Unity侧ProcessSerialData耗时太长队列堆积串口数据延迟后看起来就像断流。解决Java读取线程把buffer加大到1024超时设200到300毫秒。Unity侧处理函数不要直接做日志输出先把字节缓冲到帧缓冲区拼出完整帧再处理。如果还是掉考虑在AndroidManifest里给读取线程加高优先级或者直接用后台Service持有连接。5.5 锁屏或切后台后串口断开回到前台必须重新打开现象平板锁屏一段时间解锁后串口数据停了重新点打开设备报设备被占用或打不开。原因UsbDeviceConnection的存活和Activity生命周期有关。Unity场景在后台被回收Java层bridge对象虽然还在底层连接已经被系统回收。解决把bridge对象的生命周期和应用进程对齐在Unity里把SerialPortService挂到一个DontDestroyOnLoad的空物体上同时监听Application的pause和resume事件。暂停时主动stopReading恢复时再startReading可以有效减少断开概率。需要高强度后台采集的场景就得把USB连接放到前台服务里维护那已经超出本文范围但方向是明确的。6. 二次开发思路把这套链路改造成你自己的协议通道拿到这套代码后最常见的改造需求就是替换成自己团队的私有协议。我的建议是先不要动Java层Java层只负责把可靠的字节流送到C#协议相关的一切都收敛在SerialFrame和回调处理函数里。这样做的理由是Java层每次改动都要重新导出aar、重新打APK联调成本高而C#层的改动可以即时生效热更新也不受影响。具体改协议时我把SerialFrame做成静态工具类留出三个口子BuildFrame用来组包发送TryParseFrame用来从字节流里提取完整帧CalculateCRC用来替换校验算法。很多私有协议其实就是把帧头改成别的魔数或者在帧尾多塞一个应答号。这种程度的改动只需要复制一份SerialFrame改一两个方法完全不影响串口底层。还有一个实用技巧是事件驱动模式。SerialPortService已经暴露了DataReceived事件但更推荐在业务层做二次转发public class SerialDemoController : MonoBehaviour { private void OnEnable() { SerialPortService.Instance.DataReceived OnSerialData; } private void OnDisable() { SerialPortService.Instance.DataReceived - OnSerialData; } private void OnSerialData(byte[] frame) { // 在这里按命令字分发例如0x01转温度显示0x02转开关量状态 } }这个写法保证了每个业务模块只订阅自己关心的消息不会在同一个回调里写一堆switch-case。多设备场景下还可以给回调加一个deviceId参数把多个串口的字节流按端口分开。验证方面我在Unity侧放了一个调试面板实时显示收包计数、错误帧计数和最近一帧的十六进制内容。每次改动协议解析逻辑先用调试面板确认帧格式正确再开始做UI联动。从那以后我每次接新的串口设备都先花十分钟在安卓原生层用日志确认枚举、权限、连接三个节点全部通过再回来改Unity侧界面。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表