TCP 全连接队列与 tcpdump 抓包

本篇核心话题:TCP 全连接队列tcpdump抓包分析

虽然标题标注了两个核心内容,但本篇我会先给大家补充一个底层核心知识点,带大家阅读 Linux 版本内核源码,帮大家彻底搞懂「连接」在内核中的本质;把连接底层原理吃透之后,大家才能真正明白listen第二个参数的作用、以及全连接队列的完整含义。

TCP 全连接队列

闲话不多说,第一部分内容分为四大模块,我会按顺序依次讲解:

实验验证 :通过代码实操,得出listen第二个参数的作用结论。

原理拆解:通过图片来理解全连接队列核心概念。

工程调优思考:分析全连接队列为什么不能为空、同时不能设置过长。

内核底层解读:结合内核源码,理解「连接」的本质,打通全连接队列底层逻辑。

实操实验 ------ 验证listen第二个参数作用

我们不做多余铺垫,直接动手做实验,通过现象推导结论。

实验代码准备

我提前封装好了TcpSocket工具类,配套两份测试代码:服务端test_server.cc、客户端test_client.cc

test_server.cc

cpp 复制代码
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <unistd.h>

const static int default_backlog = 6;

enum
{
    Usage_Err = 1,
    Socket_Err,
    Bind_Err,
    Listen_Err
};

#define CONV(addr_ptr) ((struct sockaddr *)addr_ptr)

class TcpServer
{
public:
    TcpServer(uint16_t port) : _port(port), _isrunning(false)
    {
    }
    // 都是固定套路
    void Init()
    {
        // 1. 创建socket, file fd, 本质是文件
        _listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (_listensock < 0)
        {
            exit(0);
        }
        int opt = 1;
        setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));

        // 2. 填充本地网络信息并bind
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = htonl(INADDR_ANY);

        // 2.1 bind
        if (bind(_listensock, CONV(&local), sizeof(local)) != 0)
        {
            exit(Bind_Err);
        }

        // 3. 设置socket为监听状态,tcp特有的
        if (listen(_listensock, default_backlog) != 0)
        {
            exit(Listen_Err);
        }
    }
    void ProcessConnection(int sockfd, struct sockaddr_in &peer)
    {
        uint16_t clientport = ntohs(peer.sin_port);
        std::string clientip = inet_ntoa(peer.sin_addr);
        std::string prefix = clientip + ":" + std::to_string(clientport);
        std::cout << "get a new connection, info is : " << prefix << std::endl;
        while (true)
        {
            char inbuffer[1024];
            ssize_t s = ::read(sockfd, inbuffer, sizeof(inbuffer)-1);
            if(s > 0)
            {
                inbuffer[s] = 0;
                std::cout << prefix << "# " << inbuffer << std::endl;
                std::string echo = inbuffer;
                echo += "[tcp server echo message]";
                write(sockfd, echo.c_str(), echo.size());
            }
            else
            {
                std::cout << prefix << " client quit" << std::endl;
                break;
            }
        }
    }
    void Start()
    {
        _isrunning = true;
        while (_isrunning)
        {
            // 4. 获取连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int sockfd = accept(_listensock, CONV(&peer), &len);
            if (sockfd < 0)
            {
                continue;
            }
            ProcessConnection(sockfd, peer);
        }
    }
    ~TcpServer()
    {
    }

private:
    uint16_t _port;
    int _listensock; // TODO
    bool _isrunning;
};

using namespace std;

void Usage(std::string proc)
{
    std::cout << "Usage : \n\t" << proc << " local_port\n"
              << std::endl;
}
// ./tcp_server 8888
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        return Usage_Err;
    }
    uint16_t port = stoi(argv[1]);
    std::unique_ptr<TcpServer> tsvr = make_unique<TcpServer>(port);
    tsvr->Init();
    tsvr->Start();

    return 0;
}

test_client.cc

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>

