西门子 PLC 与 C# 通信_项目实战
一、项目概述
该项目是一个基于 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 version="1.0" encoding="utf-8"?><!-- 运行时配置:PLC 连接与采集周期。 修改后需重新部署/覆盖 exe 同目录的 S7DataCollector.exe.config 才会生效。 业务代码通过 AppSettings 读取;键缺失时使用 AppSettings 中的默认常量。 --><configuration><startup><supportedRuntimeversion="v4.0"sku=".NETFramework,Version=v4.7.2"/></startup><appSettings><!-- ========== PLC 连接参数 ========== --><!-- PLC IP 地址 --><addkey="PlcIp"value="192.168.0.1"/><!-- 机架号(S7-1200 通常为 0) --><addkey="PlcRack"value="0"/><!-- 插槽号(S7-1200 通常为 1) --><addkey="PlcSlot"value="1"/><!-- ========== 读数地址与类型 ========== --><!-- 读取地址:MD10 = M区双字,字节偏移10;也可改为 DB1.DBD0 等 --><addkey="PlcAddress"value="MD10"/><!-- Float/Real:按 IEEE754 浮点解析(读到 DInt 位型时会自动转 Float) 其它可选:DInt / DWord / Int / Word --><addkey="PlcValueType"value="Float"/><!-- ========== 采集周期 ========== --><!-- 自动写入 CSV 的间隔(分钟);仅影响存盘,不影响界面刷新 --><addkey="IntervalMinutes"value="10"/><!-- 界面「当前 PLC 数值」刷新间隔(秒)= 单路读 PLC 周期,与 CSV 存盘无关 --><addkey="UiRefreshSeconds"value="1"/><!-- PLC 通信超时上限(毫秒);单路采集实际超时还会被压到「刷新周期-100ms」以内 --><addkey="ReadTimeoutMs"value="5000"/><!-- ========== 秒级 CSV(与 10 分钟 Auto 文件分离;采集线程只入队,后台批量写) ========== --><!-- true/false:是否启用 DataLog_Second_yyyyMMdd.csv --><addkey="EnableSecondLog"value="true"/><!-- 有界队列容量(约等于可缓冲秒数);写不过来时丢最旧并计数 --><addkey="SecondLogQueueCapacity"value="600"/><!-- 单次最多刷多少行 --><addkey="SecondLogBatchSize"value="50"/><!-- 最大刷盘间隔(毫秒);与 BatchSize 任一触发即写 --><addkey="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) |
usingSystem;usingSystem.Globalization;usingS7.Net;usingS7DataCollector.Config;namespaceS7DataCollector.Services{/// <summary>/// PLC 通信服务:封装 S7netplus,负责与西门子 S7-1200 的连接、读数、断开。/// <para>/// 默认读取 M 区双字地址 MD10;数值类型由 <see cref="AppSettings.PlcValueType"/> 决定///(Float/Real 时按 IEEE754 解释 32 位原始数据)。/// </para>/// <para>本类方法均为线程安全(内部 <c>_sync</c> 锁);调用方仍建议串行化对同一实例的访问。</para>/// </summary>publicsealedclassPlcService:IDisposable{/// <summary>保护 _plc 实例的互斥锁</summary>privatereadonlyobject_sync=newobject();/// <summary>S7netplus 底层 PLC 对象;未连接或已断开时为 null</summary>privatePlc_plc;privatebool_disposed;/// <summary>PLC IP 地址</summary>publicstringIpAddress{get;}/// <summary>机架号(S7-1200 通常为 0)</summary>publicshortRack{get;}/// <summary>插槽号(S7-1200 通常为 1)</summary>publicshortSlot{get;}/// <summary>读取地址,如 MD10、MW10、DB1.DBW0</summary>publicstringAddress{get;}/// <summary>数值类型名:Float / Real / DInt / DWord / Int 等</summary>publicstringValueTypeName{get;}/// <summary>读写超时(毫秒)</summary>publicintReadTimeoutMs{get;}/// <summary>当前是否已与 PLC 建立连接</summary>publicboolIsConnected{get{lock(_sync){return_plc!=null&&_plc.IsConnected;}}}/// <summary>/// 创建 PLC 服务。参数为 null 时从 <see cref="AppSettings"/> 读取默认配置。/// </summary>publicPlcService(stringip=null,short?rack=null,short?slot=null,stringaddress=null,stringvalueType=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>publicvoidConnect(){ThrowIfDisposed();lock(_sync){DisconnectInternal();// CpuType.S71200:适配本项目目标 PLC(S7-1200)_plc=newPlc(CpuType.S71200,IpAddress,Rack,Slot){ReadTimeout=ReadTimeoutMs,WriteTimeout=ReadTimeoutMs};_plc.Open();if(!_plc.IsConnected){thrownewInvalidOperationException($"无法连接到 PLC:{IpAddress}(Rack={Rack}, Slot={Slot})");}}}/// <summary>/// 按配置地址读取数值(MD10 默认按 Float)。/// 若底层读到 DInt/DWord 位型,会按 IEEE754 转成 Float。/// </summary>/// <returns>转换后的 double 数值</returns>/// <exception cref="InvalidOperationException">未连接或读空值时抛出</exception>publicdoubleReadValue(){ThrowIfDisposed();lock(_sync){if(_plc==null||!_plc.IsConnected){thrownewInvalidOperationException("PLC 未连接,无法读取数据。");}varresult=_plc.Read(Address);if(result==null){thrownewInvalidOperationException($"读取{Address}返回空值。");}returnConvertByValueType(result);}}/// <summary>/// 按 <see cref="ValueTypeName"/> 将底层读结果转换为 double。/// </summary>privatedoubleConvertByValueType(objectresult){vartypeName=(ValueTypeName??"Float").Trim();// Float / Real:真空等模拟量常用,按 IEEE754 解释 32 位if(typeName.Equals("Real",StringComparison.OrdinalIgnoreCase)||typeName.Equals("Float",StringComparison.OrdinalIgnoreCase)){returnConvertDIntBitsToFloat(result);}if(typeName.Equals("DWord",StringComparison.OrdinalIgnoreCase)||typeName.Equals("UDInt",StringComparison.OrdinalIgnoreCase)){returnConvert.ToUInt32(result,CultureInfo.InvariantCulture);}if(typeName.Equals("Int",StringComparison.OrdinalIgnoreCase)||typeName.Equals("Word",StringComparison.OrdinalIgnoreCase)){returnConvert.ToInt16(result,CultureInfo.InvariantCulture);}if(typeName.Equals("DInt",StringComparison.OrdinalIgnoreCase)){returnConvert.ToInt32(result,CultureInfo.InvariantCulture);}// 未识别类型时:按 Float(DInt 位型转浮点)处理returnConvertDIntBitsToFloat(result);}/// <summary>/// 将 DInt/DWord 的 32 位原始数据按 IEEE754 解释为 Float。/// S7netplus 读 MD 区时有时返回 int/uint,需用位型重解释而非数值强转。/// </summary>privatestaticdoubleConvertDIntBitsToFloat(objectresult){if(resultisfloatf)returnf;if(resultisdoubled)returnd;if(resultisuintu)returnBitConverter.ToSingle(BitConverter.GetBytes(u),0);if(resultisinti)returnBitConverter.ToSingle(BitConverter.GetBytes(i),0);if(resultislongl)returnBitConverter.ToSingle(BitConverter.GetBytes(unchecked((int)l)),0);// 其它数值:先落到 32 位整型位型,再转 Floattry{varbits=Convert.ToInt32(result,CultureInfo.InvariantCulture);returnBitConverter.ToSingle(BitConverter.GetBytes(bits),0);}catch{returnConvert.ToSingle(result,CultureInfo.InvariantCulture);}}/// <summary>断开与 PLC 的连接(可重复调用)</summary>publicvoidDisconnect(){lock(_sync){DisconnectInternal();}}/// <summary>内部断开逻辑(调用方须已持有 _sync)</summary>privatevoidDisconnectInternal(){if(_plc==null)return;try{if(_plc.IsConnected){_plc.Close();}}catch{// 断开时忽略异常,避免掩盖上层重连逻辑}finally{_plc=null;}}privatevoidThrowIfDisposed(){if(_disposed)thrownewObjectDisposedException(nameof(PlcService));}/// <summary>释放资源:断开连接并标记已释放</summary>publicvoidDispose(){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
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:158.2 手动采集 CSV
Id,Data,Time,Source 1,1.45678,2026-07-16 10:03:00,Manual 2,1.56789,2026-07-16 10:08:00,Manual8.3 秒级采集 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(直接运行)方式三:命令行
cd"S7DataCollector"dotnet run-cRelease10.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实现,窗口关闭时释放所有资源
