那些年我们忽略的socket参数

7次阅读

共计 4689 个字符,预计需要花费 12 分钟才能阅读完成。

调试过网络程序的人大多使用过 tcpdump,那你知道tcpdump 是如何工作的吗?
tcpdump这类工具也被称为 Sniffer,它可以在不影响应用程序正常报文的情况下,将流经网卡的报文 复制 一份给Sniffer,然后经过加工过滤,最后呈现给用户。

本文不分析 tcpdump 的具体实现,而只是借 tcpdump 来揭示一些网络编程中一个大多数人都容易忽略的一个主题:Socket参数对用户接收报文的影响 …

相信所有接触过 Socket 编程的人都应该认识下面这个API

#include <sys/socket.h>
sockfd = socket(int socket_family, int socket_type, int protocol);

没错,它基本是 socket 编程的第一步,创建一个套接字。他有三个参数,不过又有多少人真的去了解这些参数的意义呢?对于 TCP 或者 UDP 应用的开发者来说,他们可以很容易地从互联网上 找(抄)到这样的例子:

/* 创建 TCP socket*/
sockfd = socket(AF_INET, SOCK_STREAM, 0);

/* 创建 UDP socket*/
sockfd = socket(AF_INET, SOCK_DGRAM, 0)

为什么第一个参数要使用 AF_INET, 为什么第二个参数要使用SOCK_STREAM 或者SOCK_DGRAM,为什么第三个参数要填0 ?

socket_family

第一个参数表示创建的 socket 所属的 地址簇 或者 协议簇 ,取值以AF 或者 PF 开头定义在 (include\linux\socket.h),实际使用中并没有区别(有两个不同的名字只是因为是历史上的设计原因)。最常用的取值有AF_INET,AF_PACKET,AF_UNIX 等。AF_UNIX用于主机内部进程间通信,本文暂且不谈。AF_INETAF_PACKET 的区别在于使用 前者只能看到 IP 层以上的东西,而后者可以看到链路层的信息

什么意思呢?为了说明这个问题,我们需要知道网络报文的分类。如下图所示:Ethernet II帧是应用最为广泛的帧类型 (当然也有像PPP 这样的其他链路帧类型)。Ethernet II帧内部,又可大致分为 IP 报文和其他报文。我们熟悉的 TCP 或者 UDP 报文都属于 IP 报文。

AF_INET是与 IP 报文对应的,而 AF_PACKET 则是与 Ethernet II 报文对应的。AF_INET创建的套接字称为 inet socket,而AF_PACKET 创建的套接字称为packet socket

socket_type & protocol

第一个参数 family 会影响第二个参数 socket_type 和第三个参数 protocol 取值范围

第二个参数 socket_type 表示套接字类型。它的取值不多,常见的就以下三种

enum sock_type {SOCK_STREAM    = 1,     /* stream (connection) socket  */
    SOCK_DGRAM    = 2,     /* datagram (conn.less) socket */
    SOCK_RAW    = 3,     /* raw socket                  */
};

第三个参数 protocol 表示套接字上报文的协议。

对于 AF_INET 地址簇,protocol的取值范围是如 IPPROTO_TCP IPPROTO_UDP IPPROTO_ICMP 这样的 IP 报文协议类型,或者 IPPROTO_IP = 0 这个特殊值
对于 AF_PACKET 地址簇,protocol的取值范围是 ETH_P_IP ETH_P_ARP这样的以太帧协议类型。

inet socket 的协议开关表

每一个 inet socket 只能收发一种 IP 协议类型的报文,这是在套接字创建的时候就决定的 (protocol 参数),比如 TCP 套接字是不能收发 UDP 报文的,反之也是一样。并且,protocol的值还受到 socket_type 的限制,不匹配的取值会导致套接字创建操作会返回失败。

/* 错误取值,返回失败 */
sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_TCP);

内核通过 协议开关表 记录了哪些哪些取值是有效的,inet在初始化时会将支持的协议注册在 协议开关表 中的以 socket_typeKEY的链表上:

而在创建套接字时,inet_create会在协议开关表中根据 socket_typeprotocol进行匹配

list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
        err = 0;
        /* Check the non-wild match. */
        if (protocol == answer->protocol) {if (protocol != IPPROTO_IP)
                break;
        } else {
            /* Check for the two wild cases. */
            if (IPPROTO_IP == protocol) {
                protocol = answer->protocol;
                break;
            }
            if (IPPROTO_IP == answer->protocol)
                break;
        }
        err = -EPROTONOSUPPORT;
    }

IPPROTO_IP的值为 0, 在用户使用0 作为创建套接字的第三个参数时,会匹配到该链表上的第一个协议,这正是创建 TCP 或者 UDP 套接字时,第三个参数可以为 0 的原因, 0表示由内核自动选择。··

/* 创建 TCP socket*/
sockfd = socket(AF_INET, SOCK_STREAM, 0);

