在C#上位机开发中,与下位机(如PLC、单片机等)稳定通信至关重要
下面这个表格详细对比了几种主流通信方式
| 通讯方式 及 核心协议 | 关键特点 及 依赖库实现 |
|---|---|
| 串口通信 (RS-232/RS-485) -UART | 硬件简单、成本低、协议简单;需配置参数一致(波特率、数据位等)-System.IO.Ports.SerialPort |
| TCP/IP 网络通信 -TCP | 传输距离远、速率高、可靠(TCP),依赖网络环境 -System.Net.Sockets,TcpListener |
| UDP通信 -UDP | 传输距离远、速率高、实时(UDP),依赖网络环境 -System.Net.Sockets,UdpClient |
| Modbus -Modbus RTU/ASCII, Modbus TCP | 工业标准、主从模式、简单可靠;常用库简化开发 -NModbus库 (NuGet安装) |
串口通信 (SerialPort)
串口通信是一种非常常见且基础的数据传输方式
初始化配置:使用 System.IO.Ports.SerialPort类。关键是要确保参数与下位机完全一致
描述:串口通信是上位机与下位机(如单片机、PLC)常用的通信方式,RS232 适合点对点,RS485 支持多点通信
特点:
1.简单、稳定,适合短距离通信
2.需要配置波特率、数据位、校验位等且保持与下位机一致
应用场景:工业设备控制、传感器数据采集
实例化并配置串口参数:
csharp
// 实例化并配置串口参数
SerialPort mySerialPort = new SerialPort();
mySerialPort.PortName = "COM3"; // 端口号,如COM1, COM2等
mySerialPort.BaudRate = 9600; // 波特率,如9600, 115200等
mySerialPort.DataBits = 8; // 数据位
mySerialPort.Parity = Parity.None; // 校验位
mySerialPort.StopBits = StopBits.One; // 停止位
mySerialPort.ReadTimeout = 500; // 读取超时(毫秒)
mySerialPort.WriteTimeout = 500; // 写入超时(毫秒)
// 订阅数据接收事件(异步处理)
mySerialPort.DataReceived += new SerialDataReceivedEventHandler(SerialPort_DataReceived);
打开连接:
csharp
try {
mySerialPort.Open();
Console.WriteLine("串口打开成功。");
} catch (Exception ex) {
Console.WriteLine($"串口打开失败: {ex.Message}");
}
发送数据:
csharp
// 发送字符串
string message = "Hello, Device!";
mySerialPort.Write(message);
// 或发送字节数组(适用于二进制协议或HEX指令)
byte[] dataToSend = new byte[] { 0xAA, 0x01, 0x0D, ... };
mySerialPort.Write(dataToSend, 0, dataToSend.Length);
接收数据:通过 DataReceived事件处理。务必注意数据包完整性问题,一次触发接收的数据可能不是一个完整的数据包
csharp
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) {
SerialPort sp = (SerialPort)sender;
int bytesToRead = sp.BytesToRead;
byte[] buffer = new byte[bytesToRead];
sp.Read(buffer, 0, bytesToRead);
// 处理接收到的数据 buffer
// 例如,转换为十六进制字符串显示
string receivedHex = BitConverter.ToString(buffer);
Console.WriteLine($"收到数据: {receivedHex}");
// **关键:对于自定义协议,需要在此进行数据包拼接和完整性判断**
// 例如,根据帧头、帧尾、长度字段等判断是否是一个完整包
// 可参考后续的"数据包完整性"部分
}
关闭连接:
csharp
if (mySerialPort != null && mySerialPort.IsOpen) {
mySerialPort.Close();
mySerialPort.Dispose(); // 释放资源
}
关键细节
数据包完整性:DataReceived事件是异步触发的,且触发时机和每次接收的数据量不确定。必须设计接收缓冲区,根据自定义协议(如包含帧头、数据长度、校验和、帧尾的格式)来拼接完整数据包,并进行校验
下面的代码演示了一种简单的处理思路:
csharp
private List<byte> _receiveBuffer = new List<byte>(); // 接收缓冲区
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) {
SerialPort sp = (SerialPort)sender;
byte[] tempBuffer = new byte[sp.BytesToRead];
sp.Read(tempBuffer, 0, tempBuffer.Length);
_receiveBuffer.AddRange(tempBuffer); // 新数据加入缓冲区
// 检查并处理缓冲区中的数据包
ProcessBuffer();
}
private void ProcessBuffer() {
// 示例:假设协议格式为 [帧头0xA5] [数据长度N] [N字节数据] [校验和] [帧尾0x5A]
while (_receiveBuffer.Count >= 5) { // 至少包含帧头、长度(1字节)、校验和、帧尾
int headerIndex = _receiveBuffer.IndexOf(0xA5);
if (headerIndex < 0) {
_receiveBuffer.Clear(); // 没有找到帧头,清空无效数据
break;
}
if (headerIndex > 0) {
_receiveBuffer.RemoveRange(0, headerIndex); // 丢弃帧头前的杂乱数据
continue;
}
if (_receiveBuffer.Count < 3) break; // 数据不够,等待下次接收
byte dataLength = _receiveBuffer[1]; // 获取数据段长度
int totalPacketLength = dataLength + 4; // 总包长 = 帧头(1) + 长度(1) + 数据(N) + 校验(1) + 帧尾(1)
if (_receiveBuffer.Count < totalPacketLength) break; // 数据包不完整,退出循环等待
// 提取完整数据包
byte[] packet = _receiveBuffer.GetRange(0, totalPacketLength).ToArray();
// 检查帧尾
if (packet[totalPacketLength - 1] != 0x5A) {
_receiveBuffer.RemoveAt(0); // 帧尾错误,丢弃帧头,继续查找下一个帧头
continue;
}
// 校验和检查 (示例为异或校验)
byte checksum = 0;
for (int i = 1; i < totalPacketLength - 2; i++) { // 从长度字节到数据末尾
checksum ^= packet[i];
}
if (checksum == packet[totalPacketLength - 2]) {
// 校验通过,处理有效数据包 packet[2..(totalPacketLength-2)]
OnValidPacketReceived(packet);
} else {
// 校验失败,记录日志
Console.WriteLine("校验和错误!");
}
// 从缓冲区中移除已处理的数据包
_receiveBuffer.RemoveRange(0, totalPacketLength);
}
}
这是一个简化示例,实际协议可能更复杂。核心思想是维护一个缓冲区,并按照协议规则从中提取和验证完整的数据包
跨线程访问UI:DataReceived事件在非UI线程中执行。如果要在其中更新界面控件(如TextBox、Label),必须通过 Control.Invoke或 Dispatcher.Invoke切换回UI线程
csharp
this.Invoke(new Action(() => {
textBoxReceivedData.AppendText($"收到: {Encoding.ASCII.GetString(buffer)}\r\n");
}));
TCP/IP通信
TCP/IP 是可靠的面向连接协议,适合上位机与下位机之间需要高可靠性的通信
特点:
1.提供可靠的数据传输,适合长连接
2.数据格式需自定义,灵活性高
应用场景:自定义协议通信、实时数据传输
建立连接(使用 TcpClient):
csharp
using System.Net.Sockets;
TcpClient tcpClient = new TcpClient();
try {
await tcpClient.ConnectAsync("192.168.1.100", 12345); // 服务器IP和端口
Console.WriteLine("已连接到服务器。");
NetworkStream stream = tcpClient.GetStream();
// 启动单独任务或线程接收数据
_ = Task.Run(() => ReceiveData(stream));
} catch (Exception ex) {
Console.WriteLine($"连接失败: {ex.Message}");
}
发送数据:
csharp
string message = "MEAS:VOLTage:ALL?\n"; // 示例指令
byte[] data = Encoding.UTF8.GetBytes(message);
await stream.WriteAsync(data, 0, data.Length);
接收数据:在独立线程或异步任务中循环读取
csharp
private async Task ReceiveData(NetworkStream stream) {
byte[] buffer = new byte[1024];
while (tcpClient.Connected) {
try {
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0) {
Console.WriteLine("连接已关闭。");
break; // 连接已断开
}
string receivedData = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"从服务器接收: {receivedData}");
// 触发事件或回调,通知UI更新(注意跨线程调用)
} catch (Exception ex) {
Console.WriteLine($"接收数据异常: {ex.Message}");
break;
}
}
}
关闭连接:
csharp
stream?.Close();
tcpClient?.Close();
关键细节
1.粘包与拆包:与串口类似,TCP是流式协议,消息边界不清晰。也需要定义应用层协议(如消息长度前缀、特定分隔符)来确保正确拆分数据包
2.异步操作:推荐使用 ConnectAsync, ReadAsync, WriteAsync等异步方法,避免阻塞UI线程
3.连接状态管理:需要维护连接状态(IsConnected),并在发送和接收时进行判断。妥善处理断线重连逻辑
UDP 协议
UDP 是无连接的协议,速度快但不可靠,适合实时性要求高但允许少量丢包的场景
特点:
1.无连接,传输效率高
2.数据格式需自定义
应用场景:实时传感器数据、视频流传输
代码示例(上位机发送和接收 UDP 数据):
csharp
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
class Program
{
static void Main(string[] args)
{
try
{
// 创建 UDP 客户端
UdpClient udpClient = new UdpClient();
IPEndPoint remoteEndPoint = new IPEndPoint(IPAddress.Parse("192.168.1.100"), 12345); // 下位机地址
// 发送数据
string message = "Hello, UDP Device!";
byte[] sendBytes = Encoding.ASCII.GetBytes(message);
udpClient.Send(sendBytes, sendBytes.Length, remoteEndPoint);
Console.WriteLine($"发送: {message}");
// 接收数据
IPEndPoint receiveEndPoint = new IPEndPoint(IPAddress.Any, 0);
byte[] receiveBytes = udpClient.Receive(ref receiveEndPoint);
string receivedData = Encoding.ASCII.GetString(receiveBytes);
Console.WriteLine($"接收: {receivedData} from {receiveEndPoint}");
// 关闭客户端
udpClient.Close();
}
catch (Exception ex)
{
Console.WriteLine($"错误: {ex.Message}");
}
}
}
Modbus协议
Modbus是工业领域广泛应用的标准协议
C# 实现核心步骤与代码 (以Modbus TCP为例)
使用 NModbus 库(通过NuGet安装 NModbus)可以极大简化开发
csharp
using Modbus.Device;
using System.Net.Sockets;
string ipAddress = "192.168.1.100"; // 下位机(从站)IP
int port = 502; // Modbus TCP默认端口
using (TcpClient client = new TcpClient(ipAddress, port)) {
// 创建Modbus主站
ModbusIpMaster master = ModbusIpMaster.CreateIp(client);
byte slaveId = 1; // 从站地址
ushort startAddress = 0; // 起始寄存器地址
ushort numRegisters = 10; // 要读取的寄存器数量
// 读取保持寄存器(功能码03)
ushort[] holdingRegisters = master.ReadHoldingRegisters(slaveId, startAddress, numRegisters);
// 写入单个线圈(功能码05)
master.WriteSingleCoil(slaveId, 0, true);
// 写入单个寄存器(功能码06)
master.WriteSingleRegister(slaveId, 1, 1234);
// 输出结果
for (int i = 0; i < holdingRegisters.Length; i++) {
Console.WriteLine($"寄存器 {startAddress + i}: {holdingRegisters[i]}");
}
}
关键细节
1.寄存器地址:注意协议中的寄存器地址通常是从0开始的。有些设备文档可能使用从1开始的地址,需要转换
2.功能码:不同功能码对应不同操作(如01读线圈,02读离散输入,03读保持寄存器,04读输入寄存器,05写单个线圈,06写单个寄存器等)
3.异常处理:库方法可能会抛出 ModbusException,需要捕获处理
通用注意事项与最佳实践
无论选择哪种方式,以下几点都至关重要:
定义应用层协议:通信双方必须预先严格约定数据格式,例如帧头、数据长度、指令类型、有效数据、校验和(如CRC、求和、异或)、帧尾。校验和能有效发现传输错误
超时与重试机制 :发送指令后应设置合理的等待响应超时。超时后触发重发,并限制重试次数,避免无限等待。
资源释放:通信对象(如 SerialPort, TcpClient)使用了非托管资源。务必使用 using语句或显式调用 Close()/Dispose()确保资源释放
线程安全:在异步接收数据时,对共享资源(如发送队列、连接状态标志)的访问要使用锁(lock)等机制保证线程安全
日志记录:记录关键的通信事件(如连接建立、数据发送接收、错误异常),便于后期调试和故障排查