☰
用C#和.NET Framework 4.5打造串口发送工具
2026/10/15 7:44:31 网站建设 项目流程

做上位机开发的人,尤其是刚接触工控这一行的兄弟,一定有过这种经历:设备端已经调好了,想快速验证一下通信协议,结果翻遍电脑找不到一个顺手的串口调试工具;要么下载的工具不支持新系统,要么界面乱七八糟,连发送计数都没有。于是干脆自己动手,用 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 是字符串,比如 "COM3";BaudRate 是整数波特率;DataBits 是数据位数,默认 8;Parity 是校验枚举,有 None、Even、Odd、Mark、Space;StopBits 是停止位枚举,有 One、Two、OnePointFive。

标准设置通常是 8 数据位、1 停止位、无校验,也就是常说的 8N1。如果设备手册要求“偶校验”,就要设 Parity.Even。要注意 StopBits 和 Parity 是枚举类型,不能直接塞数字。我一般不在 UI 里直接绑定枚举,而是通过 ComboBox 的选中项做 switch 赋值,代码直观而且不容易因为枚举名不匹配出错。

DataBits 常规用 8,特殊情况才用 7。StopBits 多数选 1,也有设备要求 2,1.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 能拿到 COM1,Open 却会超时或报错,这种问题只能确认硬件,代码没法解决。

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.Settings,VS 会自动生成配置字段,部署时注意文件位置就好。

我更喜欢把配置序列化成 XML,格式清楚,下次改起来方便。尤其是串口号、波特率、文本/Hex 模式、是否追加回车换行,这几项保存下来,工具体验能提升一整档。

6.3 加上 CRC 校验:从玩具变成可对接协议的工具

很多设备的通信报文最后都带校验字节,比如 Modbus 的 CRC16,或者简单的 LRC 校验。在发送前自动计算并把校验值附加到报文尾部,这个功能在对接工业设备时特别有用。CRC16 的实现代码网上很多,关键是确认多项式、初始值和高低字节序。调试时如果设备一直无响应,十有八九是高字节和低字节反了。这类问题代码很简单,但排查特别花时间,所以扩展时尽量把校验算法做成可通过界面选择的模式,而不是写死一种。

6.4 常见问题速查表

下面把串口开发中最常遇到的问题整理成一张速查表,方便直接对照:

现象可能原因解决办法
端口列表为空USB转串口驱动未装安装驱动后重新插拔
打开端口报“拒绝访问”端口被其他程序占用关闭占用程序或重启电脑
发送内容乱码编码设置错误按设备手册设置 SerialPort.Encoding
Hex数据解析报错输入了非法字符做输入校验,过滤空格和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 协议解析、多串口并发、波形显示这些方向往下走,那才是真正的“调试平台”。但小步快跑,先把一个发送功能做到顺手、稳定、可靠,同样是值得好好做的一件事。这些话不算什么总结,是踩了几年串口坑之后的一点实在建议,希望对你有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询