ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PLC通讯协议C#源码解析:从组帧到回帧的完整实现

PLC通讯协议C#源码解析:从组帧到回帧的完整实现

简介:面向工业自动化上位机开发者的LS PLC通讯协议C#源代码包,定位于解决C#程序与韩国LS产电PLC之间数据采集、寄存器读写与设备控制问题,适合需要对接PLC做远程监控或二次开发的工程师学习参考。整个资源包共52个文件,核心是12个cs源文件,还有sln/csproj工程文件便于直接打开编译,另含exe可执行示例与pdb、resources、settings、manifest等辅助文件,可快速查看运行效果并定位改动点;压缩包仅102KB,体量轻、结构清晰。内容覆盖串口(RS-232/RS-485)和以太网两种通信路径,涉及Modbus TCP/IP等协议的数据封装与解析,示例中可看到通信参数配置、寄存器地址映射和读写函数组织方式,为理解上位机与PLC的完整交互流程提供了可直接复用的模板。该资源已有581人学习下载,适合具备一定C#基础、正在接触LS PLC通讯开发或需要现成通信模块做改造的自动化工程师。

1. 某系列PLC通讯协议C#源码:一条读取指令从组帧到回帧的落地样板

做设备数据采集的人,大概率会遇到这种场景:现场有一批PLC,手册里只给了通讯协议格式,翻遍了全网找不到现成SDK。这份C#源码就是干这个活的——按协议把请求帧组出来,通过串口或以太网发出去,再把响应帧拆回寄存器值。我拆项目时会先看它的帧结构和底层收发封装,因为这两处直接决定了你接真实设备时要不要返工。你要是搞上位机开发、设备数采或者MES对接,手上又正好有这类PLC设备,这份源码可以直接抄。它不依赖第三方通讯库,CRC校验、帧切割、超时重试都自己实现,改起来不用看别人的黑匣子。

2. 技术选型与整体架构:串口/TCP双链路、帧结构与模块划分

2.1 双链路选择:RS232/RS485串口与以太网TCP/IP怎么选

PLC通讯这个细分领域,物理链路就两大派:串口和以太网。早期设备基本都是RS232/RS485,区别在于RS232适合近距离一对一,RS485走差分信号,支持一条总线上挂多台设备,靠设备地址区分,车间里几十台设备拉一根屏蔽双绞线就能组网。这几年新设备基本都带以太网口,走TCP/IP,可以直接接入车间局域网,上位机远程访问非常方便,不用跑线。

这份源码在设计上把两种链路都实现了,而且接口对齐过:串口和TCP的Client都暴露Connect、SendAndWaitAsync、Dispose这套方法,上层轮询逻辑不需要关心底层是哪种链路。实际项目里,我的习惯是纯本地单机采集优先用串口,延迟稳定、不受交换机影响;设备分散在两个车间以上或者要跨楼层采集,直接走以太网,省去一堆串口服务器的维护成本。

链路参数方面,串口常见配置是9600波特率、8个数据位、1个停止位、无校验,也就是常说的9600 8N1。如果你的设备通讯距离超过一百米,波特率建议降到9600,距离越远速率越保守,这跟网线超过一百米跑不满千兆是一个道理。以太网链路则需要关心端口号是不是被占用,还有PLC侧是否限制了允许连接的IP数量,这些在联调阶段最容易踩雷。

2.2 报文帧结构与字段定义

帧结构是一切调试的基础。我拿到这份源码第一件事就是把帧结构表画出来,后面所有代码都围绕这张表展开。这份源码采用的自定义帧格式如下:

字段长度(字节)说明
帧头2固定标识,用于同步帧起始位置
设备地址1范围0x01~0xFE,串口总线多设备时区分目标
功能码1标识本次操作类型,读寄存器、写寄存器等
数据长度2数据区字节数,大端序,高字节在前
数据区N起始地址、寄存器数量或实际数据
CRC162从设备地址到数据区结束的校验值,低字节在前

