串口 + UDP 双模式中转服务器(C# WinForms)详解
本文基于提供的完整代码(含主窗体与 IP 工具类),系统解析集串口通讯 与UDP 服务器于一体的中转程序,涵盖核心功能、代码逻辑、配置要求及运行流程,适用于硬件设备(如传感器、单片机)与网络客户端的双向数据交互场景(如物联网数据转发)。
一、项目整体概述
1. 核心定位
这是一个 "硬件 - 网络" 中转服务器,通过串口连接硬件设备 、UDP 协议连接网络客户端,实现双向数据转发:
-
下行转发:接收 UDP 客户端指令 → 通过串口发送给硬件设备
-
上行转发:接收硬件设备响应 → 通过 UDP 广播给所有客户端
-
状态监控:实时显示串口 / 网络连接状态(指示灯)、统计数据收发量
-
配置化:从配置文件加载串口参数与 UDP 端口,降低硬编码依赖
2. 技术栈与依赖
技术 / 组件 | 作用 | 核心类 / 方法 |
---|---|---|
C# WinForms | 图形化界面(参数配置、状态显示) | Form 、ComboBox 、Panel |
串口通讯(System.IO.Ports ) |
与硬件设备交互(数据收发) | SerialPort 、DataReceived 事件 |
UDP 服务器(System.Net.Sockets ) |
与网络客户端交互(数据收发) | Socket 、IPEndPoint 、ReceiveFrom |
配置文件(System.Configuration ) |
存储串口参数、UDP 端口 | ConfigurationManager.AppSettings |
异步任务(System.Threading.Tasks ) |
后台处理 UDP 数据接收(避免 UI 阻塞) | Task.Run 、CancellationTokenSource |
二、界面组件与功能映射
需在 WinForms 设计器中创建以下控件,控件与业务逻辑的对应关系如下:
控件类型 | 控件名 | 功能描述 |
---|---|---|
ComboBox |
cbbPortName |
选择串口号(自动加载本地可用串口,如COM3 ) |
ComboBox |
cbbBaudRate |
选择波特率(如9600 ,默认值从配置文件加载) |
ComboBox |
cbbDataBit |
选择数据位(如8 ,默认值从配置文件加载) |
ComboBox |
cbbStopBit |
选择停止位(如One ,默认值从配置文件加载) |
ComboBox |
cbbParity |
选择校验位(如None ,默认值从配置文件加载) |
Button |
btnStart |
启动 / 停止服务(文本切换为 "启动"/"停止") |
Panel |
panelSerialPort |
串口状态指示灯(绿色 = 已打开 / 数据传输,灰色 = 已关闭) |
Panel |
panelNetwork |
网络状态指示灯(绿色 = 已启动 / 数据传输,灰色 = 已停止) |
TextBox |
txtSendCount |
统计 "UDP→串口" 的总数据量(字节数,即发送给硬件的指令大小) |
TextBox |
txtReceiveCount |
统计 "串口→UDP" 的总数据量(字节数,即硬件响应转发给客户端的大小) |
ToolStripStatusLabel |
tsslServerStatus |
显示服务器状态(如 "服务器已启动,IP:192.168.1.100,Port:8888") |
三、核心配置文件(App.config)
程序依赖配置文件存储串口参数与 UDP 端口,需手动在项目中添加,格式如下:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<!-- 串口参数:需与硬件设备的串口配置完全一致 -->
<add key="PortName" value="COM3" /> <!-- 串口号(如COM3、COM4) -->
<add key="BaudRate" value="9600" /> <!-- 波特率(常见值:9600、115200) -->
<add key="DataBit" value="8" /> <!-- 数据位(通常为8) -->
<add key="StopBit" value="One" /> <!-- 停止位(One/Two/None) -->
<add key="Parity" value="None" /> <!-- 校验位(None/Odd/Even) -->
<!-- UDP服务器配置 -->
<add key="port" value="8888" /> <!-- UDP绑定端口(需与客户端端口一致) -->
</appSettings>
</configuration>
四、完整代码解析
1. 工具类:本地 IP 获取(IPHelper
)
功能
从本地主机的所有网络适配器中,筛选出IPv4 地址(排除 IPv6、虚拟网卡地址),用于 UDP 服务器绑定本地 IP。
代码解析
using System.Net;
using System.Net.Sockets;
namespace 服务器.Helpers
{
public static class IPHelper
{
public static IPAddress GetLocalIP()
{
IPAddress address = null;
// 1. 获取本地主机名对应的IP条目(包含所有网卡的IP)
IPHostEntry entry = Dns.GetHostEntry(Dns.GetHostName());
// 2. 遍历所有IP,筛选IPv4地址
foreach (var item in entry.AddressList)
{
// AddressFamily.InterNetwork 表示IPv4协议
if (item.AddressFamily == AddressFamily.InterNetwork)
{
address = item;
break; // 取第一个IPv4地址(通常为局域网IP)
}
}
return address;
}
}
}
示例
若本地计算机在局域网中的 IP 为192.168.1.100
,则GetLocalIP()
返回192.168.1.100
,用于 UDP 服务器绑定。
2. 主窗体类(ServerFrm
)
2.1 类成员变量
存储程序核心状态与对象,生命周期与窗体一致:
SerialPort serialPort; // 串口对象(与硬件交互)
Socket server; // UDP服务器Socket(与客户端交互)
CancellationTokenSource cts; // 异步任务取消令牌(控制UDP接收任务)
List<EndPoint> clients = new List<EndPoint>(); // 已连接UDP客户端列表
2.2 窗体初始化(ServerFrm_Load
与InitSerialPort
)
功能
窗体加载时,初始化串口参数下拉框(加载本地可用串口 + 配置文件默认值),为启动服务做准备。
代码解析
// 窗体加载事件:触发串口初始化
private void ServerFrm_Load(object sender, EventArgs e)
{
InitSerialPort();
}
// 初始化串口参数下拉框
private void InitSerialPort()
{
// 1. 加载本地所有可用串口号到cbbPortName
cbbPortName.DataSource = SerialPort.GetPortNames();
// 2. 从配置文件加载默认参数并自动选中
cbbPortName.SelectedItem = ConfigurationManager.AppSettings["PortName"];
cbbBaudRate.SelectedItem = ConfigurationManager.AppSettings["BaudRate"];
cbbDataBit.SelectedItem = ConfigurationManager.AppSettings["DataBit"];
cbbStopBit.SelectedItem = ConfigurationManager.AppSettings["StopBit"];
cbbParity.SelectedItem = ConfigurationManager.AppSettings["Parity"];
}
示例
程序启动后,cbbPortName
下拉框显示COM3
、COM4
(本地可用串口),cbbBaudRate
默认选中9600
(配置文件值)。
2.3 启动 / 停止服务(btnStart_Click
)
功能
通过btnStart
按钮切换服务状态:点击 "启动" 时初始化串口与 UDP 服务器;点击 "停止" 时释放资源并还原控件。
代码解析
private void btnStart_Click(object sender, EventArgs e)
{
try
{
if (btnStart.Text == "启动")
{
StartServer(); // 启动串口+UDP服务器
AcceptData(); // 启动UDP客户端数据接收任务
}
else
{
StopServer(); // 停止服务,释放资源
}
}
catch (Exception ex)
{
// 捕获异常并提示(如串口被占用、端口已使用)
MessageBox.Show(ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
}
2.4 启动服务(StartServer
)
功能
分两步初始化:1. 配置并打开串口(连接硬件);2. 初始化并绑定 UDP 服务器(连接客户端),最后更新控件状态。
代码解析
private void StartServer()
{
// -------------------------- 1. 初始化串口 --------------------------
// 校验串口号是否选择(避免无可用串口时启动)
if (cbbPortName.SelectedItem == null) throw new Exception("串口不可用!");
// 实例化SerialPort并配置参数
serialPort = new SerialPort(cbbPortName.SelectedItem.ToString());
serialPort.BaudRate = int.Parse(cbbBaudRate.SelectedItem.ToString()); // 波特率
serialPort.DataBits = int.Parse(cbbDataBit.SelectedItem.ToString()); // 数据位
// 停止位:字符串转枚举(如"One"→StopBits.One)
serialPort.StopBits = (StopBits)Enum.Parse(typeof(StopBits), cbbStopBit.SelectedItem.ToString());
// 校验位:字符串转枚举(如"None"→Parity.None)
serialPort.Parity = (Parity)Enum.Parse(typeof(Parity), cbbParity.SelectedItem.ToString());
// 绑定串口数据接收事件(硬件发送数据时触发)
serialPort.DataReceived += SerialPort_DataReceived;
// 打开串口(若未打开)
if (!serialPort.IsOpen) serialPort.Open();
// 串口指示灯变绿(表示已连接硬件)
panelSerialPort.BackColor = Color.Lime;
// -------------------------- 2. 初始化UDP服务器 --------------------------
// 实例化UDP Socket(IPv4、数据报、UDP协议)
server = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
// 获取本地IPv4地址(通过IPHelper工具类)
IPAddress localIp = IPHelper.GetLocalIP();
// 从配置文件获取UDP端口
int udpPort = int.Parse(ConfigurationManager.AppSettings["port"]);
// 绑定UDP端点(本地IP+指定端口)
server.Bind(new IPEndPoint(localIp, udpPort));
// 网络指示灯变绿(表示UDP服务器已启动)
panelNetwork.BackColor = Color.Lime;
// -------------------------- 3. 更新控件状态 --------------------------
btnStart.Text = "停止"; // 按钮文本改为“停止”
// 禁用串口参数下拉框(避免运行中修改导致通讯异常)
cbbPortName.Enabled = false;
cbbBaudRate.Enabled = false;
cbbDataBit.Enabled = false;
cbbStopBit.Enabled = false;
cbbParity.Enabled = false;
// 状态栏显示服务器信息(IP+端口,方便客户端连接)
tsslServerStatus.Text = $"服务器已启动,IP:{localIp},Port:{udpPort}";
}
关键注意点
-
串口参数(波特率、数据位等)必须与硬件设备完全一致,否则会出现数据乱码或通讯失败;
-
UDP 服务器绑定端口时,需确保端口未被其他程序占用(如
8888
),否则会抛出 "端口已使用" 异常。
2.5 UDP 客户端数据接收与串口转发(AcceptData
)
功能
启动后台异步任务,持续接收 UDP 客户端数据,去重存储客户端端点,再将数据转发到硬件(串口输出),并更新网络指示灯与发送数据量。
代码解析
private void AcceptData()
{
// 创建任务取消令牌(用于停止服务时终止后台任务)
cts = new CancellationTokenSource();
// 启动后台异步任务(避免阻塞UI线程)
Task.Run(() =>
{
// 循环接收:任务未取消则持续运行
while (!cts.IsCancellationRequested)
{
try
{
// 1. 数据校验:若暂无UDP数据,跳过本次循环(避免线程空等)
if (server.Available == 0) continue;
// 2. 接收UDP客户端数据
byte[] buffer = new byte[server.Available]; // 动态缓冲区(匹配数据长度)
EndPoint clientEp = new IPEndPoint(IPAddress.Any, 0); // 客户端端点(初始为空)
int dataLength = server.ReceiveFrom(buffer, ref clientEp); // 接收数据,获取客户端端点
// 3. 客户端端点去重(避免重复存储同一客户端)
int clientIndex = clients.FindIndex(ep => ep.Equals(clientEp));
if (clientIndex != -1) clients.RemoveAt(clientIndex); // 移除旧记录
clients.Add(clientEp); // 添加新记录(确保客户端列表最新)
// 4. 转发数据到硬件(串口输出)并更新状态
if (dataLength > 0) // 确保有有效数据
{
// 4.1 串口转发:将UDP客户端数据写入串口(发送给硬件)
serialPort.Write(buffer, 0, buffer.Length);
// 4.2 更新UI:网络指示灯闪烁+发送数据量统计(需Invoke切换到UI线程)
Invoke(new Action(async () =>
{
panelNetwork.BackColor = Color.Lime; // 指示灯变绿(表示数据传输)
await Task.Delay(50); // 保持50ms(闪烁效果)
panelNetwork.BackColor = Color.Gray; // 指示灯恢复灰色
// 累加发送数据量(当前数据长度+历史统计)
int oldSendCount = int.Parse(txtSendCount.Text);
txtSendCount.Text = (oldSendCount + dataLength).ToString();
}));
}
}
catch
{
// 捕获异常(如服务停止时的Socket关闭异常),避免程序崩溃
}
}
}, cts.Token); // 关联任务与取消令牌
}
示例
当 UDP 客户端(192.168.1.101:54321
)发送0x01 0x02
(2 字节指令)时:
-
服务器接收数据,存储客户端端点
192.168.1.101:54321
; -
通过串口将
0x01 0x02
发送给硬件设备; -
panelNetwork
绿色闪烁 50ms,txtSendCount
从 0 变为 2。
2.6 串口数据接收与 UDP 广播(SerialPort_DataReceived
)
功能
串口接收到硬件设备的响应数据后,广播到所有已连接的 UDP 客户端,同时更新串口指示灯与接收数据量。
代码解析
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
// 1. 接收串口数据(硬件设备的响应)
SerialPort sp = (SerialPort)sender; // 转换为当前串口对象
if (sp.BytesToRead == 0) return; // 若暂无数据,直接返回(避免空读)
byte[] buffer = new byte[sp.BytesToRead]; // 动态缓冲区(匹配串口数据长度)
int dataLength = sp.Read(buffer, 0, buffer.Length); // 读取串口数据
// 2. 广播数据到所有UDP客户端
foreach (var clientEp in clients) // 遍历所有已连接客户端
{
server.SendTo(buffer, clientEp); // 向每个客户端发送硬件响应
}
// 3. 更新UI:串口指示灯闪烁+接收数据量统计(需Invoke切换到UI线程)
Invoke(new Action(async () =>
{
panelSerialPort.BackColor = Color.Lime; // 指示灯变绿(表示数据接收)
await Task.Delay(50); // 保持50ms(闪烁效果)
panelSerialPort.BackColor = Color.Gray; // 指示灯恢复灰色
// 累加接收数据量(当前数据长度+历史统计)
int oldReceiveCount = int.Parse(txtReceiveCount.Text);
txtReceiveCount.Text = (oldReceiveCount + dataLength).ToString();
}));
}
示例
硬件设备通过串口发送0x03
(1 字节响应)时:
-
串口接收事件触发,读取
0x03
数据; -
服务器向所有客户端(如 `192.168
2.7 停止服务(StopServer
)
功能
停止服务时,释放所有资源(取消异步任务、关闭串口与 UDP 服务器、清空客户端列表),并还原控件状态,避免内存泄漏或资源占用。
代码解析
private void StopServer()
{
// 1. 取消UDP数据接收任务(避免后台线程残留)
cts?.Cancel(); // ?. 空值判断:若cts未初始化则不执行
// 2. 清空UDP客户端列表(释放客户端端点引用)
clients = new List<EndPoint>();
// 3. 关闭串口与UDP服务器(释放硬件/网络资源)
serialPort?.Close(); // 关闭串口(若已打开)
server?.Close(); // 关闭UDP Socket(若已初始化)
// 4. 还原控件状态(允许重新配置服务)
btnStart.Text = "启动"; // 按钮文本改回“启动”
// 启用串口参数下拉框(重新选择串口号、波特率等)
cbbPortName.Enabled = true;
cbbBaudRate.Enabled = true;
cbbDataBit.Enabled = true;
cbbStopBit.Enabled = true;
cbbParity.Enabled = true;
// 状态栏显示“服务器已停止”
tsslServerStatus.Text = $"服务器已停止";
// 指示灯改灰(表示服务已关闭)
panelNetwork.BackColor = Color.Gray;
panelSerialPort.BackColor = Color.Gray;
}
关键注意点
-
任务取消 :
cts?.Cancel()
必须优先执行,否则后台接收任务会继续运行,可能导致后续关闭Socket
时抛出异常; -
资源释放 :
serialPort?.Close()
和server?.Close()
需用空值判断,避免未初始化时调用引发空引用异常; -
状态还原:启用下拉框后,用户可重新选择串口参数(如更换串口号),为下次启动服务做准备。
五、完整运行流程示例
假设硬件设备(如单片机)通过COM3
串口连接电脑,UDP 客户端(如桌面应用)需连接服务器,完整交互流程如下:
步骤 1:程序启动与初始化
-
运行程序,窗体加载时触发ServerFrm_Load
,调用InitSerialPort:
-
cbbPortName
加载本地可用串口(如COM3
、COM4
),默认选中配置文件中的COM3
; -
其他串口参数(波特率
9600
、数据位8
等)自动选中配置文件值; -
指示灯(
panelSerialPort
、panelNetwork
)均为灰色,状态栏显示 "服务器已停止"。
-
步骤 2:启动服务
-
确认cbbPortName
选中COM3,点击btnStart
(文本为 "启动"):
-
执行
StartServer
:打开COM3
串口(绑定DataReceived
事件),panelSerialPort
变绿;初始化 UDP 服务器(绑定本地 IP192.168.1.100
、端口8888
),panelNetwork
变绿; -
执行
AcceptData
:启动后台任务,持续监听 UDP 客户端数据; -
控件状态更新:
btnStart
文本改为 "停止",串口参数下拉框禁用,状态栏显示 "服务器已启动,IP:192.168.1.100,Port:8888"。
-
步骤 3:UDP 客户端发送指令→硬件接收
-
UDP 客户端(
192.168.1.101:54321
)发送控制指令(如0x01 0x02
,2 字节,代表 "开启设备"); -
服务器AcceptData
任务检测到 UDP 数据:
-
接收
0x01 0x02
,获取客户端端点192.168.1.101:54321
,去重后加入clients
列表; -
通过串口
COM3
将0x01 0x02
发送给硬件设备; -
更新 UI:
panelNetwork
绿色闪烁 50ms,txtSendCount
从 0 变为 2。
-
步骤 4:硬件响应→UDP 客户端接收
-
硬件设备处理指令后,通过串口发送响应(如
0x03
,1 字节,代表 "设备已开启"); -
串口DataReceived
事件触发:
-
读取
0x03
数据,遍历clients
列表(仅192.168.1.101:54321
),通过 UDP 广播响应; -
更新 UI:
panelSerialPort
绿色闪烁 50ms,txtReceiveCount
从 0 变为 1;
-
-
UDP 客户端接收
0x03
,显示 "设备已开启"。
步骤 5:停止服务
-
点击btnStart(文本为 "停止"):
-
执行
StopServer
:取消AcceptData
任务,清空clients
列表;关闭串口COM3
和 UDP 服务器; -
控件状态还原:
btnStart
文本改为 "启动",串口参数下拉框启用,指示灯变灰,状态栏显示 "服务器已停止"。
-
六、关键注意事项
-
串口参数一致性 串口的波特率、数据位、停止位、校验位必须与硬件设备完全一致,否则会出现数据乱码 或通讯失败 。例如:硬件波特率为
115200
,而程序配置为9600
,会导致接收的数据全为乱码。 -
UDP 端口占用问题 若启动服务时抛出 "通常每个套接字地址 (协议 / 网络地址 / 端口) 只允许使用一次",说明配置文件中的
port
(如8888
)已被其他程序占用,需修改配置文件端口(如8889
),并确保 UDP 客户端使用新端口连接。 -
线程安全问题 WinForms 控件仅允许在 UI 线程操作,因此更新指示灯、文本框等必须通过
Invoke(new Action(() => { ... }))
切换到 UI 线程,否则会抛出跨线程操作无效 异常(如SerialPort_DataReceived
中更新txtReceiveCount
)。 -
客户端去重逻辑 代码中通过
clients.FindIndex(ep => ep.Equals(clientEp))
判断客户端是否已存在,避免重复存储同一客户端(如客户端重新发送数据时,更新其端点记录)。若移除去重逻辑,clients
列表会累积大量重复端点,导致广播时发送重复数据。
七、优化建议
1. 增加异常详细日志
当前代码仅通过MessageBox
提示异常,无法定位具体问题(如串口关闭失败、UDP 发送失败)。建议添加日志记录功能(如使用log4net
或自定义日志类),记录异常详情:
catch (Exception ex)
{
// 示例:写入日志文件
File.AppendAllText("error.log", $"[{DateTime.Now}] 异常:{ex.Message}\n{ex.StackTrace}\n");
MessageBox.Show($"操作失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
2. 串口重连机制
若硬件设备意外断开(如串口线松动),当前程序无法自动重连,需手动停止后重启服务。可添加串口状态监测与重连:
// 定期检测串口状态(在后台任务中执行)
private void CheckSerialPortStatus()
{
Task.Run(async () =>
{
while (!cts.IsCancellationRequested)
{
if (serialPort != null && !serialPort.IsOpen)
{
try
{
serialPort.Open(); // 尝试重连
Invoke(() => { panelSerialPort.BackColor = Color.Lime; });
}
catch
{
Invoke(() => { panelSerialPort.BackColor = Color.Red; }); // 红灯提示故障
}
}
await Task.Delay(3000); // 每3秒检测一次
}
});
}
3. 客户端超时清理
若 UDP 客户端断开连接后,其端点仍留在clients
列表,会导致广播时向无效端点发送数据(浪费资源)。可添加客户端超时清理:
// 存储客户端最后活跃时间
Dictionary<EndPoint, DateTime> clientLastActive = new Dictionary<EndPoint, DateTime>();
// 在AcceptData中更新客户端活跃时间
clientLastActive[clientEp] = DateTime.Now;
// 后台任务清理超时客户端(如5分钟未活跃)
private void CleanTimeoutClients()
{
Task.Run(async () =>
{
while (!cts.IsCancellationRequested)
{
var timeoutClients = clientLastActive.Where(kv => (DateTime.Now - kv.Value).TotalMinutes > 5).Select(kv => kv.Key).ToList();
foreach (var client in timeoutClients)
{
clients.Remove(client);
clientLastActive.Remove(client);
}
await Task.Delay(60000); // 每分钟清理一次
}
});
}
4. 数据收发可视化
当前仅显示数据量统计,可增加数据内容显示(如十六进制 / ASCII 格式),方便调试:
// 在接收UDP数据时,显示数据内容
string hexData = BitConverter.ToString(buffer, 0, dataLength).Replace("-", " ");
Invoke(() =>
{
richTextBoxLog.Text += $"[{DateTime.Now}] UDP接收:{hexData}(来自{clientEp})\n";
});
八、总结
本程序是典型的 "硬件 - 网络" 中转服务器,通过串口与 UDP 协议实现双向数据转发,核心价值在于解决 "硬件设备无网络功能" 与 "网络客户端需控制硬件" 的交互问题。掌握其逻辑后,可扩展到物联网(如传感器数据上传)、工业控制(如 PLC 远程控制)等场景,只需根据具体业务调整数据格式(如自定义协议头、校验位)即可。
UDP 客户端(Modbus 协议)详解(C# WinForms)
本文将详细解析这个基于 UDP 协议的客户端程序,该程序实现了与服务器的通信功能,并采用 Modbus 协议格式进行数据传输。我们将从整体结构、核心功能模块、协议实现和运行流程等方面进行讲解。
一、项目概述
这个 UDP 客户端程序主要功能是通过网络与服务器建立连接,发送 Modbus 协议格式的请求指令,并接收服务器返回的数据进行解析展示。程序采用 WinForms 界面,提供了连接控制、实时数据读取等功能。
核心功能
-
与 UDP 服务器建立 / 断开连接
-
配置服务器 IP 地址和端口
-
发送 Modbus 协议格式的请求数据
-
实时读取并解析服务器返回的数据
-
展示解析后的各项数据值
技术栈
-
C# WinForms:提供图形用户界面
-
UDP 协议:实现网络数据传输
-
Modbus 协议:定义数据通信格式
-
异步任务:处理后台数据接收
-
定时器:实现数据的定时发送请求
二、界面组件说明
程序界面需要包含以下控件,用于用户交互和数据展示:
控件类型 | 控件名称 | 功能描述 |
---|---|---|
TextBox | txtServerIP | 输入或显示服务器 IP 地址 |
TextBox | txtServerPort | 输入或显示服务器端口号 |
Button | btnConn | 连接 / 断开服务器的切换按钮 |
TextBox | txtSlaveId | 输入从站地址(Modbus 协议参数) |
TextBox | txtStartAddress | 输入起始地址(Modbus 协议参数) |
TextBox | txtDataLength | 输入数据长度(Modbus 协议参数) |
Button | btnRealTimeRead | 启动 / 停止实时读取数据 |
TextBox | txtData1 | 显示解析后的数据 1 |
TextBox | txtData2 | 显示解析后的数据 2 |
TextBox | txtData3 | 显示解析后的数据 3 |
TextBox | txtData4 | 显示解析后的数据 4 |
Timer | timer1 | 定时发送数据请求 |
三、配置文件
程序依赖配置文件 (App.config
) 存储服务器的默认 IP 和端口,格式如下:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="ServerIP" value="127.0.0.1" /> <!-- 服务器默认IP地址 -->
<add key="ServerPort" value="8888" /> <!-- 服务器默认端口号 -->
</appSettings>
</configuration>
四、核心代码解析
1. 主窗体类(ClientFrm)
类成员变量
Socket client; // UDP客户端Socket对象
EndPoint serverEndPoint; // 服务器端点(包含IP和端口)
CancellationTokenSource cts; // 用于取消异步任务的令牌源
这些变量用于维护客户端的核心状态和通信所需的对象。
2. 窗体初始化(ClientFrm_Load)
private void ClientFrm_Load(object sender, EventArgs e)
{
// 初始化服务器信息
InitServerConfig();
}
private void InitServerConfig()
{
// 从配置文件加载服务器默认IP和端口
txtServerIP.Text = ConfigurationManager.AppSettings["ServerIP"];
txtServerPort.Text = ConfigurationManager.AppSettings["ServerPort"];
}
这段代码在窗体加载时从配置文件读取服务器的默认 IP 地址和端口号,并显示在对应的文本框中。
3. 连接 / 断开服务器(btnConn_Click)
private void btnConn_Click(object sender, EventArgs e)
{
try
{
if (btnConn.Text == "连接")
{
// 连接服务器
ConnServer();
// 接收服务器数据
AcceptData();
}
else
{
// 断开服务器
DisConnServer();
}
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
}
这个事件处理方法根据按钮当前文本判断是要连接还是断开服务器,并调用相应的方法。
4. 连接服务器(ConnServer)
private void ConnServer()
{
// 1. 实例化UDP客户端Socket
client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
// 2. 准备好服务器身份(IP和端口)
serverEndPoint = new IPEndPoint(IPAddress.Parse(txtServerIP.Text), int.Parse(txtServerPort.Text));
// 3. 更改控件状态
btnConn.Text = "断开";
txtServerIP.Enabled = false;
txtServerPort.Enabled = false;
}
连接服务器的过程包括:
-
创建 UDP 类型的 Socket 对象
-
构建服务器端点对象,包含 IP 地址和端口
-
更新界面控件状态,禁用 IP 和端口编辑,更改按钮文本
5. 接收服务器数据(AcceptData)
private void AcceptData()
{
cts = new CancellationTokenSource();
Task.Run(() =>
{
while (!cts.IsCancellationRequested)
{
try
{
// 1. 校验是否有可用数据
if (client.Available == 0) continue;
// 2. 接收服务器端响应数据
byte[] buffer = new byte[client.Available];
client.ReceiveFrom(buffer, ref serverEndPoint);
// 3. 解析数据并展示(使用Invoke确保在UI线程操作控件)
Invoke(new Action(() =>
{
// 解析4个数据,每个数据由2个字节组成
int data1 = buffer[3] * 256 + buffer[4];
txtData1.Text = data1.ToString();
int data2 = buffer[5] * 256 + buffer[6];
txtData2.Text = data2.ToString();
int data3 = buffer[7] * 256 + buffer[8];
txtData3.Text = data3.ToString();
int data4 = buffer[9] * 256 + buffer[10];
txtData4.Text = data4.ToString();
}));
}
catch
{
// 捕获异常,防止后台线程崩溃
}
}
}, cts.Token);
}
这是一个在后台运行的任务,负责持续接收服务器发送的数据:
-
使用 Task.Run 创建后台任务
-
通过循环持续检查是否有可用数据
-
接收数据后,在 UI 线程中解析并显示
-
每个数据值由 2 个字节组成,采用大端模式解析(高位字节 * 256 + 低位字节)
6. 断开服务器连接(DisConnServer)
private void DisConnServer()
{
// 1. 如果正在实时读取数据,先停止读取
if (btnRealTimeRead.Text == "停止读取") btnRealTimeRead.PerformClick();
// 2. 取消任务,不再接收服务器响应
cts?.Cancel();
// 3. 关闭客户端,并重置客户端和服务器实例
client?.Close();
client = null;
serverEndPoint = null;
// 4. 还原控件状态
btnConn.Text = "连接";
txtServerIP.Enabled = true;
txtServerPort.Enabled = true;
}
断开连接时需要:
-
停止实时读取(如果正在进行)
-
取消后台接收任务
-
关闭 Socket 并释放资源
-
恢复界面控件状态
7. 实时读取控制(btnRealTimeRead_Click)
private void btnRealTimeRead_Click(object sender, EventArgs e)
{
if (btnRealTimeRead.Text == "实时读取")
{
if (client == null || serverEndPoint == null)
{
MessageBox.Show("先连接服务器!", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
// 省略校验:从站地址,起始地址,数据长度是否输入正确
timer1.Enabled = true;
btnRealTimeRead.Text = "停止读取";
}
else
{
timer1.Enabled = false;
btnRealTimeRead.Text = "实时读取";
}
}
这个方法用于控制是否定时发送数据请求:
-
启动时开启定时器,按钮文本变为 "停止读取"
-
停止时关闭定时器,按钮文本变回 "实时读取"
-
进行基本的状态检查,确保已连接服务器
8. 定时发送请求(timer1_Tick)
private void timer1_Tick(object sender, EventArgs e)
{
SocketHelper.SendData(
client,
serverEndPoint,
ushort.Parse(txtSlaveId.Text),
3, // 功能码3,表示读取保持寄存器
ushort.Parse(txtStartAddress.Text),
ushort.Parse(txtDataLength.Text)
);
}
定时器事件触发时,调用 SocketHelper 的 SendData 方法发送 Modbus 请求:
-
传递必要的参数:Socket 对象、服务器端点、从站地址等
-
功能码固定为 3,表示读取保持寄存器(Modbus 协议规定)
2. SocketHelper 类
这个辅助类负责按照 Modbus 协议格式构建并发送数据:
public static class SocketHelper
{
/// <summary>
/// 发送数据
/// </summary>
/// <param name="sendSocket">发送者Socket</param>
/// <param name="destEP">接收者EndPoint</param>
/// <param name="slaveId">从站地址</param>
/// <param name="functionCode">功能码</param>
/// <param name="startAddress">起始地址</param>
/// <param name="dataLength">数据长度</param>
public static void SendData(Socket sendSocket, EndPoint destEP, ushort slaveId, ushort functionCode, ushort startAddress, ushort dataLength)
{
// 构建Modbus协议报文(不包含CRC)
byte[] buffer = new byte[6];
buffer[0] = (byte)slaveId; // 从站地址
buffer[1] = (byte)functionCode; // 功能码
buffer[2] = (byte)(startAddress / 256); // 起始地址高位
buffer[3] = (byte)(startAddress % 256); // 起始地址低位
buffer[4] = (byte)(dataLength / 256); // 数据长度高位
buffer[5] = (byte)(dataLength % 256); // 数据长度低位
// 计算CRC16校验
byte[] crc16 = CRCHelper.CRC16(buffer);
// 构建完整报文(包含CRC)
byte[] data = new byte[8];
data[0] = buffer[0];
data[1] = buffer[1];
data[2] = buffer[2];
data[3] = buffer[3];
data[4] = buffer[4];
data[5] = buffer[5];
data[6] = crc16[0]; // CRC低位
data[7] = crc16[1]; // CRC高位
// 发送数据
sendSocket.SendTo(data, destEP);
}
}
Modbus 协议报文格式说明:
-
从站地址(1 字节):标识要通信的设备
-
功能码(1 字节):标识要执行的操作,3 表示读取保持寄存器
-
起始地址(2 字节):要读取的数据的起始地址
-
数据长度(2 字节):要读取的数据长度
-
CRC 校验(2 字节):用于数据完整性校验
3. CRCHelper 类
这个辅助类实现了 Modbus 协议中使用的 CRC16 校验算法:
public static class CRCHelper
{
public static byte[] CRC16(byte[] data)
{
int crc = 0xffff; // 初始值
for (int i = 0; i < data.Length; i++)
{
crc = crc ^ data[i]; // 与当前字节异或
for (int j = 0; j < 8; j++)
{
int temp = 0;
temp = crc & 1; // 取最低位
crc = crc >> 1; // 右移一位
crc = crc & 0x7fff; // 保持16位
if (temp == 1)
{
crc = crc ^ 0xa001; // 若最低位为1,与多项式异或
}
crc = crc & 0xffff; // 保持16位
}
}
// CRC寄存器高低位互换
byte[] crc16 = new byte[2];
crc16[1] = (byte)((crc >> 8) & 0xff); // 高位字节
crc16[0] = (byte)(crc & 0xff); // 低位字节
return crc16;
}
}
CRC16 校验是 Modbus 协议中保证数据传输正确性的重要机制,通过对数据进行计算生成校验值,接收方可以通过重新计算校验值来判断数据是否在传输过程中发生了错误。
五、完整运行流程
-
程序启动:
-
窗体加载,从配置文件读取服务器默认 IP 和端口
-
界面显示默认的服务器信息
-
-
连接服务器:
-
用户确认或修改服务器 IP 和端口
-
点击 "连接" 按钮,创建 Socket 并连接到服务器
-
启动后台数据接收任务
-
界面控件状态更新(按钮文本变为 "断开",IP 和端口输入框禁用)
-
-
实时读取数据:
-
用户输入从站地址、起始地址和数据长度
-
点击 "实时读取" 按钮,开启定时器
-
定时器定时触发,发送 Modbus 请求到服务器
-
-
接收和解析数据:
-
后台任务接收服务器返回的数据
-
解析数据并在界面上显示
-
四个数据文本框实时更新显示最新数据
-
-
停止读取:
-
点击 "停止读取" 按钮,关闭定时器
-
不再发送新的请求
-
-
断开连接:
-
点击 "断开" 按钮,停止数据接收任务
-
关闭 Socket,释放资源
-
界面控件状态恢复(按钮文本变为 "连接",IP 和端口输入框启用)
-
六、优化建议
-
增加输入验证: 在发送数据前,对用户输入的从站地址、起始地址和数据长度进行有效性验证,避免格式错误导致的异常。
-
添加日志功能: 记录发送和接收的数据,便于调试和问题排查。
-
优化数据解析: 目前的数据解析方式依赖固定的字节位置,可以根据实际的 Modbus 协议响应格式进行更通用的解析。
-
增加超时处理: 在发送请求后,如果长时间没有收到响应,应该进行超时处理,避免程序无响应。
-
改进 UI 交互: 添加连接状态指示、数据更新时间等信息,提升用户体验。
通过这个 UDP 客户端程序,我们可以了解 Modbus 协议的基本原理和实现方式,以及如何在 C# 中使用 UDP 进行网络通信和异步数据处理。该程序可以作为与支持 Modbus 协议的设备进行通信的基础,根据实际需求进行扩展和优化。