上周有个做自动化的朋友找我抱怨:他们厂里的上位机软件,界面丑得像 2003 年的网吧管理系统,逻辑全堆在一个 Form 里,动不动就假死。他问我,C# 能不能做出像样的 SCADA 界面?我说,不仅能,还能做得很优雅。这篇文章,就把我的实战思路完整拆给你看。
SCADA(数据采集与监视控制系统)这个词听起来很唬人,但本质上,它干的事情就三件:采数据、看数据、管设备。
工业现场的开发者最头疼的,不是算法,是结构。你见过那种把所有逻辑全塞进 Form1.cs 的上位机代码吗?定时器回调里直接操 UI,报警判断和趋势绘图混在一起,改一个需求,整个文件都得翻。这玩意儿,维护起来真的是噩梦。
咱们今天要做的这个 Demo,麻雀虽小,五脏俱全——实时趋势、报警历史、设备状态、参数配置,四个模块,一套导航,外加一个仿真引擎驱动数据。

很多人上来就拖控件、写事件,结果代码越写越乱。工业软件有个特点——模块多、状态复杂、需要长期维护。所以在动手之前,先把结构想清楚。
这个项目的核心思路是:单一 Form + 动态模块切换。
不是每个功能一个 Form,也不是 UserControl 堆叠,而是用一个 GroupBox 作为内容区,根据导航选择动态换入不同的控件。听起来简单,但细节里藏着不少门道。
布局上,用 TableLayoutPanel 三列分割:左侧导航、中间内容区、右侧参数面板。右侧面板只在"工艺趋势"模块下可见,其余模块直接把列宽收为 0,视觉上干净利落。
csharpprivate void SetRightPanelVisible(bool visible)
{
pnlRight.Visible = visible;
// 不是 Hide,而是直接把列宽归零——这样布局不会留白
tlpRoot.ColumnStyles[2].Width = visible ? RightPanelWidth : 0F;
}
这个小细节很多人会忽略。直接 Hide 控件,TableLayoutPanel 那一列还是会占位,界面会出现一块莫名其妙的空白。
没有真实 PLC 的情况下,咱们用 System.Windows.Forms.Timer 模拟数据采集。每秒触发一次,生成一个围绕设定值随机波动的过程值。
csharpprivate void SimTimer_Tick(object? sender, EventArgs e)
{
_tick += 1;
// 核心公式:设定值 + 随机方向 × 波动幅度
var value = _setPoint + (_random.NextDouble() * 2 - 1) * _fluctuation;
_xs.Add(_tick);
_ys.Add(value);
// 滑动窗口,只保留最近 120 个点,避免内存无限增长
if (_xs.Count > 120)
{
_xs.RemoveAt(0);
_ys.RemoveAt(0);
}
// 重绘趋势图
pbTrend.Plot.Clear();
var line = pbTrend.Plot.Add.Scatter(_xs.ToArray(), _ys.ToArray());
line.LineWidth = 2;
pbTrend.Plot.Axes.AutoScale();
pbTrend.Refresh();
sslValue.Text = $"当前值:{value:F2}";
UpdateClock();
RefreshCurrentModule(); // 同步刷新当前模块的状态显示
}
这里有个踩坑点需要提醒:_xs 和 _ys 用的是 List<double>,每次 Tick 都在 RemoveAt(0)。数据量小的时候没问题,但如果你把窗口开到几千个点,频繁移除头部元素的性能会很难看——List<T> 的 RemoveAt(0) 是 O(n) 操作。
更优雅的替代方案是用 Queue<double> 或者循环缓冲区(Circular Buffer)。生产项目里,这个细节值得认真对待。
默认的 ListBox 在深色主题下会显示系统蓝色选中框,破坏整体视觉风格。解决方案是开启 OwnerDrawFixed 模式,自己掌控每一个条目的绘制。
csharpprivate void LstNavigation_DrawItem(object? sender, DrawItemEventArgs e)
{
if (e.Index < 0) return;
var isSelected = (e.State & DrawItemState.Selected) == DrawItemState.Selected;
// 选中态:亮蓝色背景;未选中:深灰
var backColor = isSelected ? Color.FromArgb(30, 144, 255) : Color.FromArgb(45, 52, 60);
var textColor = isSelected ? Color.White : Color.Gainsboro;
using var backBrush = new SolidBrush(backColor);
using var textBrush = new SolidBrush(textColor);
e.Graphics.FillRectangle(backBrush, e.Bounds);
// 文字左侧留 8px 边距,视觉上不那么拥挤
var textRect = new Rectangle(e.Bounds.X + 8, e.Bounds.Y + 15,
e.Bounds.Width - 8, e.Bounds.Height);
e.Graphics.DrawString(lstNavigation.Items[e.Index].ToString(),
e.Font, textBrush, textRect, StringFormat.GenericDefault);
// 选中时,左侧画一条 4px 的亮色指示条——这是很多专业 UI 的标志性细节
if (isSelected)
{
using var indicator = new SolidBrush(Color.FromArgb(180, 230, 255));
e.Graphics.FillRectangle(indicator,
new Rectangle(e.Bounds.Left, e.Bounds.Top, 4, e.Bounds.Height));
}
}
那条 4px 的侧边指示条,是从很多现代 UI 框架里借来的设计语言。实现成本极低,但视觉提升非常明显。工业软件不一定要土,细节到位了,照样能做得有质感。
最简单的一个。用一个 Label 居中显示系统状态,包括仿真运行状态、当前设定值、波动幅度。每次 RefreshCurrentModule 都会更新文本内容,确保状态与实际同步。
csharpcase "总览看板":
_moduleLabel.Text =
$"系统状态:{(_simRunning ? "仿真运行中" : "仿真已停止")}\n" +
$"当前设定值:{_setPoint:F1}\n" +
$"波动幅度:±{_fluctuation:F1}";
grpProcessView.Controls.Add(_moduleLabel);
SetRightPanelVisible(false);
break;
把 ScottPlot 的 FormsPlot 控件(即 pbTrend)直接塞进内容区,右侧面板显示参数配置。这个模块是整个系统的核心视觉呈现,趋势图的刷新逻辑已经在 SimTimer_Tick 里处理好了。
切换到这个模块时,右侧参数面板才会显示出来——设定值和波动幅度的输入框,点击"应用"后立即生效,下一个 Tick 就能看到曲线变化。
用 DataGridView 展示历史报警记录。这里有个有意思的逻辑:
csharp// 如果仿真运行中,且最新值超过了"设定值 + 波动幅度的 80%",触发实时报警
if (_simRunning && _ys.Count > 0 && _ys[^1] > _setPoint + _fluctuation * 0.8)
_alarmGrid.Rows.Add(now.ToString("HH:mm:ss"), "PLC-001", "中", "实时值高于预警阈值");
_ys[^1] 是 C# 8.0 引入的索引语法,取列表最后一个元素。比 _ys[_ys.Count - 1] 清爽多了——这种小语法糖,用熟了真的很顺手。
报警阈值设为波动幅度的 80%,而不是 100%,是为了留出预警空间。这个思路在真实 SCADA 里叫"预警级别",比直接报警早一步介入。
ListView 以表格形式展示 PLC 设备的在线状态和当前采集值。
csharpprivate void LoadDeviceModule()
{
_deviceList.Items.Clear();
var current = _ys.Count > 0 ? _ys[^1].ToString("F2") : "--";
var status = _simRunning ? "在线" : "待机";
// 三台设备,当前都共享同一个仿真值——实际项目里每台设备有独立数据通道
_deviceList.Items.Add(new ListViewItem(new[] { "PLC-001", status, current }));
_deviceList.Items.Add(new ListViewItem(new[] { "PLC-002", status, current }));
_deviceList.Items.Add(new ListViewItem(new[] { "PLC-003", status, current }));
}
Demo 里三台设备共享同一个仿真值,这是简化处理。实际项目中,每台设备对应独立的数据通道,有各自的采集频率和量程范围。
工业软件普遍偏好深色主题,原因不是为了好看——是为了减少操作员长时间盯屏的眼疲劳,以及在强光环境下保持对比度。
这套配色方案的核心色值:
| 用途 | 色值 |
|---|---|
| 主背景 | RGB(28, 34, 40) |
| 次背景 / 表头 | RGB(45, 52, 60) |
| 选中高亮 | RGB(30, 144, 255) |
| 主文字 | Color.Gainsboro |
不是随便选的。深蓝灰的背景降低整体亮度,Gainsboro(浅灰白)的文字在深色背景上对比度适中,不刺眼。亮蓝色的选中高亮足够醒目,又不会抢走内容区的注意力。
csharpprivate void BtnApply_Click(object? sender, EventArgs e)
{
if (!double.TryParse(txtParam1.Text, out var setPoint))
{
MessageBox.Show("设定值格式错误,请输入数字。", "参数错误");
return;
}
if (!double.TryParse(txtParam2.Text, out var fluctuation) || fluctuation < 0)
{
MessageBox.Show("波动幅度格式错误,请输入大于等于 0 的数字。", "参数错误");
return;
}
_setPoint = setPoint;
_fluctuation = fluctuation;
sslMode.Text = _simRunning ? "模式:仿真运行" : "模式:参数已更新";
}
TryParse 而不是 Parse——这是防御性编程的基本素养。工业软件的操作员不一定是程序员,他们会在输入框里打空格、打中文、打各种奇怪的字符。你的代码得能优雅地处理这些情况,而不是直接抛异常崩掉。
波动幅度还加了 < 0 的校验,因为负数在物理上没有意义——波动幅度是一个量级,不带方向。
这套架构往生产级别演进,有几个方向值得考虑:
数据层分离:当前仿真数据直接在 Form 里生成和消费。实际项目应该抽出独立的数据服务层,负责与 PLC 通信(Modbus、OPC UA 等协议),Form 只负责展示。
事件驱动替代轮询:Timer 轮询是最简单的方式,但高频数据或多设备场景下,考虑用 async/await + 数据推送模式,避免 UI 线程阻塞。
报警持久化:当前报警数据是每次切换模块重新生成的,没有持久化。生产环境里,报警记录要写入数据库,支持历史查询和导出。
ScottPlot 的进阶用法:当前只用了最基础的散点图。ScottPlot 支持信号图(Add.Signal),对高频连续数据的渲染性能比 Scatter 好得多,数据量大时可以考虑切换。
工业软件这个领域,C# WinForms 至今仍是主流——不是因为技术落后,而是稳定、部署简单、与 Windows 生态深度集成。很多工厂的上位机跑在 Windows 7 上,你跟他说 Electron 或者 Web 前端,人家直接摇头。
这套 Demo 的代码量不大,但每一行都有来由。导航自绘、模块动态切换、列宽归零、滑动窗口缓冲……这些细节,是从真实项目里踩坑踩出来的。
你在做工业上位机或者类似的桌面监控系统吗?遇到过哪些让你头疼的架构问题?欢迎在评论区聊聊,说不定咱们踩过同一个坑。
#C#开发 #WinForms #SCADA #工业软件 #桌面应用
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!