news 2026/9/6 11:07:45

半导体装备实时控制:鸿道操作系统如何构建确定性底座

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
半导体装备实时控制:鸿道操作系统如何构建确定性底座

做了这么多年工业控制,我越来越觉得半导体装备的控制系统是整个设备里最“拧巴”的一环:精度要到纳米级、周期要压到微秒级、连续运行几个月不能出一次调度错乱。很多朋友一开始以为是伺服电机和光栅尺的功劳,等真正把系统搭起来才发现,底层那层操作系统才是决定控制上限的关键。鸿道操作系统这个名字,这两年频繁出现在半导体装备圈子里,我就结合自己实际接触过的项目经验,聊聊它在半导体装备实时控制里到底扮演了什么角色。

先说清楚一件事:这篇文章不扯宏观概念,只从技术实现角度出发,聊聊半导体装备实时控制的需求、鸿道这类工业级实时操作系统怎么应对这些需求,以及你要把一个运动控制或工艺控制任务真正跑起来,需要经历哪些步骤、踩过哪些坑。

1. 半导体装备的实时控制,到底“实时”在哪里

1.1 控制系统的层级结构与操作系统的位置

一台典型的半导体装备,比如刻蚀机、薄膜沉积设备或光刻机,从控制架构上看是分层的。最上面是工厂层的MES和主控上位机,负责配方管理、数据汇总、人机交互;中间是设备主控,通常跑着工艺调度逻辑和状态机;再往下是实时控制层,直接和伺服驱动器、电机、加热器、质量流量控制器、射频电源这些执行机构和传感器打交道。

很多人会把“设备主控”和“实时控制”混为一谈,实际上它们在实时性要求上天差地别。设备主控干的是流程调度,毫秒级响应甚至几十毫秒响应都够用;但实时控制层不一样,位置环、速度环、电流环、温度PID、压力控制这些任务,周期到了没算完就是事故。操作系统在这里承载的就是实时控制任务,它是介于硬件和应用程序之间的基础层,也就是标题里说的“底座”。

举个例子,一个光刻机工件台,工作台在运动过程中需要按照规划好的轨迹以亚微米甚至纳米级精度定位,控制周期通常在250微秒到1毫秒之间。如果你用的操作系统在某个周期里突然被其他任务抢占了几百微秒,那这个周期的位置误差可能直接超差,轻则重新对位,重则碎片和晶圆直接报废。这套逻辑在刻蚀机的射频功率匹配、薄膜沉积的温度控制里同样成立。

1.2 硬实时、确定性与抖动,为什么慢一点不行

实时性这个词被说得太滥了,行业内必须区分硬实时和软实时。软实时系统,比如跑着普通Linux的工控机,大多数情况下能满足时间要求,偶尔延迟一次也能继续运行,顶多界面卡顿或数据略延迟。硬实时系统则要求在确定的最坏情况下完成响应,任何一次超时都算失败。

这里有一个很容易被忽略的指标:确定性。做半导体装备控制的工程师更关心的是“最坏情况下的响应时间是多少”,而不是“平均响应时间是多少”。哪怕平均延迟只有50微秒,但最坏情况延迟飙到5毫秒,那这个系统就没法用。因为控制器的算法是按固定周期计算的,数据采集必须在一个周期内完成采集、处理、输出,否则你无法保证每一次控制动作都有效。

还有一个指标叫抖动,也就是实际执行周期和理论周期之间的偏差。假设一个运动控制任务的周期是500微秒,如果这个任务每次启动时刻的偏差在正负20微秒以内,那对伺服环路的影响可以通过算法补偿;如果偏差达到正负200微秒,整个系统的相位裕度就会崩掉,表现出来就是电机异响、震动、跟随误差变大。半导体设备对抖动的容忍度极低,这也是通用操作系统很难直接用于底层控制的原因之一。

1.3 类Linux系统做半导体控制时的几个麻烦

我不是说Linux不行,实际上Linux在半导体设备的上位机、数据采集、视觉系统里用得非常多。但要做硬实时任务,它就有些力不从心。

最核心的问题在于内核的调度机制和中断处理方式。普通Linux为了追求整体吞吐量和公平性,调度器会尽量照顾所有进程,一个用户态的实时任务很难保证在固定的微秒级时间窗口内被唤醒。即使打了PREEMPT_RT补丁,把内核变成完全抢占式,中断线程化以后可以在一定程度上改善实时性,但那也只是“软实时”,最坏情况仍然不确定,尤其在系统负载升高、缓存命中率下降的时候,延迟抖动的尾巴会变得很难看。

另外,通用操作系统里存在大量不可控的后台活动。内存回收、文件系统刷盘、网络协议栈收包、桌面环境的定时器,这些都会抢占CPU时间。对半导体设备来说,这些不确定性是不可接受的。工业级实时操作系统做的事情其实很纯粹,就是把CPU时间片和中断响应路径严格管起来,保证关键任务在任何情况下都能在规定时间内执行。

2. 鸿道操作系统是什么,凭什么用在半导体装备上

2.1 微内核架构:用减法换确定性

鸿道操作系统属于典型的微内核架构实时操作系统。微内核的思路和宏内核正好相反,宏内核把所有服务都塞进内核空间,功能强大但对实时性不友好;微内核只保留最基本的功能,比如任务调度、内存管理、进程间通信,其他模块全部放到用户态,这样内核路径短、中断响应快、行为可预测。

用“减法换确定性”这个说法可能更直观。一个系统中需要管理的对象越少、路径越短,最坏情况就越容易算清楚。半导体装备控制程序不需要内核帮它管音频驱动、图形界面、网络协议栈,它需要的就是在中断来临时尽快唤醒对应任务、在任务切换时开销可控、在访问共享内存时不会因为内核锁导致不可预期的阻塞。微内核在这几个方面天然有优势。

在实时操作系统的圈子里,VxWorks是绕不开的一个名字,很多老的半导体装备和运动控制器都基于VxWorks开发。鸿道这类系统的设计思路和它非常接近,但在架构上做了更现代的扩展,比如支持多核、支持虚拟化、支持更丰富的POSIX接口。所以很多从VxWorks迁移过来的工程师,上手会非常快。

2.2 多分区隔离与混合调度:一个盒子干多种活

半导体设备的控制不像有些人想象的那样,一个CPU只跑一套控制程序。现在的设备控制柜里,往往需要同时跑实时控制和人机交互、数据上报、远程维护等多类任务。如果每一个功能都用一台独立的工控机,成本和复杂度都不可接受。

鸿道这类系统给出的方案是分区隔离。可以把一个多核CPU划分成多个独立分区,每个分区运行不同的操作系统实例,比如分区1跑VxWorks兼容的实时环境负责运动控制,分区2跑Linux负责HMI和通信,分区3跑裸机程序负责高速IO。分区之间在时间片和内存空间上做硬隔离,一个分区崩溃不会拖垮另一个分区。这种混合关键性系统的设计思路,在航空航天领域已经很成熟,现在慢慢渗透到半导体装备行业。

对设备厂商来说,这个能力意味着可以把过去需要两三台机器才能干的活儿合并到一台机器上,同时保留实时性。设备尺寸小了、功耗低了、故障点也少了。

2.3 生态兼容:迁移到鸿道不意味着推倒重来

工业软件最怕绑死,一个系统用了十年,底下的操作系统如果换掉,应用代码要不要重写是一个生死攸关的问题。鸿道在这方面的策略很务实,提供了一整套兼容层,尤其是对VxWorks和POSIX接口的兼容。

我见过一些装备厂商做操作系统迁移,真正痛苦的往往不是实时调度,而是驱动和中间件。原系统里用了VxWorks特有的信号量、消息队列、看门狗组件,如果目标系统不支持这些接口,移植量会非常大。而鸿道在API层面做了大量兼容工作,很多VxWorks应用代码基本不用改或者改动很小就能跑起来。这一点对于缩短新系统验证周期非常关键,毕竟半导体装备的验证周期本来就长,动辄以年为单位。

3. 从需求到原型:搭建一套半导体装备实时控制的最小系统

3.1 先算账:控制周期、抖动预算、CPU余量

无论用哪个实时操作系统,动手写代码之前都要先把需求量化算清楚。以我之前做过的一个直线电机运动台控制项目为例,需求是这样的:伺服周期1kHz,也就是控制周期1毫秒;位置闭环要求整定时间小于2毫秒;允许的周期抖动不超过控制周期的2%,也就是20微秒。

20微秒这个数字看起来宽松,但你要知道这个任务里包含了编码器数据读取、坐标变换、插补计算、PID运算、指令输出,每一步都有耗时。我当时做了个粗略估算,控制任务本身的代码路径需要大概60微秒的CPU时间,那么在1毫秒的周期里,留给操作系统调度、中断、通信的余量就是1毫秒减去60微秒,再减去实时任务自身可能带来的切换损耗,大约能剩800多微秒。听起来余量很大,但别忘了同一颗CPU上还跑着其他中断服务程序和周期任务。

所以我的建议是,在第一步就不要贪心,把实时控制任务放在独立的核上,并且关掉这个核上的无关中断。如果目标芯片支持多核,一个核心跑实时控制、一个核心跑通信和人机交互是最省心的搭配。算账的时候还有一个容易被忽视的点:CPU的主频越高不代表实时性越好,关键看中断响应路径和缓存行为。有些低主频的工业芯片反而因为设计简单、行为可预测,在实时控制里表现更稳。

3.2 任务规划与优先级设计

算完账之后,就是任务的规划。一个典型的半导体装备实时控制程序里,任务大致可以分成以下几类:

  • 高速伺服任务:周期250微秒到1毫秒,处理编码器反馈、电流环、位置环,优先级必须最高;
  • 工艺I/O任务:周期1到10毫秒,处理数字量输入输出、模拟量采样、气动阀控制;
  • 状态机任务:周期10到50毫秒,处理工艺步骤切换、报警逻辑;
  • 通信任务:跑Modbus/TCP或EtherCAT主站,周期随总线同步,优先级在运动控制之下;
  • 后台监控任务:周期100毫秒以上,做数据记录、心跳上报,优先级最低。

优先级设计的原则是“让高速任务独占CPU,低速任务用剩余时间”。很多新手喜欢把状态机任务优先级调得很高,觉得这样响应快,结果状态机任务频繁抢占伺服任务,导致伺服周期抖动变大,整个系统的稳定性反而变差。

在鸿道的环境下,还可以利用分区能力把实时任务和普通任务彻底隔离。实时分区里只放伺服任务和工艺I/O任务,其他东西全部丢到另一个分区去。这样即使后台有大量的日志写入或者网络通信波动,实时分区内的任务也不受干扰。

3.3 与伺服驱动器的实时总线通信配置(EtherCAT为例)

半导体装备里,控制器和伺服驱动器之间的通信早就不再是简单的脉冲方向信号了,主流的方案是用实时工业总线,最典型的就是EtherCAT。EtherCAT的设计思想很有意思,主站发送一个报文,报文在从站之间流转,每个从站读取自己需要的数据并插入本地的数据,报文最后再返回主站,整个过程中数据帧的处理是在硬件层面完成的。

在EtherCAT网络里,同步抖动可以做到亚微秒级别,但前提是主站操作系统必须能严格同步到EtherCAT的分布式时钟。这个同步的核心在于:每个总线周期开始时,主站需要在一个确定的时间窗口内发出数据帧,如果操作系统抢占延迟太大,周期抖动就会传递到总线上。

在鸿道这类实时操作系统里跑EtherCAT主站,关键是要把EtherCAT主站任务挂到最高优先级,并且绑定到特定的CPU核。同时要把EtherCAT的周期同步中断配置成实时中断,中断触发后直接唤醒主站任务,不允许别的任务插队。我见过有人用普通Linux跑EtherCAT主站也能跑出不错的性能,但在连续高负载运行几天后,偶尔会出现一次总线超时,这就是抖动尾部的风险。而在半导体产线上,一次超时可能就意味着整批晶圆出现瑕疵。

3.4 关键代码:周期任务、锁内存、看门狗、中断响应

现在给出一个在实际项目中常见的周期任务框架,代码风格偏向POSIX接口,思路在鸿道和VxWorks上都通用。

#include <stdio.h> #include <signal.h> #include <time.h> #include <sys/mman.h> #include <pthread.h> static volatile int running = 1; /* 周期控制任务:1kHz */ void* servo_task(void* arg) { struct timespec next; struct timespec period = {0, 1000000}; /* 1ms */ clock_gettime(CLOCK_MONOTONIC, &next); while (running) { /* 等待下一个周期 */ clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL); /* 读取编码器反馈 */ read_encoder(); /* 坐标变换与插补 */ kinematic_transform(); /* PID计算 */ pid_compute(); /* 输出到驱动器 */ write_dac(); /* 喂狗 */ watchdog_kick(); next = timespec_add(next, period); } return NULL; } int main(void) { pthread_t tid; struct sched_param param; /* 防止内存换页 */ mlockall(MCL_CURRENT | MCL_FUTURE); pthread_create(&tid, NULL, servo_task, NULL); param.sched_priority = 80; /* 实时优先级 */ pthread_setschedparam(tid, SCHED_FIFO, &param); pthread_join(tid, NULL); return 0; }

这段代码里有几个细节值得说。

clock_nanosleep用绝对时间定时,而不是相对时间。相对时间定时有个问题:任务执行本身耗时,每次睡眠时间累加之后,周期会越来越往后漂。绝对时间定时是先算好下一次唤醒的绝对时间点,然后再睡到那个点,时间再长也不会累积漂移。

mlockall把所有内存页锁进物理内存,防止内存换页导致的任务切换延迟。实时任务最怕缺页中断,一旦访问的内存页面不在物理内存里,系统要去磁盘换页,这个延迟对实时系统是致命的。

pthread_setschedparam把任务设置为FIFO实时调度策略,优先级设为80。这个数字不是随便定的,要留出比它更高的优先级给系统的中断处理和EtherCAT同步任务。如果伺服任务占用了最高优先级,那中断来了以后任务不一定能及时让出CPU。一个好的优先级规划应该是:硬件中断 > 总线同步任务 > 伺服任务 > 工艺I/O任务 > 状态机任务。

4. 系统性能测试与调优实录

4.1 三个关键指标:任务切换时延、中断响应时延、调度抖动

系统搭好之后,必须用数据说话。我个人认为和实时控制最相关的测试指标有三个。

第一个是任务切换时延。一个实时任务在准备好但等待调度的状态下,到它真正获得CPU并开始执行,中间的时间差就是任务切换时延。对这个指标的影响因素包括就绪队列长度、内核锁状态、缓存命中率。

第二个是中断响应时延。外部中断信号到达CPU到中断服务程序第一条指令执行之间的时间。在微内核架构下,中断处理路径短,这个指标通常非常稳定。

第三个是调度抖动。可以理解为实际周期启动时刻相对理论时刻的最大偏差。半导体装备设计者关注的核心指标就是它。测试方法很直观,在周期任务开头记录当前时间戳,连跑几百万个周期,看所有偏差的最大值和分布。

4.2 实测数据怎么解读

我用过一次鸿道在x86平台上的评估板做性能摸底。配置是4核2.8GHz,EtherCAT 1kHz同步,伺服任务1kHz。跑了一段时间的测试,结果大概是这样:任务切换时延在中位数附近可以做到2到3微秒,最坏情况不超过8微秒;中断响应时延最坏在10微秒以内;调度抖动,也就是周期任务启动时间偏差的最大值,大约在15微秒左右。

这个数据听起来可能不够惊艳,毕竟有些单片机的裸机程序可以做到亚微秒级的确定性。但在多核、带虚拟化、跑着完整通信协议栈的系统里,做到这种程度的确定性已经很不容易。对半导体装备来说,15微秒的抖动在1kHz伺服周期下大约占比1.5%,在常规控制算法的容差范围之内。

