Files
ESP32_COMM/.zcode/plans/plan-sess_47b2261d-443a-4b5e-9210-6f34785f6d33.md
T
小龙虾 770ae14156
SPI Link v2 Self-Test / selftest (push) Has been cancelled
feat(spi_link): 添加SPI Link v2 PC自测框架并优化协议稳定性
添加SPI Link v2的PC端自测功能,包含交叉校验基准测试,覆盖HELLO/OTA/CAN/PING等
多种协议场景。同时重构了health monitor中的多字段读取逻辑,消除了volatile变量
的撕裂读问题,并增强了BLE OTA的数据传输可靠性。

refactor(health_monitor): 修复heap hook多字段撕裂读问题

采用portMUX_CRITICAL临界区实现多字段一致快照读取,消除任务上下文与heap hook
之间的数据竞争。修改了HookSnap结构体设计,通过一次性拷贝volatile变量避免
跨字段组合时的数据不一致。JSON输出中明确标注estimated值的置信度。

fix(ble_ota): 实现DATA_IN丢包负反馈机制

当malloc失败或队列满时发送缺口ACK通知前端按completed_bytes续传,避免
静默丢包导致的8秒超时重传风暴。增加节流机制防止负ACK在重传叠加时打爆
NimBLE mbuf池,提升低内存环境下的OTA传输稳定性。

refactor(spi_master): 从堆分配改为固定Job池管理

将SPI worker的动态堆分配Job改为固定大小的静态池管理,避免频繁的
calloc/free操作影响实时性。新增acquireJobSlot/releaseJobSlot机制
管理池槽生命周期,提升SPI通信的可靠性和内存使用效率。
2026-07-21 01:16:43 +08:00

