Linux原始套接字实现分析

时间:2021-05-14 11:00:03
本文从IPV4协议栈原始套 接字的 分类入手,详细 介绍了链路层和网络层原始套接字的特点及其内核实现细节。并结合原始套接字的实际应用, 说明各类型原始套接字的适应范围,以及在实际使用时需要注意的问题。 一、原始套接字概述

协议栈的原始套接字从实现上可以分为“链路层原始套接字”和“网络层原始套接字”两大类。本节主要描述各自的特点及其适用范围。

链路层原始套接字可以直接用于接收和发送链路层的MAC帧,在发送时需要由调用者自行构造和封装MAC首部。而网络层原始套接字可以直接用于接收和发送IP层的报文数据,在发送时需要自行构造IP报文头(取决是否设置IP_HDRINCL选项)。

1.1  链路层原始套接字

链路层原始套接字调用socket()函数创建。第一个参数指定协议族类型为PF_PACKET,第二个参数type可以设置为SOCK_RAW或SOCK_DGRAM,第三个参数是协议类型(该参数只对报文接收有意义)。协议类型protocol不同取值的意义具体见表1所示:

  1. socket(PF_PACKET, type, htons(protocol))

      

a)       参数type设置为SOCK_RAW时,套接字接收和发送的数据都是从MAC首部开始的。在发送时需要由调用者从MAC首部开始构造和封装报文数据。type设置为SOCK_RAW的情况应用是比较多的,因为某些项目会使用到自定义的二层报文类型。

  1. socket(PF_PACKET, SOCK_RAW, htons(protocol))

Linux原始套接字实现分析

b)      参数type设置为SOCK_DGRAM时,套接字接收到的数据报文会将MAC首部去掉。同时在发送时也不需要再手动构造MAC首部,只需要从IP首部(或ARP首部,取决于封装的报文类型)开始构造即可,而MAC首部的填充由内核实现的。若对于MAC首部不关心的场景,可以使用这种类型,这种用法用得比较少。

  1. socket(PF_PACKET, SOCK_DGRAM, htons(protocol))

      Linux原始套接字实现分析

 

表1  protocol不同取值

protocol

作用

ETH_P_ALL

 0x0003

报收本机收到的所有二层报文

ETH_P_IP

0x0800

报收本机收到的所有IP报文

ETH_P_ARP

0x0806

报收本机收到的所有ARP报文

ETH_P_RARP

0x8035

报收本机收到的所有RARP报文

自定义协议

比如0x0810

报收本机收到的所有类型为0x0810的二层报文

不指定

0

不能用于接收,只用于发送

……

……

……

表1中protocol的取值中有两个值是比较特殊的。当protocol为ETH_P_ALL时,表示能够接收本机收到的所有二层报文(包括IP, ARP, 自定义二层报文等),同时这种类型套接字还能够将外发的报文再收回来。当protocol为0时,表示该套接字不能用于接收报文,只能用于发送。具体的实现细节在2.2节中会详细介绍。

 

1.2   网络层原始套接字

创建面向连接的TCP和创建面向无连接的UDP套接字,在接收和发送时只能操作数据部分,而不能对IP首部或TCP和UDP首部进行操作。如果想要操作IP首部或传输层协议首部,就需要调用如下socket()函数创建网络层原始套接字。第一个参数指定协议族的类型为PF_INET,第二个参数为SOCK_RAW,第三个参数protocol为协议类型(不同取值的意义见表2)。产品线有使用OSPF和RSVP等协议,需要使用这种类型的套接字。

  1. socktet(PF_INET, SOCK_RAW, protocol)
   

a)       接收报文

网络层原始套接字接收到的报文数据是从IP首部开始的,即接收到的数据包含了IP首部, TCP/UDP/ICMP等首部, 以及数据部分。

      Linux原始套接字实现分析

b)      发送报文

网络层原始套接字发送的报文数据,在默认情况下是从IP首部之后开始的,即需要由调用者自行构造和封装TCP/UDP等协议首部。

Linux原始套接字实现分析

