news 2026/10/26 9:34:29

ESP32-S3 Dev Module开发实战:环境配置、I2S语音采集与踩坑经验

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32-S3 Dev Module开发实战:环境配置、I2S语音采集与踩坑经验

很多人把ESP32S3 Dev Module当成一块“比ESP32多了摄像头接口”的板子,其实这远远低估了它的特殊之处。它搭载的ESP32-S3芯片自带AI加速指令集,支持向量运算,官方主打的卖点之一就是边缘AI和语音识别场景。搭配Arduino开发环境,这块板子能做的远不止点灯和跑串口,我过去几个月用它在几个项目里当主力板,从智能小车到语音交互、从环境传感器采集到低功耗门禁,踩了不少坑,也攒了不少经验。这篇内容我就按实际使用的顺序,把从选型到环境搭建、再到核心实操和问题排查的完整链路写出来,希望能给正在纠结“ESP32S3到底怎么上手”的人一份可抄作业的参考。

1. 为什么最终常选ESP32S3 Dev Module

1.1 核心定位与参数解读

ESP32-S3是乐鑫推出的一颗MCU,虽然名字里带“S3”,但它不是ESP32的简单迭代。它采用双核Xtensa LX7处理器,主频最高能到240MHz,关键是内置了向量加速指令,官方叫法是“AI Acceleration”。这意味着在做音频处理、FFT、图像识别这类计算时,比传统的纯CPU方案能快出不少。Dev Module是市面上最常见的S3核心板形态,通常板载一个USB转串口芯片(比如CP2102或CH340)、几颗按键、一个RGB彩灯,以及把GPIO全部引出的排针。如果买的是N16R8版本,则自带16MB Flash和8MB PSRAM。我手上这块就是N16R8,实测下来跑带摄像头的人脸检测,或者同时挂好几个传感器再加TFT屏幕,内存都不至于紧张。

选这块板子,我个人看重三个点:一是原生USB支持,S3直接支持USB OTG,板子上的USB口不只是供电和下载,还能模拟成键盘、鼠标甚至MIDI设备;二是外设接口丰富,SPI、I2C、UART、I2S、SDMMC基本全齐,后期接INMP441这样的I2S麦克风几乎零门槛;三是Arduino生态成熟,乐鑫官方维护了arduino-esp32内核,虽然更新节奏偶尔让人头疼,但社区活跃,大部分问题搜一下就能解决。

1.2 与其它开发板的差异化选择

很多人会在ESP32S3和ESP32C3、ESP32D之间纠结。我简单总结一下我的判断角度。C3是单核,主打低成本低功耗,适合做简单的IoT节点;D系列虽然便宜,但双核性能一般,而且不支持AI加速。S3恰好是“性能与接口的折中”,不算最强,但胜在协议栈和工具链成熟。如果你只是点个灯、做个温湿度采集,用ESP32C3甚至NodeMCU就够用了,没必要多花钱买S3;但如果计划上摄像头、麦克风阵列、语音唤醒、屏幕交互这种多任务场景,S3比同类价位板子值很多。

还有个容易忽略的点是Dev Module的板载RGB灯和下载电路设计。部分国产S3核心板在自动下载电路上做得不严谨,经常出现插上USB后直接进入下载模式或者重启失败的情况。选板时最好优先选带官方原理图、用独立USB-UART芯片的方案,比如合宙、启明云端或者乐鑫自家ESP32-S3-DevKitC-1的原型产品,能少掉一堆莫名奇妙的坑。

2. 开发环境搭建:从Arduino IDE到PlatformIO

2.1 Arduino IDE安装ESP32S3开发板的完整流程

用Arduino IDE开发S3,第一步是给IDE安装板级支持。官方推荐的方式是通过“开发板管理器”添加JSON地址。

https://espressif.github.io/arduino-esp32/package_esp32_index.json

