libnids源码深度注释:TCP流重组与IP分片实战解析

简介:这份资源是 libnids 1.20 源码的深度解读版本,作者在原始代码基础上补充了大量中文注释,面向网络安全方向的学习者、入侵检测系统开发者以及需要理解 TCP/IP 协议栈实现细节的工程师。libnids 作为经典的开源网络入侵检测库,核心价值在于数据包捕获、协议解析与 TCP 流重组,而注释版源码能显著降低阅读门槛,帮助读者快速定位 IP 头部解析、tcp_stream 结构体管理、add_seq 与 find_seq 序列号处理以及 tcp_reassemble 重组逻辑等关键实现。资源包共 65 个文件,以 c 与 h 源码文件为主,辅以工程配置、文档说明与测试样例,整体约 245KB,结构紧凑、便于本地编译调试。目前已有 466 人学习下载,适合希望从源码层面掌握网络流量分析原理、并在此基础上做定制化检测规则开发的读者参考。

1. 从一份带注释的 libnids 源码说起:它到底能帮你省下多少翻源码的时间

如果你做过网络流量分析、入侵检测原型,或者单纯想搞明白一个 TCP 会话是怎么从零散数据包里被“拼”回来的,那你大概率绕不开 libnids。它是个轻量级的网络数据包重组库,核心干的事就三件:把 IP 分片拼回去、把 TCP 流按会话还原、把重组后的数据交给上层回调。很多 IDS 原型、流量审计小工具、协议逆向的验证环境,底层都在用它。但问题也在这——libnids 本身代码量不算大,可它把大量逻辑塞进了状态机和哈希表里,裸看源码很容易在nids_tcp.c和nids_ipfrag.c之间反复横跳,最后连tcp_stream结构体里哪个字段管什么顺序都没记住。

这份资源就是冲这个痛点来的:一份加了大量注释的 libnids 源码。注释不是那种“i++ 表示 i 自增”的废话,而是贴着关键路径写的——比如分片重组时ip_frag_q的入队条件、TCP 状态迁移里NIDS_JUST_EST到NIDS_DATA的触发点、还有nids_prm里那几个超时参数到底影响哪条链表。对新手来说,它能让你少花几个晚上去猜作者意图;对熟手来说,它是一份可以快速对照自己理解是否到位的“参考答案”。适合谁?正在做流量还原、协议解析、或者想拿 libnids 当二次开发底座的人。如果你只是想要个抓包工具,那它可能不是你的菜;但如果你想搞懂“重组”这件事在代码层面到底怎么落地,这份注释版源码值得你花时间拆一遍。

2. 先立住理论:libnids 的三大核心机制与选型理由

2.1 IP 分片重组:为什么哈希表加链表是常见做法

libnids 处理 IP 分片的核心思路是:每个分片包进来,先看它属于哪个“分片组”。分片组用源 IP、目的 IP、协议号、IP 标识符这四个元素做键,哈希到一个桶里,桶里挂一条链表,链表节点就是各个分片。注释版里会重点标出ip_frag_q的插入逻辑——它不是简单按到达顺序追加,而是按分片偏移量排序插入。这样做的原因是:重组时需要按偏移量从小到大拼接,如果每次重组都重新排序,开销太大;插入时排好,重组时直接遍历就行。

常见做法是用固定大小的哈希表,桶数量在nids_prm里由ip_frag_hash_size控制,默认值一般够用,但如果你在抓大流量,分片并发数很高,桶太小会导致链表过长,查找变慢。注释里会提醒你:ip_frag_q的每个节点还带一个ttl字段,用来做超时淘汰——分片没到齐不能无限等,超时后整组丢弃。这个超时值由ip_frag_timeout控制,单位是秒。我一般会把它设成 30 秒左右,太短容易误丢慢速路径的分片,太长会占内存。

注意:分片重组有个经典坑——重叠分片。不同操作系统对重叠部分的处理不一样,libnids 默认行为是保留先到的,但注释里会标出哪一行做了这个判断。如果你做安全分析,这里可能是绕过检测的入口,得心里有数。

2.2 TCP 流重组:状态机与半连接队列

TCP 重组比 IP 分片复杂得多,因为要维护连接状态。libnids 用了一个tcp_stream结构体来记录每个方向的数据:seq期望的下一个序列号、data缓冲区、offset当前写指针。注释版会重点解释nids_tcp.c里那个tcp_find函数——它负责根据四元组找到对应的流。如果找不到,就看是不是新连接;如果是新连接,就创建一个tcp_stream并挂到哈希表里。

