☰
最简单C# TCP/IP例程:同步Socket通信从零到实战
2026/9/29 17:24:28 网站建设 项目流程

简介:面向C#入门开发者的TCP/IP网络通信最小实现例程包,基于Socket与TcpListener/TcpClient,完整演示服务端-客户端双向通信流程,适合刚接触网络编程、想快速跑通第一个通信程序的读者对照学习。包内包含完整的Visual Studio解决方案,源码、界面与工程配置齐全,可直接编译运行。资源共55个文件,以C#源码(cs)、可执行程序(exe)、说明文档(txt)为主,另有resx界面资源、dll依赖库及缓存等辅助文件,整体仅450KB,下载打开解决方案即可查看代码结构并启动调试。目前已有206人学习下载。借助源码与编译好的服务端/客户端可执行程序,可直观理解服务端监听端口、接受连接、收发数据以及客户端建立连接、发送请求、接收响应的完整链路,便于在此基础上扩展多客户端处理与异常捕获逻辑。

1. TCP/IP C#例程:为什么说“最简单”反而最实用

翻了很久资料,你会发现网上九成的 TCP/IP C# 例程都套着异步、粘包拆包、心跳重连、SuperSocket 这类“重型外衣”,看起来功能齐全,可刚入门的人根本学不会。而真正的“最简单例程”就三样东西:一个 Socket,一个 Bind/Listen/Accept,一个 Send/Receive,几十行代码跑通整个链路。这个资源就是按这个思路做的——C# 控制台程序,一个服务端、一个客户端,同步阻塞式通信,没有花哨封装,却把 TCP/IP 通信的四板斧全演示完了。它的价值在于让你先把“连接怎么建立、数据怎么流动”这件事看透,再去看高并发框架时,你才不会被术语吓住。适合刚入门 C# 网络编程的应届生,也适合做上位机、工控机通信、写简单协议测试工具的现场工程师——先用最小代码验证链路通不通,比什么都重要。

2. 先搞懂 Socket 模型:同步阻塞例程的选型理由与核心流程

很多刚接触网络编程的人上来就写异步,结果被 BeginSend、EndReceive 和回调函数搞得一头雾水。这个最简单例程用的是同步阻塞 Socket,原因很简单:同步模型是 TCP/IP 编程的地基,所有异步、事件驱动、高性能框架,本质都是在同步收发基础上加入多线程或 I/O 复用。先把同步模型吃透,后面看啥都不虚。

2.1 同步阻塞 Socket 的通信流程和关键 API

TCP 通信的建立遵循“服务端被动等待,客户端主动连接”的模型。C# 里最核心的对象是System.Net.Sockets.Socket,整个流程可以拆成六步:

步骤服务端动作客户端动作关键 API
1创建 Socket创建 Socketnew Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
2绑定 IP 和端口无需绑定Bind(IPEndPoint)
3转为监听状态发起连接服务端Listen(backlog);客户端Connect(IPEndPoint)
4接受客户端连接已连接服务端Accept()返回新 Socket
5收发数据收发数据Send(byte[])/Receive(byte[])
6关闭连接关闭连接Shutdown()+Close()

SocketType.Stream表示流式套接字,对应 TCP;ProtocolType.Tcp则明确指定传输层协议。注意AddressFamily.InterNetwork是 IPv4 地址族,如果要用 IPv6,则为InterNetworkV6。这个最简单例程默认只处理 IPv4,足够覆盖 99% 的局域网和本机测试场景。

服务端Accept()是阻塞方法,调用后当前线程会停在那里,直到有客户端连进来才返回。返回的新 Socket 表示与客户端之间的独立通道——这是很多初学者最容易忽略的点:监听的 socket 不要直接用来收发数据,它只负责“接客”,真正收发数据的是Accept()返回的“会话 socket”。

