news 2026/10/4 23:07:32

C# TCP/IP最简例程:TcpClient与TcpListener服务端客户端互通指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# TCP/IP最简例程:TcpClient与TcpListener服务端客户端互通指南

简介:面向C#初学者的TCP/IP通信例程包,内含服务端与客户端两个独立完整模块,清晰演示了传输控制协议下如何通过TcpListener、TcpClient和Socket类完成建立连接、发送数据与接收响应的全过程,适合刚刚接触网络编程、希望快速跑通首个通信程序的开发者。rar压缩包共55个文件,大小仅450KB,以C#源文件、可直接运行的exe可执行程序,以及解决方案和工程配置文件为主,同时包含少量资源文件、调试符号与文本说明,既能在Visual Studio中打开并编译,也能直接运行体验。目前已有206人学习/下载。整套示例将代码按客户端和服务端分为ClientFile、ServerFile两个独立目录,读者可以对照源码理解绑定地址、启动监听、接受连接、连接远程端、发送与接收数据等关键步骤。压缩包内还提供编译好的服务端.exe和客户端.exe,方便先运行观察效果,再对照代码学习;其中还带有简单的界面示例,降低了TCP/IP编程的入门门槛,是一份轻量但完整的起步资料。

1. TCP/IP C# 最简单例程:客户端和服务端成对跑通

这份资源是一套 TCP/IP 的 C# 最简单例程,客户端和服务端成对给出,新建两个控制台工程把代码粘贴进去就能通信。做上位机联调的人常遇到这种尴尬:设备文档写着 TCP 通信、端口也填了,数据却是乱的;刚学 C# 的读者用 Socket 写一长串代码连不上,最后发现只是监听地址写成了回环地址没改。这套例程就是先把“能收发数据”这条链路最短路径走通,再谈协议、并发和性能。

服务端用 TcpListener 监听端口,客户端用 TcpClient 主动连接,双方通过 NetworkStream 读写字节。代码量不大,但覆盖了建立连接、发送请求、接收响应、关闭连接四个核心步骤。适合三类人:一是 C# 网络编程新手,想有个能跑通的最小工程;二是做上位机采集的工程师,要验证设备 TCP 端口通不通;三是做课程设计的学生,需要一个客户端与服务端互相通信的骨架。至于异步、粘包、多客户端,后面章节会用同一份代码往外扩展。

2. 先看选型与原理:为什么最简例程用 TcpListener 和 TcpClient

2.1 从 Socket 到 TcpClient:封装替你做了什么

TCP/IP 在 .NET 里的核心类是 System.Net.Sockets.Socket。这个类功能很全,但直接用它写一个能跑的 TCP 服务端,要处理 Bind、Listen、Accept 三个方法,还要指明 AddressFamily.InterNetwork、SocketType.Stream、ProtocolType.Tcp 三个枚举,少一个连接就建立不起来。TcpListener 和 TcpClient 是建立在 Socket 之上的简化封装:TcpListener 把 Bind 和 Listen 合并进构造与 Start(),AcceptTcpClient() 返回一个已连接套接字;TcpClient 的 Connect() 方法内部完成地址解析、协议选择和三次握手。

使用封装类不意味着失去控制力。TcpClient 暴露了 Client 属性,底层 Socket 还在那里,需要设置 KeepAlive、LingerState、NoDelay 时仍然可以直接操作。我见过有人为了“更底层”硬用 Socket 写业务,代码量翻倍,排错时反而要在更多环节里找问题。对一份要给学员和一线工程师复现的例程来说,TcpListener/TcpClient 让新手能看懂每一行在干嘛,也让熟手能快速替换成自己的协议解析逻辑。

稍微展开连接的本质:一次 TCP 连接由“本地 IP、本地端口、远端 IP、远端端口”四元组唯一确定。服务端监听端口是固定的 9000,但每个已接受的客户端都会由系统分配一个临时端口,所以一个监听端口能同时对应多个连接。这也是为什么 Accept 出来的 TcpClient 不能直接复用监听者的原因。

2.2 同步阻塞模型:联调阶段最好用

同步模型下,Accept 和 Read 调用都会让当前线程挂起。服务端执行 listener.AcceptTcpClient() 时会停在那里,直到一个客户端完成三次握手;客户端执行 stream.Read() 时也会停在那里,直到收到任何字节或连接断开。代码看起来像是“一条线”走到底,实际上每个阻塞点都是在等网络事件。

这种模型的优点是可以单步调试:断点打在 Accept 前,你能确认代码确实执行到了监听;断点打在 Read 后,你能确认收到了数据。对于排查“服务端有没有起来”“数据有没有到网卡”这类问题,同步模型远比回调模型直观。缺点是阻塞期间线程什么都干不了,一个客户端占一个线程,几千个连接时线程开销和上下文切换会拖垮性能。

所以这份最简例程的应用场景是:一次验证一条链路,或者只服务少量客户端。如果真要上并发,我通常先把每个客户端交给一个 Task 或 Thread 处理,代码大概是在 while 循环里 new Thread(HandleClient).Start(client),HandleClient 里面就是上面那套 Receive/Write 逻辑;再往后才考虑用 async/await 重写,避免线程堆积。

2.3 三个必懂的参数约定:地址、端口和防火墙

地址有三种写法要分清。127.0.0.1 只会命中本机回环接口,外网和局域网设备永远连不进来;0.0.0.0(C# 里写作 IPAddress.Any)监听所有网卡接口,最省事,但调试时不好区分流量来自哪块网卡;192.168.x.x 这种具体内网 IP 只监听指定网卡,适合机房多网卡的机器。我一般本机自测用 127.0.0.1,设备联调用 IPAddress.Any 或实际内网 IP。

端口选择有个常见误区:业务代码里用 9000、8080 这种知名端口,容易和内网其他服务撞车。按 IANA 的建议,49152 到 65535 是动态/私有端口,临时联调用这个区间最稳。1024 以下端口在 Windows 上经常需要管理员权限,不是首选。另外,客户端连接时的端口号必须和服务端监听端口完全一致,一个数字都不能差,端口不一致报的往往是“目标计算机积极拒绝”而不是“超时”,这个报错可以直接定位问题。

防火墙是第三个隐藏变量。Windows 默认会拦截未经允许的入站连接,第一次运行服务端时系统可能弹一次防火墙授权框。很多刚入门的同事点了“取消”,之后请求被系统悄悄丢掉:服务端控制台没有任何异常,客户端却一直连接超时。写完服务端代码先别急着怀疑程序,先在防火墙入站规则里看一眼有没有当前程序的放行条目。

提示:局域网联调前先互 ping 一次,确认两台机器网络通、防火墙没拦 ICMP,再谈 TCP 层的问题,不然会混淆排查方向。

3. 服务端实现:TcpListener 监听、Accept 与字节流收发

3.1 控制台工程结构与监听启动

新建控制台工程后,把文件里的默认模板替换成下面代码。目标框架选 .NET 6 或更高即可,也可以选 .NET Framework 4.8,代码不需要改:

using System; using System.Net; using System.Net.Sockets; using System.Text; namespace TcpServerDemo { internal class Program { static void Main(string[] args) { TcpListener listener = new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine("服务端已启动,监听端口 9000 ..."); while (true) { TcpClient client = listener.AcceptTcpClient(); Console.WriteLine("客户端接入:" + client.Client.RemoteEndPoint); NetworkStream stream = client.GetStream(); byte[] buffer = new byte[1024]; int len = stream.Read(buffer, 0, buffer.Length); string message = Encoding.UTF8.GetString(buffer, 0, len); Console.WriteLine("收到消息:" + message); byte[] response = Encoding.UTF8.GetBytes("服务端应答:已收到你的消息"); stream.Write(response, 0, response.Length); client.Close(); } } } }

我解释一下 listener 这块逻辑。new TcpListener(IPAddress.Any, 9000) 让监听套接字绑定到所有网卡的 9000 端口;listener.Start() 开始接受连接请求;AcceptTcpClient() 返回的是一个已经完成握手的连接对象,它对应一条独立的 TCP 连接,拥有自己的收发缓冲区。简单说,listener 是看门人,client 才是真正干活的人。

有一个细节值得注意:AcceptTcpClient() 是阻塞调用,如果在调试时程序卡在这一行而没有任何日志,恰恰说明代码是正常的,只是在等客户端进来。新手经常误以为程序死掉了直接结束进程,其实这时候应该先去启动客户端。

3.2 接收请求并回写应答:一个完整的请求-应答周期

接下来看收发。服务端先执行 stream.Read,读客户端发来的字节。这里用的重载是 Read(byte[] buffer, int offset, int size),返回值是本次实际读到的字节数,可能比 buffer 小,也可能为 0(连接正常关闭),还可能抛异常(连接被重置)。不能拿 buffer.Length 当实际数据长度,否则转换字符串时会带上多余的空字节。

我习惯先写“服务端先 Read 后 Write”,再对应客户端“先 Write 后 Read”,两侧顺序一配对,就是一个完整的请求-应答周期。文档这类同步通信模式下,大多数设备也是这个套路:上位机发请求帧,设备回响应帧,谁先等、谁先发,必须按协议走。

回写应答后用 client.Close() 关闭连接。这里有个取舍:关闭连接是最简单的让对端感知通信结束的方式,但不是所有协议都允许通信完就断开。如果要保持长连接循环读写,就不要在单次应答后 Close,而是把 Read/Write 放进 while 循环。这一点与设备是一次连接多次交互还是一问一答强相关,联调前先看设备手册。

3.3 服务端参数速查与调整方向

参数示例值说明
监听地址IPAddress.Any监听所有网卡;回环自测用 127.0.0.1
监听端口9000与客户端一致;生产建议 49152-65535
缓冲区1024 字节单条报文的最大预期长度
编码UTF-8收发必须一致,ASCII/GB2312 按设备来
关闭方式client.Close()通知对端连接结束,释放句柄

buffer 大小是调整时最容易忽视的参数。如果设备单帧报文超过 1024 字节,Read 只能读回前 1024 字节,后面数据会留在内核缓冲区等你下次读;如果接收方 Read 时机不对,还会出现“半包”。改大 buffer 不是万能药,最稳的方案是先用足够大的缓冲循环读完,再按协议帧的结束符或长度字段切包。这个在第 6 章会继续讲。

多客户端扩展方面,最简做法是 Accept 到 client 后丢到后台线程:每个连接一个线程去循环读写,互不阻塞。要注意关闭逻辑:客户端断开后 Read 返回 0 或抛异常,一定要在 finally 里 Close,否则句柄泄漏。更优雅的方案是 async/await,让线程不被连接数拖垮,但那是进阶内容,不在这份最简例程里展开。

4. 客户端实现:TcpClient 连接、发送与超时处理

4.1 三步走:连接、发送、接收

客户端代码更短,核心就三个动作。先连接,再发请求,最后等响应:

using System; using System.Net.Sockets; using System.Text; namespace TcpClientDemo { internal class Program { static void Main(string[] args) { TcpClient client = new TcpClient(); client.Connect("127.0.0.1", 9000); Console.WriteLine($"已连接服务端 {client.Client.RemoteEndPoint}"); NetworkStream stream = client.GetStream(); byte[] data = Encoding.UTF8.GetBytes("客户端消息:你好,服务端"); stream.Write(data, 0, data.Length); byte[] buffer = new byte[1024]; int len = stream.Read(buffer, 0, buffer.Length); string response = Encoding.UTF8.GetString(buffer, 0, len); Console.WriteLine("服务端响应:" + response); client.Close(); } } }

Connect() 里传的是字符串地址,C# 内部会做 DNS 解析,localhost、127.0.0.1、192.168.x.x 都可以写。连接成功后 GetStream() 拿到网络流,Write 把字节发出去,Read 阻塞等应答,最后 Close。这里要再次强调:Read 的返回值才是有效数据长度,转字符串时用 buffer 前 len 个字节,而不是整个 buffer。

这个例子里客户端先写后读,和服务端先读后写的顺序正好配对。如果两边都先读,连接建立后谁都不发数据,两个进程都会卡在 Read 上,看起来像死锁。这是 TCP 联调里最常见的低级错误,遇到时先检查代码执行顺序。

4.2 给客户端加超时:不加就是黑匣子

TcpClient 默认的 ReceiveTimeout 是 0,表示无限等待。一旦服务端出了问题,客户端 Read 会一直挂住,界面上没有日志、没有异常,程序就像进了黑匣子。要避免这种状况,连接建立后先设置两个超时:

client.ReceiveTimeout = 3000; // 3 秒收不到数据就抛异常 client.SendTimeout = 3000; // 3 秒发不出去就抛异常

ReceiveTimeout 作用于 Read,SendTimeout 作用于 Write,单位是毫秒。设了超时后,Read 到期会抛 IOException,需要包一层 try/catch 做重连或报错提示。注意这个属性不影响 Connect(),连接超时要用 client.ConnectAsync() 配合 Task.WhenAny 实现,或者使用带超时参数的 Connect 重载,否则连一个不存在的 IP 时,可能干等几十秒才返回。

4.3 上位机场景:很多设备才是服务端

聊一个实际场景:做上位机的人往往第一反应是写服务端,因为觉得“我的软件要接收数据”。但在工控和数据采集场合,大多数设备才是服务端。比如 Modbus TCP 设备默认监听 502 端口,某些传感器、拧紧枪、扭矩仪会监听厂商自定义端口,上位机反而是主动连上去的客户端。

这种情况下,例程的客户端代码稍加改动就能用:连接成功后,按设备协议周期发送请求帧,再接收响应帧解析。很多设备支持长连接,所以不用每次请求都新建 TcpClient,建立一次连接后循环收发就行。需要留意的是设备通常只允许有限个客户端连接,调试时别同时开多个测试工具,不然设备会拒绝新连接。我调试扭矩仪时踩过这个坑,现象是连接偶尔成功偶尔失败,最后发现是之前的测试客户端没关干净。

5. 常见问题与排查:五条踩坑记录与排查工具组合

5.1 服务端一启动就报“只允许使用一次”:端口被占用

现象:服务端执行 listener.Start() 时直接抛 SocketException,提示“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”。

原因:端口已经被占用。可能是上一次运行的服务端进程没退出,也可能是别的程序恰好用了同一端口。调试时按 Ctrl+C 结束控制台程序,不代表进程立刻清理干净,有时候残留进程还会占着端口。

解决:先查占用进程,再决定杀进程还是换端口。命令行执行 netstat -ano | findstr 9000,看最后一列 PID,然后去任务管理器结束对应进程;不想杀进程就直接改端口,比如把 9000 换成 52001,客户端同步改。

5.2 本机回环能连、局域网连不上:防火墙与监听地址

现象:服务端在本机跑着,用 127.0.0.1 连一切正常,换成局域网 IP 就连不上,客户端一直超时或拒绝。

原因:两类。一是服务端监听地址只写了 127.0.0.1,没监听外网网卡;二是监听用的是 IPAddress.Any,但 Windows 防火墙拦截了入站连接。前一类是代码问题,后一类是系统策略问题。

解决:服务端改成 IPAddress.Any 再重启;如果还不行,检查防火墙入站规则里有没有当前程序的允许规则。很多情况下,控制台程序第一次监听时弹出的防火墙对话框被点了取消,之后不会再弹第二次,需要手动去“Windows Defender 防火墙”添加入站规则,放行 TCP 端口。

5.3 Read 一直接收不到数据:程序像进了黑匣子

现象:连接建立成功,日志也打印了“已连接”,但 Read 一直不返回,程序卡住,没有超时也没有异常。

原因:最常见的有两种。一是双方都先 Read,等对方先发数据,形成互相等待;二是对方的数据根本没按预期发出,比如服务端写的是响应,客户端却在等请求结果,两边报文没对上。另一个隐蔽原因是半包:数据到了内核缓冲区,但 Read 的条件不满足。

解决:先确认日志里“已连接”出现,再确认对端调用过 Write,最后在阻塞的 Read 之前打一条日志,缩小范围。调试阶段给 Read 设置超时,超时后打印缓冲区状态和连接状态,能省大量时间。如果怀疑半包,用进程抓包工具看数据是否真的到了网卡。

5.4 中文变成问号乱码:编码不一致

现象:用网口调试助手发给服务端中文,服务端打印出来全是问号;或者两个 C# 程序之间收发,中文显示乱码。

原因:客户端编码和服务端编码不一致。有人用 Encoding.UTF8 发送,有人用 Encoding.Default(Windows 下是 GB2312/GBK),还有人用 ASCII,ASCII 根本表示不了中文,转出来必然是问号。

解决:统一编码。C# 程序之间通信直接用 UTF-8 最稳,因为 .NET 字符串内部就是 UTF-16,转 UTF-8 字节不会丢信息。如果对接的是老旧设备或仪器,协议里写了 ASCII 或 GB2312,就按协议来,但客户端和服务端必须完全一致。编码问题不容易从肉眼看出,我一般会在报文日志里同时打印十六进制字节串,一目了然。

5.5 服务端重启太快报“地址已在使用”:TIME_WAIT

现象:服务端关闭后立刻重新启动,偶尔报“只允许使用一次”的错误,但等一两分钟再启动又正常了。

原因:主动关闭连接的一方会进入 TIME_WAIT 状态,端口默认要等 2 到 4 分钟才能复用。如果服务端主动 Close,而客户端没有先关闭,服务端端口会短暂被系统占用。

解决:联调时最快是等一会再启动;也可以不主动做服务端 Close,让客户端先断开;非要立即重启,可以在 Socket 上设置 ReuseAddress 选项,代码里通过 listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true) 设置,但注意这可能让旧连接的数据落到新连接上,生产环境慎用。

