ESP32-S3 小技巧:单口实现同时支持 USB 设备 + 一键烧录

前言

esp32-s3-usdhid-with-flash-2

最近很喜欢用 ESP32-S3-Zero 这个开发板,尺寸跟 ESP32-S3 模块差不多,但是直接集成了 USB Type-C 接口以及相关外围电路,在 DIY 的时候就很方便了。

但是在做 USB HID 设备的时候就发现一个问题:一旦启用了 USB 设备功能,原来通过 USB/JTAG 直接一键烧录的方式就失效了。

这在调试固件的时候就很麻烦了,每次想烧录固件都得手动按住板子上的 BOOT 按钮,再按 RESET 按钮,才能进下载模式。改一行代码试一下效果,就得按一次 BOOT,开发体验可以说是相当差了。

所以就得想办法:只有一个 USB 口的情况下,怎么让 USB Device 功能和一键烧录共存?


问题分析

esp32-s3-usdhid-with-flash-3

ESP32-S3 芯片内置了两个跟 USB 相关的模块:一个是 USB OTG 控制器(用于实现 USB Device/Host 功能),另一个是内置的 USB/JTAG 串行调试模块(用于烧录和调试)。但关键在于,它们共享同一组 USB D+/D- 物理引脚(GPIO19/GPIO20)。这两个功能是互斥的,同一时刻只能有一个占用 USB 总线。

芯片默认把这组引脚映射给 USB/JTAG 调试模块,所以开箱状态下通过 USB 就能直接烧录和看日志。但一旦固件里启用了 USB OTG 控制器(比如做 USB HID 设备),引脚就被 OTG 接管了,内置的调试模块自然就没法工作了。PlatformIO 找不到 JTAG 端口,烧录直接失败。

如果板子上有额外的 UART 口,还可以走 UART 烧录。但像 ESP32-S3-Zero 这种只有一个 USB 口的板子,就只剩手动按 BOOT 按钮上电这一条路了——每次烧录都来一遍,实在太麻烦了。


方案思路

esp32-s3-usdhid-with-flash-4

在动手之前,我先用 AI 研究了一下现有的烧录触发机制。原生 USB/JTAG 模式下,esptool 走的是 JTAG 专有的 reset 协议,跟我们的场景不同。但在研究过程中发现了一个有意思的东西,esptool.py 日志中会有一个 1200bps 通信过程,搜索了一下发现,原来Arduino 生态里有一个 1200bps touch 的约定——以 1200bps 波特率打开串口再关闭,就能触发设备重启进入 bootloader。这个机制最早来自 Arduino Leonardo,后来很多支持 USB 的开发板都沿用了这个惯例。

关键是,Arduino ESP32 框架的 USBCDC 类正好支持这个机制。那思路就很清楚了:在开启 USB HID 设备的同时,再自己开一个 CDC 串口,复用 1200bps touch 来触发重启进 bootloader。

固件端实现起来相当简单,只需要通过 USBCDC 类启用 USB CDC(虚拟串口)功能,然后调用 enableReboot(true) 来启用 1200bps touch 能力,这样当外部以 1200bps 波特率连接这个串口时,固件会自动重启进入 bootloader 模式。

不过还有一个问题:使用 USB HID 时,设备在电脑上注册的串口名称跟进入 bootloader 后的不一样。应用模式下是自定义 VID/PID 对应的 CDC 串口,重启进 bootloader 后变成了 Espressif 的下载端口,名字完全不同。PlatformIO 默认不知道该连哪个,所以还需要一个 pre-upload 脚本来自动处理这个切换:找到应用 CDC 端口 → 1200bps touch → 等待 bootloader 端口出现 → 把正确的端口告诉 esptool。

最终效果就是 pio run -t upload 一键完成,不用手动按任何按钮,跟没用 USB Device 之前一样丝滑。


固件代码实现

固件端要做的事情不多,就是在原有 USB HID 的基础上,加一个 CDC 接口,组成 USB 复合设备:

#include "USB.h"
#include "USBCDC.h"

USBCDC cdc;

void setup() {
    cdc.enableReboot(true);
    cdc.begin(115200);
    // ... USB HID 初始化
    USB.begin();
}

这里最关键的就是 enableReboot(true) 这一行。它的作用是:当检测到外部以 1200bps 波特率连接这个 CDC 串口时,自动调用 esp_restart() 重启进入 bootloader 模式。烧录工具正是利用这个机制来触发下载模式的。

