技术学习··45 分钟阅读

C#上位机通讯实战之二:Socket

Socket 协议详解

上位机通讯Socket

第二关: Socket

 
 
 

Modbus 诞生于自动化工厂,而 Socket 诞生于顶尖大学的实验室。

 

Socket 不是一种协议,它是一个“插座”或者说“API 接口”。

 

一、“插座”故事

 

单机时代

在 1970 年之前,计算机是非常昂贵且巨大的。它们像一个个孤岛,互不往来。

如果你想把 A 电脑的数据给 B 电脑,你得把数据录在磁带上,抱着磁带跑到 B 电脑那里插上去。

 

协议标准化:

TCP/IP 协议 在 70 年代初被 Vint Cerf 和 Bob Kahn 发明出来。电脑之间可以相互传输数据了。

但是使用起来比较麻烦,需要在代码实现三次握手、滑动窗口、拥塞控制等极其复杂的逻辑。

 

协议栈实现

1983年加州大学伯克利分校的一帮天才,当时正在魔改 Unix 操作系统。他们把 TCP/IP 协议植入到 Unix 内核里,并给程序员提供一套编程接口。

为了方便程序员理解,他们把这套接口命名为插座(Socket)

意思是,你要用电,不需要电厂怎么发电,怎么传输。你只要把插头插进插座,就有电了。

同样,你要发数据,不需要TCP 握手怎么握,路由怎么跳,你只要使用 Socket就行了。

 

系统调用 / API

1983年,4.2BSD Unix 发布,Socket API 正式问世。

它确立了网络编程的 5 大金刚 步骤,至今 40 年未变:

  1. socket():买个电话机。
  2. bind():插上电话线(绑定端口)。
  3. listen():坐在电话旁等。
  4. connect():拨号。
  5. accept():接听。

 
 
 

二、工业 “插座”

 
 
 

接下来讲讲 Socket “入侵”工业界的故事。

 

1970年 - 2000年,串口统治期

这个时间段,工厂内只有串口, RS-232RS-485

问题是,串口速度太慢了。而且不同厂商还有不同厂商的线,互不兼容。距离也短。

刚好,互联网开始普及,网线网卡降价了,速度还快。赶紧用上。

于是,网口进了工厂,Socket 也就跟着进来了。

 

2000年 - 2010年,Socket 的“潜伏期”

这个阶段就是,Modbus TCP

虽然用上网口网线,但是工业界也不想重新开发一套复杂的协议,于是干了一件简单粗暴的事:打包(Wrapping)。把原本 Modbus RTU 去尾换头,然后底层使用 Socket 进行传输。

网线的速度可比串口快多了。能用网口,速度也快,符合这一阶段的需求。

 

2010年 - 现在:Socket 的“爆发期”

 

随着工业 4.0智能制造 的到来,大量非标、高吞吐量的设备进入工厂。老旧的 Modbus 协议扛不住了。

 

那不用 Modbus 用什么?工程师发现,Modbus TCP 简直是拿着金碗要饭。Socket 本来就是一辆载重无限的大卡车,Modbus 协议却非要规定:‘不管卡车多大,车厢里只能装寄存器那么大的小盒子’。这不是浪费运力吗? 所以工程师把 Modbus 这个紧箍咒扔了!这辆卡车想装图片就装图片,想装字符串就装字符串,彻底放飞自我,这就是 Raw Socket

 

来看下面三种种场景:

 

工业相机 (机器视觉)。拍照,一张图片 5MB,一秒钟拍 10 张。Modbus 根本传不了图片!必须用原生的 TCP Socket,建立连接后,像倒水一样把二进制图片数据“流”过来。

扫码枪与 RFID。自动扫码。很多扫码枪很简单,它不想支持复杂的 Modbus。它就做一个 TCP Server,你连上它,它扫到码就直接发一串字符串 "CODE:123456" 给你。这叫 ASCII 流

非标设备与 PC 互联。 上位机要和另一台机器(比如测试仪、贴标机)对话,两台电脑之间通讯,最简单的就是双方约定一个简单的字符串格式(比如 "START,100,OK"),直接用 Socket 发过去。

 
 
 

Socket 的高性能,是现代工业设备间通讯的基础。未来的 IIoT / MQTT 底层协议依然是 Socket 。所以掌握好 Socket 是重点。

 


TCP Socket 和 UDP Socket

 

首先明确一下:

先有的 TCP 和 UDP(协议/规则),后有的 Socket(编程接口)。

 

TCP 和 UDP

 

互联网之父 Vint Cerf 和 Bob Kahn 最早设计的时候,并没有分 TCP 和 IP,也没有 UDP。 那时候只有一个超级复杂的协议,叫 TCP (Transmission Control Protocol)。它既要负责找路(现在的 IP 的活),又要负责保证不丢包(现在的 TCP 的活)。结果就是非常安全,也非常笨重。

随着网络开始尝试传输语音,科学家发现用 TCP 传语音太难受了。因为 TCP 发现丢了一个包,就会立刻喊停:“停!第 5 句没听清,重传!”,语音通话会一直卡顿、延迟。其实打电话时,丢一个字没关系,实时性才是最重要的。

科学家 David Reed 说:“我们需要一个裸奔版的传输协议。不需要重传,不需要排序,只管发!”

于是,UDP (User Datagram Protocol) 诞生了。

 

虽然TCP 和 UDP 的规则(RFC 文档)已经写好了,但程序员写代码还是很痛苦。于是,Socket 出现了。

Socket 的设计初衷: 就是为了给程序员提供一套统一的工具,随心所欲地选择**是用 TCP 还是用 UDP。

 

工业 TCP 、 UDP

 

对比维度TCP (流式套接字)UDP (数据报套接字)
C# 关键字SocketType.StreamSocketType.Dgram
核心价值观 (不丢包、不乱序) (低延迟、高吞吐)
连接方式打电话 📞 (必须接通才能说话)村口大喇叭 📢 (喊完就算)
数据边界无边界 (像水流,有粘包问题)有边界 (像包裹,一个个独立)
工业领地统治级 (占 90%)特种兵 (占 10%)

 

明白了 TCP 和 UDP 的区别,工业界也是按照需求来选择的这两者的。

把工业界的设备分成三类,它们的选择逻辑如下:

 

第一类:控制与核心数据(90% 的场景)

PLC、机械手、温控器、变频器。

我发指令“启动电机”,电机必须启动。(不能丢)

我发“先向左,再向下”,顺序绝对不能反。(不能乱)

选择:TCP

所以 Modbus TCP,西门子 S7 通讯,三菱 MC 通讯,全是基于 TCP 的。工业环境干扰大,数据很容易在半路丢包。只有 TCP 能保证“只要我不报错,数据就是对的”。

 

第二类:高速采集与监控(5% 的场景)

振动传感器(高频采样)、工业摄像头(视频流)、雷达。

一秒钟传 50 张图片,或者 10000 个振动波形数据。如果为了纠结那丢失的一个像素点,导致画面卡顿了 3 秒,反而更危险。

**选择: UDP **

 

第三类:设备发现与广播(5% 的场景)

上位机软件刚打开,想自动搜索局域网里有几台 PLC。不知道 PLC 的 IP 是多少,也没法建立 TCP 连接。

选择: UDP

就像在广场大喊,只有 UDP 支持广播,TCP 只能一对一。

 

三:实战

 

TCP Socket 的通讯流程

 

角色 A:服务器 (Server) —— 比如工业相机

  1. Socket()装一部电话。
  2. Bind()申请一个电话号码(IP + 端口 8080)。
  3. Listen()接上电源,等待铃声响。
  4. Accept()铃声响了,拿起听筒。 (注意: 这一步会“卡住”程序,直到有人打进来)
  5. Recv()听对方说话。
  6. Send()回答对方。

角色 B:客户端 (Client) —— 比如你的上位机

  1. Socket()掏出手机。
  2. Connect()拨打服务器的电话号码(192.168.1.100 : 8080)。
  3. Send()喊话:“把照片发给我!”
  4. Recv()听到对方传来的图片数据。
  5. Close()挂断。

 
 
 

TCP Socket C# 代码

 

C# 里的 Socket (封装的艺术)

 

C# 提供了两层操作方式:

  • 底层方式 (System.Net.Sockets.Socket):
    • 原汁原味,逻辑和上面说的 bind/listen/connect 一模一样。
    • 适合: 想精准控制每一个字节,或者做高性能服务器。
  • 高层方式 (TcpClient / TcpListener):
    • 这是给“懒人”用的。它把连接细节都藏起来了。
    • 你只需要 new TcpClient(), Connect(), GetStream()
    • 适合: 新手入门,以及 90% 的工业上位机场景。

 

底层方式

服务器端 (Server)

 

// 引入必要的命名空间
using System;
using System.Net;           // 包含 IPAddress, IPEndPoint
using System.Net.Sockets;   // 包含 Socket 类
using System.Text;          // 包含 Encoding (处理字符串)