数据收发层面,Receive()同样是阻塞方法,它把收到的字节流填充到传入的 byte[] 缓冲区中,并返回实际接收到的字节数。如果返回值是 0,说明对方已正常关闭连接。这个返回值是后面判断连接是否断开的唯一可靠依据,比异常捕获更直观,很多第三方库也是基于这个特性做断线检测的。

2.2 例程里的连接建立与数据收发:代码逐段拆

先看服务端完整代码。这个例程的逻辑是:绑定本机任意 IP 的 8888 端口,循环监听,每接受一个客户端就接收一条消息并原样回发,然后关闭该会话连接。

// Server.cs - 最简单的 TCP 服务端 using System; using System.Net; using System.Net.Sockets; using System.Text; class TcpServer { static void Main(string[] args) { // 1. 创建 IPv4 的 TCP Socket Socket listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 2. 绑定本机所有网卡的 8888 端口。IPAddress.Any 表示监听所有网卡 IPEndPoint localEndPoint = new IPEndPoint(IPAddress.Any, 8888); listenSocket.Bind(localEndPoint); // 3. 开始监听,backlog=10 表示最多有 10 个等待连接的客户端排队的缓冲 listenSocket.Listen(10); Console.WriteLine("服务端已启动,监听端口 8888 ..."); while (true) { // 4. 阻塞等待客户端连接,拿到会话 socket Socket clientSocket = listenSocket.Accept(); Console.WriteLine($"客户端连接: {clientSocket.RemoteEndPoint}"); // 5. 接收数据。缓冲区大小 1024 字节 byte[] buffer = new byte[1024]; int receivedCount = clientSocket.Receive(buffer); string receivedText = Encoding.UTF8.GetString(buffer, 0, receivedCount); Console.WriteLine($"收到: {receivedText}"); // 6. 原样回发,模拟服务端响应 byte[] sendBuffer = Encoding.UTF8.GetBytes("服务端已收到: " + receivedText); clientSocket.Send(sendBuffer); // 7. 关闭会话连接,但保留监听 socket 继续接收下一个客户端 clientSocket.Shutdown(SocketShutdown.Both); clientSocket.Close(); } } }

逻辑说明和参数说明:

  • IPAddress.Any是一个特殊地址0.0.0.0,绑定它意味着服务端监听本机所有网卡。如果你只想让某一台局域网机器访问,可以改成IPAddress.Parse("192.168.1.100")绑定那个网卡的具体 IP。
  • Listen(10)中的参数是 backlog,即等待连接队列的长度。这个例程是串行处理客户端的,一次只服务一个连接,所以 backlog 只要填 1~10 都行。如果你填 0,在 Windows 上会被系统自动修正为 1 或 2,但在 Linux 上可能导致客户端Connect报“连接拒绝”。
  • Receive()是阻塞的,如果客户端连接后一直不发数据,服务端会卡在这一行,永远不会去Accept()下一个客户端。这就是同步模型的最大局限,后面避坑章节会讲怎么处理。
  • Shutdown(SocketShutdown.Both)表示发送和接收都禁止,然后Close()释放资源。只调Close()而不调Shutdown()有时会导致数据丢失,因为系统可能需要时间把缓冲区的数据发完。

再看客户端代码。客户端的职责是连接服务端,发送一段文本,然后接收服务端回显:

// Client.cs - 最简单的 TCP 客户端 using System; using System.Net; using System.Net.Sockets; using System.Text; class TcpClient { static void Main(string[] args) { // 1. 创建同类型的 Socket Socket clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 2. 连接服务端,这里的 IP 改成你服务端的实际地址 IPAddress serverIp = IPAddress.Parse("127.0.0.1"); IPEndPoint serverEndPoint = new IPEndPoint(serverIp, 8888); clientSocket.Connect(serverEndPoint); Console.WriteLine("已连接服务端"); // 3. 发送数据 string message = "Hello TCP, 这是最简单的例程"; byte[] sendBuffer = Encoding.UTF8.GetBytes(message); clientSocket.Send(sendBuffer); Console.WriteLine($"发送: {message}"); // 4. 接收服务端回显 byte[] receiveBuffer = new byte[1024]; int receivedCount = clientSocket.Receive(receiveBuffer); string response = Encoding.UTF8.GetString(receiveBuffer, 0, receivedCount); Console.WriteLine($"服务端回显: {response}"); // 5. 关闭连接 clientSocket.Shutdown(SocketShutdown.Both); clientSocket.Close(); } }

