资讯动态

C#串口温度采集上位机源码:从字节解析到实时曲线完整实现

发布时间:2026/10/13 23:07:41 来源:尧图企业网站定制
简介一份基于C#开发的温度采集上位机程序源码专为需要掌握上位机开发、串口通信及传感器数据读取的开发者准备。程序围绕DS18B20温度传感器展开涵盖初始化、温度读取、异常过滤、平均值计算、阈值报警等关键环节并采用多线程将UI与数据采集分离整体结构清晰适合初学者对照学习也是中高级开发者快速搭建类似采集系统的参考模板。资源包为zip压缩格式共2个文件总大小399KB其中rar文件为核心C#源码工程html文件为解压密码及相关说明便于使用者快速打开工程。已有1281人浏览学习说明该资源在工业自动化、物联网温度监测场景中具有不错的实用价值。通过源码可同时获得Windows Forms界面布局、SerialPort串口通信、数据持久化存储等典型实践有助于系统性提升C#项目开发能力。1. C# 温度采集上位机源码从串口裸数据到实时曲线的完整闭环拿到这份 C# 编写的温度采集上位机源码最直接的用法是接上下位机就能看到实时温度曲线。下位机每 200ms 通过串口发一帧数据上位机要做的就是把裸字节解析成温度值、绘制曲线、落盘存档再留出历史查询的入口。听起来不复杂真正跑起来断帧、乱码、UI 卡死、串口被占用每一个都能让人折腾一下午。这份源码把串口通信、帧解析、实时曲线、CSV 存储和参数配置串成了完整链路适合做环境监测、设备联调或产线数据采集的开发者直接改来当基础工具新手能顺着跑通熟手能快速替换协议和界面。2. 串口链路先立住通信参数、帧协议与数据完整性校验温度采集上位机本质上是串口数据的消费端。下位机单片机或采集模块把传感器测到的温度按固定帧格式发出来上位机要做的第一件事是把这条链路确认清楚。链路参数对不上后面解析、曲线、存储全是徒劳。拿到这份源码我建议别急着点击运行先把通信参数和帧协议逐项核对一遍再开始跑数据。2.1 通信参数怎么定波特率、数据位与校验位常见硬件拓扑有两种一种是 PT100 或热电偶经过变送器转成 RS485 信号接到 USB 转串口模块再进 PC另一种是单片机直接采样 DS18B20 或 NTC把温度值打包成帧从串口发出。不管是哪种串口参数都是由下位机固件决定的上位机只能去适配这和网络通信里客户端适应服务端是同一个道理别指望上位机改了参数下位机就跟着变。参数常见值适用场景波特率9600抗干扰强长线传输优先温度采集完全够用波特率115200高频采样或多设备轮询时用但对线材质量要求高数据位8绝大多数协议都是 8 位停止位1常规配置现场干扰大时个别设备用 2校验位None协议内部带 CRC 时硬件校验可以关掉校验位Even部分老工业仪表默认偶校验这里有个容易误判的点如果下位机协议已经做了 CRC16 校验硬件校验位设 None 就够了再叠一层偶校验会让数据位多出一个 bit两边对不齐反而更乱。确认参数后数据仍然乱码建议先用串口调试助手抓原始字节看帧头是否和协议文档一致再判断是硬件问题还是解析问题不要一上来就怀疑波特率。2.2 SerialPort 的接收写法事件驱动还是轮询C# 里读串口有三种常见姿势。一是 DataReceived 事件数据到达时在后台线程触发回调实时性最好代码最省二是开一个后台线程循环读配合 ReadTimeout 控制阻塞三是用 SerialPort.BaseStream 做异步读。多数温度采集项目用 DataReceived 就够了下面的代码就是这套源码里的标准写法。private SerialPort _serialPort; private readonly object _syncLock new object(); public bool InitSerialPort(string portName, int baudRate 9600) { try { _serialPort new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _serialPort.DataReceived OnDataReceived; _serialPort.ReadTimeout 200; // 同步 Read 的等待上限防止极端情况卡住后台线程 _serialPort.Open(); return true; } catch (Exception ex) { Console.WriteLine(串口打开失败: ex.Message); return false; } } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { SerialPort sp (SerialPort)sender; byte[] buffer new byte[sp.BytesToRead]; int count sp.Read(buffer, 0, buffer.Length); if (count 0) { AppendToBuffer(buffer); // 统一进接收缓冲区由解析逻辑拆帧 } }逻辑说明OnDataReceived 在后台线程被调用里面不能直接操作任何 UI 控件。Read 一次把当前缓冲区里的字节全部取出BytesToRead 表示此刻串口接收缓冲区里的字节数它并不等于一帧数据的长度。数据交给 AppendToBuffer 统一入缓冲由后续拆帧逻辑处理。ReadTimeout 参数在事件驱动模式下平时用不上但某些驱动在异常状态下会导致 Read 阻塞设置一个超时算保险。portName 不要写死程序启动时用 SerialPort.GetPortNames() 枚举当前可用端口做成下拉框让用户选。USB 转串口拔插后 COM 号会变写死端口是本场景最常见的翻车原因之一。提示GetPortNames() 返回的端口名有时带硬件描述比如 USB-SERIAL (COM3)解析时只取括号里的 COM 号即可。2.3 帧协议与校验CRC16 计算和缓冲区拆帧串口是流式协议不保证一次收到完整一帧上位机必须自己维护缓冲区按帧协议做字节对齐。这套源码里的帧格式如下AA 55 帧头设备地址 1 字节温度数据两个字节高字节在前最后是 CRC16 低字节在前、高字节在后的双字节校验整帧 7 字节。帧头选 AA 55 这类在二进制数据里出现概率低的组合能明显降低误判。public static ushort Crc16Modbus(byte[] data, int length) { ushort crc 0xFFFF; for (int i 0; i length; i) { crc ^ data[i]; for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) crc (ushort)((crc 1) ^ 0xA001); else crc 1; } } return crc; }逻辑说明这是 Modbus RTU 标准的多项式算法初始值 0xFFFF。调用时传入帧中除去校验字节的部分即前 5 个字节算出的结果与帧尾 2 字节比较一致才认为这一帧有效。CRC16 能检出单比特和大部分多比特错误工业现场强烈建议保留不要图省事用所有字节求和取低 8 位的累加校验抗干扰能力差一个量级。拆帧逻辑集中在一个循环里完成核心代码如下private Listbyte _recvBuffer new Listbyte(); private void AppendToBuffer(byte[] data) { lock (_syncLock) { _recvBuffer.AddRange(data); TryParseFrame(); } } private void TryParseFrame() { const int FRAME_LEN 7; while (_recvBuffer.Count FRAME_LEN) { int headIndex _recvBuffer.IndexOf(0xAA); if (headIndex 0) { _recvBuffer.Clear(); // 连帧头都没有清掉避免内存膨胀 return; } if (headIndex 0) { _recvBuffer.RemoveRange(0, headIndex); } if (_recvBuffer.Count FRAME_LEN) { return; // 帧头已找到但数据不完整等下一批 } if (_recvBuffer[1] ! 0x55) { _recvBuffer.RemoveAt(0); // 第二字节不是帧头丢一个字节重新对齐 continue; } ushort calcCrc Crc16Modbus(_recvBuffer.ToArray(), 5); int low _recvBuffer[5]; int high _recvBuffer[6]; ushort recvCrc (ushort)((high 8) | low); if (calcCrc ! recvCrc) { _recvBuffer.RemoveAt(0); // 校验失败丢掉当前字节继续对齐 continue; } byte address _recvBuffer[2]; short rawTemp (short)((_recvBuffer[3] 8) | _recvBuffer[4]); double temperature rawTemp * 0.1; OnFrameParsed(address, temperature); // 触发业务事件 _recvBuffer.RemoveRange(0, FRAME_LEN); // 消费掉完整帧 } }逻辑说明IndexOf 定位帧头 0xAA帧头前的残留字节直接清掉帧头后第二字节不是 0x55 就丢掉一个字节继续找帧头匹配且长度够 7 字节后先算 CRC16对不上就丢第一个字节重新对齐而不是整段清空——这样最多丢一帧不会把后面连续有效的数据全废掉。解析完成后通过 OnFrameParsed 事件抛出去上层只关心温度值不碰字节逻辑。这个循环里 while 而不是 if 是关键。如果缓冲区里同时到了两帧半的数据while 会连续消费掉完整帧直到剩余字节不够一帧才退出。真遇到频繁误判先怀疑协议设计帧头选得太常见比如 0xFF就必须改用更长帧头或加设备地址预校验。3. 温度解析与实时展示从 byte[] 到 Chart 控件的完整实现链路通了之后核心就是三件事解析、展示、存储。这一章按数据流向展开把温度值怎么从两个字节变成人看得懂的曲线和表格讲清楚。解析层保持独立是最重要的——不要在前面 TryParseFrame 里直接写 UI 代码否则后面加存储、加多通道都会被迫改动拆帧逻辑。3.1 温度值解析字节拼接、符号位与单位换算温度数据在串口帧里通常以定点数传输而不是浮点数。原因是浮点数占 4 字节且字节序在不同平台上容易出错定点数用 short 表示单位 0.1°C解析简单还省带宽。上面帧协议里 rawTemp 是带符号 short范围 -32768 到 32767乘 0.1 后能覆盖 -3276.8°C 到 3276.7°C对温度采集绰绰有余。private double ParseTemperature(byte highByte, byte lowByte) { // 高低字节拼成带符号 16 位整数单位 0.1°C short raw (short)((highByte 8) | lowByte); double celsius raw * 0.1; // 合理性过滤把明显越界的值挡在业务层外面 if (celsius -50 || celsius 150) { return double.NaN; // NaN 在曲线和表格里会被跳过不画出毛刺 } return celsius; }逻辑说明(highByte 8) | lowByte 是大端序拼接如果下位机发的是小端序低字节在前要改成 (lowByte 8) | highByte。C# 的 short 是带符号的若下位机用 ushort 表示温度0xFFFF 是某个大正数而不是 -1就必须换成 ushort 再转 double。还有一点容易踩byte 在运算时会隐式提升为 int(highByte 8) 的结果是 int直接赋给 short 需要显式强转不然编译报错。合理性过滤这块很多人想省但实际非常有用。工业现场偶尔会出现设备上电瞬间的异常值或者通道悬空时的 0xFFFF如果不挡曲线图上会蹦出一根直插天际的毛刺回放历史数据时特别扎眼。过滤阈值按你实际的量程范围设不一定非用 -50 到 150。3.2 Chart 实时曲线滚动窗口与跨线程刷新WinForms 的 Chart 控件画实时曲线核心是两点跨线程更新和点数裁剪。跨线程问题在事件驱动的串口读取里必然出现因为 OnDataReceived 跑在后台线程直接操作 chart1.Series 会抛跨线程异常或偶发崩溃。chart1 上需要先建一个名为 Temperature 的 Series可以在窗体设计器里加也可以在 Load 事件里用 chart1.Series.Add(Temperature) 创建。private void UpdateChart(double temperature, DateTime time) { if (chart1.InvokeRequired) { chart1.Invoke(new Action(() UpdateChart(temperature, time))); return; } Series series chart1.Series[Temperature]; series.Points.AddXY(time, temperature); // 滚动窗口最多保留 3000 个点超出后从头部删 while (series.Points.Count 3000) { series.Points.RemoveAt(0); } // X 轴跟随最新时间滚动只显示最近 5 分钟 DateTime maxTime time.AddSeconds(5); chart1.ChartAreas[0].AxisX.Maximum maxTime.ToOADate(); chart1.ChartAreas[0].AxisX.Minimum maxTime.AddMinutes(-5).ToOADate(); }逻辑说明InvokeRequired 判断当前线程是否和创建控件的线程一致不一致就用 Invoke 把更新切回 UI 线程。这个模式是串口上位机里最稳妥的跨线程写法比手动维护 delegate 简单。AddXY 的 X 轴值是 DateTimeChart 控件内部会转成 OADate 数值所以手动设置 AxisX.Maximum/Minimum 时也要先 ToOADate()否则轴范围设置会错位。点数裁剪用 while 循环删头部。实际性能瓶颈不在删点而在 Chart 控件重绘所以更新频率要控制。如果每帧都触发曲线刷新200ms 一帧就是每秒 5 次能跑但没必要。我一般会加一个刷新节流数据解析和存储逐帧完成UI 用 System.Windows.Forms.Timer 每 500ms 拉一次最新值刷曲线和数值框。这样做还有一个附带好处UI 刷新频率和数据采样频率解耦之后要调采样率不用动界面代码。3.3 数据落盘CSV 高频写入的缓冲策略温度采集是长时间运行的任务数据落盘不能每帧都打开文件写一次磁盘 IO 会成瓶颈SSD 也被频繁小写伤到。常规做法是内存里攒够一批再写代码里用 StringBuilder 做缓冲。private StringBuilder _csvBuffer new StringBuilder(); private int _csvBufferedCount 0; private const int FlushThreshold 100; // 攒够 100 条写一次文件 public void AppendCsvRecord(DateTime time, double temperature) { if (double.IsNaN(temperature)) return; // 过滤后的异常值不落盘 _csvBuffer.AppendLine( string.Format({0:yyyy-MM-dd HH:mm:ss.fff},{1:F2}, time, temperature)); _csvBufferedCount; if (_csvBufferedCount FlushThreshold) { File.AppendAllText(_csvPath, _csvBuffer.ToString(), Encoding.UTF8); _csvBuffer.Clear(); _csvBufferedCount 0; } } public void FlushCsv() { if (_csvBuffer.Length 0) { File.AppendAllText(_csvPath, _csvBuffer.ToString(), Encoding.UTF8); _csvBuffer.Clear(); _csvBufferedCount 0; } }逻辑说明File.AppendAllText 每次调用会打开、写入、关闭文件一次100 条一攒就是把 100 次 IO 合并成 1 次。FlushCsv 必须在窗体关闭时调用否则最后不足 100 条的数据会丢。文件名建议带日期比如 Temperature_20250712.csv一天一个文件后续归档和按月整理都很方便。长期运行且有查询需求的项目可以换成 SQLite但如果交付目标只是让用户能用 Excel 打开数据CSV 是最省事的。提示程序被任务管理器强杀时缓冲区里未落盘的记录会丢。数据完整性要求高的场景把阈值降到 20 左右再配合进程退出时的 Flush 兜底。4. 踩坑与排查串口乱码、断帧、UI 卡死等五个高频问题温度采集上位机项目不大但坑极多而且很多坑只在跑了一两个小时甚至过夜后才暴露。下面五条是这套源码对应场景里最高频的问题每条按现象、原因、解决三部分拆开写调试时可以直接对着查。4.1 串口层的三个坑乱码、断帧与设备掉线坑一解析出来的温度偶尔跳变曲线带毛刺现象温度值大部分时间正常但每隔几秒冒出一个 -273.15 或 65535 之类的异常值回看 CSV 也能找到这些脏数据。原因两种情况。一种是上位机把串口数据当字符串读了用 Encoding.ASCII 或 Default 去解码二进制帧字节对不上自然乱另一种是下位机发送的数据里那一帧本身就是设备上电或复位瞬间的未定义值上位机没做合理性过滤。解决二进制帧全程用 byte[] 处理任何环节都不要转 string。解析侧保留合理性过滤超出物理范围的值直接返回 NaN 丢弃。还有一个检查技巧把异常值出现的时刻和下位机设备日志对比如果每次都出现在上电瞬间说明是设备侧问题上位机只需要做到不把脏数据画上曲线。坑二一帧数据被拆成两三次收到解析大面积失败现象DataReceived 触发时 BytesToRead 只有 3~4 个字节如果按收到字节即完整帧去解析每次都差几个字节日志里全是解析失败记录。原因串口是流式协议字节到达时间取决于发送方和驱动事件触发的是有数据可读不是一帧完整到达。解决缓冲区累积模式每次读到的字节先追加再去缓冲区里找完整帧对应本章 TryParseFrame 的 while 循环逻辑。帧头找到了但长度不够就等下一批字节。接收缓冲区要加超时清空策略超过 2~3 秒没有新数据把半截帧丢弃否则设备重启后发的第一帧会和残留字节拼成错帧。坑三串口打不开或者跑着跑着设备掉线现象程序启动时 Open() 抛异常提示端口被占用或者 USB 转串口连接的下位机半夜断连第二天早晨数据断档一整段。原因端口被占用多数是上一次程序异常退出时没释放掉线的元凶经常是 Windows 电源管理把 USB 设备休眠了USB 转串口芯片和电脑之间的连接被系统掐断。解决Open() 用 try/catch 包裹并在界面上给出明确提示程序退出时释放串口资源代码里统一写在窗体关闭事件里。protected override void OnFormClosing(FormClosingEventArgs e) { if (_serialPort ! null _serialPort.IsOpen) { _serialPort.Close(); _serialPort.Dispose(); } FlushCsv(); base.OnFormClosing(e); }同时在设备管理器里把对应 USB 根集线器的允许计算机关闭此设备以节约电源勾掉。代码层面再加一层防护定时发心跳查询命令超过 N 秒没回应就自动重连这个逻辑对长时间无人值守的采集站是刚需比人工半夜起来拔插 USB 靠谱得多。4.2 界面层的两个坑UI 卡死与曲线内存暴涨坑四程序跑起来界面卡顿拖窗口像幻灯片现象数据刷新看着正常但界面响应极差鼠标拖动窗口延迟明显点下拉框要反应半天。原因跨线程更新没正确编组或者解析存储逻辑直接放在 UI 线程。DataReceived 回调里如果同步操作 Chart、TextBox后台线程和 UI 线程互相抢资源界面就被拖死。解决解析和存储全放后台线程UI 只根据最新温度值刷新。跨线程统一用 InvokeRequired Invoke 模式更推荐的做法是定时器节流刷新500ms 更新一次数值和曲线UI 线程负载降下来整个程序就顺了。还有一个容易被忽略的细节串口调试阶段加的 Console.WriteLine 在频繁调用的路径上会拖慢程序正式跑之前记得删掉或注释。坑五跑一晚上内存涨到几个 G最后崩掉现象程序启动时内存 50MB运行 8 小时后到 3GB曲线越来越迟钝最终无响应。原因Chart 控件的 Points 无限累加画了几十万个点也不做裁剪同时每次刷新时反复绑定 DataSource强制控件全量重绘内存和 CPU 一起爆。解决限制 Points 数量超出滚动窗口就 RemoveAt(0)刷新用定时器节流绘制用 AddXY 追加避免重复绑定 DataSource。如果采集频率很高比如 10ms 一帧建议把历史数据落盘后从曲线里淘汰曲线只保留最近时间段全量数据交给 CSV 或数据库别让 UI 控件扛全量。5. 进阶落地离线回放、多设备轮询与 CSV 导出的三个实战技巧链路稳定跑起来之后有三件小事能让它从能用的工具变成省事的生产工具。都是小改动但日常维护和排查问题时的体验完全不一样。5.1 原始字节留档排查现场问题的后悔药private FileStream _rawLogStream; private void InitRawLog() { string file raw_ DateTime.Now.ToString(yyyyMMdd_HHmmss) .bin; _rawLogStream new FileStream(file, FileMode.Append, FileAccess.Write); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { SerialPort sp (SerialPort)sender; byte[] buffer new byte[sp.BytesToRead]; int count sp.Read(buffer, 0, buffer.Length); if (count 0) { _rawLogStream.Write(buffer, 0, count); // 原始字节留档 _rawLogStream.Flush(); AppendToBuffer(buffer); } }现场数据对不上、曲线莫名跳变的时候手里有原始字节流就能在开发机上离线重放复现解析逻辑的问题不用跑到现场接设备盲猜。二进制日志按天切换文件名避免单文件过大。回放时把文件里的字节按同样节奏喂给解析函数配上时间戳打印问题定位很快。5.2 多设备扩展按地址分表管理前面帧协议里的第二字节是设备地址。要接多路温度不需要改串口链路只要在解析层加一个字典把不同地址的帧分发到不同通道。private Dictionarybyte, ChannelData _channels new Dictionarybyte, ChannelData(); private void OnFrameParsed(byte address, double temperature) { if (!_channels.ContainsKey(address)) { _channels[address] new ChannelData(address); } _channels[address].Push(temperature); }ChannelData 内部维护自己的曲线序列和 CSV 文件界面列表按行刷新即可。要注意如果下位机是上位机发查询命令才回数据的请求应答模式还需要加一个查询队列轮流发送查询帧每个通道一个超时计时器超时重发两次再标记断连。这套逻辑加进去后单机挂载的通道数取决于波特率和轮询周期9600 波特率下轮询 8 个通道、每通道秒级更新是可以接受的。5.3 CSV 导出 Excel 不乱码BOM 头那点事用 UTF-8 编码直接写 CSV在 Excel 里打开大概率乱码因为 Excel 默认按本地 ANSI 码页解码。解决方法是文件开头写一个 BOM 标记。File.WriteAllText(path, \uFEFF csvContent, Encoding.UTF8);\uFEFF 是 UTF-8 的字节序标记。加了它Excel 能正确识别编码中文表头和温度数值都正常显示。如果交付对象明确要求 GB2312用 Encoding.GetEncoding(GB2312) 写也可以但默认情况下优先 UTF-8 with BOM跨平台兼容性更好记事本、WPS、Excel 都能正确打开。从那以后我每次写串口上位机都把原始字节留档列为第一步哪怕协议再简单也先抓帧再写解析。少了现场往返的折腾也少了很多看起来对不上号的玄学问题。这套源码的工程文件、协议注释和配置示例是配套的按这篇的顺序把端口参数和帧协议改成一比一对应再处理好 UI 刷新策略基本就能稳定跑起来。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