
1. 从一次连不上开始TCP到底在替我们守护什么先说件我印象挺深的事。有一回线上服务半夜告警客户端大量报连接超时。我第一反应是服务端挂了结果端口还在监听CPU、内存都正常奇怪的是我用telnet从本机连都没问题从另一台机器却死活连不上。最后抓包才发现SYN 报文都发出去了对端也回了 SYN-ACK但中间设备却悄悄把后面的 ACK 给丢了于是一方认为连接已建立另一方还在 SYN_SENT 里干等。那次折腾到凌晨的经历让我意识到一个很朴素的道理TCP 协议看着像一本翻烂的教科书但真正理解它靠的是在生产环境里一层层扒开来看。这篇内容不是给你从头复述 RFC 793而是基于实际排查和开发经验把 TCP 协议里最容易被问到、也最容易被误解的东西捋一遍三次握手为什么非得三次、四次挥手多出来的那一次去哪了、dup ack和快速重传到底怎么回事、Address already in use是怎么冒出来的以及从 asio、Java、C# 到 Modbus TCP、ESP01S 这些真实场景里 TCP 长什么样。适合刚入门想搭起整体认知的新人也适合天天写网络代码但没系统梳理过的老手查漏补缺。1.1 TCP 在协议栈里的位置它管的是一条可靠管道很多人学 TCP 是直接从三次握手开始背的但我觉得先搞清楚它在整个网络模型里待在哪、上游下游分别是谁后面所有机制才有落脚点。TCP 属于传输层往上是 HTTP、FTP、Modbus TCP 这些应用层协议跟它要服务往下是 IP 协议给它提供逐跳转发。IP 层的职责是尽力而为地把数据包送到目的地它不管包是否丢失、是否乱序、是否重复TCP 则是在这条不可靠的传输管道之上硬生生造出一条可靠的有序字节流。可以这样想IP 像邮政系统的普通包裹只保证按地址投递中途丢件、顺序错乱、同一封信被送两遍它都不管。TCP 则像是你在包裹里塞了一整套物流单号、签收回执和补发机制——每发一段数据都要对方回执确认没收到就重发收到的顺序乱了就重新排队。正是因为加了这一层保障你的 HTTP 请求才能一次拿到完整的网页你的 Modbus TCP 指令才能放心地认为寄存器读写结果不会因为丢包而静默出错。这里有个特别常见的误区有人觉得 TCP 可靠就可以在应用层完全不考虑数据完整性。实际上 TCP 只保证字节流不丢不重不乱序它不保证你每次read()拿到的数据正好是你对端write()的那一整条消息。比如你发了 100 个字节对端可能分三次收到 30、50、20。字节流是连续的没错但消息边界需要应用层自己定义。这正是后面讲 Modbus TCP、自定义 TCP Server 时反复要面对的问题。1.2 TCP 与 UDP 到底差在哪选型从来不是情怀问题搜udp和tcp协议的区别的人特别多网上答案也统一TCP 面向连接、可靠、有序、慢UDP 无连接、不可靠、无顺序、快。听起来像选择题标准答案但实际工程里选型根本没这么简单。TCP 的优势是可靠和流量控制代价是头部更大最少 20 字节、握手和挥手有额外开销、丢包时会有重传延迟、队头阻塞——一个包丢了后面已经到达的数据也得等在缓冲区里不能交给应用层。UDP 的优势是低延迟、无连接开销、保留消息边界缺点是应用层得自己处理丢包、乱序、重复和拥塞。实际选型逻辑通常是这样的需要传输文件、数据库事务、远程命令执行这类不能容忍丢数据的直接用 TCP实时语音、视频、游戏位置同步这类能容忍少量丢包但无法容忍延迟抖动的UDP 更合适——所以很多音视频传输基于 UDP 再叠加 FEC前向纠错和重传逻辑。工业现场里 Modbus TCP 选 TCP 是因为它简单可靠、穿透性好但要求不太苛刻、追求更低延迟的场景也有人用 UDP 做私有协议。别被UDP 很快所以用它这种话带偏快是因为它没干活不是因为它跑得更快。1.3 端口号是 65535TCP 连接标识的底层约束关于端口号记住一句核心TCP 连接是靠四元组唯一标识的——源 IP、源端口、目标 IP、目标端口。这也是为什么很多人问为什么一个端口只能监听一个服务——监听端口只是四元组里的一个维度同一个本地端口上来自不同客户端的连接是完全不同的连接。端口范围是 0 到 65535因为 TCP 头部里端口字段只有 16 位。0 通常保留1 到 1023 是知名端口HTTP 的 80、HTTPS 的 443、Modbus TCP 的 502都在这个区间1024 到 49151 是注册端口49152 到 65535 是动态/临时端口操作系统分配客户端本地端口时一般从这个区间里挑。还有个经常被忽略的点服务端监听端口和已建立连接的本地端口都在同一个数字空间里。你的服务监听在 8080同时已建立连接里也有一堆本地端口是 8080 之外的随机值两者互不冲突因为四元组不同。后面讲Address already in use时你会更理解这点报错不是因为端口被占用这么简单而是你想绑定的那个四元组组合触发了内核的冲突判定。2. 三次握手与四次挥手连接生命周期里的状态机细节三次握手和四次挥手几乎是所有 TCP 面试的必考题但大部分人停留在背流程图的层面。我能理解为什么面试官爱问这个——握手挥手背后藏着 TCP 整套状态机、超时重传、资源回收机制能讲透的人网络功底基本不会差。2.1 三次握手为什么必须是三次三次握手的目的是让通信双方各自确认一件事我的发送能力没问题对方的接收能力没问题同时协商初始序列号。具体过程是客户端发 SYN携带自己的初始序列号 client_isn服务端回 SYN-ACK携带自己的初始序列号 server_isn并确认收到 client_isn客户端再回 ACK确认收到 server_isn。为什么不能是两次最经典的解释是防止已失效的连接请求突然到达服务器。想象一个场景客户端发了一个 SYN结果在网络里堵了很久客户端等不及超时重发后来的请求成功建立了连接并正常关闭。可就在这时之前堵在路上的那个旧 SYN 突然到达服务端。如果是两次握手服务端收到 SYN 就会分配资源、建立连接但客户端根本不知道这个连接存在于是服务端白白维护一个半死不活的空连接大量这种垃圾连接能把资源耗尽。三次握手能解决这个问题因为服务端发出 SYN-ACK 后不会直接认定连接建立必须等客户端再回一个 ACK。旧 SYN 到达后服务端确实会回 SYN-ACK但客户端发现自己根本没发起过这次连接会直接回 RST服务端收到 RST 就清理掉这个半连接不会浪费资源。另一个容易被忽视的原因是初始序列号需要双方确认。TCP 的序列号不是从 0 开始而是随机初始化的防伪造和防与旧连接的包混淆。客户端得知道服务端从哪个序号开始发服务端也得知道客户端从哪个序号开始发这本来就是一次双向的信息交换至少需要一去一回再一确认三个包。2.2 全双工下的四次挥手断开比建立多一次的原因握手三次挥手却要四次核心原因是 TCP 连接是全双工的——客户端和服务端各自有一条独立的发送通道A 的发送结束不等于 B 的发送结束。挥手的过程是主动关闭方先发 FIN表示我的数据发完了我不会再往这个方向发数据了被动关闭方回 ACK表示收到你的 FIN等被动关闭方自己的数据也发完了它再发 FIN表示我的数据也发完了最后主动方回 ACK完成关闭。所以 FIN 的本质是单向关闭。主动方的数据流关闭和被动方的数据流关闭是两件独立的事各自需要一次 FIN 和一次 ACK四步是理论最简。但实际抓包时你经常会看到挥手只有三个包——被动关闭方在收到 FIN 后如果它没有更多数据要发可以把 ACK 和它的 FIN 合并到一个包里发出去于是节省了一次往返。还有一个细节收到对端 FIN 应用层 read 才会返回 0EOF但这只代表对方不再发数据了你仍然可以继续往对方写数据前提是你还没发 FIN。很多应用层 bug 就是在这里出的一端收到 EOF 后直接把 socket 关了但另一端其实还有最后一段响应没发完于是数据被截断。2.3 从 netstat 看连接状态TIME_WAIT 与 CLOSE_WAITnetstat -ant是排查网络问题时最常用的命令但很多人看到一堆状态就懵。TCP 状态机简化来看是LISTEN监听中→ SYN_SENT / SYN_RECV握手中间态→ ESTABLISHED已建立→ 关闭阶段的一串状态。关闭阶段最值得关注的两个状态是 TIME_WAIT 和 CLOSE_WAIT因为线上大量故障都跟它们有关系。TIME_WAIT 出现在主动关闭方发完最后一个 ACK 之后它要停留 2MSL 时间MSL 是报文最大生存时间通常 30 秒到 2 分钟才彻底消失。为什么要等这么久有两个原因一是万一最后一个 ACK 丢了被动方会重发 FIN主动方得留在这个状态里能重发 ACK二是确保这个连接上所有旧数据包都在网络里自然消亡防止出现一个具有相同四元组的新连接把旧连接残留数据当自己的数据收下。TIME_WAIT 多不是问题问题是端口不够用才可能出现连接无法建立。另外强调TIME_WAIT 只在主动关闭方出现你作为客户端主动断开连接客户端会积累大量 TIME_WAIT你作为服务端主动断开服务端也会有。CLOSE_WAIT 则是一个危险状态它表示被动关闭方收到了 FIN但应用层还没有调用 close。比如你 C# 或 Java 写的服务端客户端断开了但你的代码没有处理对端 EOF不关闭 socket就会一直卡在 CLOSE_WAIT。这通常是应用层 bug不是内核问题。我见过一台服务器上几万个 CLOSE_WAIT 连接把文件描述符耗尽的场景排查到最后就是程序里一个分支忘了对关闭事件做处理。 提示看到大量 TIME_WAIT先判断谁是主动关闭方再看端口复用配置看到大量 CLOSE_WAIT先检查应用层代码有没有在 read 返回 0 或异常时正确调用 close。2.4 建连失败的典型症状SYN_SENT 和 SYN_RECV用 netstat 观察建连过程中的状态很有用。客户端netstat显示连接一直卡在 SYN_SENT意思是 SYN 发出去了一直没收到 SYN-ACK。原因可能是目标端口没人监听这时候一般会收到 RST 而不是一直卡着、中间防火墙把 SYN 包丢了、或者对端负载太高来不及回包。如果服务端大量连接卡在 SYN_RECV说明服务端收到了 SYN 也回了 SYN-ACK但客户端的 ACK 迟迟没到常见原因是服务端 backlog 太小导致 accept 队列满了或客户端回包被中间设备拦截。还有一种低频但很阴间的情况客户端发 SYN 在到达服务端之前就丢了客户端会按指数退避重发总耗时可能长达一两分钟才报超时。遇到这种问题不要只盯着两端服务器中间链路的设备安全策略、路由黑洞都得排查。3. 拆开一个 TCP 包协议栈、报文结构和可靠传输记账本协议栈这个词听起来很唬人其实就是数据从应用层到网线之间的层层封装和解封装流程。理解它之后你再去看抓包工具里的那些十六进制数据就不会觉得是一团乱码了。3.1 从网线到应用层一个数据包的层层封装你调用send()把数据交给操作系统内核会把它放进 TCP 层。TCP 层给数据加上 TCP 头部——源端口、目的端口、序列号、确认号、标志位、窗口大小等然后整个 TCP 段交给 IP 层IP 层再给它套上 IP 头部——源 IP、目的 IP、TTL、协议号TCP 是 6接着交给链路层加上以太网帧头和 CRC最后变成一串比特流从网卡发出去。接收方向反过来网卡收到比特流链路层校验帧、剥掉帧头IP 层根据协议号识别出上层是 TCP 并剥掉 IP 头TCP 层根据四元组找到对应的 socket把载荷放进接收缓冲区应用层recv()取走数据。每一层只处理自己该处理的信息不该看的不会去碰。这就是协议栈的分层协作。你平时调 HTTP 库时感觉不到这些但只要调到 raw socket、抓包分析或者嵌入式设备里用 AT 指令建 TCP 连接这些封装细节就成了排查问题的必备知识。比如 ESP01S 发送 TCP 消息时如果你在电脑上用 Wireshark 抓包会看到它发出的包是完整的以太网帧 - IP - TCP 三层结构。3.2 TCP 头部关键字段除了端口还有哪些必须认识TCP 头部最小 20 字节关键字段包括源端口16 位、目的端口16 位、序列号32 位、确认号32 位、数据偏移4 位、保留位、标志位URG/ACK/PSH/RST/SYN/FIN 各 1 位、窗口大小16 位、校验和16 位、紧急指针16 位后面还能跟可选的 TCP Options。实际排查里你必须能一眼认出这几个标志位里最常用的是 SYN、ACK、FIN、RST。SYN 表示发起连接ACK 表示确认号有效除握手第一个包外几乎所有包都置位FIN 表示数据发完要关闭RST 表示异常重置。PSH 表示立即把数据交给应用层而不是留在缓冲区URG 极少用。窗口大小是 TCP 流量控制的核心告诉对方我的接收缓冲区还能装多少字节。这个字段本身只有 16 位最大值 65535但通过 TCP Options 里的 Window Scale 可以左移 14 位最大能把窗口撑到 1GB 以上。做高性能长肥网络传输时没开 Window Scale 会导致吞吐量卡在极低的水平这是很多网速上不去问题的隐藏根因。序列号和确认号是可靠传输的记账本每个字节都有编号ACK 里的确认号表示我期待的下一个字节的序号也就是序号之前的数据我都收到了。3.3 修改TCP协议包的正确姿势什么时候该碰什么时候不该碰有人搜tcp协议包如何修改这确实是个容易引发风险的话题。我先给结论正常应用开发里你几乎永远不应该直接去构造或修改 TCP 报文。TCP 的行为由内核协议栈统一管理你想做的一切——限制发送速度、设置超时、启用端口复用、调整窗口——都有对应的 socket 选项和系统参数比如TCP_NODELAY、SO_RCVTIMEO、SO_REUSEADDR。直接改报文等于绕过内核的可靠传输管理很快会把连接搞乱。什么时候确实需要碰包一般是三类场景协议学习验证、网络实验、特殊网关开发。在这些场景里最稳妥的思路不是自己拼十六进制而是用现成的抓包构造工具比如 Scapy。例如你想验证一个不寻常的 TCP 标志组合比如同时置位 SYN 和 FIN对端会怎么响应可以在本地实验环境里构造这样的包来观察。 注意所有报文构造与重放实验请严格限制在你自己拥有或明确获得授权的环境中进行。不要对任何他人设备、公网主机做类似探测或测试这在任何地方都可能是违法行为。技术验证和攻击只有一念之差。真正的生产环境里遇到协议包需要修改的需求大概率是方向搞错了——你该改的是应用层报文、是 TCP 选项、是系统参数而不是 TCP 头本身。3.4 序列号、确认号与 dup ackFast Retransmit 背后的逻辑tcp dup ack是抓包里最常听到的术语之一。要理解它先明白正常情况接收方每收到一个按序到达的包会回一个 ACK确认号指向期待的下一个字节。如果接收方收到了一个序号大于期待值的包说明中间有数据丢了它不会沉默而是立刻重复发送我期待的那个字节的 ACK这就是重复 ACKduplicate ACK。每收到一个乱序包接收方就重发一次同样的 dup ack。发送方收到连续 3 个重复 ACK即第 4 次收到相同确认号后基本可以确认对应序号的数据丢了于是不等超时计时器立即重传丢失的包这称为快速重传Fast Retransmit。相比必须等到超时再重传快速重传能显著缩短恢复时间。还有一个相关选项叫 SACK选择性确认它能让接收方明确告诉发送方3 到 5 丢了6 到 10 我都收到了发送方就只需要重传 3 到 5不用把后面的数据都重发一遍。抓包里如果你看到大量 dup ack 伴随重传通常意味着网络里存在丢包、乱序或中间设备缓存不足。真实的排查手法我会在下一章展开。4. 那些年我们追过的 TCP 故障连接异常排查实战前端、后端、运维、嵌入式几乎每个写网络代码的人都会撞见同一批 TCP 异常。这一章我把几个高频问题的完整排查链拉出来不直接给答案重点讲定位过程。4.1 地址已在使用Java 重连报错的完整定位搜索引擎里java tcp客户端重连时报地址已在使用这种问题特别多。先看报错原文通常是java.net.BindException: Address already in use: connect注意 BindException 出现在connect上说明连接时想要绑定的本地地址和端口跟现有某个连接冲突了。客户端连接服务器时内核会分配一个临时端口作为本地端口。如果客户端没有指定本地端口很少会撞地址。真正触发这个报错的常见场景是客户端自己 bind 了固定端口或者复用了同一个 socket 去重连——第一次连接断开后 socket 没有正确关闭旧连接处于 TIME_WAIT四元组完全相同同一本地 IP端口、同一远程 IP端口新连接就无法建立。解决路径分两步。第一步确认四元组用netstat -ant | grep 本地端口看是否有 TIME_WAIT 残留。如果确认是 TIME_WAIT第二步是使用SO_REUSEADDR选项。Java 里 ServerSocket 可以在构造时设普通客户端 Socket 也有setReuseAddress(true)方法但关键在于它要在端口被占用之前、也就是绑定之前设置并且要理解它解决的是允许新连接绑定到还处于 TIME_WAIT 的本地端口。 提示服务端快速重启后报 Address already in use十有八九是 TIME_WAIT 残留。服务端监听端口加上 SO_REUSEADDR 是最常用的解法。但要注意SO_REUSEADDR 不等于 SO_REUSEPORT前者解决 TIME_WAIT 复用后者涉及多进程负载均衡别混为一谈。4.2 抓包看 dup ack 与重传网络质量的证据链排查网络质量光看应用层日志是不够的必须抓包。我的标准流程是先tcpdump抓两端入口出口的包再用 Wireshark 打开开分析 - 专家信息直接看有多少重传和重复 ACK。如果重传很少千分之一以下不用管。如果比例偏高先区分是乱序还是真丢包。乱序的特征是同一方向出现大量ACK 比当前序号大的包但重传不多——这是因为路径上有多条链路数据到达顺序变了TCP 会用 dup ack 催促补充但数据其实没丢延迟略高但不会触发重传。真丢包的特征是大量 dup ack 之后出现重传包且重传的序列号段和 dup ack 里反复确认的序列号吻合。还要懂得区分 TCP 的两种重传快速重传收到 3 个 dup ack 触发和超时重传RTO 超时触发。超时重传比快速重传影响大得多它会伴随整个连接进入慢启动吞吐量瞬间掉下来。排查时重点看tcp.analysis.retransmission和tcp.analysis.fast_retransmission的分布配合 RTT 曲线定位是哪一段链路丢包。4.3 RST 连接谁在握手过程里突然掀桌子RST 是 TCP 里的硬性中断信号。连接被 RST意味着某一方直接放弃治疗。常见的 RST 来源有这几类连接请求到达一个没有监听的端口内核直接回 RST。防火墙策略主动发送 RST很多企业防火墙对非法流量会回 RST 而不是静默丢弃。应用层异常退出、进程崩溃、SO_LINGER设置不当导致 socket 被强制关闭内核发 RST。服务端 backlog 满了且不接受新连接也可能触发 RST。序列号严重错位且超出窗口内核直接 RST这通常意味着连接双方状态不一致比如一方重启过、连接被 NAT 淘汰过。排查 RST 时最重要的一件事看清 RST 是从哪边发的、在哪个时间点发的。比如客户端发 SYN 后立刻收到 RST多半是端口没监听连接建立一段时间后收到 RST多半和防火墙 idle timeout、应用层 CRASH 有关。抓包时留意 RST 包的窗口字段——有 TCP 实现会把它设置成 0 来表示我真的什么都没收到。4.4 连接建立后反复断开从应用层到内核的递进排查连接一会儿通一会儿断是另一类高频问题。这种问题我会用排除法逐层查第一层应用层两端程序有没有在空闲时关闭连接有没有接收缓冲没读完导致对端发送超时应用层连接保活如心跳的频率是否低于中间设备的 idle 超时时间我自己就踩过坑服务端和客户端之间有层 NATNAT 的空闲映射超时是 60 秒而应用心跳 90 秒才发一次结果每隔一分半钟连接就断一次。后来把心跳调到 30 秒问题立刻消失。第二层内核参数检查net.ipv4.tcp_keepalive_time、tcp_keepalive_intvl、tcp_keepalive_probes。如果打算靠 TCP 自带的 keepalive 维持长连接默认 7200 秒显然太慢需要调小。第三层中间链路在两端出口同时tcpdump比对同一时间戳的包是否都能到对端。如果只有一端能看到对端回复基本可以断定中间丢了回包。这一套流程几乎覆盖了大多数连接不稳定问题的定位思路顺序很重要——先排除应用层自己再怀疑系统参数最后才去跟中间链路较劲。5. 技术栈大乱斗从 asio 到 ESP01S 的 TCP Server/Client 实现TCP 在不同语言和硬件平台上的实现差异非常大但核心思想一致。这一章我挑几个搜索热词对应的典型场景讲讲它们各自的坑。5.1 用 asio 库做 TCP server回调风暴与生命周期管理搜使用asio库如何做tcp server的人多半已经被原生 socket 的多线程阻塞模型折腾够了。asio 含 Boost.Asio 和 standalone 版的核心是io_context 异步事件回调单个线程就能驱动大量并发连接。一个最简 TCP server 的骨架长这样#include asio.hpp #include memory #include iostream using asio::ip::tcp; class Session : public std::enable_shared_from_thisSession { public: Session(tcp::socket socket) : socket_(std::move(socket)) {} void start() { do_read(); } private: void do_read() { auto self shared_from_this(); socket_.async_read_some(asio::buffer(data_, max_len), [this, self](std::error_code ec, std::size_t length) { if (!ec) { do_write(length); } }); } void do_write(std::size_t length) { auto self shared_from_this(); asio::async_write(socket_, asio::buffer(data_, length), [this, self](std::error_code ec, std::size_t) { if (!ec) { do_read(); } }); } tcp::socket socket_; char data_[1024]; static constexpr std::size_t max_len 1024; }; class Server { public: Server(asio::io_context io, short port) : acceptor_(io, tcp::endpoint(tcp::v4(), port)) { do_accept(); } private: void do_accept() { acceptor_.async_accept( [this](std::error_code ec, tcp::socket socket) { if (!ec) { std::make_sharedSession(std::move(socket))-start(); } do_accept(); }); } tcp::acceptor acceptor_; }; int main() { asio::io_context io; Server server(io, 8080); io.run(); return 0; }这个示例里最关键的是enable_shared_from_this。异步回调执行时Session 对象必须还活着所以每个异步操作的回调里都要shared_from_this()拿一个强引用把生命周期托管给 lambda。如果漏了这一步回调触发时对象已经析构程序直接 undefined behavior。这是 asio 新手最容易踩、最难查的坑。另一个常见误区是以为 asio 的异步回调会自动搬到一个线程池里跑。io_context.run()在哪个线程调用回调就在哪个线程执行。你用单线程跑io_context所有回调就是单线程串行执行你开 4 个线程同时run()同一个 io_context回调会分散到这 4 个线程那代码里就需要考虑数据竞争了。很多 asio 的诡异 bug 都来自以为只有自己一个线程在跑回调。5.2 Java 客户端重连SO_REUSEADDR 不是万能药回到 4.1 节的 Java 问题这里补充点更深入的。很多文章说客户端报 Address already in use 就设置 SO_REUSEADDR但如果你没自己绑定过固定本地端口这个设置通常起不了作用——因为客户端默认使用临时端口TIME_WAIT 的是临时端口新连接会分配另一个临时端口谈不上冲突。什么情况下 Java 客户端会绑固定端口比如你调用了Socket.connect()之前先用了Socket.bind(new InetSocketAddress(固定本地端口))或者你用的是某些旧框架强制指定了本地端口做防火墙白名单。这时候如果上一次连接的 TIME_WAIT 还在固定的本地端口 同一个远程端口 同一个远程 IP 就撞车了。真正通用的解法是尽量让客户端使用临时端口不要手动 bind 固定本地端口如果架构上必须固定那就在 bind 之前调用setReuseAddress(true)并确认操作系统层面没有禁止复用 TIME_WAIT 端口。顺带一提如果你在 Linux 上做压测短连接海量产生 TIME_WAIT 导致端口快耗尽更常用的手段是开启net.ipv4.tcp_tw_reuse仅对客户端出站连接生效并调大临时端口范围ip_local_port_range而不是依赖 Java 端代码。5.3 C# 写 Modbus TCP 客户端TcpClient 与报文拼接Modbus TCP 是工控行业最常见的协议之一它跟 HTTP 一样跑在 TCP 上但报文格式完全是另一套。C# 里实现一个简单 Modbus TCP 客户端核心是利用System.Net.Sockets.TcpClient连接后直接操作NetworkStream读写字节流。Modbus TCP 报文分为两部分MBAP 头7 字节和 PDU。MBAP 头包括事务标识符2 字节用于匹配请求和响应、协议标识符2 字节Modbus 固定为 0、长度2 字节指后续字节数、单元标识符1 字节用于标识从站。PDU 则包含功能码和数据。以最常用的读保持寄存器功能码 03为例请求报文是00 01 00 00 00 06 01 03 00 00 00 01。拆开看事务标识符00 01协议标识符00 00长度00 06后面还有 6 个字节单元号 1 功能码 1 起始地址 2 寄存器数量 2单元号01功能码03起始地址00 00数量00 01。响应报文则是00 01 00 00 00 05 01 03 02 00 07事务标识符和协议标识符不变长度00 05单元号、功能码不变02表示后面数据字节数是 200 07是寄存器值。C# 里用TcpClient.GetStream().Write()把这段字节发出去再Read()接收响应按 MBAP 头里的长度字段判断什么时候读完整帧。写客户端时最容易犯的错就是一次Read()可能只读到半截报文或者把多帧数据一起读出来所以一定要按长度字段解析帧边界而不是傻等固定字节数。5.4 ESP01S 发送 TCP 消息AT 指令里的那点细节ESP01S 是 ESP8266 系列中最小的模块之一不少人拿它联网发数据。它的玩法很简单模块带 AT 固件单片机或电脑通过串口发 AT 指令模块负责 Wi-Fi 连接和 TCP/UDP 通信。AT 指令是一套基于文本的命令发给模块后它会回 OK 或具体响应。典型流程先连 Wi-FiATCWJAPSSID,password等返回WIFI CONNECTED和OK然后建立 TCP 连接ATCIPSTARTTCP,192.168.1.100,8080返回CONNECT表示成功接着发送数据ATCIPSEND长度然后输入内容模块会先把内容发出去回SEND OK。搜esp01s发送tcp消息 手机的话场景通常是手机开热点ESP01S 连接手机热点手机端用网络调试助手之类的工具监听一个 TCP Server 端口ESP01S 作为客户端连过来发数据。这样手机和模块之间就能脱离路由器直接通信非常适合现场调试。这个场景的坑主要在三个地方一是供电。ESP01S 对供电很敏感启动峰值电流能到 300mA 以上用某些开发板的 LDO 供电可能反复重启。我建议用独立 3.3V 稳压模块供电别偷懒直接用 USB 转串口板上的 3.3V。二是 AT 指令结尾必须有回车换行\r\n。用串口助手手动输指令时记得勾选发送新行否则模块一直不响应。三是模块默认是单连接模式ATCIPMUX0这模式下只能建立一个 TCP 连接。想多连接得切到ATCIPMUX1但注意多连接模式下不能用透传。透传模式ATCIPMODE1ATCIPSEND只能用在单连接进去以后串口收到的数据会直接作为 TCP 数据发出去退出透传需要发送不带换行的。这块逻辑一开始很容易搞混我建议直接写一个简单的 AT 指令状态机处理别靠人肉记。6. 大规模 TCP 场景的两个切面nginx 反代与工业 ModbusTCP 协议不只存在于两台开发机的 socket 里它还是网关、代理服务器、工业总线的底层承载。这一章聊两个典型的大规模 TCP 场景一个偏互联网一个偏工业自动化。6.1 nginx 反代 TCP 的最大连接数瓶颈不在 nginx 本身nginx 从 1.9.0 开始支持stream模块可以在四层做 TCP 反向代理非常适合做 MySQL、Redis、自定义 TCP 协议的负载均衡和端口转发。搜nginx作为反向代理tcp最大连接数问的人多半是发现压测时连接数上不去。先说结论性判断nginx 反代 TCP 的并发上限几乎从来不是 nginx 配出来的一个固定数字而是一条链路上一堆资源互相制约的结果。首先要清楚一条 TCP 代理请求会占用两条连接客户端到 nginx 一条nginx 到上游服务一条。nginx 配置里worker_connections限制的是每个 worker 进程能同时打开的文件描述符数量级。假设你worker_processes 4、worker_connections 10240那 nginx 总连接上限大约是4 * 10240 40960但注意这是所有连接包括两条方向的共享的所以实际能承载的客户端并发大约是 20480 上下因为每个客户端要占掉 2 个连接。其次要看系统层限制ulimit -n限制单个进程的文件描述符数量默认可能是 1024也就是说你不调这个参数nginx 就算配了 40960 的连接数也撑不住。要改/etc/security/limits.conf里 nginx 用户的nofile或者用 systemd 你需要在 unit 文件里设置LimitNOFILE。这个坑极常见配置文件写了很大的 worker_connections实际一压测就卡在 1000 左右大概率就是 ulimit 没放开。再看内核参数net.core.somaxconn决定 listen 队列长度net.ipv4.tcp_max_syn_backlog决定半连接队列上限net.ipv4.ip_local_port_range决定 nginx 作为客户端连接上游时能用的临时端口范围。如果 nginx 每秒向上游新建大量连接临时端口耗尽也会导致连接失败。最后是内存。每条 TCP 连接在内核里都有收发缓冲区默认值乘以上万连接就是不小的内存开销net.ipv4.tcp_wmem和tcp_rmem过大时尤其明显。压测前可以用ss -s看当前系统 socket 总数用ss -ant | wc -l看实时连接数逐层对照瓶颈。6.2 Modbus TCP老工业协议如何在 TCP 上续命Modbus 是 1979 年提出的老协议能在工控领域活到今天靠的是足够简单。Modbus TCP 把传统 Modbus 串口协议包进 TCP 包里默认端口 502去掉了 CRC 校验因为 TCP 已经保证可靠传输加了一个 MBAP 头用于消息路由和帧边界识别底层直接跑在 TCP 之上。这也是为什么你搜modbus tcp会看到大量和 C#、PLC、网关相关的内容——几乎每家做上位机开发的都绕不过它。理解了 5.3 节的报文格式Modbus TCP 的调试思路就清晰了事务标识符用来匹配请求和响应长度字段告诉你一帧的边界。上位机读数据时发一条请求读 N 个寄存器返回的数据量就是 N 乘以寄存器字节数一个寄存器 2 字节加上固定开销。工控里用 Modbus TCP 有个容易被忽略的问题它是请求/响应模式上位机必须主动轮询PLC 不会主动上报。所以交互频率、轮询周期直接决定了信息量。这里涉及 502 端口的连接保持——多数 PLC 也支持多个 Modbus TCP 连接同时访问但不同品牌的连接数上限不一样有的只有 4 个或 8 个调试时如果你的上位机反复重连可能就撞上了 PLC 的连接数限制。6.3 LabVIEW 与 NI 实时机交互信息量如何量化LabVIEW 上位机和 NI 实时控制器比如 CompactRIO、myRIO之间的 TCP 交互是测控系统里非常常见的架构。LabVIEW 的 TCP 节点从函数选板 - 数据通信 - 协议 - TCP里能找到核心节点有 TCP Listen、TCP Open Connection、TCP Read、TCP Write、TCP Close。实时机作为 Server 监听上位机作为 Client 连接这是最常见的拓扑。搜labview上位机与ni实时机tcp交互的信息量怎么查询我理解这个问题有两层意思一是怎么知道对方发过来了多少数据二是怎么评估一次交互实际包含多少信息量。先说第一层。LabVIEW 的 TCP Read 节点要求你指定bytes to read但如果对方来的数据长度不确定你直接指定一个固定值可能读不满、也可能多读。比较实用的做法有两类一类是在应用层自定义帧头比如前 4 字节总是表示后面的数据长度上位机先读 4 字节解析出长度再读相应字节——这是最稳的做法也符合我前面反复强调的应用层需要自己定义消息边界另一类是使用 TCP Read 上的 timeout 和模式选项设置读取超时后返回当前缓冲区已有的数据但这依赖 LabVIEW 具体版本的节点行为工程上不如长度头可靠。至于信息量如果是在问一条交互的数据量很简单按你的协议格式算。比如实时机每 100ms 上报一组数据包含 10 个 double每个 8 字节加上 4 字节帧头和校验字节那么每次交互约 84 字节左右每秒 10 次就是约 840 字节每秒的上传信息量。如果想知道连接状态和缓冲区积压量可以用 TCP 连接引用创建属性节点读取当前可读字节数。但坦率讲真正工程上更推荐用 NI 的 Network Streams 做流式数据传输它直接把消息边界和缓存策略封装好了信息量和丢帧率都能在函数里直接配置、按需查询。回到本质不管是 LabVIEW、C# 还是 Modbus TCP读数据前先明确一帧数据的边界怎么划分、消息长度从哪来TCP 协议本身不会帮你解决这个问题但它给了你可靠传输的字节流剩下的全靠应用层约定。做网络排查这些年我最大的体会是TCP 协议本身的设计其实很自洽大多数诡异问题最后都落在应用层忘了约定消息边界、忘了正确关闭 socket、或者忽略了某一层中间设备的策略这三类原因上。遇到问题时别急着怀疑协议栈先打开抓包工具让证据说话通常比看半天日志猜来猜去高效得多。如果你正在跟某一个具体的 TCP 异常较劲抓包文件里那个反复出现的 dup ack 序号、那个迟迟不消失的 CLOSE_WAIT、那声突然的 RST往往就是整件事的答案。