news 2026/10/6 7:08:23

STM32F1实战:从GPIO到DHT11温湿度传感器驱动开发

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F1实战:从GPIO到DHT11温湿度传感器驱动开发

STM32F1这颗芯片,到今天依然是很多人嵌入式生涯里的第一颗MCU。我到现在都记得第一次点亮F103板子上的LED时那种“原来寄存器这么好玩”的感觉。F1系列主频只有72MHz,放在现在看确实不算快,但它在学习、毕设、产品打样甚至小批量量产里出现的频率高得吓人,随手搜一个外设用法,十有八九都是F1的开源代码。而DHT11温湿度传感器几乎就是F1入门之后的第一个“实战对象”,单总线时序、微秒级延时、校验和计算,一个看起来不起眼的小传感器能把F1的GPIO、时钟、定时器全串起来。这篇东西就围绕STM32F1系列,从选型、环境搭建、GPIO、时钟,一直到用F1驱动DHT11读温湿度,把那些真正踩过的坑、验证过的写法一次写清楚。

1. 为什么F1系列到现在依然是“入门第一课”

1.1 一颗Cortex-M3芯片撑起整个学习生态

F1系列基本都是基于ARM Cortex-M3内核,没有MMU、没有浮点单元,架构简单直接,跑裸机、跑FreeRTOS、RT-Thread这类轻量级系统都完全够用。它的定位从来不靠性能,靠的是“均衡”:72MHz主频、几十KB到几百KB的Flash、从48脚到144脚的封装,甚至同系列里从几块钱的C8T6到带FSMC可外扩SRAM的ZET6,全都能用一套开发方式搞定。

我记得有老工程师说过一句话:把F103调明白了,后面换F2、F4、F7甚至换到别的M3/M4芯片,底层思路都是通的。因为Cortex-M3的NVIC中断控制器、SysTick、存储器映射这些东西是ARM统一设计的,寄存器长得也类似。你学会配置GPIO的CRL/CRH寄存器,到了F4/GD32/MM32上翻一下参考手册同样能配置。这就是F1的最大价值:它是一块“黑板”,把单片机最核心的机制都摊开给你看,而不是用复杂的MPU、Cache和电源域把你绕晕。

DHT11这种传感器能在F1生态里这么火,也正是因为F1的“慢”刚好合适。72MHz主频下用GPIO模拟单总线时序,延时一个微秒是72个时钟周期,这个量级刚好能被普通软件循环和SysTick控制住,不会像几百MHz的芯片那样一优化就被编译器坑到。你完全可以用最原始的方式,一行行读引脚电平,把传感器协议的原理吃透。

1.2 型号怎么选:C8T6、RCT6、ZET6对应什么场景

F1系列最常被提起的是STM32F103这个大分支,但也别看到F103就以为都一个样。同样是F103,后缀不同,Flash、RAM、引脚数差别很大。新手最容易踩的第一个坑就是“买了ZET6开发板当C8T6用”,或者“画板用了C8T6结果Flash塞不下”。

型号封装/引脚数FlashSRAM典型场景
STM32F103C8T6LQFP4864KB20KB入门小板、传感器采集、小控制板
STM32F103RBT6LQFP64128KB20KB稍复杂逻辑、简易UI、电机控制
STM32F103RCT6LQFP64256KB48KB带屏幕交互、Modbus网关、工业设备
STM32F103ZET6LQFP144512KB64KB需要外扩总线的项目、完整产品原型

入门我几乎无脑推荐C8T6。原因很实际:便宜、资料多、够用。DHT11这种传感器它跑起来毫无压力,一个项目从点灯到温湿度采集,64KB Flash绰绰有余。但如果你准备做带TFT屏幕、或者需要大量日志缓存的东西,C8T6的20KB SRAM会很紧,这时候RCT6更合适。ZET6则适合做产品原型验证,144脚引出来方便接FSMC总线,跑彩屏很舒服,缺点是PCB布线麻烦、板子也贵。

还有一个容易忽视的问题:很多兼容芯片比如国产GD32E103、MM32F103,引脚和寄存器基本兼容F1,但外设细节有差异,DHT11这种用GPIO模拟的协议还好,如果你用到了ADC、CAN这类外设,最好还是以原厂手册为准。

2. 开发环境与工程搭建:从选型到点灯

2.1 工具链的选择:Keil、CubeMX还是寄存器裸写

