资讯详情

C# SerialPort读取电子秤重量:协议、帧解析与判稳实战

📅 2026/10/25 6:15:15 | 华诺云谱 👁 阅读
C# SerialPort读取电子秤重量:协议、帧解析与判稳实战
简介这份资源面向需要用上位机软件对接称重设备的 C# 开发者与物联网入门学习者围绕串行通信读取电子秤重量这一实际问题展开。示例工程以 System.IO.Ports.SerialPort 建立串口连接通过 DataReceived 委托事件接收秤体上报的数据并演示按设备协议解析 ASCII 重量字符串、在界面线程安全更新显示等关键环节涉及端口配置、事件回调、数据提取与跨线程更新等常见难点。压缩包共 21 个文件约 38KB其中 6 个 cs 源码文件承载主窗体与业务逻辑配套 sln 解决方案、csproj 工程文件和 resx 窗体资源可直接用 Visual Studio 打开调试另有编译输出的 exe、pdb 等文件便于快速验证。资源已有 340 人学习适合希望理解委托事件驱动串口收数流程、并在此基础上改写为自身称重采集模块的读者参考。1. 一根串口线背后的活为什么「读个重量」要写两百行代码产线上的称重工位操作员一手扶料箱一手拿扫码枪还得低头把秤上的数抄进系统出错率最高、最该被替掉的就是这一步。这份 C# 读取电子秤重量的工程资源解决的就是让上位机自己把重量拿到手打开串口、收全字节、切干净帧、转成 decimal、再判断秤稳没稳。它适合做上位机、做 MES 称重对接、做仓储出入库的 .NET 开发者也适合手上有秤有串口线、被半包粘包折腾过一轮的人。反直觉的地方在于写代码从来不是难点——难点是这台秤到底什么时候吐数、吐的又是什么格式格式猜错代码写得再漂亮也是白搭。2. 先定协议再动键盘三种发送模式与一张参数对照表拿到一台陌生的秤很多人第一反应是打开 Visual Studio 建项目。我一般不会这么干我会先找一根串口线、一个串口助手把原始字节抓出来看。这一步花十分钟能省掉后面两小时的瞎猜。原因很简单电子秤的串口协议没有强统一标准同一个厂不同型号都可能不一样波特率、校验位、帧结构、小数位全在说明书里光看代码是看不出来的。这一章要解决的就是“动键盘之前该确认什么”。很多人把秤当成一个“发字符串的设备”其实它更接近一个节奏固定的传感器你首先要搞明白的是它的说话方式。2.1 连续发送、指令应答、稳定触发先搞清楚秤在等谁说话电子秤的通讯模式大致分三类先对号入座后面的代码结构完全不同。连续发送自动发送 / Streaming是最常见的一种秤每隔 100500 毫秒主动吐一帧上位机一句话都不用说。它的优点是实现最简单你只要会收就行缺点是它不稳的时候也照发你在界面上看到数字一直在跳判稳逻辑得自己兜。适合静态称重、只要最终值、能容忍几百毫秒延迟的场景。指令应答Polling是上位机先发一个字符秤再回一帧。这个字符常见的是0x05ENQ、W、P或者干脆一个\r\n不同型号不一样。优点是节奏完全可控一台主机挂多台秤时可以轮着问不会互相打架缺点是要处理超时和重发秤没回你得能区分“没收到”和“正在称、暂时没数”否则界面就卡在那儿。稳定触发Stable-only是秤自己判断稳定之后才发帧里通常带一个ST标志。这个模式对上层最友好拿到的就是能直接用的数坑在于“一直不稳就一直不发”如果操作员手一直抖或者风一直吹上位机半小时收不到一个字节看起来像死机。选型的判断标准很朴素单台秤、只取最终重量走连续发送加自己判稳一台机器带两三个秤位走指令应答轮询对实时性敏感又不想写判稳算法走稳定触发但必须配一个“多久没数据就提示”的超时。2.2 参数对照9600-8-N-1 不是万能钥匙串口参数一共五个波特率、数据位、校验位、停止位、流控。前四个错一个就是乱码或者干脆没数据流控错了一般表现为“能收到但收不全”。参数组合常见出现在备注9600, 8, None, 1近些年出厂的台秤、计价秤先试这一组命中率最高4800, 7, Even, 1老式计价秤、部分标签秤7 位数据位配偶校验ASCII 完全够用19200 / 38400, 8, None, 1动态检重、高频采样帧间隔短对缓冲区写法要求高2400, 7, Even, 2很老的设备停止位是 2 位最容易漏配那张表里最容易被忽略的是7-E-1。很多新手默认 8 位数据位遇到老秤就一直收不到东西然后在代码里翻半天。7 位数据位的历史原因是早期串口只用 ASCII 的低 7 位第 8 位空着也是浪费索性拿来放校验。现在新设备基本不用了但工位上跑着的秤未必是新的。流控基本都设None。要注意的是 DTR 和 RTS 这两个引脚有些 USB 转串口模块靠 DTR 供电或者使能输出不置位就一个字节都出不来也有些秤把 DTR 当作复位信号一置位秤就重启。这个没有通用答案只能试——先按置位试收不到再改成不置位两次就能试出来。2.3 把一帧字节拆开看定长 ASCII、STX/ETX、十六进制三种壳参数对了接下来要认帧。常见的帧结构就那么几种帧样式样子判定要点ASCII CRLFST,GS, 12.345kg\r\n靠\r\n切帧字段顺序固定注意空格填充STX/ETX 包裹02 53 54 ... 03以 0x02 开头、0x03 结尾中间可能夹 0x0D定长二进制AA 55 01 04 D2 ...长度固定末尾带校验和或异或校验请求应答单帧发0x05回一串 ASCII节奏由上位机控制要配超时ASCII 那种最好处理但也最容易踩坑 12.345中间那两个空格是对齐填充不同重量时空格数量会变用固定下标去截字符串就会翻车。还有的秤发的是0012345这种定长整数小数点位置是隐含的说明书里写“小数位 3 位”你得自己除 1000。STX/ETX 那类要小心帧内容里可能包含0x0D如果你按\r切帧就切断了。这种必须按0x03找帧尾或者先按长度读满再校验。2.4 落地第一步用串口助手抓一段原始字节正式写代码之前先做这件事打开串口助手参数按 2.2 的表试一遍勾上“HEX 显示”让操作员压几个不同重量把收到的内容复制出来存成文本。这段日志就是后面所有解析逻辑的依据也是出问题时唯一能对照的东西。抓的时候注意三点一是空秤和满秤各抓一段看位数会不会变二是故意让秤晃一下看有没有US不稳和ST稳定的切换三是记录两次收到数据之间的时间间隔这个值决定了你后面判稳窗口要开多大。这三条信息抓全了解析代码基本一遍过。3. C# SerialPort 落地从打开端口到拿到第一帧重量协议确认完才开始写代码。.NET 里读串口只有System.IO.Ports.SerialPort这一个正牌选择桌面端够用跨平台的话新版 .NET 也带了这个包但 Linux 下要确认当前用户有没有串口设备的读写权限这个后面坑里会说。这一章的结构是按“打开 → 收字节 → 封装成类”推的每一步的代码都能直接抄但抄之前建议先看一眼注释很多参数改一个值行为就完全不同。3.1 端口对象怎么建属性、超时与打开时的两个坑先看构建和打开这一段using System.IO.Ports; // 参数顺序端口名、波特率、校验位、数据位、停止位 var port new SerialPort(COM3, 9600, Parity.None, 8, StopBits.One) { Handshake Handshake.None, // 秤基本不用软/硬流控 ReadTimeout 500, // 同步读的超时异步读用不到但留着防御 WriteTimeout 500, DtrEnable true, // 部分 USB 转串口靠 DTR 使能输出 RtsEnable true, ReceivedBytesThreshold 1 // 收到 1 字节就触发事件帧边界自己判 }; try { port.Open(); port.DiscardInBuffer(); // 关键丢掉打开瞬间残留的半帧 port.DiscardOutBuffer(); } catch (UnauthorizedAccessException ex) { // 端口被占用最常见的原因是串口助手没关 Console.WriteLine($端口被占用{ex.Message}); } catch (IOException ex) { // 线没插、USB 转串口没被识别、COM 号写错都会走到这里 Console.WriteLine($打开失败{ex.Message}); }这里有两个容易忽略的点。第一个是DiscardInBuffer()串口打开的那一瞬间硬件缓冲区里可能已经有半截数据比如秤正在发到一半如果不清掉你收到的第一帧就是残的解析器会因为找不到帧头而报错然后你就开始怀疑协议。第二个是ReceivedBytesThreshold默认值是 1但有人为了“减少事件触发次数”把它改成 20 或者更大结果低频率的秤一次只发 18 个字节事件永远不触发看起来就是“收不到数据”。ReadTimeout在纯事件驱动的写法里其实用不上但留着没坏处万一哪天加了一段同步读的调试代码没有超时会直接卡死线程。3.2 DataReceived 只做搬运事件线程、BytesToRead 与缓冲区DataReceived这个事件有几个特性必须记牢不然一定会翻车。第一它在后台线程池线程上触发不是 UI 线程。在这个事件里直接改TextBox.Text轻则跨线程异常重则界面假死。第二它触发的时机和你想象的不一样。串口驱动收到字节就触发但一次触发不代表收到一帧完整数据可能是半帧也可能是两帧粘在一起。所以事件里绝不能假设“这次读到的就是一条完整记录”。第三BytesToRead是“当前缓冲区里还有多少字节”不是“这一帧有多少字节”。正确写法是先读BytesToRead再Read到自己的数组里。基于这三点事件处理函数应该只做一件事把字节搬出来扔给解析器别的什么都不干。真正的解析、判稳、更新界面全部放到别的地方。这是整个设计里最重要的一条纪律违反了后面全是补丁。3.3 一个能直接抄的读秤类把上面几条落成一个类结构大概是这样using System; using System.IO; using System.IO.Ports; public sealed class ScalePort : IDisposable { private readonly SerialPort _port; private readonly byte[] _readBuffer new byte[1024]; // 只上报原始字节块解析交给上层 public event EventHandlerbyte[] RawChunkReceived; public ScalePort(string portName, int baudRate 9600, Parity parity Parity.None, int dataBits 8, StopBits stopBits StopBits.One) { _port new SerialPort(portName, baudRate, parity, dataBits, stopBits) { Handshake Handshake.None, ReadTimeout 500, WriteTimeout 500, ReceivedBytesThreshold 1, DtrEnable true, RtsEnable true }; _port.DataReceived OnDataReceived; } public bool IsOpen _port.IsOpen; public void Open() { if (_port.IsOpen) return; _port.Open(); _port.DiscardInBuffer(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { try { int available _port.BytesToRead; if (available 0) return; if (available _readBuffer.Length) available _readBuffer.Length; int read _port.Read(_readBuffer, 0, available); if (read 0) return; var chunk new byte[read]; Buffer.BlockCopy(_readBuffer, 0, chunk, 0, read); RawChunkReceived?.Invoke(this, chunk); // 只是搬运不做解析 } catch (TimeoutException) { /* 超时属于正常现象忽略 */ } catch (InvalidOperationException) { /* 端口已被关闭事件还在飞 */ } catch (IOException) { // 线被拔了会走到这里交给上层的重连逻辑处理 } } public void Send(byte[] data) { if (!_port.IsOpen) return; _port.Write(data, 0, data.Length); } public void Dispose() { _port.DataReceived - OnDataReceived; // 先解绑再关 if (_port.IsOpen) _port.Close(); _port.Dispose(); } }几个参数的意义再强调一遍_readBuffer给 1024 字节足够了单帧超过这个长度的秤很少见但如果你要接多台秤混在一起的总线可以给到 4096。RawChunkReceived传的是byte[]而不是string因为字节转字符串这一步必须等帧切完再做提前转会丢信息。Send方法留着给指令应答模式用连续发送模式下它是空转的。另外注意Dispose里的顺序先解绑事件再Close()。如果反过来Close()过程中如果还有字节进来事件会触发在你已经释放的对象上抛出的异常还很难定位——这个坑我踩过界面上表现为“点关闭按钮偶尔崩”日志里什么都看不出来。4. 帧解析与数据清洗把 20 字节的原始流变成 decimal 重量拿到字节块只是原料真正决定这套代码能不能上线的是解析层。解析层要干三件事把不完整的帧攒起来、把完整的帧切出来、把切出来的字符串转成带单位和状态的数值。这三件事对应的典型问题分别是半包、粘包和数值歧义。我一般会把解析写成独立于串口的类输入byte[]、输出结构体这样不接秤也能用单元测试跑后面自测那一章会用到这个特性。4.1 半包、粘包与脏数据字节缓冲的正确切法半包是指一帧被拆成两次事件送达粘包是指两帧挤在一次事件里。两者的解法是同一个维护一个字节缓冲区每次新数据追加进去然后循环找帧尾找到一个切一个切不出来就留着等下一批。using System.Collections.Generic; using System.Text; public sealed class LineFrameParser { private readonly Listbyte _buffer new Listbyte(512); private const int MaxBuffer 4096; // 防御脏数据不能把内存吃光 // 按 \n 切帧\r 作为可选的帧尾前一字节去掉 public IEnumerablestring Feed(byte[] chunk) { _buffer.AddRange(chunk); while (true) { int end _buffer.IndexOf((byte)\n); if (end 0) { // 缓冲里全是垃圾又一直等不到帧尾砍掉最老的部分防止无限增长 if (_buffer.Count MaxBuffer) _buffer.RemoveRange(0, _buffer.Count - 256); yield break; } int len end; if (len 0 _buffer[len - 1] (byte)\r) len--; var line Encoding.ASCII.GetString(_buffer.GetRange(0, len).ToArray()); _buffer.RemoveRange(0, end 1); yield return line; } } }这段代码的关键在三个地方。IndexOf找的是字节0x0A不是字符\n在字节层面找才不会因为编码问题出错。MaxBuffer那个分支是保命的如果接线接触不良收到的全是随机噪声缓冲区会一直涨最后内存爆掉程序死得莫名其妙。切帧之后RemoveRange(0, end 1)要连帧尾一起删不然下一轮永远切出空串。如果是 STX/ETX 那种帧把IndexOf((byte)\n)换成找0x03并且在切完之后校验帧头是不是0x02不是就丢掉——总线上的噪声经常会产生假的帧尾。4.2 字符串到 decimal符号、小数位、单位与毛净重切出来的字符串长这样ST,GS, 12.345kg。三个字段分别代表稳定状态、毛净重、数值和单位。用正则比按逗号 Split 更稳因为空格数量不固定。using System; using System.Globalization; using System.Text.RegularExpressions; public readonly record struct WeightReading(bool IsStable, bool IsNet, decimal Kilograms); public static class WeightParser { // ST/US 稳定/不稳GS/NT 毛重/净重 private static readonly Regex Pattern new Regex( ^(?stST|US)\s*,\s*(?modeGS|NT)\s*,\s*(?sign[-])\s*(?num\d(?:\.\d)?)\s*(?unitkg|g|lb)?, RegexOptions.Compiled | RegexOptions.IgnoreCase); public static bool TryParse(string line, out WeightReading result) { result default; if (string.IsNullOrWhiteSpace(line)) return false; var m Pattern.Match(line.Trim()); if (!m.Success) return false; // 用 InvariantCulture避免在德语等区域把小数点当千分位 if (!decimal.TryParse(m.Groups[num].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var value)) return false; if (m.Groups[sign].Value -) value -value; string unit m.Groups[unit].Success ? m.Groups[unit].Value.ToLowerInvariant() : kg; decimal kg unit switch { g value / 1000m, lb value * 0.45359237m, _ value }; result new WeightReading( IsStable: m.Groups[st].Value.Equals(ST, StringComparison.OrdinalIgnoreCase), IsNet: m.Groups[mode].Value.Equals(NT, StringComparison.OrdinalIgnoreCase), Kilograms: Math.Round(kg, 4, MidpointRounding.AwayFromZero)); return true; } }NumberStyles.Number配CultureInfo.InvariantCulture是必须的。如果服务器区域设置是德语decimal.Parse(12.345)可能把它理解成一万二千三百四十五这类问题在开发机上永远复现不了一到客户现场就出。record struct需要 C# 10 以上低版本换成普通类或者struct加只读属性就行逻辑一样。单位换算放在解析层而不是 UI 层是因为入库、对接 MES 用的都是公斤换算只做一次最不容易错。如果秤发的是定长整数比如0012345协议注明小数 3 位那就在decimal.TryParse之后补一句value / 1000m;别去挪小数点字符串容易在负数上翻车。4.3 判稳有 STABLE 位就用没有就上滑动窗口秤发出来的数一直在小幅跳这是物理特性不是故障。把跳动的数直接写进数据库后面做报表的人会来找你。最省事的判稳是信秤自己帧里带ST就用带US就等。但不是所有秤都给这个标志有的只发数值。这时候就得上滑动窗口using System; using System.Collections.Generic; using System.Linq; public sealed class StableFilter { private readonly int _window; private readonly decimal _tolerance; private readonly Queuedecimal _recent new Queuedecimal(); // window看最近几帧tolerance极差在多少公斤以内算稳 public StableFilter(int window 5, decimal tolerance 0.002m) { _window window; _tolerance tolerance; } public bool Push(decimal kg, bool scaleSaysStable, out decimal settled) { settled kg; if (scaleSaysStable) return true; // 秤自己说稳了直接信 _recent.Enqueue(kg); while (_recent.Count _window) _recent.Dequeue(); if (_recent.Count _window) return false; if (_recent.Max() - _recent.Min() _tolerance) { _recent.Clear(); // 波动太大窗口作废重来 return false; } settled Math.Round(_recent.Average(), 3); return true; } }window和tolerance这两个参数要按秤的量程和分度值调。分度值 1 克的秤tolerance给 0.002 到 0.005 都合理分度值 10 克的秤给 0.02。window给 5 意味着要连续 5 帧都在容差内按 10Hz 的发送频率算是 0.5 秒操作员基本感觉不到延迟。窗口太大比如 20 帧会导致响应变慢操作员放上去两秒了界面还没出数会被投诉窗口太小比如 2 帧又会被一次偶然抖动骗过去。我的经验是按“操作员放稳一个箱子大概需要多久”来定一般 0.3 到 0.8 秒之间。5. 避坑与排查五个让秤「没反应」的真实原因这套东西在开发机上跑通只完成了三成。剩下七成都在现场而且现象高度相似——都是“没反应”但原因天差地别。下面五条是按我遇到的频率排的。5.1 五个翻车现场现象、原因、解决现象一端口能打开一个字节都收不到。原因有三种可能参数不匹配波特率差一档就全是乱码或者空白这台秤是指令应答模式在等你先开口秤的面板菜单里“连续发送”被关掉了只保留了按键打印。解决先在串口助手里分别试9600-8-N-1和4800-7-E-1两组再主动发0x05或者W\r\n看有没有回最后翻秤的菜单找通讯设置那一项确认发送方式是“连续”还是“命令”。三步走完基本能定性。现象二重量偶尔少一位或者出现几个看不懂的怪字符。原因是DataReceived里用了ReadExisting()拼字符串一帧被切断时拼出来的半截字符串又被拿去解析边界就飘了。ReadExisting会按当前编码解码遇到半个多字节序列还会丢字节。解决一律按字节收按帧尾切切不出来留缓冲等下一批。上面 4.1 那个类就是干这个的别图省事用字符串。现象三跑两三个小时界面就卡死关窗口也关不掉。原因通常是在DataReceived里直接Invoke到 UI 线程。秤 10Hz 发、界面 10Hz 刷一开始没事跑久了消息队列堆起来主线程忙不过来。更糟的是关闭窗口时事件还在触发Invoke等主线程主线程等Close()返回互相锁死。解决事件里只入队解析在后台线程UI 刷新做节流最多 5 到 10 帧每秒。关闭前先解绑事件再Close()这一步别省。现象四开发机好好的换到工位电脑就读不到。原因可能是 USB 转串口的芯片不同导致 COM 号变了代码里写死COM3直接失效也可能是电平不匹配秤出来的是 RS232 的 ±12V被直接怼到了 TTL 口上还有一种是转换器供电不足线一拉长就丢字节。解决端口名做成配置项从配置文件读别写死接线前确认电平标准RS232 / RS485 / TTL 三者不通用接错可能烧口长距离换屏蔽线或者带独立供电的转换器。现象五界面显示 12.34实际重量是 123.4。原因是小数点位置隐含。很多秤发定长整数小数位在说明书里写死比如“小数点后 3 位”你直接按原样显示就差了三个数量级。还有一种是单位问题发的是 lb 或者斤没换算就当公斤入库。解决翻协议手册确认小数位换算放在解析层统一做到公斤UI 只负责显示。别在 UI 层做单位判断那里做一次、导出报表那里又漏一次。5.2 排查顺序从物理层往上走别一上来就怀疑代码现场排查最忌讳的是打开断点就开始单步。正确的顺序是从下往上先看设备管理器里有没有这个 COM 口、有没有黄色感叹号再用串口助手确认能不能收到字节然后确认参数和帧格式最后才怀疑自己的解析代码。这个顺序的道理很简单物理层的问题占了一多半而且验证成本最低。串口助手一分钟能确认的事在代码里可能要调半小时。我习惯在项目里内置一个“原始数据窗口”把收到的 hex 和解析后的文本并排显示现场出问题直接看这个窗口比让人拍照发微信快得多——这个东西就是这套系统的黑匣子出事之后能回放。6. 进阶验证用模拟秤闭环自测与断线重连的收尾技巧不接秤能不能测能而且必须测。我一般会写一个假秤按设定的频率往一个Stream里吐 ASCII 帧配合虚拟串口对就能在没有硬件的情况下把半包、粘包、超时、重连全跑一遍。// 自测用假秤10Hz 吐帧带 ±2g 抖动和随机的不稳标志 static async Task RunSimulatorAsync(Stream output, CancellationToken token) { var rnd new Random(20240101); decimal baseKg 12.3m; while (!token.IsCancellationRequested) { baseKg (decimal)(rnd.NextDouble() * 0.004 - 0.002); string st rnd.Next(0, 10) 0 ? ST : US; string line ${st},GS,{baseKg,8:0.000}kg\r\n; var bytes Encoding.ASCII.GetBytes(line); await output.WriteAsync(bytes, 0, bytes.Length, token); await Task.Delay(100, token); // 100ms 一帧 } }这个假秤的价值在于可复现。把Random的种子写死每次跑出来的抖动序列一样你改一版判稳算法就能直接对比结果。另外故意混入US帧能把“有标志位”和“没标志位”两条分支都跑到。重连这块我一般用一个独立的循环指数退避别在DataReceived里做async Task KeepAliveAsync(ScalePort scale, string portName, CancellationToken token) { int delay 1000; while (!token.IsCancellationRequested) { try { if (!scale.IsOpen) { scale.Open(); delay 1000; // 连上就重置退避 Console.WriteLine(${portName} 已连接); } } catch (Exception ex) { Console.WriteLine($打开 {portName} 失败{ex.Message}{delay}ms 后重试); delay Math.Min(delay * 2, 15000); // 1s → 2s → 4s … 封顶 15s } await Task.Delay(delay, token); } }退避上限一定要有。见过没设上限的版本跑一晚上delay涨到几十分钟第二天早上插好线还得等半小时才恢复操作员以为坏了。自测用例我一般列这么几张表单帧正常解析、两帧粘在一起、一帧拆成三次到达、混入随机噪声字节、连续US帧后突然ST、拔线 30 秒再插回。这六条跑通现场基本不会出大问题。注意日志要双写原始 hex 和解析结果各存一份带上毫秒时间戳。只存解析结果的话出问题你没法判断是秤发错了还是你解错了。说实话这类项目真正的成本不在写代码在于每接一款新秤就要重新确认一遍协议。所以后来我养成了一个习惯每接一款新秤先花二十分钟抓包、把帧格式和参数写进配置文件和一份说明文档代码一行不改只改配置。这个习惯救过我好几次——同一批货里混进来两台不同型号的秤改个配置就能跑不用重新发版。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