
简介面向工业自动化上位机开发者的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 Listbyte _buffer new Listbyte(); 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 Taskbyte[] SendAndWaitAsync(byte[] frame, int timeoutMs, CancellationToken token) { await _sendLock.WaitAsync(token); try { _port.DiscardInBuffer(); _port.Write(frame, 0, frame.Length); Taskbyte[] 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字节序、加发送锁这三步走完联调基本一次通过。希望帮到你祝你少踩两个坑。本文还有配套的精品资源点击获取