F1的开发环境现在基本是两大流派的天下。一派是Keil MDK配合标准外设库SPL,另一派是STM32CubeMX生成代码配合HAL库。还有一些人喜欢直接用寄存器操作,不套任何库。

我的建议是分阶段:刚开始接触F1,用标准外设库或者LL库,配合Keil MDK,因为这样能直接看到GPIO_Init、RCC_APB2PeriphClockCmd这类接口背后的操作,理解起来比HAL库直观很多。HAL库的好处是代码结构统一、换芯片方便,但封装层数太多,新手很容易出现“调用了函数但不知道寄存器到底发生什么”的情况。

如果你用CubeMX,需要注意版本匹配。老工程用HAL库1.8版本,新版F1的包还是兼容的,但生成代码的结构会有细微差别。我自己更喜欢CubeMX生成时钟和引脚配置、再往UserCode区里写业务代码的方式,省去了手写RCC的枯燥过程,同时保留了对底层逻辑的控制。

但不管用哪种库,F1有一个铁律必须记住:外设时钟必须手动打开。不像51单片机复位后所有外设都能直接用,F103的GPIO、USART、TIM全部默认关闭时钟,你没开RCC就操作寄存器,结果是读回来永远是0、引脚没反应。这个坑拦住了至少半数刚接触F1的人。

2.2 GPIO的本质:从寄存器看推挽、开漏与上下拉

GPIO是F1里最基础也最“讲究”的外设。F103的GPIO配置用CRL和CRH寄存器,CRL管0到7号引脚,CRH管8到15号引脚,每个引脚占4位,其中高2位是CNF模式,低2位是MODE速度。这么设计的好处是引脚配置非常灵活,缺点是不能像F4那样单独操作某个引脚的单一位,必须一次性写8位。

我经常用门禁闸机给新手类比:推挽输出相当于闸机既有力气往外出、也有力气往回拉,所以能稳定输出高电平和低电平;开漏输出相当于只负责把门拉开、关门要靠外面的弹簧(上拉电阻);输入模式则是把闸机电源关掉,只看外部推门的信号。DHT11的数据线天生就是开漏总线,所以用F1读取DHT11时,要么在引脚外部加上拉电阻,要么内部配置成上拉输入,否则电平飘忽不定。

点个LED灯是最典型的推挽输出场景,代码很简单:

GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOC, &GPIO_InitStructure); GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_RESET);

这里GPIO_Speed可以选2MHz、10MHz、50MHz,对普通IO来说选哪个都能工作,但高频翻转时如果速度配太低,上升沿会变缓。DHT11这类单总线通信不需要很快,10MHz都够,但为了保险我用50MHz,切换输入模式后信号沿更干净。

另一个容易翻车的地方是:PB3、PB4和PA15默认是JTAG功能,你不做任何配置就想把它们当普通IO用,发现怎么拉都没电平。这时候要复用重映射并关闭JTAG,只能保留SWD调试接口,否则引脚不听话。

2.3 下载和调试:SWD、串口ISP和boot配置

给F1下载程序,常见方式有SWD调试下载、JTAG下载和串口ISP。现阶段我强烈推荐SWD,它只要四根线:SWDIO、SWCLK、GND、3.3V,一个几块钱的ST-Link V2就能搞定。JTAG虽然也能用,但占用的IO太多,现在基本只在老工程师的测试项目里出现。

新手遇到最多的故障是“程序下载不进去”。先别急着怀疑芯片坏,看看BOOT0和BOOT1跳线:

BOOT0BOOT1启动模式
0任意从主Flash启动,正常运行用户程序
10从系统存储器启动,进入串口Bootloader
11从SRAM启动,一般调试用

如果BOOT0被拉高,芯片上电后会进入系统存储器,SWD依然能连上,但你会发现程序下载后不运行。商用开发板通常默认把BOOT0拉到GND,但很多自己焊的小板子会把BOOT0这根跳线帽插反,结果就是“能下载不能运行”。

串口ISP是另一种烧录方式,不需要调试器,只要板载USART1接出串口,BOOT0设为1、按复位键,再用FlyMcu或STM32CubeProgrammer就能下载。这种方式适合产品现场升级,但速度慢,也没有仿真调试功能,不建议作为日常开发主力。

调试还有一个看似不起眼但非常关键的细节:3.3V供电。F1是3.3V逻辑,如果你把USB转TTL模块直接接到串口上,很多时候模块是5V电平,长期用会伤害引脚,至少要在串口线上串电阻分压。下载器选ST-Link V2时也注意别让VCC线短路,接错线烧芯片是很多人的“第一次硬件事故”。

