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

C#串口通信实战:从字节流解析到上位机稳定收发

上个月朋友寄过来一台用STM32F103C8T6做控制的小设备让我帮忙用C#写个上位机通过串口通信把数据采上来。东西不复杂但前后折腾了两天——不是单片机那边的问题而是C#串口通信里那些文档不会明说的细节接收事件分包、UI线程卡顿、编码错乱、拔插USB转串口线后无法恢复……每一件单独看都是小问题串在一起就非常折磨人。这篇文章不是SerialPort类的API翻译而是把我这些年做C#上位机、对接各类串口设备的经验整理出来。从底层传输原理讲到实际封装再到疑难排查。不管你是用C#对接STM32、51单片机、扫码枪、Modbus设备还是串口屏都能找到可落地的参考。1. 串口通信不是“发字符串”那么简单先搞懂底层在传什么很多初学者把串口通信理解为“两个程序之间互相发字符串”这个认知会在第一次对接真实硬件时碰壁。实际上串口通信传输的是一个个字节也就是byte。至于这些字节是ASCII字符、GBK中文、二进制协议还是Modbus报文完全由通信双方的协议决定。C#里的SerialPort类只是帮你把硬件收到的字节流送进程序它本身不关心数据含义。1.1 从RS232/RS485到USB转串口物理层决定了你的代码写法大多数现代PC早就没有原生RS232串口了所以我们日常用的“串口”基本是USB转串口设备比如CH340、CP2102、FT232。这类设备在系统里被虚拟成一个COM口对C#来说打开COM3和使用古老的物理串口没有任何区别。但物理层差异会直接影响你的工程选型RS232电平常见于老式PLC、单片机调试口、扫码枪。传输距离一般在15米以内点对点通信。RS485电平常见于工业现场、Modbus总线、多机通信。用A/B差分信号传输距离可达1200米支持一主多从。TTL电平STM32、51单片机的最小系统板直接引出的一般是TTL串口。它和RS232电平不兼容所以很多设备需要MAX232芯片转电平。你在C#里做的事情其实是透明的不管是哪种电平系统串口驱动最终都会把收到的数据统一变成字节流交给你。唯一要注意的是接线TTL的TX接对方的RXRX接对方的TXGND必须共地。很多“通信没反应”的问题就是TX/RX接反了或者GND没连跟代码一点关系都没有。1.2 波特率、数据位、校验位、停止位为什么9600能通而4800不通串口通信的参数设置是C#里SerialPort构造函数最常看到的五个参数它们的组合决定了通信双方能否“听懂”对方参数常见值说明波特率9600、115200每秒传输的符号数双方必须一致数据位8、7一个字节中实际承载数据的位置校验位None、Odd、Even简单检错机制停止位One、Two标志一个字节传输结束的电平宽度有朋友问过“为什么串口波特率9600能通信4800反而没有数据”——这个问题通常和波特率误差有关。单片机常用的晶振频率从11.0592MHz到72MHz都有串口波特率是由定时器/计数器分频产生的不是所有波特率都能精确匹配。比如某个51单片机用11.0592MHz晶振9600波特率误差接近0%但4800波特率可能因为分频方式不同导致误差变大。如果双方晶振不同误差积累到一定程度就收不到数据。解决办法是优先使用设备厂商推荐的波特率不要自己随便改换用带自动波特率检测功能的芯片或者用逻辑分析仪看波形。此外还有“数据位7奇偶校验”这种老协议写法。现在大多数设备使用8N18数据位、无校验、1停止位如果你对接的设备文档写了8E1或7E1一定不要凭感觉改成8N1否则解析出来的数据全是乱的。1.3 SerialPort类背后的字节流与缓冲区C#的SerialPort类本身维护了一个内部接收缓冲区。当串口硬件收到数据时驱动会把字节写入缓冲区同时触发DataReceived事件。这里有个关键点DataReceived事件是在后台线程触发的而且它并不是“收到一帧完整数据才触发”而是缓冲区里有一定数据就触发。数据可能被拆成好几段也可能一次来了好几帧粘在一起。这个问题在接收处理时特别重要。很多人第一次写接收代码是这样的private void SerialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e) { string data serialPort1.ReadExisting(); textBox1.AppendText(data); }这种写法在数据量小、频率低的场景下好像也能跑但只要设备连续发送数据Text变长的速度就会让人抓狂而且你根本无法判断一帧数据的边界。正确做法是把字节先缓存起来按协议帧的完整长度解析后面第3章我会专门讲。2. 写一个能用的C#串口通信模块前先做好这几件事很多人拿到项目就开始写SerialPort的Open和Read但我建议先花半小时做两件事确认开发环境、分析通信协议。这两件事没做好后面全是返工。2.1 环境选型和项目工程落地C#串口通信在技术栈上并不挑版本。.NET Framework 4.x、.NET Core 3.1、.NET 6/7/8都能用System.IO.Ports.SerialPort。但对于新手我建议直接用Visual Studio 2022创建一个.NET 8的Windows窗体重应用或者C# WinForms。这里有个常见的坑别人用VS2019创建的C#上位机源码你用VS2015打开大概率会失败。因为.csproj文件格式和老版本不一致NuGet包和C#语言版本也可能不兼容。反过来如果你用VS2015开发又希望让别人用VS2019/2022打开那就尽量别用太新的语言特性。我曾经有一个项目在.NET Framework 4.5.2上用VS2015写的交给客户后被他们用VS2022升级打开结果代码里用了async void事件处理器的重载签名都变了编译直接报错。开发串口程序时我通常还会单独安装一个NuGet包System.IO.Ports。虽然.NET 8桌面应用已经内置了串口支持但如果你写的是类库、需要在其他项目复用显式引用这个包会更省心。2.2 协议分析和字节拼装不要一上来就写代码串口通信的核心是协议。设备端是STM32、51单片机、PLC还是扫码枪决定了你的收发帧格式。常见协议结构一般包括帧头比如0xAA 0x55用来识别一帧开始。命令字/地址告诉设备你要读什么、写什么。数据长度告诉接收方这帧有多少有效数据。数据区真正要传的内容。校验CRC16、累加和、异或校验等。帧尾0x0D 0x0A之类的结束符。我开始设计协议前会把通信双方的字节流画出来例如请求帧: AA 55 01 03 00 C8 0D 0A 帧头 帧头 命令 长度 数据 校验 帧尾然后用一个Hex字节数组把请求发出去再在接收端按同样的规则去匹配帧头、解析长度、提取数据、校验。举个例子如果协议是“读取温湿度返回4字节温度4字节湿度”那么收到数据后需要先找到帧头再判断数据长度是否足够最后按偏移量截取字节数组。截取字符串的方法在纯文本协议时可用但在二进制协议里不要用Substring去截字符串因为字节到字符串之间的编码转换会产生很多坑。2.3 SerialPort核心封装打开、关闭、收发、异常处理生产环境里的串口通信不能把SerialPort的调用散落在窗体各个按钮事件里。我会封装一个SerialPortManager类统一管理端口枚举、打开、关闭、发送、订阅接收事件和异常恢复。public class SerialPortManager : IDisposable { private SerialPort _port; public event Actionbyte[] DataReceived; public SerialPortManager(string portName, int baudRate 9600) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.DataReceived OnDataReceived; } public void Open() { if (_port.IsOpen) return; _port.Open(); } public void Close() { if (_port.IsOpen) _port.Close(); } public void Send(byte[] data) { if (!_port.IsOpen) throw new InvalidOperationException(串口未打开); _port.Write(data, 0, data.Length); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int count _port.BytesToRead; byte[] buffer new byte[count]; _port.Read(buffer, 0, count); DataReceived?.Invoke(buffer); } public void Dispose() { _port.DataReceived - OnDataReceived; Close(); _port.Dispose(); } }这里有一个细节DataReceived事件里尽量不要用ReadLine()、ReadExisting()这些偏向文本的方法。要么用Read(byte[], int, int)读原始字节要么用ReadBufferSize控制缓冲区大小。因为很多协议是二进制的用文本方法容易把0x0A这样的换行符误当成结束标志。3. 数据上来的那一刻才是问题的开始接收处理与UI刷新串口通信的困难不在打开串口而在怎么把“断续的字节流”还原成“完整的一帧帧数据”再流畅地显示到界面上。这一章的内容可以说是串口上位机开发里最容易翻车的地方。3.1 串口接收事件“一段一段”到不能直接按帧解析很多设备的发送是有间隔的比如每100ms发一帧每帧16字节。但SerialPort的DataReceived事件可不会保证每次触发正好拿到16字节。它可能先触发拿到7字节再触发拿到9字节也可能缓冲区里积累了3帧共48字节一次性触发。如果直接按“来一次事件就解析一次”处理你的程序大概率会经常出错。解决思路很简单先在内存里开一个Listbyte暂存收到的数据每次收到数据都追加进去然后在一个循环里尝试从缓存中解析出完整帧。解析成功后把这帧从缓存中移除再继续解析下一帧。核心逻辑可以用一个BufferManager来做public class ReceiveBuffer { private readonly Listbyte _buffer new Listbyte(); public void Append(byte[] data) _buffer.AddRange(data); public byte[] TryParseFrame() { // 查找帧头 int headIndex FindHead(); if (headIndex 0) return null; if (headIndex 0) _buffer.RemoveRange(0, headIndex); // 丢弃帧头前的垃圾数据 if (_buffer.Count 2) return null; int length _buffer[1]; // 假设协议的第二个字节是数据长度 int frameLength length 4; // 帧头长度数据校验 if (_buffer.Count frameLength) return null; byte[] frame _buffer.GetRange(0, frameLength).ToArray(); _buffer.RemoveRange(0, frameLength); return frame; } }这样无论设备是连续发多帧还是拆成小包发最终都能按协议帧的边界正确解析出来。注意我这里用_buffer[1]做长度字段只是举例实际协议字段位置要以你手上的协议文档为准。3.2 循环采集数据导致UI卡顿根因和四个解法“C#循环数据采集和UI刷新卡顿”是一个几乎人人都会被拷打的典型问题。原因很简单SerialPort的DataReceived事件在后台线程触发而WinForms/WPF的UI控件不能在非UI线程直接修改。很多人为了图省事直接在事件里写Invoke回UI线程去刷新TextBox、Chart、DataGridView。如果设备每50ms来一帧数据UI每50ms就被强制刷新一次界面就会卡得没法看因为UI线程长期被刷新逻辑霸占连鼠标拖动窗口都要排队。我常用的四种解法从易到难排降低刷新频率把接收到的原始数据都存到内存队列里用System.Windows.Forms.Timer每隔200ms~500ms刷新一次UI。用线程安全队列ConcurrentQueuebyte[] incomingQueue接收线程只入队UI定时器出队并更新界面。这样UI线程的压力可控。数据聚合显示如果只是显示数字没必要每帧都刷新TextBox。可以在UI定时器里把这段时间的最大值、最小值、平均值显示出来。高频实时曲线用双缓冲绘图控件用BufferedGraphics或图表库自己控制重绘区域避免每帧Full Refresh。实时性要求和UI流畅度要平衡。很多设备其实100ms发一帧200ms刷新一次UI完全够用而UI卡顿的概率会大幅下降。如果你非要做毫秒级实时显示那就不应该用常规的UI控件而是用专门的高性能曲线控件或者在独立面板里绘制。3.3 乱码、丢数据、字符串截取编码问题一锅端串口通信里“乱码”是出现频率最高的关键词。绝大多数乱码不是硬件问题而是发送方和接收方的编码方式不一致。比如设备返回的是GBK编码的中文电文你在C#里用Encoding.UTF8.GetString去解码出来的就是一团乱码反过来也一样。正确的做法是先把设备协议文档确认清楚纯ASCII、UTF-8、GBK还是ANSI。然后再对应选择解码方式Encoding.UTF8.GetString(buffer, 0, count); Encoding.GetEncoding(GBK).GetString(buffer, 0, count);另外在拆解字符串时比如要截取从第2个字节开始的后4个字节这4个字节如果是数值型数据根本不应该转成字符串而是应该转成UInt16、Int32这类数值ushort temperature BitConverter.ToUInt16(buffer, 2);很多初学者喜欢把所有接收数据都用Encoding.ASCII.GetString变成字符串再用Substring截取再int.Parse转数字。这套流程在纯文本协议里勉强能用但在二进制协议里会出大事——二进制数里很多字节可能不是有效的ASCII字符转换成字符串后长度、内容都会改变Substring的位置全部对不上。所以我的原则是二进制协议全程用byte[]处理字符串截取只在真正处理ASCII协议时才使用数值从字节转换用BitConverter。顺带一提串口发送中文时也要注意编码。如果设备要显示中文通常需要发送GBK字节而不是UTF-8字节。3.4 扫码枪等外部设备的“自动触发”事件怎么接入“C#扫码枪触发事件”也是热搜词。大多数USB或串口扫码枪本质上是一个“键盘输入设备”或者“串口设备”。如果它默认模拟键盘焦点在哪个文本框它就往哪里输入字符如果它被配置为串口模式就需要走串口接收。用C#串口接扫码枪最常见的模式是扫码枪扫描条码后自动发送一串ASCII字符并以回车0x0D结尾。这时用SerialPort.ReadLine()或者接收缓存里按回车字符截断就能得到条码内容。注意扫码枪的波特率默认常见是9600但部分新款是115200要对上设备配置。接入扫码枪事件时我建议不要在DataReceived里直接弹窗或查询数据库。扫到一个条码往往意味着要马上执行一次产品查询、库存更新、记录入库。这些操作如果放到后台线程界面会阻塞甚至卡死。正确的做法是先把条码解析出来扔进ConcurrentQueue再由后台任务或者异步方法去处理。这样连续快速扫码时程序也能保持响应。4. 串口通信的扩展场景Modbus、串口屏、STM32/51实战学会了基础的收发解析你就能应对大多数串口场景了。但在实际工控项目中还会碰到一些现成的通信协议和设备比如Modbus RTU、串口屏、单片机自定义协议。这一章挑三个典型场景讲。4.1 C#做Modbus RTU主站NModbus4的使用与坑Modbus是工业领域最常用的串口协议之一RTU模式通过串口传输二进制帧CRC校验是必须的。C#里最有名的库是NModbus4它提供ModbusSerialMaster类可以很轻松地读写保持寄存器、线圈、离散输入。using Modbus.Device; using (var port new SerialPort(COM3, 9600, Parity.None, 8, StopBits.One)) { port.Open(); var master ModbusSerialMaster.CreateRtu(port); ushort[] registers master.ReadHoldingRegisters(1, 0, 10); }这个库用起来简单但有几个坑你必须知道它底层还是依赖SerialPort的DataReceived机制所以如果你同时自己处理接收事件会和库内部读取冲突造成读到的数据错乱。默认超时时间可能太短。当设备响应慢、或串口通信受干扰时ReadHoldingRegisters会抛超时异常。你需要把串口的ReadTimeout和WriteTimeout调大并在catch里做重试。串口波特率9600在Modbus上最常见但一定不要自己改设备的波特率要和从站保持一致。如果你不想用这个库也可以用3.1节的接收缓冲区自己实现Modbus RTU帧解析多机轮询时用一个队列管理轮询命令避免同时发多条命令导致从站响应冲突。4.2 和STM32/51单片机通信的帧结构设计STM32F103C8T6、51单片机这类MCU是C#上位机最常见的对接对象。MCU端的资源有限协议越简单越好。我比较推荐的帧结构是字段长度说明帧头2字节0xAA 0x55命令1字节0x01读取0x02写入等数据长度1字节后面的数据区字节数数据区N字节按照命令定义校验1字节前面所有字节的异或和C#端发送读取命令时按字节拼好数组计算异或校验然后Write出去。单片机收到后做同样的校验如果校验不对就丢弃。反过来单片机返回数据时上位机用3.1节的接收缓冲区解析。这里要特别提醒MCU端的串口中断处理如果写得不够好你上位机发送命令的间隔太快单片机可能来不及处理导致丢帧。所以上位机发命令时一般要设置一个应答确认机制超时未收到回复就重发。重发次数限制3~5次不要无限重发否则一旦设备掉线你会把整个串口堵死。4.3 陶晶驰串口屏等显示设备的通信注意事项串口屏是另一种常见外设比如陶晶驰、迪文、大彩等品牌。它们通常用串口接收指令来切换画面、显示文本、设置进度条。以陶晶驰为例它的串口屏往往内置了组态软件只需要往串口发送特定格式的指令比如在变量地址写入数值。和普通MCU不同串口屏对指令时序要求不太高但要注意几点有些串口屏支持主动上传触摸事件。这时候你也需要接收解析。屏的通断、复位和上位机启动顺序很重要。很多串口屏在上电后的前几百毫秒不响应命令如果上位机一打开串口就疯狂发指令指令会丢失。建议串口打开后延时200ms~500ms再开始初始化。串口屏的指令集文档会写“发送HEX”还是“发送ASCII”。比如有些屏要发0xAA 0x00 0x01这样的HEX帧有些屏要发page0这个ASCII字符串。两者代码写法完全不同一定要先确认。5. 串口调试路上绕不开的坑排查思路与工具最后聊一聊真到了现场通信不通、数据乱、设备掉线时该怎么排查。我见过太多人在代码里一层层加日志搞了一天最后发现是USB转串口线的问题。所以排查顺序比排查深度更重要。5.1 为什么换一根USB转串口线通信就挂了USB转串口线的芯片有CH340、CP2102、FT232、PL2303等。不同芯片在驱动、稳定性、兼容性上差别很大。CH340便宜但部分兼容性一般FT232贵但稳定工业客户设备常常指定要用它。很多时候“同一个程序在开发机上跑得好好的换到客户工控机上就不通”就是因为客户的工控机上没有对应驱动或者驱动版本不对。排查方法很简单打开设备管理器看“端口COM和LPT”下面有没有带黄色感叹号的设备。有感叹号就是驱动问题而不是代码问题。另外不同USB口供电质量不一样。有些设备插在前面板USB口上工作正常插到后面板USB口就乱码多半是供电或电磁干扰问题。这种情况可以考虑换带屏蔽层的串口线、加磁环或者用USB隔离器。5.2 串口被占用、设备掉线、接收中断的恢复策略C#里打开串口时如果提示“拒绝访问”或“端口被占用”通常有三种原因上一次程序异常退出没有正确关闭串口另一个程序占用了相同的COM口号USB转串口设备被重新插拔后COM口号变了但你的程序还按旧的COM口号打开。我的策略是在启动时自动枚举SerialPort.GetPortNames()把可用端口列到下拉框而不是固定写死COM3打开失败时给出异常提示并在2秒后重试程序退出时保证调用Dispose关闭串口还可用SerialPort.BaseStream的DataReceived事件和ErrorReceived事件捕获断线、溢出等情况。设备掉线也是老问题。USB转串口设备在通信过程中被人拔掉再插上后COM口号可能会变成COM4或者COM7程序如果还持着COM3就直接异常。比较成熟的方案是开启一个后台线程定期检测串口设备是否存在不存在就自动重新枚举并重连。5.3 调试工具、日志和复现手段写C#串口程序时我建议手边常备三样工具串口调试助手用于在没有C#程序的情况下直接和硬件设备通信确认设备本身正常。逻辑分析仪当怀疑硬件时序、波特率、字节内容出错时用逻辑分析仪抓UART波形能直接看到TX/RX的电平变化非常直观。Hex日志自己在程序里打印收发字节的十六进制。不要只打字符串很多不可见字符在字符串界面是空白只有Hex日志才能还原现场。我的日志打印一般是这样public static string ToHex(byte[] data) { return string.Join( , data.Select(b b.ToString(X2))); }无论是发送还是接收都记录一条带时间戳的Hex日志。这样设备说“我发了0x01 0x03 0x00 0x00 0x00 0x0A”你的Hex日志里一看就知道程序到底收到了什么、改了没有。排查乱码问题时Hex日志比任何高级调试器都管用。串口上位机开发最考验人的不是C#语言本身而是你对“字节流边界”“线程模型”“设备协议”这三件事的理解。我踩过不少坑也因为这些坑总结出一套固定的封装和排查流程。如果你的项目刚起步不要急着追求炫酷的界面先把一个稳定的串口通信层写好再往上加业务逻辑。后面不管接的是什么设备都会省心很多。
分享:

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

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