/* 创建 UDP socket*/
sockfd = socket(AF_INET, SOCK_DGRAM, 0)

raw inet socket

对于 inet socket 来说,一个 TCP 报文可以这样分解:

packet = IP Header + TCP Header +  Payload

如果我们是使用 SOCK_STREAM 创建的 TCP 套接字,应用程序在通过 send 发送数据时,只需要提供 Payload 就行了,而 IP HeaderTCP Header则由内核组装完成。接收方向,应用程序通过 recv 也只能收到payload

RAW 套接字则为应用提供了更底层的控制能力

int s = socket (AF_INET, SOCK_RAW, IPPROTO_TCP);

使用上面的接口可以创建一个更原始的 TCP 套接字,当我们使用这个套接字发送数据时,需要提供 PayloadTCP Header,而 IP Header 依然由内核协议栈自动组装。

如果希望手动组装IP Header,有两个方法:

第一种是 protocol 使用IPPROTO_RAW

int s = socket (AF_INET, SOCK_RAW, IPPROTO_RAW);

第二种是置位 IP_HDRINCL 的套接字选项。

int s = socket (AF_INET, SOCK_RAW, IPPROTO_TCP);

int one = 1;
const int *val = &amp;one;
if (setsockopt (s, IPPROTO_IP, IP_HDRINCL, val, sizeof (one)) &lt; 0)
{printf (&quot;Error setting IP_HDRINCL. Error number : %d . Error message : %s \n&quot; , errno , strerror(errno));
    exit(0);
}

以上两种方法都是告诉内核,IP Header也由应用程序自己提供。

packet socket

inet socket的控制范围是 IP 报文,而 packet socket 的控制范围扩大到了以太层报文

对于 inet socket, 第二个参数socket_type 只能选择 SOCK_DGRAMSOCK_RAW 或者 SOCK_PACKET, protocol 则表示支持的网络层的协议类型。

Protocol Handler

对以太帧来说,不同的网络层协议类型 (比如IP ARP PPPoE) 有不同的接收处理函数。在内核中,这就是Protocol Handler

内核中的 Protocl Handler 是这样组织的

该 patch 将 Protocl Handlerdev下增加了 ptype_all 链表和 ptype_base 链表

无论网卡是否采用 NAPI,内核最终都会调用到__netfi_receive_skb 接收报文,这个函数会遍历 ptype_all 链表上已注册的 handler,然后再遍历ptype_base 特定协议链上的所有已注册的handler

handler的注册是通过 dev_add_pack 完成的, 如果没有指定协议 (ETH_P_ALL),该handler 就会注册在 ptype_all 上(tcpdump默认就会注册在这里),否则根据协议注册在 ptype_base 的某条链表上。

在报文接收过程中,同一个 skb 会被 deliver_skb 到多个 handler(至少将ptype_all 链表上的 handler 走一遍)。

内核启动时,inet会注册一个 handler,它支持IP 协议,所有 AF_INET 套接字实际上是共用这样一个handler,对应的接收函数是ip_rcv,区分是哪一个套接字的报文是之后的工作。

/* net/ipv4/af_inet.c */
static struct packet_type ip_packet_type __read_mostly = {.type = cpu_to_be16(ETH_P_IP),
    .func = ip_rcv,
};

static int __init inet_init(void)
{
    // code omitted
    dev_add_pack(&ip_packet_type);
    // code omitted
}

而对于 AF_PACKEThandler 是在 packet_create 中单独注册的,也就是说,每个 AF_PACKET 套接字拥有独立的handler

static int packet_create(struct net *net, struct socket *sock, int protocol,
             int kern)
{
    // code omitted
    po->prot_hook.func = packet_rcv;   
    // code omitted
    register_prot_hook(sk);  // 这里面去 dev_add_pack
}

单独的 handler,使得在接收函数packet_rcv 的时候,就已经可以知道这是属于哪一个套接字的数据了。

raw packet socket

对于 AF_PACKET 来说,一个报文可以这样分解:

packet = Ethernet Header + Payload

SOCK_DGRAMSOCK_RAW的区别就在于,在接收方向,使用 SOCK_DGRAM 套接字的应用程序收到的报文已经去除了 Ethernet Header,而SOCK_RAW 套接字则会保留。

packet socket 与 tcpdump

回到本文最初的问题,tcpdump是如何完成嗅探工作的呢?没错!它正是使用的packet socket

  • tcpdump作为sniffer,它不能影响正常的报文收发,因此它需要单独的protocol handler,这样内核接收的报文会复制一份后,交给tcpdump
  • tcpdump不止能抓取 IP 报文, 它还可以抓起链路层信息或者其他一些非 IP 报文。

REF

difference-between-pf-inet-sockets-and-pf-packet
data-link-access-and-zero-copy
raw-socket-in-linux
raw-sockets-c-code-linux

正文完
 0