
简介基于C#技术的无人值守地磅称重系统源码面向工业与物流领域需要实现称重自动化的开发人员及系统集成商。资源共243个文件约148MB涵盖100个C#源代码、47个动态链接库、19个XAML界面、13个PDF文档、10个报表文件及4个可执行文件等C#源码支撑业务逻辑与算法XAML构建交互界面PDF提供说明与维护文档报表文件用于生成统计信息。系统支持自动称重、数据采集与存储可对接条码扫描器、打印机等硬件实现无人值守运行适合作为二次开发底版或学习自动化称重系统的参考。已有256人学习下载源码目录结构完整便于按模块拆解研究从界面交互到硬件通信均有清晰实现。1. 无人值守地磅称重系统C#上位机源码到底在解决什么接触过地磅称重系统的工程师应该都有体会称重本身只是读一个数真正难的是无人值守。没人看着地磅就得用代码保证车辆不会二次过磅、不会不完全上磅、不会毛重和皮重错位还要在道闸、车牌识别、红外对射、仪表之间协调时序。基于C#技术的无人值守地磅称重系统设计源码本质上是把这套流程状态机、串口/网络通信、数据库落库和防作弊逻辑写成一份可以二次开发的C#上位机工程。它适合正在做物流园、搅拌站、钢厂、粮库等场景地磅改造的从业者也适合想快速交付整套无人值守方案的个人开发者。下文按我做上位机的习惯把架构、核心代码和最容易翻车的地方拆开讲。2. 系统架构与通信选型C#上位机为什么是地磅主控的首选2.1 无人值守地磅的四大组成单元与数据流向一套能真正跑起来的无人值守地磅系统至少包含四个独立单元称重仪表、车牌识别相机、道闸控制、业务数据库外围还会接红外对射、LED引导屏、语音播报和红绿灯。称重仪表通过串口或网口把重量读数传给上位机车牌识别相机通过HTTP或厂商SDK把车号、抓拍图传给上位机上位机做判断后再通过Modbus TCP或继电器IO控制道闸抬起或落下最终把毛重、皮重、净重、车号、时间、图片路径写进数据库。选C#做这个主控理由很实际地磅厂家的仪表协议和车牌识别SDK大多数给的是C/C示例但也有不少直接提供C#版本或者可以通过串口/HTTP轻松对接WinForm或WPF做工业界面比写Web端更顺System.IO.Ports、System.Net.Sockets都是内置能力不需要额外装运行时数据库这块SqlBulkCopy处理大批量过磅记录非常成熟机器视觉上还有OpenCvSharp可以做抓拍处理。可以说C#是最接近“开箱即用”的上位机语言踩坑少。整个数据流向可以用一句话概括车辆上磅触发红外对射仪表连续读数上位机判断重量稳定后触发抓拍和识别再按业务规则计算毛/皮/净重并落库最后抬杆放行。这个顺序不能乱尤其“稳定判定”和“道闸抬杆”之间的先后关系是后面几章反复要讲的重点。2.2 串口读取称重仪表SerialPort的初始化与稳定读数绝大多数地磅仪表都提供RS232串口输出协议五花八门常见的有耀华、柯力、托利多等格式但基本都是“帧头数据校验帧尾”。第一步不是写代码而是用串口助手抓一下仪表的原始报文确认波特率、校验位和帧格式否则后面全白做。using System.IO.Ports; // 仪表串口参数波特率96008位数据无校验1位停止位 private SerialPort _scalePort; private readonly Listbyte _frameBuffer new Listbyte(); void InitScalePort() { _scalePort new SerialPort(COM3, 9600, Parity.None, 8, StopBits.One); _scalePort.DataReceived OnDataReceived; _scalePort.Open(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var sp (SerialPort)sender; int n sp.BytesToRead; byte[] bytes new byte[n]; sp.Read(bytes, 0, n); // 仪表通常用0x02作为帧头0x03作为帧尾 foreach (byte b in bytes) { if (b 0x02) _frameBuffer.Clear(); _frameBuffer.Add(b); if (b 0x03 _frameBuffer.Count 8) { byte[] frame _frameBuffer.ToArray(); _frameBuffer.Clear(); string ascii System.Text.Encoding.ASCII.GetString(frame).Trim(\0); // 按仪表协议截取重量字段例如取第4~12位 if (decimal.TryParse(ascii.Substring(3, 8), out decimal weight)) { OnScaleWeightReceived(weight); } } } }这段代码里最关键的是帧缓冲。很多新手直接在DataReceived里一次性Read然后转字符串解析结果会发现重量偶尔少一位或粘包。因为串口DataReceived不保证一次收完一帧必须按帧头帧尾累积拼接等完整帧到了再统一解析。参数上波特率、校验位必须和仪表实际配置一致通常仪表菜单里可以改不确定时优先试9600/None/One这是出厂默认最常见。收到重量后不要直接在DataReceived事件里做界面更新或数据库写入。这个事件跑在后台线程池直接操作Label或TextBox会抛跨线程异常数据库写入也不应该高频执行。正确做法是把重量转发给状态机或UI线程后面的章节会专门说。2.3 网络摄像机与道闸C#对接UVC和SDK的选型地磅现场至少要两台相机一台拍车头用于车牌识别一台拍车尾用于核对车厢状态有些还加顶拍看是否压磅。常见的相机接入方式有两种普通网络相机走ONVIF或厂商HTTP接口抓拍一张JPEG车牌识别一体机走厂商SDK回调识别结果直接推给上位机。还有一种老式USB相机走DirectShow/UVC通道C#里可以用AForge或OpenCvSharp捕获回调里用设备索引区分多个摄像头。建议不要直接在各处散乱调用相机SDK而是先抽一个ICameraService接口把“识别到车辆”和“抓到图片”统一成事件状态机只关心事件不关心相机品牌。public class VehicleImage { public string DeviceKey { get; set; } // Front / Rear public string PlateNo { get; set; } public byte[] JpegData { get; set; } public DateTime Time { get; set; } DateTime.Now; } public interface ICameraService { event ActionVehicleImage VehicleDetected; void Start(string ip, string userId, string password); } public class HttpCameraService : ICameraService { public event ActionVehicleImage VehicleDetected; public void Start(string ip, string userId, string password) { // 常见做法HttpClient轮询或SDK事件回调 // 收到识别结果后触发 VehicleDetected } }参数上IP、用户名、密码不要写死在代码里要放配置文件否则换一台相机就要改源码重新编译。区分多个摄像头时建议用DeviceKey比如“Front”“Rear”而不是相机编号因为编号在重启后可能变化。道闸控制相对简单常见的是通过串口IO模块或Modbus TCP写一个线圈如果是网络IO继电器HTTP POST一个开关指令即可。控制道闸前一定要确认当前没有车在磅上否则会出现落杆砸车的严重事故。3. 称重核心流程从车辆上磅到数据入库的完整状态机3.1 状态机设计避免重复称重和毛皮错位无人值守地磅最大的业务风险不是设备坏而是流程错一辆车过磅两次被当成两条记录毛重和皮重错位皮重异常却没有被拦下。解决这些问题不能靠堆if必须设计一个显式的状态机。状态机把整个过磅过程拆成若干状态每个事件只有到达特定状态才被处理其余情况一律忽略。public enum WeighState { Idle, // 磅上无车 VehicleArrived, // 红外对射检测到车辆上磅 Weighing, // 正在连续读重量 Stabilized, // 重量稳定等待车牌识别和抓拍 Saving, // 正在写数据库 BarrierOpening, // 已保存抬杆放行 BarrierClosed // 杆落下等待车辆离开 } public class WeighStateMachine { private WeighState _state WeighState.Idle; public void OnVehicleDetected() { if (_state WeighState.Idle || _state WeighState.BarrierClosed) { _state WeighState.VehicleArrived; } } public void OnWeightChanged(decimal weight) { if (_state ! WeighState.VehicleArrived _state ! WeighState.Weighing) return; _state WeighState.Weighing; // 连续N次差值小于阈值后进入 Stabilized } public void OnSaveCompleted() { if (_state WeighState.Saving) _state WeighState.BarrierOpening; } }这段代码的核心思想是每个事件都做状态判断状态不对直接丢弃。比如车辆还没下磅再次触发VehicleDetected会被挡在Idle状态之外避免二次过磅。毛重和皮重的记录由业务方向决定——进门时记录毛重出门时记录皮重不能混如果是单向地磅则必须通过车号维度存当前皮重后续计算净重时再取。参数上红外对射触发后要延时几百毫秒再开始称重因为车辆上磅瞬间轮子压磅会有冲击连续稳定读数的次数建议至少3次重量差阈值按地磅分度值设定。这些参数不一定要做成配置项但至少要放在一个常量区并写注释方便现场调。3.2 车牌识别与道闸联动异步回调里区分多个摄像头车辆重量稳定后下一步是抓拍和车牌识别。这个环节最容易出现的问题就是“等车号还是不等车号”。如果等遇到车牌识别不出时整条流程卡死如果不等又可能把无车牌车辆放进去。常见做法是设置5秒超时超时后强制放行并挂异常标记人工事后复核。抓拍结果要等所有相机都返回因为后端的补拍查询需要完整图片。private readonly Dictionarystring, VehicleImage _capturedImages new Dictionarystring, VehicleImage(); private async Task CaptureAndRecognizeAsync(WeighContext ctx) { _capturedImages.Clear(); // Front和Rear两个相机并行抓拍5秒超时 var frontTask _cameraService.CaptureAsync(Front); var rearTask _cameraService.CaptureAsync(Rear); await Task.WhenAll( Task.WhenAny(frontTask, Task.Delay(5000)), Task.WhenAny(rearTask, Task.Delay(5000)) ); if (frontTask.IsCompleted) _capturedImages[Front] frontTask.Result; else Log.Error(Front相机抓拍超时); if (rearTask.IsCompleted) _capturedImages[Rear] rearTask.Result; else Log.Error(Rear相机抓拍超时); // 两个相机都返回或超时后才允许保存和抬杆 await SaveAndOpenBarrierAsync(ctx); }代码里最关键的是Task.WhenAny配合Task.Delay实现“等待但不卡死”。现场拍照时相机网络一个闪断就可能让整个队列堵住所以每个异步操作都要有超时。区分多个摄像头时回调函数的参数必须带DeviceKey不能用调用顺序去猜这是哪个相机。我见过有人用静态字段接收当前结果两台相机并发时互相覆盖最后图片张冠李戴查账非常麻烦。道闸联动要遵守一条铁律数据库保存成功后才能发抬杆指令。不要在保存的同时异步抬杆因为保存失败时车已经走了记录就丢了。正确顺序是状态机进入Saving写数据库写成功后再进入BarrierOpening调用道闸打开等道闸到位反馈再允许下一辆车上磅。3.3 数据入库用SqlBulkCopy处理高频过磅记录单条称重记录插入数据库本身不慢但无人值守地磅往往是多台磅同时工作而且每辆车还要关联图片路径、异常标志、操作用户等信息逐条INSERT在高并发下会出现锁竞争。常见做法是先攒一批DataTable然后用SqlBulkCopy批量写入写入速度是逐条INSERT的10倍以上。using System.Data; using System.Data.SqlClient; public void BulkInsertWeighRecords(DataTable records) { using (var bulk new SqlBulkCopy( Server192.168.1.100;DatabaseWeighBridge;Integrated Securitytrue;, SqlBulkCopyOptions.UseInternalTransaction)) { bulk.DestinationTableName dbo.WeighRecord; // 列映射必须写否则按顺序匹配会翻车 bulk.ColumnMappings.Add(VehicleNo, VehicleNo); bulk.ColumnMappings.Add(GrossWeight, GrossWeight); bulk.ColumnMappings.Add(TareWeight, TareWeight); bulk.ColumnMappings.Add(NetWeight, NetWeight); bulk.ColumnMappings.Add(WeighTime, WeighTime); bulk.ColumnMappings.Add(ImageFront, ImageFront); bulk.ColumnMappings.Add(ImageRear, ImageRear); bulk.ColumnMappings.Add(IsAbnormal, IsAbnormal); bulk.BatchSize 500; // 每批500行 bulk.WriteToServer(records); } }这里两个参数必须关注BatchSize控制每批写入行数太大容易锁表太小体现不出批量优势SqlBulkCopyOptions.UseInternalTransaction保证写入失败时整个批次回滚不会出现一部分写入一部分丢失。列映射一定要写清楚即使DataTable的列名和表列名完全一样也建议写防止重构时改了字段顺序导致数据错列。注意SqlBulkCopy只在SQL Server上可用如果是MySQL或PostgreSQL要换对应的批量接口但思路是一样的。4. 无人值守称重常见问题排查与避坑五个必踩的坑4.1 仪表串口偶发丢数据现象、原因与解决现象仪表读数偶尔卡住几秒过磅记录短缺现场说“系统死机了”重启后恢复正常。原因多半不是死机而是串口数据被拆包后没有正确拼接。串口的DataReceived事件触发不一定正好在一帧边界如果直接Read一串字节去解析很可能读了一半帧更隐蔽的问题是用户用串口助手调试后没释放COM口导致程序打开串口失败表现为“偶发无法称重”。解决按帧头帧尾拼帧是第一层防护第二层是打开串口前检查端口是否被占用第三层是在状态机里加读数超时比如连续3秒没有新重量就重新初始化串口。常见做法是每收到一帧完整数据就刷新看门狗时间戳定时检查这个时间戳超过阈值就Close再Open。我在现场处理过多次所谓“程序死机”最后都是串口被其他软件占用或者USB转串口线休眠导致的。注意工控机的USB供电管理Windows的USB选择性暂停会导致串口假死需要在电源管理里关掉。4.2 车辆未停稳就称重重量抖动造成的毛差错误现象同一辆车两次过磅重量差几十公斤甚至差到上百公斤司机投诉。原因车辆还没完全停稳仪表读数在跳动时就被取走或者货品是散装物料车辆经过坑洼路段后重量还在余振。解决不能只取一帧重量必须连续多次读数差值小于阈值才算稳定。阈值要根据地磅分度值设置常见1kg分度值的地磅用5kg阈值10kg分度值的地磅用10kg阈值稳定次数建议3~5次。private const int StableTimes 5; private const decimal StableDiffKg 5m; private readonly Queuedecimal _weightSamples new Queuedecimal(); private bool IsWeightStable(decimal currentWeight) { _weightSamples.Enqueue(currentWeight); if (_weightSamples.Count StableTimes) _weightSamples.Dequeue(); if (_weightSamples.Count StableTimes) return false; var min _weightSamples.Min(); var max _weightSamples.Max(); return max - min StableDiffKg; }这里要注意队列长度就是“连续稳定次数”如果最新重量和均值差距大队列会被新数据覆盖只要有一次跳变就会重新计数。不要用固定窗口内取平均因为车辆还在上磅时平均值也会漂这个按“最大最小差值”的窗口逻辑更稳。4.3 道闸与称重流程竞态先抬杆还是先保存现象系统落库还没完成道闸已经开始落下车辆没完全离开就被砸到车尾或者抬杆后程序异常记录没写库。原因道闸控制放在重量稳定后就执行没有等数据库保存完成道闸落闸是定时控制没有判断车辆是否完全离开。解决把“保存成功”作为抬杆的前置条件抬杆指令放在保存成功回调之后道闸落下前要读红外对射或轮轴检测器确认磅上没有车。我一般会把抬杆动作封装成独立异步方法只有SaveCompleted事件触发才调用。另外道闸开到位和关到位要有行程反馈信号不能只靠延时否则现场风大或机械故障时系统会误判。4.4 相机掉线与补抓拍图片凭证缺失怎么办现象称重记录里车辆照片缺一张或者车牌识别结果为空司机不认账。原因相机网络不稳定抓拍超时后没有重试机制或者抓拍回调在界面线程里执行界面卡顿导致回调延迟。解决每个相机的抓拍请求独立超时不要一台相机卡住影响另一台抓拍失败时先放行车辆并记录IsAbnormal1后台任务补抓拍如果车牌识别不到要保留原图事后人工对照车辆照片补录车号。现场最实用的做法是在地磅控制室留一台看板机把异常记录单独刷出来人工确认后补录数据而不是车辆卡在磅上等系统恢复。4.5 皮重异常与重复过磅防作弊不能只靠仪表现象司机用同一辆车换牌套牌或者过磅时不完全上磅导致两车皮重一样但实际装载不同。原因只保存了单次皮重没有维护车辆皮重档案重量稳定后没有判断车辆是否完全在磅上红外对射被遮挡顺序不对。解决维护一个车辆皮重基准表车辆首次过磅时记录皮重基准后续皮重与基准偏差超过3%~5%就报警同时在道闸两端各装一对红外对射只有两对都遮挡且重量稳定才认为车辆完全上磅。对于反复出入的固定车队皮重异常检测是无人值守最有效的防作弊手段比单纯依赖仪表稳定更有价值。5. 源码落地前要改的六个地方参数、线程与防反编译5.1 配置文件用JSON管理仪表、相机、道闸和阈值参数拿到一份C#地磅源码后第一步不是看里面写了多少功能而是看多少参数是硬编码的。硬编码串口号、IP地址、重量阈值、超时时间现场调试会让你苦不堪言。常见做法是建一个appsettings.json把所有外部依赖参数收拢进去。{ WeighBridge: { Port: COM3, BaudRate: 9600, DataBits: 8, Parity: None, StopBits: One }, Cameras: [ { Key: Front, Ip: 192.168.1.20, User: admin, Password: pass }, { Key: Rear, Ip: 192.168.1.21, User: admin, Password: pass } ], Barrier: { Type: ModbusTcp, Endpoint: 192.168.1.10:502, OpenCoil: 0 }, Threshold: { StableTimes: 5, StableDiffKg: 5, CaptureTimeoutMs: 5000, TareDeviationPercent: 3 } }public class AppConfig { public WeighBridgeConfig WeighBridge { get; set; } public ListCameraConfig Cameras { get; set; } public BarrierConfig Barrier { get; set; } public ThresholdConfig Threshold { get; set; } } // 程序启动时读一次变成全局静态实例 var config JsonSerializer.DeserializeAppConfig( File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, appsettings.json)));这里要注意几个坑一是配置文件要和exe放同一目录不要放桌面二是Parity、StopBits在System.IO.Ports里是枚举JSON里要用字符串反序列化时需要用JsonStringEnumConverter转换三是密码不要明文写在正式环境配置文件里项目初期可以先用上线前至少做一层简单加密或使用本机DPAPI。字段命名建议统一PascalCase避免一个库里一会儿下划线一会儿驼峰。5.2 界面不卡死的线程模型BeginInvoke与取消任务C#上位机最常见的界面问题就是程序“白屏不响应”原因几乎都是后台线程直接操作控件或者把耗时操作放在UI线程执行。SerialPort的DataReceived回调、TCP的异步回调都运行在线程池线程上直接写label.Text就会抛异常不用异常放在UI线程里执行网络等待也会卡死界面。private void OnScaleWeightReceived(decimal weight) { // 跨线程更新UI if (lblWeight.InvokeRequired) { lblWeight.BeginInvoke(new Actiondecimal(OnScaleWeightReceived), weight); return; } lblWeight.Text weight.ToString(0.0); }另一个更推荐的做法是使用Progress 和IProgress 配合async/await代码更清晰。界面上不要放太多一次性刷新逻辑重量显示建议每秒更新一次就够了不要每帧都刷新。还有WPF里要区分DispatcherWinForm里是Control.BeginInvoke这两个不能混用。现场工控机配置普遍不高强烈建议把“称重状态机引擎”和“UI显示”分离状态机跑在自己的类里只对外发事件UI只订阅事件做显示。这样即使界面卡住后台称重流程不会中断数据库记录不会丢。5.3 发布与防反编译混淆、强名称与日志脱敏C#编译出来的IL程序集很容易被反编译源码如果包含仪表协议或客户业务逻辑等于直接送人。商用项目至少要做三步防护第一使用开源混淆器比如ConfuserEx或Obfuscar对程序集做混淆能降低大部分代码的可读性但要注意混淆后反射和序列化可能失效需要测试再发布。第二给程序集加强名称防止程序集被替换篡改有些上位机项目还要验证Exe的数字签名防止现场被人替换成恶意版本。第三日志脱敏。串口号、IP、用户名、密码这类信息不要完整打到文本日志里尤其客户环境里有其他厂家人员时。我习惯做一个日志封装凡包含Password字段的自动替换成“***”数据库连接字符串只记录库名不记录账号。这不是技术难题但一旦泄露再补救就很麻烦。另外发布时还要注意目标框架老工控机建议用.NET Framework 4.8新机器可以用.NET 6以上但如果用了SqlBulkCopy要确认数据库客户端版本匹配避免运行时缺DLL。6. 进阶验证不接硬件也能在本地跑通整个称重流程拿到源码后想快速验证流程逻辑不用急着接仪表和道闸。我会在项目里加一个SimulatedScaleService实现和真实串口服务完全相同的接口内部用随机数模拟重量变化这样状态机、数据库落库、异常标志整个流程都能在普通电脑上跑通。public interface IScaleService { event Actiondecimal WeightReceived; void Start(); void Stop(); } public class SimulatedScaleService : IScaleService { public event Actiondecimal WeightReceived; private readonly Random _rnd new Random(); private bool _running; private decimal _currentWeight 10000m; public void Start() { _running true; Task.Run(async () { while (_running) { // 模拟车辆上磅后重量缓慢稳定 _currentWeight _rnd.Next(-2, 3); WeightReceived?.Invoke(_currentWeight); await Task.Delay(300); } }); } public void Stop() _running false; }实际项目中给状态机注入的是SerialPortScaleService测试时替换成SimulatedScaleService这就是最简单的依赖注入。验证时手动触发车辆到达事件观察状态是否按Idle到Save再到BarrierOpening流转同时检查数据库有没有记录写入。这个模拟器还有一个用途就是让现场操作员提前熟悉界面操作不接设备也能做培训。我自己的教训是第一版无人值守系统没有做状态机全用if判断结果车辆倒库和毛皮错位查了整整一周才找到问题。后来老老实实把状态机画出来把每个事件对应的状态转换打印到日志问题当天就定位了。做无人值守地磅逻辑正确比硬件可靠更重要状态机就是这个逻辑的骨架。希望帮到你。本文还有配套的精品资源点击获取