C# WinForm Socket 转串口设备服务器+Modbus CRC16校验+Socket 客户端

一、项目整体架构说明

1.1 项目核心功能

  • 配置管理:通过 INI 文件实现服务器 IP、端口、串口参数的持久化存储与加载,支持配置保存与初始化

  • Socket 服务器:支持多客户端并发连接,接收客户端发送的请求帧,实现客户端状态管理与消息转发

  • 串口通信:与串口设备建立连接,将客户端请求帧转发至设备,接收设备响应帧并回传给对应客户端

  • 界面交互:通过自定义圆形控件展示设备/网络状态,实时统计收发字节数,支持服务器启停与配置管理

1.2 核心技术依赖

  • Socket 网络通信:基于 TCP 流模式实现多客户端并发连接,采用异步方法避免 UI 卡顿

  • 串口通信(SerialPort):实现与串口设备的数据交互,支持波特率、数据位、停止位等参数配置

  • INI 文件操作:通过 kernel32.dll 动态链接库实现 INI 文件的读写,完成配置持久化

  • 异步任务(Task):实现客户端监听、消息接收、串口数据处理的非阻塞操作,提升程序流畅度

  • 自定义控件:通过 Panel 自定义圆形状态控件,实现带边框的圆形绘制与区域裁剪

  • 集合类:ConcurrentQueue 实现线程安全的客户端队列,List 存储在线客户端信息

1.3 核心工作流程

  1. 服务器启动:加载 INI 配置,初始化 Socket 服务器与串口,开启监听

  2. 客户端连接:接收客户端连接请求,将客户端信息存入集合,启动消息接收任务

  3. 请求转发:接收客户端请求帧,存入线程安全队列,转发至串口设备

  4. 响应回传:接收串口设备响应帧,从队列中取出对应客户端,将响应帧回传

  5. 服务器关闭:断开所有客户端连接,关闭串口与 Socket 服务器,释放资源


二、INI 配置操作类(FileIni)逐行解析

2.1 命名空间与全局变量

复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Configuration;

namespace 服务器.Helpers
{
    internal class FileIni
    {
        // 读取app.config中INI文件路径配置,需添加System.Configuration引用
        private static string filePath = ConfigurationManager.AppSettings["InIFilePath"].ToString();
    }
}

解析:引入核心命名空间,定义静态变量存储 INI 文件路径,路径从 app.config 配置文件中读取,实现配置路径的灵活配置,需手动添加 System.Configuration 程序集引用。

2.2 核心 API 引入(kernel32.dll)

复制代码
/// <summary>
/// 将指定的键和值写到指定的节点,存在则替换
///
/// <param name="lpAppName">节点名称</param>
/// <param name="lpKeyName">键名称,null则删除节点及所有项目</param>
/// <param name="lpString">值内容,null则删除节点中指定键</param>
/// <param name="lpFileName">INI文件路径</param>
/// <returns>操作是否成功</returns>
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WritePrivateProfileString(string lpAppName, string lpKeyName, string lpString, string lpFileName);

/// <summary>
/// 读取INI文件中指定Key的值
/// 
/// <param name="lpAppName">节点名称,null则读取所有节点</param>
/// <param name="lpKeyName">Key名称,null则读取节点所有Key</param>
/// <param name="lpDefault">读取失败默认值</param>
/// <param name="lpReturnedString">读取内容缓冲区</param>
/// <param name="nSize">缓冲区长度</param>
/// <param name="lpFileName">INI文件路径</param>
/// <returns>实际读取长度</returns>
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
private static extern uint GetPrivateProfileString(string lpAppName, string lpKeyName, string lpDefault, string lpReturnedString, uint nSize, string lpFileName);

解析:通过 DllImport 特性引入 Windows 内核文件 kernel32.dll 中的两个核心 API,分别用于 INI 文件的写入与读取,CharSet.Auto 实现自动字符集适配,SetLastError 记录操作错误信息,MarshalAs 指定返回值类型为布尔值。

2.3 封装读写方法

复制代码
/// <summary>
/// 写入INI文件配置
/// 
///<param name="section">节点名称</param>
/// <param name="key">键名称</param>
/// <param name="value">值内容</param>
/// <returns>操作是否成功</returns>
public static bool Write(string section, string key, string value)
{
    return WritePrivateProfileString(section, key, value, filePath);
}

/// <summary>
/// 读取INI文件配置
/// 
/// <param name="section">节点名称</param>
/// <param name="key">键名称</param>
/// <returns>读取到的配置值</returns>
public static string Read(string section, string key)
{
    // 初始化缓冲区,长度255,多余部分用\0填充
    String buffer = new String(new char[255]);
    // 调用API读取配置,默认值为空字符串
    GetPrivateProfileString(section, key, "", buffer, 255, filePath);
    return buffer;
}

解析:封装 INI 文件的读写方法,简化外部调用,Write 方法直接调用内核 API 完成写入,Read 方法初始化缓冲区后读取配置,返回读取到的字符串,缓冲区长度设为255,适配常规配置值长度。


三、服务器主程序(Form1)逐行解析

3.1 命名空间与全局变量

