写单片机的同事问我,数据上报用 UDP 还是 TCP;写上位机的同事问我,TCP 连上十分钟就断是什么原因;做测试的同事指着 iperf3 的输出问我,这个吞吐量到底是怎么算出来的。我发现,只要沾到网络通信,TCP 和 UDP 永远是绕不开的两个名字。不管你做嵌入式、上位机、Web 后端还是网络运维,最终都会遇到一个问题:这条数据,到底走 TCP 还是走 UDP?
这篇文章不是教科书式的协议讲解,而是我这些年实际写网络程序、调网络问题时的经验总结。我会把 UDP 和 TCP 的区别、三次握手四次挥手为什么存在、实际开发中怎么选型、怎么用代码实现、怎么用 iperf3 和抓包工具验证,以及那些你在百度上经常搜到却说不清楚的报错,一次讲明白。适合刚接触网络编程的新手,也适合被各种奇奇怪怪网络问题折腾过的老手。
1. 先搞清楚UDP和TCP在协议栈里的位置
1.1 一张图看懂数据从应用到网线要经过什么
每台联网的设备,只要走标准的 TCP/IP 协议栈,数据从你写的程序里发出去,会依次经过应用层、传输层、网络层、链路层。你写的 HTTP、Modbus TCP、FTP 这些是应用层协议;而 UDP 和 TCP 是传输层协议,它们俩是“平级”的,都在应用层下面,负责把你应用层的数据包装成报文,然后交给 IP 层去路由转发。
如果把网络通信比作寄快递,应用层决定包裹里装什么(比如你要传的传感器数据),传输层决定用哪种运输方式,网络层负责规划路线(IP 地址),链路层负责实际把包裹搬上车(MAC 地址、网卡驱动)。UDP 和 TCP 就是那两种截然不同的运输方式:一种是你扔到快递柜里就不管的平邮,一种是要求签收、丢件必赔的保价专线。
很多初学者会混淆“TCP/IP”和“TCP”这两个词。TCP/IP 其实是一整组协议族的统称,包括 IP、TCP、UDP、ICMP、ARP 等等;而 TCP 只是其中的一个成员。所以当你看到“支持 TCP/IP 协议”这种描述时,它的意思往往是“支持标准的网络通信”,不一定特指 TCP。这也是为什么很多硬件网卡宣称支持“TCP/IP 卸载”,其实说的是整个协议栈的处理能力,不是单指 TCP 一种协议。
1.2 端口号和四元组:通信双方怎么找到对方
不管是 TCP 还是 UDP,应用层的数据最终都要靠“IP 地址 + 端口号”来寻址。IP 地址告诉你数据要去哪台机器,端口号告诉你这台机器上的哪个进程在接收。一台服务器上同时跑着 SSH、Web 和数据库服务,靠的就是 22、80、3306 这些端口号把它们区分开。
这里有个细节容易被忽略:TCP 和 UDP 的端口是独立的,同一个端口号可以同时被一个 TCP 服务和一个 UDP 服务使用,互不干扰。比如 DNS 服务既监听 53 端口的 UDP,也监听 53 端口的 TCP,两条路径同时存在。我在排查端口冲突时经常遇到新手问“我明明 8080 端口被占了,为什么 netstat 里看到两个程序都用 8080”——你去看一下协议列,大概率一个是 TCP 一个是 UDP,那就不叫冲突,只是你被吓到了而已。
TCP 通信还有一个重要概念叫“四元组”:源 IP、源端口、目标 IP、目标端口。这四样合在一起才能唯一定位一条 TCP 连接。很多并发服务器的设计都依赖这个特性,同一个服务端口,可以同时跟成千上万个客户端建立连接,因为每条连接的客户端 IP 和端口不同,四元组就不会重复。UDP 没有连接的概念,它只有“信封”上的源地址和目标地址,发完就完事,不维护任何双方状态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. TCP的核心机制:三次握手、四次挥手与可靠性设计
2.1 三次握手:为什么不是两次或四次
TCP 是面向连接的可靠传输协议,连接建立的标志就是那经典的三次握手。客户端先发一个 SYN 包,表示“我要和你建立连接”;服务器回一个 SYN+ACK,表示“我收到了,我也准备好了”;客户端再回一个 ACK,表示“我确认你也准备好了”。连接正式建立。
这三次里最关键的是第二次,它同时干了两件事:确认对方的 SYN,同时发起自己的 SYN。如果你觉得“确认”和“发起”可以拆成两步,那就会变成四次握手,但 TCP 觉得没必要,合并成一步节省一次往返,所以本质上三次握手是最优状态。
那为什么不是两次?假设客户端发的第一个 SYN 在网络里卡了很久,客户端超时重传了一个新的 SYN,然后跟服务器正常建立了连接,用一阵子后关了。结果这时第一个 SYN 才慢吞吞到达服务器,服务器一看有人要建连,就回了 SYN+ACK。如果只有两次握手,服务器会以为连接已经建立成功,开始分配资源等待数据,然后一直等不到,白白浪费资源。有了第三次握手,客户端发现这个连接我已经不需要了,直接回一个 RST 告诉服务器“别等了我不要”,服务器就能及时释放分配的 PCB(进程控制块)资源。所以三次握手不只是“礼貌”,更是为了防止过期连接请求对服务器造成资源浪费。
2.2 四次挥手:为什么断开要比建立多一次
断开连接的四次挥手,很多背过面试题的人都能默写:A 发 FIN,B 回 ACK,B 再发 FIN,A 回 ACK。但真正明白为什么需要四次的没几个。
原因在于 TCP 是全双工通信,数据可以双向独立传输。A 说“我不发了”,只是 A→B 这个方向的数据结束了,但 B→A 这个方向 B 可能还有数据没发完。所以 B 先回一个 ACK,告诉 A“我知道你不发了”,然后继续把剩余数据发完,再发自己的 FIN,告诉 A“我也不发了”,最后 A 回最后一个 ACK。整个过程合起来才是彻底断开。
注意一个细节:B 回 ACK 和 B 发 FIN 之间,是可以有间隔的。这个间隔的长短取决于 B 还有多少数据要发。实际写代码时,如果你在服务器端调用 close() 之前没有把响应数据 flush 干净,就可能出现客户端收到半截数据的情况。这也是我调试 TCP 程序时最常遇到的问题之一。
还要提一下 TIME_WAIT 状态。主动关闭连接的一方(也就是第一个发 FIN 的那一方),在发出最后一个 ACK 后会进入 TIME_WAIT 状态,默认等 2MSL(报文最大生存时间的两倍,常见值 60 秒)。很多人不理解为什么要等这么久,其实是为了防止“最后一个 ACK 丢失,对方重发 FIN 时,我这边需要能再回应”。同时,也让网络上还在迷路的旧数据包彻底消失,避免影响新建的连接。写高并发服务器的都知道,如果服务器主动断开大量短连接,会出现一堆 TIME_WAIT 套接字,占用端口资源,这也是“端口不够用”的经典场景。
2.3 确认、重传、滑动窗口与拥塞控制:TCP 怎么保证可靠又高效
TCP 可靠性的核心是“确认与重传”。发送方每发一段数据,接收方都要回 ACK 确认,发送方在一定时间内没收到 ACK 就重发。这里有个关键点:确认号是按“字节序号”算的,而不是按“包数”算的。假设发送方发了 1-1000 字节,接收方收到了,就会回 ACK=1001,表示“我期待下一个字节是 1001”,这比按包确认精确得多。
如果只是“发一个等一个”,效率会低得可怕。所以 TCP 引入了滑动窗口:接收方通过 ACK 告诉发送方自己还剩多少缓冲区(也就是“窗口大小”),发送方在窗口限制内可以连续发多个报文,不用等每一个都确认。这就是为什么复制大文件时速度可以跑满带宽,而不会一个字节一个字节地磨蹭。
拥塞控制就更有意思了,它管的是“整个网络是不是太堵了”。TCP 会通过慢启动、拥塞避免、快速重传、快速恢复四个状态来动态调整发送速率。慢启动就是“先发一点点探探路,确认网络能承受,再指数增长”;拥塞避免是“涨到一定阈值后线性缓慢增长”;快速重传是“连续收到重复的 ACK 时立即重传,不用等超时”;快速恢复则是“发现丢包后不要像超时那样从零开始,降一半接着跑”。
看到这里你会发现,TCP 的可靠不是免费的。每一条确认都要占用带宽,每一次重传都有延迟。这也是为什么实时性要求高的场景会绕过 TCP 选择 UDP——TCP 为了保证不出错,愿意付出时间和带宽的代价;而 UDP 压根不管这些,发了就完,收不收得到全看网络脸色。
3. 实际开发中UDP和TCP怎么选型
3.1 判断标准:看你的业务能不能接受丢数据
选 TCP 还是 UDP,最核心的判断标准就是一句话:这条数据丢了,你能不能接受?
- 文件传输、网页加载、数据库查询、远程 Shell、设备配置下发:必须完整无缺,选 TCP。
- 实时视频通话、语音通话、在线游戏位置同步、传感器高频状态上报:允许偶尔丢一帧,但延迟必须低,选 UDP。
- 局域网内的设备发现、心跳检测:天生适合 UDP,因为广播和多播只有 UDP 支持。
很多人有个误解,觉得 UDP 就是“不靠谱”,是 TCP 的次品。实际上在实时音视频领域,UDP 是唯一可行方案。因为 TCP 如果遇到丢包,会触发重传,重传的数据包晚到了也没有意义,反而是浪费带宽,还会让整个链路越堵越卡,声音和画面会不断卡顿。UDP 丢了就丢了,视频画面花一帧或者声音断几十毫秒,人耳人眼几乎感知不到,是完全可以接受的。
3.2 工控、嵌入式和机器人领域里的实际选择
工控行业特别常见场景是 Modbus TCP。注意,Modbus TCP 是基于 TCP 的应用层协议,不是 UDP。凡是涉及 PLC 之间、PLC 与上位机之间的配置读写,都用 TCP,因为参数不能错,丢了就出事故。但如果是高速采集振动数据或者视觉系统实时传图像帧,经常走 UDP,追求的是低延迟和吞吐量,丢几帧数据不影响整体分析。
西门子 S7 协议、汇川 PLC 做 Modbus TCP 从站,这类场景全部走 TCP。有人遇到“西门子 TCP 只有每次重启才能连上一分钟”的问题,这通常是 TCP 连接被中间网络设备的空闲超时给切了,或者 PLC 侧的保持激活时间设置太短,和 TCP 协议本身关系不大,我会在后面的排查章节详细讲。
嵌入式领域,STM32F407 加 LAN8720 跑 lwIP 协议栈,做 UDP 通信非常常见。因为嵌入式设备资源有限,UDP 不需要维护复杂的状态机,内存占用小,代码也简单,往局域网广播数据或者给上位机周期上报状态,UDP 是天然选择。ROS 机器人系统的情况更有意思:ROS 1 自己定义了 TCPROS 和 UDPROS,默认走 TCP;ROS 2 使用 DDS 作为底层通信中间件,DDS 默认使用 UDP 进行发现和实时数据传输,因为机器人需要高频发布话题数据,UDP 的低延迟和发布/订阅模型更契合。所以问“ROS 是 UDP 吗”没有一个绝对答案,要看是 ROS 1 还是 ROS 2,以及具体话题的 QoS 策略。
3.3 网络调试工具和消费级场景里的隐性问题
你打开手机里的网络调试助手,往局域网 IP 的 8080 端口发一条 TCP 消息,对面设备需要先监听端口、接受连接才能收到。UDP 就简单得多,指定端口,发出去了事。ESP01S 这种 WiFi 模块要往手机发 TCP 消息,手机端必须装一个 TCP Server 工具并进入监听状态,而且要保证两个设备在同一局域网,因为模块连的是路由器,手机也在同一路由下才能互通。很多人连不上都是因为手机开了 4G/5G 流量,模块的数据根本过不去。
还有一个不得不提的问题:UDP 的弱点会被放大。比如 UDP 泛洪攻击,攻击者通过无连接的 UDP 协议向目标发送大量伪造源地址的数据包,目标设备会因为忙于处理这些垃圾包而耗尽资源。这种攻击之所以存在,正是因为 UDP 不需要三次握手、不验证来源,成本极低。所以你在部署任何 UDP 服务时,一定要想清楚:这个端口需不需要对公网开放?如果不需要,尽量在防火墙和路由器里只对可信的内网网段放开,不要裸奔到公网上。
4. 手写一个TCP和UDP通信程序:从零到能跑
4.1 环境准备和公共套路
我用 Linux 下的 C++ 来写示例,因为 Linux 的 socket 接口最标准,Windows 上的 Winsock 也长得差不多,把头文件和 init/cleanup 换一下就能移植。这段代码是为了让你看明白底层原理,不是每个项目都要从 socket 开始写,实际生产中可以封装或者用现成库,但原理是相通的。
先看公共部分:socket() 创建套接字、bind() 绑定地址、sendto/recvfrom 或 write/read 收发数据。UDP 用 SOCK_DGRAM,TCP 用 SOCK_STREAM,这就是你选协议的第一步。UDP 只有一个 socket,不需要 listen/accept;TCP 服务端需要 listen 之后 accept 为每个客户端生成新的套接字。
提示:代码里的错误处理我都做了简化,实际项目中每个系统调用都要检查返回值,否则排查问题时会非常痛苦。
4.2 UDP通信示例:无连接、发就完了
UDP 服务端代码(Linux C++):
cpp复制#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdio>
#include <unistd.h>
int main() {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
addr.sin_port = htons(12345); // 绑定端口
if (bind(sockfd, (sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
return -1;
}
char buf[1024];
sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
while (true) {
int n = recvfrom(sockfd, buf, sizeof(buf), 0,
(sockaddr*)&client_addr, &len);
buf[n] = 0;
printf("recv [%s:%d]: %s\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), buf);
// 原样回给客户端
sendto(sockfd, buf, n, 0, (sockaddr*)&client_addr, len);
}
close(sockfd);
return 0;
}
UDP 客户端代码:
cpp复制#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdio>
#include <unistd.h>
int main() {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(12345);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
const char* msg = "hello udp";
sendto(sockfd, msg, strlen(msg), 0,
(sockaddr*)&server_addr, sizeof(server_addr));
char buf[1024];
socklen_t len = sizeof(server_addr);
int n = recvfrom(sockfd, buf, sizeof(buf), 0,
(sockaddr*)&server_addr, &len);
buf[n] = 0;
printf("recv: %s\n", buf);
close(sockfd);
return 0;
}
UDP 代码最好写的地方就是这里:客户端不需要 connect,服务端不需要 listen/accept,数据到了就收,一收就知道对方是谁。注意 recvfrom 会把发送方的 IP 和端口也带出来,这是 UDP 服务端做多客户端交互的关键——没有连接管理,你要想给某个客户端单独回消息,就得靠这个“信封上的回信地址”。
4.3 TCP通信示例:连接、收发、关闭
TCP 服务端代码:
cpp复制#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdio>
#include <unistd.h>
int main() {
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
// 关键:允许端口复用,否则服务器重启时会报 Address already in use
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(12346);
bind(listenfd, (sockaddr*)&addr, sizeof(addr));
listen(listenfd, 5);
while (true) {
sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int connfd = accept(listenfd, (sockaddr*)&client_addr, &len);
printf("client connected: %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
char buf[1024];
int n = read(connfd, buf, sizeof(buf));
if (n > 0) {
buf[n] = 0;
printf("recv: %s\n", buf);
write(connfd, "hello tcp", 10);
}
close(connfd); // 处理完就关闭,简单演示
}
close(listenfd);
return 0;
}
TCP 客户端代码:
cpp复制#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdio>
#include <unistd.h>
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(12346);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
if (connect(sockfd, (sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
return -1;
}
const char* msg = "hello tcp";
write(sockfd, msg, strlen(msg));
char buf[1024];
int n = read(sockfd, buf, sizeof(buf));
buf[n] = 0;
printf("recv: %s\n", buf);
close(sockfd);
return 0;
}
这里我故意在服务端加了一行 setsockopt 和 SO_REUSEADDR,这行代码太重要了。TCP 服务端如果主动断开连接,或者程序崩溃后重启,经常会遇到 bind 报错“Address already in use”,原因就是上一个进程的套接字还处于 TIME_WAIT 状态。加了 SO_REUSEADDR 之后,允许绑定这个端口,程序重启就不容易遇到这个问题。我见过太多初学者在这里卡住,然后莫名其妙重启机器,其实一行代码就能解决。
4.4 端口占用与容器端口暴露的排查思路
热词里有一条很典型的报错:error response from daemon: ports are not available: exposing port tcp 0.0.0。这是 Docker 启动容器时端口被占用导致的。排查思路其实很固定:先用 netstat -tlnp 或者 ss -tlnp 看看 0.0.0.0:端口 是谁在监听,找到 PID 之后去任务管理器或者 ps -ef 里看是哪个进程,决定是停掉它还是换一个端口映射。
还有一条 error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket addre,这句话的意思是:同一个地址+端口的组合只能被一个 socket 绑定,现在已经被占用了。这是 Windows 系统里的报错信息风格,说明你之前启动的服务没有被正确停止,进程还活着,悄悄占着 11434 端口。解决办法同样是用 netstat 找到 PID 杀掉,或者检查是不是有某个开发工具把它设成了开机自启动。这类问题跟 TCP 协议本身没关系,但几乎每个写网络程序的人都遇到过,属于“必要经历”。
5. 打流测试与吞吐量计算:用数据说话
5.1 iperf3 使用 UDP 打流的完整操作
iperf3 是网络性能测试的标准工具。两端都要装,一个是服务端一个是客户端。先启动服务端,iperf3 -s,监听默认的 5201 端口;客户端执行 TCP 打流:iperf3 -c 192.168.1.10,默认测 10 秒的 TCP 最大带宽。
UDP 打流略有不同,因为 UDP 不会自己控制速率,打太猛会把网络打爆。客户端加 -u 和 -b 参数指定目标码率:
bash复制iperf3 -c 192.168.1.10 -u -b 100M
这条命令表示用 UDP 向对端发送 100Mbps 的流量,测 10 秒。输出里会有一个专门的小结,显示实际发送速率、接收速率、丢包率和抖动(Jitter)。
我实测过不少次,UDP 打流最经典的结论就是:你能把数据发出去,不代表对方能收得到。跑在同一台交换机的两台服务器,丢包率可能是 0;隔了几跳公网,丢包率可能直接飙到 20% 以上。而且 iperf3 报的“丢包率”是“发送方发出的包中,没有收到接收方反馈统计的那部分”,不是简单的“网络丢了 20% 的包”,因为中间设备的缓存溢出、接收端处理不过来,都可能表现为丢包。
5.2 吞吐量是怎么算出来的
很多人看 iperf3 的报告只会看最后那个大数字,不理解它是怎么来的。以 TCP 打流为例,iperf3 客户端会统计自己在测试时间内总共成功发送了多少字节。假设 10 秒内发送了 1.25GB 字节,那吞吐量就是 1.25GB * 8 / 10s = 1Gbps。8 是因为 1 字节 = 8 比特,网络带宽单位是 bit/s,不是 byte/s。
IxChariot 这类商业测试工具的原理也一样,区别是它可以在脚本里定义要测试的流数量、报文大小、持续时长,然后通过控制端和远端 Agent 之间的配合来计算吞吐量。它的结果里通常还会带上“时延”和“响应时间”,因为它是全双工测量,不止看一个方向的带宽。不管用哪个工具,核心公式都是那个:总字节数 × 8 ÷ 总时间 = 吞吐量。
5.3 抓包验证三次握手和窗口调优
光看吞吐量还不足以定位问题,抓包是网络从业者的另一只眼睛。在服务端执行 tcpdump -i eth0 tcp port 12346,然后启动客户端,你能看到三个包:SYN、SYN+ACK、ACK,这三次握手就在眼前跑了一遍。抓包看多了,你对 TCP 各种状态的理解会比背十遍书都深刻。
Windows 下可以用 netsh int tcp set global initialwindow=10 调整 TCP 初始拥塞窗口大小,netsh interface tcp set global timestamps=enabled 开启 TCP 时间戳选项。调整 InitialWindow,可以让高带宽低延迟的小文件传输更快,因为省去了慢启动阶段“试探”的时间;timestamps 有助于更精确的 RTT 估算和防序号回绕。但这些都是调优手段,不是银弹。我见过有人乱调之后把网络搞得更差,最后恢复默认才解决,所以调优前一定要先抓包看基线,再改参数对照测试。
6. 高频网络问题排查与速查
6.1 Modbus TCP 能 ping 通但 modscan 连不上
这个问题的场景非常典型:设备在网络上,ping 它 IP 通,但用 Modbus 扫描工具连 502 端口就是不行。排查顺序建议这样走:
- 先确认 502 端口是否在设备上监听:
telnet 设备IP 502,如果端口通,telnet 会显示连接成功;如果卡住或直接拒绝,说明服务端可能没起来。 - 确认防火墙是否放行了 502 端口。很多工控设备的 Windows 或嵌入式 Linux 默认防火墙会把入站 502 挡掉,只放行 ICMP,所以 ping 通但端口不通。
- 用 Wireshark 抓包看看有没有 TCP 握手包。如果只看到客户端发 SYN 没有服务器回 SYN+ACK,说明数据包到达了设备但设备根本没监听端口;如果 SYN+ACK 正常但应用层没有响应,说明设备侧的 Modbus 服务没配置好。
一句话总结:ping 通只能证明 IP 层通,端口通是 TCP 层的事,应用层通是 Modbus 协议的事,一层一层往里查,问题跑不掉。
6.2 WSL2 里的 Linux 和 Windows 怎么做 UDP 通信
WSL2 默认运行在虚拟化 NAT 网络里,Linux 里的 UDP 服务监听 0.0.0.0,Windows 主机不一定能直接访问到。反过来,WSL2 里访问 Windows 主机的 UDP 服务,通常只能通过那个动态变化的 ip route show | grep default 网关 IP。
最简单的解决方案是用 WSL2 的镜像网络模式(mirrored networking)。在 .wslconfig 里配置 networkingMode=mirrored,然后重启 WSL,让 Linux 和 Windows 共享同一套网络接口、端口绑定也互通。配置完就能直接通过 127.0.0.1 访问对方了。如果你的 WSL 版本比较老没有这个选项,就只能用端口转发工具,或者把 UDP 服务绑定到宿主机虚拟网卡的 IP 上,但那个 IP 每次重启可能都会变,非常不稳定。我的经验是:能做镜像模式就尽量用镜像模式,省去一大半 NAT 带来的通信烦恼。
6.3 TCP 连接隔一段时间就断开,尤其是工控设备
现象是“西门子 PLC 每次重启后才能连上一分钟,之后就断”。这个问题的根源往往是 TCP 连接的空闲超时和保活机制没对齐。中间路由器或交换机为了清理无效会话,会在一段时间(比如 60 秒)没有数据流量时静默丢弃这条连接。连接被切了,但两端进程都不知道,等你想再发数据时,发送方会一直等 ACK,最终超时才发现连接已经死了。
解决办法有几个方向。第一个是应用层加心跳,每隔 30 秒发一个小包保持连接活跃,这是最通用的做法。第二个是开启 TCP KeepAlive,在 Linux 里可以用 setsockopt(SO_KEEPALIVE, 1) 并调整 TCP_KEEPIDLE、TCP_KEEPINTVL 参数;Windows 上可以通过 netsh interface tcp set global keepalivetime 改。第三个是检查中间设备有没有 TCP 空闲超时配置。我之前排查过一个类似问题,最后发现是交换机的“会话表超时”设成了 30 秒,而应用层心跳是 60 秒,心跳还没发,物理设备就把连接记成废弃了。
6.4 UDP 通信的“发了但对方没收到”排查清单
- 两个设备的 IP 是否在同一网段,用
ping验证。 - 防火墙和路由器是否放行了对应的 UDP 端口。UDP 没有连接状态,防火墙拦截时不会像 TCP 那样有明确的重置包,表现就是“发出去没反应”。
- 服务端是否真的在监听目标端口?
ss -ulnp看 UDP 监听。 - 客户端发送时是否绑定了正确的本机 IP?多网卡机器经常出现“从错误的网卡发出去”的情况。
- UDP 是尽力而为的,如果中间有设备做 NAT 或负载均衡,不配置对应的 UDP 会话保持,容易丢包。说到底,UDP 通信最简单,也最容易让人觉得“没反应”,因为协议本身不给任何反馈。
6.5 一些关于协议的经验之谈
我在实际项目中有一个体会:团队里最容易出 bug 的,不是不懂 TCP 和 UDP 的区别,而是以为懂了。比如做了 TCP 通信就认定消息一定不丢,却忽略了 TCP 的超时重传和缓冲机制并不保证“应用层读到的数据一定有序完整”——如果你在应用层没有维护消息边界,一个完整的数据包可能被拆成两次 read,两个数据包也可能在一次 read 里读出来。这不是 TCP 的错,而是 TCP 只保证字节流的可靠,不保证消息的边界。UDP 反而能天然保留消息边界,一次 sendto 对应一次 recvfrom,这是很多人不知道的隐藏优势。
还有个小技巧:调试任何网络问题时,先用最简单的工具验证通不通,再进入自己的业务代码。UDP 通了,拿 nc -u 或者网络调试助手发条消息试服务端;TCP 通了,用 telnet 或者 nc 直接发。花一分钟确认底层通没通,比你盯着自己的业务代码猜十个小时强得多。
我自己到现在,写网络程序时还是会经常想起那句老话:TCP 是有连接的、可靠的、有序的;UDP 是无连接的、不可靠的、无序的。理解这两个协议的区别不是终点,真正值钱的是知道在什么场景下选什么、出问题时怎么定位。希望这篇文章能在你下次遇到网络问题时,帮你少走一段弯路。
