拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C#串口十六进制收发工具开发实战:核心原理与源码解析

简介这是面向C#串口通信学习与硬件调试场景的完整源码工程基于System.IO.Ports.SerialPort串口控件实现十六进制数据的接收显示与发送适合嵌入式开发人员、电子工程师及需要与串口设备联调的软件开发者参考。压缩包共21个文件包含6个.cs源码、解决方案与工程配置.sln/.csproj/.settings、界面资源.resx/.resources及编译调试产出.exe/.pdb等整体仅37KB代码量精简便于快速定位核心逻辑。解决方案以HexCommPort命名主窗体为Form1代码按串口类封装、数据接收事件、十六进制与字节数组互转函数、界面交互等模块展开兼顾串口打开关闭、波特率校验位等参数设置支持将接收字节转为十六进制显示也可将输入的十六进制串转换后发出。编译生成的可执行文件可帮助初学者运行验证直接观察底层通信过程。已有1797人学习下载适合用作串口通信入门实例、课程设计参考及二次开发基础模板。 你这个需求我太熟了。搞嵌入式、玩单片机、调硬件设备的人几乎都会遇到同一个场景手头没有趁手的调试工具或者市面上的串口助手用起来总觉得差点意思要么不能按帧解析要么十六进制显示乱七八糟干脆自己动手用C#写一个。今天这个项目标题很直接——“C#写的串口16进制收发程序源码”听起来像个练手小项目但真把它写明白、写稳定里面涉及的知识点一点不少。这篇文章我就从零开始拆解这个程序从串口通信的基本原理到16进制数据在C#里的各种转换细节再到完整源码的核心模块怎么实现最后把我实际调试中踩过的坑一并列出来。不管你是刚学C#上位机开发的小白还是被串口调试折磨到想自己造轮子的硬件工程师这篇文章应该都能给你点实在的东西。1. 整体设计思路串口工具拆开看也就三件事先别急着写代码。写任何程序之前得先搞清楚这个程序到底要干什么。串口收发工具的核心功能说白了就三件事打开/关闭串口、按指定格式发送数据、按可读方式接收数据。16进制收发则是在这基础上把数据和十六进制字符串之间做来回转换。这个程序选C#来写原因其实很实在。一方面C#的System.IO.Ports命名空间里直接封装了SerialPort类底层Windows API那套复杂的结构体、回调函数全给包好了对新手极度友好另一方面C#做上位机界面效率确实高拖几个控件就能把交互界面搭起来比用C写MFC或者Qt省太多事。再一个串口调试工具本身逻辑不算复杂C#这种托管语言完全扛得住而且后续想扩展TCP、UDP通信也很方便。整个程序的架构我推荐用最简单直接的“界面事件”模型界面负责展示数据和收集用户输入串口事件负责数据的收发通知。不需要引入复杂的设计模式一个Form类、一个SerialPort实例再加几个辅助转换方法足够用了。这种方案的优势在于逻辑清晰、排查问题容易。你把发送和接收逻辑完全分离开数据只管通过事件流动这样出问题时能快速定位是发送环节的问题、接收环节的问题还是转换环节的问题。很多新手一上来就搞什么多线程队列、环形缓冲区看着高大上但对于一个串口调试工具来说纯属过度设计。我在实际写这个程序时给自己定了几条“设计规矩”也分享给你参考串口参数的设置和实际的串口操作分开界面上的下拉框只负责收集参数点“打开串口”时才真正创建连接16进制转换逻辑独立封装成静态方法哪怕以后不做串口工具了这套转换代码也能拿去做别的接收数据统一走一个缓存变量界面刷新只从缓存里取避免高频串口数据导致UI卡死。2. 16进制收发的核心关键字节和字符的那点事说完了整体设计得聚焦到标题里最核心的几个字——16进制收发。很多人第一次写串口程序最容易搞混的就是字符串、字节数组、16进制文本这三者之间的关系。我这里尽量用大白话把它讲透。串口通信的本质是字节流传输。你的设备通过串口线发过来的物理上就是一堆高低电平在驱动层被转换成一串byteC#里的byte类型取值范围0~255。所以不管你在界面上看到的是“A5 5A 01 02”这种16进制文本还是“hello”这种ASCII字符串到了串口这一层统统都是字节数组。那为什么我们要用16进制来显示和发送数据呢因为很多设备尤其是Modbus协议、自定义帧协议的设备的数据定义本身就是按字节来设计的。比如一个温湿度传感器的帧格式是帧头0xA5、命令0x01、数据高字节、数据低字节、校验0x5A。你看这种帧格式用ASCII或者普通文本根本无法直观地对照查看必须用16进制视图才能一眼看出每一字节的含义。在C#里实现16进制收发本质上就是解决两个方向的转换问题发送方向把用户在文本框输入的“A5 5A 01 02”这样的字符串转换成字节数组new byte[] { 0xA5, 0x5A, 0x01, 0x02 }然后通过SerialPort.Write()发送接收方向把串口收到的字节数组转换成“A5 5A 01 02”这样的字符串显示在富文本框或者DataGridView里。听起来简单但实际转换时会遇到一堆细节问题用户输入的字符串里可能带空格、可能不带空格、可能有非法字符比如“G5”这种不是合法16进制的内容、可能长度是奇数比如“A5A”没法凑成一个完整字节。这些边界情况如果不处理程序一跑起来就崩溃或者发错数据。这也是为什么很多人从网上抄了一段代码却经常莫名其妙出问题的根本原因。2.1 字符串转字节数组的标准写法这一块我直接给你一段我项目里实际使用的、经得起折腾的转换代码。这段代码处理了三种常见情况带空格的“A5 5A 01 02”、不带空格的“A55A0102”、混合格式的“A5 5A0102”。/// summary /// 16进制字符串转换为字节数组支持带空格和不带空格的输入 /// /summary public static byte[] HexStringToBytes(string hexString) { // 输入为空直接返回空数组 if (string.IsNullOrEmpty(hexString)) return new byte[0]; // 去掉所有空格、制表符、换行符统一为紧凑格式 hexString hexString.Replace( , ).Replace(\t, ).Replace(\r, ).Replace(\n, ); // 排除非法字符只允许0-9 A-F a-f foreach (char c in hexString) { bool isDigit (c 0 c 9); bool isUpperHex (c A c F); bool isLowerHex (c a c f); if (!isDigit !isUpperHex !isLowerHex) throw new FormatException(输入包含非16进制字符: c); } // 长度必须是偶数否则无法配对成字节 if (hexString.Length % 2 ! 0) throw new FormatException(16进制字符串长度必须是偶数当前长度为: hexString.Length); byte[] bytes new byte[hexString.Length / 2]; for (int i 0; i bytes.Length; i) { string byteStr hexString.Substring(i * 2, 2); bytes[i] Convert.ToByte(byteStr, 16); } return bytes; }这里为什么要先替换掉所有空格因为用户输入的格式不可控。有的人习惯每两个字节加个空格方便阅读有的人直接一串连着写还有的人从Excel里复制出来可能带了换行。统一清洗一遍后面解析逻辑就简单多了。非法字符校验和长度奇偶校验一定不能省略。我见过很多人写的转换代码直接用Convert.ToByte(hexString.Substring(i*2, 2), 16)一把梭遇到非法输入就抛异常整个程序直接崩掉。用户体验极差。你在工具类里做一次规范校验把错误信息通过MessageBox弹出来程序既不会崩溃用户也知道自己哪里输错了。2.2 字节数组转16进制字符串的三种姿势接收方向的转换比发送方向简单毕竟字节数组是现成的只需要决定显示格式。我总结了三种常见的显示需求对应三种不同的实现方式。第一种连续不带空格比如“A55A0102”这适合后续还要做二次处理的场景比如把接收到的数据复制到其他工具里解析。public static string BytesToHexStringNoSpace(byte[] bytes) { StringBuilder sb new StringBuilder(bytes.Length * 2); foreach (byte b in bytes) { sb.Append(b.ToString(X2)); } return sb.ToString(); }第二种每字节带空格比如“A5 5A 01 02”这是最推荐的显示格式肉眼查看时能轻松区分每一字节复制到别的工具里也通用。public static string BytesToHexStringWithSpace(byte[] bytes) { StringBuilder sb new StringBuilder(bytes.Length * 3); for (int i 0; i bytes.Length; i) { sb.Append(bytes[i].ToString(X2)); if (i bytes.Length - 1) sb.Append( ); } return sb.ToString(); }第三种按行显示比如每行16字节这适合查看大量数据流基本就是Hex Editor的显示方式。通常在调试大数据包时用得上我自己的工具里会做一个可选的开关。这三种写法核心都用到了b.ToString(X2)这里的“X2”是C#格式化字符串的意思X代表十六进制格式2代表最少占两位不足补零。所以十进制数字10转换出来是“0A”而不是“A”整帧数据长度就固定了不同字节之间不会因为位数不齐导致错位。2.3 关于编码的一点提醒还有一个容易踩坑的地方是编码问题。如果你用串口发送普通文本不是16进制或者把接收到的字节直接显示为字符串就会涉及编码。很多设备默认发的是ASCII但也有一些设备发的是GBK或者UTF-8编码的中文数据。SerialPort类有个Encoding属性默认是ASCIIEncoding。如果接收的数据里有非ASCII字符比如中文你用默认编码去读出来的就是“”或者乱码。正确做法是收到原始字节后用你想要的编码手动转换比如Encoding.UTF8.GetString(buffer, 0, count)或者Encoding.GetEncoding(GBK).GetString(...)。我在自己这个工具里做了一种很实用的设计接收区的显示分为“Hex模式”和“文本模式”两个标签页。Hex模式永远显示16进制字符串文本模式允许用户选择编码方式。这样不管对接什么设备都有对应的查看方式不需要改代码重编。3. 完整源码与功能拆解一步一步把工具搭起来这部分我会给出一份可以直接复制的完整源码框架并逐段说明核心代码的作用。这份代码我尽量精简了去掉了花哨的功能保留了串口收发工具最实用的核心部分你要做二次开发的话在这个骨架上改起来也方便。3.1 界面布局说明在做界面之前先把用到的控件列出来。一个标准的串口调试工具界面大致分三个区域参数配置区、发送区、接收区。参数配置区包括端口号选择下拉框ComboBox运行时动态枚举可用端口波特率下拉框常见值9600、19200、38400、115200等数据位下拉框常用8停止位下拉框常用1校验位下拉框常用None打开/关闭串口按钮。发送区一般包括发送文本框、16进制发送勾选框、发送按钮、定时发送勾选框和间隔设置。接收区则是一个富文本框或者多行文本框加一个清空按钮。这些控件在Visual Studio里通过拖拽就能完成布局不用我说太多你按自己习惯摆就行。窗体设计器会自动生成.Designer.cs文件这部分代码我不展开重点看逻辑代码。3.2 枚举可用串口并打开连接程序启动时自动枚举电脑上所有可用串口填入下拉框。这一功能非常实用省得用户手动去设备管理器里查端口号。private void Form1_Load(object sender, EventArgs e) { string[] ports SerialPort.GetPortNames(); if (ports.Length 0) { cmbPort.Items.AddRange(ports); cmbPort.SelectedIndex 0; } else { MessageBox.Show(未检测到可用串口请检查设备连接或驱动安装。); } // 初始化常用波特率选项 cmbBaudrate.Items.AddRange(new object[] { 4800, 9600, 19200, 38400, 57600, 115200 }); cmbBaudrate.SelectedItem 9600; cmbDataBits.SelectedItem 8; cmbStopBits.SelectedItem 1; cmbParity.SelectedItem None; }打开串口的代码放在“打开”按钮的点击事件里。注意这里要做异常捕获因为串口被其他程序占用、端口不存在等情况很常见不捕获异常程序会直接闪退。private void btnOpen_Click(object sender, EventArgs e) { if (serialPort.IsOpen) { serialPort.Close(); btnOpen.Text 打开串口; return; } try { serialPort.PortName cmbPort.Text.Trim(); serialPort.BaudRate int.Parse(cmbBaudrate.Text.Trim()); serialPort.DataBits int.Parse(cmbDataBits.Text.Trim()); serialPort.Parity cmbParity.Text None ? Parity.None : (Parity)Enum.Parse(typeof(Parity), cmbParity.Text); serialPort.StopBits cmbStopBits.Text 1 ? StopBits.One : (StopBits)Enum.Parse(typeof(StopBits), cmbStopBits.Text); serialPort.Open(); btnOpen.Text 关闭串口; } catch (Exception ex) { MessageBox.Show(打开串口失败 ex.Message); } }有一种在实操中常遇到的情况SerialPort.GetPortNames()能枚举出端口但Open()仍然报“端口不存在或已被占用”。这通常是USB转串口设备没有正确插好或者驱动安装不完整导致的。所以我在打开失败时的MessageBox里会额外提示用户检查USB线和驱动尤其很多山寨USB转串口模块对驱动稳定性要求很高。3.3 发送逻辑文本与16进制一键切换发送是整个工具最核心的操作。我的设计是界面上有个“16进制发送”的CheckBox勾上时发送内容按16进制解析不勾时按文本内容发送。这样一套代码兼容两种模式不需要用户切换界面。private void btnSend_Click(object sender, EventArgs e) { if (!serialPort.IsOpen) { MessageBox.Show(串口未打开); return; } string sendText txtSend.Text; if (string.IsNullOrEmpty(sendText)) return; byte[] sendBuffer; if (chkHexSend.Checked) { try { sendBuffer HexStringToBytes(sendText); } catch (FormatException ex) { MessageBox.Show(16进制格式错误 ex.Message); return; } } else { sendBuffer Encoding.ASCII.GetBytes(sendText); } serialPort.Write(sendBuffer, 0, sendBuffer.Length); }注意这里我特地把文本模式的编码固定成了Encoding.ASCII。为什么不用默认的serialPort.Encoding因为很多设备对非ASCII字符的处理方式不同用ASCII至少不会发送多余字节。如果确实需要发送中文建议改成Encoding.UTF8.GetBytes()或者Encoding.GetEncoding(GBK).GetBytes()但前提是你明确知道对端设备支持什么编码。发送还有一种常见需求是“发送新行”。很多串口设备比如蓝牙模块、GPS模块的AT指令需要以\r\n结尾。所以我在发送时会加一个可选的“发送时追加回车换行”复选框。这个功能看起来不起眼但在调试AT指令时极其关键没有这个选项的话你得手动在文本框里敲看不见的换行符很容易出错。3.4 接收逻辑事件驱动与缓存处理接收数据用的是SerialPort.DataReceived事件。这个事件在.NET中是后台线程触发的也就是说你不能在这个事件里直接操作UI控件否则会抛“线程间操作无效”的异常。标准的处理方式有两种一是用Invoke/BeginInvoke把UI更新封送回UI线程二是先把数据保存到缓存队列再由Timer定时刷新UI。我推荐第二种方式因为串口数据到达频率不确定高频时Invoke会频繁切换线程上下文可能造成UI卡顿。用定时器缓存的方式无论数据多快UI始终按固定频率刷新海康相机等工业场景下的串口调试实测都很稳。private readonly object lockObj new object(); private StringBuilder receiveCache new StringBuilder(); private void serialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { try { int bytesToRead serialPort.BytesToRead; if (bytesToRead 0) return; byte[] tempBuffer new byte[bytesToRead]; serialPort.Read(tempBuffer, 0, bytesToRead); string dataStr; if (chkHexReceive.Checked) { dataStr BytesToHexStringWithSpace(tempBuffer) ; } else { dataStr serialPort.Encoding.GetString(tempBuffer); } lock (lockObj) { receiveCache.Append(dataStr); } } catch (Exception ex) { // 记录异常避免后台线程崩溃 System.Diagnostics.Debug.WriteLine(接收异常: ex.Message); } }UI这边用一个System.Windows.Forms.Timer每100毫秒检查一次缓存里有没有新数据有则追加显示到接收文本框同时更新接收字节计数。private void timerDisplay_Tick(object sender, EventArgs e) { string content; lock (lockObj) { if (receiveCache.Length 0) return; content receiveCache.ToString(); receiveCache.Clear(); } txtReceive.AppendText(content); }这个“双缓冲”设计是我在调一堆数据大量刷屏的设备时总结出来的经验。如果你直接在DataReceived里Invoke更新UI当数据量特别大时比如某些传感器以100Hz频率连续上报UI线程会被Invoke请求打满表现为窗口无响应、鼠标拖动卡顿。定时器方案下UI线程始终从容地每100毫秒处理一次怎么刷都不卡。3.5 清空、暂停显示与字节统计这几个功能代码量很少但很能体现工具的实用性。清空按钮直接txtReceive.Clear()即可。暂停显示我设计为一个布尔标记勾选“暂停显示”后接收区的AppendText被跳过但缓存照常累积取消勾选时一次性显示所有遗漏的数据。“暂停显示”这个功能在调试高频上报设备时非常有用数据一直在跑你想仔细看某一帧暂停住慢慢看不会丢数据。字节统计我是在DataReceived里累加一个long类型的计数器每隔1秒用Timer刷新一次状态栏显示“已接收: xxx字节”。统计功能看着不起眼但在验证数据完整性时很重要。比如你给设备发了一条请求数据帧设备应当返回500字节在状态栏看接收计数是否达到500立刻就知道通信是否成功。4. 常见问题与排查技巧实录串口程序写起来不难但调试起来是真的折磨人。我把自己在开发和使用这个工具过程中遇到的典型问题整理成表格和下面的经验段落条条都是真金白银的教训。4.1 问题速查表问题现象可能原因解决方案打开串口报“访问被拒绝”串口被其他程序占用如另一个串口助手、设备厂商软件关闭占用串口的程序或者使用虚拟串口工具排查占用进程发送数据后设备无反应波特率不匹配、校验位/停止位设置错误、未发送结束符逐一核对设备手册参数AT指令类设备勾选“追加换行”接收区出现乱码编码不匹配、误把16进制数据当文本显示文本模式切换编码ASCII/UTF-8/GBK直接切到Hex模式看原始字节接收数据断断续续不完整数据量大、接收线程处理不及时采用定时器缓存方案使用DMA方式处理的设备参考其空闲中断机制16进制发送时提示长度错误输入了奇数个字符或含非法字符检查输入格式确保为“A5 5A 01”这种偶数长度且合法字符程序一运行就闪退未捕获异常例如枚举串口时无权限全局异常捕获或对串口操作逐段try-catch拔掉USB转串口再插上端口号变了驱动分配的COM口号变了去设备管理器手动固定COM口号或者程序实时刷新端口列表4.2 数据接收不完整的经典场景这里重点展开一下数据接收不完整的问题。很多人在调试时发现设备明明一次返回了100个字节程序却分了好几次才全部显示出来甚至最后还丢了几字节。这一般不是程序逻辑错误而是串口数据到达时间本身就存在不确定性。串口通信是流式的没有“一帧”和“一包”的概念。设备发送100字节可能分3个TCP/IP网络包或者USB帧到达电脑DataReceived事件就会触发3次你每次读到的只是其中一部分。如果你非要一次性把这100字节读出来就需要自己拼包。拼包的通用思路有两种。一种是按间隔时间拼包如果两次数据到达间隔超过某个阈值比如20毫秒就认为一帧数据接收完成。实现上就是每次收到数据时记录时间戳通过Timer检查时间差。另一种是按帧头帧尾或者长度字段拼包Modbus、自定义协议都定义了帧结构可以根据帧头识别新帧、根据长度字段判断还需等多少字节。我在工具里实现的是最常见也最简单实用的方式——超时拼包机制。每收到一段数据就放到一个缓冲区如果20毫秒内没有新数据到达将缓冲区当作完整一帧交给上层处理。对于绝大多数通信速率在115200以内的设备这个方案足够可靠。如果你的设备是高速率大数据量就得改用具体的协议解析了。4.3 USB转串口驱动导致的各种怪问题做串口开发绕不开USB转串口这个硬件设备。现在笔记本电脑基本都取消原生串口了大家调试用的都是CH340、CH341、FTDI这些芯片的USB转串口模块。这些模块本身很成熟但驱动的坑一点儿不少。最典型的问题是插上模块后设备管理器里能看到COM口但打开时端口错误或者用一会儿就掉线。这种问题排查思路如下先在设备管理器里查看端口对应的驱动是否正常如果没有黄色感叹号再确认驱动版本是否过旧。CH340芯片驱动冲突时建议先卸载旧版再官网安装最新版。FTDI芯片相对稳定但注意市面假货多假芯片装驱动会报“Non-genuine device detected”错误。另外多提一句尽量在开发阶段用同一种USB转串口模块。不同芯片的时序特性、缓冲区大小其实有微妙的差异同一套程序可能在这个模块上丝滑流畅换个模块就怪事频出。这不是代码问题是硬件差异导致的。4.4 UI线程、后台线程和“卡死”问题的真面目串口程序最常见的“未响应”情况九成是UI线程被阻塞了。典型场景是有人的DataReceived里直接调用了Thread.Sleep(500)或者做了一大堆耗时处理把后台线程拖住了而UI线程又在等待后台线程返回结果就是整个程序看起来像死了一样。正确做法我之前已经说过了DataReceived里只做“读数据 存缓存”这两件事耗时操作全部交给UI线程的Timer去处理。如果你需要在收到数据后马上执行某些设备控制逻辑比如收到某个特定帧就发送下一帧指令那就把逻辑放在单独的工作线程里用事件或者队列来触发。另外还有一个.NET老版本的坑在.NET Framework 4.5之前访问UI控件如果不调用Invoke会直接抛InvalidOperationException。现在用.NET 6/8的新项目WinForms在部分场景下放宽了这个限制但为了稳妥和良好习惯跨线程访问UI控件还是用BeginInvoke或者走我这种缓存方案别图省事。5. 工具扩展思路从串口助手到万能调试平台写到这里一个基础但完整的串口16进制收发程序就成型了。不过我得说句实在话这个工具只是起点。我做上位机开发这些年发现所有调试工具都会经历一个从“能用”到“好用”再到“强大”的演进过程这里给你几个可以继续扩展的方向。第一个扩展方向是协议解析插件化。把收到的字节流按照Modbus RTU、自定义帧格式等协议自动解析成结构化的字段值比如把“地址、功能码、寄存器地址、数据值、CRC校验”拆开显示在表格里。这个功能做出来后调试协议类设备会变得极其高效。第二个方向是数据可视化。把接收到的数值型数据实时绘成折线图、波形图比如温度传感器上报的温度曲线、电机控制器的转速波形。C#里用简单的GDI绘图就能实现也可以引用LiveCharts或ScottPlot这类开源绘图库。第三个方向是自动化测试。串口工具可以扩展成自动化测试工具预设多条指令序列按条件循环发送并自动校验返回数据是否符合预期。对产线测试场景来说这一功能直接能替代部分商业软件。第四个方向是多串口同时调试。做机器人、AGV小车这类项目时一个上位机往往要同时控制好几路串口设备比如底盘串口、机械臂串口、传感器串口。我在后期重构时把SerialPort实例封装成了一个统一的SerialDevice类然后用ListSerialDevice管理多路连接界面上用TabControl分页显示每一路的收发日志。这个方案在项目里跑得很稳需要同时调试多路设备时可以参考。说回这个16进制收发程序本身。我个人的体会是别小看这种“小事”代码串口通信是硬件和软件交汇的底层环节它看起来简单但把这件事做扎实了你对字节、编码、线程、事件这一整套体系的理解都会上一个台阶。以后再去做CAN总线调试、Socket通讯、工业协议解析这些基础能力全都用得上。最后再分享一个使用过程中的小技巧调试设备时尤其是第一次上电联调建议先把波特率从低往高逐步测试同时用16进制显示模式观察原始数据流。很多所谓“通信失败”的问题其实就是波特率不匹配或者接线松动看原始字节流一眼就知道设备到底有没有发出数据、发出的数据长什么样。这一点实测下来比盲改代码有效率得多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门