C#与PLC通讯:IP/COM口协议选型与实现源码解析
2026/9/15 13:41:52 网站建设 项目流程

简介:这是一份面向C#开发者和工控人员的PLC通讯程序源码,用于解决通过IP网络口和COM串口与主流可编程控制器进行数据交互的问题,适用场景包括工业自动化、设备数据采集和上位机软件开发等。源码由程序老媛亲测校正,整理为Modbus TCP与Modbus RTU两个独立工程,分别对应网络通讯与串口通讯;从工程构成看,代码分层明确,兼顾新手理解和老手复用,可直接移植到实际项目中,也便于按需扩展。压缩包共包含一百一十个文件,大小约一点零九兆字节,核心是二十个C#源文件,另有动态库和可执行程序用于依赖支撑,多个配置与资源文件用于参数设定,两个文本文件可作为说明参考,整体结构清晰,检索方便。目前已有129人学习下载,对于需要快速搭建上位机与PLC通信的开发者,这份源码具备即取即用的参考价值。

1. C#通过IP和COM口与PLC通讯:先理清协议选型再动手写源码

做上位机开发的工程师迟早要面对这个问题:现场设备明明已经连上了网线或串口线,但用C#写程序时却不知道从哪一行代码开始。很多人在“用IP还是COM口”上摇摆不定,实际上去年我们处理一个产线数据采集项目时发现,真正决定通讯成败的不是网线或串口线,而是你选的通讯协议是否和PLC型号匹配——西门子走S7协议,三菱走MC协议,施耐德走MODBUS,协议没选对,IP和端口填得再准也白搭。

本文围绕C#与PLC通讯这条主线,先用最小篇幅帮你把IP通道和COM口通道的选型逻辑理清,再给出一套能直接落地的程序源码框架。内容覆盖s7netplus库的IP通讯写法、NModbus4库的COM口读写、RS232/RS485接线差异、CRC校验手写实现,以及通讯不稳定时的排错套路。无论你用的是西门子S7-1200还是通用MODBUS从站,都能在这套方案里找到对应的代码块。

2. 用C#通过IP/COM口访问PLC前,先把通讯协议选型搞清楚

2.1 IP通讯不等于TCP:PLC的“三层协议栈”决定了你的代码结构

很多人第一次用C#写PLC通讯时,上手就用TcpClient连PLC的IP和端口,结果要么连不上,要么连上了返回一堆乱码。原因在于:PLC的以太网通讯不是裸TCP,而是三层协议栈。以西门子S7协议为例,底层是TCP连接(默认端口102),中间是TPKT和COTP握手,最上层才是S7 PDU协议数据单元。你直接用TcpClient发命令,PLC根本解析不了。

用C#做IP通讯最省力的方案是用库,常见的有S7.net(即s7netplus)、Sharp7、以及西门子官方的S7-1200/1500通讯库。我一般用s7netplus,它封装了ISO-on-TCP和S7协议的全部握手流程,你只需要关心“读哪个地址、写什么数据”。这个库对S7-200/300/400/1200/1500都有支持,且是纯托管代码,部署时不需要额外的原生DLL,丢到工控机上就能跑。

选库时要关注PLC固件版本与通讯协议版本的兼容性。S7-1200固件4.0以上用S7协议通讯时,连接机制和老300/400不完全一样,s7netplus已经处理了这部分差异,但如果你用Sharp7就得自己留意PDU长度的协商。为了减少排查成本,我通常直接选s7netplus,代码里只需要配置IP、机架号Rack和槽号Slot,库内部会完成COTP握手的参数协商。

2.2 COM口通讯的本质:串口物理链路与数据帧格式

COM口通讯和IP通讯完全是另一套逻辑。串口没有IP地址的概念,它靠物理链路把数据按字节流逐帧发送。常见物理层标准是RS232和RS485,RS232是全双工、点对点,RS485是半双工、支持多站挂接。C#里操作串口用System.IO.Ports.SerialPort类,但SerialPort只是传输通道,它不负责“帧校验”和“站号寻址”——这些逻辑由通讯协议完成。