class CameraServer
{
    static void Main()
    {
        // ========================================================================
        // 步骤 1:装一部电话 (Socket)
        // ========================================================================
        // AddressFamily.InterNetwork = IPv4 地址
        // SocketType.Stream          = 流式 (TCP)
        // ProtocolType.Tcp           = TCP 协议
        Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        Console.WriteLine("1. 电话(Socket)已安装。");

        // ========================================================================
        // 步骤 2:申请一个电话号码 (Bind)
        // ========================================================================
        // IPAddress.Any 表示监听本机所有网卡 (无论你是插网线还是连Wi-Fi)
        // 8080 是端口号,就像电话分机号
        IPEndPoint point = new IPEndPoint(IPAddress.Any, 8080);
        
        // 绑定!告诉操作系统:8080 端口归我管了
        serverSocket.Bind(point);
        Console.WriteLine("2. 号码已申请 (绑定端口 8080)。");

        // ========================================================================
        // 步骤 3:接上电源,等待铃声响 (Listen)
        // ========================================================================
        // 10 代表“挂起连接队列”的长度。如果瞬间来了 100 个人,只能排队 10 个,剩下的占线。
        serverSocket.Listen(10);
        Console.WriteLine("3. [电话] 已接通电源,正在等待铃声响 (监听中)...");

        // ========================================================================
        // 步骤 4:铃声响了,拿起听筒 (Accept) -> ⚠️此处会卡住!
        // ========================================================================
        // 程序运行到这里会“停住”(阻塞),直到有客户端(上位机)连上来。
        // 一旦连上,它会返回一个新的 Socket (connection),专门用来和这个客户端说话。
        Socket connection = serverSocket.Accept(); 
        
        Console.WriteLine($"4. [电话] 电话接通了!对方IP:{connection.RemoteEndPoint}");

        // ========================================================================
        // 步骤 5:听对方说话 (Recv)
        // ========================================================================
        // 准备一个“空杯子” (字节数组) 来接水 (数据)
        byte[] buffer = new byte[1024];
        
        // Receive 也会卡住,直到对方发了数据过来
        // length 是实际接到了多少水 (字节数)
        int length = connection.Receive(buffer);

        // 把字节翻译成字符串
        string msg = Encoding.UTF8.GetString(buffer, 0, length);
        Console.WriteLine($"5. [电话] 听到对方说:{msg}");

        // ========================================================================
        // 步骤 6:回答对方 (Send)
        // ========================================================================
        // 模拟发送一张图片数据 (这里用文字代替)
        string reply = "Image_Data: [0xFF, 0xA2, ...]"; 
        byte[] data = Encoding.UTF8.GetBytes(reply); // 把话变成字节
        
        connection.Send(data);
        Console.WriteLine("6. [电话] 图片数据已发送。");

        // 挂断
        connection.Close();
        serverSocket.Close();
        Console.ReadKey();
    }
}

 

客户端 (Client) —— 模拟“上位机”

 

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;

class PCClient
{
    static void Main()
    {
        // ========================================================================
        // 步骤 1:掏出电话 (Socket)
        // ========================================================================
        // 参数必须和服务器一模一样
        Socket clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        Console.WriteLine("1. [上位机] 电话(Socket)已准备好。");

        try
        {
            // ========================================================================
            // 步骤 2:拨号 (Connect)
            // ========================================================================
            // 本机,IP填 127.0.0.1,端口填电话的 8080
            Console.WriteLine("2. [上位机] 正在拨号...");
            clientSocket.Connect("127.0.0.1", 8080);
            Console.WriteLine("   [上位机] 接通了!");

            // ========================================================================
            // 步骤 3:喊话 (Send)
            // ========================================================================
            string cmd = "把照片发给我!"; // 你的指令
            byte[] cmdBytes = Encoding.UTF8.GetBytes(cmd); // 必须转成字节才能顺着网线发
            
            clientSocket.Send(cmdBytes);
            Console.WriteLine($"3. [上位机] 指令已发送:{cmd}");

            // ========================================================================
            // 步骤 4:听到对方传来的图片数据 (Recv)
            // ========================================================================
            byte[] buffer = new byte[1024]; // 准备大桶接图片
            int length = clientSocket.Receive(buffer); // 等待接收
            
            string response = Encoding.UTF8.GetString(buffer, 0, length);
            Console.WriteLine($"4. [电话] 收到相机回复:{response}");

            // ========================================================================
            // 步骤 5:挂断 (Close)
            // ========================================================================
            clientSocket.Close();
            Console.WriteLine("5. [电话] 通话结束。");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"连接失败:{ex.Message}");
            Console.WriteLine("请检查:1.服务器开了吗? 2.IP端口对吗?");
        }
        Console.ReadKey();
    }
}

 
 
 

