简介:一份基于C#实现的无人值守地磅称重系统设计源码,适用于需要构建自动化称重管理流程的开发者与项目团队,可降低人工操作成本、提升过磅效率。包内文件共137个,以91个C#源代码文件与21个XAML界面文件为主体,辅以8个config配置文件、4个csproj项目文件、4个vsidx索引文件、sln解决方案文件及settings、resx、gitignore等配套资源,整体约2.44MB,目录结构清晰,便于理解系统分层与界面逻辑。目前已有414人学习下载。资源覆盖从业务逻辑到界面交互的完整实现,包含IPC摄像头接入(CHCNetSDK、Ipcsdk)等关键模块,适合用于学习C#与XAML协同开发、地磅称重无人值守方案设计,也可作为二次开发或毕业设计参考。
1. 无人值守地磅称重系统:C# 在计量环节里到底管哪一段
一辆货车从进厂到出厂,过去要司机下车递单、门卫打电话、司磅员反复核对车号、手工录重量,一车下来十来分钟,高峰期排队排到厂外。无人值守地磅称重系统要解决的就是这段效率黑洞:车牌识别相机负责认车,红外对射确认上磅位置,称重仪表把重量实时送到工控机,道闸、红绿灯、LED 屏按流程自动动作,而这一整条链路的“总控”,通常就是一台 C# 写的上位机程序。C# 在这里不碰复杂的图像识别,也不碰嵌入式仪表内部,它专注做三件事:读串口重量、编排业务流程、把每一笔记录可靠落库。正在做计量、物流、工厂进出厂系统的工程师,照着这套设计思路可以直接搭出第一版能跑的系统,这也是我接下来的落地方案。
2. 把无人值守地磅拆成可落地的模块:从车牌识别到道闸联动的数据流
2.1 无人值守地磅的系统拓扑与 C# 程序的职责边界
现场设备大概有这么几类:称台和传感器埋在地磅坑里,它们把重量信号汇总到称重仪表,仪表对外提供串口输出;入口和出口各装一套车牌识别相机,相机通过以太网把识别结果交给工控机;道闸和红绿灯由继电器控制板驱动,控制板一般走串口或网口 IO;磅房里的工控机是整个系统的核心节点,C# 程序就部署在这台 Windows 机器上。
有人误以为无人值守就是把司磅员的位置换成一台电脑,实际上电脑要同时和五类设备打交道。串口这边,常见的是仪表和道闸控制板各占一个 COM 口;以太网这边,车牌识别相机一般通过 HTTP 或 SDK 主动推送识别结果,有些还带二次确认用的对讲和磅单打印功能。C# 程序在这些设备之间扮演的是协调者:收到相机回调,先判断当前状态允不允许抬杆;收到仪表重量,先判断重量是不是已经稳定;两路信号都满足,才去触发道闸动作。这个职责边界想清楚,后面写代码才不会把业务逻辑散得到处都是。
工控机选型也有讲究,我一般建议用带 PCI 串口卡的机器,而不是依赖 USB 转串口,原因我会在避坑章具体讲。软件层面,.NET Framework 4.x 和老项目兼容性最好,新项目用 .NET 6 及以上也没什么障碍,关键不是版本,而是串口、HTTP、数据库这三个模块的封装方式。
2.2 数据流设计:一次完整称重涉及哪些环节、谁先谁后
一次标准的进出厂称重,数据流是分两步走的。第一步是进厂毛重:车到达入口道闸,入口相机抓拍车牌,识别结果推送过来;C# 程序校验车牌在白名单内,给道闸控制板发抬杆指令;车开上磅,红外对射检测到车已到位,程序开始按固定周期读仪表重量,连续几次读数差值在阈值内就认为是稳定毛重,把车号、毛重、时间、抓拍照片一并存库,同时在 LED 屏上提示“毛重已记录,请驶离”。
第二步是出厂皮重,也就是回空车过磅。车从卸货点返回,出口相机再次识别车牌,程序查到该车有未完成的毛重记录,抬杆放行;车上磅后重复读数逻辑,得到皮重,毛重减皮重得到净重。到这里才生成完整磅单,打印小票、抓拍车顶和车尾照片、抬杆放行。注意毛重和皮重是两次独立过磅,不是一次过磅把两个数都读了。
有个很容易忽略的环节是“防作弊”,数据流里必须留出红外对射和视频抓拍的位置。比如车没完全上磅就停住,只有两个轮子在称台上,重量读数会偏小;再比如换车牌、套牌、压边,都需要红外对射的到位信号加上磅前后的照片一起来约束。C# 程序要做的是把这些信号按时间顺序串起来,任何一步缺失或超时,就自动转入人工处理队列,而不是直接放行。
2.3 核心接口选型:串口/Modbus/OPC 该走哪条路
现有地磅项目里最常见的仪表输出是 RS232 串口,少数用 RS485 转串口。串口协议分两类:一种仪表持续往外发重量帧,频率几赫兹到十几赫兹;另一种是主机发指令、仪表应答。C# 这边无论是哪种,本质都是读串口缓冲区的字节流,区别只在解析方式。Modbus 协议也常见,尤其是带 PLC 控制的厂区,Modbus RTU 走串口、Modbus TCP 走网口,C# 里有 NModbus 这类成熟库可以直接引用。OPC 一般出现在已经有大 PLC 系统的老厂,C# 接 OPC 需要额外装 OPC 客户端库,开发成本比前两者高。
从落地角度看,我的选择优先级是:纯地磅无 PLC 的,直接用串口仪表,实现最快;厂区已经有 PLC 控制道闸和红绿灯的,走 Modbus 或 OPC,避免两套控制逻辑打架;改造老系统时,优先在原有 PLC 上做程序联动,C# 只负责计量和数据库。接口类型定了,后面所有代码的输入输出边界也定了。
接口选型对比表:
| 方案 | 接口形式 | C# 实现成本 | 适用场景 |
|---|---|---|---|
| 仪表串口 | RS232/RS485 | 低,SerialPort 直接读 | 多数地磅项目 |
| Modbus | RTU/TCP | 中,引用库或自己拼报文 | 带 PLC 的厂区 |
| OPC | DCOM/UA | 高,需客户端配置 | 老 PLC 系统 |
| 相机 HTTP 回调 | 以太网 | 低,HttpListener 即可 | 车牌识别接入 |
3. 用 C# 实现地磅数据采集上位机:串口读数与指令解析的最小可跑代码
3.1 串口参数与仪表协议:连续发送和指令应答两种模式
拿到一台不熟悉的仪表,第一步不是写代码,而是去仪表说明书里找串口参数。绝大多数地磅仪表出厂默认波特率 9600、8 位数据位、无校验、1 位停止位,也就是常说的 9600-8-N-1。少数老仪表用 4800,也有用偶校验的,接不上时先用串口助手逐个波特率试探,比对着代码猜快得多。
协议格式上,连续发送模式最常见的是每帧以 STX(0x02)开头,重量正文用 ASCII 字符,比如“ST,GS,+001520kg”,以回车换行结束。命令应答模式则是主机先发一组指令,比如“读毛重”的十六进制命令,仪表再回一帧。前者代码简单但程序无法控制数据到达节奏,后者多一次握手但便于和刷卡器、红外对射联动,减少串口冲突。
提示:如果现场同时有仪表和道闸控制板,务必给它们分配不同的 COM 口,并确认没有其他软件占用串口。Windows 下两个程序同时开同一个串口是直接报错的,这是无人值守现场最常见的启动失败原因。
3.2 串口采集核心代码:连接、读帧、断帧重组的 C# 实现
串口读数据不能想当然地一次 ReadLine 就收工,因为一帧数据可能被拆成两三个包到达,直接按行读很容易拿到半个帧。正确做法是维护一个字节缓冲区,收到什么先追加进去,再按帧头帧尾把完整帧切出来。这部分代码在任何地磅上位机里基本通用。
using System; using System.Collections.Generic; using System.IO.Ports; using System.Linq; using System.Text; public class ScaleSerialReader { private SerialPort _port; private List<byte> _buffer = new List<byte>(); // 解析出一帧完整重量数据后触发 public event Action<string> OnFrameReceived; public ScaleSerialReader(string portName, int baudRate = 9600) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.DataReceived += DataReceived; _port.Open(); } private void DataReceived(object sender, SerialDataReceivedEventArgs e) { // 把当前串口缓冲区的字节全部搬到内部列表 while (_port.BytesToRead > 0) { int len = _port.BytesToRead; byte[] temp = new byte[len]; _port.Read(temp, 0, len); _buffer.AddRange(temp); } // 找到帧头 STX,丢掉之前的一切垃圾字节 int start = _buffer.IndexOf(0x02); if (start < 0) return; if (start > 0) _buffer.RemoveRange(0, start); // 找帧尾 CR+LF,只有帧尾完整才切帧 int end = -1; for (int i = 0; i < _buffer.Count - 1; i++) { if (_buffer[i] == 0x0D && _buffer[i + 1] == 0x0A) { end = i; break; } } if (end < 0) return; // 帧尾还没到齐,等下一次接收事件 byte[] frame = _buffer.Take(end).ToArray(); _buffer.RemoveRange(0, end + 2); OnFrameReceived?.Invoke(Encoding.ASCII.GetString(frame)); } }这段代码的核心逻辑是“攒够了再切”。DataReceived 事件触发时,理论上有多少读多少,但串口驱动不能保证从 Read 拿到的数据恰好是一整帧,所以每次都先追加到 List,然后从头找 STX。找到后从 STX 开始往后找回车换行,找到了才算一帧,帧尾之前的数据原样保留,等着下一次事件再拼接。
_frame 解析时还有个坑:仪表的 ASCII 帧里有可能是 GBK 编码的中文单位,比如“千克”,这时候 Encoding.ASCII 会拿到乱码。我一般统一用 Encoding.GetEncoding("GBK") 读正文,或者只按正则以提取数字部分,不依赖单位字符。重量提取可以这么写:正则匹配 [+-]?\d+(.\d+)?,第一个数字就是当前毛重或皮重。
3.3 数据校验与防抖:毛重/皮重何时有效
重量读数到手不代表车已经稳住。车开上磅的瞬间,秤台会经历一个明显的摆动过程,数字从几百公斤冲到一万多又回落,这个状态下直接读重量,结果一定偏大或偏小。业界通用的做法是防抖窗口:连续读 N 帧,每帧间隔约 500 毫秒到 1 秒,N 帧中最大值与最小值的差不超过设定阈值(比如 20 公斤),才认为重量稳定。
public class WeightStabilizer { private readonly int _requiredCount = 3; private readonly double _maxDiffKg = 20; private readonly List<double> _samples = new List<double>(); public bool TryFeed(double weight, out double stableWeight) { _samples.Add(weight); if (_samples.Count < _requiredCount) { stableWeight = 0; return false; } double maxVal = _samples.Max(); double minVal = _samples.Min(); if (maxVal - minVal <= _maxDiffKg) { stableWeight = _samples.Average(); _samples.Clear(); return true; } _samples.RemoveAt(0); // 滑窗:剔除最旧,保留最新 stableWeight = 0; return false; } }这个防抖类的关键参数是 _requiredCount 和 _maxDiffKg。地磅分度值一般是 20 公斤,_maxDiffKg 设成 20 比较合适;如果现场有大风或者秤台机械结构老化,读数天然会有 30 公斤左右的波动,阈值就得适当放大。调的太严,车辆一直不能判定稳定,后面流程全部卡住;调的太松,几十公斤的误差直接进了磅单。这个参数我在现场调过很多次,没有一劳永逸的值,只有结合称台状态来定。
3.4 对接车牌识别与道闸:HTTP 回调与 IO 控制的常见做法
车牌识别相机的接口各式各样,大多数厂家都提供 HTTP 回调,也就是相机识别到车牌后,主动往我们指定的端口推一串 JSON。C# 这边用 HttpListener 起一个轻量 HTTP 服务即可,不需要上 WebAPI 框架。
using System; using System.IO; using System.Net; using System.Text; using System.Text.Json; public class PlateCallbackServer { private HttpListener _listener; public void Start(string prefix = "http://+:9000/plate/") { _listener = new HttpListener(); _listener.Prefixes.Add(prefix); _listener.Start(); _listener.BeginGetContext(OnContext, null); } private void OnContext(IAsyncResult ar) { var ctx = _listener.EndGetContext(ar); _listener.BeginGetContext(OnContext, null); using var reader = new StreamReader(ctx.Request.InputStream); string body = reader.ReadToEnd(); using var doc = JsonDocument.Parse(body); string plate = doc.RootElement.GetProperty("plate").GetString(); string timeStr = doc.RootElement.GetProperty("time").GetString(); // 把识别结果交给主流程状态机处理 Program.Instance.HandlePlateArrived(plate, timeStr); byte[] resp = Encoding.UTF8.GetBytes("ok"); ctx.Response.StatusCode = 200; ctx.Response.ContentLength64 = resp.Length; ctx.Response.OutputStream.Write(resp, 0, resp.Length); ctx.Response.Close(); } }注意 Uri 前缀要注册成机器级才能让相机跨网络访问,否则只能本机访问。注册方式是在 Windows 命令行执行 netsh http add urlacl url=http://+:9000/ user=everyone,这个细节漏掉,相机回调永远到不了程序。道闸控制也一样,串口发一个开闸命令字符,继电器动作,C# 里就是一个 SerialPort Write,没有太多花活,重点是把开闸条件卡在状态机里,而不是裸奔放开。
4. 无人值守业务流转的实现:称重流程状态机与数据库落账
4.1 称重流程为什么需要状态机而不是 if-else
把整个称重流程写成 if-else,最常见的结果是代码里到处是“当前步骤”变量,每加一个新需求就要改七八个地方。比如现场新增“倒车二次上磅”这个操作,本来只是给车一次重新对齐的机会,if-else 写法可能改完这里漏了那里。状态机是一个更严谨的思路:定义系统所有可能的状态,定义每个状态在什么事件下能跳转到哪个状态,非法跳转一律拒绝。
无人值守地磅的状态序列不复杂,但每一个状态都要能回答三个问题:我现在是哪一步、什么事件能让我走到下一步、出现问题往哪兜底。状态机写清楚之后,后续加设备、加流程只需要在状态转移表里补映射关系,而不是动业务代码逻辑。
4.2 状态机核心代码与数据库落账
状态定义用枚举,状态转移用字典加判断函数,这样整个流转在代码里一眼能看到头。下面的代码是核心状态的流转骨架:
public enum WeighState { Idle, // 空闲,等待车辆 VehicleIn, // 车牌已识别,道闸放行 OnScale, // 红外对射确认车已上磅 GrossRead, // 毛重已成 Leaving, // 车已离场,等待回程皮重 BackOnScale, // 回车再次上磅 TareRead, // 皮重已成,净重计算完成 ManualNeeded // 异常,转人工 } public class WeighFlow { private WeighState _state = WeighState.Idle; private readonly Dictionary<WeighState, List<(string evt, Func<bool> guard, WeighState next)>> _map = new(); public WeighFlow() { _map[WeighState.Idle] = new() { ("PlatesArrived", () => true, WeighState.VehicleIn) }; _map[WeighState.VehicleIn] = new() { ("AllClear", () => _infraredOk, WeighState.OnScale) }; _map[WeighState.OnScale] = new() { ("WeightStable", () => _stableWeight.HasValue, WeighState.GrossRead) }; _map[WeighState.GrossRead] = new() { ("VehicleLeft", () => true, WeighState.Leaving) }; _map[WeighState.Leaving] = new() { ("PlatesArrived", () => true, WeighState.BackOnScale) }; _map[WeighState.BackOnScale] = new() { ("WeightStable", () => _stableWeight.HasValue, WeighState.TareRead) }; } public bool Fire(string evt) { if (!_map.ContainsKey(_state)) return false; foreach (var (ev, guard, next) in _map[_state]) { if (ev != evt) continue; if (!guard()) return false; _state = next; return true; } return false; } }状态机的触发口是 Fire 方法。车牌回调到达时触发 PlatesArrived,重量稳定时触发 WeightStable,红外对射复位触发 VehicleLeft。每条转移都可以挂一个守卫条件,比如 OnScale 状态下,如果红外对射被遮挡,即使仪表读数稳定也不允许走到 GrossRead。这就是状态机比 if-else 扎实的地方:非法路径根本走不进去。
数据库落账我一般用 SQLite。之前也接过 Access 老库,C# 里换成 OleDbConnection 改一下连接串就行,但不推荐新项目再用 Access,并发的读写锁太容易卡死。SQLite 单文件部署,压在一台工控机上完全够用。
using (var conn = new SQLiteConnection("Data Source=weigh.db")) { conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = @" INSERT INTO weigh_records (plate, gross_weight, tare_weight, net_weight, gross_time, tare_time, image_path) VALUES (@p, @g, @t, @n, @gt, @tt, @img)"; cmd.Parameters.AddWithValue("@p", plate); cmd.Parameters.AddWithValue("@g", grossWeight); cmd.Parameters.AddWithValue("@t", tareWeight); cmd.Parameters.AddWithValue("@n", netWeight); cmd.Parameters.AddWithValue("@gt", grossTime); cmd.Parameters.AddWithValue("@tt", tareTime); cmd.Parameters.AddWithValue("@img", imagePath); cmd.ExecuteNonQuery(); }落库的关键不只是插一条记录,而是毛重和皮重两次记录要关联起来。我一般在车牌上用主键式的关联方式来查:进厂时插入一条只有毛重的记录,出厂时按车牌找到未完成记录,更新皮重并计算净重。查询时要注意车牌识别偶尔会有 O 和 0 的识别差异,最好统一把字母转大写、数字和字母做一次归一化清洗再入库。
4.3 关键参数配置:车号白名单、误差允许值、超限处理
现场调试时,十个问题里有八个是参数没给对。我把经常要调的参数整理成了配置表,C# 程序启动时从一个 config.json 读入,改了参数不用重新编译,这在交付阶段非常关键。
| 参数 | 默认值 | 作用与调整建议 |
|---|---|---|
| 稳定判定帧数 | 3 | 现场波动大就加到 5,响应慢但更准 |
| 稳定误差阈值 | 20 kg | 按地磅分度值设定,大风地区适当放宽 |
| 红外到位超时 | 15 s | 车识别后迟迟不上磅,超时转人工 |
| 车牌白名单开关 | false | 开启后只认名单内车牌 |
| 最小称重量 | 100 kg | 小于此值视为空秤,异常报警 |
| 超限处理方式 | 禁止抬杆 | 超载车辆启动声光报警,等待人工 |
参数化配置的收益在交付后体现得最明显。比如换了一台地磅,分度值从 20 公斤变成 50 公斤,业主只需要在 config.json 里改一个数字;没有这个设计,就要去代码里翻常量、重新发布。所以这套设计源码里,我最看重 config 层和状态机层,而不是界面长什么样。
5. 无人值守地磅常见问题与排查:这 5 个地方最容易让系统翻车
5.1 串口读数偶尔丢帧、重量跳到十万八千里
现象:程序运行几小时或半天后,重量突然出现一个离谱的大数,比如明明车已经下磅,仪表读数还是几万公斤;或者读数正常,但某几帧数据中间少了一段。
原因:先怀疑 USB 转串口。无人值守现场工控机旁边就是道闸和灯光,电磁干扰大,USB 转串口的稳定性天然不如 PCI 串口卡。其次是仪表串口线过长,或者屏蔽层接地没做好。
解决:首选换插 PCI 串口卡或用带隔离的串口服务器(串口转以太网)。代码层面做两层防线:帧头和帧尾校验不能丢,重量解析后做范围校验,单次重量超过仪表量程 120% 就直接丢弃,并记录一条日志,等稳定判断通过才采用。
5.2 车没上磅到位,道闸就抬杆了
现象:司机把车停在磅台前,人没上磅,系统直接抬杆放行,后续毛重数据全是空的。
原因:抬杆条件只依赖车牌识别回调,没加红外对射到位信号。车牌识别在入口道闸处就完成了,车可能还在道闸外,离磅台差了十几米。
解决:开闸放行分为两级。入口识别只负责第一级抬杆,让车进入称重区域;第二级必须等红外对射确认车头进入磅台,两个信号都满足才允许后续读数流程启动。状态机里把 PlatesArrived 和 AllClear 分开,就是这个原因。
5.3 重量稳定判定太严,车一直没法出磅
现象:车上了磅,仪表读数明明不怎么动了,系统却迟迟不判定稳定,入口堵了一串车,司机按喇叭。
原因:防抖参数设太极端。_requiredCount 设成 5、_maxDiffKg 设成 5,大型地磅机械结构带来的微小抖动都能让读数窗口永远达不到要求。
解决:把稳定窗口改为“连续 3 帧差值不超过 20 公斤”,同时引入超时兜底:车到位后 30 秒内仍未稳定,自动转人工,由司磅员确认后强制记录当前重量。无人值守不是把人去掉,而是把人从重复劳动里解放出来,异常时人工接管是必要的安全阀。
5.4 程序在夜间无人时卡死,第二天现场乱成一团
现象:半夜程序突然崩溃或者界面假死,早上才发现夜间一辆车都没过磅,门口排队一公里。
原因:一个线程里同时处理串口数据、HTTP 回调、数据库写入,数据库偶尔锁一下,主线程 UI 就跟着卡。另外异常没有被捕获,一只老鼠咬断红外线,或者相机断电,程序没有容错路径。
解决:串口数据、相机回调、数据库操作绝对不要塞进 UI 线程;每个设备接入点都做 try-catch 并记录日志;数据库写入失败时先缓存到内存队列,等服务恢复再补写。再配一个外部看门狗,Windows 计划任务每 5 分钟检查一次进程是否在跑,不在就拉起。这个看门狗本身极简单,一条批处理加一个任务计划,但能救回一整夜的业务数据。
5.5 红外对射被树叶或灰尘遮挡,系统误判车辆到位
现象:红外对射安装在磅台两侧,刮风时树叶飘过去遮挡光束,程序误以为有车上磅,空秤状态开始读数。
原因:红外对射本质就是一个遮挡传感器,它分不清遮挡物是卡车还是树叶子。
解决:把红外对射的到位判定从电平触发改成持续确认。第一次检测到遮挡后,延时 2 秒再确认一次,仍然被遮挡才认为车辆到位。同时配合重量零点判断:红外遮挡期间,如果仪表读数始终小于最小称重量,视为异物遮挡而不是车辆。两个逻辑合起来,误判率会低很多。
6. 上线后的三个进阶技巧:日志回放、断网缓存与远程运维
系统上线头一个月,功能跑通只是及格线,真正的考验是出了问题能不能快速定位。我最早做这个项目时,现场半夜打电话说磅单数据不对,只能大老远跑过去抓现场,在工控机上翻遍历史记录也拼不出当时的操作顺序。后来我养成了一个习惯:所有关键事件都写结构化日志,一台车从进厂到出厂,车牌回调时间、毛重读数到达时间、稳定判定通过时间、道闸抬杆时间全部记成一行 JSON,重量的原始帧也完整保留。排查时把这些日志按时间轴拉出来,问题出在哪个环节一眼就能看到。
第二个技巧是断网缓存。无人值守现场经常不是核心机房,网络偶尔闪断,如果程序只依赖实时写数据库,断网期间所有称重记录都会丢。我的做法是在本地 SQLite 里维护一张待传表,所有过磅记录先写本地,再异步同步到中心服务器,中心库里加一个 sync_flag 字段来区分是否已上传。这样断网几小时业务不中断,网络恢复后自动补传,后台看到的只是延迟而不是缺口。
第三个技巧是配置远程化。所有可调参数集中在 config.json,包括仪表类型、串口波特率、稳定阈值、超时时间,甚至 LED 屏显示文案。现场业主打电话要改提示语,远程改一下 JSON 再热加载就生效,不需要专门跑一趟现场。热加载可以做成一个很简单的文件监听,定时检查文件更新时间,变了就重新加载到内存,配一个管理员密码保护,避免误改。
我现在做任何新项目,第一件事是把日志和数据分层设计好,界面反而放最后。没人喜欢半夜接到现场电话,而齐全的日志和远程可调参数,就是给自己留的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取