用C#和.NET Framework 4.5打造串口发送工具
做上位机开发的人尤其是刚接触工控这一行的兄弟一定有过这种经历设备端已经调好了想快速验证一下通信协议结果翻遍电脑找不到一个顺手的串口调试工具要么下载的工具不支持新系统要么界面乱七八糟连发送计数都没有。于是干脆自己动手用 Visual Studio 2019 和 C#基于 .NET Framework 4.5 写一个简易的串口发送数据软件。这个项目不复杂认真写一遍从建工程到能用基本小半天就能搞定。但代码背后涉及的串口参数配置、编码转换、UI 线程同步、异常处理这些细节才是真正决定工具好不好用的地方。这篇文章把我的开发思路、核心代码和踩过的坑都整理出来适合刚学 C# 或者准备自己做上位机调试工具的开发者参考。1. 项目概述与整体设计思路1.1 需求拆解这个软件到底要做什么先讲清楚目标我要做的是一个能运行在 Windows 上的串口发送工具主要用途是向 RS232、RS485 设备发送指令数据比如单片机、PLC、各种传感器模块。核心功能可以砍成三块选择串口、配置通信参数、发送自定义数据。听起来简单但“简易”不等于“随便”。我在动工之前列了一个功能清单照着清单做就不会漏东西自动列出当前系统可用的串口号可配置波特率、数据位、停止位、校验位支持文本发送和 Hex 发送两种模式打开/关闭串口的按钮和状态提示发送按钮旁边带一个发送次数计数出现异常时给出明确提示而不是程序直接崩溃这个清单不是拍脑袋写的而是来自实际调试时的痛点很多现成工具要么不支持 Hex 发送要么没有计数功能调试时根本不方便确认指令是否发出去了。作为一个开发者自己动手能把“顺手”做到极致。1.2 技术选型背后的考量选 VS2019 和 .NET Framework 4.5 不是随手点的。当时考虑几点第一目标机器往往还是工控机或者老笔记本操作系统可能是 Win7 或者 Win10 的旧版本.NET Framework 4.5 兼容性很好系统自带或离线包安装都很容易第二串口操作本质上是调用系统底层驱动.NET Framework 的 System.IO.Ports.SerialPort 类封装得很成熟稳定度足够第三VS2019 还保留了完整的 .NET Framework 4.x 窗体应用模板用起来顺手。有人会问现在都往 .NET 6、.NET 8 上走了为什么还用 4.5原因很实在工控现场往往“能用就行”。不少老设备厂商提供的动态库、驱动组件还是基于 .NET Framework 4.x 编译的直接用 4.5 目标框架能少踩很多依赖坑。另外SerialPort 在 .NET Framework 和 .NET Core 中的行为差异不大但 4.5 的资料和案例更多遇到问题更容易搜到答案。项目体量这么小性能不是瓶颈稳定性优先。打个比方串口通信就像两个人打电话波特率是说话语速数据位、停止位、校验位是双方约定的语言规则。SerialPort 类只是帮你拨号、递话筒真正的沟通规则得自己定义。2. 环境准备与工程创建2.1 开发环境清单开发环境其实不复杂三样东西就够Visual Studio 2019随便哪个版本社区版就行Windows 操作系统Win10 上开发没有任何问题生成的目标程序也可以跑在 Win7 以上.NET Framework 4.5 目标包。VS2019 默认自带 .NET Framework 4.5 引用程序集但部分精简安装版本需要单独装一下怎么确认新建项目时如果框架下拉框里能看到 .NET Framework 4.5就没问题如果看不到去 Visual Studio Installer 的“单个组件”里勾选对应版本。还要准备什么强烈建议准备一个 USB 转串口模块。要是没有实体设备也可以先用虚拟串口工具创建一对互联的 COM 口配合串口助手做回环测试。开发阶段用虚拟串口能避免一大堆硬件接线的坑实际项目验证的时候再上真设备。2.2 创建项目的完整步骤在 VS2019 中的创建路径是启动 VS2019选择“创建新项目”项目类型选“Windows 窗体应用(.NET Framework)”注意不是不带 Framework 后缀的“Windows 窗体应用”后者创建的是 .NET Core 或 .NET 5 项目。项目名称可以叫 SerialTool位置随意点“下一步”后在框架下拉框中选中 .NET Framework 4.5最后点“创建”。创建完成后会自动生成 Form1.cs 和 Form1.Designer.cs。很多人上手就拖控件我自己的习惯是先打开 Form1.cs想清楚窗体上控件命名规则。不是矫情而是后续写事件处理时会非常舒服。比如文本框命名 txtSend按钮命名 btnOpen串口组件名保持默认 serialPort1 也没关系只要代码里能对应上。还需要补充一句因为目标框架是 4.5很多新版本 NuGet 库可能不支持但这个项目不需要任何外部包纯靠 .NET Framework 自带功能就能完成所以不存在依赖上的麻烦。3. 界面设计与串口参数配置3.1 窗体布局控件少而够用这个工具不需要花哨的界面我最后把窗体分成了三个区域连接区、发送区、状态区。连接区放两个下拉框和一个按钮串口选择、波特率选择、打开/关闭按钮。状态区放一个标签显示串口是否打开、错误信息等。发送区最核心。一个多行文本框用来输入要发送的内容一个单选或者下拉框切换文本和 Hex 模式一个复选框决定是否自动追加回车换行一个发送按钮再加一个发送次数标签。控件之间留点间距防止现场误点字号稍微调大一点工控现场经常操作界面的人年纪不小字体不能太袖珍。我建议这个阶段不要引入第三方界面库因为串口工具的界面优先级远低于稳定性。WinForms 自带的控件已经足够先保证功能不出错以后要美化再做也不迟。3.2 获取串口列表和初始化下拉框获取可用串口的代码很简单调用 SerialPort.GetPortNames() 就能拿到当前系统识别到的所有 COM 口号。但要注意这个方法不会区分 USB 转串口和原生串口只能说明系统中存在这个名字。举个例子private void LoadComPorts() { string[] ports SerialPort.GetPortNames(); cboComPort.Items.Clear(); Array.Sort(ports, (a, b) { int numA int.Parse(a.Replace(COM, )); int numB int.Parse(b.Replace(COM, )); return numA.CompareTo(numB); }); cboComPort.Items.AddRange(ports); if (cboComPort.Items.Count 0) cboComPort.SelectedIndex 0; else cboComPort.Items.Add(无可用串口); }这里有个排序坑GetPortNames 返回的顺序不一定按数字排字符串排序下 COM10 会排在 COM2 前面所以我把“COM”去掉再转成 int 排序。如果不管这个细节用户选择串口时就会很困惑。波特率下拉框可以直接在窗体设计器里把常用值填进去。我一般填这些1200、2400、4800、9600、19200、38400、57600、115200默认选中 9600。建议把下拉框设为可编辑这样遇到非标波特率能手动输入有些特殊模块 250000 之类的非标值就很需要用得上。3.3 串口参数与 SerialPort 属性对照界面上的几个参数最终要映射到 SerialPort 对象上。SerialPort 的关键属性有这些PortName 是字符串比如 COM3BaudRate 是整数波特率DataBits 是数据位数默认 8Parity 是校验枚举有 None、Even、Odd、Mark、SpaceStopBits 是停止位枚举有 One、Two、OnePointFive。标准设置通常是 8 数据位、1 停止位、无校验也就是常说的 8N1。如果设备手册要求“偶校验”就要设 Parity.Even。要注意 StopBits 和 Parity 是枚举类型不能直接塞数字。我一般不在 UI 里直接绑定枚举而是通过 ComboBox 的选中项做 switch 赋值代码直观而且不容易因为枚举名不匹配出错。DataBits 常规用 8特殊情况才用 7。StopBits 多数选 1也有设备要求 21.5 很少见。把这些做成下拉框再对照设备手册选就完事了。4. 核心代码实现打开、关闭与发送4.1 打开串口时要注意什么打开串口就是 serialPort1.Open()但它不是一行能应付完的。必须先保证所有参数都设置好再调用 Open。如果先 Open 再改 BaudRate部分驱动会重置串口导致异常。第二点要防止重复打开按钮点击后先判断 IsOpen已经有值就提示。第三点Open 可能抛异常常见的有端口不存在、端口被其他程序占用、权限不足必须要用 try-catch 包起来。我习惯把打开和关闭都写成独立方法方便复用private void btnOpen_Click(object sender, EventArgs e) { if (serialPort1.IsOpen) { CloseSerialPort(); } else { OpenSerialPort(); } UpdateUIState(); } private void OpenSerialPort() { try { serialPort1.PortName cboComPort.Text; serialPort1.BaudRate int.Parse(cboBaudRate.Text); serialPort1.DataBits int.Parse(cboDataBits.Text); serialPort1.StopBits GetStopBits(cboStopBits.Text); serialPort1.Parity GetParity(cboParity.Text); serialPort1.Open(); lblStatus.Text 串口已打开; } catch (UnauthorizedAccessException) { MessageBox.Show(串口被占用请关闭其他软件后重试。); } catch (IOException ex) { MessageBox.Show(串口打开失败 ex.Message); } catch (Exception ex) { MessageBox.Show(未知错误 ex.Message); } }关闭之前要先把定时发送的 Timer 停掉再执行 Close不然可能会在关闭过程中触发发送异常。养成这个习惯能少很多莫名其妙的报错。4.2 文本发送和 Hex 发送的本质区别“发送”按钮看起来简单实际有两个方向文本发送和 Hex 发送。文本发送就是把输入框里的字符串按指定编码转成字节数组比如英文数字组合 AT 转成 0x41 0x54。SerialPort.Write(string) 这个重载会按 SerialPort.Encoding 属性编码后写入默认是 ASCII。如果设备需要 GB2312 中文打开串口前要设置一下serialPort1.Encoding Encoding.GetEncoding(GB2312);Hex 发送则是另一个思路用户输入的是十六进制字符串比如 AA 55 01 02 03不能直接写进串口得先解析成字节数组再调用 Write(byte[], int, int)。这里常见坑是用户输入格式不统一有的带空格有的没空格有的带 0x 前缀有的甚至混了全角字符。我写了一个兼容性还行的解析函数private byte[] HexStringToBytes(string hex) { hex hex.Replace(0x, ).Replace(0X, ); hex hex.Replace( , ).Replace(\t, ).Replace(\r, ).Replace(\n, ); if (hex.Length % 2 ! 0) throw new Exception(十六进制长度必须为偶数); 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); } return bytes; }注意 Convert.ToByte 遇到非法字符会抛 FormatException所以实际调用时一定要 try-catch。我遇到过一次用户输入了“GG”这种字符程序直接崩溃后来再也不敢省略容错。看起来是小事现场却很致命。4.3 发送按钮的完整逻辑发送按钮的点击事件我做了这几件事先判断串口是否打开没打开就弹提示。然后根据单选按钮的状态去走文本模式还是 Hex 模式再判断是否要追加回车换行。调用发送方法之后更新发送计数。参考代码private void btnSend_Click(object sender, EventArgs e) { if (!serialPort1.IsOpen) { MessageBox.Show(请先打开串口); return; } string content txtSend.Text.Trim(); if (content.Length 0) return; try { if (rdoHex.Checked) { byte[] data HexStringToBytes(content); if (chkNewLine.Checked) data data.Concat(new byte[] { 0x0D, 0x0A }).ToArray(); serialPort1.Write(data, 0, data.Length); } else { string text content; if (chkNewLine.Checked) text \r\n; serialPort1.Write(text); } sendCount; lblSendCount.Text 发送次数 sendCount; } catch (Exception ex) { MessageBox.Show(发送失败 ex.Message); } }这些都是同步阻塞写数据量小完全够用。如果以后要做大数据量发送才需要考虑 WriteAsync 或者后台线程这里先提个醒免得有人照抄以后自己挖坑。4.4 关于 UI 线程和定时发送的一个提醒如果加“定时发送”功能最稳妥的是用 WinForms 的 Timer 控件而不是 while 循环加 Thread.Sleep。Timer 的 Tick 事件在 UI 线程触发不会造成跨线程访问控件的问题。用 Thread.Sleep 不仅卡界面定时也不准。我早期犯过一个错为了“发得快”开了一个后台线程循环写串口结果和 UI 交互各种冲突最后只能老老实实回 Timer。定时发送的间隔建议设成 50ms 的倍数太短对普通串口没什么意义还会增加系统负担。还要考虑波特率9600 波特率下一个字节按 1 起始位 8 数据位 1 停止位计算大约需要 10/9600 ≈ 1.04 毫秒如果要发 20 字节至少间隔 20ms 以上否则数据在驱动层排队看起来发完了实际时序已经乱了。5. 常见问题与排查技巧实录5.1 串口打开失败先查占用再查驱动“COM3 打不开”“串口被占用”是最高频的问题。我的排查顺序是先打开设备管理器看端口下面的 COM 口是否存在如果存在但报占用再用进程工具看一下哪个进程占用了串口常见的占用大户是各类串口调试助手、PLC 编程软件、甚至杀毒软件。如果设备管理器里根本没有这个 COM 口那就是 USB 转串口驱动没装好重新插拔或者换驱动版本。还有一种隐蔽情况有些主板板载串口被 BIOS 禁用或者在系统里显示为 COM1实际没有物理连接。这种情况下 GetPortNames 能拿到 COM1Open 却会超时或报错这种问题只能确认硬件代码没法解决。5.2 发送乱码编码问题先于硬件问题收到方乱码很多人第一反应是硬件接线问题。实际上软件编码不对才常见。如果设备只支持 ASCII但 SerialPort.Encoding 里设了 UTF-8中文数据直接就错了。如果是 GB2312 设备默认 ASCII 编码又可能丢高位字符。一定要先查设备手册确定目标编码再看 Hex 模式下有没有前导 0 缺失。比如输入 AA 5解析时长度是奇数直接报错但如果是 A 55解析出来的字节是 0xA5、0x50和期望的 0x0A 0x55 完全不一样。这种只有靠调试的时候多看收到的字节才能发现。5.3 界面卡死Invoke 是跨线程操作的唯一正解如果加入了接收显示功能SerialPort.DataReceived 事件是在后台线程触发的直接在这个事件里修改 Label 或 TextBox 会抛 InvalidOperationException。解决办法是用 Control.Invoke 或者 BeginInvoke 把 UI 更新封送到 UI 线程private void serialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e) { string data serialPort1.ReadExisting(); this.Invoke((MethodInvoker)delegate { txtReceive.AppendText(data); }); }有人图省事用 Application.DoEvents 来刷新界面在小工具里可能能跑但代码非常难维护。Invoke 不是银弹如果事件触发频率太高Invoke 会造成界面响应延迟可以考虑阻塞队列加定时刷新。但简易工具里Invoke 已经足够。5.4 发送不完整和数据粘包串口发送不完整多数原因是没等数据写完就关闭了串口或者发送缓冲区被下一帧覆盖。SerialPort.Write 是同步方法数据会写入驱动缓冲区如果硬件流控没接好RTS/CTS 线没连通少量数据也可能发不出去。解决方法是检查设备是否启用流控如果传输线只有 TX、RX、GND必须把 HardwareHandshake 关掉。数据量大时建议合理设置 WriteBufferSize但不要把所有数据一股脑交给缓冲区不管尤其是多线程场景那样很容融出粘包问题。5.5 没有硬件怎么调试开发阶段完全可以不接设备。用虚拟串口工具创建一对互联的虚拟 COM 口比如创建 COM3 和 COM4然后你的程序选 COM4另一个串口助手选 COM3就能在电脑上完成回环测试。这个方案极大提高开发效率。但注意虚拟串口只能验证软件逻辑不能模拟真实设备的时序最终还是要实体设备联调。现场常见的“发送了没反应”可能原因很多地址码错误、CRC 校验不对、波特率其实没完全匹配比如设备要求 9600 偶校验你配的是无校验表面上能打开串口实际收发完全乱套。6. 从“发送玩具”到“可用工具”的扩展建议6.1 加入接收显示做成双向调试助手标题只写“发送数据”但实际调试你不可能只看发送不管回复。最简单的扩展是打开串口时订阅 DataReceived 事件把收到的字节追加到只读文本框再加一个清空按钮。如果要显示 Hex必须用 Read(byte[], int, int) 方式读取原始字节再转字符串因为 ReadExisting 返回的是已经按 Encoding 解码过的 string看不到原始字节。转换代码也很直接private string BytesToHex(byte[] buffer, int count) { StringBuilder sb new StringBuilder(); for (int i 0; i count; i) { sb.Append(buffer[i].ToString(X2)); sb.Append( ); } return sb.ToString().Trim(); }6.2 记住上次参数配置文件的懒办法每次打开软件都要重新选端口和波特率很烦。最简单的做法是写一个配置文件存到软件目录下启动时读取关闭时保存。也可以直接使用 Properties.SettingsVS 会自动生成配置字段部署时注意文件位置就好。我更喜欢把配置序列化成 XML格式清楚下次改起来方便。尤其是串口号、波特率、文本/Hex 模式、是否追加回车换行这几项保存下来工具体验能提升一整档。6.3 加上 CRC 校验从玩具变成可对接协议的工具很多设备的通信报文最后都带校验字节比如 Modbus 的 CRC16或者简单的 LRC 校验。在发送前自动计算并把校验值附加到报文尾部这个功能在对接工业设备时特别有用。CRC16 的实现代码网上很多关键是确认多项式、初始值和高低字节序。调试时如果设备一直无响应十有八九是高字节和低字节反了。这类问题代码很简单但排查特别花时间所以扩展时尽量把校验算法做成可通过界面选择的模式而不是写死一种。6.4 常见问题速查表下面把串口开发中最常遇到的问题整理成一张速查表方便直接对照现象可能原因解决办法端口列表为空USB转串口驱动未装安装驱动后重新插拔打开端口报“拒绝访问”端口被其他程序占用关闭占用程序或重启电脑发送内容乱码编码设置错误按设备手册设置 SerialPort.EncodingHex数据解析报错输入了非法字符做输入校验过滤空格和0x前缀收不到回复接线错误或流控设置检查TX/RX/GND关闭硬件流控界面假死在后台线程直接操作UI使用 Invoke 或 Timer 刷新6.5 发布部署的小细节.NET Framework 4.5 程序在目标机器上不一定需要额外安装完整框架但 Win7 上可能需要补丁。发布时可以用 VS 的发布功能生成安装包或者直接把 Release 目录下的 exe 复制过去。如果目标机器是 32 位系统编译目标平台要选 x86如果是 64 位选 AnyCPU 或 x64 都行。还有一个容易被忽略的问题杀毒软件可能会拦截串口程序。发布时最好给 exe 签名或者加入白名单否则现场装完双击没反应排查半天还以为是代码写错了那就太冤枉了。实际做完这个工具我个人最大的体会是串口编程的门槛其实很低但“可用”和“好用”之间差的都是细节。UI 控件命名、Hex 输入容错、异常捕获粒度、配置记忆这些在刚写完代码时都看不到等拿到现场用两天差距就出来了。如果后续想继续深入可以从 Modbus 协议解析、多串口并发、波形显示这些方向往下走那才是真正的“调试平台”。但小步快跑先把一个发送功能做到顺手、稳定、可靠同样是值得好好做的一件事。这些话不算什么总结是踩了几年串口坑之后的一点实在建议希望对你有用。