3. 时钟、定时器与时间基准

3.1 从8MHz外部晶振到72MHz的时钟路径

F1的默认外部晶振一般是8MHz,但芯片上电后不是直接跑72MHz的,它内部有一条完整的时钟路径:外部高速时钟HSE进入,经过PLL锁相环倍频,再经过AHB预分频、APB1预分频、APB2预分频,最后分配到各个外设。STM32F103的典型配置是PLL倍频9倍,也就是8MHz×9=72MHz作为系统主频SYSCLK,AHB不分频得到HCLK=72MHz,APB1分频2得到PCLK1=36MHz,APB2不分频得到PCLK2=72MHz。

这个路径不是记住就完事,实际用起来有几个点特别容易错。首先,F103的APB1最高只能到36MHz,所以挂在APB1上的USART2/3、I2C、SPI2、TIM2/3/4,时钟基准都是36MHz或者按分频关系换算。其次,APB1分频不等于1时,定时器时钟会自动变成PCLK1的两倍,这是F1时钟树设计里的一个隐蔽规则,很多人用TIM2做延时按36MHz算,实际它的计数时钟是72MHz,算出来的定时间隔直接差一倍。

用寄存器配置时钟时,常见流程是先开HSE,等HSE稳定后配置PLL倍频,然后等待PLL锁定,再切换SYSCLK为PLL输出。这些步骤在标准外设库里由SystemInit函数完成,新手如果想深挖可以看system_stm32f10x.c文件。我的建议是:日常开发用库函数自动配置即可,但心里要清楚自己芯片的时钟树结构,否则外设的超时、波特率算错都不知道错在哪。

3.2 用SysTick和定时器构建准确的微秒延时

DHT11这类单总线传感器最怕延时不准。F1的HAL库自带HAL_Delay是毫秒级的,你直接拿来做微秒延时就是灾难。标准外设库的Delay也是自己写的,质量参差不齐。所以驱动DHT11前,先把时间基准搞定。

最简单的方案是使用SysTick,它是Cortex-M3内核自带的24位递减计数器。如果SysTick时钟源是HCLK,那么72MHz下延时1us需要计数72个周期。代码可以这样写:

static void delay_us(uint32_t us) { SysTick->LOAD = 72 * us; SysTick->VAL = 0; SysTick->CTRL = SysTick_CTRL_ENABLE_Msk; while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk)); SysTick->CTRL = 0; }

但问题来了,SysTick经常被RTOS或HAL库占用,随意重配置会破坏系统节拍。更稳妥的方案是使用DWT的周期计数器,它不占用外设定时器,也不会和SysTick冲突:

static void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } static void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * 72; while ((DWT->CYCCNT - start) < ticks); }

DWT这种方式我实测下来非常稳定,唯一要注意的是它基于SYSCLK频率,如果你改时钟树,延时系数要跟着改。另一个很多人忽视的问题是:编译器优化级别不同,普通的for循环空转延时时间会变。你以为写了个2us延时,开O3优化之后可能直接变成0.2us,整个DHT11时序全乱。所以我一直坚持用硬件计数器做延时,绝不依赖空循环。

如果你需要做更长的时间基准,比如500ms采样周期、超时判断,用通用定时器更合适。把TIM2的预分频设成71,计数时钟就是1MHz,再用输入捕获或比较中断即可。

4. 实战:用STM32F1读取DHT11温湿度

4.1 DHT11单总线协议怎么理解

DHT11只有3到4个引脚,常见模块是VCC、DATA、GND,有些还有NC。供电范围是3.3到5.5V,但DATA线输出高电平和供电电压有关,接3.3V的F1时最好也用3.3V供电,否则传感器输出的高电平可能超过MCU引脚容忍范围。

DATA线在空闲时是高电平,通信由主机发起。主机先拉低数据线,要持续至少18毫秒,一般我们做20毫秒,然后释放,上拉电阻会把线拉高。传感器收到这个起始信号后,会先拉低约80us表示响应,再拉高约80us,然后开始输出40位数据。

40位数据的排列是:湿度整数8位、湿度小数8位、温度整数8位、温度小数8位、校验和8位。校验和等于前4个字节相加的低8位,如果算出来不匹配,这组数据就直接丢弃,不要做任何补救。实际使用中DHT11的湿度整数范围是20%到90%,温度范围是0到50℃,小数位在部分型号上一直是0也没关系,协议本身就这么定的。