116 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 目标
修复 heap alloc hook 与 health_mon 任务之间共享诊断状态的「无原子采样协议」问题:hook 写多个 volatile 字段组成的「最后一击」快照,reader(`maybeRecordHeapLowWater`)在 FreeRTOS mutex 下读取——但 hook 不获取该 mutex,因此 mutex 不提供与 hook 写的互斥,多字段快照可被撕裂(`hook_fired=1` 配上上一次的 `hook_alloc_size`/`caller`/`free_after`)。同时 `g_est_free` 是只减不增的近似值,命名上未与精确实测区分。
## 现状(已确认)
- ESP32-C3 **单核** RISC-V(`CONFIG_FREERTOS_UNICORE=y`),无 SPIRAM,`CONFIG_HEAP_USE_HOOKS=y`。
- hook (`esp_heap_trace_alloc_hook`, `health_monitor.cpp:77-115`) 在 ESP-IDF heap 分配器**自带的临界区内**被调用(heap spinlock 已禁中断),且可能从 IRAM/cache-off 上下文进入 → **禁止 FreeRTOS/flash 调用**,禁止在 hook 内再加锁。
- hook 写入的共享状态(`health_monitor.cpp:50-68`):
- `g_est_free`(近似空闲,只减不增,周期 resync)
- 「最后一击」快照:`g_hook_min_est` / `g_hook_alloc_size` / `g_hook_caller_addr` / `g_hook_free_after` / `g_hook_fired`
- 环形 `g_recent_allocs[4]` + `g_recent_alloc_w` / `g_recent_alloc_n`
- reader 路径:`maybeRecordHeapLowWater()` 在 `LockGuard lock(m_mutex)`(FreeRTOS mutex)内读取上述所有字段(`689-704`)。**`m_mutex` 不能阻断 hook**(hook 不获取它,且 hook 可能在中断禁用上下文)→ 撕裂读。
- reader 还有 `dumpHeapLowWaterJson()`(httpd 任务)读 `m_heapLowWater`:这条路径已由 `m_mutex` 正确保护(writer 也是 `maybeRecordHeapLowWater` 在同一 `m_mutex` 下写 `m_heapLowWater`),无撕裂问题,**不需改**。
- 现有同类模式:`CpuStats::getSnapshot()`(`cpu_stats.cpp:67-69`)用 `portENTER_CRITICAL(&m_mux)` 做多字段快照,**reader-only 临界区**,与 writer(也是任务上下文)互斥。本库无 seqlock / std::atomic / memory fence,统一依赖单核字宽原子性 + 临界区。
## 设计:reader-only `portENTER_CRITICAL` 快照(与 CpuStats 一致)
### 关键约束分析
- 单核 + hook 已在 heap 临界区内(禁中断)→ reader 用 `portENTER_CRITICAL` 禁中断即可与 hook 互斥:reader 持有临界区期间,任何 `malloc`(从而 hook)无法打断(因为 malloc 需取 heap spinlock,而 heap spinlock 也禁中断;但更直接:reader 禁中断后,hook 所在的 malloc 调用路径在 reader 释放前无法进入——单核上禁中断即阻止所有任务/ISR 抢占)。
- reader 在临界区内**只做 volatile 拷贝,不分配、不调任何 FreeRTOS/flash 函数**,保持极短热路径(与 hook 约束对称)。
- hook 端**不动**:不加锁、不改逻辑,保持 IRAM 安全。hook 写多个字段天然在 heap 临界区内 = 对 reader 而言是「原子写」(reader 禁中断期间 hook 跑不了;hook 跑时 reader 也进不来)。
### 实施步骤
#### 1. `health_monitor.h` — 新增 portMUX 与 reader 快照结构
- 在 `HealthMonitor` private 区新增静态 spinlock:
```cpp
static portMUX_TYPE s_hookSnapMux; ///< 保护 hook 共享状态的多字段读快照(单核 = 禁中断)
```
- 声明一个私有 reader 快照结构(把 hook 端多字段一次性拷出来),供 `maybeRecordHeapLowWater` 使用:
```cpp
struct HookLastHitSnap {
uint32_t alloc_size;
uint32_t caller_addr;
uint32_t free_after;
uint32_t fired;
uint32_t est_free;
uint32_t min_est;
uint32_t recent_n;
RecentAllocSnap recent[kRecentAllocCap];
};
```
(`RecentAllocSnap` / `kRecentAllocCap` 当前在 .cpp 匿名 namespace,需提到头文件或在 .cpp 内做快照——**选择后者**:快照结构与 `s_hookSnapMux` 的定义都留在 .cpp 匿名 namespace,reader 在 .cpp 内取快照,不污染头文件。`portMUX_TYPE` 是 FreeRTOS 类型,头文件已 include `freertos/FreeRTOS.h`,但 spinlock 实例可放 .cpp 匿名 namespace。)
**修正**:`s_hookSnapMux` 放 .cpp 匿名 namespace(与现有 `g_hook_*` 同作用域),头文件零改动。快照结构也放 .cpp 匿名 namespace。
#### 2. `health_monitor.cpp` — reader 临界区快照
- 匿名 namespace 新增:
```cpp
static portMUX_TYPE s_hookMux = portMUX_INITIALIZER_UNLOCKED;
```
- 新增 reader 辅助函数(**任务上下文专用**,禁中断 + 极短):
```cpp
HookSnap captureHookSnapshot()
{
HookSnap s = {};
portENTER_CRITICAL(&s_hookMux);
s.alloc_size = g_hook_alloc_size;
s.caller_addr = g_hook_caller_addr;
s.free_after = g_hook_free_after;
s.fired = g_hook_fired;
s.est_free = g_est_free;
s.min_est = g_hook_min_est;
s.recent_n = g_recent_alloc_n;
for (uint32_t i = 0; i < s.recent_n && i < kRecentAllocCap; ++i) {
s.recent[i] = g_recent_allocs[i];
}
portEXIT_CRITICAL(&s_hookMux);
return s;
}
```
- 新增 writer 辅助(在 reader 临界区内做 hook 状态的「确认消费」):
```cpp
void consumeHookFired(uint32_t new_min_est)
{
// 必须在 portENTER_CRITICAL(&s_hookMux) 内调用
g_hook_min_est = new_min_est;
g_hook_fired = 0;
}
```
- 修改 `maybeRecordHeapLowWater` 的 hook 块(`688-730`):把 `LockGuard lock(m_mutex)` 内的裸 volatile 读 + `g_hook_min_est`/`g_hook_fired` 写,替换为:
1. 在 `m_mutex` 外、进入快照写入前,调用 `captureHookSnapshot()` 取一份一致快照(`HookSnap hs`)。
2. 用 `hs.*` 字段填充 `rec.hook_*` 与 `big_*` 搜索(不再裸读 `g_hook_*` / `g_recent_allocs`)。
3. 在 `m_mutex` 内写完 `rec` 后,再用一个 `portENTER_CRITICAL(&s_hookMux)` 块调用 `consumeHookFired(g_est_free)` 重置 `g_hook_min_est` / `g_hook_fired`(保持原语义:消费后把 min_est 抬到当前 est,fired 清零,避免同一窗口重复记)。
- 注意 `g_est_free` 在 reader 临界区外已被 `hookResyncEstFree(free_heap)` 更新过(`336`),`consumeHookFired` 读它时需在 `s_hookMux` 内读以保证与 hook 写一致——因此 `consumeHookFired` 内读 `g_est_free`。
#### 3. 命名区分 estimated vs measured + 置信度标注
- `HeapLowWaterRecord.hook_free_after`(`health_monitor.h:84`)注释已说明「est,非精确实测」。增强为在 JSON 输出与字段名上显式区分:
- JSON 字段 `hook_free_after` → 改名为 `hook_est_free_after`(明确 est);前端 schema 同步说明。
- 在 `dumpHeapLowWaterJson` 的字段输出中,紧随其后加一个 `hook_confidence` 字段:值为字符串 `"est_low_watermark"`(hook 命中时)或 `"none"`(未命中),明确告知前端这是近似值、非精确实测。
- `health_monitor.h` 注释补充:`hook_free_after`(保留成员名以减小 diff)注释行加「**置信度:est 低水位近似值**,误差 = resync 周期内 free 路径未记账量;前端应标注 estimated」。
- `g_est_free` 注释(`56` 行)补充:「**estimated**,非 measured;health_mon 周期用 `esp_get_free_heap_size()` 实测纠偏。误差上界 ≈ resync 周期(5s)内总 alloc 字节」。
- 在文件头 `46-48` 的「记录策略」注释块补一段「**采样协议**:reader(`maybeRecordHeapLowWater`)用 `portENTER_CRITICAL(&s_hookMux)` 禁中断取一致快照,与 hook(在 heap 临界区内写)互斥;hook 端不加锁、保持 IRAM 安全」。
#### 4. 文档化 hook 可调用 API / IRAM 约束
- 文件头 `36-48` 注释块已说明「禁止 FreeRTOS/flash」。补充明确契约条目:
```
// ★ hook 可调用约束(IRAM 安全契约):
// - 禁止:FreeRTOS API、flash/rodata 访问、mutex/semaphore、长函数。
// - 允许:DRAM volatile 标量读写、__builtin_return_address、纯算术。
// - 写共享状态天然原子:hook 已在 heap 临界区内(禁中断),reader 用 s_hookMux 互斥。
// - 嵌套分配:hook 内禁止任何可能触发 malloc 的调用(否则递归进 hook,armed 标志不防递归——
// 当前 hook 体内无分配调用,保持之)。
```
## 验收
- 编译通过(ESP-IDF build),无新 warning。
- 并发压力下(WiFi + OTA + CAN 并发)多次抓取 `/system/heap_low_log`:`hook_fired=1` 的记录中 `hook_alloc_size`/`hook_caller_addr`/`hook_est_free_after` 三者一致(来自同一次 alloc),不再出现「fired=1 但 size=上一次值」的撕裂。
- JSON 输出含 `hook_est_free_after` 与 `hook_confidence` 字段,前端可明确区分 estimated vs measured。
- hook 热路径长度不变(未加锁、未加函数调用),IRAM 安全契约保持。
- reader 临界区极短(仅 volatile 拷贝,~10 个字段 + ≤4 条 recent),不持 `m_mutex` 同时持 `s_hookMux`(`captureHookSnapshot` 在 `m_mutex` 外调用),避免锁序问题。
## 影响范围
- 仅改 `main/monitor/health_monitor.cpp`(hook 块、`maybeRecordHeapLowWater`、注释、新增匿名 namespace 辅助)与 `main/monitor/health_monitor.h`(`hook_free_after` 注释增强、JSON 字段名说明)。
- `dumpHeapLowWaterJson`:JSON 字段 `hook_free_after` → `hook_est_free_after`,新增 `hook_confidence`。前端(IndexedDB 累积端)需同步字段名——属可观测性 schema 变更,需告知前端。
- 不动 hook 函数体逻辑、不动 `m_mutex` 保护的 `m_heapLowWater` 写入路径、不动 ESP-IDF 构建配置。
- 不引入 seqlock / std::atomic(与库现有单核 volatile + 临界区惯例一致)。