news 2026/10/4 23:46:56

C# TCP/IP网络编程实战:从最小例程到上位机通信

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# TCP/IP网络编程实战:从最小例程到上位机通信

简介:一套面向 C# 入门阶段的 TCP/IP 网络通信最小可用例程,同时提供完整的服务端与客户端源码,适合刚接触 Socket 编程、希望在 Visual Studio 中快速跑通“监听—连接—收发数据”闭环的学习者。包内以 12 个 .cs 源码文件为主,对应服务端、客户端两个独立工程,配套 .exe 可执行文件可免编译直接运行,便于直观观察交互效果;另有 .txt 说明文档与项目配置类文件,方便对照代码梳理 TcpListener、TcpClient、NetworkStream 等核心 API 的用法。压缩包共 55 个文件、约 450KB,结构清晰,包含 WinForm 界面示例及图标、资源文件等辅助内容,整体轻量,适合课后练习或课程设计起步参考。目前已有 206 人浏览学习,对想快速理解 C# 网络编程基础流程的读者来说,是一份上手成本较低的入门资料。

1. 把两端跑通,是最快的上手路线

刚接触 TCP/IP 的程序员,绝大多数时间不是花在理解三次握手和滑动窗口上,而是卡在「服务端怎么开起来、客户端怎么连上去、连上以后怎么把数据收到」这三件事上。TCP/IP 在 C# 里最常见的落地场景,就是一张图:一个上位机程序当服务端,一个测试小工具当客户端,两边通过 localhost 把字符串来回传。这套“最简单例程”的意义,就是让你在半小时内看到连接建立、数据收发、断开释放这整条链路,后面再做上位机、做多客户端、做协议解析,都建立在它上面。这篇直接给出客户端与服务端的完整代码,并把参数选择和踩坑点写到能照抄的程度。

2. 先理清调用关系再动手:TcpListener 与 TcpClient 搭配的请求-响应模型

2.1 为什么选 TcpListener / TcpClient,而不是直接操作 Socket

.NET 的 System.Net.Sockets 命名空间里提供两层 API:底层是 Socket,上层是 TcpListener 和 TcpClient。做上位机和工控通信的人,我一般建议直接选上层。原因很直白:Socket 裸写时,Bind、Listen、Accept、Connect、Send、Receive 十几个方法要自己排布,稍有遗漏就会出现“端口没绑定就 Accept”这种低级错误;而 TcpListener 把 Bind 和 Listen 合并成一步,TcpClient 把连接建立、流获取也封装好了,例程的注意力可以全部放在业务数据上。

这两者的分工很明确:TcpListener 负责服务端的监听和接入,TcpClient 表示一条已经建立好的 TCP 连接。配合模型是服务端调用 AcceptTcpClient 阻塞等待客户端接入,接入后返回一个 TcpClient,再通过 GetStream 拿到 NetworkStream 做读写。对 C# 上位机来说,这套模型够用且不容易出错,性能上可以支撑单机几百连接以内的采集和指令下发场景。如果目标是几十万并发的服务器,那要去用 SocketAsyncEventArgs 或者直接上 Kestrel,但那已经不属于“最简单例程”的范畴。

对比项TcpListener / TcpClient直接操作 Socket
连接管理封装了 Bind/Listen/Accept/Connect全部手动
数据读写通过 NetworkStream 操作Send/Receive 手动缓冲
出错概率低,适合业务开发高,适合做协议层
性能边界几百连接够用可支撑高并发但成本高

2.2 最小服务端:创建监听、Accept、收取数据三步走

下面这段代码是一个完整的、能跑起来的服务端。它只做四件事:监听本机 8888 端口,接受一个客户端连接,把客户端发来的字符串打印出来,然后回一句确认消息,最后关闭。没有引入多线程和循环,先看单次请求-响应的最小骨架。

