Ping笔记(二)

Ping的c语言实现

函数介绍(续)

重要的函数: socket

int socket(int family, int type, int protocol); 返回值:成功时返回文件描述符 / 失败时返回-1 头文件: #include <sys/socket.h> 函数作用:创建一个套接字 一般为了执行网络I/O,一个进程必须做的第一件事就是调用socket函数

函数参数

第一个参数family:指明套接字中使用的协议族信息。 常见值有: 第二个参数type:指明套接口类型,也即套接字的数据传输方式。 常见值有: 在常见的使用socket进行网络编程中,经常使用SOCK_STREAM和SOCK_DGRAM,也就是TCP和UDP编程。在本项目中,我们将使用SOCK_RAW(原始套接字)。 原始套接字的主要作用在三个方面: 1.通过原始套接字发送/接收 ICMP 协议包。 2.接收发向本级的,但 TCP/IP 协议栈不能处理的IP包。 3.用来发送一些自己制定源地址特殊作用的IP包(自己写IP头)。 ping 命令使用的就是 ICMP 协议,因此我们不能直接通过建立一个 SOCK_STREAM或SOCK_DGRAM 来发送协议包,只能自己构建 ICMP 包通过 SOCK_RAW 来发送。 第三个参数 protocol:指明协议类型。 常见值有: 图片描述信息 参数 protocol 指明了所要接收的协议包。 如果指定了 IPPROTO_ICMP,则内核碰到ip头中 protocol 域和创建 socket 所使用参数 protocol 相同的 IP 包,就会交给我们创建的原始套接字来处理。 因此,一般来说,要想接收什么样的数据包,就应该在参数protocol里来指定相应的协议。当内核向我们创建的原始套接字交付数据包的时候,是包括整个IP头的,并且是已经重组好的IP包。如下所示: 图片描述信息 这里的数据也就是前面所说的时间戳。 但是,当我们发送IP包的时候,却不用自己处理IP首部,IP首部由内核自己维护,首部中的协议字段被设置成调用 socket 函数时传递给它的第三个参数。 我们发送 IP 包时,发送数据时从 IP 首部的第一个字节开始的,所以只需要构造一个如下所示的数据缓冲区就可以了。 图片描述信息 如果想自己处理 IP 首部,则需要设置 IP_HDRINCL 的 socket 选项,如下所示: int flag = 1; setsocketopt(sockfd, IPPROTO_TO, IP_HDRINCL, &flag, sizeof(int)); 此时,我们需要构造如下所示的数据缓冲区。 注意,我们自己填充 IP 首部时,也不是填充 IP 首部的所有字段,而是应该将 IP 首部的 id 字段设置为0,表示让内核来处理这个字段。同时,内核还会自动完成 IP 首部的校验和的计算并填充。 最后介绍发送和接收 IP 包的两个函数:recvfrom 和 sendto。

text
1
2
3
4
5
6
7
8
9
10
11
12
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void * buff, size_t nbytes, int flags, struct sockaddr * from, socklen_t * addrlen);

ssize_t sendto(int sockfd, const void * buff, size_t nbytes, int flags,const struct sockaddr * to, socklen_t addrlen);
成功时返回读写的字节数,失败时返回-1。

sockfd参数:套接字描述符。
buff参数:指向读入或写出缓冲区的指针。
nbytes参数:读写字节数。
flags参数:本项目中设置为0。
recvfrom 的 from 参数指向一个将由该函数在返回时填写数据发送者的地址信息的结构体,而该结构体中填写的字节数则放在 addrlen 参数所指的整数中。
sendto 的 to 参数指向一个含有数据报接收者的地址信息的结构体,其大小由addrlen参数指定。

校验和算法

检验和算法在 TCP/IP 协议族中是比较常见的算法。 IP、ICMP、UDP和TCP报文头部都有校验和字段,不过IP、TCP、UDP只针对首部计算校验和, 而 ICMP 对首部和报文数据一起计算校验和。 检验和算法可以分成两步来实现。 首先在发送端,有以下三步: 1.把校验和字段置为0。 2.对需要校验的数据看成以16bit为单位的数字组成,依次进行二进制求和。 3.将上一步的求和结果取反,存入校验和字段。 其次在接收端,也有相应的三步: 1. 对需要校验的数据看成以16bit为单位的数字组成,依次进行二进制求和,包括校验和字段。 2. 将上一步的求和结果取反。 3. 判断最终结果是否为0。如果为0,说明校验和正确。如果不为0,则协议栈会丢掉接收到的数据。 从上可以看出,归根到底,校验和算法就是二进制反码求和。由于先取反后相加与先相加后取反,得到的结果是一样的,所以上面的步骤都是先求和后取反。 下面用C语言来实现校验和算法,代码如下:

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* addr 指向需校验数据缓冲区的指针
* len 需校验数据的总长度(字节单位)
*/
unsigned short checkSum(unsigned short *addr, int len){
  unsigned int sum = 0;
  while(len > 1){
      sum += *addr++;
      len -= 2;
  }

  // 处理剩下的一个字节
  if(len == 1){
      sum += *(unsigned char *)addr;
  }

  // 将32位的高16位与低16位相加
  sum = (sum >> 16) + (sum & 0xffff);
  sum += (sum >> 16);

  return (unsigned short) ~sum;
}

