1. ICMP协议(IP协议的助手 )

  • ping 是基于 ICMP 协议工作的,所以要明白 ping 的工作,首先我们先来熟悉 ICMP 协议。ICMP 报文是封装在 IP 包里面,它工作在网络层,是 IP 协议的助手ICMP报文封装在ip包里。它是一个对IP协议补充协议,允许主机路由器报告差错情况异常状况。由首部数据段组成。通过wireshark软件的使用加深对此的了解(差错报告、控制报文和请求应答报文)。

请求报文:

【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_ping


应答报文:

【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_网络协议_02

1.1 简介

  • ICMP 全称是 Internet Control Message Protocol,也就是互联网控制报文协议。里面有个关键词 —— 控制,如何控制的呢?
网络包在复杂的网络传输环境里,常常会遇到各种问题。
当遇到问题的时候,总不能死的不明不白,没头没脑的作风不是计算机网络的风格。
所以需要传出消息,报告遇到了什么问题,这样才可以调整传输策略,以此来控制整个局面。

1.2 功能

  • ICMP 主要的功能包括:
  • 确认 IP 包是否成功送达目标地址
  • 报告发送过程中 IP 包被废弃的原因和改善网络设置等
    在 IP 通信中如果某个 IP 包因为某种原因未能达到目标地址,那么这个具体的原因将由 ICMP 负责通知。

    如上图例子,主机 A 向主机 B 发送了数据包,由于某种原因,途中的路由器 2 未能发现主机 B 的存在,这时,路由器 2 就会向主机 A 发送一个 ICMP 目标不可达数据包,说明发往主机 B 的包未能成功。
    ICMP 的这种通知消息会使用 IP 进行发送 。
    因此,从路由器 2 返回的 ICMP 包会按照往常的路由控制先经过路由器 1 再转发给主机 A 。收到该 ICMP 包的主机 A 则分解 ICMP 的首部和数据域以后得知具体发生问题的原因。

1.3 包头格式

【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_网络_03

  • ICMP报文包含在IP数据报中,IP报头在ICMP报文的最前面。一个ICMP报文包括IP报头(至少20字节)ICMP报头(至少8字节)ICMP报文(属于ICMP报文的数据部分)。当IP报头中的协议字段值为1时,就说明这是一个ICMP报文。ICMP报头如下图所示。

1.3.1 内存排列

0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |     Type      |     Code      |          Checksum             |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Identifier          |        Sequence Number        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |     Optional Data ...                                         |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

1.3.2 ICMP结构体定义

struct icmp {
        uint8_t icmp_type;
        uint8_t icmp_code;
        uint16_t icmp_cksum;
        uint16_t icmp_id; // 设置为ping 进程的进程ID。
        uint16_t icmp_seq; // 每个发送出去的分组递增序列号。
    };
    
/*
 Type:8,Code:0:表示回显请求(ping请求)。
 Type:0,Code:0:表示回显应答(ping应答)
 说明:ICMP所有报文的前4个字节都是一样的,但是剩下的其他字节则互不相同。
 */

1.4 ICMP协议包类型

1.4.1 查询报文

  • 一类是用于诊断的查询消息,也就是「查询报文类型
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_网络_04


  • 查询报文类型

1.4.1.1 回送消息 —— 类型 0 和 8

  • 回送消息用于进行通信的主机或路由器之间,判断所发送的数据包是否已经成功到达对端的一种消息,ping 命令就是利用这个消息实现的。
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_tcp\ip_05

  • 可以向对端主机发送回送请求的消息(ICMP Echo Request Message,类型 8),也可以接收对端主机发回来的回送应答消息(ICMP Echo Reply Message,类型 0)。
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_ping_06


1.4.1.2 相比原生的 ICMP多了两个字段

1.4.1.2.1 标识符
  • 用以区分是哪个应用程序发 ICMP 包,比如用进程 PID 作为标识符;
1.4.1.2.2 序号
  • 序列号从 0 开始,每发送一次新的回送请求就会加 1, 可以用来确认网络包是否有丢失。
    在选项数据中,ping 还会存放发送请求的时间值,来计算往返时间,说明路程的长短。

1.4.2 差错报文

  • 一类是通知出错原因的错误消息,也就是「差错报文类型
  • 差错报文类型
    目标不可达消息 —— 类型 为 3原点抑制消息 —— 类型 4重定向消息 —— 类型 5超时消息 —— 类型 11

