简介:面向C#初学者的TCP/IP网络编程示例包,提供最简单的客户端与服务端实现,演示了Socket/TcpListener的绑定、监听、连接及数据收发流程。包内共55个文件,以12个cs源代码文件为主,同时包含8个可直接运行的exe程序、6个txt说明文件以及项目配置、图标等辅助资源,整体压缩包约450KB。已有204人学习浏览,适合用于课程实验、毕设起步或快速理解网络通信原理。通过源码可掌握服务端启动监听、客户端拨号连接以及Send/Receive方法的使用;配套exe可直接运行验证,对照代码能快速梳理TCP/IP通信的完整时序,为后续开发多线程并发服务器或业务封装打下基础。 手上正好有个朋友私信问C#做TCP/IP通信的问题,他搜了一堆带“最简单例程”字眼的文章,结果还是没跑通。这个标题我太熟悉了,几乎所有搞C#的人最初都是从“客户端服务端互发消息”这一步入门的,但网上那些所谓例程要么代码不完整,要么注释少得可怜,要么直接甩一个WinForm界面出来让你自己猜逻辑。今天这篇就把我验证过的最小可用方案完整放出来,服务端怎么监听、客户端怎么连接、消息怎么收发,每一行代码都会说清楚,标题里的TCP/IP、C#、Socket这些关键词直接对着代码讲,保证看完复制粘贴就能跑,还想继续深入搞上位机、设备通讯、扫码枪数据接收的也都能用上。
我不打算搞什么AIO框架,也不引入依赖库,就用.NET自带的Socket和TcpListener/TcpClient。对绝大多数场景来说,这两个类已经足够应付,代码简单到根本没有引入第三方库的必要。这篇只讲局域网内的基础通信,不碰跨网段、NAT穿透这些重灾区。
1. 先搞清楚TCP通信这件事到底在干嘛
1.1 为什么C#做上位机绕不开TCP/IP
C#这语言在实际工控和桌面开发里出镜率非常高,最常见的一个用途就是写上位机,上位机要跟PLC、扫码枪、称重仪表、视觉系统打交道,这些设备大多支持TCP/IP协议通信。哪怕你只是把一台设备的数据读回来显示在界面上,本质也就是客户端向服务端发请求,或者服务端等设备主动上报数据。
TCP/IP四层模型这个概念教科书上写得很玄乎,但我自己的理解就一句话:IP负责把数据包送到正确的机器,TCP负责保证这些包按顺序、不丢失地到达对方手里,丢了就重发。做上层应用协议的时候,你完全不需要关心底层怎么拆包组包,只要在C#里建立一个连接,然后往两端写字节流就行。
说个类比,TCP连接就像两个人打电话,先拨号接通,然后你说一句我回一句,任意时刻双方都能说话,挂断就结束。而UDP更像发短信,写个地址扔出去就不管了,对方收没收到全看运气。搞上位机通信,但凡要求数据不能丢,基本都走TCP。
1.2 用TcpListener和TcpClient还是裸Socket
C#里实现TCP有好几条路,System.Net.Sockets.Socket是最底层的,什么都能干,但什么都要你自己管。System.Net.Sockets.TcpListener和TcpClient是封装好的高层类,内部依然使用Socket,只是把Bind、Listen、Accept这些过程简化了,对新手极其友好,上手门槛明显更低。
这篇就叫“最简单例程”,所以我选TcpListener和TcpClient,它们足够处理设备连接、消息收发这些常见需求。等到你后面碰上多客户端高并发、自定义包头拆包、异步粘包处理的时候,再回头研究Socket和SocketAsyncEventArgs不迟。
选型的时候还有一个点值得注意:TcpClient类的Connected属性在断线检测上并不那么可靠,这个我们放到最后一节问题排查里详细说,反正先知道这个坑就好。
1.3 同步代码和异步代码怎么选
.NET里的网络编程有三种写法:传统的同步阻塞、BeginXXX异步回调、async/await异步。最简单的例程肯定是同步阻塞,比如AcceptTcpClient()和Read(),代码逻辑从上往下读,很清楚。
同步的问题在于调用Read的时候如果对端没数据,线程就卡在那里。做一个小Demo完全没问题,但如果后面要写正式的界面程序,直接在UI线程里同步阻塞Read,界面就会卡死。我自己的习惯是:学习阶段先用同步代码理解整个通信过程,真正做项目时再改成async/await的异步模式。
为了照顾标题里的“最简单”,本文所有代码都用同步写法,确保你能顺利跑通。代码最后我会提一句关于异步改造的方向,避免你后面走弯路。
2. 服务端核心代码:监听连接并接收数据
2.1 服务端基本流程拆解
服务端的逻辑其实只有四步:创建监听器、开始监听、等待客户端连接、接收数据。听起来简单,但每一步背后都有对应的Socket函数,理解这些函数才能在不同场景下灵活改造。
using System; using System.Net; using System.Net.Sockets; using System.Text; class SimpleTcpServer { static void Main(string[] args) { int port = 9000; TcpListener listener = new TcpListener(IPAddress.Any, port); listener.Start(); Console.WriteLine($"服务端已启动,监听端口: {port}"); while (true) { TcpClient client = listener.AcceptTcpClient(); Console.WriteLine($"客户端已连接: {client.Client.RemoteEndPoint}"); NetworkStream stream = client.GetStream(); byte[] buffer = new byte[1024]; int bytesRead; while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0) { string msg = Encoding.UTF8.GetString(buffer, 0, bytesRead); Console.WriteLine($"收到消息: {msg}"); // 原样返回,告诉客户端我已经收到了 byte[] response = Encoding.UTF8.GetBytes("服务端已收到: " + msg); stream.Write(response, 0, response.Length); } client.Close(); Console.WriteLine("客户端已断开"); } } }上面这个代码是单线程串行处理客户端的,同一时刻只能服务一个连接,但作为例程足够了。IPAddress.Any表示监听本机所有网卡地址,这样不管客户端连你的局域网IP还是127.0.0.1都能进来,这是上位机场景最常用的监听方式。
Receive缓冲区开的是1024字节,也就是说每次Read最多只能读到1KB数据。如果对端一次发来的数据超过这个长度,Read会先返回1024字节的部分,剩下的在下次循环里读。这个机制既简单又容易踩坑,后面故障排查我会专门讲。
2.2 为什么用UTF8而不是ASCII或GB2312
很多老教程喜欢用Encoding.ASCII,ASCII只能编码英文字母和数字,一旦消息里出现中文就直接变成乱码。设备通信场景里报文经常是中文,所以必须用Encoding.UTF8。
有人会问,那为什么不用Encoding.Default呢?这是因为Encoding.Default在不同系统上默认编码不同,Windows中文系统是GB2312/GBK,Linux上却是UTF8,代码一换平台就出现编码错乱。为了跨平台稳定,直接用UTF8是最稳的选择。
有个细节很多人忽略:stream.Read返回的是实际读到的字节数,所以GetString的时候一定要用bytesRead来截取,而不是直接把整个buffer转字符串。如果你拿整个buffer去做转码,后面的空字节会变成空字符,打印出来可能看不出问题,但做字符串比较的时候就会莫名其妙地失败。
2.3 用Read还是ReadLine的问题
TcpClient的网络流是原始字节流,它没有“一行”的概念,所以Stream里没有ReadLine方法。你要想按行处理,得自己把读到的字节按\n或\r\n切分。网上有些Demo用StreamReader.ReadLine封装,确实能简化代码,但StreamReader默认带缓冲,容易引入额外的延迟和缓存问题。
我建议初学阶段直接在原始字节层面处理,先学会用Read收数据,之后再做按行解析就轻松了。真正做Wire协议的时候,字节流本身就是你唯一的数据来源,早点适应没坏处。
3. 客户端核心代码:连接服务端并发送消息
3.1 客户端基本流程拆解
客户端的逻辑只有三步:连接到服务端的IP和端口、发送数据、接收服务端的返回。相比服务端的监听、Accept等操作,客户端这部分更容易理解,从代码行数上也能看出来。
using System; using System.Net.Sockets; using System.Text; class SimpleTcpClient { static void Main(string[] args) { string serverIp = "127.0.0.1"; int port = 9000; using (TcpClient client = new TcpClient()) { client.Connect(serverIp, port); Console.WriteLine($"已连接到服务端 {serverIp}:{port}"); NetworkStream stream = client.GetStream(); while (true) { Console.Write("请输入要发送的消息(输入exit退出): "); string input = Console.ReadLine(); if (input.ToLower() == "exit") break; byte[] data = Encoding.UTF8.GetBytes(input); stream.Write(data, 0, data.Length); Console.WriteLine($"已发送: {input}"); byte[] buffer = new byte[1024]; int bytesRead = stream.Read(buffer, 0, buffer.Length); string response = Encoding.UTF8.GetString(buffer, 0, bytesRead); Console.WriteLine($"服务端返回: {response}"); } } Console.WriteLine("客户端已退出"); } }要注意一个细节,client.Connect是同步阻塞的,如果目标IP和端口不通,它会一直等到默认超时时间才抛异常,在局域网里倒是还好,因为不通基本秒失败。截图文章里那些服务端都启动不了就来问为什么连不上的,基本都是这个原因。
3.2 连接超时参数怎么设置
TcpClient.Connect方法没有直接让你传超时时间的重载,但可以用client.ConnectAsync(ip, port).Wait(2000)来做到超时控制,两秒连不上就取消。这是个使用频率很高的技巧,尤其在正式上位机软件里,连不上的设备绝不能卡住界面。
if (!client.ConnectAsync(serverIp, port).Wait(2000)) { Console.WriteLine("连接超时,请检查服务端是否启动、IP端口是否正确"); return; }Wait(2000)传入的就是毫秒数,我这里写的是两秒,实际项目根据现场网络自己调,局域网一般一两秒足够,跨网段建议放到三到五秒。注意TimeSpan和int毫秒都能用,别搞混。
4. 实操过程:从空项目到真正跑起来
4.1 创建工程和初始化设置
我用的是.NET 6.0,Visual Studio 2022。创建一个控制台应用,项目文件里会自动引用System.Net.Sockets命名空间,不需要额外安装NuGet包,这点对初学者很友好。
步骤化整理一下,照做就行:
- Visual Studio里新建控制台应用,选C#,.NET 6.0或更高版本
- 解决方案里建两个项目,一个叫TcpServer,一个叫TcpClient,也可以直接在同一个解决方案下建两张不同的控制台程序
- 把上面服务端代码粘贴到服务端项目的Program.cs,客户端代码粘贴到客户端项目的Program.cs
- 在解决方案上右键,属性,把启动项目的操作改成“多个项目”,两个项目都设为启动
- 按F5,两个控制台窗口会同时弹出
4.2 实测过程演示
先跑服务端,会看到提示“服务端已启动,监听端口: 9000”。再操作客户端窗口,随意输入一行“你好,我是客户端”,服务端就会打印“收到消息: 你好,我是客户端”,同时客户端能收到“服务端已收到: 你好,我是客户端”的回应,这个流程说明双向通信完全正常。
有人喜欢不设多项目启动,先手动运行服务端exe,再运行客户端exe,这也可以。但有个前提:先启动服务端,再启动客户端。这个顺序搞反了,客户端就会因为连不上报异常。
我在自己电脑上实测的时候,服务端在本机用127.0.0.1,客户端也指向127.0.0.1,全程无任何问题。如果你要把客户端放到另外一台电脑上跑,只需要把serverIp改成服务端电脑的局域网IP,可以通过ipconfig查到,防火墙记得放行TCP 9000端口,否则客户端连不上。
4.3 扩展场景:模拟扫码枪数据上报
很多C#读者做的东西跟扫码枪、RFID读写器有关。这类设备硬件上通常自己就是客户端,主动往服务端发扫码结果,你的上位机反而是服务端。所以你只需要把我上面那段服务端代码部署在电脑上,再用厂商SDK或TCP工具把扫码枪连过来就行。
我常在别人项目里看到的情形是:扫码枪往9000端口这发数据,服务端收到后要自动触发界面里的显示、数据库写入或PLC联动。在我给出的代码基础上,把收到消息后的Console.WriteLine部分替换成你自己的业务逻辑即可。
4.4 用网络调试助手做辅助验证
没有现成设备的时候,可以用“网络调试助手”这类软件模拟对端。开一个TCP本地端口,和你的C#程序互发消息,很快就能验证协议格式是否写对。我以前排查通讯问题都是直接用调试助手看原始报文,比看程序日志更直观。
这种调试方式在.NET写上位机的过程中非常实用。先让程序跟调试助手通,再让程序跟真实设备通,出现问题时能快速定位是硬件配置的问题还是软件解析的问题。
5. 常见问题与排查技巧实录
5.1 客户端报“目标计算机积极拒绝”
这是最常见的问题。原因就是服务端没启动或端口不对。检查方向:服务端是否真的运行起来了,监听端口是否跟客户端写的完全一致,服务端是否绑定了特殊IP导致只能从特定网卡访问。
有次我帮人排查,发现他在服务端绑定了IPAddress.Loopback,也就是127.0.0.1,那么局域网里的设备肯定连不进来,从外部设备连接时就像敲门没人应一样。改成IPAddress.Any后问题立刻解决。
5.2 读取数据不完整或者粘包
TCP是字节流协议,没有消息边界。你发送方按两次Write发两条消息,接收方可能一次Read就把两条都读出来了,也可能第一Read只读到半条,这就是所谓的粘包和拆包问题。因为我上面示例代码用的是短消息,一般碰不到,但真实项目里数据量大一点就必然出现。
解决办法是给每条消息定义明确边界,常见方案有三种:
- 约定固定长度,每条消息固定N个字节,不够补零
- 使用特殊结束符,比如\n,读到\n才认为一条完整消息
- 自定义协议头,比如前4字节是消息长度,后面对应长度的字节是消息内容
第三种是工业通讯里最常用的做法,我强烈建议以后做正式项目时直接上这个方案。C#的byte[]处理起来跟C语言很像,很多用C#做上位机的人最初不熟,但一旦理解了“前4字节长度+正文”的结构,后面所有的协议解析都通了。
5.3 中文乱码问题
客户端发出来的中文服务端收到乱码,基本都是编码不统一造成的。我见过最经典的是:发送方用了Encoding.UTF8,接收方却用Encoding.Default去解码,或者反过来。解决方案就一句话:两端统一用UTF8。
这东西最坑的地方在于你自己测试时不容易暴露,因为你可能两个程序都是同一台电脑上同一个框架写的,默认编码一致。碰上半路杀出来的真设备,它的默认编码谁也不知道,你只能按照设备厂商手册上写明的编码去解析,所以编码这块必须认真对待。
5.4 Connected属性无法准确判断断线
TcpClient.Connected属性返回的是最近一次收发操作时的连接状态,不是实时状态。也就是说,当网络中断、设备突然重启时,Connected可能还是True,但你一Read就抛异常。
判断连接是否还活着,正规做法是发心跳包。上位机每隔几秒定时往设备发一条心跳指令,设备收到后回一个对应响应,在预定时间内没收到就判定连接死亡,然后断开重连。这个机制在设备通讯里非常常见,看起来简单,却是整个通讯稳定性的基础之一。
5.5 端口被占用的问题
在服务端执行listener.Start()时抛异常“地址被占用”,表示端口已经被别的程序占用了。先用netstat -ano | findstr 9000看看到底是谁占用了端口。
netstat -ano | findstr 9000看到占用进程PID后,再用任务管理器查PID对应的进程名。很多工控软件会随机占用端口,换个不常用的端口比跟它抢要省事得多。端口选择建议避开那些常见软件的默认端口,比如3389、1433、3306之类,用8000-9999之间冷门的数字比较稳妥。
6. 从“最简单例程”往上走的三个方向
第一个方向是异步化。把AcceptTcpClient和Read改成await接受的异步版本,这样界面程序就不会因为网络阻塞而卡死。对于写WinForm或WPF上位机的场景,这一步基本是必须的,否则你连UI的按钮拖动都会变得一顿一顿。
第二个方向是设计通信协议。简单收发字符串只是demo,真正工程里往往需要包含命令字、数据长度、校验位、帧尾这些结构。你可以在C#里用byte数组去组装这些字段,结合BitConverter和它带的Byte转Int等函数,就能拼出完整的报文。
第三个方向是封装自己的通信库。把连接管理、断线重连、心跳、消息分包、订阅回调都封装成一个TcpClientHelper类,以后所有涉及TCP的上位机项目都能复用这套代码。从这种最简单的例程起步,每往上加一层概念,就能理解一层底层的意义。
从我个人的经验来看,TCP/IP这块光看书是真的记不住,把这篇代码跑通一次,再用网络调试助手折腾一遍断线、粘包、乱码,基本就能应对日常需求的大头了。后面再遇到什么多线程、异步、极致性能的问题,也都有了往上扩展的基础。
本文还有配套的精品资源,点击获取