上面的代码首先定义了一个32位无符号整型的变量sum,用来保存16bit二进制数字相加的结果,由于16bit相加可能会产生进位,所以这里使用32位变量来保存结果,其中高16bit保存的是相加产生的进位。 然后下面的 while 循环,对数据按16bit累加求和。 接下来的if语句判断是否还剩下8bit(一字节)。如果校验的数据为奇数个字节,会剩下最后一字节。把最后一个字节视为一个2字节数据的高字节,这个2字节数据的低字节为0,继续累加。 之后的两行代码作用是将 sum 高16bit的值加到低16bit上,即把累加中最高位的进位加到最低位上。(sum >> 16)将高16bit右移到低16bit,(sum & 0xffff)将高16bit全部置为0。注意,这两步都不会改变sum原来的值。 进行了两次相加可以保证 sum 高16bit都为0,没有进位了。 最后取反,并返回。 扩展: 为什么使用二进制反码求和,而不是原码或补码呢? 这是因为,使用反码计算校验和比较简单和快速。对于网络通信来说,最重要的就是效率和速度。

编码实现

整个程序的流程图如下所示: 图片描述信息 第一步,首先创建原始套接字。 第二步,封装 ICMP 报文,向目的IP地址发送 ICMP 报文,1秒后接收 ICM P响应报文,并打印 TTL,RTT。 第三步:循环第二步N次,本项目设置为5。 第四步:输出统计信息。

栗子:

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>
#include <arpa/inet.h>
#include <netdb.h>

#define ICMP_SIZE (sizeof(struct icmp))
#define ICMP_ECHO 8
#define ICMP_ECHOREPLY 0
#define BUF_SIZE 1024
#define NUM   5   // 发送报文次数

#define UCHAR unsigned char
#define USHORT unsigned short
#define UINT   unsigned int

// ICMP报文数据结构
struct icmp{
  UCHAR           type;     // 类型
  UCHAR           code;     // 代码
  USHORT         checksum; // 校验和
  USHORT         id;       // 标识符
  USHORT         sequence; // 序号
  struct timeval timestamp; // 时间戳
};

// IP首部数据结构
struct ip{
  // 主机字节序判断
  #if __BYTE_ORDER == __LITTLE_ENDIAN
  UCHAR   hlen:4;       // 首部长度
  UCHAR   version:4;     // 版本
  #endif
  #if __BYTE_ORDER == __BIG_ENDIAN
  UCHAR   version:4;
  UCHAR   hlen:4;
  #endif
  UCHAR   tos;             // 服务类型
  USHORT len;             // 总长度
  USHORT id;               // 标识符
  USHORT offset;           // 标志和片偏移
  UCHAR   ttl;           // 生存时间
  UCHAR   protocol;       // 协议
  USHORT checksum;       // 校验和
  struct in_addr ipsrc;   // 32位源ip地址
  struct in_addr ipdst;   // 32位目的ip地址
};

char buf[BUF_SIZE] = {0};

USHORT checkSum(USHORT *, int); // 计算校验和
float timediff(struct timeval *, struct timeval *); // 计算时间差
void pack(struct icmp *, int); // 封装一个ICMP报文
int unpack(char *, int, char *);       // 对接收到的IP报文进行解包

