
简介面向网络协议学习与网络诊断实践的 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为什么要手写。这问题值得正面回答我把两边的差异列出来维度手写 structScapy 这类现成库依赖体积标准库零依赖拉一串依赖冷启动明显更慢字段可见性每个字节自己填错在哪一目了然大量默认值被自动填充反而看不清校验和自己实现能顺便验证算法自动算学不到东西适合场景学习协议、写轻量诊断工具快速验证、复杂协议栈模糊测试改造成本加一个新 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锁定 IPv4SOCK_RAW表示自己组包IPPROTO_ICMP让内核只把 ICMP 协议的数据递给你。这三个参数里最容易踩的是权限Linux 需要 root 或CAP_NET_RAWmacOS 需要 rootWindows 需要管理员身份。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 字节拆成标识符和序号字段长度说明type1 字节8 Echo 请求0 Echo 应答11 超时3 不可达code1 字节同一 type 下的子原因Echo 固定为 0checksum2 字节覆盖整个 ICMP 报文头 载荷identifier2 字节用来把应答和本次请求配对常用进程号sequence2 字节每发一个包加一用于计算丢包和乱序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 位三个容易忽略的点。第一奇数字节必须补零否则最后半组数据的位移会整体错位第二回卷要写两次第一次回卷后仍可能产生新的进位只卷一次在长报文里偶发错误属于典型的小包正常、大包失败第三返回前一定要 0xFFFFPython 的整数没有位宽限制~total在负数上会得到负值直接塞进struct.pack(H)会抛异常。写完算法别急着发包先用自洽性验证把算好的校验和填进报文再对整个报文求一次校验和正确实现的结果必然是 0。这个测试不需要任何权限跑得飞快def test_checksum_self_consistent(): pkt build_echo_request(0x1234, 1, bhello-icmp) # 含校验和的完整报文再算一遍结果应为 0 assert checksum(pkt) 0 def test_checksum_odd_length(): # 奇数长度走补零分支重点验证不抛异常且自洽 pkt build_echo_request(0x1234, 2, bodd) 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, payloadbicmp-kit, timeout2.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 1id 和 seq 要和你代码里传的一致不一致说明字节序或者字段位置错了第三十六进制区的第 9 字节开始是不是你的载荷如果你传了bicmp-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, count4, interval0.2, size56): 连续发送 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 (fmin{min(rtts):.2f}ms avg{statistics.mean(rtts):.2f}ms fmax{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 headerEcho 之外Timestamptype 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 11Time 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代码里我保留了简化版但加了长度检查够用且不容易越界。探测循环从 TTL1 开始递增每轮判断收到的是 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, low1200, high1500): 二分查找可用 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, bx * 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_rawep $(readlink -f $(which python3))。注意这行命令影响的是那个解释器二进制容器或重装环境后要重新执行。现象二sendto不报错recvfrom一直超时。这类问题九成不是代码错。先抓包看有没有回包没有回包多半是目标侧安全组或本机防火墙丢弃了 ICMP也可能是目标真的离线有回包但代码没接住那就是过滤条件太严比如只判断了 type 没判断 id或者剥 IP 头的时候把长度算错了。用tcpdump分一次有没有和接没接住比在代码里加一堆 print 快得多。现象三容器里跑报Operation not permitted。容器默认会丢掉CAP_NET_RAW即使容器内是 root 也不行。启动时加--cap-addNET_RAW即可或者临时用--privileged验证一下是不是权限问题。这个坑的迷惑点在于报错信息和没加 sudo一模一样但你在容器里本来就是 root会本能地怀疑代码。5.2 解析侧的两个坑现象四抓包显示checksum incorrect但自洽测试是通过的。检查两处。一是格式串漏了!用了本机字节序导致 type 和 code 位置错位校验和自然对不上二是算校验和之后又改了报文内容比如先算校验和再追加时间戳字段导致校验和覆盖范围不完整。统一原则是所有字段定稿之后再算校验和中间不要插入任何修改。现象五TTL 探测拿不到中间跳的数据。type 11 报文里嵌的原始数据只有 IP 头加 8 字节 ICMP 头没有载荷。如果你的匹配逻辑依赖载荷内容比如比对发出去的bicmp-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(defaultb, initFalse, reprFalse) 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 归位输出 JSONtools/*.pyRTT 保留两位小数超时用 null 而非 0支持 IPv6icmp_packet.py类型号变 128/129校验和加伪首部接入监控新增 exporter别在采集线程里做阻塞 recvfrom验证方法我固定三步走从便宜到贵。第一步跑单元测试重点是校验和自洽和字段往返解析这部分零权限、毫秒级任何改动都先过这一关。第二步抓包对照改完构造逻辑必须看一眼tcpdump里的字段和(correct)标记肉眼确认比任何断言都直观。第三步才是和系统ping对拍同一目标各发 20 个包看丢包率和 RTT 分布是否在同一量级差异超过一个数量级就说明构造或计时有问题。有个细节提醒改并发版本时很容易把ident写成全局常量多个探测线程共用同一个 id回包就分不清是谁的。要么每个线程分配独立 id要么干脆用单一 socket 串行发送、并发只用在等待上。我吃过这个亏当时现象是路径偶尔少一跳查了很久才定位到 id 冲突。从那以后我每次改报文构造代码都强制先跑一遍校验和自洽测试、再抓一次包确认字段然后才去看上层业务指标——这个习惯救过我很多次。希望帮到你。本文还有配套的精品资源点击获取