网络收发包应用之抓包(含实验)

这节课的核心问题不是 tcpdump 命令怎么用,而是:一个运行在用户态的程序,为什么能看到主要发生在内核态的网络包流转过程?

读完这篇笔记,应该能回答 3 个问题:

  1. 用户态 tcpdump 如何抓到内核网络包?
  2. iptables/netfilter 丢弃的包,tcpdump 到底能不能抓到?
  3. 为什么用 socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) 就能写出一个最小抓包程序?

先给结论:在 Linux 上,tcpdump 底层依赖 libpcaplibpcap 会创建 AF_PACKET 类型的 packet socket。这个 socket 不是普通 TCP/UDP socket,而是工作在链路层附近。内核创建它时,会把一个回调函数 packet_rcv 注册到网络设备层的抓包链表 ptype_all 上。之后无论是收包路径还是发包路径,只要包走到网络设备层,内核都会遍历 ptype_all,把包递交给这些“旁路观察者”,tcpdump 就是在这里拿到包的。

flowchart LR
    A["tcpdump / libpcap"] --> B["socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))"]
    B --> C["packet_create"]
    C --> D["po->prot_hook.func = packet_rcv"]
    D --> E["dev_add_pack"]
    E --> F["ptype_all"]

    R1["收包: __netif_receive_skb_core"] --> R2["遍历 ptype_all"]
    R2 --> R3["deliver_skb"]
    R3 --> R4["packet_rcv"]
    R4 --> R5["sk_receive_queue"]
    R5 --> R6["用户态 recvfrom/read"]

    T1["发包: xmit_one / dev_hard_start_xmit"] --> T2["dev_queue_xmit_nit"]
    T2 --> T3["遍历 ptype_all"]
    T3 --> T4["packet_rcv"]

一、tcpdump 启动过程

首次启动 tcpdump 时,可以用 strace 观察它创建了什么类型的 socket。

图里最关键的一行是:

socket(AF_PACKET, SOCK_RAW, 768)

这和普通 TCP 编程很不一样。普通 TCP 客户端或服务端一般创建的是:

socket(AF_INET, SOCK_STREAM, 0)

两者的差别在于工作层级:

  • AF_INET + SOCK_STREAM:创建 IPv4 TCP socket,关注传输层字节流,应用只看到 TCP 已经处理过的数据。
  • AF_PACKET + SOCK_RAW:创建 packet socket,工作在设备层/链路层附近,能看到包含二层头部的原始帧。

第三个参数 768 也很关键。ETH_P_ALL 的值是 0x0003,表示接收所有以太网协议类型。socket 参数要求协议号使用网络字节序,所以代码里通常写 htons(ETH_P_ALL)。在小端机器上,htons(0x0003) 会变成 0x0300,十进制就是 768。所以 strace 里看到的 768,其实就是 htons(ETH_P_ALL)

换句话说,tcpdump 启动时是在告诉内核:我要创建一个链路层原始 socket,并且我对所有协议的包都感兴趣。

接下来再看内核如何根据协议族找到具体的创建函数。

Linux 内核里有一个 net_families 数组,不同协议族在数组里有不同位置。常见的包括:

  • PF_INET/AF_INET:IPv4 协议族,对应普通 TCP/UDP socket,创建函数是 inet_create
  • PF_INET6/AF_INET6:IPv6 协议族,创建函数是 inet6_create
  • PF_PACKET/AF_PACKET:packet socket 协议族,创建函数是 packet_create

AF_*PF_* 在很多场景下值相同,经常混用。更准确地说,AF 偏“地址族”,PF 偏“协议族”;实际 Linux socket 编程里经常直接等价使用。

因此,socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) 会走到 packet_create

这张图展示的是 packet_create 的关键动作:

po = pkt_sk(sk);
po->prot_hook.func = packet_rcv;

if (proto) {
    po->prot_hook.type = proto;
    register_prot_hook(sk);
}

这里的 popacket_sock,可以理解为 packet socket 在内核里的私有对象。po->prot_hook 是一个 packet_type,它会被挂到网络设备层的协议链表上。最关键的是:

  • po->prot_hook.func = packet_rcv:以后内核抓到匹配的包,就调用 packet_rcv
  • po->prot_hook.type = proto:这里的 protohtons(ETH_P_ALL)
  • register_prot_hook -> dev_add_pack:把这个抓包钩子注册到内核的协议分发链表里。

