套接口PF_PACKET目前有两种工作模式,以(SOCK_PACKET)类别运行的模式;和以(SOCK_DGRAM/SOCK_RAW)类别运行的模式。前者为传统的方式,在内核和用户层拷贝数据包,并且兼容老内核的数据包抓取接口(参考以下介绍);后者为前者的替代类型,而且可以通过设置共享内存的方式,在内核与用户层交换数据,节省内存拷贝的消耗。以下内容主要介绍后一种模式的共享内存方式。
PACKET套接口创建
内核函数packet_create处理PF_PACKET套接口的创建工作。其参数sock->type决定了采用哪一种工作模式,如果参数type为SOCK_PACKET即第一种模式,type为SOCK_DGRAM或者SOCK_RAW即为第二种模式。两种模式内核会赋予不同的操作函数集合和数据包接收函数,例如后者使用packet_ops函数集,而前者使用packet_ops_spkt函数集。接收函数一个为packet_rcv,一个为packet_rcv_spkt函数。
sock->ops = &packet_ops;
if (sock->type == SOCK_PACKET)
sock->ops = &packet_ops_spkt;
po->prot_hook.func = packet_rcv;
if (sock->type == SOCK_PACKET)
po->prot_hook.func = packet_rcv_spkt;
对应的用户态socket系统调用如下,前两个为第一种工作模式。第一个socket系统调用的domain和type组合内核已经废弃,但是为了向后兼容,内核检测到之后,将首个参数PF_INET替换为PF_PACKET,这两个socket系统调用其实完全一致。
socket(PF_INET, SOCK_PACKET, htons(ETH_P_ALL)) :
socket(PF_PACKET, SOCK_PACKET, htons(ETH_P_ALL));
后一种模式有如下两种系统调用,SOCK_DGRAM套接口在往用户层上送数据包时,会剥掉物理头部数据(MAC header),而SOCK_RAW套接口上送完整的数据包。
socket(PF_PACKET, SOCK_DGRAM, htons(ETH_P_ALL)) :
socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
SOCK_PACKET与SOCK_RAW工作模式相近,都是与用户层交互完整的数据包(包括物理头部数据),区别在于前者使用结构sockaddr_pkt表示套接口地址,后者使用结构sockaddr_ll表示套接口地址。
套接口接收选项
类型为SOCK_DGRAM/SOCK_RAW的PF_PACKET套接口,除了普通的在内核与用户层间拷贝数据包的方式外,还可通过setsockopt系统调用设置环形接收buffer,通过mmap与应用层共享这部分内存。这样就可省去拷贝操作,但是数据包的套接口地址信息就不能通过recvfrom/recvmsg调用送到用户层,内核需将这部分信息和数据包拼接在一起,另外,数据包的一些信息如时间戳、VLAN等和环形buffer管理信息也需要在内核与用户态交互,所以还需要一个结构,为此内核定义了TPACKET_HAEDER结构存储这些信息。如果通过setsockopt系统调用使能了PACKET_VNET_HDR选项,还有一个virtio_net_hdr结构,如下数据帧空间buffer中一个数据包相关的所有信息块如下:
目前TPACKET_HEADER有三个版本,每个版本的长度略有不同,用户层可使用setsockopt(PACKET_VERSION)设置需要的版本,另外也可通过getsockopt(PACKET_HDRLEN)获取到每个版本对应的头部长度,设置环形接收buffer需要此长度值。
enum tpacket_versions {
TPACKET_V1,
TPACKET_V2,
TPACKET_V3
};
int val = TPACKET_V3;
setsockopt(sk, SOL_PACKET, PACKET_VERSION, &val, sizeof(val))
getsockopt(sk, SOL_PACKET, PACKET_HDRLEN, &val, &len)
对于版本1和2,不论接收还是发送的环形buffer,需要配置4个参数:分别为内存块的大小和数量、每个数据包的大小和数据包总数。版本3暂不讨论。
struct tpacket_req {
unsigned int tp_block_size; /* Minimal size of contiguous block */
unsigned int tp_block_nr; /* Number of blocks */
unsigned int tp_frame_size; /* Size of frame */
unsigned int tp_frame_nr; /* Total number of frames */
};
用户层通过setsockopt(PACKET_RX_RING/PACKET_TX_RING)设置环形buffer参数,内核函数packet_set_ring进行处理,并对这4个字段的合法性检查,来看一下其中的要求和关联。
1)内存块大小tp_block_size必须按照页面大小对其,即必须是页面大小的整数倍;每个内存块至少要能够容纳一个数据包;另外,tp_block_size的大小要求是页面大小的2的指数倍(2,4,8倍);
if (unlikely((int)req->tp_block_size <= 0)) goto out;
if (unlikely(!PAGE_ALIGNED(req->tp_block_size))) goto out;
rb->frames_per_block = req->tp_block_size/req->tp_frame_size;
if (unlikely(rb->frames_per_block <= 0))
goto out;
order = get_order(req->tp_block_size);
2)内存块数量tp_block_nr不能太大,所有内存块不能占用超过UINT_MAX大小的内存;
if (unlikely(req->tp_block_size > UINT_MAX / req->tp_block_nr)) goto out;
3)数据包大小tp_frame_size必须是16字节(TPACKET_ALIGNMENT)对其;不能太小,必须大于TPACKET头部信息的长度;
if (unlikely(req->tp_frame_size < po->tp_hdrlen + po->tp_reserve)) goto out;
if (unlikely(req->tp_frame_size & (TPACKET_ALIGNMENT - 1))) goto out;
4)内存块数量tp_block_nr乘以每个内存块容纳的数据帧数目,应该等于数据包的总数tp_frame_nr。
if (unlikely((rb->frames_per_block * req->tp_block_nr) != req->tp_frame_nr)) goto out;
合法性检查通过后,内核根据tp_block_size和tp_block_nr分配相应的存储页面,并将相关信息保持在packet_sock套接口的成员rx_ring(packet_ring_buffer)结构体中。最后,更改数据包接收函数为tpacket_rcv,其处理环形buffer接收数据包功能。
po->prot_hook.func = (po->rx_ring.pg_vec) ? tpacket_rcv : packet_rcv;
register_prot_hook(sk);
用户层要访问内核的接收环形buffer,需要通过mmap将其映射到用户空间;
mmapbuf = mmap(0, mmapbuflen, PROT_READ|PROT_WRITE, MAP_SHARED, sk, 0);
接收数据帧
内核函数tpacket_rcv负责数据帧的接收工作。对于SOCK_DGRAM类型的套接口,当其接收到的是本机发出的数据帧的时候,跳过物理头部,将skb的data指针指到网络头。对于SOCK_RAW而言,需要将物理头部上送用户层,将skb的data指针外推到MAC头部。data为上送到用户层的数据的起始位置。
if (sk->sk_type != SOCK_DGRAM)
skb_push(skb, skb->data - skb_mac_header(skb));
else if (skb->pkt_type == PACKET_OUTGOING) {
/* Special case: outgoing packets have ll header at head */
skb_pull(skb, skb_network_offset(skb));
}
接下来需要确定新接收到的数据帧应当放入共享环形buffer的哪个位置?由函数packet_lookup_frame计算得到。参数position为保存在环形buffer中的可用帧空间的头索引(rx_ring.head),根据此索引,计算得到页面索引(内存块索引)和帧偏移,即得到可用来保存数据帧的地址(h.raw)。
static void *packet_lookup_frame(struct packet_sock *po, ...)
{
pg_vec_pos = position / rb->frames_per_block;
frame_offset = position % rb->frames_per_block;
h.raw = rb->pg_vec[pg_vec_pos].buffer + (frame_offset * rb->frame_size);
if (status != __packet_get_status(po, h.raw)) return NULL;
}
函数packet_increment_head用来增加可用帧空间头索引head,对于我们的环形buffer,在头索引head到达最大值后,从0开始下一次循环。
buff->head = buff->head != buff->frame_max ? buff->head+1 : 0;
接下来就可以拷贝数据包到找到的帧空间了(skb_copy_bits),拷贝snaplen长度的数据到帧空间中macoff偏移开始的空间。macoff之前的空间还要保存两个类型的结构体,分别是tpacket_hdr(根据TPACKET_VERSION选择不同版本的头部结构)和sockaddr_ll结构体,依次填充这两项信息。至此数据帧接收完成。
skb_copy_bits(skb, 0, h.raw + macoff, snaplen);
最后,关注一下内核与用户层在操作环形buffer时的同步实现,参见tpacket_hdr字段中的tp_status字段,此字段的第一个bit位来实现功能,当前为0时(TP_STATUS_KERNEL)标识内核在使用此段数据帧空间,反之,为1时(TP_STATUS_USER)标识用户层面在使用此段空间。前面介绍的内核使用packet_lookup_frame函数查找可用的数据帧空间,找到之后使用函数__packet_get_status来判断一下此段空间是否可用,tp_status等于TP_STATUS_KERNEL可正常使用,否则,说明用户层还没有处理此段空间内的数据帧,通常在环形buffer已满的情况下出现。
内核在填充完数据帧空间之后,将tp_status的同步位设置为TP_STATUS_USER,同时调用sk->sk_data_ready(sk)通知用户层数据已准备好。
内核版本
Linux-4.15