news 2026/10/28 18:28:07

STM32F1入门实战:DHT11驱动与底层逻辑详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F1入门实战:DHT11驱动与底层逻辑详解

1. 为什么STM32F1至今仍是入门嵌入式的首选

如果你在电子论坛或者技术群里问一句“新手学单片机选什么”,十个人里至少有六个会告诉你从STM32F1系列开始。这个答案放在十年前成立,放在今天依然成立,而且理由比当年更充分。STM32F1是意法半导体基于ARM Cortex-M3内核推出的32位微控制器家族,最经典的型号如STM32F103C8T6,也就是大家常说的“蓝板”或“最小系统板”,几十块钱就能买到一块,配套的教程、例程、开源项目多到看不完。它解决的核心问题是:让没有太多嵌入式基础的人,能够用较低的成本和较短的时间,理解32位MCU的完整开发流程——从时钟配置、GPIO操作、中断管理,到定时器、串口、ADC、I2C、SPI等外设的驱动编写。

我之所以在2025年还愿意花时间写这篇东西,是因为最近帮几个朋友处理STM32F1相关的项目时发现,网上资料虽然多,但要么太老(还在用标准外设库),要么太碎(只讲某个外设的配置代码,不讲为什么这样配),要么直接跳到HAL库但没讲清楚底层逻辑。更麻烦的是,很多人拿到DHT11温湿度传感器这类常见模块后,不知道该怎么和STM32F1配合使用,要么时序调不通,要么读出来的数据全是0或者255。所以这篇内容我会围绕STM32F1这个平台,把开发环境搭建、时钟系统理解、GPIO与定时器配合、DHT11单总线驱动实现、常见调试手段这几个核心环节串起来讲,既有原理层面的解释,也有可以直接复现的代码和操作步骤。适合刚接触STM32F1的初学者,也适合已经用过但想重新梳理底层逻辑的开发者。

2. 开发环境选型与工程模板搭建

2.1 标准库、HAL库与LL库的选择逻辑

STM32F1系列的开发方式主要有三种:标准外设库(Standard Peripheral Library)、HAL库(Hardware Abstraction Layer)和LL库(Low Layer)。标准库是ST早期推出的,直接操作寄存器,代码效率高,资料最丰富,但ST已经停止维护。HAL库是现在ST主推的,跨系列兼容性好,配合STM32CubeMX可以快速生成初始化代码,但代码体积大,执行效率相对低。LL库可以看作是HAL库的底层补充,直接操作寄存器,效率接近标准库,但API没有标准库那么直观。

我的建议是:如果你刚入门,先用标准库把STM32F1的基本外设跑一遍,理解寄存器的操作逻辑;等熟悉之后,再转到HAL库配合CubeMX做项目,提高开发效率。对于DHT11这种对时序要求比较严格的单总线设备,标准库或者LL库反而更容易调通,因为你可以精确控制延时。下面我以标准库为例,讲一下工程模板的搭建。

2.2 手动搭建标准库工程的关键步骤

很多人用Keil MDK新建工程时,直接选STM32F103C8器件,然后添加启动文件就完事了,结果编译报一堆错。问题出在缺少库文件和头文件路径配置。正确的做法是:

  1. 从ST官网下载STM32F10x标准外设库(版本3.5.0),解压后找到Libraries文件夹,里面包含CMSIS和STM32F10x_StdPeriph_Driver两个目录。
  2. 在工程目录下新建Startup、Library、User、CMSIS四个文件夹。把启动文件startup_stm32f10x_md.s(根据芯片容量选择,C8T6是中容量)放到Startup;把stm32f10x.h、system_stm32f10x.c/h放到CMSIS;把inc和src里的外设驱动文件放到Library;自己的main.c、stm32f10x_conf.h、stm32f10x_it.c/h放到User。
  3. 在Keil的Options for Target里,C/C++选项卡的Define栏填入USE_STDPERIPH_DRIVER, STM32F10X_MD,Include Paths里添加上述四个文件夹的路径。
  4. 在stm32f10x_conf.h里取消需要的外设头文件注释,比如#include "stm32f10x_gpio.h"、#include "stm32f10x_rcc.h"、#include "stm32f10x_tim.h"等。

