
简介面向C#初学者或刚接触网络编程的开发者这份压缩包提供了最简的TCP/IP客户端与服务端实现演示了从创建Socket、指定IPv4地址族与流式套接字到绑定端口、启动监听、接受连接以及客户端通过Connect建立连接后利用Send/Receive交换数据的完整链路适合作为入门练习或后续项目脚手架。包内共55个文件整体约450KB以cs源码、exe可执行程序、txt说明和项目配置文件为主客户端与服务端均包含可直接运行的exe另附pdb调试文件、resources资源及图标文件解压后即可对照源码验证通信效果无需额外配置环境。目前已有206人学习该资源。源码中同时给出了TcpListener与TcpClient两种常用写法并附有Visual Studio解决方案文件便于在工程中直接打开调试对于想进一步理解网络编程的读者还可参考描述中提到的多线程连接处理、异常捕获与数据编码等扩展方向从而快速掌握基础通信模块的搭建方法。1. TCP/IP C# 最简单例程到底解决什么问题一条跨机器的可靠文本通道做上位机、自动化设备或者办公小工具的时候最常遇到的问题不是逻辑复杂而是两台机器之间传一句话都传不踏实。工控机采集到数据要发给上位机或者上位机要下发一条指令给设备很多人的第一个念头就是“TCP/IP”。这个方向没错TCP/IP 在 C# 里做客户端和服务端靠内置的TcpListener和TcpClient就能搭出一个最小可用通道不需要引入第三方框架也不依赖复杂的通信中间件。这篇例程针对的就是这类诉求你手里有一台 Windows 主机想用它做服务端监听端口另一台机器也可能是本机另一个进程做客户端来连它互相收发字符串或小数据块。适合刚接触 C# 上位机开发的人也适合需要快速验证“服务端接口测试”是不是通的从业者。读完你能独立写出一个可运行的客户端和服务端并且知道连接不上、数据收不全时先查哪里。2. 先分清 TCP/IP 与 C# 的 API为什么这块用 TCP最小验证怎么做2.1 为什么这类例程默认选 TCP 而不是 UDPTCP/IP 协议族里应用层最常用的两个传输层协议是 TCP 和 UDP但“客户端-服务端传数据”这件事默认选 TCP。原因是它给出了三个硬承诺数据按序到达不丢不重复。对于控制指令、状态上传这类场景指令丢了可能造成产线误动作重复了可能造成重复执行这两件事的代价都比几十毫秒延迟高得多。UDP 不是不能做通信它更适合视频流、游戏同步这种允许丢帧重发的场景。你要做的是一个“最简单例程”目标是把通信链路跑通不是调低延迟所以选 TCP 是稳妥的。C# 里实现 TCP server 和 client 至少有四类 API 可选最底层的Socket、包装好的TcpListener/TcpClient、更上层的NetworkStream以及像 ASP.NET Core 那样的承载层框架。最简单例程里我用TcpListener搭档TcpClient它们把 socket 的创建、绑定、监听、连接过程收敛得很干净如果你日后要自己控制缓冲区或 IO 行为随时可以用Socket替换。2.2 用本机回环跑通最小链路的验证命令写代码之前先确认操作系统层面 TCP 通信环境是好的。这一步很重要因为很多时候代码没问题是端口被占或者防火墙拦了结果你在代码里瞎找半天这属于 TCP 编程最常见的“黑匣子”错觉。先在服务端开一个真实监听端口程序再用另一条命令去连它就能把“操作系统网络栈正常”和“C# 代码正常”两件事分开验证。# 在 PowerShell 中检查端口 9000 是否在监听 netstat -ano | findstr 9000 # 从本机发起一个 TCP 连接到 9000 端口 Test-NetConnection 127.0.0.1 -Port 9000netstat输出里看到LISTENING说明有进程在 9000 端口等待连接Test-NetConnection的TcpTestSucceeded输出为True说明 TCP 握手成功。这两条命令可以作为 C# 代码的参照物命令行能通代码连不通问题基本出在代码命令行也不通先查端口占用和防火墙。做服务端接口测试时这套命令比写完整客户端快得多我一般先用它确认端口状态再动手调试。2.3 C# 四类 API 的取舍以及什么时候放弃“最简单”Socket最灵活但代码量最大要自己处理Bind、Listen、Accept和异步收发的细节TcpListener和TcpClient是Socket的友好封装适合 90% 的业务通信NetworkStream是TcpClient.GetStream()得到的流对象负责读写字节ASP.NET Core 那套承载模型适合做 HTTP/HTTPS 服务TCP 例程用它有点重。如果你要传文件、做大并发网关或者要精确控制每个连接的发送缓冲区那就别追求最简单改用Socket并自己管理缓冲区。但如果你只是让客户端和服务端互传文本、小报文TcpListener加TcpClient是性价比最高的组合。下面两章就给这个组合的最小可运行实现代码基于 .NET 6 及以上版本的 C# 控制台项目不需要额外安装 NuGet 包。3. 服务端怎么写监听循环、接收连接和第一版消息边界处理3.1 服务端程序骨架监听、接受连接、逐个处理服务端的核心逻辑是“监听一个端口循环接受客户端连接为每个连接开一个独立任务处理收发”。下面这段代码是最简版的服务端它监听0.0.0.0:9000每来一个客户端就启动一个异步处理任务收到的消息以文本形式打印出来并原样回显一条确认文本。using System.Net; using System.Net.Sockets; using System.Text; TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine($服务端已启动监听 0.0.0.0:9000); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); Console.WriteLine($客户端接入{client.Client.RemoteEndPoint}); _ HandleClientAsync(client); // 不等待立刻接受下一个连接 } async Task HandleClientAsync(TcpClient client) { using (client) using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[1024]; while (true) { int readCount await stream.ReadAsync(buffer); if (readCount 0) { Console.WriteLine(客户端主动断开); break; } string message Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($收到消息{message}); byte[] ack Encoding.UTF8.GetBytes($ack:{message}); await stream.WriteAsync(ack); } } }这段代码里有两个关键点。第一IPAddress.Any表示服务端监听本机所有网卡 IP这样局域网内任意一台机器都能通过服务端 IP 访问如果你只想允许本机连接改成IPAddress.Loopback127.0.0.1即可。第二AcceptTcpClientAsync配合while (true)构成一个持续接收连接的循环而_ HandleClientAsync(client)的意思是“启动一个异步任务不在当前循环里等它结束”这是保证服务端能同时服务多个客户端的基础。3.2 关于半包、粘包为什么不能把一次 Read 当成一条消息上面这个例程能跑通但它有一个明显隐患ReadAsync返回的字节数不保证就是一条完整消息。TCP 是字节流协议它的底层只保证字节顺序不保证“一次写入就是一个完整消息”。客户端一次发送 100 字节服务端可能一次读回 100 字节也可能分两次读回 60 和 40客户端连续发送两条消息服务端也可能一次读回两条的合并体。常见的术语管前者叫“半包”后者叫“粘包”这不是 C# 的问题是 TCP 本身的传输特性。如果要做到“收发消息内容准确”最简单的办法是约定消息边界比如每条消息以换行符结尾或者像第 6 章那样加一个固定长度的消息头。在当前这个最简例程里数据是短文本一次ReadAsync大概率能覆盖完整一条但你要知道这只是碰巧够用不是设计保证。做完这个例程下一步就该考虑消息帧。3.3 超时参数为什么服务端要设置 ReceiveTimeout网络编程最怕“连接挂着不吐数据”线程一直阻塞在ReadAsync上。给NetworkStream设置超时时间可以避免无限等待这是服务端稳定性的重要参数。C# 里和超时相关的属性有两个ReadTimeout和WriteTimeout它们作用于同步读写对异步读写不生效。TcpClient client await listener.AcceptTcpClientAsync(); client.ReceiveTimeout 5000; // 5 秒收不到数据就抛异常 client.SendTimeout 5000; // 5 秒发不出去就抛异常 client.NoDelay true; // 关闭 Nagle 算法降低小包延迟我一般还会配一个client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true)让 TCP 层定期发送保活探测包这样客户端异常断电时服务端能在两分钟左右感知连接失效而不是一直把连接挂在列表里。对最少代码的例程来说ReceiveTimeout和NoDelay是性价比最高的两个参数前者防止死等后者让交互式小消息发得更及时。4. 客户端怎么写连接、重试和断开时怎么优雅收尾4.1 客户端最小代码连接服务端并发送一条消息客户端的职责比服务端简单直接指定服务端 IP 和端口建立连接写入数据读取回包。下面的代码就是完整可运行的最小客户端服务端跑在上面那个程序上这个客户端连上去发一条 hello tcp。using System.Net; using System.Net.Sockets; using System.Text; TcpClient client new TcpClient(); try { await client.ConnectAsync(IPAddress.Parse(127.0.0.1), 9000); Console.WriteLine(连接成功); NetworkStream stream client.GetStream(); byte[] sendData Encoding.UTF8.GetBytes(hello tcp); await stream.WriteAsync(sendData); Console.WriteLine(消息已发送); byte[] buffer new byte[1024]; int readCount await stream.ReadAsync(buffer); string response Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($服务端回包{response}); } catch (SocketException ex) { Console.WriteLine($连接失败{ex.Message}); } finally { client.Dispose(); }ConnectAsync会完成三次握手握手期间如果目标端口没监听或者防火墙拦了数据包这里会抛SocketException。注意IPAddress.Parse的地址要和服务端监听地址对应服务端监听Any客户端可以写服务端主机的实际 IP如192.168.1.10服务端监听Loopback客户端就只能写127.0.0.1。写死 IP 适合最简单的测试正式项目中 IP 和端口一般放配置文件里。4.2 断线重连连接不在建立那一刻连接是持续的过程真实生产环境里没有“连接一次用一辈子”这种好事。服务端重启、网线拔出、网络切换都会让已有连接失效。刚才那个最简客户端在连接成功后不再管断线这在开机演示里够用但作为上位机客户端不够。我一般会加一个“断线检测 自动重连”的循环让客户端始终尝试恢复连接。using System.Net; using System.Net.Sockets; using System.Text; string serverIp 127.0.0.1; int port 9000; TcpClient? client null; while (true) { try { client ?? new TcpClient(); if (!client.Connected) { Console.WriteLine(正在连接服务端...); await client.ConnectAsync(IPAddress.Parse(serverIp), port); Console.WriteLine(已连接); } NetworkStream stream client.GetStream(); byte[] data Encoding.UTF8.GetBytes($heartbeat:{DateTime.Now:HH:mm:ss}); await stream.WriteAsync(data); byte[] buffer new byte[256]; int n await stream.ReadAsync(buffer); if (n 0) { Console.WriteLine(连接被服务端关闭); client.Dispose(); client null; } else { Console.WriteLine($回包{Encoding.UTF8.GetString(buffer, 0, n)}); } } catch (SocketException ex) { Console.WriteLine($网络异常: {ex.Message}5 秒后重试); client?.Dispose(); client null; await Task.Delay(5000); } await Task.Delay(3000); // 每 3 秒发一次心跳 }这里有个细节client.Connected只能反映 TCP 连接在操作系统层面是否建立不能反映对端是否还活着。判断连接是否有效的可靠依据是ReadAsync返回 0 或者写入时抛异常。所以循环里每次先尝试写心跳再尝试读回包任一步异常就销毁旧连接重新走连接流程。重试间隔建议做退避比如第一次 3 秒、第二次 6 秒最多到 30 秒停一下避免服务端恢复时被一大波重连请求冲垮。4.3 客户端最常见的报错和处理思路客户端连不上时最常见的现象是抛SocketException提示内容五花八门由于目标计算机积极拒绝无法连接、连接尝试失败因为连接方在一段时间后未正确答复或建立连接。前者说明 IP 能到但端口没监听后者说明数据包发出去了但没收到响应多半是防火墙或服务端没监听。这类问题排查有个固定套路先用Test-NetConnection验证端口通不通通就抓代码问题不通检查服务端是否在监听、监听的是哪个 IP、防火墙是否放行。很多客户端程序报“请检查网络设置”其实网络设置没问题是服务端挂了。分清“网络不可达”和“端口拒绝”是 TCP 调试的基本功也决定了你该看哪一端的日志。5. 避坑TCP C# 例程最常见的四个坑现象、原因和解法5.1 服务端本机能连局域网其他机器连不上现象服务端程序跑在本机从这台机器上用127.0.0.1连接一切正常换一台局域网电脑用服务端 IP 连接直接超时。原因第一服务端监听的地址是IPAddress.Loopback只在回环接口上监听局域网网卡根本没有监听端口第二即便监听的是IPAddress.AnyWindows 防火墙默认会拦截入站 TCP 连接本机回环地址通常不受防火墙限制但局域网访问会被拦。解决服务端用IPAddress.Any监听并在防火墙入站规则中放行对应端口。放行操作在控制面板的“Windows Defender 防火墙”里添加“入站规则”选择 TCP 和特定本地端口填 9000。改完再用netstat -ano | findstr 9000确认监听地址是0.0.0.0:9000而不是127.0.0.1:9000。5.2 客户端一次发送两条消息服务端读到一条拼接数据现象客户端连发“A”和“B”两条消息服务端收到的是“AB”一条拆分逻辑因此错乱。原因TCP 是基于字节流的Nagle 算法和内核缓冲区可能把多个WriteAsync的数据合并进同一个 TCP 段接收方一次ReadAsync就拿到了两条消息的字节拼接。解决为消息定义边界。最简单的做法是每条消息以\n结尾服务端读到一个完整行才处理更通用的是用固定长度头加负载的帧格式这一步代码见第 6 章。收到数据后先在内存缓冲区里做“拆帧”不要拿一次ReadAsync的返回值当成一条业务消息。5.3 服务端关闭程序后客户端立刻抛“远程主机强迫关闭了一个现有的连接”现象服务端正常退出后客户端下一次ReadAsync或WriteAsync抛SocketException (10054)。原因TCP 是四步挥手协议服务端调用Dispose关闭连接后客户端继续读写时内核感知到对端已关闭上报异常错误码。10054 表示远程主机强制关闭连接10053 表示软件中止了连接。解决这是正常现象不是 bug。客户端要把这两种错误码视为“连接已失效”统一走重连逻辑不要试图让本次读写“恢复”。另外服务端不要直接Environment.Exit先Stop()监听再依次关闭客户端连接这样多数情况下能走正常挥手序列但不要依赖它因为对端断网的情况下无论如何都会变成强制关闭。5.4 发小包延迟明显零碎交互响应慢现象客户端每发一条小消息服务端要等几十毫秒才收到交互感明显不好。原因Nagle 算法默认开启它会把多个小包攒到一批再发送目的是减少小包数量。在交互式场景里消息本身小于 MSS等攒包的时间就变成了额外的延迟。解决在TcpClient上设置client.NoDelay true关闭 Nagle 算法。代价是网络里的小包数量变多在千兆内网里这是可接受的。另一个相关坑是WriteAsync之后立刻ReadAsync对端还没把数据发送完这边就超时了解决方法是给ReadTimeout留 3 到 5 秒的余量而不是设成 500 毫秒“硬等”。TCP 不是实时总线超时设太短只会得到一堆误报。6. 进阶用 4 字节长度头做消息帧顺带验证整个传输链路上面代码能跑通但消息边界问题悬而未决。这一章给出一个能直接照用的帧协议每条消息开头用 4 字节表示负载长度大端序后面跟着真正的文本数据。服务端收消息时不断积累缓冲截出完整帧再解析。这层处理做完TCP C# 例程才算是一个可以面对真实数据流的最小可靠实现。发送端组帧代码如下static byte[] BuildFrame(string message) { byte[] payload Encoding.UTF8.GetBytes(message); byte[] frame new byte[4 payload.Length]; frame[0] (byte)(payload.Length 24); frame[1] (byte)(payload.Length 16); frame[2] (byte)(payload.Length 8); frame[3] (byte)payload.Length; Buffer.BlockCopy(payload, 0, frame, 4, payload.Length); return frame; }接收端维护一个Listbyte接收缓冲每次ReadAsync后先尝试解析帧。解析逻辑要点缓冲区不足 4 字节就先攒着够 4 字节能算出负载长度但总长度不足则继续等待拆到一条完整消息就处理并从缓冲区移除已消费部分。这个过程本质是“字节流转消息”写完这一层再也不会出现把两条消息拼接当成一条的消息。我平时验证这个帧协议的方式很简单客户端循环发送 100 条编号消息服务端每收到一条完整消息就计数最后比对总数如果出现计数小于 100说明有半包没被正确拼接需要检查缓冲积累逻辑。验证表可以这样列数据量 100 条每条 50 字节预期收到 100 条实测必须也是 100 条故意把发送间隔压到 1 毫秒以内再测一次看粘包下是否仍然拆帧正确。TCP 调试第一步永远是“先证明字节没丢”再谈业务逻辑。这个方向还有一个值得做的进阶动作把服务端的连接对象和业务处理拆开。因为一旦消息帧能可靠截出来下一步自然会想到“一个连接收多种消息按消息类型分发到不同处理函数”这时候你会需要消息头里再加一个类型字段。接口留好后面扩展就顺畅了而不是把if/else写满整个处理循环。做 TCP 例程最容易翻车的地方是想一次性写一个“万能通信框架”我的教训是从一条消息一个连接做起确认字节流可靠再谈架构。希望帮到你按这套步骤先跑通客户端和服务端你会比大多数把ReadAsync当一次消息读的人更快上手。本文还有配套的精品资源点击获取