简介:VB.NET编写的串口助手源码包,适合正在学习VB.NET串口通信或需要自制串口调试工具的开发者。相比网上常见的AXcom控件方案,作者选用SerialPort类实现,并处理了委托与线程的关系,代码风格简洁,亲测可用,兼具参考性与实用性。资源共37个文件,包括6个vb源码文件、5个config配置文件、4个exe可执行程序、2个txt说明文档,以及pdb调试符号、resx资源文件、sln工程文件等,压缩包整体仅181KB,结构清晰,便于直接打开和对比学习。已有519人学习。通过这份资源,读者可以完整查看窗体设计、SerialPort配置与数据收发逻辑,理解跨线程更新UI的委托写法,还可以直接运行exe体验功能。包内还保留了App.config、My Project等工程配置,适合用Visual Studio打开后边看边改,快速搭建属于自己的串口助手,也可作为课程设计或工具开发的起步模板。 从大学第一次调单片机开始,我就对串口助手这类工具有一种又爱又恨的感觉。爱的是它们能让我直接看到设备吐出来的数据,恨的是市面上那些串口调试助手总是差那么一点意思——要么界面老气横秋,要么收发数据时偶发卡顿,想加个自动保存日志和CRC校验功能还得付费。
后来用VB.NET自己动手写了一个串口助手,从串口参数配置、十六进制收发,到数据缓存、自动发送、日志记录,全部都按自己的使用习惯来。这个项目做下来,不仅解决了我调试设备的实际痛点,还把我对.NET中SerialPort控件、多线程委托、编码处理的理解往上拉了一大截。这篇文章我就把整个开发过程和踩过的坑完整记录下来,想自己写一个串口助手的朋友可以直接照着做,也可以在这个基础上继续扩展成更适合自己的调试工具。
1. 为什么放着现成的串口调试工具一大堆,我还是要自己写一个
1.1 现成工具的问题:功能过剩或者功能缺失
市面上能下载到的串口调试助手非常多,从SSCOM、XCOM,到各种"友善版""常兴版",基本功能都差不多——选择串口号、配置波特率、打开串口、发送接收数据、十六进制显示。
但这些工具用久了你就会发现几个问题:
- 免费版往往夹带弹窗广告,装到工控电脑上非常碍眼,万一误点还会装上一堆全家桶。
- 有些工具功能做得很全,但界面密密麻麻全是按钮和输入框,找自己要用的那个功能还得上下摸索半天。
- 更关键的是,现成工具很难针对你自己的项目做定制。比如我经常测试带CRC16校验的Modbus协议,每次都要先在外部计算器里算出校验码再拼到发送帧里,步骤繁琐还容易出错。我就想把这个校验功能直接集成到串口助手的发送区里,这在大多数现成工具里根本做不到。
还有一点很现实:做嵌入式或单片机开发时,采样数据往往是高频、连续、带时间戳的,现成工具要么没有时间戳,要么导出格式乱七八糟。这些定制需求,才是推动我自己动手的核心动力。
1.2 自己写的好处:代码可控、定制自由、顺便练技术
自己开发串口助手,最大的好处就是"工具长在我手上"。
一开始我只需要一个能收发串口数据的小工具,但随着项目深入,我逐渐在它上面加了这些功能:
- 自定义快捷发送区,把常用的AT指令、Modbus帧保存成预设按钮,点击即发送。
- 接收数据显示时间戳,方便定位数据流中某条协议报文出现的时刻。
- 自动保存完整日志,崩溃或关掉之后也不怕丢失现场数据。
- 集成CRC校验计算,发Modbus帧时自动把校验字节追加到报文尾部。
- 发送间隔可配置,用来测试设备在大流量冲击下是否会丢帧。
这些需求如果靠现成工具,可能需要同时开好几个软件配合。但自己写一个就是一个窗体的事。而且用VB.NET做WinForms开发,拖控件、写事件处理器,说实话门槛真不高,很适合作为.NET桌面开发入门到进阶的实战项目。
2. 串口通信到底在做什么:速率、数据位、校验位背后的原理
好多新手在界面上把波特率、数据位、校验位、停止位一顿乱填,然后发现设备不上数,就开始怀疑代码有问题。其实串口参数的本质是收发双方的通信协议,两边设置不一致,物理层就直接对不上了。
2.1 串口参数的逻辑:9600、8、N、1 到底代表什么
我们在配置串口时经常看到"9600, 8, N, 1"这串数字,它分别代表波特率、数据位、校验位、停止位。
- 波特率:每秒传输的比特数。9600代表每秒钟传输9600个bit,实际去掉起始位、停止位、校验位后,数据吞吐量大约是960字节/秒。如果设备端是115200,你用9600去打开,收到的基本就是乱码。
- 数据位:一个字节数据用几位来表示,常见的是8位,也有一些老旧设备用7位。8位数据位时可以完整表示0~255的字节值。
- 校验位:用于检测传输过程中是否发生比特错误,常见的包括None(无校验)、Odd(奇校验)、Even(偶校验)。现在大多数场景直接用None,因为校验位只能查错不能纠错,如果数据完整性要求高,一般会在协议层用CRC校验而不是串口校验位。
- 停止位:每个字节传输结束后的停止信号长度,常见的是1位或2位。
我用一个生活化类比来理解:串口通信就像两个人隔着一条马路用对讲机说话。波特率决定了两个人说话的语速必须一致;数据位是每句话要说几个字;校验位就像在每句话后面加一个"重复一遍关键数字"的机制;停止位则是"我说完了,换你"的提示音。任何一项设置不一致,对话就进行不下去。
在VB.NET中,这些参数在SerialPort控件上都是直接暴露的:
With SerialPort1 .PortName = "COM3" .BaudRate = 115200 .DataBits = 8 .Parity = Parity.None .StopBits = StopBits.One .Handshake = Handshake.None .ReadTimeout = 500 .WriteTimeout = 500 End With2.2 数据的两种形态:ASCII字符与十六进制字节
串口传输的数据本质上是一串字节(Byte),每个字节是0~255之间的数值。但在调试界面里,这些字节有不同展现方式:
- ASCII显示:把每个字节当成一个字符显示。比如设备返回0x31、0x32、0x33,ASCII模式下显示成"123"。
- 十六进制显示:把每个字节显示成两位十六进制数,比如0x31 0x32 0x33显示成"31 32 33"。
对于只想阅读设备状态文本的场景,比如GPS模块输出的NMEA语句,直接用ASCII模式就很直观。但对于Modbus、自定义二进制协议这种面向字节流的通信,必须用十六进制模式才能看清每一帧的原始数据。
我在界面里做了一个RadioButton切换接收和发送的显示格式。发送时以字节数组为基准,如果是十六进制模式,就把用户输入的"AA BB CC"字符串解析成对应字节;如果是ASCII模式,就直接把字符串按编码转成字节。接收时则反过来。理解了"一切皆字节"这条主线,后面代码就不容易写乱。
3. 界面设计:一个顺手好用的串口助手,布局才是灵魂
先别急着写串口代码,工具类软件最重要的是"好用"两个字。我第一版界面随手乱摆控件,用起来总觉得别扭。后来重构了一版,才明白界面布局直接影响调试效率。
3.1 核心区域划分:连接区、接收区、发送区
我把窗体分成三个核心区域,分界线很清楚:
- 顶部连接区:串口号下拉框、波特率下拉框、打开/关闭串口按钮、刷新串口按钮。
- 中间接收区:带滚动条的TextBox(或者RichTextBox),显示所有接收到的数据,旁边放清空按钮和显示格式选项。
- 底部发送区:发送内容输入框、发送按钮、定时发送勾选框和间隔设置,以及快捷发送按钮区。
顶部连接区要紧凑,不要占用太多垂直空间,因为接收区才是调试时主要盯着的区域。接收区我用TextBox,设置了Multiline、ScrollBars为Vertical、ReadOnly为True,同时把Font设置为等宽字体(比如Consolas),这样十六进制数据显示出来每一列都对齐,肉眼对比数据时舒服很多。
发送区和接收区之间用SplitContainer分隔,方便用户根据当前数据量随时调整上下区域的高度。这个细节我强烈建议加上,因为当协议数据帧长度差别很大时,固定比例布局会很别扭。
3.2 用ComboBox做动态串口枚举的几个细节
串口列表不能写死在界面上,否则插拔USB转串口设备后下拉框就是哑的。我用SerialPort.GetPortNames()获取当前系统里的串口列表,在窗体加载时和点击"刷新"时调用。
这里有个小坑:GetPortNames()返回的是"COM1"、"COM3"这样的字符串格式,但顺序是随机的。我按数字部分排了个序,不然COM10会排在COM2前面,看起来乱糟糟的。
Private Sub RefreshPortList() Dim ports As String() = SerialPort.GetPortNames() Array.Sort(ports, Function(a, b) Dim na As Integer = Integer.Parse(a.Replace("COM", "")) Dim nb As Integer = Integer.Parse(b.Replace("COM", "")) Return na.CompareTo(nb) End Function) cboPort.Items.Clear() cboPort.Items.AddRange(ports) If ports.Length > 0 Then cboPort.SelectedIndex = 0 End If lblComState.Text = "串口未打开" End Sub设备插上但列表里没出现新串口,绝大多数情况是USB转串口驱动没装好。在设备管理器里看不到COM口时,优先处理驱动而不是改代码。
4. 核心实现:SerialPort控件的打开、接收与发送
界面准备好了,下面就是整个项目的核心:串口通信逻辑。
4.1 串口初始化与开关控制
我直接在窗体上拖了一个SerialPort控件,省得手动实例化传参数。打开串口时先检查是否已经打开,避免重复Open抛异常:
Private Sub btnOpen_Click(sender As Object, e As EventArgs) Handles btnOpen.Click If SerialPort1.IsOpen Then SerialPort1.Close() btnOpen.Text = "打开串口" btnSend.Enabled = False lblComState.Text = "串口已关闭" Return End If Try SerialPort1.PortName = cboPort.Text SerialPort1.BaudRate = CInt(cboBaud.Text) SerialPort1.Parity = CType(cboParity.SelectedIndex, Parity) SerialPort1.StopBits = CType(cboStopBits.SelectedIndex, StopBits) SerialPort1.DataBits = CInt(cboDataBits.Text) SerialPort1.Open() SerialPort1.DiscardInBuffer() SerialPort1.DiscardOutBuffer() btnOpen.Text = "关闭串口" btnSend.Enabled = True lblComState.Text = "串口已打开: " & cboPort.Text & " | " & cboBaud.Text Catch ex As Exception MessageBox.Show("打开串口失败:" & ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Warning) End Try End Sub这里有个易忽略的小地方:ComboBox校验位和停止位的下拉项要小心和枚举对应。我用SelectedIndex直接转换枚举,所以下拉项的顺序必须与Parity枚举、StopBits枚举一致,否则打开串口时参数会是错的。
4.2 DataReceived事件与跨线程更新UI的Invoke问题
这是串口开发里最核心、最容易翻车的部分。SerialPort的DataReceived事件是在后台辅助线程触发的,不是UI线程。如果你在事件里直接写:
txtReceive.Text &= "some data"程序不会报错,但UI可能闪烁、卡顿甚至崩溃,因为TextBox并不允许后台线程直接操作。
正确做法是用Invoke或BeginInvoke把UI更新操作封装到UI线程上执行。我习惯先判断IsHandleCreated,防止窗体关闭时Invoke引发ObjectDisposedException:
Private Sub SerialPort1_DataReceived(sender As Object, e As SerialDataReceivedEventArgs) Handles SerialPort1.DataReceived Dim bytesAvailable As Integer = SerialPort1.BytesToRead If bytesAvailable <= 0 Then Return Dim buffer(serialPort1.BytesToRead - 1) As Byte SerialPort1.Read(buffer, 0, buffer.Length) If Me.IsHandleCreated Then Me.BeginInvoke(Sub() ProcessReceivedBytes(buffer) End Sub) End If End Sub读取串口数据用Read(byte[], offset, count)方法,一次把当前缓冲区的所有字节读出来,而不是用ReadLine或者ReadExisting。这样做的原因后面第五节会细说,先把代码骨架搭对。
4.3 发送数据的编码处理与十六进制发送
发送逻辑相对简单,但编码处理要想清楚。我的发送函数分两种情况:
- 如果用户选择ASCII发送:直接把发送框里的字符串按Encoding.GetEncoding("GBK")转成字节数组。为什么用GBK而不用UTF-8?因为很多设备(尤其是国产工业设备和GPS模块)默认用GBK或ASCII,UTF-8编码的中文字符发过去会变成乱码。如果是纯英文数字,用ASCII就够了。
- 如果用户选择Hex发送:把输入框里的"AA BB CC"字符串解析成字节数组。注意要去掉空格,并且每两个字符一组,非法字符直接提示用户。
Private Function ParseHexString(hexStr As String) As Byte() Dim clean = hexStr.Replace(" ", "").Replace("-", "") If clean.Length Mod 2 <> 0 Then Throw New Exception("十六进制字符串长度必须为偶数") End If Dim result(CInt(clean.Length / 2) - 1) As Byte For i As Integer = 0 To result.Length - 1 result(i) = Convert.ToByte(clean.Substring(i * 2, 2), 16) Next Return result End Function Private Sub SendBytes(data As Byte()) If Not SerialPort1.IsOpen Then MessageBox.Show("串口未打开") Return End If SerialPort1.Write(data, 0, data.Length) End Sub发Modbus帧时我习惯在快捷发送按钮里直接写死整帧数据,或者把CRC计算也做成一个公共函数,点发送前自动计算追加,调试效率高很多。
5. 串口调试最大的坑:丢数据、粘包与乱码
开发过程中最让我头疼的,不是串口打不开,而是数据接收不稳定。在这里把排查思路完整记录下来。
5.1 为什么DataReceived里不能直接做ReadLine
我第一次写接收代码时,图省事直接在DataReceived里调用ReadLine(),结果发现数据时有时无,而且经常报"操作超时"异常。
原因是ReadLine()会一直读到换行符为止。如果设备发送的数据不是以换行符结尾,或者一帧数据还没发完就触发了DataReceived,ReadLine就会占用线程等待超时,期间新到的数据又会触发新的DataReceived,最终线程越积越多,接收就乱了。
此外,ReadLine()默认认为换行符是LF(\n),遇到CRLF结尾的帧还能读出来,但如果设备只用CR,那就是一场灾难。
我把DataReceived里的逻辑改成"先读进字节数组,再自己处理帧边界",问题就解决了。SerialPort.ReadLine和ReadExisting只适合数据量小、格式固定的简单文本协议,规规矩矩按字节流处理才是通用方案。
5.2 缓冲区读取与字节缓存:从源头解决粘包
串口数据是流式的,没有天然的消息边界。设备可能一次发来3个字节,也可能一次发来300个字节,通信驱动会在合适的时间触发DataReceived事件,但这和协议层的一帧没有必然联系。如果设备一帧是AAB1C2F4,但事件触发时你只读到了AA B1 C2,下一轮才读到F4,直接显示就会把一帧拆成两半,这就是最常见的"粘包/半包"问题。
我的处理方案是引入一个全局的接收缓存集合,把每次从串口读到的字节先追加到缓存里,然后尝试从缓存中解析出完整的帧并处理,处理完的字节就从缓存中移除。针对自己定义的协议(比如帧头+长度+数据+校验),可以写一个专门的FindFrame函数;如果只是通用调试工具,就把缓存里的字节全部追加到接收显示区,让用户自己判断帧边界。
在通用工具里,我主要保证一点:接收区显示的内容和实际收到的字节完全一致,不丢数据。具体做法就是把DataReceived里读到的buffer原封不动追加到TextBox中:
Dim text As String = Encoding.ASCII.GetString(buffer) AppendReceiveText(text)AppendReceiveText里用TextBox.AppendText而不是直接textBox.Text &= text,因为AppendText会自动滚动到底部,频繁插入时效率也高得多。
5.3 乱码问题:编码不匹配的排查思路
乱码几乎是每个做串口调试的人都会碰到的问题。乱码的原因不外乎这两个:
- 发送端的字符编码和接收端显示的编码不一致。设备输出UTF-8字节流,但你的助手按ASCII解析,那中文当然就是一堆锟斤拷。
- 波特率错误导致收到大量0x7F或0x00之类的异常字节。
排查思路我按这个顺序走:
- 先用示波器或者逻辑分析仪确认波特率是否匹配。没有示波器的话,可以用串口助手收发纯数字或英文测速,如果能正确显示"12345",说明波特率大概率没问题。
- 确认设备文档里指定的编码格式。常见的有ASCII、GB2312/GBK、UTF-8。VB.NET里编码获取方式:Encoding.GetEncoding("GBK")、Encoding.UTF8。
- 如果设备手册说支持UTF-8,但显示还是乱,检查是不是发送时VB.NET把字符串转成了UTF-16,你得显式按字节数组发送,不能直接把String塞给Write方法。
这里再补充一个工控场景常见问题:USB转串口芯片兼容性。同一块设备,用FT232芯片的线没问题,换成CH340就可能出现丢字节或乱码。这不是代码问题,换一条线或者重装驱动就能解决。我被这个坑害过整整一个下午,排查了代码、协议、编码之后才发现是转接线的问题。
6. 从"能用"到"好用":自动发送、日志记录与其他扩展
基础版串口助手能用了之后,我开始往里加真正提升效率的功能。
6.1 定时自动发送的三种实现方式
定时发送就是每隔固定时间自动发送一次数据,用来测试设备在长时间压力下的稳定性。实现思路有三种:
- Timer控件:最简单,在窗体上拖一个Timer,设置Interval,Tick事件里调用发送函数。适合发送间隔在几十毫秒以上的场景,精度足够。
- 线程+循环+Slepp:在后台线程里做循环,间隔时间用Thread.Sleep控制。发送间隔可以做到几毫秒,但注意帧间隔不准且UI无法直接通知线程停止,要用标志位控制。
- Stopwatch忙等:最高精度,但不推荐在WinForms里用,CPU占用高,而且和定时器线程抢占调度会引入不确定性。
我用的方案是第一和第二种结合。普通场景用Timer.SetInterval实现,做压力测试时切到后台线程模式,用ManualResetEvent来通知开始和停止:
Private _autoSendThread As Thread Private _autoSendCancel As New ManualResetEvent(False) Private Sub StartAutoSend() _autoSendCancel.Reset() _autoSendThread = New Thread(AddressOf AutoSendLoop) _autoSendThread.IsBackground = True _autoSendThread.Start() End Sub Private Sub AutoSendLoop() Dim interval = CInt(txtAutoInterval.Text) Dim frame = Encoding.ASCII.GetBytes(txtSend.Text & vbCrLf) While Not _autoSendCancel.WaitOne(interval) If SerialPort1.IsOpen Then SerialPort1.Write(frame, 0, frame.Length) End If End While End Sub Private Sub StopAutoSend() _autoSendCancel.Set() End Sub用ManualResetEvent.WaitOne(interval)代替Thread.Sleep的好处是停止线程时不用打断睡眠,响应非常快。
6.2 日志保存与时间戳的经验
日志保存是个"加了就回不去"的功能。我用一个独立函数处理接收数据,每次追加到界面显示的同时写入一个StringBuilder,当接收数据量达到一定阈值(比如每500行)就刷进日志文件。
时间戳设计要注意一点:不要每收到一个字节就打一次时间戳,而应该以"行的维度"打时间戳。比如收到一帧完整数据后,在该帧开头加一行:
[2025-01-15 14:22:03.125] AA BB CC DD EE FF 12 34如果一帧数据非常长,可以在数据里插入换行,但不加时间戳。日志文件用UTF-8带BOM保存,这样用记事本打开中文不会乱。
清理日志文件也要考虑:我设置日志文件超过10MB就自动切换新文件,旧文件按时间命名保留,避免一个文件无限膨胀到几百MB导致打开卡死。
6.3 一个更有野心的方向:串口波形显示
这个功能我放在最后分享,是因为它最能体现"自己写工具的乐趣"。很多现成串口助手没有示波器功能,但如果你调试的是传感器数据,比如温度、电压、加速度,一条条看十六进制数据效率太低。此时如果能在界面上直接画出曲线,设备状态一目了然。
实现思路不复杂:把接收到的数据帧解析成数值,塞进一个List(Of Double)或环形缓冲区,用GDI+或者简单的绘图控件(比如Chart控件)把曲线画出来。
我用的方案是.NET自带的Chart控件,把X轴设置成时间或采样序号,Y轴按传感器量程设置。数据解析这一步是自定义的,因为不同设备协议不同:
' 假设设备一帧是 "T:25.6;C:30.1",解析温度和电流并追加到Chart Dim parts = line.Split(";"c) For Each part In parts If part.StartsWith("T:") Then Dim temp As Double = Double.Parse(part.Substring(2)) ChartTemp.Series("温度").Points.AddY(temp) ElseIf part.StartsWith("C:") Then Dim cur As Double = Double.Parse(part.Substring(2)) ChartCur.Series("电流").Points.AddY(cur) End If Next当然,这个功能要和具体的设备协议绑定,不像通用收发那么"通用"。但正因为是自研工具,我才可以随时针对当前测试项目改解析逻辑。这也正是我一开始选择"自己写串口助手"的原因。
最后再分享一个个人经验:VB.NET做串口工具项目,不需要追求一步到位。先把收发功能做稳,再逐步加入自己真正需要的定制功能,整个过程远比下载一个现成工具更有价值。工具越好用,开发越顺手,这个正循环只有自己写过的人才能体会到。
本文还有配套的精品资源,点击获取