int main(int argc, char **argv)
{
    if (argc != 3)
    {
        std::cerr << "\nUsage: " << argv[0] << " serverip serverport\n"
                  << std::endl;
        return 1;
    }
    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    int clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (clientSocket < 0)
    {
        std::cerr << "socket failed" << std::endl;
        return 1;
    }

    sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(serverport);                  // 替换为服务器端口
    serverAddr.sin_addr.s_addr = inet_addr(serverip.c_str()); // 替换为服务器IP地址

    int result = connect(clientSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr));
    if (result < 0)
    {
        std::cerr << "connect failed" << std::endl;
        ::close(clientSocket);
        return 1;
    }
    while (true)
    {
        std::string message;
        std::cout << "Please Enter@ ";
        std::getline(std::cin, message);
        if (message.empty())
            continue;
        send(clientSocket, message.c_str(), message.size(), 0);

        char buffer[1024] = {0};
        int bytesReceived = recv(clientSocket, buffer, sizeof(buffer) - 1, 0);
        if (bytesReceived > 0)
        {
            buffer[bytesReceived] = '\0'; // 确保字符串以 null 结尾
            std::cout << "Received from server: " << buffer << std::endl;
        }
        else
        {
            std::cerr << "recv failed" << std::endl;
        }
    }
    ::close(clientSocket);
    return 0;
}

原始代码中listen的第二个参数backlog默认值为 6,本次实验我们需要修改两处代码:

  • listen(backlog)的参数直接改为1,数值太小更容易观察队列满的现象;

  • 注释掉服务端所有accept相关逻辑 ,服务端只执行bindlisten,之后循环sleep(1),不主动从内核取已建立的连接。

修改后的服务端逻辑:仅创建套接字、绑定端口、开启监听,全程不调用accept,持续休眠。 客户端代码逻辑:填充服务端 IP、端口,创建套接字调用connect发起连接,连接成功后持续休眠;本次实验我们会删减客户端打印日志,方便批量后台运行客户端。

单机器本地测试现象

编译代码后,先在同一台机器本地测试(127.0.0.1回环地址):

后台运行 1 个客户端,执行netstat -natp查看连接状态;

现象: 查询结果会出现两条ESTABLISHED连接记录。

原因: TCP 是全双工通信协议,同一台机器内,服务端视角、客户端视角会分别生成一条双向连接记录,所以会出现两条记录,容易干扰观察队列上限。

关键推论:

  • 即便应用层完全不调用accept,客户端依旧可以正常完成三次握手建立连接;

  • 三次握手的全过程由 Linux 内核协议栈自动完成,和用户层是否调用accept没有关系

  • accept系统调用的本质:从内核的全连接队列中,取出已经完成三次握手的连接,以文件描述符 (fd) 的形式返回给应用层,供程序读写据。

测试完成后,终止所有后台客户端进程,清理环境。

跨云服务器正式实验(区分客户端、服务端两台机器)

为规避本地回环地址双连接记录的干扰,我们使用两台云服务器分别部署服务端、客户端:

右侧云服务器(120 开头 IP):运行test_server服务端,监听8888端口【上面跑服务,下面跑监视】;

左侧云服务器(8 开头 IP):编译test_client客户端,右侧云服务器通过scp命令[src [要拷贝的资源] [目标用户名]@[目标IP]]将客户端二进制文件拷贝到客户端机器,赋予可执行权限chmod +x test_client

云服务器公网 / 内网 IP 补充说明

云服务器厂商会做机房 1:1 NAT 转发:

  • 机器网卡真实 IP 是内网 IPifconfig可直接查看),公网 IP 是厂商虚拟化出的转发地址,本机网卡不存在该 IP;

  • 客户端访问服务端时,目标地址填写服务端公网 IP;服务端识别客户端时,记录的也是客户端公网 IP;

  • 机器内部内核通信、绑定端口时,实际使用内网 IP,因此写socket程序时,bind建议绑定0.0.0.0(INADDR_ANY),不能直接绑定公网 IP,会报地址不可用错误。

多客户端连接实验现象

启动第 1、2 个客户端,后台运行,分别在两端执行netstat -natp查看:

服务端存在 2 条ESTABLISHED状态连接,握手全部完成,无异常;

启动第 3 个客户端:

前 2 个客户端依旧保持ESTABLISHED

第 3 个客户端连接停留在SYN_SENT状态,服务端对应连接条目为SYN_RECV,三次握手无法完成;

再启动 4 个客户端后:

仅 2 个连接稳定维持ESTABLISHED

剩余 4 个全部卡在SYN_SENT,三次握手无法收尾;

长时间无 ACK 响应后,内核自动清理半连接条目,客户端连接超时断开。

实验最终结论

我们设置listen(backlog=1),内核最多允许2 条完成三次握手的连接存入全连接队列,即:

全连接队列最大容量 = backlog + 1

listen第二个参数backlog用于限制内核全连接队列可缓存的、已完成握手但未被accept取走的连接数量上限。

原理拆解 ------ 全连接队列底层模型

内核连接的本质

