☰
C#上位机与PLC Modbus通信源码解析:数据采集到SQL存储
2026/10/7 5:50:46 网站建设 项目流程

简介:这是一份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.sln:Visual 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 ConcurrentQueue<DeviceDataRow> _queue = new ConcurrentQueue<DeviceDataRow>(); // 采集线程写入队列 _queue.Enqueue(new DeviceDataRow { ... }); // 入库线程批量取出 var batch = new List<DeviceDataRow>(); while (batch.Count < 200 && _queue.TryDequeue(out var row)) { batch.Add(row); }

ConcurrentQueue是线程安全的,不需要额外加锁。入库线程做的是先取出一批,再转成 DataTable,最后 WriteToServer。这样设计之后,数据库偶尔响应慢几秒,PLC 通信不会受影响。

5. 常见问题排查:Modbus 通信里的五个翻车现场

5.1 连不上 PLC:IP 能 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 填 235,40002 填 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 的网线做测试,对所有相关方来说都省心。希望帮到你。

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

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

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

立即咨询