☰
LS PLC串口通讯C#实现:从帧结构到CRC16校验的完整上位机开发指南
2026/10/23 5:09:59 网站建设 项目流程

简介:在工业自动化与上位机开发场景中,这份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,写入0x1234):

02 04 01 00 00 00 01 12 34 xx xx 03

其中02是STX,04是写命令,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) { List<byte> frame = new List<byte> { 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 List<ushort[]> ReadWordsByPages(byte stationNo, ushort startAddress, int totalLength) { var result = new List<ushort[]>(); 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顺序和粘包这种问题缠住过。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询