找能模拟 PLC 行为的虚拟软件,重点是能复现多线程 / 多客户端高频读写时的并发冲突、脏数据等 BUG,而且普通的快速读写模拟程序因为速度太快看不到问题,需要能精准控制读写时序的虚拟 PLC 工具。
推荐的虚拟 PLC 软件 & 定制化模拟方案
以下方案从 "即用型工具" 到 "定制化开发" 分层,既能快速复现问题,也能精准模拟脏数据 / 并发冲突场景:
方案 1:即用型虚拟 PLC(快速上手,无需大量开发)
1.1 Modbus Slave(最推荐,精准模拟读写冲突)
- 核心优势:这是专业的 Modbus 从站模拟器(对应 PLC 角色),支持 TCP/UDP,能手动设置寄存器读写延迟、模拟 "半写状态",完美复现脏数据和并发冲突。
- 操作步骤(复现脏数据) :
- 下载安装:Modbus Slave 官网(有试用版,足够测试);
- 配置:新建 Modbus TCP 从站(端口 502),添加保持寄存器(对应 PLC 的 D 区),比如地址 100-105;
- 关键设置:
- 开启「Write Delay」(写延迟):设置写操作需要 500ms 完成(模拟 PLC 写寄存器的耗时);
- 开启「Partial Write」(部分写):允许寄存器只写入一半数据(模拟脏数据);
- 测试:用你之前的 C# 多线程程序同时读写地址 100,此时能清晰看到:线程 A 写了一半数据时,线程 B 读取到 "脏数据"(比如高 8 位已更新、低 8 位还是旧值)。
1.2 S7-PLCSIM(西门子虚拟 PLC,通用兼容性好)
- 适用场景:如果你的项目后续可能对接西门子 PLC,用这个更贴近工业场景;
- 核心优势:支持多客户端连接,能模拟 PLC 的 CPU 负载、通讯缓冲区溢出,甚至可以设置 "读写互斥延迟";
- 关键操作:在仿真界面中,给指定寄存器(比如 DB1.DBW100)设置「写保护延迟」,当一个客户端写操作未完成时,另一个客户端读会获取到中间值(脏数据)。
方案 2:定制化虚拟 PLC(精准控制时序,复现任意 BUG)
如果现成工具满足不了你的 "时序精准控制" 需求(比如要模拟 "每 10 次写操作出现 1 次脏数据"),可以用 C#/VB.NET自己开发一个简易虚拟 PLC 服务器,核心是:
- 模拟 PLC 的寄存器内存;
- 给读写操作加「可控延迟」和「随机中断」;
- 记录所有读写请求的时序,方便定位 BUG。
完整代码示例(C# 版,VB.NET/VBA可参考逻辑)
cs
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Collections.Generic;
using System.Threading.Tasks;
/// <summary>
/// 定制化虚拟PLC服务器(模拟Modbus TCP,支持可控延迟、脏数据、并发冲突)
/// </summary>
public class VirtualPLCServer
{
// 模拟PLC的寄存器内存(D区,地址0-999)
private readonly ushort[] _registers = new ushort[1000];
// 读写锁(模拟PLC的CPU调度,可手动控制锁的释放时机)
private readonly object _lockObj = new object();
// 配置项:写操作延迟(毫秒),可动态调整
public int WriteDelayMs { get; set; } = 300;
// 配置项:是否模拟脏数据(写一半中断)
public bool SimulateDirtyData { get; set; } = true;
// 记录所有读写日志,方便分析时序
private readonly List<string> _log = new List<string>();
private TcpListener _listener;
private bool _isRunning = false;
// 启动虚拟PLC服务器(端口502,Modbus默认端口)
public void Start(int port = 502)
{
_listener = new TcpListener(IPAddress.Any, port);
_listener.Start();
_isRunning = true;
Console.WriteLine($"虚拟PLC已启动,端口:{port}");
// 异步接收客户端连接
Task.Run(async () =>
{
while (_isRunning)
{
var client = await _listener.AcceptTcpClientAsync();
// 每个客户端分配独立线程处理
Task.Run(() => HandleClient(client));
}
});
}
// 处理单个客户端的读写请求
private void HandleClient(TcpClient client)
{
var clientIp = ((IPEndPoint)client.Client.RemoteEndPoint).Address.ToString();
Console.WriteLine($"客户端连接:{clientIp}");
using (var stream = client.GetStream())
{
byte[] buffer = new byte[1024];
while (_isRunning && client.Connected)
{
try
{
int bytesRead = stream.Read(buffer, 0, buffer.Length);
if (bytesRead == 0) break;
// 解析Modbus请求(简化版:只处理读/写保持寄存器)
var request = ParseModbusRequest(buffer, bytesRead);
if (request == null) continue;
// 处理读/写请求(核心:模拟PLC的延迟和脏数据)
byte[] response = ProcessRequest(request, clientIp);
if (response != null)
{
stream.Write(response, 0, response.Length);
}
}
catch (Exception ex)
{
Console.WriteLine($"客户端{clientIp}处理失败:{ex.Message}");
break;
}
}
}
client.Close();
Console.WriteLine($"客户端断开:{clientIp}");
}
// 解析Modbus请求(简化版,只处理03读保持寄存器、16写多个寄存器)
private ModbusRequest ParseModbusRequest(byte[] buffer, int length)
{
// Modbus TCP帧结构:MBAP(7字节) + PDU
if (length < 8) return null;
var request = new ModbusRequest
{
TransactionId = BitConverter.ToUInt16(buffer, 0),
ProtocolId = BitConverter.ToUInt16(buffer, 2),
Length = BitConverter.ToUInt16(buffer, 4),
UnitId = buffer[6],
FunctionCode = buffer[7]
};
// 解析读请求(03:读保持寄存器)
if (request.FunctionCode == 0x03)
{
request.StartAddress = BitConverter.ToUInt16(new[] { buffer[9], buffer[8] }, 0); // 高低位互换
request.Quantity = BitConverter.ToUInt16(new[] { buffer[11], buffer[10] }, 0);
}
// 解析写请求(16:写多个寄存器)
else if (request.FunctionCode == 0x10)
{
request.StartAddress = BitConverter.ToUInt16(new[] { buffer[9], buffer[8] }, 0);
request.Quantity = BitConverter.ToUInt16(new[] { buffer[11], buffer[10] }, 0);
int byteCount = buffer[12];
request.WriteData = new ushort[request.Quantity];
for (int i = 0; i < request.Quantity; i++)
{
request.WriteData[i] = BitConverter.ToUInt16(new[] { buffer[13 + i * 2 + 1], buffer[13 + i * 2] }, 0);
}
}
return request;
}
// 处理Modbus请求(核心:模拟延迟、脏数据)
private byte[] ProcessRequest(ModbusRequest request, string clientIp)
{
lock (_lockObj) // 模拟PLC的单线程处理,但可手动释放锁制造冲突
{
string logMsg = $"{DateTime.Now:HH:mm:ss.fff} | {clientIp} | ";
byte[] response = null;
if (request.FunctionCode == 0x03)
{
// 处理读请求
logMsg += $"读寄存器 | 起始地址:{request.StartAddress} | 数量:{request.Quantity}";
ushort[] data = new ushort[request.Quantity];
for (int i = 0; i < request.Quantity; i++)
{
int addr = request.StartAddress + i;
if (addr < _registers.Length)
{
data[i] = _registers[addr];
}
}
// 构建读响应
response = BuildReadResponse(request, data);
}
else if (request.FunctionCode == 0x10)
{
// 处理写请求(模拟延迟 + 脏数据)
logMsg += $"写寄存器 | 起始地址:{request.StartAddress} | 数量:{request.Quantity} | 数据:{string.Join(",", request.WriteData)}";
// 模拟写操作延迟(关键:制造并发冲突的窗口)
Thread.Sleep(WriteDelayMs);
// 模拟脏数据:只写一半数据(随机触发,更贴近真实场景)
int writeCount = SimulateDirtyData
? new Random().Next(1, request.Quantity) // 随机写1~n个,剩下的未更新
: request.Quantity;
for (int i = 0; i < writeCount; i++)
{
int addr = request.StartAddress + i;
if (addr < _registers.Length)
{
_registers[addr] = request.WriteData[i];
}
}
if (writeCount < request.Quantity)
{
logMsg += $" | 模拟脏数据:仅写入{writeCount}个寄存器";
}
// 构建写响应
response = BuildWriteResponse(request);
}
_log.Add(logMsg);
Console.WriteLine(logMsg);
return response;
}
}
// 构建读响应
private byte[] BuildReadResponse(ModbusRequest request, ushort[] data)
{
int responseLength = 5 + data.Length * 2; // MBAP(7) + PDU(1 + 1 + n*2)
byte[] response = new byte[7 + 1 + 1 + data.Length * 2];
// MBAP头
BitConverter.GetBytes(request.TransactionId).CopyTo(response, 0);
BitConverter.GetBytes(request.ProtocolId).CopyTo(response, 2);
BitConverter.GetBytes((ushort)(1 + 1 + data.Length * 2)).CopyTo(response, 4);
response[6] = request.UnitId;
// PDU
response[7] = request.FunctionCode;
response[8] = (byte)(data.Length * 2); // 字节数
for (int i = 0; i < data.Length; i++)
{
byte[] bytes = BitConverter.GetBytes(data[i]);
response[9 + i * 2] = bytes[1]; // 高字节
response[10 + i * 2] = bytes[0]; // 低字节
}
return response;
}
// 构建写响应
private byte[] BuildWriteResponse(ModbusRequest request)
{
byte[] response = new byte[12];
// MBAP头
BitConverter.GetBytes(request.TransactionId).CopyTo(response, 0);
BitConverter.GetBytes(request.ProtocolId).CopyTo(response, 2);
BitConverter.GetBytes((ushort)5).CopyTo(response, 4);
response[6] = request.UnitId;
// PDU
response[7] = request.FunctionCode;
BitConverter.GetBytes(request.StartAddress).CopyTo(response, 8);
Array.Reverse(response, 8, 2); // 高低位互换
BitConverter.GetBytes(request.Quantity).CopyTo(response, 10);
Array.Reverse(response, 10, 2);
return response;
}
// 停止服务器
public void Stop()
{
_isRunning = false;
_listener?.Stop();
Console.WriteLine("虚拟PLC已停止");
}
// Modbus请求模型
private class ModbusRequest
{
public ushort TransactionId { get; set; }
public ushort ProtocolId { get; set; }
public ushort Length { get; set; }
public byte UnitId { get; set; }
public byte FunctionCode { get; set; }
public ushort StartAddress { get; set; }
public ushort Quantity { get; set; }
public ushort[] WriteData { get; set; }
}
}
// 测试调用
class Program
{
static void Main(string[] args)
{
// 创建虚拟PLC服务器
var virtualPlc = new VirtualPLCServer();
// 配置:写延迟300ms,模拟脏数据
virtualPlc.WriteDelayMs = 300;
virtualPlc.SimulateDirtyData = true;
// 启动服务器(端口502)
virtualPlc.Start(502);
Console.WriteLine("按任意键停止虚拟PLC...");
Console.ReadKey();
virtualPlc.Stop();
}
}
代码核心功能解释
- 模拟 PLC 内存 :用
_registers数组模拟 PLC 的 D 区寄存器; - 可控写延迟 :通过
WriteDelayMs设置写操作耗时(比如 300ms),制造 "写未完成时读" 的窗口; - 脏数据模拟 :
SimulateDirtyData开启后,写操作会随机只写入部分寄存器,复现 "半写" 的脏数据; - 详细日志:记录每个客户端的读写时序,方便分析 "哪个线程在什么时候读 / 写了哪个地址";
- 标准 Modbus TCP :兼容所有支持 Modbus 的客户端(C#/VBA/VB.NET都能连接)。
方案 3:VBA/VB.NET轻量模拟(无需服务器,直接模拟冲突)
如果只想用 VBA/VB.NET快速模拟,不用完整 PLC 协议,可以写一个「共享内存 + 延迟锁」的模拟类:
vbnet
' VB.NET版 轻量PLC内存模拟类(复现脏数据)
Public Class SimpleVirtualPLC
' 模拟寄存器内存
Private Registers As New Dictionary(Of Integer, Integer)
' 读写锁
Private ReadWriteLock As New Object
' 写操作延迟(毫秒)
Public WriteDelayMs As Integer = 200
' 是否模拟脏数据
Public SimulateDirtyData As Boolean = True
' 读寄存器
Public Function ReadRegister(ByVal addr As Integer) As Integer
SyncLock ReadWriteLock
' 模拟读操作:可能读到未写完的数据
If Registers.ContainsKey(addr) Then
Return Registers(addr)
Else
Return 0
End If
End SyncLock
End Function
' 写寄存器(模拟延迟+脏数据)
Public Sub WriteRegister(ByVal addr As Integer, ByVal value As Integer)
' 不锁定整个写过程,制造并发窗口
If SimulateDirtyData Then
' 模拟:先写低8位,延迟后写高8位(脏数据)
Dim dirtyValue As Integer = (value And &HFF) Or (Registers(addr) And &HFF00)
Registers(addr) = dirtyValue ' 写入一半数据
Threading.Thread.Sleep(WriteDelayMs) ' 延迟
Registers(addr) = value ' 补全数据
Else
Threading.Thread.Sleep(WriteDelayMs)
SyncLock ReadWriteLock
Registers(addr) = value
End SyncLock
End If
End Sub
End Class
' 测试调用
Sub Test()
Dim plc As New SimpleVirtualPLC
plc.WriteDelayMs = 200
plc.SimulateDirtyData = True
' 线程1:写数据
Dim t1 As New Threading.Thread(Sub()
For i As Integer = 1 To 100
plc.WriteRegister(100, i * 100)
Threading.Thread.Sleep(50)
Next
End Sub)
' 线程2:读数据(会读到脏数据)
Dim t2 As New Threading.Thread(Sub()
For i As Integer = 1 To 200
Dim val As Integer = plc.ReadRegister(100)
Console.WriteLine($"读取D100:{val}(可能是脏数据)")
Threading.Thread.Sleep(100)
Next
End Sub)
t1.Start()
t2.Start()
t1.Join()
t2.Join()
End Sub
关键使用技巧(复现 BUG 的核心)
- 控制延迟:写操作延迟设置为 200-500ms,读操作频率设为 100ms / 次,刚好能覆盖 "写一半" 的窗口;
- 随机脏数据:不要每次都触发脏数据,设置 10%~20% 的触发概率,更贴近真实 PLC 的偶发 BUG;
- 日志分析:记录每个读写操作的时间戳、地址、值,BUG 出现后能回溯 "哪个线程在什么时候操作了地址"。
总结
- 快速复现:优先用「Modbus Slave」,开箱即用,能精准模拟 PLC 的读写延迟和脏数据;
- 精准定制:用 C# 开发「虚拟 PLC 服务器」,可自定义延迟、脏数据触发规则,适配任意测试场景;
- 轻量测试 :VBA/VB.NET写「共享内存模拟类」,无需网络,直接复现多线程读写冲突。
用这些工具,你能清晰看到 "多线程快速读写时,读线程获取到未写完的脏数据",完美复现真实 PLC 可能出现的并发 BUG,方便调试你的 C# 多线程访问逻辑。