操作路径是:文件 → 首选项 → 附加开发板管理器地址,把这个JSON地址填进去。然后在工具 → 开发板 → 开发板管理器里搜索“esp32”,找到“esp32 by Espressif Systems”并安装。这里有个老生常谈的坑:国内网络环境下,从GitHub拉取归档文件经常超时,导致安装失败。我的处理办法是使用代理镜像源,或者下载离线包手动安装。这两条路径都能走通,但离线包更稳,特别是你用旧版Arduino IDE时,Github下载那段经常卡死。

安装完板级支持后,在“开发板列表”里就能看到大量ESP32系列。ESP32S3 Dev Module对应的选项名称就叫“ESP32S3 Dev Module”。选完板子后,还要在“Flash Size”里选择“16MB (128Mb)”,在“PSRAM”里选择“OPI PSRAM”,如果板子上没有PSRAM则选“Disabled”。这些参数必须和你的硬件匹配,否则编译能过,但烧录后跑起来容易出诡异花屏、重启、内存分配失败等问题。

2.2 PlatformIO下创建ESP32S3 Arduino项目

很多习惯用PlatformIO的人会发现,PlatformIO里搜索ESP32S3开发板,默认列表并没有单独的“ESP32S3 Dev Module”。这是因为PlatformIO对板子的标识是基于“board ID”的,ESP32S3的通用板型ID,常见的是esp32-s3-devkitc-1。如果你用的是合宙或者其他模块商的板子,可能还需要自己在platformio.ini里覆盖一些参数。

一个可用的platformio.ini示例:

[env:esp32-s3-devkitc-1] platform = espressif32 board = esp32-s3-devkitc-1 framework = arduino monitor_speed = 115200 board_build.flash_size = 16MB board_build.partitions = default_16MB.csv board_upload.flash_size = 16MB board_build.psram_type = opi

用PlatformIO的一个好处是依赖管理方便。比如你需要用TFT_eSPI库驱动屏幕,直接在lib_deps里加一行,平台会自动拉取。而且它的编译日志更详细,出现内存不足、头文件冲突时,排查体验比Arduino IDE舒服。我现在的习惯是:快速验证代码逻辑用Arduino IDE,正经工程用PlatformIO,两个环境共存,互不冲突。

2.3 驱动与串口说明

S3 Dev Module在Windows下一般需要装CP2102或CH340驱动,macOS下多数免驱。插上USB后如果系统只识别到一个未知设备,八成是驱动没装好。如果识别出两三个COM口,不要慌,其中一个是原生USB(CDC),另一个是串口芯片。Arduino IDE里要选的通常是带“USB-UART”标记的那个,具体名字因板而异,但记住选那个“Friendly Name”里没有“USB”标志的即可。烧录时有些板子需要手动按下BOOT键再上电,才能进入下载模式;但正规Dev Module的自动烧录电路一般能自动切换,不用手动操作。

3. 核心功能实操:从点灯到语音采集

3.1 最小系统验证:LED点灯与串口打印

拿到任何开发板,先跑最小系统,确认开发环境链路正常。S3 Dev Module板载的RGB灯通常接在GPIO 48,有些版本是GPIO 38,不同厂商定义不一样,最好看一下原理图。以GPIO 48为例:

#define RGB_LED 48 void setup() { Serial.begin(115200); pinMode(RGB_LED, OUTPUT); } void loop() { digitalWrite(RGB_LED, HIGH); delay(500); digitalWrite(RGB_LED, LOW); delay(500); Serial.println("Hello, ESP32S3!"); }

编译上传成功后,打开串口监视器,波特率选115200,就能看到连续的Hello输出。注意不要把Serial.begin()的波特率和下载波特率混为一谈,下载波特率在工具菜单里可以调,默认921600或460800都行,串口监视器只看你现在程序里设定的值。这个经典问题,我见过很多人卡了一晚上,其实只是监视器波特率选错了。

如果连串口输出都没有,先确认板子是否进入运行模式。焊接不牢、杜邦线松动、驱动版本太老都会导致这种情况。我的排查顺序是:换USB线(很多劣质线只有电源线没有数据线)→ 换USB口(少用USB Hub)→ 重装驱动 → 手动触发Boot下载。

3.2 开启WiFi连接网络并HTTP请求

S3的WiFi能力和其他ESP32一致,支持2.4GHz频段。这里有个细节,S3刚上电时射频校准需要几百毫秒,如果代码里上电后立即调用WiFi.begin(),偶尔会失败。更稳妥的写法是给WiFi启动一点时间:

#include <WiFi.h> #include <WiFiMulti.h> WiFiMulti wifiMulti; void setup() { Serial.begin(115200); wifiMulti.addAP("your_ssid", "your_password"); while (wifiMulti.run() != WL_CONNECTED) { delay(200); Serial.print("."); } Serial.println("Connected"); } void loop() { // 业务逻辑 }

WiFiMulti的好处是能存储多个热点信息,杜绝了“只有一个AP密码错了就必须改代码重新烧录”的尴尬。联网后下一步通常是HTTP请求,这时配合HTTPClient库就能轻松GET/POST。如果是和阿里云、腾讯云对接,这类MQTT设备的签名和双发逻辑也不复杂,无非是加一个PubSubClient库。要提醒的是,S3默认开启了WiFi省电模式,会造成ping延迟忽高忽低,如果项目对实时性要求高,调用WiFi.setSleep(false)关掉省电,代价是功耗上升一点,但很值得。

3.3 舵机控制:从40Hz到50Hz的细节

做智能小车或机械臂时,舵机控制是中央环节。Arduino坐标系下最简单的方式是使用Servo库。但S3和传统AVR的Arduino不一样,它的定时器和PWM硬件资源映射逻辑需要适配。自带的Servo库在ESP32上通常按ESP32Servo库来实现。用法:

#include <ESP32Servo.h> Servo myServo; #define SERVO_PIN 4 void setup() { myServo.attach(SERVO_PIN); } void loop() { myServo.write(0); delay(1000); myServo.write(90); delay(1000); myServo.write(180); delay(1000); }

注意attach()之后要给舵机一个稳定电源,如果直接拿S3的3.3V引脚驱动大扭矩舵机,板载LDO大概率会过热,轻则重启,重则烧LDO。我在这里栽过一次,后来老老实实外接5V BEC,共地线接一下就好了。另外舵机PWM频率不是越高越好,普通模拟舵机标准是50Hz(20ms周期),ESP32Servo库默认不需要你显式设置频率,但如果你用ledcSetup自己写PWM,就必须把频率设在50Hz附近,范围45~55Hz都能正常工作。

3.4 I2S麦克风INMP441采集,解决“峰值1”问题

这块单独拿出来说,因为它几乎是S3语音项目里翻车率最高的环节。INMP441是一款I2S接口的MEMS麦克风,六脚封装,板上自带数字输出。接线方面:SCK接GPIO 4,WS接GPIO 5,SD接GPIO 6,L/R接地选择左声道。S3需要用I2S外设采集数据,代码里最常见的做法是用driver/i2s.h的旧API:

#include <driver/i2s.h> #define I2S_WS 5 #define I2S_SCK 4 #define I2S_SD 6 #define I2S_PORT I2S_NUM_0 void setup() { i2s_config_t i2s_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate = 16000, .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT, .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format = I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 8, .dma_buf_len = 1024, .use_apll = false, .tx_desc_auto_clear = false }; i2s_pin_config_t pin_config = { .bck_io_num = I2S_SCK, .ws_io_num = I2S_WS, .data_out_num = -1, .data_in_num = I2S_SD }; i2s_driver_install(I2S_PORT, &i2s_config, 0, NULL); i2s_set_pin(I2S_PORT, &pin_config); i2s_set_clk(I2S_PORT, 16000, I2S_BITS_PER_SAMPLE_32BIT, I2S_CHANNEL_MONO); } void loop() { int32_t samples[1024]; size_t bytesRead; esp_err_t result = i2s_read(I2S_PORT, samples, sizeof(samples), &bytesRead, portMAX_DELAY); if (result == ESP_OK) { for (int i = 0; i < bytesRead / 4; i++) { // 右移12位把24bit有效数据转成有符号16bit int16_t sample = samples[i] >> 12; Serial.println(sample); } } }

