news 2026/9/9 9:09:50

ADC省IO采集旋转开关与Modbus浮点传输实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ADC省IO采集旋转开关与Modbus浮点传输实战

写嵌入式这段时间,最常让我头疼的不是什么高深算法,反而是那些看起来不起眼的小功能:明明一个旋转开关就能解决的档位选择,硬生生占了四个IO;明明一个float温度值,在Modbus寄存器里拆来拆去最后对不上号。今天这篇笔记,就把这两个问题一次性讲透——4档旋转开关怎么用1路ADC省IO采集,以及Modbus通信里float数据怎么拆分、怎么还原才能在PLC和上位机里不出错。都是我在实际项目里验证过的做法,代码和电路图都是可以直接抄作业的。

适合谁看?搞STM32、单片机、工控仪表、传感器采集的嵌入式工程师,还有刚入门想知道“IO不足怎么办”“Modbus浮点数为什么乱码”的初学者。看完你应该能直接在自己的板子上复现这两块功能。

1. 旋转开关省IO的整体思路与方案选型

1.1 为什么要想办法省IO

做嵌入式项目的朋友都有这种体验:MCU的IO数量永远不够用。一个稍复杂点的产品,LCD要并口、按键要扫描、SPI要三根线、UART要两根线、还有几个传感器中断……等你把外设挂完,发现留给拨码开关和旋转开关的IO已经没几个了。

4档旋转开关——就是那种旋到1、2、3、4挡位有明显定位感的开关,旋转时内部触点会接通不同的引脚(比如常见的是公共端和对应档位引脚导通)。如果按照最原始的办法,四个档位用四个IO去读,那就是妥妥的浪费。两个IO用二进制编码能读4档,但接线要按编码表来,软件还得做译码,而且有些旋转开关本身只支持“单触点接通”,你没法直接把它的输出当成二进制编码来用。

用ADC分压法,是性价比最高、也最省IO的方案——一路ADC就能完成四档识别。这对STM32F103这种自带多路ADC的MCU来说简直不要太合适,反正ADC通道一般都有剩余,用一路不心疼。

1.2 三种采集方案横向对比

方案占用IO数电路复杂度软件复杂度可靠性
4路IO直接读4极简,每路加下拉即可极简,读电平就行最高,每路独立
2路IO二进制编码2需要改开关接线,或用拨码开关组需要查表译码较高,但接线易错
1路ADC分压1适中,一个上拉加几个分压电阻需要ADC采样和档位判定高,注意电阻精度和滤波

我实际项目里常用的是“上拉电阻 + 每档不同下拉电阻”的方案。原理很简单:旋转开关公共端接MCU的ADC引脚,同时通过一个固定上拉电阻接到VCC;四个档位触点分别接不同阻值的下拉电阻到GND。旋到第N档时,对应档位的下拉电阻与公共端连通,ADC引脚处的电压就由这个下拉电阻和上拉电阻分压决定。不同档位对应不同电阻,也就对应不同电压,MCU只要测电压就知道当前是第几档。

这方案的优点是只需要1个IO,哪怕是BF板子的ADC通道紧张也扛得住。缺点是档位识别完全依赖电压值,所以电阻精度、ADC参考电压精度和软件判定窗口都要设计好。这些细节我会在后面展开。

1.3 为什么ADC方案在工业场景下够用

有人担心:ADC采集会不会受干扰?旋转开关的触点有接触电阻,电压会不会漂?实际上,只要做好三点——电阻分压区间够大、ADC采样加滤波、软件判定留足窗口,这方案在工业现场完全没问题。

我做过一个温控仪表项目,面板上就需要一个4档旋转开关来选探头类型。如果用4个IO,当时那个MCU的IO已经排满了;用ADC方案后,一路ADC搞定,实测在-20℃到60℃的环境温度变化下,档位判断从未出过错。关键在于:旋转开关不像电位器那样需要连续电压识别,它只有4个离散状态,电压间隔就大,抗干扰能力强得多。

2. ADC分压采集的硬件设计

2.1 电阻分压的具体计算

先说电路结构,假设VCC是3.3V,ADC引脚通过上拉电阻R_up接到3.3V,旋转开关公共端接ADC引脚,四个档位触点分别接R1、R2、R3、R4到GND。

