简介:这份资源是一套基于C#串口通信的上位机数据采集、储存与实时显示项目工程,面向工业自动化、物联网及嵌入式方向的开发者与学习者,帮助解决下位机数据接收、界面实时刷新与长期存储的完整链路问题。压缩包共95个文件,约1.97MB,以cs源码、resx与resources资源文件、exe可执行程序、config配置、csproj与sln工程文件为主,另含csv数据、pdf与docx说明文档,结构完整可直接编译运行。项目通过SerialPort类完成波特率、校验位等串口参数配置,借助Windows Forms或WPF构建实时数据显示面板,并用ADO.NET对接SQLite等数据库实现数据持久化,内容预览中的NEDC工程暗示其与车辆排放测试数据采集场景相关。目前已有5284人学习下载,适合希望掌握串口通信、GUI设计与数据库操作等工业级数据采集核心技能的读者参考实践。
1. 上位机数据采集、储存、实时显示:一套 C# 串口方案的落地拆解
车间里一台老设备只有 RS232 口,PLC 不给协议文档,操作工还得盯着仪表抄数——这种场景下,上位机数据采集、储存、实时显示就是刚需。这套 C# 串口方案解决三件事:从串口稳定收数、把数据落到本地库、在界面上实时刷新曲线和表格。适合做设备监控、工控上位机、仪器数据记录的开发者,尤其是被「丢包、粘包、界面卡死」折磨过的人。它不依赖任何商业组态软件,纯 C# 实现,源码可直接编译运行,改改协议就能接自己的设备。下面按「能跑起来 → 收得稳 → 存得住 → 显示不卡 → 排错」的顺序拆。
2. 串口收数:从打开端口到解析一帧完整报文
2.1 为什么用 SerialPort 而不是自己调 Win32 API
C# 里做串口通信,常见做法是System.IO.Ports.SerialPort。有人觉得它慢、不稳定,转头去 P/Invoke 调CreateFile、ReadFile,结果要自己处理重叠 I/O、超时、线程同步,代码量翻三倍,收益却有限。SerialPort 的坑主要在事件模型和缓冲区,不在底层能力。我一般会用它,但把DataReceived事件里的活干得足够少——只做「把字节搬进缓冲区」,解析交给独立线程。这样既避开事件重入,又不阻塞串口线程。
选型上还有一点:如果设备是 USB 转串口(CH340、CP2102、FTDI),驱动层会引入额外延迟和偶发断连。SerialPort 对这类虚拟串口的BytesToRead有时返回 0,不能死等。所以收数逻辑必须容忍「一次事件只来半个帧」。
2.2 打开端口与参数配置
using System.IO.Ports; SerialPort port = new SerialPort { PortName = "COM3", // 设备管理器里确认,别硬编码 BaudRate = 9600, // 必须和设备一致,常见 9600/19200/115200 DataBits = 8, StopBits = StopBits.One, Parity = Parity.None, ReadTimeout = 500, // 读超时,防止 Read 卡死 WriteTimeout = 500, ReadBufferSize = 4096, // 默认 4096,高频采集可加大 WriteBufferSize = 2048 }; port.DataReceived += OnDataReceived; port.Open();参数说明:BaudRate错一位就是乱码,这是最常见的翻车点;ReadBufferSize在高频采集(比如 100Hz 以上)时建议调到 8192 或 16384,否则驱动缓冲区溢出会静默丢字节。ReadTimeout不是给事件用的,是给同步Read用的,设了它Read不会无限阻塞。
2.3 粘包与半包:环形缓冲区 + 状态机
串口是字节流,没有「帧」的概念。设备发AA 55 ... 校验 ...,你可能一次收到一帧半,也可能收到半帧。血泪经验是:不要在DataReceived里直接ReadLine或按固定长度Read,那是在赌运气。
private readonly List<byte> _buffer = new List<byte>(8192); private readonly object _lock = new object(); private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int n = port.BytesToRead; if (n <= 0) return; byte[] tmp = new byte[n]; int read = port.Read(tmp, 0, n); lock (_lock) { _buffer.AddRange(tmp.Take(read)); ParseFrames(); // 在锁内解析,保证顺序 } } private void ParseFrames() { // 帧头 AA 55,帧长 = 数据域长度 + 4(头2 + 校验1 + 尾1) while (_buffer.Count >= 4) { int head = _buffer.IndexOf(0xAA); if (head < 0) { _buffer.Clear(); return; } if (head > 0) _buffer.RemoveRange(0, head); // 丢弃帧头前的垃圾 if (_buffer.Count < 4) return; int dataLen = _buffer[2]; int frameLen = dataLen + 4; if (_buffer.Count < frameLen) return; // 半包,等下次 byte[] frame = _buffer.GetRange(0, frameLen).ToArray(); _buffer.RemoveRange(0, frameLen); if (CheckSum(frame)) // 校验不过就丢 Dispatch(frame); } }逻辑说明:IndexOf(0xAA)找帧头,找不到就清空缓冲,避免垃圾字节越积越多;找到后判断是否够一帧,不够就return等下一次事件;够就取帧、校验、分发。lock保证解析和收数不交叉。参数上,dataLen的位置和帧长公式必须按你的协议改,这里只是示例。校验方式常见有累加和、异或、CRC16,按设备文档来。
提示:如果设备是 grbl 这类开源固件,协议是文本行(
\r\n结尾),那就换成按行解析,别套上面的二进制状态机。
3. 数据储存:SQLite 与 MySQL 怎么选、表怎么建
3.1 本地单机优先 SQLite,多客户端才上 MySQL
储存方案取决于部署形态。单台上位机、数据量每天几十万条以内,SQLite 足够,零配置、单文件、备份就是拷文件。多台上位机写同一个库、或者要远程查询,才上 MySQL。热搜里「mysql储存过程+错误信息」说明有人已经在用 MySQL 做业务逻辑,但采集场景我一般不建议把逻辑塞进存储过程——排查困难,迁移也麻烦。
SQLite 的坑在并发写:它默认锁整个库,多线程同时写会database is locked。解决办法是单写线程 + 队列,或者开 WAL 模式。
-- SQLite 建表:按时间分区思路,单表 + 时间索引 CREATE TABLE IF NOT EXISTS sensor_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, ts INTEGER NOT NULL, -- Unix 毫秒时间戳 channel INTEGER NOT NULL, -- 通道号 value REAL NOT NULL, quality INTEGER DEFAULT 0 -- 0 正常,非 0 异常码 ); CREATE INDEX IF NOT EXISTS idx_ts ON sensor_data(ts); CREATE INDEX IF NOT EXISTS idx_dev_ts ON sensor_data(device_id, ts);参数说明:ts用整数存毫秒,比字符串快且省空间;quality字段别省,设备异常时能区分「真值 0」和「无效值」。索引建在ts和(device_id, ts)上,实时查询按时间倒序取最新 N 条时走索引,不然数据上百万后查询会明显变慢。
3.2 批量写入:别一条一条 Insert
高频采集下,每条数据单独INSERT会把磁盘 IO 打满。常见做法是攒批:内存队列攒 500 条或 1 秒,一次性事务提交。
private readonly ConcurrentQueue<SensorRecord> _queue = new(); private const int BatchSize = 500; private async Task FlushLoop(CancellationToken ct) { var batch = new List<SensorRecord>(BatchSize); while (!ct.IsCancellationRequested) { while (batch.Count < BatchSize && _queue.TryDequeue(out var r)) batch.Add(r); if (batch.Count == 0) { await Task.Delay(200, ct); continue; } using var tx = _conn.BeginTransaction(); using var cmd = _conn.CreateCommand(); cmd.CommandText = "INSERT INTO sensor_data(device_id,ts,channel,value,quality) " + "VALUES(@d,@t,@c,@v,@q)"; var pD = cmd.Parameters.Add("@d", DbType.String); // ... 其余参数同理 foreach (var r in batch) { pD.Value = r.DeviceId; /* 赋值其余参数 */ cmd.ExecuteNonQuery(); } tx.Commit(); batch.Clear(); } }逻辑说明:ConcurrentQueue让收数线程和生产线程解耦;攒够 500 条或超时 200ms 就提交一个事务。事务把 500 次磁盘同步压成 1 次,实测写入吞吐能提升一个数量级。参数BatchSize按磁盘性能调,机械盘建议 200~500,SSD 可以到 2000。注意_conn不能跨线程共享,每个写线程一个连接。
注意:SQLite 开 WAL 后读不阻塞写,但写仍然串行。如果写入线程和查询线程共用一个连接,照样会锁。
4. 实时显示:界面不卡的三个关键点
4.1 数据更新必须回 UI 线程,但别用 Invoke 刷每一条
WinForms/WPF 里,后台线程不能直接改控件。新手常写this.Invoke(new Action(() => label.Text = ...)),每条数据都 Invoke 一次,数据一快界面就卡成幻灯片。正确做法是「攒一批、刷一次」:后台把最新值写进一个共享快照,UI 用Timer每 100~200ms 读一次快照刷新。
// 共享快照,收数线程写,UI 线程读 private volatile SensorSnapshot _snapshot = new(); // UI 定时器 private void UiTimer_Tick(object sender, EventArgs e) { var snap = _snapshot; // 读一次引用,保证一致 lblValue.Text = snap.Value.ToString("F2"); chart.Series[0].Points.AddY(snap.Value); if (chart.Series[0].Points.Count > 600) chart.Series[0].Points.RemoveAt(0); // 只留最近 600 点 }参数说明:刷新间隔 100ms 人眼已觉得「实时」,200ms 更省 CPU;曲线点数上限 600 是经验值,太多会拖慢绘制。volatile保证引用可见性,快照对象本身设计成不可变或整体替换,避免读到半更新状态。
4.2 曲线控件选型与降采样
实时曲线别用DataGridView硬画,用Chart或第三方(如 OxyPlot、LiveCharts)。数据点超过几千时,绘制开销主要在「点数」而不是「刷新率」。降采样是常用手段:显示层只保留每分钟的最大/最小/平均,原始数据全在库里。这样界面流畅,历史回溯时再查库。
4.3 表格虚拟化
如果要用表格显示实时数据流,DataGridView默认全量渲染,几千行就卡。开启虚拟模式(VirtualMode = true)只渲染可见行,配合CellValueNeeded事件按需取值。这是「组件储存已损坏怎么修复」这类问题之外,更常见的性能坑——不是组件坏了,是用错了模式。
5. 避坑与排查:五条现场踩出来的记录
5.1 现象:数据偶尔丢一段,日志无异常
原因:DataReceived事件里做了耗时操作(比如直接写数据库),串口驱动缓冲区溢出,新字节被丢弃。解决:事件里只搬字节,解析和入库放独立线程;同时把ReadBufferSize调大。
5.2 现象:中文乱码或数值全错
原因:波特率、数据位、校验位与设备不一致,或编码方式不对(文本协议用了 ASCII 而设备发 GBK)。解决:逐项核对设备手册;文本协议显式指定Encoding.GetEncoding("GBK"),别用默认。
5.3 现象:程序运行几小时后界面越来越卡
原因:曲线点数、日志文本、队列无上限增长,内存和绘制开销累积。解决:曲线限点数、日志滚动清理、队列设最大长度(满了丢最旧或落盘)。
5.4 现象:SQLite 报 database is locked
原因:多线程共用一个连接写,或读事务未及时释放。解决:单写线程 + 队列;开 WAL;读用独立连接并using及时释放。
5.5 现象:USB 转串口设备拔插后程序崩溃
原因:端口被移除时SerialPort抛IOException或UnauthorizedAccessException,未捕获。解决:所有串口操作包 try-catch,捕获后关闭端口并提示重连;监听USB设备变更事件做自动重连。
6. 进阶技巧:用环形缓冲 + 时间窗做「后悔药」与回放验证
调试采集程序最头疼的是「现场出问题但没留下数据」。我一般会在内存里加一个环形缓冲,保留最近 N 秒的原始字节,出问题时一键导出成文件,相当于后悔药。
// 环形缓冲:保留最近 60 秒原始字节 private readonly byte[] _ring = new byte[1024 * 1024]; // 1MB private int _ringPos; private readonly object _ringLock = new(); private void AppendRaw(byte[] data) { lock (_ringLock) { foreach (var b in data) { _ring[_ringPos] = b; _ringPos = (_ringPos + 1) % _ring.Length; } } } public byte[] DumpLast(int bytes) { lock (_ringLock) { var result = new byte[bytes]; int start = (_ringPos - bytes + _ring.Length) % _ring.Length; for (int i = 0; i < bytes; i++) result[i] = _ring[(start + i) % _ring.Length]; return result; } }逻辑说明:_ring固定大小,写满后覆盖最旧字节;DumpLast从当前位置往前取指定长度。参数1MB在 9600 波特率下能存约 17 分钟原始数据,115200 下约 90 秒,按需调整。导出后用离线解析脚本重跑,就能复现现场问题,不用守着设备。
验证方法上,我习惯做「回放测试」:把导出的原始字节喂给解析函数,对比解析结果和当时入库的数据是否一致。不一致就说明解析逻辑有边界问题(比如半包处理)。这套流程走一遍,比在现场反复试快得多。
从那以后我每次接新设备,都强制先跑一遍「原始字节留存 + 回放验证」,确认解析稳了再接界面和数据库。希望帮到你。
本文还有配套的精品资源,点击获取