PF_PACKET环形接收缓存
套接口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
函数。
1 | sock->ops = &packet_ops; |
对应的用户态socket系统调用如下.
工作模式1
第一个socket系统调用的domain和type组合内核已经废弃,但是为了向后兼容,内核检测到之后,将首个参数PF_INET替换为PF_PACKET,这两个socket系统调用其实完全一致。
1 | socket(PF_INET, SOCK_PACKET, htons(ETH_P_ALL)) : |
工作模式2
后一种模式有如下两种系统调用,SOCK_DGRAM
套接口在往用户层上送数据包时,会剥掉物理头部数据(MAC header)
,而SOCK_RAW
套接口上送完整的数据包。
1 | socket(PF_PACKET, SOCK_DGRAM, 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暂不讨论。
1 | struct tpacket_req { |
用户层通过setsockopt(PACKET_RX_RING/PACKET_TX_RING
)设置环形buffer
参数,内核函数packet_set_ring
进行处理,并对这4个字段的合法性检查,来看一下其中的要求和关联。
- 内存块大小
tp_block_size
必须按照页面大小对齐,即必须是页面大小的整数倍;每个内存块至少要能够容纳一个数据包;另外,tp_block_size
的大小要求是页面大小的2的指数倍(2,4,8倍);
1 | if (unlikely((int)req->tp_block_size <= 0)) goto out; |
- 内存块数量
tp_block_nr
不能占用超过UINT_MAX
大小的内存;
1 | if (unlikely(req->tp_block_size > UINT_MAX / req->tp_block_nr)) goto out; |
数据包大小
tp_frame_size
必须是16字节TPACKET_ALIGNMENT
对齐;必须大于TPACKET
头部信息的长度;1
2if (unlikely(req->tp_frame_size < po->tp_hdrlen + po->tp_reserve)) goto out;
if (unlikely(req->tp_frame_size & (TPACKET_ALIGNMENT - 1))) goto out;内存块数量
tp_block_nr
乘以每个内存块容纳的数据帧数目,应该等于数据包的总数tp_frame_nr
。
1 | 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
为上送到用户层的数据的起始位置。
1 | if (sk->sk_type != SOCK_DGRAM) |
接下来需要确定新接收到的数据帧应当放入共享环形buffer
的哪个位置?由函数packet_lookup_frame
计算得到。参数position
为保存在环形buffer
中的可用帧空间的头索引(rx_ring.head)
,根据此索引,计算得到页面索引(内存块索引)和帧偏移,即得到可用来保存数据帧的地址(h.raw)
。
1 | static void *packet_lookup_frame(struct packet_sock *po, ...) |
函数packet_increment_head用来增加可用帧空间头索引head,对于我们的环形buffer,在头索引head到达最大值后,从0开始下一次循环。
1 | buff->head = buff->head != buff->frame_max ? buff->head+1 : 0; |
接下来就可以拷贝数据包到找到的帧空间了(skb_copy_bits)
,拷贝snaplen
长度的数据到帧空间中macoff
偏移开始的空间。macoff
之前的空间还要保存两个类型的结构体,分别是tpacket_hdr
(根据TPACKET_VERSION
选择不同版本的头部结构)和sockaddr_ll
结构体,依次填充这两项信息。至此数据帧接收完成。
1 | 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
已满的情况下出现。