int main(int argc, char * argv[]){
  struct hostent *host;
  struct icmp sendicmp;
  struct sockaddr_in from;
  struct sockaddr_in to;
  int fromlen = 0;
  int sockfd;
  int nsend = 0;
  int nreceived = 0;
  int i, n;
  in_addr_t inaddr;

  memset(&from, 0, sizeof(struct sockaddr_in));
  memset(&to, 0, sizeof(struct sockaddr_in));

  if(argc < 2){
      printf("use : %s hostname/IP address \n", argv[0]);
      exit(1);
  }

  // 生成原始套接字
  if((sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)) == -1){
      printf("socket() error \n");
      exit(1);
  }

  // 设置目的地址信息
  to.sin_family = AF_INET;

  // 判断是域名还是ip地址
  if(inaddr = inet_addr(argv[1]) == INADDR_NONE){
      // 是域名
      if((host = gethostbyname(argv[1])) == NULL){
          printf("gethostbyname() error \n");
          exit(1);
      }
      to.sin_addr = *(struct in_addr *)host->h_addr_list[0];
  }else{
      // 是ip地址
      to.sin_addr.s_addr = inaddr;
  }

  // 输出域名ip地址信息
  printf("ping %s (%s) : %d bytes of data.\n", argv[1], inet_ntoa(to.sin_addr), (int)ICMP_SIZE);

  //循环发送报文、接收报文
  for(i = 0; i < NUM; i++){
      nsend++; // 发送次数加1
      memset(&sendicmp, 0, ICMP_SIZE);
      pack(&sendicmp, nsend);

      // 发送报文
      if(sendto(sockfd, &sendicmp, ICMP_SIZE, 0, (struct sockaddr *)&to, sizeof(to)) == -1){
          printf("sendto() error \n");
          continue;
      }

      // 接收报文
      if((n = recvfrom(sockfd, buf, BUF_SIZE, 0, (struct sockaddr *)&from, &fromlen)) < 0){
          printf("recvform() error \n");
          continue;
      }
      nreceived++; // 接收次数加1
      if(unpack(buf, n, inet_ntoa(from.sin_addr)) == -1){
          printf("unpack() error \n");
      }

      sleep(1);
  }

  // 输出统计信息
  printf("--- %s ping statistics ---\n", argv[1]);
  printf("%d packets transmitted, %d received, %%%d packet loss\n", nsend, nreceived,
          (nsend - nreceived) / nsend * 100);

  return 0;
}

/**
* addr 指向需校验数据缓冲区的指针
* len 需校验数据的总长度(字节单位)
*/
USHORT checkSum(USHORT *addr, int len){
  UINT sum = 0;
  while(len > 1){
      sum += *addr++;
      len -= 2;
  }

  // 处理剩下的一个字节
  if(len == 1){
      sum += *(UCHAR *)addr;
  }

  // 将32位的高16位与低16位相加
  sum = (sum >> 16) + (sum & 0xffff);
  sum += (sum >> 16);

  return (USHORT) ~sum;
}

/**
* 返回值单位:ms
* begin 开始时间戳
* end   结束时间戳
*/
float timediff(struct timeval *begin, struct timeval *end){
  int n;
  // 先计算两个时间点相差多少微秒
  n = ( end->tv_sec - begin->tv_sec ) * 1000000
      + ( end->tv_usec - begin->tv_usec );

  // 转化为毫秒返回
  return (float) (n / 1000);
}

/**
* icmp 指向需要封装的ICMP报文结构体的指针
* sequence 该报文的序号
*/
void pack(struct icmp * icmp, int sequence){
  icmp->type = ICMP_ECHO;
  icmp->code = 0;
  icmp->checksum = 0;
  icmp->id = getpid();
  icmp->sequence = sequence;
  gettimeofday(&icmp->timestamp, 0);
  icmp->checksum = checkSum((USHORT *)icmp, ICMP_SIZE);
}

/**
* buf 指向接收到的IP报文缓冲区的指针
* len 接收到的IP报文长度
* addr 发送ICMP报文响应的主机IP地址
*/
int unpack(char * buf, int len, char * addr){
  int i, ipheadlen;
  struct ip * ip;
  struct icmp * icmp;
  float rtt;         // 记录往返时间
  struct timeval end; // 记录接收报文的时间戳

  ip = (struct ip *)buf;

  // 计算ip首部长度,即ip首部的长度标识乘4
  ipheadlen = ip->hlen << 2;

  // 越过ip首部,指向ICMP报文
  icmp = (struct icmp *)(buf + ipheadlen);

  // ICMP报文的总长度
  len -= ipheadlen;

  // 如果小于ICMP报文首部长度8
  if(len < 8){
      printf("ICMP packets\'s length is less than 8 \n");
      return -1;
  }

  // 确保是我们所发的ICMP ECHO回应
  if(icmp->type != ICMP_ECHOREPLY ||
          icmp->id != getpid()){
      printf("ICMP packets are not send by us \n");
      return -1;
  }

  // 计算往返时间
  gettimeofday(&end, 0);
  rtt = timediff(&icmp->timestamp, &end);

  // 打印ttl,rtt,seq
  printf("%d bytes from %s : icmp_seq=%u ttl=%d rtt=%fms \n",
          len, addr, icmp->sequence, ip->ttl, rtt);

  return 0;

执行提示 socket() error 错误,也就是调用 socket 函数的时候出现了错误。这是因为我们创建的是原始套接字,原始套接字必须有 root 权限才能创建,所以我们可以加 sudo 执行 Ping笔记(一)

参考文献

参考文献一