原始套接口与ICMP

前言

网络程序设计的原始套接口编程部分,主要总结了ICMP的基本知识以及实现Ping和Traceroute

原始套接口

什么是原始套接口

基于传输层协议的数据交互,向程序员屏蔽了TCP、UDP和IP包的具体格式,简化了工作,限制了应用程序对通信协议的支持范围,降低了用户对数据的操作能力,影响了编程的灵活性,但允许对较低层协议(如IP或ICMP)进行直接访问,常用于检验新的网络协议实现,也可用于测试新配置或安装的网络设备。

原始套接口的定义

sockfd=socket(AF_INET,SOCK_RAW,protocol);

socket第2个参数type设置为SOCK_RAW

常用的protocol

  1. IPPROTO_ICMP:使用ICMP协议工作的原始套接口
  2. IPPROTO_IGMP:使用IGMP协议工作的原始套接口
  3. IPPROTO_IP:可以接收内核到达的任何类型的IP数据报

注意:只有root用户才能创建原始套接口,防止普通用户往网络写出自行构造的IP数据包

虽然原始套接口也能完成数据的通信,但是因其没有使用用来标识进程的端口号,所以原始套接口的数据报只能与相应主机上的协议(内核软件)进行交互,因此,原始套接口的交互就是IP协议之间的交互.当然也可以是IP层其他协议间的交互如ICMP

ICMP

什么是ICMP协议

在IP通信中,经常有数据包到达不了对方的情况。原因是,在通信途中的某处的一个路由器由于不能处理所有的数据包,就将数据包一个一个丢弃了。或者,虽然到达了对方,但是由于搞错了端口号,服务器软件可能不能接受它。这时,在错误发生的现场,为了联络而飞过来的信鸽就是ICMP 报文。在IP 网络上,由于数据包被丢弃等原因,为了控制将必要的信息传递给发信方。ICMP 协议是为了辅助IP 协议,交换各种各样的控制信息而被制造出来的。

ICMP 的内容是放在IP 数据包的数据部分里来互相交流的。也就是,从ICMP的报文格式来说,ICMP 是IP 的上层协议。但是,正如RFC 所记载的,ICMP 是分担了IP 的一部分功能。所以,被认为是与IP 同层的协议。

806469-20180306123940403-1730998630.png

ICMP的报文分为两类,一类是ICMP询问报文,一类是ICMP差错报告报文

图片1.png

图片2.png

ICMP的报头如下

180924150424623.jpg

类型: 占一字节,标识ICMP报文的类型,目前已定义了14种,从类型值来看ICMP报文可以分为两大类。第一类是取值为1~127的差错报文,第2类是取值128以上的信息报文。

代码: 占一字节,标识对应ICMP报文的代码。它与类型字段一起共同标识了ICMP报文的详细类型。

校验和: 这是对包括ICMP报文数据部分在内的整个ICMP数据报的校验和,以检验报文在传输过程中是否出现了差错。其计算方法与在我们介绍IP报头中的校验和计算方法是一样的。

标识: 占两字节,用于标识本ICMP进程,但仅适用于回显请求和应答ICMP报文,对于目标不可达ICMP报文和超时ICMP报文等,该字段的值为0。

ICMP实现Ping命令

180924150424622.jpg
ping命令执行的时候,源主机首先会构建一个ICMP请求数据包,ICMP数据包内包含多个字段。最重要的是两个,第一个是类型字段,对于请求数据包而言该字段为8;另外一个是顺序号,主要用于区分连续ping的时候发出的多个数据包。每发出一个请求数据包,顺序号就会自动加1.为了能够计算往返时间RTT,它会在报文的数据部分插入发送时间。

然后,由ICMP协议将这个数据包连同地址192.168.1.2一起交给IP层,IP层将以192.168.1.2作为目的地址,本机IP地址作为源地址,加上一些其它控制信息,构建一个IP数据包。