复制代码
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Configuration;
using System.Data;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.IO.Ports;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using 服务器.Helpers;

namespace Socket连接设备服务器
{
    public partial class Form1 : Form
    {
        // 在线客户端集合,存储客户端终端与Socket对象
        List<Client> clients = new List<Client>();
        // 线程安全队列,存储待转发响应帧的客户端
        ConcurrentQueue<Socket> cq = new ConcurrentQueue<Socket>();
        // 服务器Socket对象
        Socket serverSocket;
        // 串口通信对象
        SerialPort serialport;
        // 异步任务取消源
        CancellationTokenSource cts;
        // 服务器启动状态标识
        bool isStart = false;
    }
}

解析:引入核心命名空间,定义全局变量实现客户端管理、线程安全队列、服务器与串口状态控制,ConcurrentQueue 保证多线程操作客户端时的线程安全,List 用于存储在线客户端信息。

3.2 窗体加载事件(Form1_Load)

复制代码
private void Form1_Load(object sender, EventArgs e)
{
    // 初始化界面配置
    BindConfig();
    // 加载本地INI配置文件
    if (File.Exists("config.ini"))
    {
        txtIP.Text = FileIni.Read("Config", "ip");
        txtPort.Text = FileIni.Read("Config", "port");
        cbbPortNames.Text = FileIni.Read("Config", "com");
        cbbBaudRate.Text = FileIni.Read("Config", "botelv");
    }
}

解析:窗体加载时先调用 BindConfig 方法初始化界面默认配置,再判断 INI 配置文件是否存在,若存在则读取配置并更新界面输入框,实现配置的自动加载。

3.3 配置初始化方法(BindConfig)

复制代码
/// <summary>
/// 初始化界面配置(IP、端口、串口参数)
/// </summary>
public void BindConfig()
{
    // 1. 初始化IP地址:获取本机IPv4地址并显示
    IPAddress[] address = Dns.GetHostAddresses(Dns.GetHostName());
    foreach (IPAddress ip in address)
    {
        if (ip.AddressFamily == AddressFamily.InterNetwork)
        {
            txtIP.Text = ip.ToString();
        }
    }

    // 2. 初始化端口:默认9999
    txtPort.Text = 9999.ToString();

    // 3. 初始化串口参数
    // 串口名称:获取所有可用串口并绑定到下拉框
    cbbPortNames.DataSource = SerialPort.GetPortNames();
    cbbPortNames.SelectedIndex = 0;

    // 波特率:默认1200,生成常用波特率选项
    int botelv = 1200;
    cbbBaudRate.Items.Add(botelv);
    for (int i = 1; i < 5; i++)
    {
        if (i != 3)
        {
            cbbBaudRate.Items.Add(botelv * i * 2);
        }
    }
    cbbBaudRate.SelectedIndex = 3;

    // 数据位:默认8位
    cbbDataBits.Items.Add(8);
    cbbDataBits.SelectedIndex = 0;

    // 停止位:默认1位
    cbbStopBits.Items.Add("One");
    cbbStopBits.SelectedIndex = 0;

    // 校验位:默认无校验
    cbbParity.Items.Add("None");
    cbbParity.SelectedIndex = 0;
}

解析:完成界面所有配置项的初始化,自动获取本机 IPv4 地址作为默认 IP,设置默认端口与串口参数,生成常用波特率选项,确保程序启动后界面配置项有合理的默认值。

3.4 配置保存按钮事件(btnSave_Click)

复制代码
private void btnSave_Click(object sender, EventArgs e)
{
    // 将界面配置写入INI文件
    FileIni.Write("Config", "ip", txtIP.Text);
    FileIni.Write("Config", "port", txtPort.Text);
    FileIni.Write("Config", "com", cbbPortNames.SelectedItem.ToString());
    FileIni.Write("Config", "botelv", cbbBaudRate.SelectedItem.ToString());
}

解析:将界面上的 IP、端口、串口名称、波特率等配置写入 INI 文件,实现配置的持久化存储,下次启动程序可自动加载该配置。

3.5 服务器启停按钮事件(btnStart_Click)

复制代码
private void btnStart_Click(object sender, EventArgs e)
{
    try
    {
        if (!isStart)
        {
            // 启动服务器
            ConnectServer();
            // 开始接收客户端请求
            AcceptQuestion();
        }
        else
        {
            // 关闭服务器
            DisConnect();
        }
    }
    catch (Exception)
    {
        throw;
    }
}

解析:通过 isStart 标识判断服务器状态,实现启停切换,启动时调用 ConnectServer 初始化服务器与串口,调用 AcceptQuestion 开启客户端监听;关闭时调用 DisConnect 释放所有资源。

3.6 服务器启动方法(ConnectServer)

