C语言实现ping

学习C网络编程笔记

ping 命令

ping命令是用来查看网络上另一个主机系统的网络连接是否正常的一个工具。

1
2
3
4
ping www.baidu.com
PING www.a.shifen.com (180.97.33.107) 56(84) bytes of data.
64 bytes from 180.97.33.107: icmp_seq=1 ttl=50 time=42.2 ms
64 bytes from 180.97.33.107: icmp_seq=2 ttl=50 time=37.4 ms

ping与ICMP

要真正了解 ping 命令实现原理,就要了解 ping 命令所使用到的 TCP/IP 协议:ICMP 协议。
ICMP 是(Internet Control Message Protocol)Internet 控制报文协议。它是 TCP/IP 协议族的一个子协议,用于在 IP 主机、路由器之间传递控制消息。
控制消息有:目的不可达消息,超时信息,重定向消息,时间戳请求和时间戳响应消息,回显请求和回显应答消息。
ping 命令使用回显请求和回显应答消息。具体表现是向网络上的另一个主机系统发送 ICMP 报文,如果指定系统得到了报文,它将把报文一模一样地传回给发送者。
回显请求报文其中类型为 0,代码为 0。
回显应答报文其中类型为 8,代码为 0。
校验和字段:包括数据在内的整个 ICMP 协议数据包的校验和,具体实现方法会在下面详细介绍。
标识符字段:用于唯一标识 ICMP 报文,本项目使用程序的进程 id。因为如果同时在两个命令行终端执行 ping 命令的话,每个 ping 命令都会接收到所有的回显应答,所以需要根据标识符来判断回显应答是否应该接收。
序号字段:ICMP 报文的序号。
数据字段:也就是报文,本项目中我们将发送报文的时间戳放入数据字段,这样当接收到该报文应答的时候可以取出发送时间戳,将接收应答的时间戳减去发送时间戳就是报文往返时间(rtt)。提前预告一下,这里使用gettimeofday()API函数获取时间戳,详细介绍会在函数介绍部分说明。

数据结构

ICMP 报文 C 语言实现可以用下面的数据结构表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct icmp{
unsigned char type; // 类型
unsigned char code; // 代码
unsigned short checksum; // 校验和
unsigned short id; // 标识符
unsigned short sequence; // 序号
struct timeval timestamp; // 时间戳
};
```
系统发送ICMP报文时会将ICMP报文作为IP的数据,也就是放入IP报文格式的数据字段
```+C
struct ip{
unsigned char version:4; // 版本
unsigned char hlen:4; // 首部长度
unsigned char tos; // 服务类型
unsigned short len; // 总长度
unsigned short id; // 标识符
unsigned short offset; // 标志和片偏移
unsigned char ttl; // 生存时间
unsigned char protocol; // 协议
unsigned short checksum; // 校验和
struct in_addr ipsrc; // 32位源ip地址
struct in_addr ipdst; // 32位目的ip地址
};

地址信息表示

当我们编写网络应用程序时,必然要使用地址信息指定数据传输给网络上哪个主机,那么地址信息应该包含哪些呢?

  1. 地址族,基于IPv4的地址族还是IPv6的地址族。
  2. IP地址。
  3. 端口号。
    为了便于记录地址信息,系统定义了如下结构体:
    1
    2
    3
    4
    5
    6
    struct sockaddr_in{
    sa_family_t sin_family; // 地址族
    uint16_t sin_port; // 端口号
    struct in_addr sin_addr; // 32位IP地址
    char sin_zero[8]; // 不使用
    };

其中struct in_addr定义

1
2
3
struct in_addr{
in_addr_t s_addr; // 32位IP地址
};

in_addr_t使用如下宏指令定义,也就是无符号整型32位。

1
#define in_addr_t uint32_t

但实际上,还有一种结构体也可以表示地址信息,如下所示:

1
2
3
4
struct sockaddr{
sa_family_t sin_family; // 地址族
char sa_data[14]; // IP地址和端口
};

成员sa_data保存的信息包含IP地址和端口号,剩余部分填充0。
在网络编程中,常用的是struct sockaddr_in结构体,因为相对于struct sockaddr结构体,前者填充数据比较方便。
不过网络编程接口函数定义使用的是struct sockaddr结构体类型,这是由于最先使用的是struct sockaddr结构体,struct sockaddr_in结构体是后来为了方便填充地址信息数据定义。这就出现矛盾了,不过也不用担心上面两个结构体之间是可以相互转换的。定义地址信息时使用struct sockaddr_in结构体,然后将该结构体类型转为struct sockaddr结构体类型传递给网络编程接口函数即可。

相关函数

gettimeofday()

1
2
#include <sys/time.h>
int gettimeofday(struct timeval *tv, struct timezone *tz);

该函数的作用是把当前的时间放入struct timeval结构体中返回。

注意:

1.精确级别,微妙级别

2.受系统时间修改影响

3.返回的秒数是从1970年1月1日0时0分0秒开始

其参数tv是保存获取时间结果的结构体,参数tz用于保存时区结果。

结构体timeval的定义为:

1
2
3
4
5
struct timeval
{
long int tv_sec; // 秒数
long int tv_usec; // 微秒数
}

结构体timezone的定义为:

1
2
3
4
5
struct timezone
{
int tz_minuteswest;/*格林威治时间往西方的时差*/
int tz_dsttime; /*DST 时间的修正方式*/
}

timezone 参数若不使用则传入0即可,本项目传入0。

inet_addr函数。

1
2
#include <arpa/inet.h>
in_addr_t inet_addr(const char *string);

该函数的作用是将用点分十进制字符串格式表示的IP地址转换成32位大端序整型。
成功时返回32位大端序整型数值,失败时返回INADDR_NONE。

gethostbyname

1
2
#include <netdb.h>
struct hostent * gethostbyname(const char * hostname);

该函数的作用是根据域名获取IP地址。
成功时返回hostent结构体地址,失败时返回NULL指针。
struct hosten结构体定义如下:

1
2
3
4
5
6
7
struct hostent{
char * h_name;
char ** h_aliases;
char h_addrtype;
char h_length;
char ** h_addr_list;
};

我们最关心的是h_addr_list成员,它保存的就是域名对应IP地址。由于一个域名对应的IP地址不止一个,所以h_addr_list成员是char **类型,相当于二维字符数组。

socket。

1
2
#include <sys/socket.h>
int socket(int family, int type, int protocol);

校验

检验和算法可以分成两步来实现。

  1. 首先在发送端,有以下三步: 1.把校验和字段置为0。
  2. 对需要校验的数据看成以16bit为单位的数字组成,依次进行二进制求和。
  3. 将上一步的求和结果取反,存入校验和字段。
    其次在接收端,也有相应的三步:

  4. 对需要校验的数据看成以16bit为单位的数字组成,依次进行二进制求和,包括校验和字段。

  5. 将上一步的求和结果取反。

  6. 判断最终结果是否为0。如果为0,说明校验和正确。如果不为0,则协议栈会丢掉接收到的数据。

从上可以看出,归根到底,校验和算法就是二进制反码求和。由于先取反后相加与先相加后取反,得到的结果是一样的,所以上面的步骤都是先求和后取反。

下面用C语言来实现校验和算法,代码如下:

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秒后接收ICMP响应报文,并打印TTL,RTT。

第三步:循环第二步N次,本项目设置为5。

第四步输出统计信息。
完整代码:

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
233
234
235
236
#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;
}
将上面代码写入ping.c文件中,编译执行。