struct pid_namespace深度解析:容器PID隔离与进程回收的基石
struct pid_namespace这个名字在多数人眼里不过是一行C语言结构体声明但Linux内核里整个容器世界的进程隔离、PID重编号、进程回收底层全是它在兜底。我第一次认真抠它是因为排查一个容器内进程级联退出的故障翻到include/linux/pid_namespace.h时才发现之前很多想当然的判断都是错的。这篇文章我就用自己消化过的内容把这个结构体的设计动机、每个字段的作用、以及怎么在运行时验证它的行为完完整整讲一遍。适合正在读内核源码的、做容器运行时二次开发的以及被容器里PID对不上这种问题折磨过的读者。你不需要先精通内核只要写过C、用过Linux命令就能跟着走下来。1. 没有pid命名空间时容器只是看起来隔离1.1 进程视图共享引发的三大问题先回到没有pid命名空间的时代。早期容器实现很粗糙只是用chroot加cgroup把进程圈起来但进程看到的进程表是全局共享的。这意味着在容器里执行ps -ef你能看到宿主机上的所有进程甚至能看到隔壁容器里的进程。这在隔离性上等于裸奔。带来最直接的三个问题。第一信息泄露容器内可以轻易枚举宿主机上跑了哪些服务进程的命令行参数、环境变量都暴露在/proc下面。第二误操作风险容器里的管理工具拿到全局PID后一个kill -9 1717可能杀掉的不是你容器里的进程而是宿主机上某个关键服务。第三PID复用混淆宿主机上进程创建销毁频繁PID会被反复使用。你在容器里记录了一个PID过一会儿再查它可能已经变成了另一个完全不相干的进程。要解决这些问题就得让每个容器里的进程认为自己是一台独立机器上的第一批进程。于是内核引入了pid namespace机制而struct pid_namespace就是承载这套机制的核心结构体。1.2 struct pid_namespace在整个机制里的位置要理解这个结构体得先弄清楚它挂在哪个环节上。每个进程的task_struct里有一个nsproxy指针nsproxy里挂着各类命名空间mount namespace、uts namespace、ipc namespace、net namespace以及我们今天的主角pid_ns_for_children。注意这个名字它叫for_children。它表示的是当前进程后续fork出来的子进程会被放进哪个pid namespace。当前进程自己属于哪个pid namespace则记录在它的struct pid结构体里。struct pid才是进程在内核中的身份证里面存着该进程在所有相关pid namespace中的编号列表而struct pid_namespace是户籍系统定义了一套编号规则、编号上限、回收孤儿进程的人选。一个容器里的所有进程共享同一个struct pid_namespace。它们各自的struct pid不同但指向同一个namespace结构体。我在读代码时常犯一个误区以为fork会复制整个pid namespace其实完全不是这样。fork时copy_namespaces只是让子进程的nsproxy和父进程共享同一个pid_namespace内部通过kref增加一次引用计数。也就是说容器里每fork一个进程不是多了一份户籍系统而是多了一个户籍登记人。只有调用unshare或clone带有CLONE_NEWPID标志时才会真的创建一套新户籍系统。2. 逐字段啃一遍struct pid_namespace先放一个接近当前主线版本的简化定义不同内核版本字段略有出入但你手里那份源码大概率长这样struct pid_namespace { struct kref kref; // 引用计数 struct idr idr; // pid整数到struct pid的映射表 struct rcu_head rcu; // RCU延迟释放头 unsigned int pid_allocated; // 当前已分配的pid数量 unsigned int pid_limit; // pid数量上限 int last_pid; // 下次分配pid时从哪儿开始找 unsigned int nr_hashed; // 哈希表里的pid数量 struct task_struct *child_reaper; // 本命名空间负责收尸的init进程 struct kmem_cache *pid_cachep; // struct pid的slab缓存 unsigned int level; // 嵌套层数 struct pid_namespace *parent; // 父命名空间 struct user_namespace *user_ns; // 关联的用户命名空间 struct ucounts *ucounts; // 用户资源计数 kgid_t pid_gid; // /proc/sys/kernel/pid_max 的属组 int hide_pid; // /proc 的 hidepid 选项 struct file *proc_mnt; // 本命名空间关联的proc实例 };光看名字能猜个大概但每个字段为什么这样设计才是真正值得花时间的地方。2.1 生命周期三件套kref、rcu、level/parentkref是内核标准的引用计数器。为什么不用普通的atomic_t因为kref提供了一套约定俗成的get/put语义kref_get拿引用kref_put释放引用当计数降到0时自动调用release函数。这样可以避免自己手写原子操作时漏掉release回调。一个pid namespace被多少进程、多少挂载点引用kref都记得清清楚楚。只要还有一个引用没释放整个结构体就停留在内存里容器内进程全退光但/proc还挂在某个地方这个struct pid_namespace就不会被销毁。rcu_head配合RCU机制做延迟释放。为什么需要延迟因为可能存在一些代码路径正在遍历pid namespace而没持有锁这时如果直接把结构体释放掉那些读者就会踩到已释放的内存。RCU的思路是标记删除后等一个宽限期确保所有读者都离开临界区了再真正执行free。这个字段就是让等宽限期结束再释放成为可能。level和parent描述的是嵌套结构。pid namespace可以层层嵌套你可以在一个容器里再创建一套pid namespace形成父子关系。parent指向上层命名空间level表示当前是第几层最外层init namespace的level是0。为了限制嵌套深度内核定义了MAX_PID_NS_LEVEL这个值通常是32。超过32层再创建新pid namespace会直接失败。很多人不知道这个限制在做多层沙箱嵌套时踩到过。2.2 分配器字段idr、pid_allocated、pid_limit、last_pididr是本命名空间维护的PID分配表。内核需要根据一个整数PID快速找到对应的struct pid。idr内部是一棵基数树查找、插入、删除都是对数级复杂度比线性遍历快得多也比普通的哈希表更适合这种整数ID稀疏分配的场景。pid_allocated和pid_limit是配对的前者记录这个namespace里已分配了多少个PID后者是上限。pid_limit是从/proc/sys/kernel/pid_max继承来的默认值通常是32768大内存机器上可能是4194304。当pid_allocated逼近pid_limit时分配器会扫描并回收已经僵死的PID编号。很多人把容器里fork失败以为只是内存不够其实有时纯粹是pid_limit撞顶了。我自己印象最深的是last_pid这个字段。它记录下一次分配PID时从哪个编号开始找。实现上分配器不会每次从头扫描而是从last_pid后面继续找空位这样能利用局部性让最近释放的编号优先被复用。但这也带来了PID回绕问题当编号循环一圈回到某个刚被复用的值时如果用户态还残存着旧进程的引用就会发生挂羊头卖狗肉的混乱。2.3 child_reaper每个命名空间都要有自己的孤儿院院长这是整个结构体里最具有人文关怀的字段。child_reaper指向本命名空间的init进程。它的职责是收养所有被父进程抛弃的孤儿进程并且在子进程变成僵尸时负责回收。一个Linux系统没有init进程是活不下去的——没人收尸僵尸进程堆积最终把PID表耗尽。所以每个pid namespace一创建就必须有一个init进程也就是PID 1。在容器里你指定的entrypoint就是这里的init。child_reaper指向它。一旦这个init进程退出整个命名空间就失去了孤儿院院长。内核的做法很果断通过zap_pid_ns_processes给该命名空间里的所有进程发送SIGKILL把这个namespace清干净。你在容器里跑一个后台任务主进程一退出后台任务全部消失底层逻辑就是这里。这也是很多人疑惑为什么我的容器进程一退其他子进程全没了的真正原因。2.4 user_ns、ucounts、hide_pid、pid_gid、proc_mnt权限与proc绑定的字段user_ns把pid namespace和user namespace绑定在一起。它限定了这个pid namespace归属哪个用户命名空间权限检查时用的就是这一层关系。ucounts则负责记账记录该命名空间内的进程数量和RLIMIT_NPROC配合实现用户进程数限制。pid_gid和hide_pid是给/proc文件系统用的。挂载proc时如果带上hidepid选项普通进程就看不到其他进程的目录这组字段就是为这个特性服务的。pid_gid指定哪个组有权限看全部信息hide_pid保存挂载时传入的隐藏等级。proc_mnt指向本命名空间对应的proc文件系统实例。每个pid namespace都有一套独立的/proc视图这个实例的根就存在这里。注意proc_mnt本身又是一个引用源——即使进程全退光了只要还有地方保持着这个proc挂载的引用kref就不归零namespace就无法释放。这是我排查句柄泄漏时最关注的字段之一。3. 用代码和系统调试验证结构体的行为3.1 同一进程两种PID理解一个内核结构体最好的办法是造一个场景看它的行为。最简单的验证是unshare# 宿主机上查看当前shell的PID记下来 echo $$ # 进入新的pid namespace并挂载新的proc unshare -pf --mount-proc bash # 此时在这个新shell里 echo $$输出结果是1。原因是在新的pid namespace中这个bash进程就是整个namespace的第一个进程也就是init进程自然拿到PID 1。但切换到另一个终端在宿主机视角用ps -ef | grep bash找到这个进程它的PID是另一个值比如11452。同一个task_struct在宿主机的pid namespace里编号11452在容器内的pid namespace里编号1。这不是伪装而是这个进程的struct pid里同时存了两个编号每个编号对应一个namespace。这个过程就是struct pid_namespace里那条idr在新namespace中完成了第一次登记。3.2 setns后的getpid不变pid namespace的特殊规则getpid缓存问题我专门做过测试。先写这么一段程序#define _GNU_SOURCE #include stdio.h #include unistd.h #include fcntl.h #include sched.h int main(int argc, char **argv) { if (argc 2) return 1; int fd open(argv[1], O_RDONLY); if (fd 0) return 1; if (setns(fd, 0) ! 0) return 1; pid_t p fork(); if (p 0) printf(child in new ns: getpid()%d\n, getpid()); else printf(parent after setns: getpid()%d\n, getpid()); return 0; }把这个程序编译后先创建一个新pid namespace再拿它的namespace文件路径作为参数运行。结果你会发现父进程的getpid()没有任何变化子进程拿到的却是一个和原编号完全不同的新PID。原因是setns修改的是nsproxy-pid_ns_for_children它并不会让调用者自己马上搬到新namespace。当前进程在自己的struct pid里的编号还是旧的所以单独调用setns后打印getpid()得到的还是旧值。只有下次fork时子进程才会按照新的pid_ns_for_children去登记新编号。这算是PID namespace和mount namespace一个很重要的区别。网上很多文章说setns进入pid namespace后getpid不变是glibc缓存的问题那是把现象解释错了。真正的机制是PID namespace对调用者本人就是下次生效glibc缓存只是加深了误解。如果你用/proc/self/stat直接解析PID结果也一样不变因为调用者真的没有换编号。3.3 如何从用户空间推断level和parentlevel和parent是内核内部字段用户态没有直接接口能读。但可以通过/proc/self/ns/pid这个虚拟文件的inode号来间接判断两个进程是否处于同一个pid namespace#include stdio.h #include sys/stat.h int main(void) { struct stat st; if (stat(/proc/self/ns/pid, st) 0) printf(pid namespace inode: %lu\n, st.st_ino); return 0; }同一namespace里的任何进程这个inode号都相同不同namespace则不同。当你在一层层嵌套的容器里运行这段程序每次得到的inode号都不一样这就是parent链逐层变化的外部表现。虽然看不到level的数值但你可以通过每进一层inode变一次来体会嵌套关系的存在。有个很容易忽略的点/proc本身也是跟着pid namespace走的。如果你在容器里没有重新挂载/proc那读到的是挂载时刻那个namespace的视图和当前进程实际所属的namespace可能已经不一致。这类姿势错误在内核调试现场几乎每周都能碰到。3.4 压一下pid_limit分配器是怎么扛不住的试着在一个pid namespace里快速创建大量进程for i in $(seq 1 100000); do (sleep 30 ) done一般跑到一半就会报Resource temporarily unavailable或者Cannot allocate memory。如果是前者多半是触发了RLIMIT_NPROC如果是后者并且确认内存足够就该怀疑是不是pid_allocated顶到pid_limit了。在分配器逻辑里到达上限后它会尝试扫描并回收stale pid如果回收不过来就返回-EAGAIN。用户态看到的就是fork失败。注意pid_limit是整个pid namespace共享的不是每个进程私有。一个容器里PID被占满其他进程哪怕只想fork一个线程都困难。这常被误报成内存耗尽实际上查/proc/sys/kernel/pid_max和当前分配量才是正路。4. 理解结构体之后那些经典故障就有了答案4.1 进程列表和信号隔离的边界到底在哪很多人以为容器里ps看不到宿主机进程是因为容器运行时做了什么过滤。其实单纯的pid namespace并不能隐藏进程——关键在/proc的挂载方式。如果不带--mount-proc进入新pid namespace你仍然看到的是旧的/proc里面一样有宿主机进程。同时信号的边界是由PID编号决定的。内核在发送信号时会解析目标进程在你当前namespace中的编号。你kill 1234如果1234这个编号在你当前的pid namespace里指向的是你的容器进程那就只影响它如果这个编号没有对应进程就报No such process。这才有了隔离的边界。有个很实用的经验判断一个进程能不能被某个容器操作不是看它在不在容器里而是看它的/proc/self/ns/pid和那个容器是不是同一个inode。用这个标准去排查误杀问题比猜容器运行时行为可靠得多。4.2 容器init进程退出后的级联清理容器主进程退出经常伴随容器内其他进程全部消失。这个现象用child_reaper来解释就很清晰。主进程就是当前pid namespace的init进程它一旦退出child_reaper就指向空。内核发现这个namespace失去了reaper会立刻对所有剩余进程执行清理流程。我排查过一个场景容器里有个监控脚本理论上主进程退出后脚本还能继续跑一会儿但每次都是瞬间消失。最初以为是监控脚本被杀死看了内核日志才发现是pid namespace的级联清理生效。这类问题如果你不理解child_reaper的角色会花很长时间去找一个根本不存在的凶手。4.3 僵尸进程堆积和namespace延迟释放僵尸进程和parent exit有关但很多人没意识到僵尸进程没有完全消失之前它持有的pid namespace引用也不会释放。换句话说只要还有一个僵尸进程赖在某个已死容器里那个容器的struct pid_namespace就会一直留在内存中idr里的编号也一直占着。更隐蔽的是proc_mnt引用。容器里的/proc被某个进程通过bind mount挂到了别的地方或者有fd一直打开着即使容器所有进程都退了kref也降不到0。我在排查容器停止后内存不释放时用lsof找出那些指向已删除/proc挂载点的fd问题就水落石出了。这类泄漏查起来非常费劲但一旦你知道struct pid_namespace有这些引用源排查方向就清晰很多。4.4 不要小看32层嵌套限制MAX_PID_NS_LEVEL这个限制很少被测试到但确实真实存在。它定义在include/linux/pid_namespace.h值为32。当你尝试在第32层里再创建一个pid namespace内核会直接拒绝。实际应用中一些沙箱产品会做多层嵌套外层容器装沙箱沙箱里再套一个隔离环境一不注意就叠了五六层。虽然离32还有距离但如果你做的是极深嵌套的运行时就需要提前把这个限制考虑进去。还有一点每多一层嵌套进程编号的翻译链路就会多一跳struct pid里的编号数组也会变长。所以不要无缘无故加深嵌套层级对性能和复杂度都没好处。最后分享一个我自己的调试习惯遇到任何和PID相关的内核诡异问题第一件事不是翻日志而是先确定当前进程的struct pid_namespace是谁/proc/self/ns/pid的inode号是多少和怀疑对象是不是同一套。这个判断一旦做对后面至少能省掉一半的排查时间。结构体里的字段一个个看过去你会发现Linux的隔离设计并不是堆上去的奇技淫巧而是每一个细节都在回答一个真实的工程问题。理解了它你再回头看容器的一些怪现象基本都能猜到内核在背后干了什么。

相关新闻

PyG图神经网络节点分类实战:从GCN原理到调参排错全解析

PyG图神经网络节点分类实战:从GCN原理到调参排错全解析

1. 从一张图说起:为什么节点分类值得单独拎出来讲图神经网络这几年从学术圈火到工业界,很多人第一次接触 PyTorch Geometric(后面统一简称 PyG),都是从节点分类这个任务入手的。原因很简单:它把"图结构…

2026/10/15 5:52:37 阅读更多 →
Knock-Knock:黑盒逆向DRAM地址映射的实战指南

Knock-Knock:黑盒逆向DRAM地址映射的实战指南

内存地址从CPU出来时,是一串整齐的物理地址;可一旦落到内存控制器手里,它会被拆散、打乱,再以一种厂商私有的方式分配到一排排DRAM颗粒上。这正是我最近折腾的一个项目标题:Knock-Knock——黑盒、平台无关的DRAM地址映…

2026/10/15 5:52:37 阅读更多 →
RHEL 8 上 ELK 日志平台部署与性能调优实战指南

RHEL 8 上 ELK 日志平台部署与性能调优实战指南

上个月我帮某公司调了一次日志平台,几十台服务器的业务日志散落一地,每次排障都靠逐台跳上去 grep。我们要把这些日志统一收上来、做清洗、索引、再可视化,最后定下来的方案就是 ES Logstash Kibana 这套经典组合,跑在 RHEL 8 上…

2026/10/15 5:52:37 阅读更多 →

最新新闻

Pycopy:存储优先的Python方言,让嵌入式与云端部署更轻量

Pycopy:存储优先的Python方言,让嵌入式与云端部署更轻量

简介:Pycopy是一个极简、轻量且可高度扩展的Python方言实现,源自MicroPython,面向桌面端、云端与微控制器等各类受限环境。该源码压缩包适合嵌入式及物联网开发者,也适合想理解精简解释器设计的程序员。项目实现了完整的Python 3.…

2026/10/15 16:25:22 阅读更多 →
Meta开放式机架V3:BBU与机架结构设计深度解析

Meta开放式机架V3:BBU与机架结构设计深度解析

聊聊数据中心里的机架,可能很多人下意识想到的就是机房那一排排黑乎乎的铁柜子,标准19英寸、某个高度、前后通风,设备一层层往上摞。但如果你真的在一线维护过超大规模集群,你一定会对传统机架有一肚子槽点:电源线乱七…

2026/10/15 16:25:22 阅读更多 →
软件模拟ADS1220驱动:GPIO实现24位ADC采样,无需硬件SPI

软件模拟ADS1220驱动:GPIO实现24位ADC采样,无需硬件SPI

简介:面向STM32F103xx系列微控制器的开发者,这份软件模拟ADS1220采样芯片的驱动方案,适用于仪器仪表、工业控制等需要高精度模拟信号采集的各类测控场景。压缩包共172个文件,大小5.22MB,以C语言源码为主体,…

2026/10/15 16:25:22 阅读更多 →
软件模拟SPI驱动ADS1220:GPIO手搓24位ADC采样方案

软件模拟SPI驱动ADS1220:GPIO手搓24位ADC采样方案

简介:面向STM32F103xx开发者的ADS1220软件模拟驱动包,以纯软件方式模拟SPI时序,完成高精度ADC的初始化、工作模式配置、数据转换与结果读取,解决无硬件SPI或低成本采集场景下的模拟量输入需求。ADS1220为24位低功耗模数转换器&…

2026/10/15 16:25:22 阅读更多 →
HTML转PDF实战:无头浏览器选型、打印样式与避坑指南

HTML转PDF实战:无头浏览器选型、打印样式与避坑指南

简介:这是一套基于Aspose.Pdf库实现HTML转PDF的.NET示例工程,面向Web开发、文档生成等领域需要稳定生成PDF的技术人员。压缩包共32个文件,大小约10.62MB,涵盖C#源码、可直接引用的DLL、PDF演示文件、Web.config等配置,…

2026/10/15 16:25:22 阅读更多 →
Binder通信机制深度解析:从内存映射到线程模型,一次讲透IPC性能优化

Binder通信机制深度解析:从内存映射到线程模型,一次讲透IPC性能优化

1. 从一个"为什么调用这么慢"的疑问说起如果你做过一段时间的系统层开发,或者维护过跨进程通信频繁的模块,大概率遇到过这样的场景:一个看似简单的接口调用,耗时却比预期高出一个数量级;日志里明明只打了几行…

2026/10/15 16:24:20 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 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/15 0:14:36 阅读更多 →
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/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练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/15 9:49:14 阅读更多 →