news 2026/9/7 8:51:16

STM32 GPIO模拟串口实战:从原理到代码实现与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 GPIO模拟串口实战:从原理到代码实现与避坑指南

简介:STM32的GPIO口模拟串口通信是一份面向嵌入式开发者的实战资源,重点解决MCU缺少硬件UART或串口资源不足时的通信替代方案,适合STM32入门及进阶学习者、项目移植人员参考。内容围绕GPIO模拟串口的核心原理展开,涵盖IO配置、软件定时器产生波特率、发送接收时序、中断检测、错误处理与软流控等完整链路。资源包共133个文件,约775KB,以C源文件、头文件、Keil工程文件为主,另含多个辅助脚本、说明文档及工程备份工具,可帮助读者快速对照工程代码理解实现细节。已有3722人学习下载。通过学习可掌握无硬件串口环境下基于GPIO与软件定时器实现可靠串行通信的方法,并可将代码迁移至不同STM32型号或自定义波特率场景。 做项目最怕的就是硬件串口不够用。前段时间调试一个设备,主串口拿去跟Wi-Fi模块通信,调试串口要打印日志,第二个串口又得分给传感器,翻完芯片手册发现USART已经全部用完了,板子上还剩一把GPIO闲着。于是只好硬着头皮用GPIO去模拟串口通信,也就是平时大家说的软件串口、bit-banging。折腾了一周,把发送、接收、误码、中断优先级这些问题全部踩了一遍,最后算是稳定跑起来了。这篇文章就把我的实现思路、代码和踩过的坑完整记录下来,给遇到同样问题的朋友一个参考。如果你正面临硬件串口不够用、引脚被占用、或者想省一颗外挂UART芯片的钱,这篇文章应该能帮上忙。

1. 为什么非得拿GPIO去模拟串口

1.1 硬件串口的"不够用"时刻

STM32的USART数量是固定的,F103C8T6只有3个串口,F407也就6个。实际项目里串口永远不够用:蓝牙模块占一个、GPS占一个、调试日志占一个、上位机通信又占一个,还没接传感器就没口了。有些引脚还有重映射冲突,比如你想用的那个USART引脚已经被ADC或者PWM占了,这时候重新画板子不现实,换芯片成本又高。用GPIO模拟串口就成了最直接的解法。

我这次的情况是:手上有一批F103C8T6的最小系统板,SPI、I2C、USART1和USART2全都被占用,唯一的USART3引脚又被我设计成了LED控制,板子已经打样不能改。最后看到PB10和PB11还空着,就决定拿这两个脚模拟一个串口,波特率不需要太高,9600就能满足传感器数据回传的需求。

1.2 GPIO模拟串口和硬件UART的差别

拿GPIO模拟串口本质上是把物理层的时序用代码一点点"挤"出来,而硬件UART是芯片内部的外设模块自动完成这件事。两者各有取舍,我整理了一张对比表:

对比项硬件UARTGPIO模拟串口
波特率上限可达数Mbps受主频和代码效率影响,一般建议115200以内
CPU占用极低,硬件自动收发较高,每一位都要CPU参与延时或中断处理
可靠性高,带硬件FIFO和错误检测较低,受中断抢占和代码时序影响大
引脚选择受芯片设计约束任意GPIO均可
调试难度高,逻辑分析仪几乎是必需品
适用场景大多数常规通信串口资源不足、临时方案、低频低速通信

从表格可以看出来,模拟串口不是万能替代品,它最适合的是低速、短距离、数据量不大的场景。我这次只需要每隔100ms上报一次温度数据,一帧不超过20字节,9600波特率完全够用,CPU占用也很低,这才敢放心用模拟方案。

1.3 适合用GPIO模拟串口的典型场景

总结下来,这几种情况用GPIO模拟串口是划算的:一是硬件串口确实用完,且来不及改板;二是只需要单向通信,比如只发不收或者只收不发,实现难度直接减半;三是跟外部设备临时调试对接,比如手边没有USB转TTL工具,直接拿GPIO翻转电平配一个简易波特率发生器;四是在低功耗场景下,不想给闲置的硬件串口供时钟,用GPIO按需唤醒收发几帧数据就继续睡。

但如果是高速通信(波特率超过115200)、长帧传输(一帧超过几十字节)、或者通信双方距离较远,还是老老实实换芯片或者加一颗MAX3107这类外部UART芯片更靠谱。GPIO模拟串口的CPU占用会随着波特率和数据量直线上升,这是硬约束,别硬扛。

2. 动手前必须懂的UART底层原理

