ESP-IDF 工程能力 / Flash与分区预算发布 内容更新

ESP32 固件为什么装不下?读懂 Flash、分区表和 OTA 空间

以真实8MiB布局与app.bin预算工具区分物理Flash、镜像头、双OTA槽和整包;保留错误分区拒绝、备份与原布局恢复步骤。

读完能做什么
得到应用、模型与数据空间表及实际产物预算;在换分区前建立自己的备份恢复证据。
开始之前
先完成所列前置并会保存同次固件、ELF和配置;硬件操作只对自己的固定实验板执行。
所需器材
固定S3-DevKitC-1 v1.1 / WROOM-1-N8R8、USB数据线、IDF5.5.5及本例README指定工具
版本组合
基线:ESP-IDF5.5.5 / b774170ff46c393eeb5e495ea37936038d3f4f4f器材复验:ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8
适用范围

固定S3-DevKitC-1 v1.1 / N8R8;IDF5.5.5、8MiB未加密/未签名计划、2×2MiB OTA槽;不使用PSRAM,无模型/文件系统/升级器实施。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/engineering-maintenance-sources.json;43份固定GitHub原始文件,安装器tools.json策略差异单列,不冒称全字节相同。验证日期:
构建 / 配置已通过
docs/esp32-evidence/engineering-maintenance-builds.json;E09最终实际Exit0,原源/配置/日志/bin/ELF/map SHA保留。E09独立重叠分区构建预期Exit2。对象:ESP32-S3 / N8R8计划版本:ESP-IDF5.5.5验证日期:
仿真不适用
仅执行适用主机逻辑/离线工具,不是ESP32芯片、I2C、USB或无线仿真。
本站实板待验证
尚无本例实际烧录、运行或接线结果;observations.csv实测字段为空。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / Flash与分区预算

先区分四种大小

固定ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8,IDF5.5.5,Flash8MiB/DIO80MHz;PSRAM8MiB不计入Flash,也未初始化。完整分区表、只读运行清单程序、离线预算工具、错误表fixture与空实板表都在包内。无本站真实flash_id、刷分区、保存配置、OTA切换或恢复结果。

8MiB=8388608字节是计划物理Flash容量;厂商N8R8编码只能作选型线索,实板还须flash_id及运行esp_flash_get_physical_size核物理ID探测值、esp_flash_get_size核镜像头可用值;后者不能当物理容量。idf.py size中的RAM/Flash链接段报告不是app.bin文件长度;app.bin是一个应用镜像;完整刷写计划还含bootloader、partition-table、otadata等,合并整包可带地址填充。OTA槽需要容纳app镜像及你声明的保留余量,不能把整包放OTA槽,也不能拿PSRAM抵扣槽容量。

做到这里,你应该看到
先核固定板型、版本与本例结果范围;将尚未做的实体步骤保留空白。

参考:[1] 固定SDK分区表格式、对齐与OTA布局[3] Flash物理容量与初始化后可用容量API不同[4] 物理容量探测与可用size实现[5] 镜像头与Flash初始化容量规则

02 / Flash与分区预算

这张分区表分给谁

最后一分区恰到0x800000。表前bootloader/0x8000分区表、0x12000..0x20000对齐空隙不算全部可随意写的free空间。模型分区存在不表示已放模型、模型能运行或RAM足够;当前模型文件=0字节。没有OTA下载器、云升级或回滚完成结果,仅提供正确容量的双槽和运行识别方法。

区域偏移容量用途
nvs0x90000x6000=24KiB配置,当前程序只读不自动擦除
otadata0xf0000x2000=8KiBOTA选择元数据的两页,不是第三份app
phy_init0x110000x1000=4KiB预留PHY数据区域;本例使用SDK默认初始化
ota_00x200000x200000=2MiB首个应用槽
ota_10x2200000x200000=2MiB另一个应用槽;没有factory槽
model0x4200000x180000=1.5MiB自定义data subtype0x40,原始模型文件占位
logs0x5a00000x260000=2.375MiBSPIFFS类型空间;本例未格式化、挂载或写日志