TCP 连接在内核中,本质是内核态结构体对象struct sock系列结构体):

每一次客户端完成三次握手,内核都会创建一套结构体,存储这条连接的全部属性:源 IP、目的 IP、源端口、目标端口、握手序列号、ACK 确认号、连接创建时间等;

内核通过队列 数据结构批量管理所有已完成握手的连接,这个队列就是全连接队列(accept 队列)

应用层 调用accept()时,内核从队列头部取出一条连接结构体,转换为文件描述符返回给程序,程序通过 fd 和客户端收发数据。

全连接队列:生产者 - 消费者模型

全连接队列的运行逻辑,是标准的生产者-消费者模型

✅ 生产者:操作系统TCP内核。持续和新客户端完成三次握手,将就绪连接节点放入全连接队列;

✅ 消费者:应用层程序。通过 accept 系统调用,不断从队列中取出就绪连接,拿到文件描述符后进行读写通信处理。

我们本次实验注释了accept代码,相当于消费者停止消费,生产者持续生产,队列快速被占满,触发上限限制,新连接直接被拦截。

队列积压的场景

当应用层业务繁忙(大量计算、报文解析、IO 阻塞),来不及调用accept取连接时,已握手完成的连接会持续堆积在全连接队列中。 队列存满后,新完成握手的连接无法入队,内核不会回复第三次握手的 ACK,客户端停留在SYN_SENT,服务端停留在SYN_RECV,最终握手超时失败。

关键误区澄清

全连接队列上限backlog+1不等于服务器能同时处理的总连接数

  • 只要应用层及时调用accept把连接从队列取出,取出后的连接不再占用队列空间;

  • 哪怕队列上限只有 2,程序持续循环accept,可以同时维持成千上万个业务通信连接;

  • 队列仅缓存「握手完成、但程序还没取走」的待处理连接。

全连接队列存放的是已经建立连接、但应用层还没来得及处理的积压连接

只有当应用层处理速度 < 内核握手生产速度,连接出现积压,全连接队列才会生效限流。

工程思考 ------ 全连接队列的设计取舍

问题 1:全连接队列为什么不能为空?

全连接队列的本质是内核缓冲区 ,作用是削峰填谷、缓冲流量

业务高峰期,应用层CPU、IO负载高,处理请求变慢,来不及accept新连接。此时内核可以将完成握手的连接暂存在队列中,等待应用层空闲后统一处理。

如果没有这个队列(队列不能为空),高峰期所有来不及处理的新连接都会被内核直接拒绝。等到应用层空闲后,没有任何缓冲连接可以处理,只能被动等待新客户端接入,会大幅提升服务器闲置率、降低系统吞吐量,严重影响用户体验。

简单说:队列是服务器的「流量缓冲池」,保证忙时不丢请求、闲时快速处理积压请求。

有队列缓存后,程序空闲时可以立刻accept队列里积压的连接,提升服务吞吐量、优化用户体验,避免瞬时峰值直接拒绝全部请求。

问题 2:全连接队列为什么不能设置过长?

如果把backlog设置成几万、几十万超大数值,会出现两个核心问题:

  • 用户体验极差:队列超长意味着排队客户端极多,新连接排在队列末尾,需要等待极长时间才能被处理,大部分客户端会超时主动断开;

  • 浪费服务器内存:每一条内核连接结构体都需要占用内存维护状态,超长队列会预留大量内存存储排队连接,挤占业务程序可用内存,降低整体 IO 吞吐性能。

因此工程实践中,backlog一般设置 16/32 这类适中数值,兼顾缓冲和内存开销。

所以说: listen第二个参数backlog的核心作用: 服务器应用层繁忙、来不及调用accept获取新连接时,TCP 内核层会维护一条全连接队列缓存已握手完成的连接,队列最大可存放节点数量为backlog + 1。


上面我们已经引出全连接队列 的概念,也弄懂了listen第二个参数的作用。

但只停留在操作层面会非常抽象,大家心里会疑惑:到底什么是 TCP 连接?下面我们抛开表层 API,从进程、文件系统、内核网络结构体逐层拆解,把「套接字、连接、文件描述符」三者的底层关联彻底讲透。

内核底层解读 ------ 理解「连接」的本质

服务器进程与文件描述符基础关联

服务器本质是操作系统中运行的进程,每一个进程启动时,内核都会为它分配一套文件管理结构:

顶层:task_struct 进程控制块;

进程内文件管理载体:struct files_struct,内部维护一张文件描述符数组 struct file* fd_array[]

