news 2026/10/7 1:42:47

ICMP数据包构造从零开始:校验和、raw socket与抓包验证

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ICMP数据包构造从零开始:校验和、raw socket与抓包验证

简介:ICMP数据包构造是网络协议学习中的一项基础实践,这份压缩包面向网络初学者、在校学生及需要排查网络问题的开发人员,围绕ICMP报文结构、差错报告与查询报文分类、IP数据报封装等核心知识点,提供可直接运行的C++源码,帮助读者从零掌握构造回显请求和应答报文的方法。压缩包体积仅5KB,总共包含3个文件,其中C++源文件与头文件实现ICMP头部设置、校验和计算以及IP封装逻辑,说明文档对使用方式和验证要点做了补充,结构精简,便于逐行阅读和修改实验。资源已有1607人学习,结合描述中提到的Wireshark抓包验证思路,读者可自行观测发送与接收的ICMP报文细节,对比类型、代码与数据字段,从而深入理解网络通信的底层机制。对于有志于网络编程、协议分析或网络安全方向的学习者,这是一份轻量而实用的入门素材,既能巩固理论,也能作为后续扩展开发的基础模板。

1. 手工构造 ICMP 包:一个没人想碰、出事最不好查的活

ping 不通的时候,第一反应是查链路查路由,很少有人怀疑 ICMP 包本身被构造错了。我之前排查一次跨网段不通的问题,抓包看到回显请求的校验和字段是乱的,才发现问题出在自己写的探测工具上——不是网络,是包没构造对。ICMP(Internet Control Message Protocol)是 TCP/IP 栈里最不起眼但最常用到的协议:ping 用它探活,traceroute 靠它报告超时,网络故障排查基本绕不开它的差错报文。所谓ICMP 数据包构造,就是绕过系统自带的 ping,自己从字节层面把 ICMP 报文拼出来,再用 raw socket 发出去。这对写监控探活脚本、做网络诊断工具、学协议栈底层的人都有实用价值。本文从包结构讲起,每步直接给可跑的代码,最后落到抓包验证和踩坑排查。

2. 先拆包再装包:ICMP 报文的位级布局与校验和算法

2.1 ICMP 头四个字段拆开看:类型、代码、校验和与标识符

ICMP 报文最短 8 字节,前 4 字节是公共头,任何类型的报文都从这里开始。偏移 0 的字节叫类型(Type),8 表示回显请求,0 表示回显应答,3 表示目的不可达,5 表示重定向,11 表示超时。偏移 1 的字节叫代码(Code),它是对类型的补充说明,比如目的不可达类型下代码 0 是网络不可达,代码 1 是主机不可达,代码 3 是端口不可达。偏移 2 和 3 是校验和(Checksum),它覆盖整个 ICMP 报文包括载荷,计算时校验和字段本身先置零。

回显请求和回显应答这两种报文,头部的第 5 到第 8 字节还有额外两个字段:标识符(Identifier)和序列号(Sequence Number),各占 2 字节。标识符用来匹配请求与应答,习惯上取发送进程的 PID;序列号每次发包递增,用来判断丢包和乱序。没错,你平常敲 ping 看到的 icmp_seq,就是这个序列号字段的十进制展开。

偏移(字节)字段长度字段名典型值/作用
01 字节类型8=回显请求,0=回显应答,3=目的不可达
11 字节代码配合类型细分,回显请求/应答取 0
22 字节校验和对 ICMP 报文整体做反码求和
42 字节标识符匹配请求应答,常取进程 PID
62 字节序列号每次发包递增,判定丢包乱序

这 8 字节只是头部的固定部分,后面还可以跟载荷。回显请求的载荷随意,一般放时间戳或一串固定字符串,用来测 RTT;目的不可达报文则会紧跟触发错误的原始 IP 头和前 8 字节数据,方便对端定位是哪个连接出了问题。

2.2 校验和算法选型:RFC 1071 为什么还在用

IP、ICMP、UDP、TCP 的校验和,全都统一用 RFC 1071 定义的反码求和算法,没有分支,没有加密,查表都不需要。原理一句话:把数据按 16 位一组拆开,累加,把高 16 位进位折回低 16 位,最后取反码。ICMP 的校验和覆盖的是整个 ICMP 报文,这点和 UDP 不一样,UDP 校验和还要加一层伪头(pseudo header)来校验 IP 地址,ICMP 不需要。

算法不换的原因很简单:硬件和软件都对它做过深度优化,计算量极小,而且它有很好的数学性质——接收端对完整报文(包含校验和字段)再做一次求和,结果为 0xFFFF 则通过。这个性质在做抓包分析时很有用:你在科来 icmp 抓包或 Wireshark 里看到的 “Checksum: 0xXXXX [correct]”,本质就是软件对这一整段报文重新做了一遍校验计算。

