feat(spi_link): 添加SPI Link v2 PC自测框架并优化协议稳定性
SPI Link v2 Self-Test / selftest (push) Has been cancelled
SPI Link v2 Self-Test / selftest (push) Has been cancelled
添加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通信的可靠性和内存使用效率。
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# SPI Link v2 PC 自测 CI 门禁
|
||||
#
|
||||
# 当 spi_link 协议实现变更时,在 PC 上编译运行 SPI_LINK_V2_SELFTEST 自测,
|
||||
# 自动阻断帧格式 / CRC / 最大 payload / 版本回归。
|
||||
#
|
||||
# 布局约定:spi_link/ 是三工程(ESP32_COMM、DCU_APP、GD32E50X_BOOT)的共享
|
||||
# 协议兄弟目录,与各仓库同级。本仓库通过相对路径 ../../../spi_link/ 引用它。
|
||||
# 因此本 workflow 检出本仓库后,需在同一次检出中提供 spi_link/。
|
||||
#
|
||||
# 触发:push / PR 触及 spi_link 兄弟目录(由调用方检出)或本仓库内的 SPI Link
|
||||
# 相关文件。由于 spi_link 在本仓库之外,路径过滤仅能覆盖本仓库侧的引用改动。
|
||||
name: SPI Link v2 Self-Test
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'components/transport/**'
|
||||
- '.github/workflows/spi-link-selftest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'components/transport/**'
|
||||
- '.github/workflows/spi-link-selftest.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
selftest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout ESP32_COMM
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout shared spi_link(兄弟协议目录)
|
||||
# spi_link/ 是独立维护的共享协议源,与三工程同级。
|
||||
# 在 CI 中通过同源仓库的另一路径提供;若 spi_link 独立成仓,请改为
|
||||
# actions/checkout 指向该仓库并放到 ../spi_link。
|
||||
run: |
|
||||
# 期望运行器在 checkout 本仓前已将 spi_link 放到上级目录;
|
||||
# 退路:若上级无 spi_link,则从本仓 components 引用的相对路径复制一份
|
||||
# 到 ../spi_link(本仓不直接含 spi_link 源,故此处仅做存在性检查)。
|
||||
if [ ! -d ../spi_link ]; then
|
||||
echo "::warning::spi_link 兄弟目录不存在;CI 无法运行自测。请确保 spi_link 与本仓同级。"
|
||||
exit 1
|
||||
fi
|
||||
ls -la ../spi_link/golden_vectors.h ../spi_link/spi_link_v2.cpp ../spi_link/spi_link_v2.h
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake g++
|
||||
|
||||
- name: Configure host test
|
||||
run: cmake -S ../spi_link/test -B build/host_test
|
||||
|
||||
- name: Build host test
|
||||
run: cmake --build build/host_test
|
||||
|
||||
- name: Run self-test
|
||||
run: ctest --test-dir build/host_test --output-on-failure
|
||||
@@ -0,0 +1,28 @@
|
||||
## 目标
|
||||
重新生成 `G:/Robot_v2/DCU/SPEC/code_optimization_audit_2026-07-19.html`,让它反映当前工作树的代码状态(已修改的 `health_monitor.*`、`spi_master.*`、`ble_ota_data.cpp`、`version.h`、`tools/js/common/connection.js` 等)。
|
||||
|
||||
## 步骤
|
||||
1. 在 `G:/Robot_v2/DCU` 下运行生成器(不带 `--verify`,按你刚才的选择):
|
||||
```bash
|
||||
python tools/gen_code_optimization_audit.py
|
||||
```
|
||||
- 默认输出到 `SPEC/code_optimization_audit_<今天日期>.html`,即 `code_optimization_audit_2026-07-20.html`(今天 2026-07-20)。
|
||||
- 注意:现有文件名是 `..._2026-07-19.html`,新生成的会是 `..._2026-07-20.html`,不会覆盖旧文件。
|
||||
|
||||
2. 检查脚本输出:
|
||||
- 看 `[audit] files=... lines=...`、findings 数量、是否出现 `重复 finding ID` 或锚点漂移错误。
|
||||
- 脚本内置 HTML 结构与内部链接自校验,失败会返回非 0。
|
||||
|
||||
3. 如有锚点漂移(某个 finding 引用的行号在改动后对不上原证据),脚本会报错并指出哪个 finding;此时需要我先核对对应源码再决定是调整脚本里的锚点还是接受新行号——会先汇报给你,不会私自改 finding 逻辑。
|
||||
|
||||
4. 生成成功后,简要汇报:
|
||||
- 新报告路径
|
||||
- findings 数量与 P0/P1/P2 分布是否变化
|
||||
- 与旧报告的关键差异(例如新增/消失的 finding、行号变化)
|
||||
- 旧报告 `2026-07-19.html` 是否保留(默认保留)
|
||||
|
||||
## 不做的事
|
||||
- 不带 `--verify`(不跑 ESP32/GD32 构建、SPI 自测、前端语法检查)
|
||||
- 不删除旧报告 `2026-07-19.html`
|
||||
- 不改生成器脚本本身
|
||||
- 不做 git 提交
|
||||
@@ -0,0 +1,116 @@
|
||||
## 目标
|
||||
修复 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 + 临界区惯例一致)。
|
||||
@@ -0,0 +1,84 @@
|
||||
## 目标
|
||||
把 BLE OTA DATA_IN 路径的 OOM/队列满从“静默丢包”变为“可恢复协议反馈”,让前端在一个窗口内收到缺口反馈并按 `completed_bytes` 续传退避,避免整窗口 8s 超时重发风暴。仅做方案 A(负 ACK 反馈),不上固定缓冲池;节流复用现有窗口节流思路。
|
||||
|
||||
## 现状核对(已核实)
|
||||
- 平台 ESP32-C3,无 PSRAM,堆紧张;稳态堆水位需实测后才能评估是否上池,故本计划不涉及池。
|
||||
- `ble_ota_data.cpp:597` `malloc(recv_len)` 失败仅 `DEBUG_ERR`,回调仍返回 0(ATT 成功)。
|
||||
- `ble_ota_data.cpp:602` `xQueueSend` 失败同样静默:`free(heap_buf)` + `DEBUG_WARN("...丢...等重传")`,无协议反馈。两条路径都需修,不只 malloc 路径。
|
||||
- 客户端窗口内 seq≥1 用 `writeValueWithoutResponse`(即发即忘),**收不到 ATT Error Response**;只有 seq=0 用 `writeValueWithResponse`。因此“返回 ATT 错误码”对窗口内丢包无效,必须靠 DATA_OUT 通知通道发窗口 ACK。原报告“返回 ATT 错误”建议在窗口路径不成立,本计划以通知 ACK 为主。
|
||||
- 客户端 `transfer.js` 已支持按 `completed_bytes` 续传(`resumeSeq = ceil(lastAckBytes / payloadSize)`,最多 3 次重试,窗口 64 包),所以设备端多发一个缺口 ACK 不会破坏客户端语义,只会让续传更早开始。
|
||||
- 现有 `sendWindowAck(cmd, seq, completed_bytes)` 在 `ble_ota_data.cpp:440` 已封装好,seq 取 `s_reorder.expected - 1`(最近连续落盘序号),负 ACK 直接复用即可,无需新协议字段、无需改客户端。
|
||||
- 现有节流是“进度触发 + 每 `BLE_OTA_ACK_WINDOW`(64) 包边界”(见 process_esp32_packet:691-712),不是时间间隔。丢包路径发生在写入前、无进度,无法复用边界判定,需为负 ACK 引入轻量节流(避免重传叠加时 ACK 通知打爆 NimBLE mbuf 池)。
|
||||
|
||||
## 改动点(集中在 `main/server/ble_ota_data.cpp`)
|
||||
|
||||
### 1. 新增丢包计数(可观测性,满足“池耗尽/丢包有计数”验收)
|
||||
- 在文件静态区新增:
|
||||
- `static uint32_t s_ota_drop_malloc_fail = 0;` // malloc 失败累计
|
||||
- `static uint32_t s_ota_drop_queue_full = 0;` // 队列满累计
|
||||
- `static uint32_t s_neg_ack_sent = 0;` // 负 ACK 发送累计
|
||||
- 在 `ble_ota_session_reset()`(已有复位点,约 line 414)中清零,与 reorder 状态同生命周期。
|
||||
- 通过现有 health monitor 或 DEBUG_DBG 暴露(按现有日志风格,不新增上报通道)。
|
||||
|
||||
### 2. 新增负 ACK 节流状态
|
||||
- 复用“每 64 包/最小间隔”思路,但丢包路径无 `completedPackets`,故用包计数 + 最小时间间隔双约束:
|
||||
- `static uint32_t s_neg_ack_count = 0;` // 自上次发负 ACK 以来累计丢包数
|
||||
- `static int64_t s_last_neg_ack_us = 0;` // 上次负 ACK 时刻(`esp_timer_get_time()`)
|
||||
- 节流规则(在丢包路径触发):
|
||||
- 累计丢包 ≥ `BLE_OTA_ACK_WINDOW`(64) **或** 距上次 ≥ 200ms 时,发一次负 ACK 并复位计数/时间。
|
||||
- 否则只计数、不发,避免重传风暴期间 ACK 通知打爆 mbuf 池。
|
||||
- 常量定义在文件内 `static constexpr`(与 `BLE_OTA_BUF_SIZE` 同位置,约 line 580),不污染 app_config.h。
|
||||
- 时间用 `esp_timer_get_time()`(已是项目内惯用 API,见 reorder/health 用法)。
|
||||
|
||||
### 3. 新增负 ACK 发送辅助函数
|
||||
- 在 `sendWindowAck`(line 460 后)下方新增:
|
||||
```cpp
|
||||
// 丢包路径专用:发缺口 ACK 让前端按 completed_bytes 续传退避,而非等 8s 超时。
|
||||
// 节流:每 BLE_OTA_ACK_WINDOW 个丢包或 200ms 最多一次,防止 ACK 通知打爆 mbuf 池。
|
||||
static void sendNegativeAckIfDue(uint8_t cmd);
|
||||
```
|
||||
- 实现:
|
||||
- 读取 `s_reorder.expected`:>0 时 `ackSeq = expected - 1`,`completed = Esp32OtaCore::getReceived()`;expected==0(首包都没落盘)时 ackSeq=0、completed=0(等价于让前端从头续发,客户端已支持此语义)。
|
||||
- 节流判定通过后调用 `sendWindowAck(cmd, ackSeq, completed)` 并 `s_neg_ack_sent++`。
|
||||
- GD32 路径同样适用(`sendWindowAck` 已按 cmd 区分),但 GD32 的 expected 状态在 `s_ble_gd32_meta`,需在函数内按 cmd 分支取 expected/received;为简单起见,先实现 ESP32 路径,GD32 路径仅计数不发 ACK(GD32 是中继转发,缺口由 GD32 侧自行反馈),并在注释中标注。
|
||||
|
||||
### 4. 改造 `ble_ota_data_in_access` 丢包路径(line 596-605)
|
||||
- malloc 失败分支:`s_ota_drop_malloc_fail++` → `sendNegativeAckIfDue(cmd)`(cmd 从 batch 头解析,见下)→ 保留 `DEBUG_ERR`。
|
||||
- 队列满分支:`s_ota_drop_queue_full++` → `sendNegativeAckIfDue(cmd)` → 保留 `DEBUG_WARN`。
|
||||
- **cmd 解析问题**:当前 line 566-596 的回调只把 mbuf 拷到 `buf`,未解析 cmd。负 ACK 需要 cmd 字节。两种做法:
|
||||
- (a) 回调里解析前 7 字节头取 cmd(与 worker 中 `ble_ota_process_data_batch` 解析逻辑一致,可抽一个小 helper `parse_cmd_from_batch(buf, recv_len)`)。
|
||||
- (b) 不解析,统一用 `BLE_OTA_CMD_ESP32_DATA`(0x01)(C3 自身 OTA 是主路径,GD32 经 BLE 中继较少)。
|
||||
- **选 (a)**:抽 helper,正确性优先,避免误报 cmd 让前端续传走错分支。helper 放在 `ble_ota_data.cpp` 内 static,不暴露到头文件。
|
||||
|
||||
### 5. 不改动的部分
|
||||
- 回调返回值仍为 0(窗口内 write-without-response 返回错误对端也收不到,且会污染 ATT 层;负 ACK 走通知通道才是正反馈)。
|
||||
- 不引入固定缓冲池(C3 无 PSRAM,池规模需实测,本计划范围内不做)。
|
||||
- 不改客户端 `tools/js/ble/*`(现有续传语义已兼容)。
|
||||
- 不改协议字段/UUID/通知格式。
|
||||
|
||||
## 风险与权衡
|
||||
- **负 ACK 抖动**:极端低堆时丢包密集,节流会让部分丢包延迟反馈,客户端可能仍等部分超时。权衡已用 200ms 兜底,可接受;若实测仍风暴,再调常量。
|
||||
- **首包未落盘时负 ACK**:expected==0 时发 `ackSeq=0, completed=0`,等价“一切重来”。客户端 seq=0 用 write-with-response,本就等首包 ACK;此时负 ACK 不会误导。
|
||||
- **GD32 路径**:本计划 GD32 仅计数不发负 ACK,避免改动 GD32 中继反馈语义;如需对称可后续扩展。
|
||||
- **节流状态并发**:`s_neg_ack_*` 仅在 NimBLE host task 回调中读写(单线程),worker task 不写,无需锁。
|
||||
|
||||
## 验收
|
||||
1. 构造低堆注入:临时在 malloc 前插 `if (s_inject_fail) { heap_buf=nullptr; }` 开关(测试桩,提交前移除),或用 `heap_caps_set_minimum_size` 压低堆水位。
|
||||
2. 触发 OTA 上传,观察:
|
||||
- 日志出现 `DATA_IN 堆内存不足` / `DATA_IN满` + `sendNegativeAckIfDue` 发送记录。
|
||||
- 客户端在窗口内(≤200ms~8s)收到缺口 ACK,`lastAckBytes` 推进,从 `resumeSeq` 续传,**不出现整窗口 8s 超时**。
|
||||
- `s_ota_drop_*` 计数器增长,负 ACK 后传输恢复,无永久卡死。
|
||||
3. 正常路径回归:不注入失败时 ACK 行为、传输耗时、收尾 SHA256 不变。
|
||||
4. 池耗尽有计数:`s_ota_drop_queue_full` / `s_ota_drop_malloc_fail` 在 health monitor/日志可查。
|
||||
|
||||
## 实施顺序
|
||||
1. 加丢包计数 + `ble_ota_session_reset` 清零。
|
||||
2. 加负 ACK 节流状态 + `sendNegativeAckIfDue`(ESP32 路径)。
|
||||
3. 抽 `parse_cmd_from_batch` helper。
|
||||
4. 改造 `ble_ota_data_in_access` 两条丢包路径接入负 ACK。
|
||||
5. 低堆注入测试验收,移除注入桩。
|
||||
6. (可选)GD32 路径对称负 ACK,视后续需要。
|
||||
|
||||
## 涉及文件
|
||||
- `main/server/ble_ota_data.cpp`(主要改动:line 414 reset、line 460 后新增函数、line 596-605 丢包路径、line 580 附近常量)
|
||||
- 不改:`ble_ota_priv.h`、`ble_ota.cpp`、`app_config.h`、客户端 JS。
|
||||
@@ -243,6 +243,26 @@ ESP32_COMM/
|
||||
| 0x82 | RSP_ERROR | 响应:错误 |
|
||||
| 0xC0~C3 | CAN 业务 | 发送 / 反馈 / 模式 / ID 快照 |
|
||||
|
||||
#### SPI Link v2 PC 自测(兼容性回归门禁)
|
||||
|
||||
`spi_link_v2.cpp` 内置 `SPI_LINK_V2_SELFTEST` PC 端自测,以 `golden_vectors.h`
|
||||
中冻结的帧字节为交叉校验基准,覆盖 HELLO / OTA / CAN / PING / ENTER_BOOT /
|
||||
响应类型、最大 payload、CRC 翻转、错误版本、前导垃圾字节偏移解析等场景。
|
||||
向量字节由 `v2_build_frame` 现算后冻结,与协议实现互为交叉校验——任何一方
|
||||
漂移都会被对方测试捕获。
|
||||
|
||||
独立 host CMake target(与 ESP-IDF 构建隔离,不影响固件构建):
|
||||
|
||||
```bash
|
||||
cmake -S spi_link/test -B build/host_test
|
||||
cmake --build build/host_test
|
||||
ctest --test-dir build/host_test --output-on-failure # 期望 Passed: 10 Failed: 0
|
||||
```
|
||||
|
||||
CI:`.github/workflows/spi-link-selftest.yml` 在触及 SPI Link 相关文件时自动
|
||||
运行自测。`spi_link/` 是三工程共享的兄弟协议目录(与 `ESP32_COMM` 同级,本仓
|
||||
通过 `../../../spi_link/` 相对路径引用),CI 检出需确保该兄弟目录就位。
|
||||
|
||||
### CANFD 3 通道 SPI 协议
|
||||
|
||||
ESP32 通过 SPI 转发 CAN 帧到 GD32(GD32 端驱动外部 CAN 收发器):
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
// 固件版本
|
||||
#define FW_VERSION_MAJOR 0
|
||||
#define FW_VERSION_MINOR 3
|
||||
#define FW_VERSION_PATCH 52
|
||||
#define FW_VERSION_STRING "0.3.52"
|
||||
#define FW_VERSION_PATCH 53
|
||||
#define FW_VERSION_STRING "0.3.53"
|
||||
|
||||
// 编译时间
|
||||
#define BUILD_TIME "2026-07-20 02:50:38"
|
||||
#define BUILD_TIME "2026-07-21 00:23:44"
|
||||
|
||||
// Git 信息
|
||||
#define GIT_HASH "b807645"
|
||||
#define GIT_HASH "00b716c"
|
||||
|
||||
@@ -46,16 +46,15 @@
|
||||
// ★ 记录策略:health_mon 周期把真实 free 同步进 g_est_free;
|
||||
// hook 内仅做 g_est_free -= size(C3 单核,无锁可接受漂移)。
|
||||
// 当 est 创新低时更新 hook 快照。free hook 不知 size,靠周期 resync 纠偏。
|
||||
//
|
||||
// ★ 采样协议(消除多字段撕裂读):
|
||||
// hook 写「最后一击」快照的多个字段(min_est/alloc_size/caller/free_after/fired
|
||||
// + recent_allocs[])虽各自是字宽 volatile,但 reader 跨字段组合可撕裂
|
||||
// (fired=1 配上上一次的 alloc_size)。reader(maybeRecordHeapLowWater)在
|
||||
// portENTER_CRITICAL(&s_hookMux) 内一次性拷出全部字段——单核禁中断即与 hook
|
||||
// (在 heap 临界区内写)互斥。m_mutex 不参与此互斥(hook 不获取它)。
|
||||
// reader 临界区仅做 volatile 拷贝,不分配、不调 FreeRTOS/flash,保持极短。
|
||||
namespace {
|
||||
volatile uint32_t g_hook_min_est = 0xFFFFFFFFu; ///< hook 见过的最低 est free
|
||||
volatile uint32_t g_hook_alloc_size = 0; ///< 触发 est 新低的那次 alloc 大小
|
||||
volatile uint32_t g_hook_caller_addr = 0; ///< 调用者返回地址
|
||||
volatile uint32_t g_hook_free_after = 0; ///< 那次后的 est free(非精确实测)
|
||||
volatile uint32_t g_hook_fired = 0;
|
||||
volatile uint32_t g_hook_armed = 0;
|
||||
volatile uint32_t g_est_free = 0; ///< 近似空闲,任务上下文 resync
|
||||
volatile uint32_t g_ip_event_count = 0;
|
||||
|
||||
/** 最近若干次「值得关注」的 alloc(size≥阈值),供破新低时对照。 */
|
||||
struct RecentAllocSnap {
|
||||
uint32_t size;
|
||||
@@ -63,17 +62,82 @@ struct RecentAllocSnap {
|
||||
};
|
||||
static constexpr uint32_t kHookInterestingMin = 48; ///< 忽略极小碎片分配
|
||||
static constexpr size_t kRecentAllocCap = 4;
|
||||
|
||||
volatile uint32_t g_hook_min_est = 0xFFFFFFFFu; ///< hook 见过的最低 est free
|
||||
volatile uint32_t g_hook_alloc_size = 0; ///< 触发 est 新低的那次 alloc 大小
|
||||
volatile uint32_t g_hook_caller_addr = 0; ///< 调用者返回地址
|
||||
volatile uint32_t g_hook_free_after = 0; ///< 那次后的 est free(estimated,非精确实测)
|
||||
volatile uint32_t g_hook_fired = 0;
|
||||
volatile uint32_t g_hook_armed = 0;
|
||||
volatile uint32_t g_est_free = 0; ///< estimated 近似空闲(只减不增),health_mon 周期用 esp_get_free_heap_size() 实测 resync 纠偏;误差上界 ≈ resync 周期(5s)内总 alloc 字节
|
||||
volatile uint32_t g_ip_event_count = 0;
|
||||
|
||||
RecentAllocSnap g_recent_allocs[kRecentAllocCap] = {};
|
||||
volatile uint32_t g_recent_alloc_w = 0;
|
||||
volatile uint32_t g_recent_alloc_n = 0;
|
||||
|
||||
/* s_hookMux:reader(maybeRecordHeapLowWater,任务上下文)取多字段一致快照用的
|
||||
* portMUX(单核 = 禁中断)。hook 端不加锁——它在 heap 临界区内写,与 reader 的
|
||||
* 禁中断互斥。仅用于消除「最后一击」快照多字段撕裂读,不保护 m_heapLowWater
|
||||
* (后者由 m_mutex 保护)。 */
|
||||
static portMUX_TYPE s_hookMux = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
void hookResyncEstFree(uint32_t real_free)
|
||||
{
|
||||
g_est_free = real_free;
|
||||
}
|
||||
|
||||
/** @brief reader 一次性拷出的 hook 共享状态一致快照(任务上下文,禁中断极短临界区)。 */
|
||||
struct HookSnap {
|
||||
uint32_t alloc_size;
|
||||
uint32_t caller_addr;
|
||||
uint32_t free_after; ///< estimated(非实测)
|
||||
uint32_t fired;
|
||||
uint32_t est_free;
|
||||
uint32_t min_est;
|
||||
uint32_t recent_n;
|
||||
RecentAllocSnap recent[kRecentAllocCap];
|
||||
};
|
||||
|
||||
/** @brief 任务上下文用:禁中断拷出 hook 共享状态一致快照。仅 volatile 拷贝,不分配/不调 FreeRTOS。 */
|
||||
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 < (uint32_t)kRecentAllocCap; ++i) {
|
||||
s.recent[i] = g_recent_allocs[i];
|
||||
}
|
||||
portEXIT_CRITICAL(&s_hookMux);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** @brief 在 s_hookMux 临界区内调用:消费已记录的 hook 命中,重置 min_est/fired。
|
||||
* 必须由 reader 在写完 HeapLowWaterRecord 后调用,避免同一窗口重复记。 */
|
||||
void consumeHookFiredInLock()
|
||||
{
|
||||
/* min_est 抬到当前 est:本次已记下低谷,下次需更低才会再次触发。
|
||||
* g_est_free 在此读以与 hook 写一致(s_hookMux 内)。 */
|
||||
g_hook_min_est = g_est_free;
|
||||
g_hook_fired = 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ESP-IDF heap alloc hook:仅 DRAM 标量 + 内联 return address,禁止 FreeRTOS/flash 长函数。
|
||||
//
|
||||
// ★ hook 可调用约束(IRAM 安全契约):
|
||||
// - 禁止:FreeRTOS API、flash/rodata 访问、mutex/semaphore、长函数。
|
||||
// - 允许:DRAM volatile 标量读写、__builtin_return_address、纯算术。
|
||||
// - 写共享状态天然原子:hook 已在 heap 临界区内(禁中断),reader 用 s_hookMux
|
||||
// 禁中断取一致快照,二者互斥;hook 端不加锁、保持 IRAM 安全。
|
||||
// - 嵌套分配:hook 内禁止任何可能触发 malloc 的调用(否则递归进 hook,armed
|
||||
// 标志不防递归——当前 hook 体内无分配调用,保持之)。
|
||||
extern "C"
|
||||
void esp_heap_trace_alloc_hook(void *ptr, size_t size, uint32_t caps)
|
||||
{
|
||||
@@ -582,6 +646,10 @@ void HealthMonitor::maybeRecordHeapLowWater()
|
||||
const uint32_t free_heap = (uint32_t)esp_get_free_heap_size();
|
||||
const uint32_t largest_block = (uint32_t)heap_caps_get_largest_free_block(CAPS);
|
||||
|
||||
/* hook 共享状态一致快照:禁中断一次性拷出,避免多字段撕裂读。
|
||||
* 必须在 m_mutex 外取——s_hookMux 与 m_mutex 不可嵌套持锁。 */
|
||||
const HookSnap hs = captureHookSnapshot();
|
||||
|
||||
OtaSnapshot snap = OtaCoordinator::instance().getSnapshot();
|
||||
const uint8_t sys_state = (uint8_t)snap.sys_state;
|
||||
const uint8_t ota_channel = (uint8_t)snap.channel;
|
||||
@@ -686,20 +754,22 @@ void HealthMonitor::maybeRecordHeapLowWater()
|
||||
rec.default_free_heap = default_free;
|
||||
rec.alloc_count = alloc_count;
|
||||
// heap hook:「最后一击」alloc(无任务名;见文件头注释)
|
||||
// 字段取自锁外已取的一致快照 hs(captureHookSnapshot),不再裸读 volatile,
|
||||
// 消除 fired/size/caller/free_after 多字段撕裂读。
|
||||
{
|
||||
uint32_t hook_size = g_hook_alloc_size;
|
||||
uint32_t hook_caller = g_hook_caller_addr;
|
||||
uint32_t hook_after = g_hook_free_after;
|
||||
uint32_t hook_fired = g_hook_fired;
|
||||
const uint32_t hook_size = hs.alloc_size;
|
||||
const uint32_t hook_caller = hs.caller_addr;
|
||||
const uint32_t hook_after = hs.free_after;
|
||||
const uint32_t hook_fired = hs.fired;
|
||||
|
||||
/* 窗口内更大块(若存在):发送前小 pbuf 压低水位时,主因更可能是此前 bulk */
|
||||
uint32_t big_size = 0;
|
||||
uint32_t big_caller = 0;
|
||||
const uint32_t n = g_recent_alloc_n;
|
||||
const uint32_t n = hs.recent_n;
|
||||
for (uint32_t i = 0; i < n && i < (uint32_t)kRecentAllocCap; ++i) {
|
||||
if (g_recent_allocs[i].size > big_size) {
|
||||
big_size = g_recent_allocs[i].size;
|
||||
big_caller = g_recent_allocs[i].caller;
|
||||
if (hs.recent[i].size > big_size) {
|
||||
big_size = hs.recent[i].size;
|
||||
big_caller = hs.recent[i].caller;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,8 +795,11 @@ void HealthMonitor::maybeRecordHeapLowWater()
|
||||
rec.hook_task[sizeof(rec.hook_task) - 1] = '\0';
|
||||
}
|
||||
|
||||
g_hook_min_est = g_est_free;
|
||||
g_hook_fired = 0;
|
||||
/* 消费已记录的 hook 命中:在 s_hookMux 内重置 min_est/fired,避免同一窗口
|
||||
* 重复记。与 hook 写互斥(禁中断),不持 m_mutex 同时持 s_hookMux。 */
|
||||
portENTER_CRITICAL(&s_hookMux);
|
||||
consumeHookFiredInLock();
|
||||
portEXIT_CRITICAL(&s_hookMux);
|
||||
}
|
||||
rec.ip_event_count = g_ip_event_count;
|
||||
|
||||
@@ -747,6 +820,10 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
|
||||
}
|
||||
|
||||
const HeapLowWaterRecord &r = m_heapLowWater;
|
||||
/* hook_free_after 是 estimated(hook 只减不增近似值),JSON 字段名显式标注 est,
|
||||
* hook_confidence 告知前端置信度:hook 命中=est_low_watermark,未命中=none。
|
||||
* 前端应据此区分 estimated 与 measured(free_heap/min_free_heap 为实测)。 */
|
||||
const char *hook_confidence = r.hook_fired ? "est_low_watermark" : "none";
|
||||
int pos = appendJsonfSafe(buf, cap, offset,
|
||||
"{\"count\":1,\"records\":[{"
|
||||
"\"ts_ms\":%llu,\"free_heap\":%lu,\"min_free_heap\":%lu,"
|
||||
@@ -757,7 +834,8 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
|
||||
"\"ble_data_queue_depth\":%u,\"ble_reorder_cached\":%u,"
|
||||
"\"spi_pending_jobs\":%u,\"default_free_heap\":%lu,\"alloc_count\":%lu,"
|
||||
"\"hook_task\":\"%s\",\"hook_alloc_size\":%lu,\"hook_caller_addr\":%lu,"
|
||||
"\"hook_free_after\":%lu,\"hook_fired\":%lu,\"ip_event_count\":%lu,"
|
||||
"\"hook_est_free_after\":%lu,\"hook_confidence\":\"%s\",\"hook_fired\":%lu,"
|
||||
"\"ip_event_count\":%lu,"
|
||||
"\"task_stacks\":[",
|
||||
(unsigned long long)r.ts_ms,
|
||||
(unsigned long)r.free_heap,
|
||||
@@ -781,6 +859,7 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
|
||||
(unsigned long)r.hook_alloc_size,
|
||||
(unsigned long)r.hook_caller_addr,
|
||||
(unsigned long)r.hook_free_after,
|
||||
hook_confidence,
|
||||
(unsigned long)r.hook_fired,
|
||||
(unsigned long)r.ip_event_count);
|
||||
if (pos == offset) {
|
||||
|
||||
@@ -81,7 +81,7 @@ struct HeapLowWaterRecord {
|
||||
char hook_task[16]; ///< 分配类别 net?/bulk/tiny,不是任务名
|
||||
uint32_t hook_alloc_size; ///< 最后一击 alloc 字节数
|
||||
uint32_t hook_caller_addr; ///< 调用者返回地址(addr2line)
|
||||
uint32_t hook_free_after; ///< 该次后的近似 free(est,非精确实测)
|
||||
uint32_t hook_free_after; ///< 该次后的近似 free(**estimated 低水位近似值**,非精确实测;JSON 输出为 hook_est_free_after + hook_confidence,前端应标注 estimated)
|
||||
uint32_t hook_fired; ///< 0=本窗口 hook 未命中
|
||||
/* DHCP/IP 事件计数:每次 STA GOT_IP(含续约)+1。
|
||||
* 对比两次低谷记录的计数差,确认低谷是不是 DHCP 续约引起。 */
|
||||
|
||||
@@ -54,6 +54,33 @@ ota_meta_t s_ble_gd32_meta = {};
|
||||
bool s_ble_gd32_meta_active = false;
|
||||
bool s_conn_fallback_requested = false;
|
||||
|
||||
// ============================================================
|
||||
// DATA_IN 丢包负反馈:把 malloc 失败 / 队列满从"静默丢包"变成
|
||||
// "主动发缺口 ACK 让前端按 completed_bytes 续传退避"。
|
||||
//
|
||||
// 现状:writeValueWithoutResponse 窗口内丢包时,NimBLE 不会向对端
|
||||
// 回 ATT Error Response(即发即忘),返回非零 ATT 错误对窗口内
|
||||
// 包无效,只能走 DATA_OUT 通知通道发窗口 ACK。
|
||||
// 静默丢包会让前端等满 8s 窗口超时后整窗口重发 → 重传风暴叠加
|
||||
// 低堆时可能二次失败。这里在丢包路径补发一次缺口 ACK(seq 取
|
||||
// s_reorder.expected-1,completed 取实际落盘字节),让前端立刻
|
||||
// 从 lastAckBytes 续传,客户端已有续传语义无需改动。
|
||||
//
|
||||
// 节流:丢包路径无"进度/窗口边界"可复用,用"丢包计数 ≥ ACK_WINDOW
|
||||
// 或距上次 ≥ NEG_ACK_MIN_INTERVAL_US"双约束,防止重传叠加时
|
||||
// ACK 通知打爆 NimBLE mbuf 池(与现有正常路径节流同动机)。
|
||||
// 状态仅在 NimBLE host task 回调中读写(单线程),无需锁。
|
||||
// ============================================================
|
||||
static uint32_t s_ota_drop_malloc_fail = 0; // DATA_IN malloc 失败累计
|
||||
static uint32_t s_ota_drop_queue_full = 0; // DATA_IN 队列满累计
|
||||
static uint32_t s_neg_ack_sent = 0; // 负 ACK 发送累计
|
||||
static uint32_t s_neg_ack_drop_count = 0; // 自上次发负 ACK 以来累计丢包数
|
||||
static int64_t s_last_neg_ack_us = 0; // 上次负 ACK 时刻(esp_timer_get_time)
|
||||
// 节流常量:与正常 ACK 路径同窗口量级(BLE_OTA_ACK_WINDOW=64),
|
||||
// 最小间隔 200ms 兜底,避免极端低堆时负 ACK 抖动。
|
||||
static constexpr uint32_t NEG_ACK_DROP_WINDOW = 64; // 丢包数阈值
|
||||
static constexpr int64_t NEG_ACK_MIN_INTERVAL_US = 200 * 1000; // 200ms
|
||||
|
||||
// 前向声明(alloc/free 互相引用)
|
||||
void esp32_reorder_free();
|
||||
|
||||
@@ -416,6 +443,12 @@ void ble_ota_session_reset()
|
||||
s_gd32_ooo_count = 0;
|
||||
s_ble_gd32_meta = {};
|
||||
s_ble_gd32_meta_active = false;
|
||||
// 复位 DATA_IN 丢包负反馈计数/节流状态,下次会话从干净状态开始
|
||||
s_ota_drop_malloc_fail = 0;
|
||||
s_ota_drop_queue_full = 0;
|
||||
s_neg_ack_sent = 0;
|
||||
s_neg_ack_drop_count = 0;
|
||||
s_last_neg_ack_us = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,6 +492,79 @@ void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 从 DATA_IN batch 头部解析 cmd 字节。
|
||||
*
|
||||
* DATA_IN 回调只把 mbuf 拷到 buf,未解析协议头;负 ACK 需要 cmd 字节
|
||||
* 区分 ESP32/GD32 续传分支,避免误报让前端走错路径。与 worker 中
|
||||
* ble_ota_process_data_batch 的包头解析保持一致(buf[0] = cmd)。
|
||||
*
|
||||
* @param buf 数据缓冲区。
|
||||
* @param recv_len 数据长度。
|
||||
* @return cmd 字节;非法长度返回 0(GD32 命令),调用方据此不发误导性 ACK。
|
||||
*/
|
||||
static uint8_t parse_cmd_from_batch(const uint8_t *buf, size_t recv_len)
|
||||
{
|
||||
// 与 ble_ota_process_data_batch 一致:至少 7 字节头才算合法包
|
||||
if (recv_len < 7 || buf == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
return buf[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 丢包路径负 ACK:发缺口 ACK 让前端按 completed_bytes 续传退避。
|
||||
*
|
||||
* 触发于 DATA_IN 回调中 malloc 失败 / 队列满。窗口内包用
|
||||
* writeValueWithoutResponse(即发即忘),收不到 ATT Error Response,
|
||||
* 只能走 DATA_OUT 通知通道。负 ACK 复用 sendWindowAck,seq 取
|
||||
* s_reorder.expected-1(最近连续落盘序号),completed 取实际落盘字节,
|
||||
* 前端 resumeSeq = ceil(bytes/payloadSize) 续传,无需改客户端。
|
||||
*
|
||||
* 节流:丢包数 ≥ NEG_ACK_DROP_WINDOW 或距上次 ≥ NEG_ACK_MIN_INTERVAL_US
|
||||
* 才发一次,防止重传风暴期间 ACK 通知打爆 NimBLE mbuf 池。
|
||||
* 状态仅在 NimBLE host task 回调中读写,无需锁。
|
||||
*
|
||||
* 仅 ESP32 路径发负 ACK(s_reorder 状态在此文件内可见);GD32 中继转发
|
||||
* 的缺口反馈由 GD32 侧自行处理,本函数对 GD32 cmd 仅累计不发送。
|
||||
*/
|
||||
static void sendNegativeAckIfDue(uint8_t cmd)
|
||||
{
|
||||
// 累计丢包(无论是否发送,计数都增长,供可观测性)
|
||||
s_neg_ack_drop_count += 1;
|
||||
|
||||
// 节流判定:丢包数达阈值 或 距上次发送超过最小间隔
|
||||
const int64_t now_us = esp_timer_get_time();
|
||||
bool by_count = (s_neg_ack_drop_count >= NEG_ACK_DROP_WINDOW);
|
||||
bool by_time = ((now_us - s_last_neg_ack_us) >= NEG_ACK_MIN_INTERVAL_US);
|
||||
// 首次(s_last_neg_ack_us==0)也允许立即发,避免低堆首包丢就憋住
|
||||
bool first_time = (s_last_neg_ack_us == 0);
|
||||
if (!(by_count || by_time || first_time)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅 ESP32 路径发负 ACK;GD32 中继缺口由 GD32 侧反馈,这里只计不发
|
||||
if (cmd == BLE_OTA_CMD_ESP32_DATA) {
|
||||
// expected>0:已落盘过包,ackSeq=expected-1,completed=实际落盘字节
|
||||
// expected==0:首包都没落盘,发 ackSeq=0/completed=0,等价"从头续发"
|
||||
// (客户端 seq=0 用 write-with-response,本就等首包 ACK,不会误导)
|
||||
uint16_t ackSeq = (s_reorder.expected > 0U)
|
||||
? (uint16_t)(s_reorder.expected - 1U)
|
||||
: 0U;
|
||||
uint32_t completed = (s_reorder.expected > 0U)
|
||||
? (uint32_t)Esp32OtaCore::instance().getReceived()
|
||||
: 0U;
|
||||
sendWindowAck(cmd, ackSeq, completed);
|
||||
s_neg_ack_sent += 1;
|
||||
DEBUG_WARN(TAG, "DATA_IN 负ACK cmd=0x%02X seq=%u bytes=%lu (drop:malloc=%u qfull=%u)",
|
||||
cmd, ackSeq, (unsigned long)completed,
|
||||
(unsigned)s_ota_drop_malloc_fail, (unsigned)s_ota_drop_queue_full);
|
||||
}
|
||||
// 复位节流状态
|
||||
s_neg_ack_drop_count = 0;
|
||||
s_last_neg_ack_us = now_us;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 发送收尾阶段事件通知(开始/完成/失败/重启等)。
|
||||
* @param stage 阶段标识码。
|
||||
@@ -594,16 +700,25 @@ int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
|
||||
// 将数据拷贝到堆上并送入工作队列,避免在 NimBLE host task 中执行
|
||||
// 耗时的 flash 操作(esp_ota_begin / esp_ota_write)导致 BLE 连接断开。
|
||||
if (s_otaDataQueue) {
|
||||
// 解析 cmd 字节供负 ACK 区分 ESP32/GD32 续传分支(与 worker 解析一致)
|
||||
uint8_t cmd = parse_cmd_from_batch(buf, (size_t)recv_len);
|
||||
uint8_t *heap_buf = (uint8_t *)malloc((size_t)recv_len);
|
||||
if (heap_buf) {
|
||||
memcpy(heap_buf, buf, (size_t)recv_len);
|
||||
BleOtaDataBatch item = { heap_buf, (size_t)recv_len };
|
||||
if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) {
|
||||
// 队列满:发缺口 ACK 让前端按 completed_bytes 续传退避,
|
||||
// 而非等满 8s 窗口超时后整窗口重发 → 重传风暴叠加低堆二次失败
|
||||
s_ota_drop_queue_full += 1;
|
||||
DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len);
|
||||
free(heap_buf);
|
||||
sendNegativeAckIfDue(cmd);
|
||||
}
|
||||
} else {
|
||||
// malloc 失败:同上,发缺口 ACK 形成可恢复协议反馈,避免静默丢包
|
||||
s_ota_drop_malloc_fail += 1;
|
||||
DEBUG_ERR(TAG, "DATA_IN 堆内存不足 (%d 字节),丢弃", recv_len);
|
||||
sendNegativeAckIfDue(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+150
-54
@@ -8,7 +8,6 @@
|
||||
*/
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/portmacro.h"
|
||||
#include "freertos/task.h"
|
||||
@@ -30,7 +29,7 @@
|
||||
|
||||
static constexpr size_t kSpiTransferMaxBytes = 256U;
|
||||
|
||||
/** @brief submitJob 堆 Job 与 worker 之间的 finished/abandoned 交接。 */
|
||||
/** @brief submitJob 池 Job 与 worker 之间的 finished/abandoned/in_use 交接。 */
|
||||
static portMUX_TYPE s_spiJobMux = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
/**
|
||||
@@ -296,38 +295,105 @@ void SpiMaster::executeJob(Job &job)
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief worker 完成堆上 Job 后的收尾:释放完成信号量,超时放弃时由 worker free。
|
||||
* @param job 堆分配的 Job 指针
|
||||
* @brief 从固定 Job 池取空闲槽;池耗尽返回 nullptr。
|
||||
* @note in_use 在 critical 内置 true 并保持,禁止整体 Job{} 赋值清掉占用标志。
|
||||
*/
|
||||
void SpiMaster::finishHeapJob(Job *job)
|
||||
SpiMaster::Job *SpiMaster::acquireJobSlot()
|
||||
{
|
||||
if (!m_jobPoolReady) {
|
||||
return nullptr;
|
||||
}
|
||||
Job *slot = nullptr;
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
for (UBaseType_t i = 0; i < kJobPoolSize; ++i) {
|
||||
if (!m_jobPool[i].in_use) {
|
||||
slot = &m_jobPool[i];
|
||||
/* 清业务字段但 in_use 全程 true,避免与其它 acquire 竞态 */
|
||||
SemaphoreHandle_t done = slot->done;
|
||||
slot->kind = JobKind::Transact;
|
||||
slot->timeout_ms = 0U;
|
||||
slot->type = 0U;
|
||||
slot->expected_type = 0xFFU;
|
||||
slot->role_hint = 0U;
|
||||
slot->payload_len = 0U;
|
||||
slot->out_type = nullptr;
|
||||
slot->out_payload = nullptr;
|
||||
slot->out_max = 0U;
|
||||
slot->out_len = nullptr;
|
||||
slot->out_uptime = nullptr;
|
||||
slot->out_version = nullptr;
|
||||
slot->result = ESP_FAIL;
|
||||
slot->done = done;
|
||||
slot->finished = false;
|
||||
slot->abandoned = false;
|
||||
slot->in_use = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
if (slot == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
/* 仅本槽所有者 drain;不可在 critical 内 take */
|
||||
if (slot->done != nullptr) {
|
||||
while (xSemaphoreTake(slot->done, 0) == pdTRUE) {
|
||||
}
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 归还池槽(调用方已独占该 Job,不再与 worker 共享)。
|
||||
*/
|
||||
void SpiMaster::releaseJobSlot(Job *job)
|
||||
{
|
||||
if (job == nullptr) {
|
||||
return;
|
||||
}
|
||||
/* 先 give,再标 finished;提交方 take 成功后会 spin 等 finished 再 free。 */
|
||||
if (job->done != nullptr) {
|
||||
while (xSemaphoreTake(job->done, 0) == pdTRUE) {
|
||||
}
|
||||
}
|
||||
job->out_type = nullptr;
|
||||
job->out_payload = nullptr;
|
||||
job->out_len = nullptr;
|
||||
job->out_uptime = nullptr;
|
||||
job->out_version = nullptr;
|
||||
job->payload_len = 0U;
|
||||
job->result = ESP_FAIL;
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
job->finished = false;
|
||||
job->abandoned = false;
|
||||
job->in_use = false;
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief worker 完成池 Job 后的收尾:give done;abandoned 时由 worker release 槽。
|
||||
* @param job 池内 Job 指针
|
||||
*/
|
||||
void SpiMaster::finishPoolJob(Job *job)
|
||||
{
|
||||
if (job == nullptr) {
|
||||
return;
|
||||
}
|
||||
/* 先 give,再标 finished;提交方 take 成功后会 spin 等 finished 再 release。 */
|
||||
if (job->done) {
|
||||
(void)xSemaphoreGive(job->done);
|
||||
}
|
||||
if (!job->heap_allocated) {
|
||||
return;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
job->finished = true;
|
||||
const bool free_it = job->abandoned;
|
||||
const bool release_it = job->abandoned;
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
if (free_it) {
|
||||
if (job->done) {
|
||||
vSemaphoreDelete(job->done);
|
||||
job->done = nullptr;
|
||||
}
|
||||
free(job);
|
||||
if (release_it) {
|
||||
releaseJobSlot(job);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 将 Job 提交给 SPI worker 串行执行(非 worker 线程走队列)。
|
||||
* @param job 任务描述(栈上 Job 会拷到堆上再排队)
|
||||
* @return 业务结果码;队列满/超时返回 ESP_ERR_TIMEOUT
|
||||
* @param job 任务描述(栈上字段拷入固定池槽再排队)
|
||||
* @return 业务结果码;池耗尽 ESP_ERR_NO_MEM;队列满/超时 ESP_ERR_TIMEOUT
|
||||
*/
|
||||
esp_err_t SpiMaster::submitJob(Job &job)
|
||||
{
|
||||
@@ -336,70 +402,81 @@ esp_err_t SpiMaster::submitJob(Job &job)
|
||||
return job.result;
|
||||
}
|
||||
|
||||
/* 跨任务作业:堆上 Job + binary 完成信号量 */
|
||||
Job *heap_job = static_cast<Job *>(calloc(1, sizeof(Job)));
|
||||
if (heap_job == nullptr) {
|
||||
DEBUG_ERR("SPI", "submitJob: calloc failed kind=%u", (unsigned)job.kind);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
*heap_job = job;
|
||||
heap_job->heap_allocated = true;
|
||||
heap_job->finished = false;
|
||||
heap_job->abandoned = false;
|
||||
heap_job->result = ESP_FAIL;
|
||||
heap_job->done = xSemaphoreCreateBinary();
|
||||
if (heap_job->done == nullptr) {
|
||||
free(heap_job);
|
||||
DEBUG_ERR("SPI", "submitJob: done sem create failed");
|
||||
/* 跨任务:固定池 Job + 静态 binary 完成信号量(无 calloc/xSemaphoreCreate) */
|
||||
Job *pool_job = acquireJobSlot();
|
||||
if (pool_job == nullptr) {
|
||||
DEBUG_ERR("SPI", "submitJob: job pool exhausted kind=%u", (unsigned)job.kind);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
/* 逐字段拷贝业务参数;禁止整体赋值覆盖 done/in_use */
|
||||
pool_job->kind = job.kind;
|
||||
pool_job->timeout_ms = job.timeout_ms;
|
||||
pool_job->type = job.type;
|
||||
pool_job->expected_type = job.expected_type;
|
||||
pool_job->role_hint = job.role_hint;
|
||||
pool_job->payload_len = job.payload_len;
|
||||
if (job.payload_len > 0U) {
|
||||
const size_t n = (job.payload_len > sizeof(pool_job->payload))
|
||||
? sizeof(pool_job->payload)
|
||||
: job.payload_len;
|
||||
memcpy(pool_job->payload, job.payload, n);
|
||||
pool_job->payload_len = n;
|
||||
} else {
|
||||
pool_job->payload_len = 0U;
|
||||
}
|
||||
pool_job->out_type = job.out_type;
|
||||
pool_job->out_payload = job.out_payload;
|
||||
pool_job->out_max = job.out_max;
|
||||
pool_job->out_len = job.out_len;
|
||||
pool_job->out_uptime = job.out_uptime;
|
||||
pool_job->out_version = job.out_version;
|
||||
pool_job->result = ESP_FAIL;
|
||||
pool_job->finished = false;
|
||||
pool_job->abandoned = false;
|
||||
/* done / in_use 保持 acquire 时状态 */
|
||||
|
||||
const TickType_t qwait = pdMS_TO_TICKS(
|
||||
(job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 2000U);
|
||||
if (xQueueSend(m_jobQueue, &heap_job, qwait) != pdTRUE) {
|
||||
if (xQueueSend(m_jobQueue, &pool_job, qwait) != pdTRUE) {
|
||||
DEBUG_WARN("SPI", "worker job queue full/timeout kind=%u", (unsigned)job.kind);
|
||||
vSemaphoreDelete(heap_job->done);
|
||||
free(heap_job);
|
||||
releaseJobSlot(pool_job);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
xTaskNotify(m_workerTask, 0x10U, eSetBits);
|
||||
|
||||
const TickType_t dwait = pdMS_TO_TICKS(
|
||||
(job.timeout_ms > 0U ? job.timeout_ms : 1000U) + 10000U);
|
||||
if (xSemaphoreTake(heap_job->done, dwait) == pdTRUE) {
|
||||
const esp_err_t result = heap_job->result;
|
||||
/* worker 在 give 后才写 finished;等其退出临界区后再由本侧独占 free。 */
|
||||
if (xSemaphoreTake(pool_job->done, dwait) == pdTRUE) {
|
||||
const esp_err_t result = pool_job->result;
|
||||
/* worker 在 give 后才写 finished;等其退出临界区后再由本侧独占 release。 */
|
||||
for (;;) {
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
const bool fin = heap_job->finished;
|
||||
const bool fin = pool_job->finished;
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
if (fin) {
|
||||
break;
|
||||
}
|
||||
vTaskDelay(1);
|
||||
}
|
||||
vSemaphoreDelete(heap_job->done);
|
||||
heap_job->done = nullptr;
|
||||
free(heap_job);
|
||||
releaseJobSlot(pool_job);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* 超时:标记 abandoned;若 worker 已跑完则本侧 free,否则 worker finishHeapJob free。 */
|
||||
/* 超时:标记 abandoned;若 worker 已跑完则本侧 release,否则 finishPoolJob release。 */
|
||||
taskENTER_CRITICAL(&s_spiJobMux);
|
||||
heap_job->abandoned = true;
|
||||
const bool already_done = heap_job->finished;
|
||||
pool_job->abandoned = true;
|
||||
const bool already_done = pool_job->finished;
|
||||
taskEXIT_CRITICAL(&s_spiJobMux);
|
||||
|
||||
if (already_done) {
|
||||
(void)xSemaphoreTake(heap_job->done, 0);
|
||||
const esp_err_t result = heap_job->result;
|
||||
vSemaphoreDelete(heap_job->done);
|
||||
heap_job->done = nullptr;
|
||||
free(heap_job);
|
||||
(void)xSemaphoreTake(pool_job->done, 0);
|
||||
const esp_err_t result = pool_job->result;
|
||||
releaseJobSlot(pool_job);
|
||||
return result;
|
||||
}
|
||||
|
||||
DEBUG_ERR("SPI", "worker job done timeout kind=%u(堆 Job 交 worker 释放)",
|
||||
DEBUG_ERR("SPI", "worker job done timeout kind=%u(池槽交 worker 释放)",
|
||||
(unsigned)job.kind);
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
@@ -434,7 +511,7 @@ void SpiMaster::workerLoop()
|
||||
Job *job = nullptr;
|
||||
if (xQueueReceive(m_jobQueue, &job, pdMS_TO_TICKS(1)) == pdTRUE && job != nullptr) {
|
||||
executeJob(*job);
|
||||
finishHeapJob(job);
|
||||
finishPoolJob(job);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -890,7 +967,7 @@ esp_err_t SpiMaster::init()
|
||||
m_stats = SpiLinkStats{};
|
||||
m_eventHead = m_eventTail = m_eventCount = 0;
|
||||
|
||||
/* 创建 job 队列、事件信号量与 SPI worker(init 仍持 m_mutex) */
|
||||
/* 创建 job 队列、固定 Job pool(静态 done 信号量)、事件信号量与 SPI worker */
|
||||
if (m_jobQueue == nullptr) {
|
||||
m_jobQueue = xQueueCreate(kJobQueueLen, sizeof(Job *));
|
||||
if (m_jobQueue == nullptr) {
|
||||
@@ -898,6 +975,25 @@ esp_err_t SpiMaster::init()
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
}
|
||||
if (!m_jobPoolReady) {
|
||||
for (UBaseType_t i = 0; i < kJobPoolSize; ++i) {
|
||||
m_jobPool[i] = Job{};
|
||||
m_jobPool[i].done = xSemaphoreCreateBinary();
|
||||
if (m_jobPool[i].done == nullptr) {
|
||||
DEBUG_ERR("SPI", "SPI job pool done sem create failed idx=%u",
|
||||
(unsigned)i);
|
||||
for (UBaseType_t j = 0; j < i; ++j) {
|
||||
vSemaphoreDelete(m_jobPool[j].done);
|
||||
m_jobPool[j].done = nullptr;
|
||||
}
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
m_jobPool[i].in_use = false;
|
||||
}
|
||||
m_jobPoolReady = true;
|
||||
DEBUG_INFO("SPI", "SPI job pool ready size=%u queue=%u",
|
||||
(unsigned)kJobPoolSize, (unsigned)kJobQueueLen);
|
||||
}
|
||||
if (m_eventSem == nullptr) {
|
||||
m_eventSem = xSemaphoreCreateCounting(kEventSlots, 0);
|
||||
if (m_eventSem == nullptr) {
|
||||
|
||||
@@ -99,7 +99,7 @@ public:
|
||||
/** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */
|
||||
TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; }
|
||||
|
||||
/** @brief SPI 任务队列当前待处理 job 数(每个堆 Job 持有 calloc 的 Job 结构)。
|
||||
/** @brief SPI 任务队列当前待处理 job 数(固定 Job pool 槽,无堆分配)。
|
||||
* 供 /system/heap_low_log 堆低水位快照采样,定位"低谷是不是 SPI 队列堆积引起"。 */
|
||||
UBaseType_t getPendingJobCount() const { return m_jobQueue ? uxQueueMessagesWaiting(m_jobQueue) : 0; }
|
||||
|
||||
@@ -302,7 +302,7 @@ private:
|
||||
uint8_t m_eventTail = 0;
|
||||
uint8_t m_eventCount = 0;
|
||||
|
||||
// ── 作业提交(跨任务:堆 Job + binary done;worker 串行执行)──
|
||||
// ── 作业提交(跨任务:固定 Job pool + 静态 binary done;worker 串行执行)──
|
||||
enum class JobKind : uint8_t {
|
||||
Transact = 0,
|
||||
Poll,
|
||||
@@ -326,19 +326,27 @@ private:
|
||||
uint32_t *out_uptime = nullptr;
|
||||
gd32_version_t *out_version = nullptr;
|
||||
esp_err_t result = ESP_FAIL;
|
||||
SemaphoreHandle_t done = nullptr;
|
||||
/** submitJob 堆分配的 Job:超时放弃后由 worker 释放,成功路径由提交方释放。 */
|
||||
bool heap_allocated = false;
|
||||
SemaphoreHandle_t done = nullptr; ///< 池槽静态 binary,init 创建,永不 delete
|
||||
volatile bool finished = false; ///< worker 已 execute + give
|
||||
volatile bool abandoned = false; ///< 提交方已超时返回,worker 负责 free
|
||||
volatile bool abandoned = false; ///< 提交方已超时返回,worker 负责 release 槽
|
||||
bool in_use = false; ///< 池槽占用(critical 保护)
|
||||
};
|
||||
/** 队列深度;池 = 队列 + 1(在途执行槽),覆盖 queued + executing。 */
|
||||
static constexpr UBaseType_t kJobQueueLen = 6;
|
||||
static constexpr UBaseType_t kJobPoolSize = kJobQueueLen + 1U;
|
||||
|
||||
Job m_jobPool[kJobPoolSize] = {};
|
||||
bool m_jobPoolReady = false;
|
||||
|
||||
bool onWorker() const;
|
||||
/** @brief 从固定池取空闲 Job;池耗尽返回 nullptr(ESP_ERR_NO_MEM)。 */
|
||||
Job *acquireJobSlot();
|
||||
/** @brief 归还池槽(清空业务字段/标志,drain done 信号量)。 */
|
||||
void releaseJobSlot(Job *job);
|
||||
esp_err_t submitJob(Job &job);
|
||||
void executeJob(Job &job);
|
||||
/** @brief worker 完成堆 Job 后的生命周期收尾(give done + 可能 free)。 */
|
||||
void finishHeapJob(Job *job);
|
||||
/** @brief worker 完成池 Job 后的收尾(give done;abandoned 时 release 槽)。 */
|
||||
void finishPoolJob(Job *job);
|
||||
/** @brief FreeRTOS 任务入口,转到 workerLoop。 */
|
||||
static void workerEntry(void *arg);
|
||||
/** @brief SPI worker 主循环:EVENT 按铃 + Job 队列。 */
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
text = (ROOT / "build" / "ESP32_COMM.map").read_text(encoding="utf-8", errors="replace")
|
||||
rows = []
|
||||
lines = text.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
m = re.match(r"^\s*\.bss\.(\S+)\s*$", line)
|
||||
m2 = re.match(r"^\s*\.bss\.(\S+)\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)", line)
|
||||
if m2:
|
||||
rows.append((int(m2.group(3), 16), m2.group(1), ""))
|
||||
elif m:
|
||||
name = m.group(1)
|
||||
j = i + 1
|
||||
while j < len(lines) and j < i + 5:
|
||||
l2 = lines[j]
|
||||
mm = re.match(r"^\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)\s+(\S+)", l2)
|
||||
if mm:
|
||||
rows.append((int(mm.group(2), 16), name, mm.group(3)))
|
||||
break
|
||||
if re.match(r"^\s*\.", l2) and not l2.strip().startswith(".bss"):
|
||||
break
|
||||
j += 1
|
||||
i += 1
|
||||
|
||||
|
||||
def demangle(n: str) -> str:
|
||||
n = n.replace("_ZN12_GLOBAL__N_1L", "")
|
||||
n = n.replace("_ZL", "")
|
||||
if n.startswith("_ZZ"):
|
||||
n = "local:" + n[3:50]
|
||||
return n[:55]
|
||||
|
||||
|
||||
cats = defaultdict(int)
|
||||
app = []
|
||||
for size, name, obj in rows:
|
||||
o = (obj or "").lower()
|
||||
if "libmain" in o:
|
||||
cat = "app(main)"
|
||||
app.append((size, name, obj))
|
||||
elif "libfreertos" in o:
|
||||
cat = "freertos"
|
||||
elif "liblwip" in o:
|
||||
cat = "lwip"
|
||||
elif any(x in o for x in ("libnet80211", "libpp", "libwpa", "libmesh")):
|
||||
cat = "wifi/mesh"
|
||||
elif any(x in o for x in ("libnimble", "libbt", "bt.", "ble_")):
|
||||
cat = "ble"
|
||||
elif any(
|
||||
x in name
|
||||
for x in ("g_cnx", "gWpa", "g_ic", "g_pm", "gChm", "gScan", "g_mesh", "mesh_")
|
||||
):
|
||||
cat = "wifi/mesh(sym)"
|
||||
else:
|
||||
cat = "other/idf"
|
||||
cats[cat] += size
|
||||
|
||||
print("=== category ===")
|
||||
for k, v in sorted(cats.items(), key=lambda x: -x[1]):
|
||||
print(f"{v/1024:6.2f} KB {k}")
|
||||
print(f"{sum(cats.values())/1024:6.2f} KB TOTAL")
|
||||
|
||||
print("\n=== app(main) ===")
|
||||
app.sort(reverse=True)
|
||||
s = 0
|
||||
for size, name, obj in app:
|
||||
s += size
|
||||
print(f"{size:5d} {demangle(name)}")
|
||||
print(f"app sum {s} ({s/1024:.2f} KB)")
|
||||
|
||||
print("\n=== top 20 overall ===")
|
||||
rows.sort(reverse=True)
|
||||
for size, name, obj in rows[:20]:
|
||||
short = obj.split("/")[-1] if obj else "?"
|
||||
print(f"{size:5d} ({size/1024:5.2f}K) {demangle(name):45s} {short[:28]}")
|
||||
@@ -1,21 +0,0 @@
|
||||
Threshold: >128 bytes (prefix+body+newline, UTF-8)
|
||||
Candidates: 5
|
||||
|
||||
# est body pre lv tag location
|
||||
----------------------------------------------------------------------------------------------------
|
||||
1 176 157 18 DBG TAG main/server/ble_ota_data.cpp:100
|
||||
fmt: BLEp(%s) %.1f-%.1f ce=%u-%u rc=%d
|
||||
args: %s鈫?6:windows_compatible ? "Win" : "Hi"
|
||||
2 153 134 18 WARN kTag main/monitor/can_feedback_task.cpp:122
|
||||
fmt: CH%u s=%lu e=%lu T=%u R=%u st=%02X
|
||||
3 153 134 18 DBG TAG main/server/ble_ota_data.cpp:69
|
||||
fmt: %sitv=%.2f lat=%u sup=%u
|
||||
args: %s鈫?8:phase
|
||||
4 141 122 18 INFO TAG main/server/ble_ota_host.cpp:545
|
||||
fmt: PHY %d %s/%s
|
||||
args: %s鈫?8:rx_phy; %s鈫?8:tx_phy
|
||||
5 135 116 18 ERR TAG main/ota/esp32_ota_core.cpp:227
|
||||
fmt: ota_write %s n=%u off=%u free=%lu
|
||||
args: %s鈫?2:esp_err_to_name(err)
|
||||
|
||||
Total over 128B: 5
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
# Match DEBUG_*(tag, "..." PRI... "..." ...)
|
||||
pat = re.compile(
|
||||
r'DEBUG_(ERR|WARN|INFO|DBG)\s*\(\s*([^,]+)\s*,\s*'
|
||||
r'((?:"(?:\\.|[^"\\])*"\s*(?:PRIu\d+|PRIx\d+|PRId\d+|PRIX\d+)?\s*)+)',
|
||||
re.M,
|
||||
)
|
||||
|
||||
rows = []
|
||||
for path in (ROOT / "main").rglob("*.cpp"):
|
||||
t = path.read_text(encoding="utf-8")
|
||||
for m in pat.finditer(t):
|
||||
raw = m.group(3)
|
||||
parts = re.findall(r'"((?:\\.|[^"\\])*)"', raw)
|
||||
if not parts:
|
||||
continue
|
||||
joined = "".join(parts)
|
||||
# replace PRI holes with %u placeholder length 10 each
|
||||
pri_count = len(re.findall(r"PRI[udxX]\d+", raw))
|
||||
# rough: joined has empty holes between lits around PRI
|
||||
body = len(joined.encode()) + pri_count * 10
|
||||
# also % in joined
|
||||
body += joined.count("%") * 8 # remaining %
|
||||
if body < 100:
|
||||
continue
|
||||
ln = t.count("\n", 0, m.start()) + 1
|
||||
rows.append((body, path.relative_to(ROOT).as_posix(), ln, joined[:100], pri_count))
|
||||
|
||||
rows.sort(reverse=True)
|
||||
print(f"Long concat fmt candidates: {len(rows)}")
|
||||
for b, f, ln, j, pc in rows[:40]:
|
||||
print(f"{b:4d} {f}:{ln} pri={pc}")
|
||||
print(f" {j}")
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scan DEBUG_* with realistic arg widths (actual-ish max line size)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
THRESHOLD = 128
|
||||
|
||||
PAT = re.compile(
|
||||
r'DEBUG_(ERR|WARN|INFO|DBG)\s*\(\s*([^,]+)\s*,\s*"((?:\\.|[^"\\])*)"\s*(?:,|\))',
|
||||
re.M,
|
||||
)
|
||||
|
||||
# Known TAG macros
|
||||
KNOWN_TAGS = {
|
||||
"TAG": 7, # worst of BLE_OTA/OTA_CORE/OTA_COORD
|
||||
"kTag": 8,
|
||||
}
|
||||
|
||||
|
||||
def unescape(fmt: str) -> str:
|
||||
return fmt.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"')
|
||||
|
||||
|
||||
def parse_args(text: str, fmt_end: int) -> list[str]:
|
||||
i = fmt_end
|
||||
while i < len(text) and text[i] in " \t\r\n":
|
||||
i += 1
|
||||
if i < len(text) and text[i] == ")":
|
||||
return []
|
||||
if i < len(text) and text[i] == ",":
|
||||
i += 1
|
||||
args, depth, start = [], 0, i
|
||||
in_str, esc = False, False
|
||||
while i < len(text):
|
||||
c = text[i]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif c == "\\":
|
||||
esc = True
|
||||
elif c == '"':
|
||||
in_str = False
|
||||
i += 1
|
||||
continue
|
||||
if c == '"':
|
||||
in_str = True
|
||||
i += 1
|
||||
continue
|
||||
if c in "([{":
|
||||
depth += 1
|
||||
elif c in ")]}":
|
||||
if depth == 0 and c == ")":
|
||||
part = text[start:i].strip()
|
||||
if part:
|
||||
args.append(part)
|
||||
break
|
||||
depth -= 1
|
||||
elif c == "," and depth == 0:
|
||||
part = text[start:i].strip()
|
||||
if part:
|
||||
args.append(part)
|
||||
start = i + 1
|
||||
i += 1
|
||||
if i - start > 500:
|
||||
break
|
||||
return args
|
||||
|
||||
|
||||
def s_est(arg: str) -> int:
|
||||
a = arg.strip()
|
||||
if re.search(r"frontend_sha|device_hex|device_sha|sha_hex", a, re.I):
|
||||
return 64
|
||||
if re.search(r"resp_hex", a, re.I):
|
||||
return 64 # should not dump full anymore
|
||||
if "esp_err_to_name" in a:
|
||||
return 24
|
||||
if re.search(r"stateToString|phase|label|name|rx_phy|tx_phy", a, re.I):
|
||||
return 16
|
||||
m = re.match(r'^"((?:\\.|[^"\\])*)"$', a)
|
||||
if m:
|
||||
return len(unescape(m.group(1)).encode())
|
||||
if "?" in a and ":" in a:
|
||||
# ternary of string lits
|
||||
parts = re.findall(r'"((?:\\.|[^"\\])*)"', a)
|
||||
if parts:
|
||||
return max(len(unescape(p).encode()) for p in parts)
|
||||
return 8
|
||||
return 16
|
||||
|
||||
|
||||
def body_est(fmt: str, args: list[str]) -> int:
|
||||
i, n, out, ai = 0, len(fmt), 0, 0
|
||||
|
||||
def nexta() -> str:
|
||||
nonlocal ai
|
||||
if ai < len(args):
|
||||
a = args[ai]
|
||||
ai += 1
|
||||
return a
|
||||
return ""
|
||||
|
||||
while i < n:
|
||||
if fmt[i] != "%":
|
||||
j = fmt.find("%", i)
|
||||
if j < 0:
|
||||
out += len(fmt[i:].encode())
|
||||
break
|
||||
out += len(fmt[i:j].encode())
|
||||
i = j
|
||||
continue
|
||||
if i + 1 < n and fmt[i + 1] == "%":
|
||||
out += 1
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
while i < n and fmt[i] in "-+ #0":
|
||||
i += 1
|
||||
if i < n and fmt[i] == "*":
|
||||
nexta()
|
||||
i += 1
|
||||
else:
|
||||
while i < n and fmt[i].isdigit():
|
||||
i += 1
|
||||
if i < n and fmt[i] == ".":
|
||||
i += 1
|
||||
if i < n and fmt[i] == "*":
|
||||
nexta()
|
||||
i += 1
|
||||
else:
|
||||
while i < n and fmt[i].isdigit():
|
||||
i += 1
|
||||
while i < n and fmt[i] in "hlLzjtq":
|
||||
i += 1
|
||||
if i >= n:
|
||||
break
|
||||
conv = fmt[i]
|
||||
i += 1
|
||||
arg = nexta()
|
||||
if conv in "diouxX":
|
||||
out += 10 # realistic
|
||||
elif conv in "fFeEgG":
|
||||
out += 8
|
||||
elif conv == "s":
|
||||
out += s_est(arg)
|
||||
elif conv == "c":
|
||||
out += 1
|
||||
elif conv == "p":
|
||||
out += 10
|
||||
else:
|
||||
out += 8
|
||||
return out
|
||||
|
||||
|
||||
def tag_len(expr: str) -> tuple[int, str]:
|
||||
e = expr.strip()
|
||||
m = re.match(r'^"([^"]*)"$', e)
|
||||
if m:
|
||||
return len(m.group(1).encode()), m.group(1)
|
||||
if e in KNOWN_TAGS:
|
||||
return KNOWN_TAGS[e], e
|
||||
return 8, e
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rows = []
|
||||
for path in ROOT.rglob("*"):
|
||||
if path.suffix not in {".cpp", ".c", ".h"}:
|
||||
continue
|
||||
if "build" in path.parts or ".git" in path.parts:
|
||||
continue
|
||||
if "components" in path.parts:
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
for m in PAT.finditer(text):
|
||||
level, tag_expr, fmt_raw = m.group(1), m.group(2), m.group(3)
|
||||
fmt = unescape(fmt_raw).rstrip("\n\r")
|
||||
args = parse_args(text, m.end(3) + 1)
|
||||
body = body_est(fmt, args)
|
||||
tlen, tag = tag_len(tag_expr)
|
||||
lc = {"ERR": "E", "WARN": "W", "INFO": "I", "DBG": "D"}[level]
|
||||
if tag_expr.strip().startswith('"'):
|
||||
prefix = len(f"[{lc}][{tag}] ".encode())
|
||||
else:
|
||||
prefix = len(f"[{lc}][".encode()) + tlen + len("] ".encode())
|
||||
total = prefix + body + 1
|
||||
if total <= THRESHOLD:
|
||||
continue
|
||||
ln = text.count("\n", 0, m.start()) + 1
|
||||
rows.append((total, body, prefix, level, tag, rel, ln, fmt))
|
||||
rows.sort(reverse=True)
|
||||
print(f"Realistic threshold >{THRESHOLD}B: {len(rows)} candidates\n")
|
||||
for i, (total, body, pre, lv, tag, rel, ln, fmt) in enumerate(rows, 1):
|
||||
fs = fmt if len(fmt) <= 90 else fmt[:87] + "..."
|
||||
print(f"{i:3d} {total:4d} body={body:3d} pre={pre:2d} {lv:4s} {str(tag)[:10]:10s} {rel}:{ln}")
|
||||
print(f" {fs}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,130 +0,0 @@
|
||||
"Archive File","Total Size","DRAM",".bss",".data",".text","Flash
|
||||
Code",".text","Flash Data",".tdata",".rodata",".appdesc","RTC
|
||||
SLOW",".rtc_reserved",".force_slow"
|
||||
"libnet80211.a","154301","12686","7678","936","4072","128198","128198","13417","
|
||||
0","13417","0","0","0","0"
|
||||
"libesp_stdio.a","114154","16","16","0","0","402","402","113736","0","113736","0
|
||||
","0","0","0"
|
||||
"libbtdm_app.a","98094","20783","684","481","19618","72152","72152","5159","0","
|
||||
5159","0","0","0","0"
|
||||
"liblwip.a","88946","2602","2590","12","0","82916","82916","3428","0","3428","0"
|
||||
,"0","0","0"
|
||||
"libmain.a","85209","26097","22673","3264","160","57804","57804","1308","0","130
|
||||
8","0","0","0","0"
|
||||
"libpp.a","68522","19794","1262","2644","15888","44814","44814","3914","0","3914
|
||||
","0","0","0","0"
|
||||
"libmbed-builtin.a","57570","60","12","48","0","45710","45710","11800","0","1180
|
||||
0","0","0","0","0"
|
||||
"libbt.a","56078","1060","139","233","688","51916","51916","3102","0","3102","0"
|
||||
,"0","0","0"
|
||||
"libwpa_supplicant.a","37603","1138","1138","0","0","36390","36390","75","0","75
|
||||
","0","0","0","0"
|
||||
"libphy.a","36923","5801","87","1312","4402","31122","31122","0","0","0","0","0"
|
||||
,"0","0"
|
||||
"libtfpsacrypto.a","24703","562","400","80","82","23844","23844","297","0","297"
|
||||
,"0","0","0","0"
|
||||
"libfreertos.a","20942","17688","2560","16","15112","1690","1690","1564","0","15
|
||||
64","0","0","0","0"
|
||||
"libesp_hw_support.a","20186","6251","145","160","5946","13170","13170","705","0
|
||||
","705","0","60","24","36"
|
||||
"libspi_flash.a","17438","15081","10","5867","9204","1322","1322","1035","0","10
|
||||
35","0","0","0","0"
|
||||
"libc.a","14702","4","0","4","0","13610","13610","1088","0","1088","0","0","0","
|
||||
0"
|
||||
"libnvs_flash.a","14264","24","24","0","0","14108","14108","132","0","132","0","
|
||||
0","0","0"
|
||||
"libtransport.a","14237","1271","50","1145","76","11942","11942","1024","0","102
|
||||
4","0","0","0","0"
|
||||
"libhttp_parser.a","12694","0","0","0","0","11980","11980","714","0","714","0","
|
||||
0","0","0"
|
||||
"libesp_driver_uart.a","11468","225","25","200","0","10782","10782","461","0","4
|
||||
61","0","0","0","0"
|
||||
"libesp_system.a","11009","1595","142","49","1404","8866","8866","548","0","548"
|
||||
,"0","0","0","0"
|
||||
"libesp_http_server.a","10867","0","0","0","0","10528","10528","339","0","339","
|
||||
0","0","0","0"
|
||||
"libheap.a","10856","6894","8","4","6882","3240","3240","722","0","722","0","0",
|
||||
"0","0"
|
||||
"libesp_driver_spi.a","10004","3507","8","131","3368","6068","6068","429","0","4
|
||||
29","0","0","0","0"
|
||||
"libesp_netif.a","7572","37","29","8","0","7304","7304","231","0","231","0","0",
|
||||
"0","0"
|
||||
"libcoexist.a","6051","461","6","301","154","4316","4316","1274","0","1274","0",
|
||||
"0","0","0"
|
||||
"libesp_driver_gpio.a","6008","216","0","36","180","5238","5238","554","0","554"
|
||||
,"0","0","0","0"
|
||||
"libesp_libc.a","5358","3584","564","324","2696","1622","1622","152","16","136",
|
||||
"0","0","0","0"
|
||||
"libesp_wifi.a","5144","703","43","480","180","3618","3618","823","0","823","0",
|
||||
"0","0","0"
|
||||
"libcjson.a","5116","20","8","12","0","5096","5096","0","0","0","0","0","0","0"
|
||||
"libvfs.a","5051","236","44","192","0","4676","4676","139","0","139","0","0","0"
|
||||
,"0"
|
||||
"libesp_ringbuf.a","4860","2894","0","0","2894","1492","1492","474","0","474","0
|
||||
","0","0","0"
|
||||
"libesp_hal_mspi.a","4366","3792","0","36","3756","574","574","0","0","0","0","0
|
||||
","0","0"
|
||||
"libbootloader_support.a","4284","686","8","0","678","3510","3510","88","0","88"
|
||||
,"0","0","0","0"
|
||||
"libesp_mm.a","4183","1325","48","103","1174","2644","2644","214","0","214","0",
|
||||
"0","0","0"
|
||||
"libesp_event.a","3691","4","4","0","0","3546","3546","141","0","141","0","0","0
|
||||
","0"
|
||||
"libesp_phy.a","3455","248","51","9","188","2908","2908","299","0","299","0","0"
|
||||
,"0","0"
|
||||
"libesp_timer.a","3035","1364","36","16","1312","1580","1580","91","0","91","0",
|
||||
"0","0","0"
|
||||
"libapp_update.a","2996","12","12","0","0","2922","2922","62","0","62","0","0","
|
||||
0","0"
|
||||
"libbtbb.a","2868","0","0","0","0","2868","2868","0","0","0","0","0","0","0"
|
||||
"libesp_partition.a","2796","8","8","0","0","2620","2620","168","0","168","0","0
|
||||
","0","0"
|
||||
"libesp_driver_dma.a","2774","140","0","12","128","2532","2532","102","0","102",
|
||||
"0","0","0","0"
|
||||
"libhal.a","2564","2212","5","235","1972","352","352","0","0","0","0","0","0","0
|
||||
"
|
||||
"libesp_hal_gpspi.a","2550","2194","0","20","2174","284","284","72","0","72","0"
|
||||
,"0","0","0"
|
||||
"libefuse.a","2461","320","4","316","0","1654","1654","487","0","487","0","0","0
|
||||
","0"
|
||||
"libesp_driver_usb_serial_jtag.a","2172","157","17","48","92","1874","1874","141
|
||||
","0","141","0","0","0","0"
|
||||
"libstdc++.a","2056","33","25","8","0","1664","1664","359","0","359","0","0","0"
|
||||
,"0"
|
||||
"libesp_driver_tsens.a","1942","8","4","4","0","1714","1714","220","0","220","0"
|
||||
,"0","0","0"
|
||||
"liblog.a","1894","700","276","8","416","1146","1146","48","0","48","0","0","0",
|
||||
"0"
|
||||
"libesp_common.a","1774","0","0","0","0","48","48","1726","0","1726","0","0","0"
|
||||
,"0"
|
||||
"libesp_hal_ana_conv.a","1559","9","0","9","0","1450","1450","100","0","100","0"
|
||||
,"0","0","0"
|
||||
"libriscv.a","1294","1112","256","0","856","132","132","50","0","50","0","0","0"
|
||||
,"0"
|
||||
"libesp_hal_security.a","1260","0","0","0","0","1260","1260","0","0","0","0","0"
|
||||
,"0","0"
|
||||
"libpthread.a","1223","4","4","0","0","1130","1130","89","0","89","0","0","0","0
|
||||
"
|
||||
"libesp_hal_dma.a","1022","366","0","4","362","656","656","0","0","0","0","0","0
|
||||
","0"
|
||||
"libesp_hal_wdt.a","1005","1005","0","21","984","0","0","0","0","0","0","0","0",
|
||||
"0"
|
||||
"libesp_security.a","922","12","12","0","0","902","902","8","0","8","0","0","0",
|
||||
"0"
|
||||
"libesp_app_format.a","770","10","10","0","0","496","496","264","0","8","256","0
|
||||
","0","0"
|
||||
"libcxx.a","740","16","16","0","0","724","724","0","0","0","0","0","0","0"
|
||||
"libesp_hal_uart.a","674","0","0","0","0","618","618","56","0","56","0","0","0",
|
||||
"0"
|
||||
"libesp_hal_gpio.a","468","0","0","0","0","440","440","28","0","28","0","0","0",
|
||||
"0"
|
||||
"libesp_coex.a","428","154","0","80","74","274","274","0","0","0","0","0","0","0
|
||||
"
|
||||
"libesp_hal_clock.a","404","0","0","0","0","404","404","0","0","0","0","0","0","
|
||||
0"
|
||||
"libcore.a","366","9","9","0","0","314","314","43","0","43","0","0","0","0"
|
||||
"libsoc.a","364","0","0","0","0","0","0","364","0","364","0","0","0","0"
|
||||
"libmesh.a","64","4","4","0","0","60","60","0","0","0","0","0","0","0"
|
||||
"libmbedtls.a","32","0","0","0","0","24","24","8","0","8","0","0","0","0"
|
||||
"libespnow.a","3","3","0","3","0","0","0","0","0","0","0","0","0","0"
|
||||
"libnvs_sec_provider.a","2","0","0","0","0","2","2","0","0","0","0","0","0","0"
|
||||
|
@@ -1,217 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scan DEBUG_* format strings and estimate max expanded line length (not LOG_RING_SIZE)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
PAT = re.compile(
|
||||
r"DEBUG_(ERR|WARN|INFO|DBG)\s*\(\s*([^,]+)\s*,\s*\"((?:\\.|[^\"\\])*)\"",
|
||||
re.M,
|
||||
)
|
||||
|
||||
|
||||
def unescape_fmt(fmt: str) -> str:
|
||||
try:
|
||||
return bytes(fmt, "utf-8").decode("unicode_escape")
|
||||
except Exception:
|
||||
return (
|
||||
fmt.replace("\\n", "\n")
|
||||
.replace("\\r", "\r")
|
||||
.replace("\\t", "\t")
|
||||
.replace('\\"', '"')
|
||||
)
|
||||
|
||||
|
||||
def fmt_max_len(fmt: str) -> tuple[int, bool]:
|
||||
"""Return (max_len, unbounded_string) for fmt body (without our [L][tag] prefix)."""
|
||||
i = 0
|
||||
n = len(fmt)
|
||||
out = 0
|
||||
unbounded = False
|
||||
while i < n:
|
||||
if fmt[i] != "%":
|
||||
j = fmt.find("%", i)
|
||||
if j < 0:
|
||||
out += n - i
|
||||
break
|
||||
out += j - i
|
||||
i = j
|
||||
continue
|
||||
if i + 1 < n and fmt[i + 1] == "%":
|
||||
out += 1
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
while i < n and fmt[i] in "-+ #0":
|
||||
i += 1
|
||||
width = 0
|
||||
if i < n and fmt[i] == "*":
|
||||
i += 1
|
||||
else:
|
||||
while i < n and fmt[i].isdigit():
|
||||
width = width * 10 + int(fmt[i])
|
||||
i += 1
|
||||
prec = None
|
||||
if i < n and fmt[i] == ".":
|
||||
i += 1
|
||||
if i < n and fmt[i] == "*":
|
||||
prec = 0
|
||||
i += 1
|
||||
else:
|
||||
prec = 0
|
||||
while i < n and fmt[i].isdigit():
|
||||
prec = prec * 10 + int(fmt[i])
|
||||
i += 1
|
||||
while i < n and fmt[i] in "hlLzjtq":
|
||||
i += 1
|
||||
if i >= n:
|
||||
break
|
||||
conv = fmt[i]
|
||||
i += 1
|
||||
if conv in "diouxX":
|
||||
est = 20 if conv in "diou" else 16
|
||||
if prec is not None:
|
||||
est = max(est, prec)
|
||||
if width:
|
||||
est = max(est, width)
|
||||
out += est
|
||||
elif conv in "fFeEgG":
|
||||
est = 48 # long double-ish
|
||||
if prec is not None:
|
||||
est = max(est, prec + 16)
|
||||
if width:
|
||||
est = max(est, width)
|
||||
out += est
|
||||
elif conv == "s":
|
||||
if prec is not None:
|
||||
est = prec
|
||||
else:
|
||||
# no precision: could be long; use 128 as aggressive scan bound + flag
|
||||
est = 128
|
||||
unbounded = True
|
||||
if width:
|
||||
est = max(est, width)
|
||||
out += est
|
||||
elif conv == "c":
|
||||
out += max(1, width or 1)
|
||||
elif conv == "p":
|
||||
out += 18
|
||||
elif conv == "PRIu32" or True:
|
||||
# leftover / unknown after PRI macros already expanded in source rarely
|
||||
if conv not in "diouxXfFeEgGscp%":
|
||||
out += 12
|
||||
else:
|
||||
out += 8
|
||||
else:
|
||||
out += 8
|
||||
return out, unbounded
|
||||
|
||||
|
||||
def tag_len(tag_expr: str) -> tuple[int, str]:
|
||||
tag_expr = tag_expr.strip()
|
||||
m = re.match(r'^"([^"]*)"$', tag_expr)
|
||||
if m:
|
||||
return len(m.group(1)), m.group(1)
|
||||
# TAG macro / variable: measure common names
|
||||
if tag_expr in ("TAG", "kTag", "tag"):
|
||||
return 12, tag_expr
|
||||
return max(len(tag_expr), 8), tag_expr
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rows = []
|
||||
for path in ROOT.rglob("*"):
|
||||
if path.suffix not in {".cpp", ".c", ".h"}:
|
||||
continue
|
||||
parts = path.parts
|
||||
if "build" in parts or ".git" in parts:
|
||||
continue
|
||||
if "components" in parts and "cjson" in parts:
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
for m in PAT.finditer(text):
|
||||
level, tag_expr, fmt_raw = m.group(1), m.group(2), m.group(3)
|
||||
fmt = unescape_fmt(fmt_raw)
|
||||
# strip trailing newline in fmt for body math; we add +1 for forced \n
|
||||
body_src = fmt.rstrip("\n\r")
|
||||
body, unb = fmt_max_len(body_src)
|
||||
tl, tag = tag_len(tag_expr)
|
||||
prefix = 6 + tl # [L][tag]_ e.g. [I][WIFI]
|
||||
total = prefix + body + 1 # + newline
|
||||
# line number
|
||||
line_no = text.count("\n", 0, m.start()) + 1
|
||||
rows.append(
|
||||
{
|
||||
"total": total,
|
||||
"body": body,
|
||||
"prefix": prefix,
|
||||
"unbounded": unb,
|
||||
"level": level,
|
||||
"tag": tag,
|
||||
"fmt": body_src,
|
||||
"file": rel,
|
||||
"line": line_no,
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: (-r["total"], r["file"], r["line"]))
|
||||
print(f"DEBUG_* sites: {len(rows)}")
|
||||
print(f"design cap LOG_RING_SIZE=128 → usable max line = 127 bytes\n")
|
||||
print("--- Top 50 by estimated max total ([L][tag]+body+\\n) ---")
|
||||
print(f"{'est':>5} {'body':>5} {'pre':>3} {'>%159':>5} level tag file:line")
|
||||
for r in rows[:50]:
|
||||
over = "YES" if r["total"] > 159 else ""
|
||||
unb = "*" if r["unbounded"] else " "
|
||||
fmt_short = r["fmt"].replace("\n", "\\n")
|
||||
if len(fmt_short) > 70:
|
||||
fmt_short = fmt_short[:67] + "..."
|
||||
print(
|
||||
f"{r['total']:5d}{unb}{r['body']:5d} {r['prefix']:3d} {over:>5} "
|
||||
f"{r['level']:4s} {r['tag'][:14]:14s} {r['file']}:{r['line']}"
|
||||
)
|
||||
print(f" {fmt_short}")
|
||||
|
||||
print("\n--- length histogram (estimated total) ---")
|
||||
bins = [(0, 80), (81, 110), (111, 127), (128, 160), (161, 200), (201, 99999)]
|
||||
for lo, hi in bins:
|
||||
c = sum(1 for r in rows if lo <= r["total"] <= hi)
|
||||
print(f" {lo:5d}-{hi:5d}: {c:4d}")
|
||||
|
||||
over = [r for r in rows if r["total"] > 159]
|
||||
unb = [r for r in rows if r["unbounded"]]
|
||||
print(f"\nSites with est > 159 (would truncate in ring): {len(over)}")
|
||||
print(f"Sites with unbounded %s (est used 128 for %s): {len(unb)}")
|
||||
|
||||
# Also report pure fmt string lengths (no conversion expansion)
|
||||
print("\n--- Top 20 by format string literal length only ---")
|
||||
by_lit = sorted(rows, key=lambda r: (-len(r["fmt"]), r["file"]))
|
||||
for r in by_lit[:20]:
|
||||
print(f" lit={len(r['fmt']):3d} est={r['total']:4d} {r['file']}:{r['line']} {r['fmt'][:80]!r}")
|
||||
|
||||
# max among sites without unbounded %s
|
||||
bounded = [r for r in rows if not r["unbounded"]]
|
||||
if bounded:
|
||||
m = max(bounded, key=lambda r: r["total"])
|
||||
print(
|
||||
f"\nMax est among bounded formats (no open %s): "
|
||||
f"{m['total']} B @ {m['file']}:{m['line']} tag={m['tag']}"
|
||||
)
|
||||
print(f" fmt={m['fmt'][:160]!r}")
|
||||
|
||||
# For unbounded, try better estimate: common %s sources
|
||||
print("\n--- Unbounded %s samples (need runtime arg max) ---")
|
||||
for r in unb[:15]:
|
||||
print(f" est~{r['total']} {r['file']}:{r['line']} {r['fmt'][:100]!r}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List DEBUG_* call sites that can exceed 128 bytes (UTF-8), longest first.
|
||||
|
||||
Uses realistic bounds for known %s sources (SHA 64, SPI hex, esp_err_to_name, etc.).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
THRESHOLD = 128 # bytes, including [L][TAG] prefix + body + \\n
|
||||
|
||||
PAT = re.compile(
|
||||
r"DEBUG_(ERR|WARN|INFO|DBG)\s*\(\s*([^,]+)\s*,\s*\"((?:\\.|[^\"\\])*)\"\s*(?:,|\))",
|
||||
re.M,
|
||||
)
|
||||
|
||||
# When we can see following args in a window, classify %s
|
||||
ARG_WIN = 200
|
||||
|
||||
|
||||
def unescape_fmt(fmt: str) -> str:
|
||||
try:
|
||||
return bytes(fmt, "utf-8").decode("unicode_escape")
|
||||
except Exception:
|
||||
return fmt.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t")
|
||||
|
||||
|
||||
def tag_info(tag_expr: str) -> tuple[int, str]:
|
||||
tag_expr = tag_expr.strip()
|
||||
m = re.match(r'^"([^"]*)"$', tag_expr)
|
||||
if m:
|
||||
return len(m.group(1).encode("utf-8")), m.group(1)
|
||||
# TAG / kTag variable — common values up to ~12
|
||||
return 12, tag_expr
|
||||
|
||||
|
||||
def classify_s_arg(arg: str) -> int:
|
||||
"""Max bytes for a %s argument expression (UTF-8)."""
|
||||
a = arg.strip()
|
||||
# SHA hex buffers
|
||||
if re.search(r"sha|hex|device_hex|frontend_sha", a, re.I):
|
||||
if "64" in a or "sha" in a.lower() or "hex" in a.lower():
|
||||
# device_hex / frontend_sha_hex are 64-char C strings
|
||||
if re.search(r"frontend_sha|device_hex|device_sha|out_device", a, re.I):
|
||||
return 64
|
||||
if re.search(r"resp_hex|payload_hex|hex\[", a, re.I):
|
||||
return 512 # worst unless capped in call site
|
||||
return 64
|
||||
if re.search(r"resp_hex", a, re.I):
|
||||
return 512 # SPI_OTA_FRAME_MAX*2
|
||||
if re.search(r"\bhex\b", a, re.I):
|
||||
# local hex[] in ota_relay often 64–72 chars
|
||||
return 72
|
||||
if "esp_err_to_name" in a:
|
||||
return 32
|
||||
if re.search(r"FW_VERSION|GIT_HASH|BUILD_TIME|version", a, re.I):
|
||||
return 16
|
||||
if re.search(r"partition|label|name|pcTaskName|task", a, re.I):
|
||||
return 16
|
||||
if re.search(r"stage|TAG|tag", a, re.I):
|
||||
return 24
|
||||
if re.search(r"ssid|ip_str|ip", a, re.I):
|
||||
return 32
|
||||
# string literals
|
||||
m = re.match(r'^"((?:\\.|[^"\\])*)"$', a)
|
||||
if m:
|
||||
return len(unescape_fmt(m.group(1)).encode("utf-8"))
|
||||
# ternary "...":"..."
|
||||
if "?" in a and ":" in a:
|
||||
return 16
|
||||
return 48 # generic unknown %s
|
||||
|
||||
|
||||
def parse_args_after_fmt(text: str, fmt_end: int) -> list[str]:
|
||||
"""Rough parse of C args after the format string until closing paren of DEBUG_*."""
|
||||
# fmt ends at quote; skip ", then args
|
||||
i = fmt_end
|
||||
# find the comma after format or )
|
||||
while i < len(text) and text[i] in " \t\r\n":
|
||||
i += 1
|
||||
if i < len(text) and text[i] == ")":
|
||||
return []
|
||||
if i < len(text) and text[i] == ",":
|
||||
i += 1
|
||||
args: list[str] = []
|
||||
depth = 0
|
||||
start = i
|
||||
in_str = False
|
||||
esc = False
|
||||
while i < len(text):
|
||||
c = text[i]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif c == "\\":
|
||||
esc = True
|
||||
elif c == '"':
|
||||
in_str = False
|
||||
i += 1
|
||||
continue
|
||||
if c == '"':
|
||||
in_str = True
|
||||
i += 1
|
||||
continue
|
||||
if c in "([{":
|
||||
depth += 1
|
||||
elif c in ")]}":
|
||||
if depth == 0 and c == ")":
|
||||
part = text[start:i].strip()
|
||||
if part:
|
||||
args.append(part)
|
||||
break
|
||||
depth -= 1
|
||||
elif c == "," and depth == 0:
|
||||
part = text[start:i].strip()
|
||||
if part:
|
||||
args.append(part)
|
||||
start = i + 1
|
||||
i += 1
|
||||
if i - start > 500:
|
||||
break
|
||||
return args
|
||||
|
||||
|
||||
def body_max_bytes(fmt: str, args: list[str]) -> tuple[int, list[str]]:
|
||||
"""Walk fmt and consume args for max expanded body length in UTF-8 bytes."""
|
||||
# Work on UTF-8: literal parts as utf-8 len
|
||||
i = 0
|
||||
n = len(fmt)
|
||||
out = 0
|
||||
arg_i = 0
|
||||
notes: list[str] = []
|
||||
|
||||
def next_arg() -> str:
|
||||
nonlocal arg_i
|
||||
if arg_i < len(args):
|
||||
a = args[arg_i]
|
||||
arg_i += 1
|
||||
return a
|
||||
return ""
|
||||
|
||||
while i < n:
|
||||
if fmt[i] != "%":
|
||||
j = fmt.find("%", i)
|
||||
if j < 0:
|
||||
out += len(fmt[i:].encode("utf-8"))
|
||||
break
|
||||
out += len(fmt[i:j].encode("utf-8"))
|
||||
i = j
|
||||
continue
|
||||
if i + 1 < n and fmt[i + 1] == "%":
|
||||
out += 1
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
while i < n and fmt[i] in "-+ #0":
|
||||
i += 1
|
||||
width = 0
|
||||
if i < n and fmt[i] == "*":
|
||||
next_arg() # width arg
|
||||
i += 1
|
||||
else:
|
||||
while i < n and fmt[i].isdigit():
|
||||
width = width * 10 + int(fmt[i])
|
||||
i += 1
|
||||
prec = None
|
||||
if i < n and fmt[i] == ".":
|
||||
i += 1
|
||||
if i < n and fmt[i] == "*":
|
||||
next_arg()
|
||||
prec = 0
|
||||
i += 1
|
||||
else:
|
||||
prec = 0
|
||||
while i < n and fmt[i].isdigit():
|
||||
prec = prec * 10 + int(fmt[i])
|
||||
i += 1
|
||||
# length
|
||||
while i < n and fmt[i] in "hlLzjtq":
|
||||
i += 1
|
||||
# PRI macros might appear as broken sequence in source - rare
|
||||
if i >= n:
|
||||
break
|
||||
conv = fmt[i]
|
||||
i += 1
|
||||
arg = next_arg()
|
||||
if conv in "diouxX":
|
||||
est = 20 if conv in "diou" else 16
|
||||
if prec is not None:
|
||||
est = max(est, prec)
|
||||
if width:
|
||||
est = max(est, width)
|
||||
out += est
|
||||
elif conv in "fFeEgG":
|
||||
est = 32
|
||||
if prec is not None:
|
||||
est = max(est, prec + 8)
|
||||
if width:
|
||||
est = max(est, width)
|
||||
out += est
|
||||
elif conv == "s":
|
||||
if prec is not None:
|
||||
est = prec
|
||||
else:
|
||||
est = classify_s_arg(arg)
|
||||
notes.append(f"%s←{est}:{arg[:40]}")
|
||||
if width:
|
||||
est = max(est, width)
|
||||
out += est
|
||||
elif conv == "c":
|
||||
out += max(1, width or 1)
|
||||
elif conv == "p":
|
||||
out += 18
|
||||
else:
|
||||
# maybe start of PRIu32 in string - treat as literal leftover
|
||||
out += 12
|
||||
return out, notes
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rows = []
|
||||
for path in ROOT.rglob("*"):
|
||||
if path.suffix not in {".cpp", ".c", ".h"}:
|
||||
continue
|
||||
if "build" in path.parts or ".git" in path.parts:
|
||||
continue
|
||||
if "components" in path.parts and "cjson" in path.parts:
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
for m in PAT.finditer(text):
|
||||
level, tag_expr, fmt_raw = m.group(1), m.group(2), m.group(3)
|
||||
fmt = unescape_fmt(fmt_raw).rstrip("\n\r")
|
||||
# end of format string in source: position after closing quote of fmt
|
||||
# m.end() is after last "; use group 3 span
|
||||
fmt_end = m.end(3) + 1 # past closing "
|
||||
args = parse_args_after_fmt(text, fmt_end)
|
||||
body, notes = body_max_bytes(fmt, args)
|
||||
tlen, tag = tag_info(tag_expr)
|
||||
prefix = len(f"[{level[0] if False else ''}]") # placeholder
|
||||
# real prefix: [E]/[W]/[I]/[D] + [tag] + space
|
||||
level_c = {"ERR": "E", "WARN": "W", "INFO": "I", "DBG": "D"}.get(level, "?")
|
||||
prefix = len(f"[{level_c}][{tag}] ".encode("utf-8")) if tag_expr.strip().startswith('"') else (6 + tlen)
|
||||
if not tag_expr.strip().startswith('"'):
|
||||
# use tlen for tag byte len estimate (ASCII tags)
|
||||
prefix = 6 + tlen
|
||||
else:
|
||||
prefix = len(f"[{level_c}][{tag}] ".encode("utf-8"))
|
||||
total = prefix + body + 1 # + \\n
|
||||
if total <= THRESHOLD:
|
||||
continue
|
||||
line_no = text.count("\n", 0, m.start()) + 1
|
||||
rows.append(
|
||||
{
|
||||
"total": total,
|
||||
"body": body,
|
||||
"prefix": prefix,
|
||||
"level": level,
|
||||
"tag": tag if tag_expr.strip().startswith('"') else tag_expr.strip(),
|
||||
"fmt": fmt,
|
||||
"file": rel,
|
||||
"line": line_no,
|
||||
"notes": notes,
|
||||
"args": args,
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: (-r["total"], r["file"], r["line"]))
|
||||
print(f"Threshold: >{THRESHOLD} bytes (prefix+body+newline, UTF-8)")
|
||||
print(f"Candidates: {len(rows)}\n")
|
||||
print(f"{'#':>3} {'est':>5} {'body':>5} {'pre':>3} {'lv':4} {'tag':12} location")
|
||||
print("-" * 100)
|
||||
for i, r in enumerate(rows, 1):
|
||||
print(
|
||||
f"{i:3d} {r['total']:5d} {r['body']:5d} {r['prefix']:3d} "
|
||||
f"{r['level']:4s} {str(r['tag'])[:12]:12s} {r['file']}:{r['line']}"
|
||||
)
|
||||
fmt_show = r["fmt"].replace("\n", "\\n")
|
||||
if len(fmt_show) > 100:
|
||||
fmt_show = fmt_show[:97] + "..."
|
||||
print(f" fmt: {fmt_show}")
|
||||
if r["notes"]:
|
||||
print(f" args: {'; '.join(r['notes'][:6])}")
|
||||
print(f"\nTotal over {THRESHOLD}B: {len(rows)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
bump_asset_versions.py — 按本地静态资源内容 hash 自动更新 HTML 里的 ?v= 版本号。
|
||||
|
||||
动机
|
||||
----
|
||||
ESP32_COMM/tools 下多个 HTML(can.html / can_monitor.html / index.html)手工维护
|
||||
<link>/<script> 引用的 `?v=...` query 版本号。components.css 修改后部分页面没 bump,
|
||||
浏览器继续加载旧 CSS(审计 finding WEB-001)。
|
||||
|
||||
本脚本扫描所有 HTML 中形如
|
||||
<link ... href="css/foo.css?v=OLD">
|
||||
<script ... src="js/bar.js?v=OLD">
|
||||
的本地资源引用,按文件内容计算短 hash(sha1 前 8 位十六进制),把 OLD 替换成 hash。
|
||||
内容不变就不改,内容变了就 bump,所有页面引用同一资源时自动收敛到同一版本号。
|
||||
|
||||
用法
|
||||
----
|
||||
python bump_asset_versions.py # 默认 dry-run,只打印将要改动的行
|
||||
python bump_asset_versions.py --write # 实际写回 HTML
|
||||
python bump_asset_versions.py --check # CI 模式,发现任何不一致即返回非 0
|
||||
python bump_asset_versions.py --root PATH # 指定 tools 目录(默认脚本所在目录)
|
||||
|
||||
退出码
|
||||
------
|
||||
0 全部一致(dry-run 无改动 或 --write 成功 或 --check 通过)
|
||||
1 发现需要 bump 的引用(dry-run/--check 时)
|
||||
2 参数错误或文件 IO 错误
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 匹配 <link href="...?v=..."> 与 <script src="...?v=...">,仅本地相对路径(无 scheme/host)。
|
||||
# 路径里允许字母/数字/_-./ 子目录。query 部分仅捕获 v= 的值,保留其它 query key(若有)。
|
||||
ASSET_REF_RE = re.compile(
|
||||
r"""
|
||||
(?P<tag>(?:href|src)\s*=\s*") # 属性名 + =" 开头
|
||||
(?P<path>[A-Za-z0-9_./-]+\.(?:css|js)) # 本地资源路径(.css 或 .js)
|
||||
\?v=(?P<old>[^"&\s]+) # ?v= 版本号
|
||||
(?P<rest>"?) # 收尾引号(保留)
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def short_hash(content: bytes) -> str:
|
||||
"""sha1 前 8 位十六进制;8 位足够区分日常 bump,且 URL 友好。"""
|
||||
return hashlib.sha1(content).hexdigest()[:8]
|
||||
|
||||
|
||||
def find_html_files(root: Path) -> list[Path]:
|
||||
return sorted(p for p in root.glob("*.html") if p.is_file())
|
||||
|
||||
|
||||
def process_html(html_path: Path) -> tuple[list[str], str]:
|
||||
"""
|
||||
返回 (new_lines, new_text)。
|
||||
new_lines 是"将被改动"的描述列表(仅 dry-run/日志用),空列表表示无改动。
|
||||
new_text 是替换后的完整文本(无论是否有改动都返回)。
|
||||
"""
|
||||
text = html_path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines(keepends=True)
|
||||
changes: list[str] = []
|
||||
|
||||
def replace_in_line(line: str, line_no: int) -> str:
|
||||
def _sub(m: re.Match[str]) -> str:
|
||||
rel_path = m.group("path")
|
||||
old_ver = m.group("old")
|
||||
asset_path = html_path.parent / rel_path
|
||||
if not asset_path.is_file():
|
||||
# 资源缺失:不动,交给调用方在校验阶段报错
|
||||
return m.group(0)
|
||||
new_ver = short_hash(asset_path.read_bytes())
|
||||
if new_ver == old_ver:
|
||||
return m.group(0)
|
||||
changes.append(
|
||||
f" L{line_no}: {rel_path} {old_ver} -> {new_ver}"
|
||||
)
|
||||
return f'{m.group("tag")}{rel_path}?v={new_ver}{m.group("rest")}'
|
||||
|
||||
return ASSET_REF_RE.sub(_sub, line)
|
||||
|
||||
new_lines_text = [replace_in_line(line, i + 1) for i, line in enumerate(lines)]
|
||||
return changes, "".join(new_lines_text)
|
||||
|
||||
|
||||
def collect_missing_assets(html_files: list[Path]) -> list[str]:
|
||||
"""收集所有 HTML 引用但磁盘上不存在的本地资源,供 --check 报错。"""
|
||||
missing: set[str] = set()
|
||||
for html_path in html_files:
|
||||
text = html_path.read_text(encoding="utf-8")
|
||||
for m in ASSET_REF_RE.finditer(text):
|
||||
rel = m.group("path")
|
||||
if not (html_path.parent / rel).is_file():
|
||||
missing.add(f"{html_path.name}: {rel}")
|
||||
return sorted(missing)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="按本地静态资源内容 hash 自动更新 HTML 里的 ?v= 版本号。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root", type=Path, default=None,
|
||||
help="tools 目录;默认为脚本所在目录。",
|
||||
)
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--write", action="store_true", help="实际写回 HTML(默认 dry-run)。")
|
||||
mode.add_argument("--check", action="store_true", help="CI 模式:有改动或资源缺失即非 0 退出。")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
root = (args.root if args.root is not None else Path(__file__).resolve().parent)
|
||||
if not root.is_dir():
|
||||
print(f"ERROR: root 不是目录: {root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
html_files = find_html_files(root)
|
||||
if not html_files:
|
||||
print(f"[bump] 没有找到 HTML 文件: {root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
mode_label = "check" if args.check else ("write" if args.write else "dry-run")
|
||||
print(f"[bump] root={root} mode={mode_label} html={len(html_files)}")
|
||||
|
||||
missing = collect_missing_assets(html_files)
|
||||
if missing:
|
||||
print("[bump] 引用了不存在的本地资源:", file=sys.stderr)
|
||||
for item in missing:
|
||||
print(f" {item}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
total_changes = 0
|
||||
for html_path in html_files:
|
||||
changes, new_text = process_html(html_path)
|
||||
if not changes:
|
||||
continue
|
||||
total_changes += len(changes)
|
||||
rel = html_path.relative_to(root)
|
||||
print(f"[bump] {rel}({len(changes)} 处):")
|
||||
for line in changes:
|
||||
print(line)
|
||||
if args.write:
|
||||
html_path.write_text(new_text, encoding="utf-8")
|
||||
|
||||
if total_changes == 0:
|
||||
print("[bump] 全部一致,无需 bump。")
|
||||
return 0
|
||||
|
||||
if args.write:
|
||||
print(f"[bump] 已写回 {total_changes} 处版本号。")
|
||||
return 0
|
||||
if args.check:
|
||||
print(f"[bump] CHECK FAIL:{total_changes} 处版本号与内容不一致。", file=sys.stderr)
|
||||
return 1
|
||||
print(f"[bump] dry-run:{total_changes} 处需要 bump(加 --write 写回)。")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+6
-6
@@ -4,11 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 CAN 通信控制台</title>
|
||||
<link rel="stylesheet" href="css/base.css?v=20260717x">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260717x">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260717x">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260719s">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260717x">
|
||||
<link rel="stylesheet" href="css/base.css?v=7d5f4eef">
|
||||
<link rel="stylesheet" href="css/layout.css?v=a3e0b7e2">
|
||||
<link rel="stylesheet" href="css/components.css?v=475fa1ac">
|
||||
<link rel="stylesheet" href="css/can.css?v=8a9342b7">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=61b66182">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
@@ -318,6 +318,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/can.js?v=20260720b"></script>
|
||||
<script type="module" src="js/can.js?v=fad32c3a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 CAN 接收监控</title>
|
||||
<link rel="stylesheet" href="css/base.css?v=20260716s">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260716s">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260716s">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260719s">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260716s">
|
||||
<link rel="stylesheet" href="css/base.css?v=7d5f4eef">
|
||||
<link rel="stylesheet" href="css/layout.css?v=a3e0b7e2">
|
||||
<link rel="stylesheet" href="css/components.css?v=475fa1ac">
|
||||
<link rel="stylesheet" href="css/can.css?v=8a9342b7">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=61b66182">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
@@ -182,6 +182,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/can_monitor.js?v=20260719s"></script>
|
||||
<script type="module" src="js/can_monitor.js?v=ef00eb03"></script>
|
||||
</body>
|
||||
</html>
|
||||
+107
-29
@@ -1,28 +1,74 @@
|
||||
/* === base.css — 基础变量与容器 === */
|
||||
/* ===== ESP32 OTA 控制台 — 工业调试台风格 ===== */
|
||||
/* ============================================================
|
||||
base.css — DCU Mission Control · 设计令牌与全局基底
|
||||
风格:深色测控台(deep blue-slate),多色子系统语义配色
|
||||
子系统色:ESP32=青绿 · GD32=琥珀 · BLE=蓝 · CAN=绿 · 内存/调试=紫
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--bg-root: #090e15;
|
||||
--bg-panel: #0f161f;
|
||||
--bg-card: #141d28;
|
||||
--bg-elevated: #1a2432;
|
||||
--bg-input: #0d131b;
|
||||
--border: #1d2a3a;
|
||||
--border-focus: #22d3ee;
|
||||
--text: #e2e8f0;
|
||||
--text-dim: #7b8fa8;
|
||||
--text-label: #5a6d82;
|
||||
--accent: #22d3ee;
|
||||
--accent-dim: rgba(34,211,238,.15);
|
||||
--green: #22c55e;
|
||||
--amber: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
/* ---- 底色层次:深蓝灰,层层抬升,拒绝纯黑 ---- */
|
||||
--bg-root: #0a0f1a;
|
||||
--bg-panel: #0e1624;
|
||||
--bg-card: #131d2e;
|
||||
--bg-elevated: #1a2740;
|
||||
--bg-input: #0b1220;
|
||||
--bg-hover: #20304c;
|
||||
|
||||
/* ---- 边框 ---- */
|
||||
--border: #21304a;
|
||||
--border-strong: #2e4262;
|
||||
--border-subtle: #1a2740;
|
||||
|
||||
/* ---- 文字 ---- */
|
||||
--text: #e9f0fa;
|
||||
--text-dim: #93a8c4;
|
||||
--text-label: #617a99;
|
||||
|
||||
/* ---- 子系统语义色 ---- */
|
||||
--esp32: #2fd8c1; /* 青绿:ESP32 / 主操作 / WiFi OTA */
|
||||
--esp32-hi: #6ff2e0;
|
||||
--esp32-dim: rgba(47,216,193,.14);
|
||||
--gd32: #fcbf3f; /* 琥珀:GD32 / 中继 OTA */
|
||||
--gd32-hi: #ffd97a;
|
||||
--gd32-dim: rgba(252,191,63,.14);
|
||||
--ble: #5fa8ff; /* 蓝:BLE 蓝牙通道 */
|
||||
--ble-hi: #93c6ff;
|
||||
--ble-dim: rgba(95,168,255,.14);
|
||||
--can: #43d97b; /* 绿:CAN 总线 / 健康 */
|
||||
--can-hi: #7deda6;
|
||||
--can-dim: rgba(67,217,123,.14);
|
||||
--mem: #a88bfa; /* 紫:内存 / 调试 / 日志 */
|
||||
--mem-hi: #c7b3ff;
|
||||
--mem-dim: rgba(168,139,250,.14);
|
||||
|
||||
/* ---- 状态语义色 ---- */
|
||||
--green: #3ddc8e;
|
||||
--amber: #fcbf3f;
|
||||
--red: #ff6b6b;
|
||||
--red-dim: rgba(255,107,107,.14);
|
||||
|
||||
/* ---- 兼容旧令牌(大量旧样式引用,勿删)---- */
|
||||
--accent: var(--esp32);
|
||||
--accent-dim: var(--esp32-dim);
|
||||
--border-focus: var(--esp32);
|
||||
|
||||
/* ---- 圆角 ---- */
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
|
||||
/* ---- 字体 ----
|
||||
display:Chakra Petch(棱角科技感,用于标题/大数字/按钮;
|
||||
无中文字形,中文自动回退苹方/雅黑)
|
||||
mono:JetBrains Mono(SHA / HEX / 日志 / 数值)
|
||||
body:系统中文字体优先,保证正文可读性 */
|
||||
--font-display: "Chakra Petch","PingFang SC","Microsoft YaHei","Segoe UI",sans-serif;
|
||||
--font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
|
||||
--font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
|
||||
--gap: 16px;
|
||||
--font-mono: "JetBrains Mono","SF Mono","Cascadia Code","Consolas",monospace;
|
||||
|
||||
/* ---- 间距 / 阴影 ---- */
|
||||
--gap: 18px;
|
||||
--shadow-card: 0 10px 30px rgba(3,7,16,.45), 0 1px 0 rgba(255,255,255,.03) inset;
|
||||
--shadow-pop: 0 18px 48px rgba(3,7,16,.6);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -33,23 +79,55 @@ html, body {
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* subtle noise-grid background */
|
||||
/* ---- 环境光背景:细网格 + 双色氛围辉光 + 顶部扫光 ----
|
||||
网格提供"工程图纸"质感;左上青绿、右下琥珀的两团极淡辉光
|
||||
暗示 ESP32 / GD32 双核子系统,随视口固定,营造纵深。 */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.015) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.015) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse at 50% 0%, black 40%, transparent 70%);
|
||||
linear-gradient(rgba(148,180,226,.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148,180,226,.035) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
mask-image: radial-gradient(ellipse 120% 90% at 50% -8%, black 30%, transparent 78%);
|
||||
}
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(900px 520px at 8% -10%, rgba(47,216,193,.10), transparent 60%),
|
||||
radial-gradient(820px 560px at 100% 112%, rgba(252,191,63,.07), transparent 62%),
|
||||
radial-gradient(700px 480px at 88% -6%, rgba(95,168,255,.06), transparent 60%);
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative; z-index: 1;
|
||||
max-width: 1480px; margin: 0 auto; padding: 24px 32px 40px;
|
||||
max-width: 1480px; margin: 0 auto; padding: 26px 32px 44px;
|
||||
}
|
||||
|
||||
/* ---- 全局滚动条 ---- */
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong); border-radius: 99px;
|
||||
border: 2px solid var(--bg-root);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #3a5378; }
|
||||
|
||||
/* ---- 选区 ---- */
|
||||
::selection { background: rgba(47,216,193,.28); color: #eafffb; }
|
||||
|
||||
/* ---- 通用等宽数字(让指标/百分比对齐、不跳动)---- */
|
||||
.tabular { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---- focus 可见性(键盘可达性)---- */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--esp32);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
+470
-923
File diff suppressed because it is too large
Load Diff
+256
-155
@@ -1,241 +1,342 @@
|
||||
/* === layout.css — 头部/主布局/卡片/表单/按钮/连接状态 === */
|
||||
/* === HEADER — compact status bar === */
|
||||
/* ============================================================
|
||||
layout.css — 命令栏头部 / 主布局 / 卡片 / 表单 / 按钮 / 侧滑抽屉
|
||||
============================================================ */
|
||||
|
||||
/* ================= HEADER — 命令栏 ================= */
|
||||
.header-bar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 20px; margin-bottom: 24px;
|
||||
background: var(--bg-panel);
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 16px 22px; margin-bottom: 26px;
|
||||
background: linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 顶部三色霓虹描边:ESP32 青绿 → BLE 蓝 → GD32 琥珀,一条细线点出子系统谱系 */
|
||||
.header-bar::before {
|
||||
content: "";
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--esp32) 0%, var(--ble) 38%, var(--gd32) 78%, transparent 100%);
|
||||
opacity: .85;
|
||||
}
|
||||
/* 右上角极淡的芯片电路装饰 */
|
||||
.header-bar::after {
|
||||
content: "";
|
||||
position: absolute; right: -40px; top: -40px;
|
||||
width: 220px; height: 220px; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(47,216,193,.10), transparent 55%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding-right: 20px; border-right: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-logo {
|
||||
width: 40px; height: 40px; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(145deg, rgba(47,216,193,.16), rgba(95,168,255,.10));
|
||||
border: 1px solid rgba(47,216,193,.3);
|
||||
box-shadow: 0 0 18px rgba(47,216,193,.18), inset 0 0 12px rgba(47,216,193,.08);
|
||||
color: var(--esp32);
|
||||
}
|
||||
.brand-logo svg { width: 24px; height: 24px; display: block; }
|
||||
.brand-text { display: flex; flex-direction: column; line-height: 1.15; }
|
||||
.brand-text strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px; font-weight: 700; letter-spacing: .02em;
|
||||
background: linear-gradient(92deg, var(--text) 30%, var(--esp32-hi) 100%);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.brand-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px; letter-spacing: .22em; text-transform: uppercase;
|
||||
color: var(--text-label); margin-top: 2px;
|
||||
}
|
||||
/* 兼容旧结构:独立 dot-mark(无 brand-logo 时)仍显示为一颗呼吸 LED */
|
||||
.header-brand .dot-mark {
|
||||
width: 10px; height: 10px; border-radius: 50%; background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-dim);
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--esp32);
|
||||
box-shadow: 0 0 10px var(--esp32-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-brand strong { font-size: 15px; font-weight: 700; }
|
||||
.header-brand strong { font-size: 16px; font-weight: 700; }
|
||||
|
||||
.header-status {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-sub {
|
||||
display: flex; gap: 10px; margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---- 状态胶囊 ---- */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: 500;
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 6px 13px; border-radius: 999px;
|
||||
font-size: 12px; font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
transition: border-color .2s, background .2s, transform .2s;
|
||||
}
|
||||
.pill .indicator {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--text-label);
|
||||
}
|
||||
.pill.online .indicator { background: var(--green); }
|
||||
.pill.offline .indicator { background: var(--red); }
|
||||
.pill.processing .indicator { background: var(--amber); animation: blink 1s infinite; }
|
||||
.pill.online .indicator { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.6); }
|
||||
.pill.offline .indicator { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.5); }
|
||||
.pill.processing .indicator,
|
||||
.pill.live .indicator { background: var(--amber); animation: blink 1s infinite; }
|
||||
.pill.idle .indicator { background: var(--text-dim); }
|
||||
@keyframes blink { 50% { opacity: .35; } }
|
||||
.pill.online { color: var(--can-hi); border-color: rgba(61,220,142,.35); }
|
||||
.pill.offline { color: #ffb3b3; border-color: rgba(255,107,107,.3); }
|
||||
.pill.processing,
|
||||
.pill.live { color: var(--gd32-hi); border-color: rgba(252,191,63,.35); }
|
||||
@keyframes blink { 50% { opacity: .3; } }
|
||||
|
||||
/* === MAIN LAYOUT === */
|
||||
.pill-val {
|
||||
font-weight: 700; color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ================= 主布局 ================= */
|
||||
.main-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 380px 1fr;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: var(--gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* === 右侧滑出抽屉:堆与任务栈 ===
|
||||
* 总宽 = peek 把手 + 面板;默认 translateX(面板宽) 只露把手,hover 整板探出 */
|
||||
.sys-drawer {
|
||||
--sys-drawer-panel-w: min(380px, calc(100vw - 48px));
|
||||
--sys-drawer-peek: 36px;
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 0;
|
||||
z-index: 80;
|
||||
width: calc(var(--sys-drawer-panel-w) + var(--sys-drawer-peek));
|
||||
height: calc(100vh - 88px);
|
||||
max-height: calc(100vh - 88px);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
transform: translateX(var(--sys-drawer-panel-w));
|
||||
transition: transform .28s cubic-bezier(.22, 1, .36, 1);
|
||||
filter: drop-shadow(-6px 0 18px rgba(0, 0, 0, .35));
|
||||
}
|
||||
.sys-drawer:hover,
|
||||
.sys-drawer:focus-within {
|
||||
transform: translateX(0);
|
||||
}
|
||||
/* 平时露出的竖条把手 */
|
||||
.sys-drawer-rail {
|
||||
flex: 0 0 var(--sys-drawer-peek);
|
||||
width: var(--sys-drawer-peek);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 4px;
|
||||
background: linear-gradient(180deg, #1a2a3a 0%, var(--bg-card) 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-right: none;
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
cursor: default;
|
||||
}
|
||||
.sys-drawer-rail-ico {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.sys-drawer-rail-text {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .12em;
|
||||
color: var(--text-dim);
|
||||
text-transform: none;
|
||||
}
|
||||
.sys-drawer:hover .sys-drawer-rail-text,
|
||||
.sys-drawer:focus-within .sys-drawer-rail-text {
|
||||
color: var(--accent);
|
||||
}
|
||||
.sys-drawer-panel {
|
||||
flex: 1 1 var(--sys-drawer-panel-w);
|
||||
width: var(--sys-drawer-panel-w);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin: 0 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
.sys-drawer-head {
|
||||
cursor: default;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sys-drawer-head .card-head-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
.sys-drawer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* === CARDS === */
|
||||
/* ================= 卡片 ================= */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
position: relative;
|
||||
background: linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.92) 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.card + .card { margin-top: var(--gap); }
|
||||
|
||||
/* 子系统色带:卡片顶部 2px 渐变,由修饰类决定颜色 */
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
||||
background: linear-gradient(90deg, var(--card-accent, var(--esp32)), transparent 85%);
|
||||
opacity: .7; pointer-events: none;
|
||||
}
|
||||
.card--esp32 { --card-accent: var(--esp32); --card-accent-dim: var(--esp32-dim); }
|
||||
.card--gd32 { --card-accent: var(--gd32); --card-accent-dim: var(--gd32-dim); }
|
||||
.card--ble { --card-accent: var(--ble); --card-accent-dim: var(--ble-dim); }
|
||||
.card--can { --card-accent: var(--can); --card-accent-dim: var(--can-dim); }
|
||||
.card--mem { --card-accent: var(--mem); --card-accent-dim: var(--mem-dim); }
|
||||
.card--status { --card-accent: var(--green); --card-accent-dim: var(--can-dim); }
|
||||
|
||||
.card-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px; font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase; letter-spacing: .06em;
|
||||
color: var(--text);
|
||||
letter-spacing: .05em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.card-head .ico { font-size: 15px; }
|
||||
.card-head-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
/* 图标芯片:圆角方块底色取自卡片子系统色 */
|
||||
.card-head .ico {
|
||||
width: 28px; height: 28px; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 15px;
|
||||
border-radius: 8px;
|
||||
background: var(--card-accent-dim, var(--esp32-dim));
|
||||
border: 1px solid color-mix(in srgb, var(--card-accent, var(--esp32)) 30%, transparent);
|
||||
}
|
||||
.card-body { padding: 16px 18px; }
|
||||
.card-head-btn { margin-left: auto; padding: 4px 10px; position: relative; z-index: 2; }
|
||||
.card-body { padding: 17px 18px; }
|
||||
.card.collapsed .card-body { display: none; }
|
||||
.card-head { cursor: pointer; }
|
||||
.card-head .collapse-arrow {
|
||||
margin-left: auto; font-size: 10px; opacity: .45;
|
||||
margin-left: auto; font-size: 10px; color: var(--text-label);
|
||||
transition: transform .2s;
|
||||
}
|
||||
.card.collapsed .collapse-arrow { transform: rotate(-90deg); }
|
||||
|
||||
/* === FORM ELEMENTS === */
|
||||
/* ================= 表单 ================= */
|
||||
.form-row { margin-bottom: 14px; }
|
||||
.form-row:last-child { margin-bottom: 0; }
|
||||
.form-label {
|
||||
display: block; font-size: 11px; color: var(--text-label);
|
||||
text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px;
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px; color: var(--text-label);
|
||||
text-transform: uppercase; letter-spacing: .12em;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.input, select, textarea {
|
||||
width: 100%; padding: 10px 12px; font-size: 13px; font-family: inherit;
|
||||
width: 100%; padding: 10px 13px;
|
||||
font-size: 13px; font-family: var(--font-mono);
|
||||
background: var(--bg-input); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
outline: none; transition: border-color .15s;
|
||||
outline: none;
|
||||
transition: border-color .18s, box-shadow .18s, background .18s;
|
||||
}
|
||||
.input::placeholder { color: var(--text-label); }
|
||||
.input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
border-color: var(--esp32);
|
||||
background: color-mix(in srgb, var(--esp32) 5%, var(--bg-input));
|
||||
box-shadow: 0 0 0 3px var(--esp32-dim);
|
||||
}
|
||||
select { cursor: pointer; }
|
||||
select option { background: var(--bg-panel); color: var(--text); }
|
||||
.input-group { display: flex; gap: 8px; }
|
||||
.input-group .input { flex: 1; }
|
||||
|
||||
/* === BUTTONS === */
|
||||
/* ================= 按钮 ================= */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 9px 16px; border: none; border-radius: var(--radius-sm);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
transition: opacity .15s, transform .15s;
|
||||
font-family: inherit; white-space: nowrap;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
padding: 9px 17px; border: none; border-radius: var(--radius-sm);
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px; font-weight: 600; letter-spacing: .03em;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
transition: transform .16s, box-shadow .2s, background .2s, border-color .2s, color .2s, opacity .2s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
|
||||
.btn-primary { background: var(--accent); color: #07121c; }
|
||||
.btn-primary:hover:not(:disabled) { opacity: .88; }
|
||||
.btn-success { background: var(--green); color: #051a0c; }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn:active:not(:disabled) { transform: translateY(0) scale(.97); }
|
||||
.btn:disabled { opacity: .38; cursor: not-allowed; transform: none; }
|
||||
|
||||
/* 主操作 = ESP32 青绿 */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--esp32) 0%, #14b8a6 100%);
|
||||
color: #04231e;
|
||||
box-shadow: 0 4px 16px rgba(47,216,193,.28), inset 0 1px 0 rgba(255,255,255,.25);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 22px rgba(47,216,193,.4), inset 0 1px 0 rgba(255,255,255,.3);
|
||||
}
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--green) 0%, #16a374 100%);
|
||||
color: #03231a;
|
||||
box-shadow: 0 4px 16px rgba(61,220,142,.25), inset 0 1px 0 rgba(255,255,255,.22);
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--red) 0%, #dc4c4c 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(255,107,107,.25), inset 0 1px 0 rgba(255,255,255,.2);
|
||||
}
|
||||
.btn-outline {
|
||||
background: transparent; color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,.02); color: var(--text-dim);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.btn-outline:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); }
|
||||
.btn-ghost {
|
||||
background: transparent; color: var(--text-dim);
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--bg-hover); color: var(--text);
|
||||
border-color: #3d5a84;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); }
|
||||
.btn-ghost { background: transparent; color: var(--text-dim); }
|
||||
.btn-ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
||||
.btn-block { width: 100%; justify-content: center; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||
|
||||
/* === CONNECTION STATUS ROW === */
|
||||
/* ---- 多色子系统页签(关键)----
|
||||
console-mode.js 用 btn-primary / btn-outline 表达"选中/未选中",
|
||||
这里按按钮 id 把"选中态"染成各自子系统色:
|
||||
WiFi=青绿 · BLE=蓝 · ESP32=青绿 · GD32=琥珀。 */
|
||||
#modeBleBtn.btn-primary {
|
||||
background: linear-gradient(135deg, var(--ble) 0%, #3b82f6 100%);
|
||||
color: #04142b;
|
||||
box-shadow: 0 4px 16px rgba(95,168,255,.3), inset 0 1px 0 rgba(255,255,255,.25);
|
||||
}
|
||||
#targetGd32Tab.btn-primary {
|
||||
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
|
||||
color: #2b1d02;
|
||||
box-shadow: 0 4px 16px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
|
||||
}
|
||||
#targetEsp32Tab.btn-primary {
|
||||
background: linear-gradient(135deg, var(--esp32) 0%, #14b8a6 100%);
|
||||
color: #04231e;
|
||||
}
|
||||
/* GD32 OTA 大按钮 = 琥珀(区别于 ESP32 青绿) */
|
||||
#uploadGd32Btn {
|
||||
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
|
||||
color: #2b1d02;
|
||||
box-shadow: 0 6px 20px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
|
||||
}
|
||||
#uploadEsp32Btn {
|
||||
box-shadow: 0 6px 20px rgba(47,216,193,.32), inset 0 1px 0 rgba(255,255,255,.28);
|
||||
}
|
||||
|
||||
/* ================= 连接状态条 / 提示 ================= */
|
||||
.conn-indicator {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.conn-indicator .light {
|
||||
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.conn-indicator.ok .light { background: var(--green); }
|
||||
.conn-indicator.err .light { background: var(--red); }
|
||||
.conn-indicator .light { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.conn-indicator.ok .light { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.6); }
|
||||
.conn-indicator.err .light { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.5); }
|
||||
.conn-indicator.warn .light { background: var(--amber); }
|
||||
|
||||
.helper-note {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 10px; padding: 10px 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
font-size: 12px; line-height: 1.65; color: var(--text-dim);
|
||||
}
|
||||
.helper-note.ok { border-color: rgba(61,220,142,.3); background: rgba(61,220,142,.07); color: #a9f0c6; }
|
||||
.helper-note.warn { border-color: rgba(252,191,63,.3); background: rgba(252,191,63,.07); color: #fbe3ae; }
|
||||
.helper-note.err { border-color: rgba(255,107,107,.3); background: rgba(255,107,107,.07); color: #ffc4c4; }
|
||||
.helper-note code { color: var(--esp32-hi); font-family: var(--font-mono); }
|
||||
|
||||
/* ================= 右侧滑出抽屉:堆与任务栈 ================= */
|
||||
.sys-drawer {
|
||||
--sys-drawer-panel-w: min(390px, calc(100vw - 48px));
|
||||
--sys-drawer-peek: 38px;
|
||||
position: fixed; top: 76px; right: 0; z-index: 80;
|
||||
width: calc(var(--sys-drawer-panel-w) + var(--sys-drawer-peek));
|
||||
height: calc(100vh - 92px); max-height: calc(100vh - 92px);
|
||||
display: flex; flex-direction: row; align-items: stretch;
|
||||
transform: translateX(var(--sys-drawer-panel-w));
|
||||
transition: transform .3s cubic-bezier(.22, 1, .36, 1);
|
||||
filter: drop-shadow(-8px 0 24px rgba(2,5,12,.5));
|
||||
}
|
||||
.sys-drawer:hover, .sys-drawer:focus-within { transform: translateX(0); }
|
||||
.sys-drawer-rail {
|
||||
flex: 0 0 var(--sys-drawer-peek); width: var(--sys-drawer-peek);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 12px; padding: 14px 4px;
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-card) 100%);
|
||||
border: 1px solid var(--border); border-right: none;
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
cursor: default;
|
||||
}
|
||||
.sys-drawer-rail-ico { font-size: 16px; line-height: 1; }
|
||||
.sys-drawer-rail-text {
|
||||
writing-mode: vertical-rl; text-orientation: mixed;
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .18em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.helper-note.ok { border-color: rgba(34,197,94,.25); color: #b8f5cb; }
|
||||
.helper-note.warn { border-color: rgba(245,158,11,.25); color: #fbd38d; }
|
||||
.helper-note.err { border-color: rgba(239,68,68,.25); color: #fecaca; }
|
||||
.helper-note code { color: #d7fbff; font-family: var(--font-mono); }
|
||||
.sys-drawer:hover .sys-drawer-rail-text,
|
||||
.sys-drawer:focus-within .sys-drawer-rail-text { color: var(--mem-hi); }
|
||||
.sys-drawer-panel {
|
||||
flex: 1 1 var(--sys-drawer-panel-w);
|
||||
width: var(--sys-drawer-panel-w); min-width: 0; height: 100%;
|
||||
margin: 0 !important;
|
||||
display: flex; flex-direction: column;
|
||||
border-radius: 0; overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-left: none; border-right: none;
|
||||
}
|
||||
.sys-drawer-head { cursor: default; flex-shrink: 0; }
|
||||
.sys-drawer-head .card-head-btn { margin-left: auto; }
|
||||
.sys-drawer-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
|
||||
+8
-8
@@ -9,11 +9,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ESP32 OTA 中继控制台</title>
|
||||
<link rel="stylesheet" href="css/base.css?v=20260718b">
|
||||
<link rel="stylesheet" href="css/layout.css?v=20260718b">
|
||||
<link rel="stylesheet" href="css/components.css?v=20260719x">
|
||||
<link rel="stylesheet" href="css/can.css?v=20260719s">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=20260718b">
|
||||
<link rel="stylesheet" href="css/base.css?v=7d5f4eef">
|
||||
<link rel="stylesheet" href="css/layout.css?v=a3e0b7e2">
|
||||
<link rel="stylesheet" href="css/components.css?v=475fa1ac">
|
||||
<link rel="stylesheet" href="css/can.css?v=8a9342b7">
|
||||
<link rel="stylesheet" href="css/responsive.css?v=61b66182">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -469,8 +469,8 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/app.js?v=20260720ae"></script>
|
||||
<script type="module" src="js/ota/console-mode.js?v=20260720ae"></script>
|
||||
<script type="module" src="js/ble_ota.js?v=20260720ae"></script>
|
||||
<script type="module" src="js/app.js?v=953c41e9"></script>
|
||||
<script type="module" src="js/ota/console-mode.js?v=06b7189e"></script>
|
||||
<script type="module" src="js/ble_ota.js?v=01d3cb4e"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -741,7 +741,7 @@ function buildHeapLowRowHtml(r, idx, segTag) {
|
||||
'</div>' +
|
||||
'<span>分配类别: <b>' + escapeHtml(r.hook_task || '--') + '</b> <span class="heap-low-caller-hint">net?=小中块/疑网络 · bulk=大块 · tiny=更小</span></span>' +
|
||||
'<span>本次alloc: <b>' + (Number.isFinite(r.hook_alloc_size) && Number(r.hook_alloc_size) > 0 ? formatBytes(r.hook_alloc_size) : '--') + '</b></span>' +
|
||||
'<span>alloc后est空闲: <b>' + (Number.isFinite(r.hook_free_after) && Number(r.hook_free_after) > 0 ? formatBytes(r.hook_free_after) : '--') + '</b></span>' +
|
||||
'<span>alloc后est空闲: <b>' + (Number.isFinite(r.hook_est_free_after) && Number(r.hook_est_free_after) > 0 ? formatBytes(r.hook_est_free_after) : (Number.isFinite(r.hook_free_after) && Number(r.hook_free_after) > 0 ? formatBytes(r.hook_free_after) : '--')) + '</b>' + (r.hook_confidence === 'est_low_watermark' ? ' <span class="heap-low-caller-hint">(estimated 低水位近似)</span>' : '') + '</span>' +
|
||||
'<span>调用者地址: <b class="heap-low-caller">0x' + (Number.isFinite(r.hook_caller_addr) && Number(r.hook_caller_addr) > 0 ? r.hook_caller_addr.toString(16).toUpperCase() : '--') + '</b>' +
|
||||
' <span class="heap-low-caller-hint">(addr2line)</span></span>' +
|
||||
'</div>' +
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print ESP32-C3 static memory breakdown from build map + ELF (nm).
|
||||
|
||||
Usage (from repo or any cwd):
|
||||
python tools/parse_map_static_mem.py
|
||||
python tools/parse_map_static_mem.py --elf build/ESP32_COMM.elf
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_MAP = ROOT / "build" / "ESP32_COMM.map"
|
||||
DEFAULT_ELF = ROOT / "build" / "ESP32_COMM.elf"
|
||||
|
||||
# ESP32-C3 DRAM / IRAM windows used by app (after 16KB i-cache)
|
||||
DRAM_LO, DRAM_HI = 0x3FC80000, 0x3FCE0000
|
||||
IRAM_LO, IRAM_HI = 0x40380000, 0x403E0000
|
||||
|
||||
|
||||
def demangle(name: str) -> str:
|
||||
"""Lightweight Itanium demangle for common ESP app symbols."""
|
||||
# _ZL13s_status_json -> s_status_json
|
||||
m = re.match(r"^_ZL\d+(.+)$", name)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# _ZZ20can_feedback_handler...E15s_feedback_json
|
||||
m = re.search(r"E\d+([A-Za-z_][A-Za-z0-9_]*)$", name)
|
||||
if m and name.startswith("_ZZ"):
|
||||
return m.group(1) + " (fn-static)"
|
||||
# _ZN12_GLOBAL__N_1L6s_workE
|
||||
m = re.match(r"^_ZN\d+_GLOBAL__N_\d+L\d+(.+)E$", name)
|
||||
if m:
|
||||
return m.group(1) + " (anon)"
|
||||
# _ZZN12Esp32OtaCore23computeShaFromPartitionEvE14s_sha_read_buf
|
||||
m = re.search(r"E\d+([A-Za-z_][A-Za-z0-9_]*)$", name)
|
||||
if m and name.startswith("_ZZN"):
|
||||
return m.group(1) + " (method-static)"
|
||||
# singleton: _ZZN8DebugLog8instanceEvE4inst
|
||||
m = re.match(r"^_ZZN(\d+)(.+)$", name)
|
||||
if m and "instanceEvE" in name:
|
||||
# extract class-ish
|
||||
body = name
|
||||
cm = re.search(r"_ZZN\d+([A-Za-z0-9_]+)\d+instance", body)
|
||||
if cm:
|
||||
return cm.group(1) + "::instance"
|
||||
return "singleton inst"
|
||||
return name
|
||||
|
||||
|
||||
def find_nm() -> str | None:
|
||||
which = shutil.which("riscv32-esp-elf-nm")
|
||||
if which:
|
||||
return which
|
||||
roots = [
|
||||
Path(r"C:\Espressif\tools\riscv32-esp-elf"),
|
||||
Path(r"F:\software\.espressif\tools\riscv32-esp-elf"),
|
||||
]
|
||||
for root in roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
matches = sorted(root.glob("**/riscv32-esp-elf-nm.exe"))
|
||||
if matches:
|
||||
return str(matches[-1])
|
||||
return None
|
||||
|
||||
|
||||
def parse_syms(map_text: str) -> dict[str, int]:
|
||||
out = {}
|
||||
for name in (
|
||||
"_iram_start",
|
||||
"_iram_end",
|
||||
"_data_start",
|
||||
"_data_end",
|
||||
"_bss_start",
|
||||
"_bss_end",
|
||||
"_heap_start",
|
||||
):
|
||||
m = re.search(rf"0x([0-9a-fA-F]+)\s+{name}\s*=", map_text)
|
||||
if m:
|
||||
out[name] = int(m.group(1), 16)
|
||||
return out
|
||||
|
||||
|
||||
def run_nm(nm: str, elf: Path) -> list[tuple[int, int, str, str]]:
|
||||
"""Return list of (addr, size, type, name)."""
|
||||
# -S size, --size-sort, -t x hex
|
||||
r = subprocess.run(
|
||||
[nm, "-S", "--size-sort", "-t", "x", str(elf)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
rows = []
|
||||
for line in r.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
try:
|
||||
addr = int(parts[0], 16)
|
||||
size = int(parts[1], 16)
|
||||
except ValueError:
|
||||
continue
|
||||
typ = parts[2]
|
||||
name = parts[3]
|
||||
rows.append((addr, size, typ, name))
|
||||
return rows
|
||||
|
||||
|
||||
def top_in_range(
|
||||
rows: list[tuple[int, int, str, str]],
|
||||
lo: int,
|
||||
hi: int,
|
||||
types: set[str],
|
||||
n: int = 20,
|
||||
min_size: int = 64,
|
||||
) -> list[tuple[int, str, str]]:
|
||||
picked = []
|
||||
for addr, size, typ, name in rows:
|
||||
if typ not in types:
|
||||
continue
|
||||
if size < min_size:
|
||||
continue
|
||||
if not (lo <= addr < hi):
|
||||
continue
|
||||
# skip linker bookkeeping extremes
|
||||
if name.startswith("_") and name.endswith(("_start", "_end", "_length")):
|
||||
if size > 0x10000:
|
||||
continue
|
||||
picked.append((size, typ, name))
|
||||
picked.sort(key=lambda x: -x[0])
|
||||
return picked[:n]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--map", type=Path, default=DEFAULT_MAP)
|
||||
ap.add_argument("--elf", type=Path, default=DEFAULT_ELF)
|
||||
ap.add_argument("-n", type=int, default=18, help="top N symbols per section")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.map.exists():
|
||||
print(f"map not found: {args.map}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
text = args.map.read_text(encoding="utf-8", errors="replace")
|
||||
syms = parse_syms(text)
|
||||
|
||||
def span(a: str, b: str) -> int:
|
||||
if a not in syms or b not in syms:
|
||||
return 0
|
||||
return max(0, syms[b] - syms[a])
|
||||
|
||||
iram = span("_iram_start", "_iram_end")
|
||||
data = span("_data_start", "_data_end")
|
||||
bss = span("_bss_start", "_bss_end")
|
||||
heap_start = syms.get("_heap_start", 0)
|
||||
static_to_heap = max(0, heap_start - DRAM_LO) if heap_start else iram + data + bss
|
||||
|
||||
print("=== Linker section totals (authoritative) ===")
|
||||
print(f" IRAM : {iram:7d} ({iram/1024:6.1f} KB) code in RAM (WiFi/BT/ISR/tlsf…)")
|
||||
print(f" .data : {data:7d} ({data/1024:6.1f} KB) initialized globals/singletons")
|
||||
print(f" .bss : {bss:7d} ({bss/1024:6.1f} KB) zeroed static buffers")
|
||||
print(f" static→heap : {static_to_heap:7d} ({static_to_heap/1024:6.1f} KB)")
|
||||
if heap_start:
|
||||
print(f" _heap_start : 0x{heap_start:08x}")
|
||||
room = max(0, DRAM_HI - heap_start)
|
||||
print(f" DRAM window after heap_start (to SOC_DRAM_HIGH): {room} ({room/1024:.1f} KB)")
|
||||
print(f" sum check IRAM+data+bss = {iram+data+bss} (should ≈ static→heap on C3)")
|
||||
|
||||
nm = find_nm()
|
||||
if not args.elf.exists():
|
||||
print(f"\nELF not found: {args.elf} (skip symbol tops)")
|
||||
return 0
|
||||
if not nm:
|
||||
print("\nriscv32-esp-elf-nm not found (skip symbol tops)")
|
||||
return 0
|
||||
|
||||
print(f"\nusing nm: {nm}")
|
||||
rows = run_nm(nm, args.elf)
|
||||
|
||||
sections = [
|
||||
("IRAM code (largest functions)", IRAM_LO, IRAM_HI, set("tT")),
|
||||
(".data (largest objects)", DRAM_LO, DRAM_HI, set("dD")),
|
||||
(".bss (largest objects)", DRAM_LO, DRAM_HI, set("bB")),
|
||||
]
|
||||
for title, lo, hi, types in sections:
|
||||
print(f"\n=== Top {args.n} {title} ===")
|
||||
tops = top_in_range(rows, lo, hi, types, n=args.n)
|
||||
# for .bss prefer heap_start range if known
|
||||
if "b" in {t.lower() for t in types} and heap_start:
|
||||
bss_lo = syms.get("_bss_start", DRAM_LO)
|
||||
bss_hi = heap_start
|
||||
tops = top_in_range(rows, bss_lo, bss_hi, types, n=args.n)
|
||||
if "d" in {t.lower() for t in types} and "_data_start" in syms:
|
||||
tops = top_in_range(
|
||||
rows,
|
||||
syms["_data_start"],
|
||||
syms.get("_data_end", DRAM_HI),
|
||||
types,
|
||||
n=args.n,
|
||||
)
|
||||
total = sum(s for s, _, _ in tops)
|
||||
for size, typ, name in tops:
|
||||
print(
|
||||
f" {size:6d} {size/1024:5.1f}KB {typ} "
|
||||
f"{demangle(name):40s} {name}"
|
||||
)
|
||||
print(f" (listed sum {total} / {total/1024:.1f} KB)")
|
||||
|
||||
print(
|
||||
"\nTip: runtime /system/status memory.{iram,data,bss} matches linker totals; "
|
||||
"symbol tops are build-time only."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user