news 2026/10/14 1:07:31

VEX V5自动程序实战:轴角编码器与超声波传感器实现走直线贴墙停车

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
VEX V5自动程序实战:轴角编码器与超声波传感器实现走直线贴墙停车

前两天带学生调一台VEX EDR V5机器人,自动程序写得明明白白:“直行到墙边,停住”。结果小车一上场就开始画弧,先是往右偏,快到墙时又一头顶上去。排查到最后,问题不在电机也不在结构——程序里压根没有机器人实际走了多少、离墙还有多远的反馈。那台车上的轴角编码器和超声波传感器,装是装了,线也插了,但代码里一次都没读过。

这正是VEX V5平台自动程序里最典型的一道坎:电机转动不等于轮子真实位移,程序猜测的距离也不是真距离。想让机器人“知道自己走了多远,知道自己前面有什么”,轴角编码器和超声波传感器几乎是最顺手的两个器件。一个装在轴上输出脉冲,让控制器能数出转了多少;另一个朝前方发射声波,用回声算出障碍物的距离。两者都不需要复杂的通信协议,接3线口,用VEXcode里现成的类就能读,属于入门门槛低、又极其实用的传感器组合。

这篇东西不是给新手补课用的教材,是我自己带队伍、调车、打比赛过程中攒下来的经验,从原理到接线再到VEXcode里的读取方法,最后附一个“走直线贴墙停车”的完整逻辑。不管你是刚拿到V5主控器的学生,还是被自动程序折腾得头疼的导师,按着这个顺序走一遍,至少能少踩一半的坑。

1. V5自动程序的“尺子”和“耳朵”:两个传感器到底解决什么问题

1.1 轴角编码器:把“我感觉轮子转了”变成“轮子确实转了这么多”

很多第一次接触VEX V5的同学会问一个问题:电机自带编码器反馈,为什么还要在外面装一个轴角编码器?

这个困惑很合理。V5智能电机内部确实有内置编码器,能读到输出轴的转角,甚至能读转速。但问题在于,电机输出轴的转角不等于轮子实际滚过的距离。轮胎打滑、轮胎气压变化、地面材质差异、车架形变,都会让轮子实际滚动的距离和电机转过的角度出现偏差。尤其在地毯和光滑地胶交替的赛场上,开环控制跑出来的位置经常和预期差出一大截。

轴角编码器解决的就是这个问题——它直接装在轮子所在的传动轴或者车轮轴上,测的是轴的真实角位移。只要轴转了,它就输出对应数量的脉冲,电机内部发生了什么、中间齿轮有没有打滑,它一概不管。这种“测量执行端真实状态”的思路,是闭环控制最基本也最关键的一步。

在比赛里,它最常用的场景就是走直线和到点停车。用一个编码器测左侧驱动轮、另一个测右侧驱动轮,通过比较两侧读数差来实时修正电机功率,小车就能跑出一条不依赖轮胎一致性、也不依赖场地条件的直线。这种用法在VEX V5的SDK里有天然的API支持,写过的人都知道有多省事。

不过要提前说清楚一点:轴角编码器是相对式测量。它输出的是一串脉冲,控制器靠数脉冲来算“转了多少”,但并不知道当前轴处于第几圈、哪个绝对角度。这就像一个数车轮辐条转了多少圈的人,他能告诉你车轮从起点开始转了X圈,却不能说出现在车轮辐条指向几点钟方向。所以它的定位是“位移尺”,不是“角度尺”。

1.2 超声波传感器:用回声告诉机器人“墙就在前面”

如果说编码器解决了“机器人对自己位置没有概念”的问题,那超声波传感器解决的就是另一个痛点:机器人不知道前方什么时候会出现障碍物。

开环程序里最常见的写法是“先走1.2米再转弯”,但如果出发点位置偏了、或者场地里多了一个意外障碍物,这个1.2米就成了一个笑话。超声波传感器通过发射声波并测量反射回波的时间,实时计算前方物体的距离,让机器人能够根据实际环境动态调整行为,比如“距离小于30厘米时减速,小于15厘米时停车”。