using System; using System.Net; using System.Net.Sockets; using System.Text; class Server { static void Main() { // 1. 创建监听:绑定本机所有网卡地址的 8888 端口 IPEndPoint local = new IPEndPoint(IPAddress.Any, 8888); TcpListener listener = new TcpListener(local); listener.Start(); Console.WriteLine("服务端已启动,等待客户端连接..."); // 2. 接受连接:这行会阻塞,直到有一个客户端连进来 TcpClient client = listener.AcceptTcpClient(); Console.WriteLine("收到连接: " + client.Client.RemoteEndPoint); // 3. 读取数据:从网络流中读取客户端发来的字节 NetworkStream stream = client.GetStream(); byte[] buffer = new byte[1024]; int count = stream.Read(buffer, 0, buffer.Length); string msg = Encoding.UTF8.GetString(buffer, 0, count); Console.WriteLine("收到: " + msg); // 4. 回一条确认消息,让对方知道服务端收到了 byte[] resp = Encoding.UTF8.GetBytes("服务端已确认收到"); stream.Write(resp, 0, resp.Length); // 5. 释放资源 stream.Close(); client.Close(); listener.Stop(); } }

IPAddress.Any 表示服务端监听本机所有网卡的地址,这样不管是 127.0.0.1、192.168.x.x 还是虚拟机网卡的地址来连,都能接到。端口 8888 是随手选的,注意 1024 以下端口在 Windows 上需要管理员权限,实际项目里避开就行。TcpListener.Start 之后监听立刻生效,不需要额外握手操作。

第 3 步里的 Read 是阻塞式的,它返回实际读到的字节数。这里有一个新手常踩的坑:TCP 是流式协议,一次 Read 不保证把对方发出的数据完整读完。如果对方一次发了几万个字节,缓冲区只有 1024,Read 只能先返回 1024 字节。所以上面的代码只适合用来验证链路通不通,不能直接搬进正式项目,数据边界问题要等到第 4 章专门处理。

2.3 最小客户端:连接、发送、等待响应后关闭

服务端跑起来之后,需要一个客户端才能把链路打通。下面的客户端代码按照“连接、发送、等待响应、关闭”四步执行,逻辑保持最小,方便对照。

using System; using System.Net.Sockets; using System.Text; class Client { static void Main() { // 1. 连接服务端:IP 是 127.0.0.1,端口要与服务端一致 TcpClient client = new TcpClient(); client.Connect("127.0.0.1", 8888); Console.WriteLine("连接成功,开始发送数据..."); // 2. 获取网络流并发送 NetworkStream stream = client.GetStream(); byte[] msg = Encoding.UTF8.GetBytes("hello from client"); stream.Write(msg, 0, msg.Length); // 3. 读取服务端返回的确认消息 byte[] buffer = new byte[1024]; int count = stream.Read(buffer, 0, buffer.Length); Console.WriteLine("服务端响应: " + Encoding.UTF8.GetString(buffer, 0, count)); // 4. 关闭 stream.Close(); client.Close(); } }

Connect 方法的参数是目标 IP 和端口。这里的 127.0.0.1 是回环地址,只在同一台机器上有效;如果你要连局域网里的另一台设备,把 IP 改成那台机器的实际地址,例如 192.168.1.50。端口必须和监听端的 8888 一致,否则客户端会收到“目标计算机积极拒绝”的异常,具体排查方式第 5 章会讲。

我在这里直接用了 Encoding.UTF8,是最省事的做法。但注意:在与 PLC、单片机、扭矩仪这类设备通信时,对方可能用的是 ASCII 或 GB2312,两边编码不一致会直接导致中文或者特殊字节乱码。编码问题不要拖到最后再调,从第一步就应该显式约定好。

3. 别只用阻塞收发一次:把同步读写改成长连接收发循环

3.1 阻塞读为什么只能通一次,原因分析

第 2 章的例子跑完后,细心的读者会发现:再次向服务端发数据,服务端不响应了。原因很简单——AcceptTcpClient 和 Read 都是单次阻塞调用,一次连接、一次读、一次写,代码就走到了 Close,这是“请求-响应”模式,不是通信工具该有的样子。真正的 TCP 客户端与服务端应当建立一条长连接,在连接存活期间反复收发数据。

阻塞读本身没有错,错在“只读一次就退出”。TCP 连接的收数据动作天然是循环的:网络流上随时可能有新数据到达,Read 应当放在循环里反复调用,直到连接断开或者发生异常。服务端的 Accept 也一样,一个监听端口不是为单个客户端服务的,每 Accept 一次就应该为这个客户端开一个独立的处理流程。这里先不引入多线程,把循环结构写对,后面加线程就顺理成章。

3.2 让服务端连续接收:while 循环与 Buffer 复用

改进后的服务端接收部分,核心是把 Read 放到 while 循环里,用一个固定大小的 buffer 反复接收。注意,这里仍然是阻塞 Read,它的语义是“收到多少数据就返回多少”,而不是“必须凑满 buffer.Length 才返回”。

TcpClient client = listener.AcceptTcpClient(); NetworkStream stream = client.GetStream(); byte[] buffer = new byte[1024]; DateTime lastReceive = DateTime.UtcNow; while (true) { try { int count = stream.Read(buffer, 0, buffer.Length); // 对方正常关闭连接时,Read 返回 0 if (count == 0) { Console.WriteLine("客户端已断开"); break; } lastReceive = DateTime.UtcNow; string msg = Encoding.UTF8.GetString(buffer, 0, count); Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {msg}"); } catch (Exception ex) { Console.WriteLine("连接异常中断: " + ex.Message); break; } }

这段代码有两个关键判断点。第一,Read 返回 0 代表对端执行了正常关闭,此时应当退出循环并释放资源,不然会陷入无限空转。第二,catch 捕获的异常通常意味着网络连接受阻或对端进程崩溃,这种异常不处理的话服务端进程会跟着崩。很多简单例子不写 try-catch,一崩就重启程序,这就是“翻车”的开始。

buffer 大小选择 1024 字节,在工控场景足够日常报文;如果传输的是大型日志文件或图片,可以按 2KB 或 4KB 设置。但别盲目放大,缓冲区大了反而会提高单次 Read 的内存占用,网络数据量不大时没必要。

3.3 让客户端具备断线重连与响应等待能力

服务端进入循环收数据之后,客户端这边还需要处理两件事:等待响应的超时控制,以及连接断开后的自动重连。最常见的业务场景是客户端程序每 100 毫秒采集一次设备数据发给服务端,服务端偶尔停机维护,客户端不能因此退出,要一直尝试重新连接。

private static TcpClient ConnectWithRetry(string ip, int port, int timeoutMs) { TcpClient client = new TcpClient(); try { IAsyncResult result = client.BeginConnect(ip, port, null, null); bool success = result.AsyncWaitHandle.WaitOne(timeoutMs, false); if (success && client.Connected) { client.EndConnect(result); Console.WriteLine("连接成功"); return client; } } catch (SocketException ex) { Console.WriteLine("连接失败: " + ex.Message); } finally { if (!client.Connected) { client.Close(); } } return null; }

BeginConnect 是异步连接方法,这里用 AsyncWaitHandle.WaitOne 实现“等待指定毫秒数”。timeoutMs 一般设 3000,也就是 3 秒连不上就判定超时,由调用方决定是否稍后重试。一个容易踩的坑:WaitOne 超时之后,底层连接动作还可能继续,所以 finally 块里判断 Connected 为 false 就立刻 Close,防止句柄泄漏。

调用方的循环可以这样写:

TcpClient client = null; while (client == null) { client = ConnectWithRetry("192.168.1.50", 8888, 3000); if (client == null) { Thread.Sleep(2000); } } Console.WriteLine("重连成功,开始业务循环...");

这样客户端对服务端重启能做到自动恢复。有经验的同行会在重连里加指数退避,第一次等 1 秒,第二次等 2 秒,最多不超过 30 秒,避免服务端刚恢复时大量客户端同时涌入造成连接风暴。

4. 数据边界与编码:最常见的数据错乱是怎么发生的

4.1 粘包、拆包问题的根源

当你把第 3 章的循环接收跑起来,试着一次发送大量数据,或者连续快速发送多条消息,会发现服务端有时把两次发送的数据拼在了一起,有时一条完整消息被拆成了两段。这种现象叫“粘包”或“拆包”,是 TCP 的流式传输天然带来的,不是 C# 的 Bug。

TCP 不保证发送端的每次 Write 和接收端的每次 Read 一一对应。底层把数据看作一个连续的字节流,可能多个小包合并成一个段到达,也可能一个大包被 IP 分片后分几次到达。所以任何基于 TCP 的应用协议,都必须自己定义消息边界。如果不定义,你收到的就是一团拆不开的字节流。很多上位机项目里所谓的“玄学问题”,最后都定位到这里。

解决边界问题常见做法有两种:一是用特殊结束符,比如数据末尾加换行符;二是用长度前缀,发送前先告知“这条消息总共 N 个字节”。工业设备协议里两者都很常见,GPS 数据多用结束符,报文类协议多用长度前缀。C# 写例程时我偏向推荐长度前缀,因为它不受消息内容里出现相同字符的影响。

方案优点缺点适用场景
结束符(如 \n)简单直观内容里出现结束符时要转义纯文本日志、命令行协议
长度前缀(4字节)边界准确、解析效率高需要多读4字节大多数工业报文、上位机协议

4.2 用长度前缀约定消息边界:读取完整包体的实现

下面给出一个长度前缀的拆包示例。约定是“每条消息前 4 个字节表示包体长度,包体为 UTF8 编码的字符串”。发送端先发长度再发内容,接收端先读长度再按长度把包体完整读完。

// 发送端 byte[] body = Encoding.UTF8.GetBytes("这是一条完整消息"); byte[] lenBytes = BitConverter.GetBytes(body.Length); stream.Write(lenBytes, 0, 4); stream.Write(body, 0, body.Length); // 接收端:先把 4 字节长度完整读出来 byte[] lenBuf = new byte[4]; int offset = 0; while (offset < 4) { int n = stream.Read(lenBuf, offset, 4 - offset); if (n == 0) throw new IOException("连接关闭"); offset += n; } int bodyLen = BitConverter.ToInt32(lenBuf, 0); // 再按长度把包体完整读出来 byte[] bodyBuf = new byte[bodyLen]; offset = 0; while (offset < bodyLen) { int n = stream.Read(bodyBuf, offset, bodyLen - offset); if (n == 0) throw new IOException("连接关闭"); offset += n; } string text = Encoding.UTF8.GetString(bodyBuf); Console.WriteLine("完整消息: " + text);

两个 while 循环保证了“必须读出完整的 4 个字节”和“必须读出完整包体”。在真实网络上,一次 Read 返回的数据量不固定,很可能长度字段只读到了 2 个字节,剩下的 2 个字节要等下一次 Read。用循环累积是这个场景下唯一可靠的写法。

BitConverter.ToInt32 注意字节序:Windows 默认小端,如果另一端是单片机发来的大端序列,需要先把字节数组 Reverse 再转换。实际工程里,帧长较短、一帧一处理的场景下上面的写法没问题;如果要面对高吞吐量数据流,就应该设计一个接收缓存队列,把每次 Read 到的数据先缓存,再按“长度前缀”从缓存中解析出完整消息。这是协议封装层的内容,上位机项目有必要做,但不在最小例程的范围内。

4.3 编码一致性:默认的 UTF8 不是所有设备都认

长度边界解决的是“消息从哪里断开”,接下来要解决“字节怎么变成文字”。C# 里的 string 在内存中是 UTF-16,而网络传输的 byte[] 必须显式指定编码。上面的例程用 Encoding.UTF8,是最安全的选择,因为同一套代码两端都是 C#。

但当另一端是 PLC、激光传感器、扭矩仪,或者用其他语言写的客户端时,编码必须去查设备手册。很多国产设备默认 GB2312,一些老仪表用 ASCII 码,还有的用 Modbus 协议直接传十六进制字节,这种情况下根本不能把字节转成 string,而应该按字节顺序做字段解析。用错编码的表现很典型:英文正常、中文变乱码,或者出现半个汉字的长度偏差,导致后续解析全部错位。

最省心的习惯是:所有涉及协议解析的地方,统一用一个静态类管理编码,而不是依赖系统默认值。要记住,Encoding.Default 在中文 Windows 上不是 UTF8,换一台机器就可能“翻车”。项目一开始就把编码写死成 UTF8 或者设备手册指定的编码,能省掉后面一星期的联调时间。

5. 五个高频坑与排查路径:从“被动拒绝”到“死连接”

5.1 连接不上:报错“目标计算机积极拒绝”

现象:客户端调用 Connect 时抛出 SocketException,提示“由于目标计算机积极拒绝,无法连接”。

原因:目标主机的对应端口根本没有进程在监听,或者监听地址不是客户端访问的那个网卡。IP 地址配错、端口写错、服务端进程没起来,这三种情况都表现为同一个错误。

解决:先确认服务端真的在监听。Windows 上执行netstat -ano | findstr 8888,看到 LISTENING 说明监听正常;再用ping验证网络能通;最后确认服务端监听的是 IPAddress.Any 还是某个特定 IP。如果服务端只监听了 127.0.0.1,那局域网内的其他机器连不上是正常的,不算 Bug。

5.2 端口被占用:Start 时提示“地址已在使用”

现象:服务端调用 listener.Start() 时抛 SocketException,提示“通常每个套接字地址只允许使用一次”。

原因:上一次运行的服务端进程没有退出,端口还占着。调试环境里最常见的是 Ctrl+C 中断后进程残留,或者程序崩溃但句柄没有释放。

解决:打开任务管理器找到残留进程结束它;或者用代码设置 ReuseAddress。对 TcpListener 来说:

listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start();

这里有个边界:ReuseAddress 只对 TIME_WAIT 状态下的旧连接有效,如果端口被另一个活跃的进程占用,设置它也没用,还是老老实实找到占用进程并结束它。

5.3 客户端挂掉后,服务端一直抛异常

现象:客户端直接拔网线或者关闭程序,服务端没有提示,等下一次往这个连接上读写数据时,直接抛出 IOException,导致服务端进程退出。

原因:TCP 是面向连接的,但操作系统在“非正常断开”时检测不到,只有在读写那一刻才会发现链路断了。服务端没有 catch 异常,也没有心跳,就会跟着崩。

解决:读写必须包在 try-catch 里;Read 返回 0 时主动 break;业务上再加心跳机制。如果只是保活,3 到 5 秒发一次心跳包,连续收不到心跳就判定超时并关闭连接。

5.4 防火墙拦截了监听端口

现象:本机运行服务端,本机客户端连接正常;换一台机器通过局域网连接时,客户端一直卡在 Connect 上直到超时。

原因:Windows 防火墙默认拦截外部传入的未授权连接。服务端虽然监听了端口,但防火墙不允许外部 IP 访问,Connect 表现为超时而不是“拒绝”。

解决:在服务端所在机器的防火墙入站规则里,放行对应端口或程序。工控现场通常让 IT 管理员统一配置;开发时可以用命令临时加规则,生产环境不建议永久关闭防火墙。

5.5 拔网线后连接还在:心跳才是最后的“后悔药”

现象:客户端和服务端之间的网线被拔出,两端都认为连接还活着。等到网线插回,数据开始收发时,才突然报错或出现大量重传。

原因:TCP 的 KeepAlive 默认是 2 小时才探测一次,拔线后没有数据流动,任何一端都感知不到异常。

解决:在应用层自己做心跳。客户端定时向服务端发送心跳消息,服务端记录最后心跳时间,超过阈值就主动 Close。阈值一般设为心跳间隔的 3 倍,例如 5 秒发一次心跳,15 秒没收到就判定为死连接。很多现场上报的“断连问题”,最后都是补一个心跳解决的,这属于协议设计的一部分,不要省略。

6. 把最小例程变成能交付的上位机模块

6.1 从控制台到上位机 UI:跨线程更新控件的正确姿势

把上面的逻辑从控制台挪到 WinForms 或 WPF 界面时,会遇到第一个坎:接收数据的线程不是 UI 线程,直接给 TextBox 赋值会抛“线程间操作无效”的异常。解决办法是用Invoke或BeginInvoke让控件更新操作回到 UI 线程:

private void OnDataReceived(string msg) { if (this.textBoxLog.InvokeRequired) { this.BeginInvoke(new Action<string>(OnDataReceived), msg); } else { this.textBoxLog.AppendText(msg + Environment.NewLine); } }

6.2 消息协议的实用设计:固定报头 + 可变长度

真正用于上位机的协议,建议从一开始就按“帧头 + 长度 + 命令 + 数据 + 校验”来设计,而不要只传裸字符串。这套结构稳定可靠、排查问题也容易看:

// 报文格式: [帧头 0xAA 0x55][长度 4字节][命令 2字节][数据 N字节][CRC校验 2字节] public const int FrameHead = 0xAA55; public const int HeaderLength = 6;

我自己的做法是:先把最小例程跑通,然后立刻补上两层——第一层是收发循环与断线重连,第二层是协议解析。很多项目前期为了省事只传字符串,后面加设备、加指令时才发现协议没法扩展,只能重写通信层。早一点定好长度前缀和命令字段,反而最省钱。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 23:40:50

智能体不是聊天机器人:3个真实案例揭秘企业业务流程自动化落地

我不是写代码出身&#xff0c;转型做企业AI落地这几年&#xff0c;大部分时间都泡在客户的工位边上。2025年下半年到2026年初&#xff0c;我在合肥跑了不少本地企业&#xff0c;从高新区的软件园到经开区的工厂&#xff0c;再到天鹅湖万达旁边的写字楼&#xff0c;前前后后接触…

作者头像 李华
网站建设 2026/10/4 23:29:26

逆强化学习IRL教程代码实战:从专家轨迹反推奖励函数

简介&#xff1a;这份资源是面向强化学习与逆向强化学习&#xff08;IRL&#xff09;方向学习者与研究者的一套示例代码工程&#xff0c;重点解决从专家演示中反推奖励函数这一核心问题的实践落地。作者在实现IRL框架时对BURLAP代码库做了必要修改&#xff0c;因此包内同时附带…

作者头像 李华
网站建设 2026/10/4 23:11:59

Python序列底层机制与实战:字符串、列表、元组的高效用法

1. 开篇&#xff1a;Python序列&#xff0c;远比你想象的更有料做了这么多年Python开发&#xff0c;我越来越觉得序列类型&#xff08;字符串、列表、元组&#xff09;是新手最容易"自以为懂了"的知识点。不少人在初学阶段写过a [1,2,3]&#xff0c;会用append往里塞…

作者头像 李华