复制代码
/// <summary>
/// 启动Socket服务器与串口
/// </summary>
public void ConnectServer()
{
    // 1. 初始化并启动Socket服务器
    serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    serverSocket.Bind(new IPEndPoint(IPAddress.Parse(txtIP.Text), int.Parse(txtPort.Text)));
    serverSocket.Listen(100);

    // 2. 网络状态指示灯变绿(自定义控件)
    userControl13.BackColor = Color.Green;

    // 3. 初始化并开启串口
    serialport = new SerialPort();
    serialport.BaudRate = int.Parse(cbbBaudRate.SelectedItem.ToString());
    serialport.PortName = cbbPortNames.SelectedItem.ToString();
    serialport.DataBits = int.Parse(cbbDataBits.SelectedItem.ToString());
    serialport.Parity = (Parity)Enum.Parse(typeof(Parity), cbbParity.SelectedItem.ToString());
    serialport.StopBits = (StopBits)Enum.Parse(typeof(StopBits), cbbStopBits.SelectedItem.ToString());
    if (!serialport.IsOpen)
    {
        serialport.Open();
    }
    // 绑定串口数据接收事件
    serialport.DataReceived += Serialport_DataReceived;
    // 串口状态指示灯变绿
    userControl11.BackColor = Color.Green;

    // 4. 更新界面状态
    btnStart.Text = "停止";
    isStart = true;
    // 禁用配置项,防止启动后修改
    txtIP.Enabled = txtPort.Enabled = cbbBaudRate.Enabled = cbbDataBits.Enabled = cbbParity.Enabled = cbbPortNames.Enabled = cbbStopBits.Enabled = false;
}

解析:完成 Socket 服务器与串口的初始化,绑定 IP 与端口,开启监听,配置串口参数并打开串口,绑定串口数据接收事件,同时更新界面状态与指示灯颜色,禁用配置项避免启动后修改。

3.7 客户端监听方法(AcceptQuestion 与 AcceptClient)

复制代码
/// <summary>
/// 开启客户端监听任务
/// </summary>
public void AcceptQuestion()
{
    cts = new CancellationTokenSource();
    Task task = new Task(AcceptClient, cts.Token);
    task.Start();
}

/// <summary>
/// 异步接收客户端连接
/// </summary>
public async void AcceptClient()
{
    while (!cts.IsCancellationRequested)
    {
        try
        {
            // 异步接收客户端连接
            Socket 客户端 = await serverSocket.AcceptAsync();
            // 移除重复连接的客户端
            int index = clients.FindIndex(c => c.EndPoint == 客户端.RemoteEndPoint);
            if (index != -1)
            {
                clients.RemoveAt(index);
            }
            // 添加新客户端到集合
            clients.Add(new Client()
            {
                EndPoint = 客户端.RemoteEndPoint,
                Socket = 客户端
            });
            // 启动该客户端的消息接收任务
            ReceiveMessage(客户端);
        }
        catch (Exception)
        {
            Console.WriteLine("客户端连接异常");
        }
    }
}

解析:通过异步任务循环监听客户端连接,接收客户端后判断是否存在重复连接,若存在则移除旧连接,添加新客户端到集合,同时为每个客户端启动独立的消息接收任务,避免阻塞主线程。

3.8 客户端消息接收与转发方法(ReceiveMessage)

复制代码
/// &lt;summary&gt;
/// 接收客户端消息并转发至串口
/// </summary>
/// <param name="c1">客户端Socket对象</param>
public void ReceiveMessage(Socket c1)
{
    cts = new CancellationTokenSource();
    Task task = new Task(async () =>
    {
        while (!cts.IsCancellationRequested)
        {
            try
            {
                // 初始化接收缓冲区
                byte[] buffer = new byte[c1.Available];
                // 异步接收客户端数据
                int count = await c1.ReceiveAsync(new ArraySegment<byte>(buffer), SocketFlags.None);
                if (count > 0)
                {
                    // 将客户端加入待响应队列
                    cq.Enqueue(c1);

                    // 网络状态指示灯闪烁(黄→灰→绿)
                    userControl13.BackColor = Color.Yellow;
                    await Task.Delay(50);
                    userControl13.BackColor = Color.Gray;
                    await Task.Delay(50);
                    userControl13.BackColor = Color.Green;

                    // 转发请求帧至串口设备
                    serialport.Write(buffer, 0, buffer.Length);

                    // 串口状态指示灯闪烁(黄→灰→绿)
                    userControl11.BackColor = Color.Yellow;
                    await Task.Delay(50);
                    userControl11.BackColor = Color.Gray;
                    await Task.Delay(50);
                    userControl11.BackColor = Color.Green;

                    // 更新接收字节数统计
                    Invoke(new Action(() =>
                    {
                        int oldCount = int.Parse(txtReceiveByte.Text);
                        txtReceiveByte.Text = (oldCount + count).ToString();
                    }));
                }
            }
            catch (Exception)
            {
                Console.WriteLine("消息接收异常");
            }
        }
    }, cts.Token);
    task.Start();
}

解析:为每个客户端启动独立的异步任务,循环接收客户端发送的请求帧,将客户端加入线程安全队列,通过指示灯闪烁展示数据传输状态,将请求帧转发至串口设备,同时更新接收字节数统计。

3.9 串口数据接收与响应转发(Serialport_DataReceived)