这个传感器在V5上同样是3线接口,类名称是sonar,读取一个距离值,单位可以选厘米或英寸。它和编码器搭配起来特别顺手:一个测纵向位移,一个测前方障碍距离,刚好覆盖自动程序里“走多远”和“前面有什么”两个最核心的问题。

我第一次给队伍里的新手讲这两个传感器时打了个比方:编码器是机器人的脚,它知道自己迈了多少步;超声波是机器人的眼睛,它能看到前面有没有墙。没有这两样东西,程序就是一个闭着眼睛数拍子走路的人。

这里也顺便提一嘴:VEX EDR V5主控器上除了智能电机口以外,还有一排3线端口(标着A到H,另外还有1到8和A2等排列,不同批次可能不同)。这些端口沿用自VEX传统传感器体系,轴角编码器、超声波传感器、限位开关、光敏、陀螺仪等老器件都接在这里。V5提供了对这些老传感器完整的软件支持,换句话说,老式传感器的经验在V5上依然完全有效,只是接线方式、编程接口换了新的API而已。

2. 轴角编码器拆解:原理、接线与VEXcode实测

2.1 编码器怎么数出角度,内部到底发生了什么

VEX EDR的轴角编码器是一个光电编码器,轴孔穿过一个带栅格切槽的码盘,码盘两边分别有一个红外发光管和一个接收管。轴带动码盘转动时,栅格不断切割红外光束,接收管就输出连续的方波脉冲。

VEX EDR编码器最常见的规格是每转输出360个脉冲,也就是码盘上有360个栅格。控制器每收到一个脉冲,就知道轴转了1/360圈,也就是1度。如果程序里读到编码器数值为900,不用想,轴从清零那一刻起转了两圈半(900除以360,等于2.5圈)。

这么一解释,position()返回的deg单位就特别好理解:encoder的位置值本质上就是脉冲计数,单位可以映射成度(deg)、圈数(rev)或者原始计数(raw)。相对式测量的本质也就在这里——它没有任何“记忆”,每次上电的初始值都取决于你什么时候调用resetRotation()清零。

这种设计天然适合赛场上测轮子转了多少圈:机器人出发前清零,比赛过程中定期读取,用“当前值除以360再乘以轮子周长”就能算出实际位移。这也是后面第4章做贴墙停车的基础。

顺便说一句,在这个光电原理下,编码器的读数跟电机是否通电没关系,只要轴转动它就会输出脉冲。所以即使手动推车,也能从读数看到轮子转了多少。这在调试时是很好用的检查方法:推车走一段路,如果读数不变化,那就是接线或者安装出了问题。

2.2 接线、安装和结构上的注意事项

VEX EDR编码器通常有三根导线:红色接5V电源、黑色接GND、白色或黄色接信号线。接V5主控器的3线端口时,注意端口旁边印刷的引脚顺序——所有3线口的定义从左到右依次是电源、地、信号,插的时候不要反插,反插不会烧毁器件,但读数会乱或者没有读数。

代码里初始化需要指定端口。以C++为例,如果编码器接在Brain.ThreeWirePort.A,那就是:

vex::encoder shaftEnc(Brain.ThreeWirePort.A);

装编码器时最大的坑是把编码器当成承重件。编码器外壳是塑料的,中间的轴孔套在VEX方轴上,如果让它承受整根轴的弯矩,比如把编码器安装在悬臂轴末端还让轴去支撑轮子,轴稍微一晃就会把编码器内部结构顶坏。正确做法是让编码器轴孔只做转动跟随,用独立的支架把编码器本体固定在一个不受力、不干涉的位置。常见安装方式是做个L型支架悬在轴上方,让方轴穿过编码器孔,再用顶丝把编码器轴套和方轴固定紧。

特别注意这里:轴孔和方轴之间如果有空隙、顶丝没有拧紧,编码器会偶尔少数脉冲,症状就是程序里位移越来越不准,却又没有规律。排查这种问题最直接的办法就是手动快速转动轴,在屏幕上实时看读数,如果看到读数跳变异常,大概率是安装松了。

