windows 中 ping 的简单实现——原始套接字的应用
ping 是我们在学习计算机网络知识, 研究网络问题时最多使用的程序之一, 当网络出现问题时, 在终端输入ping baidu.com, 对命令熟悉的, 再配合一些参数, 和诸如netstat, net,等命令, 多多少少就能推断出问题原因。ping也是一种通信协议, 他是tcp/ip协议的一部分, 基于icmp(icmpv6)协议。那么在介绍ping的实现之前, 我们就需要先搞明白icmp协议了。
ICMP协议:
ICMP协议是一种面向无连接的网络层协议, 它主要用于在主机与路由器之间传递控制信息,包括报告错误、交换受限控制和状态信息等。通常ICMP包的格式如下:
对于开始的4字节, 任何类型的ICMP报文, 他们的意义都一样(无论ICMPv4还是ICMPv6):
- 类型 type:
大小1字节, ICMP报文通常分为两类——差错报文和查询报文, 他的值可以是:
意义 | ICMPv4中的值 | ICMPv6中的值 |
请求 | 8 | 128 |
请求应答 | 0 | 129 |
目标不可达 | 3 | 1 |
包丢失(源抑制, 表示缓存满了, 暂时无法处理) | 4 | - |
重定向 | 5 | 137 |
路由器公告 | 9 | 134 |
路由器请求 | 10 | 133 |
超时 | 11 | 3 |
无效IP首部 | 12 | 4 |
时间戳请求 | 13 | - |
时间戳应答 | 14 | - |
地址掩码请求 | 17 | - |
地址掩码应答 | 18 | - |
包过大 | - | 2 |
表中只列举了一部分, 更详细的可取值可以从以下链接获取:
在ping程序中, 我们只使用请求与请求应答类型。
- 代码 code:
大小1字节, 用于进一步区分某种类型的多种情况, 上面的链接中有详细说明
- 校验和 checksum:
无论是ip, tcp, udp, 还是icmp, 首部的校验和字段都是两字节, 并且都采用反码循环移位求和方式计算。之所以用这种方式计算, 主要因为
(1) 这样无论是本机字节序是大端还是小端, 结果都一样。这里需要注意一点, 这里说的反码跟普通意义上的有符号数的反码不一样, 这里是无论正负, 直接按位取反的。此外, 在计算过程中,如果最高位有进位, 则对最低为进1, 这样做就保证了结果与字节序无关, 例如假设在内存中按字节存储了A, B, C, D四个值, 我们以两字节为单位循环求和, 即 sum = [A(0~7) B(8~15)] + [C(0~7) D(8~15)], 假设15位有进位, 则会对0位进1, 同时, 加入7位有进位, 则会对8位进1, 那么很明显, [B(8~15) A(0~7)] + [D(8~15) C(0~7)]的结果也是sum, 仅仅只是读取方向不一样而已。
(2) 这种方式可以简单的校验, 假设请求包里的校验和是 01010101 01010101 , 那么服务端校验时候只需要带上校验和字段, 按照计算校验和的方式再算一次就能验证, 其原因是, 排除校验和字段的其他字段计算结果就是01010101 01010101, 把校验和字段带上计算的话, 这个字段取反就是10101010 10101010, 两者相加, 就是11111111 11111111, 其值与0相当, 也就是说,只要计算结果是0, 那么包就没出问题。
另外要说明的是,对于这种方式, 先取反在求和 与 先求和再取反结果是一样的
这里提供一个简单的实现:
1 short in_cksum(short* addr, int len) 2 { 3 int nleft = len; 4 int sum = 0; 5 u_short* w = (u_short*)addr; 6 short answer = 0; 7 8 while (nleft > 1) 9 { 10 sum += *w++; 11 nleft -= 2; 12 } 13 if (nleft == 1) 14 { 15 *(char*)(&answer) = *(char*)w; 16 sum += answer; 17 } 18 sum = (sum >> 16) + (sum & 0xffff); 19 sum += (sum >> 16); 20 answer = ~sum; 21 return answer; 22 }
PING 实现:
PING程序其实就是向目标主机发送一个ICMP 请求包, 然后等待目标主机返回的ICMP请求响应包, 如果没有这样的响应包, 那么就说明网络不通, 其次, 响应包会原封不动的把请求包的载荷返回来, 这样如果载荷是发送时的时间戳, 不就可以用来计算响应时间了嘛, 也就可以反应网络状况了。
据此我们首先写出主要的处理流程代码:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
1 #include "ping.h" 2 #include <stdio.h> 3 #include <string.h> 4 #include <memory.h> 5 6 7 int main(int argc, char** argv) 8 { 9 struct addrinfo* ai; // 目标主机信息 10 char* h; // 目标主机ip 11 12 pid = GetCurrentProcessId(); //当前进程id, 用于验证得到的响应包确实是当前进程的请求响应 13 14 initSock(); // windows中需要先初始化socket 15 char* host = argv[1]; // 这里仅实现最基础的功能, 即ping [host], 16 ai = host_serv(host, NULL, 0, 0); //获取目标主机信息 17 h = sock_ntop_host(ai->ai_addr, ai->ai_addrlen); 18 19 printf("PING %s (%s): %d data bytes\n", 20 ai->ai_canonname ? ai->ai_canonname : h, h, datalen); 21 22 // IPv4 和 IPv6 处理需要区别开, pr保存了包括处理方法, 接收和发送地址等相关信息 23 switch(ai->ai_family) 24 { 25 case AF_INET: { 26 pr = &proto_v4; 27 break; 28 } 29 case AF_INET6: { 30 pr = &proto_v6; 31 break; 32 } 33 default: 34 error_quit("unknown address famil %d", ai->ai_family); 35 } 36 37 pr->sasend = ai->ai_addr; //目标主机 38 pr->sarecv = (struct sockaddr*)calloc(1, ai->ai_addrlen); 39 pr->salen = ai->ai_addrlen; //地址结构字节数 40 readloop(); //处理循环 41 42 return 0; 43 }
其中, void readloop(void) 用于发送和处理接收到的ICMP数据包
1 u_int sockfd; 2 u_int verbose; 3 pid_t pid; 4 int nsent; /*每调用一次sendto(), 加1*/ 5 int datalen = 56; /*icmp包的载荷字节数*/ 6 char sendbuf[BUFSIZE]; 7 proto* pr; 8 9 void readloop(void) 10 { 11 int size; 12 char recvbuf[BUFSIZE]; 13 char controlbuf[BUFSIZE]; 14 WSAMSG msg; 15 WSABUF iov; 16 u_int n; 17 struct timeval tval; 18 19 sockfd = Socket(pr->sasend->sa_family, SOCK_RAW, pr->icmpproto); 20 21 size = 60 * 1024; /*将套接字接收缓冲区大小设置大点, 防止对IPv4广播地址或者多播地址ping*/ 22 setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, (const char*)&size, sizeof(size)); 23 24 iov.buf = recvbuf; 25 iov.len = sizeof(recvbuf); 26 msg.name = pr->sarecv; 27 msg.lpBuffers = &iov; 28 msg.dwBufferCount = 1; 29 30 for (;;) 31 { 32 (*pr->fsend)(); 33 msg.namelen = pr->salen; 34 msg.Control = { sizeof(controlbuf), controlbuf }; 35 int n = recvmsg(sockfd, &msg, 0); 36 if (n < 0) 37 if (GetLastError() == EINTR) 38 continue; 39 else 40 perror("recvmsg error"); 41 gettimeofday(&tval, NULL); // 将当前时间戳存储在tval中 42 (*pr->fproc)(recvbuf, n, &msg, &tval); 43 } 44 }
这里需要说明的结构体有三个,WSABUF, WSAMSG, proto
先看proto, 其定义如下:
1 struct proto { 2 void (*fproc)(char*, int, WSAMSG*, struct timeval*); 3 void (*fsend)(void); 4 struct sockaddr* sasend; 5 struct sockaddr* sarecv; 6 int salen; 7 int icmpproto; 8 };
fproc 是指向用于处理接收到ICMP包的函数的指针,
fsend 是指向用于发送ICMP数据包的函数的指针
sasend 是指向目标主机的地址信息的指针
sarecv 指示从哪接收ICMP数据包,
salen 以上两个地址结构的大小
icmpproto 指示使用的ICMP协议值, IPPROTO_ICMP 或 IPPROTO_ICMPV6
再来看WSAMSG:
这个结构体与linux系统中的msghdr结构对应, 其定义如下:
1 typedef struct _WSAMSG { 2 LPSOCKADDR name; 3 INT namelen; 4 LPWSABUF lpBuffers; 5 #if ... 6 ULONG dwBufferCount; 7 #else 8 DWORD dwBufferCount; 9 #endif 10 WSABUF Control; 11 #if ... 12 ULONG dwFlags; 13 #else 14 DWORD dwFlags; 15 #endif 16 } WSAMSG, *PWSAMSG, *LPWSAMSG;
其中, name 是信息关联的地址, namelen 是其长度,
lpBuffers 是 WSABUF 的数组首地址, 用于存储真正的数据, 而dwBufferCount 指示有多少个数据缓冲区, 相当于节点长度
Control 指示一些控制信息,
再看WSABUF 结构, 其定义如下:
1 typedef struct _WSABUF { 2 ULONG len; 3 CHAR *buf; 4 } WSABUF, *LPWSABUF;
其中, len指示缓冲区大小, buf 是缓冲区指针
另外, recvmsg在Mswsock.h头文件中, 函数名是WSARecvMsg, 这里我是对recvfrom做了简单的封装, 如下:
1 int recvmsg(int sockfd, WSAMSG* msg, int flags) 2 { 3 int bs = msg->lpBuffers->len; 4 msg->dwFlags = flags; 5 int rc = WSARecvFrom(sockfd, msg->lpBuffers, msg->dwBufferCount, 6 (LPDWORD)&bs, &msg->dwFlags, msg->name, 7 &msg->namelen, NULL, NULL); 8 if (rc != 0) 9 { 10 return -1; 11 } 12 return bs; 13 }
接下来说明一下发送ICMP包的send方法:
1 void send_v4(void) 2 { 3 int len; 4 struct icmp4_msg* icmp; 5 6 icmp = (struct icmp4_msg*)sendbuf; 7 icmp->hdr.type = ICMP_ECHO; 8 icmp->hdr.code = 0; 9 icmp->hdr.checksum = 0; 10 icmp->hdr.id = pid; 11 icmp->hdr.seq = nsent++; 12 memset(&icmp->timestamp, 0xa5, datalen); // 给载荷区填充值 13 gettimeofday((struct timeval*)&icmp->timestamp, NULL); //写入当前时间戳 14 15 len = 8 + datalen; 16 17 icmp->hdr.checksum = in_cksum((short*)icmp, len); 18 19 Sendto(sockfd, sendbuf, len, 0, pr->sasend, pr->salen); 20 }
这里需要说明的是icmp 请求的包格式, 上面已经说过, 前四字节的含义是固定的, 第5~8字节根据type不同,而不同, 这里我们用的请求报文的第5~6字节是进程id, 用于在得到响应以后确定交给哪个进程处理, 第7~8字节是序列号。
再来看对ICMP响应包的处理:
先看IPv4版本的:
1 void proc_v4(char* ptr, int len, WSAMSG* msg, struct timeval* tvrecv) 2 { 3 struct ip* ip; 4 struct timeval* tvsend; 5 double rtt; 6 7 ip = (struct ip*)ptr; 8 int hlen = ip->ip_hl << 2; /*ip头长度*/ 9 if (ip->ip_p != IPPROTO_ICMP) 10 return; 11 12 struct icmp4_msg* icmp = (struct icmp4_msg*)(ptr + hlen); /*icmp数据报*/ 13 int icmplen; 14 if ((icmplen = len - hlen) < 8) 15 return; 16 17 if (icmp->hdr.type == ICMP_ECHOREPLY) 18 { 19 if (icmp->hdr.id != pid) 20 return; 21 if (icmplen < 16) 22 return ; 23 tvsend = (struct timeval*)&icmp->timestamp; 24 tv_sub(tvrecv, tvsend); 25 rtt = tvrecv->tv_sec*1000.0 + tvrecv->tv_usec / 1000.0; 26 printf("%d bytes from %s: seq=%u, ttl=%d, rtt=%.3f ms\n", 27 icmplen, sock_ntop_host(pr->sarecv, pr->salen), icmp->hdr.seq, ip->ip_ttl, rtt); 28 } 29 else if (verbose) 30 { 31 printf(" %d bytes from %s: type = %d, code = %d\n", 32 icmplen, sock_ntop_host(pr->sarecv, pr->salen), icmp->hdr.type, 33 icmp->hdr.code); 34 }
35 }
这里要说明的是, IPv4类型的原始套接字, 我们得到的数据包是包含IP头的, ICMP包紧跟在IP头之后, 12行就是为了指向ICMP包头, 14行是为了验证ICMP包的有效性, 因为一个正常的ICMP包至少有包头的8字节, 19~20行为了过滤收到的数据包, 让程序只处理本进程发出的包的响应。
此外, 还需要说明一点: 在Windows中定义IP头结构时候需要注意长度和版本的定义顺序, 因为他们共用第一字节, 根据本机字节序的不同, 他们实际的存储顺序并不一定跟定义顺序一致, 比如我的电脑本机字节序是小端的, 如果我先定义版本的4位, 后定义长度的4位, 我们本意虽然是先版本后长度这样的顺序(让版本作为数值高位, 然后内存低地址存版本, 高地址存长度, 也就是大端存储), 但是在内存中他是先长度后版本这样的(内存中低地址存了长度), 所以这里要根据自己本机字节序对第一字节的两个字段的定义顺序做一下调整。
对于IPv6的处理, 首先要明确的是, IPv6原始套接字拿到的数据包是不包含IP首部的, 当我们的应用拿到数据时, 内核已经将首部处理了, 所以就不用像IPv4版本中那样移动指针了。
主要处理过程就这么多。