复制代码
/// <summary>
/// 串口数据接收事件,将响应帧转发至对应客户端
/// </summary>
private void Serialport_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
    // 读取串口响应帧
    byte[] buffer = new byte[serialport.BytesToRead];
    int count = serialport.Read(buffer, 0, buffer.Length);
    if (count == 0)
    {
        return;
    }

    // 从队列中取出对应客户端,转发响应帧
    if (cq.TryDequeue(out Socket s1))
    {
        if (clients.Any(v => v.Socket == s1))
        {
            s1.Send(buffer);
        }
    }

    // 更新发送字节数统计
    Invoke(new Action(() =>
    {
        txtSendByte.Text = (int.Parse(txtSendByte.Text) + count).ToString();
    }));
}

解析:串口接收到设备响应帧后,读取数据,从线程安全队列中取出对应客户端(保证请求与响应一一对应),将响应帧转发至该客户端,同时更新发送字节数统计。

3.10 服务器关闭方法(DisConnect)

复制代码
/// <summary&gt;
/// 关闭服务器,释放所有资源
/// </summary>
public void DisConnect()
{
    // 取消异步任务
    cts?.Cancel();
    // 断开所有客户端连接
    foreach (var item in clients)
    {
        Socket s1 = item.Socket;
        if (s1 != null && s1.Connected)
        {
            s1.Disconnect(false);
        }
    }
    // 清空客户端集合
    clients.Clear();

    // 关闭串口
    if (serialport != null && serialport.IsOpen)
    {
        serialport.Close();
    }

    // 关闭Socket服务器
    serverSocket?.Close();

    // 更新界面状态
    btnStart.Text = "启动";
    isStart = false;
    // 启用配置项
    txtIP.Enabled = txtPort.Enabled = cbbBaudRate.Enabled = cbbDataBits.Enabled = cbbParity.Enabled = cbbPortNames.Enabled = cbbStopBits.Enabled = true;
}

解析:实现服务器优雅关闭,先取消异步任务,断开所有客户端连接,关闭串口与 Socket 服务器,清空客户端集合,最后更新界面状态,启用配置项,便于下次启动。

3.11 辅助方法(HexStringToBytes)

复制代码
/// <summary>
/// 十六进制字符串转字节数组(测试用)
/// 
/// <param name="hexStr">十六进制字符串</param>
/// <returns>转换后的字节数组</returns>
/// <exception cref="Exception">字符串长度非法时抛出异常</exception>
public static byte[] HexStringToBytes(string hexStr)
{
    // 清除所有空格
    string cleanHex = hexStr.Replace(" ", "");
    // 校验长度是否为偶数
    if (cleanHex.Length % 2 != 0)
    {
        throw new Exception("十六进制字符长度非法!");
    }

    // 转换为字节数组
    byte[] buf = new byte[cleanHex.Length / 2];
    for (int i = 0; i < buf.Length; i++)
    {
        string sub = cleanHex.Substring(i * 2, 2);
        buf[i] = Convert.ToByte(sub, 16);
    }
    return buf;
}

解析:辅助方法,用于将十六进制字符串转换为字节数组,主要用于测试客户端发送的请求帧格式是否正确,清除字符串中的空格,校验长度后逐对转换为字节。

3.12 Client 实体类

复制代码
/// <summary>
/// 客户端实体类,存储客户端信息
/// </summary>
public class Client
{
    // 客户端终端(IP+端口)
    public EndPoint EndPoint { get; set; }
    // 客户端Socket对象
    public Socket Socket { get; set; }
}

解析:自定义客户端实体类,封装客户端终端与 Socket 对象,便于统一管理在线客户端信息,实现客户端的快速查找与操作。


四、自定义圆形控件(UserControl1)逐行解析

4.1 控件初始化与样式设置

复制代码
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace Socket连接设备服务器
{
    public partial class UserControl1 : Panel
    {
        public UserControl1()
        {
            InitializeComponent();
            // 设置控件样式:减少闪烁、启用自定义绘制、重置大小后重绘
            SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true);
        }
    }
}

解析:自定义控件继承自 Panel,构造函数中通过 SetStyle 设置控件样式,减少绘制闪烁,启用自定义绘制,确保控件大小改变时自动重绘。

4.2 区域裁剪与大小变化处理

复制代码
/// <summary>
/// 控件大小改变时,更新圆形区域
/// </summary>
protected override void OnSizeChanged(EventArgs e)
{
    base.OnSizeChanged(e);
    UpdateRegino();
}

/// &lt;summary&gt;
/// 更新控件区域为圆形
/// </summary>
public void UpdateRegino()
{
    if (Width > 0 && Height > 0)
    {
        using (GraphicsPath e = new GraphicsPath())
        {
            // 添加圆形路径
            e.AddEllipse(0, 0, Width, Height);
            // 将控件区域设置为圆形
            Region = new Region(e);
        }
    }
}

解析:重写 OnSizeChanged 方法,当控件大小改变时调用 UpdateRegino 方法,通过 GraphicsPath 添加圆形路径,将控件的 Region 属性设置为圆形,实现控件的圆形裁剪。

4.3 自定义绘制(带边框圆形)

