news 2026/10/21 1:46:05

RTOS任务核心解读:状态、调度、栈与优先级实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RTOS任务核心解读:状态、调度、栈与优先级实战

刚从裸机转过来用RTOS的那阵子,我对"任务"这个词特别不以为意:不就是把以前main函数里的几个模块拆开,各自放进一个循环里跑吗?后来项目里要同时做1kHz采样、按键扫描和串口透传,我照搬前后台的思路把代码拆了拆,编译下载后系统直接跑飞。查了一整晚,最后才发现问题出在我压根没理解"任务"是什么——它不是一段函数,不是一个大循环,而是一个拥有独立栈、独立状态、可以被随时暂停和随时恢复的执行流。

这个误解在刚接触RTOS的开发里太常见了。网上资料一大堆,但多数上来就教API怎么调、怎么建任务,真正把"任务到底是个什么东西"讲透的不多。这篇我记得不是教材,而是结合我自己趟过的坑、以及这些年带人做项目时反复被问到的问题,把"任务"这件事从头到尾梳理一遍。看完你可以回答三个问题:任务是什么、任务怎么跑、任务之间怎么配合。

1. "任务"这个词,先坑了多少裸机开发者

1.1 前后台系统里根本没有"任务"

很多老工程师对裸机编程非常熟:一个main函数里放一个大循环(后台),再加上几个中断服务函数(前台),一套系统就能跑起来。按键扫描、LED刷新、串口轮询、ADC采样,全都在那个while(1)大循环里轮着来。

这个模式有个致命伤:实时性靠运气,不靠保证。循环里越靠后的代码,被执行的周期就越难估算。比如大循环里先刷了个耗时1ms的LCD,又解析了几百字节的串口数据,排到后面的按键扫描可能10ms、20ms甚至更久才轮到一次。你在裸机里靠"把紧急代码往循环前面放、放到中断里"来缓解,但一旦系统复杂起来,这个办法很快就撑不住了。

而RTOS引入"任务"之后,处理逻辑完全不同:每个独立功能(按键扫描、ADC采样、报文发送)是一个任务,每个任务有自己明确的优先级。高优先级任务就绪了,可以随时抢占低优先级任务,CPU立刻切过去干活。实时性从"看运气"变成了"看优先级",这才是任务存在的根本意义。

1.2 任务不是函数,也不只是"线程"

这是最绕的一个地方。任务在代码形态上确实是一个函数,比如:

void vTaskProcessData(void *param) { for (;;) { // 等待数据、处理数据 } }

但任务不等于这个函数。同一个函数被多个任务使用,产生的是多个独立的执行流;一个任务即使入口函数写得再简单,背后也带着一整套内核给它配套的资源(栈、控制块、状态)。任务函数看起来是个普通C函数,但它不能被"调用",只能被调度器"切换"进去执行。

那任务和线程有什么区别?在绝大多数RTOS中,任务就是线程,叫法不同而已,一个进程内的多线程概念在RTOS里通常被弱化了——所有任务共享同一个地址空间,没有现代操作系统里那种严格的内存隔离。你要记住的就一点:任务是操作系统能调度的最小执行单元,它是"活"的,有自己的生命周期和状态,而函数是"死"的,只有被调用时才存在。

1.3 任务 = 代码 + 资源 + 状态

我后来给新人讲任务时,喜欢用这个公式:

任务 = 任务函数 + 独立任务栈 + 任务控制块(TCB)+ 优先级 + 当前状态

少了任何一个,它都只是一个函数,不是一个任务。任务函数是执行逻辑,任务栈保存执行现场,TCB记录任务的"一切档案",优先级决定它在调度器眼里的地位,状态说明它此刻是在跑步、在睡觉还是在排队。把这五个要素都理解透了,后面所有内容基本都能串起来。

2. 任务的身份证:任务控制块(TCB)里到底藏了什么

2.1 TCB:内核给每个任务建的档案袋

你调用创建任务的API(比如TaskCreate),把函数指针、栈地址、优先级传进去,内核第一件事就是在内存里给这个任务分配一个任务控制块(TCB,Task Control Block)。TCB本质上就是一个结构体,里面记录了任务的完整信息。

这里面核心的字段大致有这几类:

字段类型具体内容作用
运行信息栈指针、当前栈顶位置切换任务的直接依据
任务属性任务入口函数、参数、任务名标记任务身份与入口
调度信息优先级、时间片、就绪状态调度器挑选任务时看这里
等待信息等待的事件、阻塞超时时间知道任务在等什么、睡了多久
错误状态栈溢出标记、错误码运行出问题时排查用

你可以把TCB理解为每个员工的档案袋:调度器想查某个任务的优先级,翻TCB;想保存它的现场,把栈指针写进TCB;想知道它为什么阻塞,看TCB里的等待列表。整个调度过程,本质上就是在操作一堆TCB。

创建任务时拿到的那个"任务句柄"(handle),也并不是什么神秘的东西,它就是指向这个TCB的指针。以后你调用任务挂起、删除、修改优先级的API,最后都会顺着这个句柄找到TCB来操作。

2.2 任务栈:为什么每个任务必须独占一块"私聊空间"

任务栈是我见过新人最不重视、但翻车率最高的地方。每个任务创建时都要传入一块内存作为它的任务栈,为什么不能所有任务共用一个栈?

想象一下:CPU正在执行任务A,任务A调用了几个函数,栈里压了一堆局部变量和返回地址。这时高优先级任务B就绪了,调度器要暂停A、切换B。切换意味着把A当前所有的寄存器、局部变量、返回地址全部保存下来,等下次A恢复的时候再原样搬回去。保存的地方就是A的任务栈。

如果所有任务共用一个栈会怎样?A暂停时,它的现场还存在栈顶;B一跑,栈顶继续往上涨,直接把A的数据覆盖了。等A恢复时看到的一定是面目全非的现场。所以每个任务必须有自己的独立栈,这是任务能互相切换的前提。

任务栈的大小怎么定?这是实操中比较头疼的问题。基本原则是:任务栈空间必须覆盖"任务函数的最大调用深度 + 最多局部变量 + 中断嵌套开销(如果中断用的是同一个栈)"。不过在很多主流MCU上,中断使用独立的主栈(MSP),任务使用各自的进程栈(PSP),这种情况下中断嵌套不会吃掉任务栈空间。

新手最容易犯的错是拍脑袋定栈大小。我见过一个项目的串口解析任务,函数里放了个512字节的局部数组,栈却只分了256字节,运行起来立刻栈溢出、系统随机死机。栈大小经验起步可以给256字(即512字节)或更高,跑一段时间后用内核的栈高水位检测功能看实际用了多少,再逐步调优。宁可多留冗余,不要为难编译器。

2.3 任务状态:理解"睡觉"才是RTOS的精髓

RTOS里的任务绝大多数时间不是在运行,而是在"睡觉"。一个任务的状态大概有这几种:

  • 就绪:任务一切正常,随时可以运行,只是还没排到CPU。
  • 运行:任务正在占用CPU。
  • 阻塞/等待:任务在等某个事件,比如等一个信号量、等队列里的数据、等延时到期。此时它不消耗CPU。
  • 挂起:任务被主动暂停了,必须由别的任务或中断调用恢复接口才能继续。
  • 删除:任务的生命周期结束,内核对它进行清理。

这个状态模型是整个RTOS的灵魂。很多刚从裸机转过来的同学不适应"阻塞":明明是延时,以前就是空转等待,现在任务竟然"睡着"了?但这正是RTOS能够高效利用CPU的原因——一个任务阻塞、让出CPU,调度器立刻把其他就绪任务切进来跑,谁都不需要白等。你想想你写裸机时那个Delay函数,CPU就是在那里白烧时间,而RTOS里这段时间已经干完别的活了。

所以看一个RTOS系统忙不忙,不要只看CPU占用率,还要看有多少任务处于阻塞状态。一个设计良好的系统,除了就绪队列里排队的任务,大部分任务都阻塞着等待自己的触发条件。如果所有任务都一直就绪、抢着要CPU,那多半是任务拆分和阻塞点设计出了问题。

3. 调度器视角:多个任务是怎么"挤"在一颗CPU上的

3.1 调度器的核心逻辑:永远找"最高优先级就绪任务"

单核处理器同一时刻只能执行一个任务,但RTOS给你一种"多个任务同时在跑"的错觉。这全靠一个叫调度器的内核组件在背后不停做选择。

调度器做的事情,抽象出来就一句话:CPU空闲或发生切换时机时,遍历所有就绪任务,找出优先级最高的那个,切过去执行。

听起来简单,但"何时触发这个选择"是有讲究的。常见触发点有这几个:

  • 任务主动让出CPU(调用延时或等待某个资源)。
  • 任务从阻塞变成就绪(比如延时到期、信号量被释放)。
  • 外部中断打断了当前任务,中断处理完需要决定"继续跑原来的任务,还是抢给更高优先级的任务"。
  • 定时器节拍触发,做时间片的轮转。

在这些时机中,调度器都会运行一次"找最高优先级就绪任务"的逻辑,然后决定切换还是维持现状。正因为调度器非常频繁地运行,RTOS对一个任务能否"准点执行"才有了比较强的保证——不是靠大循环碰运气,而是靠调度算法和优先级模型来做的。

3.2 抢占、时间片与优先级反转的雏形

调度模式上,RTOS通常支持两类:

抢占式调度(Preemptive):一个高优先级任务进入就绪状态后,会立即打断正在运行的低优先级任务,强行"占据"CPU。这是大多数RTOS默认的模式,也是实时性最重要的来源。比如中断里把某个数据准备好,然后释放一个信号量,等待该信号量的高优先级任务会立刻抢到CPU开始处理,延迟非常小。

协作式调度(Cooperative):任务不会主动打断别人,必须当前任务主动让出CPU,其他任务才有机会运行。这种模式行为可预测,但实时性弱一些,一般在特殊场合使用。你可以把抢占式和协作式理解为:一个是插队式,一个是排队式——RTOS大多采用前者。

对于同优先级的多个任务,内核还提供了一个叫时间片轮转的机制:每个任务分配一小段CPU时间(比如1个系统节拍),用完后轮换给下一个同优先级任务。时间片尤其适合那些没有明确优先级差异的平级任务。

不过要注意,抢占式调度引出了一个经典问题:优先级反转。任务H(高优先级)在等任务L(低优先级)释放一把锁,而任务M(中等优先级)不需要那把锁,它就会抢走L的CPU,导致H等了很久还得不到锁。这个问题我在后面第5章重点展开,它几乎是每个RTOS项目都要经历的第一次"社会毒打"。

3.3 上下文切换:一场几百纳秒的"变脸"

任务调度中最核心、最考验性能的一步,是上下文切换(context switch)。所谓上下文,就是一个任务在某一时刻的全部状态:CPU寄存器、程序计数器PC、堆栈指针SP、各种状态寄存器。因为每个任务都以为自己独占CPU,所以每次换人上台,都得把上一任的全部状态保存下来,再把下一任的全部状态装上去。

这个过程你在C语言层面是看不到的,它藏在编译器和内核对接的那一小段汇编里。以常见MCU为例,上下文切换往往借助一个特殊的异常/中断(比如PendSV)完成:

  1. 当前任务被中断打断,硬件自动把一部分寄存器压入当前任务的栈。
  2. 软件再把剩下的寄存器手动压栈。
  3. 保存当前任务的栈指针到它的TCB。
  4. 从目标任务(下一个要运行的任务)的TCB取出新的栈指针。
  5. 恢复目标任务的寄存器现场。
  6. 跳转回目标任务被打断的位置,继续执行。

整个过程大约是几十到几百条指令,在这颗MCU上通常也就几微秒。看起来很短,但如果在高负载系统里切换非常频繁,这部分开销同样会吃掉不少CPU利用率。这也是为什么我经常跟人说:任务不要拆得过分碎,切换是有成本的。

每次切换期间,最关键的是要保证"不会切到一半再被切走"。所以内核会在上下文切换的核心代码段关闭中断,或者锁住任务链表,保证TCB的操作是原子性的。如果你在自己写特殊场景扩展逻辑时,不小心在关中断期间调用了会导致调度的接口,系统很可能直接卡死或跑飞——这是常见的低级但致命的错误,宁可多读几遍内核源码,也要搞清楚哪些代码是临界区。

4. 任务之间不是孤岛:队列、信号量、互斥锁怎么选

4.1 队列:任务间数据的"邮局"

任务之间要协作,就要传递信息、互相通知。很多新手第一反应是"直接用全局变量不就行了?"——在某些简单场景确实可以,但一旦设计不好,就会遇到两个麻烦:数据不同步(任务A写一半,任务B读到脏数据)和实时性差(任务B不知道数据什么时候来,只能轮询)。

队列(Queue)是RTOS中最常用的任务间数据传递机制。它的模型就是一个先进先出的缓冲区:一个任务往队列里放数据,另一个任务从队列里取数据。取数据的任务如果发现队列为空,可以选择阻塞在那里,等生产者放数据时再被唤醒,这就是"任务间同步"的自然实现。

我在实际项目里常这样用队列:

  • 串口中断把字节收进来,解析任务从队列取完整帧。
  • 按键任务把按键事件打包成结构体丢进队列,UI任务消费。
  • 传感器采集任务把原始数据放入队列,算法任务读取处理。

要注意几点。第一,队列放入数据通常是拷贝,也就是把结构体内容复制进队列内部的缓冲区,不是只传个指针就完事。所以你定义的队列元素大小,必须能装下你要传的最大的结构体。第二,队列长度的设定要看生产速率和消费速率的峰值差距,如果平均生产比消费快,队列总是满的,数据就会丢失。第三,跨任务只能放不可变数据或明确生命周期的指针,不要把队列当成共享内存来滥传指针,否则又是一个崩溃点。

4.2 信号量和互斥锁:控制"能不能动"而不是"传什么东西"

除了传数据,任务之间的关系更多是"控制权"的协调。这里有两个很容易混淆的工具:信号量和互斥锁。

二值信号量可以理解为一个"闹铃":一个任务(或中断)"给"信号量,相当于告诉另一个等待的任务"你可以动手了"。经典例子是中断里释放信号量,数据处理任务阻塞在信号量上,中断一响、信号量一give,任务立即被唤醒干活。二值信号量本身不携带数据,它只表达"发生了某件事"。

计数信号量是一个计数器,适合表达"剩余资源数量"。比如系统有5个缓冲区,任务取缓冲区时接收信号量(计数减1),用完了释放(计数加1),当计数值为0时新来的任务就得阻塞等待。这种场景就非常适合用计数信号量。

互斥锁则专门用来管理共享资源的独占访问。它的本质是"同一时刻只允许一个任务进入临界区",但它和信号量最大的区别在于:好的互斥锁自带优先级继承能力。什么意思?当一个低优先级任务持有锁,而高优先级任务在等这把锁时,内核会临时把持有者的优先级提升到和等待者一样高,让它尽快运行完、尽快释放锁,从而减轻优先级反转的影响。

我见过不少项目图省事,用二值信号量当互斥锁用,结果在复杂系统中出现优先级反转,熬夜排查。保护共享资源请老老实实用互斥锁,二值信号量拿来做事件通知就好。

4.3 事件标志组:等"多个条件齐了"怎么办

队列和信号量处理的是"等一个条件",但有些任务必须等好几个条件同时满足才行动。比如系统启动任务,要等电源稳定、传感器初始化完成、通信模块就绪,三个标志都置位了才能开始跑主逻辑。

这种场景可以用事件标志组:它本质是一组bit位,不同的bit代表不同事件。任务可以配置"等所有bit都置位"或"等任意一个bit置位",事件到来时内核会把任务唤醒。用完之后记得手动清除标志位,否则下次等待会"秒醒",造成逻辑混乱。

这四个机制怎么选,我常用一个很土的判断标准:要传具体数据,用队列;只通知"可以干活了",用二值信号量;数资源的个数,用计数信号量;保护一段共享代码不被同时进,用互斥锁;要等一组条件,用事件标志组。

5. 三个经典翻车现场:栈溢出、优先级反转、任务饿死

5.1 栈溢出:系统随机死机的最常见元凶

RTOS项目的"灵异事件",十有八九是栈溢出。现象五花八门:任务跑一段时间后无征兆死机、随机复位、某个函数返回值突然变了、内存里的数据被莫名覆盖。

原因很简单:任务函数调用链太深,或者函数里定义了超大的局部数组、临时结构体,超出任务栈的容量,数据一路踩到相邻内存。而相邻内存很可能就是另一个任务的栈或TCB,于是系统被慢慢搞垮。

我记得有次排查一个串口透传模块的偶发死机,用示波器看不出任何规律。后来开了任务栈高水位检测,一看那个任务峰值栈占用已经到98%,而当时栈只分了256字——它内部有个缓冲区加好几个结构体,峰值早已爆掉。后来把栈加到512字,问题再没出现。

排查栈溢出的手段不少:

  • 用内核自带的栈溢出检测钩子(在任务切换时检查栈指针是否越界)。
  • 周期性调用任务统计函数,查看每个任务的栈最大使用量。
  • 用调试器查看栈区域的填充数据是否被破坏。
  • 如果你要新加大数组,记得先估算调用深度,再给栈留出安全余量。

通用经验是:栈宁可给大一点,不要逼自己去省那几十字节。一个任务256字可能紧张,512字也没多少钱,在128KB内存的MCU上绰绰有余,但你省出来的代价可能是未来几周的崩溃排查。

5.2 优先级反转:高优先级任务怎么被"拉低智商"

优先级反转这个问题,RTOS的实时性承诺差点因此沦为纸上谈兵。再描述一遍经典场景:

  • 任务H,高优先级,负责实时采集和控制。
  • 任务M,中等优先级,负责日志输出。
  • 任务L,低优先级,持有某个互斥锁(共享的I2C总线)。
  • 任务H要上I2C,必须等L释放锁;而M不关心锁,因为它的优先级比L高,M一就绪就抢占L。于是L迟迟跑不完,释放不了锁,H就一直等着。

最终结果:H这个最高优先级任务,却被M这个中等优先级任务活活卡住,实时性完全崩掉。这在动态优先级调整缺失的RTOS里非常要命。

解决手段,第一优先是使用带优先级继承的互斥锁:L因为被H等待,临时被系统提升到H的优先级,M再也抢不过它,L快速跑完、释放锁,H立刻接管。第二,从设计上尽量减少高优先级任务和低优先级任务共享长时间持有的资源。第三,中断里绝不要等锁,否则死锁风险成倍增加。

5.3 任务饿死:低优先级任务永远轮不到CPU

和优先级反转类似的另一个问题叫任务饿死:某个低优先级任务永远等不到运行机会,因为总有高优先级任务在就绪状态。

比如一个采样任务优先级设为最高,又在里面做了个忙等循环或者大量短延时,导致CPU时间几乎被它一个人包了,后面排队的任务每个时间片都轮不到。这种情况在抢占式调度下很常见。

应对的核心思路只有一个:高优先级任务必须"短小快"——快速处理最关键的部分,然后立刻阻塞等待下一个触发事件,把CPU让出来。如果你有个高优先级任务需要做很重的运算,请把重活拆到低优先级任务去做,高优先级只做"决定"和"启动"。低优先级任务想保证不死,要确保高优先级任务有足够的阻塞时间,必要时降低高优先级任务的频率。

我曾经接受一个类似的烂摊子项目,对方说系统"偶尔卡顿",查下来是一个高优先级任务每毫秒都在跑大数组的CRC计算,吃掉70%CPU,其他任务全在饿死边缘。把他的重计算挪到低优先级任务,系统立刻活了。

6. 实战建议:新手设计任务时,记住这几条就够了

6.1 任务拆分的黄金法则:按"事件源"切,而不是按"代码功能"切

真正拿到一个项目,怎么把功能拆成任务?我的经验是:任务是围绕"事件等待点"拆的,不是围绕"函数模块"拆的。每个任务在代码里至少要有一个明确的阻塞点(等待队列、信号量、延时)。如果你设计的"任务"从头到尾没有任何阻塞点,只是一个大循环轮询若干标志位,那它其实还是裸机思维,RTOS的优势发挥不出来。

一个比较稳妥的拆分模板:

  • 事件型任务:按键输入、串口接收、网络消息——用队列/信号量触发。
  • 周期型任务:传感器采集(10ms)、界面刷新(100ms)、状态上报(1s)——用延时/周期等待。
  • 协作型任务:等待若干条件同时满足(多外设初始化完成、系统状态到位)——用事件标志组。

拿一个常见的温控器项目举例:按键扫描任务(等待按键事件)、温度采集任务(每50ms读一次传感器)、控制算法任务(按温度数据PID计算输出)、显示任务(每100ms刷新屏幕)、通信任务(等待上位机命令)。每个任务都有明确的等待点,互不干扰,优先级从高到低依次是控制算法 > 温度采集 > 按键 > 通信 > 显示。整个系统就非常清晰。

6.2 任务数量、优先级和栈大小的参考表

给新手一组我常用的起始配置参考:

参数建议值说明
任务数量3~10个超过10个任务维护成本急剧上升,先问自己是否每个都必要
优先级层级自己业务内2~4级优先级太多等于没有优先级,调度混乱
栈大小256字起步有较大局部数组的任务加到512字甚至更多,用高水位监测修订
时间片默认即可(1~5个tick)同优先级任务多时可适当加大减少切换开销
队列长度按最坏突发量+余量例如上位机一次连发10帧,队列至少深度12以上

优先级这里要注意不同内核的定义方向不同,有的数值越小优先级越高,有的反过来,使用前务必看书。我自己习惯定优先级时先列一个"重要性清单",再做映射,而不是边写边拍优先级。

6.3 调试任务的实用技巧:从"能跑"到"跑得稳"

最后分享几个我调试RTOS任务的土办法:

第一,先跑最小系统再上业务代码。刚移植完内核,先建一个LED任务,让它以自己的优先级和延时正常闪烁。这一步能确认内核时钟、任务切换、延时调度都正常,再逐步加任务。否则一上来就几十个任务,出了问题根本分不清是内核还是业务。

第二,利用内核的任务统计能力。很多内核提供运行时的任务统计,可以打印每个任务的CPU占用率、运行次数、栈最大使用量。调优阶段我几乎必开,一天跑下来看看数据,比自己猜靠谱得多。

第三,GPIO翻转测实时性。在关键任务的开头把某个引脚翻一下电平,用示波器测两个翻转沿的时间间隔,就能判断这个任务实际执行周期是否稳定、有没有被别的任务拖住。这个方法比打日志更直观,也不影响系统实时性。

第四,错误钩子必须接。内核通常提供栈溢出钩子、错误断言钩子,把这些钩子函数接到一个简单的死循环灯或状态寄存器上。产品在现场偶发故障时,这些钩子才是第一手线索,比你事后盲猜强太多。


说实话,搞了这么多年嵌入式,我觉得RTOS的一大堆API和机制里,最核心也最容易被低估的概念还是"任务"。很多同学一上来就研究调度算法复杂性、优化上下文切换时间,倒不如先老老实实把任务想明白——它有状态、有栈、有生命周期,它和函数本质不同,它是被调度器管着的"活"的执行流。我后来带人写RTOS代码,都先让新人画一张任务状态图,把所有任务、优先级、阻塞点、通信关系画在纸上,画清楚再动手写。画不清楚,代码写出来大概率也是一团乱麻。任务这个东西一旦通了,后面再深入消息队列、内存管理、中断嵌套这些,就都是顺理成章的事了。

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

5 分钟省掉 3 小时手搓:戴森球计划工厂蓝图仓库使用指南

5 分钟省掉 3 小时手搓:戴森球计划工厂蓝图仓库使用指南 【免费下载链接】FactoryBluePrints 游戏戴森球计划的**工厂**蓝图仓库 项目地址: https://gitcode.com/GitHub_Trending/fa/FactoryBluePrints FactoryBluePrints 是《戴森球计划》工厂蓝图仓库&…

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

Android权限革命:Shizuku技术终极指南与完整教程

Android权限革命:Shizuku技术终极指南与完整教程 在Android生态系统中,Shizuku技术正在掀起一场真正的权限革命!🎯 这项创新技术允许普通应用程序在非root设备上直接使用系统API,通过ADB获得提升的权限,彻底…

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

Linux root 密码遗忘怎么办?rd.break 救援模式重置全流程

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

作者头像 李华