LS PLC串口通讯C#实现:从帧结构到CRC16校验的完整上位机开发指南
简介在工业自动化与上位机开发场景中这份C#编写的LS PLC通讯协议示例工程提供了完整的通信实现思路适用于需要与韩国LS产电PLC进行数据交换的开发者。工程覆盖Modbus TCP/IP及串口RS-232/RS-485等常见通讯方式并给出.NET环境下读取寄存器、解析数据、下发控制指令的代码框架。压缩包共计52个文件大小约102KB其中12个.cs源文件为核心逻辑配套Visual Studio解决方案.sln/.csproj可直接打开同时包含编译生成的exe、调试符号pdb、界面资源resx、构建日志tlog及属性设置settings等辅助文件便于对照源码理解整个工程结构。目前已有581人学习下载。借助该工程读者能够快速理解上位机与PLC之间的通信参数配置、报文封装与解析流程以及如何通过C#程序实现实时监视与控制还可以借鉴其工程组织方式减少二次开发时的踩坑成本。适合自动化专业学生、PLC工程师和工业软件开发者作为轻量级入门参考资料。1. LS PLC通讯协议C#源代码为什么这是上位机开发最该先下的一份代码做产线数据采集的老工程师都有过这种经历PLC是LS系的XGB/XGT上位机要用C#读数据厂家光盘里却只给了VB或C的样例打开一看全是裸字节操作。最耗时间的不是写业务逻辑而是翻几十页协议文档去抠帧结构、CRC顺序、站号和地址偏移。这套“LS PLC通讯协议C#源代码”解决的就是这一层问题——它把LS系PLC最常见的串口通讯协议封装成了C#类帧构建、CRC16校验、响应解析、字/位读写全部就位拿到后改改串口参数和变量地址就能跑通。它适合三类人正在做设备联网改造、需要把LS PLC数据读进自己MES系统的上位机工程师从VB老代码迁移到C#、又不想被字节数组折磨的人以及调试时连不上PLC、想通过帧日志定位是发送错还是接收错的人。接下来我按自己的拆解习惯把这套源码的协议原理、C#实现、地址换算、踩坑记录和验证方法完整过一遍。2. 先懂帧再动代码LS PLC的两种通讯协议与串口帧结构2.1 专用协议和Modbus怎么选先看清你的对接场景LS的PLC本体一般同时支持两套通讯方式一套是厂家自己的专用协议另一套是标准的Modbus RTU。很多第一次用的人会纠结选哪套我的建议很简单看你的上位机生态。专用协议的优势在于和PLC的符号地址对齐。你在PLC程序里定义了一个%MW100表示温度用专用协议读的时候直接填%MW100对应的偏移就行而且能原生支持位强制、读PLC诊断信息这类Modbus不太好做的操作。触摸屏、组态软件和PLC编程软件之间一般都用这套协议沟通兼容性最有保证。Modbus RTU的优势在于通用性。如果现场除了LS PLC还有变频器、温控表、智能电表你上位机里已经写了一套Modbus驱动那让PLC也跑Modbus RTU是最省事的路不用为了PLC单独再养一套协议解析代码。代价是地址要经过一层映射比如PLC的%MW100在Modbus里对应保持寄存器地址你得在PLC侧或上位机侧维护这张映射表。我的习惯是这个项目只有这台LS PLC要接用专用协议语义直观这个项目后面还会接十几台别的设备统一走Modbus代码维护成本低。表1是我做选型时常看的几个维度。对比维度专用协议Modbus RTU地址语义直接对应PLC符号地址%MW/%MX映射到保持寄存器/离散量区间位操作原生支持位读取与强制用功能码01/05/0F间接实现诊断信息可读PLC运行状态、错误码有限只能通过异常码推断上手复杂度需要理解厂家帧格式通用标准参考资料多适合场景单品牌PLC直连上位机多品牌设备混接、统一驱动2.2 串口帧结构拆解STX开头ETX收尾中间全是套路这套源码按LS系XGB系列串口专用协议的帧格式实现。请求帧大致结构如下区段示例长度含义STX1字节起始符固定0x02命令码1字节区分读、写、强制等操作站号1字节PLC地址范围0~31地址区2字节字偏移或位偏移高位在前数据长度2字节要读/写的字数或位数数据区变长写操作时携带要写入的值CRC162字节校验范围从STX到数据区末尾低字节在前ETX1字节结束符固定0x03一个写单个字的请求帧的十六进制形态大致是这样示意帧站号1地址0x0000写入0x123402 04 01 00 00 00 01 12 34 xx xx 03其中02是STX04是写命令01是站号00 00是地址00 01是长度12 34是数据xx xx是CRC16计算结果03收尾。这里有两个细节影响后续代码设计。第一地址区是两字节高位在前和很多单片机工程师习惯的小端序相反构建帧时做移位要小心。第二CRC16的校验范围是从STX开始一直到数据区末尾不包括CRC本身和ETX而且结果低字节先发送。这个顺序是后续最容易翻车的地方第5章我会专门展开。2.3 响应帧和请求帧的差异状态字决定了你该解析还是该报错PLC返回的响应帧和请求帧结构大体对称但多了一个状态字。正常的响应帧状态字为0如果PLC执行不了这条指令状态字会返回非0错误码。初学时容易犯的错是只看STX和ETX就开解析把错误响应当成正常数据。举个例子站号填错或者地址越界PLC会返回一帧带非零状态字的响应。如果不检查状态字直接去取数据区读出来的可能是完全没意义的值而且这种错误是偶发的、间歇的排查起来特别费劲。所以解析函数里必须把状态字检测放在第一步非零就直接抛异常不要碰数据区。3. C#通讯核心实现串口参数、帧构建与CRC16轮子3.1 串口参数不是拍脑袋波特率、校验位与超时的设置逻辑PLC的串口参数必须在PLC编程软件的联机设置里提前确认。最常见的出厂配置是9600波特率、8数据位、1停止位、无校验但这只适合同品牌调试环境现场如果通过无线DTU或者延长线通讯串口参数可能被改过。我一般会在代码里把参数放到一个配置类里而不是写死在打开串口的地方。public class PlcSerialConfig { public string PortName { get; set; } COM3; public int BaudRate { get; set; } 9600; public int DataBits { get; set; } 8; public StopBits StopBits { get; set; } StopBits.One; public Parity Parity { get; set; } Parity.None; public int ReadTimeout { get; set; } 200; public int WriteTimeout { get; set; } 200; }这段配置说明了通讯层的设计思路把和现场物理环境相关的参数集中管理。比如遇到通讯距离长、干扰大的现场我会把波特率降到4800代码里改一个字段就行遇到PLC加了RS485转接器校验位可能要改成偶校验也只需要改配置。超时时间这里我单独说两句一帧请求发出后PLC的响应时间一般在几毫秒到几十毫秒。设200毫秒足够覆盖正常响应又不会让界面卡死。如果你通过GPRS DTU通讯这个值可能要放大到1000毫秒以上因为无线链路本身就有几百毫秒的延迟。打开串口的代码private SerialPort _sp; public void Connect(PlcSerialConfig cfg) { _sp new SerialPort { PortName cfg.PortName, BaudRate cfg.BaudRate, DataBits cfg.DataBits, StopBits cfg.StopBits, Parity cfg.Parity, ReadTimeout cfg.ReadTimeout, WriteTimeout cfg.WriteTimeout }; try { _sp.Open(); _sp.DiscardInBuffer(); _sp.DiscardOutBuffer(); } catch (Exception ex) { throw new InvalidOperationException($串口打开失败请检查端口号和设备占用: {ex.Message}); } }打开串口后立刻清空收发缓冲是一个很容易被忽略的动作。PLC可能在电脑重启前已经回复过一帧半帧的残留数据不清缓冲的话第一次读请求发出去之前接收线程就先收到了一堆脏数据可能被误判成一帧完整响应。3.2 帧构建与CRC16两个方法串起一次完整请求CRC16计算是这套源码里最值得对照检查的部分。LS系串口协议用的是CRC-16/IBM算法初值0xFFFF多项式0xA001结果发送时低字节在前。public ushort CalcCrc16(byte[] data, int offset, int length) { ushort crc 0xFFFF; for (int i offset; i offset length; i) { crc ^ data[i]; for (int bit 0; bit 8; bit) { if ((crc 0x0001) ! 0) { crc (ushort)((crc 1) ^ 0xA001); } else { crc 1; } } } return crc; }这段算法是最常见的“右移异或”实现逻辑上等价于查表法但更便于你对照文档排查。参数offset和length用于只校验帧中指定区段如果你往方法里传入整个字节数组计算范围不同会直接导致CRC对不上。构建读请求帧的方法public byte[] BuildReadFrame(byte stationNo, byte cmd, ushort address, ushort length) { Listbyte frame new Listbyte { 0x02, // STX cmd, // 命令码0x04读 stationNo // 站号 }; frame.Add((byte)(address 8)); // 地址高位 frame.Add((byte)(address 0xFF)); // 地址低位 frame.Add((byte)(length 8)); // 长度高位 frame.Add((byte)(length 0xFF)); // 长度低位 ushort crc CalcCrc16(frame.ToArray(), 0, frame.Count); frame.Add((byte)(crc 0xFF)); // CRC低字节在前 frame.Add((byte)(crc 8)); frame.Add(0x03); // ETX return frame.ToArray(); }构建帧的顺序和我在第2章表格里列的结构完全一致。注意crc 0xFF先入列crc 8后入列这就实现了“低字节在前”。如果这里写反帧看起来一模一样但PLC会直接丢弃等你发现是CRC顺序问题可能已经浪费了半个下午。3.3 响应解析先验类型再验CRC最后查状态字解析响应比构建请求更考验细节因为PLC端可能什么都不回断线、站号错也可能回错误帧。我习惯把解析流程分成三步帧边界检查、CRC校验、状态字判断。public byte[] ParseResponse(byte[] buf) { if (buf.Length 8) throw new PlcCommException(响应帧长度不足); if (buf[0] ! 0x02 || buf[buf.Length - 1] ! 0x03) throw new PlcCommException(帧头或帧尾错误); // 校验CRC范围为STX到数据区末尾不含CRC本身和ETX int crcBodyLen buf.Length - 4; ushort calcCrc CalcCrc16(buf, 0, crcBodyLen); ushort recvCrc (ushort)(buf[buf.Length - 3] | (buf[buf.Length - 2] 8)); if (calcCrc ! recvCrc) throw new PlcCommException($CRC校验失败: 计算值0x{calcCrc:X4}, 接收值0x{recvCrc:X4}); return buf; }这段代码有一个容易被忽略的细节recvCrc组合时把buf[buf.Length - 3]作为低字节、buf[buf.Length - 2]作为高字节对应发送端的低字节在前约定。crcBodyLen用buf.Length - 4是因为末尾有“CRC低字节、CRC高字节、ETX”一共3字节但还要留出1字节的不确定空间实际很多协议在CRC前还带一个字节的状态字这个差值要按协议文档微调。我在实际项目里会对ParseResponse再做一层封装让调用方不用关心帧格式直接拿到数据区public ushort[] ReadWords(byte stationNo, ushort startAddress, ushort length) { byte[] request BuildReadFrame(stationNo, 0x04, startAddress, length); byte[] response SendAndReceive(request); byte[] body ParseResponse(response); // 状态字检查body[3]非0表示PLC返回错误 if (body[3] ! 0) throw new PlcCommException($PLC返回错误状态: 0x{body[3]:X2}); // 数据区从索引4开始每2字节组成一个字高位在前 ushort[] values new ushort[length]; for (int i 0; i length; i) { values[i] (ushort)((body[4 i * 2] 8) | body[5 i * 2]); } return values; }这里的状态字位置索引body[3]是根据响应帧结构定的索引0是STX索引1是命令码回显索引2是站号索引3就是状态字。不同型号PLC的响应帧可能长度不同但状态字位置基本固定。数据区按“高字节在前”组合和请求帧的地址表示保持了一致的小端序观感。4. 地址映射与批量读写把字节帧变成可用的PLC变量4.1 %MW、%MX和协议偏移量的换算规则LS PLC的符号地址在程序里看是%MW100、%MX5、%IW2这类形式但通讯协议不认识符号名只认数字偏移。这是初上手最容易困惑的地方。以XGB系列为例字地址%MW0通常映射到协议偏移0x0000%MW100就是0x0064换算就是纯十进制转十六进制不需要额外计算。位地址%MX0是另一套偏移空间不能拿字偏移去乘以8再当位地址用这两套空间在PLC内部是分开编址的。表2是我常用的对照方式。PLC符号地址协议寻址空间十六进制偏移说明%MW100字空间0x0064读/写16位寄存器%MW500字空间0x01F4跨批量读取时注意地址连续性%MX5位空间0x0005位读取/强制走另一套命令码%IW2字空间只读0x0002输入字一般只能读不能写这个表不要求背但要求你在配置上位机变量表时做好记录。我的习惯是每个变量在配置表里同时标注“PLC符号名”和“协议偏移”调试时对照PLC编程软件核对而不是靠心算。4.2 批量读取的边界条件地址连续性与单帧长度上限批量读取的前提是变量在PLC里排布在连续的地址区段。你从%MW100开始读10个字PLC返回的是%MW100到%MW109中间不能有跳空。如果变量表里第5个点实际是%MW200就不应该混在同一个批量读数里。单帧长度上限也是一个容易被逼近的边界。PLC侧一般会对单帧读写的数据长度设上限具体值因型号而异。我不知道你手里的具体机型上限是多少但我的经验是单帧控制在64个字以内如果变量超过64个字拆成多帧按页读取。拆帧本身不复杂但要处理好“最后一页不足64字”的情况public Listushort[] ReadWordsByPages(byte stationNo, ushort startAddress, int totalLength) { var result new Listushort[](); int offset 0; const int pageSize 64; while (offset totalLength) { int len Math.Min(pageSize, totalLength - offset); ushort[] page ReadWords(stationNo, (ushort)(startAddress offset), (ushort)len); result.Add(page); offset len; } return result; }这里的pageSize不是协议硬限制而是我压过多个型号后确定的保守值。较小的单帧长度还有另一个好处某帧通讯失败时重试成本低不至于一帧丢了几百个字。startAddress offset的写法要求起始地址加页偏移不要超过PLC地址空间上限否则要加边界检查。4.3 一个可直接改造的通讯工具类把前面所有内容合到一起一个可抄作业的类骨架如下public class LsPlcClient : IDisposable { private SerialPort _sp; public void Connect(string portName, int baudRate) { // 打开串口并清缓冲 } public ushort[] ReadWords(byte stationNo, ushort startAddress, ushort length) { // 构建读帧、发送、解析响应、状态字检查 } public void WriteWords(byte stationNo, ushort startAddress, ushort[] values) { // 构建写帧命令码换成写命令数据区填入values } public byte ReadBits(byte stationNo, ushort bitAddress) { // 构建位读帧返回0或1 } public void Dispose() { _sp?.Close(); _sp?.Dispose(); } }使用这个类时的主机侧流程一般是Connect打开串口后先读一次PLC型号或运行状态确认通迅正常再进入正式读写循环。第一次上电时我会故意读一个超出范围的地址观察返回的错误帧和超时行为这样能提前暴露站号、串口参数这类整体性问题。5. 避坑通讯联调中的五个高频问题每条都是实战记录5.1 现象CRC校验不通过同一帧用调试助手却能正常通讯用串口调试助手手动发送相同内容能通换成自己的代码就CRC报错。最初以为是代码算法问题反复核对多项式后依然无解。后来对照调试助手抓取的原始字节发现代码发送时把CRC的高字节放在前、低字节放在后和协议要求的“低字节在前”恰好相反。原因构建帧时用了(byte)(crc 8)先入列再把crc 0xFF放后面顺序弄反。解决统一改成先放低字节、再放高字节并写一个单元测试把调试助手里能通的那帧字节序列作为测试用例直接断言。5.2 现象上位机一直报超时PLC完全无响应连上串口、参数全部设置正确发请求像石沉大海。用万用表量RS485的A/B线电压也正常。最后发现PLC侧通讯参数的“站号”设置是0而上位机填的是1。原因PLC和上位机的站号不匹配PLC收到帧后判断不是自己的站号直接丢弃自然不会有响应。解决先用PLC编程软件联机确认站号和通讯参数然后在上位机配置里写死排查时用“发请求→等200ms→无响应就提示具体哪个环节失败”的分层提示避免只说一个笼统的“通讯超时”。5.3 现象能连上但读到的数据和触摸屏显示不一致代码返回的数据和PLC程序界面上看到的数值对不上差值还有规律可循。检查发现变量配置表里把%MW200的地址偏移算成了0x00C8但实际这个点映射到0x00D0。原因PLC程序里变量可能不是从0开始连续分配或者中间有隐藏的保留区段。解决以PLC编程软件里实际的符号地址分配为准不要凭自增编号推算。从那以后我每次配置都先用编程软件导出一份变量地址表再照着填上位机配置。5.4 现象连续读取几十个字后数据开始串位前几次读都正常读到第50帧左右解析出来的数据突然变成上一帧的末尾值然后一直错位。原因是发送请求后没有等PLC完整响应就发了下一帧串口接收缓冲把两帧数据粘在一起解析时按单帧长度切分导致从中间切错了位置。解决改成同步收发模型发出请求后必须收到完整一帧并且CRC校验通过才允许发下一帧接收解析用状态机按STX开始、ETX结束切帧不要按固定长度读。private byte[] SendAndReceive(byte[] request) { lock (_syncRoot) { _sp.DiscardInBuffer(); _sp.Write(request, 0, request.Length); // 等待接收完整帧最多重试3次 return ReceiveFrameWithRetry(3); } }关键在lock和发送前清空接收缓冲两件事。DiscardInBuffer把上次残留的半帧数据清掉lock保证多线程调用时不会出现“线程A发请求线程B挤进来也发一帧”的交错。很多偶发的“数据不对”问题根源都是这里缺了同步锁。5.5 现象偶发收到一帧CRC校验通过的“乱码”解析出来的数据偶尔变成无意义的大数但帧头、帧尾、CRC全部合法。把原始字节打印出来对照发现数据区内容和请求的数据区一模一样而不是PLC返回的寄存器值。原因PLC对我们发出的写请求返回了确认帧但上位机把这个确认帧当成读响应来解析。写操作的确认帧长度比读响应短数据区位置不同。解决在解析逻辑里先判读命令码回显写确认帧走单独的分支不进入读取数据的解析路径。6. 进阶验证模拟回环与帧日志让通讯不再像玄学6.1 用虚拟串口对自测通讯代码实际接PLC调试出了问题要跑到现场蹲着看效率太低。我拿到这套源码后习惯先做一个“回路自测”用虚拟串口工具创建一对互联的虚拟串口比如COM3和COM4上位机代码打开COM3另写一个模拟PLC的小程序打开COM4。模拟PLC收到请求后按协议格式回一帧固定数据这样完全不必连接真实PLC就能验证帧构建、CRC校验、解析逻辑是否正确。常见做法是让模拟PLC把收到的请求帧原样打印出来再强制回一帧预设响应。这个模式下CRC算法对不对、地址偏移有没有写反都能在工位上十分钟内定位。6.2 帧日志调试期永远不该关掉的功能通讯调试最大的痛点是你不知道线路上实际跑的是什么。我一般在客户端里内置一个帧日志开关把每次收发的原始字节带时间戳打印出来public static string ToHex(byte[] data) { StringBuilder sb new StringBuilder(); foreach (byte b in data) sb.Append(b.ToString(X2)).Append( ); return sb.ToString().Trim(); } // 调用位置 Debug.WriteLine($[{DateTime.Now:HH:mm:ss.fff}] TX: {ToHex(request)}); Debug.WriteLine($[{DateTime.Now:HH:mm:ss.fff}] RX: {ToHex(response)});这段代码的价值在于把看不见的串口数据变成可对比的文本。当你和PLC编程软件自带的监控窗口对照时TX和RX的每一字节都能对上问题就直接缩小到转换层面对不上的基本就是通讯参数或者线路的问题。6.3 上线前我强制走一遍的验证清单第一回环测试模拟PLC返回5种不同类型的帧包括正常读响应、错误状态字、超短帧、坏CRC帧确认所有分支都走对。第二真实通讯接上PLC用编程软件核对每个变量的符号地址和协议偏移确认变量表没有错位。第三连续读写1000次记录最大和最小响应时间观察有没有偶发超时如果有就检查线路质量和站号冲突。第四把PLC断电重启一次再发一帧读写请求验证上电初始化逻辑不会漏掉站号或参数配置。这套源码最大的价值不是替你写业务而是把协议这层“黑匣子”打开让你在上位机层面就掌握主动权。从那以后我每次拿到一款新固件版本的LS PLC都强制把回环测试和帧日志走一遍再谈联调再也没有被CRC顺序和粘包这种问题缠住过。希望帮到你。本文还有配套的精品资源点击获取