news 2026/9/4 19:23:46

STM32F4驱动ADS8860:16位ADC数据采集的硬件设计与软件实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F4驱动ADS8860:16位ADC数据采集的硬件设计与软件实现

简介:本资源是一套基于STM32F4系列微控制器与TI ADS8860高精度16位ADC的SPI通信完整工程实现,面向嵌入式开发者、高校电子类专业学生及工业数据采集系统设计人员,解决高速模拟信号数字化采集与MCU协同控制的核心问题。压缩包共26个文件,含8个C源文件(如ads8860.c、spi.c、main.c)负责驱动开发与主控逻辑,8个头文件(ads8860.h、spi.h等)定义接口与寄存器映射,1个.ioc配置文件(CubeMX生成)、1个.uvprojx工程文件及配套调试配置,整体仅65KB,轻量但结构完整,便于快速移植与二次开发。已有811人学习下载。资源提供可直接编译运行的Keil MDK工程,涵盖ADS8860初始化、SPI时序精准配置(含CPOL/CPHA适配)、连续采样模式实现、16位数据解析与串口输出验证全流程,代码注释清晰,目录模块分明,是掌握高精度ADC与STM32深度协同开发的典型实践范例。

1. 项目概述:当STM32F4遇上ADS8860

最近在做一个需要高精度数据采集的项目,核心需求是测量一个缓慢变化的直流电压信号,精度要求达到16位,采样率倒是不用太高,几十KSPS就够用。选型的时候,TI的ADS8860这颗16位、1MSPS的SAR型ADC就进入了视野。它性能足够,接口是标准的SPI,看起来和STM32搭配是天作之合。但真动起手来,才发现从原理图到代码,从时序匹配到噪声处理,处处是细节。这个项目,我把它称为“ADS8860-STM32F4-master”,核心就是搞定这颗ADC,让STM32F4作为主机,通过SPI稳定、准确地读取到ADS8860转换的数据。整个过程下来,积累了不少实战经验,尤其是如何利用STM32的硬件SPI和DMA来优化性能,以及如何处理SPI通信中那些恼人的时序问题,今天就来详细拆解一遍。

2. 核心芯片选型与电路设计解析

2.1 为什么是ADS8860与STM32F4的组合?

在嵌入式数据采集领域,芯片选型决定了项目的天花板。我选择ADS8860,主要看中它几个点:首先是16位分辨率,对于大多数工业传感器(如压力、温度变送器)输出的0-5V或0-10V标准信号,能提供足够的量化精度,LSB(最低有效位)电压足够小。其次是SAR(逐次逼近寄存器)架构,它不像Σ-Δ型ADC需要复杂的数字滤波器,转换是“立即完成”的, latency很低,非常适合需要实时响应的控制系统。最后就是它简单的SPI接口,与MCU对接非常方便。

而MCU选择STM32F4系列,比如F407或F429,原因也很直接。一是性能足够,Cortex-M4内核带FPU,在需要对采集数据进行实时滤波或运算时游刃有余。二是其丰富的外设,特别是高速SPI接口(理论上可达37.5MHz以上)和强大的DMA控制器,为高速、连续的数据传输提供了硬件保障。三是生态完善,HAL库和LL库让开发效率大大提升。这个组合,可以说是兼顾了性能、成本和开发便利性。

2.2 关键外围电路设计要点

ADS8860的电路并不复杂,但几个关键点的设计直接影响最终性能。

1. 参考电压源(REF)这是精度的心脏。ADS8860需要一个外部参考电压,其精度和稳定性直接决定了ADC的精度。我强烈建议使用独立的低噪声、低温漂的基准电压芯片,如TI的REF5025(2.5V)或REF5040(4.096V)。REF5025是很好的选择,因为2.5V正好是ADS8860输入范围(0-Vref)的一半,能充分利用量程。布局上,REF引脚必须紧挨着ADC芯片,并用一个1μF的陶瓷电容和一个10μF的钽电容并联去耦,以滤除高频和低频噪声。

