news 2026/10/26 19:21:09

C#串口读取电子秤重量:拆包解析、稳定判定与避坑实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#串口读取电子秤重量:拆包解析、稳定判定与避坑实战

简介:面向C#上位机开发初学者以及需要对接称重硬件的工程师,这份DEMO围绕串口通信读取电子秤重量展开,重点演示如何用SerialPort配置端口参数,借助DataReceived委托事件接收数据,并按照设备协议解析出重量值,适合作为物联网设备数据采集的入门练手项目。压缩包共21个文件、约38KB,以6个cs源码为核心,配合sln与csproj工程文件可直接打开编译;另有3个exe、2个resources与2个resx界面资源、2个pdb调试符号,以及settings、suo等配置缓存文件,结构接近真实的WinForms小工程。已有340人学习,读者可从中获得串口打开与参数设置、事件回调处理、ASCII重量字符串提取、UI线程安全更新等可复用写法,并据此排查通信不通、数据乱码等常见问题。

1. 从一台总也读不准的收银秤说起:C# 读取电子秤重量到底难在哪

门店收银台边上摆一台串口电子秤,插上 USB 转串口线,写 C# 读取电子秤重量,看起来是最没有技术含量的活:打开串口、读一行字符串、正则抠出数字,半小时收工。真上手才知道,第一天能读出数字,第三天还在跟“偶尔少一位小数”“换台秤就乱码”“拔插一次线就再也不出数”较劲。电子秤这行的坑不在 C# 语法,而在协议碎片化:同样是 RS232 输出,有的秤连续往外吐帧,有的必须发指令才回一帧,帧头帧尾、正负号位置、单位后缀各家都不一样。适合读这篇的是正在做收银、称重、仓储、产线采集的 C# 开发者,手里要么已经有一台秤,要么准备用虚拟串口先把链路搭起来。下面按一个能落地的读秤工程往下拆,从选型、目录、核心代码一直讲到怎么改。

2. 项目全貌与选型:串口、协议、运行时三条线怎么定死

2.1 三层结构:字节流、帧、业务数值

读秤这件事,最怕把“读串口”和“算重量”写在一个方法里。写到第三个客户就发现,秤一换,整个方法重写。

我一般把它切成三层,每层只干一件事:

层职责输入输出出问题时的典型症状
连接层开/关串口、收发字节、重连串口参数byte[]块打不开、打开没数据、拔插后失效
解析层粘包拆包、抠出重量字段字节块原始字符串帧数字粘连、半帧、乱码
业务层稳定判定、单位换算、回调 UI/入库原始重量稳定重量decimal数字跳、去皮算错、重复入库

三层之间靠事件或接口通信,连接层不知道什么叫“千克”,业务层不知道串口叫 COM3。这个边界划清楚之后,后面加 Modbus 秤、加蓝牙秤、加一台网口秤,都只是往连接层塞新实现。

连接层往上抛的是“一帧完整报文”,不是“一次 Read 读到的字节”。这个区别是整个项目成败的分水岭——SerialPort.DataReceived触发时,BytesToRead可能是 3 字节,也可能是 27 字节,还可能只是半帧。缓冲区拆包必须发生在连接层内部,业务层永远只收到完整帧。

业务层再往上是消费方:WinForms 的一个 Label、WPF 的绑定属性、或者一个往数据库写称重记录的服务。消费方只关心“现在有一个稳定重量 1.234 kg”,不关心它是从哪个 COM 口、用什么波特率、按哪家协议解析出来的。

分层的收益在第二次需求变更时才显出来。第一次是门店要显示重量,第二次是仓库要把每次称重写进流水表,第三次是产线要同时接两台秤做比对。如果一开始就是一根筋写到底,第二次就得重构,第三次基本重写。

2.2 选型:为什么是 SerialPort 而不是现成控件

.NET 里读串口有几种路子,选哪个取决于你要交付给谁。

第一种是System.IO.Ports.SerialPort,BCL 自带。.NET Framework 下直接在System.dll里,.NET 6/7/8 需要单独装System.IO.PortsNuGet 包,Windows 上底层还是 Win32 的CreateFile/ReadFile。它的优点是没有任何第三方依赖,发布出去就一个 exe,客户机器上不用装运行库以外的东西。缺点是 API 年纪大了,关闭串口时的行为有点玄学,事件回调线程也不是 UI 线程。