2.3 动手实现校验和:Python 函数与逐行逻辑

Python 实现 RFC 1071 大约十行,我一般这样写:

def icmp_checksum(data: bytes) -> int: if len(data) % 2 != 0: data += b'\x00' # 补一个零字节,保证能按 16 位分组 s = 0 for i in range(0, len(data), 2): s += (data[i] << 8) + data[i+1] # 每两个字节拼成一个 16 位大端整数累加 s = (s >> 16) + (s & 0xffff) # 把高 16 位进位折回低 16 位 s += (s >> 16) # 折回后可能还有一次进位,再折一次 return (~s) & 0xffff # 取反码,按 16 位保留结果

代码的关键点有三个。第一,如果数据长度是奇数,末尾要补一个零字节,补的字节只参与计算不进网络。第二,累加结果可能超过 16 位,所以要把高 16 位不断折回低 16 位,直到没有进位。第三,最后要取反码而不是补码,这是校验和能否通过接收端验证的关键。如果你把~s & 0xffff换成0xffff - s,结果其实一样,因为~s & 0xffff本质就是 16 位取反;但~s本身在 Python 里是个大负数,必须按位与 0xffff 截断成两个字节,这个细节新手特别容易漏。

发送端和接收端的校验逻辑是同一个函数:发送端先在校验和字段写 0,计算后回填;接收端对整个报文再算一次,得到 0xFFFF 则校验通过。这个互通性我建议你务必亲自验证一遍,它能解释之后所有校验和报错的根因。

3. 用 Python 在本地跑通第一个 ICMP 回显包:最小代码与抓包验证

3.1 为什么选 Python 的 raw socket 而不去写 C

搞 ICMP 数据包构造,语言选择上无非 C、Python、Go 三派。C 的好处是结构体直接映射字节,性能最好,但每做一次字节序转换、每组合一次头部都得手动处理指针,写错一个偏移就是踩一个坑。Go 有golang.org/x/net/icmp这类封装好的库,但离字节层面较远,不太适合用来理解构造过程。我一般在工具原型验证阶段用 Python:标准库socket原生支持SOCK_RAW,struct.pack能按指定字节序组合字段,不需要装任何第三方依赖,改起来也快。生产环境再用 C 或 Go 重写也不迟。

raw socket 有两种用法。一种是用socket(AF_INET, SOCK_RAW, IPPROTO_ICMP),内核帮你填充 IP 头,发送数据时只需要提供 ICMP 报文本身,适合构造标准 ICMP 报文;另一种是用IPPROTO_RAW加IP_HDRINCL选项,自己拼 IP 头,适合做自定义分片、改 TTL、填奇怪源地址等场景。第一步先走前者,让内核替你处理 IP 头,少一个出错来源。

权限这件事先说清楚:Linux 下创建IPPROTO_ICMP的 raw socket 需要 root 权限或CAP_NET_RAW能力。普通用户直接跑会报PermissionError: [Errno 1] Operation not permitted。开发时可以临时sudo,生产环境建议给二进制加setcap cap_net_raw+ep,而不是把整个进程跑在 root 下。

3.2 构造并发送 ICMP Echo Request:从 struct.pack 到 recvfrom

下面是完整的最小实现,流程是组装头部、算校验和、发出去、收回应:

import socket import struct import time def icmp_checksum(data: bytes) -> int: if len(data) % 2 != 0: data += b'\x00' s = 0 for i in range(0, len(data), 2): s += (data[i] << 8) + data[i+1] s = (s >> 16) + (s & 0xffff) s += (s >> 16) return (~s) & 0xffff def build_echo_request(identifier: int, seq: int, payload: bytes) -> bytes: header = struct.pack('!BBHHH', 8, 0, 0, identifier, seq) packet = header + payload chk = icmp_checksum(packet) # 把算好的校验和回填到偏移 2 的位置 packet = struct.pack('!BBHHH', 8, 0, chk, identifier, seq) + payload return packet sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) sock.settimeout(3) addr = '10.0.0.1' # 替换成你要探测的目标地址 packet = build_echo_request(identifier=0x1234, seq=1, payload=b'hello py') sock.sendto(packet, (addr, 0)) # sendto 需要二元组,端口对 ICMP 无意义填 0 try: resp, peer = sock.recvfrom(2048) # resp 是完整 IP 包,IPv4 头 20 字节,后面才是 ICMP 报文 icmp_pkt = resp[20:] # 按大端解包 ICMP 头,得到 type, code, checksum, id, seq typ, code, chk, r_id, r_seq = struct.unpack('!BBHHH', icmp_pkt[:8]) print(f'收到 {peer[0]} 的应答:type={typ} code={code} id={r_id:#x} seq={r_seq}') print(f'原始数据:{icmp_pkt}') except socket.timeout: print('等待响应超时') finally: sock.close()