2. 模拟电源(AVDD)与数字电源(DVDD)ADS8860有独立的AVDD(模拟电源,2.7V-5.25V)和DVDD(数字电源,1.65V-AVDD)引脚。必须使用磁珠或0Ω电阻将它们隔离,并在靠近芯片引脚处分别放置去耦电容(例如,AVDD用10μF+0.1μF,DVDD用1μF+0.1μF)。这样能有效防止数字开关噪声通过电源串扰到敏感的模拟电路部分。我的方案是使用一个3.3V的LDO(如AMS1117-3.3)同时为AVDD和DVDD供电,但在进入芯片前进行隔离。如果系统有5V电源,也可以给AVDD供5V以获得更大的输入信号范围,但DVDD仍需满足其电压要求。

3. 输入信号调理ADS8860的输入是纯单端的。如果输入信号不是0-Vref的范围,就需要运放进行调理(缩放、平移)。这里要特别注意运放的选型,其带宽、压摆率、噪声指标需满足系统要求。一个经典的电路是使用一颗精密运放(如OPA2188)构成一个同相放大器或衰减器。在运放输出和ADC输入之间,建议串联一个几十欧姆的小电阻并并联一个几十pF的小电容,构成一个简单的RC低通滤波器(抗混叠滤波),同时可以抑制来自运放输出的振铃。

4. CONVST与SPI接口CONVST(转换开始)引脚是关键。它由一个GPIO控制,下降沿启动一次转换。转换期间,BUSY引脚会拉高。SPI接口方面,ADS8860支持标准SPI模式0(CPOL=0, CPHA=0)和模式1(CPOL=0, CPHA=1)。我使用的是模式0,这也是最常用的模式。需要注意的是,ADS8860的数据输出是16位的,但STM32的SPI通常配置为8位或16位数据传输。为了最高效,我们直接使用16位数据模式。

注意:电路板布局时,模拟部分(ADC、基准源、运放、模拟电源)应尽可能集中,并与数字部分(MCU、数字电源)保持距离,用地平面进行分割。模拟地(AGND)和数字地(DGND)在芯片下方单点连接,通常是ADC的GND引脚处。

3. STM32F4的SPI与DMA配置实战

3.1 SPI外设的精确配置

使用STM32CubeMX可以快速初始化,但理解每个参数背后的意义至关重要。针对ADS8860,我的配置如下:

  • 模式:Full-Duplex Master。STM32作为主机。
  • 数据大小:16 Bits。与ADS8860输出匹配。
  • 时钟极性(CPOL)与相位(CPHA):这需要根据ADS8860的数据手册确定。如前所述,我选择Low(CPOL=0)和1 Edge(CPHA=0),即SPI Mode 0。务必确认:在SCLK的第一个边沿(这里是上升沿)采样数据。
  • 片选(NSS)管理:选择“Software NSS Management”。因为ADS8860没有标准的SPI片选(/CS),它的片选功能由CONVST和BUSY信号序列替代。所以我们用一个普通GPIO(如PA4)来模拟片选是没意义的,直接禁用硬件NSS,用软件控制CONVST引脚即可。
  • 波特率预分频器:这是容易出错的地方。ADS8860的SPI接口最大时钟频率在DVDD=3.3V时典型值为20MHz。STM32F4的APB2总线时钟是84MHz(系统时钟168MHz的一半)。为了留有余量,我选择8分频,得到10.5MHz的SPI时钟,既安全又快速。计算公式是:SPI_BaudRate = APB2_Clock / Prescaler
  • 时钟极性与相位:必须与ADC严格一致。我配置为CPOL=Low, CPHA=1Edge,对应Mode 0。

在代码中,使用HAL库初始化后,关键是要正确控制时序。CONVST引脚(我用的PA5)配置为推挽输出。启动转换时,先拉低CONVST至少20ns(对于STM32的GPIO操作,这个时间远远满足),然后拉高。之后需要等待BUSY引脚(我用的PA6,配置为上拉输入)变高再变低,或者直接等待一个固定的转换时间(t_CONV,对于ADS8860在1MSPS下是710ns)。我采用查询BUSY引脚的方式,更可靠。