第二种是社区封装的串口库,比如各种SerialPortStream之类的实现,优势是跨平台表现更一致、缓冲区管理更现代,代价是多一个依赖,出问题时要多看一层源码。

第三种是买秤时厂家附带的 ActiveX 控件或 DLL。这类东西我一般不用:一是多数只支持 x86,跟现在默认 AnyCPU/x64 的工程打架;二是它把协议封死了,你想加日志、想改成异步、想换一台协议相近的秤,全都没辙。

模拟项目 X 里我选的是第一种,理由很朴素:交付环境是内网 Windows 机器,装不了额外运行库,System.IO.Ports只要把 NuGet 包一起拷贝过去,dotnet publish出来是自包含的,客户那边双击就跑。至于“关闭卡死”这类老问题,可以用代码规法规避,第 5 章会专门讲。

运行时版本上,新项目直接上 .NET 8,decimal运算、async、配置系统都是现成的;如果客户现场还是 .NET Framework 4.7.2 的老系统,代码主体不用改,把System.IO.Ports换成框架自带的命名空间、把Microsoft.Extensions.Configuration换成ConfigurationManager就行。

通信参数这块,行业里 90% 的零售秤是9600, 8, None, 1,工业秤有跑4800和19200的,少数用7, Even, 1。这些不要写死在代码里,全部进配置文件,第 4 章会给完整的 JSON。

2.3 目录结构:一个能落地的工程骨架

工程拆成四个项目,别嫌多,后面改起来省事:

ScaleReader/ ├── ScaleReader.Core/ # 与 UI 无关的读秤核心 │ ├── Transport/ │ │ ├── IScaleTransport.cs # 连接层抽象:Open/Close/DataReceived │ │ ├── SerialScaleTransport.cs# SerialPort 实现,含缓冲区拆包 │ │ └── FrameBuffer.cs # 粘包拆包,独立可单测 │ ├── Protocol/ │ │ ├── IScaleProtocol.cs # 解析层抽象:TryParse(frame, out weight) │ │ ├── ContinuousTextProtocol.cs # 连续输出文本帧 │ │ ├── RequestResponseProtocol.cs# 指令应答 │ │ └── ScaleFrame.cs # 解析结果:重量、单位、是否稳定 │ ├── WeightStabilizer.cs # 滑动窗口稳定判定 │ └── ScaleService.cs # 组装:连接 + 协议 + 稳定器 ├── ScaleReader.App/ # WinForms/WPF 壳 │ ├── MainForm.cs │ └── appsettings.json ├── ScaleReader.Simulator/ # 控制台模拟器,没有硬件时用 └── ScaleReader.Tests/ # 解析器和拆包的单元测试

Core不引用任何 UI 程序集,这一条要当成纪律执行。原因很实际:解析器是整条链路里最容易出错、也最需要单测的部分,一旦它依赖了System.Windows.Forms,测试就跑不起来,只能靠人肉插拔秤验证,效率差一个量级。

FrameBuffer单独抽出来也是为了测试。拆包逻辑的正确性靠“喂一段随机切分的字节流,看输出的帧序列对不对”来保证,这种测试用真秤根本做不了,因为真秤不会配合你制造半帧。

Simulator这个项目很多人省掉,我建议留着。它能让你在没有硬件、没有虚拟串口驱动的机器上(比如 CI、比如同事的笔记本)验证业务层逻辑,也能在客户现场怀疑“是不是秤坏了”的时候,反过来验证“是不是我的程序坏了”。

3. 核心链路拆解:一帧重量数据从串口到界面的完整旅程

3.1 打开串口:参数表与初始化代码

连接层的第一步是把配置变成SerialPort实例。这段代码短,但每个参数都对应一个现场故障。