参考:[1] 固定SDK分区表格式、对齐与OTA布局[2] 固定SDK分区二进制与MD5生成器[6] OTA API与运行/候选槽含义

03 / Flash与分区预算

从独立配置构建,再读真实产物

预算工具从实际project_description.json和flasher_args.json解析应用文件及0x20000/0x8000偏移,不让你误传merged.bin。读取真实partition-table.bin,核magic/MD5/终止、4KiB数据对齐、64KiB应用起点、重叠/越界、两个不同OTA槽和otadata两页,再按实际app文件字节数计算。固定未加密、未签名实验范围,未知分区flags拒绝;不是通用安全固件验证器。

默认65536字节是本例人为维护余量,不是签名固定开销或IDF硬性要求。当前源码实际构建app211232字节,两槽各2097152字节,扣保留后headroom1820384字节;这是离线构建文件事实,不是实板Flash容量测量。若修改源码必须重新算,不能长期抄此数字。--model-bin可计你自己真实文件字节,但不证明格式/推理/RAM合格。

代码 / 配置
idf.py -B build -D SDKCONFIG=build/sdkconfig -D SDKCONFIG_DEFAULTS=sdkconfig.defaults build
idf.py -B build size
python3 tools/budget.py --build build --flash-bytes 8388608 --flash-source planned-N8R8-not-measured --reserve-bytes 65536
python3 host/budget_test.py

参考:[1] 固定SDK分区表格式、对齐与OTA布局[2] 固定SDK分区二进制与MD5生成器

04 / Flash与分区预算

用明确错误证明检查在起作用

partitions.bad.csv故意让ota_1从0x210000开始,而ota_0到0x220000,重叠64KiB。单独配置与构建目录执行(只构建,绝不刷写):

本站实际Exit2,原始日志保留官方生成器CSV Error/Partitions overlap;它没有可用最终分区镜像,不写成“错误布局烧板通过”。12组合成输入另核MD5损坏、对齐/重叠、容量边界、重复OTA、otadata不足、app+保留恰好/超1B、模型过大、负余量、截断/缺MD5。工具成功只说明所声明输入预算自洽。

代码 / 配置
idf.py -B build-bad -D SDKCONFIG=build-bad/sdkconfig -D 'SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.bad.defaults' build

参考:[1] 固定SDK分区表格式、对齐与OTA布局[2] 固定SDK分区二进制与MD5生成器

05 / Flash与分区预算

换分区前先保存可恢复的一组文件

只在自己的无加密/无安全启动实验板使用下列步骤;现有产品的保护策略不在此例迁移范围。本机固定SDK环境实际esptool为4.12.0,命令是flash_id/read_flash/write_flash/verify_flash下划线形式,不能机械抄新版5.x连字符写法。

先保存旧项目源/锁/SDK配置、ELF、全部刷写文件和flasher_args.json、应用导出的设置及所用端口/板号。保持电源和端口明确,先做只读查询与完整备份,以下REPLACE_PORT必须换成自己确认的接口:

保留私有备份与摘要,不公开含Wi-Fi/应用配置的原始Flash。按旧CSV读真实NVS偏移/大小,不能因为本例新NVS=0x9000就假定旧数据也在那里。只保留旧NVS字节不等于应用schema已经迁移;不同表位置/密钥/格式须走应用自己的导出导入。本站未读取任何板Flash,命令是待执行模板,不用合成文件填“备份成功”。

代码 / 配置
python -m esptool --chip esp32s3 -p REPLACE_PORT flash_id
# 只有已核对实际8MiB才读这个长度;其它容量换用对应计划
mkdir -p backups
python -m esptool --chip esp32s3 -p REPLACE_PORT read_flash 0x0 0x800000 backups/flash-original.bin
python -m esptool --chip esp32s3 -p REPLACE_PORT read_flash 0x8000 0x1000 backups/partitions-original.bin
python "$IDF_PATH/components/partition_table/gen_esp32part.py" backups/partitions-original.bin backups/partitions-original.csv
python -c 'import hashlib,pathlib; p=pathlib.Path("backups/flash-original.bin"); print(p.stat().st_size,hashlib.sha256(p.read_bytes()).hexdigest())'