在PLC串口通讯里,最通用的是MODBUS-RTU协议。它定义了一个简单的请求响应模型:主站发一帧指令(包含从站地址、功能码、寄存器地址、数据、CRC16校验),从站执行后返回一帧响应。C#里实现MODBUS-RTU可以用NModbus4库(NuGet包名NModbus4),也可以完全手写帧格式。NModbus4的好处是帮你封装了功能码01/02/03/04/05/06/15/16,坏处是当PLC厂家在标准MODBUS上加了私有扩展时(比如有些国产PLC的自定义错误码),你必须能看懂原始字节流,这时手写CRC解析就有必要了。

下表是C#做PLC通讯时两个通道的选型对照,方便你在项目启动阶段快速拍板:

对比项IP通道(以太网)COM口通道(串口)
典型协议S7、MODBUS-TCP、MC协议MODBUS-RTU、自由口协议
传输介质网线、工业交换机RS232/RS485屏蔽线
最大传输距离100米(双绞线)RS232约15米,RS485约1200米
C#常用库s7netplus、Sharp7NModbus4、System.IO.Ports
点位数量大时适合,通讯速度快不适合,波特率限制吞吐
抗干扰能力依赖屏蔽和交换网络RS485差分信号抗干扰强

2.3 开发环境准备:创建项目并引入通讯库

打开Visual Studio,创建一个.NET Framework 4.6.1以上或.NET 6/8的控制台项目(工控机兼容性优先选.NET Framework 4.6.1)。然后在NuGet包管理器里安装两个库:S7netplus和NModbus4。安装完成后用下面这段代码验证串口端口枚举和IP连通性:

using System; using System.IO.Ports; using System.Net.NetworkInformation; class Program { static void Main() { // 枚举当前电脑可用的COM口,确认PLC连接到了哪个串口编号 string[] ports = SerialPort.GetPortNames(); Console.WriteLine("可用串口: " + string.Join(", ", ports)); // 用Ping验证PLC的IP是否能通,不通时优先排查网线和防火墙 using (Ping ping = new Ping()) { PingReply reply = ping.Send("192.168.0.1", 1000); Console.WriteLine("PLC IP状态: " + reply.Status); } Console.ReadLine(); } }

这段代码的作用是排查物理连接问题。SerialPort.GetPortNames()返回系统已识别的串口列表,当你插上USB转RS485线但列表里没出现新端口时,说明COM口驱动没装好或线材本身有问题。Ping的通断只能证明IP层可达,不能证明S7协议能通,但能帮你快速划分故障区域——Ping不通就不用往下查协议了。

3. C#通过IP与西门子PLC通讯:S7协议连接、读写与资源释放

3.1 用s7netplus建立S7会话:IP、Rack和Slot参数不能随意填

S7协议连接不像MODBUS-TCP那样只填IP和端口就完事。s7netplus的Plc类构造函数需要三个核心参数:IP地址、Rack机架号、Slot槽号。这几个参数对应了CPU在硬件机架上的物理位置,填错会直接抛出“无法连接”或“握手超时”。

针对S7-1200/1500,Rack固定为0,Slot为1即可;针对S7-300,常见配置是Rack=0,Slot=2;S7-400则需要看硬件组态中CPU所在的槽位编号。代码写法如下:

using System; using S7.Net; class S7IpDemo { static void Main() { // 创建一个S7协议的PLC连接对象 // 参数依次为:CPU类型、IP地址、机架号、槽号 using (Plc plc = new Plc(CpuType.S71200, "192.168.0.1", 0, 1)) { plc.Open(); // 执行TCP连接与COTP握手 if (plc.IsConnected) { Console.WriteLine("S7连接成功"); // 读取M区一个字节的数据,M0.0所在字节 byte mByte = plc.ReadBytes(DataType.Memory, 0, 0, 1)[0]; Console.WriteLine($"M0.0~M0.7 的字节值: {mByte:X2}"); // 向DB1.DBW0写入数值1000 plc.Write("DB1.DBW0", 1000); Console.WriteLine("DB1.DBW0 写入完成"); } plc.Close(); // 通讯完成后释放TCP连接 } } }

参数说明:CpuType.S71200告诉库底层使用哪个固件版本的PDU解析方式;ReadBytes的四个参数分别是数据块类型(DataType.Memory代表M区)、数据块编号(M区这块填0)、起始字节偏移、要读取的字节数。Write方法的"DB1.DBW0"是S7协议的标准地址语法,DBW代表数据字,后面的0是字节偏移起点。

3.2 S7地址映射规则与Bool/Real特殊类型处理

用库读写PLC时,最常踩的坑是Bool类型和Real类型的处理。S7协议里Bool位是压缩在字节里的,M0.0和M0.7是同一个字节的不同位。直接用ReadBytes读出来只是拿到了整个字节,你还需要用位运算把第0位抽出来:

// 读取M0.0这个Bool位 byte mByte = plc.ReadBytes(DataType.Memory, 0, 0, 1)[0]; bool m00 = (mByte & 0x01) == 0x01; // 写入M0.0为true,保留其他位不变 byte newByte = (byte)(mByte | 0x01); plc.WriteBytes(DataType.Memory, 0, 0, new byte[] { newByte });

Real类型(32位浮点数)启动时容易读成一串乱码,原因是S7用IEEE754标准存储,C#的BitConverter正好也按这个标准转换,所以不会出错。但如果PLC里填的是Float,你按双精度Double去读就会错位。读法如下:

// DB1.DBD4 是一个Real变量,占用4个字节 byte[] realBytes = plc.ReadBytes(DataType.DataBlock, 1, 4, 4); float value = BitConverter.ToSingle(realBytes, 0); Console.WriteLine($"DB1.DBD4={value}");

3.3 S7通讯的异常处理与断开重连策略

PLC在运行中可能发生程序停机、网线拔出、交换机关闭等情况。s7netplus的Open方法抛出异常后,如果直接重试可能因为TCP半开连接耗尽资源。我的做法是封装一个带超时控制和重试计数的连接函数:

private static Plc ConnectS7WithRetry(string ip, int retryCount = 3) { Plc plc = new Plc(CpuType.S71200, ip, 0, 1); plc.ReadTimeout = 2000; // 读超时2秒 plc.WriteTimeout = 2000; // 写超时2秒 for (int i = 0; i < retryCount; i++) { try { plc.Open(); return plc; } catch (Exception ex) { Console.WriteLine($"第{i + 1}次连接失败: {ex.Message}"); System.Threading.Thread.Sleep(1000 * (i + 1)); // 递增等待 } } throw new TimeoutException("超过最大重试次数,S7连接失败"); }

这里的ReadTimeout和WriteTimeout是s7netplus暴露的重要参数。默认值可能偏大,如果PLC异常掉线,你的上位机线程会卡在Read上很久不能恢复,把超时压到2秒左右能让系统更快进入重连逻辑。

4. C#通过COM口与PLC通讯:MODBUS-RTU与自由口协议的实现

4.1 用NModbus4读写PLC保持寄存器的标准动作

COM口走MODBUS-RTU时,NModbus4库帮我们省去了帧组包和CRC校验的底层工作。使用前要创建SerialPort对象,配置波特率等参数,然后交给ModbusSerialMaster使用。下面是一段可运行的MODBUS-RTU读取示例:

using System; using System.IO.Ports; using Modbus.Device; class ComModbusDemo { static void Main() { using (SerialPort port = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One)) { port.Open(); // 打开串口,占用COM3 // 基于串口创建MODBUS主站对象 using (ModbusSerialMaster master = ModbusSerialMaster.CreateRtu(port)) { // 读取从站地址为1的PLC中,保持寄存器起始地址0开始的连续10个字 ushort[] registers = master.ReadHoldingRegisters(1, 0, 10); for (int i = 0; i < registers.Length; i++) { Console.WriteLine($"寄存器[{i}] = {registers[i]}"); } // 把数值1234写入从站1的保持寄存器地址0 master.WriteSingleRegister(1, 0, 1234); } } } }

NModbus4的ReadHoldingRegisters参数依次是:从站地址(Station Number)、起始寄存器地址、读取数量。MODBUS-RTU的从站地址范围为1到247,0是广播地址一般不用于读写。这里把串口参数按9600、N、8、1配置,这是绝大多数PLC串口默认的通讯参数,具体数值要和PLC侧的“通讯参数设置”严格一致,否则连响应帧都不回。

4.2 手写MODBUS-RTU请求帧:没有库时CRC16校验这样实现

不是所有场景都能用库。国产PLC的某些型号虽然宣称“兼容MODBUS”,但错误码或寄存器映射并不标准,一旦遇到这种设备,你只能自己组帧。MODBUS-RTU请求帧格式如下:从站地址(1字节) + 功能码(1字节) + 寄存器起始地址(2字节) + 寄存器数量(2字节) + CRC16校验(2字节,低字节在前)。

以下是完整的帧构建和CRC校验代码:

// 计算MODBUS-RTU的CRC16校验值 private static ushort ModbusCrc(byte[] data, int length) { ushort crc = 0xFFFF; for (int pos = 0; pos < length; pos++) { crc ^= data[pos]; for (int i = 8; i != 0; i--) { if ((crc & 0x0001) != 0) { crc >>= 1; crc ^= 0xA001; } else { crc >>= 1; } } } return crc; } // 构建读取保持寄存器的RTU帧:站号1,功能码03,起始地址0,长度10 static byte[] BuildReadFrame(byte station, byte functionCode, ushort startAddr, ushort quantity) { byte[] frame = new byte[8]; frame[0] = station; frame[1] = functionCode; frame[2] = (byte)(startAddr >> 8); // 寄存器起始地址高字节在前 frame[3] = (byte)(startAddr & 0xFF); frame[4] = (byte)(quantity >> 8); frame[5] = (byte)(quantity & 0xFF); ushort crc = ModbusCrc(frame, 6); // 先对前6个字节计算CRC frame[6] = (byte)(crc & 0xFF); // CRC低字节在前 frame[7] = (byte)(crc >> 8); return frame; }

这段代码里的ModbusCrc函数在MODBUS协议子序列0xA001上做循环异或,是RTU模式的标准算法。发帧时要注意:帧内除CRC外的所有字段都是大端序(高字节在前),而CRC本身是小端序(低字节在前)附在帧尾。搞反顺序是手写帧最容易翻车的地方——从站收到后会直接丢帧不响应。

4.3 RS232与RS485的接线差异及COM口驱动排查

稍微成熟的工程师都知道,连着串口线却读不到数据的多数原因不在代码,而在物理连接。RS232只用TXD、RXD、GND三根线实现全双工,常见于距离较近的编程口;RS485用A/B两根差分线实现半双工,接线时A接A、B接B,且同一总线上所有设备的A/B极性必须统一,接反了会通讯失败但不是烧毁。

热词里提到的“com口驱动”问题集中在USB转串口适配器上。设备管理器里如果看不到COM号,优先检查驱动是否被系统禁用。如果COM号存在但Open抛异常,多半是端口被另一个进程占用,用以下命令可以确认占用情况:

# 查看被占用串口的进程ID,Windows下用powershell执行 Get-Process | Where-Object { $_.ProcessName -like "*COM*" } | Select-Object Id, ProcessName

4.4 无PLC环境下用虚拟串口和MODBUS从站模拟器联调

程序写好了但PLC还没到现场,不能干等着。常见做法是用虚拟串口软件(比如Virtual Serial Port Driver)创建一对互相连通的COM3和COM4,再用一个MODBUS从站模拟器(比如ModRSsim2)监听COM4,这样C#程序连接COM3就能模拟出读写真实从站的效果。这种联调方式能验证代码流程、超时处理和帧格式的正确性,但不能验证真实RS485电平转换的硬件问题。

5. 实战进阶:搭建一个稳定的C#上位机通讯服务框架

5.1 把通讯逻辑从UI线程中分离:后台轮询与事件通知

上位机开发最常见的需求是:定时读PLC的数据刷新到界面上。如果你直接在WinForm/WPF的按钮事件里同步读PLC,界面会卡死;如果在Timer回调里同步读,读操作造成的数据等待同样会阻塞UI。正确做法是把通讯逻辑放到后台线程,只把数据结果通过事件抛给UI线程更新界面。

下面是一个生产者消费者模式的轮询服务骨架,里面用到BlockingCollection做数据缓冲层,防止连续两次读请求互相覆盖:

using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; class PlcDataPoller { private readonly Plc _plc; // 复用s7netplus连接 private bool _running = true; public event Action<byte[]> DataReceived; // 数据到达事件 public PlcDataPoller(Plc plc) { _plc = plc; } public void Start() { Task.Run(() => PollLoop()); } private void PollLoop() { byte[] lastData = new byte[10]; while (_running) { try { // 每500ms读取一次M区前10字节,模拟周期轮询 byte[] data = _plc.ReadBytes(DataType.Memory, 0, 0, 10); if (!StructuralComparisons.StructuralEqualityComparer.Equals(data, lastData)) { lastData = data; DataReceived?.Invoke(data); // 数据变化才触发更新 } Thread.Sleep(500); } catch (Exception ex) { Console.WriteLine("轮询异常: " + ex.Message); Thread.Sleep(2000); // 异常时退出重试,防止死循环打日志 } } } public void Stop() { _running = false; } }

这个类把“周期读数据”和“数据是否变化”区别对待。数据没变时不上抛事件,界面不会一直被无用刷新打扰;只有值真正变化时才触发DataReceived,适合在界面上显示生产量、温度等实时变化量。

5.2 多客户端访问时的线程安全保障

s7netplus的Plc对象不是完全线程安全的。多个后台线程同时调用Read/Write方法时,底层Socket可能被并发抢占导致异常。一个稳妥做法是对所以通讯操作加锁:

private readonly object _lockObj = new object(); public byte[] SafeRead(string address) { lock (_lockObj) // 保证同一时刻只有一个线程占用S7连接 { return _plc.ReadBytes(address); } } public void SafeWrite(string address, object value) { lock (_lockObj) { _plc.Write(address, value); } }

lock关键字把Read和Write变成互斥操作,不过度设计但能保住底线。若你的项目对吞吐量要求高,可以考虑为每个从站建立独立连接对象,而不是共享一把锁。

5.3 COM口与IP通讯的统一封装建议

一套稳定程序源码通常会把IP和COM口的差异抽象掉。做法是定义统一的IPlcAdapter接口,包含Connect/Disconnect/ReadBytes/WriteBytes四个方法,分别用S7Adapter和ModbusSerialAdapter实现。这样上层业务代码只面对接口,底层换成哪种PLC都能无缝切换。接口设计的核心是接受“地址字符串”和“字节数组”,把基于真实硬件语义的复杂地址翻译放在各自Adapter内部完成。

6. 调试技巧:用一条日志和一台虚拟机验证通讯程序

开发期没有真机怎么办?我常用VMware安装一个Windows虚拟机,把宿主机上的两个实体串口映射给虚拟机(在虚拟机设置中选择“物理串口”),然后在宿主机上运行一个MODBUS-TCP从站模拟器,让虚拟机里的C#程序通过“虚拟串口重定向到TCP”的桥接方式连上宿主机模拟器。这个方案能验证完整链路,且不需要任何物理PLC硬件。

验证完成后,上线排查时最推荐的方式是开Wireshark抓包。如果IP通讯有问题,抓包后看第二层COTP的Connection Confirm报文是否正常返回;如果COM口通讯异常,可以把串口数据重定向到虚拟串口分析工具(如AccessPort)里观察字节流向。下面给出一个实用的自检脚本思路:

# 验证TCP 102端口是否开放,判断PLC的S7服务是否在监听 Test-NetConnection 192.168.0.1 -Port 102 # 用串口调试精灵发送03 03 00 00 00 0A这三个字节帧,观察从站是否有响应

C#程序上线后若通讯偶发断开,优先检查网卡节能模式和交换机端口休眠策略——不少现场掉线问题是Windows在PLC连接空闲时主动把网卡切到了低功耗模式。在网卡高级设置里把“节能以太网”设为禁用,同时把S7连接的KeepAlive定时器打开,能大幅降低长时间运行后的掉线率。通讯稳定性优化最核心的一条原则:任何时刻都不允许非主线程直接触碰PLC连接对象,锁的保护作用远比想象中的大。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询