C#电子秤数据接口中间件:多型号串口协议适配与稳定采集 简介这份托利多中间件电子秤数据接口 C# 源码面向工业称重、自动化产线、仓储收货、零售结算等场景的开发者重点解决电子秤重量数据采集、厂家 SDK 调用以及 RL00、BCOM 等多型号兼容问题适合需要把称重设备接入现有业务系统的初中级 C# 开发者参考并借助现成工程理解设备接入的完整链路。压缩包共 132 个文件约 32.67MB内含 10 个 C# 源码、31 个 dll 动态库、49 个 xml 配置与资源说明、6 个 config 配置、5 个 exe 示例程序以及 sln、csproj 等工程文件便于直接查看工程组织、编译调试和对照配置切换设备参数。已有 1442 人学习下载。读者可从中梳理中间件与电子秤的通信流程研究接口封装、协议适配、多型号配置管理和异常排查思路也可复用 SDK 与示例代码为产线称重、仓库收货、零售 POS 等业务快速搭建数据接入方案降低重复开发成本。1. 三台老秤一根串口线这套 C# 中间件先解决的是型号打架产线尾端的打包工位上并排摆着三台电子秤出厂年份差了七八年接口倒是统一,都是一根 RS232 串口线。上位机要同时读它们的重量做记录,麻烦在于三台秤的通信协议压根不是一个路子:一台定长帧带异或校验,一台连续往外吐逗号分隔的文本,还有一台必须走厂家给的那个只认 x86 的 SDK。以前的写法是每个型号一套读取代码,现场换一台秤就得改程序、重新发版,半夜被叫起来处理是常事。这份 C# 电子秤数据接口中间件干的就是把这堆脏活收进一个类库:串口收字节、协议拆帧、稳定判定、单位归一,最后统一吐一个WeightSample给业务层。它附带了厂家 SDK 的封装层,把那堆老 DLL 关在门外。正在做称重采集、产线过磅、仓储入库这类上位机,又不想为每个型号重复造轮子的开发者,基本能直接拿去当基础库用。2. 工程结构与技术选型型号差异怎么被挡在业务层之外2.1 目录结构与四层分工这套代码的目录结构没什么花活,但分层卡得很死,核心思路是让每一层都对相邻层的实现细节一无所知。先把树贴出来,后面讲代码时对着看。weighbridge/ ├── src/ │ ├── WeighBridge.Core/ # 抽象与模型,不引用任何串口类型 │ │ ├── IWeighingDevice.cs │ │ ├── WeightSample.cs │ │ └── DeviceState.cs │ ├── WeighBridge.Protocols/ # 协议适配器,只吃 byte[] 吐样本 │ │ ├── ProtocolAdapterBase.cs │ │ ├── RlSeriesProtocol.cs │ │ ├── BcomSeriesProtocol.cs │ │ ├── StableWeightFilter.cs │ │ └── ProtocolRegistry.cs │ ├── WeighBridge.Transport/ # 串口 / TCP / SDK 桥接 │ │ ├── ITransport.cs │ │ ├── SerialTransport.cs │ │ └── ReconnectPolicy.cs │ ├── WeighBridge.Config/ │ │ ├── DeviceConfig.cs │ │ └── ConfigLoader.cs │ └── WeighBridge.SdkInterop/ # 厂家 SDK 的托管包装 │ └── VendorSdkScale.cs ├── tools/ │ ├── WeighBridge.Simulator/ # 虚拟秤,没硬件也能跑 │ └── WeighBridge.Demo/ # WinForms 测试台 ├── tests/ │ └── WeighBridge.Tests/ # 用抓包回放做单测 └── configs/ └── devices.json四层的关系是单向的:Transport只负责把字节流搬上来,不知道字节里是重量还是心跳;Protocols只负责把字节切成帧、解析成样本,不知道这些字节来自串口还是 TCP;Core只定义契约;SdkInterop是旁路,只有走厂家 SDK 的那类型号才会被引用。换型号只动Protocols,换通信介质只动Transport,业务侧一行不用改。现场维护最怕的是耦合,我见过太多把串口打开、协议解析、界面刷新全塞在一个 Form 里的事件处理函数中的写法,那代码过半年自己都不敢改。这里强行拆开,代价是多写几个接口,收益是加型号变成了新增一个文件 注册一行。2.2 技术栈与双目标框架的选择项目用的是 C# 双目标框架:net6.0和net48。这不是为了炫技,是工控现场的真实情况决定的——产线机器上大量还是 Win7 或者 Win10 LTSC,装个新运行时要走审批流程,而新做的看板又跑在新系统上。csproj 里就一行:Project SdkMicrosoft.NET.Sdk PropertyGroup !-- 双目标:老工控机用 net48,新项目用 net6.0 -- TargetFrameworksnet6.0;net48/TargetFrameworks !-- 厂家 SDK 只有 32 位版本,默认按 x86 出包 -- Platformsx86;x64/Platforms PlatformTargetx86/PlatformTarget LangVersionlatest/LangVersion /PropertyGroup ItemGroup Condition$(TargetFramework) net48 !-- net48 不自带串口和 JSON,得手动引包 -- PackageReference IncludeSystem.IO.Ports Version6.0.0 / PackageReference IncludeSystem.Text.Json Version6.0.0 / /ItemGroup /ProjectTargetFrameworks用分号隔开,编译时两个都出;PlatformTarget锁x86是因为厂家那个 native DLL 只有 32 位版本,AnyCPU 在 64 位系统上跑起来必炸,这个坑后面第 5 章还会细说。串口在 net48 里没有内置,得引System.IO.Ports包,JSON 同理。依赖上刻意做减法:没有引入任何 DI 容器、没有日志框架强绑定、没有 ORM。中间件这种东西嵌在别人系统里,依赖越少,冲突概率越低。日志用一个ILogger风格的极简接口,调用方愿意接谁接谁。2.3 统一接口:先定契约再谈实现Core里最关键的三个类型是样本模型、设备接口、状态枚举。业务层眼里只有它们,看不到任何协议细节。// 一条重量样本的不可变快照 public sealed class WeightSample { public string DeviceId { get; init; } string.Empty; // 统一换算成克,避免上层反复处理 kg/g/lb public double ValueGram { get; init; } // 稳定标志,来自过滤器的判定结果 public bool IsStable { get; init; } public DateTime Timestamp { get; init; } DateTime.Now; // 原始帧,排查问题时全靠它 public string RawFrame { get; init; } string.Empty; } // 所有型号的设备都实现这个接口 public interface IWeighingDevice : IDisposable { string DeviceId { get; } bool IsConnected { get; } // 每读到一帧有效数据就触发一次 event EventHandlerWeightSample? WeightChanged; event EventHandlerDeviceState? StateChanged; void Start(); void Stop(); // 主动取一次当前值,适合按需读取的场景 WeightSample? ReadCurrent(); }ValueGram统一用克是权衡后的决定:精度够(克级小数两位完全能表达),量程从几公斤到几吨都能覆盖,上层做单位显示只是除法。RawFrame保留原始字符串是为了排查——现场扯皮你这读的数不对的时候,把原始帧一贴,是不是秤本身输出的问题一目了然。WeightSample在 net6 分支里其实可以写成record struct更省分配,但 net48 不支持,为了两套代码完全一致,这里用了带init的 sealed class。init在 net48 上需要 C# 9 IsExternalInit补齐,项目里放了个空类型解决,不影响使用。设备工厂按配置创建具体实现,业务侧只写DeviceFactory.Create(cfg):public static class DeviceFactory { public static IWeighingDevice Create(DeviceConfig cfg) { // 先解析协议,型号不认识直接抛,别等到打开串口才发现 var adapter ProtocolRegistry.Resolve(cfg.Model) ?? throw new NotSupportedException($未注册的型号: {cfg.Model}); ITransport transport cfg.Transport.Type switch { serial new SerialTransport(cfg.Transport), tcp new TcpTransport(cfg.Transport), vendorsdk new VendorSdkScale(cfg.Transport), _ throw new NotSupportedException($不支持的传输类型: {cfg.Transport.Type}) }; return new WeighingDevice(cfg, transport, adapter, new StableWeightFilter(cfg.Filter.Window, cfg.Filter.ToleranceGram)); } }这段的判断顺序有讲究:先校验型号,再建传输。串口是有独占资源的,打开之后再抛异常,资源释放就容易漏。cfg.Filter两个参数分别控制稳定判定的窗口长度和容差(克),后面 3.4 会展开。3. 协议适配层拆解RL 系列与 BCOM 系列的帧解析和稳定判定3.1 适配器基类与型号注册表两种型号的差异集中在三件事上:帧边界怎么找、字段怎么切、校验怎么算。所以抽象出来的接口只留一个喂字节的入口,不暴露任何读一帧的方法——因为连续输出型协议压根没有请求-应答的概念。public interface IProtocolAdapter { string Model { get; } // buffer/offset/count 三件套,和 Socket 的 Receive 语义对齐 void Feed(byte[] buffer, int offset, int count, ListWeightSample output); void Reset(); }这里用ListWeightSample output做输出参数,而不是返回IEnumerableWeightSample或者IAsyncEnumerable,是个踩过坑之后的决定。早期版本用yield return写得很优雅,但解析函数参数是ReadOnlySpanbyte,ref struct 不能跨yield,编译器直接报错。后来改成输出列表,顺带还省掉了每次解析的迭代器分配,在高频连续输出的秤上性能差别肉眼可见。基类负责缓冲区管理,子类只管从缓冲区里切帧:public abstract class ProtocolAdapterBase : IProtocolAdapter { private readonly byte[] _buffer new byte[8192]; private int _length; // 缓冲区里有效字节数 protected readonly object Gate new(); public abstract string Model { get; } public void Feed(byte[] buffer, int offset, int count, ListWeightSample output) { lock (Gate) { // 缓冲区满了还没切出帧,说明帧头对不上,直接清空防雪崩 if (_length count _buffer.Length) _length 0; Buffer.BlockCopy(buffer, offset, _buffer, _length, count); _length count; Drain(output); } } // 从 _buffer 里尽量多地切出完整帧,剩余字节留在缓冲区等待下次拼接 protected abstract void Drain(ListWeightSample output); protected ReadOnlySpanbyte Buffered _buffer.AsSpan(0, _length); protected void Consume(int bytes) { if (bytes 0) return; _length - bytes; if (_length 0) Buffer.BlockCopy(_buffer, bytes, _buffer, 0, _length); } public virtual void Reset() _length 0; }Feed外面套lock是因为串口DataReceived事件在线程池线程上触发,而业务侧可能同时在调Reset()切型号,不加锁会出现半帧被清掉的情况。缓冲区 8KB 是经验值:9600 波特率下一秒钟最多 960 字节,留八秒的余量足够应付任何抖动。超长还没切出帧就清空,是防止一个错误的帧头让后续所有数据永远错位。型号注册用一张静态字典,新增型号只要在启动时补一行:public static class ProtocolRegistry { private static readonly Dictionarystring, FuncIProtocolAdapter Map new(StringComparer.OrdinalIgnoreCase) { [RL] () new RlSeriesProtocol(), [BCOM] () new BcomSeriesProtocol(), }; public static IProtocolAdapter? Resolve(string model) Map.TryGetValue(model, out var factory) ? factory() : null; // 供业务方在启动时注入自定义适配器,不用改中间件源码 public static void Register(string model, FuncIProtocolAdapter factory) Map[model] factory; }StringComparer.OrdinalIgnoreCase让配置里写rl、Rl、RL都认,配置文件手写的时候不至于因为大小写卡住。Register这个口子是留给二次开发用的,后面第 6 章会用到。3.2 RL 系列:定长帧、状态位和异或校验RL 系列的输出是标准的定长帧:一个起始字节、一个状态字符、一个符号位、六位重量数字、两位单位、一个校验字节、一个回车。总长固定,所以切帧逻辑非常简单——扫到起始字节就按固定长度切一刀。public sealed class RlSeriesProtocol : ProtocolAdapterBase { private const byte Stx 0x02; // 帧起始 private const byte Cr 0x0D; // 帧结束 private const int FrameLen 18; // 整帧固定长度 public override string Model RL; protected override void Drain(ListWeightSample output) { var span Buffered; int i 0; while (i span.Length) { // 跳过帧头之前的噪声字节 if (span[i] ! Stx) { i; continue; } // 剩余不够一帧,保留等下一批数据 if (span.Length - i FrameLen) break; var frame span.Slice(i, FrameLen); if (frame[FrameLen - 1] Cr VerifyChecksum(frame)) { var sample ParseFrame(frame); if (sample ! null) output.Add(sample); } i FrameLen; // 无论校验成败都跳过整帧,避免死循环 } Consume(i); } // 校验:从状态位到单位末位逐字节累加,取低 7 位后加 0x20 private static bool VerifyChecksum(ReadOnlySpanbyte frame) { int sum 0; for (int k 1; k FrameLen - 3; k) sum frame[k]; byte expect (byte)((sum 0x7F) 0x20); return expect frame[FrameLen - 2]; } private static WeightSample? ParseFrame(ReadOnlySpanbyte frame) { char state (char)frame[1]; // S 稳定 / U 不稳定 char sign (char)frame[2]; // 或 - var digits Encoding.ASCII.GetString(frame.Slice(3, 6)); var unit Encoding.ASCII.GetString(frame.Slice(9, 2)).Trim(); if (!double.TryParse(digits, out var raw)) return null; double gram UnitConverter.ToGram(raw, unit) * (sign - ? -1 : 1); return new WeightSample { ValueGram gram, // 秤自己报的不稳定,直接判 false,不用再过过滤器 IsStable state S, RawFrame Encoding.ASCII.GetString(frame) }; } }几个位置参数得对着厂商协议文档核:frame[1]是状态位,frame[2]是符号,frame[3..9)是六位 ASCII 数字,frame[9..11)是单位,最后两位分别是校验和回车。校验用的是累加和取低七位再偏移,不是常见的异或,这点如果照抄网上示例会一直校验失败——现象就是所有帧都被丢弃,一帧数据都出不来。i FrameLen那一行有个细节:即使校验失败也要推进指针。早期写成只在成功时推进,结果遇到一段固定错位的数据就死循环了,CPU 直接打满。这个属于低级错误,但在赶工期的时候特别容易犯。3.3 BCOM 系列:连续输出流与粘包处理BCOM 系列是另一套逻辑:秤上电之后不等人问,按设定频率连续往外吐,每行以回车换行结束,行内是逗号分隔的字段。长度不固定,所以不能按固定长度切,得靠行结束符。public sealed class BcomSeriesProtocol : ProtocolAdapterBase { private const byte Lf 0x0A; // 行结束符 public override string Model BCOM; protected override void Drain(ListWeightSample output) { var span Buffered; int start 0, consumed 0; for (int i 0; i span.Length; i) { if (span[i] ! Lf) continue; // 切出一整行,去掉行尾可能的 \r var line span.Slice(start, i - start); if (line.Length 0 line[^1] 0x0D) line line[..^1]; if (line.Length 0) ParseLine(line, output); start i 1; consumed start; } Consume(consumed); } private static void ParseLine(ReadOnlySpanbyte line, ListWeightSample output) { var text Encoding.ASCII.GetString(line); var parts text.Split(,); // 字段数不符说明是别的调试信息,静默跳过 if (parts.Length 3) return; if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var raw)) return; output.Add(new WeightSample { ValueGram UnitConverter.ToGram(raw, parts[2]), IsStable parts.Length 3 parts[3] 1, RawFrame text }); } }粘包和半包是这条路上的主要障碍。秤按固定周期吐,但操作系统给到的字节块不一定对齐在行边界上:可能一次收到两行半,也可能一行被拆成两次。缓冲区 只消费到最后一个完整行的做法能覆盖两种情况。Consume(consumed)里如果把consumed写成span.Length,那半行就被扔了,表现是重量偶尔跳一个错误值,频率大概几十次里出一次,非常难查。double.TryParse必须带CultureInfo.InvariantCulture。工控机有些是区域设置改过的,小数点会被识别成千位分隔符,1.234解析成1234,重量直接放大一千倍。这个坑后面第 5 章还会展开。3.4 稳定判定与单位归一秤自己报的稳定位有时候靠不住,尤其是老秤,读数还在缓慢漂移的时候照样给你个稳定标志。所以中间件在协议层之上再加一道滑动窗口过滤:public sealed class StableWeightFilter { private readonly int _window; // 窗口长度,建议 6~10 private readonly double _tolerance; // 容差,单位克 private readonly Queuedouble _samples new(); private double _lastStable; public StableWeightFilter(int window, double toleranceGram) { _window Math.Max(2, window); _tolerance Math.Max(0.1, toleranceGram); } public bool Push(double gram, out double stableValue) { _samples.Enqueue(gram); while (_samples.Count _window) _samples.Dequeue(); stableValue _lastStable; if (_samples.Count _window) return false; // 窗口内极差小于容差,认为已经稳定 double min _samples.Min(), max _samples.Max(); if (max - min _tolerance) return false; _lastStable Math.Round(_samples.Average(), 2); stableValue _lastStable; return true; } }_window太小会误判,物料还在晃就报稳定;太大则响应慢,操作工得等着。打包场景用 8 比较合适,大约 1~2 秒出结果。_tolerance要跟秤的分度值对齐,分度 2 克的秤给 5 克容差差不多,给 0.5 克就永远等不到稳定。单位归一是另一处必须做死的地方,因为不同型号默认单位不一样:单位标识换算到克的系数说明g/G1直接使用kg/KG1000最常见t/T1000000大吨位地磅lb453.59237部分进口设备oz28.349523少见于工业场景public static class UnitConverter { public static double ToGram(double value, string unit) unit.Trim().ToLowerInvariant() switch { g value, kg value * 1000d, t value * 1_000_000d, lb value * 453.59237d, oz value * 28.349523d, // 认不出来就按克处理,同时在日志里告警 _ value }; }认不出来的单位默默按克处理是个妥协。更严格的做法是抛异常,但现场调试时抛异常会导致整个链路停住,反而不好查。折中方案是按克走并在日志里打一条告警,配置改对之后重启即可。这个取舍在中间件里很常见——可用性优先于正确性,但必须留下痕迹。4. 通信层与 SDK 封装串口生命周期、位数隔离和上机联调4.1 串口生命周期:打开、关闭与热插拔重连串口对象是有限资源,而且SerialPort这个类在 .NET 里的实现有些历史遗留问题,用不好会卡死。传输层的封装基本围绕这几点展开:public sealed class SerialTransport : ITransport { private readonly SerialPort _port; private readonly object _gate new(); private volatile bool _closing; public event Actionbyte[], int, int? DataReceived; public SerialTransport(TransportConfig cfg) { _port new SerialPort(cfg.Port, cfg.Baud, cfg.Parity, cfg.DataBits, cfg.StopBits) { ReadTimeout 500, WriteTimeout 500, // 收到 1 字节就触发,别等缓冲区攒满 ReceivedBytesThreshold 1, // 有些秤靠 DTR 供电或者判断主机是否在线,不拉高就不发数据 DtrEnable cfg.DtrEnable, RtsEnable cfg.RtsEnable }; _port.DataReceived OnDataReceived; } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { if (_closing) return; try { int n _port.BytesToRead; if (n 0) return; var buf new byte[n]; int read _port.Read(buf, 0, n); if (read 0) DataReceived?.Invoke(buf, 0, read); } catch (Exception ex) when (ex is IOException or InvalidOperationException) { // 拔线、驱动异常都会走到这里,交给上层重连策略 StateChanged?.Invoke(DeviceState.Disconnected); } } public void Close() { _closing true; _port.DataReceived - OnDataReceived; try { // 先 Discard 再 Close,能避开一部分关闭时的挂起 _port.DiscardInBuffer(); _port.DiscardOutBuffer(); if (_port.IsOpen) _port.Close(); } catch { /* 关闭阶段的异常吞掉,不影响上层 */ } _port.Dispose(); } }ReceivedBytesThreshold 1是关键。默认值是 1,但如果有人手贱改成几十,表现就是数据延迟明显,而且DataReceived触发得非常稀疏。DtrEnable更是玄学级别的存在——同样的代码,某台秤上电就吐数据,换一台死活不发,查半天才发现它要检测到 DTR 拉高才认为主机在位。Close()那段在DataReceived回调线程里直接调用会导致死锁,这是 .NET 里SerialPort的老问题。做法是在关闭前先解绑事件,再Discard,最后Close。更稳妥的方式是把关闭操作丢到线程池上异步执行,加个Task.Run包一层,避免 UI 线程等待。实际项目里我一般把Close()做成async,内部await Task.Run(...),调用方await一下,卡死概率基本归零。重连策略单独抽了个类,按指数退避:public sealed class ReconnectPolicy { private readonly int _maxDelayMs; private int _attempt; public ReconnectPolicy(int maxDelayMs 30_000) _maxDelayMs maxDelayMs; public int NextDelayMs() { // 500ms 起步,翻倍,封顶 maxDelayMs,避免拔线时疯狂重试打满 CPU var delay (int)Math.Min(_maxDelayMs, 500 * Math.Pow(2, _attempt)); return delay; } public void Reset() _attempt 0; }退避上限 30 秒是经验值。试过 5 秒的,结果一台秤网线松了,程序一整天在重连和超时之间空转,日志刷了几百兆。也试过 5 分钟的,人工插回去之后要等五分钟才恢复,操作工直接就投诉了。4.2 厂家 SDK 封装与位数隔离有些高端型号不提供裸串口协议,只给一个 SDK:一个托管 DLL 加一个 native DLL,native 那个只有 32 位版本。封装层要处理三件事:位宽、句柄生命周期、崩溃隔离。internal static class NativeMethods { // 厂家 native 库只有 x86,调用约定按文档给的是 StdCall [DllImport(VendorScaleNative, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi)] internal static extern int Scale_Open(string port, int baud, out IntPtr handle); [DllImport(VendorScaleNative, CallingConvention CallingConvention.StdCall)] internal static extern int Scale_Read(IntPtr handle, [Out] byte[] buffer, int size, out int read); [DllImport(VendorScaleNative, CallingConvention CallingConvention.StdCall)] internal static extern int Scale_Close(IntPtr handle); } public sealed class VendorSdkScale : ITransport, IDisposable { private IntPtr _handle IntPtr.Zero; private readonly byte[] _readBuffer new byte[512]; public void Open(string port, int baud) { // 返回值 0 表示成功,其余为错误码 int rc NativeMethods.Scale_Open(port, baud, out _handle); if (rc ! 0) throw new InvalidOperationException($SDK 打开失败,错误码 {rc}); } public int Read(Spanbyte dest) { if (_handle IntPtr.Zero) return 0; int rc NativeMethods.Scale_Read(_handle, _readBuffer, _readBuffer.Length, out int read); // SDK 某些版本读超时也返回非 0,这里按没有数据处理而不是抛异常 if (rc ! 0 || read 0) return 0; _readBuffer.AsSpan(0, read).CopyTo(dest); return read; } public void Dispose() { if (_handle ! IntPtr.Zero) { NativeMethods.Scale_Close(_handle); _handle IntPtr.Zero; } } }BadImageFormatException是这个模块最常见的报错,原因只有一个:32 位 native DLL 被 64 位进程加载了。解决办法就是在 csproj 里锁PlatformTargetx86,或者把整个 SDK 调用放到一个独立的 x86 辅助进程里,通过命名管道和主程序通信。后者听起来重,但在生产环境里我越来越倾向这个方案。native 库一旦内部崩了,同进程内是整个程序一起没,独立进程最多是辅助进程重启,主程序无感。代价是多一次 IPC 序列化,对重量数据这种低频场景完全可接受。4.3 配置文件与型号切换配置驱动是这套中间件能多型号兼容的基础。每台设备一段 JSON,型号、串口参数、协议参数、过滤参数全在里面:{ devices: [ { id: scale-pack-01, model: RL, transport: { type: serial, port: COM3, baud: 9600, parity: None, dataBits: 8, stopBits: One, dtrEnable: true }, protocol: { unit: kg, decimals: 2 }, filter: { window: 8, toleranceGram: 5 } }, { id: scale-store-02, model: BCOM, transport: { type: serial, port: COM5, baud: 19200, parity: Even, dataBits: 7, stopBits: One }, filter: { window: 6, toleranceGram: 2 } } ] }现场换秤,改 JSON 重启即可,不用重新编译。model字段就是ProtocolRegistry的键,新增型号只要注册一个适配器,配置文件里写上那个名字就行。加载代码要处理两个细节:一是配置里的枚举字符串要转成Parity、StopBits这些类型,二是出错时要能报出是哪一台设备哪一行。public static class ConfigLoader { private static readonly JsonSerializerOptions Options new() { PropertyNameCaseInsensitive true, // 允许 JSON 里写注释和尾逗号,现场手改配置少踩坑 ReadCommentHandling JsonCommentHandling.Skip, AllowTrailingCommas true }; public static ListDeviceConfig Load(string path) { var json File.ReadAllText(path); var root JsonSerializer.DeserializeConfigRoot(json, Options) ?? throw new InvalidDataException(配置文件为空); // 设备 id 重复会导致事件串台,加载阶段就拦住 var dup root.Devices.GroupBy(d d.Id).FirstOrDefault(g g.Count() 1); if (dup ! null) throw new InvalidDataException($设备 id 重复: {dup.Key}); return root.Devices; } }PropertyNameCaseInsensitive让 JSON 里写DtrEnable或dtrEnable都行。AllowTrailingCommas和ReadCommentHandling是给现场工程师留的余地,他们改配置时习惯加注释,严格模式下直接报错,体验很差。4.4 编译、启动与无秤联调环境准备就是 .NET SDK,双目标的项目在 Linux 上也能编译 net6 那部分,但串口调试建议在 Windows 上做。编译命令:# 只编核心库,先确认没有编译错误 dotnet build src/WeighBridge.Core/WeighBridge.Core.csproj -c Release # 编测试台,net48 目标需要 Windows dotnet build tools/WeighBridge.Demo/WeighBridge.Demo.csproj -c Release -f net48手边没秤的时候,用虚拟串口工具建一对互相连通的端口(比如 COM3 和 COM4),测试台连 COM3,模拟器连 COM4 往里灌数据。模拟器本身也是项目的一部分,核心就是往串口写拼好的帧:using var port new SerialPort(COM4, 9600, Parity.None, 8, StopBits.One); port.Open(); for (int i 0; i 100; i) { // 拼一帧 RL 格式:STX 状态 符号 6 位重量 单位 校验 CR var body $S{1234 i:D6}kg; int sum body.Sum(c (int)c); char checksum (char)((sum 0x7F) 0x20); port.Write($\u0002{body}{checksum}\r); Thread.Sleep(200); // 模拟 5Hz 输出 }\u0002就是 STX,\r是 CR。校验算法必须和解析端一致,否则模拟器自己发的帧全被丢弃,还会误以为是解析代码写错了。调试的时候建议在解析端把RawFrame打出来,能同时验证两侧。联调的验收标准很简单:连续跑十分钟,稳定值不跳变、断线能自动重连、拔插串口后能恢复。这三条过了,基本可以上现场。提示:虚拟串口对在部分驱动版本下会出现数据延迟,联调时如果偶发丢帧,先换成真实 USB 转串口线复测,别急着改代码。5. 踩坑排查串口中间件最常翻车的五个现场5.1 串口能打开,但一帧数据都收不到现象很直观:Open()返回成功,IsConnected是 true,日志里却连一条原始帧都没有,等几分钟依旧如此。原因通常有三层。最常见的是接线:RS232 的 TX/RX 要交叉,直连线接上去两个设备都在发,却谁也收不到,这个占了我遇到的案例的一大半。第二层是串口参数不匹配,尤其是校验位和数据位——某型号默认是 7 位数据 偶校验,代码里按 8 位无校验配,收到的字节全是乱码,帧头对不上就被当成噪声跳过了。第三层是硬件流控,秤那边把 DTR 当作主机在位信号,DtrEnable不给 true 就一直沉默。排查顺序建议从软到硬:先把RawFrame和原始字节打出来,如果连乱码都没有,基本是线路或 DTR 问题;如果有一堆对不上帧头的乱码,那就是波特率或者校验位配错了。我一般会写个几十行的纯收字节小程序,不经过任何协议解析,直接Console.WriteLine(BitConverter.ToString(buf)),一眼就能看出问题在哪一层。5.2 重量读数偶尔跳变,末尾几位乱抖现象是大部分时间读数正常,但每隔几十秒会蹦出一个明显错误的值,比如实际 1.234 kg 突然显示 1234 kg,或者小数点后几位完全没有规律。前者几乎可以确定是区域设置问题。double.TryParse不指定 culture 的时候走当前线程的区域设置,某些工控机的区域被设成了使用逗号做小数点的格式,1.234就会被理解成一千二百三十四。解决就是统一传CultureInfo.InvariantCulture,别依赖机器默认值。后者多半是缓冲区切帧没对齐。检查Consume()的偏移是不是只推进到最后一个完整帧的结尾,而不是把整个缓冲区长度都消费掉。这个 bug 特别阴——大部分时候数据量刚好对齐在帧边界上,看不出问题,一旦网络或驱动把数据拆包拆到了奇怪的位置就露馅。定位方法是在Feed入口把count打出来,看是不是每次都是固定值,波动很大就要怀疑切帧逻辑。5.3 连续跑几小时后句柄泄漏、进程卡死现象是程序启动后一切正常,运行四五个小时后内存缓慢上涨,任务管理器里句柄数一路攀升,最后某次操作直接无响应。原因是每次重连都new SerialPort(...),旧对象只调了Close()没Dispose(),底层句柄没释放。SerialPort内部持有SafeFileHandle,不 Dispose 的话 GC 不一定及时回收,而串口句柄在 Windows 上是独占的,泄漏几十个之后系统资源就见底了。修法是把传输层做成严格的IDisposable,重连时先using掉旧的再建新的。另外DataReceived事件必须显式-,因为事件订阅会让对象保持存活,循环重连时旧对象永远回收不掉。这一点在长周期运行的工控场景里格外致命,写代码时很难感觉到,跑一天就现原形。5.4 调试机上一切正常,拷到工控机就抛 BadImageFormatException现象是开发机上是 x64 系统,x86 和 x64 都能跑,到了工控机上程序一启动就报BadImageFormatException,堆栈指向那个 native DLL 的DllImport。原因就是位数不匹配。AnyCPU 默认在 64 位系统上以 64 位进程运行,而厂家那个 native 库只有 32 位版本,加载器找不到能用的映像就直接抛。开发机上没暴露是因为测试时可能用了不同的 target,或者根本没走到调 SDK 那条分支。解决办法有两个:一是把 csproj 的PlatformTarget锁成x86,整个进程跑 32 位,简单直接;二是把 SDK 调用拆到独立的 x86 辅助进程,主程序保持 AnyCPU。前者适合整个系统都依赖这个 SDK 的场景,后者适合只有个别型号需要走 SDK 的情况。选哪个取决于 SDK 在这个项目里是主角还是配角。5.5 断线重连之后,数据流里混进了上一次的残帧现象是拔掉串口再插回去,重连成功之后头几条重量值明显是错的,过几秒才恢复正常。原因是重连时没有清空协议层的缓冲区。断开之前可能正好收到半帧,残留在_buffer里,新连接的数据接上去之后,这个半帧加上新帧的前半段被当作一个完整帧解析,校验当然过不了,但更糟糕的是切帧指针被推到了错误位置,后面连续几帧都会错位。修法很直接:重连成功之后立刻调一次adapter.Reset(),把协议缓冲区清干净。同理,SerialTransport在Open()之后也要DiscardInBuffer(),因为驱动层的接收缓冲区也可能存着旧数据。这两步我都写成了重连回调里的固定动作,少一步都不行。注意:这五个坑里有四个都不会在开发阶段暴露,只在长时间运行和现场环境下出现,所以上现场前务必做一次超过两小时的挂机测试。6. 二次开发加一个新型号要动哪几处、怎么验证新增型号这件事,在这套结构里被压缩成了固定动作,难点不在写代码,在于把协议文档读明白。整个改造范围就这几处:步骤改动点涉及文件验证方式1抓一帧真实数据无串口助手十六进制模式2写适配器子类Protocols/XxxProtocol.cs用抓到的帧回放单测3注册型号ProtocolRegistry构造函数配置文件写新型号能创建4补单位换算UnitConverter的 switch各量程各单位的样本比对5现场联调configs/devices.json挂机两小时不漂移第一步最容易被跳过,但恰恰最重要。协议文档写的帧格式和实际设备吐出来的经常有出入,尤其是校验算法和默认单位。先把串口助手切到十六进制模式,收一段原始数据存成文本文件,后面写单测全靠它。适配器的骨架大概是这个样子,以一个新的定长 CRC16型号为例:public sealed class CrcFrameProtocol : ProtocolAdapterBase { private const byte Head 0xAA; private const int FrameLen 14; public override string Model CRCFRAME; protected override void Drain(ListWeightSample output) { var span Buffered; int i 0; while (i span.Length - FrameLen) { if (span[i] ! Head) { i; continue; } var frame span.Slice(i, FrameLen); if (!CheckCrc16(frame)) { i FrameLen; continue; } // 重量字段是大端序的定点数,除以 100 得到实际值 int raw (frame[3] 24) | (frame[4] 16) | (frame[5] 8) | frame[6]; output.Add(new WeightSample { ValueGram raw / 100d, IsStable (frame[2] 0x01) 0x01, RawFrame Convert.ToHexString p a hrefhttps://download.csdn.net/download/yjg428/12135090 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p