参考:[1] 固定SDK分区表格式、对齐与OTA布局[2] 固定SDK分区二进制与MD5生成器[7] 官方esptool release-v4:read_flash/write_flash/verify_flash

06 / Flash与分区预算

部署与运行应分别记录

备份/恢复办法已验证后,才使用本次build对应的完整idf.py flash计划;不要只刷新partition-table就以为两个app和数据一起迁好了。这个例子会列detected_physical_flash_bytes与available_header_flash_bytes、当前运行槽、另一个候选槽与每个区域容量;两值不是8MiB则STOP;物理小于镜像头时SDK可能在app_main前先拒绝启动,应保留boot日志。物理API也依赖厂商ID容量约定,应与模块手册/flash_id交叉核对。它只读e09命名空间marker,缺命名空间/键给明确提示,不自动写默认值或erase NVS;NVS初始化失败也原样报错。

空otadata时的首次启动、实际running=ota_0、重启、旧设置导入、断电恢复、两槽真实下载/选择/回滚都要各自验收。本项目没有升级器,所以“next_slot=ota_1”只是分区查询,不是OTA成功或备用槽已含可启动固件。data/spiffs标签本身不表示存在文件系统镜像。

参考:[1] 固定SDK分区表格式、对齐与OTA布局[3] Flash物理容量与初始化后可用容量API不同[4] 物理容量探测与可用size实现[5] 镜像头与Flash初始化容量规则[6] OTA API与运行/候选槽含义

07 / Flash与分区预算

恢复原布局不靠猜偏移

保存的旧全Flash与原刷写计划来自同一块已核对的无保护实验板时,按该计划恢复。完整8MiB备份的恢复模板如下,默认保留原头的模式/频率/容量,不更改eFuse:

之后重启核旧应用与导出设置,保留原始成功/失败日志;写回相同字节不代替应用功能验收。不要把不同板备份、加密镜像或只一段app当整Flash恢复。本文没有执行任何硬件写入/擦除。

代码 / 配置
python -m esptool --chip esp32s3 -p REPLACE_PORT write_flash --flash_mode keep --flash_freq keep --flash_size keep 0x0 backups/flash-original.bin
python -m esptool --chip esp32s3 -p REPLACE_PORT verify_flash 0x0 backups/flash-original.bin

参考:[1] 固定SDK分区表格式、对齐与OTA布局[7] 官方esptool release-v4:read_flash/write_flash/verify_flash

08 / Flash与分区预算

许可与结果范围

原创预算解析器/应用/fixture MIT,分区二进制格式及MD5依据固定SDK5.5.5 gen_esp32part.py;SDK各原许可保留。正常编译、受控失败、12合成与实际文件预算分别留原日志/SHA,physicalFlash/备份/重启/OTA/模型/文件系统均pending。器材仅固定开发板/数据线,无外设接线或额外Flash元件。

参考:[1] 固定SDK分区表格式、对齐与OTA布局[2] 固定SDK分区二进制与MD5生成器[3] Flash物理容量与初始化后可用容量API不同[4] 物理容量探测与可用size实现[7] 官方esptool release-v4:read_flash/write_flash/verify_flash

09 / Flash与分区预算

运行清单程序与两张分区表

以下为下载工程中对应文件的全文;带文件名的注释只是网页分隔,构建时使用独立原文件。其它构建配置、锁、许可与夹具在完整示例中。

