原文地址 cloud.tencent.com
最近使用 tcpdump 的时候突然想到这个问题。因为我之前只存在一些一知半解的认识:比如直接镜像了网卡的包、在数据包进入内核前就获取了。但这些认识真的正确么?针对...
最近使用 tcpdump 的时候突然想到这个问题。因为我之前只存在一些一知半解的认识:比如直接镜像了网卡的包、在数据包进入内核前就获取了。但这些认识真的正确么?针对这个问题,我进行了一番学习探究。
先说结论:通过 PF_PACKET 这个特殊的套接字协议,直接接收来自链路层的帧。数据包并非没有进入内核,而是在进入内核后直接跳过了内核中三层 / 四层的协议栈,直达套接字接口,被应用层的 tcpdump 所使用。实际上,在网卡驱动程序通知内核接受到数据帧的时候,数据包就已经进入了内核处理流程。具体的区别,可以见下图。
内核网络协议栈示意图
先来看看,普通的套接字的收包路径在内核中是怎么样。
以最常见的以太网网卡,当网卡接口接收到了一个帧,那么接受者知道它一定包含了一个 Ethernet 报头。封包在协议栈向上传递过程中,一定会在报头中包含一个字段,指出下一阶段的处理应该使用哪一个协议。 以太网卡拥有特定的 MAC 地址,在监听数据帧的时候,当看到帧的目的 MAC 地址与自己的地址或者链路层广播地址(FF:FF:FF:FF:FF:FF)相匹配,就会通过 DMA 把该帧读取到内存中的 ring buffer。
当一个数据帧被写入到内存后,将产生一个硬件中断请求,以通知 CPU 收到了数据包。操作系统为了减少硬中断产生的次数,会采用一个软中断 (softirq) 唤醒 NAPI 子系统。这样会产生一个单独的线程,调用网卡驱动注册的 poll 方法收包,同时禁止网卡产生新的硬中断,这样的效果便是一次中断可以接收多个包。一旦软终端代码判断有 softirq 处于 pending 状态,便会调用软终端处理函数 net_rx_action。
中断处理函数会在处理循环中调用 NAPI poll 来接收数据包。poll 方法会分配一个 sk_buff 数据结构(include/linux/skbuff.h),表示该数据包的内核视图。然后将数据从缓冲区提取到新建的 sk_buff 中,并对其中的 protocol 字段做初始化,该字段用以识别特定的协议。之后这个字段会被 netif_receive_skb 内核函数查询,用来确定该执行哪个函数来处理三层的封包。字段涉及协议的值都列在了 include/uapi/linux/if_ether.h 中,名字形如 ETH_P_XXX,比如 ip 协议为 ETH_P_IP。而有一种特殊情况,单一封包可以传递给多个处理函数,这就是 tcpdump 等网络嗅探应用会用到的 ETH_P_ALL。
软终端处理循环的最后是通过 netif_receive_skb 函数将将数据交给 TCP/IP 协议栈的。它会从数据包包头中取出协议信息,然后遍历注册在这个协议上的回调函数列表。这里的列表值得一提,分别是 ptype_all 和 ptype_base。他们是 hash table 数据结构,分别对应通用数据包(ETH_P_ALL 类型)和特定协议的数据包(ETH_P_XXX 类型),其中存放着指向对应协议处理函数的指针,当收到该类型的数据包时便调用对应的处理函数。
因此,以 IP 数据包为例,当 ETH_P_IP 类型数据包出队后,软中断处理程序 net_rx_action 最终会在 ptype_base 列表中找到 IP 协议的处理函数 ip_rcv() 并调用它,完成数据包向上提交到协议栈。这里略过 IP 协议栈的处理过程,简而言之,在经过 IP 数据包完整性校验、Netfilter 子系统(iptables 的底层实现)、路由子系统等等一些列流程之后,开始准备送往高层协议。这里的处理和 net_rx_action 很相似,从 IP 数据包头部提取出协议类型后,通过名为 inet_protos 的哈希来寻找高层协议的处理函数,每个高层协议都对应一个处理函数,型如 tcp_v4_rcv(), udp_rcv() 等。
四层协议以较为简单的 UDP 为例,udp_rcv 会对 udp 包进行合法性校验,然后查找是否有愿意接收此数据包的套接字,如果找到,__udp_queue_rcv_skb 会将包放到 socket 的接收队列。最后,所有在这个 socket 上等待数据的进程都会收到通过 sk_data_ready 函数处理的通知。
以上是一个数据包穿越协议栈到达 socket 的简要过程,实际的内核处理过程会复杂的多,这里只是做简要的描述。以引入本文的主角:PF_PACKET 协议数据包在内核中的处理路径。
当创建 PF_PACKET 套接字时,与协议相关的数据包类型将被同时注册进 ptype_all 和 ptype_base,接受函数为 packet_rcb()。此时,net_rx_action 函数会拦截所有进入机器的包,并同样通过 netif_receive_skb 函数遍历 ptype_all 后,传递给 PF_PACKET 接受函数。值得一提的是,tcpdump 依赖的 libpcap 库并非使用原始套接字 + recvfrom 的方式收包,而是在内核空间分配一块内核缓冲区,然后用户空间调用 mmap 系统调用映射到用户空间。
¶参考资料
Monitoring and Tuning the Linux Networking Stack: Receiving Data
Inside the Linux Packet Filter
《深入理解 Linux 网络技术内幕》