- 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: 补充界面语言、版本机制与协议通告布局的说明
465 lines
33 KiB
Markdown
465 lines
33 KiB
Markdown
# 烧录 / 维护客户端架构设计(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/](src/) | S3 图传固件(根 Cargo 包,xtensa) | 烧录对象 |
|
||
| [partitions.csv](partitions.csv) | 自定义分区表源文件 | 坏字节规避的**构建输入**(现状),本方案改为产物 |
|
||
| [scripts/build.ps1](scripts/build.ps1) / [scripts/build.sh](scripts/build.sh) | 读 `.env` → `cargo build` | 现场不想跑 |
|
||
| [scripts/flash.sh](scripts/flash.sh) | build → 搜偏移 → 改 csv → **重建** → 校验 → esptool 写 | 只支持 macOS,必须重编 |
|
||
| [desktop/](desktop/) | Tauri v2 + React 客户端(视频/血量/录制) | **完全没有串口/烧录能力**(全仓库 `ssid|serial|espflash` 只命中 serde) |
|
||
| [target/tools/espflash/espflash.exe](target/tools/espflash/espflash.exe) | 已落地的 espflash 4.4.0 | 现成的协议实现,可直接当库或 sidecar |
|
||
| [target/firmware/](target/firmware/) | 已导出的 bootloader / partition-table / app / merged | 真实样本,用于离线验收测试 |
|
||
|
||
产物三元组(README「编译固件」表)是 **bootloader + partition-table + app**,
|
||
今天现场要么用 ELF(`espflash flash ... --partition-table partitions.csv`),
|
||
要么用 merged(从 `0x0` 全片写)。这两条路都**没有**坏字节规避能力。
|
||
|
||
### 1.2 固件侧:配置是编译期的,运行期没有任何入口
|
||
|
||
```rust
|
||
// 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](src/main.rs#L502))。所以 **NVS 里即使存着旧配置也不会被采用**。
|
||
- `EspDefaultNvsPartition` 只是被交给 `EspWifi::new`([src/main.rs:178](src/main.rs#L178)),
|
||
用于驱动自身的存储,不参与凭据解析。
|
||
- 对比:[armor/armor-firmware/src/settings.rs](armor/armor-firmware/src/settings.rs) 的 C3 装甲板
|
||
**有** NVS 设置层(队伍/颜色)——说明“运行期配置”在本仓库是被认可的,只是 S3 固件没做。
|
||
- 构建脚本强制要求凭据([scripts/build.ps1:40-43](scripts/build.ps1#L40-L43)),
|
||
`.env` 里是 `WIFI_SSID='SR-602-2G4'`(10 字节)、`WIFI_PASS=12345678`(8 字节)。
|
||
- 已确认:该字面量就在构建产物里(对 [target/firmware/esp32s3-app.bin](target/firmware/esp32s3-app.bin)
|
||
检索 `SR-602-2G4` 有命中)。
|
||
|
||
### 1.3 OTA:三条路全断
|
||
|
||
| OTA 必要条件 | 现状 | 结论 |
|
||
| --- | --- | --- |
|
||
| `ota_0/ota_1/otadata` 分区 | 只有 `factory`([partitions.csv:16-18](partitions.csv#L16-L18)) | 可用**重写分区表**补上(不改源码) |
|
||
| 固件内有 `esp_ota_*` 接收端 | 无(全仓库无 `esp_ota`) | **必须改源码**,与本约束冲突 |
|
||
| 设备可达的控制/数据传输通道 | UDP 只有 Hello/Subscribe/视频/血量([protocol/README.md](protocol/README.md) 无线格式里无命令字) | **必须改源码** |
|
||
|
||
→ 结论:**“禁止重新编译” ⇒ 真 OTA 不可行**。这不是实现难度问题,是固件里没有任何代码能把
|
||
新镜像写进 flash。
|
||
|
||
### 1.4 坏 Flash 的真实现状(两个模组,两种缺陷)
|
||
|
||
| 模组 | 证据 | 缺陷 |
|
||
| --- | --- | --- |
|
||
| 健康板(MAC `90:70:69:f8:f2:14`) | [budget40-verify-all.log](target/camera-diagnostics/budget40-verify-all.log):App @ `0x10000`,`verify OK` | 无 |
|
||
| 故障板(开发板) | [HARDWARE.md:59-67](HARDWARE.md#L59-L67) | `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](LATENCY.md#L88))→ `0x70000`
|
||
(当前 [partitions.csv](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/](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、可回滚)
|
||
|
||
```jsonc
|
||
// 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 模板固件的构建约定(一次性,发布期)
|
||
|
||
```text
|
||
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/](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/](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/远程三处复用。
|