3.2 集成DMA实现自动连续采集

单次采集用查询或中断就够了,但要想实现稳定的高速连续采样,DMA是必需品。它能将CPU从繁琐的SPI数据搬运中解放出来,用于更重要的数据处理。

1. DMA流配置我使用SPI1的RX通道(DMA2 Stream0)。配置为外设到存储器模式,外设地址固定为&hspi1.Instance->DR,存储器地址是我们定义的数据缓冲区uint16_t adc_buffer[BUFFER_SIZE]。数据宽度为半字(16位),外设和存储器都设为非增量模式(对于存储器,我们通常希望增量,以便填充数组,但这里为了循环缓冲,先按非增量配置一个地址,稍后会在代码中处理)。最关键的是要开启DMA的循环模式(Circular Mode),这样当缓冲区填满后,DMA会自动从头开始,实现不间断采集。

2. SPI与DMA的联动配置好DMA后,在SPI初始化时使能SPI的RX DMA请求(__HAL_SPI_ENABLE(&hspi1)SET_BIT(hspi1.Instance->CR2, SPI_CR2_RXDMAEN))。采集流程变为:

  1. 启动DMA接收:HAL_SPI_Receive_DMA(&hspi1, (uint8_t*)adc_buffer, BUFFER_SIZE)
  2. 在需要启动一次转换时,拉低再拉高CONVST引脚。
  3. 关键点:必须在CONVST上升沿启动转换后,延迟片刻再通过SPI发送时钟来读取数据。因为ADC需要转换时间(t_CONV)。一种简单粗暴的方法是固定延迟1微秒(对于1MSPS,这足够了)。更精确的方法是查询BUSY引脚,但BUSY变低后,需要立即开始SPI通信。在DMA模式下,我们无法在“等待BUSY”期间让SPI暂停。因此,常用方法是使用一个定时器,在CONVST上升沿后,定时器延迟t_CONV时间,再触发一次SPI传输(实际上,由于DMA已经在等待数据,我们需要的是在正确的时间点提供SCLK)。对于ADS8860,有一个技巧:它支持“自动读回”模式。即在转换结束后,如果SCLK已有脉冲,它会自动输出数据。我们可以配置SPI在16个时钟周期后自动停止一次传输。这样,流程可以简化为:拉低CONVST -> 拉高CONVST启动转换 -> 延迟t_CONV -> 启动一次16位的SPI接收(由DMA完成)。这个“启动”可以通过软件触发,或者更好的是,用同一个定时器在延迟t_CONV后产生一个触发信号来启动SPI。

3. 双缓冲与数据处理为了避免数据处理时DMA覆盖正在读取的数据,通常使用双缓冲(Ping-Pong Buffer)或半传输中断。HAL库提供了HAL_SPI_RxHalfCpltCallbackHAL_SPI_RxCpltCallback回调函数。当DMA填充一半缓冲区时触发HalfCplt,填满时触发Cplt。我们可以在HalfCplt中断中处理前半部分数据,在Cplt中断中处理后半部分数据。这样就能实现连续不断的流水线操作。

实操心得:调试DMA+SPI时,最容易出现数据错位或全是0xFF/0x00的问题。首先用逻辑分析仪抓取CONVST、BUSY、SCLK、SDI(对STM32是MISO)的波形,对照ADS8860时序图检查。其次,检查DMA的存储器地址是否正确递增,数据宽度是否匹配。最后,确保在启动DMA接收之后,再开始产生CONVST信号和SPI时钟,否则DMA可能错过第一个数据。

4. 固件驱动层代码实现详解

4.1 底层硬件抽象层(HAL)封装

我不建议在应用层直接调用HAL_SPI_TransmitReceive这类函数。一个好的做法是封装一个ADC驱动层,向上提供简洁的接口。

首先,定义一个设备结构体,集中管理所有相关的引脚和句柄:

typedef struct { SPI_HandleTypeDef *hspi; // SPI句柄 GPIO_TypeDef *convst_port; // CONVST引脚端口 uint16_t convst_pin; // CONVST引脚 GPIO_TypeDef *busy_port; // BUSY引脚端口(如果使用查询方式) uint16_t busy_pin; // BUSY引脚 uint16_t *dma_buffer; // DMA缓冲区指针 uint32_t buffer_size; // 缓冲区大小 volatile uint8_t data_ready; // 数据就绪标志 } ADS8860_HandleTypeDef;

初始化函数ADS8860_Init会配置好GPIO,并启动DMA接收。它只做最低限度的硬件设置。

核心的读取函数有两种模式:

// 模式1:阻塞式单次读取(用于调试或低速场景) uint16_t ADS8860_ReadSingle(ADS8860_HandleTypeDef *hadc) { uint16_t adc_value = 0; // 启动转换 HAL_GPIO_WritePin(hadc->convst_port, hadc->convst_pin, GPIO_PIN_RESET); HAL_Delay(1); // 短暂延时,远大于20ns HAL_GPIO_WritePin(hadc->convst_port, hadc->convst_pin, GPIO_PIN_SET); // 等待转换完成(查询BUSY) while(HAL_GPIO_ReadPin(hadc->busy_port, hadc->busy_pin) == GPIO_PIN_SET); // 或者固定延时,例如用DWT周期计数器延时850ns // 进行SPI通信,读取16位数据 HAL_SPI_Receive(hadc->hspi, (uint8_t*)&adc_value, 1, 100); // 超时100ms return adc_value; } // 模式2:基于DMA的连续读取启动函数 void ADS8860_StartContConversion(ADS8860_HandleTypeDef *hadc) { // 确保DMA已启动并等待数据 // 然后启动一个定时器,定时触发CONVST信号和后续的SPI时钟提供 // 这部分通常与定时器中断结合 }

4.2 定时器触发与采样率精确控制

要实现精确的采样率(比如每秒10k个点),用软件延时或者SysTick是不可靠的,因为会被中断打断。最佳实践是使用STM32的高级定时器(如TIM1, TIM8)或通用定时器(如TIM2-TIM5)来产生精确的脉冲序列。

我们可以将CONVST引脚连接到定时器的某个通道(如TIM2_CH1),并将其配置为PWM输出模式。这样,定时器就能自动产生周期性的CONVST脉冲信号,其频率和占空比可以精确控制。采样率就等于这个PWM的频率。

更复杂的联动是使用定时器的主从模式(Master-Slave)或触发输出(TRGO)。例如,配置TIM2为PWM模式产生CONVST。然后,将TIM2的更新事件(UEV)作为触发输出(TRGO)。接着,配置SPI的时钟由这个TRGO触发(这需要SPI支持外部触发,有些STM32的SPI有这功能)。或者,配置另一个定时器TIM3为从模式,由TIM2的TRGO触发,然后在TIM3的中断里手动启动SPI传输(如果SPI不支持硬件触发)。这样,就能确保在CONVST上升沿之后的固定延迟(由TIM3的计数决定)后,才产生SPI时钟去读取数据,时序非常精准。

在代码中,使用CubeMX配置定时器为PWM模式,频率设为所需采样率(如10kHz),占空比设为1%即可(只要低电平时间满足CONVST的最小脉冲宽度要求)。然后在初始化时启动定时器:

HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);

CONVST引脚就会自动按照设定频率产生脉冲,自动启动转换。我们只需要专注于在DMA中断中处理源源不断到来的数据即可。

5. 数据校准、滤波与软件处理

5.1 从原始码值到实际电压

ADS8860输出的是16位二进制码。对于单极性输入(0-Vref),换算公式很简单:Voltage = (ADC_Code / 65536) * Vref其中ADC_Code是读取到的16位无符号整数,Vref是基准电压,例如2.5V。

