Ping (Packet Internet Groper)是一种因特网包探索器,用于测试网络连接量的程序。本文将基于 Socket 编程,实现一个基本的 Ping 命令程序。

ICMP 报文分析

ICMP 报文捕获

在控制台输入 ping 202.195.147.248,对该目的主机发起请求,可以看到控制台输出了一系列统计信息:4 个数据包全部接收并且往返时间为 5 ms(较短),表明与该主机之间的连接畅通。

使用 Wireshark 工具捕获 icmp 数据包,为了避免无关数据包的干扰,可以使用 filter 对数据包进行过滤,在上部栏输入 ip.src == 202.195.147.248 or ip.dst == 202.195.147.248,表明只筛选源地址或目的地址为 202.195.147.248 的数据包,最终可以得到数据包的内容。

Wireshark 数据包分析

根据 ICMP 报文的格式进行分析:

  1. Type:数据包类型,占 1 Byte,为 0x00,代表回送报文。
  2. Code:代码部分,占 1 Byte,为 0x00.
  3. Checksum:检验和,占 2 Bytes,为 0x554c.
  4. Identifier(IE):占 2 Bytes,为 0x0001.
  5. Identifier(LE):占 2 Bytes,为 0x0100.
  6. Sequence Number(BE):占 2 Bytes,为 0x000f.
  7. Sequence Number(LE):占 2 Bytes,为 0x0f00.
  8. Data:占 32 Bytes,为6162636465666768696a6b6c6d6e6f7071727374757677616263646566676869.

实现思路

构造 ICMP 报文

自定义数据结构 icmpHeader 表示 ICMP 报文头部,包含类型、代码、检验和、标识符和序列号。

// ICMP 报文头
struct icmpHeader {
	unsigned char type; // 类型
	unsigned char code; // 代码
	unsigned short checkSum; // 检验和
	unsigned short id; // 标识符
	unsigned short sequence; // 序列号
};

填充该报文,类型为 8 表示请求报文。检验和使用特定的算法计算,关于算法的具体内容可以自行查看相关文档,在此不过多赘述。标识符使用进程 id 填充。最后在 ICMP 报文头的尾部,添加 32 字节的数据作为 ICMP 报文的数据部分。

// 构造 ICMP 报文
char sendBuf[8 + 32] = { 0 };
icmpHeader* pIcmp = (icmpHeader*)sendBuf;
pIcmp->type = 8;
pIcmp->code = 0;
pIcmp->checkSum = 0;
pIcmp->id = (USHORT)::GetCurrentProcessId();
pIcmp->sequence = 0;
// 填充数据部分
memcpy(sendBuf + 8, "abcdelmnopqrstuvwiammekakuactor", 32);
// 计算检验和
pIcmp->checkSum = computeCks((icmpHeader*)sendBuf, sizeof(sendBuf));

发送请求报文

该部分使用 Socket 编程向指定 IP 地址发送 ICMP 请求报文。需要注意的是,在创建套接字时,需要使用原始套接字,且 protocol 参数为 IPPROTO_ICMP,表明使用 ICMP 协议。SOCKET s = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); 报文发送成功后,接收从客户端发送的回送报文信息。

// 初始化套接字库
WORD wReq = MAKEWORD(2, 2);
WSADATA wsadata;
WSAStartup(wReq, &wsadata);
// 填充服务端地址
SOCKADDR_IN serverAddr;
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.S_un.S_addr = inet_addr(targetIP.c_str());
// 创建套接字
SOCKET s = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);

为了得到报文往返的时间,可以在发送前和接收后使用 GetTickCount64() 获取从操作系统启动到现在所经历的的时间 start 和 end,两时间相减得到时间差。

此外,由于 recvfrom() 在未收到报文时将会阻塞,因此可以使用 setsockopt() 设定一个接收超时时间,在超过指定时间未受到数据时返回 -1,表示接收异常。

setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeOut, sizeof(timeOut)); // 设置接收超时

解析回送报文

根据回送的 IP 数据包的指定格式对信息进行解析,IPv4 头部的 4 到 8 位为 IP 报文头部长度,第 9 个字节是 TTL 的值。

完整代码

#include<iostream>
#include<string>
#include<winsock.h>
#include<thread>
#pragma comment(lib,"ws2_32.lib")

// ICMP 报文头
struct icmpHeader {
	unsigned char type; // 类型
	unsigned char code; // 代码
	unsigned short checkSum; // 检验和
	unsigned short id; // 标识符
	unsigned short sequence; // 序列号
};