所有进程默认占用 3 个标准文件描述符:

  • 0:标准输入
  • 1:标准输出
  • 2:标准错误输出

当程序调用socket()系统调用创建套接字,内核会返回当前空闲的最小文件描述符,一般是3,这个数字就是监听套接字的 fd。 到这里大家只能理解:套接字是一种文件,符合 Linux「一切皆文件」设计思想,但还有核心疑问:

我们创建套接字、调用accept获取新连接,内核底层到底新建了哪些数据结构?创建 TCP 连接到底对应内核里什么对象?

套接字分层内核结构体:从 fd 到网络协议的完整链路

第一层:VFS 虚拟文件层 ------ struct file

文件描述符数组里的下标(如 fd=3),存储指针指向struct file文件对象。 struct file内部有一个关键字段:void *private_data(私有数据指针)。 当socket()创建套接字时,内核会同步创建网络套接字核心对象struct socket,并让private_data直接指向该struct socket,完成文件系统与网络子系统的绑定

第二层:BSD 通用套接字层 ------ struct socket(网络入口层)

struct socket是所有 TCP/UDP 套接字的通用抽象层,是用户态系统调用访问网络的统一入口,核心两个成员:

  1. struct file *file:回指对应的struct file对象,双向绑定;
  2. struct sock *sk:指向底层协议专属套接字(TCP/UDP 具体实现);
  3. const struct proto_ops *ops:一组函数指针数组 ,存放套接字通用操作接口:bindconnectacceptreleasesendmsgrecvmsg等。

核心逻辑:上层调用read()/write()/accept()时,内核通过struct socket里的ops函数指针,分发到 TCP 或 UDP 专属的底层实现,这是内核实现多态的基础。

第三层:INET 通用网络层 ------ 嵌套结构体 C 语言多态实现

Linux 内核用结构体嵌套、首成员继承 模拟面向对象的继承与多态,所有 IP 协议套接字内存布局为连续嵌套(俄罗斯套娃结构),层级从外到内: struct tcp_sockstruct inet_connection_sockstruct inet_sockstruct sock

1)通用基类:struct sock

所有网络协议套接字的公共底层结构,是内核网络栈通用载体,存放所有协议共用资源:

  • sk_receive_queue:TCP 接收缓冲区(双向链表存储sk_buff报文)
  • sk_write_queue:TCP 发送缓冲区(双向链表存储sk_buff报文)
  • 套接字状态、锁、内存管理、等待队列等通用字段。
2)IP 层底座:struct inet_sock

继承struct sockstruct sock sk是结构体第一个成员),存放 IP 通信专属信息:

  • 源 IP、目的 IP(saddr/daddr
  • 源端口、目的端口(sport/dport
  • TTL、tos、绑定地址等 IP 层参数。 TCP、UDP 套接字都会包含该结构,因为二者都基于 IP 协议传输。
3)面向连接扩展:struct inet_connection_sock

仅 TCP 这类面向连接协议拥有,继承struct inet_sock,核心成员:

  • request_sock_queue accept_queue全连接队列(accept 队列) ,存放三次握手完成、等待accept取走的 TCP 连接;
  • 重传定时器、超时控制、拥塞辅助参数。

UDP 是无连接协议,struct udp_sock仅嵌套到struct inet_sock,不会包含inet_connection_sock,因此 UDP 不存在全连接队列、半连接队列。

4)TCP 专属顶层:struct tcp_sock

我们口中TCP 连接的真实内核载体 ,继承struct inet_connection_sock,存放 TCP 独有特性字段:

  • 发送序列号、接收确认号
  • 慢启动阈值、拥塞窗口、滑动窗口参数
  • 重传计数、ACK 确认缓存、延迟 ACK 控制
  • 拥塞控制全套参数。

关键结论 :三次握手全部完成后,内核创建的struct tcp_sock结构体,就是我们常说的「TCP 连接」;监听套接字的全连接队列里,排队存储的就是一个个tcp_sock对象。

C 语言内核多态原理(纵向多态)

因为所有协议专属结构体(tcp_sock/udp_sock)的第一个成员都是父类结构体,内存地址完全对齐,内核可以通过强制类型转换自由访问不同层级字段:

  1. 拿到struct sock *sk指针 → 强转struct inet_sock*,读取源 / 目的 IP、端口;
  2. 强转struct inet_connection_sock*,访问accept_queue全连接队列;
  3. 强转struct tcp_sock*,读取 TCP 序列号、拥塞窗口等专属参数。
