news 2026/10/7 1:14:11

STM32F1与DHT11实战:从选型到调试的嵌入式入门指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F1与DHT11实战:从选型到调试的嵌入式入门指南

1. 为什么STM32F1到今天还值得花时间学

如果你最近在电子论坛或者开源硬件社区里逛,大概率会看到一个现象:一边是各种高性能新芯片层出不穷,另一边是大量工程师、学生、爱好者依然在拿STM32F1系列做项目。尤其是搭配DHT11温湿度传感器做环境监测这个组合,几乎成了很多人入门嵌入式开发的第一套实战方案。我自己也是从这个组合开始真正理解单片机到底怎么用的,所以这篇内容就围绕STM32F1系列和DHT11这个经典搭配,把从选型、原理、接线、代码到踩坑排查的完整链路讲透。

STM32F1系列是意法半导体基于ARM Cortex-M3内核推出的32位微控制器家族,最经典的型号包括STM32F103C8T6、STM32F103RCT6、STM32F103ZET6等。它最大的特点是外设资源丰富、资料生态极其庞大、价格亲民,而且开发工具链成熟。你几乎可以在任何一家电子元器件平台上买到它的最小系统板,十几块钱就能起步。对于想从8位单片机进阶到32位开发的人来说,F1系列是一个非常好的跳板——它不会像某些新系列那样让你在底层配置上花太多时间,但又足够让你理解时钟树、中断优先级、DMA、外设寄存器这些核心概念。

而DHT11温湿度传感器则是另一个极端:便宜、简单、单总线通信、只有四个引脚。它精度一般,温度±2℃、湿度±5%RH,采样周期不低于1秒,但对于学习单总线时序、GPIO方向切换、微秒级延时这些嵌入式基本功来说,它是最好的教具之一。把STM32F1和DHT11放在一起,你实际上是在练三件事:第一,理解32位MCU的GPIO如何灵活配置;第二,掌握单总线协议的时序控制;第三,学会用定时器或者系统滴答来做精确延时。

这套组合适合谁?如果你是电子类专业的学生,正在做课程设计或者毕业设计;如果你是转行做嵌入式开发的初学者,想找一个能跑通完整流程的项目;如果你是有经验的工程师,想快速验证一个环境监测方案的原型——STM32F1加DHT11都能满足。它不追求高性能,但能让你把基础打扎实。接下来我会从整体设计思路开始,一步步拆到代码和调试细节,尽量把每个“为什么这么做”讲清楚。

2. 整体方案设计与核心思路拆解

2.1 为什么选STM32F1而不是其他系列

选型这件事,很多人一上来就看主频、看Flash大小、看外设数量,但实际做项目的时候,真正决定体验的是生态和容错空间。STM32F1系列的主频通常是72MHz,Flash从64KB到512KB不等,SRAM从20KB到64KB。对于DHT11这种单总线传感器来说,这个性能绰绰有余,甚至可以说是大炮打蚊子。但为什么不用更便宜的8位机?因为STM32F1的GPIO配置更灵活,你可以通过寄存器或者标准外设库精确控制引脚模式,而且在微秒级延时上,72MHz的主频配合SysTick或者定时器,能做出比8位机更稳定的时序。

另一个关键原因是调试工具。STM32F1支持SWD调试,你只需要两根线就能单步跟踪、看变量、设断点。相比之下,很多8位机还在用串口打印调试,效率差很多。再加上STM32CubeMX这类图形化配置工具,初始化代码几乎可以一键生成,省去了大量查手册配寄存器的时间。当然,如果你用的是标准外设库或者直接操作寄存器,也能更深入理解底层,但CubeMX+HAL库的组合对新手更友好。

还有一点容易被忽略:STM32F1的供电范围是2.0V到3.6V,通常用3.3V。而DHT11的工作电压是3.3V到5.5V,两者可以直接对接,不需要额外的电平转换。如果你用5V的单片机,DHT11的数据引脚输出是5V电平,虽然很多STM32的IO口标称5V容忍,但长期看还是3.3V对3.3V更稳妥。所以从电气兼容性上,这套组合也是顺理成章的。

2.2 DHT11的单总线协议到底是怎么回事

DHT11用的是一种叫单总线的通信方式,顾名思义,数据和命令都走一根线。这根线在空闲时被上拉电阻拉高,主机和从机通过拉低和释放总线来传递0和1。具体来说,一次完整的通信流程是这样的:主机先拉低总线至少18毫秒,然后释放,接着从机响应,拉低80微秒再释放80微秒,之后从机连续发出40位数据,每一位都以50微秒的低电平开始,然后高电平的持续时间决定是0还是1——26到28微秒表示0,70微秒左右表示1。