接下来,需要加入MAC头。如果在本机ARP映射表中找出IP地址192.168.1.2所对应的MAC地址,则可以直接使用;如果没有,则需要发送ARP协议查询MAC地址,获得MAC地址后,由数据链路层构建一个数据帧,目的地址是IP层传过来的MAC地址,源地址则是本机的MAC地址;还要附加上一些控制信息,依据以太网的介质访问规则,将他们传送出去。

主机B收到这个数据帧后,先检查它的目的MAC地址,并和本机的MAC地址对比,如果符合则接收,否则就丢弃。接收后检查该数据帧,将IP数据包从帧中提取出来,交给本机的IP层。同样IP层检查后,将有用的信息提取后交给ICMP协议。

主机B会构建一个ICMP应答包,应答数据包的类型字段为0,顺序号为接收到的请求数据包中的顺序号,然后再发送出去给主机A。

在规定的时间内,源主机如果没有接到ICMP的应答包,则说明目标主机不可达,如果接收到了ICMP应答包,则说明目标主机可达。

C语言实现Ping

看来看去还是这篇写的最详细,就直接搬运过来了https://www.cnblogs.com/skyfsm/p/6348040.html?utm_source=itdadao&utm_medium=referral

ICMP包的封装
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void icmp_pack(struct icmp* icmphdr, int seq, int length)
{
int i = 0;

icmphdr->icmp_type = ICMP_ECHO; //类型填回送请求
icmphdr->icmp_code = 0;
icmphdr->icmp_cksum = 0; //注意,这里先填写0,很重要!
icmphdr->icmp_seq = seq; //这里的序列号我们填1,2,3,4....
icmphdr->icmp_id = pid & 0xffff; //我们使用pid作为icmp_id,icmp_id只是2字节,而pid有4字节
for(i=0;i<length;i++)
{
icmphdr->icmp_data[i] = i; //填充数据段,使ICMP报文大于64B
}

icmphdr->icmp_cksum = cal_chksum((unsigned short*)icmphdr, length); //校验和计算
}

注意,icmp_cksum 必须先填写为0再执行校验和算法计算,否则ping时对方主机会因为校验和计算错误而丢弃请求包,导致ping的失败

checksum(校验和):计算机网络通信时,为了检验在数据传输过程中数据是否发生了错误,通常在传输数据的时候连同校验和一块传输,当接收端接受数据时候会从新计算校验和,如果与原校验和不同就视为出错,丢弃该数据包,并返回icmp报文。

IP/ICMP/IGMP/TCP/UDP等协议的校验和算法都是相同的,采用的都是将数据流视为16位整数流进行重复叠加计算。为了计算检验和,首先把检验和字段置为0。然后,对有效数据范围内中每个16位进行二进制反码求和,结果存在检验和字段中,如果数据长度为奇数则补一字节0。当收到数据后,同样对有效数据范围中每个16位数进行二进制反码的求和。由于接收方在计算过程中包含了发送方存在首部中的检验和,因此,如果首部在传输过程中没有发生任何差错,那么接收方计算的结果应该为全0或全1(具体看实现了,本质一样) 。如果结果不是全0或全1,那么表示数据错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*校验和算法*/
unsigned short cal_chksum(unsigned short *addr,int len)
{ int nleft=len;
int sum=0;
unsigned short *w=addr;
unsigned short answer=0;

/*把ICMP报头二进制数据以2字节为单位累加起来*/
while(nleft>1)
{
sum+=*w++;
nleft-=2;
}
/*若ICMP报头为奇数个字节,会剩下最后一字节。把最后一个字节视为一个2字节数据的高字节,这个2字节数据的低字节为0,继续累加*/
if( nleft==1)
{
*(unsigned char *)(&answer)=*(unsigned char *)w;
sum+=answer;
}
sum=(sum>>16)+(sum&0xffff);
sum+=(sum>>16);
answer=~sum;
return answer;
}
ICMP包的解包

