ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# TCP/IP网络编程入门:TcpListener与TcpClient例程详解及粘包半包排查

C# TCP/IP网络编程入门:TcpListener与TcpClient例程详解及粘包半包排查 简介TCP/IP C#最简单例程是一套面向初学者的网络编程示例包含客户端与服务端两套完整实现。整套资源基于C#编写提供Visual Studio工程文件与编译好的exe可先运行再对照源码学习TcpListener、TcpClient、Socket等API用法覆盖创建套接字、绑定端口、监听连接、数据收发等基本过程压缩包内共包含55个文件以cs源文件、csproj工程、sln解决方案、exe可执行文件和txt说明为主整体约450KB结构紧凑便于快速查阅。已有206人浏览学习。服务端通过TcpListener循环监听客户端请求客户端使用TcpClient发起连接完整展示TCP通信的核心交互逻辑其中服务端与客户端代码结构清晰有助于把握字节流读写的处理思路。下载后可直接运行客户端与服务端exe进行本机通信测试结合源码理解TCP/IP通信流程适合刚接触C#网络编程的开发者参考与扩展也可用于课程实验或小型工具开发场景。1. TCP/IP 在 C# 里的最小形态一次握手、两条消息、一个循环C# 里写 TCP/IP 通信十有八九是上位机场景连一台设备、采一组扭矩值、回一条控制指令。很多老工程师带新人给的第一个例程就是TcpListener和TcpClient因为它们把三次握手、数据帧拆分、缓冲区管理全打包了十几行就能让客户端和服务端完成一次对话。这篇笔记就把这套“最简单例程”拆开讲透——从选型、写码到避坑照着敲一遍就能跑通跑通之后再回头理解 IP、端口和流读写概念自然就钉死了。适合三类人刚接触 C# 上位机的工程师、准备网络编程面试的学生、以及想快速验证设备协议是否支持 TCP 的调试者。2. 先搞清楚选型再动手TcpListener/TcpClient 和 Socket 差在哪为什么例程选前者很多人一开始被Socket类吓住觉得那才是“正经网络编程”。实际上 .NET 早就把 Socket 包了两层TcpListener管服务端监听TcpClient管客户端连接两者内部都是 Socket只是把最容易写错的部分——绑定、监听、接受——封装成几个方法。入门写 TCP选这一对封装类就够了等你需要同时管理几千个连接、做 IOCP 高性能服务再退回SocketSocketAsyncEventArgs不迟。2.1 TCP 的时序模型为什么客户端和服务端永远是一问一答TCP 最核心的模型是“连接导向”。服务端先Listen监听客户端Connect连接连接建立后两端各有一个NetworkStream可以同时读写互不阻塞。但例程里为了让大家看清流程通常写成“客户端发一条、服务端回一条、客户端读一条”的串行方式这就是所谓的一问一答。理解这个时序很重要TCP 没有“消息边界”。你Write进去的字节流对端Read出来不保证一次拿全也不保证一次只拿一条。这就是后面粘包、半包问题的根源。例程里只有一条短消息一次Read刚好读完所以看起来正常真实设备通信时一条 20 字节的帧可能分两次到达两条帧也可能合并到达。第 5 章我会专门讲这个坑。2.2 类库选型NetworkStream、TcpClient、Socket 的职责划分TcpClient负责连接真正读写走的是它返回的NetworkStream。用StreamReader/StreamWriter也可以读写但那是文本流带字符编码转换对二进制帧不友好所以工业上位机例程里几乎清一色用NetworkStreambyte[]裸读写。Socket类则是更底层的抽象。TcpClient.Client属性返回的就是 Socket你可以拿到它设置SendTimeout、ReceiveTimeout、NoDelay等参数。例程里需要控制超时或禁用 Nagle 算法时记得通过client.Client去设而不是自己 new 一个 Socket 重写一遍连接逻辑。2.3 开发环境差异Console、WinForms、WPF 里放代码的位置不一样同样一段 TCP 代码放的位置决定了它会不会“卡死界面”。在 Console 程序里写stream.Read()是阻塞的——程序停在那里等数据这没问题但如果在 WinForms 或 WPF 的按钮点击事件里直接写阻塞 Read界面就会假死拖动窗口都没反应。所以例程如果只在 Console 里跑怎么简单怎么来一旦挪进 WinForms/WPF要么用Task.Run把阻塞读写丢到后台线程要么用TcpClient的异步方法ConnectAsync、ReadAsync。最简单的过渡方案是后台线程 阻塞读写逻辑跟例程完全一致只是包了一层线程。这也是我建议新手先跑 Console 例程的原因——先把网络逻辑和界面逻辑分开别让 GUI 干扰你判断 bug。3. 服务端例程TcpListener 从监听、接受到回显的完整代码服务端的职责就四件事监听端口、接受连接、读数据、回数据。下面这份例程故意去掉所有异常处理把主干露出来跑通了再加 try-catch 不迟。using System; using System.Net; using System.Net.Sockets; using System.Text; class TcpServerDemo { static void Main() { int port 9000; // 1. 创建监听器绑定本机所有 IP 的 9000 端口 TcpListener listener new TcpListener(IPAddress.Any, port); listener.Start(); Console.WriteLine(服务端已启动监听端口: {0}, port); while (true) { // 2. 阻塞等待客户端接入每接入一个客户端accept 返回一个独立的 TcpClient TcpClient client listener.AcceptTcpClient(); Console.WriteLine(客户端接入: {0}, client.Client.RemoteEndPoint); // 3. 拿到网络流开始读写 NetworkStream stream client.GetStream(); byte[] buffer new byte[1024]; // 4. 阻塞读取客户端发来的数据n 是实际读到的字节数 int n stream.Read(buffer, 0, buffer.Length); string message Encoding.UTF8.GetString(buffer, 0, n); Console.WriteLine(收到: {0}, message); // 5. 回一条消息给客户端 string reply 服务端已收到: message; byte[] replyBytes Encoding.UTF8.GetBytes(reply); stream.Write(replyBytes, 0, replyBytes.Length); // 6. 处理完本次消息就关闭连接 client.Close(); } } }这段代码有一个刻意简化Read只调了一次假设一次就能读完一条完整消息。真实设备如果一包数据分两次到达第二次就会被堵在系统缓冲区里直到客户端关闭连接时Read才返回 0服务端就会漏掉消息。所以在生产级代码里Read要包一层循环直到读够一帧的长度再解析。这里先不展开第 6 章给封帧方案。参数说明参数取值说明IPAddress.Any0.0.0.0监听本机所有网卡。如果只想让本机访问可改成IPAddress.Loopback127.0.0.1想让局域网其他电脑连必须用 Anyport90001024 以下多为系统保留端口调试建议选 10000–60000 区间避免和常见服务冲突buffer1024 字节接收缓冲。调大不会丢数据但单次Read返回的仍是当前底层已到达的字节数不保证填满client.Client.RemoteEndPointIP:端口客户端来源地址多客户端接入时用它区分谁发的消息这个服务端是“一次连接处理一条消息就关闭”的模型适合验证连通性。真实上位机服务端通常要循环接收同一条连接的多条消息并且支持多个客户端同时接入——那需要在AcceptTcpClient后开启新线程去处理每个连接。等例程跑通你自然会遇到这个需求。4. 客户端例程TcpClient 连接、发送、接收的完整代码与超时设置客户端的代码比服务端更简洁核心步骤是解析 IP、连接、拿流、发数据、收回复、关闭。下面这份例程和服务端对应连上后发一条文本然后阻塞读取服务端的回复。using System; using System.Net; using System.Net.Sockets; using System.Text; class TcpClientDemo { static void Main() { string serverIp 127.0.0.1; int port 9000; using (TcpClient client new TcpClient()) { // 1. 设置连接超时防止目标不可达时卡死 20 秒 client.SendTimeout 3000; client.ReceiveTimeout 3000; // 2. 连接服务端IP 可以是 127.0.0.1、局域网 IP 或域名 client.Connect(IPAddress.Parse(serverIp), port); Console.WriteLine(已连接服务端: {0}:{1}, serverIp, port); // 3. 获取网络流并发送数据 NetworkStream stream client.GetStream(); string content 你好TCP/IP; byte[] data Encoding.UTF8.GetBytes(content); stream.Write(data, 0, data.Length); Console.WriteLine(已发送: {0}, content); // 4. 阻塞接收服务端回复 byte[] buffer new byte[1024]; int n stream.Read(buffer, 0, buffer.Length); string response Encoding.UTF8.GetString(buffer, 0, n); Console.WriteLine(服务端回复: {0}, response); // 5. using 块结束自动关闭连接 } } }Connect是同步阻塞的。如果服务端没启动默认会等很久才抛异常所以我先设了SendTimeout和ReceiveTimeout但Connect本身受操作系统连接超时影响这三个参数并不能完全控制。更可靠的做法是用client.ConnectAsync(ip, port).Wait(3000)超时返回 false 再手动关闭。这个写法在调试“服务端没开”的场景下特别好用后面你排查问题时会感谢这行。IPAddress.Parse只接受点分十进制格式。如果你要连接的主机名是localhost或某个域名应该改用Dns.GetHostAddresses(serverIp)取第一个 IPv4 地址再传入 Connect。这也是新手常栽的地方拿一个域名往IPAddress.Parse里塞直接抛FormatException。看完这两段代码你已经在本地跑通了 TCP/IP 最小链路。别急着往里加业务先把服务端开着、客户端连着用任务管理器或netstat观察这条连接的状态——这才是真正理解 TCP 的开始。5. 网络通信的五大常见问题排查粘包、端口占用、乱码、假死和只收一条消息例程能跑通只是第一步。真正的坑都在例程之外数据乱码、连接被拒、收不到完整帧。下面这几条是我带人调网络程序时出现频率最高的每一条都按“现象 → 原因 → 解决”写。5.1 粘包与半包TCP 没有消息边界现象客户端一次发了“A”和“B”两条消息服务端一次Read却读出了“AB”或者客户端发了一条 500 字节的帧服务端Read只读到了 200 字节拆开解析全是错位的数据。原因TCP 是字节流协议底层可能把多次Write的数据合并成一次发送粘包也可能把一次Write的数据分段发出半包。Read返回多少字节只取决于系统缓冲区当前有多少数据和Write几次没有对应关系。解决给每一条消息加一个固定长度的帧头帧头里写清这条消息的总长度。服务端先读 4 字节拿到长度再循环Read直到读够这个长度才解析出一条完整消息。第 6 章我给一个可直接抄的封装。血泪经验不要在没定义帧格式之前就去调业务逻辑那是浪费两个小时。5.2 端口占用服务端重启时报“地址已在使用”现象服务端程序崩了立刻重启listener.Start()抛SocketException错误码 10048提示地址已被占用。原因TCP 连接关闭后端口会进入TIME_WAIT状态持续约 2 分钟Windows 默认 240 秒期间该端口不能被重新绑定。调试时 CtrlC 杀掉程序再快速启动撞上 TIME_WAIT 的概率极高。解决最省事的是换一个端口继续调试等 TIME_WAIT 过期再用原端口。也可以用listener.ExclusiveAddressUse false配合SetSocketOption(SocketOptionName.Socket, SocketOptionName.ReuseAddress, true)但这会带来端口重绑的老连接串扰风险生产环境慎用。你要是只在本机调试还有个办法代码里捕获异常后提示“端口被占用请稍等 2 分钟”比默默崩溃友好得多。5.3 Windows 防火墙拦截服务端运行正常客户端却连接超时现象服务端在本机跑着netstat -ano也能看到监听端口但局域网里另一台电脑的客户端Connect直接超时。排查代码无果最后发现是防火墙拦了入站连接。原因Windows 默认禁止外部访问未放行的端口。控制台程序在监听端口时系统会弹一次防火墙提示很多人手滑点了“取消”或者程序以服务方式运行根本没弹窗连接就被静默丢弃。解决开发调试阶段可以临时给程序加防火墙入站规则命令如下netsh advfirewall firewall add rule nameTcpDemo 9000 dirin actionallow protocolTCP localport9000或者干脆在 Windows 防火墙面板里允许该程序通过。注意写死某个端口的规则只对当前端口有效程序换端口要跟着改。公司局域网如果还套了组策略那就要找网管单独放行这不是代码能解决的问题。5.4 中文乱码服务端收到的字符串全是“锟斤拷”现象客户端发送“你好”服务端打印出来是乱码反过来服务端回中文客户端也乱码。但两边代码明明都写了Encoding.UTF8。原因编码不一致是表象更隐蔽的原因是两次编码标准不同——有的代码用了Encoding.DefaultWindows 下是 GBK有的用了Encoding.UTF8。还有一类是半包导致一个汉字被截成两段UTF-8 解码时把半个字节对成了非法字符后续所有字节全部错位。解决通信双方统一用 UTF-8这是当前默认约定。收到数据后不要用Encoding.Default兜底宁可解码失败抛异常也不要静默出乱码。半包乱码必须靠帧头解决光换编码没用。判定方法很直观收到字符串里全是“锟斤拷”“烫烫烫”前者是 UTF-8 被 GBK 解后者是缓冲区没清干净——Read返回 n 小于 buffer 长度时只取前 n 个字节不要整个 buffer 去解码。5.5 连接只处理一条消息就关闭导致双方 Read 返回 0现象服务端Read第二次调用直接返回 0客户端那边发第二条消息时抛SocketException说“远程主机强迫关闭了一个现有的连接”。原因我把例程写成了“接一条、回一条、Close”。如果客户端连续发两条消息第二条就会落在已经关闭的连接上。Read返回 0 意味着对端已关闭连接或连接已被重置这是 TCP 的关闭信号不是“没有数据”。解决要求长连接就把Read放进 while 循环循环条件就是n 0客户端要主动断开时调用client.Close()走四次挥手。注意别在Main结束后自动释放连接——那是半开状态服务端会莫名读到 0。这个问题最容易出现在“客户端连上了但没发数据”的场景服务端第一次 Read 永远阻塞看起来像卡死其实是正常的要区分“阻塞等待”和“读到了 0”。6. 给例程加一层帧协议把最简单的代码改造成能用的通信模块例程跑通后下一步通常是接真实设备或对接别的服务端。这时你手里那套“读一次解一次”的逻辑就不够用了。我给自己的工具代码加了一个最小帧协议4 字节长度头 业务数据服务端按长度收帧客户端按长度发帧。这样能同时解决粘包、半包和乱码。6.1 定长帧头的设计4 字节 Int32 大端序帧结构定义如下字段长度说明长度头4 字节表示后续业务数据的字节数用BitConverter转业务数据可变按 UTF-8 编码的文本或任意二进制选 4 字节是为了单帧最大 2GB工业和设备通信场景完全够用。字节序建议统一用大端网络序因为很多 PLC 和嵌入式设备默认大端C# 的BitConverter默认小端转换时要用IPAddress.HostToNetworkOrder调一下。6.2 代码改造收帧用循环补读发帧先写长度再写数据服务端读完整一帧的核心函数byte[] ReadFrame(NetworkStream stream, byte[] lengthBuffer, byte[] dataBuffer) { // 1. 先读满 4 字节长度头 int read 0; while (read 4) { int n stream.Read(lengthBuffer, read, 4 - read); if (n 0) { throw new IOException(连接已关闭无法继续读取帧头); } read n; } // 2. 把大端字节序转成主机序整数 int bodyLength IPAddress.NetworkToHostOrder( BitConverter.ToInt32(lengthBuffer, 0)); // 3. 按长度循环补读业务数据 read 0; while (read bodyLength) { int n stream.Read(dataBuffer, read, bodyLength - read); if (n 0) { throw new IOException(连接在读取中途关闭帧不完整); } read n; } // 4. 取前 bodyLength 字节返回避免缓冲区残留干扰解析 byte[] frame new byte[bodyLength]; Array.Copy(dataBuffer, frame, bodyLength); return frame; }初次接触这段代码的人最不理解的是为什么Read要循环。因为Read只保证“尽量读但不保证填满你给的缓冲区”它是低级 IO 的典型行为。NetworkStream没有内置“读满 N 字节”的能力所以必须靠循环累加。Read返回 0 的情况在这里被当成异常抛出符合 TCP 语义——连接都断了再等也不可能等到数据。发帧同样要配套写长度void WriteFrame(NetworkStream stream, byte[] payload) { byte[] lengthBytes BitConverter.GetBytes( IPAddress.HostToNetworkOrder(payload.Length)); stream.Write(lengthBytes, 0, 4); stream.Write(payload, 0, payload.Length); }注意两次Write之间不要画蛇添足加Flush。NetworkStream不是缓冲流Write直接就进内核了加了Flush反而容易让人误以为之前的数据还被滞留在托管层。真正要注意的是payload.Length不能超过 int 上限正好也是帧头上限天然一致。6.3 验证用延迟和吞吐两个指标确认帧协议没写错改完帧协议别急着接设备。先拿服务端和客户端做一轮自测用下面两个指标看效果指标测试方法合格标准往返延迟发一条消息并读回复统计耗时局域网内首次 5ms冷启动 20ms吞吐循环发 10 万条短消息统计总耗时不报异常内存不涨耗时线性增长延迟超标先看是不是服务端在Accept循环里做了太多事吞吐上不去多半是 Nagle 算法在作怪——短消息太多时算法攒包视觉表现是“时快时慢”。可以在连接建立后加一行client.NoDelay true;这会禁用 Nagle 的自动合并让短帧立刻发出换取更低延迟代价是网络报文数量变多。设备通信这种小包场景利大于弊大文件传输场景开着反而拖慢吞吐。这也是我后来才悟到的参数没有绝对好坏全看你的业务包是大是小。回头看看这套例程我犯过最蠢的错是在 GUI 线程里直接跑AcceptTcpClient结果界面卡成白板还以为是网络问题。网络通信的代码本质是等——等连接、等数据、等关闭你只要记住“阻塞调用会卡住当前线程可能要卡住界面”就能躲开大部分入门翻车。先把最小的 Console 例程跑熟再迁到 WinForms 或 WPF 里用 Task 包一层后面无论接 Modbus TCP 还是给工业扭矩枪写采集上位机套路都是一样的连接、按帧收发、处理异常、关闭。希望这份笔记能让你少走两小时弯路。本文还有配套的精品资源点击获取
返回列表