这种套接字也提供了发送时从IP首部开始构造数据的功能,通过setsockopt()给套接字设置上IP_HDRINCL选项,就需要在发送时自行构造IP首部。

   

  1. int val = 1;
  2. setsockopt (sockfd, IPPROTO_IP, IP_HDRINCL, &val, sizeof(val));

表2  protocol不同取

protocol

作用

IPPROTO_TCP

6

报收TCP类型的报文

IPPROTO_UDP

17

报收UDP类型的报文

IPPROTO_ICMP

1

报收ICMP类型的报文

IPPROTO_IGMP

2

报收IGMP类型的报文

IPPROTO_RAW

255

不能用于接收,只用于发送(需要构造IP首部)

OSPF

89

接收协议号为89的报文

……

……

……

表2中protocol取值为IPPROTO_RAW是比较特殊的,表示套接字不能用于接收,只能用于发送(且发送时需要从IP首部开始构造报文)。具体的实现细节在2.3节中会详细介绍。

 

二、原始套接字实现

本节主要首先介绍链路层和网络层原始套接字报文的收发总体流程,再分别对两类套接字的创建、接收、发送等具体实现细节进行介绍。

 

2.1   原始套接字报文收发流程

Linux原始套接字实现分析

图1  原始套接字收发流程

 

如上图1所示为链路层和网络层原始套接字的收发总体流程。网卡驱动收到报文后在软中断上下文中由netif_receive_skb()处理,匹配是否有注册的链路层原始套接字,若匹配上就通过skb_clone()来克隆报文,并将报文交给相应的原始套接字。对于IP报文,在协议栈的ip_local_deliver_finish()函数中会匹配是否有注册的网络层原始套接字,若匹配上就通过skb_clone()克隆报文并交给相应的原始套接字来处理。

注意:这里只是将报文克隆一份交给原始套接字,而该报文还是会继续走后续的协议栈处理流程。

      链路层原始套接字的发送,直接由套接字层调用packet_sendmsg()函数,最终再调用网卡驱动的发送函数。网络层原始套接字的发送实现要相对复杂一些,由套接字层调用inet_sendmsg()->raw_sendmsg(),再经过路由和邻居子系统的处理后,最终调用网卡驱动的发送函数。若注册了ETH_P_ALL类型套接字,还需要将外发报文再收回去。

 

2.2   链路层原始套接字的实现 2.2.1   套接字创建

调用socket()函数创建套接字的流程如下,链路层原始套接字最终由packet_create()创建。

sys_socket()->sock_create()->__sock_create()->packet_create()

    当socket()函数的第三个参数protocol为非零的情况下,会调用dev_add_pack()将链路层套接字packet_sock的packet_type结构链到ptype_all链表或ptype_base链表中。    

  1. void dev_add_pack(struct packet_type *pt)
  2. {
  3.         ……
  4.         if (pt->type == htons(ETH_P_ALL)) {
  5.                 netdev_nit++;
  6.                 list_add_rcu(&pt->list, &ptype_all);
  7.         } else {
  8.                 hash = ntohs(pt->type) & 15;
  9.                 list_add_rcu(&pt->list, &ptype_base[hash]);
  10.         }
  11.         ……
  12. }

    当protocol为ETH_P_ALL时,会将套接字加入到ptype_all链表中。如图2所示,这里创建了两个链路层原始套接字。

Linux原始套接字实现分析

图2  ptype_all链表

当protocol为其它非0值时,会将套接字加入到ptype_base链表中。如图3所示,协议栈本身也需要注册packet_type结构,图中浅色的两个packet_type结构分别是IP协议和ARP协议注册的,其处理函数分别为ip_rcv()和arp_rcv()。图中另外3个深色的packet_type结构则是链路层原始套接字注册的,分别用于接收类型为ETH_P_IP、ETH_P_ARP和0x0810类型的报文。

Linux原始套接字实现分析

图3  ptype_base链表

 

2.2.2   报文接收

网卡驱动程序接收到报文后,在软中断上下文由netif_receive_skb()处理。首先会逐个遍历ptype_all链表中的packet_type结构,若满足条件“(!ptype->dev || ptype->dev == skb->dev)”,即套接字未绑定或者套接字绑定网口与skb所在网口匹配,就增加报文引用计数并交给packet_rcv()函数处理(若使用PACKET_MMAP收包方式则由tpacket_rcv()函数处理)。