收到一个ICMP包,不要就认为这个包就是我们发出去的ICMP回送回答包,我们需要加一层代码来判断该ICMP报文的id和seq字段是否符合我们发送的ICMP报文的设置,来验证ICMP回复包的正确性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
int icmp_unpack(char* buf, int len)
{
int iphdr_len;
struct timeval begin_time, recv_time, offset_time;
int rtt; //round trip time

struct ip* ip_hdr = (struct ip *)buf;
iphdr_len = ip_hdr->ip_hl*4;
struct icmp* icmp = (struct icmp*)(buf+iphdr_len); //使指针跳过IP头指向ICMP头
len-=iphdr_len; //icmp包长度
if(len < 8) //判断长度是否为ICMP包长度
{
fprintf(stderr, "Invalid icmp packet.Its length is less than 8\n");
return -1;
}

//判断该包是ICMP回送回答包且该包是我们发出去的
if((icmp->icmp_type == ICMP_ECHOREPLY) && (icmp->icmp_id == (pid & 0xffff)))
{
if((icmp->icmp_seq < 0) || (icmp->icmp_seq > PACKET_SEND_MAX_NUM))
{
fprintf(stderr, "icmp packet seq is out of range!\n");
return -1;
}

ping_packet[icmp->icmp_seq].flag = 0;
begin_time = ping_packet[icmp->icmp_seq].begin_time; //去除该包的发出时间
gettimeofday(&recv_time, NULL);

offset_time = cal_time_offset(begin_time, recv_time);
rtt = offset_time.tv_sec*1000 + offset_time.tv_usec/1000; //毫秒为单位

printf("%d byte from %s: icmp_seq=%u ttl=%d rtt=%d ms\n",
len, inet_ntoa(ip_hdr->ip_src), icmp->icmp_seq, ip_hdr->ip_ttl, rtt);

}
else
{
fprintf(stderr, "Invalid ICMP packet! Its id is not matched!\n");
return -1;
}
return 0;
}
发送数据包

根据PING程序的框架,我们需要建立一个线程用于ping包的发送,使用sendto进行发包,发包速率维持在1秒1发,同时还需要用一个全局变量记录第一个ping包发出的时间,除此之外,我们还需要一个全局变量来记录我们发出的ping包到底有几个,这两个变量用于后来收到ping包回复后的数据计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void ping_send()
{
char send_buf[128];
memset(send_buf, 0, sizeof(send_buf));
gettimeofday(&start_time, NULL); //记录第一个ping包发出的时间
while(alive)
{
int size = 0;
gettimeofday(&(ping_packet[send_count].begin_time), NULL);
ping_packet[send_count].flag = 1; //将该标记为设置为该包已发送

icmp_pack((struct icmp*)send_buf, send_count, 64); //封装icmp包
size = sendto(rawsock, send_buf, 64, 0, (struct sockaddr*)&dest, sizeof(dest));
send_count++; //记录发出ping包的数量
if(size < 0)
{
fprintf(stderr, "send icmp packet fail!\n");
continue;
}

sleep(1);
}
}
接收数据包

同样建立一个接收包的线程,采用select函数进行收包,并为select函数设置超时时间为200us,若发生超时,则进行下一个循环。同样地,我们也需要一个全局变量来记录成功接收到的ping回复包的数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void ping_recv()
{
struct timeval tv;
tv.tv_usec = 200; //设置select函数的超时时间为200us
tv.tv_sec = 0;
fd_set read_fd;
char recv_buf[512];
memset(recv_buf, 0 ,sizeof(recv_buf));
while(alive)
{
int ret = 0;
FD_ZERO(&read_fd);
FD_SET(rawsock, &read_fd);
ret = select(rawsock+1, &read_fd, NULL, NULL, &tv);
switch(ret)
{
case -1:
fprintf(stderr,"fail to select!\n");
break;
case 0:
break;
default:
{
int size = recv(rawsock, recv_buf, sizeof(recv_buf), 0);
if(size < 0)
{
fprintf(stderr,"recv data fail!\n");
continue;
}

ret = icmp_unpack(recv_buf, size); //对接收的包进行解封
if(ret == -1) //不是属于自己的icmp包,丢弃不处理
{
continue;
}
recv_count++; //接收包计数
}
break;
}

}
}

