传输
传输是指报文离开本地到另外一台主机,可以由L4层或转发调用。在此之前,内核需要做的准备包括:
- 查询路由子系统下一跳
- 初始化IP头
- 处理选项
- 分段
- 校验和
传输相关重要函数
ip_queue_xmit L4协议吧PMTU考虑后,已经把数据切成合适大小的段,IP层只要加上IP头。
ip_push_pengding_frames 调用该函数的L4协议不考虑IP分片相关操作。
ip_append_data用来存储几个传输请求而不传输任何东西。使得IP层更容易处理分片,提高性能。当L4必须刷新数据时调用ip_push_pengding_frames ,此函数对IP报文进行必要的分片,将分片后所有的报文向下传递给dst_output。
相关数据结构
Linux套接字使用struct sock 描述。协议簇专用的数据结构会嵌套sock结构,比如PF_INET套接字这个结构使用struct inet_sock 进行描述,这个结构的第一个字段就是struct sock,其余的是PF_INET私有信息。
这样给定一个sock结构后,IP层可以使用强制类型转换指向struct inet_sock结构。
inet_sock结构的cork字段在ip_append_data函数中存储函数实现分片功能时的信息。包括IP报文头的选项(如果有的话)以及片段长度。
struct inet_sock {
struct sock sk;
...
struct inet_cork_full cork;
struct rtable路由表缓存项目,包含诸如外出设备、外出设备的MTU以及下一跳网关相关信息。
ip_queue_xmit
该函数由TCP和SCTP使用的函数。
如果报文已经设定路由信息,SCTP协议处理时可能会这么做。
/* Skip all of this if the packet is already routed,
* f.e. by something like SCTP.
*/
rcu_read_lock();
inet_opt = rcu_dereference(inet->inet_opt);
fl4 = &fl->u.ip4;
rt = skb_rtable(skb);
if (rt)
goto packet_routed;
__sk_dst_check函数检查套接字结构中是否已经缓存了一个路径,如果有该路径检查该路径是否有效。
如果没有有效路径就需要使用ip_route_output_ports函数寻找一个路径。并将结果存到sock数据结构中供下次传输时直接使用。
最后将查询到的路由结果保存到skbuff结构中。
/* Make sure we can route this packet. */
rt = (struct rtable *)__sk_dst_check(sk, 0);
if (!rt) {
__be32 daddr;
/* Use correct destination address if we have options. */
daddr = inet->inet_daddr;
if (inet_opt && inet_opt->opt.srr)
daddr = inet_opt->opt.faddr;
/* If this fails, retransmit mechanism of transport layer will
* keep trying until route appears or the connection times
* itself out.
*/
rt = ip_route_output_ports(net, fl4, sk,
daddr, inet->inet_saddr,
inet->inet_dport,
inet->inet_sport,
sk->sk_protocol,
RT_CONN_FLAGS_TOS(sk, tos),
sk->sk_bound_dev_if);
if (IS_ERR(rt))
goto no_route;
sk_setup_caps(sk, &rt->dst);
}
skb_dst_set_noref(skb, &rt->dst);
首先检查来源路由选项如果是严格的检查目的IP是否和选项中的一致。
在调用ip_queue_xmit时skb->data指向L3载荷数据的开始处,我们向前移动指针,skbuff中头部预留有空间。然后为IP头初始化字段、构造option数据。
ip_select_ident_segs函数负责选取IP id
ip_local_out函数中计算校验和并最终调用dst_output函数。
packet_routed:
if (inet_opt && inet_opt->opt.is_strictroute && rt->rt_uses_gateway)
goto no_route;
/* OK, we know where to send it, allocate and build IP header. */
skb_push(skb, sizeof(struct iphdr) + (inet_opt ? inet_opt->opt.optlen : 0));
skb_reset_network_header(skb);
iph = ip_hdr(skb);
*((__be16 *)iph) = htons((4 << 12) | (5 << 8) | (tos & 0xff));
if (ip_dont_fragment(sk, &rt->dst) && !skb->ignore_df)
iph->frag_off = htons(IP_DF);
else
iph->frag_off = 0;
iph->ttl = ip_select_ttl(inet, &rt->dst);
iph->protocol = sk->sk_protocol;
ip_copy_addrs(iph, fl4);
/* Transport layer set skb->h.foo itself. */
if (inet_opt && inet_opt->opt.optlen) {
iph->ihl += inet_opt->opt.optlen >> 2;
ip_options_build(skb, &inet_opt->opt, inet->inet_daddr, rt, 0);
}
ip_select_ident_segs(net, skb, sk,
skb_shinfo(skb)->gso_segs ?: 1);
/* TODO : should we use skb->sk here instead of sk ? */
skb->priority = sk->sk_priority;
skb->mark = sk->sk_mark;
res = ip_local_out(net, sk, skb);
rcu_read_unlock();
return res;
ip_append_data函数
这个函数提供给L4用于把数据暂存在缓冲区中,此函数并不传输数据。多次调用该函数可以让L4数据尽可能多的放在缓冲区中,知道PMTU尺寸,然后依次传送,可以提高效率。
ip_append_data函数任务:
- 把L4输入数据组成缓冲区,易于处理IP分段工作,把这些数据放到缓冲区中还要考虑让L3和L2增加底层协议头。
- 优化内存分片,如果上层指定马上还有更多请求,MSG_MORE标志置为,分配大一些的缓冲区。如果出口设备支持分散/聚集IO,可以优化。
- 处理L4校验和。
/*
- ip_append_data() and ip_append_page() can make one large IP datagram
- from many pieces of data. Each pieces will be holded on the socket
- until ip_push_pending_frames() is called. Each piece can be a page
- or non-page data.
- - Not only UDP, other transport protocols - e.g. raw sockets - can use
- this interface potentially.
- - LATER: length must be adjusted by pad at tail, when it is required.
*/
int ip_append_data(struct sock *sk, struct flowi4 *fl4,
int getfrag(void *from, char *to, int offset, int len,
int odd, struct sk_buff *skb),
void *from, int length, int transhdrlen,
struct ipcm_cookie *ipc, struct rtable **rtp,
unsigned int flags)
from参数指向要传输数据指针,该指针指向用户空间数据,getfrag参数处理L4层的数据。
flags包含一些标志
- MSG_MORE L4 马上有更多数据传输
- MSG_DONTWAIT 函数调用不可以阻塞。当内存资源无法分片时,不可以阻塞
- MSG_PROBE 探测路径。
内存分配和缓冲区组织
ip_append_data可以建立一个或者多个sk_buff,每个sk_buff代表一个IP报文。这些sk_buff会以链表的形式放到struct sock结构的sk_write_queue下,同时将数据复制到sk_buff中时要讲L4协议下层协议头空间预留出来。
ip_append_data数据大于PMTU时,函数就会建立多个sk_buff,其中除了最后一个sk_buff外其他的都达到PMTU尺寸。最后一个如果参数flags指定了MSG_MORE 选项,就将最后一个sk_buff也分配PMTU尺寸的内存,否则只分配可以容纳最后剩余数据的内存即可。
聚集IO/分散IO内存分配和缓冲区组织
L3层不管L4层数据放置的缓冲区,让设备把这些缓冲区结合起来做传输,可以减少分配内存和拷贝数据的消耗。
加上L4连续产生很多小的数据项,L4层将数据存在不同的缓冲区中,L3传输前要讲这些数据拷贝在一起形成一个报文,如果设备支持该特性就可以不复制数据。
分散/聚集IO第一个缓冲区需要复制数据建立sk_buff结构,之后的数据片段存储在frags数组中。frags数据中每一项是skb_frags_t结构,结构中存储由指向存储数据的内存页面。每个加入内存页面的数据片段都会增加页面的引用计数,从而使一个页面可以存储多个IP报文的数据。
struct sock结构中保存有上次传输数据使用的page指针和偏移量,可以再下次传输时复用这个page。
typedef struct skb_frag_struct skb_frag_t;
struct skb_frag_struct {
struct {
struct page *p;
} page;
#if (BITS_PER_LONG > 32) || (PAGE_SIZE >= 65536)
__u32 page_offset;
__u32 size;
#else
__u16 page_offset;
__u16 size;
#endif
};
frags数组里数据时该sk_buff报文的存储地方,frags_list数据代表的是另外一个IP报文的数据。
sk_write_queue
每当ip_append_data分配一个新的sk_buff就将它放入sk_write_queue队列,稍后函数为数据加入IP头信息即可往下传输。
只有当sk_write_queue最后一个元素的数据长度达到maxfraglen时才会建立新元素。
只有第一个片段必须包含传输报文头和选用的外部报文头(外部报文头是由IPsec套件协议里使用的报文头),sk_write_queue为空说明是第一个报文,初始化exthdrlen。
transhdrlen!=0说明ip_append_data工作在第一个片段
transhdrlen =0说明ip_append_data没有工作在第一个片段
在决定要把多少数据拷贝到每个IP报文时,第一个报文需要包含L4报文和选用的外部报头,有效载荷空间较少。
hh_len代表L2报文头的长度。
fragheaderlen代表IP头的长度
maxnonfragsize 如果忽略不允许分片设置为64K否则为mtu大小。
skb = skb_peek_tail(queue);
exthdrlen = !skb ? rt->dst.header_len : 0;
mtu = cork->gso_size ? IP_MAX_MTU : cork->fragsize;
paged = !!cork->gso_size;
if (cork->tx_flags & SKBTX_ANY_SW_TSTAMP &&
sk->sk_tsflags & SOF_TIMESTAMPING_OPT_ID)
tskey = sk->sk_tskey++;
hh_len = LL_RESERVED_SPACE(rt->dst.dev);
fragheaderlen = sizeof(struct iphdr) + (opt ? opt->optlen : 0);
maxfraglen = ((mtu - fragheaderlen) & ~7) + fragheaderlen;
maxnonfragsize = ip_sk_ignore_df(sk) ? 0xFFFF : mtu;
if (cork->length + length > maxnonfragsize - fragheaderlen) {
ip_local_error(sk, EMSGSIZE, fl4->daddr, inet->inet_dport,
mtu - (opt ? opt->optlen : 0));
return -EMSGSIZE;
}
getfrag
ip_append_data函数的任务是把输入数据拷贝到创建的片段中,不同协议可能对数据实现不同的操作,比如L4校验和计算。另外有些本地产生的报文数据在用户空间,而转发报文或者内核产生的报文在内核空间中。基于以上的理由ip_append_data函数使用调用者传入的getfrag函数作为实际拷贝数据的函数。常用的getfrag函数:
协议 | 函数 |
---|---|
ICMP | icmp_gluebits |
UDP | ip_generic_getfrag |
RAW IP | ip_generic_getfrag |
TCP | ip_reply_glue_bits |
ICMP使用的icmp_gluebits 由内核产生,所以传输的数据在内核的内存中。
UDP和RAW IP发出sendmsg系统调用,内核最后会调用ip_append_data函数,数据来自用户空间。ip_generic_getfrag作为getfrag指针指向的地址传入函数,我们查看该函数可以发现,这个函数使用函数copy_from_iter_full或者csum_and_copy_from_iter_full拷贝数据,具体使用哪个取决于L4校验和使用硬件计算还是软件计算。
int
ip_generic_getfrag(void *from, char *to, int offset, int len, int odd, struct sk_buff *skb)
{
struct msghdr *msg = from;
if (skb->ip_summed == CHECKSUM_PARTIAL) {
if (!copy_from_iter_full(to, len, &msg->msg_iter))
return -EFAULT;
} else {
__wsum csum = 0;
if (!csum_and_copy_from_iter_full(to, len, &csum, &msg->msg_iter))
return -EFAULT;
skb->csum = csum_block_add(skb->csum, csum, odd);
}
return 0;
}
缓冲区分配
ip_append_data分配缓冲区取决于:
- 一次传输还是多次传输,如果设定MSG_MORE标记,就分配大一点的内存。
- 分散/聚集IO,如果设备可以分散聚集IO,片段可以更加有效率的存储到内存页面。
fraggap ,除了最后一个缓冲区,所有的片段都必须遵循IP片段的有效载荷必须是8整数倍。因此当内核分配一个新缓冲区不是给最后一个片段使用的时,可能必须要从前一个缓冲区内尾端移动一端数据到新分配缓冲区的头部,是前一个缓冲区的头部是8字节的长度。
alloc_new_skb:
skb_prev = skb;
if (skb_prev)
fraggap = skb_prev->len - maxfraglen;
else
fraggap = 0;
/*
* If remaining data exceeds the mtu,
* we know we need more fragment(s).
*/
datalen = length + fraggap;
if (datalen > mtu - fragheaderlen)
datalen = maxfraglen - fragheaderlen;
fraglen = datalen + fragheaderlen;
pagedlen = 0;
if ((flags & MSG_MORE) &&
!(rt->dst.dev->features&NETIF_F_SG))
alloclen = mtu;
else if (!paged)
alloclen = fraglen;
else {
alloclen = min_t(int, fraglen, MAX_HEADER);
pagedlen = fraglen - alloclen;
}
ip_append_data在一个while循环中将要发送的数据拷贝到sk_buff中,直到所有数据拷贝完成。
当sk_write_queue为空,也就是第一个分片时或者最后一个分片填满时,分配新的sk_buff。
while (length > 0) {
/* Check if the remaining data fits into current packet. */
copy = mtu - skb->len;
if (copy < length)
copy = maxfraglen - skb->len;
if (copy <= 0) {
char *data;
unsigned int datalen;
unsigned int fraglen;
unsigned int fraggap;
unsigned int alloclen;
unsigned int pagedlen;
struct sk_buff *skb_prev;
ip_append_page函数
内核提供一个接口给应用空间程序:send_file,这个函数允许应用程序优化传输,零拷贝TCP/UDP。
只有出口设备支持分散/聚集IO时才可以使用send_file接口。在这种情况下,ip_append_page不需要拷贝任何数据,内核只需要初始化sk_buff中的frag向量即可,然后必要时处理L4校验和就可以了。
一个新片段加入一个页面时,ip_append_page首先尝试吧新片段和已存在于页面中的前一个片段合并起来,
ip_append_page函数
当L4要把sock->sk_write_queue中的报文打包起来传输时就会调用ip_push_penging_frames
ip_finish_skb函数会把第一个sk_buff之后的所有缓冲区都排一个frag_list链表中,并更新该链表头部缓冲区的len和data_len字段。然后释放sk_write_queue链表,L4层认定数据已经传输。现在数据已经流转到L3层的掌控。
ip_send_skb函数负责调用dst_output函数。
int ip_push_pending_frames(struct sock *sk, struct flowi4 *fl4)
{
struct sk_buff *skb;
skb = ip_finish_skb(sk, fl4);
if (!skb)
return 0;
/* Netfilter gets whole the not fragmented skb. */
return ip_send_skb(sock_net(sk), skb);
}
ip_finish_skb函数中完成两个任务
- 将sk_write_queue链表整理成frag_list
- 构建IP头信息注意此处只有frag_list链表第一个元素的IP头被初始化。
RAW套接字
使用RAW套接字可以把包含IP层数据直接发送出去,为此应该使用setsockopt函数设置IP_HDRINCL选项。当设定了这个选项时,RAW IP直接调用dst_output函数。