这里的关键参数是tcp_hash_size,控制 TCP 流哈希表的桶数。默认值在轻量场景够用,但如果你同时跟踪几千条连接,建议调大。另一个重点是NIDS_JUST_EST状态:当 libnids 看到一个 SYN 包时,会先建立一个“半连接”条目,等三次握手完成才真正进入数据接收状态。注释里会标出nids_tcp.c中处理 SYN、SYN-ACK、ACK 的分支,以及tcp_stream里state字段的迁移条件。

我见过不少人在这里翻车:以为只要看到数据包就能拿到流数据,结果发现回调一直不触发。原因往往是半连接没完成,或者nids_prm里的tcp_timeout设得太短,连接刚建立就被淘汰了。常见做法是把tcp_timeout设成 60 秒以上,具体看你的业务里连接平均存活时间。

2.3 回调机制:为什么你的处理函数必须快

libnids 把重组好的数据通过回调交给上层。回调函数在nids_register_tcp和nids_register_ip_frag里注册。注释版会强调一点:回调是在 libnids 的主循环里同步调用的,也就是说,你的回调执行多久,libnids 就阻塞多久。如果你在回调里做磁盘 IO 或者复杂计算,整个抓包流程都会卡住。

常见做法是在回调里只做数据拷贝,把后续处理丢到另一个线程或队列里。注释里会标出nids_run的主循环结构,让你看清楚回调是在哪个位置被触发的。参数方面,nids_prm里的pcap_timeout控制每次抓包的等待时间,设太小会导致 CPU 空转,设太大会增加延迟。我一般设成 1 到 10 毫秒之间,具体看流量密度。

3. 动手复现:从编译到跑通第一个 TCP 重组示例

3.1 环境准备与编译参数

这份注释版源码本质上还是 libnids 的代码,所以编译方式跟原版一致。你需要先装好 libpcap 和 libnet(如果用到发送功能)。在 Linux 下,常见做法是:

# 安装依赖,不同发行版包名可能略有差异 sudo apt-get install libpcap-dev libnet1-dev # 进入源码目录,注释版一般会保留原有的 configure 脚本 ./configure --prefix=/usr/local/libnids # 编译并安装 make sudo make install

这里有个细节:注释版可能会在configure.ac或Makefile.am里加一些调试开关,比如--enable-debug。如果你看到注释里提到DEBUG宏,可以在 configure 时加上CFLAGS="-g -O0",方便用 gdb 单步跟。参数说明:--prefix决定安装路径,如果你不想污染系统目录,可以改成$HOME/.local,然后设置LD_LIBRARY_PATH指向lib目录。

编译完可以跑一下自带的示例,一般在samples/目录下。常见的是nids_sample.c,它注册了一个 TCP 回调,把收到的数据打印出来。编译示例:

gcc -o nids_sample samples/nids_sample.c -lnids -lpcap -lnet

如果报找不到nids.h,检查--prefix对应的include目录是否在编译器的搜索路径里,可以用-I指定。

3.2 写一个最小可用的 TCP 重组程序

下面这个例子是我自己常用的骨架,比官方示例多了一点错误处理和参数调整。你可以直接抄,然后按注释改。

#include <nids.h> #include <stdio.h> #include <stdlib.h> // TCP 回调:每收到一段重组后的数据就调用一次 void tcp_callback(struct tcp_stream *ts, void **param) { if (ts->nids_state == NIDS_DATA) { // 只处理有数据的阶段 int len = ts->server.count - ts->server.offset; if (len > 0) { printf("Server -> Client, %d bytes\n", len); // 这里只打印长度,实际可以拷贝 ts->server.data + ts->server.offset } len = ts->client.count - ts->client.offset; if (len > 0) { printf("Client -> Server, %d bytes\n", len); } // 告诉 libnids 数据已经消费,可以释放缓冲区 ts->server.offset = ts->server.count; ts->client.offset = ts->client.count; } else if (ts->nids_state == NIDS_CLOSE) { printf("Connection closed\n"); } } int main(int argc, char **argv) { if (argc != 2) { fprintf(stderr, "Usage: %s <interface>\n", argv[0]); return 1; } // 调整参数:加大哈希表,延长超时 nids_params.device = argv[1]; nids_params.tcp_hash_size = 4096; nids_params.tcp_timeout = 120; nids_params.ip_frag_hash_size = 1024; nids_params.ip_frag_timeout = 30; if (!nids_init()) { fprintf(stderr, "nids_init failed: %s\n", nids_errbuf); return 1; } nids_register_tcp(tcp_callback); nids_run(); // 进入主循环,不会返回 return 0; }

逻辑说明:nids_params是全局结构体,在nids_init之前设置才有效。tcp_hash_size和ip_frag_hash_size必须是 2 的幂,否则 libnids 内部会向下取整。tcp_timeout和ip_frag_timeout单位是秒。回调里判断NIDS_DATA才处理数据,NIDS_CLOSE做清理。ts->server.count是当前缓冲区里已有的字节数,offset是已经交给上层的字节数,两者之差就是新数据。处理完必须更新offset,否则下次回调会重复给你同一段数据。

参数怎么改:如果你抓的是高并发短连接,tcp_timeout可以降到 30 秒,但别低于 10 秒,否则握手慢的连接会被误杀。tcp_hash_size根据并发连接数来,一般设成预期峰值的 2 倍以上。ip_frag_hash_size在分片不多的场景用默认值就行。

3.3 验证重组是否正确:用 tcpdump 对照

跑起来之后,怎么知道重组结果是对的?我一般会同时开一个 tcpdump 抓同样的包,然后对比。比如:

# 终端 1:跑你的程序 sudo ./nids_sample eth0 # 终端 2:抓包存文件 sudo tcpdump -i eth0 -w capture.pcap # 终端 3:用 tshark 看某条流的原始数据 tshark -r capture.pcap -Y "tcp.stream == 0" -T fields -e tcp.payload

如果 libnids 打印的字节数和 tshark 看到的 payload 总长度一致,基本就对了。注意:libnids 重组的是 TCP 载荷,不包括 TCP 头,所以对比时只看 payload。如果长度对不上,常见原因是分片丢失或者超时淘汰,可以看注释里关于tcp_timeout的说明,适当调大。

提示:如果你在虚拟机或容器里跑,网卡要设成混杂模式,否则只能抓到自己进出的包。nids_init默认会尝试设混杂模式,但有些环境不允许,需要手动用ip link set eth0 promisc on。

4. 避坑与排查:注释版源码里那些容易看漏的细节

4.1 现象:回调只触发一次,后续数据收不到

原因:回调里没有更新offset,或者更新错了方向。libnids 用count和offset来管理缓冲区,如果你只读不更新,下次有数据进来时count会增加,但offset没变,libnids 会认为你还没消费完旧数据,可能就不再触发回调,或者重复给你同一段。

解决:每次处理完数据,把对应方向的offset设成count。注意server和client是两个独立方向,别只更新一个。注释版里会在tcp_stream结构体定义处标出这两个字段的用途,翻到那里看一眼就明白了。

4.2 现象:程序启动后报 “pcap_open_live: no such device”

原因:nids_params.device设错了,或者当前用户没有权限打开网卡。libnids 底层用 libpcap,需要 root 或者CAP_NET_RAW能力。

解决:先用ip link或ifconfig -a确认网卡名,然后sudo运行。如果是在容器里,检查是否加了--net=host或者对应的 capability。注释版里nids_init附近会标出pcap_open_live的调用点,可以对照错误码。

4.3 现象:内存持续增长,跑一段时间后被 OOM kill

原因:多半是 TCP 流或 IP 分片没有及时淘汰。tcp_timeout和ip_frag_timeout设得太大,或者哈希表太小导致链表过长,淘汰扫描变慢。还有一种可能是回调里拷贝了数据但没释放,不过 libnids 的缓冲区是内部管理的,只要你更新了offset,它就会复用。

解决:先把tcp_timeout降到 60 秒,ip_frag_timeout降到 15 秒,观察内存曲线。如果还是涨,用valgrind跑一下,看是不是自己的代码有泄漏。注释版里会在超时处理函数附近标出tcp_scan和ip_frag_scan的调用周期,可以对照调整。

4.4 现象:重组出来的数据顺序不对,或者中间缺了一段

原因:TCP 是流式协议,libnids 按序列号重组,但如果遇到重传、乱序,它需要等缺失的段到达才能继续。如果tcp_timeout太短,缺失段还没到,整个流就被淘汰了,你拿到的就是残缺数据。另外,如果抓包时丢了包(比如缓冲区溢出),libnids 也无能为力。

解决:适当调大tcp_timeout,同时检查抓包是否丢包。可以用tcpdump -i eth0 -w capture.pcap -s 0并观察tcpdump退出时的统计信息,看 “dropped by kernel” 是否非零。如果是,调大pcap_timeout或者用更高效的抓包方式。注释版里会标出nids_pcap_handler里对丢包的处理,可以看看有没有统计输出。

4.5 现象:编译时提示找不到nids.h或链接-lnids失败

原因:安装路径没加到编译器和链接器的搜索路径里。如果你用了--prefix=$HOME/.local,默认不会去那里找。

解决:编译时加-I$HOME/.local/include,链接时加-L$HOME/.local/lib,运行时加LD_LIBRARY_PATH=$HOME/.local/lib。或者干脆装到/usr/local,然后sudo ldconfig。注释版里Makefile.am可能会标出安装头文件的路径,可以对照确认。

5. 进阶技巧:用注释版源码做二次开发与性能调优

5.1 改哈希表大小和超时参数的正确姿势

很多人以为改nids_params里的值就行,但注释版会告诉你:tcp_hash_size和ip_frag_hash_size必须在nids_init之前设置,而且必须是 2 的幂。如果你设成 1000,libnids 内部会向下取到 512。另外,tcp_timeout和ip_frag_timeout的单位是秒,但实际扫描周期是nids_params.tcp_scan_interval控制的,默认是 10 秒。也就是说,一个流即使超时了,也要等下一次扫描才会被真正释放。如果你对内存敏感,可以把tcp_scan_interval调小,比如 5 秒,但会增加 CPU 开销。

我一般会这样配:

nids_params.tcp_hash_size = 8192; // 支持约 8000 条并发流 nids_params.tcp_timeout = 60; // 60 秒无数据就淘汰 nids_params.tcp_scan_interval = 5; // 每 5 秒扫描一次 nids_params.ip_frag_hash_size = 2048; nids_params.ip_frag_timeout = 15; nids_params.ip_frag_scan_interval = 5;

这些值不是固定的,你得根据自己的流量特征调。注释版里会在每个参数定义处写清楚它影响哪条路径,翻一遍比看文档快。

5.2 在回调里做零拷贝:直接引用缓冲区

libnids 的tcp_stream里,data字段指向内部缓冲区,offset和count标记有效范围。如果你只是分析数据,不需要长期保存,完全可以直接读ts->server.data + ts->server.offset,长度是count - offset。这样避免了 memcpy。但要注意:回调返回后,libnids 可能会复用这块内存,所以如果你要异步处理,必须自己拷贝一份。

注释版里会在tcp_stream结构体附近标出缓冲区的生命周期,以及tcp_scan里什么时候会释放。我一般会在回调里只做解析,把结果写到另一个结构体里,然后更新offset,这样既快又安全。

5.3 用 gdb 跟一遍分片重组流程

如果你想彻底搞懂 IP 分片重组,光看注释还不够,最好用 gdb 跟一遍。编译时加-g -O0,然后在ip_frag_process或类似函数上下断点。注释版里会标出关键函数名,你可以直接break ip_frag_process,然后run eth0,再在另一个终端用ping -s 3000发个大包触发分片。单步走一遍,看ip_frag_q怎么插入、怎么合并,比看十遍代码都管用。

注意:gdb 跟的时候,libnids 的主循环会阻塞,所以你得在另一个终端发包。如果断点触发太频繁,可以加条件,比如break ip_frag_process if frag->offset == 0。

5.4 一个我常犯的错误:忘了设nids_params.device

刚开始用的时候,我总以为nids_init会自动选网卡,结果它默认是NULL,直接报错。后来我养成了一个习惯:在main开头就检查argc,强制要求传网卡名,然后赋给nids_params.device。注释版里nids_init附近会标出device为NULL时的处理逻辑,看一眼就知道为什么必须设。

从那以后,我每次跑 libnids 相关程序,都强制走一遍“设 device → 调哈希表 → 设超时 → 注册回调 → run”这个流程,再也没在初始化上翻过车。希望这份带注释的源码能帮你省下那些我当年花在猜作者意图上的时间,也希望你拆完之后,能自己往里加注释——那才是真的懂了。

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

相关新闻

技能提升的底层逻辑:体系搭建、刻意练习与能力沉淀

技能提升的底层逻辑:体系搭建、刻意练习与能力沉淀

1. 内容概览&#xff1a;为什么"skills"值得单独拎出来聊标题就一个词&#xff0c;skills&#xff0c;但越简单的词往往越容易糊弄过去。不少人在简历里写"熟练掌握XX技能"&#xff0c;问到底掌握到什么程度&#xff0c;答不上来&#xff1b;也有人天天刷教…

