ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#上位机与PLC Modbus通信源码解析:数据采集到SQL存储

C#上位机与PLC Modbus通信源码解析:数据采集到SQL存储 简介这是一份C#与通用PLC通过Modbus通信的实例源码来自真实生产数据采集项目上位机采用C#编写通过Modbus协议对接低端PLC并将设备运行数据写入SQL数据库。压缩包共42个文件含6个cs源码、5个exe可执行程序另有resx窗体资源、pdb调试文件及项目配置等整体仅977KB结构完整。已有1181人浏览学习适合新手及有开发经验的工程人员。读者可获得HaierDataCollecing解决方案包括主窗体数据读写逻辑、程序入口、资源与项目文件代码清晰可帮助理解Modbus通信封装、PLC数据采集与SQL落库的整体流程也可直接运行验证或按需改造。1. C# 与 PLC 的 Modbus 通信这个源码解决的是什么问题前阵子公司接了个项目要采集客户设备在生产过程中的数据并保存到 SQL 数据库。硬件侧是 PLC上位机用 C# 编写一开始我按惯性想直接走 TCP/IP 协议结果发现客户采购的低端 PLC 根本不支持直接挂工业以太网要用 OPC 服务还得单独部署一台网关成本直接上去了。后来换成 Modbus 协议一条命令读一片寄存器链路很快打通。这个实例源码就是当时完整跑在产线上的那套东西C# 上位机、Modbus 通信、SQL 存储从连接 PLC 到数据落库全流程都有。适合刚接触上位机开发的新手也适合做设备数据采集的熟手做参考尤其是那些预算有限、只能用低端 PLC 的场景。下面我把选型逻辑、关键代码和踩过的坑都拆开讲。2. Modbus 协议选型为什么放弃 TCP/IP 和 OPC直接读写寄存器2.1 低端 PLC 的通信现实能用的协议很少Modbus 是底线项目原话写得很实在公司采购的 PLC 属于低端产品需要 OPC 服务。翻译成现场语言就是这台 PLC 的以太网口可能只支持 Modbus TCP 服务或者只有串口支持 Modbus RTU厂家不给 S7 那样私有协议的口子。低端 PLC 上能选的通信方式就那么几样Modbus 是出厂标配协议文档公开任何语言都能自己实现。OPC 这条路不是不能走但 OPC Server 要么装在另一台 Windows 机器上要么买个网关盒子多一层设备就多一个故障点还要管授权。而 Modbus 是开放的C# 侧用现成库或者自己拼报文都不难。所以这个项目选 Modbus 不是因为它有多先进是因为在低端 PLC 上它是底线也是上限。选型时先看 PLC 的通讯手册确认支持 Modbus RTU 还是 Modbus TCP。这两者的差别不只是介质还影响报文结构、轮询方式和从站数量对比项Modbus RTUModbus TCP选型参考传输介质RS-232 / RS-485 串口以太网现场已有 485 布线就选 RTU报文校验CRC16MBAP 头无 CRCRTU 对线缆质量更敏感从站数量总线挂载有限通常 32 台以内几乎不限多台设备集中采集用 TCP波特率限制轮询周期受波特率约束百兆网口带宽充裕高速采集必须 TCP实现复杂度需要自己处理 CRC 和字节序报文更规整好调试新手建议从 TCP 入手低端 PLC 的 RS-485 口是标配许多老产线布线也是 485 总线。如果现场只有两三个从站、数据量不大RTU 足够稳定。如果像我这次一样客户设备已经联网PLC 带以太网口直接走 Modbus TCP 最省事一根网线插上就能通。2.2 功能码与寄存器模型电气人眼里是地址软件人眼里是内存PLC 的数据区域被 Modbus 映射成四类对象线圈、离散输入、保持寄存器、输入寄存器。实际采集生产数据绝大多数点位是开关量和模拟量落在保持寄存器和线圈上。S7-200 SMART、台达 DVP、汇川 H 系列这个映射基本通用Modbus 地址区间功能码数据类型常见 PLC 区域00001 - 0999901 读 / 05 写线圈位M 区 / Q 区10001 - 1999902 读离散输入位I 区30001 - 3999904 读输入寄存器字AI 区40001 - 4999903 读 / 06 写保持寄存器字D 区 / 数据寄存器C# 侧采集生产数据我用得最多的是 03 功能码读保持寄存器。一次请求最多读 125 个寄存器把连续地址一次拉回来再在内存里拆位拆字节。这样比一条一条读快得多也减少总线上的报文数量。一个容易混淆的细节地址编号从 1 开始还是从 0 开始。协议报文里的地址字段是从 0 开始的而设备手册上写的通常是 40001 这种从 1 开始的编号。比如手册说数据在 40001那报文里起始地址填 0数据在 41001报文里填 1000。这个偏移处理不好读出来的数据全是乱的。功能码的边界也要心里有数03 读的是保持寄存器04 读输入寄存器。前者是 PLC 程序里写出来的数据后者是外部硬接线的模拟量输入。生产数据采集比如设备当前温度、压力、扭矩、产量计数基本都在保持寄存器里因为 PLC 程序要把传感器信号做累加、滤波、换算之后放到 D 区上位机才能读到有意义的值。3. 源码结构拆解从 HaierDataCollecing.sln 到真实可跑的轮询逻辑3.1 工程文件里有什么sln、Form1、pfx 和资源文件拿到源码包先看文件清单再动手。这套代码的核心结构是这样的HaierDataCollecing.slnVisual Studio 解决方案入口双击打开Program.cs应用程序入口负责启动 WinForms 主窗体Form1.cs和Form1.Designer.cs主窗体的逻辑代码和界面设计代码Properties/包含程序集信息和ResourceHome.png图标资源HaierDataCollecing_TemporaryKey.pfx临时签名证书用于开发阶段编译注意那个.pfx文件它是 Visual Studio 在启用 ClickOnce 签名时自动生成的临时密钥。如果打开项目提示证书无效不用慌在项目属性里把签名选项取消勾选或者在同一个解决方案里重新生成一个临时证书就行不影响业务代码。解决方案名里的HaierDataCollecing是客户代号但代码逻辑不绑定特定品牌。把 PLC 的 IP、端口、寄存器地址改掉就能接到其他设备上。之前有同事拿这套代码去接测扭矩值的仪表仪表侧支持 Modbus RTU同样用这个套路跑通了。3.2 核心读取流程TCP 连接、MBAP 请求、响应解析上位机连 PLC 的常规套路是TcpClient 连接 502 端口组装一条 Modbus TCP 请求发送后读取响应校验功能码和数据长度再按点位表解析。下面是组装请求的核心代码// 组装 Modbus TCP 读保持寄存器请求 var tcpClient new TcpClient(); tcpClient.Connect(192.168.1.10, 502); // PLC 的 IP 和 Modbus TCP 端口 var stream tcpClient.GetStream(); byte[] transactionId { 0x00, 0x01 }; // 事务标识符每请求递增 byte[] protocolId { 0x00, 0x00 }; // 协议标识符Modbus 固定为 0 byte[] length { 0x00, 0x06 }; // 后续字节数UnitID 功能码 地址 数量 byte unitId 0x01; // 站号和 PLC 通讯参数保持一致 byte functionCode 0x03; // 功能码读保持寄存器 byte[] startAddress { 0x00, 0x00 }; // 起始地址 0对应手册里的 40001 byte[] quantity { 0x00, 0x0A }; // 读取 10 个寄存器 byte[] request new byte[12]; request[0] transactionId[0]; request[1] transactionId[1]; request[2] protocolId[0]; request[3] protocolId[1]; request[4] length[0]; request[5] length[1]; request[6] unitId; request[7] functionCode; request[8] startAddress[0]; request[9] startAddress[1]; request[10] quantity[0]; request[11] quantity[1]; stream.Write(request, 0, request.Length);这段代码的要点是 MBAP 头前 6 个字节是固定结构后面跟着功能码和数据。length字段只算 UnitID 之后的字节数读请求固定是 6。事务 ID 每次请求要递增否则多线程轮询时响应和请求对不上那是排查起来最难受的玄学问题。响应解析同样有固定格式// 读取响应并解析 byte[] response new byte[256]; int read stream.Read(response, 0, response.Length); if (response[7] functionCode) // 功能码原样返回说明正常 { int byteCount response[8]; // 后面跟随的数据字节数 ushort[] registers new ushort[byteCount / 2]; for (int i 0; i byteCount / 2; i) { // 大端序高字节在前低字节在后 registers[i] (ushort)((response[9 i * 2] 8) | response[10 i * 2]); } }响应解析的边界条件要盯死功能码最高位置 1 是异常响应比如0x83表示读保持寄存器请求被拒绝byteCount 必须是偶数的 2 倍否则报文截断。新手最容易踩的就是以为所有 PLC 都是大端序遇到台达某些型号配置了小端模式高低字节一换读出来的数据直接翻几倍。读回来之后的数据怎么显示和存储就看项目里 Form1 的处理逻辑。WinForms 主窗体一般会放一个 Timer定时触发展轮询把解析结果更新到界面上。这套源码的轮询逻辑就在Form1.cs里打开Timer_Tick事件就能看到完整链路。4. 寄存器映射与 SQL 入库参数怎么设、数据怎么存更稳4.1 点位表与字节序拿到点位之后的统一动作设备数据采集项目里PLC 程序一般由电气工程师或者设备厂家写好上位机这边拿到的是点位表。点位表长什么样大致是这张表的格式寄存器地址点位名称数据类型倍率读写方向40001设备运行时长uint161只读40002当前产量uint321只读40004温度int160.1只读40010设备启停bool-读写拿到点位表的第一步不是写代码是先在 Excel 里做地址规划。上面例子有个细节40002 是 uint32占两个寄存器那 40003 就不能再分配给其他点位否则数据会冲突。bool 点位在保持寄存器里是按位存放的一个寄存器 16 个位可以放 16 个开关量解析时要移位取位。这里我把地址映射做成一个配置类而不是写死在 Form1 里public class PointItem { public string Name { get; set; } public int Address { get; set; } // 报文里的地址偏移 public DataType Type { get; set; } // UInt16, Int16, UInt32, Bool public double Scale { get; set; } // 倍率温度传感器常用 0.1 }Scale这个参数容易忽略。PLC 程序里温度值存的是 235倍率 0.1实际温度是 23.5 度。如果上位机不做倍率换算直接入库报表里所有温度都放大十倍产线工程师一看数据就知道不对。倍率放到配置里换点位不用改代码只改配置。字节序问题在这个环节就要定死。Modbus 协议标准是大端序但不少国产 PLC 和仪表为了兼容旧系统寄存器内部存储是小端序。我一般会在统一解析函数里加一个字节序开关// 解析 32 位整数兼容大小端配置 public uint ReadUInt32(byte[] buffer, int offset, bool bigEndian) { if (bigEndian) return (uint)((buffer[offset] 24) | (buffer[offset 1] 16) | (buffer[offset 2] 8) | buffer[offset 3]); else return (uint)((buffer[offset 2] 24) | (buffer[offset 3] 16) | (buffer[offset] 8) | buffer[offset 1]); }这个开关不是拍脑袋加的是吃过亏才加的。之前接入一台国产仪表文档写的是标准 Modbus结果读出来的数据高低字完全颠倒查了半天最后拿 Modbus Poll 手发报文对照才发现是小端模式。从那以后我接任何新设备第一件事就是用 Modbus Poll 手动读寄存器确认字节序再写代码。4.2 数据入库从 DataTable 到 SQL Server 的批量写入数据采集程序的核心诉求是把生产过程数据存进 SQL但逐条 INSERT 的性能在点位多的时候会拖垮程序。一台设备 50 个点位一秒轮询一次一分钟就是 3000 次插入SQL Server 的事务日志和锁竞争足够让采集线程卡死。常见做法是攒一批数据用 SqlBulkCopy 批量写入。这也是这套源码里值得抄的部分// 批量写入采集数据到 SQL Server DataTable dt new DataTable(DeviceData); dt.Columns.Add(DeviceId, typeof(int)); dt.Columns.Add(PointName, typeof(string)); dt.Columns.Add(PointValue, typeof(double)); dt.Columns.Add(CollectTime, typeof(DateTime)); // 把每次轮询解析的 N 个点位填入 DataTable foreach (var point in parsedPoints) { dt.Rows.Add(deviceId, point.Name, point.Value, DateTime.Now); } using (var bulk new SqlBulkCopy(connectionString)) { bulk.DestinationTableName DeviceData; bulk.ColumnMappings.Add(DeviceId, DeviceId); bulk.ColumnMappings.Add(PointName, PointName); bulk.ColumnMappings.Add(PointValue, PointValue); bulk.ColumnMappings.Add(CollectTime, CollectTime); bulk.WriteToServer(dt); }SqlBulkCopy的优势是走原生大容量接口吞吐量比逐条 INSERT 高一个数量级。需要注意的一点如果目标表结构变了比如加了列或者改了列名映射关系要同步更新否则运行时会报列名无效。之前遇到过表加了列但映射没更新的情况程序在轮询到第 50 个点位时中断因为目标表的字段顺序和 DataTable 对不上。写入频率不要和轮询频率绑定在同一个 Timer 里。我一般会把采集和入库解耦采集线程负责读 PLC、解析、放进内存队列入库线程每隔 5 秒或者攒够 200 条执行一次批量写入。这样 PLC 通信的抖动不会直接影响数据库写入数据库写入慢也不会阻塞采集。采集线程和入库线程之间用一个ConcurrentQueue传递数据private ConcurrentQueueDeviceDataRow _queue new ConcurrentQueueDeviceDataRow(); // 采集线程写入队列 _queue.Enqueue(new DeviceDataRow { ... }); // 入库线程批量取出 var batch new ListDeviceDataRow(); while (batch.Count 200 _queue.TryDequeue(out var row)) { batch.Add(row); }ConcurrentQueue是线程安全的不需要额外加锁。入库线程做的是先取出一批再转成 DataTable最后 WriteToServer。这样设计之后数据库偶尔响应慢几秒PLC 通信不会受影响。5. 常见问题排查Modbus 通信里的五个翻车现场5.1 连不上 PLCIP 能 Ping 通但 502 端口无响应现象上位机提示TcpClient.Connect超时但 Ping PLC 的 IP 地址是通的。原因PLC 的 Modbus TCP 服务没有启用。很多低端 PLC 的以太网口默认只用来做程序下载和 HMI 通信Modbus TCP 服务需要在 PLC 编程软件里单独勾选启用。还有一种是站号和 IP 都对但防火墙拦了 502 端口。解决先到 PLC 系统块里确认 Modbus TCP 使能选项打开。然后用telnet 192.168.1.10 502测试端口连通性能连通说明服务已开启。如果 telnet 通了但程序还连不上检查 C# 代码里的连接超时设置TcpClient.Connect默认可能无限等待建议加上try/catch并设置 3 秒超时。5.2 读到的数据是实际值的数倍或小数位错乱现象温度读数正常显示 235程序读出来变成 2350。或者寄存器读出来 0x0102仪表显示 0x0201。原因倍率没有换算或者字节序不正确。温度值在 PLC 里存的是 235倍率 0.1上位机没有乘倍率直接入库。另一种是高低位颠倒0x0102按大端解析是 258按小端解析是 513。解决点位表里给每个点位配Scale字段入库前统一乘倍率。字节序问题用 Modbus Poll 手动构造报文对比先确认协议是标准大端还是小端再在解析函数里加字节序开关。5.3 轮询一会儿就断线程序卡死在读响应现象程序运行 5 分钟后通信中断界面无响应重启程序恢复正常。原因轮询频率太快PLC 那边处理不过来或者通信超时后没有清理失败的 TcpClient 连接。上次的项目是被调试助手的保持连接参数带偏了实际产线上 PLC 带的设备多了响应时间不稳定一旦读超时Socket 没有释放后续所有请求全部堆积。解决统一管理连接生命周期。每次轮询前检查tcpClient.Connected实际这个属性并不可靠最稳的是在读响应时设置ReadTimeout超时就Dispose掉整个客户端重新连接。轮询周期不要低于 500ms多数低端 PLC 处理一条读请求需要 50ms 到 200ms周期太短迟早出问题。5.4 SqlBulkCopy 报目标表结构无效现象批量入库时抛出异常错误信息是目标表的列名或数据类型不匹配。原因数据库表结构改动后代码里的ColumnMappings没有同步更新。比如表加了WorkShift列但映射里没有这一列SQL Server 在接受 BulkCopy 操作时会拒绝整个批次。解决写一个初始化方法在程序启动时查询目标表的 schema动态生成映射关系。如果项目简单更直接的做法是把ColumnMappings集中配置到一个常量类里表结构变更时只改一处。5.5 读返回异常功能码数据全是零现象响应报文收到0x83、0x04这种异常码或者寄存器值全部为 0。原因0x83的高位是异常标志0x04表示请求的设备地址越界。最常见的是上位机请求的寄存器数量超过了 PLC 实际配置的数据区。比如 PLC 程序只配置了 10 个保持寄存器上位机一次读 20 个PLC 直接拒绝。解决把读取数量限制在点位表实际范围内。每个设备关联一个点位表配置按配置计算读取长度不要写死 125 个寄存器。全部为 0 的情况还要检查站号对不对多台 PLC 组网时站号传错请求会打到错误的设备上。6. 用 Modbus Poll 桩测上位机拿到新程序先做端口仿真验证写完上位机程序直接接 PLC 调试风险在于出了问题不知道是 PLC 那边配置不对还是 C# 代码写错了。Modbus Poll 这个工具可以模拟一个 Modbus 从站在上位机程序的眼里它就是一台 PLC。先把 Modbus Poll 配置成模拟设备打开软件后选择 TCP/UDP 模式监听 502 端口站号设为 1。在功能码下拉框选 03读保持寄存器起始地址设 0数量设 10。然后在寄存器表格里手动填上测试值比如 40001 填 23540002 填 1000。这样它就扮演了一台保持寄存器里存着数据的 PLC。接着跑上位机程序把连接 IP 指向本机127.0.0.1端口 502。观察两点第一上位机的点位值显示和 Modbus Poll 里填的值对不对第二Modbus Poll 下方的通信日志里接收到的请求报文是否符合预期。尤其要关注通信日志里的时间戳。如果请求报文一条接一条没有间隔说明上位机的轮询周期设置太快模拟从站能扛住实际 PLC 可能扛不住。这时把上位机侧轮询周期从 100ms 调到 1000ms 重新测试观察日志里的请求间隔是否稳定。核对字节序也靠这招在 Modbus Poll 的寄存器里填0x1234如果上位机显示 4660说明大端解析正确如果显示 13330说明高低字节颠倒了。这个对照测试比对着协议文档翻半天快得多。测完 Modbus TCP再验证 SQL 入库逻辑。把程序跑一晚上保持模拟从站在线第二天检查数据库里的数据连续性。如果中间有断档回看程序日志里是否出现了重连记录排查连接管理是否异常。这套桩测流程做下来能过滤掉大部分低级错误。经验是每次程序改动之后都强制走一遍 Modbus Poll 模拟流程再放行不用真的去拨产线 PLC 的网线做测试对所有相关方来说都省心。希望帮到你。本文还有配套的精品资源点击获取
返回列表