很多新手遇到的现象是:I2S读回来的一直是满幅值1或-1,不随环境声音变化。这基本可以断定是WS信号极性错误或者位深配置不匹配。INMP441是24位有效数据存储在32位帧中,如果你在i2s_read后直接打印原始int32_t,会看到大量0xFFFFFF或0x800000附近的数,看起来就是“峰值1”或“峰值-1”。解决办法是在转换数据时做一次算术右移,把高24位有效位取出来,再用As64转换成16位。我实际测下来,采样率16kHz、32bit、单声道,读取后右移12位或14位都能得到合理的音频样本。另一个容易被忽略的点是L/R引脚的接法,INMP441的L/R如果悬空,默认可能选错声道,导致数据和WS错位,表现出类似不正常的数据。我总是把L/R直接接GND,同时I2S配置设成ONLY_LEFT,这样最为省心。

3.5 屏幕驱动与PSRAM应用示例

如果给S3配一块IPS屏幕,比如160x80的ST7735或者240x135的ST7789,用TFT_eSPI库非常常规。关键在User_Setup.h里选择正确的驱动、引脚和帧缓冲模式。S3有PSRAM后,可以开双缓冲提升刷新率。一个最简单的用法:

#include <TFT_eSPI.h> TFT_eSPI tft; void setup() { tft.init(); tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setCursor(0, 0); tft.print("S3 Ready"); } void loop() {}

TFT_eSPI库能自动检测PSRAM或剪裁较大的帧缓冲,但SPIRAM选项要在编译宏里打开。PlatformIO里可以直接加-DBOARD_HAS_PSRAM。Arduino IDE里则是选好PSRAM类型,一般就不会有太大问题。屏幕驱动最常见的坑是引脚不匹配,每个模组的引脚定义都不同,不要拿着别人写好的配置文件直接烧,先对照原理图核对一下SCLK、MOSI、CS、DC、RST引脚,否则就是花屏或白屏。

4. 常见问题与调试技巧实录

4.1 无法识别串口/驱动不装,到底谁的锅

这个问题在Windows上出现频率最高。S3 Dev Module的板载USB芯片,CP2102一般要装驱动,CH340则要看Windows版本。Win10/11通常能自动安装,Win7下我也折腾过。判断方法很简单:设备管理器里看有没有“COM和LPT”,没有就说明驱动缺失。装上驱动后如果还是识别不到,换线换口再试。特别说一句,type-c线看起来都长一个样,有些只能充电,拿iPhone的充电线插开发板,电脑永远不会有反应。

如果用的是S3原生USB接口而不是板载串口芯片,还要检查系统是否枚举了新的“ESP32-S3”设备。原生USB模式需要在代码里使用USB CDC功能,Arduino环境下默认的Serial接口不一定映射到原生USB口,这时需要在setup()里调用Serial.begin()外,还要设置USBCDC为true。不同板厂的接线设计差异很大,采购前先看清楚原理图。

4.2 编译报内存不足、找不到头文件的通用解法

S3开发中编译报错是最耗耐心的事。我总结三个高发类型。

第一种是Flash Size和Partition选错。默认的default.csv分区表只有4MB,如果你选了16MB Flash却忘记改分区表,就会出现代码烧录后反复启动失败或者数据根本写不进去。处理方式是Tools菜单里选“Flash Size”为16MB,同时Partition Scheme选Huge APP (3MB No OTA/1MB SPIFFS)或者default_16MB.csv。在PlatformIO里则必须匹配board_build.partitions。