这里有两个值得展开的细节。Connect()是同步阻塞方法,它会完成 TCP 三次握手后才返回。如果服务端没启动,Connect()会抛SocketException,错误码10061表示目标机器积极拒绝,通常就是端口没监听或防火墙拦截。Send()方法把字节流发送出去就立即返回,返回值是实际写入系统发送缓冲区的字节数,不等于对方“已经收到”——TCP 只保证在传输过程中不丢序,不保证应用层立即处理。

编码问题也是这个例程特意使用UTF8的原因。Encoding.UTF8是跨平台最通用的文本编码,Windows 控制台默认编码可能是 GBK,如果你用Console.WriteLine打印中文乱码,不代表网络传输乱码,而是控制台显示问题。在代码里,只要两端都统一用 UTF8 编码,就不会出现中文乱码。我见过太多现场问题最后都是两端编码一个 GBK 一个 UTF8 导致的。

3. 把例程跑起来:从编译到两台电脑联调

代码看懂是一回事,能跑起来又是另一回事。这一章直接给你操作步骤和参数清单,确保在 Visual Studio 或命令行环境下都能顺利运行。

3.1 同一台机器回环测试:按步骤操作

先在本机做回环测试,目的有两个:一是验证代码本身没有逻辑错误,二是熟悉程序运行时的行为特征。步骤如下:

  1. 新建一个控制台项目,将服务端代码保存为Server.cs,客户端代码保存为Client.cs。如果你用 Visual Studio,可以建两个单独的控制台项目分别编译;如果用命令行,用csc分别编译两个源文件。
# 编译服务端和客户端(假设已安装 .NET SDK 或 Visual Studio 的开发者命令行) csc Server.cs -out:Server.exe csc Client.cs -out:Client.exe
  1. 先启动Server.exe,控制台会输出“服务端已启动,监听端口 8888 ...”。此时服务端阻塞在Accept()。

  2. 再打开另一个命令行窗口,运行Client.exe。正常情况下客户端输出“已连接服务端”“发送: ...”“服务端回显: ...”,服务端窗口输出“客户端连接: 127.0.0.1:xxxxx”和“收到: ...”。

  3. 如果客户端抛SocketException,先检查服务端是否真的在运行、端口是否写错、本机是否有其他程序占用了 8888 端口。

提示:在同一台机器上测试时,客户端的IPAddress.Parse("127.0.0.1")是回环地址,数据不会经过物理网卡,所以即使你没有联网也能跑通。这是验证逻辑最快的路径。

参数方面,缓冲区byte[1024]足够承载例程里的短消息。如果你测试长文本,超过 1024 字节的部分会被截断。这个例程没有处理“接收不完整”的情况,属于正常现象——后续避坑章节会说明为什么 TCP 一次接收不一定等于你一次发送。

3.2 局域网联调:IP、端口、防火墙的配置清单

本机跑通后,把客户端移到另一台电脑上,才能真正理解 TCP/IP 的“IP 地址 + 端口”定位方式。你需要做三件事:

配置项服务端客户端说明
服务端绑定 IPIPAddress.Any或固定局域网 IP不依赖绑定 Any 最省事
客户端连接 IP不依赖填写服务端局域网 IP,例如192.168.1.10不要写 127.0.0.1
端口88888888两端必须一致
Windows 防火墙放行 8888 端口或允许程序通过一般不需要改出站通常默认允许