需要提醒的是,评估板的数据和你现场设备的数据是两回事。最终的抖动表现取决于主板设计、BIOS配置、内存条质量、外设的中断负载。在整机调试阶段,一定重新测一遍,不要直接沿用实验室数据。

4.3 多核隔离、缓存绑定与确定性调优

在实际调优中,有三个手段能明显改善实时性能。

第一是CPU核隔离。Linux上有isolcpus参数,可以把某些核从普通调度器中剥离出来,只运行绑定到该核的任务。类似地,实时操作系统里通常也有CPU亲和性设置,把实时任务固定绑定到一个核上,避免任务在不同核之间漂移。核间漂移会让缓存失效,还会引入跨核通信的延迟,对实时性非常不利。

第二是缓存管理。实时任务访问的数据结构要尽量小、尽量连续,保证能装进L1或L2缓存。我见过一个案例,运动控制任务里定义了一个很大的结构体数组,里面塞满了调试信息,结果每次任务执行时缓存都装不下,频繁访问内存,任务执行时间从60微秒涨到了180微秒。把调试信息挪到另一个非实时任务里之后,执行时间立刻恢复。

第三是中断负载均衡。多核系统默认会把所有中断平均分配到各个核上,但对实时系统来说这不是好事。最好把影响实时路径的中断,比如EtherCAT同步中断、编码器计数卡中断,都亲和到运行实时任务的核;把网卡、磁盘、USB这些无关紧要的中断亲和到其他核。否则每来一包网络数据,都有可能打断实时任务。

5. 现场常见的坑与排查方法

5.1 优先级反转:一个锁引发的“血案”

很多实时系统出问题,最后查到根上都是优先级反转。概念很简单:高优先级任务在等待一个资源,这个资源被低优先级任务占着,但低优先级任务又被中优先级任务抢占了CPU,导致高优先级任务被间接阻塞了很长时间。

这个问题的危害在半导体设备上表现得特别明显。我曾经遇到过一个现场问题:设备运行几分钟后偶尔出现一次运动超差报警,但概率不高,很难复现。后来抓trace发现,高优先级的伺服任务偶尔会访问一个共享参数块,而这个参数块被低优先级的监控任务加了锁。监控任务在执行过程中被一个中等优先级的日志任务打断,日志任务又跑了几十毫秒,伺服任务就在锁上干等了那么久。

解法和教科书上写的一样:使用优先级继承协议或者优先级天花板协议。实时操作系统提供的互斥量一般都有这个选项,需要主动打开。手动加自旋锁或者裸的开关中断来保护共享数据,在单核系统上偶尔可行,但在多核系统里很容易死锁或者造成不可预测的延迟。

5.2 抖动突然飙升:先从缓存和共享资源查

有一种很诡异的现象:系统刚启动时抖动漂亮得很,运行几个小时之后抖动慢慢变大,有时候还会出现周期性的峰值。排查这类问题,我的经验是先怀疑缓存,再怀疑共享资源。

缓存问题通常和内存布局有关。系统长时间运行后,堆上分配的新内存可能落到不同的物理页面,导致实时任务访问的数据和某个非实时任务频繁共享缓存行,互相冲刷。解决思路是给实时任务预分配固定的内存池,从启动到结束都用同一块内存,不要频繁malloc和free。

共享资源的问题则更多出在总线上。PCIe设备、DMA引擎和CPU争抢内存带宽,会直接拉长实时任务访存的时间。现场排查时可以试试挂一个高负载的DMA传输任务,同时观察实时任务的最大执行时间,如果明显变大,说明内存带宽已经被争抢,你需要检查DMA缓冲区的分配策略,尽量让DMA传输和实时任务访问的内存不在同一个内存通道上。

5.3 启动阶段看门狗误触发的原因

看门狗在工业控制系统里是保命符,但用不好也会变成“夺命符”。最常见的误触发场景发生在系统启动阶段:看门狗已经使能,但应用任务还没创建出来,没有人去喂狗,系统不断重启。

