简介:这份资源是面向工业自动化上位机开发者的 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建议设 300~1000 毫秒,产线短帧响应通常几十毫秒够,网络跨交换机时放宽到 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 List<byte>(); 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 ms |
4.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 毫秒间隔。
可观测性是最容易被忽略、又最省事的一项:把每次请求的耗时、响应码、重试次数记下来,落到本地日志或者内存环形缓冲,界面上做一个简单的成功率显示。现场出问题时,这些数字能直接告诉你到底是链路抖动还是协议解析错误,省掉大量「复现不了」的扯皮。
验证方法建议固定成三步:先在办公室里用已知值回环测试协议解析,确认字节序和地址映射无误;再去现场只接一台设备跑一小时看成功率;最后接入正式采集服务观察资源占用。跨过任何一步直接上产线,出问题的概率都会明显上升。
有一次我为了赶进度跳过了第一步,直接把改过的字节序推到现场,结果所有温度值都显示成负五十几,排查了半天才发现是双字顺序的问题——从那以后我每次改完转换层,都强制先用固定值做一遍回环校验,再碰真实设备。希望这份拆解能帮你少走这一趟弯路。
本文还有配套的精品资源,点击获取