☰
C# WinForm对接电子秤RS232串口通信实战
2026/11/4 13:02:24 网站建设 项目流程

简介:这份资源面向需要在 WinForm 桌面程序中接入电子秤硬件的开发者,聚焦耀华 3190-A12+E 型号电子秤通过 RS232 串口读取重量的实现。代码区分了连续发送与指令两种工作模式:连续发送模式对应方案 2,指令模式对应方案 1,默认指令模式下可完成清零与读数操作,并提示先用超级终端验证电子秤能否正常向电脑发送数据,波特率 9600、其余参数默认即可,其他品牌电子秤需参照说明书调整。压缩包共 41 个文件,约 102KB,以 cs 源码、resx 与 resources 资源文件、config 配置、txt 说明及编译生成的 exe、pdb 等为主,构成一个可直接运行的完整 WinForm 工程。已有 2145 人学习下载,适合需要快速打通串口称重采集、理解两种通信模式差异并排查串口连通问题的开发者参考。

1. 车间里那台老秤,为什么非得用 WinForm 去读

产线打包工位上摆着一台耀华 3190-A12+E 电子秤,RS232 串口输出,工人称完重手动抄数再敲进 ERP,一天几百次,抄错一位就得返工对账。这类场景最省事的解法不是上什么新框架,而是写个 WinForm 小工具,把串口数据直接读进界面,称完自动落库。这个项目干的就是这件事:用 C# 桌面程序对接电子秤的 RS232 接口,实时解析重量帧,显示、记录、导出。适合谁?手上有一台带串口输出的秤、需要做称重采集或二次开发的工控/仓储开发者,以及想搞明白串口通信到底怎么落地的新手。下面从接线到代码一路拆开讲。

2. 技术选型与工程骨架:WinForm + SerialPort 为什么够用

2.1 为什么是 WinForm 而不是别的

工控现场的电脑大多是 Windows 7/10 的老机器,装个 .NET Framework 4.x 就能跑,不需要额外运行时。WinForm 拖控件就能出界面,部署就是拷一个 exe 加几个 dll,产线文员双击就能用。换成 WPF 界面更漂亮但学习曲线陡,换成 Web 又要配服务端和浏览器,对“一台秤一台电脑”的场景是过度设计。SerialPort 是 .NET 自带的串口类,底层封装了 Win32 的串口 API,收发、超时、事件都齐了,不用引第三方库。

技术栈就三样:C# + .NET Framework 4.7.2(或 4.8)+ System.IO.Ports。数据存储看需求,轻量用 SQLite 或直接 CSV,重一点接 SQL Server。界面用 DataGridView 展示称重记录,Timer 做超时兜底。

2.2 工程目录怎么摆

一个能维护的 WinForm 项目,别把所有代码堆在 Form1.cs 里。我一般按职责拆:

WeightReader/ ├── Forms/ │ ├── MainForm.cs 主界面,只负责 UI 事件转发 │ └── MainForm.Designer.cs ├── Serial/ │ ├── ScaleSerialPort.cs 串口封装:打开、关闭、收数据 │ └── FrameParser.cs 帧解析:把字节流还原成重量 ├── Models/ │ └── WeightRecord.cs 称重记录实体 ├── Services/ │ ├── RecordService.cs 落库/导出 │ └── ConfigService.cs 串口参数持久化 ├── app.config 串口配置、数据库连接串 └── Program.cs

这样拆的好处是:换一台不同协议的秤,只改 FrameParser;换存储,只改 RecordService;界面怎么改都不碰串口逻辑。二次开发的人拿到代码,先看 Serial 目录就知道数据从哪来。

2.3 串口参数从哪来、怎么配

耀华 3190-A12+E 这类秤的 RS232 输出,常见出厂设置是波特率 9600、8 位数据位、1 位停止位、无校验(8-N-1),但不同批次、不同设置菜单里可能被改成 4800 或 19200。所以参数不能写死,要放进配置文件让现场能改。

<!-- app.config --> <appSettings> <add key="PortName" value="COM3" /> <add key="BaudRate" value="9600" /> <add key="DataBits" value="8" /> <add key="StopBits" value="One" /> <add key="Parity" value="None" /> <add key="ReadTimeout" value="1000" /> </appSettings>

PortName是设备管理器里看到的 COM 口号,插不同 USB 转串口线会变,所以界面上要放个下拉框枚举SerialPort.GetPortNames()让用户选。ReadTimeout设 1000 毫秒,意思是读不到数据最多等 1 秒就抛超时,避免界面卡死。这些参数在打开串口前一次性赋给 SerialPort 实例,打开后再改会抛异常。

提示:USB 转 RS232 线一定要装对应芯片的驱动(CH340、PL2303、FT232 各有各的驱动),设备管理器里出现带黄色感叹号的未知设备,八成是驱动没装对,跟代码无关。

3. 核心链路拆解:从串口字节流到界面上的一个重量值

3.1 打开串口与事件注册