复制代码
/// <summary>
/// 自定义绘制带边框的圆形
/// </summary>
protected override void OnPaint(PaintEventArgs e)
{
    base.OnPaint(e);
    // 绘制灰色边框,宽度2
    using (Pen p = new Pen(Color.Gray, 2))
    {
        e.Graphics.DrawEllipse(p, 0, 0, Width - 1, Height - 1);
    }
}

解析:重写 OnPaint 方法,先调用父类绘制逻辑,再通过 Pen 绘制灰色圆形边框,实现带边框的圆形控件效果,用于展示服务器网络与串口状态。


配置文件

复制代码
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <startup> 
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
    </startup>
  <appSettings>
    <add key="InIFilePath" value="./config.ini"/>
  </appSettings>
</configuration>

五、核心易错点与注意事项

5.1 配置操作易错点

  • 使用 FileIni 类需手动添加 System.Configuration 程序集引用,否则会报错

  • INI 文件路径需在 app.config 中配置 InIFilePath 节点,否则会出现路径为空的异常

  • 保存配置时需确保界面输入的配置项合法,避免写入无效配置导致后续加载失败

5.2 Socket 与串口通信易错点

  • Socket 服务器启动后需禁用配置项,防止运行过程中修改配置导致异常

  • 串口参数配置需与设备一致,否则无法正常通信,波特率、数据位、停止位、校验位需匹配

  • 客户端接收任务需独立启动,避免阻塞主线程,同时需通过 CancellationTokenSource 实现任务可控终止

  • ConcurrentQueue 保证线程安全,避免多线程操作客户端时出现队列异常,确保请求与响应一一对应

5.3 资源释放易错点

  • 服务器关闭时,需先取消异步任务,再断开客户端连接、关闭串口与 Socket,避免资源泄漏

  • 客户端断开时需从集合中移除,避免集合中存在无效客户端,导致后续转发失败

  • 自定义控件绘制时需确保 GraphicsPath 与 Pen 正确释放,避免资源占用

5.4 其他注意事项

  • 网络状态指示灯闪烁逻辑需合理,避免闪烁频率过高导致界面卡顿,延迟时间可根据需求调整

  • 接收与发送字节数统计需通过 Invoke 跨线程更新 UI,避免线程安全异常

  • HexStringToBytes 方法仅用于测试,实际项目中可根据需求保留或移除,注意处理异常情况

  • 服务器启动前需确保串口未被其他程序占用,否则会导致串口打开失败

Modbus CRC16校验+Socket 客户端

一、项目整体架构说明

1.1 项目核心功能

  • CRC16校验:实现 Modbus 协议标准的 CRC16校验算法,用于数据传输完整性校验,支持校验码高低位互换输出

  • 数据帧封装:按 Modbus 协议规范,封装读取/写入请求帧,自动添加 CRC 校验码

  • Socket 客户端:实现与服务器的连接/断开,周期性发送请求帧,接收并解析服务器响应帧,展示数据

  • 界面交互:支持实时读取开关控制、数据写入操作,实时展示接收数据,异常处理保证程序稳定性

1.2 核心技术依赖

  • CRC16算法:基于 Modbus 标准,初始值0xFFFF,反转多项式0xA001,低位在前高位在后传输

  • Socket 通信:基于 TCP 流模式实现客户端与服务器的异步连接、数据收发,避免 UI 卡顿

  • 定时器(Timer):实现周期性发送请求帧,控制实时读取频率

  • 异步任务(Task):实现数据接收的非阻塞操作,跨线程更新 UI 保证界面流畅

1.3 核心工作流程

  1. 客户端连接服务器,初始化 Socket 与定时器

  2. 开启实时读取后,定时器周期性调用数据发送方法,封装请求帧并添加 CRC 校验码

  3. 服务器接收请求帧并转发至设备,设备返回响应帧后,服务器回传至客户端

  4. 客户端接收响应帧,解析数据并更新界面展示

  5. 关闭实时读取或断开连接时,停止定时器、释放 Socket 资源,重置界面状态


二、CRC16校验帮助类(CRCHelper)逐行解析

2.1 核心原理说明

CRC16-Modbus 校验属于循环冗余校验的一种,核心是通过模二多项式除法计算校验码,用于检测数据传输过程中的错误。其核心约定:初始值为0xFFFF(16位全1),反转多项式为0xA001(对应正向多项式0x8005),计算完成后校验码低位字节在前、高位字节在后传输。

2.2 类结构与核心方法