首先,用ipconfig查看服务端的 IPv4 地址。客户端代码里IPAddress.Parse("127.0.0.1")要改成这个实际地址。其次,防火墙是最大的隐形杀手。Windows 默认防火墙会拦截入站连接,第一次运行服务端时系统会弹窗询问是否允许访问,如果没有弹窗,则要手动添加规则:

netsh advfirewall firewall add rule name="TCP Test 8888" dir=in action=allow protocol=TCP localport=8888

这条命令为入站方向放行 TCP 8888 端口。注意dir=in是入站规则,因为服务端是“接收连接”的一方;客户端作为发起方,出站方向默认放行,通常不需要额外配置。如果你在公司网络或域环境下,可能还要考虑组策略或安全软件拦截。

端口选择上,8888 这个端口号是我常用的测试端口,它不在常见的动态端口范围(49152~65535)内,也不容易被系统随机占用。你可以换成 8080、9000 或其他空闲端口,但尽量避免使用 80、22、3389 这类已被知名服务占用的端口,否则容易冲突。

联调时,用 TCP 工具验证链路是一个好习惯。在服务端跑的机器上,用telnet 192.168.1.10 8888测试端口通不通。如果 telnet 显示“无法连接”,说明网络不通或服务端没在监听;如果连上后没有任何提示但也看不到连接被拒绝,说明端口通,只是协议测试数据不匹配——这能帮你把问题从“网络层”和“应用层”分开,不浪费时间去调代码。

4. 避坑与排查:TCP/IP 例程最常见的六个翻车点

写完这个例程后,我把它丢给过很多新人跑,收集了不少真实翻车现场。这一章把最有共性的六个问题写出来,每条都是“现象 → 原因 → 解决”的结构,你照着检查就行。

4.1 客户端报“目标计算机积极拒绝”或“连接超时”

现象:运行客户端,抛SocketException: 由于目标计算机积极拒绝,无法连接;或者本机没问题,换了一台机器就报“连接超时”。

原因:积极拒绝通常是服务端没启动,或者端口没监听、客户端把端口写错了。连接超时则是网络层不通,比如 IP 写错、不在同一网段、防火墙丢包。特别注意,同网段和跨网段的表现不一样:同网段连不通往往是防火墙,跨网段则先查路由。

解决:先netstat -ano | findstr 8888确认服务端是否在监听;再用ping测 IP 连通性;最后用 telnet 测端口。把这三步走完,就能定位到 70% 的连接问题。

4.2 服务端收到了数据但客户端收不到回显

现象:客户端发送成功,服务端打印了收到的内容,但客户端Receive()一直阻塞,永远等不到响应。

原因:在同步阻塞模型里,如果客户端发送后立即调用Receive(),而服务端在Send()之后直接Close(),客户端也可能因为时序问题收不到完整的响应。更常见的情况是:服务端的Send()抛异常了,但客户端没感知。另一种冷门情况:服务端用Console.ReadLine()提前退出,导致连接被清理。

解决:在服务端Send()之后,加一个Thread.Sleep(100)再关闭,或者调用Close()前调用Shutdown(SocketShutdown.Send)。其实从根上讲,这个例程是“一问一答”式同步通信,只要服务端不主动Close(),客户端阻塞在Receive()就不会返回 0。最简单的修复:服务端发送完回显后不立即Close(),而是等到客户端先断开再清理。

4.3 服务端只能接收一个客户端,第二个连不上

现象:第一个客户端通信正常,第二个客户端连上后没有收到任何响应,甚至报连接失败。

原因:这个例程的服务端while(true)循环里,Accept()之后直接处理单个连接,处理完再回到Accept()。但如果第一个连接的处理逻辑中Receive()阻塞了,服务端就永远卡住,顾不上Accept()新连接。同步串行模型天生只能服务一个客户端。

解决:把每个客户端的处理放到独立线程或Task.Run()中。这是从“例程”走向“可用程序”的第一步,后面进阶章节会完整给出改造方案。

4.4 中文收发乱码