public sealed class SerialScaleTransport : IScaleTransport, IDisposable { private readonly SerialPort _port; private readonly FrameBuffer _frameBuffer = new(); private readonly object _sync = new(); public event Action<byte[]>? FrameReceived; public SerialScaleTransport(ScaleOptions opt) { _port = new SerialPort( portName: opt.PortName, baudRate: opt.BaudRate, parity: opt.Parity, dataBits: opt.DataBits, stopBits: opt.StopBits) { // 关键:读到 1 字节就触发事件,别指望它凑满一帧 ReceivedBytesThreshold = 1, ReadTimeout = 300, WriteTimeout = 300, // 部分老驱动对编码敏感,统一用 ASCII 避免 BOM 干扰 Encoding = Encoding.ASCII, }; _port.DataReceived += OnDataReceived; } public void Open() { if (_port.IsOpen) return; _port.Open(); // 打开瞬间驱动缓冲区可能残留上次的半帧,必须丢掉 _port.DiscardInBuffer(); _port.DiscardOutBuffer(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var port = (SerialPort)sender; int available = port.BytesToRead; if (available <= 0) return; var chunk = new byte[available]; try { int read = port.Read(chunk, 0, available); if (read < available) Array.Resize(ref chunk, read); } catch (TimeoutException) { return; } // 读超时属正常,不当异常处理 catch (InvalidOperationException) { return; } // 端口被关闭时正在回调 lock (_sync) { _frameBuffer.Append(chunk); foreach (var frame in _frameBuffer.Extract(DateTime.UtcNow)) FrameReceived?.Invoke(frame); } } public void Dispose() { // 顺序很重要:先摘事件再关,否则关闭过程中还会回调进来 _port.DataReceived -= OnDataReceived; if (_port.IsOpen) _port.Close(); _port.Dispose(); } }

ReceivedBytesThreshold = 1是最容易被忽略的一行。它的默认值也是 1,但有人为了“减少事件次数”把它改成帧长度,结果秤发半帧过来之后事件迟迟不触发,界面就像卡死一样。串口是流式设备,不要假设发送方的边界。

DiscardInBuffer放在Open之后、开始读之前。电子秤是持续输出的设备,程序启动那一刻串口上很可能已经堆积了上次运行留下的半帧数据,不清掉就会出现“一启动就显示一个乱七八糟的重量”的现象。

Dispose里先-=再Close,这是血泪经验。DataReceived是在线程池线程上触发的,如果主线程正在Close(),回调线程恰好进到OnDataReceived里访问已经释放的端口,就会抛ObjectDisposedException;更糟的情况是双方互相等锁,表现为程序关不掉。第 5 章会把这个场景展开说。

3.2 DataReceived 与粘包拆包:缓冲区才是正解

SerialPort给你的是一堆没有边界的字节。连续的秤会不停发ST,GS,+ 1.234kg\r\n,一秒五帧,中间可能因为驱动调度被切成ST,GS,+和1.234kg\r\n两次投递,也可能一次给你一帧半。

用ReadLine()看起来很香,实际会翻车:有的秤用\r结尾,有的用\r\n,有的协议是定长帧根本不带换行,ReadLine会一直等到超时。所以自己维护缓冲区是唯一稳的做法。

public sealed class FrameBuffer { private readonly List<byte> _buffer = new(512); private readonly int _maxFrameBytes; // 防御:超过这个长度一定是有脏数据 private readonly TimeSpan _idleFlush; // 空闲多久认为半帧失效 private DateTime _lastAppend = DateTime.UtcNow; public FrameBuffer(int maxFrameBytes = 256, TimeSpan? idleFlush = null) { _maxFrameBytes = maxFrameBytes; _idleFlush = idleFlush ?? TimeSpan.FromMilliseconds(800); } public void Append(byte[] chunk) { _buffer.AddRange(chunk); _lastAppend = DateTime.UtcNow; // 防止对端乱喷数据把内存吃光 if (_buffer.Count > _maxFrameBytes * 8) _buffer.RemoveRange(0, _buffer.Count - _maxFrameBytes); } /// <summary>按 CR/LF 切帧,返回完整帧(不含行尾)</summary> public IEnumerable<byte[]> Extract(DateTime now) { var frames = new List<byte[]>(); int start = 0; for (int i = 0; i < _buffer.Count; i++) { if (_buffer[i] != (byte)'\r' && _buffer[i] != (byte)'\n') continue; int len = i - start; if (len > 0) { var frame = new byte[len]; _buffer.CopyTo(start, frame, 0, len); frames.Add(frame); } start = i + 1; } if (start > 0) _buffer.RemoveRange(0, start); // 只有半个帧且长时间没新数据:说明这帧废了,清掉避免污染下一帧 if (_buffer.Count > 0 && now - _lastAppend > _idleFlush && _buffer.Count >= _maxFrameBytes) _buffer.Clear(); return frames; } }

Extract里连续两个\r\n会被切成空帧,代码里用len > 0过滤掉了。空帧不能往上抛,否则解析层要额外处理一次。

_maxFrameBytes和_idleFlush是防御性参数,专门对付“线接触不良导致半帧卡在缓冲区里”这种情况。没有这道保险,缓冲区里会一直留着一截残帧,后面每一帧拼上去都错位,程序表现为“重量永远是错的,但偶尔重启能好”。

拆包的单元测试很便宜,含义很大:

[Fact] public void 分三次投递也能切出完整帧() { var buf = new FrameBuffer(); buf.Append(Encoding.ASCII.GetBytes("ST,GS,+")); buf.Append(Encoding.ASCII.GetBytes(" 1.23")); buf.Append(Encoding.ASCII.GetBytes("4kg\r\n")); var frames = buf.Extract(DateTime.UtcNow).ToList(); Assert.Single(frames); Assert.Equal("ST,GS,+ 1.234kg", Encoding.ASCII.GetString(frames[0])); }

这类测试覆盖的是真实发生概率最高的故障,比手工插拔秤验证有效得多。

3.3 解析一帧:连续模式与指令应答模式的差别

拿到完整帧之后,解析层只做一件事:把ST,GS,+ 1.234kg变成1.234m加上单位和稳定标记。差异全在协议上。

连续输出模式最常见,秤上电就按固定频率往外发,程序只读不写。帧格式大体是“状态位 + 符号 + 数值 + 单位”,各家字段顺序略有不同。解析时别用Split(',')硬切,因为数值字段带空格,有些秤还用固定位宽右对齐。用正则更稳:

public sealed class ContinuousTextProtocol : IScaleProtocol { // 允许:可选符号、整数最多 6 位、小数最多 3 位、可选单位 private static readonly Regex WeightRegex = new( @"(?<sign>[+-])?\s*(?<value>\d{1,6}(?:\.\d{1,3})?)\s*(?<unit>kg|g|lb)?", RegexOptions.Compiled | RegexOptions.IgnoreCase); public bool TryParse(byte[] frame, out ScaleFrame result) { result = default; string text = Encoding.ASCII.GetString(frame).Trim('\0', ' ', '\r', '\n'); if (text.Length == 0) return false; // 状态字段里出现 "US" 表示不稳定,这一帧不参与稳定判定 bool stable = !text.Contains("US", StringComparison.OrdinalIgnoreCase); var m = WeightRegex.Match(text); if (!m.Success) return false; var value = decimal.Parse(m.Groups["value"].Value, CultureInfo.InvariantCulture); if (m.Groups["sign"].Value == "-") value = -value; string unit = m.Groups["unit"].Success ? m.Groups["unit"].Value.ToLowerInvariant() : "kg"; result = new ScaleFrame(value, unit, stable, DateTime.UtcNow); return true; } }

CultureInfo.InvariantCulture这里必须写死。开发机上区域设置是中文,小数点还是.,似乎没问题;客户机器上如果区域是德语或法语,decimal.Parse会把.当千分位,1.234变成1234,重量直接放大一千倍。这个 bug 在门店端非常难查,因为程序看起来“正常工作”。

stable标记来自秤自己的判断,不是我们算的。多数秤在读数波动时会输出US(Unstable),稳定后输出ST。优先信秤的状态位,再叠加自己的滑动窗口,两层判定比单靠一层靠谱。

指令应答模式反过来:秤平时安静,你发一个指令(常见是W\r\n或单字节0x05),它回一帧。写法上多一个定时器和写串口的路径:

public sealed class RequestResponseProtocol : IScaleProtocol { public byte[] BuildRequest() => Encoding.ASCII.GetBytes("W\r\n"); // 解析部分与连续模式共用同一套正则 public bool TryParse(byte[] frame, out ScaleFrame result) => _inner.TryParse(frame, out result); }

ScaleService里用System.Threading.Timer每 200ms 发一次指令,同时在FrameReceived里解析回帧。要注意两点:一是发送必须加锁,多个定时器回调同时写串口会互相踩;二是要设超时,发出去 500ms 没回帧就记一次失败,连续失败 5 次触发重连。

还有一个容易被忽略的第三类:Modbus RTU。工业秤常见,帧是二进制的,带 CRC16 校验,重量放在保持寄存器里。这套协议不适合用文本正则,要单独写一个ModbusScaleProtocol,第 6 章会给改造思路。

3.4 稳定判定:把抖动的数字变成可入库的重量

秤输出的是瞬时值,1.234、1.235、1.233 一直跳。业务上要的是“用户把东西放稳之后的那个数”,不能每帧都往数据库写。

稳定判定用滑动窗口,逻辑简单但参数要调:

public sealed class WeightStabilizer { private readonly int _windowSize; // 采样窗口,比如 5 帧 private readonly decimal _tolerance; // 允许的极差,单位 kg private readonly Queue<decimal> _samples = new(); public WeightStabilizer(int windowSize = 5, decimal tolerance = 0.002m) { _windowSize = windowSize; _tolerance = tolerance; } /// <summary>返回 null 表示尚未稳定</summary> public decimal? Push(decimal weight, bool scaleStable) { if (!scaleStable) { _samples.Clear(); return null; } _samples.Enqueue(weight); while (_samples.Count > _windowSize) _samples.Dequeue(); if (_samples.Count < _windowSize) return null; decimal max = _samples.Max(), min = _samples.Min(); if (max - min > _tolerance) return null; return Math.Round(_samples.Average(), 3, MidpointRounding.AwayFromZero); } public void Reset() => _samples.Clear(); }

_tolerance取多少取决于秤的分度值。分度值 1g 的秤,窗口内极差 2g 以内算稳;分度值 10g 的台秤,0.002m太严,永远稳不下来,得放到0.02m。这个参数一定要进配置文件,别写死。

scaleStable == false时清空窗口,这一步很多人漏掉。秤报不稳定意味着物品还在晃,这时候攒的样本没有意义,留着只会拖慢下一次稳定判定。

稳定判定出来的值往上抛一次就够,不要重复抛。ScaleService里用一个_lastStableValue做去重,值变了才触发事件,否则界面会收到一秒五次相同的回调,数据库那边也会重复插。

3.5 回到 UI 线程:别在串口回调里直接摸控件

DataReceived在线程池线程上跑。WinForms 里直接写label1.Text = ...十次有九次看着正常,第十次抛InvalidOperationException: 线程间操作无效。WPF 里更直接,绑定属性不在 UI 线程改会抛异常。

正确做法是把更新动作 marshal 回 UI 线程:

private void OnStableWeight(decimal weight, string unit) { if (IsDisposed) return; // BeginInvoke 异步投递,避免串口线程被 UI 卡住 BeginInvoke(new Action(() => { lblWeight.Text = $"{weight:F3} {unit}"; _lastStableWeight = weight; })); }

用BeginInvoke而不是Invoke。Invoke是同步的,UI 线程忙的时候串口线程会被阻塞在回调里,缓冲区继续堆积,表现是“点一下按钮,重量就滞后好几秒”。BeginInvoke投递完就返回,串口线程继续干活。

IsDisposed判断不能省。窗体关闭过程中,已经投递但还没执行的BeginInvoke会作用在已释放的控件上,抛ObjectDisposedException,日志里一堆红色堆栈,客户以为程序崩了。

如果是 WPF 或者控制台程序,用SynchronizationContext或者IProgress<decimal>更干净,不用依赖具体控件类型。ScaleService对外只暴露event Action<ScaleFrame> WeightChanged,壳工程自己决定怎么回到 UI 线程。

4. 上手指南:没有真秤也能把整条链路跑通

4.1 环境与依赖:.NET 版本和 System.IO.Ports

工程基于 .NET 8 控制台 + WinForms,两个项目都引用ScaleReader.Core。第一步是建工程和装包:

dotnet new sln -n ScaleReader dotnet new classlib -n ScaleReader.Core -f net8.0 dotnet new winforms -n ScaleReader.App -f net8.0-windows dotnet new console -n ScaleReader.Simulator -f net8.0 dotnet sln add ScaleReader.Core ScaleReader.App ScaleReader.Simulator # .NET 8 里串口不在 BCL 默认集合中,必须显式引用 dotnet add ScaleReader.Core package System.IO.Ports --version 8.0.0 dotnet add ScaleReader.App package Microsoft.Extensions.Configuration.Json --version 8.0.0

System.IO.Ports的版本号跟运行时大版本对齐,用 8.0.0 配 .NET 8。如果客户现场是 .NET Framework 4.7.2,把net8.0换成net472,删掉这条包引用,命名空间仍然是System.IO.Ports,代码不用动。

WinForms 项目要设TargetFramework为net8.0-windows,否则编译不过。控制台模拟器设为net8.0就行,它不需要 UI。

发布给客户时用自包含单文件,客户机器上不用装运行时:

dotnet publish ScaleReader.App -c Release -r win-x64 --self-contained true \ -p:PublishSingleFile=true -o ./publish

4.2 没有硬件怎么办:虚拟串口对 + 模拟秤

手里没秤的时候,用虚拟串口软件建一对互通的端口(COM10 和 COM11),一端给程序,一端给模拟器。这一步能把 90% 的代码验证完,剩下 10% 是电气和协议细节。

模拟器用 Python 写最省事,几十行搞定:

import serial, time, random # 与程序端配对的是 COM11,程序连 COM10 with serial.Serial('COM11', 9600, bytesize=8, parity='N', stopbits=1, timeout=1) as ser: while True: # 模拟秤的抖动:真值上下浮动 2g weight = 1.234 + random.uniform(-0.002, 0.002) # 模拟连续输出协议:状态 + 符号 + 数值 + 单位 frame = f"ST,GS,+{weight:8.3f}kg\r\n" ser.write(frame.encode('ascii')) time.sleep(0.1) # 10Hz,与多数零售秤一致

ST表示稳定、GS是毛重,这是行业里很常见的状态字段组合。%8.3f做右对齐补空格,正好还原真秤那种“数值前面带空格”的格式,能提前暴露解析器的空格处理问题。

想验证拆包逻辑,把ser.write拆成两次、中间time.sleep(0.02),人为制造半帧:

data = f"ST,GS,+{weight:8.3f}kg\r\n".encode('ascii') ser.write(data[:7]) # 只发前半截 time.sleep(0.02) ser.write(data[7:]) # 再发后半截

程序如果还能正确显示,说明缓冲区拆包是对的。这个测试用真秤做不了,真秤不会配合你切包。

4.3 配置化:把协议差异关进 JSON

客户现场的秤五花八门,代码里写死COM3和9600意味着每次换机器都要重新编译。配置全部外置:

{ "Scale": { "PortName": "COM10", "BaudRate": 9600, "Parity": "None", "DataBits": 8, "StopBits": "One", "Protocol": "ContinuousText", "Stabilizer": { "WindowSize": 5, "Tolerance": 0.002 }, "Reconnect": { "IdleTimeoutMs": 3000, "MaxRetry": 5 } } }

读取用Microsoft.Extensions.Configuration:

var config = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); var options = config.GetSection("Scale").Get<ScaleOptions>() ?? throw new InvalidOperationException("缺少 Scale 配置节");

reloadOnChange: true打开之后,现场改完 JSON 不用重启程序。SetBasePath(AppContext.BaseDirectory)也别省,用相对路径在调试时能找到文件,发布成单文件之后就找不到了,这是单文件发布的常见翻车点。

Protocol字段对应解析器的工厂:

IScaleProtocol protocol = options.Protocol switch { "ContinuousText" => new ContinuousTextProtocol(), "RequestResponse" => new RequestResponseProtocol(options.PollIntervalMs), _ => throw new NotSupportedException($"未知协议: {options.Protocol}") };

现场加一种新秤,只要加一个case和一个配置值,不动其它代码。

4.4 日志:给串口链路装一个黑匣子

串口问题最难的地方是“事后无法复现”。客户说“今天上午有几次读不出来”,你到现场一切正常。所以原始字节必须落盘,这就是黑匣子。

public sealed class FrameLogger { private readonly StreamWriter _writer; private readonly object _lock = new(); public FrameLogger(string path) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); _writer = new StreamWriter(path, append: true, Encoding.UTF8) { AutoFlush = true }; } public void Log(string direction, byte[] frame) { // 只记录 ASCII 可打印部分,十六进制留作对照,排查乱码时非常关键 string text = Encoding.ASCII.GetString(frame) .Replace("\r", "\\r").Replace("\n", "\\n"); string hex = Convert.ToHexString(frame); lock (_lock) _writer.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {direction} [{hex}] {text}"); } }

日志里同时留文本和十六进制两列。文本那列方便肉眼扫,十六进制那列用来判断“是不是压根就没收到数据”还是“收到了但不是 ASCII”。有一次线上问题是秤配成了7,Even,1,程序按8,None,1收,文本列全是问号和方块,十六进制列一眼就看出高位全是 1,省了半天排查。

日志要按天切,别一个文件写到几百兆。现场机器硬盘小,撑不住。

5. 避坑与排查:串口读取翻车的五种典型现场

5.1 现象:程序一关就卡死,任务管理器结束进程才退得掉

原因:FormClosing里调了SerialPort.Close(),而此刻DataReceived正在线程池线程上执行。Close内部会等待 I/O 完成,回调线程又在等 UI 线程的锁,两边互等,形成死锁。

解决:关窗体时先把事件摘掉、把服务停掉,再关串口。顺序是Unsubscribe → Stop poll timer → Close port → Dispose。如果还是不稳,把Close放到Task.Run里加一个 3 秒超时兜底,超时就强制Dispose:

var closeTask = Task.Run(() => transport.Dispose()); if (!closeTask.Wait(TimeSpan.FromSeconds(3))) _logger.Warn("串口关闭超时,已强制释放");

5.2 现象:USB 转串口线拔插一次,程序再也不出数,重启才好

原因:USB 转串口拔掉时,SerialPort对象进入异常状态,IsOpen可能还返回true,但底层句柄已失效,Read抛IOException之后事件不再触发。

解决:连接层加心跳检测。连续IdleTimeoutMs没收到任何字节,就认为链路断了,Dispose旧对象、重新枚举串口、重新Open。重连要做退避,别一秒重试十次把 CPU 打满:

private async Task ReconnectLoopAsync(CancellationToken ct) { int retry = 0; while (!ct.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, retry))), ct); try { Dispose(); Open(); retry = 0; // 成功就复位退避 _logger.Info($"串口 {_options.PortName} 重连成功"); } catch (Exception ex) { retry++; _logger.Warn($"第 {retry} 次重连失败: {ex.Message}"); } } }

还有个细节:拔插之后串口号可能变(从 COM10 变成 COM12)。要重连稳定,最好按 USB 设备的 VID/PID 或描述去枚举匹配,而不是死认端口名。

5.3 现象:读出来的重量小数点位置不对,1.234 变成 1234

原因:decimal.Parse用了当前线程的区域设置。某些区域把.当千分位分隔符,1.234被解析成1234。

解决:所有数值解析统一CultureInfo.InvariantCulture,格式化输出也一样。这条要写进代码规范,别指望 code review 每次都能抓到。可以加一条全局检查:搜索代码里出现decimal.Parse(的地方,必须带CultureInfo参数。

5.4 现象:重量数字一直跳,业务端不停地插记录

原因:稳定判定没做,或者scaleStable状态位没参与判断,每帧都当成有效值往外抛。

解决:滑动窗口 + 状态位双保险,再加一层“值变化才上报”的去重。阈值一定按分度值配,0.002这种硬编码换台秤就失效。另外业务端要做幂等,同一笔称重单重复写入不能出错。

5.5 现象:COM10 及以上的端口打不开,报“端口不存在”

原因:老式 Win32 端口名解析对大于 COM9 的名字有特殊要求,部分 API 需要\\.\COM10这种前缀形式。SerialPort在多数情况下内部处理了,但某些驱动或旧框架版本上仍然会踩到。

解决:配置里允许写COM10,代码里判断端口号数字大于 9 时自动补前缀:

string Normalize(string port) { if (port.StartsWith(@"\\.\", StringComparison.Ordinal)) return port; var m = Regex.Match(port, @"^COM(\d+)$", RegexOptions.IgnoreCase); return m.Success && int.Parse(m.Groups[1].Value) > 9 ? $@"\\.\{port}" : port; }

顺带说一句,先确认设备管理器里确实有这个口、驱动装好了、没被别的程序占用。这三点听起来废话,实际现场至少能解决一半的“打不开”。

6. 二次开发:把通用读秤器改成你项目里的称重模块

拿到这套骨架之后,最常见的三种改造是加协议、加多秤、加落库。

加协议最省事。实现IScaleProtocol接口,在工厂switch里加一个分支,配置里改Protocol值就生效。工业秤常用的 Modbus RTU 需要单独处理 CRC16 和寄存器读取:

public sealed class ModbusScaleProtocol : IScaleProtocol { // 功能码 0x03 读保持寄存器,起始地址和数量按厂家手册改 public byte[] BuildRequest(byte slaveId = 1) => new byte[] { slaveId, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00 }; // 最后一字节由 CRC 填充 public bool TryParse(byte[] frame, out ScaleFrame result) { result = default; if (frame.Length < 9) return false; if (!Crc16.Check(frame)) return false; // 校验不过直接丢弃 // 两个寄存器拼成 32 位有符号整数,再按分度值换算成 kg int raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6]; result = new ScaleFrame(raw / 1000m, "kg", true, DateTime.UtcNow); return true; } }

多秤并发不需要新线程模型,每个秤一个ScaleService实例即可,连接层、缓冲区、稳定器各自独立,天然线程安全。要小心的是共享的日志文件和数据库连接,这两处必须加锁或者用队列串行化。

落库这块,称重记录表建议带上设备号、原始帧、稳定重量、采集时间四个字段:

字段类型说明
device_novarchar(32)秤的编号,多秤场景必需
raw_framevarchar(128)原始帧十六进制,出问题时的后悔药
weightdecimal(10,3)稳定后的重量,单位统一为 kg
captured_atdatetime2采集时间,用 UTC 存储

改造完的验证方法很固定:先跑单测(拆包 + 解析 + 稳定判定),再用模拟器跑一遍全链路,最后接真秤做 30 分钟的连续稳定性观察,重点看有没有漏帧、有没有重复上报、拔插线之后能不能自己恢复。这三步走完再交给现场。

从那以后我每次接新的读秤需求,都强制先把FrameBuffer的拆包单测和模拟器跑一遍,绝不会先插上真秤凭感觉调。真秤只能证明“这一刻是通的”,证明不了“明天上午十点也通”。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 19:20:12

大数据实训项目全流程:从集群部署到可视化交付

简介&#xff1a;面向沈阳航空航天大学大数据实训的综合性项目设计源码&#xff0c;专为大数据专业学生提供从数据采集、存储、处理到可视化展示的完整实战参考。压缩包共542个文件&#xff0c;约95.44MB&#xff0c;涵盖118个PNG图片、81个Java源文件、50个HTML文档、45个Java…

作者头像 李华
网站建设 2026/10/26 19:16:51

c/c++中的左值右值详解

前言 「左值」「右值」是 C 里被提到最多、被讲清最少的两个词。很多教程用「等号左边是左值,右边是右值」来解释,这个说法在 C 里勉强够用,在 C11 之后完全不够:等号左边也可以是右值引用,函数实参、返回值、成员访问都各有自己的值类别。真正决定一个表达式能做什么的,是它的…

作者头像 李华
网站建设 2026/10/26 19:16:47

e2e端到端全解析:从测试、机器学习到链路追踪与加密

1. 从“e2e”这三个字母说起&#xff1a;它到底指什么第一次看到“e2e”这个标题&#xff0c;很多人会愣一下——三个字母&#xff0c;没有上下文&#xff0c;没有说明&#xff0c;连个关键词都没给。但恰恰是这种极简的命名方式&#xff0c;在工程圈里反而指向性极强。e2e 是 …

作者头像 李华
网站建设 2026/10/26 19:15:05

13岁少年AI Coding夺冠:开源数据库与AI编程新趋势

1. 社区嘉年华现场观察&#xff1a;当13岁少年遇上AI Coding那天下午的社区嘉年华主舞台&#xff0c;当主持人宣布AI Coding挑战赛第二名得主是位初中生时&#xff0c;现场爆发的掌声持续了近两分钟。这个穿着校服、身高不足1米6的男孩站在领奖台上&#xff0c;手里紧握着的奖状…

作者头像 李华
网站建设 2026/10/26 19:15:04

Claude记忆中间件设计:本地化AI会话状态管理实践

1. 项目概述&#xff1a;一个被误读的命名&#xff0c;实则指向本地化AI记忆机制的设计实践“claude-mem”这个词最近在技术社区里频繁出现&#xff0c;但几乎没人能说清它到底指什么——它既不是官方发布的模型名称&#xff0c;也不是Claude系列API里的标准参数&#xff0c;更…

作者头像 李华
网站建设 2026/10/26 19:13:13

Python requests模块在接口自动化测试中的实践指南

1. 接口自动化测试与request模块概述在当今的软件开发流程中&#xff0c;接口自动化测试已经成为质量保障体系中不可或缺的一环。作为Python生态中最常用的HTTP请求库&#xff0c;requests模块因其简洁优雅的API设计和强大的功能特性&#xff0c;成为了接口测试工程师的首选工具…

作者头像 李华