简介:面向C语言网络编程学习者,提供一份用C语言实现Ping命令功能的精简示例,以解决ICMP协议报文构造、原始套接字使用及往返时间计算等核心问题,可作为计算机网络课程设计、网络实验或底层协议入门的参考,适合学生、运维及网络开发人员快速上手。资源共2个文件,以cpp源码和txt说明为主,压缩包仅2KB,其中cpp代码可直接编译测试,txt文档对实现步骤与关键函数作了配套讲解。已有414人学习,内容涵盖从创建原始套接字到解析应答报文的完整流程,包括头部填充、校验和计算、发送接收及超时处理。通过这份代码,读者可以掌握socket原始套接字编程、ICMP头部填充与校验和构造方法,并能够基于gettimeofday计算往返时间,为后续扩展诸如TCP/UDP探测或网络监测工具打下基础。
1. 现成的 ping 命令一键就通,为什么还有人用 C 语言把它重写一遍
接手一台 CentOS 7 虚拟机,报错ping: www.baidu.com: temporary failure in name resolution的时候,先别急着怪网络,这八个字告诉你域名解析挂了,而不是 ICMP 不通。换个角度看,ping 命令能暴露的问题,往往比“丢包”两个字多得多。系统里明明有现成的 ping,为什么还有人在学“用 C 语言实现 Ping 程序功能”?因为自带 ping 是个黑匣子,你看到的是输出行,看不到它背后的 ICMP 构造、校验和、超时重传和 RTT 计时。这篇文章就带你把这一层拆开,从原理到可编译代码,在 Linux 上完整走一遍。
这篇适合三类人:想把 ping 命令的功能和用法吃透底层原理的 C 语言初学者,准备网络编程面试的人,以及需要自己造带日志、批量检测工具的一线工程师。不管那个 .rar 包里源码注释多还是少,下面这份能落地的实现路径都是可靠的参考。
2. 原理先立住:ICMP 回显报文结构与原始套接字的选型
2.1 为什么 Ping 程序不用 TCP/UDP,非要用 ICMP
很多第一次写网络程序的 C 语言学习者会惯性去想:我要“连”目标机器,是不是该用 socket 建 TCP 连接?这是常见的误区。Ping 要验证的是网络层和链路层通不通,而 TCP/UDP 是传输层协议,握手和端口探测会被防火墙策略拦下。你想测路由器通不通,可目标端口 80 根本没开,TCP SYN 直接被丢,你会得到“不可达”,但这个“不可达”是端口不可达,不是链路不可达,信息是混淆的。
ICMP 是网络层协议,不需要端口,路由器转发时也不关心端口号。它让每个收到 Echo Request 的节点都有义务回一个 Echo Reply,除非被明确的 ACL 或防火墙策略丢弃。这个“义务”就是 Ping 程序的底层契约。另外 ICMP 报文回程时,IP 头里的 TTL 会原样带回来,你顺手就能拿到一个关于路径跳数的信息。用 TCP 去做,你需要自己处理三次握手、连接超时、端口状态,复杂且不准确。
所以结论很直接:Ping 程序功能实现,ICMP Echo 是唯一干净的载体。系统自带的 ping 命令、网络设备上的 ping,原理都是这一套。C 语言实现时不需要引入任何第三方库,Linux 内核头文件里已经给你定义好了报文结构。
2.2 ping 命令原理是什么:Echo 请求与应答的字段拆解
ping 命令原理是什么?一句话概括:发送方构造一个 ICMP Echo Request(类型 8),目标收到后原样回一个 ICMP Echo Reply(类型 0),发送方根据有没有收到、多久收到来判断连通性和时延。核心字段只有这几个:
| 字段 | 长度 | Echo Request 取值 | 接收方处理方式 |
|---|---|---|---|
| Type | 1 字节 | 8 | 目标主机回 Type=0 |
| Code | 1 字节 | 0 | 0 表示正常回显 |
| Checksum | 2 字节 | 发送前计算 | 校验失败直接丢弃 |
| Identifier | 2 字节 | 自己填,常见用 PID | 原样回显,用于匹配进程 |
| Sequence | 2 字节 | 从 0 递增 | 原样回显,用于判断丢包和乱序 |
| Data | 可变 | 任意,实践里放时间戳 | 回显,接收方取回计算 RTT |
这里最容易踩坑的是 Checksum。ICMP 头部一共 8 字节:Type、Code、Checksum、Identifier、Sequence 各占 1/1/2/2/2 字节。计算校验和时,要把 Checksum 字段本身先清零,再对整个 ICMP 报文按 16 位为单位累加,进位回卷后取反。目标主机校验失败会直接丢包,表现就是抓包能看到 Request,但永远收不到 Reply,程序一直 timeout。
Identifier 是另一个关键设计。raw socket 在系统里不是独占的,同一台机器上可能有多个程序在收 ICMP 报文。如果你不检查 Identifier 是不是自己填的那个值,很可能把别的 ping 进程的响应当成自己的,打印出完全陌生的 seq 和时延。实践里最常见的坑就是只判断了 ICMP 类型,没判断 ID,导致程序在机器上有多个 ping 运行时行为全乱。这部分在第四章的避坑里细说。
2.3 原始套接字:SOCK_RAW 的权限与最小骨架
普通 socket 收发的是端口数据,而 Ping 程序要自己组装 ICMP 头,所以必须用原始套接字。Linux 下创建方式很固定,就用socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)。这里的SOCK_RAW告诉你,你可以收发没有被内核剥掉的 ICMP 报文,但 IP 头依然由内核处理,你不用自己拼 IP 头,只拼 ICMP 头即可。
最小骨架代码如下,先验证当前环境能不能创建 raw socket:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <netinet/ip.h> #include <netinet/ip_icmp.h> #include <arpa/inet.h> int main(int argc, char *argv[]) { if (argc < 2) { fprintf(stderr, "usage: %s <dst-ip>\n", argv[0]); return 1; } int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock < 0) { perror("socket"); return 1; } printf("raw socket ok: %d\n", sock); close(sock); return 0; }这段代码逻辑很简单:创建 raw socket 成功就打印描述符,失败则用perror输出原因。它验证的关键点是权限,raw socket 需要 root 权限或者CAP_NET_RAW能力,普通用户运行大概率会看到socket: Operation not permitted。这个报错不是代码问题,是权限问题。后续所有代码都建立在这个 socket 上,先跑通这一段,后面的收发循环才有意义。
编译命令也很常见:gcc -Wall -O2 -o sockcheck sockcheck.c,然后sudo ./sockcheck 192.168.1.1。能够看到raw socket ok就继续往下走。
3. 用 C 写一个最小 Ping:套接字初始化、校验和与收发循环
3.1 三件前置准备:头文件、编译命令、参数约定
正式动手前先把习惯立好。很多人拿到这类实现,上来就贴一大堆代码,结果编译报implicit declaration才知道头文件缺了。这次的核心头文件四件套是固定的:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <sys/time.h> #include <netinet/in.h> #include <netinet/ip.h> #include <netinet/ip_icmp.h> #include <arpa/inet.h><netinet/ip.h>提供 IP 头结构,<netinet/ip_icmp.h>提供 ICMP 头结构,<sys/time.h>提供gettimeofday用来计算 RTT。这个组合在 Ubuntu、CentOS、Debian 上都能直接用,不需要额外安装库。编译命令建议带-Wall -O2,警告全开,优化级别不要用-O0,否则checksum里那些循环展开相关的性能差异虽然对小包无感,但养成好习惯没坏处。
参数约定上,命令行最少要传目标 IP 和发包数量,超时时间给个默认值 1000 毫秒。用inet_pton做 IP 转换,不要用inet_addr,后者对255.255.255.255这类地址返回 -1,判断起来容易翻车。域名解析先不处理,等主流程跑通再加,后续单独讲。
3.2 校验和实现:为什么先清零再计算,字节序怎么处理
校验和函数是 Ping 程序的第一个核心,也是很多 C 语言入门者第一次接触“网络字节序”的地方。它的算法来自 RFC 1071:以 16 位为单位累加,进位回卷到低位,最后取反。实现如下:
static unsigned short checksum(void *buf, int len) { unsigned short *p = buf; unsigned int sum = 0; while (len > 1) { sum += *p++; len -= 2; } if (len == 1) sum += *(unsigned char *)p; sum = (sum >> 16) + (sum & 0xffff); sum += (sum >> 16); return (unsigned short)(~sum); }逻辑说明:循环按 2 字节累加,如果报文长度是奇数,最后剩一个字节补在低位。累加完成后,把进位从高 16 位回卷到低 16 位,这就是sum >> 16加sum & 0xffff的意义。整个算法对大小端不敏感,因为它按网络字节序的字节流原样计算。关键是调用处必须先把 Checksum 字段清零。
构造 Echo 报文的函数如下:
static void build_echo(char *packet, int pkt_size, int seq) { memset(packet, 0, pkt_size); struct icmp *icmp = (struct icmp *)packet; icmp->icmp_type = ICMP_ECHO; // 8: 回显请求 icmp->icmp_code = 0; icmp->icmp_id = htons(getpid() & 0xffff); icmp->icmp_seq = htons(seq); // 数据区前 8 字节放发送时刻,对端回显后取回来算 RTT struct timeval *tv = (struct timeval *)(packet + 8); gettimeofday(tv, NULL); icmp->icmp_cksum = 0; icmp->icmp_cksum = checksum(packet, pkt_size); }参数说明:pkt_size这里固定传 64,对应标准 ping 默认荷载。Identifier 用进程 PID 的低 16 位,调用htons转成网络字节序,这是对端回显时不关心大小端的关键。数据区偏移 8 字节开始放struct timeval,因为 ICMP 头固定 8 字节,数据区从第 9 字节开始。gettimeofday时间戳会在接收端被取回来做差值,这是计算 RTT 最朴素可靠的做法,不用依赖单调递增的计数器。
3.3 发送与接收循环:超时设置、IP 头解析、类型与 ID 双重过滤
发送和接收是主循环的核心。发送用sendto,目标地址填struct sockaddr_in;接收用recvfrom,关键点有两个:一是必须设置SO_RCVTIMEO,否则断网时进程永远阻塞在recvfrom上;二是收到的报文带 IP 头,要先跳过 IP 头才能拿到 ICMP。代码如下:
static int ping_once(int sock, struct sockaddr_in *addr, int seq) { char pkt[64]; build_echo(pkt, sizeof(pkt), seq); struct timeval start, end; gettimeofday(&start, NULL); if (sendto(sock, pkt, sizeof(pkt), 0, (struct sockaddr *)addr, sizeof(*addr)) < 0) { perror("sendto"); return -1; } char rbuf[512]; struct sockaddr_in from; socklen_t fromlen = sizeof(from); ssize_t n = recvfrom(sock, rbuf, sizeof(rbuf), 0, (struct sockaddr *)&from, &fromlen); if (n < 0) { printf("timeout for seq=%d\n", seq); return -1; } struct ip *iphdr = (struct ip *)rbuf; int iphlen = iphdr->ip_hl * 4; // IP 头长度,单位是 4 字节 struct icmp *rcv = (struct icmp *)(rbuf + iphlen); if (rcv->icmp_type != ICMP_ECHOREPLY) { printf("unexpected icmp type=%d\n", rcv->icmp_type); return -1; } if (ntohs(rcv->icmp_id) != (getpid() & 0xffff)) { return -2; // 别的进程的报文,直接跳过 } gettimeofday(&end, NULL); double ms = (end.tv_sec - start.tv_sec) * 1000.0 + (end.tv_usec - start.tv_usec) / 1000.0; printf("%zd bytes from %s: icmp_seq=%d ttl=%d time=%.2f ms\n", n - iphlen, inet_ntoa(from.sin_addr), ntohs(rcv->icmp_seq), iphdr->ip_ttl, ms); return 0; }逻辑说明:发送后立刻记录start,接收成功后再取end,差值是完整往返时间。iphdr->ip_hl的单位是 4 字节,所以乘 4 才是真实 IP 头长度。打印长度用n - iphlen,这对应 ICMP 报文的真实长度。TTL 直接从 IP 头里取,这就是系统 ping 命令里ttl=64那部分的来源。
返回值 -2 表示收到了其他进程的 ICMP 响应,并没有超时。当前代码把它当作一次失败,统计上会拉高丢包率。严谨的做法是在收到 -2 时继续recvfrom循环读,直到匹配自己的 ID 或超时为止。生产级实现会这么做,这个简单版保留了边界,能跑通,但要清楚它的局限。主函数需要设置超时并循环发包:
int main(int argc, char *argv[]) { if (argc < 3) { fprintf(stderr, "usage: %s <ip> <count> [timeout_ms]\n", argv[0]); return 1; } const char *dst = argv[1]; int count = atoi(argv[2]); int timeout_ms = argc > 3 ? atoi(argv[3]) : 1000; struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; if (inet_pton(AF_INET, dst, &addr.sin_addr) != 1) { fprintf(stderr, "invalid ip: %s\n", dst); return 1; } int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock < 0) { perror("socket"); return 1; } struct timeval tv; tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); int ok = 0; for (int i = 0; i < count; i++) { int ret = ping_once(sock, &addr, i); if (ret == 0) ok++; usleep(300 * 1000); } close(sock); printf("--- %s ping statistics ---\n", dst); printf("%d sent, %d received, %.1f%% loss\n", count, ok, (count - ok) * 100.0 / count); return ok == count ? 0 : 1; }主函数的说明:usleep(300 * 1000)控制发包节奏,避免 1000 个包在几毫秒内全部打出去。这会让每轮循环大约耗时RTT + 300ms,统计丢包按包数算不按时间算,这一点要和系统 ping 区分开。setsockopt 的超时值直接影响丢包率,局域网内 200 到 500 毫秒足够,跨运营商链路建议 1000 毫秒,给足重传空间。
3.4 编译运行与结果解读
按顺序把上面的函数拼进同一个ping.c,编译命令如下:
gcc -Wall -O2 -o myping ping.c sudo ./myping 192.168.1.1 4输出类似:
64 bytes from 192.168.1.1: icmp_seq=0 ttl=64 time=0.52 ms 64 bytes from 192.168.1.1: icmp_seq=1 ttl=64 time=0.48 ms 64 bytes from 192.168.1.1: icmp_seq=2 ttl=64 time=0.47 ms 64 bytes from 192.168.1.1: icmp_seq=3 ttl=64 time=0.51 ms --- 192.168.1.1 ping statistics --- 4 sent, 4 received, 0.0% loss这里ttl=64说明目标和自己同一个网段,中间没有经过路由器;如果 TTL 是 51 或者更小,说明中间有路由器转发。RTT 从 0.5 毫秒左右可以判断是同网段的正常水平。
如果目标是域名,比如www.baidu.com,编译后直接跑会报invalid ip,因为inet_pton只接受点分十进制。常见做法是先调用getaddrinfo把域名解析成 IP,再把 IP 填进sockaddr_in。这个逻辑不影响主流程,但值得知道,生产环境里没人会天天记公网 IP。
4. 编译运行与 5 个典型坑:从报错到“看起来通但总丢包”
4.1 校验和没清零,抓包全是请求没有回包
现象:程序能发出包,也能正常退出,但收到的全是 timeout。用 tcpdump 抓包时,能看到出去的ICMP echo request,但对面一个echo reply都不回。
原因:这是 Ping 程序里最容易翻车的一步。发送前忘了把icmp_cksum先设置为 0,或者先调了 checksum 再去填 payload,导致校验和把无关数据也算进去了。目标主机校验不过,直接把包丢了。还有一种变体是计算长度不对,checksum的 len 传了sizeof(struct icmp),但实际报文是 64 字节,数据区的 56 个字节没有参与校验。
解决:严格按先清零、再填充数据、最后调 checksum 的顺序执行。用重放抓包验证:终端 1 执行sudo tcpdump -i any icmp -nn,终端 2 执行sudo ./myping 192.168.1.1 3。如果抓包显示有echo request但没有echo reply,优先怀疑校验和;如果有echo reply但程序还报 timeout,那是接收解析的过滤条件出了问题。
4.2 没设 SO_RCVTIMEO,断网时进程彻底卡死
现象:拔掉网线后运行程序,recvfrom一直阻塞,按 Ctrl+C 才能终止,进程看起来像死循环。
原因:raw socket 默认是阻塞模式,recvfrom在没有数据时会一直等下去。网络正常时无所谓,一旦对端不可达或路由黑洞,这个调用永远不返回。大部分初学者写 Ping 程序都会漏掉超时设置,因为正常环境测试时根本触发不了。
解决:在主函数里用setsockopt设置SO_RCVTIMEO,超时值建议从 500 毫秒起步。注意struct timeval的精度是秒和微秒,别把毫秒直接塞进tv_sec,否则 1000 会变成 1000 秒。设置后recvfrom超时返回 -1,配合perror或者自己打印 timeout 日志即可。
4.3 收到 echo reply 却显示丢包,可能是 ID 没匹配上
现象:抓包看到明确的echo reply,目标也回包了,本程序却一直报 timeout,或者打印出的 seq 和 RTT 跳跃得毫无规律。
原因:raw socket 收到的 ICMP 报文中,可能包含同一台机器上系统 ping、其他自写工具、监控程序发出的请求对应的回包。如果只判断icmp_type == ICMP_ECHOREPLY就收下,会把别人的响应当成自己的,导致统计完全乱掉。
解决:必须校验ntohs(rcv->icmp_id) == (getpid() & 0xffff)。更严谨的做法是在收到非匹配 ID 时继续调用recvfrom再读下一个包,直到超时。另外提醒一点,getpid()低 16 位理论上会在进程 PID 回绕后和其他进程冲突,实践里概率极低,但如果跑长期监测服务,建议用启动时刻的时间戳低 16 位作为 ID 来源。
4.4 权限报错 Operation not permitted,代码怎么改都白搭
现象:编译正常,运行时报socket: Operation not permitted。
原因:创建 raw socket 需要CAP_NET_RAW能力,普通用户没有。这个和代码逻辑无关,纯环境问题。在 Docker 容器里跑时,部分容器运行时默认没有授予NET_RAW权限,同样报这个错。
解决:开发调试用sudo ./myping 目标IP 3。生产环境不想给 root 权限,可以对编译好的二进制单独提权:sudo setcap cap_net_raw+ep ./myping,之后再普通用户跑就不需要 sudo 了。容器里给权限加启动参数--cap-add=NET_RAW,例如docker run --cap-add=NET_RAW ...。另外提醒一下,setcap对设置了 setuid 权限的二进制可能不生效,调试时优先直接 sudo。
4.5 虚拟机或云主机里“能 ping 通网关,ping 不通宿主”的玄学
现象:在 Ubuntu 虚拟机里运行自己写的 Ping 程序,ping 网关正常,ping Windows 宿主 IP 一直 timeout,但同一台虚拟机里用系统/bin/ping也一样不通。看起来像代码问题,实际和代码一点关系都没有。
原因:Windows 防火墙默认拦截了 inbound ICMP Echo Request,宿主机根本不会回包。虚拟机走 NAT 还是桥接也会影响路径,NAT 模式下虚拟机出网要过一层地址转换,部分链路对 ICMP 处理延迟大。
解决:先做环境排除法。在虚拟机上用系统自带的ping命令试试目标地址,如果系统 ping 都不通,说明是网络配置或防火墙问题,不是自己 C 程序的锅。排查宿主机防火墙,临时放行 ICMPv4-In 回显请求,或者把虚拟机网络切到桥接模式再试。同理,云主机安全组如果入方向没放行 ICMP,公网 IP 是 ping 不通的,这也能解释为什么ssh 用户名@公网ip能通但 ping 不通。
5. 从单次 ping 到持续监测:断连记录、批量目标与绑定源地址
系统自带的 ping 默认只做一轮就退出,实际运维里更常用的是持续监测。把上面代码改造成工具型程序,有几个顺手就能加的小功能,都围绕一个目标:让程序能在无人值守时留下日志,而不是只在终端里刷输出。
记录断连时刻的常见做法是维护一个连续失败计数器。连续 3 次超时判定链路 down,恢复后连续 3 次成功判定 recovered,这比单次超时就报警可靠得多。伪代码如下:
int consecutive_fail = 0; for (int i = 0; i < total; i++) { int ret = ping_once(sock, &addr, i); if (ret == 0) { if (consecutive_fail >= 3) { time_t now = time(NULL); printf("[%s] link recovered\n", ctime(&now)); } consecutive_fail = 0; } else { consecutive_fail++; if (consecutive_fail == 3) { time_t now = time(NULL); printf("[%s] link down\n", ctime(&now)); } } }判断逻辑说明:ctime返回带换行的字符串,打印时不需要再加\n。阈值 3 是按默认 1 秒超时推算的,实际部署要根据监控精度调整,要秒级告警就连续 2 次判 down,要减少误报就连续 5 次。
多网卡机器上跑自写 Ping,经常会遇到“默认路由选错了网卡”的问题。笔记本同时插着网线和 WiFi 时,路由表可能把目标网段指向无线网卡,但你想测的是有线链路。系统自带的 ping 有-I参数,C 语言里对应的常见做法是SO_BINDTODEVICE:
if (ifname != NULL && setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, ifname, strlen(ifname)) < 0) { perror("SO_BINDTODEVICE"); }ifname传eth0、wlan0这类字符串。这个操作同样需要 root 权限,网卡名不存在时报No such device。调试时先用ip route get 目标IP看一下当前路由会走哪个接口,然后决定要不要强行绑定。
批量扫描多个 IP 时,经验是保持 300 毫秒以上的发包间隔,不要一次性并发打上千个包。很多公司网络会扫描 ICMP 流量,短时间高频发包容易被安全策略误判为探测攻击。把目标地址放进数组循环调用,一份代码就能实现类似“超级 ping”的批量检测工具,但发得慢一点,活得久一点。
最后说一个习惯:我现在写 Ping 工具,第一件事是先写超时处理和 ID 过滤,再写发送逻辑,这个顺序帮我避开了不少灵异问题。先保证程序不会卡死、不会接错包,剩下的功能都是往上加东西。希望帮到你。
本文还有配套的精品资源,点击获取