1.4.2.1 目标不可达消息(Destination Unreachable Message)

  • IP 路由器无法将 IP 数据包发送给目标地址时,会给发送端主机返回一个目标不可达的 ICMP 消息,并在这个消息中显示不可达的具体原因,原因记录在 ICMP 包头的代码字段。
    由此,根据 ICMP 不可达的具体消息,发送端主机也就可以了解此次发送不可达的具体原因。
    举例 6 种常见的目标不可达类型的代码
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_tcp\ip_07


1.4.2.1.1 网络不可达代码为 0
  • IP 地址是分为网络号主机号的,所以当路由器中的路由器表匹配不到接收方 IP 的网络号,就通过 ICMP 协议网络不可达(Network Unreachable)的原因告知主机。
1.4.2.1.2 主机不可达代码为 1
  • 当路由表中没有该主机的信息,或者该主机没有连接到网络,那么会通过 ICMP 协议以主机不可达(Host Unreachable)的原因告知主机。
1.4.2.1.3 协议不可达代码为 2
  • 当主机使用 TCP 协议访问对端主机时,能找到对端的主机了,可是对端主机的防火墙已经禁止 TCP 协议访问,那么会通过 ICMP 协议以协议不可达的原因告知主机。
1.4.2.1.4 端口不可达代码为 3
  • 当主机访问对端主机 8080 端口时,这次能找到对端主机了,防火墙也没有限制,可是发现对端主机没有进程监听 8080 端口,那么会通过 ICMP 协议以端口不可达的原因告知主机。
1.4.2.1.5 需要进行分片但设置了不分片位代码为 4
  • 发送端主机发送 IP 数据报时,将 IP 首部的分片禁止标志位设置为1。根据这个标志位,途中的路由器遇到超过 MTU 大小的数据包时,不会进行分片,而是直接抛弃。随后,通过一个 ICMP 的不可达消息类型,代码为 4 的报文,告知发送端主机。

1.4.2.2 原点抑制消息(ICMP Source Quench Message) —— 类型 4

  • 在使用低速广域线路的情况下,连接 WAN 的路由器可能会遇到网络拥堵的问题。为了缓和这种拥堵情况。
    当路由器向低速线路发送数据时,其发送队列的缓存变为零而无法发送出去时,可以向 IP 包的源地址发送一个 ICMP 原点抑制消息。收到这个消息的主机借此了解在整个线路的某一处发生了拥堵的情况,从而增大 IP 包的传输间隔,减少网络拥堵的情况。
    然而,由于这种 ICMP 可能会引起不公平的网络通信,一般不被使用。

1.4.2.3 重定向消息(ICMP Redirect Message) —— 类型 5

  • 如果路由器发现发送端主机使用了「不是最优」的路径发送数据,那么它会返回一个 ICMP 重定向消息给这个主机。
    在这个消息中包含了最合适的路由信息和源数据。这主要发生在路由器持有更好的路由信息的情况下。路由器会通过这样的 ICMP 消息告知发送端,让它下次发给另外一个路由器。

1.4.2.4 超时消息(ICMP Time Exceeded Message) —— 类型 11

  • IP 包中有一个字段叫做 TTL (Time To Live,生存周期),它的值随着每经过一次路由器就会减 1,直到减到 0 时IP 包会被丢弃。此时,路由器将会发送一个 ICMP 超时消息给发送端主机,并通知该包已被丢弃。设置 IP 包生存周期的主要目的,是为了在路由控制遇到问题发生循环状况时,避免 IP 包无休止地在网络上被转发
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_icmp_08


2. ping 命令(查询报文类型的使用)
  • ping程序使用ICMP协议的强制回显请求数据报以使主机网关发送一份 ICMP 的回显应答回显请求数据报含有一个 IPICMP的报头,后跟一个时间值关键字然后是一段任意长度的填充字节用于把保持分组长度为16的整数倍。接下来,我们重点来看 ping 的发送和接收过程。
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_ping_09

  • ICMP规则要求在回射应答中返回来自回射请求的标识符、序列号和任何可选数据。在回射请求中存放时间戳使得我们可以在收到回射应答时计算RTT
    同个子网下的主机 A 和 主机 B,主机 A 执行ping 主机 B 后,我们来看看其间发送了什么?

2.1 主机 A ping 主机 B