2.3 VEXcode里怎么读,几种单位怎么选

在VEXcode V5里读取编码器,核心就是Encoder类(C++)或encoder对象(Python)。C++代码如下:

#include "vex.h" using namespace vex; brain Brain; encoder shaftEnc(Brain.ThreeWirePort.A); int main() { shaftEnc.resetRotation(); // 清零 while (true) { Brain.Screen.clearScreen(); Brain.Screen.setCursor(1, 1); Brain.Screen.print("Angle: %.1f deg", shaftEnc.position(rotationUnits::deg)); Brain.Screen.print("Rev: %.2f rev", shaftEnc.position(rotationUnits::rev)); vex::task::sleep(100); } }

position(rotationUnits::deg)返回浮点型角度,rev返回圈数,raw返回原始脉冲计数。调试时我一般只看deg,因为和“轴转了多少度”直接对应,特别好算。正式跑比赛逻辑时大多数人也用deg,因为配套的计算公式最直观。

需要提一个细节:编码器数值不是百分比、没有负数上限之类的奇怪限制,正负取决于轴的旋转方向。接线反了,或者编码器装在轴的另外一侧,读数方向会和预期相反。这个通过调整电机方向或者让电机反转,都能纠正。很常见的处理方式是在程序里先做个短距离点动,确认推车前进时编码器读数是增加而不是减小,再继续往下写真正的逻辑。

3. 超声波传感器拆解:原理、接线与VEXcode实测

3.1 声波测距的原理和那些隐形成本

超声波传感器的原理一句话就能讲完:发射器发出一束超声波,声波遇到物体后反射回来,接收器收到回波,控制器记录“发出到收到”的时间差t,距离就是声速乘以时间的一半。因为声波走了个来回,所以要除以2:

d = (340米/秒 × t) / 2

VEX的超声波模块工作频率大约在40kHz,属于人耳听不到的频段,所以比赛中不会造成蜂鸣声干扰。它的量程一般是2厘米到3米左右,近距离盲区和远距离衰减都要靠程序去处理。

超声波和常见红外距离传感器相比有一个核心优势:它不依赖环境光。红外传感器在强光、反光地面上经常失灵,超声波则主要看物体表面材质和声波反射角度。光滑平面、硬质墙面反射好,读数稳定;海绵、布艺、绒毛这类吸收声波的物体,回波很弱,读数容易出现无效值。赛场上的挡板和墙面基本都是硬质的,所以实际比赛表现相当不错。

它的劣势同样明显:声波从发射到接收有几十毫秒的传播时间,而且回波还可能从斜角反射走。所以超声波传感器的采样频率不比编码器实时计数器,不适合做高速避障反馈,更适合做中速、低速下的距离判断。

3.2 安装位置比想象中更重要

VEX超声波模块上通常是两个圆筒状换能器,一个负责发射、一个负责接收。安装时要把这两个圆筒朝外,正对要测量的方向,并且前端不能有任何遮挡——哪怕是一根5毫米宽的结构梁横在探头前面,也会让读数变得随机。

这一点在比赛车上特别容易翻车。很多队伍把超声波装在车头,后面看觉得一切正常,上场跑起来却发现距离时有时无。拆开一看,前保险杠的一个螺丝头正好挡在发射探头和接收探头之间。这种问题不靠肉眼检查很难发现,因为机器人静止时用手势去挡,读数还可能是正常的,一跑起来震动改变了位置,读数就开始飘。

另一个重要的安装原则是探头不要正对地面。如果安装角度稍微朝下,声波会打到地面再反射回来,读数会突然变成地面的距离。理想状态是探头平面和地面保持平行、和墙面保持垂直,声波能原路反射回来。

安装位置选定之后,要留出足够的走线空间,不要和电机线并排走太长距离。电机大电流换向时会产生较强的电磁干扰,信号线上会出现尖峰毛刺,超声波模块的模拟部分容易先中招。我在实践中会把编码器信号线和超声波信号线都尽量和电机动力线分开走,交叉时保持垂直,不平行贴在一起。