但实际应用中,需要考虑失调误差和增益误差。一个简单的两点校准法非常有效:

  1. 零点校准:将ADC输入端接地(或接一个已知的0V电压),采集N个样本求平均,得到零点码值Code_zero
  2. 满量程校准:将ADC输入端接一个高精度、已知的接近Vref的电压(如2.45V),同样采集求平均,得到满量程码值Code_full,对应的已知电压为V_cal
  3. 计算实际电压:Voltage = (ADC_Code - Code_zero) * (V_cal / (Code_full - Code_zero))

在代码中,我们可以预先计算好斜率k = V_cal / (Code_full - Code_zero)和零点偏移b = -Code_zero * k。那么每次转换就是:Voltage = ADC_Code * k + b。这是一个线性校正,能显著提高测量精度。

5.2 数字滤波降噪处理

即使硬件设计得当,ADC读数仍会存在随机噪声。软件滤波是提升读数稳定性的关键。对于低速直流信号,移动平均滤波(Moving Average Filter)简单有效。

#define FILTER_WINDOW_SIZE 32 uint16_t filter_buffer[FILTER_WINDOW_SIZE] = {0}; uint8_t filter_index = 0; uint32_t filter_sum = 0; uint16_t Moving_Average_Filter(uint16_t new_sample) { filter_sum = filter_sum - filter_buffer[filter_index] + new_sample; // 减去最旧的,加上最新的 filter_buffer[filter_index] = new_sample; filter_index = (filter_index + 1) % FILTER_WINDOW_SIZE; return (uint16_t)(filter_sum / FILTER_WINDOW_SIZE); }

对于动态变化的信号,可以结合一阶低通数字滤波器(IIR滤波器):filtered_value = α * new_sample + (1 - α) * old_filtered_value其中α是滤波系数(0<α<1),越小滤波越强,但响应越慢。这个算法计算量小,非常适合在MCU中实时运行。

注意事项:滤波会引入相位延迟和响应变慢。移动平均的延迟是窗口长度的一半。在选择滤波算法和参数时,必须在“噪声抑制”和“响应速度”之间取得平衡。对于控制环路中的反馈信号,过度的滤波可能导致系统不稳定。

6. 调试技巧与常见问题排查

6.1 硬件调试:示波器与逻辑分析仪是眼睛

没有仪器,调试这种高速数字接口就是盲人摸象。一个数字存储示波器或一个逻辑分析仪(即使是便宜的USB逻辑分析仪配合Sigrok软件)是必备的。

排查清单:

  1. 电源和地:首先测量AVDD、DVDD、REF引脚上的电压是否稳定,纹波是否在数据手册要求范围内(通常要求<10mVpp)。检查地平面是否完整。
  2. CONVST和BUSY信号:触发CONVST的下降沿,观察BUSY信号是否在CONVST上升沿后立即变高,并在大约700ns后变低。BUSY高电平的时间就是转换时间。
  3. SPI时序:放大观察CONVST上升沿到第一个SCLK上升沿之间的时间(t_CONVST_SCLK),是否符合数据手册要求(最小25ns)。观察SCLK的频率和占空比。在SCLK的下降沿(对于Mode 0,数据在下降沿变化),检查MISO线上的数据位是否稳定。
  4. 数据值:给一个固定的输入电压(比如接Vref/2),看读取到的16位数据是否稳定在一个值附近小幅波动。如果数据乱跳,可能是时序问题、电源噪声或接地不良。

6.2 软件问题与解决方案速查表

