ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#电子秤数据接口中间件:多型号串口协议适配与稳定采集

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 Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <!-- 双目标:老工控机用 net48,新项目用 net6.0 --> <TargetFrameworks>net6.0;net48</TargetFrameworks> <!-- 厂家 SDK 只有 32 位版本,默认按 x86 出包 --> <Platforms>x86;x64</Platforms> <PlatformTarget>x86</PlatformTarget> <LangVersion>latest</LangVersion> </PropertyGroup> <ItemGroup Condition="'$(TargetFramework)' == 'net48'"> <!-- net48 不自带串口和 JSON,得手动引包 --> <PackageReference Include="System.IO.Ports" Version="6.0.0" /> <PackageReference Include="System.Text.Json" Version="6.0.0" /> </ItemGroup> </Project>

TargetFrameworks用分号隔开,编译时两个都出;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 EventHandler<WeightSample>? WeightChanged; event EventHandler<DeviceState>? 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, List<WeightSample> output); void Reset(); }

这里用List<WeightSample> output做输出参数,而不是返回IEnumerable<WeightSample>或者IAsyncEnumerable,是个踩过坑之后的决定。早期版本用yield return写得很优雅,但解析函数参数是ReadOnlySpan<byte>,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, List<WeightSample> 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(List<WeightSample> output); protected ReadOnlySpan<byte> 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 Dictionary<string, Func<IProtocolAdapter>> 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, Func<IProtocolAdapter> 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(List<WeightSample> 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(ReadOnlySpan<byte> 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(ReadOnlySpan<byte> 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(List<WeightSample> 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(ReadOnlySpan<byte> line, List<WeightSample> 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 Queue<double> _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 Action<byte[], 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(Span<byte> 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 里锁PlatformTarget=x86,或者把整个 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 List<DeviceConfig> Load(string path) { var json = File.ReadAllText(path); var root = JsonSerializer.Deserialize<ConfigRoot>(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(List<WeightSample> 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 href="https://download.csdn.net/download/yjg428/12135090" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进