现象:客户端发送“你好”,服务端打印出来是乱码;或者反过来,服务端回显中文在客户端显示乱码。

原因:两端编码不一致。最常见的组合是:服务端默认用 GBK 解码,客户端用 UTF8 编码;或者控制台显示编码和网络编码混在一起。

解决:统一使用Encoding.UTF8。代码里严格用Encoding.UTF8.GetBytes()和Encoding.UTF8.GetString()。同时注意 Windows 控制台显示 UTF8 字符可能需要设置字体或使用Console.OutputEncoding = Encoding.UTF8;。网络传输层面,只要两端字节序列一致,显示问题完全不影响通信正确性。

4.5 Receive 缓冲区大小没搞对导致数据截断

现象:客户端发送一段超过 1024 字节的消息,服务端只收到前 1024 字节,内容被截断。

原因:Receive()一次最多读取缓冲区容量的数据量。例程里缓冲区为 1024,但 TCP 是字节流,没有消息边界,一次Send的数据可能被拆成多次Receive,多次Send也可能合并成一次Receive。所以“每次 Receive 返回的数据等于一次 Send 的数据”是错误的假设。

解决:先定义协议,最常见的是“长度前缀法”。发送时先发 4 字节的消息长度,再发消息体;接收时先读 4 字节得到长度,再循环读完剩余部分。这个方案在进阶章节会给出代码。如果只是测试用短消息,把缓冲区扩到 4096 或 8192 能缓解,但不治本。

4.6 客户端断开后服务端抛异常崩溃

现象:客户端直接关掉控制台窗口,服务端在Receive()处抛SocketException: 远程主机强迫关闭了一个现有的连接。

原因:TCP 连接断开时,对端会发送 FIN 或 RST。如果客户端进程被强制结束,系统来不及正常关闭连接,服务端的Receive()会收到一个异常。很多新手没有在Receive()周围捕获异常,程序直接崩溃。

解决:Receive()返回 0 表示正常断开,抛异常表示异常断开,两种情况都必须处理。在服务端代码里用try-catch包住收发逻辑,并在catch中记录连接信息和异常原因,然后Close()会话 Socket。这是长期运行的服务端程序最基本的健壮性要求。正确的断开判断逻辑是:

try { int receivedCount = clientSocket.Receive(buffer); if (receivedCount == 0) { Console.WriteLine("客户端正常断开"); } } catch (SocketException ex) { Console.WriteLine($"客户端异常断开: {ex.SocketErrorCode}"); } finally { clientSocket.Close(); }

4.7 防火墙规则没生效的排查顺序

现象:添加了防火墙规则还是连不上,或者第一次能连,重启之后又不通了。

原因:防火墙规则可能只添加到了“单一配置文件”而不是所有配置文件;也可能是程序本身被禁止监听端口,而端口规则只管入站,没管本地监听。

解决:先netsh advfirewall firewall show rule name="TCP Test 8888"确认规则状态。如果Enabled不是Yes,重新执行添加命令,并加上profile=any。如果还是不行,临时关闭防火墙测试(仅测试环境,生产环境不要关),确认是不是安全软件或组策略覆盖。

5. 进阶:从同步例程升级到可用的通信框架

最简单例程的价值在于“能跑通”,但真实项目中不可能只服务一个客户端。这一章给你三个最实用的升级技巧,让你下班前就能把例程改造成勉强能上线的通信模块。

5.1 用 Task.Run 实现多客户端并发

核心改造点:Accept()循环始终只负责接受连接,把每个客户端的收发逻辑丢给线程池处理。这样即使某个客户端卡住不发送数据,也不会阻塞新连接的接入。改造后的服务端骨架:

while (true) { Socket clientSocket = listenSocket.Accept(); Task.Run(() => ProcessClient(clientSocket)); } static void ProcessClient(Socket clientSocket) { try { byte[] buffer = new byte[1024]; int receivedCount = clientSocket.Receive(buffer); // 业务处理 ... clientSocket.Send(responseBuffer); } catch (SocketException ex) { Console.WriteLine($"会话异常: {ex.SocketErrorCode}"); } finally { clientSocket.Close(); } }

Task.Run把每个连接的处理从主循环挪到后台线程,主循环立刻回到Accept()等待下一个客户端。这个模型适合几十个连接的小型场景。如果吞吐量要求高,后续可以再改成BeginAccept异步模型或直接上SocketAsyncEventArgs,但核心思路不变:每个连接独立处理,互不干扰。

5.2 数据帧协议:解决粘包拆包

这是每个 TCP 开发者都要过的坎。简便做法是“4 字节长度前缀 + 消息体”。发送方构造帧:

byte[] bodyBytes = Encoding.UTF8.GetBytes("你好,这是一条消息"); byte[] lengthBytes = BitConverter.GetBytes(bodyBytes.Length); // 4字节小端整数 byte[] frame = new byte[4 + bodyBytes.Length]; Buffer.BlockCopy(lengthBytes, 0, frame, 0, 4); Buffer.BlockCopy(bodyBytes, 0, frame, 4, bodyBytes.Length); // 一次 Send 整帧 clientSocket.Send(frame);

接收方要读取完整的一帧:先读 4 字节得到长度,再循环读取直到读满该长度。

byte[] lengthBytes = new byte[4]; int lengthReceived = 0; while (lengthReceived < 4) { int chunk = clientSocket.Receive(lengthBytes, lengthReceived, 4 - lengthReceived, SocketFlags.None); if (chunk == 0) return; // 连接断开 lengthReceived += chunk; } int bodyLength = BitConverter.ToInt32(lengthBytes, 0); // 再根据 bodyLength 循环读 body

这里的Receive()重载多了三个参数:缓冲区、起始偏移量、要接收的字节数、SocketFlags。注意 TCP 流的特征:一次Receive()返回的字节数可能小于你请求的字节数,所以必须循环累加,直到接收够指定长度。这个写法把“粘包”和“拆包”都一并解决了——无论对方一次发多少帧,接收方总是按帧边界读取。

5.3 超时和心跳:让通信更可靠

同步阻塞模型下,Connect()在局域网内一般几十毫秒返回,但遇到 IP 不可达时可能要等很久。建议设置连接超时,避免 UI 卡死:

clientSocket.ReceiveTimeout = 5000; // 接收数据超时 5 秒 clientSocket.SendTimeout = 5000; // 发送数据超时 5 秒 clientSocket.Connect(serverEndPoint); // 连接超时不受这两个属性控制

连接超时在 .NET 中比较难做,同步Connect没有内置超时参数。常见做法是先调用BeginConnect并配合WaitOne(3000)超时,再结束异步等待。对最简单例程来说,你只要把ReceiveTimeout设上,防止客户端无限期等待,就已经比 80% 的例程健壮了。

心跳是一个更复杂的主题。简单场景下,可以定义一个 5 秒定时器,定时向服务端发送一个空消息或特定指令。服务端如果连续 3 次心跳超时未收到,就判定连接死亡并主动关闭。注意心跳消息必须有别于业务消息,通常在协议头中加一个消息类型字段,比如 0 表示心跳,1 表示业务数据。这一点在例程里不体现,但你一旦把它接入真实系统,就会理解心跳的作用——很多现场“假死”问题靠心跳能迅速暴露。

我自己的习惯是,任何 Socket 代码写完之后,都要做一个“关闭后重连”的验证:客户端连上、发送、断开、再重连、再发送,连续循环 10 次不崩溃,才敢把代码交到现场。这个验证能一次性暴露资源泄漏、状态残留、端口未释放等一系列问题。从那以后,我每次写完 TCP 通信代码都强制走一遍“连 → 发 → 断 → 重连”的流程,哪怕是最简单的例程也不例外。希望这套流程和上面的避坑清单能帮到你,让你不再被 TCP/IP 的“玄学”问题折磨。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询