struct.pack('!BBHHH', ...)里面的感叹号表示网络字节序(大端)。ICMP、IP、TCP、UDP 协议头里的多字节字段全部是大端存储,B是 1 字节无符号整数,H是 2 字节无符号整数。如果你写成小端struct.pack('<BBHHH', ...),标识符和序列号在抓包工具里就会显示成字节颠倒,而且校验和也会错。

第二个细节是sendto的地址参数。Python 的sendto要求传一个二元组(IP, 端口),ICMP 没有端口概念,端口填 0 即可,内核不会把它写进任何字段。返回的resp是整个 IP 包,IPv4 头默认 20 字节,要访问 ICMP 层必须跳过前 20 个字节。如果你在某处看到有人直接resp[:8]解包 ICMP,那多半是他收到的数据已经不包含 IP 头了——这在注入方式不同的环境下会发生,后面踩坑部分会讲。

3.3 用科来抓包工具验证自己的包:该看到什么

发完这个脚本,你最好立刻用抓包工具验证一次,别急着关终端。常见的做法是用 Wireshark 或科来 icmp 抓包工具选对应的网卡,过滤条件写icmp,再触发一次脚本发送。正常的抓包结果应该是两行:一行是你发出去的 Echo Request,类型 8 代码 0,校验和显示正确;一行是对端返回的 Echo Reply,类型 0 代码 0。双击请求包展开 ICMP 层,你会在里面看到和脚本里一模一样的标识符、序列号、载荷内容。

这里有一个常被忽略的点:如果校验和显示为红色并标注 incorrect,说明你构造的包在接收端校验没通过,对端会直接丢弃,表现为抓包只有请求没有应答。此时第一优先排查自己的校验算法,第二排查是不是发了两遍校验收尾时手抖把冷却数据写进去了。校验和不对这个现象,是 ICMP 数据包构造里最容易自证的 bug,因为它完全可以离线算出来。

4. 进阶构造:ICMP 重定向、不可达与时间戳请求的包模板

4.1 除了 ping,ICMP 还有哪些类型值得手工构造

回显请求只是 ICMP 的敲门砖。真正在故障排查时有用的是差错报文,下面这张表列出我日常会手工构造的类型:

类型值名称代码举例构造用途
0回显应答0模拟对端回复,测试探活逻辑
3目的不可达0=网络,1=主机,3=端口模拟路由失败、防火墙拒绝
5重定向0=网络,1=主机测试主机路由表更新逻辑
8回显请求0连通性探测
11超时0=TTL 超时,1=分片重组超时模拟 traceroute 中间节点反馈
13时间戳请求0获取对端系统时间,早期攻击探测手段
14时间戳应答0配合 13 使用

为什么要手工构造这些?因为系统自带的 ping 只发回显请求,而你想验证的设备行为,比如路由重定向处理、分片超时告警、防火墙拦截日志,往往需要你先构造一个特定差错报文喂给它。我做过一次交换机路由表异常排查,就是手工发了一个重定向报文给被测主机,观察它是否按 RFC 792 的要求更新了路由缓存。

4.2 目的不可达(类型 3)报文模板:差错包怎么拼接原始 IP 头

目的不可达报文的设计逻辑是:当路由器或主机发现包无法送达时,它要把原始 IP 包的关键信息退回给发送方,好让发送方知道是哪个连接出的问题。因此报文结构是 8 字节 ICMP 头 + 原始 IP 头 + 原始数据前 8 字节。这个“原始”不是指当前报文的源 IP,而是触发错误的那个包的开头片段。

构造时核心难点在拼接原始 IP 包片段,代码可以这样实现:

def build_unreachable(original_ip_pkt: bytes, code: int) -> bytes: # 原始 IP 头 + 原始载荷前 8 字节,必须保留 ip_head = original_ip_pkt[:20] org_data = original_ip_pkt[20:28] # 载荷的前 8 字节 rest = ip_head + org_data # 差错报文的数据区 header = struct.pack('!BBHHH', 3, code, 0, 0, 0) # 类型 3,标识符序列号无意义填 0 packet = header + rest chk = icmp_checksum(packet) # 回填校验和,重新拼装 packet = struct.pack('!BBHHH', 3, code, chk, 0, 0) + rest return packet