2.1.1 构造icmp头

  • ping 命令执行的时候,源主机首先会构建一个 ICMP 回送请求消息数据包
    ICMP 数据包内包含多个字段,最重要的是两个
    第一个是类型,对于回送请求消息而言该字段为 8
    另外一个是序号,主要用于区分连续 ping 的时候发出的多个数据包。每发出一个请求数据包,序号会自动加 1。为了能够计算往返时间 RTT,它会在报文的数据部分插入发送时间
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_tcp\ip_10


2.1.2 添加IP头

  • 然后,由 ICMP 协议将这个数据包连同地址 192.168.1.2 一起交给 IP 层。IP 层将以 192.168.1.2 作为目的地址,本机 IP 地址作为源地址,协议字段设置为 1 表示是 ICMP 协议,再加上一些其他控制信息,构建一个 IP 数据包
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_网络协议_11


2.1.3 添加MAC头

  • 接下来,需要加入 MAC 头。如果在本地 ARP 映射表中查找出 IP 地址 192.168.1.2 所对应的 MAC 地址,则可以直接使用;如果没有,则需要发送 ARP 协议查询 MAC 地址,获得 MAC 地址后,由数据链路层构建一个数据帧,目的地址是 IP 层传过来的 MAC 地址,源地址则是本机的 MAC 地址;还要附加上一些控制信息,依据以太网的介质访问规则,将它们传送出去。
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_ping_12


2.1.4 检查数据包

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

2.1.5 构建 ICMP响应消息数据包

  • 主机 B 会构建一个 ICMP 回送响应消息数据包,回送响应数据包的类型字段为 0,序号为接收到的请求数据包中的序号,然后再发送出去给主机 A。
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_网络协议_13

  • 在规定的时候间内,源主机如果没有接到 ICMP 的应答包,则说明目标主机不可达;如果接收到了 ICMP 回送响应消息,则说明目标主机可达。
    此时,源主机会检查,用当前时刻减去该数据包最初从源主机上发出的时刻,就是 ICMP 数据包的时间延迟。
    针对上面发送的事情,总结成了如下图:
  • 【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_网络协议_14

  • 但是对于 ICMP 的头来讲,是没什么影响的。会影响的是根据目标 IP 地址,选择路由的下一跳,还有每经过一个路由器到达一个新的局域网,需要换 MAC 头里面的 MAC 地址。
    说了这么多,可以看出 ping 这个程序是使用了 ICMP 里面的 ECHO REQUEST(类型为 8 ) 和 ECHO REPLY (类型为 0)。
3. icmp协议发送和接收

3.1 原始套接字创建

if (ip_version == IP_V4 || ip_version == IP_VERISON_ANY) {
        memset(&addrinfo_hints, 0, sizeof(addrinfo_hints));
        addrinfo_hints.ai_family = AF_INET;
        addrinfo_hints.ai_socktype = SOCK_RAW;
        addrinfo_hints.ai_protocol = IPPROTO_ICMP;
        gai_error = getaddrinfo(target_host,
                                NULL,
                                &addrinfo_hints,
                                &addrinfo_head);
    }

    if (ip_version == IP_V6
        || (ip_version == IP_VERISON_ANY && gai_error != 0)) {
        memset(&addrinfo_hints, 0, sizeof(addrinfo_hints));
        addrinfo_hints.ai_family = AF_INET6;
        addrinfo_hints.ai_socktype = SOCK_RAW;
        addrinfo_hints.ai_protocol = IPPROTO_ICMPV6;
        gai_error = getaddrinfo(target_host,
                                NULL,
                                &addrinfo_hints,
                                &addrinfo_head);
    }

    if (gai_error != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(gai_error));
        goto error_exit;
    }

    for (addrinfo = addrinfo_head;
         addrinfo != NULL;
         addrinfo = addrinfo->ai_next) {
        sockfd = socket(addrinfo->ai_family,
                        addrinfo->ai_socktype,
                        addrinfo->ai_protocol);
        if (sockfd >= 0) {
            break;
        }
    }

    if (sockfd < 0) {
        fprint_net_error(stderr, "socket");
        goto error_exit;
    }

    switch (addrinfo->ai_family) {
        case AF_INET:
            addr = &((struct sockaddr_in *)addrinfo->ai_addr)->sin_addr;
            break;
        case AF_INET6:
            addr = &((struct sockaddr_in6 *)addrinfo->ai_addr)->sin6_addr;
            break;
    }

    inet_ntop(addrinfo->ai_family,
              addr,
              addrstr,
              sizeof(addrstr));


    if (fcntl(sockfd, F_SETFL, O_NONBLOCK) == -1) {
        fprint_net_error(stderr, "fcntl");
        goto error_exit;
    }
  • 创建一个套接字涉及如下步骤:
    1、IPV4第一个参数为AF_INETIPV6第一个参数为AF_INET6
    2、不管是IPV4、IPV6把第二个参数指定为SOCK_RAW
    3、第三参数(协议)通常不为0,例如:IPPROTO_XXX的某个常值,IPV4参数选择IPPROTO_ICMP,IPV6参数选择IPPROTO_ICMPV6
    4、调用socket函数,创建一个原始套接字,
    5、然后调用getaddrinfo函数,它是协议无关的,既可用于IPv4也可用于IPv6。能够处理名字到地址以及服务到端口这两种转换,返回的是一个 struct addrinfo 的结构体(列表)指针而不是一个地址清单。