串口通信有两种收数方式:轮询ReadExisting(),或者挂DataReceived事件。轮询简单但占 CPU、容易丢帧;事件方式是后台线程回调,数据一到就触发,是正路。但事件回调不在 UI 线程,直接更新控件会抛跨线程异常,这是新手第一个翻车点。

public class ScaleSerialPort : IDisposable { private readonly SerialPort _port = new SerialPort(); // 收到完整帧后往外抛,UI 层订阅 public event Action<string> FrameReceived; public void Open(string portName, int baudRate) { _port.PortName = portName; _port.BaudRate = baudRate; _port.DataBits = 8; _port.StopBits = StopBits.One; _port.Parity = Parity.None; _port.ReadTimeout = 1000; _port.DataReceived += OnDataReceived; // 后台线程回调 _port.Open(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { try { int len = _port.BytesToRead; if (len <= 0) return; byte[] buf = new byte[len]; _port.Read(buf, 0, len); // 一次把缓冲区读空 string raw = Encoding.ASCII.GetString(buf); FrameReceived?.Invoke(raw); // 交给解析层 } catch (TimeoutException) { /* 读超时忽略,等下一帧 */ } catch (Exception ex) { /* 记录日志,别让异常吞掉线程 */ } } public void Dispose() { if (_port.IsOpen) _port.Close(); _port.Dispose(); } }

关键点:BytesToRead拿到当前缓冲区里有多少字节,一次性Read出来,避免半帧。FrameReceived用事件往外抛,串口类不关心谁处理,解耦。异常必须 catch,DataReceived回调里抛未捕获异常会直接把进程带崩,这是血泪经验。

3.2 帧解析:电子秤吐出来的到底是什么

连续输出模式下,秤会不停往串口发重量帧。耀华这类秤常见格式是 ASCII 文本帧,形如ST,GS,+ 12.345kg\r\n或+0012.345\r\n,具体前缀、单位、分隔符看型号和设置。解析的核心不是死记格式,而是“找帧头、截帧尾、提数字”。

public static class FrameParser { // 从一段原始文本里提取重量,单位 kg public static bool TryParse(string raw, out decimal weight) { weight = 0m; if (string.IsNullOrWhiteSpace(raw)) return false; // 按行切,一帧一行;秤可能一次发多行 foreach (var line in raw.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { // 用正则抓带符号的数值,兼容 +12.345 / -0.020 / 12.345 var m = Regex.Match(line, @"([+-]?\s*\d+\.\d+)"); if (!m.Success) continue; string num = m.Groups[1].Value.Replace(" ", ""); if (decimal.TryParse(num, NumberStyles.Number, CultureInfo.InvariantCulture, out weight)) { return true; // 拿到第一个有效值就返回 } } return false; } }

Regex里[+-]?\s*\d+\.\d+的意思是:可选正负号、可选空格、整数部分、小数点、小数部分。用InvariantCulture解析是为了防止某些系统区域设置把小数点当千分位。返回bool而不是直接返回数值,是为了让调用方能区分“这帧没解析出来”和“重量就是 0”。

注意:如果秤设成了“稳定才发”模式,只有重量稳定时才输出一帧,那解析逻辑没问题但频率低;如果设成“连续发”,一秒可能几十帧,界面刷新要做节流,否则 DataGridView 疯狂重绘会卡。

3.3 跨线程更新界面与去重

解析出来的重量最终要显示到 Label、写进表格。因为回调在后台线程,必须Invoke回 UI 线程。同时连续模式下同一重量会重复上报,要按“值变化才记录”去重,否则表格里全是重复行。

private decimal _lastWeight = -1m; private void OnFrameReceived(string raw) { if (!FrameParser.TryParse(raw, out decimal w)) return; // 跨线程回到 UI 线程 this.Invoke(new Action(() => { lblWeight.Text = w.ToString("F3") + " kg"; // 值没变就不重复记录,避免连续模式刷屏 if (w == _lastWeight) return; _lastWeight = w; var record = new WeightRecord { Weight = w, Time = DateTime.Now }; recordBindingList.Add(record); // 绑定到 DataGridView })); }

Invoke是同步的,会阻塞后台线程直到 UI 处理完;如果 UI 很忙,回调会堆积。更稳的做法是BeginInvoke异步投递,或者用一个ConcurrentQueue缓冲、UI 定时器批量取。_lastWeight做去重,注意初始值设 -1 而不是 0,因为真实重量可能就是 0(空秤),用 0 初始化会把第一帧 0 吞掉。

3.4 记录落库与导出

称重记录要能查、能导。轻量场景直接写 CSV,重一点用 SQLite。下面是最小可用的 CSV 追加写法,注意用FileShare.ReadWrite允许别的进程同时读,避免文件被占用报错。

public void AppendCsv(WeightRecord r, string path) { // 文件不存在先写表头 bool needHeader = !File.Exists(path); using (var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) using (var sw = new StreamWriter(fs, Encoding.UTF8)) { if (needHeader) sw.WriteLine("时间,重量(kg)"); sw.WriteLine($"{r.Time:yyyy-MM-dd HH:mm:ss},{r.Weight:F3}"); } }

FileMode.Append保证只追加不覆盖,FileShare.ReadWrite是关键,否则 Excel 开着这个文件时程序写会抛IOException。时间格式用yyyy-MM-dd HH:mm:ss排序友好,重量固定三位小数跟秤的精度对齐。

4. 避坑与排查:串口读不到数时先看这五条

4.1 现象:串口能打开,但一帧数据都没有

原因通常是接线或参数不匹配。RS232 是交叉接法,秤的 TX 要接电脑的 RX,GND 必须共地,只接两根信号线不接地经常收不到。参数上波特率、数据位、校验位任何一项和秤的设置不一致,收到的就是乱码或空。解决:先用串口调试助手(如 SSCOM)单独测,能出正确文本再回来跑程序;确认秤的设置菜单里输出模式是“连续”而不是“命令应答”。

4.2 现象:程序一开串口就抛“访问被拒绝”

原因是这个 COM 口已被别的程序占用,最常见的是串口调试助手没关,或者上一次程序异常退出没释放句柄。解决:关掉占用程序;在代码里Open包 try-catch,失败时提示“COMx 被占用,请关闭其他串口工具”;程序退出时在FormClosing里确保Dispose被调用。

4.3 现象:界面卡死、点不动

原因是把串口读或Thread.Sleep放在了 UI 线程,或者Invoke里又做了耗时操作(比如同步写数据库)。解决:收数一律走DataReceived事件,不在按钮点击里while轮询;Invoke里只更新控件,落库丢到后台线程或队列。另外连续模式下帧率太高,界面刷新要节流,比如 100ms 才刷一次 Label。

4.4 现象:重量数字偶尔跳变、出现离谱值

原因是半帧被当成完整帧解析,或者一次Read只读到半截。比如+12.3和45分两次到达,第一次解析出 12.3。解决:解析前按行切分,只处理以\r\n结尾的完整行;或者维护一个缓冲区,累积到帧尾再解析。正则匹配失败就丢弃,不要拿残缺数据凑数。

4.5 现象:换台电脑或换根线,COM 口号变了

原因是 USB 转串口设备的 COM 号由驱动按插入顺序分配,不是固定的。解决:界面上提供端口下拉框,启动时枚举SerialPort.GetPortNames();配置里存端口名但允许用户改;更稳的做法是存设备描述(WMI 查Win32_PnPEntity),按描述匹配端口,避免每次手动选。

5. 二次开发:把单秤采集改成多秤并行与协议可插拔

拿到这套代码要往上加功能,最常见的两个方向是多台秤同时采集、以及适配不同品牌的秤。我的做法是把“串口连接”和“帧解析”彻底分开,用接口约束,新增协议只写一个类。

先定义解析接口:

public interface IFrameParser { string Name { get; } // 协议名,界面上选 bool TryParse(string raw, out decimal weight); }

耀华 ASCII 协议实现一个YaohuaAsciiParser,别的品牌如果是固定长度二进制帧,就再写一个BinaryFrameParser,内部按字节偏移取重量。主界面放一个协议下拉框,选中后通过简单工厂创建对应解析器:

public static IFrameParser Create(string name) => name switch { "YaohuaAscii" => new YaohuaAsciiParser(), "BinaryFixed" => new BinaryFrameParser(), _ => throw new NotSupportedException($"未知协议: {name}") };

多秤并行则是把ScaleSerialPort实例化多份,每份绑一个 COM 口和一个解析器,收到的记录带上“秤编号”字段。注意每台秤一个DataReceived回调,UI 更新仍要Invoke,但去重的_lastWeight要按秤分开存,用Dictionary<string, decimal>,否则两台秤的重量会互相干扰。

验证方法很直接:不接真秤,用虚拟串口工具建一对 COM 口,一端用脚本按协议定时发帧,另一端给程序读,能稳定解析、去重、落库就算通。改完协议解析后,先跑这个虚拟回放,再上真机,能省掉大量现场调试时间。

改造点改动位置验证方式
新增秤协议实现 IFrameParser + 工厂注册虚拟串口回放该协议帧
多秤并行多实例 ScaleSerialPort,记录加秤号两路虚拟串口同时发帧
换存储RecordService 换实现写入后查库/开 CSV 核对
界面节流收数回调加时间戳判断连续模式下观察 CPU 与刷新

有个细节容易忽略:多秤时程序退出要逐个Dispose,漏一个就会留下占用句柄,下次启动报“访问被拒绝”。我一般把所有串口实例放进一个List<ScaleSerialPort>,FormClosing里遍历释放。

从那以后我每次接新秤,都强制先用串口助手抓一段原始帧、把十六进制和 ASCII 都看一遍,确认帧头帧尾和波特率,再动代码——这个习惯帮我省掉了无数次“代码没问题但就是读不到数”的来回折腾。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询