中断处理

我们规定了一次ping发送的包的最大值为64个,若超出该数值就停止发送。作为PING的使用者,我们一般只会发送若干个包,若有这几个包顺利返回,我们就crtl+c中断ping。这里的代码主要是为中断信号写一个中断处理函数,将alive这个全局变量设置为0,进而使发送ping包的循环停止而结束程序。

1
2
3
4
5
6
7
8
void icmp_sigint(int signo)
{
alive = 0;
gettimeofday(&end_time, NULL);
time_interval = cal_time_offset(start_time, end_time);
}

signal(SIGINT, icmp_sigint);
完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
#include <stdio.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <unistd.h>
#include <signal.h>
#include <arpa/inet.h>
#include <errno.h>
#include <sys/time.h>
#include <string.h>
#include <netdb.h>
#include <pthread.h>


#define PACKET_SEND_MAX_NUM 64

typedef struct ping_packet_status
{
struct timeval begin_time;
struct timeval end_time;
int flag; //发送标志,1为已发送
int seq; //包的序列号
}ping_packet_status;



ping_packet_status ping_packet[PACKET_SEND_MAX_NUM];

int alive;
int rawsock;
int send_count;
int recv_count;
pid_t pid;
struct sockaddr_in dest;
struct timeval start_time;
struct timeval end_time;
struct timeval time_interval;

/*校验和算法*/
unsigned short cal_chksum(unsigned short *addr,int len)
{ int nleft=len;
int sum=0;
unsigned short *w=addr;
unsigned short answer=0;

/*把ICMP报头二进制数据以2字节为单位累加起来*/
while(nleft>1)
{
sum+=*w++;
nleft-=2;
}
/*若ICMP报头为奇数个字节,会剩下最后一字节。把最后一个字节视为一个2字节数据的高字节,这个2字节数据的低字节为0,继续累加*/
if( nleft==1)
{
*(unsigned char *)(&answer)=*(unsigned char *)w;
sum+=answer;
}
sum=(sum>>16)+(sum&0xffff);
sum+=(sum>>16);
answer=~sum;
return answer;
}

struct timeval cal_time_offset(struct timeval begin, struct timeval end)
{
struct timeval ans;
ans.tv_sec = end.tv_sec - begin.tv_sec;
ans.tv_usec = end.tv_usec - begin.tv_usec;
if(ans.tv_usec < 0) //如果接收时间的usec小于发送时间的usec,则向sec域借位
{
ans.tv_sec--;
ans.tv_usec+=1000000;
}
return ans;
}

void icmp_pack(struct icmp* icmphdr, int seq, int length)
{
int i = 0;

icmphdr->icmp_type = ICMP_ECHO;
icmphdr->icmp_code = 0;
icmphdr->icmp_cksum = 0;
icmphdr->icmp_seq = seq;
icmphdr->icmp_id = pid & 0xffff;
for(i=0;i<length;i++)
{
icmphdr->icmp_data[i] = i;
}

icmphdr->icmp_cksum = cal_chksum((unsigned short*)icmphdr, length);
}

