news 2026/10/21 1:18:28

FreeRTOS重新学习

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FreeRTOS重新学习

2025/5/4重启韦东山老师RTOS

1、重新复习韦东山老师:韦东山freeRTOS快速入门视频教程。

链接:

01_单片机_RTOS_架构的概念_哔哩哔哩_bilibili

韦东山老师此教程:视频基于Keil自带的STM32F103模拟器,可以让你0硬件成本掌握FreeRTOS的用法。这个教程B站只有一部分内容,剩余的需要拼团在老师自己的网站观看。


2、接着复习:

链接:FreeRTOS入门与工程实践 --由浅入深带你学习FreeRTOS(FreeRTOS教程 基于STM32,以实际项目为导向

这个教程配套有硬件,结合硬件让你工程实践(在工作中如何使用FreeRTOS来解决实际问题)。

​

此篇笔记(FreeRTOS重新学习)在上一篇(freeRTOS学习)的基础上补充更多细节,结合硬件调试结果,方便以后复习。

章节目录与老师教程对齐。

1.0单片机_RTOS_架构的概念

对于整个单片机程序,我们称之为application,应用程序。
用FreeRTOS时,我们可以在application中创建多个任务(task),有些文档把任务也称为线程 (thread)。

如下图一个经典单片机程序:
​
他的缺点可以用下图形象表达出来:这两件事互有影响,一个事情发生的时间过长,另一个就要过好久才能进行。
​
怎么办呢?
对于单片机程序而言,把这喂饭和回消息的任务,各自拆分成五个时间段,任务1 T1~5之后进行任务2Ta~e,这两个任务循环执行。

​
而对于RTOS来说,如梦函数当中创建一个为喂饭的任务,创建一个回消息的任务,使用start_scheduler()这个函数来进行调度,进行如左图所示的交叉执行。(只要切换的足够快就可以看作他们两个在交替执行,而不是像上面裸机一样循环执行。)
​​

2.1堆的概念

● 堆,heap,就是一块空闲的内存,需要提供管理函数
๐ malloc:从堆里划出一块空间给程序使用
๐ free:用完后,再把它标记为"空闲"的,可以再次使用
● 栈,stack,函数调用时局部变量保存在栈中,当前程序的环境也是保存在栈中
๐ 可以从堆中分配一块空间用作栈
​​

在此程序的基础上做个简单测试。(进入调试界面注意要进入的是模拟调试,需要在魔术棒的DE bug那里更改两个配置,不然运行后会卡死到一个时钟那里,DRAMSTM.DLL,-pSTM32F103RC)

​
​
​
我们这里编写了一个my_malloc()函数它其实是模拟的自带的malloc()函数。以便我们更加清晰地学习了解它的内部机制。
如上图调试界面所示根据三个步骤可看出heap_buf[],这个的原始地址是0x20000004。
当调试到下面这一步的时候*buf = my_malloc(100);分配了100个内存,但是要注意的是只运行了一次所以my_malloc()函数当中的原始值为0最终返回的地址也就是返回的heap_buf[0],也就是首地址,所以循环第一次可以发现定义的buf 这个,是跟它的原始地址相同。
​
多运行几次这个for循环在左下角buf的调试界面可以发现首地址底下开始依次写入数据与右边的相对应。这个实验就成功了。
​

2.2栈的概念

● 堆,heap,就是一块空闲的内存,需要提供管理函数
๐ malloc:从堆里划出一块空间给程序使用
๐ free:用完后,再把它标记为"空闲"的,可以再次使用
● 栈,stack,函数调用时局部变量保存在栈中,当前程序的环境也是保存在栈中
๐ 可以从堆中分配一块空间用作栈
​​

如下代码中,在main中我要调a,我就得首先记录下a的返回地址,所以第①步他把a的返回值保存在某个寄存器当中,LR寄存器。第②步就是调用a。
接着在函数a里边需要调用函数b,那么第①步就是要记录下b的返回地址到LR寄存器,不然在b执行完之后不知道跳到哪里去。第②步就是调用b。(!也就是说,在调用函数之前,会将该函数的下一条语句的地址保存下来,等到函数执行完毕,编译器再回到之前保存的地址)
从下面的图中可以看出如果不对这个LR作出处理那么这个返回值就会被覆盖。要怎么做避免呢?就是在调用a函数的时候将LR寄存器中的值保存到栈中,然后把LR空出来,然后再在调用b函数的时候再把LR的值保存到栈中,再把LR空出来。
​​
这样两个问题就得以解决第①个问题返回地址保存在哪里:保存到栈当中。
第②个问题:使用main
在使用main函数之前他要设置这个SP栈寄存器,让它指向一个空闲的内存。
​
①BL main

第一,将返回地址存在LR中

第二,执行main函数
main函数会划分出自己的栈,假设他划分出SP=sp-N,这里面会保存lr等寄存器,会保存局部变量。
②执行a函数

第一 ,a的返回地址存在LR。

第二,执行函数a,函数a执行的事情和main类似,划分出SP=sp-m的栈,在这一块空间里面会放lr的值以及a的局部变量。

③调用到函数b
操作同上两步差不多。在执行b的时候,他会将返回值付给lr,也就是c(下一句代码的位置)。划分出SP=sp-p的栈,在这一块空间里面会放lr的值以及b的局部变量,然后执行b函数 。

④当b函数执行完之后,注意是执行完之后,他会返回到哪里?:他会去SP=sp-p这里把LR取出来然后跳过去执行。

总结:也就是几个步骤。
先执行这个函数的时第1个是将它的返回地址,也就是下一句代码的地址保存到一个叫LR的寄存器当中,然后第2步函数他会划分出一块栈出来将之前LR寄存器当中的值又保存到栈中去,然后再叫一些他自己函数的局部变量保存到他自己的栈中。然后函数执行完之后第3步他会从他自己划分的栈中取出之前保存的lr寄存器的值,然后返回到这个地址。

从这里就候是怎么栈在保存返回地址的时候时候是怎么起作用的。
同时也体现出划分内存来保存局部变量的值。赋值是由那些指令语句完成的,但是这些值的地址是由栈来划分的,值由栈保存的(1分配内存2设置初始值3更新它的值)。

在RTOS里边栈是非常重要的,每个任务都有它自己的栈!
我们有两个任务(所谓任务你可以理解为就是函数)。
比如下图Task_A 和Task_B,Task_A里边有a,b,c,Task_B里面有f1,2,f3 局部变量,你在使用RTOS的时候创建任务你就必须给他们两个任务都分别分配独立栈,这是非常重要的。

3.1从官方源码精简出第1个FreeRTOS程序

这里是精简,所以有的东西删不删无所谓我们为的是使代码更加精简清爽。

如下图所示的25个源码都是老师根据官方源码修改而来做测试调试使用,跟老师所配对的查快速入门教程文档相对应。

官方库可以通过FreeRTOS官网进行下载下载有实例和没有实例的源文件,但是新改版后的网站找不到在哪儿下载先用旧的吧。进入02视频配套的代码当中找到从官网下载的源码,把它解压到一个没有中文路径的文件夹当中。

下面进行一些精简裁剪删除无用和不需要的东西,

打开一个文件进去看一看它的目录结构整体应该如下图所示:

下面就可以根据这个进行精简。首先删除第二个文件夹Freertos-Plus,然后删除第三个tools它是亚马逊相关的东西无用直接删掉。接下来在第一个文件夹里边找到demo,它里边有很多的示例,但是我们要学习的是cortex M3 STMF103的,所以找到他把剩余的全部删掉。如图只保留这两项。

打开资源文件夹如图框选的就是Freertos的核心文件。portable是移植RTOS的时候所涉及的文件。portable只保留两个文件其余的先删掉,同时进入portable的RVDS文件夹只保留cortex M3其余的架构都删掉因为我们使用的是STM F32F103。


接下来打开这个工程在这个路径下点开keIL因为它是用keIL4编写的所以进入页面之后需要把它转换为keIL5。


转化完之后关闭,重新打开。


编译没有错误没有警告。
我们此次学习是基于软件自带的模拟器来调试的所以需要对官方自带的示例进行进一步剪裁删除它的LED等等。

3.2修改官方源码增加串口打印

在03的基础上进行删减修改。04_freertos_example_base_uart

Source Insight新建工程

打开Source Insight新建工程在这个文件里面进行代码编辑方便一些这个软件好用,在project里边new project然后输入起一个新的文件名(04_freertos_example_base_uart),输入要编辑的文件的路径(D:\1.2FreeRtos_Study_2025.5.4start\04_freertos_example_base_uart)然后一路OK到下面图。等底下空白project file里面出现东西后点击close关闭。Show only known file types这个记得不要勾选下图中勾选了要去掉不然到时候找文件不好找。

然后在project里边点2同步一下,

打开ke IL工程将无用的文件删掉,

打开工程将除红框内的两个文件之外的所有文件都删掉因为是测试所以不需要那么多的冗余文件。将system里边LCD等代码也删掉。

然后编译报bug许多,(注意以下是老师的教学方法通过一步一步的细致操作来教你解决报错怎么修改,会比较繁琐。)

开始改错:

1:滑到报错最上端发现有一个vUARTInterruptHandler:

这是因为有一个函数没有被使用我们需要找到他在哪里,

通过.S文件找到它的汇编文件然后在里边找这个函数然后如下图所示一个删掉一个注释掉虽然我也不知道为什么()。


重新编译发现这个bug消失。这个是我们刚才删掉的文件中一个定义过的函数在底下串口修改中也会出现类似的错误需要用同样的方法进行修。(vUARTInterruptHandler)

2:有一个LCD clean函数我们需要搜索一下谁使用了它。

如下图搜索LCD.C文件


在LCD.C中找到clear


双击这个函数可以用快捷键ctrl加/也可以右键选择它查找引用


可以看到main.C函数中有两个地方用到了,我们找过去把它删掉。

将鼠标放在它左边的那两个小箭头上可以看到有一段话写的是点击这个可以返回到这个东西的主链接,


然后把它删掉注意把他的声明也要通过查找也删掉。同时把它底下那个LCD task也删掉注意查找全部把含有他的都删掉。

如下所示在Windows里边点这个可以切换刚才的页面


我们切回去然后查找fputc in
然后我们发现这个里边是通过屏幕将它打印出来,但是我们一会儿使用的是模拟串口,所以把它里边的内容全部删掉一会儿重新写。


现在重新编译还有18个错误
3:接下来解决这个

通过按1全局搜索或者按快捷键ctrl shift+F搜索这个关键词,会出现如图所示的结果然后点击2它就会跳转到刚才和上面一样的查找引用的地方。


注意勾选这三个东西不然查不到

如图所示搜索到如下:

如下图所述找到他在这个任务中出现过我们不需要把整个任务完全删除,

最后删的main函数当中只有一个硬件设置prvSetupHardware();的还有底下的这个函数只实现先使用窗口打印的功能

就只有一个错误了


找到那个警告把它删掉

然后全局搜索那个错误查找引用


其实这么多文件只有main函数里边那个删掉就行,其余的已经从工程中移除了(最开始的精简文件)只不过Source Insight添加了整个工程keil里边移除的文件也有。之后再次编译如下图所示编译成功。

实现串口代码修改:

接下来,我们需要实现printf打印hello world,Printf函数是库里边封装好的,它里边用到了一个fputc函数。我们要重新写这个putc函数,将第一个参数"ch"从串口中打印出来,我们要做两件事,第一个是初始化这个串口,第二个是实现putc函数。

对这个串口初始化函数进行更改。原来是如下所示:

更改只需要初始化功能然后删除掉它的创建任务的代码.,他的函数类型进行更改为void。他的判断都更改掉去掉。

将他的中断以及return等都删掉,没有用到,只是需要让它初始化就行以下是改造后的代码,

之后删除其他的函数。只保留这个初始化函数就可以。现在串口C文件中只有这两个函数①void SerialPortInit(void) ②int fputc( int ch, FILE *f ),

还需要对serial.H文件进行一些修改声明并且删除其他没用的东西。
如下标蓝部分:

只将重新修改过的这个加进来:

然后再在main函数中将串口初始化加到硬件配置的函数中:


然后将main函数中的put C函数移到serial串口函数中实现,
以下是重新写的put C函数:重新定义一个结构体变量串口,将字符数据Ch写入DR寄存器。还有判断数据有没有写入,查找它的状态寄存器对它进行操作。
一开始的时候状态寄存器USART_SR它的值应该是1,表明它已经发送完了,所以while循环放在数据写入之前(这样发送完数据就可以干别的事情了不用死等他判断是否将数据发送完。),会使代码效率更高一些。USART_SR它的是1,就表面=明可以将新的数据放入数据寄存器DR当中。




注意加#include <stdio.h>文件放入serial.C文件中因为你将main函数中的put C函数移到了串口函数中,int fputc( int ch, FILE *f )中的这个FILE 会报错。
编译后有一个报错有一个中断是错的没有用到需要删除:

同时把它写为0。

至此没有错误可以借用模拟器调试了。

模拟器调试:

右下角窗口中就打印出来Hello world了。


所以RT OS主要就是以下这几个文件学会它就OK:

4.1自己的第1个FreeRTOS程序

在04的基础上进行删减修改。05_freertos_example_createtask

Source Insight新建工程

然后在project里边点2同步一下,

source 功能提示

如下步骤可以在最下面的窗口看到函数的各个参数,方便进行调试编写代码,只需要在使用的时候点击函数名字就可以,比如下面的这个xTaskCreate。

4.1.1 创建任务:

使用的函数如下:

BaseType_t xTaskCreate( TaskFunction_t pxTaskCode, // 函数指针, 任务函数 const char * const pcName, // 任务的名字 const configSTACK_DEPTH_TYPE usStackDepth, // 栈大小, 单位为word,10表示40字节 void * const pvParameters, // 调用任务函数时传入的参数 UBaseType_t uxPriority, // 优先级 TaskHandle_t * const pxCreatedTask ); // 任务句柄, 以后使用它来操作这个任务

参数解释:

参数描述
pvTaskCode

函数指针,可以简单地认为任务就是一个C函数。

它稍微特殊一点:永远不退出,或者退出时要调用"vTaskDelete(NULL)"

pcName

任务的名字,FreeRTOS内部不使用它,仅仅起调试作用。

长度为:configMAX_TASK_NAME_LEN

usStackDepth

每个任务都有自己的栈,这里指定栈大小。

单位是word,比如传入100,表示栈大小为100 word,也就是400字节。

最大值为uint16_t的最大值。

怎么确定栈的大小,并不容易,很多时候是估计。

精确的办法是看反汇编码

pvParameters调用pvTaskCode函数指针时用到:pvTaskCode(pvParameters)
uxPriority

优先级范围:0~(configMAX_PRIORITIES – 1)

数值越小优先级越低, 如果传入过大的值,xTaskCreate会把它调整为(configMAX_PRIORITIES – 1)

pxCreatedTask

用来保存xTaskCreate的输出结果:task handle。

以后如果想操作这个任务,比如修改它的优先级,就需要这个handle。

如果不想使用该handle,可以传入NULL。

返回

成功:pdPASS;

失败:errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY(失败原因只有内存 不足)

注意:文档里都说失败时返回值是pdFAIL,这不对。

pdFAIL是0,errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY是-1。

例1:

创建一个任务1,

第一个参数的函数如下:

注意最后一个参数句柄,要用去&,main函数一开始处已经定义它了,是这个结构体类型的。

模拟器调试1:

右下角窗口中就打印出来Hello world和11111111了。

例2:

添加一个任2:给同样的优先级但是不给句柄。

模拟器调试2:

我们以上两个任务是用同一个串口进行打印,可以看出他们在交叉打印。

4.2源码结构与编程规范

4.2.1数据类型

每个移植的版本都含有自己的 portmacro.h 头文件,里面定义了2个数据类

● TickType_t:
๐ FreeRTOS配置了一个周期性的时钟中断:Tick Interrupt

๐ 每发生一次中断,中断次数累加,这被称为tick count

๐ tick count这个变量的类型就是TickType_t

๐ TickType_t可以是16位的,也可以是32位的

๐ FreeRTOSConfig.h中定义configUSE_16_BIT_TICKS时,TickType_t就是uint16_t

๐ 否则TickType_t就是uint32_t

๐ 对于32位架构,建议把TickType_t配置为uint32_t

● BaseType_t:

๐ 这是该架构最高效的数据类型

๐ 32位架构中,它就是uint32_t

๐ 16位架构中,它就是uint16_t

๐ 8位架构中,它就是uint8_t

๐ BaseType_t通常用作简单的返回值的类型,还有逻辑值,比如 pdTRUE/pdFALSE

4.2.2变量名

变量名有前缀:

变量名前缀含义
cchar
sint16_t,short
lint32_t,long
x

BaseType_t,

其他非标准的类型:结构体、task handle、queue handle等

uunsigned
p指针
ucuint8_t,unsigned char
pcchar指针

4.2.3函数名

数名的前缀有2部分:返回值类型、在哪个文件定义。

函数名前缀含义
vTaskPrioritySet

返回值类型:void

在task.c中定义

xQueueReceive

返回值类型:BaseType_t

在queue.c中定义

pvTimerGetTimerID

返回值类型:pointer to void

在tmer.c中定义

4.2.4宏的名

宏的名字是大小,可以添加小写的前缀。前缀是用来表示:宏在哪个文件中定义。

宏的前缀含义:在哪个文件里定义
port (比如portMAX_DELAY)portable.h或portmacro.h
task (比如taskENTER_CRITICAL())task.h
pd (比如pdTRUE)projdefs.h
config (比如configUSE_PREEMPTION)FreeRTOSConfig.h
err (比如errQUEUE_FULL)projdefs.h

通用的宏定义如下:

宏值
pdTRUE1
pdFALSE0
pdPASS1
pdFAIL0

5.1动态_静态创建任务

在05的基础上进行删减修改。06_freertos_example_createst static

之前是动态的接下来创建一个静态的任务。静态适合任务数固定,更具体

打印3

这个静态任务在task.C中被定义,注意它使用的前提条件需要进行一些配置(对于嵌入式系统来说,内存资源很有限对于一些没有用的函数我们可以加开关来选择是否使用它):

如下图所示在这个文件中定义它。

但是会产生一个新的报错:

ctrl+shift+F全局查找vApplicationGetIdleTaskMemory。如下:

因为在前面加了一个定义所以也把这个开关打开了,


这个是空闲任务的内存分配, 因为FREERTOS要CPU不停运转 当你创建的任务都停止时,空闲任务运转。所以根据老师的课程添加一些其他代码在main的上面,先不用管。

模拟器调试:

如下所示三个任务轮换打印:


至此:通过第四节的第五节我们可以创建任务,使用动态以及静态,静态呢就是你得事先准备好TCP结构体就是定义这两个东西。

5.2进一步实验

在06的基础上进行删减修改。07_freertos_example_test

优先级实验

修改代码:

模拟器调试:

必须先让它运行到main函数这里的时候然后我们就可以将刚才设置的变量放入逻辑分析仪观察。


如下:

单击右键选择Bit:

重新开始全速运行,出现波形后暂停运行,滚轮放大缩小观看波形。可以看出他们是交叉运行的,所以同优先级他们是交叉运行的。

将任务一的优先级改为2,其余两个不变,再次调试,发现只有任务一写1,了其余两个都没有打印2和3,说明在Rtos当中如果高优先级的任务没有主动放弃执行的话,那么其余的优先级的任务是无法执行的。


删除任务

任务最后一个参数有个句柄,如果想引用它删除它必须要有这个句柄。

模拟器调试:

在任务2中添加一个判断代码将i加到一百的时候把任务一杀掉,使用vTaskDelete这个函数,当i等于两百的时候,把自己杀掉自杀使用这个函数,杀掉别人传入的是别人的句柄,杀掉自己传入null。程序运行结果如右下角所示到最后面不打印一和二了只有三了因为一跟二先是被杀后是自杀。

需要注意的是对于静态函数也可以使用同样的函数杀掉他,但是注意传入的句柄需要提前接收,因为它的返回值是一个句柄如有下图所示:

创建多个任务

还有一个实验是,同一个同一个函数创建多个任务,
可以打印出四和五来为什么可以这样呢因为执行任务时他们分配的栈是不同的。

这些局部变量保存在栈当中他们的站是不一样的所以可以打印出都属于每一个任务的值也就是参数来。它们运行的时候互不影响。

栈大小实验

理论讲解:我们使用malloc()函数去分配长度Len字节的内存空间之后,如果要释放掉它需要使用free函数去释放掉这个buffer,但是如何知道这个分配了内存空间的长度到底是多少呢,这个Len是多少呢?其实他在分配的时候先会在前面分配一个头部,然后再分配一个长度,但是返回的地址是Len,也就是长度前这个地址,在使用free释放的时候它会往前倒推找到这个头部获取它的长度信息等等。

现在做一个实验任务一分配了100乘以4等于400个字节的空间。但是在函数1当中定义了一个五百字节的数组,然后让它分配数据,结果运行的结果就是程序崩了,所以所以一定要注意栈的分配使用。


程序崩溃的原因就是因为:

栈是从高地址往下增长的?你申请一块内存之后它会分配一个头部以及在头部后分配一块儿Len长度的内存,如果从上往下增长你大量使用局部变量后会破坏掉头部等信息。
为什么要这样设计呢简单了解即可主要是要知道要合理分配栈大小。

6.1任务状态理论讲解(任务管理)

1. 任务状态理论

Tick ISR(时钟中断)它的时钟周期就是一毫秒,这个时钟周期是可以在源文件中进行配置的更改的。如下图FreeRTOSConfig.h所示它配置为1000HZ也就是一毫秒。不同的操作系统这个东西可以切换站freertos中它就是规定的1ms

之前的那个波形图放大可以看到几乎是一毫秒(printf一次大约100us)切换一次,这是他的时间基准一毫秒产生一个中断,任务进行轮换。(tick Count)

之前的那个波形图为什么是任务三先运行先打印呢?
(因为创建任务的函数里,有设置当前任务为该任务,而任务三最后写的, 覆盖掉了之前的设置)


任务状态

running

Ready

Blocked阻塞状态--vTaskDelay()函数也是阻塞效果!!!

Suspended暂停状态(挂起)

怎样管理不同状态的任务::看视频太多了理解就这一节目录的视频。

6.2任务状态实验

在07的基础上进行删减修改。08_freertos_example_test_status

调试的时候注意在魔术棒里边更改时间赫兹为8兆赫兹不然到时候延时会发现对不上,之前里边应该是12。

开始调试:

运行结果如下所示,



通过下面这个函数可以获取当前运行的时间它是一个时间基准的计数,

vTaskDelay(10);这个函数代表他主动进入阻塞状态,等待等待某个时刻的到来。在这里就是当前时间加上十个tick。

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

STM32+ESP8266腾讯云IoT OTA升级实战:固件校验、断点续传与双Bank切换

简介&#xff1a;本资源是一套基于STM32与ESP8266协同实现腾讯云物联网平台OTA远程固件升级的完整嵌入式开发方案&#xff0c;面向物联网初学者、单片机开发者及嵌入式工程师&#xff0c;解决传统设备需手动烧录、维护成本高的痛点。压缩包共288个文件&#xff0c;含54个C源码&…

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

MATLAB光伏并网仿真:MPPT+LCL+PLL三合一实测模型

简介&#xff1a;本资源是一套完整的光伏并网系统仿真教学方案&#xff0c;面向电子信息工程、自动化、新能源科学与工程等专业的本科生及研究生&#xff0c;助力掌握分布式电源并网建模、MPPT控制、逆变器协同与电网交互等核心知识点。压缩包共含源码、仿真模型、实测/合成数据…

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

从御剑到dirsearch:Web目录扫描工具完整实战指南

1. 为什么从御剑转向dirsearch&#xff1a;一个老牌工具的优缺点盘点1.1 御剑的先天优势与尴尬现状御剑在早期是Windows平台上很多入门者最先接触的目录扫描工具&#xff0c;GUI界面简洁、双击即用&#xff0c;预置字典丢进去就能开扫&#xff0c;确实帮一代人完成了启蒙。但用…

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

我谈拉普拉斯模板——MATLAB的拉普拉斯参数是如何来的?

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

作者头像 李华