news 2026/10/1 8:53:48

ESP32-P4实战ROS2小车:蓝牙遥控、温度采集与OTA升级全记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32-P4实战ROS2小车:蓝牙遥控、温度采集与OTA升级全记录

先交代一块背景板。我最近入手的板子丝印上写着ESP32-P4NRW32X,第一反应是它很像一套“复合型”机器人控制平台:核心是乐鑫新一代ESP32-P4主控,旁边还挂了一个 NRW32X 无线模组,专门负责 Wi-Fi 和蓝牙链路。整块板最后被我用在了一个小型 ROS2 小车项目上:手机 App 通过 BLE 控制车轮,Ubuntu 里的ROS2 Humble通过串口桥向下发/cmd_vel,同时板子一直在采集 DS18B20 温度传感器的数据,还带 OTA 远程升级。

如果你手里也有类似的 ESP32 平台,或者正想从 Arduino 裸机玩法过渡到机器人操作系统,那下面这些环境搭建、蓝牙调试、串口协议设计、低功耗和 OTA 的实操记录,应该能帮你少踩不少坑。文章不会端着教科书腔调,我就按自己实际折腾的顺序来写,遇到哪个梗就说哪个梗。

1. 项目整体拆解:ESP32-P4NRW32X 到底能做什么

1.1 硬件平台与命名解读

很多朋友第一次看到“P4NRW32X”这串字符会误以为是乐鑫的官方型号,严格来说它更像一块第三方评估板的定制命名。拆开来看:“ESP32-P4”指主控芯片,使用 RISC-V 双核跑到 400MHz 级别,带向量扩展和 AI 加速,视频编码方面也继承了 H264/H265 的能力;“NRW32X”则是我这块板子上的 2.4G 无线模组,负责提供 Wi-Fi 和 BLE 射频能力。为什么要这么拆?因为 ESP32-P4 本身不带射频,这是它和 S3/C3 最大的差异点,乐鑫的定位就是把 P4 当成“多媒体 + 算力”核心,而把无线通信交给外挂模组去处理。

板子上除了主控和无线模组,还有几组比较关键的硬件资源,我整理了一张速查表供参考:

硬件模块具体配置项目中的用途
主控ESP32-P4,双核 RISC-V,内置 SRAM跑控制逻辑、传感器数据处理、ROS2 帧解析
无线模组NRW32X,2.4G Wi-Fi / BLE手机蓝牙遥控、后续 OTA 升级
电机驱动接口双路 H 桥,带 PWM 输入驱动小车左右轮
传感器接口3.3V/5V 电平可选,I2C/UART/SPI 引出接 DS18B20、编码器、差分 RTK 模块
调试烧录板载 USB 转串口 + JTAG固件下载、日志输出、串口桥接

说句实在话,把无线和主控分开的方案虽然增加了硬件设计复杂度,但工程上非常灵活。如果你需要换用 ESP32-S3 做低功耗控制端,或者单独升级无线模组,都不需要重新打整块主板。这也是我决定拿它做综合项目的原因之一:以后想外接 OV2640 摄像头做视觉识别,或者接差分 RTK 模块做户外导航,P4 的多媒体和算力基础已经提前铺好了。

1.2 应用画布:为什么是 ROS2 加蓝牙双链路

项目真正动手前,我最纠结的一个问题不是“能不能驱动电机”,而是“遥控链路到底走哪条”。最终定的方案是双链路并存:手机 BLE 遥控走了低延迟手动控制,ROS2 则负责相对复杂的自动控制。两条链路共用同一套串口指令协议,协议层独立于物理链路。

这个设计有几个明确的好处。第一,调试阶段效率极高,手机 App 不用通过 ROS 节点中转,连上 BLE 就能直接低层验证电机转向和转速。第二,跑 ROS2 算法时可以保留手动接管的能力,小车遇到墙上障碍,手机一键下发停止指令,比在 RViz 上点按钮快得多。第三,上下位机只维护一种帧格式,后续如果要接手柄、接入 Web 控制页面,或者加第二块 ESP32 作为子节点,都不需要为每种通信方式单独造协议。

蓝牙这边我用的是 BLE 而不是经典蓝牙。经典蓝牙虽然带宽更高、连接建立最简单,但手机端往往要配对,功耗也不理想;BLE 在手机兼容性和低功耗唤醒上更舒服,遥控小车这种每秒 20~50Hz 的小数据量指令,BLE 完全够用。ROS2 那边则用的是串口桥接方式,没有上 micro-ROS。原因很朴素:在当前阶段我不想让 MCU 侧承担 DDS 中间件的额外内存开销,串口协议只需要十几个字节就能表达速度指令,把解析和反馈逻辑放在上位机节点里,可维护性反而更好。

2. 开发环境搭建:三套工具链的取舍与避坑

2.1 Arduino 环境是最快的上手路径(含国内镜像)

我先在第一晚用 Arduino IDE 2.x 点亮了板载 LED 和跑了个串口回环,原因是 Arduino 对新手最友好,生态里的库也都现成。在“开发板管理器”里添加 ESP32 支持包时,我选了离线安装方式,用了arduino-esp32 3.3.11 的完整离线包,这样不需要每次去拉 GitHub 资源,Windows 下直接解压到%LOCALAPPDATA%\Arduino15\packages就能用。如果你网络环境不方便,也可以手动把包地址指向乐鑫在国内的镜像仓库,效果类似。

添加完支持包之后,在开发板列表里找到 ESP32-P4 对应的型号。不同版本的 Arduino core 对 P4 的支持力度不一样,建议优先用 3.x 以上版本。我实际测试中,GPIO、UART、I2C、PWM 这些基础外设都能正常用,但个别硬件加速外设(比如 H264 编码)在 Arduino 里还不太完善,这部分我后面改用了 ESP-IDF 来搞。

第一次烧录时有一个小坑:如果你的板子丝印上既有 USB 串口又有 JTAG,电脑上会出现两个虚拟串口。默认 Arduino 会枚举到COMx的第一个,但烧录有时候需要切换到第二个。我最开始一直显示“连接失败,串口无响应”,最后才发现是选错了串口。建议烧录前先用串口助手打开两个口各发一个 0x00,看哪个有响应就选哪个。

2.2 ESP-IDF v5.x 与 PlatformIO 的取舍

Arduino 适合快速验证,但涉及 P4 的深度优化,比如双核任务分配、低功耗模式和 AI 加速器,我最后还是切到了ESP-IDF v5.x。IDF 的组件化结构更适合项目工程化,也有完整的idf.py menuconfig配置界面,可以很直观地开启/关闭各种驱动和协议栈。

安装 IDF 时,我用了官方的install.sh,然后通过设置IDF_GITHUB_ASSETS环境变量把工具链下载指向国内镜像,这样install过程会快很多。装完之后记得source $IDF_PATH/export.sh,之后用idf.py create-project创建新工程。对比 Arduino,IDF 的学习曲线确实陡,但它的优势在于轻量、可裁剪、出错时能看到更多底层日志。

如果你同时维护多个芯片项目,也可以考虑用 PlatformIO。PlatformIO 的好处是用一个工程文件就能选择不同开发板、不同框架,小项目用 Arduino 框架,复杂项目用 IDF 框架,切换成本低。但要注意,P4 的 platform 支持包不一定跟随最新版本,如果发现 board 列表里没有 P4,可以手动在platformio.ini里指定board为乐鑫官方 P4 型号,并锁定较新的platform-espressif32版本。

2.3 烧录方式与常见失败原因

这套板子支持三种烧录方式:板载 USB 转串口、JTAG、以及 UART 外部下载。日常使用中,USB 转串口最方便,也就是idf.py flash monitor一键完成。需要注意的是,启动时如果 NRW32X 模组和主控之间有硬串口连接,烧录过程可能被模组的日志数据干扰。我的做法是把模组通信跳线在烧录时断开,烧完再接回去,稳定起见。

用 esptool 直接刷 bin 文件的场景也经常遇到,比如从 Arduino 导出的版本或者别人给好的工厂固件。标准命令大致是:

esptool.py --chip esp32p4 -p /dev/ttyUSB0 -b 921600 write_flash 0x0 combined.bin

常见的失败原因我集中整理过:

  • 串口被日志工具占用,关掉监听再烧。
  • 供电不足:部分 P4 板载功率较大,插 USB 可能没问题,但外接舵机或电机驱动后电压跌落,导致芯片复位,烧录不稳。这时候外接 5V/2A 电源稳得多。
  • Boot 模式不对:有些板子需要按住 BOOT 再上电;如果一直连接超时,先按 BOOT,再点烧录,最后复位。
  • 串口线过长或 USB 线质量差:换一根短而粗的线,很多玄学问题就消失了。

3. 核心功能实现:蓝牙、温度传感器与外部中断

3.1 蓝牙 Classic 还是 BLE?我用 BLE 控制小车

项目需要同时跑控制和 OTA,所以我给无线部分分配了两个服务:BLE 负责低功耗指令通道,Wi-Fi 负责 OTA 和远程状态上报。BLE 这边的设计其实很简单,一个自定义服务,两个 characteristic,一个用于下行指令(写),一个用于上行状态通知(读/通知)。

在 Arduino 环境下,核心代码框架大概是这样:

#include <BLEDevice.h> #include <BLEUtils.h> #include <BLEServer.h> #define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e" #define CHAR_RX_UUID "6e400002-b5a3-f393-e0a9-e50e24dcca9e" #define CHAR_TX_UUID "6e400003-b5a3-f393-e0a9-e50e24dcca9e" BLEServer *server; BLECharacteristic *txChar; bool speedChanged = false; uint8_t cmd = 0; class RxCallback : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *c) override { std::string value = c->getValue(); if (value.length() > 0) { cmd = value[0]; speedChanged = true; } } }; void setupBLE() { BLEDevice::init("P4NRW32X"); server = BLEDevice::createServer(); BLEService *service = server->createService(SERVICE_UUID); BLECharacteristic *rxChar = service->createCharacteristic( CHAR_RX_UUID, BLECharacteristic::PROPERTY_WRITE); rxChar->setCallbacks(new RxCallback()); txChar = service->createCharacteristic( CHAR_TX_UUID, BLECharacteristic::PROPERTY_NOTIFY); service->start(); server->getAdvertising()->start(); }

这里最容易被忽略的是 BLE 写操作的拆分:如果手机一次发送超过 MTU 大小的数据,ESP32 会把数据拆成多包,onWrite会被多次触发。遥控指令就一个字节,问题不大,但如果以后要传地图或基站坐标,一定得自己处理分包和重组,否则会出现“指令被截断但 CRC 永远对不上”的诡异现象。

手机端我最初用的 nRF Connect 手动发指令,后来写了个简单 Flutter App,网页端也可以用 Web Bluetooth。说到底 BLE 只是通道,真正重要的是把收到的cmd翻译成电机 PWM。我这里定义了 0x01 前进、0x02 后退、0x03 左转、0x04 右转、0x00 停止,用了两张映射表,一张给蓝牙,一张给 ROS2 串口桥,两张表完全一致。

3.2 DS18B20 温度传感器的接线与读值

为什么小车项目里要放一个温度传感器?一方面我想验证板子的 I2C/单总线外设能力,另一方面小车电机和电池包长时间运行后温度很直观,留着这个数据后面可以做高温保护。DS18B20 是单总线器件,接线只需要一根数据线和一根地线,加上一个 4.7kΩ 上拉电阻到 3.3V。

我把它接到了 P4 的任意 GPIO 上,因为芯片内部有上拉和下拉,但为了保证长线下的时序,还是外置了一个 4.7kΩ 电阻更稳妥。Arduino 下直接用OneWire和DallasTemperature两个库:

#include <OneWire.h> #include <DallasTemperature.h> #define ONE_WIRE_BUS 5 OneWire oneWire(ONE_WIRE_BUS); DallasTemperature sensors(&oneWire); void setup() { Serial.begin(115200); sensors.begin(); } void loop() { sensors.requestTemperatures(); float temp = sensors.getTempCByIndex(0); Serial.printf("Temperature: %.2f°C\n", temp); delay(1000); }

温度读不到的高发原因,我实测下来有两个。一个是 GPIO 选到了错误的引脚,很多板子引脚图上是 5,但其实背面的丝印标的是 D5,对应芯片内部另一个 GPIO 号;另一个是 DS18B20 的 VCC 和 GND 接反,会直接发烫,换一颗就好。此外,当单总线器件比较多时,上拉电阻可能要降到 2.2kΩ,否则高频时序下信号边沿不足,读出来的数据偶尔会跳变。乐鑫的传感器库里自带 CRC 校验,如果经常返回85度,基本都是线路干扰或供电电压过低。

3.3 外部中断实战:编码器与激光雷达触发

小车要闭环,必定要读编码器。最初我用的是 GPIO 外部中断,每个上升沿进一次 ISR,做一边方向判断一边计数。这种简单方案有两个隐藏问题:P4 上如果多个 GPIO 同时触发中断,处理不及时会丢脉冲;并且 ISR 里不能做重活,否则主循环会被拖慢。

所以最终我改用了 ESP32 的 PCNT 外设来数脉冲。PCNT 是硬件计数单元,上升沿下降沿自动累加,完全不需要 CPU 干预。你的板子如果引出了编码器 A/B 相,直接映射到两路 PCNT 输入即可。这里重点说一个通用外部中断的经验:ISR 里千万不要打印日志,也不要调用delay,只设置一个volatile标志位,真正的工作丢到loop()或者独立 task 里去做。

我还在车头加了一个激光对射传感器用来检测障碍物,触发方式类似。逻辑是传感器输出低电平时触发外部中断,中断里把obstacleFlag = true,主循环一旦检测到标志就立刻刹车。这个倒不是为了精密避障,而是为了验证外部中断响应速度。实测下来,如果主循环跑在 50Hz 控制周期,从外部触发到执行刹车动作,延迟基本在 10ms 以内,完全能满足低速小车的安全逻辑。

4. ROS2 Humble 串口桥接:让 ESP32 小车接入机器人生态

4.1 串口协议设计(帧格式与校验)

在 ROS2 和 MCU 之间通信,很多人第一反应是直接用 JSON 或逗号分隔文本,调试起来确实直观。但机器人控制要求的是稳定和可解析性,我在这个项目里用了一套更紧凑的二进制帧格式。帧结构如下:

字段长度说明
帧头2 字节0xAA 0x55,用于同步
长度1 字节负载段长度
命令字1 字节0x01 速度控制,0x02 读传感器等
负载N 字节速度数据、转向角、CRC 数据等
CRC162 字节校验长度+命令+负载

选 CRC16 而不是累加和,是因为串口链路里偶发的连续多位翻转很难被累加和检测出来。CRC16 的多项式直接查表实现,在 ESP32 上跑一个 8 字节帧,耗时几乎可以忽略。设计协议的时候还有一个经验,把“长度”字段放在命令字前面,这样接收端可以先缓冲完整帧,再解析,避免串口粘包。粘包问题在高频率通信时很常见,处理方法就是维护一个环形缓冲区,每次从缓冲区扫描帧头,一帧凑齐长度就切出去处理。

波特率我设成了 115200,8N1。这个速度对 50Hz 控制周期绰绰有余:一帧大概 10 个字节,换算下来不到 1ms 传输时间。如果你插入了 GPS 或 RTK 数据,需要把波特率提到 230400 或更高,但注意两边都要改,而且高速率下对 USB 转串口芯片质量也有要求。

4.2 ROS2 节点实现(serial + geometry_msgs/Twist)

上位机部分运行在 Ubuntu 22.04 上的 ROS2 Humble。我写了一个轻量 Python 节点,用pyserial打开串口,订阅geometry_msgs/msg/Twist话题,再把线速度和角速度拆成左右轮的 PWM 值,封装到上面的帧里发给 ESP32。

关键代码可以拆成三块:串口初始化、话题回调、帧发送。伪代码如下:

#!/usr/bin/env python3 import rclpy from rclpy.node import Node from geometry_msgs.msg import Twist import serial import struct class SerialBridge(Node): def __init__(self): super().__init__('esp32_serial_bridge') self.ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.1) self.sub = self.create_subscription(Twist, 'cmd_vel', self.cmd_cb, 10) def crc16(self, data): crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc def cmd_cb(self, msg): linear = max(-0.5, min(0.5, msg.linear.x)) angular = max(-2.0, min(2.0, msg.angular.z)) payload = struct.pack('<ff', linear, angular) frame = b'\xaa\x55' + bytes([len(payload), 0x01]) + payload frame += struct.pack('<H', self.crc16(frame[2:])) self.ser.write(frame) def main(args=None): rclpy.init(args=args) node = SerialBridge() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

这个节点虽然短,但已经够用。需要注意 Linux 下串口权限问题,如果pyserial打不开/dev/ttyUSB0,把当前用户加入dialout组,或者写一个 udev 规则固定设备名字。还有一点,cmd_vel话题在仿真或遥控手柄里经常高频发布,我这里加了线速度和角速度的限幅,避免上位机误发过大的速度把电机驱动烧掉。

比较过 micro-ROS 方案后,我坚持用串口桥。因为 P4 已经承担了很多东西,再接 DDS 到 MCU 上会增加内存和调试成本;串口桥只负责很小的一个子集,透明且可控。如果你的项目需要多节点动态发现、参数服务器、action 通信,再考虑升级成 micro-ROS 也不迟。

4.3 实测效果与延时数据

在真正跑起来之前,我最担心的是串口解析在主循环里会不会阻塞其他任务。实际测试的结果让我比较满意:ROS2 下发速度指令到电机 PWM 改变的端到端延迟,在 50Hz 控制周期下大约 40~60ms,其中大部分是我故意在 ESP32 端做了一个 20ms 的滤波缓冲,防止车辆抖动。手机 BLE 手动遥控的延迟反而更低,基本在 30ms 左右,因为 BLE 指令不需要经过 ROS 节点和话题机制。

如果要进一步降延迟,可以从几个方向着手:把串口波特率提到 460800;在 ESP32 端启用 UART 的 RX 中断而不是轮询;把控制周期从 50Hz 提到 100Hz。但提高周期并不总是更好,电机驱动太频繁会导致 PWM 输出抖动,反而增加电流噪声。我个人建议小车主控周期 50Hz 是一个比较均衡的数值。

上位机调试时,我习惯用ros2 topic hz /cmd_vel和rostopic echo检查话题频率和数据内容,然后再看串口是否对齐。遇到乱码时,不要先怀疑代码,先看两边波特率和数据位是否一致。蓝牙控制正常但 ROS2 控制不响应,往往不是协议问题,而是 Python 节点的 SPI 串口没设置好流控,导致 CTS/RTS 信号互相拉扯。

5. 低功耗、OTA 与常见问题

5.1 睡眠模式与 I2C 复位问题

小车项目跑通之后,我又想把整套系统往低功耗方向改一改,毕竟户外场景经常要靠电池供电。ESP32 家族的睡眠模式分浅睡和深度睡眠,P4 也不例外。浅睡眠时 CPU 暂停但外设还保持状态,适合需要快速唤醒的控制场景;深度睡眠则把大部分 RAM 断电,只能靠定时器、GPIO、或者触摸等外设唤醒,唤醒后相当于重启。

有网友问过“休眠后 I2C 复位”的问题,我也遇到了。现象是深度睡眠唤醒后,接在 I2C 总线上的温湿度传感器经常读不到。仔细排查后发现,I2C 外设在睡眠恢复后可能处于异常状态,此时最好的办法是在初始化的时候重新执行一遍 I2C 驱动安装,并且把 SDA/SCL 引脚做一次电平复位:

i2c_driver_install(I2C_NUM_0, I2C_MODE_MASTER, 0, 0, 0); i2c_driver_reset(I2C_NUM_0);

如果你的传感器是 5V 模块,还要确认电平转换器是否在睡眠时也断电,否则总线漏电会让唤醒后的系统永远拉低 SDA,导致挂死。

深度睡眠的唤醒源我同时开了定时器和 GPIO。定时器用于周期上报传感器数据,GPIO 则接了一个触摸引脚,人一摸就唤醒屏幕和系统。P4 的低功耗表现比老款 ESP32 好不少,但 NRW32X 无线模组本身还是会耗电,所以我在不需要 Wi-Fi/BLE 时直接把模组电源引脚关掉,休眠电流能再降一截。这个细节在算电池续航时非常关键。

5.2 OTA 升级:从 Arduino 到 IDF 的差异

当一个设备部署到现场,总不能每次更新都拆机插串口。OTA 就成了必要功能。Arduino 环境里有现成的Update.h库,配合 Web 网页可以实现浏览器上传固件,这就是很多教程里提到的“内嵌 Web 网页”玩法。做法是在同一块板子同时启动 HTTP Server,访问http://192.168.x.x就能看到一个上传页面,点击选择固件 bin,然后调Update.writeStream()写入flash。

IDF 的 OTA 要稍微细一些,核心是分区表和 rollback。分区表里必须预留两个ota_0和ota_1分区,固件先写入未激活分区,再把激活分区标记切换。升级失败时,IDF 可以通过esp_ota_mark_app_valid_cancel_rollback()告诉系统“当前版本可用”,如果你没调用这个接口,重启后系统会自动回滚到上一个可用分区。这个安全机制在无人在现场的远程升级里是保命符。

我实测过一个小坑:P4 搭配 NRW32X 做 OTA,下载固件时如果无线信号不稳定,HTTP 连接会中断。解法是采用分段下载,并在下载完整的固件大小达到目标长度后才进行校验,不要收到一半就开始写 flash。另外,OTA 前记得把 Web 服务的缓冲区调大,不然加载响应缓慢会被误判成死机。

5.3 常见问题速查表

按照惯例,把折腾过程中遇到的高频问题丢进一张表里,方便你直接检索:

现象可能原因解决方案
串口无输出选错串口或 TX/RX 接反切换板载两个串口;检查板卡原理图
烧录一直失败BOOT 模式未进入或供电不足按住 BOOT 上电,外加电源
BLE 连不上手机NRW32X 模组没上电或天线匹配差确认模组电源引脚拉高,检查天线区域
ROS2 串口乱码波特率不一致或 GND 未共地统一 115200,检查串口线共地
温度传感器读数 85单总线干扰或 CRC 错误加强上拉电阻,缩短杜邦线
深度睡眠唤醒后传感器失效I2C 驱动未重新初始化调用i2c_driver_reset,重新枚举外设
OTA 升级失败重启后回滚未标记固件有效升级成功后在业务代码里调用 rollback 接口

最后分享一个我在这个项目里体会最深的设计习惯:不要把所有功能都耦合在同一个loop()里。P4 有双核,我把电机控制、蓝牙解析、传感器采集分别放到不同的 FreeRTOS 任务里,用队列传递指令;主核跑控制逻辑,辅核跑协议解析和日志输出。这样不仅延迟更稳定,后期加功能时也不用担心某个delay()把整个系统卡住。这套小车的代码已经变成我测试各种传感器的通用底座,后面如果再碰到想验证的新外设,直接插入协议表即可,不需要重写一遍通信框架。

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

AIO Sandbox:全功能集成开发沙箱架构与实战指南

1. 项目概述&#xff1a;为什么需要一个“全功能集成沙箱”&#xff1f;AIO Sandbox 这个名字里的“AIO”不是“人工智能优化”&#xff0c;也不是“高级输入输出”&#xff0c;而是All-in-One——字面意思&#xff0c;把所有开发、调试、分析、交互场景塞进同一个容器里。我第…

作者头像 李华
网站建设 2026/10/1 8:50:13

马德拉自由行全攻略:8天7夜徒步路线、自驾住宿与美食避坑指南

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

作者头像 李华
网站建设 2026/10/1 8:49:22

C++中const与constexpr的本质区别与协同使用

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

作者头像 李华
网站建设 2026/10/1 8:49:04

艾思控高压驱动器选型指南:220V/380V直驱方案实战解析

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

作者头像 李华