注意这里和回显请求有两个明显差别。第一,标识符和序列号字段对目的不可达没有意义,通常填 0,抓包工具也不会刻意展示它们。第二,差错报文的数据区必须包含原始 IP 头和前 8 字节数据,否则对端无法识别是哪个 socket 的包出了问题;如果省略这部分,很多协议栈会直接丢弃这个差错报文,RFC 1122 里明确要求收到不完整的差错包要静默丢弃。你构造时别为了少两行代码省掉这段,省掉的代价就是白发。

4.3 时间戳请求与应答:拿到对端系统时间的另类路径

时间戳请求(类型 13)是 ICMP 里一个比较有意思的类型:它不测连通性,而是要求对端返回它当前的系统时间,精确到毫秒。报文的格式是 8 字节 ICMP 头外加 12 字节时间戳:原始时间戳、接收时间戳、发送时间戳,各 4 字节。发送时间戳由请求方填写,格式是自 UTC 零点起经过的毫秒数,应答方把接收时间戳和发送时间戳填上后原样返回。

构造时间戳请求的难点在时间戳字段的计算。自零点经过的毫秒数用 Python 这么算:

import datetime now = datetime.datetime.now() midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) elapsed_ms = int((now - midnight).total_seconds() * 1000) # 原始时间戳填 elapsed_ms,接收和发送时间戳先填 0 payload = struct.pack('!III', elapsed_ms, 0, 0) header = struct.pack('!BBHHH', 13, 0, 0, 0xabcd, 1) packet = header + payload chk = icmp_checksum(packet) packet = struct.pack('!BBHHH', 13, 0, chk, 0xabcd, 1) + payload

应答包里三个时间戳会被对端填入真实值。把发送时间戳减去原始时间戳,就是单程时间的粗略估计;把接收时间戳减去原始时间戳,可以粗略估算两个系统之间的时钟偏差。这个方法在 NTP 不可用的内网里偶尔能派上用场,但注意很多现代系统会默认禁用 ICMP 时间戳,或过滤掉这类报文,构造出来收不到应答太正常了。

5. 构造 ICMP 数据包的 5 个常见翻车现场与排查方法

5.1 现象:抓包工具提示校验和 incorrect,对端无响应

原因:发送端在校验和字段清零之前就先算了整个包的校验和,或者把校验算法写错了。如果脚本里packet变量在校验和计算前已经是带非零校验和字段的版本,结果必然是错的。

解决:严格按 “先填 0,再计算,再回填” 的顺序:第一次struct.pack把校验和位置填 0,计算后第二次struct.pack用真实校验和替换。建议在代码里写一个build_packet()函数强制这个流程,并在函数末尾打印一次icmp_checksum(packet)验证结果应为 0,这叫自校验,能提前拦截大多数低级错误。另外用一个已知正确的样本比对:用系统ping命令抓包保存一份 Echo Request 的十六进制,和你的脚本输出逐字节对比,差异一眼就能看出来。

5.2 现象:普通用户跑脚本报 Operation not permitted

原因:Linux 系统不允许非 root 用户创建IPPROTO_ICMP类型的 raw socket,这是内核的安全策略,不是代码问题。Windows 上则需要管理员权限并安装 WinPcap/Npcap 驱动,而且 Windows 对 raw socket 的 ICMP 支持还受防火墙影响。

解决:开发阶段用sudo python3 your_script.py快速验证。生产环境更推荐给解释器或二进制程序设 capability:sudo setcap cap_net_raw+ep $(which python3),这样普通用户也能跑,不需要把整个进程提权到 root。注意 setcap 对 shell 脚本无效,必须先确定 python 解释器的真实路径。容器环境别忘了在 docker run 时加--cap-add=NET_RAW,否则容器内照样报权限不足。

5.3 现象:小包正常,加长载荷后 ping 超时

原因:载荷增大导致 IP 包超过链路 MTU,触发分片。如果构造时设置了IP_DF(不分片)标志,或对端策略丢弃 ICMP 分片,就会表现为超时。回显请求还好,目的不可达这类差错报文一旦加长载荷,分片后的重组逻辑复杂度会明显上升。

解决:确认链路 MTU。ip link show看接口 MTU,默认 1500 字节,扣除 IP 头和 ICMP 头各 20 字节和 8 字节,ICMP 载荷超过 1472 字节就会分片。调试分片行为时,可以故意把载荷拉到 2000 字节,再用抓包工具观察分片偏移字段,关注抓包工具显示的 “Fragmented IP protocol” 是否一致。如果不想处理分片,payload 不要超过 1472 字节。要测试分片路径,可以在建 socket 后用IP_MTU_DISCOVER选项控制是否允许分片,但这属于 IP 层调参,不在 ICMP 报文结构范围内。