横向多态:区分 TCP 与 UDP 套接字

struct socketshort type字段标记套接字类型:

  • SOCK_STREAM:TCP 流式套接字,底层绑定tcp_prot_ops函数集;
  • SOCK_DGRAM:UDP 数据报套接字,底层绑定udp_prot_ops函数集。

上层统一调用ops里的接口,内核根据type分发到 TCP/UDP 各自的底层实现,同一套文件系统接口兼容所有网络协议。

网络报文载体:struct sk_buff

内核所有收发的数据包,都会封装为sk_buff结构体统一管理,是协议栈传输报文的最小单元。

四大核心指针(零拷贝设计核心)
cpp 复制代码
unsigned char *head;  // 整块缓冲区固定起始地址(不变)
unsigned char *data;  // 当前有效报文数据起始(封装/解包时移动)
unsigned char *tail;  // 当前有效报文数据末尾(封装/解包时移动)
unsigned char *end;   // 整块缓冲区固定结束地址(不变)
  • headroomdata - head,头部空闲空间,用于向上封装二层帧头、IP 头、TCP 头;
  • tailroomend - tail,尾部空闲空间,用于追加应用层载荷;
  • 协议栈分层处理报文时只移动指针、不拷贝内存,实现高性能零拷贝。
缓冲区链表管理

struct sock的收发缓冲区本质是sk_buff_head双向链表,大量sk_buff串成队列:

  1. 网卡收到报文 → 内核分配sk_buff,填充链路层头部,推入sk_receive_queue
  2. 应用层调用read() → 内核从接收队列取出sk_buff,拷贝数据到用户缓冲区;
  3. 应用层调用write() → 内核将用户数据封装新sk_buff,推入sk_write_queue,向下交付网卡发送。

监听套接字 vs 业务通信套接字:accept 完整底层流程

监听套接字(listen_fd,示例 fd=3)

调用socket()bind()listen()创建,内核行为:

  1. 生成struct filestruct socket、完整嵌套tcp_sock结构体;
  2. 初始化tcp_sock->inet_conn.accept_queue全连接队列,队列上限backlog + 1
  3. 仅负责监听端口、处理三次握手、缓存已完成握手的连接,不能收发业务数据
客户端三次握手完成,内核前置操作
  1. 客户端发送第三次握手 ACK,内核判定握手完成;
  2. 新建独立完整struct tcp_sock(一条全新 TCP 连接),填充双方 IP、端口、序列号;
  3. 将该tcp_sock对象挂载到监听套接字的accept_queue全连接队列尾部;
  4. 此时连接状态为ESTABLISHED,但用户层无法访问,只能等待accept取出。
用户调用accept()获取新连接完整内核流程

当上层执行int conn_fd = accept(listen_fd, ...),内核分步执行:

  1. 从 fd=3 对应的struct file拿到struct socket,访问底层tcp_sockaccept_queue
  2. 从队列头部取出排队的struct tcp_sock(已完成握手的连接);
  3. 全新创建一组资源:新struct file文件对象、新struct socket通用套接字;
  4. 在进程文件描述符数组分配新空闲 fd(示例 fd=4),让 fd=4 指向新建struct file
  5. struct socketsk指针,指向刚才从全连接队列取出的tcp_sock
  6. 返回 fd=4 给用户程序,这个 fd 就是业务通信套接字 ,可执行read/write和客户端交互。

核心区分

  • 监听 fd(3):只负责握手、缓存连接,不收发数据;
  • 通信 fd(4):绑定独立 TCP 连接结构体,专门用于业务数据读写;
  • 二者在内核是两套完全独立的struct file+struct socket资源,仅tcp_sock连接对象复用。

半连接队列补充说明

监听套接字除全连接队列accept_queue外,内核同步维护半连接队列(SYN 队列)

  1. 客户端发第一次 SYN 报文 → 内核创建临时struct request_sock,存入半连接队列,回复服务端 SYN+ACK;
  2. 此时连接状态为SYN_RECV,结构体仅初始化少量基础信息,TCP 完整参数未填充;
  3. 超时 30s~1min 未收到客户端 ACK,内核自动销毁半连接节点,握手失败;
  4. 收到客户端 ACK、握手完成 → 将节点从半连接队列移除,新建完整tcp_sock移入全连接队列。

