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

9.5 KiB
Raw Blame History

目标

修复 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:
    static portMUX_TYPE s_hookSnapMux;  ///< 保护 hook 共享状态的多字段读快照(单核 = 禁中断)
    
  • 声明一个私有 reader 快照结构(把 hook 端多字段一次性拷出来),供 maybeRecordHeapLowWater 使用:
    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 新增:
    static portMUX_TYPE s_hookMux = portMUX_INITIALIZER_UNLOCKED;
    
  • 新增 reader 辅助函数(任务上下文专用,禁中断 + 极短):
    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 状态的「确认消费」):
    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 + 临界区惯例一致)。