sk_buff结构体是Linux网络协议栈的核心中的核心,几乎所有的操作都是围绕sk_buff这个结构体进行的,它的重要性和BSD的mbuf类似(看过《 TCP/IP详解 卷2》的都知道),sk_buff就是网络数据包本身以及针对它的操作元数据。
sk_buffer简称skb,sk_buffer是Linux网络模块中的核心结构体各个层用到的数据包都存在这个结构体里。 skb内部其实包含了网络协议中所有的 header。比如在设置 TCP HEADER的时候,只是把指针指向 sk_buffer的合适位置。后面再设置 IP HEADER的时候,在把指针移动一下就行,避免频繁的内存申请和拷贝,效率很高。
网络分层模型
这是一切的本质。网络被设计成分层的,所以网络的操作就可以称作一个“栈”,这就是网络协议栈的名称的由来。在具体的操作上,数据包最终形成的过程就是一层一层封装的过程,在栈上形成一段连续的数据,我们可以称作是一层一层的push操作。同样的,数据包的解封装的过程,则可以认为是一层一层的pop操作。
sk_buff的操作.
要想形成一个最终的数据包,即以太帧(不考虑其它的链路层)。要进行以下的操作:
- 分配一个skb结构体
- 分配数据包的数据区
- 在skb数据区定位应用层起始位置
- 拷贝数据到应用层(假设应用层协议没有在socket接口之上被封装)
- 在skb数据区定位传输层起始位置
- 设置传输层头部字段
- 在skb数据区定位IP层起始位置
- 设置IP层头部字段
- 在skb数据区定位以太层起始位置
- 设置以太头部字段
可以看出基本的模式,即“定位/设置”两步骤操作,有点区别的是应用层操作,这是因为应用层的操作一般都是在socket接口之上完成的。但是既然本文讲述的是skb的通用操作,就不再区分这个了。
skb的核心操作
在上面一小节,我们展示了skb的封装逻辑,但是具体到接口层面,就涉及到了skb的核心操作。
-
分配skb
这个是由alloc_skb完成的,完成同一任务的接口形成一个接口族,但是alloc_skb是最基本的接口。该alloc_skb接口完成两件事,即分配skb结构体以及skb数据包缓冲区,设置初始值。size参数表示skb的数据包缓冲区的大小,这个大小包括所有层的总和。如果该函数成功返回,那么就相当于你已经有了一个大小为size的空数据包缓冲区以及操作该数据包缓冲区的skb元数据。如下图所示:
image.png
-
初始定位(skb_reserve)
skb的逐层封装的关键在于写指针的定位,即这一层从哪个位置开始写。从协议封装的压栈形象来看,这个定位应该是顺序有规律的。初始定位十分重要,后面的定位就是例行公事了。初始定位当然是定位到应用层的末端,从这里开始,逐层将协议头push到skb的数据包缓冲区。初始定位图示如下:
image.png
- 拷贝应用层数据(skb_push/copy)
当skb分配好了之后,需要将协议“栈”的位置定位在数据包的“最低处”,这是初始定位,这样才可以把每一层的数据或者协议头push到栈上,这个操作由skb_reserve来完成。应用层数据已经在socket之上封装好了,那么就把skb的数据包缓冲区写指针定位到应用数据的开始处,此时的写指针在应用层缓冲区的末尾,因此需要使用skb_push操作将写指针定位到应用层开始处,这等于说压入了应用层栈帧。
skb_push接口是将一个协议栈帧压入协议栈的接口,它返回一个position,该position就是skb数据包的写指针,告诉调用者,这里开始按照你的封装逻辑封装数据包,写多少字节呢?由skb_push的参数n指示。应用层的压栈操作如下图所示:
将应用层栈帧压入协议栈之后,就可以在写指针位置开始,往后连续写n字节的应用层数据了,一般而言,这些数据来自socket。
- 设置传输层头部
和应用层的操作类似,这次需要把传输层栈帧压入协议栈中,如下图所示:
接下来就可以愉快地在skb_push返回的位置设置传输层头部了,UDP,TCP,就看你对传输层的理解了。设置传输层头部其实就是在skb_push返回的位置开始写数据,写入的长度由skb_push的参数指定,即n。
- 设置IP层头部
和应用层以及传输层操作类似,这次需要把IP层的栈帧压入协议栈中,如下图所示:
接下来就可以愉快地在skb_push返回的位置设置IP层头部了,如何设置,就看你对IP层的理解了。由于只是演示skb如何封装,因此没有涉及IP层相当重要的IP路由过程。
- 设置以太帧头部
这个就不说了,和上述的类似...如下图所示:
到此为止,我封装了一个完整的以太帧,可以直接通过dev_queue_xmit发送的那种。一路下来,你会发现,skb数据包缓冲区以“压栈(push)”的方式逐渐被填充,每一层,都是通过skb_push接口压入一个栈帧,返回写指针,然后按照该层的协议逻辑从写指针开始写入栈帧长度的数据。
在skb_push返回的那一刻,一个栈帧被压入了协议栈,然后该栈帧还仍未被写入数据,也就是说还没有完成封装过程,具体的封装过程由调用者自己实现。
skb_push导致了skb数据包缓冲区写指针位置的前推,连带的改变了好几个变量,首先数据包的长度增加了n个字节,其次缩小了headroom的空间,然后通过reset_XXX_header的调用,skb记住了某层协议头在数据包中的位置(这点特别重要!比如在TSO/UFO的情况下,网卡驱动需要协议头的位置信息,用以计算校验值,所以虽然skb不记住协议头的位置,一个数据包也能完成封装,但是对于协议栈的完整实现而言,却是不正确的做法,毕竟网卡计算校验码已经成了一种事实上的标准[即便它违背了严格的分层原则!])
- 在应用数据后面追加PADDING
目前为止,从最后的图示上可以看到,在skb数据包缓冲区中,还有两块区域没有使用,一个headroom,一个是tailroom,这些是干什么用的呢?作为一个练习的例子,由于存在某种对齐原则,在封装完成后,我需要在数据包的最后追加一些填充,或者说我需要在最前面加一个前导码,或者最常见的,我要在数据包的最后加一个纠错码,此时应该怎么办呢?
这个时候就需要headroom或者tailroom了,以在数据包最后追加数据为例,请看下图:
实际上,skb_put的操作就是,在数据包的末尾追加数据。至于说headroom如何使用,我就不多说了,其实还是skb_push,headroom有什么用呢?前导码,X over Y封装,不一而足。
实际的例子
下面我给出一个实际的例子,封装一个以太帧,然后发送出去:
skb = alloc_skb(1500, GFP_ATOMIC);
skb->dev = dev;
// 例行填充skb元数据
/* 保留skb区域 */
skb_reserve (skb, 2 + sizeof(struct ethhdr) +
sizeof(struct iphdr) +
sizeof(struct udphdr) +
sizeof(app_data));
/* 构造数据区 */
p = skb_push(skb, sizeof(app_data));
memcpy(p, &app_data[0], sizeof(app_data));
p = skb_push(skb, sizeof(struct udphdr));
udphdr = (struct udphdr *)p;
// 填充udphdr字段,略
skb_reset_transport_header(skb);
/* 构造IP头 */
p = skb_push(skb, sizeof(struct iphdr));
iphdr = (struct iphdr*)p;
// 填充iphdr字段,略
skb_reset_network_header(skb);
/* 构造以太头 */
p = skb_push(skb, sizeof(struct ethhdr));
ethhdr = (struct ethhdr*)p;
// 填充ethhdr字段,略
skb_reset_mac_header(skb);
/* 发射 */
dev_queue_xmit(skb);
解封装的过程和封装的过程相反,解封装的过程是协议栈栈帧逐层pop的过程,但是Linux协议栈并没有用栈的术语来定义接口名字,而是使用了push的反义词,即pull来定义的,skb_pull就是核心接口,和skb_push严格相对。我就不再一一画图了。
按照接口编码而不是按照实现编码
这好像是Effective C++里面的一条,同样也适合于skb的操作场景。典型的就是“如何让skb记住IP层协议头,传输层协议头,mac头的位置”,接口是:
skb_reset_mac_header
skb_reset_network_header
skb_reset_transport_header
调用时机为skb_push返回的当时。曾几何时,我按照下面的方式设置了协议头的位置:
/* 构造IP头 */
p = skb_push(skb, sizeof(struct iphdr));
iphdr = (struct iphdr*)p;
// 填充iphdr字段,略
//skb_reset_network_header(skb);
skb->network_header = p;
有错吗?咋一看是没错的,但是却报错了:
protocol 0008 is buggy, dev eth2
这是怎么回事?原因就在于skb纪录的协议头位置是错误的!难道以上的设置skb的network_header字段的方式有何不妥吗?当然不妥!这就是没有按照接口编码的恶果。
原因在于,系统设置skb的network_header字段的方式有两种,通过一个宏来识别:NET_SKBUFF_DATA_USES_OFFSET。也就是说,可以通过相对于skb的head指针的偏移来定位协议头的位置,也可以通过绝对地址来定位,具体使用哪一种取决于系统有没有定义NET_SKBUFF_DATA_USES_OFFSET宏,以上的skb->network_header = p明显是通过绝对地址来定位的,一旦系统定义了NET_SKBUFF_DATA_USES_OFFSET宏,肯定就不对了。既然宏定义在编译期确定,那么通过定义接口就可以在编译期唯一确定一种实现,程序员不必在乎是否定义了NET_SKBUFF_DATA_USES_OFFSET宏,这就是通过接口编程的益处。如果基于skb的实现来编程,你不得不针对所有的情况编写好几套实现,而以上错误的实现只是其中一种,而且还用错了场景!这是多么痛的领悟!
NET_SKBUFF_DATA_USES_OFFSET宏是一个细节问题,如果使用接口编程便不必关注这个细节,否则你就必须搞清楚系统为何这么设计,即便这并不是你所关注的!为何呢?
由于指针的长度大小在32位系统和64位系统中是不一样的,所以按理说skb中的指针型的元数据大小也会不同,且64位系统的将会是32位系统的两倍,为了平滑掉这个差别,使元数据大小一致,就必须让64位系统的对应指针类型变为4个字节,而这是不可能的。因此在64位系统中,使用偏移来定位元数据,而偏移的类型为固定不变的unsigned int,即4个字节。为了支持上述说法,skb中加入了一个新的层次,即定义了一种新的数据类型sk_buff_data_t,该类型在编译期确定:
#if BITS_PER_LONG > 32
#define NET_SKBUFF_DATA_USES_OFFSET 1
#endif
#ifdef NET_SKBUFF_DATA_USES_OFFSET
typedef unsigned int sk_buff_data_t;
#else
typedef unsigned char *sk_buff_data_t;
#endif
节约空间之外,对于和大小相关的操作,接口实现也更加统一。这就是细节,而这些细节并不是玩网络协议栈的人所要关注的,不是吗?这完全是系统实现的层面,和业务逻辑是无关的
内核发送网络包流程
RingBuffer结构详解
关于RingBuffer网上有很多说法,有的人说RingBuffer是系统启动时就预先申请好的一个环形数组,有的人说RingBuffer是在接收或发送数据时才动态申请的一个环形数组,那么到底RingBuffer的结构是怎么样的呢?由下图可以看到
- 从宏观上讲RingBuffer可以笼统的称为【环形数组】
- 从RingBuffer的详细结构上来说,其实一个RingBuffer中包含了【两个环形数组】,这两个【环形数组】是系统启动时就预先分配好的
- 当有数据要接收或者发送时,当数据到达RingBuffer之前便会封装为一个个skb(比如:发送数据时,当数据到达【传输层】,此时就会将【socket发送队列】里的待发送的数据封装为一个个skb(也就是将socket发送队列里的数据copy一份,用skb封装)),此时该skb就是【动态申请】的内存空间。当skb到达RingBuffer的时候,此时会将数组的对应空位置指向这个待发送的skb
- 发送或接收数据都会将要发送的数据封装为一个个skb结构(也就是一个结构体),然后在接收 / 发送链路上都是对这个封装好的skb进行操作,而不是直接操作数据
-
关于【内核使用的指针数组】和【网卡使用的指针数据】
A.【内核使用的指针数组】是内核程序(包括ksoftirqd线程)所使用的数组(比如:内存程序会将skb放入到RingBuffer,此时指的RingBuffer就是RingBuffer中的【内核使用的指针数组】)
B.【网卡使用的指针数据】是网卡发送数据时所使用到的数组(比如:网卡发送数据时会从RingBuffer中获取要发送的数据,此时的RingBuffer指的就是【网卡使用的指针数据】中取skb来进行发送)
image.png
。
传统的数据发送与接收内存拷贝
- 首先由DMA先将要发送的数据从磁盘拷贝到操作系统的page cache中
- 然后由CPU将page cache中的数据拷贝到用户内存中
- 然后再由CPU将数据从用户内存中拷贝到socket缓冲区
- 然后再从socket发送缓冲区中拷贝出数据,然后申请一个skb,将数据挂在这个skb上
A. skb的全称叫做sk_buff,sk_buff缓冲区,是一个维护网络帧结构的双向链表,链表中的每一个元素都是一个网络帧。虽然 TCP/IP 协议栈分了好几层,但上下不同层之间的传递,实际上只需要操作这个数据结构中的指针,而无需进行数据复制。
B. 这里是经过协议栈时做的操作,skb构建完成后就会将这个skb传递给传输层,网络层,直到最后的网卡队列的RingBuffer里都是在对这个skb进行操作 - 最后由DMA将数据(skb)从RingBuffer中取出送到网卡进行发送
可以看到这里第2步和第3步的拷贝明显有点多余了,能不能省掉呢?能,sendfile就是这么干的。
sendfile内存拷贝
- 首先由DMA先将要发送的数据从磁盘拷贝到操作系统的page cache中
- 然后由CPU将page cache中的数据直接拷贝到socket发送缓冲区(这里数据不会到达用户内存了)
- 然后再从socket发送缓冲区中拷贝出数据,然后申请一个skb,将数据挂在这个skb上
这里是经过协议栈时做的操作,skb构建完成后就会将这个skb传递给传输层,网络层,直到最后的网卡队列的RingBuffer里都是在对这个skb进行操作 - 最后由DMA将数据(skb)从RingBuffer中取出送到网卡进行发送
内核发送网络包简单流程
先上一个发送网络包的简单流程,以便于理解内核发送网络包的整体脉络!!!
- 首先是在应用程序里调用【send方法】来将数据发送出去,此时便会触发【系统调用】来发送数据
- 将用户数据从【用户空间】拷贝到【内核空间】,并将数据封装为一个个【skb结构】(skb可以简单理解为一个封装待发送数据的数据结构,在内存层面待发送的数据都是以skb来表示并传递的)
- skb进入协议栈进行处理(分别经过传输层、网络层)
- 然后skb会被传送到网卡【传输队列RingBuffer】里(网卡有多个队列,那么就有多个RingBuffer,并且每个队列对应一个发送队列一个接收队列)
- 网卡将RingBuffer里面的数据真实的发送到网络上
- 当网卡发送完数据后,网卡会向CPU发出一个数据发送完毕的【硬中断】
-
CPU响应该硬中断,找到硬中断处理函数,在硬中断处理函数里会发出软中断,然后由【内核线程ksoftirqd】去响应并处理软中断,【内核线程ksoftirqd】找到该软中断对应的处理函数(网卡驱动启动时注册的),然后进行调用,在处理函数中会清理RingBuffer
image.png
- 内核发送数据流程详解
以一个极简的伪代码表示我们在日常开发中如何发送数据包的,以便于理解内核发送数据包的整个流程
int main(){
// 创建一个socket对象,返回内核中socket的文件描述符(也就是内核socket对象的句柄)
fd = socket(fd,...);
// 该socket绑定IP和端口
bing(fd,....);
// socket监听端口
listen(fd,....);
// 等待客户端连接,用户连接后会在内核中创建代表该客户端的socket对象并且返回该socket的文件描述符cfd
cfd = accept(fd,...);
// 客户端连接后,处理用户请求
dosomething();
// 向客户端发送数据, cfd表示该客户端的socket对象的文件描述符,buf表示要发送的数据
// 【内核发送数据包流程就体现在这里】
send(cfd, buf, sizeof(buf), 0);
}
如果是用Java里的NIO来编写的话,大致代码流程如下:
public static void main() throws Exception {
// 创建服务端的socketchannel
ServerSocketChannel ssc = ServerSocketChannel.open();
// 设置这个ServerSocketChannel为阻塞(当有客户端连接请求并且和客户端建立连接成功后,下面的accept才继续向下执行)
ssc.configureBlocking(true);
// 阻塞接收客户端连接,客户端连接成功后会返回sc
SocketChannel sc = ssc.accept();
// 设置该客户端为阻塞的,方便演示
sc.configureBlocking(true);
// 申请一个byteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 填充要发送的数据到byteBuffer中
buffer.put("hello world.".getBytes());
// 向该客户端发送数据,此时的发送过程就由内核来处理了
int write = sc.write(buffer);
}
image.png
- 用户进程调用send方法开始发送数据,最终会调用到内核的sendto方法
- 在【sendto方法】里会根据传入的【socket句柄】找到【内核中的socket对象】(比如:Java nio里通过Java的socket对象找到内核中对应的socket对象),内核socket对象里记录着各种协议栈的函数地址。然后构造出struct msghdr对象,将用户空间中待发送的数据全部封装在这个struct msghdr结构体中。然后调用内核协议栈函数inet_sendmsg,发送流程进入内核协议栈处理。在进入到内核协议栈之后,内核会找到Socket上的具体协议的发送函数。在发送函数里会创建内核数据结构sk_buffer,将struct msghdr结构体中的发送数据拷贝到sk_buffer中。调用tcp_write_queue_tail函数获取Socket发送队列中的队尾元素,将新创建的sk_buffer添加到【内核socket对象的发送队列尾部】。发送流程走到这里,数据终于才从用户空间拷贝到内核空间了。但是此时能不能继续发送数据还得通过条件判断!
- 【socket对象的发送队列】其实就是由sk_buffer组成的一个双向链表
注意此时如果不满足发送条件(比如数据没有达到TCP滑动时间窗口的一半),则用户进程将数据拷贝到【socket的发送队列】里他的工作就算做完了,此时【内核socket对象的发送队列】里的数据会等到合适的时机进行发送
这里的满足条件是:socket发送缓冲区中未发送的数据是否已经超过TCP最大窗口的一半了,若满足则继续调用传输层tcp_write_xmit函数进行处理,反之用户进程将数据拷贝到【socket发送队列】就算完了
调用传输层(也就是调用传输层的实现方法,不要想的太神秘),在传输层的方法里会将【socket发送队列里的skb】拷贝一个skb副本(这里为什么要拷贝呢???),此处对【skb副本】还会进行如下处理:
A. TCP滑动时间窗口管理、拥塞控制
B. 给副本skb数据设置上TCP头
C. 为什么不直接使用Socket发送队列中的sk_buffer而是需要拷贝一份呢?因为TCP协议是支持丢包重传的(可靠的传输协议),在没有收到对端的ACK之前,这个sk_buffer是不能删除的。内核每次调用网卡发送数据的时候,实际上传递的是sk_buffer的拷贝副本,当网卡把数据发送出去后,sk_buffer拷贝副本会被释放。当收到对端的ACK之后,Socket发送队列中的sk_buffer才会被真正删除。 - 调用网络层(也就是在传输层的实现方法里调用网络层的实现方法),将skb由传输层传递给网络层进行处理(其实就是调用方法然后通过方法参数传递skb而已),网络层主要会进行如下处理:
A. 查找路由项,从本机路由表里通过目标IP查找路由项(总得知道要发到哪儿吧)
B. 执行netfilter过滤(比如:我们可以用iptables设置一些过滤规则,以过滤一些某些IP发来的或发送给某些IP的数据包)
C. 给待发送的skb数据设置IP头(这就是我们经常说的类似于一层层包快递)
D. 此时,如果该skb的数据大小超过了【MTU】那么还会将该skb进行【分片处理】(也就是将skb数据拆分为多个skb,使得每个skb的大小都小于 MTU)。所以这里也是一个性能优化点,比如QQ会尽量控制一个skb的大小小于MTU,以节省skb分片的消耗,以及当skb被分为多个片后,只要其中一个片传输失败了,那么该skb的所有的分片都需要重传 - 调用到【邻居子系统】,该步主要是发送【ARP请求】,获取Mac地址
A. 邻居子系统位于内核协议栈中的网络层和网络接口层之间,用于发送ARP请求获取MAC地址,然后将sk_buffer中的指针移动到MAC头位置,填充MAC头。
B. 经过【邻居子系统】的处理,现在sk_buffer中已经封装了一个完整的数据帧,随后内核将【sk_buffer】交给网络设备子系统进行处理 - 调用到【网络设备子系统】,该步主要是选择网卡发送队列(网卡可能有多个队列),然后将skb放入到网卡的发送队列。这一步有可能会中断,然后将执行的进程由【用户进程】变为【内核进程】:
A. 如果满足条件,则由用户进程继续向下执行,调用图示的dev_hard_start_xmit方法继续执行
B. 如果不满足条件,则会触发一个软中断,后续由响应该软中断的【内核ksoftirqd线程】来调用dev_hard_start_xmit方法继续执行
C. 这里可以看到【用户进程并不是调用完send就阻塞住了】,然后将一切工作交给内核线程帮忙处理。而是这个用户进程由用户态切换到了内核态然后继续执行的。我们常说的【Java中无法直接操作内核空间或硬件(比如控制鼠标)】,这个不是说我们的这个Java进程一旦要访问内核空间了就会将这个进程给park掉,然后委托操作系统进程进行处理。而是说虽然我们Java程序中没有办法直接访问内核空间(需要调用内核提供的接口),但是我们的进程可以执行内核代码呀,运行内核相关代码的时候可以由我们的进程进行运行呀。。。 - 调用到网卡驱动程序,选取可用的RingBuffer位置(也就是RingBuffer中的数组里选取可用的位置),关联该位置和skb。然后【网卡驱动程序】通过DMA方式将数据通过【物理网卡】发送出去
- 当数据发送完毕后会触发一个【硬中断】,CPU响应该硬中断,吊起网卡驱动启动时向内核注册的该硬中断对应的处理函数,执行处理函数,在处理函数中最终会发起一个【软中断】
-
【内核线程ksoftirqd】响应并处理该软中断,该线程会吊起网卡启动时注册的【该类型的软中断】(有很多类型的软中断,他们都对应不同的处理函数)的处理函数,执行处理函数,在处理函数中会执行如下操作:
A. 释放掉RingBuffer中的数组对skb的引用(注意:此时RingBuffer里的数组虽然放弃了对skb的引用,但是该skb并不会被立即清除,因为TCP有重传机制,必须要保证收到了对方的ack应答后再彻底删除该skb,如果没有收到对方的ack,那么传输层还可以重传该skb)
B. 清理RingBuffer(以便于下次使用)
image.png
性能开销分析
- 应用程序调用send方法时,线程会从【用户态切换到内核态】,这里有一次上下文切换的开销
- sendto方法会构建struct msghdr结构体,将要发送的数据都封装到struct msghdr结构体里,并且最终将struct msghdr结构体里的数据拷贝封装为一个个的【sk_buffer对象】并且将这个【sk_buffer对象】挂到socket的发送队列的尾部,这里有一次数据拷贝的开销(数据从用户空间拷贝到内核空间)
- 到达协议栈后(比如传输层的tcp_sendmsg函数),会将【socket发送队列】里的【sk_buffer对象】拷贝一个副本(为了保证TCP的可靠传输(也就是失败重传)),并将这个副本sk_buffer对象向后传递,这里有一次内核空间的拷贝开销
- 在网络层,如果sk_buffer对象的大小超过了MTU,则还会将【sk_buffer对象】进行拆分,拆分为多个大小小于MTU的【sk_buffer对象】(将原来的sk_buffer里的数据拷贝到小的sk_buffer对象里)
- 在第六步,选择网卡发送队列RingBuffer时,如果用户线程内核态CPU quota用尽时会触发NET_TX_SOFTIRQ类型软中断,内核线程响应软中断的开销,并且由内核线程来继续调用dev_hard_start_xmit函数
- 在【网络设备子系统】中将sk_buffer挂到网卡发送队列RingBuffer中时,此时只是操作RingBuffer里的对应的指针,将RingBuffer里的空闲位置的指针指向待发送的sk_buffer即可,这里没有拷贝开销
- 网卡发送完数据,向CPU发送硬中断,CPU响应硬中断的开销。以及在硬中断中发送NET_RX_SOFTIRQ软中断执行具体的RingBuffer内存清理动作。内核响应软中断的开销。
所以,在网络数据发送流程中,一共发送了【一次上下文太态的切换】,两次数据拷贝(一次用户空间到内核空间,一次内核空间到内核空间的拷贝),如果skb大于MTU的话还有数据切分开销,两次硬中断和两次软中断开销
三、跨机网络通信总流程
image.png
网友评论