3.3 VEXcode里读距离,单位别搞混

V5的超声波调用很简单:

vex::sonar sonic(Brain.ThreeWirePort.B); int main() { while (true) { double d = sonic.distance(vex::distanceUnits::cm); Brain.Screen.clearScreen(); Brain.Screen.setCursor(1, 1); Brain.Screen.print("Distance: %.1f cm", d); vex::task::sleep(50); } }

distanceUnits::cm返回厘米,distanceUnits::in返回英寸。我用厘米习惯,因为场地纸上的尺寸标注基本都是厘米,程序里的目标距离也用厘米,省得换算出错。

一个最容易踩的坑是返回值无效处理。超声波测距有盲区也有量程上限,如果距离太近、太远或者回波无效,distance()会返回一个负数,常见的是-1或者0。新手最容易犯的错误就是直接拿这个值去做判断,结果距离本来很近该停车了,程序读到-1以为是超大距离,直接全速冲上去。

下面这个写法比较稳妥:

double dist = sonic.distance(vex::distanceUnits::cm); if (dist <= 0 || dist > 200) { // 无效读数,按未知情况处理 // 通常建议直接停车或维持上一次有效值 } else { // 正常情况,按距离做逻辑 }

我自己常用的处理方式分为两种:如果是防撞逻辑,读不到有效值就按“距离很近”处理,宁可早停也不晚停;如果是贴墙停车逻辑,读不到有效值就维持上一次的有效值,防止突然丢一个数导致程序误判。

4. 组合实战:走直线贴墙停车程序的完整设计

4.1 场景设定与硬件布局

把两个传感器放到同一个场景里,最能体现它们的价值。我拿一个最典型的比赛需求做例子:小车从出发区中间出发,向前直行,最终停在距离前方墙面15厘米的位置。

硬件布局:左侧驱动电机连左轮,右侧驱动电机连右轮,左轮轴上装一个轴角编码器接ThreeWirePort.A,右轮轴上装一个轴角编码器接ThreeWirePort.B,超声波传感器装在车头正前方,接ThreeWirePort.C。这个配置不需要任何额外模块,驱动底盘和几个传感器就能跑。

走直线这个需求看起来很简单,实际一跑就露馅:两侧电机转速不可能完全一致,两个驱动轮直径可能因胎压不同略有差别,地面摩擦系数左右也不一样。如果不加反馈,程序里给左右电机同样的百分比功率,小车一定偏。只是有的人偏得少,有的人偏得多。

轴角编码器提供的左右轮真实转角数据,让程序能实时知道“左边跑多了还是右边跑多了”,并对左右电机功率做微调。这就是闭环控制里的“反馈校正”,也是最容易理解的P控制。

4.2 双编码器直线纠偏:怎么用差值修正电机功率

纠偏逻辑的核心不是让左右电机输出相等,而是让左右编码器的实时读数尽量相等。每过一个很小的周期(比如10毫秒),程序读取两侧编码器的位置值,求出差值:

error = leftPos - rightPos

如果error为正,说明左轮转得比右轮多,车头在往右偏,那就左电机降一点、右电机升一点。如果error为负,则反过来。

写成最简单的比例修正:

double baseSpeed = 30; // 基础功率 double correction = error * 0.3; // 比例系数 leftMotor.spin(fwd, baseSpeed + correction, percent); rightMotor.spin(fwd, baseSpeed - correction, percent);

这里的0.3不是拍脑袋定的,它决定纠正力度。系数太小,偏了很长时间才纠正回来;系数太大,车头会因为过度修正而左右晃。我的经验是先给一个较小的系数(0.2左右),在场地实测中逐步加大,直到直线偏差肉眼可见地变小且不出现蛇形走位。另外一定要给修正量做限幅,比如最大修正不超过15%,不然在起步瞬间左右编码器数值差异稍大,功率跳变会给机械结构带来冲击。

4.3 超声波控速分段:距离越近,越要稳住

直线跑的问题解决了,剩下就是怎么利用超声波把车稳稳停在指定距离。直接做法是当距离等于15厘米时停车,但小车有惯性,高速状态下刹车会滑出去,最后停在哪全靠命。

更可靠的做法是把距离判断拆成多个阈值:

  • 当前方距离大于50厘米时,保持基础速度跑;
  • 前方距离小于50厘米后,降到基础速度的一半;
  • 前方距离小于25厘米后,降到更慢的速度(比如10%);
  • 前方距离小于等于15厘米时,电机反转刹停一瞬间后停车。

这种分段减速的思路和人类开车很像:不是到终点才踩刹车,而是看到快到了先松油门,再逐步减速,最后稳稳停住。

完整代码骨架如下:

#include "vex.h" using namespace vex; brain Brain; motor leftMotor(PORT1, ratio18_1, false); motor rightMotor(PORT10, ratio18_1, true); encoder leftEnc(Brain.ThreeWirePort.A); encoder rightEnc(Brain.ThreeWirePort.B); sonar frontSonar(Brain.ThreeWirePort.C); int main() { leftEnc.resetRotation(); rightEnc.resetRotation(); double baseSpeed = 30; double targetDist = 15.0; while (true) { double dist = frontSonar.distance(distanceUnits::cm); if (dist <= 0) { dist = targetDist + 5; // 无效读数时保守处理,维持低速接近 } double leftPos = leftEnc.position(rotationUnits::deg); double rightPos = rightEnc.position(rotationUnits::deg); double error = leftPos - rightPos; double correction = error * 0.3; if (correction > 15) correction = 15; if (correction < -15) correction = -15; double speed = baseSpeed; if (dist < 50) speed = baseSpeed * 0.6; if (dist < 25) speed = 10; if (dist <= targetDist) { leftMotor.stop(); rightMotor.stop(); break; } leftMotor.spin(fwd, speed + correction, percent); rightMotor.spin(fwd, speed - correction, percent); vex::task::sleep(10); } }

这段逻辑在真实比赛中跑起来的效果,比单纯的“先转N圈再刹车”稳定得多。编码器负责不让左右偏,超声波负责不让车头撞墙,各干各的、互不干扰。

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

5.1 编码器完全不读数,先别怀疑硬件坏了

编码器插上后读数恒为0,90%的情况不是编码器坏了,而是接线或初始化问题。按这个顺序查:

第一,看3线口的线序。红线接电源、黑线接地、白线接信号,反插后模块不通电,读数必然为0。第二,把编码器轴手动转一下,看屏幕上读数有没有变化。如果读数跳了,说明编码器和程序没问题,是驱动端没带动编码器,去查安装。第三,检查初始化时机。有些队伍把resetRotation()写在构造函数之后,但V5主控器启动时3线口供电可能还没稳住,清完零之后才开始读数,问题不大;但如果代码里在初始化之前就尝试调用position(),偶尔会读到垃圾值。

最后一种情况比较隐蔽:接线正常、安装正常、代码正常,但读数不更新。这种多半是3线端口选了错的口,初始化写的是ThreeWirePort.A,线却插在ThreeWirePort.C。不细看很难发现。

5.2 编码器读数乱跳,先排除结构松动

读数不是0,而是异常跳变,比如推车匀速前进,读数一帧是100、下一帧直接变到250,这大概率是机械安装松了。重点检查顶丝是否压住方轴平面,编码器支架是否有共振,编码器轴孔和轴之间有没有旷量。

另一个常见来源是电机干扰。虽然编码器和电机各自独立,但VEX的动力线上电流很大,如果编码器信号线长期贴着电机线走,电机的EMI会耦合到信号里。症状就是读数在高转速时偶尔多跳几个脉冲,低速时又没症状。解决办法就是上文说的:分离线束,交叉不平行。

5.3 超声波读数失效,注意盲区和环境

超声波返回-1,最典型的原因是距离超出了量程。其次是墙面角度过斜,回波没有回到接收探头。还有一种情况是前方物体太软,比如挡板前贴了层海绵,声波被吸收掉,回波幅度过低,模块判定无效。

读数变成0或极小的固定值,通常是发射/接收探头被结构遮挡,或者离障碍物太近进入了盲区。超声波盲区一般是2到5厘米,如果车头已经把探头几乎贴到墙上了,读回来0是完全正常的。

多个超声波同时开机也会互相干扰。A车的高频声波可能被B车的接收探头收到,产生随机距离值。比赛时如果场上多台车都用超声波,没法保证不串扰。有效做法是在程序里限制每次测距后至少间隔几十毫秒再测下一次,利用时间错开降低碰撞概率,同时开启声波前先做一次软件滤波,比如连续读三次取中值。

5.4 三个赛场上最容易被忽略的细节

第一个细节是插头固定。V5的3线插头在车架震动下容易松脱,传感器线很长,稍微挂到结构件就会松出来。比赛前一定要用扎带或束线管给插头位置做限位,或者直接把线绕主控器引脚缠绕一圈再插,防止震动位移。

第二个细节是传感器自检程序。我强烈建议队伍里写一个固定模板:上电后把所有传感器数据全部打到屏幕上,然后手动转动编码器、在探头前晃动手掌,看对应数值是否变化。这个自检程序比赛前跑一遍,比检查任何文档都有用。

第三个细节是“一次只改一个变量”。很多队伍遇到传感器读数不对,又动安装、又改代码、又换端口,结果越调越乱。正确做法是先把传感器固定在已知状态,比如推车前进手动观察编码器读数,确定接线和方向;再独立测超声波,确定量程和盲区;最后才把它们接到控制逻辑里联调。每一步都验证过,合起来才可靠。

最后再说点个人经验

带队伍这些年,让我印象最深的一次不是赢了哪场比赛,而是一个学生自己发现“程序以为走了60厘米,实际只走了40厘米,因为轮子在地胶上打滑”。他拿着编码器读数对比实际测量距离,在实验记录本上写了一行字:传感器告诉你的不一定对,但传感器告诉你的,至少比感觉可靠。

这话我很认同。轴角编码器和超声波传感器都不是什么高精尖设备,原理简单,API也就几行,但真正把它们调到可靠、可信,需要仔细对待接线、安装、无效值处理和联调流程。传感器是机器人的眼睛和触觉,但它能不能起作用,取决于你有没有正确地相信它、正确地解读它。

最后分享一个我一直沿用的做法:给每个项目新队员的第一次任务就是写一个“传感器自检程序”,把编码器读数、超声波距离同时显示在V5 Brain屏幕上,旋转轴、挡探头,观察数值变化并把结果记录成表格。等他能熟练解释每个读数背后的物理意义、能判断什么时候数据是可疑的,我才会让他上手写控制逻辑。这个门槛看起来简单,但跨过去之后,后面所有调试都顺畅非常多。

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

街景字符识别模型设计:从物理约束到鲁棒判别

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

作者头像 李华
网站建设 2026/10/14 1:07:19

PyQt QListView+ComboBox联动实现搜索过滤与导航功能

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

作者头像 李华
网站建设 2026/10/14 1:07:14

Python HDF5实战:h5py处理大规模传感器数据的核心技巧

1. 为什么值得花时间啃透HDF5这块硬骨头做数据处理的同行大概都有过这种体验&#xff1a;手头一个几十GB的传感器采集文件&#xff0c;用pandas.read_csv一读&#xff0c;内存直接爆掉&#xff0c;机器风扇狂转&#xff0c;等了十分钟还没出结果。更让人头疼的是&#xff0c;这…

作者头像 李华
网站建设 2026/10/14 1:06:19

MATLAB图片导出不清晰、fig转EPS显示不全及EPS转PDF全流程解析

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

作者头像 李华
网站建设 2026/10/14 1:06:19

RTOS任务机制详解:从裸机主循环到多任务调度与任务间通信

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

作者头像 李华