网卡驱动->netif_receive_skb()->deliver_skb()->packet_rcv()/tpacket_rcv()

    以非PACKET_MMAP收包方式为例进行说明,packet_rcv()函数中比较重要的代码片段如下。当报文skb到达packet_rcv()函数时,其skb->data所指的数据是不包含MAC首部的,所以对于type为非SOCK_DGRAM(即SOCK_RAW)类型,需要将skb->data指针前移,以便数据部分可以包含MAC首部。最后将skb放到套接字的接收队列sk->sk_receive_queue中,并唤醒用户态进程来读取套接字中的数据。

  1. ……
  2. if (sk->sk_type != SOCK_DGRAM) //即SOCK_RAW类型
  3.         skb_push(skb, skb->data - skb->mac.raw);
  4. ……
  5. __skb_queue_tail(&sk->sk_receive_queue, skb);
  6. sk->sk_data_ready(sk, skb->len); //唤醒进程读取数据
  7. ……

PACKET_MMAP收包方式的实现有所不同,tpacket_rcv()函数将skb->data拷贝到与用户态mmap映射的共享内存中,最后唤醒用户态进程来读取数据。由于报文的内容已存放在内核空间和用户空间共享的缓冲区中,用户态可以直接读取以减少数据的拷贝,所以这种方式效率比较高。

    上面介绍了报文接收在软中断的处理流程。下面以非PACKET_MMAP收包方式为例,介绍用户态读取报文数据的流程。用户态recvmsg()最终调用skb_recv_datagram(),如果套接字接收队列sk->sk_receive_queue中有报文就取skb并返回。否则调用wait_for_packet()等待,直到内核软中断收到报文并唤醒用户态进程。

sys_recvmsg()->sock_recvmsg()->…->packet_recvmsg()->skb_recv_datagram()

 

2.2.3   报文发送

用户态调用sendto()或sendmsg()发送报文的内核态处理流程如下,由套接字层最终会调用到packet_sendmsg()。

sys_sendto()->sock_sendmsg()->__sock_sendmsg()->packet_sendmsg()->dev_queue_xmit()

    该函数比较重要的函数片段如下。首先进行参数检查及skb分配,再调用驱动程序的hard_header函数(对于以太网驱动是eth_header()函数)来构造报文的MAC头部,此时的skb->data是指向MAC首部的,且skb->len为MAC首部长度(即14)。对于创建时指定type为SOCK_RAW类型套接字,由于在发送时需要自行构造MAC头部,所以将skb->tail指针恢复到MAC首部开始的位置,并将skb->len设置为0(即不使用内核构造的MAC首部)。接着再调用memcpy_fromiovec()从skb->tail的位置开始拷贝报文数据,最终调用网卡驱动的发送函数将报文发送出去。

注:如果创建套接字时指定type为SOCK_DGRAM,则使用内核构造的MAC首部,用户态发送的数据中不含MAC头部数据。

        

  1. ……
  2. res = dev->hard_header(skb, dev, ntohs(proto), addr, NULL, len); //构造MAC首部
  3. if (sock->type != SOCK_DGRAM) {
  4.         skb->tail = skb->data; //SOCK_RAW类型
  5.         skb->len = 0;
  6. }
  7. ……
  8. err = memcpy_fromiovec(skb_put(skb,len), msg->msg_iov, len); //拷贝报文数据
  9. ……
  10. err = dev_queue_xmit(skb); //发送报文
  11. ……

 

2.2.4  其它

a)         套接字的绑定

链路层原始套接字可调用bind()函数进行绑定,让packet_type结构dev字段指向相应的net_device结构,即将套接字绑定到相应的网口上。如2.2.2节报文接收的描述,在接收时如果套接口有绑定就需要进一步确认当前skb->dev是否与绑定网口相匹配,只有匹配的才会将报文上送到相应的套接字。

sys_bind()->packet_bind()->packet_do_bind()

b)        套接字选项

以下是比较常用的套接字选项

PACKET_RX_RING:用于PACKET_MMAP收包方式设置接收环形队列

PACKET_STATISTICS:用于读取收包统计信息

c)       信息查看

