西门子 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 复制代码
<?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 分钟)维护一个最低值:

  1. 采样阶段:每秒读取的数值与当前周期最低值比较,保留较小值
  2. 结算阶段:自动存盘时取出周期最低值,清空累加器
  3. 回滚机制:若写盘失败,已取出的最低值会合并回当前周期
  4. 周期边界:拒绝上周期遗留的陈旧采样,避免边界低压点污染

六、并发设计

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 配置修改注意事项

  1. 修改 S7DataCollector.exe.config(运行时)而非源码中的 App.config
  2. 修改后必须关闭程序再重新打开,配置才会生效
  3. PLC 需允许 PUT/GET 访问,电脑防火墙需放行
  4. 连不上时查看界面红色状态灯和 Logs\ErrorLog.txt

10.3 部署要求

  • 目标机器需安装 .NET Framework 4.7.2 或更高版本
  • 电脑与 PLC 需在同一网段
  • 建议先 ping 通 PLC IP 再启动程序

十一、设计要点总结

  1. 单路采集:一个定时器读 PLC,多路消费,降低通信负荷
  2. 缓存策略:使用缓存瞬时值避免边界再读 PLC,新鲜度阈值 2 秒
  3. 周期最低值:半开区间累加,写盘失败回滚,边界不污染
  4. 异步秒级写入:有界队列 + 后台批量写,不阻塞主流程
  5. 异常防护:全局异常钩子 + 自动重试 + 状态恢复,支持长期无人值守
  6. 资源管理 :完整的 IDisposable 实现,窗口关闭时释放所有资源
相关推荐
合众恒跃1 小时前
RK3588 + RK182X 双芯异构边缘计算方案:部署流程与实测性能解析
大数据·人工智能·科技·编辑器·etl工程师
百胜软件@百胜软件2 小时前
AI大促之后:618的三大启示与双11的三大预判
大数据·人工智能·零售
qq_33776320192 小时前
国际期货资管系统开发方案|内外盘交易平台源码授权与二次开发支持
java·c语言·开发语言·c++·c#
明航咨询—张老师12 小时前
软考软件评测师技术深度剖析:2026年考试改革后的知识体系与备考架构
大数据·架构
千桐科技13 小时前
qData 数据中台开源版从 MySQL 到 Doris完成一次数据同步
大数据·mysql·开源·doris·数据中台·qdata
rockey62713 小时前
基于AScript的JavaScript脚本语言发布啦
javascript·c#·.net·js·script
LuoCore13 小时前
AForge.Video.FFMPEG.dll加载失败解决指南
c#
吴可可12314 小时前
C#元组解构实现变量交换
c#
ACP广源盛1392462567315 小时前
GSV6155@ACP# 搭配 AI 服务器、AI PC 完整适配方案
大数据·服务器·人工智能·分布式·单片机·嵌入式硬件