文章目录
前言

最近很喜欢用 ESP32-S3-Zero 这个开发板,尺寸跟 ESP32-S3 模块差不多,但是直接集成了 USB Type-C 接口以及相关外围电路,在 DIY 的时候就很方便了。
但是在做 USB HID 设备的时候就发现一个问题:一旦启用了 USB 设备功能,原来通过 USB/JTAG 直接一键烧录的方式就失效了。
这在调试固件的时候就很麻烦了,每次想烧录固件都得手动按住板子上的 BOOT 按钮,再按 RESET 按钮,才能进下载模式。改一行代码试一下效果,就得按一次 BOOT,开发体验可以说是相当差了。
所以就得想办法:只有一个 USB 口的情况下,怎么让 USB Device 功能和一键烧录共存?
问题分析

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 按钮上电这一条路了——每次烧录都来一遍,实在太麻烦了。
方案思路

在动手之前,我先用 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 来匹配串口设备。
上传脚本的工作流程
脚本的逻辑其实就五步,对应上一节方案思路里讲的那个流程:
- 扫描串口,找应用 CDC 端口 —— 遍历系统当前所有串口,通过 VID/PID 匹配找到固件暴露的那个 CDC 虚拟串口。
- 检查是否已经有 bootloader 端口 —— 如果系统里已经有一个 VID 为
0x303A(Espressif)的端口,说明芯片已经在 bootloader 模式了,直接跳过后面的步骤。这种情况通常是上次烧录失败后芯片停在了 bootloader 里。 - 1200bps touch 触发重启 —— 以 1200bps 波特率打开 CDC 端口,设置 DTR/RTS 为低,然后关闭连接。固件检测到这个信号后会自动调用
esp_restart()重启进入 bootloader。这一步有重试机制,因为端口可能暂时被占用或者响应慢。 - 轮询等待 bootloader 端口出现 —— 芯片重启后,USB 设备会先消失再重新枚举,出来的就是 bootloader 的端口了。脚本会在超时时间内反复扫描,直到发现新端口。
- 替换 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
......
总结

整个方案在 macOS + PlatformIO + Arduino 下测试成功了,其他平台可能需要根据实际碰到的问题进行修改。
要改的东西其实不多,固件端加几行 CDC 初始化代码,PlatformIO 端加一个 pre-upload 脚本和两行配置。换来的是 pio run -t upload 一键烧录,不用再反复按 BOOT 按钮了。
理论上这个脚本也是可以在项目间共用的,不过放在以后再慢慢折腾吧。

0 条评论。