C# TCP - 串口转发
C# TCP - 串口转发服务器代码笔记
一、项目概述
该项目是一个基于 C# WinForms 的TCP 服务器与串口通信结合的转发系统,核心功能是实现 “TCP 客户端 ↔ 服务器 ↔ 串口设备” 之间的双向数据转发,适用于需要通过网络远程控制串口设备或读取串口设备数据的场景(如工业控制、物联网设备监控等)。
二、核心功能模块
1. 配置管理模块
1.1 功能说明
自动加载并显示本机 IPv4 地址、串口列表及通信参数(波特率、数据位等)
支持从配置文件(
App.config
)读取历史配置,实现 “配置持久化”提供配置保存功能,修改后需重启服务器生效
1.2 关键代码解析
// 1. 绑定串口参数(波特率、数据位等) private void BindData() {// 加载本机IPv4地址string hostName = Dns.GetHostName();IPAddress[] addresses = Dns.GetHostAddresses(hostName);foreach (IPAddress address in addresses){if (address.AddressFamily == AddressFamily.InterNetwork) // 筛选IPv4IP = address.ToString();} // 从配置文件读取历史配置(优先级:配置文件 > 自动获取)txtIP.Text = IP != ConfigurationManager.AppSettings["IP"] ? IP : ConfigurationManager.AppSettings["IP"];txtPort.Text = ConfigurationManager.AppSettings["Port"];cbbPortNames.Text = ConfigurationManager.AppSettings["PortName"];// ... 其他参数(波特率、数据位等)同理 } // 2. 保存配置到App.config private void btnSave_Click(object sender, EventArgs e) {// 输入校验(非空判断)if (string.IsNullOrWhiteSpace(txtIP.Text)){MessageBox.Show("服务器IP不能为空!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Error);return;} // 写入配置文件Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);config.AppSettings.Settings["IP"].Value = txtIP.Text.Trim();config.AppSettings.Settings["Port"].Value = txtPort.Text.Trim();// ... 其他参数同理config.Save(); // 保存配置MessageBox.Show("保存配置成功!\n请重新启动服务器!", "提示"); }
1.3 注意事项
配置文件需提前在项目中创建,需包含
IP
、Port
、PortName
等关键字段保存配置后需重启服务器,配置才会生效
2. 服务器启停模块
2.1 功能说明
启动:同时初始化 TCP 服务器和串口,禁用配置修改控件,更新状态指示(绿色 = 运行)
停止:关闭 TCP 服务器和串口,启用配置修改控件,更新状态指示(红色 = 停止)
2.2 关键代码解析
// 1. 启动服务器(TCP+串口) private void StartServer() {// 初始化串口serialPort1.PortName = cbbPortNames.Text;serialPort1.BaudRate = int.Parse(cbbBaudRate.Text);serialPort1.DataBits = int.Parse(cbbDataBits.Text);serialPort1.StopBits = (StopBits)Enum.Parse(typeof(StopBits), cbbStopBits.Text); // 枚举转换serialPort1.Parity = (Parity)Enum.Parse(typeof(Parity), cbbParity.Text);serialPort1.Open(); // 打开串口 // 初始化TCP服务器server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);int port = int.Parse(ConfigurationManager.AppSettings["Port"]);IPEndPoint ipEndPoint = new IPEndPoint(IPAddress.Any, port); // 监听所有网卡的指定端口server.Bind(ipEndPoint); // 绑定IP和端口server.Listen(100); // 最大监听队列100 // 更新UI状态btnStart.Text = "停止";txtIP.Enabled = false; // 禁用配置修改panelSerialPort.BackColor = Color.Lime; // 串口运行(绿色)panelNetwork.BackColor = Color.Lime; // 网络运行(绿色) } // 2. 停止服务器 private void StopServer() {server.Close(); // 关闭TCP服务器serialPort1.Close(); // 关闭串口 // 恢复UI状态btnStart.Text = "启动";txtIP.Enabled = true; // 启用配置修改panelSerialPort.BackColor = Color.Red; // 串口停止(红色)panelNetwork.BackColor = Color.Red; // 网络停止(红色) } // 3. 启停触发按钮 private void btnStart_Click(object sender, EventArgs e) {try{if (!serialPort1.IsOpen) // 未启动 → 启动{StartServer(); AcceptData(); // 启动数据接收任务}else // 已启动 → 停止{CacelAcceptData(); // 取消数据接收任务StopServer(); }}catch (Exception ex){MessageBox.Show(ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);} }
2.3 注意事项
启动前需确保串口未被其他程序占用,TCP 端口未被占用
枚举转换(
StopBits
/Parity
)需保证下拉框值与枚举名一致(如 “One” 对应StopBits.One
)
3. 数据转发模块
3.1 核心流程
TCP 客户端 → 服务器 → 串口设备:服务器接收 TCP 客户端数据,通过串口转发给设备
串口设备 → 服务器 → TCP 客户端:服务器接收串口设备应答数据,广播转发给所有 TCP 客户端
3.2 关键代码解析
3.2.1 接收 TCP 客户端数据并转发到串口
private void AcceptData() {cts1 = new CancellationTokenSource(); // 任务取消令牌(用于停止时中断任务)Task.Run(() => // 异步任务(避免阻塞UI){while (!cts1.IsCancellationRequested){// 1. 接收TCP客户端连接Socket client = server.Accept(); // 阻塞等待新连接string clientKey = client.RemoteEndPoint.ToString(); // 客户端标识(IP:端口)// 去重:移除已存在的相同客户端if (clients.ContainsKey(clientKey))clients.Remove(clientKey);clients.Add(clientKey, client); // 加入客户端字典 // 2. 接收该客户端的持续数据cts2 = new CancellationTokenSource();Task.Run(() =>{while (!cts2.IsCancellationRequested){byte[] buffer = new byte[client.Available]; // 根据可用数据长度创建缓冲区int len = client.Receive(buffer); // 接收客户端数据 if (len > 0){// 转发到串口serialPort1.Write(buffer, 0, len); // 异步更新UI(数据统计、状态闪烁)Invoke(new Action(async () =>{txtSendByte.Text = (int.Parse(txtSendByte.Text) + len).ToString(); // 发送字节数统计panelReceive.BackColor = Color.Lime; // 接收状态闪烁(绿色)await Task.Delay(70); // 闪烁时长panelReceive.BackColor = Color.Gray;}));}}}, cts2.Token);}}, cts1.Token); }
3.2.2 接收串口数据并广播到所有 TCP 客户端
// 串口数据接收事件(设备应答数据) private void serialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e) {byte[] buffer = new byte[serialPort1.BytesToRead]; // 缓冲区长度=可用数据长度int len = serialPort1.Read(buffer, 0, buffer.Length); // 读取串口数据 if (len > 0){// 广播到所有TCP客户端foreach (var dict in clients){Socket client = dict.Value;if (client != null && client.Connected) // 确保客户端连接正常{client.Send(buffer); // 转发数据 // 异步更新UI(接收字节数统计、状态闪烁)Invoke(new Action(async () =>{txtReceiveByte.Text = (int.Parse(txtReceiveByte.Text) + len).ToString(); // 接收字节数统计panelSend.BackColor = Color.Lime; // 发送状态闪烁(绿色)await Task.Delay(70);panelSend.BackColor = Color.Gray;}));}}} }
3.3 注意事项
数据缓冲区长度使用
client.Available
/serialPort1.BytesToRead
,避免内存浪费跨线程更新 UI 需使用
Invoke
(WinForms 控件线程安全限制)客户端管理使用
Dictionary<string, Socket>
,键为客户端IP:端口
,便于去重和广播
4. 安全与异常处理模块
4.1 功能说明
任务取消:通过
CancellationTokenSource
安全中断异步数据接收任务窗体关闭保护:服务器运行时禁止关闭窗体,避免资源泄漏
输入校验:保存配置前检查关键参数非空
4.2 关键代码解析
// 1. 取消数据接收任务 private void CacelAcceptData() {cts2?.Cancel(); // 取消单个客户端数据接收任务cts1?.Cancel(); // 取消客户端连接监听任务 } // 2. 窗体关闭保护 private void Server_FormClosing(object sender, FormClosingEventArgs e) {if (serialPort1.IsOpen) // 服务器运行中 → 禁止关闭{e.Cancel = true; // 取消关闭操作MessageBox.Show("服务器正在运行中,请停止服务器后,再关闭!", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);} }
三、UI 控件说明
控件类型 | 控件名称 | 用途 |
---|---|---|
TextBox | txtIP | 显示 / 输入服务器 IP |
TextBox | txtPort | 显示 / 输入 TCP 端口 |
ComboBox | cbbPortNames | 选择串口名称(如 COM3) |
ComboBox | cbbBaudRate | 选择波特率(如 9600、115200) |
ComboBox | cbbDataBits | 选择数据位(5-8) |
ComboBox | cbbStopBits | 选择停止位(One、Two 等) |
ComboBox | cbbParity | 选择校验位(None、Odd 等) |
Button | btnStart | 启动 / 停止服务器 |
Button | btnSave | 保存配置到 App.config |
TextBox | txtSendByte | 统计转发到串口的字节数 |
TextBox | txtReceiveByte | 统计从串口接收的字节数 |
Panel | panelSerialPort | 串口状态指示(绿 = 运行,红 = 停) |
Panel | panelNetwork | TCP 服务器状态指示 |
Panel | panelReceive | TCP 数据接收状态闪烁 |
Panel | panelSend | 串口数据转发状态闪烁 |
四、常见问题与解决方案
串口打开失败
原因:串口被其他程序占用、串口名称选择错误
解决方案:关闭占用串口的程序,重新选择正确的串口
TCP 端口绑定失败
原因:端口被其他程序占用、端口号超出范围(0-65535)
解决方案:更换未占用的端口,确保端口号合法
跨线程更新 UI 报错
原因:WinForms 控件不允许非 UI 线程直接修改
解决方案:使用
Invoke(new Action(() => { ... }))
包裹 UI 更新代码
配置保存后不生效
原因:配置需重启服务器加载
解决方案:保存后关闭服务器,重新启动
客户端连接后无法接收数据
原因:客户端未正确连接、数据缓冲区长度不足
解决方案:检查客户端 IP 和端口是否正确,确保缓冲区长度足够(建议使用固定长度缓冲区如 1024,避免
client.Available=0
时缓冲区为空)
五、扩展建议
增加日志功能:记录客户端连接 / 断开、数据转发详情,便于问题排查
客户端心跳检测:定期检测客户端连接状态,移除断开的客户端
数据格式解析:支持自定义协议(如帧头 + 数据 + 校验位),过滤无效数据
多串口支持:扩展为多串口转发,适配多个设备
UI 优化:增加客户端列表显示(当前连接的客户端 IP: 端口),支持手动断开指定客户端
C# TCP 客户端(Modbus 协议)代码笔记
一、项目概述
该客户端是基于 C# WinForms + TCP 协议 + Modbus-RTU 协议 的设备通信工具,核心功能是与前文的 “TCP - 串口转发服务器” 交互,实现对串口设备的 数据读取(Modbus 功能码 03) 和 数据写入(Modbus 功能码 06),适用于工业设备(如传感器、控制器)的远程监控与控制场景。
二、核心技术栈
网络通信:
Socket
类实现 TCP 客户端,支持异步连接、发送、接收协议处理:Modbus-RTU 协议(功能码 03 读保持寄存器、功能码 06 写单个寄存器)
数据校验:CRC16 循环冗余校验(确保 Modbus 报文完整性)
异步编程:
Task
+CancellationTokenSource
实现非阻塞通信,避免 UI 卡顿
三、核心功能模块
1. TCP 连接管理模块
1.1 功能说明
建立连接:根据输入的服务器 IP 和端口,异步创建 TCP 连接
断开连接:关闭 Socket,释放资源,恢复 UI 可编辑状态
状态控制:连接成功后禁用 IP / 端口输入,切换按钮文本为 “断开”
1.2 关键代码解析
csharp
private async void btnConnOrClose_Click(object sender, EventArgs e) {try{if (btnConnOrClose.Text == "连接"){// 1. 初始化TCP Socket(IPv4、流式传输、TCP协议)client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);// 2. 异步连接服务器(避免阻塞UI)// 解析IP和端口:IPAddress.Parse(txtIP.Text) → 转换为IP地址对象;int.Parse(txtPort.Text) → 转换为端口号await client.ConnectAsync(new IPEndPoint(IPAddress.Parse(txtIP.Text), int.Parse(txtPort.Text)));// 3. 更新UI状态(连接成功)btnConnOrClose.Text = "断开"; // 按钮文本切换为“断开”txtIP.Enabled = false; // 禁用IP输入txtPort.Enabled = false; // 禁用端口输入}else{// 1. 断开连接:先关闭连接,再释放Socketclient.Disconnect(false); // false = 不允许后续重用Socketclient.Close(); // 关闭Socket,释放资源client = null; // 置空,避免空引用// 2. 恢复UI状态(断开成功)btnConnOrClose.Text = "连接"; // 按钮文本切换为“连接”txtIP.Enabled = true; // 启用IP输入txtPort.Enabled = true; // 启用端口输入}}catch (Exception ex){// 异常处理(如IP格式错误、端口占用、服务器未启动等)MessageBox.Show(ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);return;} // 连接成功后,启动数据接收任务(持续接收服务器转发的设备应答)await ReceiveMessage(); }
1.3 注意事项
异步连接:使用
ConnectAsync
而非同步Connect
,避免 UI 卡死异常捕获:需处理
FormatException
(IP / 端口格式错误)、SocketException
(连接失败)等资源释放:断开时必须调用
Close()
,否则会导致 Socket 资源泄漏
2. Modbus 数据读取模块(功能码 03)
2.1 功能说明
实时读取:勾选 “实时读取” 后,每 3 秒自动发送 Modbus 读指令(功能码 03)
报文构造:生成包含 “从站地址、功能码、寄存器地址、寄存器数量、CRC 校验” 的完整 Modbus 报文
数据解析:接收设备应答报文后,解析寄存器值并显示到 UI
2.2 关键代码解析
2.2.1 实时读取触发(复选框事件)
csharp
private void cbRealTimeRead_CheckedChanged(object sender, EventArgs e) {if (cbRealTimeRead.Checked){// 校验连接状态:未连接则提示if (client == null || !client.Connected){MessageBox.Show("先建立连接,再读取!", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);cbRealTimeRead.Checked = false; // 取消勾选return;} // 启动数据发送任务(每3秒发一次读指令)_ = SendMessage(); // 用_忽略Task返回值,避免编译器警告}else{// 取消实时读取:终止发送任务cts1?.Cancel(); // cts1是发送任务的取消令牌} }
2.2.2 构造 Modbus 读报文并发送
csharp
private Task SendMessage() {// 初始化取消令牌(用于终止实时发送任务)cts1 = new CancellationTokenSource();return Task.Run(async () =>{while (!cts1.IsCancellationRequested) // 任务未取消则循环{// 1. 构造Modbus读指令核心报文(功能码03)// 格式:[从站地址(1字节)][功能码(1字节)][起始寄存器地址高8位(1字节)][起始寄存器地址低8位(1字节)][读取寄存器数量高8位(1字节)][读取寄存器数量低8位(1字节)]byte[] modbusCore = new byte[6] { 0x01, // 从站地址:1(默认设备地址)0x03, // 功能码:03(读保持寄存器)0x00, 0x00, // 起始寄存器地址:0x0000(从第0个寄存器开始读)0x00, 0x02 // 读取寄存器数量:0x0002(读2个寄存器)}; // 2. 计算CRC16校验(Modbus-RTU必须加CRC,确保报文无差错)byte[] crc = CRC16(modbusCore); // 3. 组装完整报文(核心报文 + CRC校验)byte[] fullPacket = new byte[8]; // 6字节核心 + 2字节CRC = 8字节Array.Copy(modbusCore, 0, fullPacket, 0, modbusCore.Length); // 复制核心报文Array.Copy(crc, 0, fullPacket, modbusCore.Length, crc.Length); // 复制CRC // 4. 发送报文到服务器(由服务器转发给串口设备)client.Send(fullPacket); // 5. 延迟3秒(避免频繁发送,减轻设备压力)await Task.Delay(3000, cts1.Token); // 传入取消令牌,支持延迟中取消}}, cts1.Token); }
2.2.3 接收并解析设备应答报文
csharp
private Task ReceiveMessage() {// 初始化取消令牌(用于终止接收任务)cts2 = new CancellationTokenSource();return Task.Run(async () =>{while (!cts2.IsCancellationRequested) // 任务未取消则循环{// 1. 创建缓冲区(长度=当前可用数据长度,避免内存浪费)byte[] buffer = new byte[client.Available];// 2. 接收服务器转发的设备应答数据int receiveLen = client.Receive(buffer); // 返回实际接收的字节数// 3. 校验应答报文长度(Modbus读2个寄存器的应答应为9字节:1+1+1+2*2+2)// 格式:[从站地址][功能码][字节数][寄存器1高8位][寄存器1低8位][寄存器2高8位][寄存器2低8位][CRC高8位][CRC低8位]if (receiveLen == 9){// 跨线程更新UI(WinForms控件不允许非UI线程直接修改)Invoke(new Action(() =>{// 解析寄存器1值:高8位*256 + 低8位(16位无符号整数)int reg1Value = buffer[3] * 256 + buffer[4];txtReadData1.Text = reg1Value.ToString(); // 显示到UI// 解析寄存器2值:同理int reg2Value = buffer[5] * 256 + buffer[6];txtReadData2.Text = reg2Value.ToString(); // 显示到UI}));}// 4. 延迟1秒(降低CPU占用)await Task.Delay(1000, cts2.Token);}}, cts2.Token); }
3. Modbus 数据写入模块(功能码 06)
3.1 功能说明
单寄存器写入:根据输入的数值,构造 Modbus 写指令(功能码 06),写入指定寄存器
输入校验:确保输入为合法整数,避免无效指令发送
报文构造:包含 “从站地址、功能码、目标寄存器地址、写入值、CRC 校验”
3.2 关键代码解析(以写入寄存器 1 为例)
csharp
private void button1_Click(object sender, EventArgs e) {// 1. 输入校验:确保输入是合法整数bool isInt = int.TryParse(txtSetData1.Text, out int writeValue);if (!isInt){MessageBox.Show("输入正确格式的数据!", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);return;}// 2. 拆分写入值为高低8位(Modbus寄存器是16位,需分高低字节传输)byte highByte = (byte)(writeValue / 256); // 高8位:数值除以256取整byte lowByte = (byte)(writeValue % 256); // 低8位:数值除以256取余// 3. 构造Modbus写指令核心报文(功能码06)// 格式:[从站地址(1字节)][功能码(1字节)][目标寄存器地址高8位(1字节)][目标寄存器地址低8位(1字节)][写入值高8位(1字节)][写入值低8位(1字节)]byte[] modbusCore = new byte[6] { 0x01, // 从站地址:10x06, // 功能码:06(写单个保持寄存器)0x00, 0x00, // 目标寄存器地址:0x0000(写入第0个寄存器)highByte, lowByte // 写入值的高低8位};// 4. 计算CRC16校验byte[] crc = CRC16(modbusCore);// 5. 组装完整报文(核心报文 + CRC)byte[] fullPacket = new byte[8];Array.Copy(modbusCore, 0, fullPacket, 0, modbusCore.Length);Array.Copy(crc, 0, fullPacket, modbusCore.Length, crc.Length);// 6. 发送报文(前提:已建立连接)if (client != null && client.Connected)client.Send(fullPacket); }
3.3 写入寄存器 2 的差异
仅 目标寄存器地址 不同:将 0x00, 0x00
改为 0x00, 0x01
,对应写入第 1 个寄存器,其余逻辑完全一致(代码见 button2_Click
方法)。
4. CRC16 校验模块
4.1 功能说明
Modbus-RTU 协议要求所有报文末尾必须添加 2 字节 CRC16 校验,用于检测报文在传输过程中是否出现差错(如丢包、错码)。该模块实现标准的 CRC16 算法(多项式 0xA001
,初始值 0xFFFF
)。
4.2 关键代码解析
csharp
private static byte[] CRC16(byte[] data) {int dataLen = data.Length;if (dataLen == 0) // 空数据返回空校验return new byte[] { 0, 0 };ushort crc = 0xFFFF; // 初始值:0xFFFF// 1. 遍历数据字节,计算CRCfor (int i = 0; i < dataLen; i++){crc = (ushort)(crc ^ data[i]); // 当前CRC与数据字节异或// 2. 每字节循环8位(处理每一位)for (int j = 0; j < 8; j++){// 若最低位为1:右移1位后与多项式0xA001异或;否则仅右移1位crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);}}// 3. CRC结果高低位交换(Modbus-RTU要求小端序:低字节在前,高字节在后)byte crcLow = (byte)(crc & 0x00FF); // 低8位byte crcHigh = (byte)((crc & 0xFF00) >> 8); // 高8位return new byte[] { crcLow, crcHigh }; // 返回小端序CRC }
四、UI 控件说明
控件类型 | 控件名称 | 用途 |
---|---|---|
TextBox | txtIP | 输入服务器 IP 地址(如 192.168.1.100) |
TextBox | txtPort | 输入服务器 TCP 端口(如 9999) |
Button | btnConnOrClose | 建立 / 断开 TCP 连接 |
CheckBox | cbRealTimeRead | 勾选启用实时读取设备数据 |
TextBox | txtReadData1 | 显示读取的寄存器 1 数值 |
TextBox | txtReadData2 | 显示读取的寄存器 2 数值 |
TextBox | txtSetData1 | 输入要写入寄存器 1 的数值 |
TextBox | txtSetData2 | 输入要写入寄存器 2 的数值 |
Button | button1 | 触发写入寄存器 1 |
Button | button2 | 触发写入寄存器 2 |
五、常见问题与解决方案
连接失败,提示 “无法连接到远程服务器”
原因:服务器未启动、IP / 端口输入错误、网络不通(防火墙拦截)
解决方案:确认服务器已启动,检查 IP 和端口是否与服务器一致,关闭防火墙或开放对应端口
实时读取无数据,UI 无显示
原因:Modbus 报文格式错误(从站地址、寄存器地址错误)、CRC 校验错误、服务器未转发数据
解决方案:
检查从站地址是否与设备匹配(默认 0x01,若设备地址不同需修改)
用串口工具(如 SSCOM)抓取报文,验证 CRC 是否正确
确认服务器已正常连接串口设备
写入数据后,设备无响应
原因:写入值超出寄存器范围(如 16 位寄存器最大 65535)、目标寄存器地址错误
解决方案:确认写入值在设备寄存器允许范围内,检查目标寄存器地址是否与设备手册一致
UI 卡顿
原因:未使用异步编程,同步发送 / 接收阻塞 UI 线程
解决方案:确保所有网络操作(
Connect
、Send
、Receive
)使用异步方法(ConnectAsync
、SendAsync
),并用Task.Run
将循环逻辑放入后台线程
取消实时读取后,任务仍在运行
原因:未正确调用
CancellationTokenSource.Cancel()
,或取消后未释放令牌解决方案:确保
cbRealTimeRead
取消勾选时,调用cts1?.Cancel()
,且任务循环中检查cts1.IsCancellationRequested
六、扩展建议
增加报文日志:记录发送 / 接收的原始字节(如
01 03 00 00 00 02 D4 0B
),便于调试协议问题支持多从站:增加从站地址输入框,支持同时与多个地址的设备通信
批量读写:扩展 Modbus 功能码(如功能码 16 批量写寄存器),支持一次写入多个寄存器
数据格式转换:支持十进制 / 十六进制显示切换,适配不同设备的数据格式
断线重连:增加自动重连机制,服务器断开后无需手动重新连接
错误处理增强:解析 Modbus 异常响应(如功能码 + 0x80 表示错误),提示具体错误