链路层原始套接字的信息可通过/proc/net/packet进行查看。如下为图2和图3中创建的原始套接字的信息,可以查看到创建时指定的协议类型、是否绑定网口、已使用的接收缓存大小等信息。这些信息对于分析和定位问题有帮助。

  1. cat /proc/net/packet
  2. sk RefCnt Type Proto Iface R Rmem User Inode
  3. ffff810007df8400 3 3 0810 0 1 0 0 1310
  4. ffff810007df8800 3 3 0806 0 1 0 0 1309
  5. ffff810007df8c00 3 3 0800 0 1 560 0 1308
  6. ffff810007df8000 3 3 0003 0 1 560 0 1307
  7. ffff810007df3800 3 3 0003 0 1 560 0 1306

 

2.3   网络层原始套接字的实现 2.3.1   套接字创建

如图4所示,在IPV4协议栈中一个传输层协议(如TCP,UDP,UDP-Lite等)对应一个inet_protosw结构,而inet_protosw结构中又包含了proto_ops结构和proto结构。网络子系统初始化时将所有的inet_protosw结构hash到全局的inetsw[]数组中。proto_ops结构实现的是从与协议无关的套接口层到协议相关的传输层的转接,而proto结构又将传输层映射到网络层。

Linux原始套接字实现分析

图4  inetsw[]数组结构

    调用socket()函数创建套接字的流程如下,网络层原始套接字最终由inet_create()创建。

sys_socket()->sock_create()->__sock_create()->inet_create()

    inet_create()函数除用于创建网络层原始套接字外,还用于创建TCP、UDP套接字。首先根据socket()函数的第二个参数(即SOCK_RAW)在inetsw[]数组中匹配到相应的inet_protosw结构。并将套接字结构的ops设置为inet_sockraw_ops,将套接字结构的sk_prot设置为raw_prot。然后对于SOCK_RAW类型套接字,还要将inet->num设置为协议类型,以便最后能调用proto结构的hash函数(即raw_v4_hash())。

        

  1. ……
  2. sock->ops = answer->ops; //将socket结构的ops设置为inet_sockraw_ops
  3. answer_prot = answer->prot;
  4. ……
  5. if (SOCK_RAW == sock->type) { //SOCK_RAW类型的套接字,设置inet->num
  6.         inet->num = protocol;
  7.         if (IPPROTO_RAW == protocol) //protocol为IPPROTO_RAW的特殊处理,
  8.                 inet->hdrincl = 1; 后续在报文发送时会再讲到
  9. }
  10. ……
  11. if (inet->num) {
  12.         inet->sport = htons(inet->num);
  13.         sk->sk_prot->hash(sk); //调用raw_v4_hash()函数将套接字链到raw_v4_htable中
  14. }
  15. ……

 

经过如上操作后,相应的套接字结构sock会通过raw_v4_hash()函数链到raw_v4_htable链表中,网络层原始套接字报文接收时需要使用到raw_v4_htable。如图5所示,共创建了3个网络层原始套接字,协议类型分别为IPPROTO_TCP、IPPROTO_ICMP和89。

Linux原始套接字实现分析

图5  raw_v4_htable链表

 

2.3.2   报文接收

网卡驱动收到报文后在软中断上下文由netif_receive_skb()处理,对于IP报文且目的地址为本机的会由ip_rcv()最终调用ip_local_deliver_finish()函数。ip_local_deliver_finish()主要功能的代码片段如下,先根据报文的L4层协议类型hash值在图5中的raw_v4_htable表中查找是否有匹配的sock。如果有匹配的sock结构,就进一步调用raw_v4_input()处理网络层原始套接字。不管是否有原始套接字要处理,该报文都会走后续的协议栈处理流程。即会继续匹配inet_protos[]数组,根据L4层协议类型走TCP、UDP、ICMP等不同处理流程。

        

  1. ……
  2. hash = protocol & (MAX_INET_PROTOS - 1); //根据报文协议类型取hash值
  3. raw_sk = sk_head(&raw_v4_htable[hash]); //在raw_v4_htable中查找
  4. ……
  5. if (raw_sk && !raw_v4_input(skb, skb->nh.iph, hash)) //处理原始套接字
  6. ……
  7. if ((ipprot = rcu_dereference(inet_protos[hash])) != NULL) { //匹配inet_protos[]数组
  8.         ……
  9.         ret = ipprot->handler(skb); //调用传输层处理函数
  10.         ……
  11. } else { //如果在inet_protos[]数组中未匹配到,则释放报文
  12.         ……
  13.         kfree_skb(skb);
  14. }
  15. ……

 

