- 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: 补充界面语言、版本机制与协议通告布局的说明
33 KiB
烧录 / 维护客户端架构设计(ESP32-S3 图传板)
状态:提议中(Proposed),等待第 9 节的问题清单答复后冻结。 输入约束:现场存在 SPI Flash 坏块;需要兼顾 OTA 与现场排障;不改固件源码、不重新编译; 优先“模板固件 + 动态 patch”。 本文所有结论都在仓库里逐条对过代码/脚本/分区表/文档,引用位置见各节链接。
0. 结论摘要(先看这五条)
- “不改固件源码”可以做到;但“真 OTA”做不到。 当前固件没有
ota_0/ota_1/otadata分区, 没有esp_ota调用,UDP 协议里也没有任何命令通道(只有 Hello/Subscribe/视频/血量)。 设备自己没有能力接收固件。所谓“兼顾 OTA”,在现约束下只能是现场 USB 串口更新, 或者先落一个 OTA-就绪的分区表(只改二进制,不改源码)作为未来选项。这是必须先决的问题。 - 坏块规避其实根本不需要重编译。
scripts/flash.sh之所以要重编,是因为它把新偏移写回partitions.csv(构建输入)再触发重建;而 App 镜像不包含自己的分区偏移,偏移只存在于0x8000的partition-table.bin(0xC00 字节 + 末尾 16 字节 MD5)。 → 对同一份已构建镜像,搜索偏移 + 重写分区表 + 原样写 App,全程零编译。 这是本方案最大的收益点,也是“模板 + patch”的第一块拼图。 - SSID/密码不是 NVS,是
.rodata里的字符串字面量(env!编译期注入)。 二进制里能直接 grep 到明文。所以“动态改 SSID”= 字节级 patch App 镜像, 且必须连带处理字符串长度常量与镜像 checksum/SHA256。 长度常量能否可靠定位是这个方案真正的技术风险(见 7.2)。 - 坏块信息不能“读”出来,只能“写进去再读回来”差分出来。
flash_id只给芯片型号与容量, SFDP 只给参数,启动日志只给症状。仓库里0x10863的 bit6 挂在 1,就是整段回读比对发现的 (HARDWARE.md)。因此客户端必须自带「写入图案 → 回读 → 差分」与「写后整段校验差分」两条链路。 - 推荐形态:纯逻辑核心库
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 但连接用的旧长度” |
工程对策(三条,缺一不可):
- 模板生产线生成 manifest(
flash/templates/):构建出 ELF 后,用符号表 + 字面量地址 反查所有引用,产出str_offset / capacity / len_words[]。这是一次性、离线、可审计的活动, 不发生在现场。 - 清单里写清能力:
capabilities.equal_length_only: ["password"]之类。M2 只在 “长度字全部定位成功”时启用;否则清单退化为 M1,客户端在长度不匹配时拒绝并给出可执行的 建议(“本模板 SSID 槽位 10 字节,请把路由器 SSID 设为 10 字节,或使用 N 字节模板”)。 - 硬件验收一次,之后才允许现场用:模板发布前跑一遍「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/远程三处复用。