代码 / 配置
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <inttypes.h>
#include "esp_flash.h"
#include "esp_partition.h"
#include "esp_ota_ops.h"
#include "esp_app_desc.h"
#include "nvs_flash.h"
#include "nvs.h"
void app_main(void){
 uint32_t physical=0,available=0;esp_err_t e=esp_flash_get_physical_size(NULL,&physical);
 if(e!=ESP_OK){printf("E09 STOP flash size read: %s\n",esp_err_to_name(e));return;}
 e=esp_flash_get_size(NULL,&available);if(e!=ESP_OK){printf("E09 STOP available/header size read: %s\n",esp_err_to_name(e));return;}
 printf("E09 detected_physical_flash_bytes=%"PRIu32" available_header_flash_bytes=%"PRIu32" plan_bytes=8388608\n",physical,available);
 if(physical!=8388608||available!=8388608){puts("E09 STOP actual chip is not this 8MiB plan; do not infer capacity from PSRAM or a module nickname");return;}
 const esp_partition_t *running=esp_ota_get_running_partition(),*next=esp_ota_get_next_update_partition(NULL);
 const esp_app_desc_t *app=esp_app_get_description();
 printf("E09 version=%s project=%s IDF=%s running=%s next_slot=%s; this example does NOT perform OTA\n",app->version,app->project_name,app->idf_ver,running?running->label:"NONE",next?next->label:"NONE");
 esp_partition_iterator_t it=esp_partition_find(ESP_PARTITION_TYPE_ANY,ESP_PARTITION_SUBTYPE_ANY,NULL);
 while(it){const esp_partition_t *p=esp_partition_get(it);printf("E09 PART,%s,type=%u,subtype=%u,offset=0x%08"PRIx32",capacity_bytes=%"PRIu32"\n",p->label,p->type,p->subtype,p->address,p->size);it=esp_partition_next(it);}
 /* Iterator automatically released when next() returns NULL. Do not erase NVS
  * on init failure: this tutorial is about preserving existing device data. */
 e=nvs_flash_init();if(e!=ESP_OK){printf("E09 NVS_INIT=%s; no automatic erase\n",esp_err_to_name(e));return;}
 nvs_handle_t handle;e=nvs_open("e09",NVS_READONLY,&handle);
 if(e==ESP_ERR_NVS_NOT_FOUND){puts("E09 namespace missing: expected on clean flash; no write performed");return;}
 if(e!=ESP_OK){printf("E09 nvs_open=%s\n",esp_err_to_name(e));return;}
 uint32_t marker=0;e=nvs_get_u32(handle,"marker",&marker);nvs_close(handle);
 if(e==ESP_ERR_NVS_NOT_FOUND)puts("E09 marker missing; no default write or migration performed");
 else printf("E09 marker_read=%s marker=%"PRIu32"; compare only with your saved pre-migration record\n",esp_err_to_name(e),marker);
}


# ---- partitions.csv ----
# Name,Type,SubType,Offset,Size,Flags
nvs,data,nvs,0x9000,0x6000,
otadata,data,ota,0xf000,0x2000,
phy_init,data,phy,0x11000,0x1000,
ota_0,app,ota_0,0x20000,0x200000,
ota_1,app,ota_1,0x220000,0x200000,
model,data,0x40,0x420000,0x180000,
logs,data,spiffs,0x5a0000,0x260000,


# ---- partitions.bad.csv ----
# Deliberate overlap fixture; NEVER flash
# Name,Type,SubType,Offset,Size,Flags
nvs,data,nvs,0x9000,0x6000,
otadata,data,ota,0xf000,0x2000,
ota_0,app,ota_0,0x20000,0x200000,
ota_1,app,ota_1,0x210000,0x200000,

参考:[1] 固定SDK分区表格式、对齐与OTA布局[2] 固定SDK分区二进制与MD5生成器[3] Flash物理容量与初始化后可用容量API不同[4] 物理容量探测与可用size实现[5] 镜像头与Flash初始化容量规则[6] OTA API与运行/候选槽含义[7] 官方esptool release-v4:read_flash/write_flash/verify_flash[8] 固定官方DevKitC-1 v1.1:N8R8、双USB接口、GPIO与供电