int icmp_unpack(char* buf, int len)
{
int iphdr_len;
struct timeval begin_time, recv_time, offset_time;
int rtt; //round trip time

struct ip* ip_hdr = (struct ip *)buf;
iphdr_len = ip_hdr->ip_hl*4;
struct icmp* icmp = (struct icmp*)(buf+iphdr_len);
len-=iphdr_len; //icmp包长度
if(len < 8) //判断长度是否为ICMP包长度
{
fprintf(stderr, "Invalid icmp packet.Its length is less than 8\n");
return -1;
}

//判断该包是ICMP回送回答包且该包是我们发出去的
if((icmp->icmp_type == ICMP_ECHOREPLY) && (icmp->icmp_id == (pid & 0xffff)))
{
if((icmp->icmp_seq < 0) || (icmp->icmp_seq > PACKET_SEND_MAX_NUM))
{
fprintf(stderr, "icmp packet seq is out of range!\n");
return -1;
}

ping_packet[icmp->icmp_seq].flag = 0;
begin_time = ping_packet[icmp->icmp_seq].begin_time;
gettimeofday(&recv_time, NULL);

offset_time = cal_time_offset(begin_time, recv_time);
rtt = offset_time.tv_sec*1000 + offset_time.tv_usec/1000; //毫秒为单位

printf("%d byte from %s: icmp_seq=%u ttl=%d rtt=%d ms\n",
len, inet_ntoa(ip_hdr->ip_src), icmp->icmp_seq, ip_hdr->ip_ttl, rtt);

}
else
{
fprintf(stderr, "Invalid ICMP packet! Its id is not matched!\n");
return -1;
}
return 0;
}

void ping_send()
{
char send_buf[128];
memset(send_buf, 0, sizeof(send_buf));
gettimeofday(&start_time, NULL); //记录第一个ping包发出的时间
while(alive)
{
int size = 0;
gettimeofday(&(ping_packet[send_count].begin_time), NULL);
ping_packet[send_count].flag = 1; //将该标记为设置为该包已发送

icmp_pack((struct icmp*)send_buf, send_count, 64); //封装icmp包
size = sendto(rawsock, send_buf, 64, 0, (struct sockaddr*)&dest, sizeof(dest));
send_count++; //记录发出ping包的数量
if(size < 0)
{
fprintf(stderr, "send icmp packet fail!\n");
continue;
}

sleep(1);
}
}

void ping_recv()
{
struct timeval tv;
tv.tv_usec = 200; //设置select函数的超时时间为200us
tv.tv_sec = 0;
fd_set read_fd;
char recv_buf[512];
memset(recv_buf, 0 ,sizeof(recv_buf));
while(alive)
{
int ret = 0;
FD_ZERO(&read_fd);
FD_SET(rawsock, &read_fd);
ret = select(rawsock+1, &read_fd, NULL, NULL, &tv);
switch(ret)
{
case -1:
fprintf(stderr,"fail to select!\n");
break;
case 0:
break;
default:
{
int size = recv(rawsock, recv_buf, sizeof(recv_buf), 0);
if(size < 0)
{
fprintf(stderr,"recv data fail!\n");
continue;
}

ret = icmp_unpack(recv_buf, size); //对接收的包进行解封
if(ret == -1) //不是属于自己的icmp包,丢弃不处理
{
continue;
}
recv_count++; //接收包计数
}
break;
}

}
}

void icmp_sigint(int signo)
{
alive = 0;
gettimeofday(&end_time, NULL);
time_interval = cal_time_offset(start_time, end_time);
}

void ping_stats_show()
{
long time = time_interval.tv_sec*1000+time_interval.tv_usec/1000;
/*注意除数不能为零,这里send_count有可能为零,所以运行时提示错误*/
printf("%d packets transmitted, %d recieved, %d%c packet loss, time %ldms\n",
send_count, recv_count, (send_count-recv_count)*100/send_count, '%', time);
}