判断“0”和“1”的关键是看高电平的持续时间。每一位先由传感器拉低约50us,然后释放。如果随后高电平只持续26到28us左右,这就是“0”;如果高电平持续70us左右,这就是“1”。所以主机读位的核心就是:等待数据线从低变高,然后延时约30us再读引脚,如果还是高电平,说明是高电平更宽的那个位,判为1,否则判为0。

4.2 代码实现:起始信号、数据读取与校验

我以标准外设库为例,假设DHT11的DATA接在PB0上。第一步实现引脚模式切换,起始信号阶段需要推挽输出,等待阶段需要上拉输入:

#define DHT11_PORT GPIOB #define DHT11_PIN GPIO_Pin_0 static void DHT11_Pin_Out(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, &GPIO_InitStructure); } static void DHT11_Pin_In(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_PORT, &GPIO_InitStructure); }

发送起始信号时,在输出模式下拉低至少18ms,再拉高30us左右,然后切回输入模式:

void DHT11_Start(void) { DHT11_Pin_Out(); GPIO_WriteBit(DHT11_PORT, DHT11_PIN, Bit_RESET); delay_us(20000); GPIO_WriteBit(DHT11_PORT, DHT11_PIN, Bit_SET); delay_us(30); DHT11_Pin_In(); }

读一位的函数我建议返回int类型,用-1表示超时。逻辑是:先等待当前位低电平结束,然后延时30us判断高低,再等待当前位高电平结束,保证下次调用时引脚正好处于低电平阶段:

static int DHT11_ReadBit(void) { uint16_t timeout = 0; while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == RESET) { if (++timeout > 100) return -1; delay_us(1); } delay_us(30); int level = GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN); timeout = 0; while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == SET) { if (++timeout > 100) return -1; delay_us(1); } return level == SET ? 1 : 0; }

读一个字节就是把8个位拼起来:

static int DHT11_ReadByte(void) { int value = 0; for (int i = 7; i >= 0; i--) { int bit = DHT11_ReadBit(); if (bit < 0) return -1; value = (value << 1) | bit; } return value; }

最后在读取函数里拿到5个字节并做校验:

uint8_t DHT11_ReadData(uint8_t *humi_int, uint8_t *humi_dec, uint8_t *temp_int, uint8_t *temp_dec) { int data[5]; DHT11_Start(); for (int i = 0; i < 5; i++) { data[i] = DHT11_ReadByte(); if (data[i] < 0) return 1; } uint8_t checksum = (uint8_t)(data[0] + data[1] + data[2] + data[3]); if (checksum != (uint8_t)data[4]) return 2; *humi_int = (uint8_t)data[0]; *humi_dec = (uint8_t)data[1]; *temp_int = (uint8_t)data[2]; *temp_dec = (uint8_t)data[3]; return 0; }

主循环里调用时记得加延时,一次读取结束至少等1.5到2秒再发起下一次。很多人第一次调DHT11失败,不是因为代码逻辑错,而是连续读取太快,传感器还没准备好。

4.3 上拉电阻、时钟误差和采样周期的坑

先说说拉电阻。F1内部上拉电阻阻值大约在30到50kΩ,单看阻值偏大,DHT11数据线在切换状态时恢复速度会偏慢。如果你用内部上拉模式,在长线上或者供电波动大的时候,读出来的位可能有错误。最稳妥的做法是直接在DATA线上外部接一个4.7kΩ到10kΩ的上拉电阻到3.3V,这样信号边沿干净很多。很多DHT11模块板上已经带了上拉电阻,买模块时确认一下即可。

再就是时钟误差。我前面说delay_us用DWT或SysTick来写,就是因为DHT11的时序参数范围其实挺友好:起始低电平是18ms到30ms之间都行,响应等待也是几十us的范围,但位判断里那个30us的基准点一旦偏差太大,就会把0误判成1。如果你用的延时函数是基于for循环且开了优化,可以试试在优化等级变化之后传感器读数是跳变的,这就是延时被编译器改掉的表现。

采样周期这个坑很多人一开始想不到。DHT11不是随时随地都能输出的传感器,它内部测量周期大约是1秒,你如果像读I2C设备那样高频去读,第二次发出起始信号时传感器根本没准备好响应,读回来的数据全是0xFF或者校验失败。我在项目里统一把采样周期放在2秒,稳定后就没再出过偶发异常。

