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_v4和proto_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_v4或send_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_v4或proc_v6处理。
下面我们就来看看send_v4和proc_v4函数是如何实现的。
send_v4函数发送ICMP回显请求报文,报文的格式如下:
我们通常将标识符字段设置为进程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程序效果如下: