熟悉无人机或者机器人底盘项目的朋友应该对“光流”这个概念不会陌生。室内没有GPS飞机又不想靠超声波那么粗糙的高度与速度反馈这时候光流传感器就派上了用场。而PMW3901MB就是这类传感器里非常典型的一款它本质上是一颗带光学透镜的低分辨率摄像头通过计算连续图像帧之间的像素移动来输出x/y两个方向的运动增量。我以前一直把它跟STM32搭配用最近换了块ESP32开发板顺手把整个流程重新梳理了一遍硬件连接、SPI底层时序、寄存器读写、再到最后的运动位移累计全链路都跑通了。这篇文章就把我踩过的坑和验证过的代码完整记录下来给正在用ESP32做小车、机器人底盘、室内定位或者课程设计的同学一个可以直接抄作业的参考。1. 方案选型为什么选PMW3901MB搭配ESP321.1 光流法测运动的基本原理光流法听起来高大上其实原理很朴素。你看窗外风景往后跑就知道车在往前开光流传感器做的也是这件事。传感器内部有一颗30x30分辨率的低分辨率灰度图像传感器以大约121fps的帧率连续抓拍地面然后硬件直接完成前后两帧图像的互相关计算输出的就是这段时间内图像在x和y方向上偏移了多少个像素。这些像素偏移量被封装成8位有符号整数通过SPI接口供主控读取。PMW3901MB区别于普通光学鼠标传感器的关键点在于它使用了专门定制的透镜和更大的视场角支持从80mm高度到无穷远的工作范围而且对低纹理表面比如普通木地板、水泥地的识别能力比鼠标传感器强很多。所以它不需要像鼠标那样紧贴桌面悬空安装就能感知相对地面的运动这就天然适合无人机悬停和小车里程计补充。1.2 PMW3901MB的关键参数解读选型的时候最应该关注的是这组参数输出分辨率、最大测量速度、工作高度范围、接口方式。PMW3901MB的帧率是121fps输出数据通过SPI读取片选单独控制。它的x/y增量寄存器是8位带符号数也就是单次读取范围在-128到127之间如果运动太快导致增量超过这个范围就会出现“丢帧”表现为累计位移比实际值偏小。实测下来在80到120mm的安装高度下水平移动速度不超过1.5m/s时数据都还算可靠这已经覆盖大多数桌面小车和室内无人机场景。另外要注意PMW3901有个孪生版本PMW3901MB-A和PMW3901MB-BB版本改进了镜头和LED驱动新买模块基本都是B版。两者寄存器定义基本一致代码可以通用。模块自带一个红光LED和镜头LED是给传感器补光用的不需要主控额外控制模块上电后会自动点亮。1.3 为什么主控选ESP32而不是STM32不是STM32不行而是ESP32在这个场景下太方便了。一是有成熟的Arduino生态SPI库和串口调试开箱即用十分钟就能把数据打出来二是自带WiFi和蓝牙光流数据可以直接通过WiFi UDP或者蓝牙发给上位机后续做地面站调试省掉一根USB线三是ESP32性能对这类低速传感器来说严重过剩同时跑光流读取、姿态解算、电机控制都没问题。唯一的注意点是ESP32的ADC精度存在一些缺陷但那跟SPI数字接口没关系完全不影响光流传感器使用。1.4 这个组合能用在哪些项目里最直接的应用是给小车加一个“地面速度计”和轮子编码器互相校正。编码器会受打滑影响而光流传感器测的是相对地面的真实光学运动两者融合能得到更可靠的里程数据。再进阶一点就是无人机室内悬停用光流数据做水平位置环的反馈配合气压计或TOF激光测距做高度反馈这已经是很成熟的低成本方案。如果只是做毕设或者课程设计用这个传感器读取数据、绘制运动轨迹、实现简单的方向判断也足够撑起一个完整性不错的项目。2. 硬件连接引脚定义与接线全流程2.1 模块引脚与ESP32的对应关系PMW3901模块的封装有好几种常见的Arducam风格的模块引出6到8个引脚VCC、GND、SCK、MOSI/SDI、MISO/SDO、CS/NCS部分模块还多一个LED_EN。核心引脚就六个和ESP32的VSPI接口直接对插即可。我用的默认引脚接线如下。PMW3901模块引脚ESP32引脚说明VCC3V3电源3.3V输入GNDGND共地SCKGPIO18SPI时钟MOSI/SDIGPIO23主机输出接传感器输入MISO/SDOGPIO19主机输入接传感器输出CS/NCSGPIO5片选低电平有效需要注意模块上的引脚名称可能标注为SDI和SDO对应关系不要接反。SDI是数据输入接到ESP32的MOSI主机输出SDO是数据输出接到ESP32的MISO主机输入。接反的后果是读不到ID或者读到全0xFF。2.2 电平匹配和供电细节PMW3901的工作电压是2.7V到3.6VI/O端口也是3.3V电平所以和ESP32之间不需要电平转换直接连接是安全的。这里最容易被忽略的是千万不要拿5V给模块供电模块上如果带板载稳压芯片还好如果是裸芯片模块5V上去很可能直接烧毁。供电质量对光流数据的影响很大很多人装好后发现SQUAL值忽高忽低或者静止时数据乱跳最后排查发现是电源纹波太大。传感器的LED补光灯在高频开关如果电源带载能力不足LED亮度波动会直接影响成像质量。我的做法是在模块的VCC和GND之间就近加一颗10uF电解电容和一颗0.1uF陶瓷电容去耦效果立竿见影。2.3 布局和走线上的经验传感器安装高度是硬件布局里最重要的一项。PMW3901的工作范围下限是80mm低于这个距离镜头无法合焦输出SQUAL会掉到10以下数据基本不可用。建议安装高度控制在80到150mm之间最理想的是100mm左右。如果装在小车上传感器尽量靠近车体中心减少旋转时带来的额外光流分量。另外模块镜头要朝下安装时注意方向别装反否则会导致x和y轴的符号与预期相反这不是代码问题是坐标系没对齐。SPI线材方面我测试过10厘米以内的杜邦线在2MHz时钟下工作稳定超过15厘米就需要考虑降低SPI时钟或者使用双绞线。如果项目中同时有电机和电调传感器连接线要尽量远离动力线避免大电流切换时的电磁干扰耦合进SPI信号。3. SPI通信原理寄存器地图与关键时序3.1 PMW3901MB的寄存器速查表SPI设备无非就是一堆寄存器理解这些寄存器就等于掌握了一半的驱动代码。PMW3901MB核心寄存器不算多我把项目里实际用到的整理成了速查表。寄存器地址名称读/写说明0x00Product_ID1只读固定值0x42用于握手0x01Product_ID2只读固定值0x010x02Motion只读bit7表示是否有新运动数据0x03Delta_X只读x方向运动增量带符号0x04Delta_Y只读y方向运动增量带符号0x05SQUAL只读表面质量越大代表地面纹理越清晰0x0EResolution只读分辨率相关参数0x3FInverse_Product_ID只读反相ID固定值0xBD0x7FPower_Up_Reset等读写配置寄存器00x61写进入配置模式写0xAD0x63写退出配置模式每次读取运动数据的过程是先读0x02 Motion寄存器如果bit7为1说明这一帧有新的运动数据然后再读0x03和0x04拿x和y的增量同时读0x05拿SQUAL评估地面质量。Motion寄存器被读取之后传感器内部会自动把Motion位清零所以读取顺序不能乱先读Motion再读Delta否则下一帧的数据状态会无法判断。3.2 SPI读写时序到底长什么样PMW3901MB使用的SPI模式是Mode 3也就是CPOL1、CPHA1。这个必须注意如果用了默认的SPI Mode 0去读大概率读回来全是垃圾数据。SPI时钟建议设置在1到2MHz之间足够快也足够稳定太高的时钟反而容易出现读错字节的问题。读单个寄存器的时序分成两段先片选拉低发送一个地址字节地址字节的bit7置1表示读操作然后发送一个任意字节通常发0x00用来产生时钟同时从MISO上读回数据最后片选拉高。写寄存器则更简单片选拉低后发送地址字节bit7为0紧接着发送要写入的数据然后片选拉高。代码层面就是SPI库的简单封装uint8_t readRegister(uint8_t addr) { _spi.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE3)); digitalWrite(_cs, LOW); _spi.transfer(addr | 0x80); // bit71 表示读 uint8_t val _spi.transfer(0x00); // 产生时钟并读数据 digitalWrite(_cs, HIGH); _spi.endTransaction(); return val; } void writeRegister(uint8_t addr, uint8_t val) { _spi.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE3)); digitalWrite(_cs, LOW); _spi.transfer(addr 0x7F); // bit70 表示写 _spi.transfer(val); digitalWrite(_cs, HIGH); _spi.endTransaction(); }3.3 芯片上电握手与初始化序列传感器上电后需要先读ID确认SPI通信链路正常。读0x00寄存器应该得到0x42读0x3F寄存器应该得到0xBD如果这两个值对不上说明接线、电压或者SPI模式有问题不要急着往下写代码。握手通过之后芯片还需要一段初始化序列才能真正开始输出运动数据。这个序列在不同的模块之间不是百分之百相同但Arducam版本和市面上大多数国产模块都支持下面这套我实测可以正常出数据writeRegister(0x7F, 0x00); writeRegister(0x61, 0xAD); writeRegister(0x7F, 0x03); writeRegister(0x40, 0x00); writeRegister(0x7F, 0x05); writeRegister(0x4E, 0x20); writeRegister(0x7F, 0x00); writeRegister(0x63, 0x00);这段序列的意思大致是先重置寄存器页然后写入特定密钥进入配置模式配置LED控制寄存器使能板载补光灯最后退出配置模式。如果初始化之后SQUAL一直为0首先怀疑这个序列没执行到位。4. 完整代码实现从底层驱动到运动检测4.1 代码整体架构代码基于Arduino框架开发适用于ESP32全系列开发板。整个工程拆成两个部分PMW3901驱动类负责SPI通信和寄存器读写主程序负责周期读取数据、做简单滤波、累计位移并输出结果。这样做的好处是传感器逻辑和业务逻辑解耦以后想移植到STM32或者树莓派Pico只需要改动驱动类内部实现上层逻辑不用动。驱动类的核心成员函数就是begin、readMotion。begin里完成ID握手和初始化序列readMotion一次性读出dx、dy和squal三个关键值。对外调用方不需要关心寄存器细节这让代码的复用性提高很多。4.2 PMW3901驱动类实现下面是完整的驱动类头文件和实现我直接在单个.ino文件里写完方便复制。默认使用ESP32的VSPI接口引脚分别是SCK18、MISO19、MOSI23CS用GPIO5。#include SPI.h #define SCK_PIN 18 #define MISO_PIN 19 #define MOSI_PIN 23 #define CS_PIN 5 class PMW3901 { public: PMW3901(uint8_t cs, SPIClass spi) : _cs(cs), _spi(spi) {} bool begin() { pinMode(_cs, OUTPUT); digitalWrite(_cs, HIGH); _spi.begin(SCK_PIN, MISO_PIN, MOSI_PIN, -1); uint8_t id1 readRegister(0x00); uint8_t id2 readRegister(0x3F); if (id1 ! 0x42 || id2 ! 0xBD) { return false; } writeRegister(0x7F, 0x00); writeRegister(0x61, 0xAD); writeRegister(0x7F, 0x03); writeRegister(0x40, 0x00); writeRegister(0x7F, 0x05); writeRegister(0x4E, 0x20); writeRegister(0x7F, 0x00); writeRegister(0x63, 0x00); return true; } void readMotion(int16_t dx, int16_t dy, uint8_t squal) { uint8_t motion readRegister(0x02); dx 0; dy 0; squal 0; if (motion 0x80) { dx (int8_t)readRegister(0x03); dy (int8_t)readRegister(0x04); } squal readRegister(0x05); } private: void writeRegister(uint8_t addr, uint8_t val) { _spi.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE3)); digitalWrite(_cs, LOW); _spi.transfer(addr 0x7F); _spi.transfer(val); digitalWrite(_cs, HIGH); _spi.endTransaction(); } uint8_t readRegister(uint8_t addr) { _spi.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE3)); digitalWrite(_cs, LOW); _spi.transfer(addr | 0x80); uint8_t val _spi.transfer(0x00); digitalWrite(_cs, HIGH); _spi.endTransaction(); return val; } uint8_t _cs; SPIClass _spi; }; PMW3901 flow(CS_PIN, SPI);读寄存器函数里面的_spi.transfer(0x00)不是真的在发数据而是为了产生SCLK时钟同时把MISO上的数据读回来。SPI是全双工协议主机发一个字节的同时会收到从机返回的一个字节这个机制是理解SPI读操作的关键。4.3 主程序数据读取、滤波与位移累计主程序的逻辑很直接10毫秒一个周期调用readMotion把增量做一次死区滤波再累加到全局变量里。死区滤波的意思是当增量绝对值小于2时直接看成0用来消除静止状态下传感器内部噪声引起的漂移。这个阈值不是死的如果发现数据太灵敏就调大如果发现微小移动检测不到就调小。long x_acc 0; long y_acc 0; void setup() { Serial.begin(115200); if (!flow.begin()) { Serial.println(PMW3901 init failed, check SPI wiring!); while (1) { delay(100); } } Serial.println(PMW3901 ready); } void loop() { int16_t dx 0, dy 0; uint8_t squal 0; flow.readMotion(dx, dy, squal); // 死区滤波消除静止噪声 if (dx -2 dx 2) dx 0; if (dy -2 dy 2) dy 0; x_acc dx; y_acc dy; // 串口发送字符 r 可以清零累计位移 if (Serial.available() 0) { char c Serial.read(); if (c r) { x_acc 0; y_acc 0; Serial.println(accumulate reset); } } Serial.printf(dx%4d dy%4d squal%3d x_acc%6ld y_acc%6ld\n, dx, dy, squal, x_acc, y_acc); delay(10); }运行之后打开Arduino IDE的串口监视器把波特率调到115200就能看到一行行实时数据。模块静止时dx和dy应该在0附近小幅波动x_acc和y_acc基本保持不变手拿着模块在桌面上方平移增量方向对应移动方向累计值会跟随移动持续变化。这个反应速度很跟手基本没有延迟感。4.4 简单运动方向判断纯累计位移已经足够看出运动趋势但为了验证数据可用性我加了一段方向判断逻辑当x方向的累计位移超过100时判定为一次明显的左右移动并清零重计。阈值100是counts单位在100mm高度大概对应几厘米的实际位移具体换算关系见下一节。if (abs(x_acc) 100) { Serial.println(x_acc 0 ? - RIGHT : - LEFT); x_acc 0; } if (abs(y_acc) 100) { Serial.println(y_acc 0 ? ^ FORWARD : v BACKWARD); y_acc 0; }这个判断逻辑放在loop里readMotion之后即可。实测在桌面上平移模块到左右两侧串口能稳定输出LEFT和RIGHT方向达到预期效果。方向的正负号取决于模块安装方向如果符号反了把模块转180度安装或者交换代码里的判断逻辑都可以。4.5 从counts换算成实际距离很多人在这一步卡住光流输出的dx/dy到底是什么单位答案是counts也就是像素偏移计数。要想换算成毫米必须知道传感器在当前安装高度下的分辨率系数。PMW3901没有固定输出的毫米值因为同一个像素偏移量在不同高度对应的地面位移是不一样的这是几何透视的自然结果。经验值是在80到120mm安装高度下1 count大约对应1.0到1.5mm的地面位移。但这个值受表面纹理、光照、镜头个体差异影响最可靠的办法是现场标定。把模块固定在小车上让车走一段已知距离比如1米记录累计counts然后用实际距离除以累计counts得到这个安装高度下的标定系数。标定系数k的公式是k 实际位移(mm) / 累计counts。以后每次移动的距离估算就是dx乘以k。这个标定过程看似简单但实际做的时候要注意小车走的速度要均匀不能急加速急减速否则传感器瞬时增量可能超出寄存器范围导致丢数据标定结果就会偏大或偏小。我自己的经验是来回跑三趟取平均这样标出来的系数误差能控制在5%以内。4.6 实测效果展示在普通木纹桌面上我的模块高度约100mmSQUAL值稳定在45到55之间静止状态dx/dy输出基本为0。手拿着模块以正常速度平移10厘米累计值大约在80到120 counts之间和标定系数估算的范围吻合。在白色A4纸表面上SQUAL会掉到25左右但依然能检测到运动只是偶发一两个像素的跳动在纯色光滑瓷砖上SQUAL低于15数据明显变差会出现漏检和方向不稳。这个结果符合预期光流传感器依赖地面纹理特征纹理越丰富数据越可靠。纯色光滑表面是光流传感器的天敌这不是模块质量问题是物理限制。5. 常见问题与避坑指南5.1 读不到芯片ID怎么办这是第一个也是最常见的坑。程序卡在init failed读0x00寄存器不是0x42。排查顺序我建议从硬件到软件先用万用表确认模块VCC引脚确实是3.3VGND和ESP32共地然后核对SCK、MOSI、MISO、CS四根线是否一一对应MISO和MOSI接反的概率最高确认无误后看SPI模式是否设置为Mode 3默认的Mode 0会读不到正确ID。如果硬件和模式都正确还是读不到检查SPI时钟频率。有些模块对时序要求苛刻2MHz不行就降成1MHz试。还有一个小细节CS引脚在初始化之前必须处于高电平因为PMW3901片选是低电平有效如果上电时CS被拉低芯片可能进入异常状态。我的代码里在begin函数第一件事就是把CS拉高这个顺序很重要。5.2 SQUAL值一直很低或者为0SQUAL是表面质量参数数值越大代表地面纹理越清晰。我实测的参考标准是大于30数据可靠20到30勉强能用低于15基本没法用。如果SQUAL长期个位数先检查安装高度模块距离地面低于80mm会离焦这是最常见原因。把模块抬高到100mm左右再试通常会有明显改善。排除高度问题后看地面材质纯白纸、亚克力板、抛光瓷砖都属于弱纹理表面光流效果会很差。这不是传感器坏了换一块木纹纸或者地毯就能验证。还要检查镜头表面是不是沾了灰模块上的小镜头很容易积灰用棉签蘸酒精轻轻擦拭即可。最后确认板载LED有没有亮如果LED不亮可能是供电不足或者LED控制寄存器被错误配置。5.3 数据跳动很大静止时累计值乱飘静止时dx/dy理论应该为0但实际上会有±1或者±2的噪声这是正常的。如果噪声幅度超过了±5就要排查电源。PMW3901内部有LED补光电源纹波大会导致成像亮度波动直接影响光流计算。处理办法是在模块供电脚加去耦电容10uF和0.1uF并联效果最好。另外SPI线太长也会导致读回来的数据偶发错误表现就是某一次dx突然跳到一个很大的值。解决方法是缩短杜邦线或者把SPI时钟从2MHz降到1MHz。如果项目中同时有电机在转传感器线要远离电机线我之前把光流线跟电机线绑在一起走结果电机一转数据就狂跳分开走线后问题立刻消失。5.4 为什么移动方向和实际方向相反这通常不是传感器故障而是坐标系问题。PMW3901的x/y正方向是相对于芯片封装丝印定义的如果模块安装方向和你的车体坐标系不一致输出符号就会反。解决办法要么旋转模块安装方向要么在程序里对dx/dy取反。还有一点容易被忽视模块平移和旋转时光流数据是平移和旋转分量的叠加如果模块没有安装在车体旋转中心小车的原地旋转也会产生平移动量。这个在做精确里程计的时候必须注意。5.5 传感器和WiFi同时使用会不会互相干扰ESP32开WiFi时射频工作频段是2.4GHzPMW3901的SPI时钟只有2MHz两者频率差得很远正常情况下WiFi射频不会直接影响SPI通信。真正的问题在电源WiFi发射瞬间电流可以达到几百毫安如果ESP32和传感器共用一组稳压电源这个瞬时跌落会传导到传感器的LED驱动导致帧间亮度波动。所以如果项目同时开启WiFi和光流电源设计要更扎实传感器供电最好单独用一路LDO并且加足够的去耦电容。实测在ESP32开WiFi发送UDP数据的同时读取光流数据依然稳定没有出现丢包或者误码。6. 后续可以怎么扩展如果跑通了这个基础版后面有很多有价值的扩展方向。最简单的是给模块增加MPU6050惯性测量单元用光流数据校准加速度计积分漂移可以做更平滑的速度估计。稍微复杂一点的是用ESP32的WiFi把光流数据通过UDP广播到电脑在上位机里实时绘制运动轨迹这个非常适合作为课程设计或者毕业设计的展示内容。再进阶就是用光流做小车的闭环走直线控制通过读取光流侧向位移量修正电机的PWM输出。按我的经验最值得先做的是把标定系数测准。很多人在这一步嫌麻烦直接用经验值结果小车跑一米测出来一米三整个系统都跟着不准。花十分钟现场标定一次后面所有数据才谈得上可信。光流传感器是一个“看着简单细节很多”的器件接线、时序、安装高度、供电质量任何一环掉链子都会让数据变得不可用但也正因为如此完整跑通一遍之后对SPI协议和传感器特性的理解会非常扎实。
企业数字化 ERP 产品动态
相关推荐
OpenCV三维点云重建:从深度图到网格的全流程实践 简介:这是一份面向计算机视觉工程师、三维重建研究者及OpenCV进阶学习者的深度技术文档,系统解决稠密点云重建中法线估计不准、网格生成质量差、多视角融合误差累积等核心难题。全书732页,含52个章节,覆盖从相机标定、立体匹配、视… · 2026/9/25 2:02:49
uni-app跨端开发入门:从环境搭建到多端适配完整指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 2:02:43
ESP32开发:从-Og切到-O2后程序崩溃?排查与解决 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 2:02:43
英伟达暑期实习笔试样题解析:GPU体系结构与深度学习考点 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 2:36:14
使用 AWS SDK for .NET 构建 Amazon SES v2 优惠券新闻邮件工作流:从联系人列表到模板化群发 示例工程教程后端 【免费下载链接】aws-doc-sdk-examples Welcome to the AWS Code Examples Repository. This repo contains code examples used in the AWS documentation, AWS SDK Developer Guides, and more. For more information, see the Readme.md file below. 项目地… · 2026/9/25 2:36:14
校园失物招领系统毕设资料包二次开发与答辩指南 简介:这份资源是面向计算机相关专业在校学生与教师的校园失物招领系统毕业设计完整资料包,已获导师认可并通过答辩评审,适合作为毕设、课程设计、作业或项目初期立项演示的参考方案,也便于基础较好的学习者在此基础上二次开发扩展… · 2026/9/25 2:36:14
微信小程序汉字笔顺动画组件:Canvas渲染与避坑指南 简介:这是一份面向微信小程序开发者的 Hanzi Writer 组件源码包,用于在小程序内快速集成汉字书写器,实现笔画顺序动画、写法演示与问答交互等教学功能。组件原仓库虽已停止维护,但作者提供了 npm 的 beta 安装方式,适合… · 2026/9/25 2:36:08
创维E900V22D刷机全攻略:S905L3SB芯片兼容性解析与救砖实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:31
MQTT协议原理与Broker服务器搭建实战:从Mosquitto到EMQX /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:37