ESP32-S3一键烧录包制作:merge_bin与esptool封装实战 做嵌入式开发最烦的不是写代码而是把编译出来的固件安全送到烧录它的人手里。我们前段时间的 ESP32-S31 项目就踩了这么一脚硬件调通了、功能验证完了结果到了小批量产阶段测试同事拿着一堆零散的 bin 文件对着命令行发懵。更让我后背发凉的是有人尝试把 app.bin 直接烧到 0x0000把 bootloader 覆盖得干干净净。那时候我才意识到对不常碰嵌入式底层的人来说“地址”“分区表”“镜像格式”这些概念真不是常识。你眼里清楚得像地图一样的 flash 布局在别人眼里就是一堆莫名其妙的十六进制数字。所以我把整个流程收敛成一个 Windows 一键烧录包双击运行选一下串口等进度条走完。这篇文章就把这套做法拆开讲清楚重点围绕三个东西完整镜像怎么生成、esptool 怎么封装、数据边界到底指什么。适合正在做产线交付、要给非嵌入式同事发固件、或者单纯想把烧录流程固化成标准动作的开发者参考。1. 为什么非嵌入式同事也需要一键烧录包1.1 问题从烧错地址开始我们的 ESP32-S31 项目早期一直是我自己烧录命令行敲习惯了觉得这有什么难的无非是 esptool 加上几个地址参数。但真正把固件交给别人时问题立刻暴露出来。第一个坑就是烧录地址。项目固件有三个必烧文件bootloader、partition table、app。bootloader 在 0x0000partition table 在 0x8000app 在 0x10000。这些地址在我脑子里是条件反射但对别人来说就是三个毫无规律的数字。有人拿着 app.bin 填在 0x0000于是 bootloader 直接被覆盖有人把三个文件地址全填成一样的结果后写的把先写的冲掉。设备反复重启、起不来、串口日志一堆乱码硬件同事还以为是板子坏了。第二个坑是环境。esptool 有独立 exe、有 Python 包、有 IDE 插件里的内置版本。测试同事电脑上没有 Python命令行里敲esptool.py只会得到“不是内部或外部命令”。你去给他装环境又牵扯到 Python 版本、pip 源、权限问题。这些对搞嵌入式的人都是小事但消耗的沟通成本一点都不小。第三个坑是“知道该选哪些参数”。同样是烧录flash 模式要不要指定频率是多少flash 容量写多少命令行里少一个--flash_size 4MBbootloader 可能就把 flash 识别成默认值后面分区表对不上app 起不来。这些隐含知识全部藏在你的脑子里别人没有。1.2 一键包解决的问题一键烧录包本质上是把前面所有“隐含知识”固化成一个可重复的流程。使用者只需要做四件事插上 USB 线双击 bat 文件从列表里选一个串口等它烧完。这个流程对使用者的要求降到了最低不需要理解地址、分区、flash 参数。同时它也在保护我自己不再需要反复回答“地址填多少”“为什么烧不进去”“是不是我命令少了个参数”这类问题。一键包还有一个额外的好处就是把固件和烧录工具绑定成一个整体。你发出的不是一个 app.bin 和一段说明文字而是一个完整的发布目录。固件版本、校验值、工具版本、烧录参数都在里面任何一次烧录结果都可复现。这对产线追溯来说非常重要。1.3 为什么不建议直接把 esptool 命令丢给使用者有些开发者会说我把命令写成文档复制粘贴不就行了听起来可行实际操作中依然会出错。命令行本身是高度压缩的信息。esptool.py write_flash 0x0 bootloader.bin 0x8000 partition-table.bin 0x10000 app.bin这一行里每个地址背后的原因都没有体现出来。一旦某个文件换了分区表或者某个芯片 flash 容量不同使用者根本不知道要不要改怎么改。而且命令行允许用户自由发挥。他可以加参数、改参数、删参数然后遇到一个你完全没法预判的环境。一键烧录包则把这些自由度全部收掉用户只有一个选择——选哪个串口。自由度越少出错概率越低这才是产线工具该有的样子。2. 完整镜像生成是第一步merge_bin 的地址拼接与参数匹配2.1 先盘点固件产物做一键包之前先要把需要烧录的文件和它们的默认地址理清楚。不同 SDK 生成的文件名略有差异但一般来说包含下面几类文件烧录地址说明bootloader.bin0x0000芯片上电后最先执行的程序负责引导 apppartition_table.bin0x8000分区表描述 flash 上每一块区域的用途和边界app.bin由分区表决定应用程序本体常见默认地址是 0x10000nvs.bin0x9000 附近非易失存储数据可选不是所有项目都需要预烧phy_init.bin0xf000 附近射频校准参数一般由出厂默认值替代这里最需要注意的是 app 的地址。很多项目会用自定义分区表app 可能不在 0x10000。比如设置了 ota_0 和 ota_1 两个 app 分区时第一个 app 可能在 0x10000第二个可能在 0x180000。所以不要背地址要以你项目里的分区表为准。后面第 3 节会专门讲怎么读。2.2 merge_bin 命令是怎么把多个 bin 拼成一个的esptool 有两种常见的烧录方式。一种是write_flash直接在命令行里按地址逐文件写入esptool.exe --chip esp32s3 --port COM5 write_flash -z --flash_mode dio --flash_freq 80m --flash_size 4MB 0x0 bootloader.bin 0x8000 partition-table.bin 0x10000 app.bin这种方式灵活适合开发阶段但不符合一键包的需求因为它把地址暴露给了使用者。另一种方式是merge_bin它把多个 bin 拼接成一个从 0x0000 开始的完整镜像文件esptool.exe --chip esp32s3 merge_bin -o merged.bin --flash_mode dio --flash_freq 80m --flash_size 4MB 0x0 bootloader.bin 0x8000 partition-table.bin 0x10000 app.bin执行完之后你会得到一个merged.bin。它不是一个普通意义上的 app 镜像而是整片 flash 内容的快照。之后烧录时只需要一条命令esptool.exe --chip esp32s3 --port COM5 write_flash 0x0 merged.bin为什么我强烈建议一键包使用 merge_bin第一分发文件只有一个不容易漏文件。第二地址已经被固化了使用者不需要知道哪段代码放在哪个位置。第三合并后的文件可以直接计算 SHA256方便发布前校验完整性。第四整个烧录过程只写一次大文件在串口波特率足够高时整体烧录时间并不会比逐个写更长。2.3 三个 flash 参数为什么容易埋雷merge_bin 命令里出现了--flash_mode、--flash_freq、--flash_size三个参数很多人会忽略它们觉得反正能跑。这三个参数恰恰是最容易让固件“烧录成功但起不来”的元凶。flash_mode 是访问 flash 的方式常见有 DIO 和 QIO。如果固件编译时用的是 QIO 模式而你 merge 时写成了 DIO程序大概率能启动但读取速度会下降反过来如果硬件不支持 QIO 而你写了 QIO可能直接启动崩溃。所以 merge 时用的 flash_mode 必须和 SDK 编译配置保持一致。flash_freq 决定 flash 时钟频率。ESP32-S31 这类芯片通常支持 80MHz但部分 flash 芯片在高速模式下不稳定。如果 merge 参数里写了 80MHz而实际 flash 只能稳定跑 40MHz设备会出现随机性死机、日志报Flash read error。想排查这个问题很费时间因为它不是必现的。flash_size 是我踩过最深的坑。芯片实际 flash 是 8MB但 merge 时没写或者写成了 4MBbootloader 启动后就会按 4MB 来管理 flash。如果分区表里有分区落在 4MB 之后比如 OTA 分区、文件系统分区那这些分区就像不存在一样访问即报错。更隐蔽的是合并镜像头部会把 flash_size 信息固化进去后面单独改命令都不一定覆盖掉。所以发布包里的 flash_size 必须写死成目标硬件的真实容量不能依赖探测。3. 数据边界才是烧录方案的核心3.1 Flash 布局和每一段的边界在所有烧录问题里我最想强调的就是“数据边界”这四个字。它说的不是抽象的软件边界而是非常具体的物理边界每一份数据在 flash 里的起始地址、最大长度、内容类型、以及校验方式。以默认的 4MB flash 分区表为例典型的布局是这样的起始地址大小用途0x00000x8000bootloader0x80000x1000partition table0x90000x5000NVS 非易失存储0xf0000x1000phy_init 射频初始化数据0x100000x300000app 主分区每一行都代表一个严格的边界。bootloader 只能放在 0x0000不能放在 0x1000因为芯片上电后固定从 0x0000 取指令。partition table 只能放在 bootloader 之后、NVS 之前的预留位置bootloader 会按固定偏移去读它。app 不能随意挪到 0x8000因为那块区域属于分区表你在那里写 app 等于把分区表覆盖了bootloader 根本找不到 app 入口。我之前遇到过一个很典型的问题为了“节省空间”把 phy_init 和 nvs 合并掉结果系统启动后不断重启。后来查了才知道Wi-Fi 协议栈启动时会按固定地址读取 phy_init 数据发现数据和它期望的边界对不上就直接复位了。边界这个东西不是你想省就能省的。3.2 自定义分区表时怎么读 offset很多项目不会使用默认分区表而是自定义一个partitions.csv。这个文件的名字听起来像配置文件实际上它就是数据边界最权威的来源。比如我们的 ESP32-S31 项目分区表大概是这样的# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x300000,如果你想把自定义分区里的文件烧进固件offset 那一列就是你要用的地址。不要把 app 烧到 0x10000 当成默认值而是要去看factory这一行的 Offset。只要你的分区表变了merge_bin 命令里的地址也必须跟着变。这里有一个特别容易踩的坑修改分区表之后忘了同步修改烧录命令。比如你给 OTA 加了两个分区把 factory 挪到了别的位置但 merge 命令里还写着 0x10000。结果烧进去的文件其实落在了一个不存在的分区上bootloader 按分区表去寻找 app找到的是一段无效代码设备当然起不来。所以我做一键包的规范是每次改完分区表第一时间更新 merge 命令和发布脚本不允许“下次再改”。3.3 怎么验证边界没有超只靠眼睛看不够我建议在发布流程里加两个验证动作。第一个是查看镜像信息。esptool 的image_info可以显示镜像内部的 segment 地址和校验和esptool.exe --chip esp32s3 image_info app.bin esptool.exe --chip esp32s3 image_info bootloader.bin输出里会列出每个 segment 的加载地址和长度。你要确认这些地址都落在分区表规定的范围内。比如 app 的 segment 地址应该在 factory 分区内部如果出现跨出分区范围的地址基本可以判定镜像有问题。第二个是烧录后回读比对。用 esptool 把 flash 内容读出来再和原始 merged.bin 做逐字节比较esptool.exe --chip esp32s3 --port COM5 read_flash 0x0 0x400000 dump.bin然后在 Windows 的命令行里用fc /b dump.bin merged.bin比较。比较通过说明烧进去的内容和发出去的镜像完全一致。如果比较不通过先怀疑串口波特率太高、USB 线质量差、或者供电不稳。有一点需要提醒merged.bin是多个独立镜像的拼接体不是标准 app 镜像所以image_info不能像解析单个 app.bin 那样去解析它。要验证 merged.bin更靠谱的办法是分别验证各组成部分或者整片回读后做哈希比对。3.4 安全启动和 flash 加密会改变边界规则上面说的都是明文项目。如果项目启用了 secure boot 或者 flash encryption一键包就完全不是一个玩法了。首先是 flash encryption。开启后写入 flash 的 app 和分区表必须是加密的直接用明文 merged.bin 刷进去启动时解密失败设备起不来。这种场景下不能用普通 merge_bin 生成发布包而是要对每个 bin 单独处理烧录时让 esptool 使用--encrypt参数或者使用专门的加密镜像生成工具。其次是 secure boot。开启后 bootloader 会对 app 做签名校验乱改 app 或者混用不同版本的 bootloader 和 app都会导致签名校验失败。此时 merge 出的镜像不再是“把几个 bin 拼起来”那么简单而是必须从同一套构建产物中生成确保签名链完整。我个人的建议是如果你的项目开了安全启动或 flash 加密尽量不要做“一键全量镜像”方案而是保持分文件烧录让 esptool 按地址依次写入并处理加密和签名。一键包的简化程度是要和安全性做平衡的。4. Windows 一键烧录脚本的完整实现4.1 发布包目录怎么组织一键烧录不只是一段 bat 脚本而应该是一个完整的发布目录。我习惯的组织方式如下release/ ├─ flash.bat ├─ README.txt ├─ tools/ │ └─ esptool.exe └─ firmware/ └─ ESP32S31_merged_v1.2.0_abc123.bintools 目录放 esptool 独立可执行文件。我建议直接用官方 release 里提供的 standalone exe不要依赖使用者的电脑上装 Python、装 pip。实测在 Win10 和 Win11 上都能直接运行体积也不算大发布包整体可以控制在几 MB 左右。firmware 目录放合并后的完整镜像。文件名里一般带上版本号和 git commit 短号比如上面的v1.2.0_abc123。这样做的好处是拿到包的人不用打开文件属性或者看 README就知道当前烧的是哪个版本、哪个提交点出来的。现场如果出现多份发布包也能快速区分。README.txt 不需要长篇大论写三件事就够了第一这个固件对应的项目名称和版本第二烧录步骤插线、双击、选择串口、等待完成第三SHA256 校验值方便现场验证发布包有没有损坏。4.2 自动检测串口是批处理的难中之难Windows 下想要在 bat 里枚举串口最靠谱的办法是借助 PowerShell。PowerShell 可以直接拿到系统当前的串口列表[System.IO.Ports.SerialPort]::GetPortNames()这条命令会输出一串类似COM3 COM5 COM7的端口名。批处理里用for /f把它循环读出来再显示成带序号的选择菜单。不建议做全自动盲刷脚本按顺序尝试每个端口对某个端口执行擦写。万一选中的是调试器虚拟串口或者别的设备会把固件刷到错误的地方。宁可让操作人员看一眼列表手动选一次能避免大量生产事故。4.3 完整 flash.bat 实战代码下面是我在用的一键烧录脚本做了简化但保留了完整逻辑。代码里的中文提示建议保存成 ANSI 编码或者干脆用英文提示避免 bat 的中文编码问题。echo off setlocal enabledelayedexpansion cd /d %~dp0 set ESPTOOLtools\esptool.exe set FIRMWAREfirmware\ESP32S31_merged_v1.2.0_abc123.bin echo echo ESP32-S31 One-click Flasher echo echo. if not exist %ESPTOOL% ( echo [ERROR] esptool.exe not found in tools folder. echo Please keep the whole package complete. pause exit /b 1 ) if not exist %FIRMWARE% ( echo [ERROR] Firmware file not found. echo Expected: %FIRMWARE% pause exit /b 1 ) echo Firmware: %FIRMWARE% echo Size: %~zFIRMWARE% bytes echo. echo Scanning available COM ports... echo. set /a N0 for /f %%p in (powershell -NoProfile -Command [System.IO.Ports.SerialPort]::GetPortNames()) do ( set /a N1 set PORT_!N!%%p echo [!N!] %%p ) if !N!0 ( echo [ERROR] No COM port detected. echo Please plug in the USB cable and try again. pause exit /b 1 ) echo. set SEL set /p SELSelect port number [1-!N!]: if not defined SEL ( echo [ERROR] No selection. pause exit /b 1 ) set COMPORT!PORT_%SEL%! if not defined COMPORT ( echo [ERROR] Invalid selection: %SEL% pause exit /b 1 ) echo. echo Selected port: !COMPORT! echo Flashing... echo. %ESPTOOL% --chip esp32s3 --port !COMPORT! --baud 460800 write_flash -z --flash_mode dio --flash_freq 80m --flash_size 4MB 0x0 %FIRMWARE% if errorlevel 1 ( echo. echo [ERROR] Flash failed. echo Tips: unplug USB, plug again, make sure no serial tool is using the port. pause exit /b 1 ) echo. echo [OK] Flash completed successfully. pause这段脚本的关键点有几个。cd /d %~dp0是让脚本无论从哪里被双击都先切换到自身所在目录。这样后面引用的tools\esptool.exe和firmware\...路径都是相对发布包根目录的不会出现“从桌面双击结果找不到文件”的问题。setlocal enabledelayedexpansion是开启延迟变量展开。串口列表里用了PORT_!N!这种动态生成的变量不开延迟展开的话for 循环内部拿到的值会是循环开始前的旧值实际使用时很容易踩这个坑。%~zFIRMWARE%是直接获取文件的字节大小并打印方便操作人员确认固件文件不是 0 字节或者被截断。烧录参数里波特率我选了 460800。实际上 ESP32-S31 可以跑到 921600但某些 USB 转串口芯片在高速下不稳定。针对产线的一键包我更倾向保守一点460800 在速度和稳定性的平衡上更稳。4.4 错误处理要能让人看懂脚本里的每一处退出都给出了具体提示。这不是废话而是产线工具必要的设计。使用者不是开发者他看到“Flash failed”这样的提示需要立刻知道下一步做什么。我还习惯在烧录前打印固件文件大小在烧录完成后提示成功。esptool 本身会在日志末尾输出类似 “Hash of data verified” 的信息那代表写入校验已经通过。操作人员只要看到[OK]就可以把设备交给下一道工序。如果脚本失败我提示的第一句话一定是“拔插 USB 线关闭串口调试工具”。因为 90% 的烧录失败都是这两个原因不需要让使用者去理解 esptool 的底层报错。5. 实测翻车记录三个必须提前预防的问题5.1 串口被占用导致脚本“卡死”第一次把一键包发给测试同事时他反馈说烧录到一半卡住了。我远程看日志发现 esptool 在打开 COM5 时直接报错could not open port COM5。原因是他的电脑上装了一款串口助手占用了 COM5而我们的脚本枚举串口时只看到了端口存在没看到已经被占用。这个问题要彻底解决不太现实因为 Windows 下枚举串口时不会显示占用状态。我的缓解方案是把烧录步骤做成“选择端口 - 提示关闭串口工具 - 执行烧录”。同时在错误提示里明确写“如果端口被占用请先关闭串口监视器软件”。还有一个更隐蔽的情况某些蓝牙模块和 USB 调试器会虚拟出多个 COM 口。操作人员不清楚哪个是设备真正的串口随便选一个就会失败。所以脚本里加了失败后重试的建议但真正的经验是在选择菜单里同时打印端口列表让有经验的人看一眼就知道哪些端口是虚拟出来的。5.2 旧 bootloader 混入新 app这个坑纯粹是我自己埋的。某次版本迭代只改了几行应用代码我觉得没必要重新构建整个工程就把老的 merge 脚本里的 app.bin 替换成了新的其他文件保持不动。结果烧录完成后设备反复复位串口日志里看到 bootloader 不断报校验失败。排查过程比较痛苦。我先是怀疑新 app 有问题单独编译了一个最小 app 烧进去能跑。再怀疑分区表变了检查了 CSV没变。最后才想到 bootloader 和 app 不是同一套构建产物。那次 SDK 其实升级过老的 bootloader 和新的 app 在协议上有兼容性问题。从那次之后我定了一条铁律一键烧录包里的 merged.bin 必须由同一个构建过程一次性生成不允许手工替换单个文件。bootloader、partition table、app 必须同源同版本。这个约束看起来很死板但能挡住大量莫名其妙的问题。5.3 flash_size 与加密标志不一致还有一个翻车现场也值得说一下。老板给了我一台 8MB flash 的样机但 merge 命令里还写着 4MB。烧录后 app 能启动但一访问文件系统分区就出错。文件系统分区在分区表里被安排到了 4MB 之后而 bootloader 以为 flash 只有 4MB整个后段区域都不可用。这种问题的排查难点在于它不会在烧录时报错一切看起来都正常直到运行时访问到那一块区域才暴露。最后我用esptool flash_id查了实际 flash 容量才发现镜像头里记录的容量和硬件对不上。至于 flash 加密我也在一款开了加密的样机上试过直接把 merged.bin 刷进去结果启动后直接 panic。因为镜像内容没有加密芯片尝试解密时得到的是明文。后来确认开加密的产品必须单独走加密烧录方案不能用常规一键包。现在我在发布流程里加了一步每次构建时从目标板读取一次 flash 元数据用脚本比对 flash_size 是否和 merge 参数一致不一致就拒绝继续。这个检查放在人工之前的自动环节里能在问题发生前拦住一大半。最后再分享一个实际经验每次发布一键包时我都会把 git commit 短号写进文件名同时在 README 里放一份 SHA256 校验值。产线现场如果刷完设备行为不对第一件事不是怀疑硬件而是先核对发布包版本和校验值。这个习惯救过我很多次。做一键烧录包技术上并不难难的是把隐含知识全部显性化把可能出错的自由操作全部收掉。只要你不给用户犯错的机会产线就安稳许多。