5.6 telnet 和 netstat:先看链路断在哪一环

排查 TCP 问题,我习惯先看链路状态再查代码。telnet 能测端口通不通,netstat 能看连接状态。

客户端机器上执行 telnet 192.168.1.100 9000,如果端口开着,命令行会变成空白或提示连接成功;如果立即退出并报“无法打开到主机的连接”,说明端口不通或防火墙拦截。服务端机器上执行 netstat -ano | findstr 9000,能看到 LISTENING 和 ESTABLISHED 两种状态,前者说明监听正常,后者说明有客户端连了进来。这两条命令一分钟内就能定位“是防火墙问题、服务端没启动、还是连接根本没建立”,比看代码猜效率高得多。

6. 进阶验证:给例程加上长度前缀与心跳检测

6.1 TCP 是流,不是消息:先解决粘包半包

最简例程里一次 Read 对应一次发送,数据量小、频率低时能跑通,但高频连续发送时就会出问题。TCP 不保证消息边界,发送端连续 Write 两次,接收端可能一次 Read 就把两段数据都读回来,这叫粘包;反过来一条消息拆成两次 Read 才读完,这叫半包。网上把这个当玄学,其实本质就是读写缓冲区和网络分片的不同步。

最简单的方案是给每条消息加长度前缀。发送端先写一个 4 字节的 Int32 表示包体长度,再写包体;接收端先凑够 4 字节,解析长度,再按长度循环读包体。这样无论底层怎么粘包拆包,应用层都能还原出完整消息。