这种设计在工业协议里非常典型:固定帧头解决粘包时的边界定位,设备地址支持总线级联,数据长度字段让接收方能够预判还需要等多少个字节再解析。功能码在不同厂商协议里含义不同,这份源码里定义了读保持寄存器、写单个寄存器、写多个寄存器三个操作,对应的功能码值可以在枚举里改,接你自己设备时对照手册调整即可。

CRC16校验用的是工业界最常见的Modbus风格多项式:0xA001,初始值0xFFFF。注意发送时是低字节在前,这一点很多第一次写通讯的人会翻车,后面避坑章节详细说。

2.3 解决方案结构与类职责

源码按功能拆成了五个项目,职责边界比较干净。我通常会先扫一遍项目结构再读代码,避免钻进细节出不来。

Plc.Protocol/ 协议编解码:CRC计算、报文构建、响应解析 Plc.Transport/ 链路层:SerialPort与TcpClient封装、帧切割 Plc.Service/ 业务层:轮询调度、数据缓存、重试策略 Plc.Demo/ 控制台Demo:配置加载、收发演示 Plc.Tests/ 单元测试:CRC向量、组帧解析回归

类职责划分大致是这样的:

类名职责关键点
Crc16CalculatorCRC查表与计算多项式0xA001,结果低字节在前
PlcPacket帧的构建与解析持有帧头、地址、功能码、数据区
PlcSerialClient串口收发封装事件驱动接收,内部做帧切割
PlcTcpClientTCP收发封装同步连接,异步等待响应
PlcProtocol业务指令封装提供ReadHoldings、WriteRegister等方法
PlcDataBuffer寄存器数据缓存解析结果按地址存字典
PlcLogger十六进制日志收发帧全部以Hex输出方便排查

我自己看这种通讯源码,会重点看三个类:Crc16Calculator、PlcSerialClient、PlcProtocol。校验算法对了,帧没白组;接收切割对了,数据不乱码;业务封装对了,上层调用可读性才有保障。剩下的缓存和日志属于锦上添花,改造空间也大。

3. 核心代码拆解:一条读取指令从组帧到解析的完整链路

3.1 CRC16校验:最容易翻车的地方

通讯协议里CRC校验看着不起眼,实际90%的联调失败都栽在这里。要么是多项式用错,要么是初始值不对,要么是高低字节顺序反了。这份源码用查表法实现,运行时快,代码也直观。

public static class Crc16Calculator { private const ushort Polynomial = 0xA001; private static readonly ushort[] Table = BuildTable(); private static ushort[] BuildTable() { ushort[] table = new ushort[256]; for (int i = 0; i < 256; i++) { ushort value = (ushort)i; for (int j = 0; j < 8; j++) { value = (value & 0x0001) != 0 ? (ushort)((value >> 1) ^ Polynomial) : (ushort)(value >> 1); } table[i] = value; } return table; } public static ushort Compute(byte[] buffer, int offset, int length) { ushort crc = 0xFFFF; for (int i = offset; i < offset + length; i++) { int index = (crc ^ buffer[i]) & 0xFF; crc = (ushort)((crc >> 8) ^ Table[index]); } return crc; } }

Compute方法的三个参数分别是待计算缓冲区、起始偏移和参与计算的长度。注意这里传入的buffer通常从设备地址开始,不包含帧头,具体取哪些字节要在调用处根据厂商协议圈定。查表法思想是先把0~255这256个字节各自的CRC变化预计算出来,运行时用当前CRC的低字节索引查表,只需一次移位和一次异或,比逐位计算快得多。

使用时的坑在于结果输出顺序。Compute返回的ushort是计算完的完整值,组帧发送时一般要求先低字节后高字节,即(byte)(crc & 0xFF)放前面,(byte)(crc >> 8)放后面。解析响应时则要反过来拼:(byte)(frame[len - 2] | (frame[len - 1] << 8))。你要是把发送和接收的字节序搞反了,设备端校验永远不过,表现为设备一直不回帧。