3.2 构造并发送回射请求

uint16_t id = (uint16_t)getpid();
uint16_t seq;

for (seq = 0; ; seq++) {
        struct icmp icmp_request = {0};
        int send_result;
        char recv_buf[MAX_IP_HEADER_SIZE + sizeof(struct icmp)];
        int recv_size;
        int recv_result;
        socklen_t addrlen;
        uint8_t ip_vhl;
        uint8_t ip_header_size;
        struct icmp *icmp_response;
        uint64_t start_time;
        uint64_t delay;
        uint16_t checksum;
        uint16_t expected_checksum;

        if (seq > 0) {
            usleep(REQUEST_INTERVAL);
        }

        icmp_request.icmp_type =
            addrinfo->ai_family == AF_INET6 ? ICMP6_ECHO : ICMP_ECHO;
        icmp_request.icmp_code = 0;
        icmp_request.icmp_cksum = 0;
        icmp_request.icmp_id = htons(id);
        icmp_request.icmp_seq = htons(seq);

        switch (addrinfo->ai_family) {
            case AF_INET:
                icmp_request.icmp_cksum =
                    compute_checksum((const char *)&icmp_request,
                                     sizeof(icmp_request));
                break;
            case AF_INET6: {
                struct {
                    struct ip6_pseudo_hdr ip6_hdr;
                    struct icmp icmp;
                } data = {0};

                data.ip6_hdr.ip6_src.s6_addr[15] = 1; /* ::1 (loopback) */
                data.ip6_hdr.ip6_dst =
                    ((struct sockaddr_in6 *)&addrinfo->ai_addr)->sin6_addr;
                data.ip6_hdr.ip6_plen = htonl((uint32_t)sizeof(struct icmp));
                data.ip6_hdr.ip6_nxt = IPPROTO_ICMPV6;
                data.icmp = icmp_request;

                icmp_request.icmp_cksum =
                    compute_checksum((const char *)&data, sizeof(data));
                break;
            }
        }

        send_result = sendto(sockfd,
                             (const char *)&icmp_request,
                             sizeof(icmp_request),
                             0,
                             addrinfo->ai_addr,
                             (int)addrinfo->ai_addrlen);
        if (send_result < 0) {
            fprint_net_error(stderr, "sendto");
            goto error_exit;
        }

        printf("Sent ICMP echo request to %s\n", addrstr);
        
        switch (addrinfo->ai_family) {
            case AF_INET:
                recv_size = (int)(MAX_IP_HEADER_SIZE + sizeof(struct icmp));
                break;
            case AF_INET6:
                /* When using IPv6 we don't receive IP headers in recvfrom. */
                recv_size = (int)sizeof(struct icmp);
                break;
        }
  • 构造ICMPV4、ICMPV6消息,把标识符字段设置为本进程ID

3.3 校验和计算

  • 为了计算ICMP校验和,参考http://tools.ietf.org/html/rfc1071
static uint16_t compute_checksum(const char *buf, size_t size) {
    size_t i;
    uint64_t sum = 0;

    for (i = 0; i < size; i += 2) {
        sum += *(uint16_t *)buf;
        buf += 2;
    }
    if (size - i > 0) {
        sum += *(uint8_t *)buf;
    }

    while ((sum >> 16) != 0) {
        sum = (sum & 0xffff) + (sum >> 16);
    }

    return (uint16_t)~sum;
}
  • 有效的校验和实现对于良好的性能至关重要。随着实施技术的进步,其余的协议处理中,校验和计算成为其中之一。

3.4 计算时间戳

static uint64_t get_time(void) {

struct timeval now;
return gettimeofday(&now, NULL) != 0
	? 0
	: now.tv_sec * 1000000 + now.tv_usec;

}

3.5 处理所接收的ICMP消息

start_time = get_time();/*回射请求中的时间戳*/

        for (;;) {
        	/*通过从当前时间减去消息发送时间,*/
            delay = get_time() - start_time;

            addrlen = (int)addrinfo->ai_addrlen;
            recv_result = recvfrom(sockfd,
                                   recv_buf,
                                   recv_size,
                                   0,
                                   addrinfo->ai_addr,
                                   &addrlen);
            if (recv_result == 0) {
                printf("Connection closed\n");
                break;
            }
            if (recv_result < 0) {

                if (errno == EAGAIN) {

                    if (delay > REQUEST_TIMEOUT) {
                        printf("Request timed out\n");
                        break;
                    } else {
                        /* No data available yet, try to receive again. */
                        continue;
                    }
                } else {
                    fprint_net_error(stderr, "recvfrom");
                    break;
                }
            }

            switch (addrinfo->ai_family) {
                case AF_INET:
                    /* 与IPv6相比,对于IPv4连接,我们确实在传入数据报中接收IP标头。
                     * VHL = version (4 bits) + header length (lower 4 bits).
                     */
                    ip_vhl = *(uint8_t *)recv_buf;
                    /*将IPV4熟不长度字段乘以4得出IPV4首部以字节为单位的大小*/
                    ip_header_size = (ip_vhl & 0x0F) * 4;
                    break;
                case AF_INET6:
                    ip_header_size = 0;
                    break;
            }
			/*把ICMP设置成指向ICMP首部的开始位置*/
            icmp_response = (struct icmp *)(recv_buf + ip_header_size);
            icmp_response->icmp_cksum = ntohs(icmp_response->icmp_cksum);
            icmp_response->icmp_id = ntohs(icmp_response->icmp_id);
            icmp_response->icmp_seq = ntohs(icmp_response->icmp_seq);
			/*如果所处理的消息是一个ICMP回射应答,那么我们必须检查标识符字段,判断该应答是否响应于由本进程的发出请求*/
            if (icmp_response->icmp_id == id
                && ((addrinfo->ai_family == AF_INET
                        && icmp_response->icmp_type == ICMP_ECHO_REPLY)
                    ||
                    (addrinfo->ai_family == AF_INET6
                        && (icmp_response->icmp_type != ICMP6_ECHO
                            || icmp_response->icmp_type != ICMP6_ECHO_REPLY))
                )
            ) {
                break;
            }
        }

        if (recv_result <= 0) {
            continue;
        }

        checksum = icmp_response->icmp_cksum;
        icmp_response->icmp_cksum = 0;

        switch (addrinfo->ai_family) {
            case AF_INET:
                expected_checksum =
                    compute_checksum((const char *)icmp_response,
                                     sizeof(*icmp_response));
                break;
            case AF_INET6: {
                struct {
                    struct ip6_pseudo_hdr ip6_hdr;
                    struct icmp icmp;
                } data = {0};

                /* 需要以某种方式获取源地址和目标地址*/

                data.ip6_hdr.ip6_plen = htonl((uint32_t)sizeof(struct icmp));
                data.ip6_hdr.ip6_nxt = IPPROTO_ICMPV6;
                data.icmp = *icmp_response;

                expected_checksum =
                    compute_checksum((const char *)&data, sizeof(data));
                break;
            }
        }

        printf("Received ICMP echo reply from %s: seq=%d, time=%.3f ms",
               addrstr,
               icmp_response->icmp_seq,
               delay / 1000.0);

3.6 编译运行

使用原始套接字通常需要管理特权,因此您将需要以root用户身份运行ping:

【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_网络协议_15

3.7 捕获数据包

tcpdump -i any -w ping.pcap -v icmp

【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_tcp\ip_16

3.8 wireshark打开ping报文

【genius_platform软件平台开发】第四十五讲:ICMP协议讲解与ping命令的关系和实现_tcp\ip_17

3. 总结
  • 本文所讲的是实现一个ping命令,ping诊断工具使用原始套接字完成任务,开发这个ping程序支持IPV4、IPV6版本。
    写这篇文章主要的目标是熟悉原始套接字编程的基本流程,理解ping程序的实现机制,理解ICMP协议。

参考:1、UNIX网络编程
2、https://tools.ietf.org/html/rfc1071
3、https://tools.ietf.org/html/rfc2463#section-2.3