简介:一份围绕“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能在几行代码里拿到RTT
2.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 3,TTL耗尽时路由器会回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_seq=1 ttl=52 time=12.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超时报文:观察对方操作系统的小技巧
TTL(Time 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常用64,Windows常见128,一些网络设备是255。用“初始TTL”减“返回值中的TTL”,可以估算本机到对端的跳数。比如发出去设置TTL=64,回来看到TTL=52,大概经历了12跳。这个点在课设答辩时特别好讲,因为它把“协议字段”和“实测观察”串起来了,比单纯背书深刻得多。
4.3 包大小与MTU边界:1472字节这个数字怎么来的
ping -s指定的包大小,标准ping里指的是ICMP载荷的大小。整体链路帧长 = 以太网头14字节 + IP头20字节 + ICMP头8字节 + 载荷。如果以太网MTU是1500,那么IP头+ICMP头+载荷最大是1500,载荷最大就是1500-28=1472字节。超过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_raw+ep ./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字段分发到对应目标的统计结构里。每个目标分配独立id,seq连续递增,接收线程只需一张表就能维护多个目标的RTT、丢包和超时状态。
traceroute的思路更漂亮:发送TTL=1的UDP或ICMP包,第一个路由器收到后会丢弃并把TTL减到0,回送Type 11超时报文,源地址就是第一跳的地址;再把TTL调成2,第二个路由器回送,以此类推,就能还原一路的路径。在你的ping程序里,接收逻辑已经能解析ICMP类型了,只需要再加上“Type 11时打印来源IP”的分支。
我当年调试校验和时最深的教训是:不要只看“通不通”,要抓包看“对不对”。把Wireshark开在旁路,比对程序发出的包和标准ping发出的包的每个字段,很多问题一眼就明白了。后来我给自己立了个规矩,每个参数都要回答“改了会怎样、不改会怎样”,比如超时1秒改成500毫秒,丢包率会怎么变;TTL从64改成32,又有哪个地址会从通变成不通。带着这种习惯写课设,答辩时不管老师往哪个方向追问,你都能给出有依据的回答,而不是“代码跑通了我也不知道为什么”。希望帮到你。
本文还有配套的精品资源,点击获取