《UNIX网络编程 卷1》 笔记: 原始套接字—ping程序

原始套接字可以提供普通的TCP和UDP套接字不支持的三个能力:
    1. 进程可以读写ICMPv4、IGMPv4、ICMPv6分组。
    2. 进程可以读写内核不处理其协议字段的IPv4数据报。

    3. 进程可以使用IP_HDRINCL套接字选项自行构造IPV4首部。

本节我们使用原始套接字来实现一个常用的程序:ping。为了同时支持ICMPv4和ICMPv6(这里不贴出ICMPv6相关的代码,读者可以在书中查阅),我们定义了一个如下的协议相关的proto结构:

struct proto {
	void (*fproc)(char *, ssize_t, struct msghdr *, struct timeval *); /*接收处理函数*/
	void (*fsend)(void); /*发送函数*/
	void (*finit)(void); /*初始化函数*/
	struct sockaddr *sasend; /*发送端套接字地址结构*/
	struct sockaddr *sarecv; /*接收端套接字地址结构*/
	socklen_t salen; /*套接字地址结构长度*/
	int icmpproto; /*ICMP协议版本*/
} *pr;
并定义了两个proto结构的变量proto_v4proto_v6

struct proto proto_v4 = 
	{proc_v4, send_v4, NULL, NULL, NULL, 0, IPPROTO_ICMP};

struct proto proto_v6 = 
	{proc_v6, send_v6, NULL, NULL, NULL, 0, IPPROTO_ICMPV6};
为发送ICMP回显请求报文我们定义了一些全局变量,意义如下:

#define BUFSIZE 	1500

char sendbuf[BUFSIZE]; //ICMP报文缓冲区

int datalen = 56; //ICMP报文数据长度(不包含ICMP首部)
char *host; //目的主机IP地址
int nsent; //序列号
pid_t pid; //进程号
int sockfd; //套接字描述符
int verbose;

定义ICMP数据的长度为56字节,加上ICMP首部的8字节,整个ICMP报文的长度就是64字节,与实际的ping程序一致。

主函数就是做一些初始化全局变量的工作,注册SIGALRM信号处理函数,然后根据参数host(目标主机名)是IPv4地址还是IPv6地址使用相应版本的proto结构,发送ICMP回显请求的功能是在readloop函数里实现的。

int main(int argc, char **argv)
{
	int c;
	struct addrinfo *ai;
	char *h;

	opterr = 0;
	while ((c = getopt(argc, argv, "v")) != -1) {
		switch (c) {
		case 'v':
			verbose++;
			break;
		case '?':
			err_quit("unrecognized option: %c", c);
		}
	}

	if (optind != argc - 1)
		err_quit("usage: ping [ -v ] ");
	host = argv[optind]; /*目标主机名*/
	pid = getpid() & 0xffff; /*进程号*/ 

	Signal(SIGALRM, sig_alrm);

	ai = Host_serv(host, NULL, 0, 0); /*获取主机名相关的addrinfo结构*/
	h = Sock_ntop_host(ai->ai_addr, ai->ai_addrlen); /*返回套接字关联的IP地址*/
	printf("PING %s (%s): %d data bytes\n", 
			ai->ai_canonname ? ai->ai_canonname : h, h, datalen);
	if (ai->ai_family == AF_INET) { /*根据IP协议版本号指定处理函数*/
		pr = &proto_v4;
	} else if (ai->ai_family == AF_INET6) {
		pr = &proto_v6;
	} else
		err_quit("unknown address family %d", ai->ai_family);
	pr->sasend = ai->ai_addr;
	pr->sarecv = Calloc(1, ai->ai_addrlen);
	pr->salen = ai->ai_addrlen;

	readloop();

	exit(0);
}

host_serv函数在名字与地址转换一节中实现,sock_ntop_host函数是将sockaddr结构中的IP地址数值格式转换为表达格式,支持IPv4和IPv6,代码如下:

char* sock_ntop_host(const struct sockaddr *sa, socklen_t salen)
{
    static char str[128];		/* Unix domain is largest */

	switch (sa->sa_family) {
	case AF_INET: {
		struct sockaddr_in	*sin = (struct sockaddr_in *) sa;
		/*IP地址数值格式转表达格式*/
		if (inet_ntop(AF_INET, &sin->sin_addr, str, sizeof(str)) == NULL)
			return(NULL);
		return str;
	}

	case AF_INET6: {
		struct sockaddr_in6	*sin6 = (struct sockaddr_in6 *) sa;

		if (inet_ntop(AF_INET6, &sin6->sin6_addr, str, sizeof(str)) == NULL)
			return(NULL);
		return(str);
	}
	default:
		snprintf(str, sizeof(str), "sock_ntop_host: unknown AF_xxx: %d, len %d",
				 sa->sa_family, salen);
		return str;
	}
    return NULL;
}

信号处理函数sig_alrm代码如下。它先调用send_v4send_v6发送相应的ICMP请求回显,然后又设置了1秒的定时器,这样每秒钟都会发送一个ICMP回显请求。

void sig_alrm(int signo)
{
    (*pr->fsend)();

    alarm(1);
    return;
}

第一个ICMP回显请求报文由readloop函数调用sig_alrm函数发出。在发送报文之前必须先创建一个ICMP类型的原始套接字。readloop函数代码如下:

void readloop(void)
{
	char recvbuf[BUFSIZE];
	char controlbuf[BUFSIZE];
	struct msghdr msg;
	struct iovec iov;
	ssize_t n;
	struct timeval tval;

	/*创建原始套接字,需要超级用户权限*/
	sockfd = Socket(pr->sasend->sa_family, SOCK_RAW, pr->icmpproto);
	setuid(getuid());
	if (pr->finit)
		(*pr->finit)();


	/*发送ICMP回显请求*/
	sig_alrm(SIGALRM);

	iov.iov_base = recvbuf;
	iov.iov_len = sizeof(recvbuf);
	msg.msg_name = pr->sarecv;
	msg.msg_iov = &iov;
	msg.msg_iovlen = 1;
	msg.msg_control = controlbuf;
	for ( ; ; ) {
		msg.msg_namelen = pr->salen;
		msg.msg_controllen = sizeof(controlbuf);
		n = recvmsg(sockfd, &msg, 0); /*接收到达接口的ICMP报文*/
		if (n < 0) { 
			if (errno == EINTR)
				continue;
			else
				err_sys("recvmsg error");
		}
		Gettimeofday(&tval, NULL); /*获取报文到达时间*/
		(*pr->fproc)(recvbuf, n, &msg, &tval); /*处理接收的报文*/
	}
}
在发送第一个ICMP回显请求后,它循环调用recvmsg接收ICMP报文,然后调用proc_v4proc_v6处理。

下面我们就来看看send_v4proc_v4函数是如何实现的。

send_v4函数发送ICMP回显请求报文,报文的格式如下:

《UNIX网络编程 卷1》 笔记: 原始套接字—ping程序_第1张图片

我们通常将标识符字段设置为进程ID号。序号字段从0开始,每发送一个报文递增1。为了计算报文往返时间RTT,我们将数据填充为发送时间戳。send_v4的代码如下:

void send_v4(void)
{
	int len;
	struct icmp *icmp;

	icmp = (struct icmp *)sendbuf;
	icmp->icmp_type = ICMP_ECHO; /*类型 = 8, 代码 = 0 请求回显*/
	icmp->icmp_code = 0;
	icmp->icmp_id = pid; /*标识符字段设置为发送进程的pid*/
	icmp->icmp_seq = nsent++; /*序列号*/
	memset(icmp->icmp_data, 0x0, datalen); /*数据长度58字节*/
	Gettimeofday((struct timeval *)icmp->icmp_data, NULL); /*填充发送时间戳*/
	len = 8 + datalen; /*ICMP报文长度64字节*/
	icmp->icmp_cksum = 0;
	icmp->icmp_cksum = in_cksum((u_short *)icmp, len); /*计算校验和*/
	Sendto(sockfd, sendbuf, len, 0, pr->sasend, pr->salen);
}
由于readloop函数创建原始套接字时IP_HDRINCL套接字选项未开启,因此我们构造的数据(sendbuf)是指IP首部之后的数据,IP首部由内核构造并添加到我们的数据之前。在这个例子中我们发送的以太网帧长是64 + 20 + 14 = 98字节。

在接收到ICMP报文时,我们调用proc_v4处理,打印出发送给本进程的ICMP回显应答。函数最后一个参数是readloop函数中获取的接收到报文时的时间戳,由此可以计算报文往返时间RTT。

void proc_v4(char *ptr, ssize_t len, struct msghdr *msg, 
					struct timeval *tvrecv)
{
	int hlen1, icmplen;
	double rtt;
	struct ip *ip;
	struct icmp *icmp;
	struct timeval *tvsend;

	/*验证报文合法性*/
	ip = (struct ip *)ptr;
	hlen1 = ip->ip_hl << 2;
	icmp = (struct icmp *)(ptr + hlen1);
	if ((icmplen = len - hlen1) < 8)
		return;

	
	if (icmp->icmp_type == ICMP_ECHOREPLY) { /*ICMP回显应答*/
		if (icmp->icmp_id != pid) /*只处理发送给本进程的回显应答*/
			return;
		if (icmplen < 16)
			return;
		/*获取报文发送时间*/
		tvsend = (struct timeval *)icmp->icmp_data;
		/*计算RTT*/
		tv_sub(tvrecv, tvsend);
		rtt = tvrecv->tv_sec * 1000.0 + tvrecv->tv_usec / 1000.0;
		/*打印出回显应答报文的数据长度,序列号ttl,报文往返时间TTL*/
		printf("%d bytes from %s: seq = %u, ttl = %d, rtt = %.3f ms\n", 
				icmplen, Sock_ntop_host(pr->sarecv, pr->salen), 
				icmp->icmp_seq, ip->ip_ttl, rtt);
	} else if (verbose) { /*打印其他类型的ICMP报文*/
		printf(" %d bytes from %s: type = %d, code = %d\n", 
				icmplen, Sock_ntop_host(pr->sarecv, pr->salen), 
				icmp->icmp_type, icmp->icmp_code);
	}
}
我们只关心发送给本进程的ICMP回显应答,如果开启了-v选项,那么我们打印其他类型的ICMP报文。

我们实现的ping程序效果如下:

《UNIX网络编程 卷1》 笔记: 原始套接字—ping程序_第2张图片

你可能感兴趣的:(UNIX网络编程,卷1)