资讯动态

C#串口读取电子秤重量:协议解析、稳定滤波与异常排查

发布时间:2026/10/26 3:19:04 来源:尧图企业网站定制
简介这份C#读取电子秤重量DEMO面向需要与串口电子秤对接的C#开发者聚焦串行通信、委托事件与重量数据解析等实用技能。压缩包共21个文件以6个cs源码、sln与csproj工程文件以及exe、resx资源、settings配置等为主整体约38KB目录虽小但保留了完整的工程组织与编译运行结构。已有340人学习下载。内容围绕SerialPort端口参数配置、DataReceived事件注册、接收数据读取与协议解析以及UI显示和线程安全等环节展开能够帮助读者理解从串口收数到界面呈现的完整链路。对于仓储称重、生产检测等需要实时读取电子秤数据的场景这份DEMO可作为小型参考实现便于按设备协议调整解析逻辑并快速搭建自己的读数程序。1. 从一台串口秤说起C# 读取电子秤重量不是打开端口就算完产线上最容易被低估的接口往往是一台电子秤。C# 读取电子秤重量看起来只是打开串口、读几个字节实际到了现场波特率对不上、协议帧拆错、读数一直跳任何一个都能让上位机停在那里。这份资源围绕 C# 读取电子秤重量这条链路把串口接入、帧解析、重量换算、稳定滤波和异常排查拆成可复用的代码骨架适合做产线上位机、称重工位、实验室数据采集的开发者也适合刚接触 RS232/RS485 串口通信、需要从零跑通一台秤的新手。它解决的不是“显示一个数字”而是让重量值可校验、可重连、可记录。2. 串口接入与设备握手先把字节流稳定收上来电子秤接入上位机的第一道门槛不是解析而是“收得稳”。很多现场问题在打开串口那一刻就埋下了参数抄错一位、流控开错、DTR/RTS 状态不对后面解析写得再漂亮也读不到完整帧。我的习惯是先不写业务代码用最小读取程序把原始字节打印出来确认帧头、帧尾和发送节奏再往上叠解析和界面。这个顺序看起来慢实际能省掉大量“到底是协议错还是接线错”的猜测。2.1 串口参数怎么定波特率、数据位、停止位、校验位电子秤常见的是 RS232 或经转换器接入的 RS485上位机侧最终都表现为一个串口。参数不能凭经验猜必须看设备说明书或秤体菜单。下面这张表是现场最常见的两组配置只作为排查起点最终以设备实际设置为准。参数常见配置 A常见配置 B说明波特率96004800 / 19200两端必须完全一致数据位87ASCII 协议常见 7 位或 8 位停止位11少数设备用 2 位校验位NoneEven7E1 在部分秤上很常见流控NoneNone除非说明书明确要求编码ASCIIASCII二进制协议不走字符编码实际做的时候我会先把串口参数做成配置项而不是写死在代码里。现场换一台秤、换一个转换器参数就可能变。另一个容易翻车的点是 USB 转串口线的端口号今天插上是 COM3明天换一个 USB 口可能变成 COM5所以代码里要允许手动选端口不要默认写死。对于需要握手信号的设备DtrEnable 和 RtsEnable 也要暴露出来有些秤不拉这两根线就不发数。using System.IO.Ports; public static SerialPort CreateScalePort(string portName, ScaleSerialOptions options) { // 参数全部从配置来避免现场换秤后改代码重编译 var port new SerialPort(portName, options.BaudRate, options.Parity, options.DataBits, options.StopBits) { Handshake Handshake.None, ReadTimeout 500, WriteTimeout 500, DtrEnable options.DtrEnable, RtsEnable options.RtsEnable, // 读字节时不要依赖编码自动分帧 Encoding System.Text.Encoding.ASCII }; return port; } public sealed class ScaleSerialOptions { public int BaudRate { get; set; } 9600; public Parity Parity { get; set; } Parity.None; public int DataBits { get; set; } 8; public StopBits StopBits { get; set; } StopBits.One; public bool DtrEnable { get; set; } true; public bool RtsEnable { get; set; } true; }这段代码只做一件事把串口参数变成可配置对象并创建 SerialPort。BaudRate、Parity、DataBits、StopBits 必须和秤体一致ReadTimeout 和 WriteTimeout 用于避免异常时无限等待DtrEnable、RtsEnable 不是所有设备都需要但保留配置项能减少现场反复改代码。注意不要用 ReadLine 去读连续输出的秤很多秤的帧里没有换行符ReadLine 会一直等最后表现为“串口打开了但没有数据”。2.2 用 SerialPort 写一个可复用的读取循环SerialPort 的 DataReceived 事件新手最容易误用。它只表示“缓冲区里有数据了”不保证一次事件对应一帧也不保证帧完整。连续输出的电子秤可能一次来半帧也可能一次来两帧。正确做法是把收到的字节追加到自己的缓冲区再按协议找帧头、帧尾和长度。下面的读取器用后台线程读 BaseStream把字节流交给上层拆包避免在事件里做重活。using System; using System.IO; using System.IO.Ports; using System.Threading; using System.Threading.Tasks; public sealed class ScaleSerialReader : IDisposable { private readonly SerialPort _port; private readonly byte[] _readBuffer new byte[512]; private CancellationTokenSource? _cts; private Task? _readTask; public event Actionbyte[], int? BytesReceived; public ScaleSerialReader(SerialPort port) { _port port; _port.Open(); } public void Start() { _cts new CancellationTokenSource(); _readTask Task.Run(() ReadLoopAsync(_cts.Token)); } private async Task ReadLoopAsync(CancellationToken token) { var stream _port.BaseStream; while (!token.IsCancellationRequested) { try { // 读多少算多少不假设一次就是一帧 int n await stream.ReadAsync(_readBuffer, 0, _readBuffer.Length, token) .ConfigureAwait(false); if (n 0) continue; BytesReceived?.Invoke(_readBuffer, n); } catch (OperationCanceledException) { break; } catch (TimeoutException) { // 连续输出设备短时间无数据可接受继续等 } catch (IOException ex) { // 现场常见USB转串口被拔掉、转换器供电异常 OnError(ex); break; } } } public event ActionException? Error; private void OnError(Exception ex) Error?.Invoke(ex); public void Dispose() { _cts?.Cancel(); try { _readTask?.Wait(500); } catch { } if (_port.IsOpen) _port.Close(); _port.Dispose(); _cts?.Dispose(); } }这段读取循环的重点是“字节流不等于帧”。BytesReceived 只把原始字节和长度抛给上层拆包逻辑独立出来后面换协议不用动串口层。ReadAsync 使用 CancellationToken 控制退出Dispose 时先取消再关闭端口避免后台线程还在读时端口被释放。IOException 要单独捕获USB 转串口线被拔掉、转换器掉电在现场很常见这类异常不应该让整个上位机崩掉而应该触发重连提示。2.3 线程、超时与重连别让 UI 被串口拖死串口读取不能放在 UI 线程也不能在 DataReceived 里直接更新控件。WinForms 和 WPF 都要求控件在创建它的线程上更新跨线程直接赋值轻则界面卡顿重则抛 InvalidOperationException。我一般让串口层只负责收字节和抛事件业务层做解析和滤波UI 层通过 Invoke 或 Dispatcher 更新显示。重连也不要写成死循环要有退避间隔和最大重试次数否则端口被占用时会疯狂刷日志。// WinForms 中的 UI 更新示例 private void OnWeightChanged(decimal weight) { if (InvokeRequired) { BeginInvoke(new Actiondecimal(OnWeightChanged), weight); return; } lblWeight.Text weight.ToString(F2); } // 简单的重连策略间隔逐步拉长最多重试若干次 public async Task ReconnectAsync(string portName, CancellationToken token) { var delays new[] { 500, 1000, 2000, 3000 }; foreach (var delay in delays) { token.ThrowIfCancellationRequested(); try { using var port CreateScalePort(portName, new ScaleSerialOptions()); using var reader new ScaleSerialReader(port); reader.BytesReceived OnBytes; reader.Start(); await Task.Delay(Timeout.Infinite, token); } catch (UnauthorizedAccessException) { // 端口被占用等下一次重试 } catch (IOException) { // 设备掉线等下一次重试 } await Task.Delay(delay, token); } }重连代码里最容易忽略的是“端口被占用”和“设备掉线”是两类异常。UnauthorizedAccessException 通常表示另一个程序占着串口或者上一次关闭没释放干净IOException 更多是线被拔、转换器掉电、波特率不对导致设备无响应。两类异常都要记录但处理方式不同前者要提示用户关掉占用程序后者要检查物理连接。退避间隔从 500 毫秒逐步拉长是为了避免设备刚掉线时高频重试把日志刷爆。3. 协议拆包与重量换算从十六进制帧到小数点串口收上来的是字节业务要的是带单位的重量值中间隔着协议解析。电子秤协议大致分两类连续输出型和请求应答型。连续输出型每隔几十毫秒发一帧上位机只管拆请求应答型要上位机先发命令秤再回一帧。两种模式的拆包思路不同但都离不开帧头、长度、校验、单位和小数位这几个字段。现场最常见的翻车不是不会写解析而是把示例帧当成通用协议换一台秤就全错。3.1 常见连续输出协议与请求应答协议的区别连续输出型适合实时显示秤体按固定节奏往外发上位机不需要主动请求。它的风险是帧可能被截断尤其在上位机刚打开串口时第一帧往往是从中间开始的所以拆包器要能丢弃无效前缀。请求应答型适合需要控制采样时机的场景上位机发一条读重量命令秤回一条响应。它的风险是超时和命令格式命令字节错一位秤可能完全不回。类型触发方式优点常见坑连续输出秤主动发实时性好上位机逻辑简单首帧截断、帧粘连、缓冲区越积越多请求应答上位机发命令采样可控流量小命令格式错、超时、应答错位二进制帧按字节定义紧凑适合固定长度字节序、符号位、校验容易错ASCII 帧可读字符调试直观小数点、单位、前导空格处理麻烦我一般先确认秤是哪种模式再决定拆包器是“持续扫描缓冲区”还是“发命令后等响应”。连续输出型不要把每次收到的数据都当一帧正确做法是维护一个 byte 列表不断找帧头找到后判断长度和帧尾完整才取出不完整就保留等待下一次数据。请求应答型则要给每次请求配一个超时超时后丢弃残帧再重发避免上一次的响应被当成这一次的结果。3.2 帧头、长度、校验、单位一个解析器的骨架下面用一个自定义示例帧说明解析骨架帧头 0x026 位 ASCII 重量1 位单位字符1 位异或校验帧尾 0x03。这个帧格式只用于演示拆包和校验思路真实设备必须按说明书替换字段位置和校验算法。代码里把“找帧”和“解帧”分开找帧负责从字节流里切出完整帧解帧负责校验和换算。using System; using System.Collections.Generic; using System.Globalization; using System.Text; public sealed class ScaleFrameParser { private readonly Listbyte _buffer new(); // 返回 true 表示切出一帧完整数据 public bool TryExtractFrame(out byte[] frame) { frame Array.Emptybyte(); while (true) { int start _buffer.IndexOf(0x02); if (start 0) { _buffer.Clear(); return false; } if (start 0) _buffer.RemoveRange(0, start); // 示例帧固定 10 字节头1 数据7 校验1 尾1 if (_buffer.Count 10) return false; if (_buffer[9] ! 0x03) { // 帧尾不对丢掉当前帧头继续找 _buffer.RemoveAt(0); continue; } frame _buffer.GetRange(0, 10).ToArray(); _buffer.RemoveRange(0, 10); return true; } } public void Append(byte[] data, int count) { for (int i 0; i count; i) _buffer.Add(data[i]); // 防止异常数据把缓冲区撑大 if (_buffer.Count 4096) _buffer.RemoveRange(0, _buffer.Count - 1024); } } public static class ScaleFrameDecoder { public static bool TryDecode(byte[] frame, out decimal weight, out string unit) { weight 0m; unit string.Empty; if (frame.Length ! 10 || frame[0] ! 0x02 || frame[9] ! 0x03) return false; // 校验数据区 1..7 异或 byte xor 0; for (int i 1; i 7; i) xor ^ frame[i]; if (xor ! frame[8]) return false; var text Encoding.ASCII.GetString(frame, 1, 6).Trim(); if (!decimal.TryParse(text, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var raw)) return false; unit ((char)frame[7]).ToString(); // 示例协议约定整数最后一位为 0.1 单位 weight raw / 10m; return true; } }TryExtractFrame 的核心是“找到帧头后不急着消费先判断长度和帧尾”。如果帧尾不对只丢掉当前帧头不要清空整个缓冲区否则可能把后面真正的帧一起扔掉。Append 里加了 4096 字节上限是为了防止波特率或校验配错时缓冲区无限增长最后拖死内存。TryDecode 先校验再解析校验不过直接返回 false业务层收到 false 只记日志不要拿错误重量去参与业务。3.3 重量值换算与符号位处理重量换算最容易错在三处小数位、单位、符号。有的秤输出 001234 表示 123.4有的表示 12.34必须看协议里的“分度值”或“小数点位数”。单位可能是 kg、g、lb业务层最好统一转成克或千克再入库不要一会儿克一会儿千克。符号位有的用 ASCII 负号有的用状态字节的最高位表示二进制协议里尤其要注意有符号整数和补码。public static decimal NormalizeToKilogram(decimal value, string unit) { return unit.ToLowerInvariant() switch { kg value, g value / 1000m, lb value * 0.45359237m, _ throw new NotSupportedException($未识别单位: {unit}) }; } // 二进制有符号 16 位示例高字节在前 public static short ReadInt16BigEndian(byte high, byte low) { return (short)((high 8) | low); }NormalizeToKilogram 把单位统一到千克避免业务表里混着不同单位。 lb 的换算系数是固定值但现场如果秤本身已经做了单位切换上位机不要再重复换算。二进制协议里高低字节顺序必须以说明书为准高字节在前还是低字节在前错了会得到完全离谱的重量。符号位不要靠“看起来像负数”判断ASCII 协议看负号字符二进制协议看数据类型和有符号位。4. 稳定读数与工程封装把跳动的数字变成可用重量原始重量值跳动不一定是秤坏了。产线振动、风、物体刚放上去、秤体自己的稳定判定都会让数字在几十分度内来回跳。上位机如果直接把每一帧都显示出来操作员会觉得设备“不准”。实际做法是保留原始值用于诊断再对业务值做稳定判定和滤波。滤波不能过度窗口太大响应会变慢窗口太小又压不住抖动参数要根据秤的分度值和采样频率调。4.1 为什么原始读数会跳采样、滤波与去皮电子秤内部一般有自己的采样和稳定算法但输出到串口的仍可能是瞬时值。连续输出模式下秤可能每 100 毫秒发一帧物体放上去后的前几帧本来就不稳定。上位机要做的是识别“稳定窗口”连续若干帧落在容差范围内才认为重量可用。去皮则是把当前稳定值记为皮重后续显示净重。去皮前必须确认秤已经稳定否则皮重本身就是跳的净重会一直漂。提示不要把滤波后的值再写回去皮计算皮重和净重都要基于同一套稳定判定否则日志对不上。我一般把原始值、滤波值、稳定标志、单位一起放进一个结果对象。界面显示稳定值日志记录原始值。这样客户说“数字跳”时能拿出原始帧判断是秤在跳还是显示逻辑在跳。稳定容差不要设成 0分度值 0.1g 的秤容差设 0.2g 到 0.5g 都算常见具体看工艺要求。4.2 滑动窗口与中值滤波的实现滑动窗口加中值滤波适合压制偶发跳变。窗口里保留最近 N 个重量值取中位数作为输出再判断窗口内最大值和最小值的差是否在容差内。中位数比平均值更抗单点异常平均滤波遇到一帧离谱值会把整体拉偏。下面的实现把“滤波”和“稳定判定”放在一个类里业务层只需要 Push 一个新值拿到的要么是稳定重量要么是 null。using System; using System.Collections.Generic; using System.Linq; public sealed class WeightStabilizer { private readonly Queuedecimal _window new(); private readonly int _capacity; private readonly decimal _tolerance; public WeightStabilizer(int capacity 10, decimal tolerance 0.2m) { if (capacity 3) throw new ArgumentOutOfRangeException(nameof(capacity)); _capacity capacity; _tolerance tolerance; } public decimal? Push(decimal value) { _window.Enqueue(value); if (_window.Count _capacity) _window.Dequeue(); if (_window.Count _capacity) return null; var ordered _window.OrderBy(x x).ToArray(); var median ordered[ordered.Length / 2]; var max ordered[^1]; var min ordered[0]; // 窗口内极差超过容差认为还在波动 if (max - min _tolerance) return null; return median; } public void Reset() _window.Clear(); }capacity 是窗口长度采样 100 毫秒一帧时10 帧大约 1 秒适合大多数称重工位如果工艺要求响应快可以降到 5 到 6 帧。tolerance 是稳定容差要大于等于秤的分度值太小会永远不稳定太大会把真实变化吃掉。Push 返回 null 表示当前还不可用业务层不要拿 null 去更新重量显示而应该保持上一次稳定值或显示“稳定中”。Reset 用于去皮、换产品、重新连接后清空历史避免旧窗口影响新状态。4.3 封装成 IWeightScale 接口与事件模型串口、解析、滤波都写完之后业务层不应该直接依赖 SerialPort。我一般抽一个 IWeightScale 接口把连接、读稳定重量、重量变化事件、错误事件暴露出去。这样产线工位用真实秤实验室用模拟秤单元测试用假数据都不需要改业务代码。接口里不要塞太多设备细节保持“连接、读值、事件、释放”四件事就够了。public interface IWeightScale : IDisposable { bool IsConnected { get; } event EventHandlerWeightChangedEventArgs? WeightChanged; event EventHandlerScaleErrorEventArgs? Error; Task ConnectAsync(string portName, CancellationToken token default); Taskdecimal ReadStableWeightAsync(TimeSpan timeout, CancellationToken token default); } public sealed class WeightChangedEventArgs : EventArgs { public decimal RawWeight { get; init; } public decimal? StableWeight { get; init; } public string Unit { get; init; } kg; public DateTime Timestamp { get; init; } DateTime.Now; } public sealed class ScaleErrorEventArgs : EventArgs { public string Message { get; init; } string.Empty; public Exception? Exception { get; init; } }RawWeight 保留原始值StableWeight 为 null 表示还在稳定判定中Unit 统一成业务单位Timestamp 用于日志对齐。ReadStableWeightAsync 适合“放上去—等稳定—取数”的工位流程内部可以循环等待 WeightChanged 直到 StableWeight 有值或超时。错误事件不要把异常吞掉串口掉线、校验连续失败、超时都要让上层知道。接口一旦稳定后面换协议、换滤波参数都不会影响业务层。5. 避坑与排查串口能打开但读不到数先查这五处现场排查最怕一上来就改代码。串口能打开但读不到数原因可能在线、在参数、在协议也可能在缓冲区。我的顺序是先看原始字节有没有再看字节对不对再看帧能不能切出来最后才看重量换算。下面五条是 C# 读取电子秤重量时最常见的翻车记录每条按现象、原因、解决写照着顺序查能少走很多弯路。5.1 现象一端口能打开但一直没有数据现象是 SerialPort.Open 成功界面上没有重量日志里也没有字节。原因通常有三类秤没有配置成连续输出需要上位机先发命令DTR/RTS 状态不对设备不启动发送RS485 转换器方向控制或接线不对。解决时先用厂家调试工具或最小串口助手确认设备是否真的在发字节再检查代码里 DtrEnable、RtsEnable 是否和说明书一致。如果是请求应答型别等数据先发读重量命令。USB 转串口线也要换口测试有些线在特定 USB 口上枚举正常但数据不通。提示先证明“线上有字节”再证明“字节是完整的”不要一上来就怀疑解析代码。5.2 现象二收到乱码或固定几字节就停乱码优先查波特率、数据位、校验位、停止位。9600 配成 19200或者 8N1 配成 7E1都会出现看似有数据但全是乱码。固定几字节就停常见原因是流控开错代码里 Handshake 设成了硬件流控但线缆没有对应引脚设备发一点就停。另一种情况是 ReadExisting 按字符读二进制帧里包含 0x00 或 0x03 被编码层截断。解决办法是统一按字节读 BaseStream把串口参数做成可切换配置逐项排除。5.3 现象三重量值跳变、归零异常、单位错跳变先看原始帧是否在跳。原始帧稳定而显示跳问题在滤波和 UI 更新原始帧本身跳问题在秤体稳定判定、振动或采样时机。归零异常常见于去皮时没等稳定皮重记成了瞬时值后面净重一直漂。单位错多半是协议里单位字符没解析或者业务层重复换算。解决时把原始重量、单位、稳定标志、时间戳一起打日志先确认解析出的值和秤显示面板是否一致再查滤波窗口和去皮流程。5.4 现象四长时间运行后丢帧或卡死连续输出型设备跑几个小时后开始丢帧常见原因是缓冲区只增不减异常帧越积越多或者 UI 线程被同步读串口阻塞界面看起来卡死。解决时给拆包缓冲区设上限异常数据超过阈值就丢弃最旧部分串口读取放后台任务UI 更新用 Invoke 或 Dispatcher日志不要每帧都刷 Debug 输出高频写日志本身也会拖慢程序。还有一类是 USB 转串口线过热或供电不足表现为跑一段时间后 IOException换线或加供电比改代码更有效。5.5 现象五跨线程更新 UI 抛异常现象是程序运行一段时间后弹 InvalidOperationException提示控件只能由创建它的线程访问。原因是在串口回调或后台任务里直接给 Label、TextBox 赋值。解决时在事件入口统一切回 UI 线程WinForms 用 InvokeRequired BeginInvokeWPF 用 Dispatcher.CheckAccess Dispatcher.Invoke。不要在串口读取循环里直接操作控件也不要用 Thread.Sleep 等界面刷新。跨线程问题在调试时不一定立刻出现压力测试和长时间运行才暴露所以从第一版代码就把线程边界划清楚。6. 进阶验证用模拟秤和日志把问题关在实验室里真机不在手边时最值得先写的是模拟秤。我一般用一个后台任务按固定间隔往解析器里塞示例帧帧里带递增重量和偶发跳变先把拆包、校验、滤波、UI 更新整条链路跑通再接真机。这样现场出问题时能快速判断是协议层还是设备层。模拟帧不要只发正常值要故意插入半帧、错校验、粘连帧拆包器能不能丢掉无效前缀、能不能恢复靠这些异常帧验证。// 模拟连续输出每 100ms 发一帧示例数据 public async Task RunSimulatorAsync( Actionbyte[], int onBytes, CancellationToken token) { var random new Random(42); decimal weight 1000.0m; while (!token.IsCancellationRequested) { weight (decimal)(random.NextDouble() * 0.4 - 0.2); var text ((int)(weight * 10)).ToString(D6); var frame new byte[10]; frame[0] 0x02; var body System.Text.Encoding.ASCII.GetBytes(text); Array.Copy(body, 0, frame, 1, 6); frame[7] (byte)g; byte xor 0; for (int i 1; i 7; i) xor ^ frame[i]; frame[8] xor; frame[9] 0x03; onBytes(frame, frame.Length); await Task.Delay(100, token); } }模拟器里的 weight 故意加了 ±0.2 的抖动用来验证稳定窗口帧格式和解析器保持一致方便替换成真实协议。random 用固定种子是为了让每次回放可重复排查问题时不会“这次又不复现”。真机接入前我还会做一张验证表端口参数、原始字节、帧头帧尾、校验、单位、稳定时间、去皮后净重、掉线重连每项都过一遍。血泪经验是现场只测“能显示数字”远远不够掉线重连和长时间运行才是最容易翻车的地方。从那以后我每次接新秤都强制先跑模拟帧再上真机并且把原始字节日志留到验收后一周。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