注意:启动文件的选择非常关键。STM32F103C8T6的Flash是64KB,属于中容量产品,必须用startup_stm32f10x_md.s。如果错选成hd(大容量)或ld(小容量),程序可能跑不起来或者中断向量表错位。

2.3 用CubeMX生成HAL库工程的注意事项

如果你决定用HAL库,STM32CubeMX确实能省很多事。但有几个坑我踩过:第一,CubeMX默认把系统时钟配置成内部HSI,精度不够,做串口通信容易出波特率误差,一定要在Clock Configuration里切换到HSE外部晶振,并把PLL倍频到72MHz。第二,生成工程时如果选MDK-ARM,注意勾选“Copy only necessary library files”,否则工程文件夹会非常大。第三,HAL库的延时函数HAL_Delay()依赖SysTick中断,如果在中断服务函数里调用它,会导致死锁,这一点在写DHT11驱动时要特别注意。

3. 时钟树与GPIO的底层逻辑:从寄存器到实际配置

3.1 STM32F1时钟系统的核心脉络

STM32F1的时钟系统比8位单片机复杂得多,但理解之后会发现它的设计很灵活。核心时钟源有四个:HSI(内部8MHz RC振荡器)、HSE(外部4-16MHz晶振)、LSI(内部40kHz RC,用于看门狗)、LSE(外部32.768kHz晶振,用于RTC)。这些时钟源经过PLL倍频、分频后,分配给AHB总线、APB1总线(低速,最高36MHz)、APB2总线(高速,最高72MHz)以及各个外设。

以最常见的STM32F103C8T6为例,外部接8MHz晶振,经过PLL 9倍频得到72MHz作为系统时钟SYSCLK。AHB预分频器设为1,所以HCLK=72MHz。APB1预分频器设为2,所以PCLK1=36MHz,挂载在APB1上的外设(如TIM2、TIM3、USART2、I2C1)时钟为36MHz。APB2预分频器设为1,所以PCLK2=72MHz,挂载在APB2上的外设(如GPIOA、USART1、SPI1、ADC1)时钟为72MHz。

为什么要搞清楚这些?因为后面配置定时器计算延时、配置串口计算波特率、配置DHT11的微秒级延时,都依赖准确的时钟频率。如果你用CubeMX生成了代码但没注意时钟配置,很可能实际运行频率和预期不一致,导致延时不准、通信失败。

3.2 GPIO八种工作模式的适用场景

STM32F1的每个GPIO引脚都可以配置成八种模式之一:输入浮空、输入上拉、输入下拉、模拟输入、开漏输出、推挽输出、开漏复用输出、推挽复用输出。很多人写代码时随便选一个推挽输出就完事了,但在实际项目中,模式选错会导致电平异常、功耗增加甚至损坏器件。

  • 推挽输出:最常用,可以输出强高低电平,驱动LED、继电器等。输出速度根据需求选2MHz、10MHz或50MHz,速度越高功耗和EMI越大,不要无脑选50MHz。
  • 开漏输出:输出端没有上拉,需要外部接上拉电阻,适合I2C总线、电平转换等场景。多个开漏引脚可以线与在一起,实现总线仲裁。
  • 复用推挽输出:当引脚被用作外设功能(如USART_TX、SPI_SCK)时使用,由外设控制输出。
  • 模拟输入:用于ADC采集,此时施密特触发器关闭,引脚直接连接到ADC模块。
  • 输入上拉/下拉:用于按键检测,省去外部电阻。但要注意,STM32F1的内部上拉电阻约40kΩ,如果干扰严重,还是建议用外部4.7kΩ-10kΩ上拉。

配置GPIO的代码通常长这样:

GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure);

提示:在配置GPIO之前,必须先使能对应端口的时钟。忘记使能时钟是新手最常见的错误之一,表现是引脚完全没反应,但代码编译没问题。

