下面我将分步讲解如何实现一个简化版的 ping 程序。

(图片来源网络,侵删)
ping 的工作原理
我们需要理解 ping 的核心工作流程:
- 构造 ICMP 数据包:
ping程序构造一个 ICMP "回显请求" (Echo Request) 数据包,这个数据包包含了:- ICMP 头部:类型(8)、代码(0)、校验和、标识符、序列号等。
- 有效载荷:通常是发送方的一些数据,比如时间戳,用于计算往返时间。
- 发送数据包:程序将这个 ICMP 数据包通过原始套接字 发送到目标主机的 IP 地址。
- 接收响应:程序在同一原始套接字上等待接收数据,当目标主机收到回显请求后,会返回一个 ICMP "回显应答" (Echo Reply) 数据包。
- 解析响应:程序收到数据包后,解析其内容,验证它是否是对之前发送请求的应答。
- 计算时间:比较请求和应答中的时间戳,计算出往返时间。
- 显示结果:将结果(如 "64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=15.2 ms")打印到屏幕上。
关键技术和数据结构
1 所需头文件
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <netinet/ip.h> // IP 头部 #include <netinet/ip_icmp.h> // ICMP 头部 #include <arpa/inet.h> #include <sys/time.h> // 用于 gettimeofday() #include <errno.h>
2 ICMP 数据包结构
一个完整的 ICMP Echo Request 包包含 IP 头部和 ICMP 头部,在 Linux 中,我们可以使用系统定义的结构体来方便地构造它们。
// ICMP 头部 (已包含在 <netinet/ip_icmp.h> 中)
struct icmp {
uint8_t icmp_type; // 类型, 8 for Echo Request
uint8_t icmp_code; // 代码, 0 for Echo Request
uint16_t icmp_cksum; // 校验和
uint16_t icmp_id; // 标识符
uint16_t icmp_seq; // 序列号
// ... 后面是时间戳等数据
};
// IP 头部 (已包含在 <netinet/ip.h> 中)
struct ip {
// ... (我们不需关心所有字段)
uint8_t ip_hl:4; // 头部长度 (以 32 位字为单位)
uint8_t ip_v:4; // 版本 (IPv4)
// ...
};
3 校验和计算
校验和是网络协议中确保数据完整性的重要部分,ICMP 和 IP 头部都需要计算校验和。
计算步骤:

(图片来源网络,侵删)
- 将校验和字段先置为 0。
- 将数据部分视为一系列的 16 位(2字节)整数,并将它们相加(如果数据长度为奇数,则在末尾补一个 0 字节)。
- 如果相加结果超过 16 位(即溢出),则将溢出的部分加到低 16 位上(这个过程称为“回卷”)。
- 将最终结果的 16 位取反,得到校验和。
下面是一个通用的校验和计算函数:
// 校验和计算函数
unsigned short calculate_checksum(unsigned short *addr, int len) {
long sum = 0;
while (len > 1) {
sum += *addr++;
len -= 2;
}
// 如果长度是奇数,加上最后一个字节
if (len == 1) {
sum += *(unsigned char *)addr;
}
// 回卷
sum = (sum >> 16) + (sum & 0xffff);
sum += (sum >> 16);
// 取反
return ~sum;
}
C 语言实现代码
下面是一个功能完整的 ping 程序的源代码,代码包含了详细的注释来解释每一步的作用。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <arpa/inet.h>
#include <sys/time.h>
#include <errno.h>
#include <signal.h>
#define PACKET_SIZE 64
#define DEFAULT_TTL 64
// 全局变量,用于 Ctrl+C 退出
volatile int keep_running = 1;
void signal_handler(int signum) {
if (signum == SIGINT) {
printf("\nPing program terminated.\n");
keep_running = 0;
}
}
// 校验和计算函数
unsigned short calculate_checksum(unsigned short *addr, int len) {
long sum = 0;
while (len > 1) {
sum += *addr++;
len -= 2;
}
if (len == 1) {
sum += *(unsigned char *)addr;
}
sum = (sum >> 16) + (sum & 0xffff);
sum += (sum >> 16);
return ~sum;
}
// 构造 ICMP 包
void build_icmp_packet(char *packet, int packet_size, int seq_num) {
struct icmp *icmp_hdr = (struct icmp *)packet;
struct timeval *tval;
icmp_hdr->icmp_type = ICMP_ECHO;
icmp_hdr->icmp_code = 0;
icmp_hdr->icmp_cksum = 0;
icmp_hdr->icmp_id = htons(getpid()); // 使用 PID 作为标识符
icmp_hdr->icmp_seq = htons(seq_num);
// 在数据部分填充时间戳
tval = (struct timeval *)icmp_hdr->icmp_data;
gettimeofday(tval, NULL);
// 计算校验和
icmp_hdr->icmp_cksum = calculate_checksum((unsigned short *)icmp_hdr, packet_size);
}
// 解析接收到的 IP 包
void parse_ip_packet(char *recv_buf, int recv_len, struct sockaddr_in *from_addr) {
struct ip *ip_hdr = (struct ip *)recv_buf;
int ip_hdr_len = ip_hdr->ip_hl * 4;
struct icmp *icmp_hdr = (struct icmp *)(recv_buf + ip_hdr_len);
// 确保是 ICMP 包
if (icmp_hdr->icmp_type != ICMP_ECHOREPLY) {
return;
}
// 确保是来自我们自己的请求的应答
if (icmp_hdr->icmp_id != htons(getpid())) {
return;
}
// 获取发送时间
struct timeval *sent_time = (struct timeval *)icmp_hdr->icmp_data;
struct timeval current_time;
gettimeofday(¤t_time, NULL);
// 计算往返时间 (RTT)
long rtt = (current_time.tv_sec - sent_time->tv_sec) * 1000 +
(current_time.tv_usec - sent_time->tv_usec) / 1000;
printf("%d bytes from %s: icmp_seq=%d ttl=%d time=%ld ms\n",
recv_len - ip_hdr_len,
inet_ntoa(from_addr->sin_addr),
ntohs(icmp_hdr->icmp_seq),
ip_hdr->ip_ttl,
rtt);
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <hostname or IP>\n", argv[0]);
exit(EXIT_FAILURE);
}
// 注册信号处理,用于 Ctrl+C
signal(SIGINT, signal_handler);
// 1. 创建原始套接字 (需要 root 权限)
int sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
if (sockfd < 0) {
perror("socket");
fprintf(stderr, "This program requires root privileges to create a raw socket.\n");
exit(EXIT_FAILURE);
}
// 2. 设置 TTL
if (setsockopt(sockfd, IPPROTO_IP, IP_TTL, &DEFAULT_TTL, sizeof(DEFAULT_TTL)) < 0) {
perror("setsockopt");
close(sockfd);
exit(EXIT_FAILURE);
}
// 3. 获取目标主机 IP
struct sockaddr_in dest_addr;
memset(&dest_addr, 0, sizeof(dest_addr));
dest_addr.sin_family = AF_INET;
if (inet_pton(AF_INET, argv[1], &dest_addr.sin_addr) <= 0) {
// 如果不是 IP 地址,尝试解析主机名
struct hostent *host = gethostbyname(argv[1]);
if (host == NULL) {
herror("gethostbyname");
close(sockfd);
exit(EXIT_FAILURE);
}
memcpy(&dest_addr.sin_addr, host->h_addr, host->h_length);
}
printf("PING %s (%s): %d data bytes\n", argv[1], inet_ntoa(dest_addr.sin_addr), PACKET_SIZE);
char send_packet[PACKET_SIZE];
char recv_packet[4096]; // 接收缓冲区要足够大
struct sockaddr_in from_addr;
socklen_t from_len = sizeof(from_addr);
int seq_num = 0;
// 4. 循环发送和接收
while (keep_running) {
seq_num++;
// 构造 ICMP 包
memset(send_packet, 0, sizeof(send_packet));
build_icmp_packet(send_packet, sizeof(send_packet), seq_num);
// 发送包
if (sendto(sockfd, send_packet, sizeof(send_packet), 0,
(struct sockaddr *)&dest_addr, sizeof(dest_addr)) <= 0) {
perror("sendto");
continue;
}
// 设置接收超时 (1秒)
struct timeval tv;
tv.tv_sec = 1;
tv.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof tv);
// 接收包
int recv_len = recvfrom(sockfd, recv_packet, sizeof(recv_packet), 0,
(struct sockaddr *)&from_addr, &from_len);
if (recv_len > 0) {
parse_ip_packet(recv_packet, recv_len, &from_addr);
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("Request timeout for icmp_seq=%d\n", seq_num);
} else {
perror("recvfrom");
}
}
// 间隔 1 秒
sleep(1);
}
close(sockfd);
return 0;
}
如何编译和运行
-
保存代码:将上面的代码保存为
my_ping.c。 -
编译: 由于我们使用了原始套接字,编译时需要链接
resolv库(用于gethostbyname)。
(图片来源网络,侵删)gcc my_ping.c -o my_ping -lresolv
-
运行: 注意:创建原始套接字需要
root权限。sudo ./my_ping <目标主机名或IP>
sudo ./my_ping www.baidu.com # 或 sudo ./my_ping 8.8.8.8
代码的局限性
这个简化版的 ping 程序与系统自带的 ping 命令相比,有一些局限性:
- 权限要求:必须使用
sudo运行,因为创建原始套接字需要超级用户权限。 - ICMP 过滤:有些网络策略或防火墙可能会过滤掉 ICMP 包,导致程序无法正常工作,系统自带的
ping可能使用其他技巧(如原始 IP 套接字)来绕过。 - 功能简化:没有实现
-c(发送指定次数后退出)、-i(间隔时间)、-s(包大小)等常用参数。 - 错误处理:对各种网络错误的处理比较简单。
- 数据包分片:没有处理 IP 数据包分片的情况。
通过实现这个 C 语言的 ping 程序,你可以学到:
- 原始套接字 的创建和使用。
- ICMP 协议 的数据包结构和字段含义。
- 校验和 的计算原理和实现。
- 网络字节序 和 主机字节序 的转换 (
htons,ntohs)。 gettimeofday用于高精度计时。inet_pton和gethostbyname用于主机名和 IP 地址的转换。setsockopt用于设置套接字选项(如 TTL 和超时)。
这是一个非常经典且富有教育意义的网络编程项目。
