
一块 ESP32 上同时跑好几个小应用这事听着不复杂做起来全是坑。我接过不止一个这样的项目一个固件里既要处理 Web 配网、又要周期存传感器数据、还要写运行日志旁边可能还挂着一个 ROS2 串口桥接小车节点更常见的是同一块 Flash 上放了两个业务固件靠 OTA 来回切换。头几次我都在同一个地方栽跟头——升级一次固件之前存的配置没了或者 A 模块清理数据把 B 模块的参数洗得干干净净。后来才明白问题根本不在代码而在对 ESP32 Flash 的分区管理。ESP32 上电后靠分区表决定 Flash 里每个地址区间归谁用、多大、能否擦写把多个小应用的数据和固件安排在同一块 Flash 里本质上就是做好这块地契的划分再配合代码层的边界检查。这篇文章会从 Flash 映射、分区表结构、实际布局计算、代码隔离一直讲到错误排查把一套可以直接抄作业的做法完整展开。适合正在做多业务固件、OTA存储或者调试阶段被数据窜扰折磨的朋友。1. 先从硬件层看起Flash 为什么能切着用1.1 一块 Flash 通过 SPI 进来先被映射成两块虚拟地址空间ESP32 的 Flash 并不是想读哪个地址就直接读的普通存储它本质上挂在 SPI 总线上。芯片内部有一个 Flash Controller 和 MMU内存管理单元负责把 SPI Flash 的内容映射到 CPU 可寻址的地址空间指令访问走 0x400D0000 附近的内嵌 Flash 映射区数据访问走 0x3F400000 附近。也就是说当你的代码直接访问一个指针时背后可能是 Cache 命中了 Flash 里的某段内容这让你可以像读普通内存一样去读 Flash甚至可以在某些条件下执行其中的代码。但这也带来一个隐藏约束MMU 的映射页大小是固定的通常 4KB 或 64KB所以 Flash 的布局必须对齐这些粒度不能想怎么切就怎么切。那分区是什么简单说就是在整块 Flash 的地址空间上人为切成若干连续段每段起一个名字、规定大小和类型。启动时 bootloader 从固定位置读回分区表把这些段的边界信息交给上层应用。之后你调用esp_partition_read、esp_partition_write这些接口时驱动层已经知道了你的分区边界读写和擦除都在边界内做合法性判断。所以数据会不会串门第一道防线就是这个分区表设计得对不对。1.2 默认那几块房型nvs、otadata、phy_init、app 都是干什么的用 ESP-IDF 创建一个新工程默认就带了一套分区表nvs、otadata、phy_init然后是一块factory或ota_0ota_1的 app 分区。用工具生成布局时你会发现nvs通常从 0x9000 开始大小 0x4000otadata在 0xd000大小 0x2000phy_init在 0xf000大小 0x1000真正的 app 固件从 0x10000 开始。这些分区各有各的用途nvs保存 Wi-Fi 校准、设备配置、证书等键值对掉电不丢otadata记录当前启动的 OTA 槽位phy_init存放射频校准数据app就是你的固件镜像。很多人自定义分区表时只想着给固件和数据腾地方随手把 nvs 删了或者把 phy_init 移到一个奇怪地址结果 WiFi 大概率连不上、射频性能变差或者反复重启。原因很简单系统在启动早期或者 WiFi 初始化时会主动访问这些分区你破坏了它的预期布局它就用不正常的方式回报你。所以第一个结论不管你有多少小应用系统保留分区的地址和大小建议尽量沿用默认布局尤其不要把phy_init放进可擦除的数据区也不要把otadata挪到 app 区域里。既然要共用 Flash地基先按默认来剩下的事情在 0x10000 之后安排。1.3 分区边界信息最终落在 0x8000 那 4KB 里补充一个容易忽略的点分区表本身也占了 Flash 空间。IDF 的默认配置是把分区表放在偏移 0x8000大小 0x10004KB。每个分区条目固定 32 字节加上末尾的 MD5 校验字段。bootloader 启动时先读这段内容校验 MD5然后才决定启动哪个 app。一旦 CSV 里地址写错、对齐不满足、或者某条目类型非法bootloader 可能直接拒绝启动具体表现就是串口反复打印类似ota_data[0] invalid的信息或者一直处于下载模式。到这里为什么 Flash 能切着用已经清楚了硬件层按页映射逻辑层靠分区表圈地应用层用 API 访问三层共同保证隔离。接下来看场景多个小应用到底该怎么分。2. 多个小应用共用的场景决定了你该怎么切2.1 一个固件里的多个小模块优先用 NVS 命名空间而不是硬拆分区先理清一个理解误区。很多人一听到多个小应用共用一块 Flash下意识就打算把 Flash 切成很多小块每个模块一个分区。但实际操作中如果你的多个小应用其实是同一份固件里的一堆任务模块那最大的数据隔离需求是配置而配置量通常不大。此时最优解往往是共用一个nvs分区但用不同的 namespace命名空间隔离。NVS 是一个带命名空间的键值存储。打开 NVS 的时候传入一个 namespace 字符串之后这个 namespace 下的键值就带上前缀写入分区即使两个模块使用完全相同的键名也不会互相覆盖。举个例子配网模块用wifi_cfg命名空间保存 SSID 和密码传感器模块用sensor_cfg命名空间保存校准系数日志模块用log_cfg保存上次日志位置三者都往同一个 NVS 分区写但读出来互不干扰。我见过有的项目为了隔离给每个模块各切了一个 16KB 的 NVS 分区配置量又很小纯属浪费 Flash 空间还增加了启动时打开分区的代码量。与其那样不如一个分区 多个 namespace 更清爽。但这里有个前提NVS 适合小数据、低频写。它的每个键值对开销不小约 32 字节起而且写入需要先经过磨损均衡逻辑总容量有限。如果某个模块要存几十 KB 的采集数据或者反复覆盖大块缓冲区NVS 就不是好去处那才应该考虑在分区表里给它单独划一块数据区。2.2 两个独立固件并存考虑要不要用标准 OTA 机制另一类更常见的多个小应用不是同一份代码而是两块独立的固件镜像同时躺在同一块 Flash 里比如一个做 ROS2 小车串口桥接、一个做 OTA 维护 Web 服务。这时你要在分区表里放两个甚至多个app类型分区并明确它们的启动关系。ESP-IDF 对app分区有严格限制起始地址必须按 64KB0x10000对齐类型可用factory、ota_0、ota_1等表示。factory是出厂默认分区ota_x是可通过 OTA 覆盖的槽位。需要来回切换两个小应用最稳妥的做法是给它们分别分配一个 OTA 槽位比如ota_0和ota_1然后用esp_ota_set_boot_partition()在运行时切换启动目标。OTAData 分区会记录当前启动到了哪个槽位bootloader 下次启动时照着记录走。为什么要强调用标准 OTA 机制而不是自己写个切换逻辑因为 OTA 机制背后有完整的镜像摘要、重启生效、失败回滚处置。你自己写切换逻辑时很容易漏掉写一半掉电这类情况镜像写到一半断电下次开机 bootloader 可能启到一个残缺的固件直接死循环。既然 ESP-IDF 已经把这套机制做好了直接用是最省心也最安全的。如果你确实有超过两个自定义固件槽的需求可以让typeapp的 subtype 从 0x10 开始自定义这在技术上可行但 bootloader 和 OTA 代码不一定完全支持。多数场景下两个标准 OTA 槽位已经足够覆盖主固件 维护固件的需求。2.3 大数据区、日志区、文件系统区按 data 类型继续细分还有一个高频需求同一个固件里除了小配置还要存历史数据、日志、用户上传的文件。如果把这些都塞进 NVS分区会很快写满如果全塞进一个文件系统分区那每次日志写入也会拖着文件系统索引一起磨损。在 ESP-IDF 里数据分区可以用data类型再配合不同的 subtypespiffs、fat、nvs、otadata等。日志这类高频率覆盖写的数据我更倾向用自定义type0x40的裸数据分区自己按环形缓冲方式管理。这样做的好处是擦写粒度可控整块 4KB 擦除不会被文件系统的元数据拖累坏区处理也更简单。用户文件则用 SPIFFS 或 LittleFS 分区随机访问方便目录结构直观。两个分区互不干扰日志分区写满时循环覆盖不会影响文件系统的目录项文件系统格式化或重建也不会危及日志区和 NVS 区。因此在动笔画分区表前先回答自己三个问题各小应用的数据是低频配置还是高频日志是固件镜像还是运行数据是需要目录结构还是简单读写回答完这三问分区类型基本就定了。3. 分区表实操CSV 字段、地址计算与烧录流程3.1 一个可以直接改的 CSV 模板与字段解释ESP-IDF 用 CSV 描述分区表每行一个分区。常用字段是name, type, subtype, offset, size, flags。这里给一个实测过的 4MB Flash 双 App 布局模板# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x1c0000, ota_0, app, ota_0, 0x1d0000, 0x1c0000, storage, data, spiffs, 0x390000, 0x70000,逐行解释nvs和phy_init沿用了默认布局保证系统功能正常otadata负责 OTA 槽位切换两个 App 分区各 0x1c00001.75MB剩余 0x70000448KB给文件系统。注意factory分区先存在之后如果你往ota_0里刷了一次正式镜像并设置了 OTA 启动后续 bootloader 就会以 otadata 的记录为准启动factory变成不常启动的落点。size的单位是字节的十六进制不是 KB。比如0x1c0000就是 1.75MB。Flash 的扇区最小擦除单位是 4KB所以所有offset和size都建议按 4KB 对齐app类型分区必须按 64KB 对齐否则编译时工具会直接报错或 bootloader 不认。offset留空也能让工具自动追加但我还是建议手写。只要手算一遍绝大多数地址冲突问题都能提前发现。3.2 地址计算实战4MB Flash 从 0x000000 排到 0x3FFFFF很多刚接触的人是拿到一个模板就行等到想加第三个分区时就开始犯愁地址会不会重叠可用空间够不够这里把整个计算过程拆开说一遍。先把总容量写出来4MB 0x400000 字节。前 64KB0x000000 到 0x00FFFF用来放 bootloader、分区表、nvs、otadata、phy_init然后所有 app 分区从 0x10000 开始排。分配两个 app 槽位各 0x1c0000第一个从 0x10000 开始结束于0x010000 0x1c0000 - 1 0x1cffff第二个从 0x1d0000 开始结束于0x1d0000 0x1c0000 - 1 0x38ffff。这样还剩 0x390000 到 0x3fffff 共 0x70000给它命名为storage用作文件系统。所有起止地址都满足 4KB 对齐app 的两个起始地址 0x10000 和 0x1d0000 也满足 64KB 对齐整个布局可以用工具生成分区表二进制。如果以后想增加第三个业务固件面对同样 4MB Flash 就必须缩减 app 槽位尺寸。比如三个 app 槽各 0x1200001.125MB从 0x10000 排到 0x037ffff留给数据区只剩 0x40000256KB。算完如果觉得不够用要么换更大 Flash 模块要么把部分数据挪到外挂 SPI Flash。这个计算没有捷径一定要自己拿笔算或写个小脚本算一遍不能靠肉眼估计。3.3 把配置烧进工程menuconfig、partitions.bin 与验证在 ESP-IDF 里要让自定义分区表生效先在工程根目录新建partitions.csv然后执行idf.py menuconfig在Partition Table里选择Custom partition table CSV并指定文件路径。编译时idf.py build会生成build/partition_table/partition-table.bin。烧录固件时用idf.py flash会把 bootloader、分区表和应用镜像按正确地址一次性写入。但这里有个很多新手会踩的坑只改 CSV 后直接idf.py flash有时候分区表并没有真正刷进 Flash因为烧录脚本认为你没改默认地址或者你只是在编译固件但 flash 命令没带分区表。稳妥做法是单独执行一次idf.py partition-table-flash把分区表烧到 0x8000。更省事的办法是在调试阶段直接idf.py erase-flash全片擦除再烧整套镜像。这能保证设备上的旧分区表和烧录工具内的新分区表完全一致。验证分区表是否烧对了可以读回来看esptool.py --port /dev/ttyUSB0 read_flash 0x8000 0x1000 partition_table.bin再配合 IDF 自带的gen_esp32part.py partition_table.bin把二进制解析成 CSV。在设备端也可以写一小段启动代码遍历esp_partition_find打印每个分区的 label、type、address、size确认设备拿到的分区边界和 CSV 里写的完全一致。4. 代码层的隔离与边界保护不写死地址不越界4.1 用 esp_partition 系列 API 代替裸 Flash 地址分区表只是静态的地契程序运行时能不能守住边界还得看代码怎么写。一个非常常见的坏习惯是直接在代码里写 Flash 地址常量// 千万别这么干 uint32_t addr 0x390000; spi_flash_write(addr, data, len); spi_flash_erase_sector(addr / 4096);这样写分区表一改或者某次 OTA 把分区布局调整了你的地址常量就会写到别人的地盘里去。正确做法是先按 label 找到分区句柄然后基于esp_partition_t做读写const esp_partition_t *part esp_partition_find_first( ESP_PARTITION_TYPE_DATA, 0x40, log_area); if (part NULL) { ESP_LOGE(main, log_area not found); return ESP_FAIL; } esp_partition_erase_range(part, 0, part-size); esp_partition_write(part, pos, buf, len); esp_partition_read(part, pos, out, len);esp_partition_find_first的第一个参数是类型第二个是 subtype第三个就是 CSV 里的 label。这三个条件组合基本不会认错分区。好处是无论分区表怎么调整只要 label 不变代码就不用动定义分区的人也都清楚这块空间属于日志而不是某个神秘的魔法数字0x390000。4.2 NVS 命名空间和独立分区的取舍回到第 2 章聊过的 NVS。如果你的多个小应用共享同一个 NVS 分区那代码里的隔离手段是命名空间。一个很实用的小技巧给每个模块统一用不同前缀的命名空间比如wifi_cfg、sensor_cfg再用nvs_open(namespace, NVS_READWRITE, handle)打开句柄后面所有读写都通过 handle 完成。即便两个 namespace 下面出现了同名的键也不会互相覆盖因为 NVS 存储时实际会用 namespace 做前缀。用nvs_get_blob/nvs_set_blob读写大块结构体数据时同样安全。什么情况需要给某个应用单独的 NVS 分区我的经验是当一个应用对配置可靠性要求特别高或者频繁擦写导致数据损坏而你希望这个应用的配置坏了不影响其他应用的时候。两个 NVS 分区在物理上是两块地址空间一个分区出现校验错误不会波及另一个。代价是每次初始化多调几次esp_partition_find_first而且 Flash 空间占用翻倍。如果没有这种强隔离需求一个 NVS 加命名空间就够用了。4.3 运行时越界最容易从哪里冒出来就算用了 esp_partition API也还是有几个常见越界来源值得留意。第一是擦除范围算错。esp_partition_erase_range(part, start, size)的size如果加起来超出了part-size接口会返回错误但错误提示有时候很不明显更好的做法是在写入前先判断start len part-size或者干脆每次调用都基于part-size做一次守卫。把守卫逻辑封装成一个公共函数所有小应用都走同一个入口能省掉不少排查时间。第二是文件系统与裸分区混用。同一个物理分区你既用 SPIFFS 挂载又用 esp_partition_write 直接写这两套逻辑会互相踩踏。文件系统有自己的索引和分配策略它不会知道你在哪个偏移写了原始数据。所以每个分区要么归文件系统管要么归裸读写管不要混。第三是 OTA 过程中新旧镜像的分区表不一致。升级固件 A 时如果新镜像里带了不同的分区表然后恰好又执行了分区表更新设备的 NVS 和文件系统偏移可能整体漂移表现就是升级后配置全丢日志区读到旧数据本质上已经是跨代串门。规避方法升级镜像和当前运行环境的 CSV 保持一致数据分区宁可单独管理也不要让固件通过 OTA 去改数据分区布局。5. 升级、日志、擦写串门的排查方法5.1 症状到原因到解法快速表我把实际项目里遇到过的问题整理成一张表排查时可以按图索骥症状典型原因解决方向升级固件后 Wi-Fi 配置丢失新固件分区表与旧固件不一致NVS 偏移变化固定 CSV升级前不改变系统分区布局某模块清数据另一模块配置被冲掉擦除范围越界或裸地址直接写到了他人分区改用 esp_partition API封装统一守卫生设备反复重启刷不进固件分区表 MD5 校验失败或 app 起始地址未按 64KB 对齐检查 CSVidf.py erase-flash后重烧日志区写满后系统卡顿或配置没了日志分区与 NVS/文件系统共用空间或太小独立环形日志分区定期覆盖不碰配置区OTA 后启到空白固件镜像写入一半掉电otadata 状态被破坏使用 OTA 官方接口保留工厂分区做回滚5.2 设备端实际排查动作当我在现场排查数据串门问题时通常会按三条路径走。第一步在板子上跑一段分区画像代码启动时遍历esp_partition_find(NULL, NULL, NULL, NULL)把每个分区的address、size、label通过串口打印出来。这一步能快速确认设备上的实际布局是不是我预期的。很多所谓串门其实是从某个旧版本固件升级上来后Flash 上还残留旧分区表导致的打印出来一眼就能看清。第二步如果怀疑 NVS 串门直接把 NVS 分区整块读回来看键值。IDF 的parttool.py可以对nvs分区做读取也可以直接esptool.py read_flash导出后再用脚本解析。重点看两个 namespace 下是否有相同键名的残留以及键名是否带着意外的命名空间前缀。第三步复现擦写串门现场时我会在公共读写函数里临时加一行日志打印label、offset、len、part-size。如果日志里出现某次写入的offset len刚好等于另一个分区起始地址那问题基本就锁定了——不是数据本身错而是业务逻辑把写入范围算错了。加日志排查比你去猜哪个模块动了 Flash 高效得多。5.3 控制复杂度分区管理的几条长期经验最后把我在多次项目里沉淀下来的管理经验列一下不一定所有项目都适用但大概率能帮你少走弯路。分区表一旦定稿尽量当成接口契约冻结。每次改动都要同步所有固件端和烧录脚本不能只改一边。给每个数据分区预留至少 20% 余量。日志和文件系统写满后的行为比分区本身写满更折磨人。大数据优先放独立文件系统或裸分区不要硬塞 NVSNVS 是给人存配置的不是拿来当数据库的。多固件并存时优先用 OTA 官方机制不要手写切换逻辑手写逻辑的掉电场景会让你怀疑人生。调试阶段碰到奇怪问题先执行idf.py erase-flash再做完整烧录这能排除八成旧数据干扰新逻辑的问题。我在实际工作中感触最深的一点是分区表这类东西平时不出声一旦出错就是最难查的那类玄学。有一次排查了一个下午最后发现是升级脚本里把partition-table.bin的烧录地址写成了 0x10000刚好覆盖了第一个 app 分区的头部。所以不管你的小应用有多少先把地契画好、把分区表校验加进工程流程这比任何运行时的防御都有用。