3.3 用定时器实现微秒级延时的两种方案

DHT11的通信协议要求微秒级延时,比如主机拉低总线至少18ms,然后拉高20-40us,再读取从机的响应。如果用HAL_Delay()或者循环空转,精度根本不够。我试过两种方案:

方案一:用SysTick定时器做微秒延时。SysTick是Cortex-M3内核自带的24位递减计数器,配置成1us中断一次,然后在中断里对一个全局变量累加。但这种方法在中断里累加,如果延时期间被其他高优先级中断打断,精度会受影响。

方案二:用通用定时器TIM做微秒延时。以TIM4为例,挂载在APB1上,时钟36MHz。设置预分频器PSC=35,则计数频率为1MHz,即每计数一次1us。设置自动重装载值ARR=65535,然后通过读取当前计数值来计算延时。这种方法的精度取决于定时器时钟的准确性,比SysTick更稳定。

我最终采用的是方案二的变体:不开启定时器中断,直接启动定时器,在循环里读取计数值,达到目标延时后关闭定时器。代码片段如下:

void delay_us(uint16_t us) { TIM_SetCounter(TIM4, 0); TIM_Cmd(TIM4, ENABLE); while (TIM_GetCounter(TIM4) < us); TIM_Cmd(TIM4, DISABLE); }

这个函数在72MHz系统时钟、APB1=36MHz、PSC=35的条件下,实测延时误差在1us以内,足够DHT11使用。

4. DHT11单总线驱动的完整实现与调试

4.1 DHT11的通信协议拆解

DHT11是一款含有已校准数字信号输出的温湿度复合传感器,内部由一个电阻式感湿元件和一个NTC测温元件组成,通过单总线与MCU通信。一次完整的通信过程包括:主机发送起始信号、从机响应、从机发送40位数据、主机读取数据。

具体时序如下:

  1. 主机拉低总线至少18ms,然后拉高20-40us,等待从机响应。
  2. 从机检测到起始信号后,拉低总线80us,再拉高80us,表示准备发送数据。
  3. 从机依次发送40位数据,每一位以50us低电平开始,随后高电平持续26-28us表示“0”,持续70us表示“1”。
  4. 最后一位数据发送完毕后,从机拉低总线50us,然后释放总线,进入空闲状态。

40位数据的格式是:8位湿度整数 + 8位湿度小数 + 8位温度整数 + 8位温度小数 + 8位校验和。校验和等于前四个字节之和的低8位。DHT11的湿度小数和温度小数通常为0,因为它的分辨率是1%RH和1℃。

4.2 GPIO模式切换与代码实现

DHT11的数据线需要双向通信,所以GPIO要在输出和输入模式之间切换。我把它接在PB12上,配置代码如下:

#define DHT11_GPIO_PORT GPIOB #define DHT11_GPIO_PIN GPIO_Pin_12 #define DHT11_RCC RCC_APB2Periph_GPIOB void DHT11_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(DHT11_RCC, ENABLE); GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); } void DHT11_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); } void DHT11_SetInput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(DHT11_GPIO_PORT, &GPIO_InitStructure); }

读取数据的核心函数如下:

uint8_t DHT11_ReadByte(void) { uint8_t i, byte = 0; for (i = 0; i < 8; i++) { while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 0); delay_us(40); byte <<= 1; if (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 1) { byte |= 1; while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 1); } } return byte; } uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5], i; DHT11_SetOutput(); GPIO_ResetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_ms(20); GPIO_SetBits(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_us(30); DHT11_SetInput(); if (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 0) { while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 0); while (GPIO_ReadInputDataBit(DHT11_GPIO_PORT, DHT11_GPIO_PIN) == 1); for (i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } if (buf[0] + buf[1] + buf[2] + buf[3] == buf[4]) { *humi = buf[0]; *temp = buf[2]; return 1; } } return 0; }

4.3 调试中常见的三个问题与排查方法

