Files
ESP32S3_vt/FLASH-CLIENT-ARCH.md
T
sloray 896b21fe44
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s
feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形
- feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退
- feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页
- feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路
- feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码
- feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔
- feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写
- feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容
- feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因
- feat(firmware): 固件包版本与客户端同源,都从 version.json 读
- fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载
- fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页
- fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系
- fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失
- fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径
- fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录
- build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO
- build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-*
- docs: 补充界面语言、版本机制与协议通告布局的说明
2026-10-19 02:40:08 +08:00

33 KiB
Raw Blame History

烧录 / 维护客户端架构设计(ESP32-S3 图传板)

状态:提议中(Proposed),等待第 9 节的问题清单答复后冻结。 输入约束:现场存在 SPI Flash 坏块;需要兼顾 OTA 与现场排障;不改固件源码、不重新编译; 优先“模板固件 + 动态 patch”。 本文所有结论都在仓库里逐条对过代码/脚本/分区表/文档,引用位置见各节链接。


0. 结论摘要(先看这五条)

  1. “不改固件源码”可以做到;但“真 OTA”做不到。 当前固件没有 ota_0/ota_1/otadata 分区, 没有 esp_ota 调用,UDP 协议里也没有任何命令通道(只有 Hello/Subscribe/视频/血量)。 设备自己没有能力接收固件。所谓“兼顾 OTA”,在现约束下只能是现场 USB 串口更新, 或者先落一个 OTA-就绪的分区表(只改二进制,不改源码)作为未来选项。这是必须先决的问题。
  2. 坏块规避其实根本不需要重编译。 scripts/flash.sh 之所以要重编,是因为它把新偏移写回 partitions.csv(构建输入)再触发重建;而 App 镜像不包含自己的分区偏移,偏移只存在于 0x8000 的 partition-table.bin(0xC00 字节 + 末尾 16 字节 MD5)。 → 对同一份已构建镜像,搜索偏移 + 重写分区表 + 原样写 App,全程零编译。 这是本方案最大的收益点,也是“模板 + patch”的第一块拼图。
  3. SSID/密码不是 NVS,是 .rodata 里的字符串字面量(env! 编译期注入)。 二进制里能直接 grep 到明文。所以“动态改 SSID”= 字节级 patch App 镜像, 且必须连带处理字符串长度常量与镜像 checksum/SHA256。 长度常量能否可靠定位是这个方案真正的技术风险(见 7.2)。
  4. 坏块信息不能“读”出来,只能“写进去再读回来”差分出来。 flash_id 只给芯片型号与容量, SFDP 只给参数,启动日志只给症状。仓库里 0x10863 的 bit6 挂在 1,就是整段回读比对发现的 (HARDWARE.md)。因此客户端必须自带「写入图案 → 回读 → 差分」与「写后整段校验差分」两条链路。
  5. 推荐形态:纯逻辑核心库 flash-core + 优先 CLI + 桌面端加一页。 把最危险的逻辑(镜像格式、分区表、偏移搜索、补丁)做成不碰 I/O 的纯函数, 在主机上用真实 1.1 MB 镜像做往返测试——这与仓库已有的 protocol/、stream_control、 link_control「能在主机跑测试的策略代码」风格一致。

1. 现状梳理(对着代码说)

1.1 工程与构建/烧录链