int main(int argc, char* argv[])
{
int size = 128*1024;//128k
struct protoent* protocol = NULL;
char dest_addr_str[80];
memset(dest_addr_str, 0, 80);
unsigned int inaddr = 1;
struct hostent* host = NULL;

pthread_t send_id,recv_id;

if(argc < 2)
{
printf("Invalid IP ADDRESS!\n");
return -1;
}

protocol = getprotobyname("icmp"); //获取协议类型ICMP
if(protocol == NULL)
{
printf("Fail to getprotobyname!\n");
return -1;
}

memcpy(dest_addr_str, argv[1], strlen(argv[1])+1);

rawsock = socket(AF_INET,SOCK_RAW,protocol->p_proto);
if(rawsock < 0)
{
printf("Fail to create socket!\n");
return -1;
}

pid = getpid();

setsockopt(rawsock, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); //增大接收缓冲区至128K

bzero(&dest,sizeof(dest));

dest.sin_family = AF_INET;

inaddr = inet_addr(argv[1]);
if(inaddr == INADDR_NONE) //判断用户输入的是否为IP地址还是域名
{
//输入的是域名地址
host = gethostbyname(argv[1]);
if(host == NULL)
{
printf("Fail to gethostbyname!\n");
return -1;
}

memcpy((char*)&dest.sin_addr, host->h_addr, host->h_length);
}
else
{
memcpy((char*)&dest.sin_addr, &inaddr, sizeof(inaddr));//输入的是IP地址
}
inaddr = dest.sin_addr.s_addr;
printf("PING %s, (%d.%d.%d.%d) 56(84) bytes of data.\n",dest_addr_str,
(inaddr&0x000000ff), (inaddr&0x0000ff00)>>8,
(inaddr&0x00ff0000)>>16, (inaddr&0xff000000)>>24);

alive = 1; //控制ping的发送和接收

signal(SIGINT, icmp_sigint);

if(pthread_create(&send_id, NULL, (void*)ping_send, NULL))
{
printf("Fail to create ping send thread!\n");
return -1;
}

if(pthread_create(&recv_id, NULL, (void*)ping_recv, NULL))
{
printf("Fail to create ping recv thread!\n");
return -1;
}

pthread_join(send_id, NULL);//等待send ping线程结束后进程再结束
pthread_join(recv_id, NULL);//等待recv ping线程结束后进程再结束

ping_stats_show();

close(rawsock);
return 0;

}

编译:gcc -g -o ping_test2 ping_test2.c -lpthread

运行

1
2
3
4
5
6
➜  ping_test ./ping_test2 www.baidu.com
PING www.baidu.com, (14.215.177.39) 56(84) bytes of data.
64 byte from 14.215.177.39: icmp_seq=0 ttl=128 rtt=20 ms
64 byte from 14.215.177.39: icmp_seq=1 ttl=128 rtt=20 ms
64 byte from 14.215.177.39: icmp_seq=2 ttl=128 rtt=20 ms
64 byte from 14.215.177.39: icmp_seq=3 ttl=128 rtt=19 ms

ICMP实现Traceroute

Traceroute基于报文头中的TTL值来逐跳跟踪报文的转发路径。为了跟踪到达某特定目的地址的路径,源端首先将报文的TTL值设置为1。该报文到达第一个节点后,TTL超时,于是该节点向源端发送TTL超时消息,消息中携带时间戳。然后源端将报文的TTL值设置为2,报文到达第二个节点后超时,该节点同样返回TTL超时消息,以此类推,直到报文到达目的地。这样,源端根据返回的报文中的信息可以跟踪到报文经过的每一个节点,并根据时间戳信息计算往返时间。Traceroute是检测网络丢包及时延的有效手段,同时可以帮助管理员发现网络中的路由环路。

工作流程

  1. 向目的地发送一个TTL为1的UDP数据报(随机选取的未被使用的目的端口发送数据报)
  2. 该数据报导致第一跳路由返送一个ICMP错误(time exceeded in transit 传输超时)
  3. 每递增TTL一次发送一个UDP数据报,逐步确定下一跳路由器
  4. 当UDP数据报(TTL增加到一定大小后)到达最终目的地时,目标主机返送一个ICMP错误(port unreachable 端口不可达)

图片3.png

806469-20180306124141264-1747233430.png

参考链接

https://www.cnblogs.com/iiiiher/p/8513748.html

https://www.linuxidc.com/Linux/2018-09/154369.htm

https://www.cnblogs.com/skyfsm/p/6348040.html?utm_source=itdadao&utm_medium=referral