这个协议看起来简单,但对时序要求很严。如果你用STM32F1的HAL库延时函数,比如HAL_Delay,它的最小单位是毫秒,根本不够用。所以必须用微秒级延时,通常有两种做法:一种是直接用SysTick定时器做忙等待,另一种是用一个通用定时器配置成1微秒计数。我个人的习惯是用SysTick,因为它在整个工程里都能用,而且不占用额外外设。但要注意,SysTick的中断优先级和系统时钟配置有关,如果你在中断里调用延时,可能会出问题。所以DHT11的读取最好放在主循环或者低优先级任务里,避免在中断上下文中操作。

还有一个细节:DHT11的数据引脚是开漏输出还是推挽?实际上DHT11的数据线是双向的,主机需要先输出低电平,然后切换成输入模式去读从机的响应。在STM32F1上,你可以把GPIO配置成开漏输出加上拉电阻,这样释放总线时靠外部上拉电阻拉高,不需要频繁切换模式。但更常见的做法是:输出时用推挽,读的时候切成浮空输入或者上拉输入。两种方式都能用,但开漏加外部上拉在总线冲突时更安全。我一般会在数据线和VCC之间接一个4.7kΩ到10kΩ的上拉电阻,实测4.7kΩ在短线缆下波形最干净。

2.3 系统框架与数据流向

整个项目的框架其实很清晰:STM32F1作为主机,通过一个GPIO引脚与DHT11通信,读取40位数据,然后解析出湿度的整数部分、湿度的 decimal 部分、温度的整数部分、温度的小数部分,最后一位是校验和。校验规则是前四个字节相加,取低8位,如果等于第五个字节,说明数据有效。解析完成后,你可以把数据通过串口打印到电脑,或者显示在OLED屏幕上,也可以上传到上位机做记录。

数据流向是:DHT11内部有一个电容式湿度传感元件和一个NTC测温元件,它们输出的模拟信号经过内部ADC转换成数字量,再由DHT11自己的小MCU处理成40位数据。STM32F1只负责发起通信和读取结果。这里要注意,DHT11的采样周期不能低于1秒,如果你读得太快,它会返回上一次的数据或者直接不响应。所以主循环里最好加一个1秒以上的延时,或者用定时器来触发读取。

如果你打算做低功耗项目,DHT11并不是一个省电的选择,因为它每次测量都需要主机拉低总线至少18毫秒,这段时间MCU不能休眠。但如果你只是做桌面环境监测,功耗不是问题。另外,DHT11的测量范围是温度0到50℃,湿度20%到90%RH,如果你需要更宽的范围或者更高精度,可以考虑DHT22或者SHT30,但那是另一个话题了。

3. 核心细节解析与实操要点

3.1 硬件连接与上拉电阻的选择

先看接线。DHT11通常有三个引脚或者四个引脚,三个引脚的是裸传感器,四个引脚的是模块。模块上一般已经集成了上拉电阻和滤波电容,接线更简单。以三引脚裸传感器为例:VCC接3.3V,GND接地,DATA接STM32F1的某个GPIO,比如PA0。如果你用的是四引脚模块,其中一个引脚是NC,不用接。

上拉电阻的选择很关键。DHT11的数据线在空闲时必须保持高电平,所以需要一个上拉电阻。阻值太小,功耗大,而且从机拉低时电流大;阻值太大,上升沿变缓,高速通信时可能误判。DHT11的通信速率不高,一位数据最长70微秒,所以10kΩ通常没问题。但如果你用的线缆比较长,比如超过20厘米,建议用4.7kΩ,这样上升沿更快。我实测过,在面包板上用10kΩ,读取成功率大概95%,换成4.7kΩ后基本100%。另外,电源引脚旁边最好加一个100nF的去耦电容,减少电源噪声对传感器的影响。

还有一个容易忽略的点:STM32F1的GPIO在复位后默认是浮空输入,如果你直接接DHT11,在初始化之前数据线是浮空的,可能导致DHT11误判。所以建议在GPIO初始化之后,先输出高电平一段时间,让总线稳定。如果你用的是开漏输出模式,外部上拉电阻会自然把总线拉高,但开漏模式下输出高电平实际上是释放总线,所以写1就是高阻态。这一点在代码里要特别注意。

