用C语言实现ping程序:ICMP协议、原始套接字与RTT统计全解析
简介一份围绕“Ping程序的设计与实现”的计算机网络课程设计资源面向高校计算机专业学生及C语言网络编程初学者。报告完整呈现课程设计全过程从Ping运行原理入手逐步讲解ICMP协议格式、原始套接字创建、校验和算法以及Winsock初始化、socket创建与关闭、主机名转IP、数据报收发等关键编程技术。资源包为单个doc文档共1个文件约189KB内容紧凑且结构清晰既可作为课程设计报告模板也能用于理解Ping程序实现的核心步骤。目前已有404人浏览学习适合需要完成类似课题或练习网络编程的读者参考。文档中还包含课程设计任务书、进度安排、ICMP头结构定义和校验和函数源码可直接对照学习。通过完整阅读该报告读者可以掌握使用原始套接字实现Ping基本功能含ping -t的思路为后续深入学习Windows网络编程打下基础。1. ping程序的设计与实现计算机网络课程设计里性价比最高的动手题又到课程设计选题季很多同学在“聊天室”“FTP文件传输”“模拟TCP”这些题目里反复纠结其实还有一个被低估的好项目ping程序的设计与实现。它覆盖了ICMP协议、原始套接字、校验和、超时重传、RTT统计、DNS解析这些计算机网络课的核心知识点代码量不大但每个部分都能在答辩时讲出实质内容是纯粹的“计算机网络”方向项目不是把精力耗在界面和框架上。这篇笔记按我实际做课设辅导的思路从协议原理讲到可编译运行的最小实现再落到参数设置和排错最后给出一个能拿去做答辩亮点的并发探测和路由追踪方向照着走一遍你就能交付一个敢让老师现场跑、现场改参数的ping程序。2. ICMP回显请求与应答机制为什么ping能在几行代码里拿到RTT2.1 从Socket到报文ping到底走了哪一层很多人一开始会惯性思维ping是不是也用TCP或UDP并不是。ping基于ICMP协议而ICMP是IP层的附属协议不经过端口号也不建立连接。它封装在IP数据报里IP头部的协议字段值为1。以太网上的完整链路是“以太网帧 → IP报文头 → ICMP报文 → 载荷”。这也是为什么你的TCP服务开着却ping不通时往往不是服务本身的问题而是主机对ICMP的处理策略变了。报文格式本身很固定一共必须有8字节的头部后面的载荷可自定义struct icmp_echo { uint8_t type; /* 8表示回显请求0表示回显应答 */ uint8_t code; /* 回显报文的code恒为0 */ uint16_t checksum; /* 对整个ICMP报文的反码和校验 */ uint16_t id; /* 标识符用于区分本机多个ping实例 */ uint16_t seq; /* 序号用于统计丢包和乱序 */ uint8_t data[]; /* 载荷通常放发送时间戳 */ };Type和Code的意义不仅仅是回显。目标不可达时对端会回Type 3TTL耗尽时路由器会回Type 11。这就是为什么一个小小的ping程序写好了之后再扩展traceroute会非常顺手。Identification字段平时被很多人忽略但在并发场景下它是接收端区分“这个回包是给谁的”的关键Sequence Number则负责配合RTT统计和丢包判定。2.2 校验和为什么要自己算反码求和的两种写法TCP、UDP、ICMP、IP头部都有Checksum字段但ICMP要求发送方必须计算接收方发现校验错误就静默丢弃。校验算法统一是16位二进制反码求和把报文按每16位切分、累加进位回卷最后取反。算法不复杂但实现细节特别容易翻车。常见的教科书写法是这样的unsigned short checksum_naive(void *buf, int len) { unsigned short *p (unsigned short *)buf; unsigned long sum 0; while (len 1) { sum *p; len - 2; } if (len 1) { /* 奇数长度时补一个字节 */ sum *(unsigned char *)p; } while (sum 16) { /* 高位进位回卷 */ sum (sum 0xffff) (sum 16); } return ~sum; }看起来没问题但实际使用时容易踩三个点。第一计算前必须把Checksum字段本身置为0否则算出来永远是错的第二累加器用unsigned long避免小位宽整数相加溢出后丢进位第三如果报文长度是奇数最后剩下的1字节要当作高字节还是低字节参与累加标准做法是当成低字节高位补0理解了这个再去看那些“为什么我的校验和总差1”的玄学问题就清楚了。更随手的写法是每加完一个16位就回卷一次进位循环结束后再回卷一次。两种最终结果一致但第一种只在最后收尾时回卷效率更高。我一般会先自己写一遍朴素的再用tcpdump -XX抓包和Wireshark做交叉验证看ICMP层的Checksum是不是显示correct。2.3 系统里已有的工具先看标准ping怎么工作动手写代码之前我建议先在实验环境里把系统自带的ping完整玩一遍目的是确认你接下来要复现的行为是什么。比如在Linux下执行ping -c 3 baidu.com会看到“64 bytes from …: icmp_seq1 ttl52 time12.3 ms”这样的输出加上-v可以看到更细节的报文信息用-s 1400可以感受大包带来的分片。同一台机器上同时跑多个ping实例时系统会保证每个进程拿到不同的ICMP Identifier所以回包能正确送到对应进程。这些现象就是后续程序的验收基准你写的程序在同样网络条件下RTT和回包内容应当与标准ping基本一致。先用现成工具建立“正确输出长什么样”的印象再上手写而不是写完才发现自己连预期值都不知道。3. 用C语言搭一个最简ping原始套接字建包、校验和与超时接收3.1 创建原始套接字为什么需要root权限在Linux下创建ICMP原始套接字只需要一行int sockfd socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);这里不能轻易改用SOCK_DGRAM因为ICMP不是流式协议也没有端口概念。IPPROTO_ICMP告诉内核由我们自己提供ICMP报文内容。创建成功后发送时用sendto接收用recvfrom用法上更像UDP但内核不再帮你填ICMP头全部自己来。需要注意权限问题创建SOCK_RAW需要root或CAP_NET_RAW能力普通用户运行会报Operation not permitted。课设演示一般直接sudo ./myping即可这个点在后面避坑章节还会展开。3.2 构造回显请求包时间戳与序列号构造报文时有一个容易被忽略的设计选择载荷里放什么。标准ping的载荷放的是发送时刻的时间戳这样接收方收到回包后用当前时刻减去载荷中的发送时刻就能算出单向链路经历的往返时间RTT。为了跨平台兼容最好不直接塞struct timeval而是统一为毫秒数避免在64位和32位系统间出现结构体大小不一致。static uint64_t now_ms(void) { struct timeval tv; gettimeofday(tv, NULL); return (uint64_t)tv.tv_sec * 1000 tv.tv_usec / 1000; } static void build_icmp_packet(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 getpid() 0xffff; /* 用进程号区分并发实例 */ icmp-icmp_seq seq; uint64_t ts now_ms(); memcpy(packet 8, ts, sizeof(ts)); /* 时间戳放在ICMP载荷开头 */ icmp-icmp_cksum 0; /* 先清零再算校验和 */ icmp-icmp_cksum checksum_naive(packet, pkt_size); }注意icmp_cksum必须放在内存中计算。因为ICMP头是网络字节序而我们的校验和算法是按主机字节序累加的这会有边界问题。这里我采用的做法是构造完整个报文后再调校验函数保证发送前内存里的Checksum已经填好。关于大小端标准ping在不同架构上都能正确工作就是因为整包拷贝进内核由网卡按网络字节序发送我们不需要手工做htonl只要不要对某个多字节字段单独做字节序转换就行。3.3 接收回显应答先跳IP头再校验和判类型recvfrom从原始套接字读到的数据起始位置是IP报文头不是ICMP报文头。初学者最容易翻车的地方就在这里把收到的缓冲区直接强转成struct icmp *结果类型、校验和、id全读错。正确做法是先解析IP头用ip_hl字段单位是4字节算出IP头长度再偏移到ICMP段。static int recv_reply(int sockfd, int timeout_ms, uint64_t *rtt_ms, struct sockaddr_in *from) { fd_set fds; struct timeval tv; FD_ZERO(fds); FD_SET(sockfd, fds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; if (select(sockfd 1, fds, NULL, NULL, tv) 0) { return -1; /* 超时本次按丢包处理 */ } char buf[512]; socklen_t addrlen sizeof(*from); int n recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)from, addrlen); if (n 0) { return -1; } struct ip *ip (struct ip *)buf; int ip_header_len ip-ip_hl * 4; struct icmp *icmp (struct icmp *)(buf ip_header_len); if (icmp-icmp_type ! ICMP_ECHOREPLY) { return 1; /* 收到报文但不是回显应答比如目标不可达 */ } if (icmp-icmp_id ! (getpid() 0xffff)) { return 1; /* 是别的ping进程的回包忽略 */ } uint64_t *sent_ts (uint64_t *)((char *)icmp 8); *rtt_ms now_ms() - *sent_ts; return 0; }这里用select做超时控制而不是sleep后不去读是因为recvfrom默认阻塞如果只发不收程序会卡死在等待上。select的好处是既能等待可读事件又能在超时后立刻返回进入下一轮发送或统计丢包。返回值三种语义0表示正常收到回显应答-1表示超时1表示这一包不是本进程关心的内容。3.4 完整最小实现能编译能跑通的版本下面是一个可以编译运行的完整最小程序命令用法与标准ping接近。我把它控制在约120行适合课设提交时作为核心源码。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include signal.h #include sys/socket.h #include sys/time.h #include sys/select.h #include netinet/in.h #include netinet/ip.h #include netinet/ip_icmp.h #include arpa/inet.h #include netdb.h static int sockfd; static volatile int stop 0; static int sent_count 0; static int recv_count 0; static void on_sigint(int sig) { stop 1; } static uint64_t now_ms(void) { struct timeval tv; gettimeofday(tv, NULL); return (uint64_t)tv.tv_sec * 1000 tv.tv_usec / 1000; } static unsigned short checksum_naive(void *buf, int len) { unsigned short *p (unsigned short *)buf; unsigned long sum 0; while (len 1) { sum *p; len - 2; } if (len 1) { sum *(unsigned char *)p; } while (sum 16) { sum (sum 0xffff) (sum 16); } return ~sum; } static void build_packet(char *packet, int pkt_size, int seq) { memset(packet, 0, pkt_size); struct icmp *icmp (struct icmp *)packet; icmp-icmp_type ICMP_ECHO; icmp-icmp_code 0; icmp-icmp_id getpid() 0xffff; icmp-icmp_seq seq; uint64_t ts now_ms(); memcpy(packet 8, ts, sizeof(ts)); icmp-icmp_cksum 0; icmp-icmp_cksum checksum_naive(packet, pkt_size); } static int recv_reply(int timeout_ms, uint64_t *rtt_ms) { fd_set fds; struct timeval tv; FD_ZERO(fds); FD_SET(sockfd, fds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; if (select(sockfd 1, fds, NULL, NULL, tv) 0) { return -1; } char buf[512]; struct sockaddr_in from; socklen_t addrlen sizeof(from); int n recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)from, addrlen); if (n 0) { return -1; } struct ip *ip (struct ip *)buf; int ip_header_len ip-ip_hl * 4; struct icmp *icmp (struct icmp *)(buf ip_header_len); if (icmp-icmp_type ! ICMP_ECHOREPLY) { return 1; } if (icmp-icmp_id ! (getpid() 0xffff)) { return 1; } uint64_t *sent_ts (uint64_t *)((char *)icmp 8); *rtt_ms now_ms() - *sent_ts; return 0; } int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s 目标IP或域名\n, argv[0]); return 1; } struct sockaddr_in dest; memset(dest, 0, sizeof(dest)); dest.sin_family AF_INET; if (inet_pton(AF_INET, argv[1], dest.sin_addr) ! 1) { struct hostent *h gethostbyname(argv[1]); if (h NULL) { fprintf(stderr, 域名解析失败: %s\n, argv[1]); return 1; } memcpy(dest.sin_addr, h-h_addr, h-h_length); } sockfd socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sockfd 0) { perror(创建socket失败); return 1; } signal(SIGINT, on_sigint); printf(正在 ping %s ...\n, argv[1]); int pkt_size 64; int seq 1; while (!stop) { char packet[128]; build_packet(packet, pkt_size, seq); if (sendto(sockfd, packet, pkt_size, 0, (struct sockaddr *)dest, sizeof(dest)) 0) { perror(sendto失败); break; } sent_count; uint64_t rtt_ms 0; int ret recv_reply(1000, rtt_ms); if (ret 0) { recv_count; printf(来自 %s 的回复: seq%d time%llu ms\n, inet_ntoa(dest.sin_addr), seq, (unsigned long long)rtt_ms); } else if (ret -1) { printf(请求超时: seq%d\n, seq); } seq; sleep(1); } printf(--- 统计 ---\n); printf(已发送%d 已接收%d 丢包率%.1f%%\n, sent_count, recv_count, 100.0 - (sent_count ? 100.0 * recv_count / sent_count : 0.0)); close(sockfd); return 0; }编译与运行gcc myping.c -o myping sudo ./myping 127.0.0.1 sudo ./myping baidu.com这个程序有几个设计点可以用来答辩。一是发送序号递增能判断是否乱序二是接收时按identifier过滤保证统计的RTT是“自己的”往返时间三是用select实现精确到毫秒的超时控制而不是简单地sleep固定时间再recv。它和标准ping的主要差距是没做滑动窗口并发发送、没解析ICMP差错报文的详细信息但作为课设主体已经足够扎实。提示在Windows下原始套接字的权限模型和字节填充方式与Linux不同建议课设队伍统一在Linux环境随便一个虚拟机或云主机里跑避免跨平台适配消耗大量时间。4. 把参数调到课设答辩能讲明白超时、TTL、包大小与统计输出4.1 超时重传与RTT计算select的timeout参数怎么设超时值的选择不是玄学它直接决定丢包率和RTT的统计口径。标准ping的默认超时是1秒也就是发出回显请求后等1000毫秒超过就认为这一包丢失。如果你的课程设计还要求支持“不可达”判定通常是连续3次超时后打印“目标不可达”并停止。超时值设得太小跨网段时RTT本身就不稳定容易误报丢包设得太大程序对断网的感知会迟钝演示时影响观感。我的建议是做成命令行参数-w默认1000毫秒。select的timeval结构里tv_sec与tv_usec要一分为二不能直接塞毫秒数。在接收逻辑里超时返回-1之后不要立刻停止因为这可能是偶发拥塞。只有连续3个seq都超时才进入“疑似不可达”的状态这也是和标准ping行为对齐的。答辩老师常问“为什么你的程序丢包率比标准ping高”多数情况下就是因为你把超时时间写成了500毫秒甚至更短。4.2 TTL参数与ICMP超时报文观察对方操作系统的小技巧TTLTime To Live是IP头里的一个字段每经过一个路由器减1减到0就直接丢弃并回送一个ICMP Type 11超时报文。你可以在自己的程序里增加-t参数来设置发送TTL代码里只需要在IP头上做设置。注意在原始套接字下你可以直接构造IP头但更简单的做法是用setsockopt设置IP_TTL让内核填IP头。int ttl 64; setsockopt(sockfd, IPPROTO_IP, IP_TTL, ttl, sizeof(ttl));收到回显应答时IP头里的TTL字段是“经过跳数衰减后剩余的值”。不同操作系统默认初始TTL不一样Linux常用64Windows常见128一些网络设备是255。用“初始TTL”减“返回值中的TTL”可以估算本机到对端的跳数。比如发出去设置TTL64回来看到TTL52大概经历了12跳。这个点在课设答辩时特别好讲因为它把“协议字段”和“实测观察”串起来了比单纯背书深刻得多。4.3 包大小与MTU边界1472字节这个数字怎么来的ping -s指定的包大小标准ping里指的是ICMP载荷的大小。整体链路帧长 以太网头14字节 IP头20字节 ICMP头8字节 载荷。如果以太网MTU是1500那么IP头ICMP头载荷最大是1500载荷最大就是1500-281472字节。超过1472就必须IP分片而很多路径上的设备会丢弃分片包。这就是经典的“小包能通、大包不通”问题排查MTU问题时的标准做法是用ping -s 1472 -M do来测试。我们在自己的程序里实现-s参数时要注意包总长度和缓冲区长度的一致性。发送缓冲区要能容纳IP头ICMP头载荷接收缓冲区要比发送的更大一些因为路径上可能有额外的选项字段。我习惯把接收缓冲区定为512字节发送缓冲根据参数在128到1504之间选择。如果目标地址是IPv6大小计算逻辑完全不同课设建议锁定IPv4避免范围失控。4.4 统计输出的三种口径丢包率、RTT分布与抖动一个能让老师眼前一亮的ping程序尾部统计不能只有“发了多少、收了多少”。网络测量里常用的指标有三个丢包率、平均RTT、RTT抖动Jitter。平均RTT体现链路延迟的总体水平抖动体现稳定性。计算方式很简单rtt_min min(rtt_min, rtt); rtt_max max(rtt_max, rtt); rtt_sum rtt;丢包率用“已发送”和“已接收”计算发送包含每次发送的序号不考虑超时后重传的包。抖动建议用相邻两次RTT差值绝对值的指数移动平均比简单方差直观。标准ping底部的rtt min/avg/max/mdev里mdev用的就是标准差你可以在文档里写明自己用的是哪种答辩时被追问也能站住脚。另外建议统计里打印第一包RTT和后续包RTT的差异。因为第一包往往要触发ARP解析RTT会明显偏高很多初学者看到第一包慢就以为网络有问题其实是邻居发现机制在起作用。把这一条写进实验报告能体现你真的观察过细节而不是只抄代码。5. ping不通的排查清单从权限到MTU的五个高频踩坑点5.1 现象socket创建失败提示Operation not permitted原因Linux不允许普通用户创建原始套接字这属于内核的安全能力限制。你的代码没错但运行方式错了。解决用sudo ./myping运行或者给二进制文件单独加能力sudo setcap cap_net_rawep ./myping加了之后不需要root也能运行。课设环境如果是虚拟机的CentOS 7还要额外检查虚拟机网络模式NAT模式下部分ICMP包会被宿主机的防火墙拦掉导致“代码没问题但就是不通”的假象。5.2 现象对方明明回包了程序却一直显示请求超时原因最常见的是把recvfrom收到的缓冲区直接当成ICMP报文解析忘了前面还有IP头。结构体错位后type值完全对不上自然进不了“收到回显应答”的分支。另一个常见原因是你拿到的回包是Type 3目标不可达或Type 11超时这类报文也要打印日志否则会误判为超时。解决接收时先读struct ip的ip_hl算出IP头长度再偏移解析ICMP对不同type分别打印至少输出目标不可达或TTL超时。5.3 现象Wireshark里看到自己发的包Checksum显示错误对端不回原因校验和计算前没有把icmp_cksum清零或者累加进位只在最后回卷一次但累加器本身溢出了。前者是逻辑错误后者是类型选择错误。解决把socket和校验函数放在同一源文件里构造报文时写上icmp-icmp_cksum 0;再调用校验函数累加器用unsigned long不要用unsigned short。还有一个很少人注意的细节如果你修改了发送缓冲区但忘记重新计算校验和会出现“第一个包通后面全不通”的诡异现象排查时优先怀疑自己改没改报文内容。5.4 现象内网ping通外网ping不通报temporary failure in name resolution原因这根本不是ICMP的问题是DNS解析失败。temporary failure in name resolution的意思是本机没配好DNS或DNS服务器无响应域名解析不出来。解决先用ping 223.5.5.5这类纯IP地址测试外网连通性再检查/etc/resolv.conf里的nameserver配置。还有一种Windows下常见的情况内网ping通但外网提示“一般故障”多半是默认路由缺失或防火墙阻止了出站ICMP先ip route看默认网关再临时关闭防火墙做对照实验。课设里验证链路通不通的正确顺序是先ping本机回环地址再ping网关最后ping外部IP一层层缩小范围。5.5 现象多开几个ping进程统计结果互相串包原因原始套接字接收到的报文是广播式的一个进程收的包可能属于另一个ping实例。如果只用type判断就会把他人的回显应答也算成自己的RTT统计完全乱掉。解决用icmp_id按进程号过滤。发送时icmp_id getpid() 0xffff接收时比对identifier不一致就跳过这是标准ping的做法。排查时如果发现回包里的id和你自己发的不一样不用怀疑协议有问题它本来就是别人的包丢掉就好。6. 从ping到traceroute并发探测与逐跳路由的加分进阶最后一步给你一个能明显拉开差距的进阶方向把单发单收的ping升级成带并发探测的小工具再顺势实现一个极简traceroute。并发探测的核心不是开多个线程而是用同一个原始套接字接收所有回包再按照ICMP的identifier字段分发到对应目标的统计结构里。每个目标分配独立idseq连续递增接收线程只需一张表就能维护多个目标的RTT、丢包和超时状态。traceroute的思路更漂亮发送TTL1的UDP或ICMP包第一个路由器收到后会丢弃并把TTL减到0回送Type 11超时报文源地址就是第一跳的地址再把TTL调成2第二个路由器回送以此类推就能还原一路的路径。在你的ping程序里接收逻辑已经能解析ICMP类型了只需要再加上“Type 11时打印来源IP”的分支。我当年调试校验和时最深的教训是不要只看“通不通”要抓包看“对不对”。把Wireshark开在旁路比对程序发出的包和标准ping发出的包的每个字段很多问题一眼就明白了。后来我给自己立了个规矩每个参数都要回答“改了会怎样、不改会怎样”比如超时1秒改成500毫秒丢包率会怎么变TTL从64改成32又有哪个地址会从通变成不通。带着这种习惯写课设答辩时不管老师往哪个方向追问你都能给出有依据的回答而不是“代码跑通了我也不知道为什么”。希望帮到你。本文还有配套的精品资源点击获取