另外,这个 CDC 串口也可以用来当作普通串口使用,我就用 AI 实现了一个 REPL 交互能力,可以调调参数什么的。

还有一点值得一提:CDC 和 HID 是作为 USB 复合设备共存的,系统会同时识别出一个串口设备和一个 HID 设备,互不影响。


PlatformIO 端配置 + 上传脚本

固件端准备好了,接下来就是让 PlatformIO 在每次 upload 的时候自动完成"找端口 → 触发重启 → 等 bootloader → 烧录"这一整套流程。

platformio.ini 配置

先看 platformio.ini 里需要加什么:

[env:esp32s3]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
extra_scripts = pre:scripts/native_usb_upload.py
build_flags =
    -DAPP_USB_VID=0x1234
    -DAPP_USB_PID=0x5678

几个要点:

  • extra_scripts = pre:scripts/native_usb_upload.py —— pre: 前缀表示这个脚本在 upload 动作之前执行。脚本的职责就是找到正确的端口并把芯片切到 bootloader 模式,然后再交给 esptool 去烧录。
  • build_flags 里的 -DAPP_USB_VID-DAPP_USB_PID 要和固件里实际用的 VID/PID 一致。上面的 0x1234 / 0x5678 只是占位,换成实际固件中使用的值。上传脚本会从这里读取 VID/PID 来匹配串口设备。

上传脚本的工作流程

脚本的逻辑其实就五步,对应上一节方案思路里讲的那个流程:

  1. 扫描串口,找应用 CDC 端口 —— 遍历系统当前所有串口,通过 VID/PID 匹配找到固件暴露的那个 CDC 虚拟串口。
  2. 检查是否已经有 bootloader 端口 —— 如果系统里已经有一个 VID 为 0x303A(Espressif)的端口,说明芯片已经在 bootloader 模式了,直接跳过后面的步骤。这种情况通常是上次烧录失败后芯片停在了 bootloader 里。
  3. 1200bps touch 触发重启 —— 以 1200bps 波特率打开 CDC 端口,设置 DTR/RTS 为低,然后关闭连接。固件检测到这个信号后会自动调用 esp_restart() 重启进入 bootloader。这一步有重试机制,因为端口可能暂时被占用或者响应慢。
  4. 轮询等待 bootloader 端口出现 —— 芯片重启后,USB 设备会先消失再重新枚举,出来的就是 bootloader 的端口了。脚本会在超时时间内反复扫描,直到发现新端口。
  5. 替换 esptool 参数,开始烧录 —— 把 esptool 的 --before 改成 no-reset(因为我们已经手动触发了重启),--after 改成 watchdog-reset,上传端口指向 bootloader 端口。然后 PlatformIO 正常调用 esptool 完成烧录。

整个流程里最容易出问题的就是第 3 步和第 4 步。USB 设备的消失和重新枚举有时候会比较慢,不同操作系统的行为也不太一样。


新的烧录过程日志

加上前面的配置之后,再次使用 pio run -t upload 命令烧录固件时,可以从日志中看到查找串口、1200bps touch 的过程。

......
CURRENT: upload_protocol = esptool
_before_upload(["upload"], [".pio/build/esp32s3/firmware.bin"])
Native USB upload: application CDC port is /dev/cu.usbmodemDCB4D916D93C2
Native USB upload: touching /dev/cu.usbmodemDCB4D916D93C2 at 1200 bps
Native USB upload: bootloader port is /dev/cu.usbmodem11301
Looking for upload port...
Using manually specified: /dev/cu.usbmodem11301
Uploading .pio/build/esp32s3/firmware.bin
......

总结

esp32-s3-usdhid-with-flash-5

整个方案在 macOS + PlatformIO + Arduino 下测试成功了,其他平台可能需要根据实际碰到的问题进行修改。

要改的东西其实不多,固件端加几行 CDC 初始化代码,PlatformIO 端加一个 pre-upload 脚本和两行配置。换来的是 pio run -t upload 一键烧录,不用再反复按 BOOT 按钮了。

理论上这个脚本也是可以在项目间共用的,不过放在以后再慢慢折腾吧。

发表评论?

0 条评论。

发表评论


注意 - 你可以用以下 HTML tags and attributes:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>