5.4 现象:抓包看到的标识符和代码里填的值对不上

原因:字节序不对。你代码里用struct.pack('!BBHHH', ...)是大端,但如果系统从小端读取,或者抓包工具解析时按小端解,标识符0x1234会变成0x3412。大多时候问题出在代码用了不带!前缀的本地字节序@或小端<。

解决:检查struct.pack的格式字符串是否以!开头。不要在代码里手工交换字节序做强转,那只会制造更多混乱。校验和字段的错误往往也和字节序错误同时发生,因为算法假设数据是大端排列。一个快速自检办法:打印packet.hex()看前 8 字节是不是08 00 xxxx 12 34,其中08 00是类型和代码,12 34是你的标识符。只要这里看起来是对的,字节序基本没问题。

5.5 现象:发送成功但收不到回包,抓包连请求都没出网卡

原因:多网卡主机上 raw socket 的默认路由没选对。sendto指定了对端 IP,但内核选源 IP 和出口网卡时依据路由表,如果目标网段走的是另一张网卡,请求可能从错误的物理口发出,抓到包的网卡当然看不到。

解决:构造 socket 后用setsockopt绑定出口接口:

import fcntl, socket, struct as st def bind_device(sock, ifname): sock.setsockopt(socket.SOL_SOCKET, 25, ifname.encode()) # SO_BINDTODEVICE=25

25 是 Linux 的SO_BINDTODEVICE值,不同系统数值不同,生产代码建议从系统头文件读取而不是硬编码。绑定时确认源 IP 与出口网卡在同一子网。如果源 IP 和路由表冲突,抓包会看到一个诡异现象:请求已经发出,但源地址是另一张网卡的 IP,对端回包打到了错误路径上。这种问题抓包在 A 网卡看请求、在 B 网卡看回包,不仔细看源地址根本发现不了。

6. 构造后这步必须做:用抓包工具回读自己的包并确认格式

6.1 抓包回读的三个检查点

构造完数据包,我习惯按固定顺序做三遍校验:先用icmp_checksum对整个报文算一遍,结果必须为 0;再打印报文的 hex 前 16 字节,肉眼对一遍类型、代码、标识符;最后用 Wireshark 或科来 icmp 抓包工具走一遍实际收发,看抓包工具的协议解析是否和你意图一致。这三步不是多余的,它们分别验证校验和正确性、字段布局正确性、以及内核和协议栈的最终呈现效果。

在抓包工具里我一般会开启显示过滤icmp.type == 8 && icmp.code == 0来精确过滤目标包,并打开时间列确认 RTT。如果用的是科来抓包工具,重点看它的 Expert 信息区有没有提示 Checksum 错误或包被重传;Wireshark 则看左下角的 “Expert Info” 里是否出现红色感叹号。这两个工具的提示逻辑不完全一样,但判定标准都是同一个 RFC 1071 校验算法,看到 [correct] 或者绿色对勾就说明校验这一步过了。

6.2 最后一个习惯:把构造参数和抓包结果保存成对照文本

我的经验是,调 ICMP 构造代码时最容易翻车的不是写代码的当下,而是几周后回来看脚本忘了某个字段是什么意思。建议每次验证通过后,把代码里的 identifier、seq、payload、目标 IP、抓包工具导出的关键帧字段,追加到一个 notes 文本里,格式随意但必须能对应上。这个习惯帮我省过很多次重复排查,特别是分片场景下,没有当时的抓包导出,几乎不可能区分是构造错误还是链路问题。

如果你读到这里正准备动手,把我上面那段最小回显请求脚本跑通,加上一次抓包验证,就已经完成了从 “知道 ICMP 数据包构造” 到 “能构造 ICMP 数据包” 的跨越。之后再碰类型 3、类型 5、时间戳这些,都是同一套套路:清空校验和、拼字段、回填校验和、抓包验证。这套步骤我走了很多遍,最深的体会是构造协议头这种事,别信眼睛,信校验结果和抓包导出。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 1:42:34

YOLOv8路口信号灯通行规则识别:从训练到RK3588部署实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:42:31

从学弟逆袭看编程自学:可复制的技能积累与成长系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:41:40

SAP HANA账号创建与授权配置完全指南:从权限模型到角色实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:41:38

HFSS相控阵天线仿真方法详解:从单元法到全阵验证的实战路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:41:16

ESP32-P4 上 LVGL V9.4 的 PPA 硬件加速实战:帧率翻倍与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:40:31

FX3U高速脉冲输出定位控制:从选型接线到指令实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华