☰
Python从零实现Ping命令:原始套接字与ICMP协议详解
2026/10/27 2:51:21 网站建设 项目流程

网络连通性排查这件事,说大不大,说小也不小。日常运维、开发联调、家庭网络折腾,绕不开的一个动作就是"先 ping 一下看看通不通"。图形化工具点一点固然省事,但真到了批量探测、定时巡检、结果落库这类场景,还是得自己写点代码。这次我拿 Python 从零实现一个 Ping 命令,不调用系统自带的 ping 可执行文件,而是直接用原始套接字收发 ICMP 报文,把"网络编程"这四个字落到实处。整件事的价值在于:你会真正搞明白 ping 背后发生了什么——ICMP 报文长什么样、校验和怎么算、往返时延从哪来、TTL 为什么能反映跳数。适合已经会写 Python 基础语法、想往网络底层走一步的读者,也适合做运维工具、网络监控、设备巡检的同行参考。下面我按设计思路、协议细节、代码实现、问题排查四块展开,把我踩过的坑和验证过的参数一并交代清楚。

1. 整体设计与技术选型拆解

1.1 为什么不用 subprocess 调系统 ping

最省事的做法是subprocess.run(["ping", "-c", "4", host]),解析输出文本。我早期做巡检脚本就是这么干的,能跑,但问题一堆。第一,跨平台输出格式不一致,Linux 的 ping 和 Windows 的 ping 文本结构完全不同,正则得写两套,维护成本高。第二,解析文本本质上是"二次加工",系统 ping 已经算好的时延、丢包率,你只能被动接受它的输出格式,想拿到更细的字段(比如每个包的 TTL、ICMP 序列号)就得跟文本格式死磕。第三,也是最关键的——这么做你学不到任何网络编程的东西,只是当了个文本搬运工。

自己构造 ICMP 报文就不一样了。你要亲手拼 IP 头之外的那部分(严格说 ICMP 报文在 IP 层之上,用户态构造的是 ICMP 部分,IP 头由内核补),要算校验和,要处理序列号匹配,要自己计时。这套流程走一遍,TCP/IP 协议栈里"网络层"那一块就从纸面知识变成了手上的代码。所以我选原始套接字方案,代价是需要管理员权限,这一点后面会专门讲怎么处理。

1.2 原始套接字与 ICMP 协议的基本盘

ICMP 是 IP 层的"信使协议",全称 Internet Control Message Protocol。ping 用的是其中的 Echo Request(类型 8)和 Echo Reply(类型 0)这一对。一个 ICMP Echo 报文的结构非常朴素:

字段长度说明
Type1 字节8 表示请求,0 表示应答
Code1 字节Echo 类型固定为 0
Checksum2 字节覆盖整个 ICMP 报文的校验和
Identifier2 字节标识符,用来区分不同进程的 ping
Sequence2 字节序列号,逐包递增
Data可变载荷,通常填时间戳或填充字节

校验和的算法是经典的 16 位反码求和:把报文按 16 位一组累加,进位回卷,最后取反。这个算法我在代码里会单独封装成函数,因为它是整个实现里最容易出错的地方之一——字节序搞反、奇数字节没补齐,都会导致校验和错误,表现就是对方收到包但直接丢弃,你这边一直超时,排查起来很折磨。

Identifier 字段值得多说一句。系统 ping 通常用进程 PID 填充这个字段,这样多个 ping 进程同时跑的时候,内核能把应答分发给正确的进程。我们自己实现时也建议用os.getpid() & 0xFFFF来填,取低 16 位是因为字段只有 2 字节。Sequence 从 1 开始递增,收到应答时靠它和 Identifier 一起匹配,确认这个回复是发给自己的、且对应哪一次请求。

1.3 权限模型与跨平台差异

原始套接字(socket.SOCK_RAW)需要创建者具备相应权限。Linux 下要么用 root 跑,要么给 Python 解释器加CAP_NET_RAW能力(setcap cap_net_raw+ep $(readlink -f $(which python3))),后者更精细,不用整个进程提权。Windows 下则需要管理员权限运行,普通用户创建原始套接字会直接抛PermissionError。

还有一个平台差异必须提前说清楚:Linux 下用socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP)创建后,recvfrom拿到的是包含 IP 头的完整数据包,解析时要先跳过 20 字节(标准 IPv4 头长度,实际应按 IHL 字段算,但常规场景 20 字节够用)。而 Windows 下同样方式创建的套接字,收到的数据不含 IP 头,直接从 ICMP 部分开始。这个差异如果不处理,代码在 Linux 上跑得好好的,挪到 Windows 上解析就全乱套。我的处理办法是判断sys.platform,Linux 下偏移 20 字节再解析 ICMP,Windows 下从 0 开始。

提示:如果你的运行环境不允许原始套接字,还有一个折中方案——用SOCK_DGRAM配合IPPROTO_ICMP(Linux 支持),它不需要 root,但拿不到 IP 头,且部分系统不支持。生产环境我更倾向显式检查权限并给出清晰报错,而不是偷偷降级。