3.2 构建读取保持寄存器请求帧

读取指令是最常用的操作。构建请求帧就是把设备地址、功能码、起始地址、寄存器数量按帧结构填进去,最后追加CRC。看这段代码:

public byte[] BuildReadHoldingsFrame(byte deviceAddr, ushort startAddr, ushort count) { if (count < 1 || count > 125) throw new ArgumentOutOfRangeException(nameof(count), "单次读取不能超过125个字"); byte[] frame = new byte[10]; frame[0] = FrameHead[0]; // 0x01 frame[1] = FrameHead[1]; // 0xFE frame[2] = deviceAddr; // 目标PLC地址 frame[3] = FunctionCode.ReadHoldings; // 功能码 frame[4] = (byte)(startAddr >> 8); // 起始地址高字节 frame[5] = (byte)(startAddr & 0xFF); // 起始地址低字节 frame[6] = (byte)(count >> 8); // 数量高字节 frame[7] = (byte)(count & 0xFF); // 数量低字节 ushort crc = Crc16Calculator.Compute(frame, 2, frame.Length - 2 - 2); frame[8] = (byte)(crc & 0xFF); // CRC低字节在前 frame[9] = (byte)(crc >> 8); return frame; }

帧长度为什么是10?帧头2字节加设备地址1字节加功能码1字节加起始地址2字节加数量2字节,这已经8字节了,再算上尾部CRC两字节刚好10字节。格式里的数据长度字段在这里没体现,因为这个协议采用固定长度请求帧,而不是每个请求都带长度描述。如果你的设备协议要求带长度字段,在上面的代码中间加两个位置即可,逻辑一样。

startAddr是寄存器地址。注意不同厂商对地址的基准定义不同,有的从0开始,有的从1开始,还有的文档里写的地址是十六进制而代码用十进制,对照手册换算时格外小心。单次读取数量限制在125以内,是因为很多PLC协议规定单帧数据区最大长度,超过这个值设备会直接报错。

3.3 串口数据接收与帧切割

串口通讯的一个事实是:调用SerialPort.Read读回来的数据,不保证一次就是一个完整帧。可能一次读回来半个帧,也可能一次读回来三个帧粘在一起。所以接收侧必须要做帧切割。源码里用一个List缓冲区积累字节,每来一批数据先追加,再进入循环尝试提取帧。

private readonly List<byte> _buffer = new List<byte>(); private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int available = _port.BytesToRead; byte[] chunk = new byte[available]; _port.Read(chunk, 0, available); _buffer.AddRange(chunk); while (TryExtractFrame(out byte[] frame)) { OnFrameReceived(frame); } }

事件处理器先尽量读完当前串口缓冲区,再把字节追加到累积缓冲区,最后循环切帧。这样做的好处是把“网络字节流”转换成“消息帧”的过程集中在一个方法里,解析逻辑不必关心底层分片。

TryExtractFrame是核心:

private bool TryExtractFrame(out byte[] frame) { frame = null; if (_buffer.Count < 6) return false; // 起码的帧骨架长度 if (_buffer[0] != FrameHead[0] || _buffer[1] != FrameHead[1]) { _buffer.RemoveAt(0); // 非法字节丢掉,继续找帧头 return false; } int dataAreaLen = (_buffer[2] << 8) | _buffer[3]; // 按协议取数据区长度 int totalLen = 4 + dataAreaLen + 2; if (_buffer.Count < totalLen) return false; // 半帧,等下一批数据 frame = _buffer.Take(totalLen).ToArray(); _buffer.RemoveRange(0, totalLen); return true; }

这里有个细节值得注意:当数据不足一个完整帧时直接return false,剩下的字节留在_buffer里等下一次DataReceived事件。很多初写者会在收到半帧时把缓冲区清掉,结果设备响应稍慢一点就丢帧。还有,当帧头匹配失败时,这里每次只移除一个字节,宁可多循环几次也不能直接清空,因为帧头后面的数据可能正好是下一帧的起点。

3.4 响应帧解析与数据类型转换

请求发出去,设备会回一个响应帧。解析时先做合法性检查,再摘出数据区,最后按高低字节拼成寄存器值。响应帧的数据区包含返回的寄存器字节,一般的协议里每个寄存器占两个字节。

public int[] ParseReadHoldingsResponse(byte[] frame) { if (frame.Length < 8) throw new InvalidFrameException("响应帧长度不足"); ushort calcCrc = Crc16Calculator.Compute(frame, 2, frame.Length - 2 - 2); ushort recvCrc = (ushort)(frame[frame.Length - 2] | (frame[frame.Length - 1] << 8)); if (calcCrc != recvCrc) throw new CrcMismatchException("响应CRC校验失败"); int dataLen = (frame[2] << 8) | frame[3]; int registerCount = dataLen / 2; int[] registers = new int[registerCount]; for (int i = 0; i < registerCount; i++) { int offset = 4 + i * 2; registers[i] = (frame[offset] << 8) | frame[offset + 1]; } return registers; }

解析的第一步永远是CRC校验,这能挡住大部分物理层干扰导致的脏数据。校验通过后,按数据区长度除以2得到寄存器个数,然后逐个拼值。拼接方向是高位在前低位在后,也就是大端序。如果你的PLC协议用小端序,把循环里的移位反一下即可。

这个代码片段顺手展示了一个工程习惯:把解析结果直接映射为int数组,上层拿到的就是这个数组,不必关心字节怎么拼的。数据转换的职责还可以继续下放,比如寄存器原始值到物理工程量的换算,放在业务层做更合适,协议层保持纯粹。

3.5 发送串行化与超时重试

串口设备本身是半双工的,同一时刻只允许一个请求在途。如果上位机有多个线程同时调用读写方法,必须保证发送是串行的,否则两个线程的字节会穿插在一条物理链路上,设备收到的帧直接错乱。

private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); public async Task<byte[]> SendAndWaitAsync(byte[] frame, int timeoutMs, CancellationToken token) { await _sendLock.WaitAsync(token); try { _port.DiscardInBuffer(); _port.Write(frame, 0, frame.Length); Task<byte[]> respTask = _pendingResponse.Task; Task completed = await Task.WhenAny(respTask, Task.Delay(timeoutMs, token)); if (completed != respTask) throw new TimeoutException($"设备{_deviceAddr}响应超时"); return await respTask; } finally { _sendLock.Release(); } }

发送前调用DiscardInBuffer清理串口接收缓冲区,是为了防止上一次通讯残留的脏数据被误当成当前帧的响应。SemaphoreSlim在这里充当互斥锁,确保并发调用时只有拿到锁的线程能真正写串口。超时用Task.Delay做竞速,谁先完成走谁,简单直接。

重试策略上,源码里采用最大三次重试,每次重试间隔递增,避免设备一卡就疯狂刷请求把PLC通讯模块打挂。这个策略在工业现场很重要——你在这里下手轻一点,后面的设备寿命会长一点。

4. 从编译到连通:项目运行与现场联调步骤

4.1 环境准备与配置初始化

运行环境用Visual Studio 2022,目标框架用的.NET 6,如果你还在维护老工控机,源码也兼容.NET Framework 4.7.2,把目标框架切换一下重新编译即可。整个解决方案不依赖NuGet第三方包,串口用的System.IO.Ports,这部分在.NET框架里是自带能力。

连接参数全部放在配置文件里,Demo项目用的是appsettings.json:

{ "Plc": { "LinkMode": "Serial", "SerialPort": "COM3", "BaudRate": 9600, "DataBits": 8, "StopBits": "One", "Parity": "None", "DeviceAddress": 1, "ReadTimeoutMs": 1000, "RetryCount": 3 } }

参数说明:LinkMode填Serial走串口,填Tcp走以太网;SerialPort是串口号,可以在设备管理器里确认;BaudRate、DataBits、StopBits、Parity四项必须和PLC侧通讯参数完全一致,任何一个不匹配都收不到任何数据;DeviceAddress是PLC的站号或者叫设备地址;ReadTimeoutMs是单次请求超时,工业现场一般500到1500毫秒比较合理,设太短设备慢半拍就误报超时,设太长故障感知又太慢。

4.2 串口链路联调步骤

串口联接时先在设备管理器里确认COM口号,插上USB转RS485线后系统会分配一个COM口,一般是COM3到COM9之间。如果COM号超过10,有些老设备的驱动识别会有问题,建议在设备管理器里手动改成较小的COM号。

接下来设置PLC侧串口参数。用PLC厂家编程软件连上设备,找到通讯设置页,把波特率、数据位、停止位、校验位跟appsettings.json里保持一致。这一步最容易忽略的是停止位和校验位,很多PLC出厂默认是停止位1、无校验,但也有部分设备默认是停止位2,对接前必须看一眼实际配置。

然后是设备地址确认,PLC面板或编程软件里都能读到。一个串口总线上挂多台PLC时,地址必须唯一,地址重复会导致数据混乱。

最后启动Demo,观察控制台输出。正常情况下应该看到周期性的十六进制请求帧和响应帧,数据区数值稳定。如果只发不收,先用串口调试助手抓一下总线上有没有设备回帧,有回帧说明物理链路通,问题在解析;没有回帧说明请求帧本身可能没组对,先查CRC和地址。

4.3 以太网链路联调

以太网联调的关键参数是IP地址、端口号和PLC侧连接许可。把LinkMode改成Tcp,再补上IP和端口配置:

{ "Plc": { "LinkMode": "Tcp", "IpAddress": "192.168.0.20", "TcpPort": 2001, "DeviceAddress": 1, "ReadTimeoutMs": 1000, "RetryCount": 3 } }

用ping命令确认上位机到PLC的网络通不通。多数PLC默认会开放固定的通讯端口,具体数值以设备手册为准。如果上位机装了多个网卡,需要确认路由有没有走对网段,这种问题在工程现场非常常见,一张办公网一张工业网,数据就跑到错误网卡上去了。

以太网链路还需要注意防火墙。Windows防火墙默认会拦陌生端口的入站连接,如果程序发送后一直没有响应,先在命令行里关掉防火墙测试一把,通了再把程序目录加入白名单。

4.4 验证连通性的三个现场信号

联调是否成功,不能只靠“收到数据”来判。我一般看三个信号:请求帧周期稳定、响应帧CRC全过、寄存器数值符合工艺逻辑。

请求帧周期稳定看的是轮询调度有没有抖动,抖动大会导致设备端通讯缓冲区堆积。响应帧CRC全过说明链路层没有丢字节错位。寄存器数值符合工艺逻辑这条容易被忽视——比如温度是负值但读出来是超大正数,八成是符号位没处理,而不是通讯没通。

源码里PlcLogger会把每次收发的帧以Hex字符串打到日志文件,格式类似Tx: 01 FE 01 04 00 00 00 01 84 0A。联调出问题时把这个日志跟通讯手册里的示例帧对齐比对,一眼就能看出是哪一段不对。

5. 现场排查避坑:帧、串口、线程三类高发问题

5.1 设备从不回帧,CRC高低字节顺序反了

现象:请求帧发出后设备像没收到一样,串口调试助手也看不到任何响应。用逻辑分析仪看总线,数据确实发出去了,但设备就是沉默。

原因:CRC计算结果发送时高低字节放反了。计算出来的是0x0A84,发送时却先发了0x0A再发0x84,设备按自己的校验逻辑算出来不匹配,直接丢弃了整帧。

解决:查发送组帧代码,确认CRC低字节在前发送,即frame[8] = (byte)(crc & 0xFF); frame[9] = (byte)(crc >> 8);。如果你对照的是以高位在前为标准的手册,把这两行反过来。接不同设备时先看手册里示例帧的校验字节顺序,再决定怎么拼。

