一、项目概述
该项目是一个基于 WPF 和 .NET Framework 4.7.2 开发的西门子 S7-1200 PLC 数据采集应用程序,用于某周期性数据采集场景。
核心功能
- 单路实时采集:每秒读取 PLC 指定地址的数值,实时刷新界面
- 自动周期存盘:按配置间隔(默认 10 分钟)将瞬时值和周期最低值写入 CSV
- 手动单次采集:用户可随时触发一次采集并独立存盘
- 秒级高精度采集:可选的秒级 CSV 异步写入,不影响主采集路径
- 长期稳定运行:支持 8 小时以上连续运行,异常不导致进程退出
技术栈
| 组件 | 版本 | 说明 |
|---|---|---|
| 框架 | .NET Framework 4.7.2 | WPF 桌面应用 |
| PLC 通信 | S7netplus 0.20.0 | 西门子 S7 协议库 |
| UI 框架 | WPF | 现代化界面设计 |
二、项目结构
S7DataCollector/
├── Config/
│ └── AppSettings.cs # 集中配置读取(App.config + 默认值)
├── Models/
│ └── DataRecord.cs # 采集记录模型
├── Services/
│ ├── CsvLogger.cs # 自动/手动 CSV 写入器
│ ├── ErrorLogger.cs # 错误日志写入器
│ ├── PlcService.cs # PLC 通信服务封装
│ └── SecondCsvWriter.cs # 秒级 CSV 异步写入器
├── App.xaml / App.xaml.cs # 应用入口与全局异常处理
├── MainWindow.xaml / MainWindow.xaml.cs # 主窗口与业务逻辑
├── App.config # 运行时配置
└── S7DataCollector.csproj # 项目配置
三、核心架构
3.1 单路采集设计
程序采用单路采集架构,即只有一个定时器负责读取 PLC,读取结果同时供给三个消费端:
UiTimer (1秒) ──→ Read PLC ──→ ① UI 实时显示
──→ ② 周期最低值累加器
──→ ③ 秒级 CSV 入队
这种设计确保了:
- 每秒只读取一次 PLC,降低通信负荷
- 所有消费端使用同一数据源,避免数据不一致
- 读超时被压缩到刷新周期内,避免一次慢读阻塞后续采集
3.2 双定时器模型
| 定时器 | 间隔 | 职责 |
|---|---|---|
_uiTimer |
UiRefreshSeconds(默认 1 秒) |
唯一读 PLC 路径,更新界面和周期最低值 |
_logTimer |
IntervalMinutes(默认 10 分钟) |
触发自动存盘,写入 CSV 和历史表 |
3.3 三路 CSV 输出
程序生成三类独立的 CSV 文件:
| 文件类型 | 文件名 | 触发方式 | 特点 |
|---|---|---|---|
| 自动采集 | DataLog_Auto_yyyyMMdd.csv |
定时器周期触发 | 含瞬时值 + 周期最低值 |
| 手动采集 | DataLog_Manual_yyyyMMdd.csv |
用户点击触发 | 独立序号,不含周期最低值 |
| 秒级采集 | DataLog_Second_yyyyMMdd.csv |
每秒入队,后台批量写 | 异步写入,不阻塞主流程 |
四、配置说明
配置文件位于 App.config(开发时)或 S7DataCollector.exe.config(运行时)。
xml
<?xml version="1.0" encoding="utf-8"?>
<!--
运行时配置:PLC 连接与采集周期。
修改后需重新部署/覆盖 exe 同目录的 S7DataCollector.exe.config 才会生效。
业务代码通过 AppSettings 读取;键缺失时使用 AppSettings 中的默认常量。
-->
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
<appSettings>
<!-- ========== PLC 连接参数 ========== -->
<!-- PLC IP 地址 -->
<add key="PlcIp" value="192.168.0.1" />
<!-- 机架号(S7-1200 通常为 0) -->
<add key="PlcRack" value="0" />
<!-- 插槽号(S7-1200 通常为 1) -->
<add key="PlcSlot" value="1" />
<!-- ========== 读数地址与类型 ========== -->
<!-- 读取地址:MD10 = M区双字,字节偏移10;也可改为 DB1.DBD0 等 -->
<add key="PlcAddress" value="MD10" />
<!-- Float/Real:按 IEEE754 浮点解析(读到 DInt 位型时会自动转 Float)
其它可选:DInt / DWord / Int / Word -->
<add key="PlcValueType" value="Float" />
<!-- ========== 采集周期 ========== -->
<!-- 自动写入 CSV 的间隔(分钟);仅影响存盘,不影响界面刷新 -->
<add key="IntervalMinutes" value="10" />
<!-- 界面「当前 PLC 数值」刷新间隔(秒)= 单路读 PLC 周期,与 CSV 存盘无关 -->
<add key="UiRefreshSeconds" value="1" />
<!-- PLC 通信超时上限(毫秒);单路采集实际超时还会被压到「刷新周期-100ms」以内 -->
<add key="ReadTimeoutMs" value="5000" />
<!-- ========== 秒级 CSV(与 10 分钟 Auto 文件分离;采集线程只入队,后台批量写) ========== -->
<!-- true/false:是否启用 DataLog_Second_yyyyMMdd.csv -->
<add key="EnableSecondLog" value="true" />
<!-- 有界队列容量(约等于可缓冲秒数);写不过来时丢最旧并计数 -->
<add key="SecondLogQueueCapacity" value="600" />
<!-- 单次最多刷多少行 -->
<add key="SecondLogBatchSize" value="50" />
<!-- 最大刷盘间隔(毫秒);与 BatchSize 任一触发即写 -->
<add key="SecondLogFlushIntervalMs" value="1000" />
</appSettings>
</configuration>
4.1 PLC 连接参数
| 配置项 | 默认值 | 说明 |
|---|---|---|
PlcIp |
192.168.0.1 |
PLC IP 地址 |
PlcRack |
0 |
机架号(S7-1200 通常为 0) |
PlcSlot |
1 |
插槽号(S7-1200 通常为 1) |
csharp
using System;
using System.Globalization;
using S7.Net;
using S7DataCollector.Config;
namespace S7DataCollector.Services
{
/// <summary>
/// PLC 通信服务:封装 S7netplus,负责与西门子 S7-1200 的连接、读数、断开。
/// <para>
/// 默认读取 M 区双字地址 MD10;数值类型由 <see cref="AppSettings.PlcValueType"/> 决定
///(Float/Real 时按 IEEE754 解释 32 位原始数据)。
/// </para>
/// <para>本类方法均为线程安全(内部 <c>_sync</c> 锁);调用方仍建议串行化对同一实例的访问。</para>
/// </summary>
public sealed class PlcService : IDisposable
{
/// <summary>保护 _plc 实例的互斥锁</summary>
private readonly object _sync = new object();
/// <summary>S7netplus 底层 PLC 对象;未连接或已断开时为 null</summary>
private Plc _plc;
private bool _disposed;
/// <summary>PLC IP 地址</summary>
public string IpAddress { get; }
/// <summary>机架号(S7-1200 通常为 0)</summary>
public short Rack { get; }
/// <summary>插槽号(S7-1200 通常为 1)</summary>
public short Slot { get; }
/// <summary>读取地址,如 MD10、MW10、DB1.DBW0</summary>
public string Address { get; }
/// <summary>数值类型名:Float / Real / DInt / DWord / Int 等</summary>
public string ValueTypeName { get; }
/// <summary>读写超时(毫秒)</summary>
public int ReadTimeoutMs { get; }
/// <summary>当前是否已与 PLC 建立连接</summary>
public bool IsConnected
{
get
{
lock (_sync)
{
return _plc != null && _plc.IsConnected;
}
}
}
/// <summary>
/// 创建 PLC 服务。参数为 null 时从 <see cref="AppSettings"/> 读取默认配置。
/// </summary>
public PlcService(
string ip = null,
short? rack = null,
short? slot = null,
string address = null,
string valueType = null,
int? readTimeoutMs = null)
{
IpAddress = string.IsNullOrWhiteSpace(ip) ? AppSettings.PlcIp : ip.Trim();
Rack = rack ?? AppSettings.PlcRack;
Slot = slot ?? AppSettings.PlcSlot;
Address = string.IsNullOrWhiteSpace(address) ? AppSettings.PlcAddress : address.Trim();
ValueTypeName = string.IsNullOrWhiteSpace(valueType) ? AppSettings.PlcValueType : valueType.Trim();
ReadTimeoutMs = readTimeoutMs ?? AppSettings.ReadTimeoutMs;
}
/// <summary>
/// 连接到 PLC。若已有连接会先断开再重建。
/// </summary>
/// <exception cref="InvalidOperationException">连接失败时抛出</exception>
public void Connect()
{
ThrowIfDisposed();
lock (_sync)
{
DisconnectInternal();
// CpuType.S71200:适配本项目目标 PLC(S7-1200)
_plc = new Plc(CpuType.S71200, IpAddress, Rack, Slot)
{
ReadTimeout = ReadTimeoutMs,
WriteTimeout = ReadTimeoutMs
};
_plc.Open();
if (!_plc.IsConnected)
{
throw new InvalidOperationException($"无法连接到 PLC:{IpAddress} (Rack={Rack}, Slot={Slot})");
}
}
}
/// <summary>
/// 按配置地址读取数值(MD10 默认按 Float)。
/// 若底层读到 DInt/DWord 位型,会按 IEEE754 转成 Float。
/// </summary>
/// <returns>转换后的 double 数值</returns>
/// <exception cref="InvalidOperationException">未连接或读空值时抛出</exception>
public double ReadValue()
{
ThrowIfDisposed();
lock (_sync)
{
if (_plc == null || !_plc.IsConnected)
{
throw new InvalidOperationException("PLC 未连接,无法读取数据。");
}
var result = _plc.Read(Address);
if (result == null)
{
throw new InvalidOperationException($"读取 {Address} 返回空值。");
}
return ConvertByValueType(result);
}
}
/// <summary>
/// 按 <see cref="ValueTypeName"/> 将底层读结果转换为 double。
/// </summary>
private double ConvertByValueType(object result)
{
var typeName = (ValueTypeName ?? "Float").Trim();
// Float / Real:真空等模拟量常用,按 IEEE754 解释 32 位
if (typeName.Equals("Real", StringComparison.OrdinalIgnoreCase)
|| typeName.Equals("Float", StringComparison.OrdinalIgnoreCase))
{
return ConvertDIntBitsToFloat(result);
}
if (typeName.Equals("DWord", StringComparison.OrdinalIgnoreCase)
|| typeName.Equals("UDInt", StringComparison.OrdinalIgnoreCase))
{
return Convert.ToUInt32(result, CultureInfo.InvariantCulture);
}
if (typeName.Equals("Int", StringComparison.OrdinalIgnoreCase)
|| typeName.Equals("Word", StringComparison.OrdinalIgnoreCase))
{
return Convert.ToInt16(result, CultureInfo.InvariantCulture);
}
if (typeName.Equals("DInt", StringComparison.OrdinalIgnoreCase))
{
return Convert.ToInt32(result, CultureInfo.InvariantCulture);
}
// 未识别类型时:按 Float(DInt 位型转浮点)处理
return ConvertDIntBitsToFloat(result);
}
/// <summary>
/// 将 DInt/DWord 的 32 位原始数据按 IEEE754 解释为 Float。
/// S7netplus 读 MD 区时有时返回 int/uint,需用位型重解释而非数值强转。
/// </summary>
private static double ConvertDIntBitsToFloat(object result)
{
if (result is float f) return f;
if (result is double d) return d;
if (result is uint u)
return BitConverter.ToSingle(BitConverter.GetBytes(u), 0);
if (result is int i)
return BitConverter.ToSingle(BitConverter.GetBytes(i), 0);
if (result is long l)
return BitConverter.ToSingle(BitConverter.GetBytes(unchecked((int)l)), 0);
// 其它数值:先落到 32 位整型位型,再转 Float
try
{
var bits = Convert.ToInt32(result, CultureInfo.InvariantCulture);
return BitConverter.ToSingle(BitConverter.GetBytes(bits), 0);
}
catch
{
return Convert.ToSingle(result, CultureInfo.InvariantCulture);
}
}
/// <summary>断开与 PLC 的连接(可重复调用)</summary>
public void Disconnect()
{
lock (_sync)
{
DisconnectInternal();
}
}
/// <summary>内部断开逻辑(调用方须已持有 _sync)</summary>
private void DisconnectInternal()
{
if (_plc == null) return;
try
{
if (_plc.IsConnected)
{
_plc.Close();
}
}
catch
{
// 断开时忽略异常,避免掩盖上层重连逻辑
}
finally
{
_plc = null;
}
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(PlcService));
}
/// <summary>释放资源:断开连接并标记已释放</summary>
public void Dispose()
{
if (_disposed) return;
Disconnect();
_disposed = true;
}
}
}
4.2 读数参数
| 配置项 | 默认值 | 说明 |
|---|---|---|
PlcAddress |
MD10 |
读取地址(如 MD10, DB1.DBD0) |
PlcValueType |
Float |
值类型(Float/Real/DInt/DWord/Int) |
4.3 采集周期
| 配置项 | 默认值 | 说明 |
|---|---|---|
IntervalMinutes |
10 |
自动 CSV 存盘间隔(分钟) |
UiRefreshSeconds |
1 |
界面刷新间隔,即单路采集周期(秒) |
ReadTimeoutMs |
5000 |
PLC 通信超时上限(毫秒) |
4.4 秒级 CSV 参数
| 配置项 | 默认值 | 说明 |
|---|---|---|
EnableSecondLog |
true |
是否启用秒级 CSV |
SecondLogQueueCapacity |
600 |
有界队列容量(约缓冲秒数) |
SecondLogBatchSize |
50 |
单次最多刷盘行数 |
SecondLogFlushIntervalMs |
1000 |
最大刷盘间隔(毫秒) |
五、数据流程
5.1 启动流程
App.OnStartup
└── StartApplication
├── RegisterGlobalExceptionHandlers() # 注册三类异常钩子
├── EnsureLogsDirectory() # 确保 Logs 目录存在
├── WriteSessionStart() # 写入会话启动日志
└── new MainWindow().Show() # 显示主窗口
5.2 自动采集流程
BtnStart_Click
├── EnsurePlcConnected(forceReconnect: true)
├── StartTimers() # 启动双定时器
├── ResetIntervalMin() # 重置周期最低值累加器
├── RefreshRealtimeAsync() # 立即刷新界面(写入缓存)
└── SaveAutoSampleAsync() # 立即记一条自动 CSV
┌── UiTimer_Elapsed (每秒触发)
│ └── RefreshRealtimeAsync()
│ ├── Read PLC
│ ├── PublishSample()
│ │ ├── UpdateLastSample() # 更新缓存
│ │ ├── UpdateIntervalMin() # 更新周期最低值
│ │ └── SecondCsvWriter.TryEnqueue() # 秒级入队
│ └── 更新 UI
└── LogTimer_Elapsed (每10分钟触发)
└── SaveAutoSampleAsync()
├── TryGetFreshInPeriodSample() # 取新鲜缓存瞬时值
├── TakeAndClearIntervalMin() # 结算周期最低值
├── CsvLogger.Append() # 写入 CSV
├── AddHistoryBounded() # 追加历史表
└── BeginNewPeriod() # 推进周期起点
5.3 周期最低值机制
程序在每个自动采集周期内(默认 10 分钟)维护一个最低值:
- 采样阶段:每秒读取的数值与当前周期最低值比较,保留较小值
- 结算阶段:自动存盘时取出周期最低值,清空累加器
- 回滚机制:若写盘失败,已取出的最低值会合并回当前周期
- 周期边界:拒绝上周期遗留的陈旧采样,避免边界低压点污染
六、并发设计
6.1 同步原语
| 锁对象 | 用途 | 说明 |
|---|---|---|
_plcLock |
PLC 读写互斥 | 所有 PlcService 调用须在此锁内 |
_logGate |
存盘互斥 | 使用 SemaphoreSlim,支持 async/await |
_intervalMinLock |
周期最低值保护 | 保护 _intervalMinValue, _intervalMinTime, _periodStart |
_lastSampleLock |
采样缓存保护 | 保护 _lastValue, _lastStamp |
_uiRefreshBusy |
界面刷新防重入 | 使用 Interlocked.CompareExchange |
6.2 秒级 CSV 异步写入
SecondCsvWriter 采用生产者-消费者模式:
- 采集线程 :只调用
TryEnqueue()非阻塞入队 - 后台工作线程 :按批(
BatchSize)或按间隔(FlushIntervalMs)批量刷盘 - 有界队列:队列满时丢弃最旧数据,不反压采集线程
七、错误处理与容错
7.1 全局异常防护
程序注册了三类未处理异常钩子,全部记录日志并尽量阻止进程退出:
| 异常类型 | 处理方式 |
|---|---|
DispatcherUnhandledException |
记日志,标记 Handled = true |
AppDomain.UnhandledException |
记日志,尽力阻止终止 |
TaskScheduler.UnobservedTaskException |
记日志,调用 SetObserved() |
7.2 自动存盘失败处理
- 存盘冲突 :自动采集标记延后补写(
_pendingAutoSave),不丢周期 - 写盘失败 :回滚已取出的周期最低值,预约重试(
_autoSaveRetryAfterTicks) - 重试触发:由下次秒级刷新路径顺带触发补写
7.3 PLC 通信异常处理
- 连接失败:进入周期重试模式,由秒级定时器持续尝试重连
- 连续失败计数:记录连续失败次数,用于状态提示
- 安全断开:断开时忽略异常,避免掩盖上层重连逻辑
八、文件输出格式
8.1 自动采集 CSV
csv
Id,Data,Time,Source,MinData,MinTime
1,1.23456,2026-07-16 10:00:00,Auto,1.12345,2026-07-16 10:05:30
2,1.34567,2026-07-16 10:10:00,Auto,1.23456,2026-07-16 10:12:15
8.2 手动采集 CSV
csv
Id,Data,Time,Source
1,1.45678,2026-07-16 10:03:00,Manual
2,1.56789,2026-07-16 10:08:00,Manual
8.3 秒级采集 CSV
csv
Id,Data,Time
1,1.23456,2026-07-16 10:00:00
2,1.23457,2026-07-16 10:00:01
3,1.23455,2026-07-16 10:00:02
九、界面布局
主窗口自上而下分为五个区域:
| 区域 | 内容 | 功能 |
|---|---|---|
| 通信状态区 | 指示灯 + 状态文字 + 配置摘要 | 显示连接状态和当前配置 |
| 实时数据区 | 大字号数值 + 采样时间 | 实时显示 PLC 读数 |
| 历史表格区 | DataGrid 绑定 ObservableCollection<DataRecord> |
显示采集历史记录 |
| 控制按钮区 | 启动采集 / 停止采集 / 手动采集一次 | 控制采集流程 |
| 路径+状态栏 | 文件路径提示 + 运行状态消息 | 显示输出路径和运行状态 |
十、运行说明
10.1 启动方式
方式一:直接运行(最快)
双击:S7DataCollector\bin\Release\net472\S7DataCollector.exe
方式二:Visual Studio
打开 S7DataCollector.sln → F5(调试)或 Ctrl+F5(直接运行)
方式三:命令行
bash
cd "S7DataCollector"
dotnet run -c Release
10.2 配置修改注意事项
- 修改
S7DataCollector.exe.config(运行时)而非源码中的App.config - 修改后必须关闭程序再重新打开,配置才会生效
- PLC 需允许 PUT/GET 访问,电脑防火墙需放行
- 连不上时查看界面红色状态灯和
Logs\ErrorLog.txt
10.3 部署要求
- 目标机器需安装 .NET Framework 4.7.2 或更高版本
- 电脑与 PLC 需在同一网段
- 建议先 ping 通 PLC IP 再启动程序
十一、设计要点总结
- 单路采集:一个定时器读 PLC,多路消费,降低通信负荷
- 缓存策略:使用缓存瞬时值避免边界再读 PLC,新鲜度阈值 2 秒
- 周期最低值:半开区间累加,写盘失败回滚,边界不污染
- 异步秒级写入:有界队列 + 后台批量写,不阻塞主流程
- 异常防护:全局异常钩子 + 自动重试 + 状态恢复,支持长期无人值守
- 资源管理 :完整的
IDisposable实现,窗口关闭时释放所有资源