问题一:读出来的数据全是0或者255。最常见的原因是延时不准。DHT11对时序非常敏感,尤其是读取每一位数据时,判断高电平持续时间的那个40us延时。如果系统时钟不是72MHz,或者定时器配置有误,这个延时就会偏差很大。我的排查方法是:用示波器或者逻辑分析仪抓一下数据线的波形,看起始信号、响应信号、数据位的高低电平时间是否符合手册要求。如果没有仪器,可以临时把延时函数改成循环空转,通过调整循环次数来逼近正确时序。

问题二:第一次读取成功,第二次失败。这种情况通常是总线没有正确释放。DHT11在通信结束后会释放总线,但如果你在读取完数据后没有把GPIO切回输出模式并拉高,下一次通信时主机拉低总线的动作可能无效。我的做法是在每次读取失败后,重新初始化GPIO为推挽输出并拉高,等待至少1秒再重试。DHT11的采样周期建议不低于1秒,频繁读取会导致传感器发热,湿度读数偏高。

问题三:湿度值一直偏高或偏低。这通常不是驱动问题,而是传感器放置位置的问题。DHT11对安装环境有要求,不要把它放在MCU芯片旁边,因为芯片发热会影响温度读数,进而影响湿度计算。也不要把传感器密封在壳子里,要保持空气流通。我在一个项目里把DHT11放在STM32F1板子正上方5mm处,读出来的温度比实际环境高了3℃,后来把传感器用排线引出10cm,数据就正常了。

5. 从裸机到实用:数据滤波、串口输出与低功耗处理

5.1 对DHT11原始数据做滑动平均滤波

DHT11的精度是±2℃和±5%RH,本身就不高,再加上单总线通信容易受干扰,读出来的数据偶尔会跳变。直接显示原始值体验很差,我通常会在MCU端做一个滑动平均滤波。具体做法是:定义一个长度为5的数组,每次读取成功后把新数据存入,然后计算平均值。如果某次读取失败,就用上一次的有效值填充,避免数据断层。

#define FILTER_LEN 5 uint8_t temp_buf[FILTER_LEN] = {0}; uint8_t humi_buf[FILTER_LEN] = {0}; uint8_t buf_index = 0; void Update_Filter(uint8_t temp, uint8_t humi) { temp_buf[buf_index] = temp; humi_buf[buf_index] = humi; buf_index = (buf_index + 1) % FILTER_LEN; } uint8_t Get_Filtered_Temp(void) { uint16_t sum = 0; for (uint8_t i = 0; i < FILTER_LEN; i++) sum += temp_buf[i]; return sum / FILTER_LEN; }

这个滤波方法简单有效,代价是响应速度变慢,适合环境变化不剧烈的场景。如果要做快速响应,可以改用中值滤波或者卡尔曼滤波,但计算量会大一些。

5.2 通过串口把温湿度数据发到上位机

STM32F1的USART1挂载在APB2上,时钟72MHz,配置成115200波特率很方便。初始化代码如下:

void USART1_Init(uint32_t bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = bound; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); }

发送数据时,用printf重定向会更方便。在Keil里勾选“Use MicroLIB”,然后重写fputc函数:

int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }

这样就能在串口助手里看到格式化的温湿度数据了。我习惯每2秒发送一次,格式为“Temp: 25C, Humi: 60%”,方便用串口绘图工具做趋势图。

5.3 低功耗场景下的DHT11读取策略

如果你的STM32F1项目是电池供电的,比如无线温湿度节点,那就要考虑功耗。DHT11本身工作电流约1mA,不算高,但STM32F1在72MHz全速运行时的电流约30-50mA,这才是耗电大户。我的做法是:让STM32F1在大部分时间进入Stop模式,用RTC闹钟或者外部中断唤醒,醒来后快速读取DHT11,通过串口或无线模块发送数据,然后继续休眠。