2.1 UART一帧到底长什么样

串口通信的物理层在空闲时是高电平,这是约定俗成的"静止状态"。要发一个字节,先拉低电平,持续一个位时间,这叫起始位,用来告诉接收方"我要开始发数据了"。然后依次输出8个数据位,低位在前、高位在后。最后拉高电平,持续一个位时间或更长,这是停止位。如果加了校验位(奇校验或偶校验),就插在数据位和停止位之间。

这一帧结构必须刻在脑子里,因为后面所有的代码都是在跟这个时序打交道:高→拉低(起始位)→逐位翻转电平(数据位,LSB first)→拉高(停止位)。只要有一个位的时间不对,对端就解析出错,轻则乱码,重则整帧丢掉。

2.2 波特率、位时间与采样点

波特率就是每秒传输的位数,单位是bps。9600bps意味着每一位持续的时间是 1/9600 ≈ 104.17微秒,115200bps每一位大约8.68微秒。模拟串口发送的本质就是:把每个位持续的时间用延时函数"撑住",到点就翻电平;接收则是反过来,在每一个位时间的中间点去读引脚电平,因为位中间是信号最稳定的位置,远离跳变沿,不容易采到不确定电平。

这里有个关键认知:模拟接收时并不是在起始位的下降沿立刻采样,而是要延后大约1.5个位时间。为什么?因为下降沿只是告诉你"开始",第一个数据位真正稳定出现要到起始位结束之后。延后1.5个位时间采样,正好落在第一个数据位的中间,往后每隔一个位时间再采一次,就能依次拿到8个数据位。这个1.5倍的偏移量是模拟串口接收的灵魂。

2.3 GPIO工作模式怎么选

STM32的每个GPIO都有8种工作模式(热词里也总被问到),分别是:浮空输入、上拉输入、下拉输入、模拟输入、开漏输出、推挽输出、开漏复用输出、推挽复用输出。模拟串口用得上的其实就几种,别被8种模式吓到。

发送引脚配置成推挽输出最省事,输出高电平就是高电平,输出低就是低,驱动能力强,不需要外部上拉。如果通信双方电平不匹配(比如STM32是3.3V,对端是5V设备),可以改用开漏输出,再接一个上拉电阻到对端需要的电平,这样电平转换的问题顺便就解决了。接收引脚配置成上拉输入浮空输入都可以,我习惯用上拉输入,因为空闲态是高电平,上拉输入能保证引脚闲着时不会悬空乱跳,误触发起始位中断。

顺带说一句GPIO和Pin的关系:GPIO是端口(比如GPIOB),Pin是具体的引脚(比如Pin10),代码里写GPIOB->BSRR = GPIO_Pin_10这样的操作,本质就是操作GPIOB端口的第10位。理解了这个映射关系,配置GPIO时思路会清晰很多。

3. 发送端实现:按位灌电压,延迟控节拍

3.1 发送的核心逻辑

发送比接收简单得多,因为主动权在自己手里。第一个数据位是起始位,输出低电平;紧接着要发8个数据位,LSB在前,每次判断这一位是0还是1,把引脚拉低或拉高;最后输出停止位高电平。每一位之间都要延时一个位时间,保持电平稳定,让接收方有时间采样。

具体到代码,核心就是一个bytectrl(字节控制)函数。在硬件配置上,我习惯用一个宏定义把引脚号、端口封装好,方便不同板子之间移植。下面是基于标准库的完整实现,芯片是STM32F103C8T6,结构跟用户提供的代码风格保持一致。

3.2 发送代码实现

#include "stm32f10x.h" #include "systick_delay.h" // 自己封装的微秒级延时 /* ======================= 硬件配置 ======================= */ #define SOFT_TX_GPIO_PORT GPIOB #define SOFT_TX_GPIO_PIN GPIO_Pin_10 #define SOFT_TX_GPIO_CLK RCC_APB2Periph_GPIOB #define SOFT_TX_HIGH() GPIO_SetBits(SOFT_TX_GPIO_PORT, SOFT_TX_GPIO_PIN) #define SOFT_TX_LOW() GPIO_ResetBits(SOFT_TX_GPIO_PORT, SOFT_TX_GPIO_PIN) #define BAUDRATE 9600 #define BIT_TIME_US (1000000 / BAUDRATE) // 104us @9600 static void Soft_TX_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(SOFT_TX_GPIO_CLK, ENABLE); GPIO_InitStructure.GPIO_Pin = SOFT_TX_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 高速翻转,减少边沿失真 GPIO_Init(SOFT_TX_GPIO_PORT, &GPIO_InitStructure); SOFT_TX_HIGH(); // 空闲状态保持高电平 } void Soft_UART_SendByte(uint8_t byte) { uint8_t i; SOFT_TX_LOW(); // 起始位 delay_us(BIT_TIME_US); for (i = 0; i < 8; i++) { if (byte & 0x01) { SOFT_TX_HIGH(); } else { SOFT_TX_LOW(); } byte >>= 1; delay_us(BIT_TIME_US); } SOFT_TX_HIGH(); // 停止位 delay_us(BIT_TIME_US); }

