Socket通讯详解:从TCP/UDP基础到工业设备连接实战
发布时间:2026/10/8 12:31:50 锦皓数字建站

Socket通讯说白了就是让两台设备之间能传数据的一套标准套路。你在后台看到的各种“端口”“TCP三次握手”“客户端/服务端”这些词其实都绕着socket转。这个话题适合谁看刚接触网络编程的学生、做上位机和PLC对接的工程师、搞单片机想跟电脑通信的硬件开发者还有那些被“connect timeout”“端口被占用”报错折腾过的人。这篇我按自己平时带人入门的路子来讲先建立概念再直接上手写代码最后把高频报错和工业通讯里的坑一并说掉。1. socket到底是什么先别看文档把它想成打电话1.1 一句话理解socketsocket是操作系统提供的一组网络编程接口。你不需要自己造网卡驱动也不需要手动处理IP数据包的分片和重传只需要调用socket、bind、listen、connect、send、recv这几个函数数据就能在网络上流动。我习惯拿打电话来类比。通话要拨号码号码就是IP地址它定位到某台设备拨通后还要转分机号分机号就是端口它定位到设备上的某个具体程序。而socket本身更像你手里那部电话机你可以拿起它拨号connect接通以后说话send听对方讲话recv最后挂断close。两台设备之间的“电话线路”就是一条数据通道数据从一端写入从另一端读出。计算机里那个“电话总机”是操作系统内核中的协议栈。你调用socket函数时内核会创建一块数据结构来记录这条连接的状态、收发缓冲区和对方地址。后面所有收发的数据都会经过这块结构路由到正确的网卡和进程。1.2 通讯的基本流程谁等谁谁找谁网络通讯通常分两种角色。一种一直在等待别人连接叫服务端另一种主动寻找对方并建立连接叫客户端。服务端流程固定四步创建socket绑定一个端口进入监听状态然后循环接受连接。客户端相对更简单创建socket直接向服务端地址发起连接连接成功后就开始收发数据。这个模型是不是很像实体店。服务端把店开在一个固定门牌号上IP加端口然后对外营业listen。客户找上门connect店员接待accept之后双方开始交谈send/recv。店员一次只能服务一个客户所以服务端通常要不停accept新的客户把每一个连接交给独立的工作线程或协程去处理。服务端必须明确指定端口因为客户端要找到这个固定的“门牌号”。客户端一般不指定自己的端口由操作系统自动分配一个空闲的临时端口就像你打电话时电话局自动给你分配一条线路你不需要操心自己用的是哪条。2. 写socket通讯前必须搞懂的三个关键选择2.1 IP地址和端口少了谁都不行IP地址解决的是“这包数据送到哪台机器”端口解决的是“送到这台机器上的哪个进程”。端口是一个16位的数字范围0到65535。其中0到1023是知名端口通常被系统服务占用比如80是HTTP443是HTTPS22是SSH。你自己写程序时建议用1024以上的端口避免跟系统服务冲突也不容易被权限拦下来。我见过不少新手只写IP不写端口或者反过来只写端口不写IP然后问为什么连不上。原因很简单光知道小区地址没用不知道单元楼门牌还是找不到人光知道门牌号不知道在哪个小区数据根本送不到。服务端绑定的时候还要考虑用哪个网卡地址。如果绑定0.0.0.0意思是监听本机所有网卡上的对应端口无论客户端从哪个网卡方向过来都能连上如果绑定127.0.0.1那就只有本机能访问外部设备即使知道IP也连不进来。调试阶段用127.0.0.1没问题生产环境要暴露给外部设备时千万不要写死成回环地址。2.2 TCP和UDP一个像快递一个像漂流瓶创建socket时第二个参数决定了通讯协议。最常见的是SOCK_STREAM对应TCPSOCK_DGRAM对应UDP。TCP是面向连接的可靠协议会先通过三次握手建立一条虚拟线路发送的数据有编号、有确认、有重传、有拥塞控制保证数据不丢、不乱序。代价是开销大建立连接有延迟协议头也更重。适合文件传输、指令下发、消息推送这类对完整性要求高的场景。UDP是无连接协议不需要握手直接把数据包扔到网络上不管对方收没收到也不管顺序对不对。好处是开销小、延迟低、可以广播或组播坏处是数据可能丢、可能乱序。适合视频通话、语音、游戏同步这类实时性强、能容忍少量丢包的场景。选型有一条很实用的经验如果你不确定优先用TCP。UDP只有在确认自己需要低延迟或者广播能力时才选。哪怕视频传输现在大部分实现也会在UDP上自己做丢包恢复和排序这不是新手该轻易碰的活。2.3 阻塞、非阻塞、同步、异步到底什么关系这几个词经常把新手绕晕。先理解阻塞和非阻塞它描述的是函数调用会不会卡住。默认创建出来的socket是阻塞模式调用recv时如果没有数据到达线程会在那里一直等着直到有数据或者连接断开。非阻塞模式下没有数据时recv会立刻返回一个错误码线程可以继续干别的事。同步和异步描述的是“调用后怎么知道结果”。同步就是调用后干等结果异步就是调用先返回等结果出来后通过回调函数或事件通知你。最常见的组合是“阻塞式socket加多线程”每个连接分配一个线程线程内部用阻塞的recv循环读数据。代码简单直接一个读一个写。缺点是线程数量受系统资源限制上万连接时会崩。“非阻塞socket加事件循环”是高性能服务器的做法比如Linux的epoll。所有socket都注册到同一个事件循环里哪个有数据来了就处理哪个线程数很少能撑起很高并发。很多语言内置框架已经把这种模式封装好了比如Python的asyncio、Node.js的net模块。如果你只是写个上位机、小工具、对接一下设备不需要一上来就搞事件循环。阻塞式加一两个线程完全够用。3. 从零写一个TCP通讯程序最简回显服务3.1 服务端代码监听、接受、循环读数据先把一个最朴素的服务端写出来用Python表达最直观。这个服务端做的事情很简单等待客户端连接客户端发来什么就原样回什么。import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 9000)) server.listen(5) print(服务端已启动监听端口9000) while True: conn, addr server.accept() print(f客户端连接来自: {addr}) while True: data conn.recv(1024) if not data: break print(f收到: {data.decode()}) conn.sendall(data) conn.close()重点说几个容易出错的地方。bind的第二个参数是端口号必须是一个整数不能传字符串9000很多新手在这里踩坑。listen(5)里的5是等待连接队列的长度不是最大连接数限制。系统会把正在握手或等待accept的连接放进这个队列超过长度时新连接会被拒绝。对大多数小项目5到32这个量级就够。recv(1024)的1024是一次最多读取的字节数。TCP是字节流协议数据没有“消息边界”一次recv拿到的可能只是半个包也可能包含两个包的内容这正是后面会说的粘包问题的根源。conn.sendall和conn.send不一样。send可能只发送了部分数据返回值告诉你实际发送了多少字节。sendall会循环发送直到全部发完或出错平时写代码尽量用sendall。if not data表示对端关闭了连接。TCP里连接关闭时recv返回空字节串不要把它当成正常数据去处理否则会导致死循环。3.2 客户端代码建立连接发送数据收响应import socket client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((127.0.0.1, 9000)) client.sendall(bhello socket) response client.recv(1024) print(f收到服务端响应: {response.decode()}) client.close()这里有个内存细节值得展开说。sendall(bhello socket)中的b前缀表示字节串socket通讯只认字节不认字符串。从文件读出来的内容、从界面输入的文本都要先编码成字节再发送。接收端拿到字节后再按约定的编码方式解码成字符串。编码方式不一致比如一边UTF-8一边GBK就会出现乱码。connect如果失败会抛出异常。常见异常有ConnectionRefusedError说明服务端没启动或者端口不对TimeoutError说明网络不通或对方防火墙丢弃了数据包。初学者最容易犯的错是把connect放进try里面但把业务逻辑放在外面连接失败时程序直接崩溃根本看不出原因。正确做法是把连接、收发、关闭放在同一个try块里统一处理异常并打印堆栈。3.3 数据收发中必须理解的粘包和半包TCP不保证一次发送对应一次接收它只保证字节流的顺序。如果你连续调用两次sendall发送两个消息对端一次recv可能把两个消息一起读走这就是粘包。如果你的消息很长超过接收缓冲区大小一次recv只能读到前半段这就是半包。解决粘包的办法一般有三种。第一种是固定长度每条消息固定N字节不够补齐读够N字节再处理第二种是分隔符用\n或\r\n分隔消息读到分隔符就算一条完整消息第三种是在消息前面加4字节长度头先读出长度再读对应长度的消息体。第三种最通用二进制数据也好用HTTP协议的Content-Length就是这个思路。为了更直观我给出一个简单的长度头收发函数。发送时打包成8字节消息头加消息体的格式接收时先完整读出头再按头里的长度读消息体。实际生产中可以拆成更加完善的协议工具类。import struct def send_msg(sock, data: bytes): length struct.pack(I, len(data)) sock.sendall(length data) def recv_exact(sock, n: int): buffer b while len(buffer) n: chunk sock.recv(n - len(buffer)) if not chunk: raise ConnectionError(连接已断开) buffer chunk return buffer def recv_msg(sock): header recv_exact(sock, 4) length struct.unpack(I, header)[0] return recv_exact(sock, length)struct.pack(I)中的表示大端序网络传输默认大端。这里有个一致性要求收发两端必须使用同样的打包格式否则读出来的长度就是天文数字程序会一直等不到数据。协议在项目里是最高优先级的设计不要等写代码时再随意定。4. 进阶把收发放进回调里别把界面卡死4.1 为什么不能用主线程直接recv很多人在写C#上位机时犯同一个错误在主线程里直接调用client.Receive(byte[])一旦没有数据主线程就卡在那里窗口无响应鼠标转圈。根本原因在于GUI程序都有一个消息循环负责处理鼠标、键盘、重绘事件。主线程被recv阻塞后消息循环停了界面自然假死。正确的思路是谁也别占着主线程。你可以用后台线程读数据也可以使用异步API让操作系统在有数据到达时通过回调通知你。下面这段说的是C#里最常用的异步接收写法也是你搜索“C# socket begin receive回调”时最想找到的东西。4.2 C#服务端异步接收的实现思路C#的Socket类有两条异步路线。老一点的是BeginReceive/EndReceive基于异步编程模型新一点的是SocketAsyncEventArgs基于事件和对象池性能更好。这里用BeginReceive系列说明回调机制因为它最贴近回调这两个字逻辑也容易看明白。private static byte[] _buffer new byte[1024]; private static void StartReceive(Socket client) { try { client.BeginReceive(_buffer, 0, _buffer.Length, SocketFlags.None, ReceiveCallback, client); } catch (SocketException ex) { Console.WriteLine($接收启动失败: {ex.Message}); } } private static void ReceiveCallback(IAsyncResult ar) { Socket client (Socket)ar.AsyncState; try { int received client.EndReceive(ar); if (received 0) { byte[] data new byte[received]; Array.Copy(_buffer, data, received); HandleReceive(data); StartReceive(client); } else { client.Close(); } } catch (SocketException ex) { Console.WriteLine($接收异常: {ex.Message}); client.Close(); } catch (ObjectDisposedException) { // 连接被主动关闭时正常触发 } }回调函数运行在线程池线程上不是主线程。如果需要在界面上显示收到的数据不能直接操作控件必须通过Control.BeginInvoke或者Task.Run切换到UI线程更新。private static void HandleReceive(byte[] data) { string text Encoding.UTF8.GetString(data); if (this.InvokeRequired) { this.BeginInvoke(new Actionstring(msg textBox1.AppendText(msg \r\n)), text); } else { textBox1.AppendText(text \r\n); } }这个InvokeRequired判断是C#的经典写法。为什么要做这个切换因为UI控件不是线程安全的跨线程访问会导致不可预知的崩溃。初学者最容易在这里遇到一个奇怪的“第一次运行正常第二次报错”的诡异问题根因就是回调线程和UI线程交错。4.3 从一个连接扩展到多连接用字典管理客户端生产中很少只服务一个客户端。C#服务端可以维护一个Dictionarystring, Socket以客户端标识为键值为对应的socket。每接受一个新连接就把它加入字典并启动异步接收每次收到数据根据socket的句柄或自定义Id找到对应的连接。当连接断开时从字典中移除并释放资源不要在回调里直接删除正在遍历的集合。可以先把要删除的键收集到临时列表遍历结束后再删否则会抛出“集合已修改”的异常。如果要同时维护几百个连接BeginReceive这种写法也够用。但如果你要做更大规模的网关建议换SocketAsyncEventArgs它的优势是减少每次异步操作的对象分配回收复用。这个API写起来更繁琐要处理每个事件的完成回调封装层比较多但性能收益在连接数上来后很明显。5. 高频报错通常每个套接字地址只允许使用一次5.1 这个错误到底是谁报出来的你搜索的“windows socket error: 通常每个套接字地址(协议/网络地址/端口)只允许使用一次”对应错误码是WSAEADDRINUSE中文是“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”。这个错误几乎都发生在服务端调用bind或者客户端显式bind时。原因很简单你试图绑定的那个IP加端口组合已经被另一个socket占用了。最常见的场景你启动服务端关闭程序马上重新启动结果bind失败报这个错误。不是因为你没关干净而是操作系统不会立即释放这个端口。5.2 根本原因TIME_WAIT状态在作祟TCP连接关闭时主动关闭连接的一方要进入TIME_WAIT状态并持续大约2倍的报文最大生存时间Windows上通常是60到120秒。这不是操作系统犯傻而是为了保证网络中残留的旧数据包不会污染新连接。TIME_WAIT期间该连接占用的本地端口没有真正释放。所以如果你在代码中给客户端bind了一个固定端口连接关闭后立刻重建连接就会撞上这个错误。服务端的情况也一样虽然服务端socket本身一直存在但关闭新连接时主动关闭的一方也可能是服务端端口依然会被TIME_WAIT卡住。还有一个容易被忽略的场景程序崩溃后之前监听的端口没有被正常释放。任务管理器里看不到进程了但端口被系统内核中的残留结构占用短时间无法重新bind。5.3 解决办法换端口、复用地址、检查占用第一排查手段是确认那个端口是不是真被别的程序占了。在Windows命令行执行netstat -ano | findstr 9000如果看到状态为LISTENING的行先判断这个进程是不是你自己的旧服务。是就用任务管理器结束它不是就用下面的命令看是哪个进程tasklist | findstr 进程号确认只是TIME_WAIT导致的问题后可以在bind前设置地址复用选项。Python里就是服务端代码中的那一行server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)C#里同样有对应写法server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);设置这个选项后处于TIME_WAIT状态的连接就能立即重用该端口。要注意这不意味着你可以在同一个端口上同时bind两个不同程序它只是放宽了TIME_WAIT限制让同一个进程重启时能立刻重新绑定。开发调试时我建议直接把这个选项加上能省掉大量“重启一次服务要等一分钟”的时间。但在高安全环境里SO_REUSEADDR要谨慎使用因为可能把一个本该被拒绝的连接接进来或者在不同进程间引起端口归属歧义。还有一个更省事的方案服务端不绑定固定端口而绑定端口0。系统会从1024到65535之间自动分配一个空闲端口然后通过getsockname查询实际分配的端口号。这个做法在分布式服务里很常见进程间通过注册中心交换端口避免端口冲突。6. 工业通讯里的socket从Modbus到PLC都得靠它6.1 Modbus TCP协议本质上就是socket搜索热词里有一堆“modbus通讯”“MCGS触摸屏跟西门子1500跨网段通讯”“汇川AM系列和威纶通通讯”这些在TCP/IP层面全是socket。Modbus TCP只是规定了应用层数据的格式。它把Modbus RTU报文去掉CRC校验前面加一个MBAP头然后通过TCP的502端口传输。你的上位机程序要做的就是建立一个socket连接把功能码、起始地址、寄存器数量这些字段按协议拼成字节数组发送给PLC再接收响应并解析。用前面写好的socket收发函数再套一层Modbus协议编解码就可以直接跟PLC通讯了。很多开源库把这个过程封装成了现成接口比如Python的pyModbus、C#的NModbus。但封装库出了问题不好排查的时候你要能看穿这层壳回到socket的收发包逻辑来定位是协议格式错、字节序错还是连接根本就没建立起来。6.2 其它工业总线串口、现场总线跟socket不是一个层级热词中还大量出现RS485、GPIB通讯、K210与STM32通讯。这些从严格意义上讲跟socket不是同一个层级的东西。RS485是物理层和链路层标准定义的是电压电平、收发时序、半双工控制这些电气规范。你可能在网络项目里用一根USB转RS485线接设备然后在驱动层得到一个串口再用带串口通讯的socket库去收发。其实是从物理层到应用层都自己处理了一遍。GPIB是一种仪器控制总线常用于连接示波器、信号源、电源这类仪器。现代GPIB设备通常通过USB或网口适配器接入电脑。网口模式下比如支持VXI-11的仪器底层就是socket只是应用层走了RPC协议。排查仪器连接问题时先ping通、再telnet端口、最后构造协议包基本思路还是socket调试思路。如果你正在做树莓派、K210这类单片机跟STM32通讯大概率用的是串口UART不是socket。串口和socket的模型非常像都有接收缓冲区、发送缓冲区都要处理粘包和半包都要约定波特率、校验位、帧头帧尾。理解了socket的数据流模型理解串口协议会快很多。反过来如果你想让单片机通过ESP8266模块联网就要在串口驱动的上面再搭建网络协议栈此时你接触到的AT指令其实只是建立了一个用户态到协议栈的通道发出去的数据最终还是要被封装成TCP或UDP报文。6.3 写工业通讯程序时的几个经验教训把socket用在工业设备上比纯互联网应用更多坑。第一个坑是字节序。PLC的寄存器数据大概率是大端存储Windows上位机可能是小端。你从socket读到4字节直接按int解析出来数值不对十有八九是忘了做字节序转换。用BitConverter的时候要小心必要时反转字节数组再转。第二个坑是超时。工厂车间里电磁环境复杂网线松动、交换机故障、设备断电都会导致数据中断。阻塞socket不设置发送和接收超时就可能永远卡死在那里。建立连接后必须设置超时超时后抛出异常并重连。Python里是client.settimeout(3)C#里有同步接收超时和异步连接超时两种设置。异步连接超时比较隐蔽BeginConnect本身没有超时参数需要用Timer配合断开。最简单可靠的工程做法是异步连接加一个独立的看门狗计时器超过5秒没完成就强制关闭socket并通知上层。第三个坑是断线重连。工业设备不像网站服务器没人看着它运行几个月都不能出问题。程序里要设计重连策略通常是指数退避第一次断开后等1秒重试第二次等2秒第三次等4秒最多等30秒封顶避免设备故障期间程序陷入疯狂重连的循环把网络打满。我个人在实际项目里最常用的套路是顶层用协议解析中间层封装连接管理和重连逻辑最底层才是socket收发。每一层都要能单独打日志。设备通讯出问题时先看底层有没有收到字节再看协议层解析正不正常。没有这两层分离的代码排查问题会痛苦得多。如果你刚开始学别急着上SocketAsyncEventArgs、epoll那些复杂模式。先把这个最简单的服务端和客户端跑通用WireShark抓包看看三次握手再亲手制造一次粘包然后自己想办法解决。这些基础打牢了后面用什么语言、什么框架都只是包装层的事。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。