ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#上位机与欧姆龙PLC串口通讯:FINS协议帧结构、地址映射与代码实现

C#上位机与欧姆龙PLC串口通讯:FINS协议帧结构、地址映射与代码实现 简介这是一份面向工业自动化开发者的C#与欧姆龙PLC通讯入门资源重点演示如何通过System.IO.Ports串口通信以及FINS协议完成上位机与PLC之间的寄存器读写、数据转换与异常处理适合有基础C#语法、希望快速上手PLC上位机程序的初学者。压缩包内共26个文件整体仅84KB以cs源码、csproj/sln工程文件为核心附带可运行的exe与dll另有resx、png等界面与资源文件结构紧凑便于直接打开工程对照学习。目前已有799人学习下载。内容围绕FINS协议展开包含协议帧结构、命令代码、地址映射、字节序转换、异步编程和调试排错等关键知识点通过示例程序展示完整通讯流程读者可从Form1.cs、Program.cs中理解界面逻辑与入口流程在Visual Studio中直接编译运行快速搭建欧姆龙PLC上位机通讯原型是一份轻量而实用的参考资料。1. 为什么是C#和FINS一套能落地的欧姆龙PLC上位机通讯方案产线上经常有这种需求PLC控制着十几台电机上位机要把当前转速、温度、报警码实时拉到看板上。用C#写这类欧姆龙PLC通讯程序最大的坎不是语言本身而是你拿到的示例代码往往只演示了“按一个按钮读一个地址”到了现场却不知道帧为什么发不出去、响应为什么超时。我拆过不少类似的上位机项目结论很直接想跟欧姆龙PLC稳定通信绕不开FINS协议的结构、串口参数和地址映射这三样吃透了再用SerialPort或第三方库都只是层层皮。这套资源里的PLC_COM解决方案就是典型的C#上位机雏形源码里包含了Form1.cs、串口初始化、读写命令的骨架适合正在做欧姆龙PLC通讯的C#工程师。无论你是刚接触上位机还是已经在用HSLCommunication或NModbus4读懂FINS帧结构都能帮你少走很多弯路。下面从协议帧开始一步步落到能跑的代码上。2. FINS协议帧结构、命令码与欧姆龙地址映射2.1 FINS帧结构的每个字节到底是什么FINS协议是欧姆龙PLC的核心通讯协议支持Ethernet、RS-232C、RS-485和Host Link。C#上位机最常见的场景是走串口线连接PLC的RS-232C口发送FINS命令。很多初学者直接抄一段SerialPort.Write的代码却不知道发出去的10个字节里哪个是目标地址、哪个是命令码这就是调试半天对不上的根本原因。一个标准FINS命令帧由两部分组成10字节的FINS Header加命令码和数据区。Header结构固定下面这张表建议存下来对照用偏移名称含义常见值0ICF信息控制域0x80表示需要响应0x801RSV保留字节固定0x000x002GCT网关计数1个网关设0x02直连设0x040x023DNA目标网络号本机网络为0x000x004DA1目标节点号PLC站号0x00或实际站号5DA2目标单元号CPU单元为0x000x006SNA源网络号上位机为0x000x007SA1源节点号上位机节点号0x018SA2源单元号0x009SID服务标识用于匹配请求和响应0x00真正需要按现场改的只有两个DA1是PLC的站号拨码开关决定SA1是上位机自己起的节点号一般设1就行。其它字节在直连串口场景下基本都是固定值。在PLC_COM工程里我看到作者把这段Header直接写进了Form1.cs的按钮事件里方便是方便但换一台PLC就要改代码。更好的做法是封装成一个FinsFrame结构让地址和站号成为参数。2.2 命令码0x03读、0x02写与DM区地址映射FINS命令码就两个最常用0x01是读0x02是写。注意这是FINS的标准命令码不要和串口Modbus的03/06搞混。比如读PLC保存寄存器DM区的数据命令码取0x01后面跟要读的起始地址、长度、数据属性。这里网络热词“欧姆龙plc编程实例108例”里也反复提到DM区是保持型数据区断电不丢所以工艺参数一般都放这里。地址映射是另一个高频踩坑点。欧姆龙PLC的DM区字地址在FINS命令里用三位BCD码表示比如DM区地址D100在帧里要换算成“内存区代码0x82 地址高字节0x01 地址低字节0x00”。常用的地址区域对应关系如下PLC区域内存区代码地址范围说明DM区0x820x0000–0xFFFF16位字掉电保持CIO区0xB00x0000–0x0FFFF输入输出继电器区WR区0xB10x0000–0x01FF工作继电器HR区0xB20x0000–0x01FF保持继电器举个例子读D100一个字计算后的实际数据区是82 01 00 00 01区域码、地址高、地址低、读长度高、读长度低。你要是直接填82 00 64读到的就是D0000到D0064偏移而不是D100。这个坑我在现场调了十几分钟才反应过来——PLC程序里的地址是十进制字编号FINS要求的是按字偏移后的十六进制且高字节在前。所以写工具函数做地址换算比在调用点手写十六进制数字安全得多。2.3 字节序与数据转换小端模式下别读反了欧姆龙C系列PLC和大部分工控设备一样多字节数据在内存中是低字节在前Little-Endian。但FINS帧内的字地址又是高字节在前Big-Endian这两层字节序混在一起最容易让人头晕。比如PLC里一个16位整数值0x1234在FINS响应数据帧里先收到0x12再收到0x34。如果直接按收到的顺序拼成0x3412显示出来的数值就完全错了。我一般在解析层做一个统一的转换函数而不是在UI层到处BitConverter。下面这段是工程里可以复用的C#代码专门处理FINS响应中的16位和32位整数public static ushort ReadUInt16(byte[] data, int offset) { // FINS响应内数据为高字节在前 return (ushort)((data[offset] 8) | data[offset 1]); } public static uint ReadUInt32(byte[] data, int offset) { // 32位数据同样高字节在前注意PLC内字顺序 return (uint)((data[offset] 24) | (data[offset 1] 16) | (data[offset 2] 8) | data[offset 3]); }逻辑很简单把data[offset]视为最高字节左移8位或24位再与后续字节按位或。参数说明里要强调offset一定是实际数据区的起始位置而不是整个响应的第0字节因为FINS响应前面还有16字节的Header。常见做法是在解析前先把响应数组的Header剥离只保留命令码后面的数据段这样后面所有偏移计算都是从0开始不容易错位。3. 用SerialPort实现FINS命令收发与超时重传3.1 打开串口波特率、数据位、校验位怎么选串口看似简单但参数不匹配连数据都读不回来。欧姆龙CP1H、CJ2M这类PLC自带的RS-232C口默认通讯参数一般是波特率9600、数据位8、停止位1、偶校验Even、无流控制。注意欧姆龙默认用偶校验和很多国产设备默认无校验不一样这是PLC上位机通讯失败的第一大原因。如果你的PLC连线后收不到任何响应先拿串口调试助手对一下这五项参数再查代码。在PLC_COM工程的Form1.cs里作者是用界面下拉框配置串口的这类写法适合测试。正式项目我通常建议把串口参数集中到一个配置类里方便现场快速切换。下面这段初始化代码是标准的C#串口起手式SerialPort sp new SerialPort(); sp.PortName COM3; sp.BaudRate 9600; sp.DataBits 8; sp.Parity Parity.Even; sp.StopBits StopBits.One; sp.Handshake Handshake.None; sp.ReadTimeout 500; // 读超时500ms sp.WriteTimeout 500; // 写超时500ms sp.Open();参数说明ReadTimeout和WriteTimeout是必须设的否则串口接收不到数据时Read会一直阻塞住UI线程。超时时间建议先设300–500ms现场如果因为线路干扰导致偶发超时再适当加大到1000ms但不要超过3000ms否则设备故障时上位机要等很久才能报错。Handshake记得设NonePLC串口大多数不启用RTS/CTS硬件流控设错了反而会把数据卡住。3.2 构造FINS指令并发送一条完整的读命令串口打开后核心工作是把FINS命令转成字节数组。这里给出一个实际可用的SendFinsCommand方法发送读DM区命令并返回响应数据。它封装了2.2节讲的地址换算规则调用方只需要传入起始地址和读取字数。public byte[] SendReadDmCommand(ushort startAddr, ushort wordCount) { // 1. 构建FINS Header byte[] fins new byte[18]; // 10字节头 2字节命令码 6字节参数 fins[0] 0x80; // ICF 需要响应 fins[1] 0x00; fins[2] 0x02; fins[3] 0x00; // DNA fins[4] 0x00; // DA1 目标节点直连PLC可填0 fins[5] 0x00; // DA2 fins[6] 0x00; // SNA fins[7] 0x01; // SA1 源节点 fins[8] 0x00; // SA2 fins[9] 0x01; // SID // 2. 命令码0x01 读 fins[10] 0x01; fins[11] 0x01; // 3. 地址与长度DM区 0x82地址高字节和低字节 fins[12] 0x82; fins[13] (byte)((startAddr 8) 0xFF); fins[14] (byte)(startAddr 0xFF); fins[15] (byte)((wordCount 8) 0xFF); fins[16] (byte)(wordCount 0xFF); // 4. 发送并接收 sp.Write(fins, 0, fins.Length); byte[] response new byte[1024]; int len sp.Read(response, 0, response.Length); return response.Take(len).ToArray(); }逻辑说明前面10字节是固定的FINS Header第11、12字节0x01 0x01代表读命令。第13字节0x82是DM区代码紧接着两个字节是起始地址高、低字节最后两个字节是读取字数。注意startAddr是PLC里的字地址比如要读D100就传100方法内部会转成十六进制字节。响应数组里前16字节是响应Header第17字节0x01代表正常如果第17字节不是0x01说明PLC返回了错误码具体含义可以参考FINS错误码表0x11表示命令长度错误0x21表示地址范围错误。3.3 解析响应从响应帧中剥离数据区并处理异常响应帧格式比命令帧多了一个端到端延迟时间其余结构类似。通常响应前16字节是Header第17字节是命令码和请求相同第18字节是结束码第19字节开始才是真正的数据。解析时不能直接Take后就看数据必须先判断结束码。我常用的解析写法是先剥离Header再转数据public Listushort ParseReadResponse(byte[] response) { if (response.Length 18) return null; // 结束码索引16是命令码索引17是结束码 if (response[17] ! 0x00) throw new Exception($FINS错误码: 0x{response[17]:X2}); Listushort values new Listushort(); int dataOffset 18; // 数据段从第18字节开始 while (dataOffset 1 response.Length) { // 高字节在前合成16位无符号整数 ushort v (ushort)((response[dataOffset] 8) | response[dataOffset 1]); values.Add(v); dataOffset 2; } return values; }这里的18不是拍脑袋定的响应Header是16字节加上1字节命令码和1字节结束码正好18字节。具体排列为ICF…SID共10字节后面是自定义信息区串口多1字节延迟时间、命令码、结束码。实际项目中建议还是用前面写的ReadUInt16从第18字节开始读这样一旦欧姆龙固件版本有差异你只需要调整dataOffset一个常量即可。构造写命令的代码与此对称命令码改为0x01 0x02参数区从内存区代码 地址 写入字数 数据组成同样注意数据高字节在前。4. 异步采集与UI刷新解决循环通讯卡顿的常见问题4.1 用async/await替代Thread.Sleep做轮询很多C#上位机教程把读取PLC放在Timer事件或while循环里中间用Thread.Sleep(100)控制频率。这种方式在PLC通讯慢的时候问题不大但一旦读取间隔小于PLC响应时间下一轮请求会在串口缓冲区里和上一轮响应混在一起轻则数据错位重则通讯线程挂死。更关键的是在UI线程里Sleep界面会直接白屏鼠标拖动都卡。网络热词里“c# 循环数据采集和ui刷新卡顿”说的就是这类场景。常见做法是把轮询改成async/await让出线程资源。比如每300ms读一次DM区用Task.Delay代替Sleepprivate async Task PollingLoop(CancellationToken token) { while (!token.IsCancellationRequested) { try { byte[] resp await Task.Run(() SendReadDmCommand(100, 10)); var values ParseReadResponse(resp); // 更新UI见4.3 } catch (Exception ex) { // 记录日志避免异常中断循环 Debug.WriteLine(ex.Message); } await Task.Delay(300, token); } }逻辑说明await Task.Run把耗时的串口读取放到线程池避免阻塞UI线程Task.Delay(300)在等待期间不占用线程比Sleep高效。这里有一个很重要的参数是token取消令牌可以在窗体关闭时通知循环退出避免程序退出后串口还被占用。注意Task.Run里调用了SendReadDmCommand里面用的SerialPort实例必须是你预先打开的同一个对象不能在异步方法里反复开关串口。4.2 用CancellationToken控制采集启停实际现场调试时操作员经常要启停采集或者切换PLC站号。如果程序里只有一个bool标志位控制循环很容易出现标志位改了但线程还卡在Read上的问题。CancellationToken的好处是能和Task.Delay、SerialPort.Read的超时机制配合起来。启动和停止的逻辑可以这样组织private CancellationTokenSource _cts; private void StartButton_Click(object sender, EventArgs e) { _cts new CancellationTokenSource(); _ PollingLoop(_cts.Token); // 不等待异步执行 } private void StopButton_Click(object sender, EventArgs e) { _cts?.Cancel(); _cts?.Dispose(); _cts null; }注意PollingLoop里每次await Task.Delay(300, token)会立刻抛出OperationCanceledException所以while条件即使不检查token也能通过异常退出循环。我在代码里同时写token.IsCancellationRequested是为了让逻辑更清晰两者作用不同前者是提前检查后者是响应取消请求。如果你用SerialPort的同步Read取消时它会一直等直到ReadTimeout超时才能退出。所以给串口设置合理的ReadTimeout不仅是为了报错也是为了让取消动作及时生效。4.3 UI刷新策略只更新变化值和用BeginInvoke循环采集本身不卡卡的是在循环里直接操作UI控件。每300ms读一次PLC如果每次把TextBox.Text赋值一次UI线程会被Invalidate和重新布局拖垮。常见做法是做好两点一是把UI更新代码切回UI线程二是在一个周期内批量刷新控件而不是逐条赋值。下面是一个典型的批量刷新片段private void UpdateUI(Dictionarystring, ushort values) { if (InvokeRequired) { BeginInvoke(new Action(() UpdateUI(values))); return; } // 只在数值变化时才更新减少无效绘制 foreach (var kvp in values) { if (_lastValues.ContainsKey(kvp.Key) _lastValues[kvp.Key] kvp.Value) continue; var ctrl _controls[kvp.Key]; ctrl.Text kvp.Value.ToString(); _lastValues[kvp.Key] kvp.Value; } }这里的_controls是一个提前配好的字典把PLC变量名映射到界面控件例如_controls[D100] txtSpeed1。参数说明InvokeRequired判断当前线程是否不是UI线程如果是后台线程就通过BeginInvoke异步切回UI线程。BeginInvoke相比于Invoke是异步等待的调用方不会堵塞适合高频率刷新场景。比较_lastValues里的旧值可以避免没变化的数据也触发一次UI重绘。实际项目中如果你的PLC点比较多还可以把多个变量拼成一个字符串后用Label整体刷新性能提升更明显。5. 调试技巧从Wireshark抓包到Sysmac Studio联调验证5.1 先记录原始字节再谈解析对不对我在现场写过大量PLC上位机最深的教训是程序读不到数据时先别急着改代码把发出和收到的原始字节全部打出来。很多所谓“通讯不稳定”其实是发送的帧本身有问题比如站号不对、命令码写错、地址超范围。C#里加日志很简单重点要记录完整的十六进制字符串同时保留时间戳private string ByteArrayToHex(byte[] data) { return string.Join( , data.Select(b b.ToString(X2))); } // 在发送和接收处调用示例 // Console.WriteLine($[{DateTime.Now:HH:mm:ss.fff}] TX: {ByteArrayToHex(fins)}); // Console.WriteLine($[{DateTime.Now:HH:mm:ss.fff}] RX: {ByteArrayToHex(response)});ByteArrayToHex方法把每个字节转成两位十六进制并用空格连接。发送日志可以和命令帧的构造过程对照接收日志能看出PLC是不是返回了错误码。这里要特别注意响应长度如果收回来数据比预期少很多多半是串口缓存没读完需要循环Read直到满足最小长度如果比预期多则可能是因为上一次请求超时后这次的读操作把上次的响应也带出来了解决方法是每次发送前先清空串口输入缓冲区DiscardInBuffer()。5.2 Wireshark抓虚拟串口数据不用到处插逻辑分析仪排查串口通讯问题最直接的工具是逻辑分析仪但不是每个人都有硬件。软件上常见的做法是安装虚拟串口工具创建一对com0com虚拟串口把C#程序连到虚拟COM口再把串口调试助手的另一端连上Wireshark无法直接抓普通串口但可以用USBPcap或直接把数据重定向到网络端口的方式来看。这里不铺开讲只给你一个最省事的经验先用串口调试助手手动发一条FINS命令确认PLC能回应再回到C#程序里逐字节比对。我一般会把调试助手里手动验证过的命令和C#程序发的命令放在一起对照。例如你手动发送以下内容给PLC看返回是否正常80 00 02 00 00 00 00 01 00 00 01 01 82 00 64 00 01如果这条命令在调试助手里能返回正确数据而C#里读不到那问题一定出在串口参数或字节序上如果手动发送也失败那得先检查PLC的串口设定和站号。记住一个原则先证明通讯链路通的再去怀疑代码逻辑。5.3 用Sysmac Studio和PLC程序交叉验证地址范围最后一步是回到欧姆龙自己的工具链也就是Sysmac Studio或CX-Programmer去PLC程序里确认地址确实存在、数据类型匹配。我见过很多次C#程序一直报地址范围错误最后打开PLC程序才发现D100被当作32位浮点数使用了但C#这边还在按16位整数读数据当然对不上。这就是为什么地址映射不能只看一张通用表必须结合具体PLC型号和程序里的软元件注释。具体验证方法是在Sysmac Studio的“内存监视”窗口里输入D100看PLC当前值再对照C#程序读到的数值。如果PLC里显示4000C#里读出来是16384那就是字节序或类型长度不一致优先检查ReadUInt16的合成方向。如果PLC里显示的是小数需要考虑浮点数格式IEEE 754的转换在C#里用BitConverter.ToSingle先把字节转成32位整数再调用BitConverter.Int32BitsToSingle。调试时还可以在Sysmac Studio的IO表里把PLC站号临时改大用C#程序配置成不同DA1去连接验证站号是否参与通讯流程。把这套字节日志、手动对比、地址交叉验证结合起来基本能覆盖现场90%的FINS通讯问题。真正到了网络不稳定、数据量大的项目再考虑引入HSLCommunication这类成熟库或者换成以太网FINS/TCP串口这条路走通后后面的路自然顺了。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进