复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace 串口读写威盟士五插针.Helper
{
    public class CRCHelper
    {
        /// <summary>
        /// 生成 Modbus 标准 CRC16校验码
        /// </summary>
        /// <param name="data">待计算校验码的字节数组(不含最后2位 CRC 校验位)</param>
        /// <returns>2字节 CRC 校验码,低位在前、高位在后</returns>
        public static byte[] CRC16(byte[] data)
        {
            // 1. 初始化 CRC 寄存器(Modbus 标准初始值0xFFFF)
            int crc = 0xffff;
            Console.WriteLine($"========== 开始计算CRC16 ==========");
            Console.WriteLine($"初始值: {crc:X4} (0xFFFF)");
            Console.WriteLine($"数据长度: {data.Length} 字节");
            Console.WriteLine();

            // 2. 逐字节处理数据(排除最后2位预留的 CRC 校验位)
            for (int i = 0; i < data.Length - 2; i++)
            {
                Console.WriteLine($"--- 处理第 {i + 1} 个字节: 0x{data[i]:X2} ---");
                Console.WriteLine($"处理前 CRC = {crc:X4}");

                // 2.1 当前字节与 CRC 寄存器低8位异或
                crc = crc ^ data[i];
                Console.WriteLine($"异或后 CRC = {crc:X4} (0x{data[i]:X2} 异或进寄存器)");

                // 2.2 逐位处理(1字节=8位,循环8次)
                for (int j = 0; j < 8; j++)
                {
                    // 2.3 取出 CRC 寄存器最低位(0或1)
                    int temp = crc & 1;
                    Console.Write($"  第{j + 1}位: 最低位={temp}, ");

                    // 2.4 寄存器右移1位,丢弃最低位
                    crc = crc >> 1;
                    Console.Write($"右移后={crc:X4}, ");

                    // 2.5 清除最高位(确保16位有效,避免符号位影响)
                    crc = crc & 0x7fff;

                    // 2.6 若取出的最低位为1,与反转多项式0xA001异或
                    if (temp == 1)
                    {
                        crc = crc ^ 0xa001;
                        Console.Write($"异或0xA001后={crc:X4}");
                    }

                    // 2.7 确保 CRC 寄存器仅保留低16位(可选,此处为冗余校验)
                    crc = crc & 0xffff;
                    Console.WriteLine();
                }
                Console.Write($"不移位(保持={crc:X4})");
                Console.WriteLine();
            }

            // 3. 计算完成,输出结果
            Console.WriteLine($"========== 计算完成 ==========");
            Console.WriteLine($"最终CRC值: {crc:X4}");

            // 4. 高低位互换(Modbus 协议要求:低位在前,高位在后)
            byte[] crc16 = new byte[2];
            crc16[1] = (byte)((crc >> 8) & 0xff); // 高位字节
            crc16[0] = (byte)(crc & 0xff);         // 低位字节
            return crc16;
        }
    }
}

2.3 关键步骤深度解析

  • 初始化寄存器:采用 Modbus 标准初始值0xFFFF(16位全1),这是 CRC16-Modbus 与其他 CRC16算法的核心区别之一,直接决定校验结果的正确性。

  • 字节异或操作:将当前待处理字节与 CRC 寄存器低8位异或,高8位保持不变,实现数据与校验寄存器的关联,为后续位运算做准备。

  • 逐位右移与校验:循环8次处理1个字节的8个比特,每次取出最低位,右移丢弃该位;若最低位为1,则与反转多项式0xA001异或,这是 CRC 校验的核心运算,实现模二除法的余数计算。

  • 高低位互换:Modbus 协议规定 CRC 校验码低位字节在前、高位字节在后传输,因此需将最终寄存器值的高低位拆分并互换,生成符合协议要求的2字节校验码。

  • 冗余操作说明:代码中 crc = crc & 0x7fff 和 crc = crc & 0xffff 为冗余操作,因 int 类型右移后最高位自动补0,可省略,但保留可增强代码健壮性,避免高位干扰。

2.4 易错点与优化点

  • 易错点1:数据处理范围错误,需排除最后2位预留的 CRC 校验位,否则会导致校验码计算错误,与设备端校验不匹配。

  • 易错点2:多项式使用错误,Modbus 协议需用反转多项式0xA001,而非正向多项式0x8005,否则校验结果完全不符。

  • 易错点3:高低位互换顺序错误,必须低位在前、高位在后,否则设备端无法识别校验码,导致数据传输失败。

  • 优化点:可将 Console 输出改为日志记录,避免调试信息影响程序运行;可添加参数校验,防止 data 数组长度小于2导致越界。


三、数据发送帮助类(SendDataHelper)逐行解析

3.1 类结构与核心方法

复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using 串口读写威盟士五插针.Helper;

namespace Socket连接设备客户端
{
    public class SendDataHelper
    {
        /// <summary>
        /// 按 Modbus 协议封装并发送请求帧(支持读取/写入)
        /// </summary>
        /// <param name="s">客户端 Socket 对象</param>
        /// <param name="slaveId">从站地址(设备地址)</param>
        /// <param name="functionCode">功能码(03=读取,06=写入)</param>
        /// <param name="startAddress">起始寄存器地址</param>
        /// <param name="datalength">数据长度(读取时为寄存器数量,写入时为写入数据)</param>
        public static void SendData(Socket s, ushort slaveId, ushort functionCode, ushort startAddress, ushort datalength)
        {
            // 1. 初始化请求帧缓冲区(8字节:从站地址+功能码+起始地址+数据长度+CRC校验码)
            byte[] data = new byte[8];

            // 2. 填充请求帧核心字段
            data[0] = (byte)slaveId;         // 从站地址(1字节)
            data[1] = (byte)functionCode;    // 功能码(1字节)

            // 3. 起始地址(2字节,高8位在前,低8位在后)
            data[2] = (byte)(startAddress / 256); // 地址高8位
            data[3] = (byte)(startAddress % 256); // 地址低8位

            // 4. 数据长度/写入数据(2字节,高8位在前,低8位在后)
            data[4] = (byte)(datalength / 256);   // 高8位
            data[5] = (byte)(datalength % 256);   // 低8位

            // 5. 计算 CRC16校验码并填充到请求帧最后2位
            byte[] crc = CRCHelper.CRC16(data);
            data[6] = crc[0]; // CRC 低位字节
            data[7] = crc[1]; // CRC 高位字节

            // 6. 发送请求帧至服务器
            s.Send(data);
        }
    }
}