具体流程是:配置RTC每2秒产生一次闹钟中断,在中断里退出Stop模式,读取DHT11,发送数据,然后重新进入Stop模式。注意,进入Stop模式前要把GPIO配置成模拟输入或者保持状态,避免悬空引脚漏电。DHT11的数据线在空闲时应该由主机拉高,所以进入Stop模式前要把PB12设置为推挽输出并输出高电平。

实测下来,这种策略可以把平均电流降到2mA左右,用两节AA电池能撑好几个月。当然,如果你对功耗要求更极致,可以考虑用STM32L系列,但那是另一个话题了。

6. 几个容易忽略的硬件细节与个人经验

6.1 电源去耦与复位电路

STM32F1的每个电源引脚旁边都要放一个100nF的陶瓷电容,越靠近引脚越好。我见过有人画PCB时把去耦电容放在板子另一面,结果ADC采集出来的数据跳动很大。另外,NRST引脚上接一个100nF电容到地,可以防止复位引脚受干扰导致MCU意外复位。BOOT0和BOOT1引脚要接10kΩ下拉电阻到地,确保从主Flash启动。

6.2 晶振布局与负载电容

外部8MHz晶振要尽量靠近MCU的OSC_IN和OSC_OUT引脚,走线要短且对称。负载电容通常选20pF,但具体值要根据晶振手册来定。如果晶振不起振,先检查焊接有没有虚焊,再检查负载电容是否匹配。我遇到过一批板子,晶振能起振但频率偏了0.5%,导致串口波特率误差累积,通信偶尔出错,后来把负载电容从20pF换成15pF就正常了。

6.3 SWD调试接口的保留

不管项目多小,我都会把SWD接口(SWDIO、SWCLK、GND、3.3V)引出来,用4Pin的排针。这样调试时可以直接用ST-Link下载和单步调试,比串口打印高效得多。注意SWDIO和SWCLK不要被其他外设占用,如果必须复用,要在初始化时先配置成SWD功能,否则下次下载程序时可能连不上。

6.4 关于DHT11替代方案的思考

DHT11便宜、简单,但精度和响应速度确实一般。如果你做的是需要精确控温的项目,比如孵化器、恒温箱,建议换成DHT22(AM2302)或者SHT30。DHT22的精度是±0.5℃和±2%RH,价格贵一些但值得。SHT30是I2C接口,精度更高,而且有工业级版本。不过对于学习STM32F1的单总线驱动来说,DHT11依然是最好的练手对象,因为它把时序要求推到了极致,调通之后再去看其他单总线设备,会发现思路完全一样。

我在实际项目中用STM32F103C8T6配合DHT11做过一个温室监控节点,连续运行了三个月,中间只出现过两次读取失败,都是因为传感器附近湿度突然变化太大导致的。后来加了滑动平均滤波和失败重试机制,就再没出过问题。这个组合虽然简单,但稳定性足够应付大多数非工业场景。如果你也在用STM32F1和DHT11做东西,希望上面这些代码和经验能帮你少走点弯路。

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

能智云智能家居系统实战:从网关选型到场景联动全解析

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

作者头像 李华
网站建设 2026/10/28 18:27:07

EtherCAT主站实战:X86平台KPA协议栈集成与抖动优化

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

作者头像 李华
网站建设 2026/10/28 18:26:57

先进封装:摩尔定律失效后的性能突围新路径

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

作者头像 李华
网站建设 2026/10/28 18:26:33

档案管理信息系统实战:从数据模型到全文检索的落地指南

简介&#xff1a;这份资源是面向档案管理信息化建设者与Delphi开发者的档案管理信息系统完整源码工程&#xff0c;基于Delphi与SQL技术栈构建&#xff0c;可用于学习桌面端档案管理软件的界面设计、业务逻辑分层与数据库交互实现。压缩包共106个文件&#xff0c;约1.51MB&#…

作者头像 李华
网站建设 2026/10/28 18:26:07

音频调校工具实战:LUFS响度归一化与批处理避坑

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

作者头像 李华
网站建设 2026/10/28 18:25:50

从射线感知到反向传播:手写神经网络赛车全链路避坑

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

作者头像 李华