这段代码的逻辑非常直白:先拉低输出起始位,然后循环8次,每次取当前最低位输出,再右移一位,每一位输出后延时一个位时间,最后拉高放停止位。用byte & 0x01判断当前要发的位,用右移操作逐位取出,这个写法最直观,也方便调试。

3.3 微秒延时精度是发送成败的关键

发送方最怕的就是延时不准。delay_us我建议用SysTick或者TIM定时器实现,不要用简单的空循环计数,因为空循环的延时时间会随着编译器优化等级变化,同一个延时函数在-O0和-O2下时间能差出两倍。用SysTick的SysTick_Config配置一个1MHz的计数器,每次延时就是纯粹的硬件计数,不受软件优化影响。

另外提醒一个坑:如果你在中断服务函数里调用发送函数,优先保证这个中断不能被更高优先级的中断打断太多次。GPIO模拟串口要求每一位的延时尽量精确,一旦在延时的104微秒中间被一个耗时50微秒的中断插一脚,这一位的电平持续时间就变成了154微秒,对端立刻采样错位。所以在发送过程中,要么临时屏蔽不重要的中断,要么就把模拟串口的发送函数放到一个足够高的优先级上下文去执行。

4. 接收端实现:起始位下降沿捕获+定时器采样

4.1 接收的核心难点

接收比发送难一个数量级。发送方知道什么时候输出每一位,接收方不知道,只能靠"听"——先听到下降沿(起始位),然后数着时间点去采样后面的数据位。如果采样点偏了,或者中途被中断打断,这一帧就废了。

我实现接收用了两种思路对比。第一种是纯延时查询:外部中断检测到起始位下降沿之后,进入一个阻塞式的循环,用delay_us依次在1.5倍位时间、2.5倍位时间……去读引脚电平。这种方式代码最简单,但有一个致命问题:如果这时候来了一个串口接收中断,采样时序全乱。所以我后来改成第二种方案:外部中断检测起始位,然后用定时器的周期中断去采样,这样即使有短暂中断插入,只要定时器中断优先级够高,采样点就不会被其他中断挤掉。

4.2 外部中断+定时器采样的完整实现

接收引脚用PB11,配置成上拉输入,同时开启EXTI外部中断,检测下降沿触发。定时器用TIM3,配置周期正好等于一个位时间。检测到起始位下降沿后,启动定时器,并且让定时器在第一次溢出时"晚半拍"——这里我用了一个小技巧,启动定时器时把ARR设置成1.5倍位时间,第一次溢出正好落在第一个数据位的中间,之后再把ARR改回1倍位时间,这样就不需要单独的"半位延时"代码了。

/* ======================= 硬件配置 ======================= */ #define SOFT_RX_GPIO_PORT GPIOB #define SOFT_RX_GPIO_PIN GPIO_Pin_11 #define SOFT_RX_GPIO_CLK RCC_APB2Periph_GPIOB #define SOFT_RX_EXTI_LINE EXTI_Pin_11 #define SOFT_RX_EXTI_PORT EXTI_Source_GPIOB #define SOFT_RX_EXTI_PIN EXTI_PinSource11 #define SOFT_RX_READ() GPIO_ReadInputDataBit(SOFT_RX_GPIO_PORT, SOFT_RX_GPIO_PIN) #define BAUDRATE 9600 #define BIT_TIME_US (1000000 / BAUDRATE) #define HALF_BIT_US (BIT_TIME_US / 2) #define ONE_AND_HALF (BIT_TIME_US + HALF_BIT_US) /* 接收状态机 */ static volatile uint8_t rx_state = 0; // 0:空闲 1:起始位确认 2:接收数据 static volatile uint8_t rx_bit_idx = 0; static volatile uint8_t rx_byte = 0; static volatile uint8_t rx_done = 0; static volatile uint8_t rx_buffer = 0; /* 定时器预分频和周期配置:APB1时钟36MHz,预分频36,计数周期1us */ #define TIM3_PRESCALER (36 - 1) static void Soft_RX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(SOFT_RX_GPIO_CLK, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin = SOFT_RX_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入 GPIO_Init(SOFT_RX_GPIO_PORT, &GPIO_InitStructure); GPIO_EXTILineConfig(SOFT_RX_EXTI_PORT, SOFT_RX_EXTI_PIN); EXTI_InitStructure.EXTI_Line = SOFT_RX_EXTI_LINE; EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; // 下降沿触发起始位 EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 高优先级 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); } static void Soft_RX_TIM_Init(void) { TIM_TimeBaseInitTypeDef TIM_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_InitStructure.TIM_Period = BIT_TIME_US - 1; // 默认一个位时间 TIM_InitStructure.TIM_Prescaler = TIM3_PRESCALER; TIM_InitStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_InitStructure); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; // 最高优先级,保证采样不被挤掉 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }

中断服务函数写在下面。外部中断负责捕捉起始位下降沿,定时器中断负责按节奏采样。有一点要注意,接收数据的起始位验证:下降沿触发后我们不能立刻确定这就是起始位,可能只是干扰毛刺,所以我在定时器第一次中断时又读了一次引脚,确认还是低电平才进入数据接收状态,这样就滤掉了一部分噪声。

void EXTI15_10_IRQHandler(void) { if (EXTI_GetITStatus(SOFT_RX_EXTI_LINE) != RESET) { EXTI_ClearITPendingBit(SOFT_RX_EXTI_LINE); if (rx_state == 0 && SOFT_RX_READ() == 0) { /* 检测到下降沿,准备采样第一个数据位中间点 */ rx_state = 1; TIM_SetAutoreload(TIM3, ONE_AND_HALF - 1); // 先等1.5个位时间 TIM_SetCounter(TIM3, 0); TIM_Cmd(TIM3, ENABLE); // 启动定时器采样 } } } void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if (rx_state == 1) { /* 第一次中断,确认当前确实还是低电平(起始位有效) */ if (SOFT_RX_READ() == 0) { rx_state = 2; rx_bit_idx = 0; rx_byte = 0; TIM_SetAutoreload(TIM3, BIT_TIME_US - 1); // 后面的采样间隔改为一个位时间 TIM_SetCounter(TIM3, 0); } else { rx_state = 0; TIM_Cmd(TIM3, DISABLE); } } else if (rx_state == 2) { if (rx_bit_idx < 8) { /* 依次采样8个数据位 */ if (SOFT_RX_READ()) { rx_byte |= (uint8_t)(0x01 << rx_bit_idx); } rx_bit_idx++; } else { /* 采样停止位:理论上应为高电平,若为低说明这帧数据可能有问题 */ if (SOFT_RX_READ() == 1) { rx_buffer = rx_byte; rx_done = 1; } rx_state = 0; TIM_Cmd(TIM3, DISABLE); } } } }

4.3 状态机设计的一点心得

很多朋友写模拟串口接收,喜欢用一堆if堆逻辑,结果越写越乱。我建议一开始就按状态机来设计,哪怕简单一点也值得。状态0是空闲,只等下降沿;状态1是起始位确认,只做一件事;状态2是数据位收集,循环8次收完为止。每一个状态只干属于自己的活,出错了回到状态0重新等起始位,这样即使收到一个坏帧,下一个帧还能继续收。

这里第一个容易踩的坑是定时器中断里千万不要调用任何耗时的函数,比如printfdelay_usGPIO_ToggleBits带的反逻辑操作。定时器中断每一段时间就进来一次,你多耗一微秒,采样点就偏一微秒,几脚下来整帧就废了。我调试的时候用了一个很笨的办法:在定时器中断里给一个全局变量置标志,主循环里看到标志再打印,这样既能看到收了多少字节,又不干扰采样时序。

第二个坑是整体规划好中断优先级。我把定时器(采样)优先级设成0(最高),外部中断(起始位检测)设成1,这样即使外部中断正在执行,定时器也能打断它完成采样;反过来,如果主程序里有一些次要的中断想插队,也插不进采样中断里。优先级配反了,数据就会乱。

5. 实战中的常见问题与排查实录

5.1 波特率误差导致的乱码

最典型的问题就是波特率不一致。GPIO模拟的波特率取决于延时函数的精度,如果你的延时函数是空循环写的,在不同优化等级下编译出来的延时时间会变化,实际波特率可能不是9600而是9000,对端自然收不到正确数据。

排查方法是使用逻辑分析仪(没有的话用示波器)直接看波形,测量起始位到停止位的实际时间。如果实测位时间差超过5%,就要检查延时代码。另外,两个设备之间通信,建议发送方和接收方都把自己测出来的波特率误差控制在±2%以内,这比理论上的最大容限更保守,尤其是在连续传输长帧时,误差会逐位累计,最后一位的采样点偏移很容易超出容限。

5.2 延时函数卡死的问题

热词里经常看到"stm32延时函数delay卡死"这类搜索,我在这次调试中也被坑了一次。排查下来发现,卡死的原因是SysTick的优先级被配置成了最低,主程序里正好有一段关全局中断的临界区代码,SysTick一直得不到执行,而模拟串口又在等延时时间到,两边就这么干瞪眼。

要解决这个问题,一是SysTick优先级尽量配高,二是不要在关中断的保护区内调用模拟串口的收发函数。如果确实需要原子操作,先把要发的字节放进缓冲区,出了临界区再逐字节发送。这是很多人容易忽略的细节,但实际项目中很致命。

5.3 接收丢字节和中断优先级配置

异常现象可能原因排查与解决方法
收不到任何字节接收引脚配置成下拉或浮空,空闲态不稳定改成上拉输入,确保空闲为高电平
只能收到第一个字节状态机卡在数据接收状态,没有复位每次完成或出错都要回到状态0
数据偶发错误(个别位反了)采样点偏移,或定时器中断被其他中断抢占用示波器测量位时间,观察采样点位置;提高定时器中断优先级
发送正常,对方收不到发送引脚推挽输出驱动能力不足或多个输出源冲突检查引脚是否被其他外设复用(AFIO配置冲突)
通信双方波特率明明一样还是乱码双方的时钟精度差异或延时函数误差累积用逻辑分析仪实测双方波形,校准延时

5.4 电平不匹配和接线问题

STM32是3.3V逻辑,如果你要对接的传感器模块是5V TTL电平,直接用推挽输出怼过去有烧引脚的风险,改用开漏输出加外部上拉到5V是最稳的方案。接收端如果外设输出5V电平,GPIO配置成上拉输入会有容忍风险,F103其实并不完全5V兼容,稳妥做法是加一个电阻分压(比如2K+1K),或者串一个限流电阻再接引脚。

另外接线越短越好,模拟串口的时序本来就不如硬件UART稳定,杜邦线拉个20厘米,波形上会有明显的振铃和过冲,轻则偶发误码,重则完全收不到数据。我一般控制在10厘米以内,并且收发共地,这是串口通信最基础也最容易被忽略的点。

最后再分享一点我的实际体会

用GPIO模拟串口这件事,说难也难,说简单也简单。难在接收端的时序和状态机处理,简单在发送端只要会翻电平就行。我在这次项目里总共花了两天时间调试接收,最后稳定跑起来之后,CPU占用在9600波特率下几乎可以忽略,20字节一帧的数据收发完全无压力。即便后来调高了波特率到38400,也只是占用多一些CPU,功能上依然能跑。这里给后来的朋友两个建议:第一,发送端尽量用硬件定时器做延时,不要依赖空循环;第二,接收端一定用中断加定时器采样的方案,别图省事用纯阻塞查询。另外,调试工具里逻辑分析仪比示波器好用得多,能直接解析UART帧,省去数波形的时间。如果你正要接一个类似的活儿,希望这篇记录能让你少走几步弯路。

本文还有配套的精品资源,点击获取

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

Python开发Word转PDF批量转换桌面工具实战:PySide6与win32com详解

简介&#xff1a;这是一份基于 PySide6 的 Word 转 PDF 桌面应用脚本&#xff0c;面向需要频繁处理文档格式转换的办公人员&#xff0c;也很适合正在学习 Python 图形界面开发的初学者。脚本借助 docx2pdf 库调用 Word 底层的转换能力&#xff0c;利用 PySide6 构建直观的交互界…

作者头像 李华
网站建设 2026/9/7 8:47:13

WITSML实战指南:钻井数据交换标准解析与常见坑

简介&#xff1a;WITSML通信实现代码包&#xff0c;面向石油天然气行业.NET开发者&#xff0c;聚焦C#环境下基于XML、SOAP和HTTP协议的井数据交换&#xff0c;适合正在集成WITSML服务接口的工程师参考。资源共52个文件&#xff0c;压缩包仅74KB&#xff0c;主体为.cs源文件、.c…

作者头像 李华
网站建设 2026/9/7 8:45:48

5分钟完成Codex安装配置:AI编程助手从入门到实战

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

作者头像 李华