现象可能原因排查步骤与解决方案
读取值始终为0x0000或0xFFFF1. SPI通信根本未建立。
2. 片选或CONVST控制错误。
3. ADC未上电或损坏。
1. 用逻辑分析仪检查SCLK、MOSI、MISO线是否有波形。确认SPI初始化成功(HAL_SPI_Init返回HAL_OK)。
2. 检查CONVST引脚控制序列,确保有拉低拉高的脉冲。检查BUSY引脚响应。
3. 测量ADC各引脚电压,特别是电源和参考电压。
数据值随机乱跳,噪声大1. 模拟输入噪声大。
2. 参考电压噪声大。
3. 电源去耦不足。
4. 数字地噪声串扰。
1. 输入端增加RC低通滤波。
2. 检查基准源电路,加大去耦电容,或更换更安静的基准源。
3. 在AVDD和DVDD引脚最近处补上0.1μF和10μF电容。
4. 优化PCB布局,确保模拟地单点接地。
DMA传输数据错位(如高低8位颠倒)1. SPI数据帧格式设置错误。
2. DMA数据传输宽度不匹配。
3. 字节序(Endianness)问题。
1. 确认SPI配置为MSB First。ADS8860输出是MSB在先。
2. 检查SPI和DMA均配置为16位数据宽度。
3. STM32是小端模式。如果定义uint16_t缓冲区,直接存储16位数据是正确的。如果以8位数组接收,需要手动组合。
采样率达不到预期1. 转换时间(t_CONV)或SPI读取时间过长。
2. 代码中有不必要的延时或中断阻塞。
3. 定时器配置频率错误。
1. 确认总时间= t_CONV + 16个SCLK周期。计算理论最大采样率。
2. 优化代码,将数据处理移出中断,或使用DMA双缓冲。
3. 检查定时器自动重载值(ARR)和预分频器(PSC)计算是否正确。
使用DMA时数据缓冲区更新不及时1. DMA缓冲区大小设置不当。
2. 数据处理速度跟不上DMA填充速度。
3. 未正确使用双缓冲或半传输中断。
1. 增大缓冲区大小,给数据处理留出更多时间。
2. 简化中断服务程序中的处理逻辑,或使用标志位在主循环中处理。
3. 实现HAL_SPI_RxHalfCpltCallbackHAL_SPI_RxCpltCallback,分别处理前后半缓冲区。

6.3 性能优化与进阶思考

当基本功能调通后,可以考虑进一步优化:

  • 过采样与分辨率提升:对于带宽很低的信号,可以通过软件过采样(比如采集256个点求平均)来将有效分辨率提高到16位以上,降低噪声。
  • 多通道扩展:如果需要多个通道,可以使用模拟开关(如ADG系列)在ADS8860前端切换。这时需要协调CONVST信号和模拟开关的通道选择信号。
  • 与RTOS集成:在FreeRTOS中,可以将DMA完成中断与任务通知或队列结合。ADC驱动任务阻塞在一个队列上,当DMA填满半个缓冲区时,在中断中发送一个指针到队列,驱动任务被唤醒并处理数据。这样实现了很好的解耦。
  • 自校准:有些高精度ADC有自校准功能。虽然ADS8860没有,但可以在系统上电时自动执行前述的两点校准,并将系数保存在STM32的Flash中。

这个项目最耗时的部分往往不是写代码,而是理解数据手册上的时序图,以及用仪器验证硬件上的信号是否“干净”。一旦硬件和底层时序搞定,上层应用就会非常顺畅。STM32F4的强大外设,尤其是定时器和DMA,为实现高性能数据采集提供了坚实的硬件基础,关键在于如何巧妙地让它们协同工作。

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

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

Android健康管家系统开发:从传感器采集到数据可视化的完整实践

简介&#xff1a;本资源是一套完整的Android平台个人健康管理应用毕业设计解决方案&#xff0c;面向计算机、软件工程等专业本科生&#xff0c;解决毕业设计选题难、开发周期长、文档不规范等实际问题。项目包含253个文件&#xff0c;涵盖57个Java核心逻辑代码、79个XML界面与配…

作者头像 李华
网站建设 2026/9/4 19:14:58

300元自制25G网卡:FPGA开源方案实战指南

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

作者头像 李华
网站建设 2026/9/4 19:14:40

推荐系统引入好奇心机制:打破信息茧房的工程实践

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

作者头像 李华
网站建设 2026/9/4 19:13:55

Windows平台SOEM控制禾川伺服:从环境搭建到使能状态机全链路实战

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

作者头像 李华
网站建设 2026/9/4 19:10:39

软件测试效率低?从用例设计到自动化选型的6个改进方向

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

作者头像 李华