简介:面向网络协议学习与网络诊断实践的 ICMP 数据包构造材料,适合网络管理员、开发者及希望理解 TCP/IP 底层机制的学习者。内容围绕差错报告报文与查询报文的分类展开,说明类型字段、代码字段及可变数据字段的具体含义,并提示先将 ICMP 报文封装进 IP 数据报再发送,可借助 Wireshark 过滤 ICMP 流量,核对回显请求与回显应答等字段是否符合预期。压缩包共 3 个文件,由 cpp、h 与 txt 组成,分别承载实现代码、头文件声明和说明文字,整体约 5KB,体积轻便,便于快速查阅与对照实验,目录简洁,便于按代码、声明和说明三部分定位内容。已有 1609 人学习,可作为协议分析、网络排错与安全分析的入门参考;对理解回显机制、报文构造流程、IP 封装关系以及抓包验证思路也有直接帮助,适合课程实验与自学演练时对照使用。
1. 从一次 ping 通但业务超时说起:ICMP 数据包构造能解决什么
有个场景我印象很深:某开发者复现一个"小包正常、大包卡死"的问题,ping默认只发 56 字节数据,看上去一切正常,可业务请求一旦超过 1400 字节就超时。现成的 ping 命令像个黑匣子,它只告诉你通或不通,没法指定 DF 位、没法自定义载荷、也没法把 TTL 精确调到某一跳。ICMP 数据包构造的价值就在这儿——把 8 字节头、校验和、载荷逐个字节拼出来,塞进 raw socket 发出去,再自己解析回来的报文。
这套代码用 Python 加struct做了一套最小可用的构造器,覆盖 Echo 请求/应答、Timestamp 请求、TTL 递增路径探测和 MTU 探测四类场景,附带抓包对照与单元测试。适合三类人:想真正搞懂 IP/ICMP 字段布局的网络初学者、准备写网络诊断工具的后端开发、以及需要构造特定报文做协议兼容性验证的测试同学。下面按"结构—构造—扩展—排错—改造"的顺序拆开讲。
2. 项目结构与技术选型:raw socket 与 struct 的分工
2.1 目录结构:三层职责边界
这套代码刻意分成三层,构造、传输、工具各管一段,改一处不会牵连另一处。目录大致是这样:
icmp-kit/ ├── icmp_packet.py # 纯函数层:头部打包、校验和、报文解析 ├── icmp_socket.py # 传输层:创建 raw socket、收发、剥 IP 头、超时 ├── tools/ │ ├── ping.py # Echo 往返 + RTT 统计 │ ├── traceroute.py # TTL 递增,收 Time Exceeded │ └── mtu_probe.py # DF 位 + 二分查找 ├── tests/ │ └── test_packet.py # 校验和自洽、字段往返解析 └── README.mdicmp_packet.py里没有一行socket调用,全是bytes进bytes出的纯函数。这样写的好处很直接:校验和算错、字段顺序拼错,跑单元测试就能定位,不用每次都sudo发真包。等构造层稳了,icmp_socket.py只负责把bytes交给内核。工具脚本则是薄薄一层命令行封装,参数解析加循环。
我一般会把"需要特权的代码"和"不需要特权的代码"物理隔开,原因很现实——调试报文格式时你会反复跑测试,而每次都以 root 身份跑 pytest 既慢又容易把环境搞乱。构造层零依赖,CI 里随便跑;传输层才需要CAP_NET_RAW。
2.2 为什么不用 Scapy:一份对比
很多人第一反应是"Scapy 一行就能发 ICMP,为什么要手写"。这问题值得正面回答,我把两边的差异列出来:
| 维度 | 手写 struct | Scapy 这类现成库 |
|---|---|---|
| 依赖体积 | 标准库,零依赖 | 拉一串依赖,冷启动明显更慢 |
| 字段可见性 | 每个字节自己填,错在哪一目了然 | 大量默认值被自动填充,反而看不清 |
| 校验和 | 自己实现,能顺便验证算法 | 自动算,学不到东西 |
| 适合场景 | 学习协议、写轻量诊断工具 | 快速验证、复杂协议栈模糊测试 |
| 改造成本 | 加一个新 type 只要加一个打包函数 | 要读库的字段定义 |
真正让我坚持手写的原因是"可控"。做 MTU 探测时你需要精确控制"载荷长度 + DF 位 + TTL"三者组合,用现成库经常出现"我明明设了 DF,抓包却没看到"的情况,最后还得回到抓包里逐字段核对。手写只有几十行,翻车的时候至少知道该看哪一行。当然,如果哪天要做完整的 TCP 握手模拟或者模糊测试,我会毫不犹豫换回成熟库,工具没有高下,只有合不合适。
2.3 环境门槛:raw socket 的权限与跨平台差异
ICMP 不走 TCP/UDP 端口,必须用SOCK_RAW。这决定了三件事:权限、平台语义、返回内容。先看创建 socket 这段:
import socket import sys def open_icmp_socket(timeout: float = 2.0) -> socket.socket: """创建一个 ICMP raw socket,统一处理权限与超时""" try: # AF_INET + SOCK_RAW + IPPROTO_ICMP:直接拿到 ICMP 层 sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) except PermissionError: sys.exit("缺少权限:用 sudo 运行,或给解释器加 CAP_NET_RAW") except OSError as exc: sys.exit(f"创建 raw socket 失败:{exc}") sock.settimeout(timeout) # 必须设,否则 recvfrom 会永久阻塞 return sockAF_INET锁定 IPv4;SOCK_RAW表示自己组包;IPPROTO_ICMP让内核只把 ICMP 协议的数据递给你。这三个参数里最容易踩的是权限:Linux 需要 root 或CAP_NET_RAW,macOS 需要 root,Windows 需要管理员身份。settimeout也不建议省,没有它,目标不回包时脚本会一直挂在那里,看起来像"程序卡死",实际是在等一个永远不来的字节。
平台差异还体现在recvfrom的返回值上。Linux 的 raw socket 会把 IP 头一并返回,macOS 在部分场景下则不返回,Windows 的行为又是另一套。与其记平台名,不如用首字节的版本号判断——IPv4 头第一个字节高 4 位固定是 4,这个判据比platform.system()靠谱得多,第 3 章会给出实现。
3. 报文构造核心:从 8 字节头到校验和的逐字节拼装
3.1 头部字段与 struct 格式串
ICMP 头部固定 8 字节,前 4 字节所有类型通用,后 4 字节的含义随 type 变化。Echo 类型里后 4 字节拆成标识符和序号:
| 字段 | 长度 | 说明 |
|---|---|---|
| type | 1 字节 | 8 = Echo 请求,0 = Echo 应答,11 = 超时,3 = 不可达 |
| code | 1 字节 | 同一 type 下的子原因,Echo 固定为 0 |
| checksum | 2 字节 | 覆盖整个 ICMP 报文(头 + 载荷) |
| identifier | 2 字节 | 用来把应答和本次请求配对,常用进程号 |
| sequence | 2 字节 | 每发一个包加一,用于计算丢包和乱序 |
| payload | 变长 | 自定义数据,也用于撑大到指定长度 |
对应到struct,格式串是!BBHHH。那个!是网络字节序(大端),千万别漏——漏了就用本机小端,抓包会看到 type 和 code 位置整个反过来,校验和自然也算不对,这种错查起来相当玄学。下面这段就是完整的 Echo 请求构造:
import struct ICMP_ECHO_REQUEST = 8 def build_echo_request(ident: int, seq: int, payload: bytes = b"") -> bytes: """构造一个 Echo 请求报文,返回可直接 sendto 的 bytes""" # 第一步:checksum 先填 0,占位 header = struct.pack("!BBHHH", ICMP_ECHO_REQUEST, 0, 0, ident, seq) packet = header + payload # 第二步:对整个报文(头 + 载荷)算校验和 csum = checksum(packet) # 第三步:把校验和回填到第 3、4 字节的位置 header = struct.pack("!BBHHH", ICMP_ECHO_REQUEST, 0, csum, ident, seq) return header + payload这里有个顺序不能颠倒:校验和必须覆盖"含载荷的完整报文",而且计算时校验和字段本身要当作 0。所以代码里出现了两次struct.pack——第一次是为了拿到待计算的字节串,第二次才是最终报文。参数上,ident建议取os.getpid() & 0xFFFF,保证同一台机器上多个实例互不干扰;seq从 0 或 1 递增,超过 65535 自动回绕,这是协议本身决定的,不用特殊处理。
3.2 校验和:16 位反码求和
ICMP 校验和是互联网校验和的标准实现:把数据按 16 位分组求和,进位回卷,最后取反。写起来短,但细节全是坑:
def checksum(data: bytes) -> int: """RFC 1071 互联网校验和:16 位反码求和后取反""" if len(data) % 2: # 奇数字节要补一个 0,否则分组会错位 data += b"\x00" total = 0 for i in range(0, len(data), 2): total += (data[i] << 8) | data[i + 1] # 大端拼接,别写成小端 total = (total >> 16) + (total & 0xFFFF) # 进位回卷 total += total >> 16 # 再卷一次,防止二次进位 return (~total) & 0xFFFF # 取反并截断到 16 位三个容易忽略的点。第一,奇数字节必须补零,否则最后半组数据的位移会整体错位;第二,回卷要写两次,第一次回卷后仍可能产生新的进位,只卷一次在长报文里偶发错误,属于典型的"小包正常、大包失败";第三,返回前一定要& 0xFFFF,Python 的整数没有位宽限制,~total在负数上会得到负值,直接塞进struct.pack("H")会抛异常。
写完算法别急着发包,先用自洽性验证:把算好的校验和填进报文,再对整个报文求一次校验和,正确实现的结果必然是 0。这个测试不需要任何权限,跑得飞快:
def test_checksum_self_consistent(): pkt = build_echo_request(0x1234, 1, b"hello-icmp") # 含校验和的完整报文再算一遍,结果应为 0 assert checksum(pkt) == 0 def test_checksum_odd_length(): # 奇数长度走补零分支,重点验证不抛异常且自洽 pkt = build_echo_request(0x1234, 2, b"odd") assert checksum(pkt) == 0我见过不止一次有人把校验和算成"能跑但抓包标红",原因就是漏了第二次回卷,在 56 字节的 ping 里看不出来,一到 1400 字节就翻车。这两个测试写完,后面所有问题都能排除掉校验和这一项嫌疑。
3.3 发送与接收:一次完整的往返
构造好了,接下来是收发。注意回包处理有两件事必须做:剥掉可能存在的 IP 头,以及按标识符和序号过滤——因为 raw socket 会收到本机所有 ICMP 流量,包括别人发的和系统自己发的。
import time def strip_ip_header(packet: bytes) -> bytes: """Linux 的 raw socket 会带 IP 头,用版本号判断比判断平台可靠""" if packet and (packet[0] >> 4) == 4: ihl = (packet[0] & 0x0F) * 4 # 首部长度以 4 字节为单位 return packet[ihl:] return packet def ping_once(sock, addr, ident, seq, payload=b"icmp-kit", timeout=2.0): """发一个 Echo 请求并等待匹配的应答,返回 RTT 毫秒或 None""" sock.sendto(build_echo_request(ident, seq, payload), (addr, 0)) start = time.perf_counter() deadline = start + timeout while time.perf_counter() < deadline: try: raw, _peer = sock.recvfrom(2048) except socket.timeout: break body = strip_ip_header(raw) if len(body) < 8: continue r_type, r_code, _cs, r_id, r_seq = struct.unpack("!BBHHH", body[:8]) # 必须同时校验类型、标识符、序号,否则会误收别的回包 if r_type == 0 and r_id == ident and r_seq == seq: return (time.perf_counter() - start) * 1000 return None几个参数的选择理由。recvfrom(2048)的缓冲区要大于最大预期报文,1500 MTU 加上可能的 IP 头,2048 足够;(addr, 0)里的端口对 raw socket 没有意义,但sendto的签名要求给,填 0 即可;time.perf_counter()是单调时钟,不受系统时间调整影响,比time.time()更适合测 RTT。
那个while循环是必须的。Echo 应答可能晚到,可能被别的进程的 ICMP 穿插,也可能收到目标返回的不可达(type 3)。如果只recvfrom一次就判断,遇到干扰包会直接误判超时——这是新手最容易写出的版本,也是"明明通却显示丢包"的常见来源。
3.4 抓包对照:字段对不对,tcpdump 说了算
代码跑通不等于报文正确。最省事的验证方式是抓包,把每个字段和预期对一遍:
# 只抓目标主机的 ICMP,-XX 同时打印十六进制和 ASCII sudo tcpdump -i any -n -vv -XX 'icmp and host 192.0.2.10'看三件事。第一,cksum 0x???? (correct),括号里必须是 correct,出现 incorrect 就回到 3.2 检查回卷;第二,echo request, id 12345, seq 1,id 和 seq 要和你代码里传的一致,不一致说明字节序或者字段位置错了;第三,十六进制区的第 9 字节开始是不是你的载荷,如果你传了b"icmp-kit",这里应该能肉眼认出 ASCII。
提示:先用
ping -c 1抓一条系统生成的报文做基线,把自己构造的报文和它逐字段对齐,比对着 RFC 读要快得多。
4. 从 ping 到诊断工具:四种 ICMP 类型的构造差异
4.1 Echo 请求/应答与 RTT 统计
Echo 是最简单的一类,也是整套代码的底座。真正让它变成"工具"的是统计逻辑:连续发 N 个包,记录每个 RTT,最后输出最小、平均、最大和标准差。这段不复杂,但有个细节值得说——标准差用总体还是样本公式,会影响小样本下的数字,ping 类工具普遍用总体标准差,因为那 N 个包就是全部观测值,不是抽样。
import statistics def ping_series(sock, addr, ident, count=4, interval=0.2, size=56): """连续发送 count 个 Echo 请求,返回 RTT 列表""" payload = b"\x00" * size rtts = [] for seq in range(1, count + 1): rtt = ping_once(sock, addr, ident, seq & 0xFFFF, payload) if rtt is not None: rtts.append(rtt) time.sleep(interval) # 控制发送节奏,避免被限速 return rtts def summarize(rtts): if not rtts: return "全部超时" return (f"min={min(rtts):.2f}ms avg={statistics.mean(rtts):.2f}ms " f"max={max(rtts):.2f}ms mdev={statistics.pstdev(rtts):.2f}ms")size参数指 ICMP 载荷字节数,系统 ping 默认是 56,加上 8 字节 ICMP 头和 20 字节 IP 头正好 84 字节。想验证大包路径就把它调到 1400 以上,配合下一节的 DF 位使用。interval不是协议要求,纯粹是礼貌——发太快容易触发目标侧或中间设备的 ICMP 限速,表现就是"前几个通,后面全超时",很容易被误判成网络质量问题。
4.2 Timestamp 请求:20 字节的 rest of header
Echo 之外,Timestamp(type 13)值得单独讲,因为它的头部结构完全不一样:前 4 字节通用,后 4 字节不再是 id/seq,而是接三个 32 位时间戳,总长 20 字节。
ICMP_TIMESTAMP_REQUEST = 13 def build_timestamp_request(ident: int, seq: int, originate_ms: int) -> bytes: """构造 Timestamp 请求:发起/接收/发送三个 32 位毫秒时间戳""" # 后两个时间戳由对端填充,请求里填 0 body = struct.pack("!III", originate_ms, 0, 0) header = struct.pack("!BBHHH", ICMP_TIMESTAMP_REQUEST, 0, 0, ident, seq) packet = header + body csum = checksum(packet) header = struct.pack("!BBHHH", ICMP_TIMESTAMP_REQUEST, 0, csum, ident, seq) return header + body三个时间戳的含义要分清:originate 是你发出的时刻,receive 是对端收到的时刻,transmit 是对端回包的时刻,单位都是"自 UTC 零时起的毫秒数",取模 2 的 32 次方。由此能算出两段单向时延:发出到对端收到、对端发回到你收到,比单纯 RTT 更能定位延迟发生在哪一段。
不过这类报文在实际网络里经常不回,很多系统默认关闭 Timestamp 应答。所以我的做法是把它当成"可选能力":发出去收不到就静默跳过,不当作故障。写工具时把这种"预期可能失败"的路径单独处理,比事后被用户问"为什么你这版没有时间同步"要省心。
4.3 TTL 递增做路径探测
路径探测的核心不是构造新报文,而是复用 Echo 请求加上不同的 TTL。每经过一个路由设备 TTL 减一,减到 0 时设备回一个 type 11(Time Exceeded),里面嵌着原始 IP 头和 ICMP 的前 8 字节。靠这前 8 字节里的标识符和序号,就能知道是哪一跳、对应哪次探测。
def set_ttl(sock, ttl: int) -> None: # IP_TTL 是 IP 层选项,对 raw socket 同样生效 sock.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, ttl) def parse_time_exceeded(body: bytes): """从 type 11 报文里取出内嵌的原始 ICMP 头,返回 (id, seq)""" if len(body) < 8 + 20 + 8: return None # 跳过外层 ICMP 头(8) 和内嵌 IP 头(20),取内嵌 ICMP 的前 8 字节 inner = body[8 + 20: 8 + 20 + 8] _t, _c, _cs, ident, seq = struct.unpack("!BBHHH", inner) return ident, seq这里假设内嵌 IP 头是 20 字节无选项,绝大多数情况成立,严谨写法应该像 3.3 那样用首字节算 IHL,代码里我保留了简化版但加了长度检查,够用且不容易越界。探测循环从 TTL=1 开始递增,每轮判断收到的是 Echo 应答(到了目标)还是 Time Exceeded(中间跳),前者说明路径走完,可以退出。
有个血泪经验:别用固定的seq做匹配。路径探测是并发友好的场景,多轮探测的应答可能乱序返回,用seq编码 TTL 才能把应答和跳数对应上,否则打印出来的路径会莫名其妙地跳动。
4.4 MTU 探测:DF 位与二分查找
判断路径 MTU 的思路是:发一个设了"不分片"标志的报文,如果中途某段链路装不下,设备会回 type 3 code 4(需要分片但设了 DF),据此缩小尺寸重试。DF 位的设置方式各平台不同,所以代码里做了回退:
IP_PMTUDISC_DO = 2 def set_dont_fragment(sock) -> None: """尽量打开 DF 位,不同平台选项名不一样,做两级回退""" try: # Linux 走 IP_MTU_DISCOVER,值为 2 表示强制不分片 opt = getattr(socket, "IP_MTU_DISCOVER", 10) sock.setsockopt(socket.IPPROTO_IP, opt, IP_PMTUDISC_DO) except OSError: # 其他平台退到 IP_DONTFRAG sock.setsockopt(socket.IPPROTO_IP, getattr(socket, "IP_DONTFRAG", 26), 1) def probe_mtu(sock, addr, ident, low=1200, high=1500): """二分查找可用 MTU,返回探测到的最大值""" best = low while low <= high: mid = (low + high) // 2 payload_len = mid - 28 # 减去 IP 头 20 + ICMP 头 8 if payload_len < 0: break rtt = ping_once(sock, addr, ident, mid & 0xFFFF, b"x" * payload_len) if rtt is not None: best = mid low = mid + 1 # 能通,往大试 else: high = mid - 1 # 不通,往小试 return bestmid - 28这个减法必须算清楚:MTU 指的是 IP 包总长,包含 20 字节 IP 头和 8 字节 ICMP 头,载荷只能是剩下的部分。写错这一步,测出来的 MTU 会稳定偏大 28,而且现象很迷惑——小包全通,接近阈值时偶发失败。
还有个前提:DF 位没设成功的话,超长报文会被中间设备静默分片而不是回 type 3,探测就退化成了"永远能通"。所以set_dont_fragment之后建议先抓一次包确认标志位真的置上了,这一步花三十秒,能省掉后面半小时的困惑。
5. 避坑与排查:权限、字节序、内嵌报文的五个现场
5.1 发送侧的三个坑
现象一:本地跑得好好的,sudo之后报ModuleNotFoundError。原因很直接,sudo换了一套环境变量,虚拟环境里的第三方包路径没带过去。解决方式是sudo -E python tools/ping.py <host>,把环境透传过去;更干净的做法是给解释器加能力位,之后就不用每次提权:sudo setcap cap_net_raw+ep $(readlink -f $(which python3))。注意这行命令影响的是那个解释器二进制,容器或重装环境后要重新执行。
现象二:sendto不报错,recvfrom一直超时。这类问题九成不是代码错。先抓包看有没有回包:没有回包,多半是目标侧安全组或本机防火墙丢弃了 ICMP,也可能是目标真的离线;有回包但代码没接住,那就是过滤条件太严,比如只判断了 type 没判断 id,或者剥 IP 头的时候把长度算错了。用tcpdump分一次"有没有"和"接没接住",比在代码里加一堆 print 快得多。
现象三:容器里跑报Operation not permitted。容器默认会丢掉CAP_NET_RAW,即使容器内是 root 也不行。启动时加--cap-add=NET_RAW即可,或者临时用--privileged验证一下是不是权限问题。这个坑的迷惑点在于报错信息和"没加 sudo"一模一样,但你在容器里本来就是 root,会本能地怀疑代码。
5.2 解析侧的两个坑
现象四:抓包显示checksum incorrect,但自洽测试是通过的。检查两处。一是格式串漏了!,用了本机字节序,导致 type 和 code 位置错位,校验和自然对不上;二是算校验和之后又改了报文内容,比如先算校验和再追加时间戳字段,导致校验和覆盖范围不完整。统一原则是"所有字段定稿之后再算校验和",中间不要插入任何修改。
现象五:TTL 探测拿不到中间跳的数据。type 11 报文里嵌的原始数据只有 IP 头加 8 字节 ICMP 头,没有载荷。如果你的匹配逻辑依赖载荷内容(比如比对发出去的b"icmp-kit"),永远匹配不上。正确做法是用内嵌头里的 identifier 和 sequence 配对,并且给内嵌 IP 头的长度做 IHL 计算而不是写死 20,否则遇到带选项的报文会整体偏移,解析出一堆垃圾值。
注意:raw socket 收到的是本机所有 ICMP 流量。调试时如果发现 id 频繁变化,先确认是不是系统自带的 ping 或其他监控进程在发包,别急着怀疑自己的构造逻辑。
6. 二次开发:把构造器抽成类,以及我怎么验证改动
真正要长期用,第一件事是把散落的函数收成一个构造器,让"改字段"变成改属性而不是改格式串。我通常这么写:
from dataclasses import dataclass, field @dataclass class IcmpEcho: ident: int seq: int payload: bytes = b"" type: int = 8 code: int = 0 _raw: bytes = field(default=b"", init=False, repr=False) def pack(self) -> bytes: self._raw = struct.pack("!BBHHH", self.type, self.code, 0, self.ident, self.seq) + self.payload csum = checksum(self._raw) self._raw = struct.pack("!BBHHH", self.type, self.code, csum, self.ident, self.seq) + self.payload return self._raw抽成类之后,加新类型只要继承并覆写pack,加 IPv6 支持也只需换地址族和校验和规则(ICMPv6 的校验和要带伪首部,这点和 IPv4 完全不同,别直接复用)。几条具体的改造路线我列在下面,按投入从低到高排:
| 改造点 | 涉及模块 | 需要注意的地方 |
|---|---|---|
| 加并发探测 | tools/traceroute.py | 用 seq 编码 TTL,应答按 seq 归位 |
| 输出 JSON | tools/*.py | RTT 保留两位小数,超时用 null 而非 0 |
| 支持 IPv6 | icmp_packet.py | 类型号变 128/129,校验和加伪首部 |
| 接入监控 | 新增 exporter | 别在采集线程里做阻塞 recvfrom |
验证方法我固定三步走,从便宜到贵。第一步跑单元测试,重点是校验和自洽和字段往返解析,这部分零权限、毫秒级,任何改动都先过这一关。第二步抓包对照,改完构造逻辑必须看一眼tcpdump里的字段和(correct)标记,肉眼确认比任何断言都直观。第三步才是和系统ping对拍,同一目标各发 20 个包,看丢包率和 RTT 分布是否在同一量级,差异超过一个数量级就说明构造或计时有问题。
有个细节提醒:改并发版本时很容易把ident写成全局常量,多个探测线程共用同一个 id,回包就分不清是谁的。要么每个线程分配独立 id,要么干脆用单一 socket 串行发送、并发只用在等待上。我吃过这个亏,当时现象是"路径偶尔少一跳",查了很久才定位到 id 冲突。
从那以后我每次改报文构造代码,都强制先跑一遍校验和自洽测试、再抓一次包确认字段,然后才去看上层业务指标——这个习惯救过我很多次。希望帮到你。
本文还有配套的精品资源,点击获取