2026/10/22 0:19:11 阅读更多 →
敏捷开发中的需求审批与变更管理:从失控到轻量协同

敏捷开发中的需求审批与变更管理:从失控到轻量协同

1. 先搞清楚&#xff1a;敏捷里的“需求审批”到底在反对什么1.1 传统审批的机械流程为什么让人讨厌我最早接触敏捷的时候&#xff0c;带我的老前辈说过一句特别狠的话&#xff1a;“审批一多&#xff0c;敏捷就死了。”当时我深以为然&#xff0c;因为那时候团队刚从一个重型流…

2026/10/22 0:19:10 阅读更多 →
AI声音克隆配音接单实战:从零搭建生产线,成本压到零

AI声音克隆配音接单实战:从零搭建生产线,成本压到零

1. 配音接单这门生意&#xff0c;成本结构到底长什么样先算一笔账。传统配音接单&#xff0c;如果你自己上&#xff0c;设备投入是麦克风加声卡加监听耳机&#xff0c;入门级一套下来一千到三千不等&#xff0c;稍微像样点的电容麦加专业声卡直接奔五千。这还只是硬件&#xff…

2026/10/20 15:33:25 阅读更多 →

最新新闻

Electron桌面应用开发实战:从环境搭建到打包发布全流程

Electron桌面应用开发实战:从环境搭建到打包发布全流程

做前端这些年&#xff0c;我大部分时间都在跟浏览器标签页打交道&#xff0c;直到第一次把桌面应用跑起来&#xff0c;才发现原来自己一直被限制在地址栏和刷新按钮围起来的框框里。Electron这个词&#xff0c;最开始是在某段时间频繁出现在同事的讨论里&#xff0c;一个用HTML…

2026/10/21 9:32:40 阅读更多 →
滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书是一份PDF格式的官方技术文档&#xff0c;主要面向智能锁安装人员、终端用户及售后维护者&#xff0c;帮助快速掌握产品安装、配置与常见故障处理。全包仅1个PDF文件&#xff0c;大小4.37MB&#xff0c;内容结构清晰&#xff…

2026/10/21 9:32:40 阅读更多 →
PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

上个月我在调试一台高速AGV的驱动系统&#xff0c;整车要求空载跑到8m/s&#xff0c;电机转速已经拉到额定点&#xff0c;驱动器的输出电压也顶到了母线电压极限附近。再往上踩速度指令&#xff0c;转速纹丝不动&#xff0c;三相电流反而开始震荡。那会儿我的第一反应就是&…

2026/10/21 9:32:40 阅读更多 →
C++核心知识点:从内存管理到STL的实战指南

C++核心知识点:从内存管理到STL的实战指南

C 这门语言我前前后后折腾了十几年&#xff0c;用它写过嵌入式驱动、做过游戏服务端&#xff0c;也搭过基础组件库。每次看到新人一头扎进"C 核心知识点"这个搜索词&#xff0c;我其实挺感慨的——这门语言的难点从来不是语法多&#xff0c;而是知识点之间的耦合太深…

2026/10/21 9:32:40 阅读更多 →
T109滑盖智能锁怎么装?安装调试与故障排查全攻略

T109滑盖智能锁怎么装?安装调试与故障排查全攻略

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书以PDF格式发布&#xff0c;适合智能锁用户、安装人员与售后维修人员阅读。内容围绕T109系列指纹密码刷卡锁展开&#xff0c;先介绍前面板、后面板、锁体、机械钥匙、电池盒盖等部件名称与功能&#xff0c;再给出完整安装步骤&…

2026/10/21 9:32:40 阅读更多 →
LLM知识降熵:从信息垄断到理解权下放

LLM知识降熵:从信息垄断到理解权下放

你有没有过这种经历&#xff1a;翻开源码注释、论文摘要或者一份专业报告&#xff0c;每个字都认识&#xff0c;连在一起却像天书。想找人聊聊&#xff0c;身边要么没人懂&#xff0c;要么懂的懒得跟你解释。后来我试着把同一段内容丢给一个大语言模型&#xff0c;几秒钟之后&a…

2026/10/21 9:31:32 阅读更多 →

日新闻

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/22 1:47:45 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/21 21:32:28 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/21 4:27:17 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/21 21:50:56 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/21 21:51:55 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/21 16:24:48 阅读更多 →