2. 核心细节解析与实操要点

2.1 校验和计算的坑与正确写法

校验和函数是整个实现的地基,写错了后面全白搭。标准做法是把数据按大端序每 16 位切一组求和,累加过程中把高 16 位的进位加回低 16 位,最后对结果取反并截断到 16 位。我见过最常见的错误是直接用 Python 的sum()然后~取反,忘了处理进位回卷,导致大包校验和算错。

def checksum(data): if len(data) % 2: data += b'\x00' total = 0 for i in range(0, len(data), 2): word = (data[i] << 8) + data[i + 1] total += word total = (total & 0xFFFF) + (total >> 16) return ~total & 0xFFFF

这段代码里total = (total & 0xFFFF) + (total >> 16)就是进位回卷的核心,每加一次就立刻把溢出位折回来,保证 total 始终在 16 位范围内。奇数字节补\x00也不能省,否则data[i+1]会越界。实测下来,这个写法对任意长度的 ICMP 载荷都能算对,跟 Wireshark 抓包里的校验和字段能对上。

2.2 报文打包与解包的对齐问题

打包用struct.pack,格式串"!BBHHH"对应 Type、Code、Checksum、Identifier、Sequence 五个字段,!表示网络字节序(大端)。这里有个细节:计算校验和时 Checksum 字段要先填 0,算完再回填。很多人图省事先填个随机值再算,结果校验和永远对不上。

header = struct.pack("!BBHHH", 8, 0, 0, ident, seq) payload = struct.pack("!d", time.time()) packet = header + payload chk = checksum(packet) header = struct.pack("!BBHHH", 8, 0, chk, ident, seq) packet = header + payload

载荷我选择塞一个double类型的发送时间戳(8 字节),收到应答后解出来跟当前时间相减,就是精确的 RTT。这比在外部用字典记录发送时间更可靠,因为报文自带时间戳,即使并发多个请求也不会串。解包时注意 Linux 下要先跳过 IP 头:

icmp_header = packet[20:28] # Linux type_, code, chk, recv_id, recv_seq = struct.unpack("!BBHHH", icmp_header)

判断应答有效性要同时满足三个条件:type_ == 0(是 Echo Reply)、recv_id == ident(是自己的包)、recv_seq == seq(序列号对得上)。少一个条件都可能把别人的应答误认成自己的。

2.3 超时控制与重传策略

settimeout是必须设的,否则网络不通时recvfrom会一直阻塞。我一般设 1 到 2 秒,局域网 1 秒足够,跨公网可以放宽到 2 秒。超时后抛socket.timeout,捕获它就算一次丢包,继续发下一个序列号。

重传策略上,我倾向"不重传、只统计"。原因很简单:ping 的语义就是"发一个包看回不回",重传会污染 RTT 统计,让你分不清这个时延是第一次的还是重传后的。真要重传,也应该在应用层单独记录,而不是在单次 ping 里偷偷补发。这个取舍在做监控工具时尤其重要——丢包率是核心指标,重传会让它失真。

注意:settimeout设得太短(比如 0.1 秒)在跨地域链路上会大量误判丢包,因为正常 RTT 就可能超过这个值。我吃过这个亏,后来统一按目标网段调整:内网 0.5 秒,同城 1 秒,跨地域 2 秒。

3. 完整实操过程与核心环节实现

3.1 环境准备与权限配置

先把环境理清楚。Python 3.7 以上即可,不需要第三方库,标准库的socket、struct、time、os、sys足够。Linux 下验证权限:

python3 -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP); print('ok')"

能打印 ok 说明权限到位。报PermissionError就说明需要提权。生产环境我推荐用setcap给解释器加能力,而不是整个脚本用 sudo 跑,这样最小权限原则守得住。Windows 下用管理员身份打开终端再执行即可。

3.2 单次 ping 的完整函数实现

把前面的碎片拼起来,单次 ping 的核心函数长这样:

import socket, struct, time, os, sys def ping_once(host, seq, timeout=1.0): ident = os.getpid() & 0xFFFF header = struct.pack("!BBHHH", 8, 0, 0, ident, seq) payload = struct.pack("!d", time.time()) packet = header + payload chk = checksum(packet) header = struct.pack("!BBHHH", 8, 0, chk, ident, seq) packet = header + payload sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) sock.settimeout(timeout) try: send_time = time.time() sock.sendto(packet, (host, 0)) while True: recv_packet, addr = sock.recvfrom(1024) offset = 20 if sys.platform.startswith("linux") else 0 icmp_part = recv_packet[offset:offset + 8] type_, code, chk2, recv_id, recv_seq = struct.unpack("!BBHHH", icmp_part) if type_ == 0 and recv_id == ident and recv_seq == seq: rtt = (time.time() - send_time) * 1000 return rtt, addr[0] except socket.timeout: return None, None finally: sock.close()

这里有个循环while True的设计意图:原始套接字会收到本机所有 ICMP 流量,包括别人 ping 本机的请求、其他进程的应答。所以收到包后必须校验,不匹配就继续收,直到超时。这个循环不能省,否则并发场景下会拿到错误的包。

