Unity安卓串口通信实战:JNI与termios打通工控机收发链路

发布时间:2026/10/29 15:04:37
Unity安卓串口通信实战:JNI与termios打通工控机收发链路 简介面向需要在 Unity 中与 Arduino、树莓派、传感器等外部硬件通信的安卓开发者这份代码包围绕 Android 串口收发提供可参考的工程实现涉及 Java 层串口接口、JNI 桥接、C# 侧 AndroidJavaObject 调用、权限声明以及回调或协程接收数据等关键环节适合具备一定 Unity 与 Android 基础、希望打通原生串口链路的读者。压缩包共 30 个文件约 124KB内含 5 个 java 接口与 JNI 源码、6 个 so 库、3 个 C/C 目标文件以及 mk 构建脚本、头文件、Shell 脚本和版本管理元数据便于对照从本地编译到 Unity 调用的目录组织。已有 1175 人学习可作为串口通信功能搭建、工程结构理解和调试排错的参考样本。1. 安卓工控机上那个“读不到”的串口做整机外设集成的同行大概都经历过这个场景Unity 工程在 Editor 里用 System.IO.Ports 把串口调得通顺打包装到安卓一体机上第一行就抛出 PlatformNotSupportedException。这不是配置漏了哪一项而是 Mono 的安卓运行时里压根没有这套 P/Invoke 实现换个 API Level 也救不回来。安卓侧的串口本质是/dev/ttyS*、/dev/ttyMSM*这类字符设备节点只能靠 Java 或 Native 层用open termios去配置再想办法把数据搬回 C#。这份 Unity 安卓串口收发代码走的就是这条路C# 管业务与线程Java 管封装JNI 管termios三层拼出一条完整的收发链路。适合正在做自助终端、检测设备、机器人上位机被串口卡住的开发者如果你只想在 PC 上调试那直接上虚拟串口工具更省事。2. 工程结构与选型为什么必须走 JNI 这一层先把结论摆在前面在安卓上做串口绕不开“谁去调open()”这个问题。Unity 的 C# 运行在 Mono/IL2CPP 之上能 P/Invoke 到的只有系统 libc 里被导出的符号而tcsetattr、cfsetispeed这些 termios 函数虽然在 libc 里存在但不同厂商的安卓固件对字符设备的 SELinux 标签、权限组划分差异极大纯 C# 直连会在真机上死得莫名其妙。更现实的做法是 Java 层拿节点、Native 层配参数、C# 层做协议——三层各管一段。2.1 三层调用链路长什么样链路自上而下是业务脚本 →SerialPortManagerC#负责线程、缓冲、回调→SerialBridgeJava负责FileInputStream/FileOutputStream封装→libserial_port.soC/JNI负责open、tcgetattr、tcsetattr→/dev/ttyS*。反过来数据从硬件进来先被阻塞在read()上JNI 不做任何加工原样交给 Java 的InputStream再由 C# 读线程一包一包捞出来。这样分层的好处是协议解析完全留在 C# 侧改帧格式不用重编.so也不用碰 Android Studio坏处是多了一次跨语言拷贝不过串口波特率通常 9600115200一次拷贝的开销远小于一帧的传输时间这点损耗可以忽略。选型上还有两条路我没走一是 USB 转串口方案走UsbManager拿UsbDeviceConnection好处是不用 root、不用管节点权限坏处是只对 CH34x、CP210x、FTDI 这类芯片有效板载 UART 伸出来的 TTL 排针它管不了二是把整段逻辑写成纯.so由 C# 直接DllImport省掉 Java 中间层但那样就得自己在 Native 里处理线程附着和回调调试成本翻倍。这份代码选择了 Java 中间层是权衡之后最稳的一档。2.2 目录结构与产物落位工程里跟串口相关的东西其实不多但每一份都必须放对位置放错了 Unity 不会报错只会在运行期提示DllNotFoundException或者ClassNotFoundException。路径内容说明Assets/Scripts/Serial/SerialPortManager.csC# 门面类对业务只暴露 Open/Close/Write/OnFrameAssets/Scripts/Serial/FrameParser.cs拆包器与串口解耦可单独替换Assets/Scripts/Serial/RingBuffer.cs环形缓冲可选用于高频小包场景Assets/Plugins/Android/libs/arm64-v8a/libserial_port.soJNI 动态库64 位必须单独提供Assets/Plugins/Android/libs/armeabi-v7a/libserial_port.soJNI 动态库32 位兼容视机型取舍Assets/Plugins/Android/serialbridge.aarJava 插件包内含classes.jar与 AndroidManifestAssets/Plugins/Android/AndroidManifest.xml合并清单声明硬件特性与权限.so这一层要特别说一句。安卓从 7.0 起对 32 位和 64 位做了严格区分机器是 arm64 的系统只加载lib/arm64-v8a/下的库如果只在armeabi-v7a里放了一份装上就崩。反过来如果你的.so只有 32 位版本Player Settings 里就必须把 ARMv7 勾上、ARM64 去掉否则一样找不到符号。我见过不止一个工程栽在这里日志里只有一句干巴巴的java.lang.UnsatisfiedLinkError看着像玄学其实就是库没对上。2.3 权限模型与设备节点绕不过去的坎安卓应用要以普通用户身份读写/dev/ttyS3前提是这个节点的权限位允许。默认情况下板载 UART 节点的属主多是root:root、权限600或660应用进程根本够不着。三种解法按可行性排序第一种是设备厂商在 SELinux 策略里给应用所属的untrusted_app打上serial_device标签或者把节点权限放宽到666。这是唯一“干净”的方案但需要固件配合普通开发者推不动。第二种是 root 之后调su改权限代码里就是起一个Runtime.getRuntime().exec(su -c chmod 666 /dev/ttyS3)。这条路径最省事也最常见但会引入一个明确的依赖机器必须已经 root且su二进制可用。打包给客户之前一定要确认清楚别等现场才发现机器是出厂未 root 的锁死固件。第三种是android:sharedUserId共享系统 UID只有系统签名应用才做得到。代码里对这三种情况做了兜底先canRead()/canWrite()探测不行再尝试su最后交给 JNI 的open()返回null由 Java 抛SecurityExceptionC# 侧捕获后给出可读的错误码。这样至少排查时你能一眼看出是权限问题还是参数问题而不是对着一个空指针发呆。提示su那条命令在不同固件上返回码不一样有的su -c可用有的要su 0还有的必须交互式输入。稳妥做法是把节点权限处理放到安装后首次启动的初始化流程里失败就弹一次提示不要每帧重试。3. 跑起来从空场景到第一个字节回显这一章按真实顺序走一遍先把插件和 so 放对再找节点最后用杜邦线短接做回环验证。任何一步跳过后面都是在猜。3.1 Player Settings 与 Manifest 的最小改动打开Build Settings → Player Settings切到 Android 页签脚本后端选 IL2CPPTarget Architectures勾上 ARM64如果两个.so都提供了就两个都勾Minimum API Level建议 24 以上。API Level 定得太低会在高版本系统上踩到FileDescriptor反射的兼容问题。AndroidManifest.xml要合并进工程的Assets/Plugins/Android/下内容大致如下。串口 UART 不需要 USB Host 特性声明这点跟 USB 转串口方案不一样别顺手抄错。?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.demo.serialapp !-- 串口为板载 UART 时无需额外硬件特性若走 USB 转串口需补 usb.host -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / application android:labelstring/app_name !-- 保留 Unity 自动注入的 Activity 声明 -- /application /manifest打包后先跑一次空场景确认应用能起来且不闪退再往下走。闪退的话用adb logcat -s Unity:V看堆栈UnsatisfiedLinkError就是 so 没对上ClassNotFoundException就是 aar 没打进包里。3.2 找设备节点adb 三条命令定生死节点名不能靠猜。ttyS0在很多板子上是调试串口接进去会跟控制台抢数据严重的直接把系统卡住。上机之后用下面三条命令确认# 1. 看当前存在的 tty 设备节点及其权限位 adb shell ls -l /dev/ttyS* /dev/ttyMSM* /dev/ttyHSL* 2/dev/null # 2. 看内核启动日志里 UART 控制器的注册情况 adb shell dmesg | grep -i tty\|uart | head -30 # 3. 直接试读能出数据说明节点通、权限也够 adb shell cat /dev/ttyS3 | head -c 32 | od -An -tx1第一条命令输出形如crw-rw-rw- root root 4, 67 /dev/ttyS3就说明权限已经放开如果是crw-------说明得先做权限处理。第二条里出现ttyS3 at MMIO 0x...之类的行基本能确认这个节点绑定在物理 UART 上。第三条是一条验证命令cat卡住不动是正常的没有数据进来按 CtrlC 退出。3.3 回环测试与最小收发节点确认之后把板子上的 TX 和 RX 用一根杜邦线短接发出什么就应该收回什么。这是唯一能证明“你的 termios 配对了、波特率没错、数据确实在 физически 走”的方法比盯着界面看有没有字靠谱得多。测试代码可以短到只有十几行// 最小回环验证打开 - 发 4 字节 - 等回显 - 打印 void Start() { var mgr new SerialPortManager(); if (!mgr.Open(/dev/ttyS3, 115200)) { Debug.LogError(串口打开失败先查节点和权限); return; } byte[] probe { 0x01, 0x02, 0x03, 0x04 }; mgr.Write(probe, 0, probe.Length); // 1 秒后统计是否把 4 字节原样收回来了 Invoke(nameof(CheckEcho), 1f); } void CheckEcho() { Debug.Log($[UART] 回环收到 {mgr.ReceivedCount} 字节); }Open内部做的第一件事是构造AndroidJavaObject(com.demo.serial.SerialBridge)第二步是Callbool(open, path, baudrate)返回 false 就说明 Java 侧抛了异常被吞了这时候要把adb logcat里的SerialPort标签捞出来看。Write是把byte[]直接丢给 Java 的OutputStream.write中间不做任何字符串编码转换——串口协议里出现中文是最容易翻车的写法后面会讲。回环通了之后接上真实外设再做一次同样的验证如果回环通、外设不通问题在波特率或帧格式如果回环都不通问题在权限或节点。这条分界线能省掉大量瞎猜。4. 核心模块拆解一个字节从硬件到业务回调这一章把三层代码拆开看重点是 JNI 里那几行 termios 配置和 C# 侧那条读线程其余都是外围。4.1 JNI 打开设备与 termios 参数open()这三个参数决定了后面所有行为O_RDWR读写、O_NOCTTY不让这个设备成为进程的控制终端不加的话某些调制解调器会把信号挂到你的 app 进程上后果是进程被意外挂起、O_NONBLOCK先以非阻塞方式打开避免在等待 DCD 载波信号时空转卡死。JNIEXPORT jobject JNICALL Java_com_demo_serial_SerialPort_open (JNIEnv *env, jclass clazz, jstring path, jint baudrate, jint flags) { const char *pathStr (*env)-GetStringUTFChars(env, path, 0); // 1. 非阻塞打开防止 open 卡在等待载波 int fd open(pathStr, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd 0) { __android_log_print(ANDROID_LOG_ERROR, SerialPort, open %s failed: %d, pathStr, errno); return NULL; } // 2. 配置成原始模式8N1无流控 struct termios tio; tcgetattr(fd, tio); cfmakeraw(tio); // 关掉回显、行缓冲、特殊字符处理 tio.c_cflag | (CLOCAL | CREAD); // 忽略调制解调器控制线使能接收 tio.c_cflag ~CRTSCTS; // 关硬件流控 tio.c_cflag ~CSTOPB; // 1 位停止位 tio.c_cflag ~PARENB; // 无校验 tio.c_cflag ~CSIZE; tio.c_cflag | CS8; // 8 位数据位 tio.c_cc[VMIN] 1; // 至少有 1 字节才返回 tio.c_cc[VTIME] 0; // 不设超时靠阻塞读 cfsetispeed(tio, to_speed(baudrate)); cfsetospeed(tio, to_speed(baudrate)); tcsetattr(fd, TCSANOW, tio); tcflush(fd, TCIOFLUSH); // 清掉残留脏数据 // 3. 清掉非阻塞标志让 read 变成阻塞式 fcntl(fd, F_SETFL, 0); // 4. 把 fd 塞进 java.io.FileDescriptor jclass cFd (*env)-FindClass(env, java/io/FileDescriptor); jmethodID ctor (*env)-GetMethodID(env, cFd, init, ()V); jobject fileDesc (*env)-NewObject(env, cFd, ctor); jfieldID fid (*env)-GetFieldID(env, cFd, descriptor, I); (*env)-SetIntField(env, fileDesc, fid, fd); (*env)-ReleaseStringUTFChars(env, path, pathStr); return fileDesc; }几个参数值得单独拎出来说。VMIN1, VTIME0是阻塞语义没有数据就死等来一个字节立刻返回。这种模式的好处是延迟最低坏处是关闭时必须能把阻塞的read打断否则线程会僵在那儿。解决办法是close()时先close(fd)被阻塞的read会立刻返回-1并置errno EBADFJava 侧抛IOException读线程顺势退出。cfmakeraw这一行也关键它会清掉ICANON、ECHO、ISIG少了它你写进去的0x03会被当成 CtrlC 直接触发信号。波特率映射用一个switch就够了。termios不接受任意整数只认B9600、B115200这类常量所以必须做一次转换遇到不认识的值退回到 115200 并打日志。static speed_t to_speed(int baudrate) { switch (baudrate) { case 9600: return B9600; case 19200: return B19200; case 38400: return B38400; case 57600: return B57600; case 115200: return B115200; case 230400: return B230400; case 460800: return B460800; default: __android_log_print(ANDROID_LOG_WARN, SerialPort, unsupported baudrate %d, fallback to 115200, baudrate); return B115200; } }4.2 C# 读线程与主线程派发读线程是整条链路里最容易出事的地方。Unity 的Transform、GameObject、Instantiate都只能在主线程调用读线程里碰一个就崩而且崩得毫无规律典型的血泪经验。void ReadLoop() { // 子线程调用 Java 前必须附着到 JVM否则 JNI 直接崩 AndroidJNI.AttachCurrentThread(); byte[] buffer new byte[512]; try { while (running) { // 阻塞读到至少 1 字节close 时返回 -1 抛异常循环自然结束 int n bridge.Callint(read, buffer, 0, buffer.Length); if (n 0) { byte[] chunk new byte[n]; Buffer.BlockCopy(buffer, 0, chunk, 0, n); mainQueue.Enqueue(chunk); // 只入队不碰 Unity 对象 } } } catch (Exception e) { if (running) Debug.LogWarning([UART] 读线程异常退出: e.Message); } finally { AndroidJNI.DetachCurrentThread(); // 配对释放泄漏会拖垮 JVM } } void Update() { // 主线程统一消化保证回调里能安全操作 UI while (mainQueue.TryDequeue(out byte[] chunk)) { parser.Feed(chunk, OnFrame); } }AttachCurrentThread和DetachCurrentThread必须成对出现这一点没有后悔药可吃——只附着不释放JVM 的线程表会一直涨跑上几个小时就 OOM。bridge.Callint每次都会跨一次 JNI但 115200 下满速也就每秒十几帧这点开销完全不是瓶颈。running用volatile修饰保证主线程置 false 之后读线程能立刻看见。关闭顺序也有讲究先把running置 false再调bridge.Call(close)打断阻塞读最后Join线程并设个 500ms 超时超时就强制放弃并打日志。顺序反了会出现“主线程等线程退出、线程等数据”的死锁。4.3 环形缓冲与粘包拆包串口是字节流没有消息边界。一次read可能返回半个帧也可能一次返回三帧半粘在一起这是物理层特性不是 bug。拆包逻辑必须独立于串口本身做成一个只吃字节数组的纯函数式组件这样 PC 上写单元测试也能跑。public class FrameParser { const byte HEAD0 0xAA, HEAD1 0x55; readonly Listbyte cache new Listbyte(1024); // 帧格式AA 55 | LEN(1) | PAYLOAD(LEN) | SUM(1) public void Feed(byte[] data, Actionbyte[] onFrame) { cache.AddRange(data); while (true) { int start FindHeader(); if (start 0) { cache.Clear(); return; } // 没有帧头全丢 if (start 0) cache.RemoveRange(0, start); // 丢掉帧头前的噪声 if (cache.Count 4) return; // 还不够算长度和校验 int len cache[2]; int total 4 len; // 头2长度1数据校验1 if (cache.Count total) return; // 半包等下一批 byte sum 0; for (int i 0; i 3 len; i) sum cache[i]; if (sum ! cache[3 len]) { cache.RemoveRange(0, 1); // 校验错滑一字节重找 continue; } byte[] payload cache.GetRange(3, len).ToArray(); cache.RemoveRange(0, total); onFrame(payload); } } int FindHeader() { for (int i 0; i cache.Count - 1; i) if (cache[i] HEAD0 cache[i 1] HEAD1) return i; return -1; } }三个细节。cache.Clear()那行看着粗暴其实是对的连帧头都找不到说明前面全是噪声留着只会让后续查找越来越慢。校验失败时滑一个字节而不是丢整帧是为了应对“帧头出现在数据区”的极端情况代价是多几次循环。total里len之后还要加帧头的 2 字节和长度的 1 字节、校验的 1 字节所以常量是 4这个偏移错一位整个解析就永远对不上。高频小包场景下Listbyte的RemoveRange会有内存搬移开销可以换成定长byte[]加读写双指针的环形缓冲容量取 4096。实测 115200 波特率、20ms 一帧的情况下List版本 CPU 占用完全可接受没必要提前优化。5. 避坑与排查真机上九成问题都在这五处下面五条都是我在真机上踩过的按出现频率排序每条都写清楚现象、原因和解决路径。5.1 打开返回 false日志里什么都没有现象Open()返回 falseadb logcat里既没有SecurityException也没有IOException。原因Java 侧open方法把异常吞掉了只return falseJNI 的__android_log_print走的是 native 日志通道标签是SerialPort而不是Unity用adb logcat -s Unity根本看不到。解决把过滤条件放开成adb logcat | grep -i serial\|tty同时检查节点路径是否拼错——/dev/ttyS3和/dev/ttyS03在某些固件上是两个不同的东西。还有个隐蔽原因应用没有存储权限时Android 10 以上会在File构造阶段就抛异常日志同样不显眼。5.2 能打开但一个字节都收不到现象Open()成功写出去的数据外设确实有反应但读线程一直阻塞不返回。原因TX 和 RX 接反了或者外设的 GND 没和板子共地。串口是差分之外的单端信号不共地时电平参考漂移收端识别不出来。另一个常见原因是tcflush(fd, TCIOFLUSH)放在了tcsetattr之后但读线程已经启动把刚到的第一帧冲掉了。解决先做杜邦线短接回环通了再接外设如果不通万用表量一下 TX-RX 之间的电平。代码上把tcflush挪到打开流程的最后一步、读线程启动之前。5.3 数据来了但全是乱码现象收到一堆0x00、0xFF或者毫无规律的字节。原因波特率不匹配这是最经典的一类。误差超过 2%3% 就开始出误码超过 5% 基本全是垃圾。另一个来源是时钟源某些低成本板子的 UART 时钟分频算不出精确的 115200实际跑在 114000 左右短帧看不出来长帧必错。解决把波特率降到 9600 试一次能通就说明是分频精度问题老老实实降速或者换 38400/57600 这类容易分频的值。另外确认外设是不是 8N1有些仪表默认是 8E1偶校验PARENB没打开就会一直错位。5.4 应用切后台再回来串口就死了现象按 Home 键再切回前台读线程没了写也没反应。原因安卓在内存紧张时会回收后台进程的 native 资源FileDescriptor被系统关掉但 C# 侧running还是 true读线程一直在读一个失效的 fd返回-1之后被当成 EOF 静默退出。解决在OnApplicationPause(true)里主动Close()OnApplicationPause(false)里重新Open()。别指望系统帮你保住 fd这类资源本来就该跟着生命周期走。重新打开时记得把FrameParser的缓存清空否则残留的半包会跟新数据拼在一起。5.5 IL2CPP 打包后反射拿到的方法找不到现象Editor 和 Mono 后端都正常切 IL2CPP 之后AndroidJavaObject.Call抛NoSuchMethodError。原因IL2CPP 的代码裁剪把 Java 侧没被显式引用的方法签名裁掉了尤其是重载方法。Callint(read, buffer, 0, len)里的参数类型必须和 Java 声明严格一致byte[]传成sbyte[]或者int传成short都会在运行期才暴露。解决link.xml里保留相关程序集或者把Call的参数类型逐个对齐 Java 签名。更稳的做法是在 Java 侧只暴露一个read(byte[])单参重载减少签名匹配的歧义把 offset 和 length 的活交给 C# 自己算。6. 二次开发把协议层从串口层里剥出来这份代码最值得改的地方不是串口本身而是它的分层边界。SerialPortManager只负责“把字节搬进来、把字节送出去”一行协议相关的东西都不该有FrameParser只负责“从字节流里切出完整帧”一个串口 API 都不该调。把这条线守住换协议、换机型、换外设都不用动底层。具体做法是先定义一个接口把串口抽象成可替换的传输通道public interface ITransport { bool Open(string endpoint, int baudrate); void Close(); void Write(byte[] data, int offset, int length); event Actionbyte[] OnBytes; // 原始字节不做任何解析 }然后让SerialPortManager实现它再写一个TcpTransport实现同一接口。这样在 PC 上调试协议时用 TCP 连一个转发服务就能跑不必每次都烧到板子上。我一般还会加一个MockTransport按脚本定时吐预设帧用来测边界情况——半包、校验错、帧头出现在数据区这三种真机很难稳定复现Mock 里三行就能造出来。协议层的改造点通常是这三处帧头从AA 55换成你们自己的校验从累加和换成 CRC16注意大小端长度字段从 1 字节扩到 2 字节时total的计算和FindHeader的边界判断都要跟着改。改完先跑 Mock 里的用例再上回环最后接真实外设这个顺序别跳。验证方法上有个笨但有效的习惯在OnFrame里把每帧的原始十六进制打出来同时记录时间戳。设备偶发丢帧时看时间戳间隔就能判断是发送端漏发还是接收端漏收——如果间隔恰好是两倍帧周期说明中间少了一帧如果间隔正常但内容错位说明是拆包逻辑的问题。这比在几千行日志里翻要快得多。还有一个容易被忽略的点写操作要加锁。Write可能被 UI 线程和定时器线程同时调用而OutputStream.write本身不保证多线程安全两帧数据交叉写入会直接产生一帧废数据。最简单的方式是给bridge加个lock对象串口的写频率通常不高锁竞争可以忽略。从那以后我每次接新外设都强制先跑一遍短接回环再碰协议宁可多花五分钟接根线也不愿意在几百帧数据里找那个错位的字节。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询