简介:一套基于Modbus协议的上位机串口通信与MySQL存储的完整工程方案,面向工业监控、环境数据采集及QT上位机开发者,解决温湿度数据实时采集、动态曲线展示与历史存储查询等问题。压缩包共15个文件,包含5个C++源文件(负责业务逻辑与通信)、4个头文件(声明接口与数据结构)、4个QT界面文件(可调整显示布局)以及工程配置与用户设置文件,整体仅24KB,结构紧凑便于阅读。工程覆盖串口参数配置、Modbus读写命令封装、MySQL建表与插入查询、实时曲线绘制等关键模块,并设计了按时间段检索历史数据的功能,代码量精炼且逻辑完整,可直接编译运行或在此基础上二次扩展。目前已有197人学习下载,适合具备一定C++/QT基础、需要快速搭建同类监控系统的开发者参考。
1. 上位机、串口、Modbus 与 MySQL 组成的温湿度采集链路
很多做上位机开发的人,第一次接触 Modbus 就是在温湿度采集场景里。下位机是带 RS-485 或 RS-232 的温湿度变送器,上位机用 C# 写窗体程序,通过串口每隔几秒读一次寄存器,再把数据写进 MySQL。整条链路拆开其实是三段:串口要保证帧不丢不乱,Modbus RTU 要处理 CRC 与寄存器地址映射,MySQL 要设计成能存一年以上的时序数据还不卡。动态曲线只是最上面一层表现。下面按这三段展开,从协议解析、表结构设计、曲线联动一路到历史回放与排错,适合正在做设备监控、产线数据采集上位机,或者被客户要求留三年历史数据的工程师。
2. 串口上跑 Modbus RTU:从报文结构到温湿度寄存器的解析实现
2.1 Modbus RTU 帧结构与时序:为什么 CRC 必须自己算
Modbus RTU 是主从问答式协议,上位机永远作为主机发起请求,温湿度变送器作为从机被动应答。一条读请求固定 8 字节:从站地址 1 字节、功能码 1 字节、起始寄存器地址 2 字节、寄存器数量 2 字节、CRC16 校验 2 字节。从站地址决定总线上哪个设备回答,功能码区分读什么类型的寄存器,CRC 则是整个报文里最容易抄错的部分。
| 字节位置 | 内容 | 长度 | 说明 | 调试示例 |
|---|---|---|---|---|
| 0 | 从站地址 | 1 | 拨码开关或模块配置 | 0x01 |
| 1 | 功能码 | 1 | 0x03 读保持寄存器,0x04 读输入寄存器 | 0x04 |
| 2-3 | 起始地址 | 2 | 寄存器地址,高字节在前 | 0x0101 |
| 4-5 | 寄存器数量 | 2 | 本次一次读几个寄存器 | 0x0002 |
| 6-7 | CRC16 | 2 | 低字节在前 | 计算得到 |
温湿度变送器的寄存器地址随厂家而异,常见做法是把温度放在 0x0101、湿度放在 0x0102,用 0x04 功能码读输入寄存器。也有不少模组把数据放到保持寄存器里,这时要把功能码从 0x04 换成 0x03,地址不变。很多人在这一步栽跟头:用 Modbus 调试工具能读到数据,换成上位机就超时,往往就是功能码和寄存器类型没对上。RTU 还有个容易被忽略的时序要求:报文之间至少要空闲 3.5 个字符时间,9600 波特率下大约是 4 毫秒,这个间隔串口驱动会负责,但连续高频查询时如果间隔太短,从站会把两个请求粘成一帧。
2.2 用 C# 构建读请求:从站地址、功能码与寄存器地址对齐
2.2.1 读请求代码与 CRC 校验实现
CRC16 在 .NET 的串口 API 里不会自动生成,必须自己算。标准 CRC-16/MODBUS 的算法是初始值 0xFFFF,把帧里每个字节异或进去,再对每个 bit 按多项式 0xA001 移位处理。
static ushort ComputeCrc16(byte[] data, int length) { ushort crc = 0xFFFF; // CRC-16/MODBUS 初始值 for (int i = 0; i < length; i++) { crc ^= data[i]; for (int bit = 0; bit < 8; bit++) { crc = (crc & 0x0001) != 0 ? (ushort)((crc >> 1) ^ 0xA001) // 多项式 0xA001 : (ushort)(crc >> 1); } } return crc; }这段函数从字节 0 到 length-1 逐步计算,返回的是 16 位无符号整数。注意返回值不能直接强转成 byte,发送时要拆成两个字节,并且低字节在前。
static byte[] BuildReadRequest(byte slaveId, ushort startRegister, ushort registerCount) { byte[] frame = new byte[8]; frame[0] = slaveId; frame[1] = 0x04; // 读输入寄存器;对保持寄存器改 0x03 frame[2] = (byte)(startRegister >> 8); // 地址高字节在前 frame[3] = (byte)(startRegister & 0xFF); frame[4] = (byte)(registerCount >> 8); frame[5] = (byte)(registerCount & 0xFF); ushort crc = ComputeCrc16(frame, 6); frame[6] = (byte)(crc & 0xFF); // CRC 低字节在前 frame[7] = (byte)(crc >> 8); return frame; }startRegister 就是 0x0101 这种具体寄存器地址,registerCount 是要读的寄存器个数。如果温度和湿度各占一个寄存器,count 传 2,一次请求拿回 4 字节数据,比发两次请求省一半交互时间。请求帧通过 SerialPort.Write 发出去,前提是初始化参数已经配对:数据位 8、停止位 1、无校验(即 8N1)是绝大多数温湿度变送器的默认组合,波特率出厂多为 9600,与下位机不一致的后果是收不到任何响应。
2.2.2 响应帧解析与小数位还原
从站的正常响应是 9 字节:地址 1、功能码 1、字节数 1、数据 4、CRC 2。温度和湿度在小端机器上的拼装方式和手册里的“高字节在前”相反,需要手动把两个字节拼成 16 位整数。
static bool TryParseResponse(byte[] resp, byte slaveId, out double temp, out double hum) { temp = 0; hum = 0; if (resp.Length < 9) return false; // 长度 9 为正常帧,5 为异常帧 if (resp[0] != slaveId) return false; if (resp[1] == 0x84) return false; // 异常功能码:最高位置 1 if (resp[1] != 0x04) return false; ushort crcRecv = (ushort)(resp[resp.Length - 2] | (resp[resp.Length - 1] << 8)); if (ComputeCrc16(resp, resp.Length - 2) != crcRecv) return false; short rawTemp = (short)((resp[3] << 8) | resp[4]); // 温度寄存器,高字节在前 short rawHum = (short)((resp[5] << 8) | resp[6]); // 湿度寄存器 temp = rawTemp / 10.0; hum = rawHum / 10.0; return true; }响应里的数值多数按 0.1 精度放大存储,2551 表示 25.5 摄氏度,所以解析完要除以 10。用 short 而不是 int 是为了保留符号位,冷库的负温度靠这个符号位还原。CRC 比较必须在拼值之前做,否则脏帧会把温度显示成异常大数,甚至把故障数据写进历史库。
提示:寄存器数量一变,正常响应长度就变,计算方式是
5 + 2 * registerCount。代码里按读 2 个寄存器固定 9 字节处理,改成读 4 个寄存器时记得同步调整长度判断。
2.3 用 Modbus Slave 模拟温湿度从站,不接硬件也能联调
手头没有传感器时,用 Modbus Slave 这类从站模拟器加上一对虚拟串口,就能把整条通信链路跑起来。虚拟串口软件会把 COM3 和 COM4 组成一对,上位机打开 COM3,Modbus Slave 监听 COM4,两边就像用一条串口线连着。在 Slave 里设置从站地址为 1,在 0x0101 和 0x0102 两个输入寄存器里分别填入 255 和 520,上位机每隔一秒读一次,显示区应该能看到 25.5℃ 和 52.0%RH 跟随变化。这一步把协议解析和硬件调试隔离,先证明上位机程序没问题,再去查现场的接线、干扰和波特率。想反过来验证上位机的请求字节对不对,可以把 Modbus Poll 这类主机工具挂在同一对虚拟串口的另一端,直接对照请求帧的十六进制内容。读寄存器类型必须和功能码对应:Slave 里填输入寄存器,上位机就要用 0x04 读;填保持寄存器,上位机则必须用 0x03,两边类型不一致,调试界面上全是超时异常。
3. MySQL 历史数据表设计与批量写入策略
3.1 时序数据表结构:不设外键,按时间精确到毫秒
温湿度数据是典型的时序数据,和业务表不同,它几乎只做追加写入和时间范围查询,很少更新单行。表结构设计要按这个读写特点来。
CREATE TABLE temp_hum ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, device_id VARCHAR(32) NOT NULL COMMENT '从站地址或设备编号', temperature DECIMAL(5,2) NOT NULL COMMENT '温度,单位℃', humidity DECIMAL(5,2) NOT NULL COMMENT '湿度,单位%RH', sample_time DATETIME(3) NOT NULL COMMENT '采集时间,毫秒精度', raw_data VARCHAR(64) DEFAULT NULL COMMENT '原始寄存器字节,排障用', PRIMARY KEY (id), KEY idx_device_time (device_id, sample_time), KEY idx_time (sample_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='温湿度历史表';字段类型上有两个容易被忽略的点。第一,temperature 用 DECIMAL(5,2) 而不用 FLOAT。浮点类型在 MySQL 里存 25.5 看着没问题,但做 AVG、SUM 聚合时会出现 25.499999 这类尾巴,DECIMAL 是定点数,聚合结果能和界面显示值对上。第二,sample_time 用 DATETIME(3) 而不是 DATETIME,采样周期到 1 秒以内时,毫秒能区分同一秒内多条记录,排序更可靠,也不会因为相邻记录时间完全相同,把曲线画成竖直跳变。
这张表不建任何外键,也不和设备档案表做 JOIN。理由很直白:时序表的写入要快,外键约束带来额外开销,而且历史查询通常只需要 device_id 和时间范围两个条件,索引已经够用。设备名、安装位置这类静态信息放到另一张表,查询时在应用层合并结果,比数据库里做 JOIN 压力小。
3.2 采样周期、数据量与按月份分区
表结构确定后要估算数据量。假设采样周期 1 秒,一天产生 86400 条记录,按单条约 60 字节算,单日约 5MB,一个月 150 万条左右,一年接近 3150 万条。这个量级对 MySQL 单表不是不能跑,但查询会明显变慢,备份和清理也不好做。常见做法是按月做 RANGE 分区,注意 InnoDB 分区表的唯一键必须包含分区键,所以建表时主键要写成 (id, sample_time)。
CREATE TABLE temp_hum_part ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, device_id VARCHAR(32) NOT NULL, temperature DECIMAL(5,2) NOT NULL, humidity DECIMAL(5,2) NOT NULL, sample_time DATETIME(3) NOT NULL, raw_data VARCHAR(64) DEFAULT NULL, PRIMARY KEY (id, sample_time), KEY idx_device_time (device_id, sample_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 PARTITION BY RANGE COLUMNS(sample_time) ( PARTITION p202501 VALUES LESS THAN ('2025-02-01'), PARTITION p202502 VALUES LESS THAN ('2025-03-01'), PARTITION p202503 VALUES LESS THAN ('2025-04-01'), PARTITION pMax VALUES LESS THAN (MAXVALUE) );分区以后,删除过期数据直接ALTER TABLE temp_hum_part DROP PARTITION p202503;,秒级完成,不需要发出几百万行的 DELETE。查询时 MySQL 自动做分区裁剪,只扫描命中时间段的分区。已经在线运行的单表想改成分区表,常规做法是新建分区表、INSERT 迁移、业务切表,避免直接 ALTER TABLE 触发长时间锁表和主键重建。注意最后的 pMax 分区要保留,否则时间超出分区范围的数据会写入失败。
3.3 批量写入与连接池配置:从一条一条插到每 2 秒一批
3.3.1 批量 INSERT 代码
上位机采集是高频写入,每条都开连接再 COMMIT 一次,每秒几十条数据时 MySQL 很容易到连接上限。常见做法是采集线程只解析数据,把记录放进内存队列,每 2 秒取一批做批量 INSERT。
async Task BatchInsertAsync(List<(string dev, double t, double h, DateTime dt)> rows) { if (rows.Count == 0) return; var values = new StringBuilder(); for (int i = 0; i < rows.Count; i++) { if (i > 0) values.Append(','); var r = rows[i]; values.Append($"('{r.dev}','{r.t:0.00}','{r.h:0.00}','{r.dt:yyyy-MM-dd HH:mm:ss.fff}')"); } using var conn = new MySqlConnection(connectionString); await conn.OpenAsync(); using var tx = await conn.BeginTransactionAsync(); string sql = "INSERT INTO temp_hum(device_id,temperature,humidity,sample_time) VALUES " + values.ToString(); using var cmd = new MySqlCommand(sql, conn, tx) { CommandTimeout = 10 }; await cmd.ExecuteNonQueryAsync(); await tx.CommitAsync(); }这是千万行级数据量下最简单的批量写法:一条 SQL 插入所有行,事务包住保证要么全成功要么全失败。温度、湿度用0.00格式化成两位小数再放进 SQL,避免系统区域设置把小数点变成逗号导致语法错误。CommandTimeout 设 10 秒,数据库短暂不可用时宁可这次失败,也不能让采集队列越积越大。批量行数控制在 100 到 500 条之间比较合适,太大会超过 max_allowed_packet 默认值。
注意:批量 SQL 的 VALUES 顺序必须和 INSERT 目标列严格一致。临时加字段后最常出现的问题不是 SQL 报错,而是数值错位。
3.3.2 连接串参数与常见误区
connectionString 里值得逐个确认的参数有下面几个。
| 参数 | 建议值 | 说明 |
|---|---|---|
| Server | 数据库地址 | 上位机本机部署用 127.0.0.1 |
| Pooling | true | 连接池默认开启,不要手工关闭 |
| Max Pool Size | 20 | 2 秒一批写入,20 足够 |
| ConnectionTimeout | 5 | 数据库不可达时快速失败,避免 UI 卡顿 |
| SslMode | None | 仅限内网采集场景,公网部署必须按合规要求配置 |
| Charset | utf8mb4 | 与表字符集一致,避免中文设备名乱码 |
常见误区是把连接串写在循环里,每次都 new MySqlConnection。局部 using 本身没问题,问题在于频繁开关连接会让连接池反复收缩,表现为运行半小时后偶发超时。稳妥做法是让 BatchInsertAsync 里的连接实例在整个程序生命周期内被复用,或者至少配置 Min Pool Size=2 减少收缩抖动。
4. 实时温湿度显示与动态变化曲线
4.1 串口数据进入 UI 的线程模型:生产者-消费者
C# 上位机里 SerialPort.DataReceived 事件运行在串口后台线程,绝对不能在这个事件里直接给 TextBox、Chart 控件赋值。WinForms 里这样做会抛跨线程异常,WPF 里会随机闪烁或直接崩溃。正确处理是把事件当生产者,把解析好的数据点放进线程安全队列,UI 的窗体计时器作为消费者定时取出。
BlockingCollection<(double temp, double hum, DateTime ts)> dataQueue = new(4096); void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var sp = (SerialPort)sender; int available = sp.BytesToRead; byte[] buffer = new byte[available]; sp.Read(buffer, 0, available); receiveBuffer.AddRange(buffer); // 当前按读 2 个寄存器、响应 9 字节切分 while (receiveBuffer.Count >= 9) { byte[] frame = receiveBuffer.Take(9).ToArray(); receiveBuffer.RemoveRange(0, 9); if (TryParseResponse(frame, slaveId, out double temp, out double hum)) { if (!dataQueue.TryAdd((temp, hum, DateTime.Now))) { dataQueue.TryTake(out _); // 队列满时丢最旧 dataQueue.TryAdd((temp, hum, DateTime.Now)); } } } }TryParseResponse 直接复用第 2 章的解析逻辑,但串口底层可能把一个完整帧拆成两次到达,所以用累积缓冲区,只有凑够 9 字节并校验 CRC 通过才认为拿到完整帧。队列容量 4096,按 1 秒采样足够;满时先丢最旧再入队,避免上位机长时间卡顿导致内存上涨。UI 侧用 System.Windows.Forms.Timer,间隔 100 到 500 毫秒,从队列批量取数据。
private void UiTimer_Tick(object sender, EventArgs e) { while (dataQueue.TryTake(out var point)) { AppendToChart(point.ts, point.temp, point.hum); dbBuffer.Add(point); } if (dbBuffer.Count >= 100 || (dbBuffer.Count > 0 && DateTime.UtcNow - lastDbFlushUtc > TimeSpan.FromSeconds(2))) { var snapshot = dbBuffer.ToList(); dbBuffer.Clear(); lastDbFlushUtc = DateTime.UtcNow; _ = Task.Run(() => BatchInsertAsync(snapshot)); } }100 毫秒轮询在 UI 线程上消耗极小。这个模型把串口接收、界面显示、MySQL 写入三个环节完全解耦,任何一环慢都不会阻塞串口读取,这是上位机长时间运行不卡的基础。界面刷新率由计时器决定,而不是由传感器采集速度决定,调速度时只改计时器间隔即可。
4.2 Chart 控件画双轴动态曲线:滑动窗口与坐标自适应
实时曲线推荐用 WinForms 自带的 Chart 控件。.NET Framework 工程里直接在工具箱引用 System.Windows.Forms.DataVisualization,.NET 6 及以上要从 NuGet 安装对应包。图表里加两个 Series:
| 系列名 | ChartType | 对应轴 | 单位 |
|---|---|---|---|
| 温度 | Line | AxisY 主 Y 轴 | ℃ |
| 湿度 | Line | AxisY2 次 Y 轴 | %RH |
湿度范围通常在 0 到 100,温度可能是 -20 到 60,两个量级不同。把湿度挂到次 Y 轴,两条曲线都能撑满各自高度,否则湿度会被压成一条接近直线的平线。开双轴和鼠标缩放都在窗体 Load 里设置一次。
this.chart1.ChartAreas[0].CursorX.IsUserEnabled = true; this.chart1.ChartAreas[0].CursorX.IsUserSelectionEnabled = true; this.chart1.Series["温度"].YAxisType = AxisType.Primary; this.chart1.Series["湿度"].YAxisType = AxisType.Secondary;追加数据点时,动态曲线最核心的问题是老点什么时候移出视线。只 AddXY 不清理,跑一天后图上有几万个点,CPU 和内存双双失控。常见做法是限制最多显示最近 600 个点,超出就从索引 0 开始移除。
const int maxPointsOnChart = 600; void AppendToChart(DateTime ts, double temp, double hum) { pointCount++; chart1.Series["温度"].Points.AddXY(ts, temp); chart1.Series["湿度"].Points.AddXY(ts, hum); while (pointCount > maxPointsOnChart) { chart1.Series["温度"].Points.RemoveAt(0); chart1.Series["湿度"].Points.RemoveAt(0); pointCount--; } chart1.ChartAreas[0].RecalculateAxesScale(); }RecalculateAxesScale 会在移除后重新计算坐标范围,不必自己维护 X 轴最小值。600 个点对应 1 秒采样下的 10 分钟窗口,是一个便于观察变化的时间窗。补充一个环境问题:VS2019 里建的 C# 上位机工程,目标框架如果是 net5.0 或 net6.0,VS2015 打不开;要跨版本共用,把目标框架降到 .NET Framework 4.6.2 再重新生成解决方案。
4.3 显示刷新频率、曲线采样点数与缓冲区上限的配合
串口接收、界面显示、数据库写入三者频率要错开,不能绑在同一个心跳上。
| 环节 | 频率 | 理由 |
|---|---|---|
| 串口读取与帧解析 | DataReceived 触发 | 串口来数据就要收,否则缓冲区溢出 |
| 曲线刷新 | 100-500 ms 一次 Timer | 太快浪费 UI 线程,太慢曲线卡顿 |
| MySQL 批量写入 | 每 2 秒或积满 100 条 | 减少提交次数,降低连接压力 |
这三组参数之间没有必须严格相等的关系。界面可以只显示最近 600 个点,数据库则永远全量保存。曲线上的点不代表历史数据的全部,只代表屏幕目前容纳的窗口。MySQL 写入缓冲的容量要比曲线窗口大得多,建议至少能容纳 2 个小时的数据,这样数据库短暂不可用时,恢复后还能把积压补写进去。
5. 历史数据查询与回放:把 MySQL 数据还原成曲线
5.1 原始数据查询与分页:先按时间过滤再排序
历史数据查询的目的不外乎两个:在表格里看明细,或者把某段时间重新画成曲线。最常写的查询是按设备和时间过滤。
SELECT device_id, temperature, humidity, sample_time FROM temp_hum WHERE device_id = '01' AND sample_time BETWEEN '2025-06-01 00:00:00' AND '2025-06-01 23:59:59' ORDER BY sample_time LIMIT 5000;这里有两个容易踩的性能点。第一,ORDER BY 配合 LIMIT 时尽量用联合索引 idx_device_time,让排序直接走索引避免文件排序。第二,翻页不要用LIMIT 400000, 5000这种大偏移,MySQL 会先读 40 万行再丢弃前 40 万行,越翻越慢。正确做法是记录上一页最后一条的 sample_time,下一页用WHERE sample_time > 上次值继续。对温湿度历史表,一次拉回超过 5000 条也没有意义,界面上 5000 个点挤在一起什么也看不出来,精确时间段加限量数据才是历史表的正确打开方式。
5.2 按小时/按天聚合统计:避免一年数据全量拉取
要回答“过去三十天每天的最高温度是多少”这类问题,应该让数据库先聚合,而不是把所有原始记录拉回上位机在内存里算。
SELECT DATE_FORMAT(sample_time, '%Y-%m-%d') AS stat_day, ROUND(AVG(temperature), 2) AS avg_temp, ROUND(MAX(temperature), 2) AS max_temp, ROUND(MIN(temperature), 2) AS min_temp, ROUND(AVG(humidity), 2) AS avg_hum FROM temp_hum WHERE sample_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY stat_day ORDER BY stat_day;这个查询的核心是让 MySQL 把一个月的数据压缩成 30 行,传输量和显示量都大幅降低。代价是 DATE_FORMAT 包裹了 sample_time 后,索引无法用于分组排序。这条 SQL 成为报表高频查询时,可以加一列 date_int(如 20250601)并在应用层同步维护,配合普通索引速度差距明显。MySQL 8.0 的函数索引也能解决,但会增大写入开销,先量化再决定。这类统计不需要 MySQL 存储过程,一条 SELECT 就能完成,存储过程只会让上位机和数据维护之间的沟通成本变高。
5.3 回放实现:定时器逐点推进 + DataGridView 联动高亮
历史回放的常见做法是把查询结果放进内存列表,界面计时器每 500 毫秒往曲线里推一个点,同时把表格当前行选中并滚动到可见位置。
List<TempHumRecord> history; int playIndex = 0; void PlaybackTimer_Tick(object sender, EventArgs e) { if (playIndex >= history.Count) { playbackTimer.Stop(); return; } var row = history[playIndex++]; AppendToChart(row.SampleTime, row.Temperature, row.Humidity); grid.Rows[playIndex - 1].Selected = true; if (grid.FirstDisplayedScrollingRowIndex < playIndex - 1) grid.FirstDisplayedScrollingRowIndex = playIndex - 1; }回放时间和真实时间的比例由计时器间隔控制,500 毫秒推一个点适合观察变化,想快进就每次推 5 个点,进度条提供倍速选择。AppendToChart 里 600 个点数上限对回放同样生效,长历史回放到后半段时,曲线窗口会自动滑动,这正是“最近 600 个点”窗口语义的复用。表格高亮行号要在历史查询结果里维护,不能拿数据库自增 id 当表格行号用。走到这一步,历史数据查询已经从“能查到”变成了“能演示”,整条链路的功能闭环就完整了。
6. 串口、Modbus、MySQL 联合排错与运行期验证
6.1 常用故障现象与对应处理思路
| 现象 | 原因 | 处理 |
|---|---|---|
| 串口打开失败 | CH340 驱动未装好,或端口被其他程序占用 | 设备管理器确认 COM 口存在,先关闭占用程序 |
| 串口烧写失败 | 上位机持续占用端口,下载器无法独占 | 烧写前退出上位机监听,下位机重新上电 |
| 请求总是超时 | 波特率、从站地址、寄存器类型不匹配 | 用串口调试助手或 Modbus 调试工具逐项核对 |
| 温度显示异常大数 | 响应帧没先做 CRC 和长度校验 | 先校验再拼值,按5 + 2 * count计算帧长 |
| 写 MySQL 偶发超时 | Max Pool Size 太小,或端口被系统策略拦截 | 按第 3 章配置连接池,确认采集机到数据库的 3306 连通性 |
| 历史曲线有空洞 | 采集进程重启期间采样丢失 | 用 6.2 的 SQL 定位缺口,补断点续采逻辑 |
排错保持先后顺序:先确认串口层通,再确认 Modbus 能读到寄存器,最后查 MySQL 写入。顺序颠倒会浪费大量时间,CRC 还没算对就去排查数据库连接串是最常见的无效操作。
6.2 用一条 SQL 验证写入链路是否完整
判断采集系统隔夜运行是否正常,不需要盯着界面看一夜。用一条聚合 SQL 就能评估昨晚的写入完整性。
SELECT COUNT(*) AS actual_rows FROM temp_hum WHERE sample_time >= '2025-06-01 00:00:00' AND sample_time < '2025-06-02 00:00:00' AND device_id = '01';如果配置是 1 秒采样一次,实际行数应该是 86400,允许一次重启导致的少量丢失。偏差过大时再按小时分组定位缺失时间段。用 MySQL Workbench 跑这条 SQL 并看执行计划,比命令行直观,也能确认索引有没有被正确使用。
6.3 长期运行的验收手段
最后一个经验是:上位机程序不要只在开发机上跑两小时就上线。至少做一次 24 小时连续运行,用 6.2 的 SQL 检查写入量,再观察内存占用是否随运行时间持续上涨,确认队列没有在没人注意时偷偷打满。用串口调试助手挂在边上观察是否有持续的解码异常,异常帧率应该低于千分之一。再把下位机断电再上电模拟现场断电,验证上位机在串口重连后能继续采集,不需要人工重启。把“下位机断电再上电”写进运维手册,现场人员照着做一次,比任何说明都管用。
本文还有配套的精品资源,点击获取