
简介面向网络编程初学者、网络管理员与协议分析爱好者的ICMP数据包构造实践资料围绕ICMP报文结构、类型与代码字段含义展开并说明将其封装进IP数据报的基本思路帮助读者从代码层面区分差错报告报文与查询报文理解回显请求、回显应答等典型场景的实现要点。压缩包为rar格式共3个文件以1个cpp源码、1个h头文件和1个txt说明文档为主源码用于呈现报文构造过程头文件承载结构声明说明文档补充背景与使用提示整体约5KB体量轻便适合作为协议学习与实验对照的小型示例。已有1609人学习具备一定参考热度。读者可借此获得ICMP报文构造的代码框架、字段组织与结构定义参考并延伸理解IP封装、报文校验及抓包比对等排错思路适合边阅读源码边结合网络抓包工具核对报文细节夯实对TCP/IP底层通信机制的认识。1. ICMP 数据包构造先别急着用现成库手搓一遍才知道坑在哪线上有台机器ping不通但业务端口连得上运维说“网络是好的”开发说“我这边超时”。这种扯皮场景里现成的 ping 工具只丢给你一句超时什么信息都没有。这个 ICMP 数据包构造的小项目就是把这层黑匣子拆开用 Python 标准库自己拼一个 8 字节 ICMP 头、算反码校验和、走原始套接字发出去再把回来的报文一层层剥开看。它适合三类人想搞明白 ping 和 traceroute 底层到底发了什么的人要给自研监控加一条轻量探测链路的人以及想练手struct二进制打包的老哥。整个工程不依赖第三方库几十行核心代码就能跑剩下的都是把这件事做得更稳的脚手架。2. 报文结构与工程骨架8 字节头决定了一切ICMP 看着神秘拆开就是一个定长头加一段载荷。Echo Request 的头部固定 8 字节后面跟任意长度的 data接收方原样回显。真正容易翻车的地方不在字段本身而在字节序、校验和时机和标识符取值这三件事上。2.1 ICMP Echo 的字段布局与 struct 格式串先把字段摆清楚。Echo Requesttype8和 Echo Replytype0共用同一套布局偏移长度字段说明01type8 请求0 应答11 超时11codeEcho 场景固定 022checksum覆盖整个 ICMP 报文的反码和42identifier用来匹配请求与应答常见取进程号62sequence序号逐包递增8Ndata载荷可放时间戳或自定义内容对应的格式串是!BBHHH。!表示网络字节序大端这很重要——不管你跑在 x86 还是 ARM 上线上传输的永远是高位在前。B是无符号单字节H是无符号双字节。很多新手第一次写会漏掉!在本机自测时因为收发都在同一台机器上字节序一致反而看不出问题一跨机器就全乱。工程里我把报文相关的纯函数收在一个模块里不碰 socket方便单元测试# probe/packet.py import struct ICMP_ECHO_REQUEST 8 ICMP_ECHO_REPLY 0 ICMP_TIME_EXCEEDED 11 # 8 字节定长头! 强制网络字节序 ICMP_HEADER_FMT !BBHHH ICMP_HEADER_LEN struct.calcsize(ICMP_HEADER_FMT) # 8 def build_echo_request(ident: int, seq: int, payload: bytes b) - bytes: 构造一个 ICMP Echo Request校验和先填 0 占位 header struct.pack( ICMP_HEADER_FMT, ICMP_ECHO_REQUEST, # type 0, # code 0, # checksum 占位 ident 0xFFFF, # identifier截断到 16 位 seq 0xFFFF, # sequence ) return header payload这个函数只负责拼不算校验和。为什么不在这里一步到位因为校验和要覆盖“头 载荷”整体如果先算头再拼载荷算出来的值一定是错的。工程上更稳妥的做法是先拼完整包、再回填校验和这个顺序在下一节展开。ident 0xFFFF这个截断也别省。os.getpid()在某些系统上返回值可能超过 65535struct.pack遇到超范围的值会直接抛struct.error报错信息还很含糊排查起来浪费时间。2.2 目录结构与模块职责划分仓库不大但分层是清晰的。报文构造、校验和、套接字收发、TTL 探测、命令行入口各自独立单测只覆盖前两层不需要 root 权限就能跑icmp-probe/ ├── probe/ │ ├── __init__.py │ ├── packet.py # 报文构造与解析纯函数 │ ├── checksum.py # 反码求和校验和 │ ├── sender.py # raw socket 创建、发送、收包 │ ├── tracer.py # TTL 递增探测逻辑 │ └── cli.py # 命令行入口与参数解析 ├── tests/ │ └── test_packet.py # 不依赖网络的单元测试 ├── requirements.txt # 实际上为空仅标准库 └── README.md这么拆的理由很实际packet.py和checksum.py是纯计算可以在没有管理员权限的 CI 环境里跑sender.py需要CAP_NET_RAW或者 root本地开发时才用得上tracer.py是策略层负责“TTL 从 1 加到多少、每跳等多久”跟底层报文无关。三层的边界一旦划清后面加 IPv6 或者加自定义载荷改动范围就很可控。requirements.txt留空不是偷懒是刻意的约束——原始套接字这部分标准库的socket已经够用引入第三方抓包库反而会把“报文长什么样”这层又遮回去。如果只是想快速验证用现成库没问题但要理解构造过程从struct.pack起步更直接。2.3 报文构造函数的逐参数说明把build_echo_request的每个参数掰开说。ident的作用是让接收方知道这个回复属于哪个请求。同一台机器上可能同时跑着系统自带的 ping 和你的脚本如果两边用了相同的 ident 和 seq收包时就会互相抢对方的应答出现“明明发出去了却收到一个莫名其妙的回复”这种玄学现象。常见做法是取os.getpid() 0xFFFF进程号天然区分不同实例。seq是从 1 开始递增的计数器作用是在连续发包时对齐请求与应答同时用来算丢包率。这里有个细节seq在 traceroute 场景下不只是序号它还要和 TTL 绑定因为每一跳可能返回多个应答多路径得靠 seq 反查这一跳发了几个包。payload是载荷。系统自带的 ping 默认填一段递增字节我们这里建议填发送时刻的时间戳# 载荷放 8 字节 double 时间戳收到回包后反解算 RTT import struct, time payload struct.pack(!d, time.time())这么做的好处是不依赖本地记录的发送时间表。回包里的载荷原样带回收到后struct.unpack(!d, payload)就能算出往返延迟即使程序中间重启、或者多个探测并发也不会串。代价是载荷从 8 字节起步比默认的 56 字节小但完全不影响 ICMP 语义。构造完成后报文长度 8 len(payload)。这个长度在发送时不用显式告诉 socketsendto会按 bytes 长度处理但在解析回包时要用到因为要判断“收到的数据够不够长、载荷在哪”。3. 校验和与收发链路从 checksum 到 recvfrom 的完整闭环报文拼好了不等于能发出去。ICMP 的校验和是强制字段算错一个字节内核或者对端会直接丢弃你在应用层看到的现象就是“发了没反应”连个错误码都不给。这一章把校验和算法、原始套接字的创建参数、回包解析三段串起来。3.1 反码求和校验和的实现与常见写法对比ICMP 校验和用的是 16 位反码求和把报文按 16 位切块累加进位回卷到低位最后取反。实现上有几个容易写错的点我按踩过的顺序列# probe/checksum.py def checksum(data: bytes) - int: 16 位反码求和返回可直接填入头部的校验和 # 奇数长度补一个 0 字节保证按 16 位切 if len(data) % 2: data b\x00 total 0 for i in range(0, len(data), 2): # 大端拼成 16 位字与网络字节序一致 word (data[i] 8) | data[i 1] total word # 进位回卷高 16 位加到低 16 位可能要卷两次 while total 16: total (total 0xFFFF) (total 16) return (~total) 0xFFFF第一个坑是奇数长度。ICMP 头固定 8 字节载荷如果是奇数长度比如你塞了个 5 字节字符串整体就是奇数不补零会越界或者漏算最后一字节。补的零只在计算时用不发出去。第二个坑是回卷次数。用while而不是一次if是因为累加过程中可能产生多次进位。虽然实践中两次回卷就收敛但写if在极端数据下会漏掉高位校验和就错了。第三个坑是取反后的掩码。Python 的~作用于整数会得到负数 0xFFFF把它拉回 16 位无符号。这一步漏了struct.pack会直接报错还算好排查真正麻烦的是某些实现里用~total 1写成了补码跟反码和是两个东西发出去的包对端一律不回。算校验和的时机也要注意。正确顺序是先拼“头 载荷”的完整字节串校验和字段填 0整体算一遍再把结果回填到偏移 2 的位置def finalize(packet: bytes) - bytes: packet 必须是 checksum 字段为 0 的完整报文 csum checksum(packet) # 头部 8 字节checksum 位于偏移 2长度 2 return packet[:2] struct.pack(!H, csum) packet[4:]这里用切片替换而不是重新pack整个头是为了避免把 type、code、ident、seq 再抄一遍出错。字节串不可变切片拼接会生成新对象开销可以忽略。顺便说个对照如果哪天你改用抓包库构造报文校验和通常由库自动算。好处是省事坏处是你不知道它算错了该怎么查。手写一遍之后遇到“包发出去了但对端没反应”第一反应就是抓包看校验和字段是不是 0 或者明显异常这个直觉很值钱。3.2 raw socket 创建与发包参数原始套接字的创建有三个参数每个都有讲究# probe/sender.py import socket, os, time from .packet import build_echo_request, ICMP_HEADER_FMT, ICMP_HEADER_LEN from .checksum import checksum def send_echo(host: str, seq: int, timeout: float 2.0): ident os.getpid() 0xFFFF payload struct.pack(!d, time.time()) # 1) 先拼完整包校验和占位 0 raw build_echo_request(ident, seq, payload) # 2) 整体算校验和并回填 csum checksum(raw) packet raw[:2] struct.pack(!H, csum) raw[4:] # AF_INET SOCK_RAW IPPROTO_ICMP 是固定组合 with socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) as s: s.settimeout(timeout) # 第三个参数是端口ICMP 无端口概念填 0 s.sendto(packet, (host, 0))AF_INET指定 IPv4。SOCK_RAW表示我们要自己构造协议头内核只负责加 IP 头。IPPROTO_ICMP告诉内核按 ICMP 协议注册这样内核才知道把哪些回包投递到这个套接字。sendto的地址是个二元组(host, 0)端口写 0 不是随便填的——ICMP 没有端口概念这个位置在内核里被忽略但 API 要求必须给。有人会填 1 或者 80反正不生效写 0 语义最清楚。还有一个隐藏参数IP_TTL。默认值通常是 64ping 场景无所谓traceroute 场景必须每发一个包改一次下一章细说。修改方式是s.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, ttl)注意选项级别是IPPROTO_IP而不是IPPROTO_ICMP写错了会抛OSError。3.3 回包解析剥掉 IP 头再拆 ICMPrecvfrom收上来的数据在 Linux 上是带 IP 头的完整 IP 包不是纯 ICMP。第一步得先算 IP 头长度def parse_reply(data: bytes): # IP 头第一个字节低 4 位是首部长度单位是 4 字节 ihl (data[0] 0x0F) * 4 icmp data[ihl:] if len(icmp) ICMP_HEADER_LEN: return None itype, code, chksum, ident, seq struct.unpack( ICMP_HEADER_FMT, icmp[:ICMP_HEADER_LEN] ) payload icmp[ICMP_HEADER_LEN:] return {type: itype, code: code, ident: ident, seq: seq, payload: payload}(data[0] 0x0F) * 4这行的意思是IP 头首字节高 4 位是版本号4低 4 位是首部长度单位是 32 位字所以乘 4 得到字节数。最小值 20带选项时可能到 60。写死 20 是最常见的错误在本机自测时通常没选项、恰好 20 字节看着能跑一旦经过某些中间设备加了选项解析就整体错位读出来的 type 可能是任意值。拿到 ICMP 部分后用同一个格式串拆头剩下的就是载荷。校验和字段这里一般不再校验因为内核在投递给原始套接字之前通常已经验证过一遍如果要自己验把整个 ICMP 部分丢进checksum()结果应该是 0。过滤环节不能省。原始套接字会收到本机所有 ICMP 流量包括系统 ping、其他进程的探测、甚至路由器的不可达通知。判断标准是type 0 and ident 我的 ident and seq 我发的 seq三条件同时满足才算自己的回包。只看 type 不看 ident多进程并发时必然串包。4. 从 ping 到 traceroute用 TTL 把路径一段段逼出来ping 是一条直线发一个、收一个、算时间。traceroute 是沿着路径逐跳试探靠的是 IP 头里那个 TTL 字段。同一个套接字、同一套报文构造逻辑只是把 TTL 从 1 开始递增就能把中间经过的每一跳逼出来。4.1 TTL 与 setsockopt 的配合TTL 的机制很朴素每经过一个路由器减 1减到 0 时路由器丢弃这个包并回一个 ICMP 超时报文type11code0。所以把 TTL 设成 1第一个路由器就会回超时它的地址就是第一跳设成 2第二个路由器回以此类推。代码上就是在发送前改套接字选项# probe/tracer.py import socket, time from .packet import build_echo_request, ICMP_HEADER_FMT, ICMP_HEADER_LEN from .checksum import checksum def probe_hop(host: str, ttl: int, seq: int, timeout: float 2.0): ident 0x1234 # traceroute 场景下可用固定值 payload struct.pack(!d, time.time()) raw build_echo_request(ident, seq, payload) csum checksum(raw) packet raw[:2] struct.pack(!H, csum) raw[4:] with socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) as s: s.settimeout(timeout) # 关键每跳改一次 TTL必须在 sendto 之前 s.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, ttl) start time.perf_counter() s.sendto(packet, (host, 0)) try: while True: data, addr s.recvfrom(1500) ihl (data[0] 0x0F) * 4 icmp data[ihl:] itype, code icmp[0], icmp[1] rtt (time.perf_counter() - start) * 1000 if itype 11: # 超时报文拿到的是中间路由器的地址 return {hop: ttl, addr: addr[0], rtt: rtt, done: False} if itype 0 and icmp[4:6] struct.pack(!H, ident): # Echo Reply到达目标 return {hop: ttl, addr: addr[0], rtt: rtt, done: True} except socket.timeout: return {hop: ttl, addr: None, rtt: None, done: False}setsockopt必须在sendto之前调用这个顺序不能反。套接字选项影响的是后续发送的包改晚了就是对已经发出去的包做无用功现象是“明明设了 TTL 却一直收到目标主机的回复”第一次遇到会怀疑人生。timeout设 2 秒是经验值。跨运营商链路偶尔会有几百毫秒的抖动设 0.5 秒会产生大量假丢包设 5 秒则遇到不通的跳时整轮探测被拖得很长。2 秒在大多数场景下够用真要优化可以按跳自适应。4.2 超时报文 type11 的识别与 RTT 统计回包处理里最关键的一个分支是itype 11。超时报文的载荷里嵌着原始 IP 头和原始 ICMP 头的前 8 字节理论上可以从中反解出我们当初发的 ident 和 seq用来精确匹配是哪个探测触发的超时。但实现上有个现实问题不同系统回填的原始报文长度不一致有的只带 8 字节解析逻辑要写得非常防御。更省事的做法是靠 TTL 本身来区分——每个 TTL 值一次只发一个包收到任何 type11 就归到当前这一跳。代价是并发度低探测一轮要按跳串行。如果要提速就得改成“一个 TTL 发多个包 从载荷里反解 seq”复杂度直接上一个台阶。RTT 的计算点也要注意。start是在sendto之前打的包含了内核处理时间通常几十微秒可以忽略。但如果你的程序里还在循环内做日志落盘、DNS 反解这类慢操作就一定得把start打在紧贴sendto的位置不然测出来的是“业务耗时”而不是“网络耗时”。统计层面每跳返回一个结构体最后汇总成表。丢包的跳addr is None通常显示成*连续多跳都超时一般说明中间某段做了 ICMP 限速或者直接丢弃这时候继续加大 TTL 也没用得换个探测方式。4.3 批量探测的循环控制与超时处理把单跳函数串成完整 traceroute 的主循环有几个控制点def trace(host: str, max_hops: int 30): seq 1 for ttl in range(1, max_hops 1): result probe_hop(host, ttl, seq) seq 1 if result[addr] is None: print(f{ttl:2} * 请求超时) continue print(f{ttl:2} {result[addr]:15} {result[rtt]:.2f} ms) if result[done]: print(已到达目标) breakmax_hops默认 30 是常见上限够覆盖绝大多数公网路径。不设上限的风险是遇到路由环路时无限打下去TTL 一直加包一直发脚本挂在那里不动。seq在主循环里递增而不是每跳重置是为了在日志和抓包里能唯一标识每一个发出的包。如果按跳重置出问题时你会看到一堆 seq1 的包根本分不清是第几跳发的。continue那行的逻辑值得单独说。某一跳超时不代表结束可能只是那台路由器不响应 ICMP继续加 TTL 后面的跳往往还能通。真正要终止的条件只有两个收到 Echo Reply到达目标或者 TTL 到上限。把“超时”当终止条件是最容易犯的逻辑错误会让 traceroute 在第一台不回应的小路由器那儿就断掉看起来像是“网络只有一跳”。5. 避坑与排查那些在 root 权限下也发不出去的包这套代码逻辑本身不长但真正跑通往往要跟环境搏斗几轮。下面几条是实际调试时最常撞上的按“现象 → 原因 → 解决”记录。5.1 现象PermissionError提示不允许的操作原因创建SOCK_RAW套接字需要CAP_NET_RAW能力普通用户直接调用会被拒绝。Linux 下报PermissionError: [Errno 1] Operation not permittedWindows 下则是OSError: [WinError 10013]。解决Linux 用sudo跑或者给 Python 解释器单独赋能力。Windows 需要用管理员权限打开终端。注意别在容器里想当然——容器默认的能力集通常包含NET_RAW但有些加固过的运行时会把它去掉症状和宿主机权限不足一模一样得进容器capsh --print看一眼才确认。5.2 现象能发出去但永远收不到回包超时原因最常见的是校验和算错包被对端静默丢弃其次是防火墙或者安全组把 ICMP 入站挡了。两者现象完全一致都是“发了没反应”。解决先在本机ping 127.0.0.1验证构造逻辑本机回环不经过防火墙能收到说明校验和没问题收不到就回头查checksum()。本机通了、外网不通基本就是链路或者策略问题换目标、抓包确认。5.3 现象解析出来的 type 是乱值或者程序直接下标越界原因把 IP 头长度写死成 20 字节。部分网络路径会给 IP 头加选项实际长度 24 或 28此时data[20:]切出来的不是 ICMP 头。解决统一用(data[0] 0x0F) * 4动态计算并且在切之前判断len(data) ihl。解析函数里加长度防御比事后抓包猜要省事得多。5.4 现象Windows 上收到回包解析全乱Linux 上却正常原因两个平台对原始套接字的语义不一样。Linux 上IPPROTO_ICMP收上来的是完整 IP 包带 IP 头Windows 上收上来的通常直接从 ICMP 头开始没有 IP 头。同一段data[ihl:]在 Windows 上会把 ICMP 头切掉 20 字节。解决在parse_reply里加平台判断或者更稳的办法是看数据特征——判断data[9] 1IP 头里的协议字段是 ICMP来决定要不要剥 IP 头。别用sys.platform硬编码同一平台不同版本的行为也可能有差异。5.5 现象多进程同时跑收到的回包对不上号原因ident 冲突。两个进程如果都用了同一个固定 ident比如都写死 0x1234内核投递回包时不会区分谁先recvfrom谁拿到。解决ident 至少取os.getpid() 0xFFFF再进一步可以叠加随机数。判断回包归属时同时比对 ident 和 seq只比一个都不够。6. 二次开发把探测能力塞进自己的监控里这套代码跑通之后最自然的改造方向是把它从“命令行小工具”变成“监控系统里的一个探测器”。我的做法是保留packet.py和checksum.py不动把sender.py外面包一层采集器输出结构化的探测结果供上层消费。第一个改造点是并发模型。现在的实现是一个套接字、串行发收探测 30 个目标要等 30 轮超时最坏情况能拖到分钟级。改成每个目标一个线程、每个线程独立套接字互不干扰总耗时降到单轮超时。线程数按目标数量控制超过 50 个就上线程池限流避免把本机的文件描述符耗光。第二个改造点是载荷设计。现在塞的是 8 字节时间戳够算 RTT。如果要区分“哪个业务线发起的探测”可以把载荷扩成时间戳 4 字节业务 ID回包解析时一并取出来。这里要注意载荷长度会改变报文总长校验和必须重算别拿旧的缓存值直接用。第三个改造点是结果落地。探测函数只返回字典落库、打点、告警都放在外层。这样单测好写也方便替换存储。表结构大概长这样字段类型说明targetstring探测目标地址tsint64探测发起时间戳毫秒rtt_msfloat往返延迟超时为 NULLlostbool本次是否丢包src_ipstring回包来源地址用于发现路径漂移验证改造有没有跑偏我一般走三步。第一步ping 127.0.0.1和ping 网关各跑一轮确认基础链路和校验和没问题。第二步对着一个已知会丢 ICMP 的目标跑确认程序是把结果标记成丢包而不是抛异常退出。第三步抓包比对——用系统自带的抓包工具看发出的 type、code、checksum、ident、seq 五个字段跟你代码里算的是否一致这一步能一次性排掉绝大多数“逻辑对但结果不对”的问题。还有一个容易被忽略的点长期运行的探测器一定要有资源回收。原始套接字用完必须close()用with语句最省心。我曾经写过一个每分钟探测一次的常驻脚本忘了关套接字跑了两天之后进程里堆了几千个句柄最后socket()直接抛Too many open files日志里还看不出任何异常排查了大半天才发现是个with漏写。从那以后我每次写这种循环发包的代码都强制先在本地跑 200 轮压测看句柄数是否稳定再往线上放。希望这些经验能帮到你少走几段弯路。本文还有配套的精品资源点击获取