简介面向工业自动化上位机开发者的 LS PLC 通信 C# 示例工程适合已具备 C# 基础、希望打通上位机与控制器数据链路的开发者参考。内容围绕 LS 产电 PLC 的通信实现展开涵盖串口RS-232/RS-485与以太网两种链路下的参数配置、连接建立、寄存器读写与数据解析思路示例代码可直接作为项目模板改造。压缩包共 52 个文件、约 102KB以 cs 源码、sln 解决方案与 csproj 工程文件为主配有 resx 资源、settings 配置、编译产物 exe/pdb 及少量 txt 说明完整保留了可运行的工程结构便于对照调试。已有 583 人学习下载。读者可从中获得通信函数封装与调用范例、协议报文收发的基本处理流程以及读写 PLC 数据点时的排错思路适合作为对接 LS PLC 的起步参考。1. 一次读数差 256 倍LS PLC 通讯协议为什么在字节序上翻车某次调试一台 LS PLC 的%MW100里明明放着 1上位机用 C# 读回来却是 256。代码翻来覆去没毛病问题出在两个字节的排列顺序上报文里天然是大端落到 C# 的short上就变成了 256。这类问题在 LS PLC 通讯协议的实现里极其典型——协议本身不复杂一套二十字节固定头加变长数据区的二进制帧,麻烦的是它把好几处约定藏在了字节里地址要以 ASCII 文本传、长度字段要大端写、位设备要按位打包。手上有 LS PLC 通讯协议 C# 源代码这类实现的人,往往能跑通读写,却说不清为什么这么拼帧,换一个设备类型就全线失败。搞懂这套帧的构造逻辑比记住几个命令字有用得多。2. 帧结构拆解为什么地址要写成 ASCII 字符串先把一帧完整拆开。LS PLC 走的是厂商自定义的二进制应用层协议物理层可以是串口也可以是以太网上的 TCP两种介质上跑的是同一套应用层帧区别只在串口多一层站号寻址和校验、TCP 靠连接本身定位对端。这意味着只要把帧的编排逻辑写对一次换介质只需要替换传输层帧构造代码几乎不用改。正是这个特性让帧结构的正确性成了整个实现的地基。地基歪了后面写的重连、缓存、批量优化全是空转。2.1 二十字节固定头与变长数据区一帧的骨架是「固定头 变长数据区 一字节尾」。固定头长度 20 字节字段顺序固定全部按大端书写数据区长度由头里的长度字段决定最后一个字节是固定结束符0x04。常见的字段划分如下偏移长度字段说明08Company ID固定 ASCIILSIS-XGT81CPU 类型与 PLC 型号相关本地 CPU 常填0x0091位置/槽号本地机架填0x00101站号串口多站时用于区分从站111保留填0x00122命令字两个 ASCII 字符如rW、wS142数据类型位、字节、字、双字或连续读162保留 / 错误状态请求帧填 0响应帧此处是错误码182数据长度数据区的字节数不含尾字节20n数据区地址 ASCII 数据或个数20n1尾固定0x04响应帧和请求帧的头部长度完全一致只有偏移 16 处的两个字节语义不同请求里是保留位响应里是错误状态。错误状态为 0 表示成功非 0 表示 PLC 侧拒绝了这次请求比如地址越界、设备类型不支持、站号不对。这个对称设计省掉了一套独立的响应解析器同一段读头代码能复用。用 C# 拼一帧连续读字写法大致如下const int HeaderSize 20; const byte TailByte 0x04; // 拼一帧「连续读字」请求 static byte[] BuildReadWordFrame(byte station, string address, ushort wordCount) { var addrBytes Encoding.ASCII.GetBytes(address.ToUpperInvariant()); // 数据区 地址 ASCII 2 字节的读取个数 int dataLen addrBytes.Length 2; var frame new byte[HeaderSize dataLen 1]; Encoding.ASCII.GetBytes(LSIS-XGT).CopyTo(frame, 0); // 公司标识写错一个字母 PLC 直接不响应 frame[8] 0x00; // CPU 类型 frame[9] 0x00; // 位置 / 槽号 frame[10] station; // 站号 frame[12] (byte)r; // 命令读 frame[13] (byte)W; // W 表示连续字连续位用 B WriteUInt16BE(frame, 14, 0x0014); // 数据类型连续读 WriteUInt16BE(frame, 18, (ushort)dataLen); // 长度只算数据区字节数 addrBytes.CopyTo(frame, 20); WriteUInt16BE(frame, 20 addrBytes.Length, wordCount); frame[^1] TailByte; return frame; } static void WriteUInt16BE(byte[] buf, int offset, ushort value) { buf[offset] (byte)(value 8); buf[offset 1] (byte)(value 0xFF); }WriteUInt16BE是必须自己写的因为BitConverter在 x86/ARM 上产出的是小端直接用它写长度字段会得到0x1400而不是0x0014PLC 会当成一个它没见过的类型码回一个错误状态或者干脆不响应。这类失败没有任何提示日志里只能看到「超时」是典型的黑匣子场景。2.2 地址编码%MW100为什么不能当数字传这是整套协议里最容易被误判的一处设计。地址不是「设备类型 偏移」的二进制结构体而是一整段 ASCII 文本原样塞进数据区。%MW100就是 6 个字节25 4D 57 31 30 30%MD100也是 6 个字节但%MW1000是 7 个字节。为什么要这么干一个合理的解释是设备地址空间本身不连续。%MW、%MD、%MB、%MX是彼此独立的分区%IW、%QW、%FW又是另外几套如果强行用二进制编码就得到处定义「设备类型码 分区码 偏移」一旦增加一个新分区就得改协议版本。用文本编码后新增分区只是多了一个字符串前缀PLC 侧的解析表加一行即可协议帧格式纹丝不动。代价也明显报文变长、需要解析、长度不固定调用方少写一个%或者把MW写成mw都可能被拒。C# 侧的标准做法是统一规范化再编码// 规范化为 PLC 认的地址格式后再转 ASCII static byte[] EncodeAddress(string address) { var s (address ?? string.Empty).Trim().ToUpperInvariant(); if (s.Length 0) throw new ArgumentException(address 不能为空); // 容错允许调用方传 MW100补上 % if (!s.StartsWith(%)) s % s; // 设备前缀必须是字母后面必须是纯十进制数字 int i 1; while (i s.Length char.IsLetter(s[i])) i; if (i 1 || i s.Length) throw new ArgumentException($地址格式非法: {address}); for (int k i; k s.Length; k) if (!char.IsDigit(s[k])) throw new ArgumentException($地址偏移必须是十进制数字: {address}); return Encoding.ASCII.GetBytes(s); }这段代码的价值不在编码本身而在把「错地址」的判定从 PLC 侧提前到调用侧。PLC 返回的错误状态只有一个笼统的非零值你分不清是地址不存在、类型不匹配还是长度超限而在本地拦下来异常信息里能直接带上原始字符串排查效率差一个量级。还有一处隐性约定数据区里地址和数据之间不加分隔符。解析方靠「地址字符集只包含%、字母和数字」这一点来判断地址何时结束后面紧跟的就是数据。有些实现在两者之间插一个0x00作为分隔这属于私有扩展跨厂商或跨型号时极容易翻车——对方不认这个字节会把它当成数据的第一位。2.3 长度字段与尾字节报文边界的唯一线索协议同时给了长度字段和结束符看起来冗余其实两者职责不同。长度字段是给接收方的定量依据尾字节是对帧完整性的最后确认。TCP 上尤其需要前者一次Receive可能只拿到半帧也可能一次拿到两帧半如果不看长度字段而去找0x04只要数据区里恰好出现一个字节等于 4整条流就错位了。所以正确的收包逻辑永远是先凑够 20 字节头从头里读出dataLen再凑够dataLen 1字节最后校验尾部是不是0x04。尾部校验失败说明流已经脏了唯一的后悔药是断开重连、丢弃所有缓存而不是跳过这一个字节继续找下一个0x04——那样只会越找越偏。注意长度字段只统计数据区字节数不包含 20 字节头和 1 字节尾。多算或少算一个字节接收方会一直等差值那么多个字节表现为「第一帧正常第二帧开始全乱」。3. 字节序与类型码为什么读回来的 1 变成了 256地址编码解决的是「问什么」字节序解决的是「答的是什么」。这一层出错时现象最有迷惑性通讯不报错、错误状态是 0、数据长度也对就是数值离谱。3.1 大端序在 C# 里的三处落点PLC 侧一律大端C# 运行环境一律小端转换点必须明确且唯一。文本编码解码本身不存在字节序问题容易漏掉的是三类数值第一类是头部里的长度字段偏移 18 的两个字节。写的时候要手动移位读的时候要用BinaryPrimitives.ReadUInt16BigEndian不能用BitConverter.ToUInt16。第二类是数据区里的单值内容。读一个 16 位字返回的两个字节是高位在前读一个 32 位双字四个字节也是高位在前。这里有个容易忽略的细节32 位值在 PLC 侧通常按「字交换」的方式存放也就是低 16 位字在前、高 16 位字在后字内部又是大端。直接BinaryPrimitives.ReadInt32BigEndian会得到错误结果需要先交换两个 16 位字再读。第三类是数据区里的个数参数。请求连续读 10 个字那个10也要大端写。这个是最容易忘的因为它是「参数」而不是「数据」写代码时注意力往往放在数据上。// 读 16 位字两个字节大端 static short ReadInt16BE(ReadOnlySpanbyte b, int off) (short)((b[off] 8) | b[off 1]); // 读 32 位双字先按字交换再大端拼 static int ReadInt32BE_WordSwap(ReadOnlySpanbyte b, int off) { // PLC 侧布局[低字高字节][低字低字节][高字高字节][高字低字节] int low (b[off] 8) | b[off 1]; int high (b[off 2] 8) | b[off 3]; return (high 16) | (low 0xFFFF); }ReadInt32BE_WordSwap里两行 8 |是字内部的大端最后的(high 16) | low是字之间的交换。写成一行BinaryPrimitives.ReadInt32BigEndian是最常见的错误症状是读 1000 得到 65536000看着像天文数字。3.2 位设备的打包与解包位设备的读取方式和字设备不同请求里给的是「起始位地址 位个数」返回的却是按位打包的字节数组每字节承载 8 个位低位在前。读 10 个位会返回 2 个字节第二个字节只有低 2 位有效高位是填充。// 把打包字节还原成 bool 数组bit0 是最低位 static bool[] UnpackBits(ReadOnlySpanbyte data, int bitCount) { var result new bool[bitCount]; for (int i 0; i bitCount; i) { int byteIdx i / 8; int bitIdx i % 8; if (byteIdx data.Length) throw new ArgumentOutOfRangeException(nameof(bitCount)); result[i] (data[byteIdx] (1 bitIdx)) ! 0; } return result; }打包方向搞反的话读%MX0到%MX7会得到一个完全镜像的结果——%MX0变成%MX7的值。这种错误在小数据量下极难发现因为单个位来回翻转时看起来是对的只有多个位同时为真、且值不对称时才暴露。写位时反过来先把 bool 数组打包成字节再按大端写长度。写入个数在数据区里同样是大端的两字节。还有一个边界要卡死连续读位一次不要超过设备手册允许的上限常见是 256 位即 32 字节超过会被 PLC 拒绝而错误状态不会告诉你是「数量超限」还是「地址越界」。3.3 类型码与命令字的组合规则命令字和数据类型是两个独立维度组合起来才决定实际行为。命令字决定方向读/写和粒度单个/连续类型码决定每个元素的宽度。命令字方向粒度配套类型码rS读单个0x0000位 /0x0001字节 /0x0002字 /0x0003双字rB读连续位0x0014rW读连续字0x0014wS写单个同上四种wB写连续位0x0014wW写连续字0x0014这张表里最容易出错的是连续读的0x0014。它不区分位和字具体读什么由命令字第二位的B/W决定。有人照着单个读的写法连续读字时填0x0002PLC 会按「读单个字」处理只返回 2 字节而调用方按wordCount * 2去解析直接越界。写操作的组合更绕一点wW0x0014时数据区是「地址 ASCII 数据本体」数据本体的字节数由前面的长度字段倒推而写入的元素个数并不显式给出——它是(dataLen - 地址长度) / 2算出来的。这就意味着写操作对地址长度的正确性极度敏感地址里多一个空格计算出的元素个数就少半个PLC 会静默截断或报长度错误。4. C# 传输层粘包、超时与并发串行化帧拼对了只是第一步。真实产线上网络抖动、PLC 扫描周期、上位机线程池调度三者叠加会让「每 100 ms 读一次」这种朴素写法在运行几小时后开始出现零星的超时和错帧。4.1 固定头 长度字段的收包循环NetworkStream.ReadAsync的返回值只是「这次拿到了多少字节」不保证是一次完整消息。稳妥的写法是先读满 20 字节头解析长度再读满剩余部分两次都用一个「读满为止」的辅助方法。static async Task ReadExactAsync(NetworkStream s, Memorybyte buf, CancellationToken ct) { int read 0; while (read buf.Length) { int n await s.ReadAsync(buf.Slice(read), ct).ConfigureAwait(false); if (n 0) throw new IOException(连接被对端关闭); // PLC 主动断连通常意味着上一帧格式非法 read n; } } async Taskbyte[] ReceiveFrameAsync(NetworkStream s, CancellationToken ct) { var header new byte[HeaderSize]; await ReadExactAsync(s, header, ct); // 头部第一件事校验公司标识脏流尽早丢弃 if (Encoding.ASCII.GetString(header, 0, 8) ! LSIS-XGT) throw new InvalidDataException(帧头非法流已失步); int dataLen BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(18)); var body new byte[dataLen 1]; await ReadExactAsync(s, body, ct); if (body[^1] ! TailByte) throw new InvalidDataException(尾字节校验失败); ushort error BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(16)); if (error ! 0) throw new PlcException(error); // 地址/类型/权限类错误走这里 var data new byte[dataLen]; Array.Copy(body, data, dataLen); return data; }这段代码有两个关键点。一是ReadExactAsync里n 0的处理Read返回 0 只表示对端正常关闭但在这个协议里PLC 主动断连往往是因为收到了格式非法的帧把它当作异常抛出而不是静默重连能让人看到真正的第一现场。二是帧头校验放在长度解析之前——如果流已经失步dataLen会是垃圾值可能是个几万的数照着它去分配缓冲区就是一次内存事故。4.2 一次请求到响应的时序与超时这个协议是严格的问答式一帧请求对应一帧响应没有主动上报没有序号字段。这意味着协议层无法区分「响应迟到」和「响应属于上一次请求」——一旦超时后重试而旧响应在重试之后才到达读到的就是上一个请求的结果。超时值不能拍脑袋。PLC 的扫描周期在几十毫秒量级加上网络往返正常往返通常在一百毫秒以内。把超时设在 500 ms 到 1 s 之间比较稳妥设得太短偶发的调度抖动就会触发重试重试又带来错配风险设得太长一次故障要等好几秒才会被感知。比「超时后重试」更稳的做法是超时即断连。因为协议没有事务号无法安全地复用一条已经可能残留脏数据的连接。断开重连的成本是几十毫秒握手换来的是流状态的确定性。// 超时后重建连接而不是在同一条流上重发 async Taskbyte[] TransactAsync(byte[] request, TimeSpan timeout) { using var cts new CancellationTokenSource(timeout); try { await _stream.WriteAsync(request, cts.Token); await _stream.FlushAsync(cts.Token); return await ReceiveFrameAsync(_stream, cts.Token); } catch (OperationCanceledException) { _needReconnect true; // 标记脏连接由连接管理层统一重建 throw new TimeoutException(PLC 响应超时); } }FlushAsync容易被省掉NetworkStream默认是无缓冲的所以通常没事但如果中间套了BufferedStream或者换成了别的实现不 flush 就等于请求根本没发出去。4.3 并发读写为什么必须串行化上位机里读 PLC 的线程往往不止一个画面刷新一路、报警检测一路、历史归档一路。如果这些线程共用同一个Socket两帧请求会在 TCP 流里交错PLC 按顺序解析时会立刻发现第一帧的尾巴不对然后断开连接。协议没有事务 ID所以不存在安全的并发。常见的做法有三种取舍不同方案实现成本吞吐上限适用场景全局SemaphoreSlim(1,1)最低等于单连接串行吞吐点数少、周期要求不苛刻每线程独立连接中等线性扩展读数点数多、允许 PLC 侧多个连接单线程采集 内存快照较高单连接满吞吐点数多、实时性要求高第一种最省事缺点是所有调用方排队一个慢请求拖住全部。第二种要注意 PLC 侧同时打开的连接数有上限工业设备上常见是 8 个或 16 个超了会被拒绝。第三种是长期运行的系统里最稳的形态只有一个采集线程持有连接按固定周期把所有点位打包成尽量少的请求发出去读回来写进一块共享内存其他线程只读快照。这样连接状态完全可控也不会有并发问题。// 共用连接时的最小保护一次事务必须原子 private readonly SemaphoreSlim _gate new(1, 1); async Taskbyte[] SafeTransactAsync(byte[] request, TimeSpan timeout) { await _gate.WaitAsync(timeout); try { return await TransactAsync(request, timeout); } finally { _gate.Release(); } }WaitAsync里的timeout是排队超时和TransactAsync里的响应超时是两回事别共用一个值。排队超时设得比响应超时略大比较合理否则队列里等太久的请求会先放弃白白占着信号量。5. 常见问题排查让通讯时好时坏的五条血泪经验这一节记录的都是「代码看着没错但就是不工作」的场景每一条都能在日志里找到特征现象。现象能连上写入也不报错但读回来永远是 0。原因通常是 PLC Info 里的 CPU 类型字节填错。有些实现图省事两个字节全填 0对本地 CPU 是能通的一旦设备是扩展机架或者换了型号0 会被解析成一个不存在的 CPU 类型PLC 不返回错误状态只返回全 0 的数据区。解决方式是把 CPU 类型和槽位做成配置项别硬编码在组帧函数里同时加一条启动自检连上后先读一个已知的非零系统区地址读不到就判定配置错误。现象第一帧正常之后每帧都超时。这是长度字段算错的典型症状。多算了 N 个字节接收方读满后还剩 N 个字节在流里下一帧的「帧头」其实是上一帧的尾巴公司标识校验会失败。少算则相反会把下一帧的头当成自己的数据。排查办法是把dataLen和实际frame.Length - 21打出来对比两者必须相等。地址里带空格、带了小写的mw、或者多写了一个%都会让地址字节数变化进而让长度对不上。现象读多个字时前一半数值正确后一半是乱码。写入时元素个数不是由显式字段给出的而是用(dataLen - 地址长度) / 2倒推。如果地址长度算错一个字节比如%MW100误当成 5 字节倒推出的个数就会多一个或少一个PLC 按它的理解截断数据末尾几个字就变成了相邻地址的内容。稳妥的写法是组帧时把地址长度、数据长度、元素个数三者一起算出来任何一处不一致就在本地抛异常不要发给 PLC 去猜。现象单个位读写没问题批量读位时高低位颠倒。位打包是低位在前但不少人按「第一个字节的最高位对应第一个位」来解。单点读时因为只有一个位有效怎么解都对批量读时就会明显镜像。定位方法是写入%MX0 true、其余为 false读回来检查第一个字节是不是0x01是0x80就说明方向反了。现象白天运行正常夜里偶发超时重连后恢复。这类问题多半不是协议本身的错。产线上夜班切换时网络设备的省电策略、交换机端口的自协商抖动、或者上位机所在的虚拟机被调度走都会造成为期几百毫秒的连接停滞。协议层能做的是把超时后的处理做成「快速断连 退避重连」第一次失败立刻重连连续失败则指数退避避免在网络故障期间疯狂重连把 PLC 的连接数耗尽。同时把每次超时的时刻和耗时记下来连续几天的日志放在一起看规律往往非常明显。注意所有排查都依赖一件事——把收发的原始字节以十六进制打出来。没有这一层日志里只有「超时」两个字等于没有线索。6. 批量读与轮询节奏什么时候该放弃「一次读一个点」点数一多「一个点一帧」的写法很快就撑不住。一帧请求加上往返保守算 20 毫秒1000 个点就是 20 秒一轮任何实时性要求都谈不上。更麻烦的是它把 PLC 的连接和 CPU 都占满了——每帧都要 PLC 的中断上下文切一次累加起来反而是上位机在拖慢控制器。批量读的核心思路是把地址空间上相邻的点合并成一帧。字设备按%MW的连续偏移合并位设备按%MX的连续偏移合并两者不能混在一帧里因为命令字不同。合并时要注意两个约束单帧的数据区总字节数不能超过设备允许的上限常见是 512 字节量级以及跨越不同类型分区时不能合并。下面这个简单的合并逻辑就能把典型点表压缩一个数量级// 把连续的 %MW 地址合并成区间跳过的空洞用少量冗余读换请求数下降 static List(int start, int count) MergeRanges(IEnumerableint offsets, int maxGap 8, int maxCount 200) { var sorted offsets.Distinct().OrderBy(x x).ToList(); var result new List(int, int)(); int i 0; while (i sorted.Count) { int start sorted[i], end sorted[i], j i 1; while (j sorted.Count sorted[j] - end maxGap sorted[j] - start 1 maxCount) { end sorted[j]; j; } result.Add((start, end - start 1)); i j; } return result; }maxGap是关键参数它决定「为了少发一帧愿意多读多少个无用的地址」。设成 0 就是严格连续才合并帧数多但数据零浪费设成 8 到 16 之间帧数能降一大截多读的几十个字对上位机来说毫无成本。maxCount则是防止单帧超限的保护超了之后拆成多帧。轮询周期同理不是越短越好。周期短于 PLC 的扫描周期时你会读到同一个扫描结果好几次看起来数据「卡住了」周期接近扫描周期时会出现采样混叠某些中间状态永远采不到。合理的起点是把轮询周期设在扫描周期的 2 到 3 倍再根据实际数据变化率调整。我自己的教训是早期做这类采集时总想着「读得越快越好」把周期压到 50 毫秒结果运行一天后 PLC 的连接开始周期性断开排查了两天才发现是控制器侧对单位时间内的请求数有隐性限制。从那以后我每次接新设备都先用最保守的周期跑满 24 小时观察连接稳定性和 PLC 的负载指示再逐步往下压而不是一上来就按理想值配。协议本身不复杂复杂的是它运行的那个环境。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?