进程 - 文件 - 网络四层分层总结

  1. 第一层:VFS 虚拟文件层 task_struct进程 → files_struct文件表 → struct file文件对象,实现「套接字即文件」,用户态只用 fd 统一操作;
  2. 第二层:BSD 通用套接字层 struct socket,统一网络操作入口,通过proto_ops函数指针实现协议分发;
  3. 第三层:INET 网络协议层 sockinet_sockinet_connection_socktcp_sock嵌套结构体,C 语言模拟多态,存放 IP、TCP 专属连接状态;
  4. 第四层:硬件设备层 struct net_device网卡设备,sk_buff报文载体,完成报文收发、硬件交互。

弄懂这套内核嵌套结构后,再回看之前实验里的全连接队列上限 = backlog+1 就非常容易理解: listen第二个参数backlog限制监听套接字tcp_sockaccept_queue的最大节点数,队列里每个节点都是一条完整、可交付用户的 TCP 连接。 只要上层及时调用accept把连接取出,节点离开队列,就能持续建立成千上万条业务连接;若上层阻塞、不调用accept,队列存满后新完成握手的连接无法入队,客户端握手卡死在SYN_SENT

以上就是套接字、TCP 连接、全连接队列的完整底层原理,本节课核心打通进程、文件系统、网络协议栈三者的关联。我们短暂休息,下一节开始实操tcpdump抓包分析 TCP 报文流程。

TCPDump 工具介绍

接下来我们进入第二部分:抓包。当我们掌握了 TCP 套接字、UDP 套接字代码编写,理解底层原理,清楚理论原理如何和代码实践结合之后,我们需要进一步提升动手实操能力,所以下面我们一起来学习网络抓包。

抓包环境分为两种:Linux 抓包、Windows 抓包。这里我们重点演示Linux 命令行环境下抓包

这里先给大家强调一个关键点:云服务器抓包和本地虚拟机抓包存在区别 。云服务器抓包时,抓到的 IP 地址展示会出现特殊现象。我们之前讲解listen第二个参数验证的时候也提到过相关情况,一会大家看到奇怪的 IP 地址不用感到疑惑,属于正常现象。下面我们正式开始。

首先介绍抓包工具:tcpdump 。绝大多数 Linux 发行版已经预装tcpdump。 验证本机是否安装:直接在终端输入tcpdump

重点:抓包操作必须拥有超级管理员 root 权限 ,执行命令需要搭配sudo

为避免大家测试时踩坑,所有演示统一使用sudo执行。直接输入:

bash 复制代码
sudo tcpdump

命令执行成功就会持续抓取网络报文。 如果执行提示command not found,代表未安装。

  • Ubuntu:先apt update,再安装;

  • CentOS/RHEL 系列:使用yum安装。

开始讲解抓包基础参数。网络抓包需要认识网卡接口 ,我们可以用ifconfig查看本机所有网卡:

  • lo:本地环回接口;

  • ethX:云服务器内网通信使用的真实网卡;

  • 部分机器出现dockerX网卡:安装 Docker 后自动生成的转发网卡,大家没有也不用在意;

  • 虚拟机环境网卡常见名称ens33

1. 基础过滤:指定网卡、指定协议

想要抓取任意网卡上所有报文,命令:

bash 复制代码
sudo tcpdump -i any

参数说明:-i 全称 interface,指定网卡;any代表抓取全部网卡。 上面命令会抓取所有类型报文,如果我们只想抓取TCP 报文,追加过滤条件:

bash 复制代码
sudo tcpdump -i any tcp

此时只会捕获 TCP 协议报文,先不运行,报文量太大,输出杂乱不方便观察。

2. 过滤条件:指定源 IP(src host)

语法:src host [IP地址],代表只抓取从该 IP 发出 的数据包。 示例需求:只抓取 shturl.cc/4IhrS 这台主机发来的 TCP 报文,条件之间用and连接:

bash 复制代码
sudo tcpdump -i any src host shturl.cc/4IhrS and tcp

现在这条命令如果没有输出,因为目标主机暂时没有向本机发送任何报文。 我们新开终端,运行tcp client尝试连接本机服务端口。 哪怕服务器程序并未启动,客户端执行connect()依然会向外发送报文,也就是三次握手第一次 SYN 报文,我们就能抓取到。

执行客户端连接命令,虽然最终connect连接失败,但是tcpdump成功捕获报文:报文中标志位S,就是SYN 置 1 ,同时携带初始序列号、窗口大小信息。 这就直观证明:connect()底层本质就是向服务端发起 TCP 三次握手的 SYN 请求。

3. 过滤条件:指定目的 IP(dst host)