3.2 微秒级延时的实现与精度校准

前面提到,DHT11的时序需要微秒级延时。在STM32F1上,最常用的方法是利用SysTick定时器。SysTick是一个24位的递减计数器,可以配置成1毫秒中断或者更短。但如果你用它做微秒延时,通常的做法是:先配置SysTick的时钟源为HCLK或者HCLK/8,然后写一个忙等待函数,读取当前的计数值,循环直到经过指定的微秒数。

具体来说,假设系统时钟是72MHz,SysTick的时钟源设为HCLK/8,也就是9MHz,那么每个计数周期是1/9微秒,约0.111微秒。你要延时10微秒,就需要计数90次。但SysTick是递减的,所以你要先读取当前值,然后计算目标值,注意处理重装载的情况。这种方法的精度取决于中断是否关闭。如果在延时期间有中断发生,计数可能会被干扰。所以更稳妥的做法是:在微秒延时函数里暂时关闭全局中断,延时结束后再打开。但关闭中断时间不能太长,否则会影响其他外设。DHT11的最长延时是18毫秒,如果你关闭中断18毫秒,可能会丢失串口数据或者导致其他定时器不准。所以通常只在读取40位数据的那几毫秒里关闭中断,发起起始信号的那18毫秒可以用普通延时。

另一种方法是用一个通用定时器,比如TIM2,配置成1微秒计数,然后轮询计数器。这种方法的优点是不依赖SysTick,也不怕中断干扰,但占用一个定时器资源。如果你项目里定时器够用,我推荐这种方式。配置步骤是:使能TIM2时钟,设置预分频器为72-1(假设72MHz),自动重装载值为65535,然后启动计数。延时函数就是读取当前计数值,加上需要的微秒数,然后等待计数器到达目标值。注意要处理计数器溢出的情况。

不管用哪种方法,都建议用示波器或者逻辑分析仪校准一下。我遇到过因为编译器优化导致延时不准的情况,比如在延时循环里用了volatile变量,但优化等级设成-O2后循环被优化掉了。所以延时函数里的变量一定要加volatile,或者用汇编插入NOP指令。实测下来,用SysTick加volatile变量,在72MHz下延时误差可以控制在1微秒以内,足够DHT11用了。

3.3 GPIO模式切换的时机与陷阱

DHT11的通信过程中,GPIO需要在输出和输入之间切换。具体流程是:主机先输出低电平至少18毫秒,然后切换成输入模式,等待从机的响应。从机响应后,会连续发出40位数据,主机一直保持输入模式读取。读取完成后,主机再切换回输出模式,输出高电平,让总线回到空闲状态。

这里有几个陷阱。第一,切换模式的时候,如果从输出直接切到输入,而外部上拉电阻还没把总线拉高,可能会读到低电平。所以切换之前,最好先输出高电平,等一小段时间,再切换成输入。第二,STM32F1的GPIO模式配置需要操作CRL和CRH寄存器,如果你用HAL库,调用HAL_GPIO_Init函数会重新配置整个端口,可能影响同一端口的其他引脚。所以建议把DHT11的数据引脚单独放在一个端口上,或者用寄存器操作只修改那一个引脚的配置位。第三,输入模式最好用上拉输入,这样即使外部上拉电阻没焊,内部上拉也能保证空闲时是高电平。但内部上拉电阻比较大,大概40kΩ左右,上升沿会比较慢,所以外部上拉电阻还是必要的。

还有一个细节:在读取40位数据时,每一位的开始都是50微秒的低电平,然后是高电平。你需要先等待低电平结束,然后测量高电平的持续时间。如果高电平持续26到28微秒,就是0;如果持续70微秒左右,就是1。但实际测量时,由于中断和代码执行时间,可能会有几微秒的误差。所以判断阈值通常设在40微秒左右:高电平时间大于40微秒算1,小于算0。这个阈值不是固定的,你可以根据实测调整。我一般会在读取函数里加一个超时机制,如果等待高电平的时间超过100微秒,就认为通信失败,直接返回错误。

4. 实操过程与核心环节实现

4.1 工程搭建与CubeMX配置

我习惯用STM32CubeMX来生成初始化代码,因为这样可以少查手册,而且时钟树配置直观。具体步骤是:打开CubeMX,选择STM32F103C8T6,然后配置系统时钟。在RCC里把HSE设为Crystal/Ceramic Resonator,然后在Clock Configuration里把HCLK设为72MHz。接着配置一个GPIO引脚,比如PA0,先设为GPIO_Output,初始电平为高。然后再配置一个串口,比如USART1,波特率115200,用于打印数据。最后在Project Manager里选择MDK-ARM或者STM32CubeIDE,生成代码。

