简介:面向需要对接Arduino、各类传感器等外部硬件设备的Unity3D开发者,提供一套无需经过Java层中转、直接在C#脚本中操作Android串口的完整示例。资源覆盖了串口打开、波特率与数据位等参数配置、数据发送接收、串口关闭等核心流程,并给出拼包拆包、异常重连、多脚本并发访问时的同步控制思路,适用于物联网、机器人控制、工业数据采集等项目的快速原型验证。
压缩包共4184个文件,其中包含217个C#源码脚本、110个DLL依赖库、2127个bin数据文件,以及大量Unity工程所需的meta、asset、png等配置与资源,整体约46MB。目前已有1441人学习下载。
通过该示例可快速掌握Unity下C#直接调用Android串口能力,显著减少Java与C#之间的桥接代码量;同时深入理解串口数据流处理、字节格式转换、资源释放与异常处理等关键细节。源码结构完整、划分清晰,便于二次开发,可直接嵌入现有Unity项目,提升硬件交互模块的开发效率。
1. 打开 testU3DSerialPort 之前,先搞明白 U3D 串口测试为什么总在“连不上”这一步翻车
拿到一个名为 testU3DSerialPort.rar 的压缩包,通常是硬件同事把他们在 U3D 里做的串口联调工程打包发过来。拆开看,核心不是游戏逻辑,而是一个能选择端口、打开串口、收发数据、显示回显的最小场景。串口在 U3D 里属于典型的“看着简单、一测就翻车”:有人打开端口直接抛异常,有人收发全是乱码,有人一按 Play 编辑器就卡死。这篇笔记把串口测试工程从底层机制、代码搭建、参数配置到避坑排查完整串一遍。适合需要和传感器、单片机、工控板联调的 U3D 开发者照着复现,也适合刚拿到类似工程、急着定位问题的人快速找到排查入口。
2. U3D 串口通信的底层机制:SerialPort、事件线程与平台差异
2.1 SerialPort 在 U3D 运行时里的真实工作方式
U3D 的 C# 脚本跑在 .NET 运行时上,桌面平台可以直接引用 System.IO.Ports.SerialPort 类。这个类本身是对操作系统串口驱动的封装:实例化时设置波特率、数据位等参数,调用 Open() 时操作系统为进程申请串口独占访问;打开成功后,设备发来的字节持续进入驱动维护的接收缓冲区,应用层用 Read 方法或 DataReceived 事件把字节取走。理解驱动缓冲加应用读取这个两层模型,就能解释后面很多奇怪现象:数据明明到了,程序却没读到,多半是读得不及时,或者事件挂接时机不对。
关键点是 DataReceived 事件回调运行在 .NET 线程池的后台线程上,不是 U3D 主线程。在这个回调里做 UI 赋值、调用 UnityEngine API,都属于跨线程访问。编辑器模式下有时不报错,看起来像玄学,其实只是渲染管线恰好没在读取那个控件;打包后的程序里大概率表现为 UI 卡死、画面撕裂甚至闪退。我的处理原则是:把事件回调当搬运工,只负责把字节搬进队列,业务处理统一放主线程。
SerialPort 的读取方式也要选对。直接用 ReadLine 依赖 NewLine 字符,设备如果不按行发数据,或者一帧数据被拆成两段到达,ReadLine 会一直等到超时才返回,看起来就像丢数据。更可靠的做法是先看 BytesToRead 拿到当前可读字节数,再一次性 Read 到本地缓冲区。还有一个参数叫 ReceivedBytesThreshold,默认值是 1,表示缓冲区每多 1 个字节就触发一次事件;高波特率下触发频率非常高,建议调到 16 或 32,减少线程唤醒次数,这个细节对降低 CPU 占用很有用,也顺带减小了断包的概率。
2.2 平台差异:Windows 的 COM 口、macOS 的 tty 与 cu
GetPortNames() 返回的端口名跨平台格式差异很大。Windows 上一般是 COM3、COM10;macOS 上是 /dev/tty.usbserial-xxxx 或 /dev/cu.usbserial-xxxx;Linux 上常见 /dev/ttyUSB0、/dev/ttyACM0。写测试工程时,端口下拉列表直接调 GetPortNames() 填充,但要注意过滤去重:macOS 上同一块 USB 转串口设备会同时出现 tty 和 cu 两个路径,选错就打开失败。
tty 开头会等待调制解调器的 DCD 信号,很多 USB 转串口模块的电平逻辑不满足这个条件,Open() 会一直阻塞或直接抛异常;cu 开头是“呼叫方”,不等待对端信号,适合主动连接设备的测试场景。我在 macOS 上联调统一用 /dev/cu 开头的路径,能少走好几轮“为什么别人能开我不能”的排查。另外,打开一个旧工程时先检查 Project Settings 里的 API Compatibility Level,如果选的是过低的兼容级别,SerialPort 相关 API 可能直接不可用;桌面联调一般选 .NET Standard 2.1 或更高。
还有一个容易忽略的平台限制:SerialPort 这套 API 在 Android 和 iOS 上基本不可用。移动平台没有标准的串口驱动访问接口,Android 要基于 USB Host 做自定义封装,苹果的生态限制更严格。所以 testU3DSerialPort 这类测试工程通常只定位在桌面编辑器联调,移动端需要单独做跨平台抽象,别指望一套 SerialPort 代码通吃所有平台。
2.3 解压后应该先看哪些文件
拿到 rar 先别急着按 Play,我一般按顺序检查三样东西。第一是场景文件,看有没有串口控制面板:端口下拉框、打开/关闭按钮、发送输入框、接收显示区。第二是脚本里 SerialPort 的实例化参数,波特率默认值写的是 9600 还是 115200,这决定连设备时先核对哪一项。第三是配套协议说明,帧头、帧尾、校验方式、指令格式,通常在 readme 或工程里的 txt 里。
如果压缩包里没有文档,只能从脚本反推协议。把 Read 附近的解析代码读一遍,看它是按行分隔、按固定长度解析还是按帧头找包。这个动作必须做,因为很多设备协议不是标准格式,后面调参调了半天发现是帧头判断写错的情况很常见。测试工程还应该坚持在编辑器里跑,不要急着打包,因为编辑器能看到完整日志和错误堆栈,打包后的黑盒状态会让串口这种硬件联调问题难上加难。解压后典型的文件与检查重点如下。
| 文件 | 作用 | 检查重点 |
|---|---|---|
| Assets/Scenes 下的场景 | 测试 UI 与物体布局 | 是否有端口下拉、收发区 |
| 串口管理脚本 | 封装 SerialPort 生命周期 | 波特率、事件挂接、关闭逻辑 |
| 协议解析脚本 | 把字节转成业务数据 | 帧头、长度、校验字段 |
| README 或协议 txt | 设备端通信约定 | 波特率、指令格式、换行符 |
这一章的结论一句话:串口测试工程的大部分问题不是协议难,而是线程模型和平台差异造成的。理清事件回调线程与主线程的关系,按平台选对端口路径,再照着第三章搭代码,基本不会跑偏。
3. 搭建最小串口读写测试场景:打开端口、队列接收与回显验证
3.1 创建 SerialPort 实例并打开端口
核心步骤就四行:new SerialPort、设参数、Open、挂事件。但事件挂接的时机有讲究,最好在 Open 成功之后挂,避免端口还没就绪事件就触发。下面是一个可以塞进测试工程直接用的打开方法。
using System.IO.Ports; public class SerialTest : MonoBehaviour { private SerialPort _port; // portName 示例:Windows 传 COM3,macOS 传 /dev/cu.usbserial-xxx public bool OpenPort(string portName, int baudRate = 115200) { ClosePort(); // 先清理旧连接,防止端口句柄泄漏 _port = new SerialPort(portName, baudRate) { DataBits = 8, Parity = Parity.None, StopBits = StopBits.One, Handshake = Handshake.None, ReadTimeout = 500, WriteTimeout = 500 }; try { _port.Open(); Debug.Log($"[SerialTest] {portName} opened @ {baudRate}"); return true; } catch (UnauthorizedAccessException ex) { // 常见原因:串口调试工具还开着,或上次运行没释放 Debug.LogError($"[SerialTest] open failed: {ex.Message}"); return false; } } public void ClosePort() { if (_port != null) { if (_port.IsOpen) _port.Close(); _port.Dispose(); _port = null; } } }这段代码里每个参数都有实际意义。DataBits 是数据位,绝大多数设备用 8;Parity 是校验位,没有校验就设 None;StopBits 是停止位,默认 One;Handshake 是流控,USB 转串口模块一般不接 RTS/CTS 硬件流控线,必须设 None,否则双方流控状态不一致会互相等待。ReadTimeout 和 WriteTimeout 设为 500 毫秒,是为了防止同步读取操作把主流程卡住。
Open() 抛出的异常不止一种。端口被占用抛 UnauthorizedAccessException,端口名不存在或设备被拔掉会抛 ArgumentException 或 IOException。测试工程建议把 catch 写细,至少把 UnauthorizedAccessException 和其他异常分开打日志,这样同事拿到工程一看日志,就知道是设备没插还是端口被占,省掉来回确认的时间。
3.2 用事件加队列接收数据,主线程负责消费
串口接收如果直接在主线程里轮询 BytesToRead,高波特率下容易丢数据;反过来在 DataReceived 回调里做解析和 UI 更新,又会踩跨线程的坑。正确的做法是中间加一个线程安全的队列,回调只负责入队,Update 里统一出队。
private readonly Queue<byte[]> _rxQueue = new Queue<byte[]>(); private void OnEnable() { if (_port != null) _port.DataReceived += OnDataReceived; } private void OnDisable() { if (_port != null) _port.DataReceived -= OnDataReceived; } // 后台线程回调:只搬数据,不做任何 UI 操作 private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var sp = (SerialPort)sender; int n = sp.BytesToRead; // 当前缓冲区可读字节数 if (n <= 0) return; byte[] buf = new byte[n]; sp.Read(buf, 0, n); // 一次性读走,避免事件重复触发时读到空 lock (_rxQueue) { _rxQueue.Enqueue(buf); // 字节原样进队列,协议解析放到主线程 } } private void Update() { byte[] data = null; lock (_rxQueue) { if (_rxQueue.Count > 0) data = _rxQueue.Dequeue(); } if (data != null) { string text = Encoding.UTF8.GetString(data); Debug.Log($"[SerialTest] RX: {text}"); // 在这里解析协议、刷新 UI 都是安全的 } }这里有三个细节容易写错。第一,BytesToRead 必须在调用 Read 之前取,并且最好把当前可读数据全部读出来,否则数据可能留在缓冲区里,事件不再触发,界面就一直等不到新数据。第二,Queue 的入队和出队分属两个线程,必须用 lock 保护,否则队列在扩容时被另一个线程读取会直接抛异常。第三,UnityEngine 的 Debug.Log 在后台线程调用不一定立刻崩,但真机上行为不可预期,日志也尽量放主线程打。
这个模式的本质是把底层接收和业务解析解耦。回调里完全不管协议,Update 里按帧解析,正好对应两个不同的需求:不丢数据、不改 UI。以后换设备、改协议,只需要动 Update 里的解析代码,事件挂接部分可以一直复用。如果设备数据量大,还可以把 ReceivedBytesThreshold 调高来减少事件次数,但注意别调太高,否则小包数据要攒够才触发,实时性会下降。
3.3 发送指令与回显验证
发送相对简单,但换行符和编码两个点决定设备认不认你的指令。很多设备协议里每条指令以回车换行结尾,没带 \r\n 设备就默默忽略。再就是编码,设备端固件用 ASCII 或者 GBK 解析,你这边用 UTF-8 发中文,双方字节一对照就全是乱码。测试阶段建议全部用纯 ASCII 指令或十六进制,等协议确认了再引入中文。
public void SendCommand(string cmd) { if (_port == null || !_port.IsOpen) return; // 按协议补换行:设备文档要求 \r\n 就加 \r\n,要求 \n 就加 \n string line = cmd.EndsWith("\n") ? cmd : cmd + "\r\n"; byte[] bytes = Encoding.UTF8.GetBytes(line); _port.Write(bytes, 0, bytes.Length); Debug.Log($"[SerialTest] TX: {line}"); } // 十六进制发送,适合调试非文本协议 public void SendHex(string hex) { hex = hex.Replace(" ", ""); byte[] bytes = new byte[hex.Length / 2]; for (int i = 0; i < bytes.Length; i++) bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); _port.Write(bytes, 0, bytes.Length); }SendCommand 里判断是否已带换行,是为了防止 UI 输入框已经手动输了换行又重复追加。SendHex 则应对那些“显示上是十六进制字符串、实际要按字节发”的设备。把两种发送都放到测试面板上,排查效率会差很多:文本发不出去可以先怀疑换行和编码,十六进制发不出去就可以锁定物理链路和波特率。
回显验证是测试工程的验收标准。打开端口后,用一根导线把串口模块的发送和接收脚短接,在发送框输入任意字符串,接收区原样显示出来,说明端口、线程、解析整条链路是通的;显示不出来,就从物理连接一层层往上查。这个回环操作在第六章还会展开。UI 上把 OpenPort 绑定到打开按钮、SendCommand 绑定到发送按钮,端口下拉框用 GetPortNames() 填充并留一个刷新按钮,就是一套完整的测试面板。
4. 串口参数怎么配:波特率、数据位、编码与换行符的调参清单
4.1 六个必调参数与典型值
SerialPort 的默认参数组合是波特率 9600、数据位 8、停止位 1、无校验,这套默认值能覆盖大约六成设备。但剩下四成要么波特率是 57600,要么要偶校验,拿到设备不先对参数直接连,大概率是乱码或无响应。我建议把参数全部显式写出来,不依赖构造器默认值,这样出问题时至少知道参数是谁设的。
| 参数 | 典型值 | 说明 | 调参方向 |
|---|---|---|---|
| BaudRate | 115200 / 9600 | 每秒传输符号数,收发双方必须一致 | 不一致表现为乱码或完全无响应 |
| DataBits | 8 | 数据位数,旧设备可能用 7 | 设备文档写明才改 |
| Parity | None / Even / Odd | 奇偶校验位 | 设备端有校验就设一致,没有就 None |
| StopBits | One / Two | 停止位长度 | 绝大多数设备用 One |
| Handshake | None | 流控方式 | USB 转串口未接流控线,必须 None |
| ReadTimeout | 500ms | 同步读超时 | 按协议帧间隔调整,事件模式时兜底即可 |
超时参数最容易纠结。ReadTimeout 设大了,设备不下发数据时同步读取会把线程卡住;设小了,数据在传输中途被截断,读超时一到期就抛 TimeoutException。我的原则是:用事件方式接收时不依赖 ReadTimeout,把它当兜底;用同步读时,把超时设为协议最大帧间隔的两到三倍。
4.2 波特率不匹配:乱码与无响应的第一嫌疑
波特率是串口联调里最容易踩的坑,也是排查时第一个要核对的项目。设备端固件定的是 9600,你在 U3D 里开 115200,结果通常是两种:一种完全收不到数据,因为双方采样时钟不一致,帧同步直接错位;另一种收到一串乱码,数据确实到了,但每个字节被按错误的时序解析,出来就是错位的数据。
现在的开发板普遍支持 115200,传输速度快,调试时刷屏不卡顿,所以测试工程默认值我写在 115200。但注意一点:USB 转串口模块本身也是个设备,它的波特率要和两端同时匹配。有些模块标称支持 921600,实际高波特率下丢字节严重,设备回传的数据经常不完整。遇到这种情况,把波特率降到 57600 或 9600 再试,往往就好了。
检查波特率不匹配有个技巧:用十六进制显示看乱码规律。如果收到的字节每隔固定间隔出现一个明显错误,比如连续的 FF FE 这样的值,基本是波特率偏差导致位采样错位;如果乱码连续且无规律,那就要怀疑设备端根本没发数据,或者 TX/RX 接反了。TX/RX 接反的表现是无响应,但有些模块自带收发交叉,肉眼看不出来,这时用回环测试一验便知。
4.3 编码与换行符:字节都收到了,坏在解释方式
收数据时最容易误判的是“收到的全是乱码”,实际上串口链路是通的,问题是字节被按错误的编码解释。设备发的是 GBK 编码的中文,你用 Encoding.UTF8.GetString 去解,中文就变成经典乱码;设备发 ASCII,你按 UTF-8 解,纯英文没问题,一旦出现扩展字符就错位。测试工程里我通常放一个编码下拉框,UTF-8 和 ASCII 自由切换,几秒钟就能定位是不是编码问题。
换行符的问题更隐蔽。设备协议里“一帧结束”的标志可能是 \r\n(Windows 风格)、\n(Unix 风格)或者裸 \r(老式设备)。解析代码里只认 \n,设备发 \r\n,你会在每行行尾看到多余字符,行首多一个空位。更麻烦的是有些设备只在帧末尾发 \r,你用 Split('\n') 去切就永远只有一整块。没有调试器时,把接收到的原始字节打成十六进制字符串对照协议文档查结束符,比肉眼盯着文本猜要快得多。
编码和换行符的设置,我习惯集中放在脚本顶部的“设备参数区”,和波特率、校验位放在一起,每次换设备只改这一处。这个习惯能把你从“这次怎么又是乱码”的循环里解脱出来,排查范围从整个项目缩小到几行配置。协议文档更新时,顺手在这个区域旁边加个注释说明文档版本,避免团队里几个人各改各的。
5. U3D 串口调试避坑:五个高频翻车场景与排查路径
5.1 打开端口直接抛异常:先查占用,再查权限
现象:调用 Open() 时抛 UnauthorizedAccessException,端口列表里明明能看到 COM3,就是打不开。原因分两类:一是另一个程序占着端口,最常见的是串口调试工具没退出,或者上一个测试进程没关干净;二是当前用户没有设备访问权限,Linux 下比较常见,需要把自己加入 dialout 组。解决:先关掉所有可能占用端口的工具,再重开编辑器;Linux 上执行 usermod 加组之后要注销重登才生效。代码层面把 catch 写细,日志直接打出异常信息,定位时只看日志就能区分两种情况。
补充一条经验:Windows 上有些 USB 转串口芯片没装驱动时,系统里能看到端口但状态异常,Open 必然失败,这时候重装芯片厂商驱动比改代码快得多。判断方法是在系统设备列表里看端口有没有感叹号标记,有就是驱动问题,代码两边改都没用。
5.2 数据到了但 UI 不刷新,编辑器还卡死
现象:接上设备后日志里能看到 RX 数据,但界面上的接收文本一动不动,多等一会整个编辑器卡住,只能强制退出。原因:DataReceived 回调在后台线程,直接给 Text 组件赋值等于在渲染线程以外改 UI 状态。编辑器里很多组件对跨线程访问没有强校验,结果数据写进去了,渲染队列不知道要刷新,界面看起来就是死了。解决:回到第三章的队列模式,回调只入队,Update 里统一赋值。如果工程里有现成的并发工具类,也可以把回调内容投递到主线程执行,但队列模式更直观、依赖更少。
还有一个隐蔽点:如果用的是固定容量的缓冲而不是 Queue,入队速度大于出队速度时新数据会覆盖旧数据,界面上看到的是丢数据。此时把出队逻辑改成每帧多次消费,或者把缓冲容量调大,而不是在入队端盲目丢包。这个坑的症状和协议粘包很像,但本质完全不一样,排查时先确认队列长度有没有持续打满。
5.3 拔插 USB 设备后端口列表不刷新
现象:程序运行中拔掉设备再插上,下拉列表里还是旧的端口名,选中重新打开时报设备不存在。原因:GetPortNames() 在 Start 里只调用了一次,枚举结果被缓存,后续拔插不会自动更新;另外操作系统枚举设备本身需要时间,刚插上立刻刷新可能还没枚举出来。解决:把端口列表刷新放到下拉框展开事件里,每次展开重新调用 GetPortNames();再加一个刷新按钮作为手动兜底。
macOS 上拔插后旧端口名通常要等一两秒才消失,新端口名出现也要时间,所以刷新时不要直接清空旧列表,而要把新旧合并去重,避免用户看到空白列表。Windows 上如果拔插后 GetPortNames() 一直不更新,检查系统设备列表的端口项是否正常枚举,不正常就是驱动或硬件问题,代码里怎么刷新都没用。
5.4 数据粘包与断包:串口是字节流,不是消息流
现象:设备每 50 毫秒发一条 32 字节的帧,但接收区经常出现一条 64 字节或一条 16 字节的“残帧”。原因:串口本质是字节流,没有消息边界,操作系统把连续收到的字节攒在缓冲区里,应用层一次 Read 读到的可能是半帧、一帧、两帧,完全取决于调度时机。解决:协议层必须自带边界。常见做法是帧头加长度加校验的结构,解析时先把整帧收齐再处理;简单场景可以用换行符做分隔。下面是一个按字节缓冲查找帧头的解析骨架。
private byte[] _frameBuf = new byte[4096]; private int _frameLen = 0; // 把新收到的一批字节追加进缓冲,尝试切出完整帧 private void AppendRx(byte[] data) { // 缓冲满时先腾空,防止后续数据撑爆数组 if (_frameLen + data.Length > _frameBuf.Length) _frameLen = 0; System.Buffer.BlockCopy(data, 0, _frameBuf, _frameLen, data.Length); _frameLen += data.Length; const byte HEAD = 0xAA; // 协议帧头,按设备文档修改 int start = -1; for (int i = 0; i < _frameLen - 3; i++) { if (_frameBuf[i] == HEAD && _frameBuf[i + 1] == HEAD) { start = i; break; } } if (start >= 0 && _frameLen - start >= 4) { int len = _frameBuf[start + 2]; // 第 3 字节是负载长度 if (start + len + 3 <= _frameLen) { // 取出一整帧,交给业务逻辑 byte[] frame = new byte[len + 3]; System.Array.Copy(_frameBuf, start, frame, 0, frame.Length); ProcessFrame(frame); // 剩余字节搬回缓冲开头,继续找下一帧 int remain = _frameLen - (start + frame.Length); System.Buffer.BlockCopy(_frameBuf, start + frame.Length, _frameBuf, 0, remain); _frameLen = remain; } } }这个骨架每次追加完都扫描帧头,找到完整帧就切出来,剩余数据留在缓冲里等下一批到达。注意 ProcessFrame 本身是被 Update 调用的,里面不要再做跨线程操作。帧头 HEAD 和长度字段位置必须和设备文档一致,否则会出现“一帧错位、后面全错”的连锁反应,这是串口调试里最折磨人的现象之一。字段错位时先打印一整段 hex,数一下第几个字节是帧头,再回头改常量。
5.5 退出编辑器后端口还占着,设备掉线
现象:U3D 编辑器正常退出,设备端指示灯还亮,或者下次启动时报端口被占用。原因:退出时没调 Close() 和 Dispose(),操作系统认为进程还持有串口句柄;有时是场景切换时旧对象被销毁,端口却没人释放。解决:在 OnApplicationQuit 和 OnDestroy 两个生命周期里都调用 ClosePort()。第三章的 ClosePort 内部已经处理了 IsOpen 判断和 Dispose,重复调用不会报错。测试时把设备插上,同时开串口调试工具和 U3D 工程,两边互相抢占,能复现最常见的占用场景,从而验证关闭逻辑是否真的释放了句柄。
另外提醒一点:编辑器退出时如果脚本抛了异常,生命周期方法可能执行不完整。稳妥做法是在串口管理脚本里加一个日志,记录 Close 的调用时机,配合操作系统设备列表确认句柄释放。串口这块没有后悔药,端口一旦被占,只能找到占用进程杀掉才能继续调试,所以关闭逻辑在测试工程里和打开逻辑同等重要。
这五个场景覆盖了串口联调里最容易磨人的环节:打不开、收不到、不刷新、切不开帧、退不干净。把第三章的队列模式、本章的帧缓冲解析、端口刷新和退出清理写进工程,后续换设备基本只需要改协议参数,不会再被底层问题反复折腾。
6. 把测试工程升级成调参工具:回环验证与日志落盘
6.1 回环验证:没有真实设备也能把链路测到确定
回环是串口联调里性价比最高的验证手段。用一根导线把串口转接模块的 TX 和 RX 短接,模块收什么就发什么,形成一个闭环。配合第三章的 SendCommand,发送任意字符串能原样收到,说明 U3D 脚本、端口参数、线程队列全链路都是通的;收不到,问题一定在模块、驱动或接线,和 U3D 代码无关。这个隔离判断能把调试范围缩小一半,尤其在设备还没到场、只能先验证工具链的时候,回环测试几乎是唯一的确定性验证方式。
6.2 数据落盘:把收发过程变成可回溯的日志
联调最怕的是黑匣子状态:设备回了一条异常数据,界面刷过去了,没来得及看就没了。把收发过程落盘成本地日志,出问题时翻文件就能复盘。下面是一段最小实现。
private StreamWriter _log; void InitLog(string path) { _log = new StreamWriter(path, true, Encoding.UTF8); _log.WriteLine("time,direction,data"); } void WriteLog(string direction, byte[] data) { if (_log == null) return; string hex = ToHex(data); _log.WriteLine($"{Time.time:F3},{direction},{hex}"); _log.Flush(); // 调试期每写必 Flush,防止进程崩溃丢日志 } static string ToHex(byte[] data) { var sb = new System.Text.StringBuilder(data.Length * 2); foreach (byte b in data) sb.Append(b.ToString("X2")); return sb.ToString(); }日志里存十六进制而不是直接存文本,是因为编码和换行问题还没定位时,hex 是唯一不会二次失真的格式。Flush 会降低写入性能,但调试期数据量不大,每写一条 Flush 保证日志实时落盘,程序崩溃也能保留最新几条。关闭时在 OnDestroy 里记得 Close 掉 StreamWriter,否则文件句柄不释放,下次日志写不进去。
6.3 收尾习惯
我自己的习惯是:端口名和波特率在启动时用 PlayerPrefs 记住,重开工程不用重新选;发送框保留最后一条指令,出问题时先复盘自己发了什么;设备参数区只放协议相关信息,任何和通信无关的逻辑不混进去。这套习惯是从一次次“为什么这次连不上”的排查里攒出来的,后来接手类似工程时,我也总是先做这三件事再动业务代码。串口联调本身不难,难的是把不确定性隔离清楚,希望帮到你。
本文还有配套的精品资源,点击获取