语法:dst host [IP地址],只抓取发往该 IP的数据包。

⚠️ 注意环境差异:

  • 虚拟机:可以直接使用公网 IP 作为目的地址过滤;

  • 云服务器:使用公网 IP 过滤通常无法抓到报文,只能使用内网 IP 进行目的地址过滤。

我们还可以同时指定源 IP + 目的 IP,多个过滤条件继续用and连接,只捕获两台主机之间双向通信报文。 适用场景:中间人排查问题,只观察 A 主机和 B 主机相互收发的数据。

4. 高频实用:指定端口抓包(重点)

日常开发最常用:只抓取指定端口的 TCP 报文 。 语法:port [端口号] 示例:只抓取和本机8888端口通信的 TCP 报文

bash 复制代码
sudo tcpdump -i any port 8888 and tcp

测试:客户端向8888端口发起连接,可以抓到报文;如果连接8889端口,则不会捕获任何数据。

抓包输出里大家会看到R标识,代表RST 重置报文,大概率是历史残留失效连接,我们暂时不用关注。

重要参数:-n 禁止域名解析

默认情况下tcpdump会尝试把 IP 反向解析为主机名,云服务器经常出现一串随机主机名称,干扰阅读。 加上-n参数,直接显示原始 IP 地址,不做域名解析

bash 复制代码
sudo tcpdump -i any -n port 8888 and tcp

补充:随机主机名现象和云厂商机制相关,云平台会给大量云服务器分配临时内部主机名,属于正常情况。同时大家留意:云服务器抓包看到的基本都是内网 IP,无法直接捕获对外暴露的公网 IP 报文

5. 抓包数据保存到文件 & 离线分析

想要把捕获的报文存入文件,后续慢慢分析,使用参数-w(write 写入):

bash 复制代码
sudo tcpdump -i any -n port 8888 and tcp -w data.pcap

约定抓包文件后缀为.pcap。 ⚠️ 注意:直接打开 pcap 文件看到的都是二进制乱码,无法直接阅读。

离线读取 pcap 抓包文件:使用参数-r(read 读取)

bash 复制代码
sudo tcpdump -r data.pcap

很多场景需要持续抓包、事后复盘分析,这个用法非常实用。


实操演示:抓包验证 TCP 完整通信流程(三次握手 + 数据传输 + 四次挥手)

准备环境:同一台机器启动 TCP 服务端tcp server 8888;另一台主机运行tcp client连接。 终端 A 执行抓包命令:

复制代码
sudo tcpdump -i any -n port 8888 and tcp

终端 B 运行客户端发起连接:

复制代码
./tcp_client [服务器IP] 8888

① 抓取【三次握手】报文

抓包日志依次出现:

  1. 客户端 → 服务端:SYN(第一次握手,发起连接请求)
  2. 服务端 → 客户端:SYN+ACK(第二次握手,同步序列号 + 确认客户端 SYN)
  3. 客户端 → 服务端:ACK(第三次握手,确认服务端 SYN)

重点细节:第三次握手的 ACK 报文不占用 TCP 序列号 。 握手阶段报文还会协商 MSS 最大报文段长度、滑动窗口大小、初始序列号

② 抓取【业务数据传输】

连接建立成功后,客户端发送字符串 12345(5 个字符),抓包可以观察到报文数据长度 = 5,报文带有P标识,也就是PSH 标志位,代表推送数据,通知上层应用读取缓冲区数据。 服务端收到数据之后会回复 ACK 确认报文;如果服务端回传响应数据,同样可以被抓取。

③ 关键难点:为什么经常抓不到标准四次挥手?

客户端按下Ctrl+C主动断开连接,理论上 TCP 需要四次挥手:

  1. 客户端→服务端:FIN (客户端不再发送数据)
  2. 服务端→客户端:ACK(确认收到 FIN)
  3. 服务端→客户端:FIN(服务端无数据发送,也要关闭)
  4. 客户端→服务端:ACK(确认服务端 FIN)

实操的时候我们经常只抓到 3 条报文,看起来是「三次挥手」。 原因:服务端代码存在逻辑问题。 场景 1:客户端断开之后,服务端立刻感知连接关闭,处理连接的函数马上返回,紧接着直接调用close()关闭文件描述符。 客户端发FIN过来,服务端回复ACK的同时,自身也向外发送FIN第二次、第三次挥手报文合并为一条 FIN+ACK,最终报文合并成 3 条,也就是大家看到的三次挥手现象。

