news 2026/9/16 2:38:45

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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表示需要响应0x80
1RSV保留字节,固定0x000x00
2GCT网关计数,1个网关设0x02,直连设0x040x02
3DNA目标网络号,本机网络为0x000x00
4DA1目标节点号,PLC站号0x00或实际站号
5DA2目标单元号,CPU单元为0x000x00
6SNA源网络号,上位机为0x000x00
7SA1源节点号,上位机节点号0x01
8SA2源单元号0x00
9SID服务标识,用于匹配请求和响应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,读到的就是D0000D0064偏移,而不是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();

参数说明:ReadTimeoutWriteTimeout是必须设的,否则串口接收不到数据时Read会一直阻塞住UI线程。超时时间建议先设300–500ms,现场如果因为线路干扰导致偶发超时,再适当加大到1000ms,但不要超过3000ms,否则设备故障时上位机要等很久才能报错。Handshake记得设None,PLC串口大多数不启用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 List<ushort> ParseReadResponse(byte[] response) { if (response.Length < 18) return null; // 结束码:索引16是命令码,索引17是结束码 if (response[17] != 0x00) throw new Exception($"FINS错误码: 0x{response[17]:X2}"); List<ushort> values = new List<ushort>(); 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代替Sleep

private 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.DelaySerialPort.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(Dictionary<string, 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里显示4000,C#里读出来是16384,那就是字节序或类型长度不一致,优先检查ReadUInt16的合成方向。如果PLC里显示的是小数,需要考虑浮点数格式(IEEE 754)的转换,在C#里用BitConverter.ToSingle先把字节转成32位整数,再调用BitConverter.Int32BitsToSingle。调试时还可以在Sysmac Studio的IO表里把PLC站号临时改大,用C#程序配置成不同DA1去连接,验证站号是否参与通讯流程。

把这套字节日志、手动对比、地址交叉验证结合起来,基本能覆盖现场90%的FINS通讯问题。真正到了网络不稳定、数据量大的项目,再考虑引入HSLCommunication这类成熟库或者换成以太网FINS/TCP,串口这条路走通后,后面的路自然顺了。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 2:38:37

STM32三相SPWM波输出:从定时器配置到死区调试的全流程指南

简介&#xff1a;基于STM32的三相SPWM波输出压缩包&#xff0c;面向电机控制与逆变器方向的嵌入式开发者&#xff0c;重点解决用STM32高级定时器生成相位差120度的三相正弦脉宽调制波。工程覆盖PWM模式配置、预分频、比较寄存器占空比、死区插入&#xff0c;用三角载波与正弦表…

作者头像 李华
网站建设 2026/9/16 2:37:27

MATLAB实战短时交通流量预测:ARIMA+BP组合模型全解析

简介&#xff1a;这是一份面向交通工程、智能交通系统研究人员与MATLAB初学者的短时交通流量预测源码资源。压缩包内为单个MATLAB脚本文件&#xff0c;大小仅1KB&#xff0c;代码轻量紧凑&#xff0c;聚焦交通流量预测这一核心场景。该脚本围绕数据预处理、特征工程、模型构建、…

作者头像 李华
网站建设 2026/9/16 2:37:01

智慧路灯管理后台系统模板:物联网接入与GIS可视化运维实践

简介&#xff1a;面向城市智慧照明管理场景的后台系统模板&#xff0c;适合物联网平台开发人员、前端工程师及方案集成商快速搭建路灯运维管理界面。模板覆盖设备监控、能耗统计、报警管理、远程控制等核心功能模块&#xff0c;并提供地图可视化与图表展示的交互原型。资源以静…

作者头像 李华
网站建设 2026/9/16 2:36:13

Java网络编程实战:从TCP/IP到Socket与UDP通信全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 2:36:08

2026企业AI办公工具选型指南:从场景匹配到平台盘点

企业引入AI办公工具的过程中&#xff0c;很多管理者容易陷入单一指标判断的误区。部分团队评估工具时&#xff0c;只统计平台内置功能数量&#xff0c;或是单纯参考市场热度&#xff0c;以此作为采购决策依据。这类评估方式会忽略工具与企业业务流程的适配性&#xff0c;不少企…

作者头像 李华
网站建设 2026/9/16 2:35:49

系统设计笔记的本质:从概念堆砌到可执行的工程肌肉记忆

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华