半导体装备的启动流程通常很复杂,要初始化EtherCAT总线上的几十个从站,要加载工艺配方,要校准机械零点。这些耗时的初始化过程放在实时任务里会卡住控制周期,放在非实时任务里又可能来不及喂狗。我的做法是把启动流程拆成阶段:第一阶段只启动看门狗和网络同步任务,第二阶段做硬件初始化,每完成一个子步骤就喂一次狗,第三阶段等所有硬件就绪后再启动正式的控制任务。看门狗的超时时间也要根据最长的启动步骤重新设定,不能用手册里的默认值。

5.4 与上位机通信拖慢实时任务怎么办

半导体设备总逃不过和上位机通信,MODBUS、OPC UA、SECS/GEM协议,一个比一个复杂。这些通信任务如果在实时分区里跑,非常容易出问题。

典型的情况是通信协议栈内部用了动态内存分配,或者操作系统在收包时触发了一个高优先级的中断,导致实时任务被不必要地抢占。解决方案就是把通信任务放到非实时分区里去,实时分区和通信分区之间用无锁队列或者共享内存来交换数据。这样即使通信协议栈卡死、崩溃,也不会影响实时控制回路。

这种架构下有一个细节需要注意:两个分区操作同一个共享内存时,要防止数据撕裂。用双缓冲区机制,通信任务先把数据写到备用缓冲区,然后原子地切换主缓冲区指针;实时任务永远只读当前主缓冲区的内容,不中途修改指针。这样能保证实时任务看到的数据始终是完整的。

写在最后的一点感受

如果把半导体装备比作一台精密的乐器,实时操作系统就是乐手的手腕,手腕稍微抖一下,音符就会跑偏。我做了这些年控制系统,最大的体会是:选操作系统不是选功能多的,而是选行为可预测的。鸿道这类实时操作系统能进入半导体装备领域,靠的不是单个指标多惊艳,而是它能让你在出现问题时有一个清晰的排查路径——任务为什么延迟、中断在哪一环堵住了、哪个核被谁抢占了,这些问题都能通过trace工具一层层挖出来。最后再分享一个小经验:无论用什么操作系统,接手一个新平台时,第一件事不是急着写功能代码,而是花一周时间把实时性能和调度特性摸透。这份基线数据,以后排查所有疑难杂症都用得上。

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

FasterTransformer源码解析:GPU推理加速的核心优化与实践

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

作者头像 李华
网站建设 2026/9/6 11:03:53

物联网终端JL-17T接传感器数据上传小程序的实战指南

JL-17T 到底能不能接传感器并把数据发到小程序&#xff1f;这是我在拿到样机后第一个想验证的问题&#xff0c;也是很多做物联网项目的人最关心的。先说结论&#xff1a;能&#xff0c;但拆开讲就一句话——GPIO、ADC、UART 这三类接口在平台原生层面就能搞定&#xff0c;I2C 和…

作者头像 李华
网站建设 2026/9/6 10:59:47

RISC-V启动流程与Bootloader实战:从复位向量到内核交棒

RISC-V 的启动流程&#xff0c;是我接触过的所有处理器体系里最“拆得开”的一条链路&#xff1a;上电复位、Bootloader 多级接力、固件特权级切换、最终把内核加载进内存再交棒。可它也是最容易让人懵圈的链路——因为 RISC-V 指令集规范本身并不规定上电后第一步该干什么&…

作者头像 李华
网站建设 2026/9/6 10:58:53

从沙漏到代码:定时任务的状态机与幂等控制设计

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

作者头像 李华
网站建设 2026/9/6 10:58:03

Canal 数据脱敏与安全同步:保障企业数据安全的传输解决方案

Canal 数据脱敏与安全同步&#xff1a;保障企业数据安全的传输解决方案 Canal 作为阿里巴巴开源的数据库增量订阅组件&#xff0c;在数据同步过程中如何保障敏感数据安全成为企业关注的焦点。本文详细介绍了 Canal 的字段级脱敏机制、敏感数据过滤策略以及传输加密实现方案&…

作者头像 李华
网站建设 2026/9/6 10:58:01

FreeRTOS、RT-Thread、Zephyr对比:内核、生态与选型指南

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

作者头像 李华