还有一个小体验:DHT11上电后第一次读取经常失败,尤其是传感器和MCU同时上电的时候。建议程序启动后先延时2秒左右再开始第一轮读取。如果第一轮失败,不要立刻重试,等一个完整采样周期后再试。这个处理在实际产品里非常重要。

5. 新手常遇到的问题排查与调试经验

5.1 硬件和环境问题排查表

现象可能原因解决办法
程序下载后不运行BOOT0引脚被拉高把BOOT0拉低后复位
ST-Link连不上芯片SWD线太长或接触不良缩短杜邦线,降低SWD频率
引脚没输出外设时钟没打开检查RCC_APB2PeriphClockCmd是否调用
PB3/PB4电平异常默认是JTAG功能禁用JTAG并开启SWJ重映射
芯片发烫或电流异常3.3V和5V接反断电检查电源电路,必要时换芯片
串口乱码波特率配置和系统时钟不匹配核对SYSCLK是否为72MHz,查时钟配置

5.2 软件和时序问题排查表

现象可能原因解决办法
DHT11读出来都是0xFF传感器没响应或线没接对检查DATA线接的是哪个引脚,上电等待2秒
温度湿度偶尔突变校验和失败后没有丢弃数据严格要求校验和一致才采用数据
优化级别改了就出错用了空循环做微秒延时改用SysTick、定时器或DWT延时
二次读取失败采样间隔太短主循环间隔至少1.5秒,推荐2秒
高电平持续时间不对上拉电阻缺失或阻值过大在DATA线上外接4.7kΩ上拉

5.3 几个实测好用的调试验证方法

调试DHT11这类单总线设备,最推荐的工具是逻辑分析仪,即使是最便宜的24MHz采样率的也行。把探针夹在DATA线上,抓一段起始和响应波形,你能直接看到低电平持续时间、高电平持续时间,以及每一位是26us还是70us。肉眼一对比就知道问题在延时函数还是电路连接。

没有逻辑分析仪的情况下,可以用串口打印调试。把读到的每一位原始电平持续时间打印出来,虽然累,但在没有工具的时候也能定位问题。我自己调试时还会把校验和计算每一步的中间值打印出来,比如前4个字节的和,这样能快速判断是数据采集错误还是校验逻辑错误。

还有一个很多人问的细节:GPIO切换模式用标准外设库的GPIO_Init函数,会不会太慢?实测确实比直接操作CRL寄存器慢一些,但对DHT11这个量级来说完全够用。如果你要追求极致精度,可以自己封装一个直接改CRL寄存器低位4位配置的宏,一次切换只要两三行赋值语句:

#define DHT11_MODE_INPUT() GPIOB->CRL = (GPIOB->CRL & ~0x0F) | 0x08 #define DHT11_MODE_OUTPUT() GPIOB->CRL = (GPIOB->CRL & ~0x0F) | 0x03

这里0x08对应上拉输入,0x03对应50MHz推挽输出。这种方式速度更快,但可读性差,适合你已经完全理解寄存器之后再用。

最后再分享一个我自己的体会。F1配合DHT11这个组合看起来简单,但它实际上把GPIO模式切换、微秒延时、单总线时序、校验和计算全串在了一起。要是能独立调通这个小项目,后面去玩DHT22、DS18B20、红外遥控、甚至NEOPixel灯带都会顺畅很多。很多人会觉得DHT11精度一般、采样率低,不如一步到位用SHT30,但从学习角度讲,DHT11的时序反而更适合第一课,因为它慢、容错范围大,不容易一开始就劝退人。等你在F1上把这套时序稳稳跑通,再去看那些更快的传感器,心态会完全不一样。

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

运放跟随器自激振荡:容性负载导致相位裕度崩溃的实战解析

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

作者头像 李华
网站建设 2026/10/6 7:07:32

Windows下用CLion搭建ESP32开发环境:ESP-IDF配置实战指南

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

作者头像 李华
网站建设 2026/10/6 7:06:33

MOS管当二极管用:原理、仿真与电源防反接实战

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

作者头像 李华
网站建设 2026/10/6 7:05:48

SAP HANA Studio建模实战:从安装到Column View交付

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

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

MOS管栅极电阻并联二极管的作用与方向选择:以AO3400A为例

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

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

华为GVRP实验配置全解析:从VLAN自动注册到Trunk链路实战

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

作者头像 李华