第二种是库冲突。最典型就是同时装了老版本Servo和ESP32Servo,或者TFT_eSPI的两个分支同时在libraries目录里。卸载多余的、只保留一个版本即可。我在PlatformIO里习惯用lib_deps锁定版本号,避免某一天一个“自动拉取的最新版”把已有项目搞崩。

第三种是编译时提示multiple definition。如果用了Arduino IDE,本质是同一个函数在多个源文件里定义了;如果是PlatformIO,则是未加入extern "C"声明或者头文件重复包含。虽然解决起来要看具体代码,但通用技巧是采用头文件里加#pragma once,并保证所有全局函数只在一个.cpp里实现。

4.3 在线安装Arduino ESP32包总是失败的处理经验

我自己经历过从2.0.9到2.0.11版本更新时,Arduino IDE一直卡在“Downloading archives”的环节。原因多是网络对GitHub的连通性不稳定。最稳妥办法是下载离线包。乐鑫官方和各家代理商基本都会放出.zip离线包,手动解压到Arduino的hardware/espressif/esp32目录。具体操作分三步:

  1. 先通过板管理器安装一次,如果失败也无需清理,只要把目录里的旧文件删干净。
  2. 下载离线包并解压到%LOCALAPPDATA%/Arduino15/packages/esp32/hardware/esp32/2.0.x。
  3. 重新打开Arduino IDE,在开发板列表里应该能看到对应版本。

选版本时,我的建议是别盲目追新。如果你想用最新的S3原生USB、摄像头库或者某些AI加速指令,那2.0.11及以上的版本会更好;但如果你的项目用的库比较老,比如某些TFT屏幕驱动只适配了2.0.7,那强行升级内核反而会出现一些奇怪的编译期错误。我手上两个环境共存,一个IDE用2.0.17版本做AI类项目,另一个PlatformIO里的框架固定在4.4.6,互不干扰。

4.4 PlatformIO下的ESP32S3多板兼容配置

PlatformIO找不到“ESP32S3 Dev Module”这个具体名称时,解决办法有两个,一是直接用esp32-s3-devkitc-1来代换,二是自己做板型定义。如果你换了板子不想改代码,我建议在platformio.ini里把公共设置抽出来,然后利用extends方式写多个环境。比如:

[platformio] default_envs = s3_dev_kits [common] platform = espressif32 framework = arduino monitor_speed = 115200 board_build.flash_size = 16MB board_upload.flash_size = 16MB board_build.partitions = default_16MB.csv board_build.psram_type = opi [env:s3_dev_kits] extends = common board = esp32-s3-devkitc-1 build_flags = -DBOARD_HAS_PSRAM

这样做的好处是,未来如果改用手里的其他S3板卡,比如某个狭长板型引脚不同,只需新增一个环境覆盖引脚分配即可,主逻辑代码完全不用动。PlatformIO编译通过后,烧录时需要留意upload_port,如果插了多个设备或系统存在多个COM口时,最好手动指定端口,否则可能烧错设备。

4.5 S3的GPIO分配“雷区”

很多人画PCB时管脚没注意,结果核心功能怎么调都不通。S3有部分GPIO是绑定特定功能的,比如GPIO 26到GPIO 32常用于LCD的数据总线,GPIO 35/36/37是ADC1的通道,如果拿来驱动LED闪烁没问题,但想读精确模拟量就麻烦了。此外,GPIO 19/20默认是USB D-/D+引脚,如果板子设计出来让你用USB口,这两个脚就别做别的用了。GPIO 46、47之类有些板子是给Flash/PSRAM使用的,扩展板上有时候是悬空的。

项目里如果用到多个外设,最好提前画一张“引脚占用表”,把我常用的分配列一下:

外设类型推荐引脚备注
I2S麦克风4/5/6可用Index 0
TFT屏幕8/9/10/11/12/13SPI和CS/D/C分好
舵机14/15/16PWM可用
RGB LED48板载默认
UART0串口43/44下载与调试
摄像头15/16/17/18/38/39/40/41/42/45带PSRAM更顺

这样规划,后期加外设基本不会因为引脚冲突而返工。有一点特别提醒:给屏幕和外设供电时,尽量从5V引脚取电,再经过模块自身的稳压,不要全部都压在S3开发板的3.3V引脚上,否则稳压器温度很高,重启周期会越来越短。

5. 一些值得尝试的衍生玩法

5.1 用S3做智能小车/遥控车大脑

前面说的舵机控制、WiFi串口通信,组合起来就是一套标准的智能小车控制系统。S3相对普通Arduino的优势在于双核,可以用一个核处理传感器融合,另一个核处理WiFi和运动控制。这里我给一个思路:主循环里用TaskHandle_t创建两个任务,一个跑PID平衡,一个跑网络指令接收。注意在FreeRTOS任务里不要频繁Serial.print,因为串口访问会阻塞调度,把日志缓冲到队列里,再由定时器统一输出。

5.2 离线语音助手雏形

S3最大的特点是支持本地语音唤醒,配合INMP441采样、运行ESP-DL或者ESP-SR的唤醒词模型,就能做出不上云的“小智”类离线语音词条。这个玩法对内存需求比较高,N16R8的8MB PSRAM在这里就是关键配置。提醒一句,模型文件记得烧写到LittleFS或SD卡里,不要全部塞进常量结构体,否则静态内存很容易爆。

5.3 数据采集与边缘处理

比如连接BME280温湿度传感器,间隔采样并保存到SD卡,再用S3的向量指令在本地做统计计算,只把结果上传到服务器。这一类玩法非常稳,也很适合用PlatformIO做单元测试。如果做低功耗场景,S3的深度睡眠模式能把电流降到几十微安,但要注意保留GPIO唤醒引脚的时候,必须为RTC域的电源做适当隔离。

结尾:写在最后的几条经验

我在这块板子上花的时间加起来超过了一百个小时,踩坑最惨的一次是没看原理图就把舵机电源接到了3.3V引脚,当场烤糊了一个稳压芯片,后来换了块板子才继续。老实说,ESP32S3 Dev Module并不是市面上性能最强的开发板,但它在Arduino生态下的平衡性确实好:资料多、库全、编译链路成熟。你要是刚入门,建议从官方的DevKitC-1或同规格N16R8起步,先把环境跑通,再逐步尝试摄像头、麦克风、屏幕这些高阶玩法。如果让我给项目初始化一个建议,那就是“先画引脚表,再写功能代码”,别等硬件都焊完了才想明白同一个引脚要复用两遍。

最后再分享一个不起眼的小技巧:项目文件命名别用中文路径,PlatformIO对中文路径的兼容性比Arduino IDE更差,有时候编译报错的堆栈信息全是乱码,排查半天其实只是路径问题。老老实实用英文目录,能省掉很多不必要的烦恼。后续我再写一篇关于S3跑摄像头人脸识别和离线语音唤醒的具体参数调优,到时再把实测的数据放出来。

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

PLC工程师入行指南:从应届生到独当一面的核心技能与实战经验

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

作者头像 李华
网站建设 2026/10/26 9:34:17

PLC编程分水岭:位逻辑的时空观与状态流转实战

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

作者头像 李华
网站建设 2026/10/26 9:33:47

IEC 60730-2-11:2025换版指南:能源调节器合规升级全解析

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

作者头像 李华
网站建设 2026/10/26 9:32:54

基于Unity ML-Agents的自行车智能体自主导航避障训练实战

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

作者头像 李华
网站建设 2026/10/26 9:32:06

2025易支付(epay)运营版源码:轮询、代付与对账机制解析

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

作者头像 李华