3.3 多包统计与结果输出

单次 ping 只是原子操作,真正的命令要发多个包并统计。我按 4 个包、间隔 1 秒的经典配置来组织:

def ping(host, count=4, timeout=1.0): rtts = [] lost = 0 for seq in range(1, count + 1): rtt, ip = ping_once(host, seq, timeout) if rtt is None: lost += 1 print(f"seq={seq} 超时") else: rtts.append(rtt) print(f"seq={seq} 来自 {ip} 时延 {rtt:.2f} ms") if seq < count: time.sleep(1) if rtts: print(f"发送 {count} 接收 {len(rtts)} 丢包率 {lost/count*100:.1f}%") print(f"最小 {min(rtts):.2f} 最大 {max(rtts):.2f} 平均 {sum(rtts)/len(rtts):.2f} ms")

输出格式我刻意模仿系统 ping 的观感,但字段更精简。丢包率用lost/count算,注意分母是发送数不是接收数,这个细节很多人写反。平均时延用算术平均,如果要做更严谨的统计可以加标准差,但日常排查用不上。

3.4 域名解析与异常处理

传入的 host 可能是域名,sendto时内核会自动解析,但为了拿到解析后的 IP 并处理解析失败,我建议先显式解析:

try: target_ip = socket.gethostbyname(host) except socket.gaierror: print(f"无法解析主机 {host}") return

gaierror是 DNS 解析失败的专属异常,单独捕获它比笼统的except Exception更清晰。解析成功后把 IP 传给ping_once,输出里也能显示实际打的是哪个 IP,排查 CDN 或多 IP 场景时很有用。

4. 常见问题与排查技巧实录

4.1 典型故障速查表

实际跑起来会遇到各种"看起来通但就是没结果"的情况,我整理了一张速查表:

现象可能原因排查动作
一直超时,无任何应答目标禁 ping / 防火墙拦截 ICMP换目标验证,或用 TCP 端口探测对比
报 PermissionError权限不足Linux 加 CAP_NET_RAW,Windows 用管理员
校验和错误,对方不回字节序或进位处理错用 Wireshark 抓包对比校验和字段
收到应答但匹配不上Identifier/Sequence 判断缺失检查三个匹配条件是否齐全
Linux 正常 Windows 解析乱IP 头偏移未处理按平台判断 offset
RTT 明显偏大超时设太短导致重发或链路本身慢调大 timeout,分段测试

4.2 几个只有踩过才知道的细节

第一个坑是本机回环。ping 127.0.0.1 时,原始套接字同样能收到自己发的请求包(Type 8),如果不校验 Type 直接当应答处理,RTT 会接近 0 且数值异常稳定,看着像"特别快",其实是把自己的请求当成了回复。所以type_ == 0这个判断绝不能省。

第二个坑是序列号回绕。Sequence 是 16 位,超过 65535 会回绕到 0。日常 ping 几个包碰不到,但做长时间巡检、每秒一个包跑一天就会撞上。我的处理是序列号对 65536 取模,匹配时也按取模后的值比,避免长跑时突然全部匹配失败。

第三个坑是多网卡环境。机器有多块网卡时,sendto走哪块网卡由路由表决定,但recvfrom收到的包可能来自任意网卡。如果发现应答的源 IP 跟你预期的不一致,多半是路由选路的问题,可以用sock.bind显式绑定源地址来锁定出口。

提示:调试阶段强烈建议开一个 Wireshark 或 tcpdump 同步抓包,tcpdump -i any icmp -nn,把你发出去的包和收到的包逐字段对比。校验和、Identifier、Sequence 三个字段一对,问题基本无处遁形。这个习惯帮我省了大量瞎猜的时间。

4.3 性能与并发上的取舍

单线程顺序 ping 简单可靠,但批量探测几十上百个目标时太慢。要提速有两个方向:多线程或异步。多线程用ThreadPoolExecutor一把梭,每个目标一个线程,注意原始套接字是线程安全的创建但共享要小心,我倾向每个线程独立创建套接字,用完即关,避免状态串扰。异步方案用asyncio配合非阻塞套接字更优雅,但 ICMP 的异步封装相对繁琐,收益在目标数不多时并不明显。

我的经验是:目标数 20 以内,多线程足够;上百个目标且要求秒级出结果,才值得上异步。别为了技术而技术,巡检工具的第一要求是结果可信,第二才是快。另外并发数别开太大,原始套接字发太快容易触发目标侧 ICMP 限速,反而制造假丢包,我一般控制在 10 到 20 并发。

这套代码我在内网巡检和联调排查里用了挺久,最大的体会是:自己实现一遍 ping,比看十篇协议讲解都管用。那些字节序、校验和、IP 头偏移的细节,只有亲手调通一次才会真正记住。后续如果想扩展,可以加上 TTL 解析来估算跳数(从 IP 头里读 TTL 字段,用初始值减),或者把结果输出成 JSON 方便接入监控系统,都是很自然的下一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询