上位机实践1

📅 2026/7/31 22:31:09 👁️ 阅读次数 📝 编程学习
上位机实践1

1,编写Tcp客户端工具,要求实现可同步/异步接收Tcp服务端的数据,Tcp服务端可使用现有第三方功能;
2,拓展工具功能,支持Modbus通信,支持基本的参数配置,Modbus服务端可使用Modbus Slave;
3,自行编写Tcp服务端,代替之前的第三方Tcp服务端工具,要求可支持主动/被动 发送模式;
4,自行编写Modbus服务端,代替之前使用的Modbus Slave,要求可实现自定义站号/方法/地址等常用服务参数;
5,自定义数据协议,且上面的客户端和服务可根据自定义数据协议实现各自完整的通信流程;

1、tcp客户端/服务端界面

Tcp服务端开启监听,等待客户端连接,这时候需要相同的ip+port才能够完成通信,和客户端发起连接。客户端主动发送数据后服务端会接收到发送的数据。服务端会主动下发数据到客户端。服务端的被动发送是客户端发送请求后服务端再返回特定的返回数据。

1、TcpClientManager.cs

usingSystem;usingSystem.Net.Sockets;usingSystem.Text;usingSystem.Threading.Tasks;namespaceTcpClientApp{publicclassTcpClientManager{privateSystem.Net.Sockets.TcpClientclient;privateNetworkStreamstream;// 连接客户端publicboolConnect(stringip,intport){try{Disconnect();client=newSystem.Net.Sockets.TcpClient();client.Connect(ip,port);stream=client.GetStream();returntrue;}catch(Exceptionex){Console.WriteLine(ex.Message);returnfalse;}}//异步发送数据publicasyncTaskSendAsync(stringmsg){if(client==null||!client.Connected||stream==null)return;byte[]data=Encoding.UTF8.GetBytes(msg);awaitstream.WriteAsync(data,0,data.Length);}//同步发送数据,未使用publicstringReceiveSync(){if(stream==null)returnnull;byte[]buffer=newbyte[1024];intlength=stream.Read(buffer,0,buffer.Length);returnEncoding.UTF8.GetString(buffer,0,length);}// 异步接收数据publicasyncTaskReceiveAsync(Action<string>callback){if(stream==null)return;byte[]buffer=newbyte[1024];try{while(client!=null&&client.Connected){intlen=awaitstream.ReadAsync(buffer,0,buffer.Length);if(len>0){stringmsg=Encoding.UTF8.GetString(buffer,0,len);callback(msg);}else{break;}}}catch{// 忽略断开异常}}// 断开连接publicvoidDisconnect(){try{stream?.Close();client?.Close();stream=null;client=null;}catch{}}}}

知识点:
1、System.Net.Sockets(什么是 Socket)

  • Socket(套接字) 是操作系统提供的一种网络通信抽象机制。
  • 实际作用:它封装了底层的 TCP/IP、UDP 等协议细节。不管是浏览器请求网页,还是游戏连接服务器,底层都是通过 Socket
    建立连接并传送数据的。
  • 代码中的 TcpClient:System.Net.Sockets.TcpClient 是 C# 在低级 Socket
    类之上封装的高级类,专门用来简化 TCP 协议客户端的开发。
    2、GetStream() 的作用
    GetStream() 是 TcpClient 的一个方法,用于获取网络字节流(NetworkStream)。

TCP 是基于字节流(Byte Stream)传输的。获得 NetworkStream 后,你就可以像对文件流(FileStream)一样,使用 Write / WriteAsync 向服务端发送字节,或者用 Read / ReadAsync 从服务端读取字节。

3、async Task(异步编程)用法
async 和 Task 是 C# 中实现异步编程(Async/Await 模式)的核心关键字,用来避免阻塞 UI 界面或主线程。

  • 单线程阻塞问题:如果在主线程调用同步的 stream.Read(…),如果服务器一直没发数据,你的软件界面(如 WinForms / WPF)就会卡死。
  • Task:代表一个正在进行或将要完成的操作(相当于一个“承诺”或异步任务)。
  • async / await:async 修饰方法,表明该方法内部有异步操作。await 用于暂停当前方法的执行,把线程释放回系统处理其他事(比如刷新 UI),直到异步操作(如网络传输)完成后再自动接着往下走。

4、Action callback(回调函数/委托)用法
Action 是 C# 内置的一种委托(Delegate),用来把一个方法作为参数传递给另一个方法。
5、stream?.Close()(空条件运算符与资源释放)

if (stream != null)
{
stream.Close();
}

2、TcpServerManager.cs

usingSystem;usingSystem.Net;usingSystem.Net.Sockets;usingSystem.Text;usingSystem.Threading.Tasks;namespaceTcpServerApp{publicclassTcpServerManager{privateTcpListenerlistener;privateSystem.Net.Sockets.TcpClientclient;publicboolIsAutoReply{get;set;}=false;publicstringAutoReplyContent{get;set;}="";// 开启监听publicasyncTaskStartAsync(stringip,intport){Stop();listener=newTcpListener(IPAddress.Parse(ip),port);listener.Start();client=awaitlistener.AcceptTcpClientAsync();}// 接收异步传输publicasyncTaskReceiveAsync(Action<string>onMessageReceived){if(client==null||!client.Connected)return;NetworkStreamstream=client.GetStream();byte[]buffer=newbyte[1024];try{while(client!=null&&client.Connected){intlen=awaitstream.ReadAsync(buffer,0,buffer.Length);if(len>0){stringmsg=Encoding.UTF8.GetString(buffer,0,len);onMessageReceived?.Invoke(msg);// 被动自动应答逻辑if(IsAutoReply){stringreplyMsg=string.IsNullOrEmpty(AutoReplyContent)?$"[服务端被动回复]: 已成功收到数据 [{msg}]":AutoReplyContent;awaitSendAsync(replyMsg);onMessageReceived?.Invoke($"[服务端被动自动回复] ➔{replyMsg}");}}else{onMessageReceived?.Invoke("[提示] 客户端已断开连接!");break;}}}catch(Exceptionex){onMessageReceived?.Invoke($"[提示] 连接中断:{ex.Message}");}}// 异步发送数据publicasyncTaskSendAsync(stringmsg){if(client==null||!client.Connected)return;NetworkStreamstream=client.GetStream();byte[]data=Encoding.UTF8.GetBytes(msg);awaitstream.WriteAsync(data,0,data.Length);}// 断开连接publicvoidStop(){try{client?.Close();listener?.Stop();client=null;listener=null;}catch{}}}}

2、modbus主站从站

从站开始监听,主站发起来连接也是对应相同的ip+port。主站实现了01 到 06功能码,只提供了读取寄存器和写入寄存器这两个按钮。可以从起始地址、读取长度、写入值设置寄存器的内容。从站动态改变值。

1、ModbusTcpServer

usingSystem;usingNModbus;usingTcpClientApp.Config;namespaceTcpClientApp{publicclassModbusTcpClient{privateSystem.Net.Sockets.TcpClienttcpClient;privateIModbusMastermaster;// 主站自动开启publicboolConnect(ModBusConfigconfig){try{tcpClient=newSystem.Net.Sockets.TcpClient(config.Ip,config.Port);varfactory=newModbusFactory();master=factory.CreateMaster(tcpClient);returntrue;}catch{returnfalse;}}// 读线圈publicbool[]ReadCoils(byteslaveId,ushortstartAddress,ushortcount){returnmaster?.ReadCoils(slaveId,startAddress,count);}// 读保持寄存器publicushort[]ReadHoldingRegisters(byteslaveId,ushortaddress,ushortcount){returnmaster?.ReadHoldingRegisters(slaveId,address,count);}// 读输入寄存器publicushort[]ReadInputRegisters(byteslaveId,ushortaddress,ushortcount){returnmaster?.ReadInputRegisters(slaveId,address,count);}// 写入线圈publicvoidWriteCoil(byteslaveId,ushortaddress,boolvalue){master?.WriteSingleCoil(slaveId,address,value);}// 写入寄存器publicvoidWriteRegister(byteslaveId,ushortaddress,ushortvalue){master?.WriteSingleRegister(slaveId,address,value);}}}

2、ModbusTcpClient

usingSystem;usingSystem.Net;usingSystem.Net.Sockets;usingSystem.Threading.Tasks;usingNModbus;namespaceTcpServerApp{publicclassModbusTcpServer{privateTcpListener_listener;privateIModbusSlaveNetwork_slaveNetwork;privateIModbusSlave_slave;publicbyteSlaveId{get;privateset;}=1;publicboolIsRunning{get;privateset;}=false;publiceventAction<ushort[]>OnRegistersUpdated;// 开启从站publicasyncTaskStartAsync(stringip="127.0.0.1",intport=502,byteslaveId=1,ushortstartAddress=0,ushort[]initialRegisters=null){if(IsRunning)return;SlaveId=slaveId;IPAddressiPAddress=IPAddress.Parse(ip);_listener=newTcpListener(iPAddress,port);_listener.Start();varfactory=newModbusFactory();_slaveNetwork=factory.CreateSlaveNetwork(_listener);_slave=factory.CreateSlave(slaveId);if(initialRegisters!=null&&initialRegisters.Length>0){_slave.DataStore.HoldingRegisters.WritePoints(startAddress,initialRegisters);}else{ushort[]defaultRegisters=newushort[]{0,0,0,0,0,0,0,0,0,0};_slave.DataStore.HoldingRegisters.WritePoints(0,defaultRegisters);}_slaveNetwork.AddSlave(_slave);IsRunning=true;// ✨ 开启后台 200ms 实时监听:一旦客户端写入修改内存,立即自动通知 UI 刷新看板与日志_=Task.Run(async()=>{ushort[]lastValues=newushort[10];while(IsRunning){awaitTask.Delay(200);if(_slave!=null){ushort[]current=GetHoldingRegisters(0,10);boolchanged=false;for(inti=0;i<current.Length;i++){if(current[i]!=lastValues[i]){changed=true;break;}}if(changed){Array.Copy(current,lastValues,current.Length);OnRegistersUpdated?.Invoke(current);}}}});await_slaveNetwork.ListenAsync();}//写入保持寄存器publicvoidSetHoldingRegisters(ushortstartAddress,ushort[]values){if(_slave==null)return;_slave.DataStore.HoldingRegisters.WritePoints(startAddress,values);}//读取保持寄存器publicushort[]GetHoldingRegisters(ushortstartAddress,ushortcount){if(_slave==null)returnnewushort[0];return_slave.DataStore.HoldingRegisters.ReadPoints(startAddress,count);}// 断开连接publicvoidStop(){if(!IsRunning)return;_slaveNetwork?.Dispose();_listener?.Stop();IsRunning=false;}}}

1、为什么变量名开头有一个下划线 _?
变量名开头的下划线 _ 是一种 C# 代码命名规范(Coding Convention),用来标识这个变量是类的私有成员变量(Private Field)。

快速区分变量作用域:
在代码中看到 _listener,就能立马知道它是定义在整个类层级的私有字段;而如果看到没有下划线的 listener,通常代表它是一个局部变量或函数参数。

避免与方法参数同名冲突:
在构造函数或属性赋值时,省去了写 this. 的麻烦。

publicclassServer{privateTcpListener_listener;// 私有字段// 没有下划线时,需要写 this.listener = listener;// 有下划线时,可以直接写:publicServer(TcpListenerlistener){_listener=listener;}}

3、自定义通信协议

一、为什么需要自定义通信协议?

在实际工程中:

  • 普通 TCP 协议:只管发裸数据,容易出现**“粘包/断句乱码”**(比如发"Hello""World",接收端可能收到"HelloWor""ld"),且没有数据校验。
  • Modbus TCP 协议:功能固定,只适合读写 16 位整数寄存器,无法高效传输复杂字符串、结构化浮点传感器或大块数据
  • 自定义通信协议:兼具防粘包断句、CRC16 防乱码校验、消息自增流水号、结构化浮点传感器等能力,是商业设备和硬件通信的标准选择。

二、 协议数据帧全景结构图

我们协议的数据包在网线上传输时,由8 大核心字段紧密排列组成:

字节位置: [ 0..1 ] [ 2 ] [ 3..4 ] [ 5 ] [ 6..7 ] [ 8 .. 8+N ] [ 8+N+1..8+N+2 ] [ 8+N+3..8+N+4 ] 字段名称: [ 帧头 ] [版本] [消息流水号] [命令字] [数据长度] [ 结构化 Payload ] [ CRC-16 校验码 ] [ 帧尾 ] 对应大小: 2字节 1字节 2字节 1字节 2字节 N 字节 (变长) 2 字节 2 字节 示例数据: AA 55 0x10 0x00 0x01 0x03 0x00 0x0E [ 14字节传感器结构 ] 0x4A 0x2B 0x0D 0x0A
  1. 帧头 (0xAA 0x55, 2 字节):协议暗号。接收端检测到0xAA 0x55才开始拆包,过滤无效杂波。
  2. 版本号 (0x10, 1 字节):代表 V1.0 协议,方便未来软件版本升级兼容。
  3. 消息流水号SeqID(2 字节):每发送一个数据包,序号自动加 1(1, 2, 3...),用于防止丢包、重放攻击与并发匹配。
  4. 命令字Cmd(1 字节):代表本包数据的功能类型。
  5. 数据长度 (2 字节):大端序记录后方数据内容 Payload 的实际字节数。
  6. 数据内容Payload(N 字节):变长数据区域(文本、心跳或结构化传感器)。
  7. CRC-16 校验码 (2 字节):标准的CRC16-Modbus查表法冗余校验,拦截网络干扰造成的乱码。
  8. 帧尾 (0x0D 0x0A, 2 字节):固定换行符\r\n,保证整帧数据闭环。

三、 支持的 4 大命令字指令集

命令字 (Hex)命令名称功能描述传输载荷 (Payload) 示例
0x01心跳包 (PING)客户端定期检测服务端是否在线"PING"
0x02文本消息包发送任意长度的 UTF-8 变长文本"Hello Protocol"
0x03高精度传感器包传输结构化多传感器浮点数据设备ID (4B) + 温度 Float (4B) + 湿度 Float (4B) + 电压 UInt16 (2B)
0x80ACK 应答包服务端处理成功后的被动确认回复包含原请求SeqID的回复确认字