做嵌入式开发最烦的不是写代码,而是把编译出来的固件安全送到烧录它的人手里。我们前段时间的 ESP32-S31 项目就踩了这么一脚:硬件调通了、功能验证完了,结果到了小批量产阶段,测试同事拿着一堆零散的 bin 文件对着命令行发懵。更让我后背发凉的是,有人尝试把 app.bin 直接烧到 0x0000,把 bootloader 覆盖得干干净净。
那时候我才意识到,对不常碰嵌入式底层的人来说,“地址”“分区表”“镜像格式”这些概念真不是常识。你眼里清楚得像地图一样的 flash 布局,在别人眼里就是一堆莫名其妙的十六进制数字。所以我把整个流程收敛成一个 Windows 一键烧录包:双击运行,选一下串口,等进度条走完。这篇文章就把这套做法拆开讲清楚,重点围绕三个东西:完整镜像怎么生成、esptool 怎么封装、数据边界到底指什么。适合正在做产线交付、要给非嵌入式同事发固件、或者单纯想把烧录流程固化成标准动作的开发者参考。
1. 为什么非嵌入式同事也需要一键烧录包
1.1 问题从烧错地址开始
我们的 ESP32-S31 项目早期一直是我自己烧录,命令行敲习惯了,觉得这有什么难的?无非是 esptool 加上几个地址参数。但真正把固件交给别人时,问题立刻暴露出来。
第一个坑就是烧录地址。项目固件有三个必烧文件:bootloader、partition table、app。bootloader 在 0x0000,partition table 在 0x8000,app 在 0x10000。这些地址在我脑子里是条件反射,但对别人来说就是三个毫无规律的数字。有人拿着 app.bin 填在 0x0000,于是 bootloader 直接被覆盖;有人把三个文件地址全填成一样的,结果后写的把先写的冲掉。设备反复重启、起不来、串口日志一堆乱码,硬件同事还以为是板子坏了。
第二个坑是环境。esptool 有独立 exe、有 Python 包、有 IDE 插件里的内置版本。测试同事电脑上没有 Python,命令行里敲esptool.py只会得到“不是内部或外部命令”。你去给他装环境,又牵扯到 Python 版本、pip 源、权限问题。这些对搞嵌入式的人都是小事,但消耗的沟通成本一点都不小。
第三个坑是“知道该选哪些参数”。同样是烧录,flash 模式要不要指定?频率是多少?flash 容量写多少?命令行里少一个--flash_size 4MB,bootloader 可能就把 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.bin | 0x0000 | 芯片上电后最先执行的程序,负责引导 app |
| partition_table.bin | 0x8000 | 分区表,描述 flash 上每一块区域的用途和边界 |
| app.bin | 由分区表决定 | 应用程序本体,常见默认地址是 0x10000 |
| nvs.bin | 0x9000 附近 | 非易失存储数据,可选,不是所有项目都需要预烧 |
| phy_init.bin | 0xf000 附近 | 射频校准参数,一般由出厂默认值替代 |
这里最需要注意的是 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 时没写或者写成了 4MB,bootloader 启动后就会按 4MB 来管理 flash。如果分区表里有分区落在 4MB 之后,比如 OTA 分区、文件系统分区,那这些分区就像不存在一样,访问即报错。更隐蔽的是,合并镜像头部会把 flash_size 信息固化进去,后面单独改命令都不一定覆盖掉。所以发布包里的 flash_size 必须写死成目标硬件的真实容量,不能依赖探测。
3. 数据边界才是烧录方案的核心
3.1 Flash 布局和每一段的边界
在所有烧录问题里,我最想强调的就是“数据边界”这四个字。它说的不是抽象的软件边界,而是非常具体的物理边界:每一份数据在 flash 里的起始地址、最大长度、内容类型、以及校验方式。
以默认的 4MB flash 分区表为例,典型的布局是这样的:
| 起始地址 | 大小 | 用途 |
|---|---|---|
| 0x0000 | 0x8000 | bootloader |
| 0x8000 | 0x1000 | partition table |
| 0x9000 | 0x5000 | NVS 非易失存储 |
| 0xf000 | 0x1000 | phy_init 射频初始化数据 |
| 0x10000 | 0x300000 | app 主分区 |
每一行都代表一个严格的边界。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 "ESPTOOL=tools\esptool.exe" set "FIRMWARE=firmware\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 N=0 for /f %%p in ('powershell -NoProfile -Command "[System.IO.Ports.SerialPort]::GetPortNames()"') do ( set /a N+=1 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 SEL="Select 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 校验值。产线现场如果刷完设备行为不对,第一件事不是怀疑硬件,而是先核对发布包版本和校验值。这个习惯救过我很多次。做一键烧录包,技术上并不难,难的是把隐含知识全部显性化,把可能出错的自由操作全部收掉。只要你不给用户犯错的机会,产线就安稳许多。