如图6所示的inet_protos[]数组,每项由net_protocol结构组成。表示一个协议(包括传输层协议和网络层附属协议)的接收处理函数集,一般包括一个正常接收函数和一个出错接收函数。图中TCP、UDP和ICMP协议的接收处理函数分别为tcp_v4_rcv()、udp_rcv()和icmp_rcv()。如果在inet_protos[]数组中未配置到相应的net_protocol结构,报文就会被丢弃掉。比如OSPF报文(协议类型为89)在inet_protos[]数组中没有相应的项,内核会将其丢弃掉,这种报文只能提供网络层原始套接字接收到用户态来处理。

Linux原始套接字实现分析

图6  inet_protos[]数组结构

    网络层原始套接字的总体接收流程如下,最终会将skb挂到相应套接字上,并唤醒用户态进程读取报文数据。

网卡驱动->netif_receive_skb()->ip_rcv()->ip_rcv_finish()->ip_local_deliver()->ip_local

_deliver_finish()->raw_v4_input()->raw_rcv()->raw_rcv_skb()->sock_queue_rcv_skb()


  1. ……
  2. skb_queue_tail(&sk->sk_receive_queue, skb); //挂到接收队列
  3. if (!sock_flag(sk, SOCK_DEAD))
  4.         sk->sk_data_ready(sk, skb_len); //唤醒用户态进程
  5. ……

       上面介绍了报文接收在软中断的处理流程,下面介绍用户态进程读取报文是如何实现的。用户态的recvmsg()最终会调用raw_recvmsg(),后者再调用skb_recv_datagram。如果套接字接收队列sk->sk_receive_queue中有报文就取skb并返回。否则调用wait_for_packet()等待,直到内核软中断收到报文并唤醒用户态进程。

sys_recvmsg()->sock_recvmsg()->…->sock_common_recvmsg()->raw_recvmsg()

 

2.3.3   报文发送

用户态调用sendto()或sendmsg()发送报文的内核态处理流程如下,最终由raw_sendmsg()进行发送。

sys_sendto()->sock_sendmsg()->__sock_sendmsg()->inet_sendmsg()->raw_sendmsg()

    此函数先进行一些参数合法性检测,然后调用ip_route_output_slow()进行选路。选路成功后主要执行如下代码片段,根据inet->hdrincl是否设置走不同的流程。raw_send_hdrinc()函数表示用户态发送的数据中需要包含IP首部,即由调用者在发送时自行构造IP首部。如果inet->hdrincl未置位,表示内核会构造IP首部,即调用者发送的数据中不包含IP首部。不管走哪个流程,最终都会经过ip_output()->ip_finish_output()->…->dev_queue_xmit()将报文交给网卡驱动的发送函数发送出去。

 

  1. ……
  2. if (inet->hdrincl) { //调用者要构造IP首部
  3.         err = raw_send_hdrinc(sk, msg->msg_iov, len,
  4.                               rt, msg->msg_flags);
  5. } else {
  6.         …… //由内核构造IP首部
  7.        err = ip_push_pending_frames(sk);
  8. }
  9. ……

   注:inet->hdrincl置位表示用户态发送的数据中要包含IP首部,inet->hdrincl在以下两种情况下被置位。

    a). 给套接字设置IP_HDRINCL选项

          setsockopt (sockfd, IPPROTO_IP, IP_HDRINCL, &val, sizeof(val))

    b). 调用socket()创建套接字时,第三个参数指定为IPPROTO_RAW,见2.3.1节。

          socktet(PF_INET, SOCK_RAW, IPPROTO_RAW)

 

2.3.4   其它

a)       套接字绑定