// 计算检验和
unsigned short computeCks(icmpHeader* picmp, int len) {
	long sum = 0;
	unsigned short* pusicmp = (unsigned short*)picmp;
	while (len > 1) {
		sum += *(pusicmp++);
		if (sum & 0x80000000)
			sum = (sum & 0xffff) + (sum >> 16);
		len -= 2;
	}
	if (len)
		sum += (unsigned short)*(unsigned char*)pusicmp;
	while (sum >> 16)
		sum = (sum & 0xffff) + (sum >> 16);
	return (unsigned short)~sum;
}

int ping(const std::string& targetIP) {
	// 初始化套接字库
	WORD wReq = MAKEWORD(2, 2);
	WSADATA wsadata;
	WSAStartup(wReq, &wsadata);
	// 填充服务端地址
	SOCKADDR_IN serverAddr;
	memset(&serverAddr, 0, sizeof(serverAddr));
	serverAddr.sin_family = AF_INET;
	serverAddr.sin_addr.S_un.S_addr = inet_addr(targetIP.c_str());
	// 创建套接字
	SOCKET s = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
	
	// 构造 ICMP 报文
	char sendBuf[8 + 32] = { 0 };
	icmpHeader* pIcmp = (icmpHeader*)sendBuf;
	pIcmp->type = 8;
	pIcmp->code = 0;
	pIcmp->checkSum = 0;
	pIcmp->id = (USHORT)::GetCurrentProcessId();
	pIcmp->sequence = 0;
	// 填充数据部分
	memcpy(sendBuf + 8, "abcdelmnopqrstuvwiammekakuactor", 32);
	// 计算检验和
	pIcmp->checkSum = computeCks((icmpHeader*)sendBuf, sizeof(sendBuf));
	
	// 发送报文
	DWORD start = GetTickCount64();
	int sendLen = sendto(s, sendBuf, sizeof(sendBuf), 0, (SOCKADDR*)&serverAddr, sizeof(SOCKADDR));
	if (sendLen < 0) printf("errno = %d\n", GetLastError());
	// 接收报文
	char recvBuf[1024];
	SOCKADDR_IN fromAddr;
	int fLen = sizeof(fromAddr);
	unsigned timeOut = 1000; // 超时时间
	setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeOut, sizeof(timeOut)); // 设置接收超时
	while (true) {
		int len = recvfrom(s, recvBuf, 1024, 0, (SOCKADDR*)&fromAddr, &fLen);
		if (len < 0) {
			std::cout << "请求超时" << std::endl;
			return INT32_MAX;
		}
		else break;
	}
	DWORD end = GetTickCount64();
	DWORD timeSpan = end - start;

	// 回送报文解析
	char ipInfo = recvBuf[0];
	// ipv4 头部的第 9 个字节为 TTL 的值
	unsigned char ttl = recvBuf[8];
	int ipHeadLen = ((char)(ipInfo << 4) >> 4) * 4; // IP报文头部长度
	icmpHeader* icmpResp = (icmpHeader*)(recvBuf + ipHeadLen);
	if (icmpResp->type == 0) { //回显应答报文
		printf("来自 %s 的回复:字节=32 时间=%2dms TTL=%d\n",
			targetIP.c_str(), timeSpan, ttl);
		return timeSpan;
	}
	else {
		printf("请求超时。type = %d\n", icmpResp->type);
		return INT32_MAX;
	}
}

int main() {
	std::cout << "请输入目的IP地址:";
	std::string IP;
	std::cin >> IP;
	int maxTime = INT32_MIN, minTime = INT32_MAX, timeSum = 0, acpkgCnt = 0;
	printf("\n正在 Ping %s 具有 32 字节的数据:\n", IP.c_str());
	for (int i = 0; i < 4; ++i) {
		std::this_thread::sleep_for(std::chrono::seconds(1));
		int timeSpan = ping(IP);
		acpkgCnt += timeSpan != INT32_MAX;
		maxTime = max(maxTime, timeSpan);
		minTime = min(minTime, timeSpan);
		timeSum += timeSpan;
	}
	printf("\n%s 的 Ping 统计信息:\n", IP.c_str());
    printf("    数据包: 已发送 = 4,已接收 = %d,丢失 = %d (%d%% 丢失),\n",
		acpkgCnt, 4 - acpkgCnt, (4 - acpkgCnt) * 100 / 4);
	if (!acpkgCnt) return 0;
    printf("往返行程的估计时间(以毫秒为单位):\n");
    printf("    最短 = %dms,最长 = %dms,平均 = %dms\n", minTime, maxTime, timeSum / acpkgCnt);
}

运行结果

可见,本地与该目的主机的连通性较好。

可见,本地与该目的主机无法连通。