当旋到第1档时,等效电路是R_up在上、R1在下,ADC电压为:

V1 = 3.3 × R1 / (R_up + R1)

以此类推。我实际用的电阻值是R_up=10kΩ,R1=1kΩ,R2=2.2kΩ,R3=4.7kΩ,R4=10kΩ,算下来:

  • 1档:3.3 × 1 / (10 + 1) ≈ 0.30V
  • 2档:3.3 × 2.2 / (10 + 2.2) ≈ 0.594V
  • 3档:3.3 × 4.7 / (10 + 4.7) ≈ 1.106V
  • 4档:3.3 × 10 / (10 + 10) = 1.65V

为什么选这几个阻值?两个原因:

  1. 电阻都是E24系列常见值,好买,误差1%精度也不贵。
  2. 挡位之间的电压差足够大。1档到2档差了约0.29V,2档到3档差了约0.51V,3档到4档差了约0.54V。STM32的12位ADC在3.3V参考电压下,每个LSB大约是0.8mV,0.29V的间隔相当于360多个LSB,就算硬件上有噪声,软件上有误差,也不会判错。

提示:千万别用等比例电阻什么2k、4k、6k、8k,因为分压公式是非线性的,算出来电压不一定等间隔,反而不利于判定。用上面这种“上拉固定、下拉递增”的组合,电压单调递增且间隔都比较舒服。

2.2 完整电路与引脚规划

实际接线时,我建议把齿轮开关放在板边,走线短一点,ADC采样脚和上拉电阻之间不要铺地铜太近,避免数字噪声耦合。

旋转开关选型上,我用的是常见的1P4T旋转开关——一个公共端COM,四个档位触点,旋转有定位感。这种开关其实还可以用在键盘扫描、地址配置等场景,用途很广。公共端接ADC引脚,四个触点分别接四个下拉电阻,电阻另一端统一接到GND。

ADC引脚旁边的滤波电容建议加一个100nF,到GND。作用有两个:一是滤掉高频噪声,二是给ADC采样电容提供电荷,采样值更稳定。如果板子上有空间,再串一个1kΩ电阻进ADC脚,可以进一步限制噪声电流,但注意串阻会和采样电容组成一个RC低通,采样时间要留够,否则会影响精度。好在我们的应用是按钮式的档位识别,对采样速率要求极低,慢一点完全没关系。

2.3 省IO方案的另一个思路

这里再提一个思路:如果你不想用ADC,也可以用RC充放电法省IO——用普通IO口输出高电平给电容充电,再把IO切到输入模式,测量电容放电到逻辑阈值的时间,间接测电阻。这个方案不占ADC,但测量时间不稳定、精度也差,我试过一次就不再用了。ADC方案明显更省心,前提是MCU得有ADC外设。如果你用的芯片连ADC都没有,那只能老老实实上两路IO编码方案了。

3. 软件读取与档位判定

3.1 ADC采样的代码实现

ADC初始化这块,以STM32F103标准库为例。配置成单通道连续采样,或者单通道单次转换都行,关键是在读取函数里做多次采样取平均。

我习惯的做法是:每次档位判断时连续读8次ADC,去掉最大值和最小值,剩下的6次取平均,然后再做档位判定。这样即使有一次采样被干扰,也不会影响结果。