高层方式

 

服务端 (TcpListener):模拟一个智能仪表,收到指令后回复数据。

客户端 (TcpClient):你的上位机软件,发送指令并读取回复。

 

服务端代码 (TcpListener)

服务端不再用 Socket 类监听,而是用 TcpListener

using System;
using System.IO;            // 核心:用于 StreamReader/StreamWriter
using System.Net;
using System.Net.Sockets;   // 核心:TcpListener, TcpClient

class SimpleServer
{
    static void Main()
    {
        // 1. 设置监听端口 (8888)
        // IPAddress.Any 代表本机所有网卡
        TcpListener listener = new TcpListener(IPAddress.Any, 8888);

        // 2. 开始营业 (相当于 Socket.Listen)
        listener.Start();
        Console.WriteLine("服务端:服务已启动,等待连接...");

        // 3. 等待客户 (相当于 Socket.Accept)
        // 注意:这里返回的直接是一个 TcpClient 对象,而不是 Socket
        TcpClient client = listener.AcceptTcpClient();
        Console.WriteLine("服务端:有人连上了!");

        // =========================================================
        // 🌟 核心高光时刻:获取网络流 (NetworkStream)
        // =========================================================
        // 我们不直接操作 Socket,而是拿到一个“流”。
        // 把它想象成一个管子,数据像水一样在里面流。
        NetworkStream stream = client.GetStream();

        // 4. 给管子装上“读写器” (像读写文件一样操作网络!)
        // StreamReader: 专门读字符串
        // StreamWriter: 专门写字符串
        StreamReader reader = new StreamReader(stream);
        StreamWriter writer = new StreamWriter(stream);
        
        // *重要*: 开启 AutoFlush,否则数据会卡在缓存区发不出去
        writer.AutoFlush = true; 

        try
        {
            // 5. 读一行 (ReadLine 会一直卡住,直到对方发来换行符 \n)
            string msg = reader.ReadLine(); 
            Console.WriteLine($"服务端收到指令:{msg}");

            // 6. 回一行
            string reply = "温度: 25.6℃, 湿度: 60%";
            writer.WriteLine(reply); // 自动加换行符
            Console.WriteLine($"服务端已回复:{reply}");
        }
        catch (Exception ex)
        {
            Console.WriteLine("断开了:" + ex.Message);
        }

        // 7. 关门送客
        client.Close();
        listener.Stop();
        Console.Read();
    }
}

 

客户端代码 (TcpClient)

 

using System;
using System.IO;
using System.Net.Sockets; // 只需要这一个

class SimpleClient
{
    static void Main()
    {
        try
        {
            // 1. 创建客户端 (自动挡,无需设置参数)
            TcpClient client = new TcpClient();

            // 2. 连接服务器
            Console.WriteLine("客户端:正在连接...");
            client.Connect("127.0.0.1", 8888);
            Console.WriteLine("客户端:连接成功!");

            // =========================================================
            // 🌟 核心高光时刻:获取网络流
            // =========================================================
            NetworkStream stream = client.GetStream();
            
            // 包装成读写器
            StreamReader reader = new StreamReader(stream);
            StreamWriter writer = new StreamWriter(stream);
            writer.AutoFlush = true; // 必加!只要用 StreamWriter 就要加

            // 3. 发送指令 (WriteLine 自动带回车换行)
            // 很多工业仪表要求指令必须以 \r\n 结尾,WriteLine 完美解决
            string cmd = "GET_TEMP";
            writer.WriteLine(cmd);
            Console.WriteLine($"客户端发送:{cmd}");

            // 4. 接收回复 (ReadLine)
            // 它会等待直到收到回车换行符
            string response = reader.ReadLine();
            Console.WriteLine($"客户端收到:{response}");

            // 5. 关闭
            client.Close();
        }
        catch (Exception ex)
        {
            Console.WriteLine("连接异常:" + ex.Message);
        }

        Console.Read();
    }
}

 
 
 
 
 

总结:Socket 是什么?

  1. 它是产生的背景: 为了让程序员能方便地使用 TCP/IP 协议,伯克利大学发明的一套 API 接口
  2. 它的本质: 是网络世界的**“插座”**。
  3. 它的地位: 它是网络编程的**“原子”**。HTTP、MQTT、Modbus TCP,这所有的上层协议,扒掉外衣,里面全是 Socket

如果你学会了 Socket,你就拥有了**“重新发明任何协议”**的能力。