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

发布时间:2026/10/4 23:46:58
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 不保证把对方发出的数据完整读完。如果对方一次发了几万个字节缓冲区只有 1024Read 只能先返回 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提示“通常每个套接字地址只允许使用一次”。原因上一次运行的服务端进程没有退出端口还占着。调试环境里最常见的是 CtrlC 中断后进程残留或者程序崩溃但句柄没有释放。解决打开任务管理器找到残留进程结束它或者用代码设置 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 Actionstring(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;我自己的做法是先把最小例程跑通然后立刻补上两层——第一层是收发循环与断线重连第二层是协议解析。很多项目前期为了省事只传字符串后面加设备、加指令时才发现协议没法扩展只能重写通信层。早一点定好长度前缀和命令字段反而最省钱。希望帮到你。本文还有配套的精品资源点击获取