ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C#串口助手开发实战:从SerialPort到Excel自动化记录

C#串口助手开发实战:从SerialPort到Excel自动化记录 简介这是一份基于C#的串口通信助手项目面向需要实现串口收发与数据管理的C#开发者可在Visual Studio中直接打开解决方案进行编译运行。项目完整覆盖了SerialPort类初始化、波特率/数据位/停止位/校验位参数配置、DataReceived事件实时接收数据以及通过Excel存储串口数据等关键环节也适用于物联网、设备调试与自动化控制场景。压缩包共323个文件包含8个cs核心源码、Visual Studio工程配置sln/csproj以及可直接运行的exe/dll另有大量ico/png图标资源整体大小10.2MB结构清晰便于按目录检索。已有273人学习下载。通过该项目开发者不仅能掌握C#串口通信与Excel交互的完整写法还能学习到缓存文件、资源文件与辅助模块的组织方式并参考其中错误处理与日志记录的设计思路为二次开发或类似工具编写提供实用的范本。1. 串口助手为什么值得自己写一个 C# 版本调试一块单片机板子时最常见的工作流是设备通过串口吐数据电脑上开一个串口调试助手盯着看数据量一大再手动复制进 Excel非常容易漏行。这个项目用 C# 把串口收发和 Excel 存储做进一个上位机工具数据从串口进来后自动落表需要时还能把 Excel 里的数据按行重新发给设备。源码是典型的 Visual Studio 解决方案目录里除了 Form1.cs、Class1.cs还有 ResolveAssemblyReference.cache 这类编译缓存文件不用管打开 .sln 直接编译即可。它适合正在写 C# 上位机的开发者、做设备联调的嵌入式工程师以及需要搭建串口数据记录仪的测试人员。2. SerialPort 类核心参数与 DataReceived 事件的处理边界串口通信在 C# 里被 System.IO.Ports.SerialPort 封装得足够简单。所谓串口助手本质上就是把 SerialPort 的打开、参数配置、读写、事件监听四个环节拼装起来。但简单不等于没有坑尤其是 DataReceived 事件运行在后台线程很多人第一次写出来都是跨线程异常。2.1 端口枚举与参数配置打开串口之前要先知道自己要打哪个 COM 口。常见做法是用 GetPortNames() 拿到可用端口列表填充到 WinForms 的 ComboBox 里string[] ports SerialPort.GetPortNames(); // 返回当前系统可识别的串口名例如 COM3、COM5 portComboBox.Items.Clear(); foreach (string p in ports) { portComboBox.Items.Add(p); }GetPortNames() 从注册表读取插拔设备后需要重新枚举。很多调试板子使用 CH340 驱动拔插后 COM 号会变所以不要硬编码“COM3”。我一般会加一个定时器每两秒刷新一次端口列表但要在 ComboBox 下拉时暂停刷新否则用户选择会被打断。端口确定后下一步是设置通信参数下面是打开串口前最常用的一组配置serialPort1.PortName portComboBox.SelectedItem.ToString(); serialPort1.BaudRate 115200; serialPort1.DataBits 8; serialPort1.Parity Parity.None; serialPort1.StopBits StopBits.One; serialPort1.Handshake Handshake.None; serialPort1.ReadTimeout 500; serialPort1.WriteTimeout 500; serialPort1.Open();波特率必须与设备侧一致常见值为 115200 和 9600老式仪表可能用 4800 或 19200。数据位、停止位、校验位合起来构成一个通信帧格式多数嵌入式设备使用 8-N-1也就是 8 数据位、无校验、1 停止位符合 Modbus 等协议的默认要求。Handshake 默认设为 None使用 RTS/CTS 流控或者 RS485 收发切换时才会动它。除了这些参数Encoding 和 NewLine 也容易被忽略SerialPort 默认编码是 ASCII设备返回中文时需要改成 UTF-8 或 GB2312ReadLine 方法依赖 NewLine 属性默认值是 \n有些设备用 \r\n不匹配时会一直读到超时。参数常见值什么时候需要改PortNameCOM3换 USB 口或换设备记录仪时BaudRate115200设备协议写 9600 时DataBits8部分工业仪表用 7ParityNone设备手册指定 Even 或 OddStopBitsOne旧设备有特殊要求时HandshakeNone硬件流控开 RTS/CTS 时2.2 DataReceived 事件运行在后台线程SerialPort 的 DataReceived 事件在数据到达时触发但触发线程是系统线程池不是 UI 线程。如果在这个事件里直接修改 TextBox会抛出跨线程操作异常。最简单的写法是用 BeginInvoke 把更新排到 UI 线程serialPort1.DataReceived (sender, e) { string chunk serialPort1.ReadExisting(); // 读取当前缓冲区中的字符 textBoxReceive.BeginInvoke(new Action(() { textBoxReceive.AppendText(chunk); })); };ReadExisting() 会把缓冲区中剩余可见字符一次读出但不保证是完整逻辑帧。BeginInvoke 是异步方法调用后立刻返回UI 线程稍后执行委托。这个写法在小数据量时没问题数据量大时会带来高频的 UI 消息堆积导致界面卡顿后续章节会给出缓冲刷新的方案。2.3 打开串口时最常见的三类异常实际使用中串口打开失败比读写失败更常见尤其是设备热插拔或者程序异常退出之后。下面这段 try/catch 覆盖了三类典型情况可以放在 Open 方法外层try { serialPort1.Open(); } catch (UnauthorizedAccessException) { MessageBox.Show(串口被占用或没有权限); } catch (IOException) { MessageBox.Show(设备不存在或已被拔出); } catch (ArgumentException) { MessageBox.Show(COM 口参数不合法); }UnauthorizedAccessException 通常表示其他程序占用了同一个 COM 口可能是另一个串口调试助手也可能是上次程序异常退出没有释放端口。IOException 常见于设备拔出后仍尝试读写。ArgumentException 则是 COM 号不存在常见于 CH340 驱动没有正确识别设备。遇到问题先打开设备管理器看 COM 号是否还在再确认后台进程有没有残留比反复重启程序更有效。3. 从串口到 ExcelInterop 与 NPOI 两种存储方案的取舍把串口收到的数据全部堆积在 ListBox 里不是一个好习惯数据一多就会卡死后续分析也没有落点。更实用的做法是把数据写进 Excel。C# 写 Excel 有两条主流路线一条是 Microsoft.Office.Interop.Excel一条是 NPOI。两条路线解决的问题不同选错会带来很大的维护成本。3.1 Interop Excel 的数据写入与进程释放Interop 方式的本质是通过 COM 调用本机安装的 Excel 程序优点是能用代码控制图表、格式、公式缺点是依赖 Office 环境并且 COM 对象释放非常繁琐。看一个标准写入流程using Excel Microsoft.Office.Interop.Excel; Excel.Application app new Excel.Application(); app.Visible false; // 不显示 Excel 界面 Excel.Workbook workbook app.Workbooks.Add(Type.Missing); Excel.Worksheet sheet workbook.Worksheets[1]; int row 1; foreach (var record in records) { sheet.Cells[row, 1] record.Time; sheet.Cells[row, 2] record.Data; row; } workbook.SaveAs(D:\\logs\\serial_data.xlsx); workbook.Close(false); app.Quit(); System.Runtime.InteropServices.Marshal.ReleaseComObject(sheet); System.Runtime.InteropServices.Marshal.ReleaseComObject(workbook); System.Runtime.InteropServices.Marshal.ReleaseComObject(app);这里通过 Cells[row, column] 按行列写值row 从 1 开始与 Excel 的行号一致。SaveAs 时要指定完整路径如果目标文件正在被 Excel 打开会抛出权限异常所以正式代码里需要加上 try/finally。关闭工作簿时如果不希望再保存Close(false)如果已经 SaveAs 过这里传 false 是安全的。最后一步 Marshal.ReleaseComObject 负责释放 COM 引用漏掉任何一个都会导致任务管理器里残留 Excel.exe 进程这也是很多人用过 Interop 以后转向 NPOI 的原因。3.2 使用 NPOI 创建 xlsx 文件NPOI 是不依赖 Office 的纯托管库通过 NuGet 引用即可适合部署到没有办公环境的测试机。下面是用 NPOI 写入 xlsx 的完整例子using NPOI.XSSF.UserModel; using NPOI.SS.UserModel; var workbook new XSSFWorkbook(); // xlsx 工作簿 ISheet sheet workbook.CreateSheet(SerialData); // 工作表名为 SerialData int rowIndex 0; foreach (var record in records) { IRow row sheet.CreateRow(rowIndex); // 行号从 0 开始 row.CreateCell(0).SetCellValue(record.Time); row.CreateCell(1).SetCellValue(record.Data); } using (var fs new FileStream(records.xlsx, FileMode.Create, FileAccess.Write)) { workbook.Write(fs); }CreateRow 的行号从 0 开始所以读取时第一行是 sheet.GetRow(0)。SetCellValue 有多种重载可以写字符串、数字、日期。FileMode.Create 会直接覆盖同名文件如果要做增量记录必须先读取原有文件追加行后再写回。NPOI 的内存占用比 Interop 小但 XSSFWorkbook 在处理几万行时依然明显数据量极大时可以改用 CSV 或者分文件存储。3.3 两种方案的对比与选择对比项Interop ExcelNPOI是否依赖安装 Office是否实时高频写入不推荐配合批量写入可行文件格式.xls/.xlsx受版本限制HSSF 对应 .xlsXSSF 对应 .xlsx内存占用高且不可控相对可控部署难度需要 COM 注册NuGet 引用纯程序集开发时如果电脑上正好装了 Office导出少量数据用 Interop 最方便设备长时间采集数据、软件要交给测试人员使用的时候我会选择 NPOI避免因为 Office 版本差异导致部署出问题。3.4 从 Excel 读取数据重放给设备串口助手里另一个常用的操作是回放把 Excel 中保存的指令逐行读到程序里再通过串口发送给设备。NPOI 读取方式如下using (var fs new FileStream(records.xlsx, FileMode.Open, FileAccess.Read)) { var workbook new XSSFWorkbook(fs); ISheet sheet workbook.GetSheetAt(0); for (int i 0; i sheet.LastRowNum; i) { IRow row sheet.GetRow(i); if (row null) continue; // 跳过空行 string command row.GetCell(0)?.ToString(); serialPort1.WriteLine(command); Thread.Sleep(200); // 给设备留出处理时间 } }GetSheetAt(0) 取工作簿中的第一个工作表LastRowNum 是最后一个有数据的行号但中间可能有空行GetRow 返回 null 时需要跳过。发送循环里的 Thread.Sleep 延时需要根据设备响应速度调整太快设备会丢命令太慢会导致整轮回放耗时很长。另外写文件时最好把文件名按日期切分比如 records_20250115.xlsx避免单个文件越写越大也方便串口数据记录仪按天归档。4. 实战发送接收、日志记录与 UI 刷新卡顿的排查串口助手上手容易真正让程序变难用的是高频数据。在 115200 波特率下设备每 10ms 传来一帧数据DataReceived 事件就会频繁触发。如果每一帧都通过 BeginInvoke 更新 TextBoxUI 线程的消息队列会被迅速塞满表现为界面拖不动、接收区越显示越慢。要解决这个问题需要把“接收”和“显示”拆到两个不同的节奏里。4.1 接收线程只往 StringBuilder 里写接收线程的任务越轻越好。常见做法是维护一个 StringBuilder 作为中转private StringBuilder _receiveBuffer new StringBuilder(); private readonly object _bufferLock new object(); private void serialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e) { string chunk serialPort1.ReadExisting(); if (chunk.Length 0) return; lock (_bufferLock) { _receiveBuffer.Append(chunk); // 只写缓冲不碰 UI } }加锁是为了避免 UI 线程在读取缓冲时接收线程仍在写入。ReadExisting 返回的是字符串适合 ASCII 或 UTF-8 文本协议如果设备返回的是二进制帧需要用 byte[] 读取并按帧解析第五章的代码会给出对应做法。4.2 用 Timer 让 UI 以固定频率刷新显示端用一个系统 Windows.Forms.Timer每 50 毫秒从缓冲里取一次文本一次性追加到 TextBoxprivate void InitUiTimer() { var timer new System.Windows.Forms.Timer(); timer.Interval 50; // 20 FPS timer.Tick (s, e) RefreshReceiveDisplay(); timer.Start(); } private void RefreshReceiveDisplay() { string text; lock (_bufferLock) { text _receiveBuffer.ToString(); _receiveBuffer.Clear(); } if (string.IsNullOrEmpty(text)) return; textBoxReceive.AppendText(text); string[] lines textBoxReceive.Lines; if (lines.Length 2000) { string[] newLines new string[2000]; Array.Copy(lines, lines.Length - 2000, newLines, 0, 2000); textBoxReceive.Lines newLines; } }无论串口数据来的多快UI 刷新频率都被限制在每秒 20 次这是解决卡顿的核心。限制行数到 2000 行是为了避免 TextBox 内部文本无限增长导致的绘制变慢。这里有一个细节TextBox.Lines 属性在文本很大的时候会触发一次完整的字符串拆分所以把限制行数放在 AppendText 之后立刻做比放到下次 Tick 更可控。4.3 把日志写入 Excel 放到后台在缓冲方案的基础上写日志文件或 Excel 也走同一条缓冲区。判断数据已经积累到阈值时把列表快照拿出来交给后台任务写入避免磁盘 IO 阻塞 UIif (records.Count 100) { ListRecord snapshot; lock (recordsLock) { snapshot new ListRecord(records); records.Clear(); } Task.Run(() ExcelWriter.AppendRows(snapshot)); }这里用锁保证快照和清空是原子的但注意不能一直持有锁去写文件。AppendRows 内部需要保证同一时间只有一个任务写同一个 xlsx 文件最简单的方式是在方法内部加一个 static lock或者把写入任务放到一个单线程队列里。否则多个后台线程同时打开文件会触发文件占用异常。4.4 高频调试中的故障排查现象原因处理串口打开失败提示被占用其他程序占用了 COM 口关闭并释放检查进程残留COM 口在设备管理器消失USB 转串口驱动异常重装 CH340/FTDI 驱动收到的数据是乱码波特率或数据格式不一致核对设备手册尝试 9600 与 115200指令发出后设备没响应TX/RX 接反或没有共地交叉连接 TX/RX确认 GND 相连UI 一卡一卡高频 BeginInvoke 使消息队列堆积改用 Timer 批量刷新程序崩溃后 COM 口不能再用SerialPort 没有释放在窗体关闭事件里 Close 并 Dispose串口烧写失败是另一个常见现象多数原因是烧录软件占用了同一 COM 口或者设备没有进入 Boot 模式。排查原则是先把占用串口的软件全部退出再检查设备管理器中的端口号是否变化最后看设备上的启动引脚状态。5. 给串口助手加一个按帧解析的缓冲拼接技巧很多设备不会一次把完整帧发完DataReceived 每次只给一段字节。一个常见的坑是收到前半帧就按协议解析发现长度不够直接丢弃下一次后半帧又对不上。正确做法是把所有字节先追加到一个缓冲区再循环从缓冲区里截出完整帧。这个技巧对 Modbus、私有二进制协议、仪器仪表的数据采集都通用。public class FrameParser { private Listbyte _buffer new Listbyte(); public void Append(byte[] data) { _buffer.AddRange(data); } public bool TryReadFrame(byte frameHeader, out byte[] frame) { frame null; while (_buffer.Count 0) { int headerIndex _buffer.IndexOf(frameHeader); if (headerIndex 0) { _buffer.Clear(); // 没有帧头缓冲区已不可用 return false; } if (_buffer.Count headerIndex 2) return false; // 帧长度字节还没凑齐 int frameLength _buffer[headerIndex 1]; if (frameLength 0 || _buffer.Count headerIndex 2 frameLength) return false; // 数据不完整等下一次 Append frame _buffer.GetRange(headerIndex, 2 frameLength).ToArray(); _buffer.RemoveRange(0, headerIndex 2 frameLength); return true; } return false; } }TryReadFrame 的解析顺序是先找帧头再读帧长然后判断缓冲区里是否已经有完整长度的数据。如果有了就把这一帧切出来并移除同时继续在 while 循环里解析下一帧这样一次能解出多帧。frameLength 是第二个字节这种协议结构在工业设备里很常见如果项目用的协议没有长度字段就把判断条件改成固定长度。查找帧头用 IndexOf如果协议帧头是两位字节例如 0xAA 0x55可以自己写一个 FindHeader 方法先找 0xAA 再确认下一个字节是 0x55。使用方式放在 DataReceived 事件里byte[] data new byte[serialPort1.BytesToRead]; serialPort1.Read(data, 0, data.Length); // 读二进制数据 _parser.Append(data); while (_parser.TryReadFrame(0xAA, out byte[] frame)) { HandleFrame(frame); // 解析一帧完整数据准备写入 Excel }这里用 Read 而不是 ReadExisting因为 ReadExisting 返回字符串会把原始字节按编码转换二进制帧经过转换后可能直接损坏。HandleFrame 里可以按字段拆分数据再把拆好的结果交给第四章的日志写入模块保存到 Excel。遇到帧头被拆成两次 DataReceived 时缓冲区会保留不完整数据等下一次 Append 后继续拼接这样就彻底规避了断帧问题。如果设备断电或线路噪声导致缓冲区出现大量无效数据TryReadFrame 会在找不到帧头时清空缓冲区让设备重新连接后从头开始同步。本文还有配套的精品资源点击获取
返回列表