BUAAZP.

手把手教你打造自己的ping工具

June 13, 2014

手把手教你打造自己的ping工具

@招牌疯子

记得之前看到过一句话:什么是Geek,Geek就是当看到什么东西不顺手时,第一时间想到的是怎么把它改造到顺手好用。

其实事情是这样的,ping命令大家都用过,Linux下的ping命令本身很强大,但是有一点不太符合用户体验的是,它不会自动结束(哼我当然知道-c选项啊魂淡>_<平心而论这方面windows下的ping就好用一点),因为我们ping一个地址一般只是为了测试下网络是否通畅,每次都得多按一下ctrl+C说多不多,说少也不少,SO,正好前阵子在一本书上看到过ping的原理和实现,于是自己动手改出了一个用着顺手的版本,代码会贴在本文末尾。

1.ping简介
ping是用来查看自己这边跟网络中某个主机之间是否联通的工具,原理是从本地向目标地址发送ICMP报文,若对方收到了会将报文一模一样地发回来。但是值得注意的是,在TCP/IP体系中,ping是位于应用层,并直接操纵网络层的,自己写ping的话不能使用常见的TCP协议。
下面是我在自己机器上使用ping命令的结果:

zippo@openSUSE:~/develop/linuxc> ping buaa.us  
PING buaa.us (106.187.95.231) 56(84) bytes of data.  
64 bytes from li415-231.members.linode.com (106.187.95.231): icmp_seq=1 ttl=47 time=197 ms  
64 bytes from li415-231.members.linode.com (106.187.95.231): icmp_seq=2 ttl=47 time=196 ms  
64 bytes from li415-231.members.linode.com (106.187.95.231): icmp_seq=3 ttl=47 time=197 ms  
64 bytes from li415-231.members.linode.com (106.187.95.231): icmp_seq=4 ttl=47 time=197 ms  
^C
--- buaa.us ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3003ms  
rtt min/avg/max/mdev = 196.656/197.183/197.578/0.637 ms  

其中的icmp_seq是报文顺序号,ttl是生存时间,time是这个报文来回的时间,可以看到位于海外的本站主机延时高达200ms。

2.ICMP协议简介
详细的介绍建议搜索一下,简单来说ICMP是TCP/IP协议族中的一个,是位于IP层的一个协议,由于涉及到路由选择等问题,ICMP报文需要通过IP协议来发送。其报文数据先加一个ICMP报头形成ICMP报文,再加上IP报头形成一个标准的IP数据报。 先来看IP报,IP报头由许多部分组成,从linux中/usr/include/netinet/ip.h头文件可以看到其数据结构:

struct ip  
  {
#if __BYTE_ORDER == __LITTLE_ENDIAN
    unsigned int ip_hl:4;        /* header length */
    unsigned int ip_v:4;        /* version */
#endif
#if __BYTE_ORDER == __BIG_ENDIAN
    unsigned int ip_v:4;        /* version */
    unsigned int ip_hl:4;        /* header length */
#endif
    u_int8_t ip_tos;            /* type of service */
    u_short ip_len;            /* total length */
    u_short ip_id;            /* identification */
    u_short ip_off;            /* fragment offset field */
#define    IP_RF 0x8000            /* reserved fragment flag */
#define    IP_DF 0x4000            /* dont fragment flag */
#define    IP_MF 0x2000            /* more fragments flag */
#define    IP_OFFMASK 0x1fff        /* mask for fragmenting bits */
    u_int8_t ip_ttl;            /* time to live */
    u_int8_t ip_p;            /* protocol */
    u_short ip_sum;            /* checksum */
    struct in_addr ip_src, ip_dst;    /* source and dest address */
  };

那我们的ping只用到了其中的iphl和ipttl两个成员,直接使用人家现成的即可。