10 / Flash与分区预算

直接读取实际构建产物的完整预算工具

以下为下载工程中对应文件的全文;带文件名的注释只是网页分隔,构建时使用独立原文件。其它构建配置、锁、许可与夹具在完整示例中。

代码 / 配置
# ---- tools/budget.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Read actual build outputs and compute byte capacity, never flash anything.
Scope: this example's unencrypted/unsigned S3 table at0x8000, MD5 enabled.
A fitting image is not a valid OTA transaction, valid model, or available RAM.
"""
import argparse,hashlib,json,pathlib,struct

def read_table(path,flash_bytes):
 raw=pathlib.Path(path).read_bytes();parts=[];md5=hashlib.md5();verified=False;ended=False
 if len(raw)%32:raise ValueError('table length must be a multiple of32')
 for at in range(0,len(raw),32):
  record=raw[at:at+32]
  if record==b'\xff'*32:
   if raw[at:]!=b'\xff'*(len(raw)-at):raise ValueError('nonempty data after table terminator')
   ended=True;break
  if record[:2]==b'\xeb\xeb':
   if verified or record[:16]!=b'\xeb\xeb'+b'\xff'*14 or record[16:]!=md5.digest():raise ValueError('partition MD5 mismatch/duplicate')
   verified=True;continue
  if verified:raise ValueError('partition record after MD5')
  magic,kind,subtype,offset,size,label,flags=struct.unpack('<2sBBII16sI',record)
  if magic!=b'\xaa\x50':raise ValueError('invalid partition magic')
  label=label.split(b'\0',1)[0].decode('ascii')
  if not label or offset<0x9000 or offset%4096 or not size or size%4096:raise ValueError('invalid example partition offset/size/alignment')
  if kind==0 and offset%65536:raise ValueError('app must be64KiB aligned')
  if offset+size>flash_bytes:raise ValueError('partition exceeds declared Flash')
  if flags:raise ValueError('this unsigned/unencrypted budget does not accept partition flags')
  if any(x['name']==label for x in parts):raise ValueError('duplicate label')
  parts.append({'name':label,'type':kind,'subtype':subtype,'offset_bytes':offset,'capacity_bytes':size,'end_bytes':offset+size});md5.update(record)
 if not parts or not verified or not ended:raise ValueError('missing entries/MD5/end marker')
 for a,b in zip(sorted(parts,key=lambda p:p['offset_bytes']),sorted(parts,key=lambda p:p['offset_bytes'])[1:]):
  if a['end_bytes']>b['offset_bytes']:raise ValueError('partition overlap')
 return parts

def assess(parts,app_bytes,model_bytes,reserve_bytes):
 if min(app_bytes,reserve_bytes)<0 or app_bytes==0 or model_bytes<0:raise ValueError('positive app and nonnegative reserve/model required')
 slots=[p for p in parts if p['type']==0 and p['subtype'] in [16,17]]
 if len(slots)!=2 or {p['subtype'] for p in slots}!={16,17}:raise ValueError('two distinct ota_0/ota_1 slots required')
 ota=[p for p in parts if p['type']==1 and p['subtype']==0]
 if len(ota)!=1 or ota[0]['capacity_bytes']<8192:raise ValueError('otadata requires at least two4KiB sectors')
 model=next((p for p in parts if p['name']=='model' and p['type']==1),None)
 if model is None or model_bytes>model['capacity_bytes']:raise ValueError('model asset exceeds dedicated data partition')
 for p in slots:
  if app_bytes+reserve_bytes>p['capacity_bytes']:raise ValueError('app+declared reserve exceeds OTA slot '+p['name'])
 return {'app_image_bytes':app_bytes,'declared_reserve_bytes':reserve_bytes,'minimum_ota_capacity_bytes':min(p['capacity_bytes'] for p in slots),'minimum_ota_headroom_after_reserve_bytes':min(p['capacity_bytes'] for p in slots)-app_bytes-reserve_bytes,'model_asset_bytes':model_bytes,'model_capacity_bytes':model['capacity_bytes'],'model_headroom_bytes':model['capacity_bytes']-model_bytes,'scope':'file/partition byte budget only; no OTA download/switch/rollback, no model validity/RAM guarantee'}

def inspect(build,flash_bytes,flash_source,reserve,model_bin=None):
 build=pathlib.Path(build).resolve()
 if flash_bytes!=8388608:raise ValueError('this fixed N8R8 plan requires8388608 declared bytes; do not change the physical part by editing this number')
 description=json.loads((build/'project_description.json').read_text());flasher=json.loads((build/'flasher_args.json').read_text())
 app=(build/description['app_bin']).resolve();application=(build/flasher['app']['file']).resolve()
 if app!=application or int(flasher['app']['offset'],0)!=0x20000:raise ValueError('app metadata mismatch; do not pass a merged/full Flash image as an app')
 table=(build/flasher['partition-table']['file']).resolve()
 if int(flasher['partition-table']['offset'],0)!=0x8000:raise ValueError('this example supports table offset0x8000 only')
 parts=read_table(table,flash_bytes);model_bytes=pathlib.Path(model_bin).stat().st_size if model_bin else 0
 if app.read_bytes()[:1]!=b'\xe9':raise ValueError('not the expected app image header')
 result=assess(parts,app.stat().st_size,model_bytes,reserve)
 result.update(declared_flash_bytes=flash_bytes,flash_capacity_basis=flash_source,partitions=parts,app_path=str(app),app_sha256=hashlib.sha256(app.read_bytes()).hexdigest(),table_sha256=hashlib.sha256(table.read_bytes()).hexdigest(),outside_app_data_partition_bytes=flash_bytes-sum(p['capacity_bytes'] for p in parts),outside_partition_note='includes bootloader/table and alignment gaps; NOT all free or writable',model_path=str(model_bin) if model_bin else None)
 return result
if __name__=='__main__':
 p=argparse.ArgumentParser();p.add_argument('--build',required=True);p.add_argument('--flash-bytes',required=True,type=int);p.add_argument('--flash-source',choices=['planned-N8R8-not-measured','measured-by-user-esptool-flash-id'],required=True);p.add_argument('--reserve-bytes',type=int,default=65536);p.add_argument('--model-bin');a=p.parse_args()
 try:print(json.dumps(inspect(a.build,a.flash_bytes,a.flash_source,a.reserve_bytes,a.model_bin),indent=2))
 except (ValueError,KeyError,OSError) as e:p.exit(2,'BUDGET_REJECTED: '+str(e)+'\n')
做到这里,你应该看到
按README保存自己的原始日志和SHA;下载包的observations.csv实测列仍为空,不能用合成输入补填。

参考:[1] 固定SDK分区表格式、对齐与OTA布局[2] 固定SDK分区二进制与MD5生成器[3] Flash物理容量与初始化后可用容量API不同[4] 物理容量探测与可用size实现[5] 镜像头与Flash初始化容量规则[6] OTA API与运行/候选槽含义[7] 官方esptool release-v4:read_flash/write_flash/verify_flash[8] 固定官方DevKitC-1 v1.1:N8R8、双USB接口、GPIO与供电

BACK TO THE SOURCE

资料来源与版本说明

保留原计划E09用途与slug;固定上游、实际本机构建和适用的逻辑检查独立留证。未补造无线、采样、屏幕、真实Flash或掉电结果。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. 固定SDK分区表格式、对齐与OTA布局
  2. 固定SDK分区二进制与MD5生成器
  3. Flash物理容量与初始化后可用容量API不同
  4. 物理容量探测与可用size实现
  5. 镜像头与Flash初始化容量规则
  6. OTA API与运行/候选槽含义
  7. 官方esptool release-v4:read_flash/write_flash/verify_flash
  8. 固定官方DevKitC-1 v1.1:N8R8、双USB接口、GPIO与供电