dev_add_pack 最终会根据协议类型选择注册位置:

if (pt->type == htons(ETH_P_ALL))
    return &ptype_all;
else
    return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];

所以 tcpdump 监听所有协议时,会被挂到 ptype_all

这张图可以理解为:ptype_all 是网络设备层上的一条观察者链表。tcpdump 创建 packet socket 后,就往这条链表里插入了一个节点,这个节点的协议类型是 ETH_P_ALL,回调函数是 packet_rcv

之后只要网络包走到网络设备层的抓包点,内核就会遍历这条链表,把包递交给 packet_rcv。这就是用户态 tcpdump 能看到内核网络包的入口。

二、网络包接收过程中的抓包

收包路径可以先按这个粗粒度顺序理解:

网卡收到包
  -> DMA 写入接收 RingBuffer
  -> 网卡触发硬中断
  -> 驱动通过 NAPI poll 收包
  -> 触发/执行 NET_RX_SOFTIRQ
  -> 进入网络设备层
  -> 进入 IP/TCP/UDP 等协议栈
  -> 唤醒用户进程读取 socket 接收队列

tcpdump 的抓包点在“网络设备层”,而 iptables/netfilter 的常见过滤点在 IP 协议层。这个先后顺序决定了收包时一个非常重要的现象:即使后面被 iptablesINPUTPREROUTING 规则丢掉,tcpdump 通常仍然已经看到了这个包。

总体接收如下图。

图中的关键位置是:包从驱动进入网络设备层后,会先到 tcpdump 能观察的位置;再往后才进入协议栈中的 netfilter 钩子。换句话说,收包时 tcpdump 比 IP 层过滤更早看到包。

内核源码里对应的位置在 net/core/dev.c__netif_receive_skb_core

这段代码的意思是:在网络设备层接收包时,内核会遍历 ptype_all

list_for_each_entry_rcu(ptype, &ptype_all, list) {
    if (!ptype->dev || ptype->dev == skb->dev) {
        if (pt_prev)
            ret = deliver_skb(skb, pt_prev, orig_dev);
        pt_prev = ptype;
    }
}

ptype_all 里挂着所有“想看所有包”的 packet socket。tcpdump 之前通过 dev_add_pack 注册进去的 packet_type,就在这里被遍历到。

deliver_skb 本质上就是调用这个 packet_type 的回调函数:

return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);

对于 tcpdump 的 packet socket 来说,func 就是启动阶段设置好的 packet_rcv

这里容易被“钩子函数、链表、队列”这些词绕晕,可以拆开看:

  • ptype_all 不是存放数据包的队列,而是一张“旁路观察者名单”。名单里的每一项都是一个 packet_type,表示“我想看哪些包,以及看到包后调用哪个函数”。
  • packet_rcvtcpdump 对应 packet socket 登记进去的回调函数。所谓“钩子函数”,就是内核走到这个抓包点时,额外调用一下这个函数。
  • sk_receive_queue 才是 packet socket 自己真正暂存数据包的接收队列。packet_rcv 的核心工作,就是把抓到的 skb 放到这个队列里,等待用户态的 tcpdump/libpcap 读取。

这里的 packet_type 不是数据包本身,而是网络设备层的一条“包处理/包观察规则”。它大致包含这些信息:

struct packet_type {
    __be16 type;              // 关心哪种以太网协议类型
    struct net_device *dev;   // 只监听某个网卡,或 NULL 表示所有网卡
    int (*func)(...);         // 包匹配后调用哪个处理函数
    struct list_head list;    // 挂到 ptype_all/ptype_base 链表里的节点
};

如果是正常 IPv4 协议栈,可能注册的是“type = ETH_P_IPfunc = ip_rcv”,意思是收到 IPv4 以太网帧后交给 IP 层处理。如果是 tcpdump 这种抓包程序,注册的则更像是“type = ETH_P_ALLfunc = packet_rcv”,意思是所有以太网协议类型的包都想看一眼,匹配后调用 packet_rcv 放入 packet socket 接收队列。

所以打开 tcpdump 后,收包路径不是被改写了,而是在网络设备层多了一条旁路递交流程:

不抓包时:

网卡 -> 驱动 -> 网络设备层 -> IP/ARP 等协议处理 -> TCP/UDP socket -> 应用

抓包时:

                         -> tcpdump 的 packet socket 接收队列
                        /
网卡 -> 驱动 -> 网络设备层
                        \
                         -> IP/ARP 等协议处理 -> TCP/UDP socket -> 应用

也就是说,tcpdump 更像在网络设备层旁边放了一个观察者。它会额外拿到一份包,但原本该交给 IP、ARP、TCP、UDP 的主流程仍然继续向下走。

packet_rcv 做的核心事情,是把当前 skb 放进 packet socket 自己的接收队列:

__skb_queue_tail(&sk->sk_receive_queue, skb);

这样用户态的 tcpdump/libpcap 后续调用 recvfromread 或者通过 PACKET_MMAP 读取 ring buffer 时,就能拿到这个包。

所以收包方向可以串成一条完整链路:

网卡/驱动生成 skb
  -> __netif_receive_skb_core
  -> 遍历 ptype_all
  -> deliver_skb
  -> packet_rcv
  -> sk_receive_queue
  -> 用户态 tcpdump 读取

这也解释了一个实践现象:如果你在本机配置了 iptables -A INPUT -s x.x.x.x -j DROP,对端发来的包可能应用层收不到,但 tcpdump -i eth0 仍然能抓到。因为在收包路径上,tcpdump 站在 netfilter 之前。

三、网络包发送过程中的抓包

发包路径和收包路径刚好反过来。发包时,包先从用户进程进入协议栈,经过 TCP/IP、路由、netfilter 等处理后,才到网络设备层,最后交给驱动和网卡发送。

粗略顺序如下:

用户进程 send/write
  -> TCP/UDP 传输层
  -> IP 网络层
  -> 路由选择
  -> netfilter OUTPUT / POSTROUTING
  -> 邻居子系统
  -> 网络设备层
  -> 驱动
  -> 网卡发送

发送过程总体如图。

图中的关键点是:发送方向里,netfilter 位于 tcpdump 抓包点之前。如果包在 OUTPUTPOSTROUTING 等位置被 netfilter 丢弃,它就不会继续向下走到网络设备层,因此 tcpdump 也抓不到。

这和接收方向正好相反:

  • 收包:网络设备层先看到包,netfilter 后过滤,所以被过滤的入方向包仍可能被 tcpdump 抓到。
  • 发包:netfilter 先过滤,网络设备层后看到包,所以被过滤的出方向包通常抓不到。

发送路径中的抓包源码在 net/core/dev.c

在较新的内核中,发送路径会在 xmit_one 中检查是否存在 packet socket 监听者:

if (dev_nit_active_rcu(dev))
    dev_queue_xmit_nit(skb, dev);

旧版本代码里也能看到类似逻辑:在 dev_hard_start_xmit 或相关发送入口处,如果 ptype_all 非空,就调用 dev_queue_xmit_nit

dev_queue_xmit_nit 的作用是把即将发出的包递交给网络监听者。它会遍历 ptype_all,并通过 deliver_skb 调用对应回调。对 tcpdump 来说,这个回调仍然是 packet_rcv

发送方向的抓包链路可以串成:

send/write
  -> TCP/IP 协议栈
  -> netfilter OUTPUT / POSTROUTING
  -> 网络设备层 xmit_one/dev_hard_start_xmit
  -> dev_queue_xmit_nit
  -> 遍历 ptype_all
  -> packet_rcv
  -> tcpdump 读取
  -> 驱动真正发送

注意,这里说的是从协议栈正常发出的包。还有一些特殊场景,例如 XDP、硬件 offload、虚拟交换、抓错 interface、容器网络命名空间不同,都可能影响你实际能看到的包。但这节课要掌握的主线是:tcpdump 的经典抓包点在网络设备层,和 netfilter 的先后顺序决定了能否抓到被过滤的包。

四、抓包实验程序

理解了上面的原理后,自己写一个最小抓包程序就不神秘了。核心就是创建一个 packet socket,然后不断从这个 socket 里读取包。

实验源码主要内容如下图。

关键代码可以概括为三步:

sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));

while (1) {
    len = recvfrom(sock, buffer, BUFFER_MAX, 0, NULL, NULL);

    mac_hdr = (struct ethhdr *)buffer;
    ip_hdr = (struct iphdr *)(buffer + sizeof(struct ethhdr));
}