若原始套接字调用bind()绑定了一个地址,则该套接口只能收到目的IP地址与绑定地址相匹配的报文。内核的具体实现是raw_bind(),将inet->rcv_saddr设置为绑定地址。在原始套接字接收时,__raw_v4_lookup()在设置了inet->rcv_saddr字段的情况下,会判断该字段是否与报文目的IP地址相同。

sys_bind()->inet_bind()->raw_bind()

b)      信息查看

网络层原始套接字的信息可通过/proc/net/raw进行查看。如下为图5所创建的3个网络层原始套接字的信息,可以查看到创建套接字时指定的协议类型、绑定的地址、发送和接收队列已使用的缓存大小等信息。这些信息对于分析和定位问题有帮助。

  1. cat /proc/net/raw
  2. sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
  3. 1: 00000000:0001 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 1323 2 ffff8100070b2380
  4. 6: 00000000:0006 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 1322 2 ffff8100070b2080
  5. 89: 00000000:0059 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 1324 2 ffff8100070b2680

 

 三、应用及注意事项

3.1   使用链路层原始套接字

注意事项:

a)       尽量避免创建过多原始套接字,且原始套接字要尽量绑定网卡。因为收到每个报文除了会将其分发给绑定在该网卡上的原始套接字外,还会分发给没有绑定网卡的原始套接字。如果原始套接字较多,一个报文就会在软中断上下文中分发多次,造成处理时间过长。

b)      发包和收包尽量使用同一个原始套接字。如果发包与收包使用两个不同的原始套接字,会由于接收报文时分发多次而影响性能。而且用于发送的那个套接字的接收队列上也会缓存报文,直至达到接收队列大小限制,会造成内存泄露。

c)       若只接收指定类型二层报文,在调用socket()时指定第三个参数的协议类型,而最好不要使用ETH_P_ALL。因为ETH_P_ALL会接收所有类型的报文,而且还会将外发报文收回来,这样就需要做BPF过滤,比较影响性能。

 

3.2  使用网络层原始套接字

注意事项:

a)       由于IP报文的重组是在网络层原始套接字接收流程之前执行的,所以该原始套接字不能接收到UDP和TCP的分组数据。

b)      若原始套接字已由bind()绑定了某个本地IP地址,那么只有目的IP地址与绑定地址匹配的报文,才能递送到这个套接口上。

c)       若原始套接字已由connect()指定了某个远地IP地址,那么只有源IP地址与这个已连接地址匹配的报文,才能递送到这个套接口上。

 

3.3   网络诊断工具使用原始套接字

很多网络诊断工具也是利用原始套接字来实现的,经常会使用到的有tcpdump, ping和traceroute等。

tcpdump

该工具用于截获网口上的报文流量。其实现原理是创建ETH_P_ALL类型的链路层原始套接字,读取和解析报文数据并将信息显示出来。

ping

该工具用于检查网络连接。其实现原理是创建网络层原始套接字,指定协议类型为IPPROTO_ICMP。检测方构造ICMP回射请求报文(类型为ICMP_ECHO),根据ICMP协议实现,被检测方收到该请求报文后会响应一个ICMP回射应答报文(类型为ICMP_ECHOREPLY)。然后检测方通过原始套接字读取并解析应答报文,并显示出序号、TTL等信息。

traceroute

该工具用于跟踪IP报文在网络中的路由过程。其实现原理也是创建网络层原始套接字,指定协议类型为IPPROTO_ICMP。假设从A主机路由到D主机,需要依次经过B主机和C主机。使用traceroute来跟踪A主机到D主机的路由途径,具体步骤如下,在每次探测过程中会显示各节点的IP、时间等信息。

a)       A主机使用普通的UDP套接字向目的主机发送TTL为1(使用套接口选项IP_TTL来修改)的UDP报文;

b)      B主机收到该UDP报文后,由于TTL为1会拒绝转发,并且向A主机发送code为ICMP_EXC_TTL的ICMP报文;

c)       A主机用创建的网络层原始套接字读取并解析ICMP报文。如果ICMP报文code是ICMP_EXC_TTL,就将UDP报文的TTL增加1并回到步骤a)继续进行探测;如果ICMP报文的code是ICMP_PROT_UNREACH,表示UDP报文到达了目的地。

              A主机―>B主机―>C主机―>D主机