6.2 用 4 字节长度前缀做分包

接收端的核心逻辑可以写成这样:

// 先完整读出 4 字节长度头 byte[] lenBuf = new byte[4]; int read = 0; while (read < 4) { int n = stream.Read(lenBuf, read, 4 - read); if (n == 0) return; // 连接已断开 read += n; } int bodyLen = BitConverter.ToInt32(lenBuf, 0); // 再按长度读包体 byte[] body = new byte[bodyLen]; read = 0; while (read < bodyLen) { int n = stream.Read(body, read, bodyLen - read); if (n == 0) return; read += n; }

这里两个 while 循环都是“凑够指定长度才继续”,能同时消化粘包和半包。发送端对应写:先 BitConverter.GetBytes(body.Length) 写进去,再写 body。要注意大小端:BitConverter 在 Windows 上是小端,如果对端设备协议用大端,发送前要把字节数组 Reverse 一下,这是设备联调里最容易翻车的字节序问题。

从那次被一个扭矩采集项目坑过之后,我每次给客户发 TCP 例程,都会强制自己把客户端和服务端各跑一遍,再用 netstat 确认端口状态,最后用循环发包压一遍粘包场景才交付。这套习惯帮我挡掉了不少售后问题。希望帮到你。

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

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

会议室预定系统微服务实战:SpringCloud+分布式锁+分布式事务

去年接了一个会议室预定系统的项目&#xff0c;需求方把技术栈圈得很死&#xff1a;SpringBoot Vue SpringCloud&#xff0c;而且明确要求做成微服务分布式架构&#xff0c;不能拿单体应用糊弄。心里第一反应是&#xff0c;会议室预定这种业务也要上微服务&#xff1f;但做完…

作者头像 李华
网站建设 2026/10/4 22:54:53

OpenClaw人人养虾:Kilocode 接入 Kilo Gateway 的 API Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 22:53:07

让Agent接管GitHub Issue到PR全链路:工程实践与避坑指南

1. 为什么我决定让 Agent 接管 Issue 到 PR 这条链路第一次冒出"让代码 Agent 处理 GitHub Issue"这个念头&#xff0c;是在一个再普通不过的深夜。项目仓库里堆了三十多个 open issue&#xff0c;一半是"这个按钮点不动"&#xff0c;一半是"文档里的…

作者头像 李华