5.2 响应帧时有时无,串口缓冲区半帧没处理干净

现象:设备有时回帧有时不回,回的时候偶尔解析报错,日志里能看到不完整的Hex串。

原因:串口DataReceived事件触发时机不固定,数据分片到达时缓冲区里只有一个半帧。如果代码在数据不足时清空了缓冲区,后半段字节就丢了,对应这一次响应永远拼不完整。

解决:接收缓冲区积累数据后先循环尝试切帧,切不出完整帧时保留剩余字节等待下次事件,绝对不能Clear。同时要考虑残留脏数据问题,长时间找不到帧头时应该触发一次缓冲区重置,避免垃圾数据一直堆积。

5.3 多线程并发写串口导致帧字节穿插

现象:两台设备并发采集时,串口总线上出现完全不存在的帧,设备响应错乱。

原因:多个工作线程同时调用SerialPort.Write,两个帧的数据在驱动层被交错发送,设备收到的字节顺序全部错位。

解决:所有发送操作走同一个SemaphoreSlim互斥锁,或者维护一个发送队列由单一线程消费。我在源码里采用的是前者,锁开销小实现也直观。对于以太网链路同样要加锁,TCP虽然全双工,但同一时刻只允许一个请求在途是PLC通讯的基本原则。

5.4 轮询间歇性中断,设备侧通讯模块被频繁重试打挂

现象:设备正常运行一段时间后通讯突然中断,断电重启又能恢复,过一段时间再次中断。

原因:上位机在设备没有响应时立即重试,短时间内请求洪水把设备通讯模块的缓冲打满,设备进入异常状态,上位机感知到的就是越重试越没反应。

解决:重试采用退避策略,第一次超时后等100毫秒,第二次等300毫秒,第三次等1秒,达到上限后进入离线状态,不再主动请求,等待人工介入或下一个轮询周期。源码里RetryCount和超时参数都放配置文件里了,现场调整不需要重新编译。

5.5 寄存器值解析出来全是0,数据区长度与字节序不匹配

现象:通讯正常,CRC通过,但读回来的值恒为0,把设备里的寄存器改成非零值还是读成0。

原因:寄存器地址偏移算错,读的根本不是目标地址。另一个常见原因是设备手册里地址的单位是字节,而程序里按字计算,起始地址偏了一位。

解决:先读一个已知值去反推地址。比如设备里某个温度寄存器实际值是25,换几个地址试读,直到返回值吻合。我一般会在协议解析里加一个“原始值转物理量”的映射层,把地址、倍率、偏移写在配置表里,调试时改配置不用改代码。

6. 二次开发:把通讯库改造成通用数据采集中间件

6.1 协议层与业务层彻底分离

源码里PlcProtocol已经封装了ReadHoldings、WriteRegister这些方法,二次开发时尽量保持这个边界。业务侧只依赖PlcProtocol暴露的方法,不直接碰字节数组,这样换PLC型号时只改Protocol内部实现,上层采集逻辑不动。

6.2 点位表驱动采集

把寄存器地址、数量、数据类型、倍率、轮询周期放进配置表,运行时加载为点位对象集合。改造后新增采集点只改JSON配置,不用动代码。

public class PointConfig { public string Name { get; set; } public ushort Address { get; set; } public string DataType { get; set; } // Int16 / UInt16 / Float32 }

6.3 对接MQTT与时序数据库

采集到的数据先进入PlcDataBuffer,再由独立线程推送到MQTT Broker或写入时序数据库。这样PLC通讯线程只负责收发,数据分发不阻塞采集链路。

6.4 验证方式

改造后先跑单元测试验证协议组帧解析没回归,再用模拟从站工具做全点位测试,最后接真实PLC试运行一个轮询周期对比原始日志。

我从那次做完PLC对接之后,每次接新设备都强制自己先画帧结构表、对CRC字节序、加发送锁,这三步走完,联调基本一次通过。希望帮到你,祝你少踩两个坑。

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

返回列表