uint16_t adc_read_average(void) { uint32_t sum = 0; uint16_t val[8]; uint16_t max, min; uint8_t i; for (i = 0; i < 8; i++) { val[i] = adc_read_single(); // 单次转换结果 } max = val[0]; min = val[0]; for (i = 0; i < 8; i++) { if (val[i] > max) max = val[i]; if (val[i] < min) min = val[i]; sum += val[i]; } sum -= max; sum -= min; return (uint16_t)(sum / 6); }

这种“掐头去尾平均法”比单纯的均值滤波效果好很多,因为单次采样可能因为电源毛刺或开关抖动产生极值,去掉极值后再平均,结果基本就是稳定电压。

3.2 档位判定:阈值窗口怎么留

得到平均ADC值后,下一步是判定当前处于哪一档。

理论上,3.3V参考下,12位ADC读0.30V是约372,读0.594V是约738,读1.106V是约1373,读1.65V是约2048。但实际上,电阻有误差,参考电压有偏差,所以不能拿这些理论值死卡,得留判定窗口。

我用的方法是:先把每个档位的理论ADC值算出来,再以相邻两档的中间值作为边界,看采样值落在哪个区间。

#define THRESHOLD_1_2 ((ADC_1ST + ADC_2ND) / 2) #define THRESHOLD_2_3 ((ADC_2ND + ADC_3RD) / 2) #define THRESHOLD_3_4 ((ADC_3RD + ADC_4TH) / 2) uint8_t get_switch_position(uint16_t adc_val) { if (adc_val < THRESHOLD_1_2) return 1; else if (adc_val < THRESHOLD_2_3) return 2; else if (adc_val < THRESHOLD_3_4) return 3; else return 4; }

这个判定的逻辑是:取相邻两档的理论值中点作为分界,只要实际电压没有偏移超过“相邻间隔的一半”,就不会判错。以1档和2档为例,理论值中点是555,实际如果电阻误差导致1档电压到了550,还是很安全,因为离中点还有一段距离。

注意:千万不要直接拿“离哪个理论值最近”来判定,虽然逻辑上相似,但代码里更常见的还是区间比较,因为区间比较效率更高,也不容易出边界问题。

3.3 软件上的防抖处理

旋转开关是机械结构,旋的时候会有抖动,触点也会在临界位置短暂接触不良。如果程序在主循环里反复读开关,可能会在切换瞬间出现“1档→2档→1档”这样的跳变,如果这个档位恰好控制着设备运行参数,那就是个隐患。

我的做法是给开关状态做一个“连续确认”机制:第一次读到一个档位后,不立即生效,而是延时20ms再读一次,如果两次结果一致,才认为档位有效;如果两次不一致,继续等待。

uint8_t get_switch_position_debounce(void) { uint8_t pos1, pos2; pos1 = get_switch_position(adc_read_average()); delay_ms(20); pos2 = get_switch_position(adc_read_average()); if (pos1 == pos2) return pos1; else return get_switch_position_debounce(); // 重新确认 }

这里用了递归,实际项目里更推荐改成带计数的循环,避免递归深度不可控。不过在这类低速场景下,递归也没啥问题,代码还更直观。20ms的延时要加在获取档位的函数里,不能在主循环里统一延时,因为主循环可能还有其他任务要跑。

3.4 实测效果

我在一块STM32F103C8T6最小系统板上做了验证,把旋转开关接到PA1(ADC1的通道1),用串口打印档位值。连续旋转500次,每次停留约1秒,没有任何一次误判。ADC数值在静止状态下的抖动范围大约是±5个LSB,远远小于档位间的几百个LSB间隔,识别非常可靠。

4. Modbus中float数据拆分还原原理

4.1 Modbus寄存器与float的冲突

Modbus协议本身是为PLC和工业设备设计的,一个寄存器是16位,能存的最大无符号整数是65535。但工程里的温度、压力、流量这些变量,经常是float——32位。一个float塞不进一个寄存器,就得拆分。

比如我做的仪表要上报当前温度3.14℃,如果用8位整数表示314,精度不够,因为可能是3.1415℃。用float,就得拆成两个寄存器来传。

关键问题是:两个寄存器谁在前谁在后?每个寄存器里高字节和低字节又是怎么排的?这就是最容易出乱子的地方。

4.2 字节序与寄存器顺序的坑

先看float在内存里的真面目。一个float在C语言里占4字节,按IEEE 754标准存储:第31位是符号位,第30到23位是指数,第22到0位是尾数。通信双方只要都把同一段二进制数据按相同顺序拆装,数值就不会错。

以3.14f为例,它在内存里的4个字节可能是:

  • 71 0E 49 40(小端序存储,x86架构和ARM默认就是这种)

把它当成一个无符号32位整数看,就是0x40490E71。

Modbus寄存器传输数据时,两个寄存器应该有约定。最常见的约定叫“ABCD”:第一个寄存器存高16位,第二个寄存器存低16位。也就是:

  • 寄存器40001存0x4049
  • 寄存器40002存0x0E71

接收方要还原,就把第一个寄存器左移16位,和第二个寄存器按位或起来,得到0x40490E71,再按float解释。

但很多设备不是按ABCD来的,还有BADC(按字节对调)、CDAB(按16位字对调)、DCBA(全反)等。Modbus Poll这类调试工具里就专门有这个选项:

顺序说明常见场景
ABCD寄存器1高字在前,寄存器2低字在后大多数PLC
CDAB寄存器1低字在前,寄存器2高字在后部分仪表
BADC字节对调某些国产设备
DCBA字节和字都反特殊协议栈

我做项目的经验是:先确认从站和主站约定的是哪种顺序,如果两边不一致,就会出现“数据看起来有数值,但完全不对”的诡异现象,比如读回来的温度是几千、几万,或者负数看起来毫无规律。

4.3 C语言实现拆分还原

知道了原理,代码就很简单了。拆分float时,我推荐用memcpy,因为用强制类型转换可能违反严格别名规则,在编译器优化下出问题。

拆分代码(把float拆成两个16位寄存器值):

void float_to_modbus_regs(float value, uint16_t *reg_hi, uint16_t *reg_lo) { uint32_t raw; // 将float按位复制到uint32_t memcpy(&raw, &value, sizeof(raw)); // 取高16位和低16位 *reg_hi = (uint16_t)(raw >> 16); *reg_lo = (uint16_t)(raw & 0xFFFF); }

还原代码(把两个16位寄存器值还原成float):

float modbus_regs_to_float(uint16_t reg_hi, uint16_t reg_lo) { uint32_t raw; float value; // 组装成uint32_t raw = ((uint32_t)reg_hi << 16) | reg_lo; // 按位复制到float地址 memcpy(&value, &raw, sizeof(value)); return value; }

注意,这套代码默认是“寄存器1存高16位、寄存器2存低16位”,也就是Modbus Poll里的ABCD顺序。如果你在对端设备上发现数据不对,优先试试Modbus Poll里的其他三种Data Format,大概率有一个能对得上。

提示:我们MCU内部存float是按小端存的,但这套代码不需要管小端大端。你把float复制成一个uint32_t,再手动拆高16位和低16位,得到的是逻辑上的二进制值,和内存字节排列无关,非常安全。要是你用“取地址然后按字节发”的方式,才会遇到大小端问题。

5. 在STM32与FreeModbus里落地

5.1 移植FreeModbus时寄存器表怎么规划

我用的Modbus协议栈是FreeModbus v1.6,移植到STM32F103标准库,串口用RS232或者RS485都行。FreeModbus里,用户只需要实现几个回调函数,其中最重要的是读写保持寄存器(Holding Register)。

假设我们的设备有:当前温度(float,占两个寄存器)、设备状态(uint16,占一个寄存器)、启动/停止开关(uint16,占一个寄存器)。

那寄存器地址可以这样规划:

功能码寄存器地址(协议地址)数据格式说明
03读保持寄存器40001uint16运行状态
03读保持寄存器40002uint16温度高16位
03读保持寄存器40003uint16温度低16位
06写保持寄存器40001uint16启停控制

在FreeModbus中,读写保持寄存器的回调函数是eMBRegHoldingCB,里面有一个usRegNRegs参数,表示本次访问的寄存器数量。比如主站一次读3个寄存器,就把寄存器表的三项都返回。

5.2 把ADC采集到的档位和float数据一起上报

假设我的仪表采集到的温度是一个float变量temperature,旋转开关的档位是一个uint8_t变量switch_pos,我想让主站能读到这个温度和档位。

在主循环里,先把温度拆到寄存器表,把档位也放到寄存器表:

extern uint16_t usRegHoldingBuf[]; extern uint16_t usRegHoldingBufLen; void update_modbus_regs(void) { uint16_t reg_hi, reg_lo; // 温度寄存器,假设温度值已经更新到temperature float_to_modbus_regs(temperature, &reg_hi, &reg_lo); usRegHoldingBuf[1] = reg_hi; // 40002 usRegHoldingBuf[2] = reg_lo; // 40003 // 档位寄存器,放入低8位即可 usRegHoldingBuf[0] = switch_pos; }

注意,FreeModbus的访问机制是:当主站发起读写时,协议栈会调用eMBRegHoldingCB,把usRegHoldingBuf中的内容打包发送。所以我们的任务就是不断更新usRegHoldingBuf里的数据,让寄存器表随时保持最新。但这里有个关键点——如果更新频率太快,而主站正在读取寄存器,会不会读到半新半旧的数据?FreeModbus在发送数据期间,我们更新usRegHoldingBuf是可能影响到正在打包的对像的。

实际做法有两种:

  1. 在eMBRegHoldingCB回调里更新,但这样要判断操作方向,代码绕一点。
  2. 在更新寄存器表前加一个临界区保护,关中断后再更新,更新完再开中断。

我一般用第二种:

void update_modbus_regs(void) { uint16_t reg_hi, reg_lo; float_to_modbus_regs(temperature, &reg_hi, &reg_lo); __disable_irq(); usRegHoldingBuf[1] = reg_hi; usRegHoldingBuf[2] = reg_lo; usRegHoldingBuf[0] = switch_pos; __enable_irq(); }

因为关中断的时间只是两个寄存器赋值的瞬间,毫秒级延时都算不上,对整个系统实时性的影响可以忽略。

5.3 用Modbus Poll验证还原正确性

Modbus Poll是调试Modbus从站的神器,强烈建议每个搞工控的开发者都备一份。新建连接时填从站地址、功能码、串口参数(波特率、数据位、停止位、校验位),然后点连接,就能周期性读到寄存器值。

读float时,在Modbus Poll里把对应寄存器改显示格式为“float”或“float inverse”,就能直接看到还原后的浮点数。如果你的从站按ABCD顺序,就选Float ABCD;如果按CDAB,就选Float CDAB。不同的从站会有不同的顺序,试一下就知道。

我调试时会同时打开16位整型显示和float显示两列。整型列用来检查寄存器高16位和低16位是否正常拆分,float列用来直观验证数值对不对。如果float列显示的数值和预期一致,就说明写进寄存器的数据没问题;如果不一致,先别急着查协议栈,先检查自己的拆分代码和字节序选择。

6. 常见问题与排错技巧

6.1 旋转开关部分的典型故障

掉进这个坑的人不少,我整理成一份速查表:

现象可能原因处理方法
档位跳变,显示忽1忽2旋转开关抖动,或ADC采样太快没滤波加软件防抖,延时20ms再确认一次
某两个档位识别混淆分压电阻精度不够,或ADC参考电压不稳换1%精度电阻,检查电源纹波,必要时加滤波电容
所有档位都读到同一个值公共端接触不良,或上拉/下拉电阻焊错万用表测各引脚通断和电压,排查虚焊
ADC值漂浮不定采样引脚悬空或噪声干扰检查开关是否在空档位,加100nF滤波电容
上电瞬间读错档位MCU复位时ADC尚未稳定主循环里延时100ms后首次采样校准

我印象最深的一个问题是:某批板子有大约5%在3档和4档之间偶尔误判。排查到最后发现是电阻批次问题,3档用的4.7k电阻实际阻值偏差到了+3%,而那批板的3.3V电源纹波又比设计值大。后来把电阻全部换成1%精度,并在ADC引脚加了一个100nF电容,问题彻底消失。

6.2 float数据乱码的排查思路

Modbus读float读出来不对,八成是顺序问题。我摸索出一套排查流程:

  1. 先把寄存器按无符号整型显示,读出40001和40002两个值。
  2. 用手算或者写个小脚本,把这俩值按ABCD组合成一个32位数,转成float看看。
  3. 如果不是,依次按CDAB、BADC、DCBA试。
  4. 找到正确组合后,看是哪种顺序,然后去设备配置里把Modbus格式改成对应的,或者改代码里的拆分顺序。

举例:我读到的40001=0x4049,40002=0x0E71,按ABCD组合是0x40490E71,解析float就是3.14;如果设备实际按CDAB传,那40001的0x4049其实才是低16位的位置,组合出来是0x0E714049,解析出来就是一个特别大的数。

这套方法基本能覆盖90%以上的float乱码问题。剩下10%是Modbus寄存器地址偏移搞错了——比如协议文档里说寄存器地址是0001,但实际功能码读取时要从0000开始,或者PLC侧地址需要加1,也会让数据对不上。

6.3 Modbus通讯层面的其他坑

  • 串口参数不一致:从站和主站的波特率、校验位、停止位必须完全一致。STM32里配置的是8位数据位+1位停止位+无校验,但PLC侧默认可能是8E1(偶校验),对不上就会一直报CRC错误。
  • RS485方向控制:FreeModbus在发送完成中断里控制DE引脚方向,这个时序如果没做好,会出现“回波干扰”或者“收到自己的数据”。我用的是RS485自动收发电路(带延时芯片),省了很多麻烦。
  • 寄存器数量不匹配:主站读3个寄存器,但从站定义的寄存器表长度不够,FreeModbus会回复异常码。调试时看Modbus Poll右侧的“Exception Response”就能发现。

最后再分享一个小经验

这两个功能其实经常在同一个项目里出现:旋转开关设置设备参数,设备把这些参数通过Modbus上报给上位机或PLC。我建议你在做旋转开关档位判定和float寄存器规划时,一定要把档位、温度、压力这些变量的含义都写进寄存器映射表里,版本化保存。因为现场调试时,你不一定记得自己当初把温度放在40002还是40003,也不一定记得哪个设备用的CDAB顺序、哪个用的ABCD。有一张随时能查的映射表,调试效率能提升一大截。另外,旋转开关的档位最好不要只靠ADC电压唯一判定,最好能在设备初始化时把当前档位打印到串口日志里,这样以后联调时不用猜板子当前到底旋到哪了。

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

2026年Windows平板选购指南:从处理器到触控笔,避开这些坑

桌上这台Surface Pro已经陪了我三年多&#xff0c;前几天帮同事挑新机器&#xff0c;又把2026年的Windows平板市场翻了个底朝天。说实话&#xff0c;Windows平板这个品类每年的产品不算多&#xff0c;但买起来特别容易犯选择困难&#xff1a;既要看品牌、看处理器、看内存硬盘&…

作者头像 李华
网站建设 2026/9/9 9:08:49

当AI帮你写代码时,它到底在优化什么

你有没有遇到过这种情况&#xff1a;让AI帮你写一个网页小工具&#xff0c;它信誓旦旦地给了你一份代码&#xff0c;运行起来却是这也不对那也不对。按钮点了没反应&#xff0c;输入框改了数字页面上却没更新&#xff0c;或者干脆页面加载就报错。你把问题反馈给它&#xff0c;…

作者头像 李华
网站建设 2026/9/9 9:07:45

Spring Boot与Vue3构建健康饮食数据管理系统开发实战

先交代项目背景。这个系统不是拍脑袋想的&#xff0c;是我在日常和几个健身房朋友聊天时发现的需求&#xff1a;大多数人不是不想吃得健康&#xff0c;而是不知道自己每天该吃多少、吃了什么、缺了什么&#xff0c;光靠“少吃多动”四个字根本没法落地。所以我干脆做了一个前后…

作者头像 李华
网站建设 2026/9/9 9:06:57

大厂Java面试实战:发帖链路与RAG智能客服考点全解析

1. 面试现场的底层逻辑&#xff1a;为什么偏偏是发帖链路和RAG智能客服我记得很清楚&#xff0c;那天面试官推门进来&#xff0c;没有让我做自我介绍&#xff0c;直接在白板上写了两行字&#xff1a;一行是"用户发帖"&#xff0c;一行是"智能客服"。他说&a…

作者头像 李华
网站建设 2026/9/9 9:06:28

RISC-V矩阵扩展MX:硬件与软件协同的系统工程

1. 这不是“加个指令”那么简单&#xff1a;RISC-V矩阵扩展的真实战场你点开这篇标题&#xff0c;大概率是因为在芯片设计文档、AI加速器白皮书&#xff0c;或者某次技术分享会上听到了“RISC-V矩阵扩展”这个词。它听起来像一个顺理成章的技术演进——既然有向量扩展&#xff…

作者头像 李华
网站建设 2026/9/9 9:06:27

城市监测平台WebGIS开发实录:技术选型、核心功能与性能优化

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

作者头像 李华