ARTICLE DETAIL

资讯详情

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

C# 通过 Socket 与 Modbus 通信:同步与异步配置骨架与验证

C# 通过 Socket 与 Modbus 通信:同步与异步配置骨架与验证 1. 工控上位机里为什么还要手写 Socket 跑 Modbus TCP在 C# 工控上位机开发里读写 PLC、仪表、变频器最常用的协议就是 Modbus。很多人第一反应是装 NModbus 或者用现成库但真到现场你会发现设备五花八门有的返回报文带脏字节有的站号被改过有的要求你精确控制超时和重试节奏。这时候库的封装反而成了黑盒出问题只能干瞪眼。所以我的习惯是核心通信层用原生System.Net.Sockets.Socket手写一遍 Modbus TCP。它本质上就是「TCP 长连接 固定格式的字节数组」搞懂一次后面调任何设备都是改几个字节的事。这篇就聚焦 C# 工控上位机场景把同步阻塞和异步 Task 两套收发骨架都给你再配一份可复制的config.toml把统一 Key/API 通道也一起管起来。适合谁看写过一点 C#、知道Socket大概长啥样但没系统跑通过 Modbus TCP 的同学或者已经在用库、想搞清楚底层报文到底怎么拼的人。读完你能拿到三样东西——一份配置骨架、两套能直接跑的收发代码、一套连通性验证和超时重试的实测动作。先说清楚 Modbus TCP 的报文结构这是后面所有代码的地基。一个请求帧由 MBAP 头7 字节 PDU 组成字段长度示例值含义事务标识符2 字节00 86自己维护请求响应要一致协议标识符2 字节00 00Modbus 固定为 0长度2 字节00 06后面还有多少字节单元标识符1 字节01站号功能码1 字节0303 读保持寄存器起始地址2 字节00 00从哪个寄存器开始寄存器数量2 字节00 0A读 10 个拼成字节数组就是{ 0x00, 0x86, 0x00, 0x00, 0x00, 0x06, 0x01, 0x03, 0x00, 0x00, 0x00, 0x0A }。响应帧的 MBAP 头基本照抄PDU 变成「功能码 字节数 数据」。记住这个结构下面代码里每个字节你都能对上号。2. 前置准备TaoToken 统一 Key 与 API 通道配置工控项目现在越来越多要接大模型做辅助——比如把采集到的异常数据丢给模型做诊断建议或者用 coding-plan 帮你生成寄存器映射表。与其每个项目各写一套鉴权不如用 TaoToken 做统一入口一个 Key 走所有模型通道。TaoToken 在这里的角色是「统一 API 网关」你申请一个 Key就能通过同一套接口访问不同模型省得每个厂商单独对接。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 注册后在控制台生成 Key 即可。API 基址是 https://taotoken.net/api 这个地址不加 UTM 参数直接用于代码里。我建议把这类配置和 Modbus 的设备参数放同一个config.toml集中管理。下面这份骨架你可以直接复制改掉 Key 和 IP 就能用# config.toml —— 工控上位机统一配置骨架 [modbus] host 127.0.0.1 # 设备/仿真器 IP port 502 # Modbus TCP 默认端口 unit_id 1 # 站号 start_address 0 # 起始寄存器 register_count 10 # 读取数量 connect_timeout_ms 3000 read_timeout_ms 2000 max_retry 3 # 超时重试次数 retry_interval_ms 200 [taotoken] api_base https://taotoken.net/api api_key sk-你的Key # 控制台生成勿提交到仓库 model gpt-4o-mini # 按需替换 timeout_ms 15000C# 侧读这份配置用Tomlyn这个库最省事NuGet 搜Tomlynusing Tomlyn; using Tomlyn.Model; public class ModbusConfig { public string Host { get; set; } 127.0.0.1; public int Port { get; set; } 502; public byte UnitId { get; set; } 1; public ushort StartAddress { get; set; } 0; public ushort RegisterCount { get; set; } 10; public int ConnectTimeoutMs { get; set; } 3000; public int ReadTimeoutMs { get; set; } 2000; public int MaxRetry { get; set; } 3; public int RetryIntervalMs { get; set; } 200; public static ModbusConfig Load(string path) { var model Toml.ToModel(File.ReadAllText(path)); var mb (TomlTable)model[modbus]; return new ModbusConfig { Host mb[host].ToString()!, Port Convert.ToInt32(mb[port]), UnitId Convert.ToByte(mb[unit_id]), StartAddress Convert.ToUInt16(mb[start_address]), RegisterCount Convert.ToUInt16(mb[register_count]), ConnectTimeoutMs Convert.ToInt32(mb[connect_timeout_ms]), ReadTimeoutMs Convert.ToInt32(mb[read_timeout_ms]), MaxRetry Convert.ToInt32(mb[max_retry]), RetryIntervalMs Convert.ToInt32(mb[retry_interval_ms]), }; } }注意api_key千万别硬编码进源码提交到 Git。本地开发放config.toml并加进.gitignore生产环境走环境变量或密钥管理服务。3. 同步阻塞版收发骨架同步版适合「采集频率低、逻辑简单」的场景比如每 5 秒读一次温度。代码直白调试时断点一打就能看清每一步。核心是Connect→Send→Receive三步配合ReceiveTimeout防死等。using System.Net; using System.Net.Sockets; public class ModbusSyncClient : IDisposable { private readonly ModbusConfig _cfg; private Socket? _socket; public ModbusSyncClient(ModbusConfig cfg) _cfg cfg; public void Connect() { _socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _socket.ReceiveTimeout _cfg.ReadTimeoutMs; _socket.SendTimeout _cfg.ConnectTimeoutMs; var ep new IPEndPoint(IPAddress.Parse(_cfg.Host), _cfg.Port); _socket.Connect(ep); // 同步阻塞连不上会抛异常 } // 拼一个读保持寄存器(03)的请求帧 private byte[] BuildReadRequest(ushort transactionId) { var frame new byte[12]; frame[0] (byte)(transactionId 8); frame[1] (byte)(transactionId 0xFF); frame[2] 0x00; frame[3] 0x00; // 协议标识 frame[4] 0x00; frame[5] 0x06; // 长度 frame[6] _cfg.UnitId; // 站号 frame[7] 0x03; // 功能码 frame[8] (byte)(_cfg.StartAddress 8); frame[9] (byte)(_cfg.StartAddress 0xFF); frame[10] (byte)(_cfg.RegisterCount 8); frame[11] (byte)(_cfg.RegisterCount 0xFF); return frame; } public byte[] ReadHoldingRegisters(ushort transactionId) { if (_socket null) throw new InvalidOperationException(未连接); var request BuildReadRequest(transactionId); _socket.Send(request, SocketFlags.None); // 响应长度 9 寄存器数*2先按最大可能收 var buffer new byte[9 _cfg.RegisterCount * 2]; int received _socket.Receive(buffer, SocketFlags.None); var result new byte[received]; Array.Copy(buffer, result, received); return result; } public void Dispose() { _socket?.Shutdown(SocketShutdown.Both); _socket?.Close(); _socket?.Dispose(); } }调用起来就三行var cfg ModbusConfig.Load(config.toml); using var client new ModbusSyncClient(cfg); client.Connect(); var resp client.ReadHoldingRegisters(0x0086); Console.WriteLine(BitConverter.ToString(resp));BitConverter.ToString输出形如00-86-00-00-00-17-01-03-14-...比Encoding.Unicode.GetString靠谱得多——后者会往数组里塞00填充读出来全是乱码。这是我早期踩过的坑转十六进制字符串才是工控调试的正确姿势。4. 异步 Task 版收发骨架同步版在 UI 线程里跑会卡界面采集频率一高就顶不住。异步版用Taskasync/await不阻塞主线程还能配合CancellationToken做优雅退出。下面这套是「连接一次、循环读取」的骨架适合上位机后台采集线程。using System.Net; using System.Net.Sockets; public class ModbusAsyncClient : IDisposable { private readonly ModbusConfig _cfg; private Socket? _socket; public ModbusAsyncClient(ModbusConfig cfg) _cfg cfg; public async Task ConnectAsync(CancellationToken ct default) { _socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); var ep new IPEndPoint(IPAddress.Parse(_cfg.Host), _cfg.Port); using var timeoutCts CancellationTokenSource .CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(_cfg.ConnectTimeoutMs); await _socket.ConnectAsync(ep, timeoutCts.Token); } private byte[] BuildReadRequest(ushort transactionId) { var frame new byte[12]; frame[0] (byte)(transactionId 8); frame[1] (byte)(transactionId 0xFF); frame[4] 0x00; frame[5] 0x06; frame[6] _cfg.UnitId; frame[7] 0x03; frame[8] (byte)(_cfg.StartAddress 8); frame[9] (byte)(_cfg.StartAddress 0xFF); frame[10] (byte)(_cfg.RegisterCount 8); frame[11] (byte)(_cfg.RegisterCount 0xFF); return frame; } public async Taskbyte[] ReadHoldingRegistersAsync( ushort transactionId, CancellationToken ct default) { if (_socket null) throw new InvalidOperationException(未连接); var request BuildReadRequest(transactionId); await _socket.SendAsync(request, SocketFlags.None, ct); var buffer new byte[9 _cfg.RegisterCount * 2]; using var timeoutCts CancellationTokenSource .CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(_cfg.ReadTimeoutMs); int received await _socket.ReceiveAsync(buffer, SocketFlags.None, timeoutCts.Token); var result new byte[received]; Array.Copy(buffer, result, received); return result; } public void Dispose() { _socket?.Shutdown(SocketShutdown.Both); _socket?.Close(); _socket?.Dispose(); } }循环采集 超时重试的完整用法var cfg ModbusConfig.Load(config.toml); using var client new ModbusAsyncClient(cfg); using var cts new CancellationTokenSource(); await client.ConnectAsync(cts.Token); ushort txId 0; while (!cts.Token.IsCancellationRequested) { int attempt 0; while (true) { try { var resp await client.ReadHoldingRegistersAsync(txId, cts.Token); Console.WriteLine($[OK] {BitConverter.ToString(resp)}); break; } catch (OperationCanceledException) when (attempt cfg.MaxRetry) { attempt; Console.WriteLine($[重试 {attempt}/{cfg.MaxRetry}] 超时); await Task.Delay(cfg.RetryIntervalMs, cts.Token); } } await Task.Delay(1000, cts.Token); // 采集间隔 }这里有个关键点ReceiveAsync的超时靠CancellationTokenSource.CancelAfter实现而不是Socket.ReceiveTimeout——后者对异步 API 不生效。很多人异步版收不到数据就卡死八成是漏了这个。5. 连通性验证与超时重试实测代码写完别急着接真设备先用仿真器把链路跑通。Modbus Slave 这类仿真工具开一个 TCP 从站监听 502 端口建几个保持寄存器填上值。然后按下面顺序验证第一步确认端口通不通。在命令行敲telnet 127.0.0.1 502能连上说明 TCP 层没问题。连不上先查仿真器是否启动、防火墙是否放行 502。第二步跑同步版读一次看返回的十六进制。正常响应长这样00-86-00-00-00-17-01-03-14-00-0A-00-0B-...拆开看00-86事务号对上了00-00协议标识00-17表示后面还有 23 字节01站号03功能码14是十六进制的 20表示后面有 20 字节数据10 个寄存器 × 2。数据段每两字节一个寄存器值。对不上就检查站号和起始地址。第三步故意制造超时验证重试。把config.toml里的host改成一个不存在的 IP或者把仿真器停掉观察日志[重试 1/3] 超时 [重试 2/3] 超时 [重试 3/3] 超时如果重试次数和间隔符合配置说明重试逻辑生效。实测下来retry_interval_ms设 200 比较稳太短会在网络抖动时疯狂重发太长又拖慢恢复。第四步验证异步版不卡 UI。在 WinForms 里点按钮触发采集同时拖动窗口——如果窗口能正常拖动说明没阻塞主线程。这一步是同步和异步最直观的区别。6. 本篇常见错误排查报错一SocketException: 由于目标计算机积极拒绝无法连接仿真器没启动或者端口写错了。Modbus TCP 默认 502但有些仿真器默认 5020 之类去软件设置里核对。报错二收到数据但全是乱码用了Encoding.Unicode.GetString或Convert.ToBase64String。工控报文是二进制必须用BitConverter.ToString转十六进制或者按字节手动解析。报错三异步版ReceiveAsync永远不返回漏了CancellationTokenSource.CancelAfter或者传的CancellationToken是CancellationToken.None。异步 API 不吃Socket.ReceiveTimeout超时必须靠 token。报错四响应长度对不上解析越界响应帧长度是动态的9 寄存器数 × 2。别写死 29 或 1024按RegisterCount算。如果设备返回异常帧功能码最高位置 1如0x83长度只有 9 字节要单独判断。报错五连续读取时事务号不递增事务标识符用来匹配请求和响应循环读取时每次要txId。如果一直用同一个值在高并发下可能把响应配错请求。单设备单线程问题不大但养成递增习惯。报错六TaoToken 调用返回 401api_key填错或过期。去控制台重新生成注意别把 Key 前后的空格带进去。API 基址确认是https://taotoken.net/api不要多加路径。7. 把配置和代码接进你的项目到这里一份能跑的 Modbus TCP 通信骨架就齐了config.toml管设备参数和 TaoToken 通道同步版负责简单采集异步版负责高频后台读取超时重试兜底网络抖动。你可以先把仿真器跑通再逐个替换成现场设备的 IP 和站号。如果后面要接大模型做数据诊断直接在项目里读config.toml的[taotoken]段用统一 Key 调 API 就行不用再单独维护鉴权。需要生成 Key 或查看接入文档去控制台和文档页想先验证模型通不通用模型对话页试一句长期做编码和 Agent 类任务可以看下 Coding Plan 的额度方案。把通信层和模型层都收进同一份配置项目结构会清爽很多。
返回列表