生成代码后,你需要手动添加DHT11的驱动文件。我一般会建一个dht11.c和dht11.h,把延时函数、GPIO模式切换函数、读取函数都放在里面。延时函数可以用SysTick,但CubeMX生成的代码里已经初始化了SysTick为1毫秒中断,所以你不能直接改SysTick的配置,否则会影响HAL_Delay。这时候有两个选择:一是用HAL库的HAL_GetTick和忙等待实现微秒延时,但精度不够;二是用另一个定时器,比如TIM3,专门做微秒延时。我推荐第二种,因为不影响系统滴答。

配置TIM3的步骤是:在CubeMX里使能TIM3,时钟源选内部时钟,预分频器设为72-1,计数器周期设为65535,然后生成代码。在代码里启动TIM3:HAL_TIM_Base_Start(&htim3)。然后写一个延时函数:

void delay_us(uint16_t us) { uint16_t start = __HAL_TIM_GET_COUNTER(&htim3); while ((__HAL_TIM_GET_COUNTER(&htim3) - start) < us); }

注意这里要用无符号16位减法,这样即使计数器溢出也能正确计算。但如果你延时超过65535微秒,这个函数就不适用了。DHT11的最长延时是18毫秒,也就是18000微秒,在范围内。

4.2 DHT11读取函数的完整实现

读取函数是整个项目的核心。我把它分成几个步骤:发起起始信号、等待响应、读取40位数据、校验、解析。先看发起起始信号:

void DHT11_Start(void) { DHT11_SetOutput(); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 至少18ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); delay_us(30); DHT11_SetInput(); }

这里用HAL_Delay(20)是因为18毫秒用微秒延时也行,但HAL_Delay更简单。注意拉低之后要先输出高电平再切换成输入,这样总线能快速回到高电平。切换成输入后,从机会在80微秒内拉低总线,然后释放。所以接下来要等待从机的响应:

uint8_t DHT11_WaitResponse(void) { uint32_t timeout = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (++timeout > 10000) return 0; } timeout = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (++timeout > 10000) return 0; } timeout = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (++timeout > 10000) return 0; } return 1; }

这段代码依次等待从机拉低、释放、再拉低。如果超时,说明传感器没响应。超时阈值10000是根据循环执行时间估算的,实际调试时可以调整。

读取40位数据的函数:

uint8_t DHT11_ReadByte(void) { uint8_t byte = 0; for (int i = 0; i < 8; i++) { while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET); delay_us(40); if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { byte |= (1 << (7 - i)); while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET); } } return byte; }

这里先等待50微秒的低电平结束,然后延时40微秒,再读引脚。如果还是高电平,说明这一位是1,否则是0。读完1之后要等待高电平结束,准备下一位。这个逻辑简单但有效,实测在72MHz下很稳定。

读取完40位后,进行校验:

uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; DHT11_Start(); if (!DHT11_WaitResponse()) return 0; for (int i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } if (buf[4] == (buf[0] + buf[1] + buf[2] + buf[3])) { *humi = buf[0]; *temp = buf[2]; return 1; } return 0; }

注意DHT11的湿度小数部分和温度小数部分通常都是0,所以只取整数部分就够了。如果你需要小数,可以取buf[1]和buf[3],但DHT11的分辨率是1,小数部分没有实际意义。

4.3 主循环与串口输出

主循环里每隔2秒读取一次,然后通过串口打印:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM3_Init(); HAL_TIM_Base_Start(&htim3); uint8_t temp, humi; char msg[64]; while (1) { if (DHT11_ReadData(&temp, &humi)) { sprintf(msg, "Temp: %d C, Humi: %d %%\r\n", temp, humi); } else { sprintf(msg, "DHT11 read error\r\n"); } HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), 1000); HAL_Delay(2000); } }

这里用HAL_Delay(2000)保证采样周期大于1秒。如果你用RTOS,可以把读取任务放在一个独立的线程里,用osDelay。串口输出方便你在电脑上用串口助手查看数据。如果你没有USB转串口模块,也可以用ST-Link的虚拟串口,但需要额外配置。

5. 常见问题与排查技巧实录

5.1 读取失败的原因分析与排查表

DHT11读取失败是新手最常遇到的问题。我整理了一个排查表,按概率从高到低排列:

现象可能原因排查方法解决方法
一直返回错误接线错误检查VCC、GND、DATA是否接对重新接线,确保VCC是3.3V
偶尔成功上拉电阻太大用示波器看数据线波形换成4.7kΩ上拉电阻
完全无响应延时不准用逻辑分析仪抓时序校准微秒延时,关闭中断
数据校验失败电源噪声测量VCC纹波加100nF和10uF电容
读取间隔太短采样周期不足检查主循环延时确保间隔大于1秒
温度湿度不变传感器损坏换一个DHT11测试更换传感器

我遇到过最诡异的一次是:读取函数在调试模式下正常,但全速运行就失败。后来发现是编译器优化把延时循环优化掉了。解决方法是在延时函数里加volatile变量,或者把优化等级降到-O0。但-O0会影响整体性能,所以更好的做法是用定时器做延时,不依赖循环。

5.2 时序调试的实战经验

如果你有逻辑分析仪,调试DHT11会轻松很多。把探头接在数据线上,触发方式设为下降沿,然后看波形。正常的起始信号是:主机拉低18毫秒,然后释放,从机拉低80微秒,释放80微秒,然后开始40位数据。每一位的低电平都是50微秒,高电平26到28微秒是0,70微秒是1。如果你看到高电平时间不对,比如都是50微秒,那可能是延时函数不准。如果看到从机根本没响应,那可能是主机拉低时间不够,或者上拉电阻没接。

没有逻辑分析仪怎么办?可以用串口打印调试信息。比如在等待响应的每个循环里打印一个字符,看卡在哪一步。但串口打印本身会影响时序,所以只能用来判断大致位置,不能用来精确测量。另一个办法是用示波器的单次触发功能,虽然不如逻辑分析仪方便,但也能看个大概。

还有一个经验:DHT11对电源很敏感。如果你用USB供电,电脑的USB口噪声比较大,可能导致读取失败。我试过用电池供电,成功率明显提高。所以如果你一直调不通,可以试试换一个干净的电源,或者在VCC和GND之间并一个100uF的电解电容。

5.3 从DHT11进阶到其他传感器的思路

DHT11玩熟了之后,你可能会觉得它精度不够、响应慢。这时候可以看看DHT22,它的协议和DHT11类似,但精度更高,测量范围更宽,价格也贵不了多少。DHT22的数据格式是40位,但湿度和温度各占16位,有小数部分。读取代码只需要稍微改一下解析部分。

如果你想要更专业的方案,可以看SHT30或者SHT31,它们用I2C接口,精度更高,而且有CRC校验。从单总线转到I2C,你需要重新配置STM32F1的I2C外设,但CubeMX可以帮你生成大部分代码。I2C的调试比单总线简单,因为有时钟线同步,不容易出现时序问题。

再往上,你可以把数据上传到云端或者本地服务器。STM32F1加上ESP8266或者ESP32做WiFi透传,就能把温湿度数据发到MQTT服务器。这时候DHT11的精度可能不够看,但作为学习链路,从传感器到MCU到通信模块到上位机,整个流程跑通一遍,收获会很大。

我个人在实际操作中的体会是:DHT11最大的价值不是它的精度,而是它逼着你去理解时序、去调延时、去看波形。这些东西在以后做更复杂的项目时都会用到。比如你以后用SPI或者I2C,虽然硬件外设帮你处理了大部分时序,但遇到通信失败时,你还是得回到波形上去找原因。所以花时间把DHT11调稳,绝对不亏。

最后再分享一个小技巧:如果你在读取DHT11的时候经常失败,可以在读取函数外面包一层重试机制。比如连续读三次,只要有一次成功就返回成功。这样能显著提高稳定性,尤其是在电源不太干净的环境里。但重试之间要加至少1秒的间隔,否则DHT11来不及完成下一次测量。这个技巧我在好几个项目里都用过,实测有效。

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

JavaWeb期末大作业实战:在线购书系统从环境搭建到部署上线

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

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

空心杯电机内部结构拆解:有刷无刷对比与DIY实践

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

作者头像 李华
网站建设 2026/10/7 1:11:57

Python Web项目管理信息系统:Flask+SQLAlchemy实战与避坑指南

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

作者头像 李华
网站建设 2026/10/7 1:11:36

智能车原理图分析:从电路符号到系统行为的解码指南

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

作者头像 李华
网站建设 2026/10/7 1:10:57

DDPG在无人机边缘计算卸载中的动态决策优化

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

作者头像 李华