ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#串口上位机实战:数据采集、SQLite储存与实时显示避坑指南

C#串口上位机实战:数据采集、SQLite储存与实时显示避坑指南 简介这份资源是一套基于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 APIC# 里做串口通信常见做法是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 Listbyte _buffer new Listbyte(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 ConcurrentQueueSensorRecord _queue new(); private const int BatchSize 500; private async Task FlushLoop(CancellationToken ct) { var batch new ListSensorRecord(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按磁盘性能调机械盘建议 200500SSD 可以到 2000。注意_conn不能跨线程共享每个写线程一个连接。注意SQLite 开 WAL 后读不阻塞写但写仍然串行。如果写入线程和查询线程共用一个连接照样会锁。4. 实时显示界面不卡的三个关键点4.1 数据更新必须回 UI 线程但别用 Invoke 刷每一条WinForms/WPF 里后台线程不能直接改控件。新手常写this.Invoke(new Action(() label.Text ...))每条数据都 Invoke 一次数据一快界面就卡成幻灯片。正确做法是「攒一批、刷一次」后台把最新值写进一个共享快照UI 用Timer每 100200ms 读一次快照刷新。// 共享快照收数线程写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 秒按需调整。导出后用离线解析脚本重跑就能复现现场问题不用守着设备。验证方法上我习惯做「回放测试」把导出的原始字节喂给解析函数对比解析结果和当时入库的数据是否一致。不一致就说明解析逻辑有边界问题比如半包处理。这套流程走一遍比在现场反复试快得多。从那以后我每次接新设备都强制先跑一遍「原始字节留存 回放验证」确认解析稳了再接界面和数据库。希望帮到你。本文还有配套的精品资源点击获取
返回列表