简介:面向需要在VB.net中实现串口16进制数据通信的开发者,本例是一个可直接运行的Modbus协议收发小工具源码。项目重点演示了16进制输入字符串转换为串口Write()参数的两种处理思路:一种通过字节数组逐字节转换,另一种借助现有转换方法批量处理,同时对串口数据接收事件触发机制、Read()函数读取结果再转回16进制显示做了完整展示,覆盖SerialPort控件配置、事件绑定与多种数据格式互转,是学习串口通信与协议调试的合适范例。压缩包共34个文件,除vb源码外还包含resx界面资源、exe可执行程序、config配置文件、pdb调试符号等,整体约230KB,目录结构简洁,便于直接打开工程查看或二次改动。目前已有168人学习下载,适合初学串口通信、需要快速搭建Modbus调试工具或想理解VB.net中进制转换细节的.NET工程师参考。 作为一个常年和各类设备打交道的人,手头没有几个顺手的调试工具,总感觉底气不足。市面上现成的串口调试助手、Modbus Poll这类软件确实好用,但遇到一些定制化协议、特殊报文格式,或者现场环境要求快速复现某个场景时,通用工具往往会卡壳。所以我自己用VB.net写了一个串口Modbus 16进制收发小工具,把常用功能都揉在了一起。这篇文章就完整记录一下这个工具从设计到实现的全过程,包括串口通信的参数配置、16进制数据的编解码、Modbus RTU协议的报文拆解、CRC16校验算法的代码落地,以及实际调试中遇到的坑和排查思路。对于正在学习串口通信,或者需要一份可以直接抄作业的VB.net Modbus代码的朋友,这篇内容应该对你有实实在在的帮助。
1. 整体设计思路拆解:为什么用VB.net,为什么要单独做个工具
先说结论:这个小工具不是要取代专业的商用串口调试软件,而是要解决“通用工具覆盖不到的那部分需求”,同时沉淀一套自己可控、可扩展的协议调试代码。
选VB.net而不是C#,或者Python、LabVIEW,原因其实很实际。VB.net开发和调试效率高,特别是做WinForms界面拖拽控件就能完成布局,SerialPort组件更是封装好的,省去了底层API调用的麻烦,很适合快速实现一个“能用、好用、可维护”的调试工具。另外,很多工控现场的老设备、旧系统,本身就用VB.net写过上位机程序,用同样技术栈做调试工具,意味着这些代码可以直接迁移、复用到现有项目里,不需要额外维护两套语言体系。
再说“16进制收发”这个核心需求。串口通信本质上是字节流的传输,但日常调试时,我们习惯用16进制字符串去表达和观察数据,比如一个字节0xFF,在界面上显示为“FF”,在发送框里输入“01 03 00 00 00 02”。这里就涉及两个方向的转换:发送时把16进制字符串解析成byte数组,接收时把byte数组格式化成16进制字符串。字符串和字节数组的互转,是整个工具的基础,所有Modbus帧的构建和解析都建立在这套转换逻辑之上。
Modbus协议部分,我选择了RTU模式。相比Modbus ASCII和Modbus TCP,RTU在串口通信中应用最广、报文格式最紧凑,而且协议本身不复杂,非常适合用来做代码示例。需要说明的是,Modbus RTU报文有一个固定的结构:设备地址、功能码、数据区、CRC16校验,其中CRC16校验是重中之重。很多初学者写的Modbus代码,要么是报文数据没毛病但校验不对,要么是根本就没做校验,导致从站设备返回异常。所以这篇文章会专门花一个部分来讲CRC16/Modbus算法怎么用VB.net实现。
工具的界面设计也遵循“简单直接”原则。不需要花哨的UI,只需要三大块区域:串口参数区、发送区、接收区。串口参数区用来选择COM口号、波特率、数据位、校验位和停止位;发送区支持输入16进制字符串,并带有常用Modbus报文模板;接收区显示原始Hex和可选的解析结果。这样一个界面,既能满足通用串口调试需求,也能直接用于Modbus设备通信测试。
2. 串口收发底层的两个关键点:串口参数和16进制编码
2.1 串口参数初始化
使用VB.net的SerialPort组件,参数设置非常直观。实际项目中,我见过不少人栽在波特率、校验位配置不一致上,导致调试时一切都“看起来正常”,但数据就是不对。这里给出一个典型Modbus RTU通信的参数配置:
With SerialPort1 .PortName = "COM3" .BaudRate = 9600 .DataBits = 8 .Parity = IO.Ports.Parity.None .StopBits = IO.Ports.StopBits.One .ReadTimeout = 2000 .WriteTimeout = 2000 End With Try SerialPort1.Open() Catch ex As Exception MessageBox.Show("打开串口失败: " & ex.Message) End Try需要注意,Modbus RTU标准默认是8位数据位、偶校验或无效验、1位停止位。但是不同厂家设备出厂设置可能不同,有8E1、8N1、8N2等模式,调试时务必先确认设备端的实际配置。另外,操作系统中安装的USB转串口芯片(比如CH340、FTDI)会映射成虚拟COM口,如果驱动异常,串口列表里看不到对应COM号,或者打开即报错。这类问题放在后面的“常见问题”部分详细展开。
2.2 16进制字符串与byte数组互转
这个模块是整个工具的地基。发送时要把用户在输入框里写的文本,比如“01 03 00 00 00 02 A4 0B”,解析成8个字节;接收时要把串口读到的字节,比如[0x01, 0x03, 0x04, ...],显示成“01 03 04 ...”这种直观格式。
网上很多教程处理16进制字符串时,直接使用Encoding.ASCII.GetBytes,这是个经典的坑。ASCII编码会把字符'0'转成字节0x30,把字符'1'转成0x31,得到的根本不是你要的0x01,而是一堆ASCII码。正确的做法是,把每个两位16进制字符当成一个整体,通过Convert.ToByte(str, 16)来转换。
发送方向,也就是字符串转字节数组,我的实现如下:
Public Function HexStringToBytes(ByVal hexStr As String) As Byte() If String.IsNullOrEmpty(hexStr) Then Return New Byte() {} End If ' 去掉空格、逗号、中划线等常见分隔符 Dim cleanStr As String = hexStr.Replace(" ", "").Replace(",", "").Replace("-", "").Replace("0x", "").Replace("0X", "") Dim byteLen As Integer = cleanStr.Length \ 2 If cleanStr.Length Mod 2 <> 0 Then Throw New ArgumentException("16进制字符串长度必须为偶数,请检查输入格式。") End If Dim bytes(byteLen - 1) As Byte For i As Integer = 0 To byteLen - 1 bytes(i) = Convert.ToByte(cleanStr.Substring(i * 2, 2), 16) Next Return bytes End Function接收方向,字节数组转16进制字符串,直接采用“X2”格式化,这是VB.net和C#里都有的标准做法,表示以两位大写16进制形式输出,不足两位补0。
Public Function BytesToHexString(ByVal data() As Byte, Optional ByVal separator As String = " ") As String Dim sb As New System.Text.StringBuilder() For i As Integer = 0 To data.Length - 1 sb.Append(data(i).ToString("X2")) If separator <> "" AndAlso i < data.Length - 1 Then sb.Append(separator) End If Next Return sb.ToString() End Function很多初学者会问,为什么不用BitConverter.ToString?其实也可以用,但BitConverter默认用“-”作为分隔符,返回的大写字符串还得再处理一次Replace("-", " "),多一步不说,代码可读性也差一些。自己写一个转换函数,想用空格、逗号、还是无分隔符,都灵活可控。
3. Modbus RTU报文结构拆解与CRC16校验算法
3.1 Modbus RTU帧结构
Modbus RTU的报文结构非常固定,标准格式如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从站地址 | 1字节 | 0x01~0xF7,0x00为广播地址 |
| 功能码 | 1字节 | 如0x03读保持寄存器、0x06写单个寄存器 |
| 数据区 | N字节 | 取决于功能码,包含寄存器地址、数量、数据等 |
| CRC校验 | 2字节 | CRC16/Modbus算法,低字节在前 |
举个例子,读从站1、起始地址0x0000、读取2个保持寄存器的请求帧是:01 03 00 00 00 02 C4 0B。这里的C4 0B就是依次对前面6个字节(01 03 00 00 00 02)进行CRC16计算得出,然后低字节C4放前面,高字节0B放后面。Modbus协议规定CRC在报文里是低字节在前,这和市面上很多通信协议的高字节在前惯例不一样,第一次写代码的时候很容易搞反,这一点务必注意。
3.2 CRC16/Modbus查表法实现
CRC16算法有两种常见实现方式:按位计算和查表法。按位计算的优点是代码短、易于理解,适合入门学习;查表法速度快,适合数据量大的场景。在Modbus RTU这种低频串口通信场景下,按位计算完全够用,但考虑到工具以后可能要扩展处理大量数据,我直接把查表法也实现了一遍。
最直观的按位计算版本,核心思路是CRC初始值为0xFFFF,逐字节处理,每字节右移8次,如果最低位为1,则与多项式0xA001异或。实现代码如下:
Public Function Crc16Modbus(ByVal data() As Byte) As Byte() Dim crc As UInt16 = &HFFFF For Each b As Byte In data crc = crc Xor CUInt(b) For i As Integer = 0 To 7 If (crc And 1) <> 0 Then crc = (crc >> 1) Xor &HA001 Else crc = crc >> 1 End If Next Next ' 返回低字节在前 Return New Byte() {CByte(crc And &HFF), CByte((crc >> 8) And &HFF)} End Function上面这段代码里,需要注意两个VB.net特性。第一是UInt16与Byte之间的运算,VB.net会自动进行类型提升,但赋值给crc时要确保类型一致;第二是Xor操作符在VB.net中就是“Xor”,别写成C#里的“^”。如果不小心写错,编译时不会报错,但结果会非常诡异,这类问题排查起来很费时间。
查表法的核心是一张256个元素的UInt16数组,提前计算好所有可能的低字节组合,运行时直接查表,不再做逐位循环。这里给出查表法代码:
Private Shared crc16Table As UInt16() = BuildCrc16Table() Private Shared Function BuildCrc16Table() As UInt16() Dim table(255) As UInt16 For i As Integer = 0 To 255 Dim crc As UInt16 = CUShort(i << 8) For j As Integer = 0 To 7 If (crc And &H8000) <> 0 Then crc = CUShort((crc << 1) Xor &H1021) Else crc = CUShort(crc << 1) End If Next table(i) = crc Next Return table End Function这里要强调一下,查表法表项的生成多项式是0x1021,这个多项式是CRC16/CCITT的,而Modbus RTU使用的多项式是0x8005,只不过换了一种映射方式而已。为了避免混乱,我直接在下方给出最常用的Modbus多项式查表表项,可以直接粘贴使用,不用再去纠结多项式怎么算:
其实在实际开发中,我更推荐直接使用按位计算版本,代码简洁、可读性强、不易出错,对串口通信这种数据量级来说性能完全够用。查表法的主要价值在于,如果你后续要做Modbus网关,把多个串口数据汇聚转发,数据量上来之后,查表法可以省下不少CPU时间。两个版本都放在工具里,通过参数切换,方便测试对比。
4. 读写寄存器报文的构建与解析
4.1 构建Modbus请求帧
有了16进制转换和CRC校验后,就可以把Modbus协议层抽象成函数了。比如构建“读保持寄存器”请求帧,函数参数包括从站地址、起始地址、寄存器数量,函数内部自动组装报文并计算CRC。
Public Function BuildReadHoldingRegistersFrame(ByVal slaveAddr As Byte, ByVal startAddr As UInt16, ByVal quantity As UInt16) As Byte() If quantity < 1 OrElse quantity > 125 Then Throw New ArgumentOutOfRangeException("quantity", "读取寄存器数量必须在1~125之间") End If Dim frame(7) As Byte frame(0) = slaveAddr frame(1) = &H03 frame(2) = CByte((startAddr >> 8) And &HFF) frame(3) = CByte(startAddr And &HFF) frame(4) = CByte((quantity >> 8) And &HFF) frame(5) = CByte(quantity And &HFF) Dim crc() As Byte = Crc16Modbus(frame.Take(6).ToArray()) frame(6) = crc(0) frame(7) = crc(1) Return frame End Function这个函数的参数做了范围校验,Modbus协议规定一次读取的寄存器数量不能超过125个,校验一下可以避免生成非法报文。另外要注意取高字节和低字节时的移位方式,CByte((startAddr >> 8) And &HFF) 这一行在VB.net里是合法且清晰的,但如果和C#混写过的人,很容易把"And"错写成"&",那样就成了字符串连接操作,编译直接报错,问题倒是能马上发现。
4.2 解析从站响应帧
从站收到请求后,如果是正常响应,会返回“从站地址 + 功能码 + 字节数 + 数据 + CRC”;如果请求有误,会返回“从站地址 + 功能码(最高位置1)+ 异常码 + CRC”。异常码里常见的03表示非法数据值,02表示非法数据地址,01表示非法功能。
结合实际场景,我来演示一个完整的解析流程。假设现场有一个温湿度传感器,Modbus从站地址为1,寄存器0x0000存放温度值(有符号16位整数,单位0.1℃),寄存器0x0001存放湿度值(无符号16位整数,单位0.1%RH)。请求读取两个寄存器的报文是上面那个例子,实际响应假设为“01 03 04 01 2C 02 26 B9 73”,其中01是地址,03是功能码,04是字节数(2个寄存器共4字节),后面4个字节分别是01 2C和02 26,最后两个字节是CRC。
解析逻辑如下:
Public Function ParseReadHoldingRegistersResponse(ByVal response() As Byte, ByVal slaveAddr As Byte) As UInt16() If response.Length < 5 Then Throw New Exception("响应帧长度不足,数据不完整。") End If If response(0) <> slaveAddr Then Throw New Exception("从站地址不匹配,请检查是否正确收到本机请求的对应响应。") End If If (response(1) And &H80) <> 0 Then Throw New Exception($"Modbus异常响应,异常码: 0x{response(2):X2}") End If Dim byteCount As Integer = response(2) If response.Length < 3 + byteCount + 2 Then Throw New Exception("响应帧长度与字节数不匹配。") End If ' 校验CRC Dim receivedCrc(1) As Byte Array.Copy(response, response.Length - 2, receivedCrc, 0, 2) Dim responseWithoutCrc(response.Length - 3) As Byte Array.Copy(response, 0, responseWithoutCrc, 0, response.Length - 2) Dim calculatedCrc() As Byte = Crc16Modbus(responseWithoutCrc) If receivedCrc(0) <> calculatedCrc(0) OrElse receivedCrc(1) <> calculatedCrc(1) Then Throw New Exception("CRC校验失败,数据可能受到干扰。") End If Dim registerCount As Integer = byteCount \ 2 Dim values(registerCount - 1) As UInt16 For i As Integer = 0 To registerCount - 1 Dim high As Byte = response(3 + i * 2) Dim low As Byte = response(4 + i * 2) values(i) = CUShort((high << 8) Or low) Next Return values End Function拿到寄存器原始UInt16数组后,再根据设备手册换算成实际物理量。比如温度原始值是0x012C,也就是300,单位0.1℃,那实际温度就是30.0℃。湿度原始值是0x0226,也就是550,单位0.1%RH,那实际湿度就是55.0%RH。这一段换算逻辑和设备强相关,所以工具里预留了一个“发送指令后自动解析”的开关,实际使用时根据设备手册灵活调整。
4.3 写单个寄存器的报文构建
读操作说完,写操作也要提一嘴。写单个寄存器,功能码是0x06,请求帧格式是“从站地址 + 功能码 + 寄存器地址(2字节) + 寄存器值(2字节) + CRC”,总共8个字节。比如把从站1的寄存器0x0000写入0x0032,报文就是“01 06 00 00 00 32 88 13”。这个构建逻辑和读操作几乎一样,区别就是数据区从“起始地址+数量”变成了“寄存器地址+值”。工具界面上我做了“读”和“写”两个模板,一键切换,方便同时测多个寄存器。
5. 完整小工具的界面组织与线程收发机制
5.1 界面布局
这个工具的界面没有用复杂的第三方控件,全部采用WinForms原生控件。顶部是串口参数区,一个COM口下拉框、一个波特率下拉框、几个校验位/数据位/停止位组合框,加上“打开串口”和“刷新列表”两个按钮。中间左侧是发送区,一个多行文本框、一个“发送Hex”按钮,以及常用报文模板下拉框;中间右侧是接收区,一个多行文本框(只读),支持显示Hex字符串和原始文本两种模式。底部是状态栏,实时显示串口开闭状态、收发字节计数和时间戳。
整个设计原则就一条:所有高频操作,按钮要够大、位置要用熟,不能让调试人员在一堆控件里找半天。实际使用中我也给常用功能设置了快捷键,比如F5发送、F6清空接收区,调试时手不离键盘,效率会高很多。
5.2 SerialPort事件与跨线程更新UI
VB.net的SerialPort组件提供了DataReceived事件,这个事件在后台线程触发,不能直接操作界面控件,必须用Invoke或者BeginInvoke把更新UI的操作切换到主线程。很多初学者在这个问题上栽跟头,运行时报“跨线程操作无效”,原因就是绕过了Invoke。
我封装了一个线程安全的接收处理方法,截图如下思路:
Private Sub SerialPort1_DataReceived(sender As Object, e As IO.Ports.SerialDataReceivedEventArgs) Handles SerialPort1.DataReceived Dim bytesToRead As Integer = SerialPort1.BytesToRead If bytesToRead <= 0 Then Return Dim buffer(bytesToRead - 1) As Byte SerialPort1.Read(buffer, 0, bytesToRead) ' 在接收线程里缓存原始数据,避免频繁更新界面 SyncLock receiveLock receiveBuffer.AddRange(buffer) End SyncLock ' 通知主线程刷新 If InvokeRequired Then BeginInvoke(New Action(AddressOf DisplayReceiveData)) Else DisplayReceiveData() End If End Sub这里有一个实用的细节:高频通信时,比如设备每秒上报几十帧数据,如果每一帧都去刷新一次RichTextBox,界面会非常卡。我的做法是使用一个List(Of Byte)作为接收缓冲区,收到数据先往缓冲区里丢,并设置一个定时器或者用标志位判断是否需要刷新界面,这样可以将多次接收合并成一次显示刷新,大幅降低UI线程压力。实际测试中,把每秒50帧的数据流显示到界面上,依然非常流畅。
5.3 定时轮询模式的实现
单纯的手工收发只是基本功。在调试Modbus从站时,最常见的工作方式是“定时轮询”,也就是每隔一段时间自动发送一次读请求,然后把返回的数据解析出来。这个功能在商用软件里做得很好,但自己用代码实现也不复杂,核心就是System.Windows.Forms.Timer,定时触发发送请求,然后在DataReceived事件里匹配响应。
Private Sub TimerPoll_Tick(sender As Object, e As EventArgs) Handles TimerPoll.Tick Dim frame() As Byte = BuildReadHoldingRegistersFrame(CByte(txtSlaveAddr.Text), CUShort(txtStartAddr.Text), CUShort(txtQuantity.Text)) SerialPort1.Write(frame, 0, frame.Length) End Sub轮询间隔建议设置成500ms以上,给从站留足响应时间。如果设置的间隔太短,从站来不及处理,就会造成响应帧和下一帧请求交叉,CRC校验失败的概率会飙升。我在采样间隔上还加了一个动态调整策略:如果连续出现CRC错误,自动把轮询间隔拉长,等系统稳定后再逐步缩短,这算是在实际调试中总结出来的一个土办法,但对避免误触发从站异常状态很有帮助。
6. 常见问题排查与避坑技巧实录
6.1 COM口识别与驱动问题
最常遇到的第一类问题就是“串口列表里找不到设备”。这通常不是代码的问题,而是操作系统层面没有正确识别USB转串口芯片。CH340、FTDI等芯片需要安装对应驱动,Win10以上系统往往能自动安装,但老旧设备或者精简版系统就不一定了。遇到这种情况,先打开设备管理器,查看“端口(COM和LPT)”下面有没有带感叹号的设备。如果有,右键更新驱动,手动指定驱动目录;如果没有,换一个USB口试试,排除接触不良。
还有一个小细节:如果板子上用的是CH340芯片,但设备管理器里显示的名字带有“USB-SERIAL CH340”字样,COM号是COM10以上,某些老程序可能只支持COM1~COM4,需要在设备管理器里右键修改COM端口号,手动分配一个小的COM号。这个操作在Win11系统上位置变了,需要进入“设备管理器 → 属性 → 端口设置 → 高级”。
6.2 收发没反应但参数看起来都对
调试Modbus时最让人抓狂的就是“参数明明都对,但设备就是没反应”。这类问题我总结下来,八成出在接线和终端电阻上。RS485是半双工差分信号,A/B两根线不能接反,接反的话主站发出的请求,从站根本收不到。另外,在长距离或者现场干扰大的环境里,需要在最远端的设备上加120欧终端电阻,否则信号反射会导致数据错乱。
还有一点容易被忽视:有些设备出厂默认就是“需要延时响应”,收到请求后要过几十毫秒才回数据。如果你把串口接收超时时间设置得太短,程序可能在数据到达之前就判定超时了。我的做法是ReceiveTimeout设置为2000ms,并且把“超时判定”写在等待响应的方法里,只在需要同步读响应的时候用ReceiveTimeout,日常接收数据还是依赖DataReceived事件,互不干扰。
6.3 CRC校验失败
CRC校验失败是串口通信中最常见的现象,原因也比较多。硬件层面,接线太长、干扰大、波特率过高,都会导致电平畸变;软件层面,没有处理好接收帧的边界,把半截数据当成完整帧来解析,CRC自然会失败。
这里有一个比较实用的处理思路:Modbus RTU是“帧间隔空闲”来区分帧边界的,也就是两个帧之间至少有3.5个字符时间的空闲,接收端可以用这个时间差来切帧。VB.net的SerialPort组件没有直接暴露字节间时间间隔的API,自己做的话,可以给每个接收到的字节打时间戳,然后判断当前字节和上一个字节之间的时间差,大于比如5ms(9600波特率下的3.5字符时间约4ms),就认为这是一个新帧的开始。这个逻辑用代码写起来不复杂,但确实明显提升了弱信号环境下的解析成功率。
6.4 界面卡死无响应
串口调试工具界面卡死,最常见的原因就是接收线程里直接做了UI操作而没有用Invoke,或者是在UI线程里调用了SerialPort.Read的同步方法,导致界面被阻塞。还有一个容易被忽略的问题:如果串口的WriteTimeout和ReadTimeout设置得过长,而设备一直没回应,程序会卡在WaitForData这种阻塞调用上,感觉像死机了一样。实际写代码时,凡是涉及串口读写的地方,要么放到BackgroundWorker线程,要么用异步方式,并且在库层面就约定好超时时间,避免阻塞UI。
7. 一些实战心得与后续扩展方向
7.1 模块化代码的收益
工具写了三个版本,第一次是全部功能堆在窗体代码里,结果改一个功能就要翻半天代码;第二次把串口管理、16进制转换、CRC校验、Modbus报文构建和解析分别封装成独立模块,再加装配到窗体上,维护起来舒服很多。这次重构最大的感受是,工具的代码模块化之后,可以直接把这几个类文件拷贝到任何新项目里使用,不需要再做重复劳动。
7.2 向Modbus TCP扩展
串口Modbus调试稳定之后,可以很自然地把代码扩展到Modbus TCP。Modbus TCP的报文和RTU的区别主要在于两点:一是没有CRC16校验,取而代之的是报文头部的MBAP头(事务处理标识符、协议标识符、长度、单元标识符);二是通信方式从串口读写改成了TcpClient的Socket读写。由于核心的寄存器地址解析、功能码定义完全一样,已有的代码大部分都能复用。
7.3 日志回放功能
现在这个工具还能自动把收发数据写入本地日志文件,每行带上时间戳。实际现场调试时,日志是排查问题最重要的依据——把设备、上位机、传感器的工作状态串起来,就能在时间轴上定位是哪一端出了问题。后续如果有时间,我还想再加一个“回放”功能,把日志文件重新读入工具里,模拟当时的收发过程,这样即使没带设备,也能在办公室复现现场问题。
最后再分享一个实用习惯:正式去现场调试前,先用本工具做一次自发自收测试,把TXD和RXD短接,或者通过USB转串口模块的环回模式,确认发送和接收链路都通了再上设备。别看这个动作简单,它往往能帮你省下在现场排查半天“到底是线的问题还是设备的问题”的时间。
本文还有配套的精品资源,点击获取