每次给人演示 ESP32-S31 项目,最难受的环节就是现场烧录。固件编译出来了,可烧录要开终端、找串口、对地址:bootloader 放 0x0,分区表放 0x8000,应用固件放 0x10000,三个文件一个都不能放错位置,放错一步整块 flash 布局就全乱。后来我花了一个下午,用 esptool 把完整镜像合并成一个 bin,再配一个 Windows 批处理脚本,做成了真正意义上的一键烧录包。这件事做完之后,不管是自己迭代还是让同事接手,都只是双击一下 bat 的差别。这篇文章我会把完整镜像生成、esptool 参数、以及最容易被忽略的数据边界问题全部拆开讲清楚,适合所有在给 ESP32 类项目做固件交付和量产烧录的开发者参考。
1. 为什么我非要把烧录流程做成一个包
1.1 手动烧录的痛点,做过的人都知道
ESP32-S31 这个项目我们内部迭代很快,几乎每天都要烧好几次板子。手动烧录看起来只是几条命令的事,但真正操作起来非常折磨人:首先你得记住三份 bin 文件各自的烧录地址,bootloader 在 0x0,分区表在 0x8000,应用在 0x10000;其次每次都要确认当前设备在设备管理器里占用的是哪个 COM 口,插拔一次可能就变了;更麻烦的是,如果哪次迷迷糊糊选错了文件,或者把 app.bin 烧到了 bootloader 的位置,板子会直接变砖,只能重新进下载模式整片擦除再来。
这些过程中最隐蔽的坑其实是数据边界。什么叫数据边界?简单说就是每一段数据在 flash 里的合法范围。bootloader 占了 0x0 到 0x8000 前的区域,分区表占了 0x8000 到 0x9000 前的区域,应用固件从 0x10000 开始往后排,但具体能排多长,取决于分区表里给 factory 分区分配了多大空间。只要 app.bin 的实际大小超过这个分区的边界,写进去的数据就会溢出到后面的 nvs 或 OTA 分区,轻则导致启动异常,重则把整个分区表数据覆盖掉。
我踩过最惨的一次是应用固件编译出来 3.2MB,而分区表里只给了 2MB,esptool 烧录时一点警告都没给,烧录完成显示成功,但设备重启后直接打印一串 panic,连 WiFi 都起不来。所以后面我下定决心,必须把烧录这件事变成一个不依赖人记忆的自动化流程。
1.2 技术选型:为什么是 esptool 加批处理
做一键烧录包之前我也纠结过方案,市面上有图形化的烧录工具,但要么只支持自家开发板,要么需要额外安装运行时,一台新电脑要装半天。最终我选择 esptool 加 Windows 批处理,理由很直接。
esptool 是芯片厂商官方开源的烧录工具,专门和 ESP32 全系列芯片打交道,烧录协议、flash 加密、安全启动这些底层逻辑都被它处理好了。它的最大优点是不只是简单地把文件写进去,还能在写完后自动做数据校验,报错信息也比较直白。更关键的是它支持 merge_bin 命令,可以把多个 bin 文件按指定地址合并成一个完整镜像,这正好满足我做一体化交付的需求。
Windows 批处理脚本则是为了没有 Python 经验的同事也能用。脚本里面自动检测 Python、自动安装 esptool、列出可用串口、运行烧录命令,用户全程只需要输入一个 COM 口号。这样整个工具包分发出去之后,只要电脑联网,基本能做到零配置上手。
2. 先把“数据边界”这件事彻底整明白
2.1 Flash 布局:bootloader、分区表、应用镜像怎么排
想做好一键烧录包,第一步不是写脚本,而是把 ESP32-S31 这块芯片的 flash 地址布局刻在脑子里。芯片外接 flash 是一个线性地址空间,上电后 CPU 会从 0x0 地址开始执行,所以 bootloader 必须放在 0x0。这是硬件层面的硬约束,不能改。
紧接着是分区表,芯片内部的 ROM bootloader 启动后会去固定的 0x8000 地址读取分区表,所以分区表文件也必须在 0x8000。分区表本身很小,通常 4KB 左右,也就是说它实际占用的范围是 0x8000 到 0x9000。从 0x9000 开始,才是用户可以自由规划的数据区。
默认情况下,大家习惯把应用固件放在 0x10000,也就是 64KB 的位置。这前面从 0x0 到 0x10000 一共 64KB 的空间,其实已经包含了 bootloader 主体、bootloader 的附加配置、分区表以及一些安全相关的保留区。0x10000 不是随便写的,它是 flash 缓存映射的对齐边界,应用镜像放在这个地址上,芯片的 flash cache 才可以高效工作。
你可能要问:既然 0x9000 之后就能放数据了,为什么应用不放在 0x9000 而是 0x10000?答案是应用分区要求 64KB 对齐。这是 ESP32-S3 硬件设计决定的,app 镜像需要被映射到指令缓存中,映射的粒度是 64KB,如果放在 0x12000、0x1A000 这种非 64KB 对齐的位置,轻则性能下降,重则直接启动失败。所以默认 0x10000 这个位置是所有开发者的公约数。
2.2 对齐规则与边界计算,一个数字都不能错
flash 的最小擦除单位是 4KB 扇区,这是所有烧录操作的基本粒度。烧录工具在写数据之前会先把对应的扇区擦掉,如果你的某一段数据偏移没有按 4KB 对齐,写操作就可能跨越扇区边界,把相邻扇区里的旧数据一并擦除。所以,分区表里的每一项 offset,都必须是 0x1000 的整数倍。
下面是我们项目 16MB flash 版用的一个分区表示例:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x300000,逐行看这个表就能理解边界关系。nvs 分区从 0x9000 开始,大小 0x6000,也就是 24KB,所以它的结束地址是 0xF000。紧接着 phy_init 从 0xF000 开始,大小 0x1000,4KB,结束地址是 0x10000。这个设计非常巧妙:0xF000 和 0x10000 都是 4KB 对齐,而且 phy_init 正好填满 0x10000 之前的最后一块空隙。
factory 分区是应用固件的家,从 0x10000 开始,大小 0x300000,也就是 3MB。结束地址是 0x310000。对于 16MB 总容量的 flash,也就是 0x1000000 字节来说,还留下了大量空间给 OTA、存储之类的扩展分区。重点是这句话:分区表里每一项的 offset 加上 size,绝对不能超过 flash 总容量,也绝对不能和相邻分区重叠。
2.3 分区表越界引起的那些“灵异现象”
边界问题最坑的地方在于,很多错误不会在烧录时报出来。我早期遇到过一次 app.bin 有 3.1MB,而 factory 分区只给了 3MB,结果 esptool 还是满不在乎地写进去了。因为从 esptool 的角度看,它只是把一个文件写到指定地址,文件长度超出分区规划它并不知情,烧录完成后校验也算通过。但设备重启后,应用镜像尾部已经写进了 nvs 或 phy_init 所在区域,轻则 WiFi 无法校准,重则系统直接崩溃。
还有一种更隐蔽的情况:如果你在一键烧录包里用了完整镜像 merged.bin,而 merged.bin 的生成参数里 flash_size 写的是 4MB,但实际板子上是 16MB flash,那合并出来的镜像在 4MB 之后的区域就是空白,烧进去后分区表里引用到的地址如果超过 4MB,设备会发现读出来的全是 0xFF,同样会启动失败。
所以做一键烧录包前,我强烈建议先做一份分区表文档,把每个分区的名称、偏移、大小、用途全部列出来,然后写一个边界校验脚本,让电脑替我们检查这些数字,人脑负责记忆是不可靠的。
3. 组装完整镜像:从三个散文件到一个 merged.bin
3.1 先把手里的固件产物收集齐全
做完整镜像前,先把构建产物收集齐。我们的 ESP32-S31 项目用的是开源构建工具链,编译完之后成果物一般在 build 目录下,只需要三个文件:
- bootloader.bin:引导程序,烧录地址 0x0
- partition-table.bin:分区表,烧录地址 0x8000
- app.bin:应用固件,烧录地址 0x10000
这三个文件缺一不可。如果你用的是其他构建框架,文件名可能不一样,比如有些工具生成的是 firmware.bin,本质就是 app.bin,烧录地址依然是分区表里 factory 分区指定的偏移。关键是一定要搞清楚每个文件在 flash 里的目标地址,而不是凭文件名猜。
我习惯把这三个文件拷贝到一个干净的目录里,比如 E:\esp32-s31-flash\ 下,然后在这个目录里生成 merged.bin 和烧录脚本。这样整个一键包是自包含的,发给谁都能直接用,不会出现脚本里写着 bootloader.bin,结果文件在另一个盘符的问题。
3.2 用 esptool merge_bin 合成烧录用镜像
esptool 的 merge_bin 命令可以把多个 bin 文件按偏移地址拼成一个整体镜像。对 ESP32-S3 类的芯片,命令大概是这样的:
python -m esptool --chip esp32s3 merge_bin \ -o merged.bin \ --flash_mode dio \ --flash_freq 80m \ --flash_size 16MB \ 0x0 bootloader.bin \ 0x8000 partition-table.bin \ 0x10000 app.bin命令里的几个参数值得多说两句。--flash_mode dio是指 flash 工作在双 IO 模式,这是绝大多数模块默认的模式;--flash_freq 80m是 flash 时钟频率,ESP32-S3 常见支持到 80MHz;--flash_size 16MB必须和板子实际 flash 容量一致。如果不确定,可以先运行python -m esptool --chip esp32s3 flash_id来读取芯片信息,esptool 会直接打印出 flash 容量。
执行完 merge_bin 之后,会得到一个 merged.bin。默认情况下,这个文件只包含从 0x0 到 app.bin 末尾的有效数据,后面的区域不会自动生成。如果你想让 merged.bin 精确覆盖整块 flash,可以加一个--fill-flash参数,它会把未使用区域填充成 0xFF,让最终镜像大小等于 flash 总容量。
我个人建议在量产交付时用--fill-flash,因为这样烧出来的 flash 内容是完全确定的,不会出现哪块区域残留旧数据的情况。而在开发阶段则不一定需要,烧录工具在写入时会把目标区域自动擦除,空余区域并不影响运行。
3.3 写一键烧录脚本:从批处理说起
镜像合并好之后,就差最后一层包装。我写的 flash.bat 核心逻辑很简单:检查环境、查串口、跑烧录命令。完整脚本放下面,可以直接复制改一改再用。
@echo off setlocal enabledelayedexpansion chcp 65001 >nul cd /d "%~dp0" echo ========================================== echo ESP32-S31 一键烧录工具 echo ========================================== REM 检查 Python python --version >nul 2>nul if errorlevel 1 ( echo [错误] 未检测到 Python, 请先安装 Python 3.8+ 并勾选 Add to PATH pause exit /b 1 ) REM 检查 esptool python -m esptool version >nul 2>nul if errorlevel 1 ( echo [信息] 未检测到 esptool, 正在安装... python -m pip install --upgrade esptool if errorlevel 1 ( echo [错误] esptool 安装失败, 请检查网络 pause exit /b 1 ) ) echo. echo [信息] 当前系统可用的串口: powershell -NoProfile -Command "$ports=[System.IO.Ports.SerialPort]::GetPortNames(); if($ports.Count -eq 0){Write-Host '(未检测到串口, 请检查 USB 连接)'}; $ports | ForEach-Object { Write-Host $_ }" set /p COM=请输入要使用的串口号, 例如 COM3: if "%COM%"=="" ( echo [错误] 串口号不能为空 pause exit /b 1 ) if not exist merged.bin ( echo [错误] 当前目录下找不到 merged.bin pause exit /b 1 ) echo [信息] 开始烧录 %COM%, 请勿拔线... python -m esptool --chip esp32s3 --port "%COM%" --baud 460800 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x0 merged.bin if errorlevel 1 ( echo [错误] 烧录失败, 请检查接线和串口占用情况 pause exit /b 1 ) else ( echo [成功] 烧录完成, 设备即将自动重启 ) pause这段脚本有几个细节是实际踩坑后加上的。cd /d "%~dp0"是让脚本无论从哪里被双击,都先切换到自己的目录,避免因为工作目录不对而找不到 merged.bin。chcp 65001是为了让中文提示在部分 Windows 系统上正常显示,否则可能出现乱码。用python -m esptool而不是直接调 esptool 命令,是因为 pip 安装后脚本路径在不同系统上差异很大,而用模块方式调用永远稳定。
如果你更喜欢 PowerShell,也可以把烧录逻辑写成 ps1 脚本,核心命令差不多,只是串口列表可以用[System.IO.Ports.SerialPort]::GetPortNames()直接拿到数组,处理起来更优雅。但批处理最大的优势是双击即用,对非开发背景的同事更加友好。
4. 完整烧录包的实操过程记录
4.1 目录结构与最终交付物
我最终交付的 flash 工具目录长这样:
esp32-s31-flash/ ├─ bootloader.bin ├─ partition-table.bin ├─ app.bin ├─ merged.bin ├─ verify_boundary.py ├─ flash.bat └─ README.txtbootloader、partition-table、app 是原始输入,merged.bin 是合成产物。verify_boundary.py 是边界校验脚本,flash.bat 是执行入口,README 里写一句话说明使用步骤和注意事项。整个目录打包成 zip 后大概十几 MB,发到任何一台 Windows 电脑上都能用。
交付时我还会附上一份说明:要求用户电脑有 Python 3.8 以上版本。如果没有,脚本会自动尝试通过 pip 安装 esptool。如果公司内网无法访问 PyPI,也可以提前把 esptool 的 wheel 包放到同目录下,在脚本里改成python -m pip install --no-index --find-links=./packages esptool,这样就能完全离线安装。
4.2 双击 bat 之后的整段流程
用户拿到工具后,唯一需要做的就是双击 flash.bat,然后输入串口号。整个过程其实是这样的:
脚本先检测 Python 和 esptool,然后打印出当前可用的串口列表。接着用户输入 COM3 之类的端口号,esptool 就开始连接芯片。连接成功后会输出类似下面这样的日志:
Serial port COM3 Connecting...... Chip is ESP32-S3 (revision v0.1) Features: WiFi, BLE, Embedded Flash Crystal is 40MHz MAC: 84:CC:A8:XX:XX:XX Uploading stub... Running stub... Changing baud rate to 460800 Configuring flash size... Flash will be erased from 0x00000000 to 0x00ffffff... Compressed 24576 bytes... Wrote 24576 bytes at 0x00000000 (100.0%) ... Hash of data verified. Hard resetting via RTS pin...看到Hash of data verified基本就稳了,说明数据写入后回读校验通过了。最后一行是芯片自动复位,设备会重启进入新固件。
有个细节值得注意:烧录日志里会出现两次 baud rate 切换。esptool 先用一个低速波特率和芯片 ROM 里的串口下载程序建立连接,然后往芯片 RAM 里上传一段 stub 程序,再切换到高速模式进行数据传输。所以我们把--baud设成 460800 是没问题的,但如果线材质量差或者用了 USB 延长线,建议降到 230400 甚至 115200,稳定优先。
4.3 加一道数据边界校验,把风险挡在烧录前
虽然 esptool 会在写入后做数据校验,但它校验的是文件本身有没有写对,而不是文件该不该放在那个位置。所以我在烧录前增加了一个 verify_boundary.py 脚本,用来检查三个文件的偏移是否对齐、大小是否越界。下面是脚本的核心部分:
import os import sys FLASH_SIZE = 0x1000000 # 16MB,按实际 flash 修改 LAYOUT = [ ("bootloader.bin", 0x0, 0x8000), ("partition-table.bin", 0x8000, 0x1000), ("app.bin", 0x10000, 0x300000), ] def main(): ok = True for fname, offset, max_size in LAYOUT: if not os.path.exists(fname): print(f"[FAIL] 缺少文件 {fname}") ok = False continue size = os.path.getsize(fname) end = offset + size print(f"{fname}: offset=0x{offset:X} size=0x{size:X} end=0x{end:X}") if end > FLASH_SIZE: print(f" -> 文件结束地址越过 flash 末尾 0x{FLASH_SIZE:X}") ok = False if offset % 0x1000 != 0: print(f" -> 偏移未按 4KB 对齐") ok = False if fname == "app.bin" and offset % 0x10000 != 0: print(f" -> app 偏移未按 64KB 对齐") ok = False if size > max_size: print(f" -> 文件大小 0x{size:X} 超过分配空间 0x{max_size:X}") ok = False sys.exit(0 if ok else 1) if __name__ == "__main__": main()这段脚本里的每个分区上限,都需要和分区表文件里的实际配置保持同步。我在实际项目中就是因为不同版本的分区表改过好几次,每次改完都手动跑一遍这个脚本,确保三份文件的大小还装得下。如果脚本返回非 0 退出码,flash.bat 里可以加一行提前终止的逻辑,这样就不会把一个注定起不来的镜像烧进设备了。
烧录完成后如果还想做二次确认,也可以用 esptool 把 flash 内容读回来做一个全量比对:
python -m esptool --chip esp32s3 --port COM3 read_flash 0x0 0x100000 flash_back.bin然后用二进制工具对比 flash_back.bin 和 merged.bin 的前 1MB。不过正常情况下Hash of data verified已经够用了,这个回读主要用于交付前的出厂抽检。
5. 常见问题与排查实录
5.1 大概率是端口和驱动问题
我整理了一份高频问题速查表,烧录出问题时先按这个表排查:
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
提示could not open port COM3: Access is denied | 串口被串口监视器或其他软件占用 | 关闭所有可能占用的工具,重新插拔 USB |
提示No serial data received | 数据线是纯充电线,或驱动未安装 | 换一根已知能传数据的线,确认设备管理器里有串口 |
提示Timed out waiting for packet header | 芯片没有进入下载模式 | 按住 BOOT 键上电,或检查 EN 和 IO0 的时序 |
提示Invalid head of packet | 选错串口,或用了副厂的劣质转接线 | 核对串口号,降低波特率到 115200 再试 |
| COM 口列表为空 | 驱动缺失或 USB 口供电不足 | 安装芯片厂商的 USB 驱动,换一个 USB 口 |
系统里同时插着好几块板子的时候,非常容易选错串口。我在脚本里打印串口列表,就是为了让用户先确认目标板子和串口号的对应关系。更稳妥的做法是在每块板子上贴标签,写明对应的串口号,这在批量生产的现场能省下大量时间。
5.2 烧录报错和地址边界相关的坑
A fatal error occurred: Stub received invalid data,这个报错本质是高速模式下芯片 stub 收到的数据不完整,通常是线材太长、供电不稳、或者波特率太高。解决方法是降波特率,我一般在量产时直接用 230400,稳定压倒一切。Invalid chip id,说明芯片型号选错了。ESP32-S31 项目实际芯片识别名是 esp32s3,如果你在命令里写成了别的型号,连接阶段就会被拒绝。可以在 3.2 节说过用flash_id命令验证芯片信息。Flash size error,esptool 在 write_flash 阶段会对比参数里的--flash_size和实际读到的 flash 容量,不一致直接报错。这个错基本就是我们修改过 flash 芯片,但脚本参数没同步。- 烧录成功但重启后反复打印 boot 日志然后重启,很多时候是因为 app.bin 实际大小超过分区分配的大小,镜像尾部覆盖了后续分区。这种情况 esptool 不会报错,只有通过分区表边界校验才能提前发现。
- 烧录 merged.bin 后设备 WiFi 连不上或 MAC 异常,很可能是完整镜像覆盖了 nvs 分区里的出厂校准数据。开发阶段想保留 nvs 时,就不要烧 merged.bin,而是单独烧 app.bin 到 0x10000。
5.3 Windows 环境特有的一些注意点
Windows 上有几个开发机上不会遇到的坑。第一个是中文路径问题。esptool 在个别 Python 版本下遇到中文路径会解析失败,提示找不到文件,但实际上文件就在那里。所以我坚持让工具目录用纯英文命名,比如 esp32-s31-flash,README 里也强调不要放到带中文或空格的路径下。
第二个是安全软件的误报。很多杀毒软件会把包含 Python 脚本和批处理的工具包当成可疑文件,尤其是用 PyInstaller 打成 exe 的一键工具,误报率更高。我不想让同事在杀毒软件弹窗里纠结,所以选择了免安装的方案,靠系统 Python 运行 esptool,基本不会触发误报。
第三个是老旧 Windows 系统的 PowerShell 策略限制。脚本里用 PowerShell 获取串口列表时,在个别精简版系统上可能会因为执行策略禁止脚本运行而失败。我给 bat 脚本加了个兜底逻辑:如果 PowerShell 取串口失败,就让用户自己打开设备管理器手动输入 COM 口号,不阻塞主流程。
6. 一点个人体会
这套一键烧录包做完之后,最大的收获不是省了多少分钟,而是把烧录这件事变成了一道不会出错的流程。以前手动烧录依赖经验,烧得多了才记得住那些地址、哪些文件不能混用;现在所有约束都被脚本和校验逻辑固定住了,一个刚接触项目的人也能安全地完成烧录。
我最推荐的做法是:每次发布之前,先跑一遍 verify_boundary.py,看到三个文件都不越界,再生成 merged.bin,最后双击 flash.bat。我的习惯是哪怕只改了一行代码,也把校验脚本跑一遍再发出去。数据边界这种事,靠脑子记不住,靠工具最放心。如果你也正在被烧录流程反复折腾,我建议你挑一个下午,也把这套东西搭起来,成本不高,后面省下的时间却非常可观。