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請求響應包, 如果沒有這樣的響應包, 那么就說明網絡不通, 其次, 響應包會原封不動的把請求包的載荷返回來, 這樣如果載荷是發送時的時間戳, 不就可以用來計算響應時間了嘛, 也就可以反應網絡狀況了。
據此我們首先寫出主要的處理流程代碼:

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版本中那樣移動指針了。
主要處理過程就這么多。