然后是ICMP报文,ICMP的报头共8字节,前4字节由类型、代码、检验和(cheksum)组成;后面四个字节与ICMP的类型有关,我们的ping只使用到了ICMPECHO和ICMPECHOREPLY两种,在头文件/usr/include/netinet/ip_icmp.h中它们分别被定义成常量8和0,具体ICMP的数据结构也可以在这个头文件中看到,我就不贴了。

3.ICMP报文的构造
那我们想要自己构造一个ICMP报文,就需要了解它的报头校验和算法:

把被校验的数据进行16位累加,然后取反码,若数据长度为奇数则末尾补0。此算法名叫网际校验和算法,在网络协议上用得极为广泛,包括你所熟知的TCP、UDP、IPV4等等,更详细的内容可以看RFC1071标准文档。下面这段是我们用到的校验和算法实现:

unsigned short cal_chksum(unsigned short *addr, int len)  
{
    int nleft=len;
    int sum=0;
    unsigned short *w=addr;
    unsigned short answer=0;
    while(nleft>1)
    {
        sum+=*w++;
        nleft-=2;
    }
    if(nleft==1)
    {
        *(unsigned char *)(&answer)=*(unsigned char *)w;
        sum+=answer;
    }
    sum=(sum>>16)+(sum&0xffff);
    sum+=(sum>>16);
    answer=~sum;
    return answer;
}

ICMP报文还有一个重要的部分是标识符,如果没有标识符,你在自己机器上起了多个ping的时候岂不是要乱套了么(ICMP协议中没有端口概念,只能定位到IP)。为了区分我们用进程ID来作为标识符。

另外为了使我们的ping命令能够显示出耗时信息,由于其自身没有相应的成员,我们采用的办法是发送时取本地时间放入报文中,等收到回来的报文时再用接收时间做差即为往返时间。用到数据结构timeval和gettimeofday()函数。但是由于要加入我们自己的东西,就需要把ICMP数据结构中的icmp->icmp_data强制转换为timeval类型并重新设置报头大小和计算新的校验和,实现如下:

int pack(int pack_no)  
{
    int i, packsize;
    struct icmp *icmp;
    struct timeval *tval;

    icmp=(struct icmp*)sendpacket;
    icmp->icmp_type=ICMP_ECHO;
    icmp->icmp_code=0;
    icmp->icmp_cksum=0;
    icmp->icmp_seq=pack_no;
    icmp->icmp_id=pid;
    packsize=8+datalen;
    tval=(struct timeval*)icmp->icmp_data;
    gettimeofday(tval, NULL);
    icmp->icmp_cksum=cal_chksum((unsigned short *)icmp, packsize);
    return packsize;
}

4.发送和接收
OK,现在我们需要的材料已经齐了,要怎么发送和接收这些报文呢?上文已经提到ICMP协议不属于TCP和UDP等传输层协议,不能使用常用的socket编程(SOCKSTREAM和SOCKDRAGM),不过没关系,linux还给我们提供另外一种套接口——原始套接口(SOCK_RAW),估计也就是给那些发明新协议的人用的,从名字也可看出来它更加原始,更加自由。
注意:只有root用户才能创建原始套接口,所以在编译程序时需要切换到root身份。 创建原始套接口也是使用socket()函数:

int sockfd=socket(AF_INET, SOCK_RAW, protocol->p_proto);  

然后原始套接口的发送接收函数也跟TCP/UDP类似:

sendto(sockfd, sendpacket, packetsize, 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr));  
recvfrom(sockfd, recvpacket, sizeof(recvpacket), 0, (struct sockaddr *)&from, &fromlen);  

5.组装实现运行
万事俱备,定义一个发送次数,我设的4次,循环发送构造好的ICMP报文并尝试接受,最后计算成功率并输出,一个我们自己打造的ping命令就呈现在眼前了,下面是运行结果:

openSUSE:/home/zippo/develop/linuxc # ./myping buaa.us  
PING buaa.us(106.187.95.231): 64 bytes data in ICMP packets.  
64 bytes from 106.187.95.231: icmp_seq=1 ttl=47 time=197.433 ms  
64 bytes from 106.187.95.231: icmp_seq=2 ttl=47 time=197.063 ms  
64 bytes from 106.187.95.231: icmp_seq=3 ttl=47 time=197.161 ms  
64 bytes from 106.187.95.231: icmp_seq=4 ttl=47 time=196.994 ms

---------PING statitics-----------
4 packets transmitted, 4 received, %0 lost  

可以看到与系统自带的ping命令基本无差别,至此我们的任务也就完成了。

6.完整代码

/*
本代码来自电子工业出版社出版的《Linux C编程》
第11章最后的示例代码
由于其中有部分错误,我做了少量修正并实际编译运行通过
此代码所有权归原作者所有
所有改动之处都有注释说明
如果你在使用过程中发现错误请联系本人:
新浪微博:@招牌疯子
邮箱:zp@buaa.us
2013.4.12  
*/

#include <stdio.h>
#include <signal.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <netdb.h>
#include <setjmp.h>
#include <errno.h>

#define PACKET_SIZE 4096
#define MAX_WAIT_TIME 5
#define MAX_NO_PACKETS 4

char sendpacket [PACKET_SIZE];  
char recvpacket [PACKET_SIZE];  
int sockfd, datalen=56;  
int nsend=0, nreceived=0;  
struct sockaddr_in dest_addr;  
pid_t pid;  
struct sockaddr_in from;  
struct timeval tvrecv;

void statistics(int signo);  
unsigned short cal_chksum(unsigned short *addr, int len);  
int pack(int pack_no);  
void send_packet(void);  
void recv_packet(void);  
int unpack(char *buf, int len);  
void tv_sub(struct timeval *out, struct timeval *in);

void statistics(int signo)  
{
    printf("\n---------PING statitics-----------\n");
    printf("%d packets transmitted, %d received, %%%d lost\n", nsend, nreceived, (nsend-nreceived)/nsend*100);
    close(sockfd);
    exit(1);
}

unsigned short cal_chksum(unsigned short *addr, int len)  
{
    int nleft=len;
    int sum=0;
    unsigned short *w=addr;
    unsigned short answer=0;
    while(nleft>1)
    {
        sum+=*w++;
        nleft-=2;
    }
    if(nleft==1)
    {
        *(unsigned char *)(&answer)=*(unsigned char *)w;
        sum+=answer;
    }
    sum=(sum>>16)+(sum&0xffff);
    sum+=(sum>>16);
    answer=~sum;
    return answer;
}

int pack(int pack_no)  
{
    int i, packsize;
    struct icmp *icmp;
    struct timeval *tval;

    icmp=(struct icmp*)sendpacket;
    icmp->icmp_type=ICMP_ECHO;
    icmp->icmp_code=0;
    icmp->icmp_cksum=0;
    icmp->icmp_seq=pack_no;
    icmp->icmp_id=pid;
    packsize=8+datalen;
    tval=(struct timeval*)icmp->icmp_data;
    gettimeofday(tval, NULL);
    icmp->icmp_cksum=cal_chksum((unsigned short *)icmp, packsize);
    return packsize;
}

void send_packet()  
{
    int packetsize;
    while(nsend<MAX_NO_PACKETS)
    {
        nsend++;
        packetsize=pack(nsend);
        if(sendto(sockfd, sendpacket, packetsize, 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr))<0)
        {
            perror("sendto error");
            continue;
        }
        //此处插入调用recv_packet()函数使统计和显示更准确
        recv_packet();
        sleep(1);
    }
}