3.2 关键步骤深度解析

  • 请求帧结构:严格遵循 Modbus 协议,8字节固定长度,各字段顺序为:从站地址(1B)→功能码(1B)→起始地址(2B)→数据长度/写入数据(2B)→CRC 校验码(2B),确保设备端可正常解析。

  • 字段拆分:16位的起始地址、数据长度需拆分为高8位和低8位,按"高字节在前、低字节在后"的顺序填充,与 CRC 校验码的传输顺序区分开,避免解析错误。

  • CRC 校验码填充:调用 CRCHelper.CRC16方法计算前6字节的校验码,将返回的2字节校验码(低位在前、高位在后)填充到请求帧最后两位,完成请求帧封装。

  • 通用性设计:通过功能码区分读取(03)和写入(06)操作,复用同一封装方法,降低代码冗余,适配不同业务场景。

3.3 易错点与优化点

  • 易错点1:字段填充顺序错误,尤其是16位数据的高低位拆分顺序,必须高字节在前,否则设备端解析出的地址或数据会完全错误。

  • 易错点2:CRC 校验码计算范围错误,需仅计算前6字节,若包含最后2位校验位,会导致校验码计算错误。

  • 优化点:添加参数校验,防止 slaveId、functionCode 超出字节范围,避免数组越界;可支持可变长度请求帧,适配更多 Modbus 功能码。


四、客户端主程序(Form1)逐行解析

4.1 命名空间与全局变量

复制代码
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace Socket连接设备客户端
{
    public partial class Form1 : Form
    {
        // 客户端 Socket 对象,用于与服务器通信
        Socket client;
        // 异步任务取消源,用于终止数据接收任务
        CancellationTokenSource cts = null;
        // 定时器,用于周期性发送请求帧
        System.Timers.Timer t1;

        public Form1()
        {
            InitializeComponent();
        }
    }
}

解析:引入核心命名空间,定义全局变量实现客户端连接、数据接收、周期性发送的状态控制,构造函数仅完成窗体初始化,核心功能通过按钮事件和定时器实现。

4.2 连接/断开按钮事件(btnConnOrClose_Click)

复制代码
private void btnConnOrClose_Click(object sender, EventArgs e)
{
    try
    {
        if (cbRealTimeRead.Text == "实时读取")
        {
            // 连接服务器
            ConnectServer();
            // 启动数据接收任务
            AcceptData();
        }
        else
        {
            // 断开服务器连接
            DisConnect();
        }
    }
    catch (Exception)
    {
        Console.WriteLine("连接/断开操作异常");
    }
}

解析:通过按钮文本判断操作类型,连接时调用 ConnectServer 建立 Socket 连接,调用 AcceptData 启动数据接收任务;断开时调用 DisConnect 释放资源,异常捕获保证程序不崩溃。

4.3 服务器连接方法(ConnectServer)

复制代码
public async void ConnectServer()
{
    // 初始化 Socket(IPv4地址族、流模式、TCP协议)
    client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    // 异步连接服务器,避免 UI 卡顿
    await client.ConnectAsync(new IPEndPoint(IPAddress.Parse(txtIP.Text), int.Parse(txtPort.Text)));
    // 连接成功,更新按钮状态
    btnConnOrClose.Text = "断开";
}

解析:采用异步连接方式,避免阻塞主线程导致界面卡顿,从界面输入框获取服务器 IP 和端口,连接成功后更新按钮状态,防止重复连接。

4.4 数据接收方法(AcceptData)

复制代码
public void AcceptData()
{
    // 初始化异步任务取消源
    cts = new CancellationTokenSource();
    // 启动异步任务,循环接收服务器响应帧
    Task.Run(() =>
    {
        while (!cts.IsCancellationRequested)
        {
            // 初始化接收缓冲区
            byte[] buffer = new byte[client.Available];
            // 接收服务器数据
            int count = client.Receive(buffer);
            if (count == 0) continue;

            // 跨线程更新 UI,解析并展示数据
            Invoke(new Action(() =>
            {
                // 仅处理功能码为03(读取响应)的帧
                if (buffer[1] == 0x03)
                {
                    // 解析响应数据(2个寄存器值,每个寄存器2字节)
                    txtReadData1.Text = (buffer[3] * 256 + buffer[4]).ToString();
                    txtReadData2.Text = (buffer[5] * 256 + buffer[6]).ToString();
                }
            }));
        }
    }, cts.Token);
}

解析:启动异步任务循环接收服务器响应帧,通过 Invoke 跨线程更新 UI,避免线程安全异常;仅解析功能码为03的读取响应帧,提取寄存器值并展示到界面,过滤无效数据。

