一、 核心组件与依赖安装
在 .NET 8.0 环境中,我们选择开源社区最稳定、使用最广泛的 S7netplus。
通过 NuGet 包管理器或控制台安装:
bash
dotnet add package S7netplus
二、 配置文件与选项模式 (Options Pattern)
在现代 .NET 开发中,硬编码是大忌。我们使用 Options 模式来管理 PLC 的连接参数。
1. S7PlcOptions.cs (配置实体类)
csharp
using S7.Net;
namespace IIoT.PlcIntegration;
/// <summary>
/// PLC 连接配置选项
/// </summary>
public class S7PlcOptions
{
/// <summary>
/// 配置文件中的节点名称
/// </summary>
public const string Position = "S7PlcConfig";
/// <summary>
/// CPU 类型 (例如: S71200, S71500, S7300)
/// 默认使用 S7-1500
/// </summary>
public CpuType Cpu { get; set; } = CpuType.S71500;
/// <summary>
/// PLC 的 IP 地址
/// </summary>
public string IpAddress { get; set; } = "127.0.0.1";
/// <summary>
/// 机架号 (对于 S7-1200/1500 通常为 0)
/// </summary>
public short Rack { get; set; } = 0;
/// <summary>
/// 插槽号 (对于 S7-1200/1500 通常为 1,S7-300 通常为 2)
/// </summary>
public short Slot { get; set; } = 1;
/// <summary>
/// 连接超时时间(毫秒)
/// </summary>
public int TimeoutMs { get; set; } = 3000;
}
三、 接口抽象定义
遵循依赖倒置原则(DIP),我们先定义清晰的接口。这不仅方便后续通过依赖注入使用,也极其方便单元测试(Mock)。
2. IS7PlcService.cs (接口定义)
csharp
using S7.Net;
namespace IIoT.PlcIntegration;
/// <summary>
/// S7 PLC 通信服务接口
/// </summary>
public interface IS7PlcService
{
/// <summary>
/// 获取当前是否处于连接状态
/// </summary>
bool IsConnected { get; }
/// <summary>
/// 泛型读取方法 (适用于读取单个变量,如 DB1.DBD4)
/// </summary>
/// <typeparam name="T">期望转换的数据类型 (如 float, short, bool)</typeparam>
/// <param name="variable">PLC 变量地址</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>读取成功返回对应类型的值,失败返回默认值</returns>
Task<T?> ReadAsync<T>(string variable, CancellationToken cancellationToken = default);
/// <summary>
/// 写入单个变量
/// </summary>
/// <param name="variable">PLC 变量地址</param>
/// <param name="value">要写入的值</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>是否写入成功</returns>
Task<bool> WriteAsync(string variable, object value, CancellationToken cancellationToken = default);
/// <summary>
/// 批量读取连续的字节 (高性能推荐用法)
/// 工业现场为了节约带宽和降低延迟,通常一次性读取整个 DB 块的字节数组,然后在 C# 中解析
/// </summary>
/// <param name="dataType">数据块类型 (如 DataType.DataBlock)</param>
/// <param name="db">DB 块号</param>
/// <param name="startByteAdr">起始字节偏移量</param>
/// <param name="count">读取的字节长度</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>字节数组,失败返回 null</returns>
Task<byte[]?> ReadBytesAsync(DataType dataType, int db, int startByteAdr, int count, CancellationToken cancellationToken = default);
}
四、 核心服务实现(灵魂代码)
这是整篇文章的核心所在。需要向读者强调:工控通信的命门在于并发控制和异常处理。这里使用了 C# 12 的主构造函数(Primary Constructors)。
3. S7PlcService.cs (核心实现)
csharp
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using S7.Net;
namespace IIoT.PlcIntegration;
/// <summary>
/// 高可用 S7 PLC 服务实现
/// </summary>
/// <remarks>
/// 设计要点:
/// 1. 使用 SemaphoreSlim 保证线程安全,防止多线程同时发起 TCP 请求导致 S7 协议报文错乱。
/// 2. 采用惰性连接 (Lazy Connection) 与断线自愈机制,读写前自动检查并重建连接。
/// 3. 捕获底层异常时主动释放底层 Socket,避免死锁或半连接状态。
/// </remarks>
public class S7PlcService(IOptions<S7PlcOptions> options, ILogger<S7PlcService> logger) : IS7PlcService, IDisposable
{
private readonly S7PlcOptions _options = options.Value;
private Plc? _plc;
// 【核心组件】信号量,只允许 1 个线程同时进入底层读写,防止串线
private readonly SemaphoreSlim _semaphore = new(1, 1);
// 判断内部 PLC 对象是否实例化且处于连接状态
public bool IsConnected => _plc is { IsConnected: true };
/// <summary>
/// 内部核心方法:确保连接就绪。
/// 注意:调用此方法前,必须确保已经获得了 _semaphore 锁!
/// </summary>
private async Task<bool> EnsureConnectionAsync(CancellationToken cancellationToken)
{
// 如果已经连接,直接返回,避免重复开销
if (IsConnected) return true;
try
{
// 彻底清理旧的连接实例,防止内存泄漏或 Socket 端口占用
_plc?.Close();
// 重新实例化 PLC 对象
_plc = new Plc(_options.Cpu, _options.IpAddress, _options.Rack, _options.Slot);
_plc.ReadTimeout = _options.TimeoutMs;
_plc.WriteTimeout = _options.TimeoutMs;
// 异步发起 TCP 连接
await _plc.OpenAsync(cancellationToken);
logger.LogInformation("成功连接到西门子 PLC [{IpAddress}]. Rack:{Rack}, Slot:{Slot}",
_options.IpAddress, _options.Rack, _options.Slot);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "连接西门子 PLC [{IpAddress}] 失败!请检查网络状态或机架/插槽配置。", _options.IpAddress);
return false;
}
}
public async Task<T?> ReadAsync<T>(string variable, CancellationToken cancellationToken = default)
{
// 等待进入临界区
await _semaphore.WaitAsync(cancellationToken);
try
{
// 检查并建立连接,如果连接失败直接返回默认值
if (!await EnsureConnectionAsync(cancellationToken)) return default;
// 调用底层读取
var result = await _plc!.ReadAsync(variable, cancellationToken);
if (result == null) return default;
// 类型转换处理:处理底层返回类型与泛型 T 不完全一致的情况
return result is T typedResult ? typedResult : (T)Convert.ChangeType(result, typeof(T));
}
catch (Exception ex)
{
logger.LogError(ex, "读取 PLC 变量 {Variable} 异常。将强制断开连接触发下一次重连。", variable);
// 【防御性编程】一旦发生异常,立刻关闭当前连接,强迫下次请求重建连接
_plc?.Close();
return default;
}
finally
{
// 无论成功失败,必须释放锁
_semaphore.Release();
}
}
public async Task<bool> WriteAsync(string variable, object value, CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
if (!await EnsureConnectionAsync(cancellationToken)) return false;
await _plc!.WriteAsync(variable, value, cancellationToken);
logger.LogDebug("成功向变量 {Variable} 写入值: {Value}", variable, value);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "写入 PLC 变量 {Variable} 异常!", variable);
_plc?.Close();
return false;
}
finally
{
_semaphore.Release();
}
}
public async Task<byte[]?> ReadBytesAsync(DataType dataType, int db, int startByteAdr, int count, CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken);
try
{
if (!await EnsureConnectionAsync(cancellationToken)) return null;
return await _plc!.ReadBytesAsync(dataType, db, startByteAdr, count, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "批量读取 PLC 字节数据失败。DB:{Db}, 起始地址:{Start}, 长度:{Count}", db, startByteAdr, count);
_plc?.Close();
return null;
}
finally
{
_semaphore.Release();
}
}
/// <summary>
/// 释放非托管资源
/// </summary>
public void Dispose()
{
_plc?.Close();
_semaphore.Dispose();
GC.SuppressFinalize(this);
}
}
五、 在项目中的注册与实战运用
在 appsettings.json 中配置 PLC 信息:
json
{
"S7PlcConfig": {
"Cpu": "S71500",
"IpAddress": "192.168.0.200",
"Rack": 0,
"Slot": 1,
"TimeoutMs": 3000
}
}
在 Program.cs 中注册服务(注册为单例,因为底层做了并发控制和重连,全应用共用一个实例即可,节约连接数):
csharp
var builder = WebApplication.CreateBuilder(args);
// 1. 绑定配置到 IOptions
builder.Services.Configure<S7PlcOptions>(builder.Configuration.GetSection(S7PlcOptions.Position));
// 2. 注册为单例服务
builder.Services.AddSingleton<IS7PlcService, S7PlcService>();
// 3. 注册测试用的后台工作服务
builder.Services.AddHostedService<PlcDataCollectorWorker>();
var app = builder.Build();
app.Run();
编写一个后台采集服务 (PlcDataCollectorWorker.cs) 测试效果:
csharp
using IIoT.PlcIntegration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public class PlcDataCollectorWorker(IS7PlcService plcService, ILogger<PlcDataCollectorWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("PLC 数据采集服务已启动...");
while (!stoppingToken.IsCancellationRequested)
{
try
{
// 示例 1:读取简单的单变量 (DB1 块,偏移量 0 的双字转换为 Float)
var temperature = await plcService.ReadAsync<float>("DB1.DBD0", stoppingToken);
logger.LogInformation("当前设备温度: {Temp} ℃", temperature);
// 示例 2:心跳写入 (将 M0.0 置为 true 告诉 PLC 上位机在线)
await plcService.WriteAsync("M0.0", true, stoppingToken);
}
catch (Exception ex)
{
// 防止主循环崩溃
logger.LogError(ex, "采集周期发生未处理异常");
}
// 间隔 1 秒采集一次
await Task.Delay(1000, stoppingToken);
}
}
}