void recv_packet()  
{
    int n, fromlen;
    extern int errno;

    signal(SIGALRM, statistics);
    fromlen=sizeof(from);
    while(nreceived<nsend)
    {
        alarm(MAX_WAIT_TIME);
        if((n=recvfrom(sockfd, recvpacket, sizeof(recvpacket), 0, (struct sockaddr *)&from, &fromlen))<0)
        {
            if(errno==EINTR)
                continue;
            perror("recvfrom error");
            continue;
        }
        gettimeofday(&tvrecv, NULL);
        if(unpack(recvpacket, n)==-1)
            continue;
        nreceived++;
    }
}

int unpack(char *buf, int len)  
{
    int i, iphdrlen;
    struct ip *ip;
    struct icmp *icmp;
    struct timeval *tvsend;
    double rtt;

    ip=(struct ip*)buf;
    iphdrlen=ip->ip_hl<<2;
    icmp=(struct icmp*)(buf+iphdrlen);
    len-=iphdrlen;
    if(len<8)
    {
        printf("ICMP packets\'s length is less than 8\n");
        return -1;
    }
    if((icmp->icmp_type==ICMP_ECHOREPLY) && (icmp->icmp_id==pid))
    {
        tvsend=(struct timeval *)icmp->icmp_data;
        tv_sub(&tvrecv, tvsend);
        //为了让时间显示更精确,强制转换tv_usec(long)为double类型
        rtt=tvrecv.tv_sec*1000+(double)tvrecv.tv_usec/1000; 
        printf("%d bytes from %s: icmp_seq=%u ttl=%d time=%.3f ms\n", len, inet_ntoa(from.sin_addr), icmp->icmp_seq, ip->ip_ttl, rtt);
    }
    else
        return -1;
}

void tv_sub(struct timeval *out, struct timeval *in)  
{
    if((out->tv_usec-=in->tv_usec)<0)
    {
        --out->tv_sec;
        out->tv_usec+=1000000;
    }
    out->tv_sec-=in->tv_sec;
}

main(int argc, char *argv[])  
{
    struct hostent *host;
    struct protoent *protocol;
    unsigned long inaddr=01;
    int waittime=MAX_WAIT_TIME;
    int size=50*1024;

    if(argc<2)
    {
        printf("usage: %s hostname/IP address\n", argv[0]);
        exit(1);
    }
    if((protocol=getprotobyname("icmp"))==NULL)
    {
        perror("getprotocolbyname");
        exit(1);
    }
    if((sockfd=socket(AF_INET, SOCK_RAW, protocol->p_proto))<0)
    {
        perror("socket error");
        exit(1);
    }
    setuid(getuid());
    setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));
    bzero(&dest_addr, sizeof(dest_addr));
    dest_addr.sin_family=AF_INET;
    inaddr=inet_addr(argv[1]);
    if(inaddr==INADDR_NONE)
    {
        if((host=gethostbyname(argv[1]))==NULL)
        {
            perror("gethostbyname error");
            exit(1);
        }
        memcpy((char *)&dest_addr.sin_addr, host->h_addr, host->h_length);
    }
    else
    {
        //memcpy((char *)&dest_addr, (char *)&inaddr, host->h_length);
        //源程序此处为BUG,因为if条件不满足则host->h_length为空值,运行时导致段错误
        //此处直接用sizeof取inaddr的长度即可
        //而且dest_addr不能强制转换为char*,应该是作者漏掉了成员.sin_addr
        memcpy((char *)&dest_addr.sin_addr, (char *)&inaddr, sizeof(inaddr));
    }
    pid=getpid();
    //由于在ICMP包中加入了时间统计数据,实际发送包大小是56+8字节
    printf("PING %s(%s): %d bytes data in ICMP packets.\n", argv[1], inet_ntoa(dest_addr.sin_addr), datalen+8);
    send_packet();
    //recv_packet()函数放到send_packet()函数里面,让程序可以及时显示发送进度
    //recv_packet();
    statistics(SIGALRM);
    return 0;
}

招牌疯子

Coder, OpenSource, DataStorageEngineer. Work@ByteDance
开源爱好者,zimg作者,大规模数据存储工程师。