如果想要稳定抓到标准四次挥手,修改代码逻辑: 客户端先执行断开;延迟 1 秒之后服务端再调用 close 关闭连接 。 两端关闭操作错开,报文不会合并,此时抓包就能清晰看到完整四组报文:FINACKFINACK

补充:Wireshark 抓包也会出现 FIN+ACK 合并现象,原理完全一致。

使用 wireshark 分析 TCP 通信流程 (了解)

wireshark 是 windows 下的一个网络抓包工具。虽然 Linux 命令行中有 tcpdump 工具同样能完成抓 包,但是 tcpdump 是纯命令行界面,使用起来不如 wireshark 方便.

下载 wireshark

官方下载地址:

https://1.na.dl.wireshark.org/win64/Wireshark-win64-2.6.3.exehttps://link.wtturl.cn/?target=https%3A%2F%2F1.na.dl.wireshark.org%2Fwin64%2FWireshark-win64-2.6.3.exe&scene=im&aid=497858&lang=zh 网盘链接:

https://pan.baidu.com/s/159UUIoZ8b7guWDeuAHoF9Ahttps://link.wtturl.cn/?target=https%3A%2F%2Fpan.baidu.com%2Fs%2F159UUIoZ8b7guWDeuAHoF9A&scene=im&aid=497858&lang=zh 提取码:k79r

**安装 wireshark:**直接双击安装,没啥太多注意的.

启用 telnet 客户端

参考

https://jingyan.baidu.com/article/95c9d20d96ba4aec4f756154.htmlhttps://link.wtturl.cn/?target=https%3A%2F%2Fjingyan.baidu.com%2Farticle%2F95c9d20d96ba4aec4f756154.html&scene=im&aid=497858&lang=zh

启动 wireshark 并设置过滤器

由于机器上的网络数据报可能较多,我们只需要关注我们需要的。因此需要设置过滤器 过滤器 1 (指定 IP):

复制代码
ip.addr == [服务器 ip]

过滤器 2 (指定端口):

复制代码
tcp.port == 9090

更多过滤器的设置,参考 https://blog.csdn.net/donot_worry_be_happy/article/details/80786241

观察三次握手过程

启动好服务器. 使用 telnet 作为客户端连接上服务器

复制代码
telnet [ip] [port]

抓包结果如下: 192.168.0.107 1 - - -1-1 观察三个报文各自的序列号和确认序号的规律. 在中间部分可以看到 TCP 报文详细信息

观察确认应答

在 telnet 中输入一个字符​ 可以看到客户端发送一个长度为 1 字节的数据,此时服务器返回了一个 ACK 以及一个 9 个字节的响应 (捎带应答), 然后客户端再反馈一个 ACK (注意观察 序列号和确认序号)

观察四次挥手

在 telnet 中输入 ctrl + ], 回到 telnet 控制界面,输入 quit 退出. ​ 实际上是 "三次挥手", 由于捎带应答,导致其中的两次重合在了一起. ​

注意事项

如果使用虚拟机部署服务器,建议使用 "桥接网卡" 的方式连接网络. NAT 方式下由于进行了 ip 和 port 的替换. ​ 使用云服务器测试,更加直观方便. 录

相关推荐
Pniubi1 小时前
Gitee&GitHub同步仓库教程
gitee·github
TunerT_TQ2 小时前
GitHub深度工程评测:AI 系统提示词泄露知识库深度评测:6万星system_prompts_leaks情报资产背后,藏着什么?
人工智能·chatgpt·github·提示词工程·#valhalla静态工程审阅·systemprompt·大模型工程
InfinitePlus2 小时前
Git基本操作-命令行
git
炸膛坦客4 小时前
Git 和 GitHub:(十四)rebase 到某个有新提交的远程仓库的分支
git·github
小弥儿5 小时前
GitHub今日热榜 | 2026-07-31:AI Agent工作流共享赛道升温
人工智能·学习·github
白猫不黑17 小时前
GitHub 30K Star 的 AI 渗透测试工具
web安全·网络安全·信息安全·渗透测试·github·ai渗透测试
炸膛坦客18 小时前
Git 和 GitHub:(十二)基于远程仓库的某一个分支的某一笔提交创建新分支
git·github
一点一木19 小时前
🚀 2026 年 7 月 GitHub 十大热门项目排行榜 🔥
人工智能·github
阿里嘎多学长20 小时前
2026-07-30 GitHub 热点项目精选
开发语言·程序员·github·代码托管