4.5 实时读取开关事件(cbRealTimeRead_CheckedChanged)

复制代码
private void cbRealTimeRead_CheckedChanged(object sender, EventArgs e)
{
    if (cbRealTimeRead.Checked)
    {
        // 开启实时读取:需先确保已连接服务器
        if (client != null && client.Connected)
        {
            // 初始化定时器,间隔1秒
            t1 = new System.Timers.Timer(1000);
            t1.Start();
            // 定时器触发事件:周期性发送读取请求帧
            t1.Elapsed += (s, e1) =>
            {
                // 发送读取请求:从站地址1,功能码03,起始地址0,读取2个寄存器
                SendDataHelper.SendData(client, 1, 3, 0, 2);
            };
        }
    }
    else
    {
        // 关闭实时读取:停止定时器,清空展示数据
        t1.Stop();
        txtReadData1.Text = "";
        txtReadData2.Text = "";
    }
}

解析:通过复选框控制实时读取功能,开启时初始化定时器,每隔1秒发送一次读取请求帧;关闭时停止定时器,清空界面展示数据,实现按需读取。

4.6 数据写入按钮事件(button1_Click、button2_Click)

复制代码
// 写入第一个寄存器数据
private void button1_Click(object sender, EventArgs e)
{
    SendDataHelper.SendData(client, 1, 6, 50, ushort.Parse(txtSetData1.Text));
}

// 写入第二个寄存器数据
private void button2_Click(object sender, EventArgs e)
{
    SendDataHelper.SendData(client, 1, 6, 51, ushort.Parse(txtSetData2.Text));
}

解析:调用 SendDataHelper 封装写入请求帧,功能码为06(写入),分别向寄存器50、51写入界面输入的数据,实现设备数据的远程写入。

4.7 服务器断开方法(DisConnect)与窗体关闭事件

复制代码
public void DisConnect()
{
    // 取消异步任务
    cts?.Cancel();
    // 断开并关闭 Socket 连接
    client?.Disconnect(false);
    client?.Close();
    // 更新按钮状态
    btnConnOrClose.Text = "连接";
}

private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
    // 窗体关闭时自动断开连接,释放资源
    DisConnect();
}

解析:实现客户端优雅断开,先取消异步任务,再关闭 Socket 连接,更新按钮状态;窗体关闭时触发断开逻辑,避免资源泄漏。


五、核心易错点与注意事项

5.1 CRC 校验相关易错点

  • CRC 计算时需排除最后2位校验位,否则会导致校验码错误,与设备端校验不匹配

  • 多项式必须使用反转多项式0xA001,不可使用正向多项式0x8005,否则校验结果完全不符

  • CRC 校验码传输顺序必须为低位在前、高位在后,否则设备端无法识别,导致数据传输失败

5.2 Socket 通信相关易错点

  • 连接服务器时需采用异步方式,避免阻塞主线程导致界面卡顿

  • 数据接收需通过异步任务实现,且跨线程更新 UI 必须使用 Invoke,否则会抛出线程安全异常

  • 断开连接时需先取消异步任务,再关闭 Socket,避免任务残留导致程序卡顿

5.3 定时器与数据收发易错点

  • 实时读取开启前需先确保已连接服务器,否则会导致 Socket 空引用异常

  • 定时器需在关闭实时读取时停止,避免后台持续发送请求帧,占用资源

  • 请求帧字段填充需严格遵循 Modbus 协议,高低位拆分顺序不可颠倒,否则设备端解析错误

5.4 其他注意事项

  • 界面输入的 IP、端口、写入数据需进行合法性校验,避免无效参数导致程序异常

  • 可添加日志记录,替代 Console 输出,便于调试和问题排查

  • 可优化异常处理逻辑,添加友好的错误提示,提升用户体验

  • 客户端与服务器的 IP、端口需保持一致,否则无法建立连接

相关推荐
小灰灰搞电子7 小时前
Rust+Slint 实现动态消息提示框源码分享
开发语言·后端·rust
传奇开心果编程8 小时前
【Rust入门知识点学与练】第21课:Trait 进阶 Advanced Traits
开发语言·学习·rust
新时代牛马8 小时前
字符设备驱动完整篇:从 cdev_add、file_operations 到chrdev_open 与排障
开发语言·python
swordbob9 小时前
ReentrantLock 与 AQS 完整学习手册
java·开发语言
淡海水9 小时前
08-03-不可变-ImmutableDictionary-TKey-TValue-与ImmutableHashSet-T-持久化哈希树
数据结构·算法·c#·哈希算法·dictionary·immutable
白山编程大哥9 小时前
Java OutputStreamWriter 详解:从字符到字节的桥梁
java·开发语言·python
刚入门的大一新生10 小时前
Linux-进程控制
linux·运维·服务器·c++
上火的金鱼妹10 小时前
K8S基础组件作用和关系整理
linux·运维·服务器·kubernetes
shmily麻瓜小菜鸡10 小时前
JavaScript / TypeScript 易踩坑知识点 —— 异步编程类
开发语言·javascript·typescript