路径 角色 与本次需求的关系
src/ S3 图传固件(根 Cargo 包,xtensa) 烧录对象
partitions.csv 自定义分区表源文件 坏字节规避的构建输入(现状),本方案改为产物
scripts/build.ps1 / scripts/build.sh 读 .env → cargo build 现场不想跑
scripts/flash.sh build → 搜偏移 → 改 csv → 重建 → 校验 → esptool 写 只支持 macOS,必须重编
desktop/ Tauri v2 + React 客户端(视频/血量/录制) 完全没有串口/烧录能力(全仓库 `ssid
target/tools/espflash/espflash.exe 已落地的 espflash 4.4.0 现成的协议实现,可直接当库或 sidecar
target/firmware/ 已导出的 bootloader / partition-table / app / merged 真实样本,用于离线验收测试

产物三元组(README「编译固件」表)是 bootloader + partition-table + app, 今天现场要么用 ELF(espflash flash ... --partition-table partitions.csv), 要么用 merged(从 0x0 全片写)。这两条路都没有坏字节规避能力。

1.2 固件侧:配置是编译期的,运行期没有任何入口

// src/main.rs:36-37
const SSID: &str = env!("WIFI_SSID");
const PASSWORD: &str = env!("WIFI_PASS");
  • 每次启动都会用编译期值覆盖驱动配置:wifi.set_configuration(&client_config(auth)?) (src/main.rs:502)。所以 NVS 里即使存着旧配置也不会被采用。
  • EspDefaultNvsPartition 只是被交给 EspWifi::new(src/main.rs:178), 用于驱动自身的存储,不参与凭据解析。
  • 对比:armor/armor-firmware/src/settings.rs 的 C3 装甲板 有 NVS 设置层(队伍/颜色)——说明“运行期配置”在本仓库是被认可的,只是 S3 固件没做。
  • 构建脚本强制要求凭据(scripts/build.ps1:40-43), .env 里是 WIFI_SSID='SR-602-2G4'(10 字节)、WIFI_PASS=12345678(8 字节)。
  • 已确认:该字面量就在构建产物里(对 target/firmware/esp32s3-app.bin 检索 SR-602-2G4 有命中)。

1.3 OTA:三条路全断

OTA 必要条件 现状 结论
ota_0/ota_1/otadata 分区 只有 factory(partitions.csv:16-18) 可用重写分区表补上(不改源码)
固件内有 esp_ota_* 接收端 无(全仓库无 esp_ota) 必须改源码,与本约束冲突
设备可达的控制/数据传输通道 UDP 只有 Hello/Subscribe/视频/血量(protocol/README.md 无线格式里无命令字) 必须改源码

→ 结论:“禁止重新编译” ⇒ 真 OTA 不可行。这不是实现难度问题,是固件里没有任何代码能把 新镜像写进 flash。

1.4 坏 Flash 的真实现状(两个模组,两种缺陷)

模组 证据 缺陷
健康板(MAC 90:70:69:f8:f2:14) budget40-verify-all.log:App @ 0x10000,verify OK 无
故障板(开发板) HARDWARE.md:59-67 0xFADE7 bit5–7 不可写(经验判据:data[addr] & 0xE0 == 0);0x10863 bit6 读回为 1(期望 0x2e 实读 0x6e)

关键推论(对架构有决定性影响):

  • 0x10863 位于未分配空洞 0x10000–0x70000(phy_init 结束到 factory 开始之间, 380 KB 全是空闲 flash)。→ 这块空洞就是天然的坏块探测试验场,写坏也不要紧。
  • 0xFADE7 落在 App 镜像的代码段内(镜像布局见 1.5),所以“把那个字节改成别的安全值” 会改坏指令;唯一安全的动作是挪分区。
  • 现行偏移已经漂过至少三次:0x10000 → 0x50000(LATENCY.md:88)→ 0x70000 (当前 partitions.csv)。每次重编都要重新核对,这正是要消灭的脆弱点。
  • 缺陷有两种方向相反的形态:卡在 0(写不进 1)与卡在 1(写不进 0)。后者在“擦除后读回 0xFF” 的检查里是隐形的,必须写 0 才暴露。探测逻辑必须两种都打图案。

1.5 已实测出来的镜像布局(fps-final-raw-serial.log 逐段对齐算出)

0x000000  esp_image_header_t (24B)          magic E9, segment_count, entry_addr, hash_appended
0x000018  segment 0 header (8B) → load 0x3c0d0020   ← DROM / .rodata(SSID 字面量在这里)
0x000020  segment 0 data (0x2a2a8)          开头 256B 是 esp_app_desc_t(version/project_name/idf_ver…)
0x02a2d0  segment 1 (0x05d48) → 0x3fc9e500  DRAM
0x030020  segment 2 (0xc60c0) → 0x42000020  IROM 代码(0xFADE7 落在这里)
0x0f60e8  segment 3 (0x021c0)
0x0f82b0  segment 4 (0x0163f8)
0x10e6b0  segment 5 (0x00020)
          → 段尾 16B 对齐填充 + 1B checksum(XOR, 种子 0xEF) + 16B 对齐 + 32B SHA256
0x10e700  = 1,107,712 B(与 budget40-verify-all.log 的 0x10e700 一致)

几个直接可用的副产品:

  • esp_app_desc_t 在 App 分区 + 0x20,内含 version / project_name / idf_ver / 编译时间。 不需要让板子跑起来,读 256 字节就能回答“这块板上到底是哪版固件”。
  • 分区表就是 0x8000 起的 0xC00 字节(verify 0xc00 @ 0x8000 佐证),末尾 16 字节 MD5。 改写偏移只要重算这 16 字节。

1.6 需求 vs 现状 gap

需求 现状 Gap
插入即自动识别 无 串口枚举 + ROM 探测 + 读镜像描述符
选文件烧录 只能用脚本 + 手敲端口 文件分类(bundle / merged / app / ELF)+ 计划预览
自动跳过坏块 半自动:改 csv + 重编 + 手工核对 偏移搜索 + 分区表重写 + 写后差分
动态改 SSID 改 .env + 重编 + 重烧 镜像内字面量 patch(含长度常量与哈希)
OTA 无 不可行(见 1.3)
现场排障 串口日志靠人看 结构化阈值断言 + 回读差分 + 设备档案/回滚

2. 三个方案对比

方案 A:桌面端内嵌烧录模块(Tauri 单进程)

在 desktop/ 里加 flash 页面,Rust 侧用 espflash 库直接开串口。

  • ✅ 现场只有一个程序;已有 NSIS 打包、CI、配置与密码闸门可复用。
  • ✅ 视频端本来就要连板子,烧完顺手进监控很自然。
  • ❌ WebView 里跑长任务 + 进度 + 日志尾随,需要额外的事件/取消/背压设计。
  • ❌ 烧录是系统级、不可中断、会变砖风险的操作,和“看画面”共享一个进程的失败域。
  • ❌ 无法在 CI / 无人值守 / 远程 SSH 场景复用。

方案 B(推荐):纯逻辑核心库 + 双前端(CLI 优先,GUI 复用)

flash/core(无 I/O,纯函数,主机可测)+ flashctl CLI + 桌面端后续加一页调用 Tauri 命令。

  • ✅ 最容易错的部分(镜像格式、checksum/SHA256、分区表 MD5、偏移搜索、补丁)全部可在主机测, 且可以用仓库里真实的 1.1 MB 镜像做往返验收。
  • ✅ CLI 先天可脚本化:CI 能跑、现场能远程跑、能出确定退出码与机器可读报告。
  • ✅ GUI 后置,做完 CLI 后界面只是薄壳;失败域分开(GUI 崩了不影响烧录工具)。
  • ❌ 多一个二进制要分发(可由桌面端作为 sidecar 一起打包解决)。
  • ❌ 现场非技术用户需要 GUI(用 P2 的 Tauri 页面补上)。

方案 C:外部进程编排(客户端只做壳,调 espflash.exe / esptool.py)

  • ✅ 落地最快,协议风险最低;target/tools/espflash/espflash.exe 已经在了。
  • ✅ 与现有 scripts/*.sh 心智一致。
  • ❌ 拿不到回读差分(espflash 内部校验只给 pass/fail,不给字节列表)——而差分是本方案 坏块检测的核心,只能再调一次 read-flash 自己比。
  • ❌ 解析人类可读输出、处理 esptool.py 的 Python 依赖,现场极易碎(现有 flash.sh 就是被 “macOS 串口名 + termios + 本地 IDF venv”绑死的)。
  • ❌ 补丁/分区表重写仍要自己写,等于 B 的核心工作一样不少,只省了协议层。

决策矩阵(权重按当前项目处境给)

维度 权重 A 内嵌 B 核心库+双前端 C 外部编排
可测试性(本方案最需要) 30% 6 9 4
坏块检测能力(要回读差分) 25% 8 9 5
现场可用性 20% 9 7 6
线上/CI 可自动化 15% 3 9 6
交付成本 10% 6 6 9
加权 6.45 8.10 5.65

推荐 B,并明确:GUI 不是不做,而是放到 P2——先用 CLI 把危险逻辑做对、做实测。


3. 推荐架构

3.1 分层(依赖方向单向,内层不认识外层的存在)

┌─────────────────────────────── 前端(可替换) ───────────────────────────────┐
│  flashctl CLI(P0/P1)              desktop Tauri 命令 + 「维护」页(P2)      │
└───────────────┬────────────────────────────────────┬────────────────────────┘
                │                                    │
┌───────────────▼────────────────────────────────────▼────────────────────────┐
│ flash-device —— I/O 适配层(唯一碰硬件的地方)                                 │
│  · 串口枚举/VID-PID 过滤   · espflash Connection/Flasher 包装   · 回读        │
│  · 监视器(日志尾随 + 断言)· 自动复位/进下载模式策略                          │
└───────────────────────────────┬──────────────────────────────────────────────┘
                                │  只传「字节 + 计划 + 观察值」
┌───────────────────────────────▼──────────────────────────────────────────────┐
│ flash-core —— 纯逻辑(无 I/O、无 serde-io、可在主机完整测试)                  │
│  image │ parttable │ placement │ badmap │ manifest │ patch │ verify │ session │
└──────────────────────────────────────────────────────────────────────────────┘

建议目录(沿用本仓库“每个目标一套 workspace”的既有做法):

flash/
  core/          crate flash-core   lib,纯函数 + 单测(含对真实镜像的往返测试)
  cli/           crate flashctl     bin,含 flash-device 适配(串口/espflash)
  templates/     模板生产脚本:从 ELF 生成 manifest(开发期一次性,不进现场)

desktop/src-tauri 之后以 path 依赖引 flash-core + flash/cli 的适配层。

3.2 模块职责

模块 职责 关键不变量 / 失败模式
image 解析 ESP-IDF App 镜像:header、段表、esp_app_desc_t、checksum 位置、SHA256 位置;重算哈希;打字节补丁 往返测试:对未修改镜像重算出的 checksum/SHA256 必须与文件里已有的逐字节相同
parttable 解析/序列化 0xC00 分区表;改某条的 offset;重算 MD5 输出必须能被 esp-idf 的 esp_partition 规则接受(4 KiB 对齐、不重叠、不越界)
placement 给定(镜像字节, 坏点表, 约束)枚举合法 App 偏移,并给出每个候选被拒的原因 纯函数;同一输入必须给同一结果;找不到时必须说清是哪条约束卡住
badmap 缺陷模型:{addr, stuck0_mask, stuck1_mask, sector, confidence};提供 byte_storable(addr, value) 两向谓词:(v & stuck0)==0 && (v & stuck1)==stuck1
manifest 模板清单:镜像哈希绑定、补丁点(偏移/容量/长度字位置)、能力标志 清单与镜像哈希不匹配 → 拒绝,不做“尽力而为”
patch 应用凭据补丁(字符串 + 长度字)+ 哈希重算,输出变更日志 每次补丁都产出「改了什么、改在哪、前后值」,写进报告
verify 期望字节 vs 回读字节 → 缺陷列表(地址、期望、实读、推导出的 stuck mask) 差分必须能表达“读出比期望多 1 位/少 1 位”
session 状态机:Enumerate → Identify → LoadBundle → Plan → Patch → Write → Verify → BootCheck → Report 每步可 dry-run;任一步失败可回滚到写前备份
report FlashReport(本次)与 DeviceProfile(按 MAC 累积的坏点表/上次好镜像/历史) 档案只作提示,最终决策永远以本次回读为准

3.3 数据模型(三份 JSON,人可读、可 diff、可回滚)

// manifest.json —— 与模板镜像一起发布,模板生产线生成
{
  "schema": 1,
  "app_sha256": "…",              // 本清单只对这一份 app.bin 有效
  "chip": "esp32s3",
  "image": { "size": 1107712, "segments": 6, "hash_appended": true },
  "patch_points": [
    { "id": "ssid",     "kind": "utf8", "str_offset": 123456, "capacity": 10,
      "len_words": [{"offset": 210000, "width": 4}], "max_len": 32 },
    { "id": "password", "kind": "utf8", "str_offset": 123466, "capacity": 8,
      "len_words": [], "max_len": 64 }
  ],
  "capabilities": { "equal_length_only": ["password"] }
}

// device-<mac>.json —— 主机侧设备档案(累积)
{ "mac": "…", "chip": "esp32s3", "flash_jedec": "0x…", "flash_size": 16777216,
  "defects": [ {"addr": 1027047, "stuck0": 224, "stuck1": 0, "confidence": "verified"} ],
  "last_good": { "bundle": "…", "app_offset": "0x50000", "at": "…" },
  "history": [ { "at": "…", "app_offset": "0x70000", "result": "ok" } ] }

// report-<timestamp>.json —— 每次烧录一份,可交给现场/复核
{ "device": "…", "bundle": "…", "plan": { "app_offset": "0x70000", "patches": ["ssid"], "erase": [...] },
  "write": { "ok": true }, "verify": { "mismatches": [] },
  "boot": { "signature": "Loaded app from partition at offset 0x70000", "ip": "…" } }

3.4 状态机(每步都能停、都能讲清为什么)

Enumerate ─▶ Identify ─▶ LoadBundle ─▶ Plan ──(人工确认)──▶ Patch ─▶ Write ─▶ Verify ─▶ BootCheck ─▶ Report
   │            │            │           │                    │        │         │          │
   │            │            │           └─ 打印全部计划:偏移/补丁/擦除范围/预计耗时
   │            │            └─ 分类:bundle | merged | app | ELF;类型必须**显示并由人确认**
   │            └─ 芯片型号、MAC、Flash JEDEC/容量、0x8000 分区表、App 描述符(离线读)
   └─ 端口枚举 + VID/PID 过滤;只有一个候选才自动选中

Plan 必须可 --dry-run 输出到 stdout(JSON),这是 CI 与现场复核的接缝。


4. 坏块屏蔽的落点(四层,各层归属明确)

4.1 检测(flash-device::probe + flash-core::verify)

手段 能发现什么 成本 用在哪
flash_id / JEDEC + SFDP 厂商、型号、容量、时序 —— 不含缺陷 毫秒 识别芯片、判断是否可疑批次
图案探测:向空洞 0x10000–0x70000 写 00 / 55 / AA / E0 / 1F 等图案并回读差 卡 0、卡 1、整扇区失效 380 KB × N 图案,约十几秒 首次接管一块板 / 用户点「深度体检」
写后整段回读差 本次实际没写进去的字节(权威) 1.1 MB 回读,秒级 每次烧录都做,不可选
串口日志诊断 症状:invalid header / checksum 失败 / rst 原因 秒级 板子已经在现场、只能看现象时

要点:

  • 图案探测必须双向:写 0 找卡 1,写 1 找卡 0。擦除态 0xFF 会让“卡 1”完全隐形—— 0x10863 就是整段回读才发现就是这个原因。
  • 探测区选空洞而不是 NVS:不要为了体检去毁 NVS。若怀疑 NVS 区有坏点,单独提供 “备份 NVS → 探测 → 还原”的危险模式,默认关闭。
  • probe 结束后必须 erase 干净,并复核空洞回到 0xFF,避免留下疑似数据。

4.2 决策(flash-core::placement,纯函数)

输入:image(bytes), badmap, {flash_size, align(min 4KiB, 首选 64KiB), min_offset=0x20000,
                            app_size=0x120000, 保留区表}
对每个候选 off(升序,先 64 KiB 网格,再 4 KiB 网格兜底):
    ① off 4 KiB 对齐,off ≥ min_offset,off + image.len() ≤ flash_size
    ② off ≥ 0x20000 且分区不与 0x10863 所在扇区重叠(等价于“不覆盖已坏空洞”)
    ③ 对每个“已证坏点 b”:若 off ≤ b < off+image.len(),要求 byte_storable(b, image[b-off])
    ④ 分区 [off, off+0x120000) 不与任何 bad sector 相交(注意:**镜像尾部之外的 slack 不算**)
输出:第一个满足全部条件的 off;若都没有,逐个候选打印“被 ③ 卡住,b=0xFADE7 需要 image 字节
      的 bit5..7 为 0,实际 0x6E”——这正是本仓库一贯的“失败要能解释”的风格。

三点必须写进设计:

  • 对齐放宽到 4 KiB:App 分区只要求 4 KiB 对齐,flash.sh 用 64 KiB 是自我限制。 候选从 ~230 个变成 ~3700 个,命中概率高一个数量级(单坏点 1/8,双坏点也能稳过)。
  • 分区大小 ≠ 镜像大小:0x120000 的分区里镜像只占 0x10E700,尾部 71 KiB 是空的。 坏扇区落在尾部 slack 里无害,别把它算成阻塞。
  • bootloader 与分区表固定偏移:0x0(0x5160) 与 0x8000(0xC00) 挪不了。 这里只能做字节级吸收:例如坏点落在分区表某条 label 字段里,就换一个 label 让那一位合法 (label 是自由文本);落在 magic/offset/size/MD5 上则该板报废。这条路径要显式实现并明确其边界。

4.3 执行(flash-core::parttable + image)

  • 改写 partition-table.bin 中 factory 的 offset(和必要时 size)→ 重算末尾 16 字节 MD5。
  • App 镜像原样写入新偏移——镜像里没有任何“我在哪个偏移”的信息,改位置不改变它。
  • 只有在“分区表/bootloader 必须字节吸收”这一层才动镜像,并且要重算 checksum/SHA256。

4.4 验证与回滚(session + report)

  • 写前:把 0x0–0x9000 + 分区表 + App 分区整段回读到 rescue-<mac>-<ts>.bin(约 1.2 MB)。 这既是回滚素材,也是一份“坏点快照”。
  • 写后:回读 → 差分 → 若有 mismatch,先更新 badmap,再重新规划,而不是简单重试。
  • 引导:抓串口,断言 Loaded app from partition at offset 0x<off> 与 ESP32 camera is live on <ip>;失败则按 rst 原因给出分类结论(镜像校验失败 / 找不到分区 / Wi-Fi 没连上 / 相机没起来)。写入成功 ≠ 能跑,这是仓库文档反复强调的点。

为什么这套不会变砖:ROM 下载模式永远在,只要不是 eFuse 层面的破坏(secure boot / flash encryption / 烧写保护),任何 App/分区表错误都只是“不启动”,可以用同一根 USB 线救回来。 前提是确认这些 eFuse 特性没开(见问题 Q6)。


5. 动态 patch 的落点

5.1 必须重算的两样东西(否则一定不开机)

App 镜像的完整性由 bootloader 校验:

checksum = 0xEF XOR (所有段的所有数据字节)      // 1 字节,位于段尾 16B 对齐之后
sha256   = SHA256(镜像中它之前的全部字节)        // 32 字节,仅当 header.hash_appended=1

任何字节补丁都会同时破坏这两者。flash-core::image 必须实现并在真实镜像上做往返测试 (重算结果与文件里已有的字节必须完全相同)——这是整个方案最便宜、最有效的验收手段, 在碰硬件之前就能证明实现是对的。

另外:esp_app_desc_t 里的 app_elf_sha256 是 ELF 的哈希,补丁后必然与内容不符。 它只用于 core dump 关联,没有校验作用,可以接受;但要在报告里写一句,避免以后有人拿它 当“镜像指纹”。真正的指纹用整镜像 SHA256。

5.2 SSID / 密码补丁的两种模式(这是本方案的真实技术风险)

事实基础:

  • const SSID: &str = env!("WIFI_SSID") 是编译期常量,长度也是编译期常量。
  • 字面量本身在 .rodata(segment 0,0x3c0d0020 附近),二进制里可 grep。
  • 但长度在机器码里可能以三种形式存在:.rodata 里的 (ptr,len) 静态对 (format_args! 走 trait object 时几乎必然产生一份)、movi 立即数、或两者混用。
模式 做法 前提 风险
M1 等长补丁 只改写字符串字节,长度不动 新值字节长度 == 模板槽位长度 低。仅当现场 SSID 长度固定时可用
M2 长度字补丁 改字节 + 改所有 (ptr,len) 长度字 模板生产期能定位全部长度字并验证 中高。混用立即数时会出现“日志显示新 SSID 但连接用的旧长度”

工程对策(三条,缺一不可):

  1. 模板生产线生成 manifest(flash/templates/):构建出 ELF 后,用符号表 + 字面量地址 反查所有引用,产出 str_offset / capacity / len_words[]。这是一次性、离线、可审计的活动, 不发生在现场。
  2. 清单里写清能力:capabilities.equal_length_only: ["password"] 之类。M2 只在 “长度字全部定位成功”时启用;否则清单退化为 M1,客户端在长度不匹配时拒绝并给出可执行的 建议(“本模板 SSID 槽位 10 字节,请把路由器 SSID 设为 10 字节,或使用 N 字节模板”)。
  3. 硬件验收一次,之后才允许现场用:模板发布前跑一遍「patch 成另一个等长 SSID → 烧 → 看日志 是否连上」。连不上就把该模板降级为 M1。这一步可以脚本化,属于发布门禁。

注意 format_args! 会把补丁后的 SSID 原样打进日志("{SSID}" was not in the scan results), 所以现场日志本身就能佐证补丁是否生效——这是免费的验收信号,也是排障的第一手材料。

5.3 模板固件的构建约定(一次性,发布期)

WIFI_SSID = 32 字节占位(留满槽位,便于 M2);WIFI_PASS = 64 字节占位
其余 .env 参数按交付基线冻结
产物:bootloader.bin + partition-table.template + app.bin + manifest.json  → 一个 bundle

这样现场只选 SSID/密码,不再触碰 .env、不再跑 cargo。


6. 关键风险

# 风险 影响 对策
R1 长度字定位不全,M2 补丁“半生效” 板子连不上 Wi-Fi,看起来像“密码错了” 清单声明能力 + 发布门禁实测 + 不匹配就拒绝(不猜)
R2 补丁后未重算 checksum/SHA256 不启动(可救,但现场中断) 真实镜像往返测试;写入前本地自校验
R3 偏移漂移:新镜像可能没有合法偏移 该板无法升级 4 KiB 对齐搜索 + 尾部 slack 检查 + 找不到时明确报“这块板的 flash 太坏”
R4 坏点表串板/过期(缺陷随温度、擦写次数变化) 写进去才发现 档案只作提示;每次写后必做回读差分
R5 坏点在 bootloader/分区表固定区 无法规避 仅 label 等自由字段可吸收;其余明确报废,别赌
R6 自动复位/进下载模式不可靠 “插入即烧”的第一步就失败 先实测;失败时降级为“按 BOOT 提示 + 重试”,并把步骤画在界面上
R7 多程序抢串口 / 抢 UDP 5600 现场打不开或烧不了 串口独占提示;烧录模式与视频模式互斥提示(沿用现有 startup-error.txt 风格)
R8 secure boot / flash encryption / eFuse 保护开启 整套方案失效(镜像是加密/签名的) 上线前确认关闭;客户端探测到加密镜像直接拒绝并说明
R9 模板与清单版本错配 打到错误偏移 清单绑定 app_sha256,不匹配即拒绝
R10 OTA 需求最终被判定为硬需求 架构返工 第 9 节 Q2 必须先答;先落 OTA-ready 分区表作为廉价期权
R11 esp_app_desc_t.app_elf_sha256 补丁后不符 误导后人 报告里注明;指纹改用整镜像 SHA256
R12 破坏 NVS(改分区布局或误擦) 丢绑定/校准,重连变慢 默认不擦 NVS;提供备份/还原;改布局前显式确认

7. 必须向用户确认的问题(决定架构是否成立)

# 问题 为什么现在必须答 我的默认建议
Q1 客户端宿主平台与技术栈?Windows-only 还是三平台?扩展 desktop/ 还是独立工具? 决定打包、串口栈、UI 形态 Rust 核心库 + CLI(P1)→ Tauri 页面(P2),Windows 优先
Q2 “OTA”到底指什么:(a) USB 现场更新;(b) 设备自己从网上拉;(c) 局域网推 (b)(c) 与“禁止改固件”直接互斥,架构会完全不同 先按 (a) 做;同时只改分区表落一个 OTA-ready 布局作期权
Q3 SSID 修改是否接受等长约束?现场 SSID 长度是否固定?是否出现过 ≥16 字节或含中文/空格? 决定 M1 够不够,还是必须做 M2 并承担风险 双模:M1 保底,M2 需模板验收通过才启用
Q4 是否允许写入图案做坏块探测(十几秒、会擦除未分配空洞)?坏板一共几块?坏点是否稳定复现? 决定检测深度与是否值得做 probe 首次接管一块板自动探测一次;之后只做写后差分
Q5 现有板子的自动复位/进下载模式可靠吗(是否必须手按 BOOT/EN)? 决定“插入即烧录”能不能成立 实测;不可靠则做“半自动 + 引导”
Q6 是否启用过 secure boot / flash encryption / eFuse 写保护? 开了则本方案整体失效 假设关闭,需你确认
Q7 现场更新时可否重写分区表与 NVS?丢了客户端绑定/设置能接受吗? 有些修复必须动布局 默认不碰 NVS;改布局前备份并确认
Q8 是否需要同一个客户端也支持 ESP32-C3 装甲板(armor/)? 影响芯片抽象与模板管理 预留 chip 抽象,P2 再接 C3
Q9 现场是否允许任意固件文件(用户挑一个 .bin/.elf),还是只允许“官方 bundle”? 决定 manifest 从哪来:bundle 自带 vs 客户端现场解析 ELF 两者都支持:任意文件走“无 patch 模式”(坏块规避仍然有效),bundle 才有 SSID patch
Q10 坏块规避失败(该板无合法偏移)时的产品行为:拒绝升级 / 允许“尽力而为” / 提示换模组? 决定失败语义 拒绝并给证据,不做尽力而为

8. 分期建议

阶段 内容 验收标准
P0(1 周) flash-core:image + parttable + 真实镜像往返测试;CLI 能“读懂一份 bundle + 打印计划(dry-run)” 未修改镜像的 checksum/SHA256 重算与原文件逐字节一致
P1(2 周) placement + verify + 串口写入 + 回读差分 + 引导断言;在健康板与故障板上各跑通一次 故障板零重编译完成升级;0xFADE7 落点字节被证明可写;日志断言通过
P2(2 周) manifest 生产线 + SSID/patch(M1 先上,M2 门禁通过再开)+ 设备档案 + 回滚 同一 bundle 换 SSID 后板子连上新 AP,无需任何编译
P3 desktop Tauri「维护」页(复用 P1/P2 的一切) 现场非技术用户可完成“插入 → 识别 → 选包 → 烧 → 看到连上”

附录 A:两条 ADR

ADR-001:坏字节规避从“重编译”改为“分区表重写”

  • 状态:提议中
  • 背景:0xFADE7 的 bit5–7 不可写;scripts/flash.sh 通过改 partitions.csv 并重建来避开它, 但重建会移动镜像内容,于是需要“第二次验证”,且只支持 macOS、必须带完整 IDF 工具链。
  • 决策:把 App 偏移从构建输入改成烧录期的计划值:搜索偏移 → 改写 partition-table.bin(重算 MD5)→ App 镜像原样写入。
  • 备选:① 继续重编译(现状);② 为每块板生成专用 merged 镜像;③ 用 otadata 之类间接重定向。
  • 影响:现场彻底摆脱工具链;同一份镜像可服务健康板与故障板;代价是客户端必须正确实现镜像与 分区表格式(用真实镜像的往返测试兜底)。

ADR-002:核心逻辑与 I/O 分离,CLI 先于 GUI

  • 状态:提议中
  • 背景:本方案里最容易错、错了最难在现场发现的,是镜像格式、哈希重算、偏移搜索与差分; 而这些恰好都能在主机上测。仓库已有 protocol/、stream_control、link_control 的先例。
  • 决策:flash-core 纯函数、无 I/O;flash-device 单独承担串口与协议;GUI 最后接。
  • 备选:直接写进 Tauri 后端(失败域大、难测);用 Python 脚本 (现有风格) 但无法复用 Rust 类型。
  • 影响:多一个 crate 与一条编译链;换来可在 CI 上守护的不变量,以及将来 GUI/CLI/远程三处复用。