C#与PLC通讯:IP/COM口协议选型与实现源码解析 简介这是一份面向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通讯不等于TCPPLC的“三层协议栈”决定了你的代码结构很多人第一次用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和RS485RS232是全双工、点对点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/1500Rack固定为0Slot为1即可针对S7-300常见配置是Rack0Slot2S7-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到2470是广播地址一般不用于读写。这里把串口参数按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抛异常多半是端口被另一个进程占用用以下命令可以确认占用情况# 查看被占用串口的进程IDWindows下用powershell执行 Get-Process | Where-Object { $_.ProcessName -like *COM* } | Select-Object Id, ProcessName4.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 Actionbyte[] 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连接对象锁的保护作用远比想象中的大。本文还有配套的精品资源点击获取