资讯详情 LS PLC通讯协议C#源代码:串口/以太网寄存器读写实战
📅 2026/10/30 6:57:25
简介这份资源是面向工业自动化上位机开发者的 C# 与 LS PLC 通信示例工程适合已具备 C# 基础、希望打通 PC 与 PLC 数据交互链路的开发者参考。压缩包共 52 个文件约 102KB以 cs 源代码与 sln 解决方案文件为核心辅以 exe 可执行程序、resx/resources 界面资源、pdb 调试符号及 settings 配置、txt 说明等构成从源码到可运行程序的完整样例便于对照理解通信初始化、数据收发与解析的组织方式。资源已有 583 人学习说明其在同类通信实践中有一定参考价值。读者可从中获取串口与以太网两种通信路径的代码骨架理解波特率、校验位、IP 与端口等参数配置以及 PLC 寄存器映射和读写函数的封装思路并借助样例软件快速验证连通性与数据点对应关系作为自建监控采集程序的起点。1. 从一台停机的产线说起这套 LS PLC 通讯 C# 源代码到底解决什么问题产线上 PLC 突然报警停机维护工程师拎着笔记本到现场发现原厂上位机装不上、驱动不兼容手上只有一个 C# 项目能读寄存器——这时候能救急的不是多高深的技术栈而是一份能直接编译、能改地址、能读懂字节序的通讯代码。这份「ls plc 通讯协议 c# 源代码」项目的定位就在这里面向 LS 系列 PLC 的上位机通讯实现用 C# 把协议组帧、串口或以太网收发、寄存器读写封装成可调用的类库让上位机、数据采集程序、工位测试软件有一个可改的起点。适合谁一类是做设备层上位机的 C# 开发者一类是需要把 PLC 数据接进 MES 或数据库的集成人员还有一类是学生或转岗工程师想弄明白 PLC 通讯协议到底怎么一层层拼接出来。它不属于「开箱即用的成品软件」更接近一份工程骨架通讯链路的部分能跑协议解析的部分能读业务层的对接要按你的现场补。C# 在这个场景里的优势是串口和 Socket 库都成熟调试时挂个断点就能看到收发字节比用脚本语言抓包更直观。LS 系列 PLC 有自己的专用协议约定不是标准 Modbus地址表达、功能码、校验方式都得按它的规矩来这也是这类代码最容易被卡住的地方。我在现场见过不少人拿着一份 Modbus 代码去连 LS 的 PLC连了一下午读回来全是异常码最后还是回到原始协议文档重新对着字节一遍遍调。2. 架构与目录拆解从通讯层到业务层代码是怎么分层的2.1 分层思路为什么把协议帧和通讯链路拆开这份代码的第一层设计判断是把「怎么把字节送出去」和「这些字节是什么意思」分开。听上去像常识但实际项目里最常见的写法是一个按钮事件里从开串口一路写到解析结果改一个地址就要翻两百行。拆开的好处是串口换成以太网时业务层不用动协议地址变时链路层不用动。常见的分层结构大致是这样通讯层串口封装SerialPort或 TCP 客户端TcpClient只负责连接建立、超时、收发原始字节。协议层负责组帧站号、命令、地址、数据长度、校验和拆帧响应码、数据区提取。数据层负责字节序转换、字/双字/位地址的映射把裸字节翻译成工程值。应用层读写封装、批量轮询、异常重试供界面或采集服务调用。我一般会把这四层分别放在独立目录里命名直接一点就行比如Communication、Protocol、DataConvert、Services。这样新人接手时不用猜哪个文件是干什么的。2.2 典型目录结构没有原始目录清单的项目按这种规模最常见的组织方式是这样的LsPlcDemo/ ├── Communication/ │ ├── ISerialTransport.cs # 串口与网络的统一接口 │ ├── SerialTransport.cs # SerialPort 实现 │ └── TcpTransport.cs # TcpClient 实现 ├── Protocol/ │ ├── LsFrameBuilder.cs # 请求帧组包 │ ├── LsFrameParser.cs # 响应帧解析 │ └── LsCommand.cs # 命令/功能码常量 ├── DataConvert/ │ ├── ByteOrderConverter.cs # 大小端与字序交换 │ └── AddressMapper.cs # %MW、%MX 等地址换算 ├── Services/ │ └── PlcClient.cs # 对外统一读写入口 └── App/ └── MainForm.cs # 调试界面这个结构的价值在于你拿到代码后先看PlcClient.cs就知道对外能干什么再往下看LsFrameBuilder就知道帧怎么拼最后看SerialTransport才知道底层怎么发。阅读顺序和调用顺序一致比按文件名首字母翻要快得多。2.3 统一传输接口串口和以太网只换一个实现把链路抽象成接口是这套代码能扩展的前提。写在协议层里的代码不应该知道数据是从串口来的还是从网线来的。public interface IPlcTransport { bool IsOpen { get; } int ReadTimeoutMs { get; set; } void Open(); void Close(); // 发送完整请求帧返回原始响应字节 byte[] Send(byte[] request); }逻辑说明Send接收已经组好的完整帧不做任何协议解释返回的也是裸字节超时失败直接抛异常由上层决定重试还是报错。参数上ReadTimeoutMs建议设 3001000 毫秒产线短帧响应通常几十毫秒够网络跨交换机时放宽到 1000 毫秒更稳。串口实现里几个参数是血泪经验波特率、数据位 8、停止位 1、校验位要与 PLC 侧配置严格一致任何一项不匹配的表现都是「能连上、发出去、一条回包都没有」很容易误判成协议错了。public class SerialTransport : IPlcTransport { private readonly SerialPort _port; public SerialTransport(string portName, int baudRate) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.ReadTimeout 500; _port.WriteTimeout 500; } public bool IsOpen _port.IsOpen; public int ReadTimeoutMs { get _port.ReadTimeout; set _port.ReadTimeout value; } public void Open() _port.Open(); public void Close() _port.Close(); public byte[] Send(byte[] request) { _port.DiscardInBuffer(); // 清掉上一轮残留避免帧错位 _port.Write(request, 0, request.Length); // 先读固定响应头再按长度字段读剩余部分避免一次性读空 byte[] header ReadExact(6); int bodyLen header[4] | (header[5] 8); byte[] body ReadExact(bodyLen); return header.Concat(body).ToArray(); } private byte[] ReadExact(int count) { byte[] buf new byte[count]; int got 0; while (got count) { int n _port.Read(buf, got, count - got); if (n 0) throw new TimeoutException(响应中断); got n; } return buf; } }这里的ReadExact是关键串口一次Read不保证读满直接Read(buf, 0, 1024)然后解析在干扰大的现场会间歇性截断表现为「偶尔成功一次多数报校验错」。先读头再读体、循环补齐比设一个足够长的等待时间可靠得多。3. 协议帧实战组包、校验与寄存器解析的完整链路3.1 请求帧怎么拼站号、命令、地址、长度逐段落位LS 协议帧的基本结构可以理解为「头部固定字段 命令码 地址 长度 数据 校验」。不同型号在细节上有差异但思路一致接收方靠固定头部判断帧起点靠长度字段知道帧尾在哪靠校验字段判断有没有被干扰。public static class LsFrameBuilder { // 组读请求帧stationId 站号cmd 命令码address 起始地址wordCount 读取字数 public static byte[] BuildReadRequest(byte stationId, byte cmd, ushort address, ushort wordCount) { var frame new Listbyte(); frame.Add(stationId); // 站号多机组网时区分设备 frame.Add(cmd); // 功能码读/写由这里决定 frame.Add((byte)(address 0xFF)); // 地址低字节在前 frame.Add((byte)(address 8)); frame.Add((byte)(wordCount 0xFF)); // 读取长度 frame.Add((byte)(wordCount 8)); byte checksum 0; foreach (var b in frame) checksum b; // 常见做法是累加和校验 frame.Add(checksum); return frame.ToArray(); } }逻辑说明这个函数只做组装不做发送所以单元测试能脱机跑。参数上address用的是协议电平的寄存器地址不是你界面上的标签名地址换算单独放在AddressMapper里wordCount一次不要贪多多数型号单帧读取上限在几十个字实测超过设备承载范围会直接无响应而不是返回错误码这就很难查。文档里对命令码的取值往往按型号分表我给的建议是先把LsCommand定义成常量类别在调用处写魔数public static class LsCommand { public const byte ReadWord 0x52; public const byte WriteWord 0x57; public const byte ReadBit 0x62; public const byte WriteBit 0x63; }提示具体的命令码和帧头字节必须以你手上那台 PLC 的通讯手册为准不同系列不完全通用代码里的值只作结构示范。3.2 校验与响应判读异常码比数据更需要先看响应帧回来之后第一件事不是取数据而是判断响应码。很多现场问题是「读回来的数值明显不对」实际上 PLC 已经明确回了错误原因只是代码跳过了那一段直接按数据解析把错误码当成了第一个寄存器值。public static class LsFrameParser { // 返回解析出的数据区失败抛异常并带上响应码 public static ushort[] ParseReadResponse(byte[] response, int expectedWords) { if (response null || response.Length 4) throw new InvalidDataException(响应长度不足); byte station response[0]; byte status response[2]; // 响应状态字节位置随型号而定 if (status ! 0x00) throw new InvalidOperationException($PLC 返回异常状态码 0x{status:X2}); int dataStart 4; if (response.Length dataStart expectedWords * 2) throw new InvalidDataException(数据区不完整检查长度字段); var words new ushort[expectedWords]; for (int i 0; i expectedWords; i) { words[i] (ushort)(response[dataStart i * 2] | (response[dataStart i * 2 1] 8)); } return words; } }逻辑说明状态字节判断放在数据解析前面报错信息里带上原始状态码现场查手册能直接对号入座。数据拼装用的是小端序低字节在前如果结果出现「整块数据高低位互换」的规律十有八九是字节序或字序搞反了这时候不要急着改协议代码先确认 PLC 侧数据区的存储顺序。参数expectedWords与请求时的wordCount必须一致建议由调用方传入而不是从响应长度推算避免设备少回一段时静默读错值。3.3 对外统一入口PlcClient 怎么包住读写与重试业务层不该关心帧结构所以给一个薄薄的客户端类把连接管理、重试和数据类型转换收进来。public class PlcClient : IDisposable { private readonly IPlcTransport _transport; private readonly byte _stationId; private const int MaxRetry 2; public PlcClient(IPlcTransport transport, byte stationId) { _transport transport; _stationId stationId; } public ushort[] ReadWords(ushort address, ushort count) { var request LsFrameBuilder.BuildReadRequest(_stationId, LsCommand.ReadWord, address, count); Exception last null; for (int i 0; i MaxRetry; i) { try { var response _transport.Send(request); return LsFrameParser.ParseReadResponse(response, count); } catch (TimeoutException ex) { last ex; Thread.Sleep(50); // 短间隔重试给设备恢复时间 } } throw new IOException($读取地址 {address} 失败, last); } public int ReadInt32(ushort address) { var words ReadWords(address, 2); return words[0] | (words[1] 16); // 双字按字序拼接 } public void Dispose() _transport.Close(); }逻辑说明只对超时做重试异常码不重试——PLC 明确告诉你地址越界了再发十次也是同样的结果。Thread.Sleep(50)是给设备留一点缓冲我在现场见过重试发得太急导致设备干脆连丢几帧的情况。ReadInt32里展示的是双字拼接注意这里假设高字在后如果你的设备是高字在前就是两个元素的顺序对调这个细节建议在注释里写死不要靠记忆。4. 跑起来从零编译、连上设备到读到第一个值4.1 环境准备与工程配置C# 这块要求不高能装 .NET Framework 4.6 以上或 .NET 6/8 的运行环境就行开发用 Visual Studio 2022 或 Rider 都可以。如果源码是经典的窗体应用直接开.sln编译如果是单个.cs文件堆在一起就自己建一个控制台项目把它们加进去改一下命名空间。几个配置项要先确认项目属性的目标框架与现场机器一致产线电脑上往往只装了较老的运行时串口相关代码在 .NET 中需要引用System.IO.Ports.NET 6 以后这个包要单独通过 NuGet 引入不引就编译不过报的是「找不到 SerialPort 类型」别去怀疑代码。连接参数建议先写在一个配置文件或者界面输入框里不要硬编码在类构造函数里参数常见取值说明端口名COM3设备管理器里查插拔 USB 转串口后可能变化波特率9600 / 19200 / 38400必须与 PLC 侧设定完全一致数据位/停止位8 / 1大多数场景固定校验位None与 PLC 侧参数对应站号1单机一般就是 1超时500 ms干扰大时放宽到 1000 ms4.2 一段最小可运行代码不要一上来就接界面先用控制台打一枪确认链路和协议通了再往上盖界面。class Program { static void Main() { // 串口方式换成以太网时只替换这一行 IPlcTransport transport new SerialTransport(COM3, 9600); transport.Open(); using var client new PlcClient(transport, stationId: 1); try { ushort[] data client.ReadWords(0x0000, 4); // 从地址 0 读 4 个字 for (int i 0; i data.Length; i) Console.WriteLine($D{i}: {data[i]}); } catch (Exception ex) { Console.WriteLine(通讯失败 ex.Message); } finally { transport.Close(); } } }逻辑说明先开链路、再建客户端、读一小段、打印、关闭。读 4 个字是为了减少变量出问题时能一眼看出是全部失败还是部分错位。Console.WriteLine打印原始值而不是转换后的工程值因为第一步要验证的是通讯正确性工程值换算要等通讯稳定之后再叠加两层问题混在一起排查成本会翻倍。跑通之后的验证方式很朴素在 PLC 编程软件里给某个寄存器强制写一个已知值比如 1234看控制台是不是原样读回来。能对上说明帧结构、字节序、地址映射都对对不上但要差 2 的倍数多半是字节序完全没响应回到上一章的串口参数和接线去查。4.3 换成以太网要改哪几行不少现场的 PLC 支持网口通讯这份代码把链路抽象出来之后切换成本很低。public class TcpTransport : IPlcTransport { private TcpClient _client; private NetworkStream _stream; public TcpTransport(string ip, int port) { /* 保存参数 */ } public void Open() { _client new TcpClient(); _client.Connect(ip, port); // 连接失败会抛 SocketException _client.ReceiveTimeout 1000; _stream _client.GetStream(); } public byte[] Send(byte[] request) { _stream.Write(request, 0, request.Length); byte[] header new byte[6]; ReadExact(header); int bodyLen header[4] | (header[5] 8); byte[] body new byte[bodyLen]; ReadExact(body); return header.Concat(body).ToArray(); } // ReadExact 与串口版本思路一致循环读满 }逻辑说明TCP 是流式协议一次Read可能只拿到半帧甚至两帧粘在一起所以同样要走「读满固定长度」的逻辑不能假设发一次收一次。参数上ReceiveTimeout设 1000 毫秒比较合适跨交换机网络的抖动比串口大。这里没有写ReadExact的完整实现跟串口版本几乎一样只是数据源换成NetworkStream。5. 避坑与排查这几处翻车我见得最多5.1 能发不能收一条回包都没有现象程序不报错串口发送计数在涨但读取一直超时抓包看不到任何回包。原因绝大多数是串口参数不匹配波特率、校验位或者接线把收发接反也有少部分是站号写错PLC 收到帧但判断不是给自己的静默丢弃。解决先用 PLC 编程软件的在线监控确认串口参数再单独测收发线站号先固定为 1 做单机验证。判断有没有收到半帧可以在ReadExact里加日志打印已读字节数能收到几个字节但凑不满说明帧结构里的长度字段理解错了。5.2 读回来的数值整块高低位颠倒现象PLC 里是 1234读回来是 53764或者浮点数读出来是天文数字。原因字节序或字序处理反了。单字看的是字节内顺序双字和浮点数还多一层「两个字谁在前」的问题不同型号、不同数据区可能不一样。解决不要盲改先读一个已知值把收到的原始字节全打出来人工比对确定是单字节对调还是字对调浮点数用BitConverter.ToSingle转换前先确认字节数组顺序必要时先Array.Reverse再转。我一般会在转换层留一个开关方便现场快速试。5.3 短时间高频轮询后通讯整体卡死现象单次读取正常加了定时器 50 毫秒轮询后跑几分钟就大量超时重启程序又好了。原因请求发得比设备处理得快。PLC 的通讯任务优先级不高扫描周期内被挤掉就丢帧串口缓冲没清干净时还会出现上一帧的尾巴混进下一帧导致校验永远不过。解决轮询周期不要低于 100 毫秒读多个地址时合并成一次批量读而不是循环单读每次发送前清输入缓冲代码里那行DiscardInBuffer就是这个作用。并发上一个 PLC 连接只用一个发送线程别多个线程抢同一个串口。5.4 现场好用换台电脑就报端口占用现象开发机一切正常部署到产线电脑上程序一启动就抛「拒绝访问 COM 口」。原因端口名变了或者有别的软件老版本组态软件、串口调试助手还占着这个口USB 转串口线换了插口编号会跳。解决程序里做端口枚举把SerialPort.GetPortNames()的结果放进下拉框让用户选不要硬编码 COM3关闭程序时确保Dispose被调用异常路径也要走finally关闭否则进程残留会锁住端口。6. 二次开发把这份骨架改成你自己现场能用的东西拿到这类代码下一步通常不是重写而是加三样东西配置化、批量化和可观测性。配置化是说把站号、端口、地址表从代码里挪出去。我一般用一个 JSON 或 INI 维护地址映射键是业务名、值是协议地址和数据类型采集层按配置循环读取。这样现场加一个测点不用重新编译发版改动成本从「改代码、走发布流程」降到「改配置、重启服务」。地址表结构大致如下{ station: 1, points: [ { name: 主轴转速, address: 100, type: word }, { name: 累计产量, address: 200, type: dword }, { name: 运行状态, address: 300, type: bit, bit: 3 } ] }批量化指相邻地址合并成一次请求。上面三个点如果地址相近完全可以一次读几十个字再按偏移切分请求次数从每天几十万降到几千现场稳定性提升很明显。实现上给PlcClient加一个ReadBlock内部按最大帧长切块块与块之间留 20 毫秒间隔。可观测性是最容易被忽略、又最省事的一项把每次请求的耗时、响应码、重试次数记下来落到本地日志或者内存环形缓冲界面上做一个简单的成功率显示。现场出问题时这些数字能直接告诉你到底是链路抖动还是协议解析错误省掉大量「复现不了」的扯皮。验证方法建议固定成三步先在办公室里用已知值回环测试协议解析确认字节序和地址映射无误再去现场只接一台设备跑一小时看成功率最后接入正式采集服务观察资源占用。跨过任何一步直接上产线出问题的概率都会明显上升。有一次我为了赶进度跳过了第一步直接把改过的字节序推到现场结果所有温度值都显示成负五十几排查了半天才发现是双字顺序的问题——从那以后我每次改完转换层都强制先用固定值做一遍回环校验再碰真实设备。希望这份拆解能帮你少走这一趟弯路。本文还有配套的精品资源点击获取