第一步创建 PF_PACKET + SOCK_RAW + ETH_P_ALL socket。这会触发前面分析过的 packet_create -> dev_add_pack -> ptype_all 注册过程。

第二步调用 recvfrom。它读取的不是普通 TCP socket 的应用数据,而是 packet socket 接收队列里的原始链路层帧。前面 packet_rcv 已经把匹配到的 skb 放进了这个 socket 的 sk_receive_queue,所以用户态能读到。

第三步按协议头解析 buffer。因为使用的是 SOCK_RAW packet socket,读到的数据从二层以太网头开始。因此:

  • buffer 起始位置可以当作 struct ethhdr,读取源 MAC、目的 MAC、以太网类型。
  • buffer + sizeof(struct ethhdr) 后面通常是 IP 头,可以按 struct iphdr 解析源 IP、目的 IP、协议号。

运行结果如下。

输出里第一段和第二段分别展示了两个方向的 TCP 包:

  • “源 MAC -> 目的 MAC”:来自以太网头。
  • “源 IP -> 目的 IP”:来自 IP 头。
  • “协议类型 TCP”:来自 IP 头里的 protocol 字段。
  • “截获内容长度”:是本次从 packet socket 读到的帧长度。

这个 demo 只解析了最基础的以太网头和 IP 头,没有处理 VLAN、IPv6、IP 分片、TCP/UDP 端口、变长 IP 头等情况。它的价值在于证明抓包的最小闭环:只要创建 packet socket,内核就会把网络设备层观察到的包送进这个 socket,用户态程序就能读取和解析。

五、容易混淆的点

1. AF_PACKET/PF_PACKETAF_INET/PF_INET

AF_INET/PF_INET 面向 IPv4 协议栈,普通 TCP/UDP socket 都走这里。应用看到的是协议栈处理后的数据。

AF_PACKET/PF_PACKET 面向链路层/设备层,能接触更原始的二层帧。tcpdump 抓包依赖的是这个能力。

2. packet socket 的 SOCK_RAW 和 IPv4 raw socket 不是一回事

socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) 是 packet socket,能看到链路层头。

socket(AF_INET, SOCK_RAW, protocol) 是 IPv4 raw socket,工作在 IP 层附近,不包含普通以太网二层头。两者都叫 raw,但层级不同。

3. 为什么 ETH_P_ALLhtons

packet socket 的第三个参数是以太网协议号,并且要求是网络字节序。ETH_P_ALL 本身是主机字节序常量,所以要写成:

htons(ETH_P_ALL)

这也是为什么 strace 里看到的不是 3,而是 768

4. ptype_allptype_base

ptype_all 用于“我想看所有协议”的监听者,例如 ETH_P_ALL 的 tcpdump。

ptype_base 用于按具体协议类型分发,例如只关心 IPv4、ARP 等特定以太网类型的处理逻辑。

可以粗略理解为:ptype_all 更像旁路抓包链表,ptype_base 更像正常协议分发入口。但二者都属于网络设备层的 packet type 机制。

5. tcpdump 看到包,不等于应用层收到包

收包时,tcpdump 在网络设备层就可能看到包。这个包后面还可能因为 netfilter、路由、本机端口不存在、TCP 状态不匹配等原因被丢弃。

所以排查网络问题时,如果 tcpdump 能看到包,只能说明包到达了这个抓包点;不能直接说明应用已经收到。

另外,打开 tcpdump 一般不会改变正常协议栈的主流程。它主要增加的是旁路观察开销:内核要额外遍历 ptype_all,把匹配的包送进 packet socket 的接收队列。如果抓包程序读得太慢,丢的通常是抓包队列里的包,不等于业务 socket 一定丢包。

6. 收包和发包方向上,tcpdump 与 netfilter 的相对位置相反

这是本节最重要的判断规则:

收包:网卡/驱动 -> 网络设备层 tcpdump -> IP 层 netfilter -> 协议栈
发包:协议栈 -> IP 层 netfilter -> 网络设备层 tcpdump -> 驱动/网卡

因此:

  • 入方向被 netfilter 丢弃的包,tcpdump 通常仍能抓到。
  • 出方向被 netfilter 丢弃的包,tcpdump 通常抓不到。

六、本文参考与延伸