增加UI 代码的注释

This commit is contained in:
2026-09-08 18:36:01 +08:00
parent 2b41bdccb5
commit 891c9ef0d9
7 changed files with 1334 additions and 489 deletions
+13 -23
View File
@@ -11,29 +11,18 @@
**五阶段单向管线**:`参考图 → P0 → P1 → P2 → P3 → P4 → P5`。每阶段产出明确**产物**、满足**放行条件(Gate)**才进入下一阶段;其中 **P1、P3 是"比对 → 修正"迭代环**,未收敛不得前进。
```text
参考图
│
▼
P0 需求录入 ────────────▶ 界面台账 + 问题清单
│ Gate: 界面定位/极性已确认 + 阻塞问题有答案
▼
P1 图片→HTML 样机 ──────▶ mockups/NN.html + NN_说明.md
│ ⟲ 迭代: 渲染样机↔原图比对→修正;用户浏览器确认↔改 HTML
│ Gate: 样机定稿 + 原图校验通过 + 问题有答案 + 字符集核对完成
▼
P2 HTML→LVGL 代码 ──────▶ Drivers/BSP/UI 代码 + 字体(重生成)
│ Gate: 参数逐项一致 + 字体已重生成 + 内存预算 + 双端注册
▼
P3 模拟器验证 ──────────▶ shot.png ↔ 样机渲染图 同构
│ ⟲ 迭代: 出图↔样机渲染图比对 → 依 HTML 改码 → 再出图
│ Gate: 出图与样机渲染图同构 + 导航符合键位语义
▼
P4 MCU 验证 ────────────▶ 日志 + 真机目检结论
│ Gate: @build 0错0警 + @test PASS + 真机目检通过
▼
P5 归档 ────────────────▶ 变更记录/台账 + 一并提交
(代码 + 字体 + 字符集 + 样机 + 文档 进 git)
```mermaid
flowchart TD
R(["参考图"]) --> P0["P0 需求录入<br/>→ 屏幕台账 + 问题清单"]
P0 -- "Gate: 屏幕定位/极性已确认 + 阻塞性问题有答案" --> P1["P1 图 → HTML 样机<br/>→ mockups/NN.html + NN_说明.md"]
P1 --> C1{"渲染样机 ↔ 对照原图<br/>收敛?"}
C1 -- "否: 修 HTML(浏览器确认 ↔ 修 HTML)" --> P1
C1 -- "是(Gate: 样机定稿 + 原图校验过 + 问题有答案 + 字集核对)" --> P2["P2 HTML → LVGL 代码<br/>→ Drivers/BSP/UI 代码 + 字体(重生成)"]
P2 -- "Gate: 参数逐项对账 + 字体重生成 + 内存预算 + 双端注册" --> P3["P3 模拟器验证<br/>→ shot.png(与样机渲染比对)"]
P3 --> C3{"输出图 ↔ 样机渲染<br/>同构?"}
C3 -- "否: 依 HTML 修代码 → 再出图" --> P3
C3 -- "是(Gate: 导航符合按键语义)" --> P4["P4 MCU 验证<br/>→ 日志 + 上机目检结论"]
P4 -- "Gate: @build 0 错 0 警 + @test PASS + 上机目检通过" --> P5(["P5 归档<br/>→ 变更记录/台账 + 随代码提交(代码+字体+字集+样机+文档)"])
```
> 小改动(文案/间距/纯交互/字体重生成)走 §11 快速通道,无需全流程。
@@ -443,3 +432,4 @@ P5 [ ] UI_设计架构.md 变更记录更新
| 2026-09-03 | 顶部"开口浅 V"两腿偏细(1bpp 斜线边缘被阈值吃掉):`ui_menu.c` 装饰拆 3 段 `lv_line`,两腿加粗 `MENU_DECO_LEG_W`=3、水平线 `MENU_DECO_HORIZ_W`=2(`deco_line_apply` 统一样式),三图腿粗现已一致(约 2~3px) |
| 2026-09-03 | §一 流程总览重画为清晰的纵向管线图:每阶段标注 产物 + Gate,P1/P3 的"比对→修正"迭代环显式标出(⟲);补全原先遗漏的 P1 原图校验环/`NN_说明.md` 产物、P2 双端注册、P3 导航 Gate,与各阶段细则(§3~§8)对齐 |
| 2026-09-04 | §九 新增 9.1“样机投入分流(按屏型)”:明确 HTML 样机=静态外观轴(设计先行、非代码生成器),设计文档 + 模拟器=行为/数据轴;静态内容页走完整样机管线、导航/交互重屏 HTML 只做静态首帧、行为靠 `菜单页面设计.md`;配套文档补列 `菜单页面设计.md` |
| 2026-09-07 | §一 五阶段管线图由 ASCII 转 Mermaid flowchart:P1/P3“比对→修正”迭代环由行内 ⟲ 注记改为显式判定节点 + 回边(未收敛不得前进更直观);各 Gate 条件与产物标注保留、语义不变 |
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 745" width="1600" height="1490" font-family="Microsoft YaHei, SimSun, sans-serif">
<rect width="800" height="745" fill="#ffffff"/>
<text x="10.0" y="24.0" font-size="14" text-anchor="start" fill="#000" font-weight="bold">级联框几何计算 —— ui_menu_view_frame_build():先算三个数(宽 w / 位置 x,y / 高 h),再照数建对象</text>
<rect x="10.0" y="40.0" width="780.0" height="208.0" fill="none" stroke="#ddd" stroke-width="1"/>
<text x="18.0" y="58.0" font-size="12" text-anchor="start" fill="#000" font-weight="bold">① 框宽 w = 最宽行 + 2×边框:同框各行宽可以不同,框宽只跟“最宽的一行”走</text>
<rect x="30.0" y="80.0" width="66.0" height="32.0" fill="#fff" stroke="#000" stroke-width="1.5"/>
<line x1="31.0" y1="96.0" x2="95.0" y2="96.0" stroke="#ccc" stroke-width="1"/>
<rect x="33.0" y="82.0" width="12.0" height="13.0" fill="none" stroke="#888" stroke-width="1"/>
<rect x="45.0" y="82.0" width="12.0" height="13.0" fill="none" stroke="#888" stroke-width="1"/>
<rect x="57.0" y="82.0" width="12.0" height="13.0" fill="none" stroke="#888" stroke-width="1"/>
<rect x="69.0" y="82.0" width="12.0" height="13.0" fill="none" stroke="#888" stroke-width="1"/>
<polygon points="83.0,83.5 83.0,93.5 93.0,88.5" fill="#000" stroke="none" stroke-width="1"/>
<rect x="33.0" y="97.0" width="12.0" height="13.0" fill="none" stroke="#888" stroke-width="1"/>
<rect x="45.0" y="97.0" width="12.0" height="13.0" fill="none" stroke="#888" stroke-width="1"/>
<rect x="57.0" y="97.0" width="12.0" height="13.0" fill="none" stroke="#888" stroke-width="1"/>
<text x="30.0" y="122.0" font-size="10" text-anchor="start" fill="#000">2 行框(1× 原尺寸):行 1 = 4字+▶(最宽),行 2 = 3字</text>
<line x1="100.0" y1="80.0" x2="100.0" y2="112.0" stroke="#444" stroke-width="1"/>
<line x1="96.0" y1="80.0" x2="104.0" y2="80.0" stroke="#444" stroke-width="1"/>
<line x1="96.0" y1="112.0" x2="104.0" y2="112.0" stroke="#444" stroke-width="1"/>
<text x="106.0" y="96.0" font-size="10" text-anchor="start" fill="#444">框宽 66</text>
<line x1="96.0" y1="88.0" x2="205.0" y2="104.0" stroke="#999" stroke-width="1" stroke-dasharray="4 3"/>
<text x="140.0" y="92.0" font-size="10" text-anchor="start" fill="#666">行 1 剖面(×4 放大)</text>
<rect x="210.0" y="80.0" width="264.0" height="68.0" fill="#fff" stroke="#000" stroke-width="2"/>
<rect x="210.0" y="80.0" width="4.0" height="68.0" fill="#d9d9d9" stroke="none" stroke-width="1"/>
<rect x="470.0" y="80.0" width="4.0" height="68.0" fill="#d9d9d9" stroke="none" stroke-width="1"/>
<rect x="214.0" y="84.0" width="256.0" height="4.0" fill="#f2f2f2" stroke="none" stroke-width="1"/>
<rect x="214.0" y="140.0" width="256.0" height="4.0" fill="#f2f2f2" stroke="none" stroke-width="1"/>
<rect x="222.0" y="88.0" width="48.0" height="52.0" fill="none" stroke="#888" stroke-width="1"/>
<rect x="270.0" y="88.0" width="48.0" height="52.0" fill="none" stroke="#888" stroke-width="1"/>
<rect x="318.0" y="88.0" width="48.0" height="52.0" fill="none" stroke="#888" stroke-width="1"/>
<rect x="366.0" y="88.0" width="48.0" height="52.0" fill="none" stroke="#888" stroke-width="1"/>
<polygon points="423.0,97.0 423.0,135.0 459.0,116.0" fill="#000" stroke="none" stroke-width="1"/>
<line x1="210.0" y1="156.0" x2="214.0" y2="156.0" stroke="#444" stroke-width="1"/>
<line x1="210.0" y1="153.0" x2="210.0" y2="159.0" stroke="#444" stroke-width="1"/>
<line x1="214.0" y1="153.0" x2="214.0" y2="159.0" stroke="#444" stroke-width="1"/>
<text x="212.0" y="171.0" font-size="9" text-anchor="middle" fill="#444">边框1</text>
<line x1="214.0" y1="156.0" x2="222.0" y2="156.0" stroke="#444" stroke-width="1"/>
<line x1="214.0" y1="153.0" x2="214.0" y2="159.0" stroke="#444" stroke-width="1"/>
<line x1="222.0" y1="153.0" x2="222.0" y2="159.0" stroke="#444" stroke-width="1"/>
<text x="218.0" y="186.0" font-size="9" text-anchor="middle" fill="#444">留白2</text>
<line x1="222.0" y1="156.0" x2="414.0" y2="156.0" stroke="#444" stroke-width="1"/>
<line x1="222.0" y1="153.0" x2="222.0" y2="159.0" stroke="#444" stroke-width="1"/>
<line x1="414.0" y1="153.0" x2="414.0" y2="159.0" stroke="#444" stroke-width="1"/>
<text x="318.0" y="171.0" font-size="9" text-anchor="middle" fill="#444">文本 4×12=48</text>
<line x1="414.0" y1="156.0" x2="422.0" y2="156.0" stroke="#444" stroke-width="1"/>
<line x1="414.0" y1="153.0" x2="414.0" y2="159.0" stroke="#444" stroke-width="1"/>
<line x1="422.0" y1="153.0" x2="422.0" y2="159.0" stroke="#444" stroke-width="1"/>
<text x="418.0" y="186.0" font-size="9" text-anchor="middle" fill="#444">间距2</text>
<line x1="422.0" y1="156.0" x2="462.0" y2="156.0" stroke="#444" stroke-width="1"/>
<line x1="422.0" y1="153.0" x2="422.0" y2="159.0" stroke="#444" stroke-width="1"/>
<line x1="462.0" y1="153.0" x2="462.0" y2="159.0" stroke="#444" stroke-width="1"/>
<text x="442.0" y="171.0" font-size="9" text-anchor="middle" fill="#444">▶10</text>
<line x1="462.0" y1="156.0" x2="470.0" y2="156.0" stroke="#444" stroke-width="1"/>
<line x1="462.0" y1="153.0" x2="462.0" y2="159.0" stroke="#444" stroke-width="1"/>
<line x1="470.0" y1="153.0" x2="470.0" y2="159.0" stroke="#444" stroke-width="1"/>
<text x="466.0" y="186.0" font-size="9" text-anchor="middle" fill="#444">留白2</text>
<line x1="470.0" y1="156.0" x2="474.0" y2="156.0" stroke="#444" stroke-width="1"/>
<line x1="470.0" y1="153.0" x2="470.0" y2="159.0" stroke="#444" stroke-width="1"/>
<line x1="474.0" y1="153.0" x2="474.0" y2="159.0" stroke="#444" stroke-width="1"/>
<text x="472.0" y="171.0" font-size="9" text-anchor="middle" fill="#444">边框1</text>
<text x="210.0" y="207.0" font-size="11" text-anchor="start" fill="#000" font-weight="bold">w = 2 + 48 + 2 + 10 + 2 + 2×1 = 66(最宽行决定)</text>
<text x="210.0" y="224.0" font-size="10" text-anchor="start" fill="#555">窄行(如 3 字)左对齐、右侧留空,框宽不变</text>
<rect x="10.0" y="258.0" width="385.0" height="232.0" fill="none" stroke="#ddd" stroke-width="1"/>
<text x="18.0" y="276.0" font-size="12" text-anchor="start" fill="#000" font-weight="bold">② X 定位:子框左缘 = 父框右缘(共享 1px 边框列)</text>
<rect x="40.0" y="298.0" width="66.0" height="77.0" fill="#fff" stroke="#000" stroke-width="1.5"/>
<rect x="105.0" y="298.0" width="42.0" height="77.0" fill="#fff" stroke="#000" stroke-width="1.5"/>
<line x1="41.0" y1="314.0" x2="105.0" y2="314.0" stroke="#e5e5e5" stroke-width="1"/>
<line x1="106.0" y1="314.0" x2="146.0" y2="314.0" stroke="#e5e5e5" stroke-width="1"/>
<line x1="41.0" y1="329.0" x2="105.0" y2="329.0" stroke="#e5e5e5" stroke-width="1"/>
<line x1="106.0" y1="329.0" x2="146.0" y2="329.0" stroke="#e5e5e5" stroke-width="1"/>
<line x1="41.0" y1="344.0" x2="105.0" y2="344.0" stroke="#e5e5e5" stroke-width="1"/>
<line x1="106.0" y1="344.0" x2="146.0" y2="344.0" stroke="#e5e5e5" stroke-width="1"/>
<line x1="41.0" y1="359.0" x2="105.0" y2="359.0" stroke="#e5e5e5" stroke-width="1"/>
<line x1="106.0" y1="359.0" x2="146.0" y2="359.0" stroke="#e5e5e5" stroke-width="1"/>
<line x1="40.0" y1="390.0" x2="105.0" y2="390.0" stroke="#444" stroke-width="1"/>
<line x1="40.0" y1="387.0" x2="40.0" y2="393.0" stroke="#444" stroke-width="1"/>
<line x1="105.0" y1="387.0" x2="105.0" y2="393.0" stroke="#444" stroke-width="1"/>
<text x="72.0" y="406.0" font-size="10" text-anchor="middle" fill="#444">65 = 父.w − 1</text>
<text x="18.0" y="428.0" font-size="10" text-anchor="start" fill="#000">减的 1 = 父框右边框列:子框左边框与它重叠为同一 1px</text>
<text x="18.0" y="442.0" font-size="10" text-anchor="start" fill="#000">→ 零间隙、边框不重画,逐级向右“生长”</text>
<rect x="150.0" y="452.0" width="210.0" height="30.0" fill="#fff" stroke="#999" stroke-width="1"/>
<rect x="240.0" y="452.0" width="25.0" height="30.0" fill="#888" stroke="none" stroke-width="1"/>
<text x="195.0" y="471.0" font-size="10" text-anchor="middle" fill="#000">父框</text>
<text x="307.0" y="471.0" font-size="10" text-anchor="middle" fill="#000">子框</text>
<text x="252.0" y="448.0" font-size="9" text-anchor="middle" fill="#555">共享边框列 1px(×25 放大)</text>
<line x1="106.0" y1="375.0" x2="245.0" y2="452.0" stroke="#999" stroke-width="1" stroke-dasharray="4 3"/>
<rect x="410.0" y="258.0" width="380.0" height="232.0" fill="none" stroke="#ddd" stroke-width="1"/>
<text x="418.0" y="276.0" font-size="12" text-anchor="start" fill="#000" font-weight="bold">③ Y 定位(默认):顶边 = 父“选中行”行顶</text>
<rect x="430.0" y="288.0" width="160.0" height="160.0" fill="#000" stroke="#000" stroke-width="1.5"/>
<text x="585.0" y="300.0" font-size="8" text-anchor="end" fill="#888">屏 160×160</text>
<rect x="434.0" y="298.0" width="42.0" height="62.0" fill="#000" stroke="#fff" stroke-width="1.5"/>
<line x1="435.0" y1="314.0" x2="475.0" y2="314.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="435.0" y1="329.0" x2="475.0" y2="329.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="435.0" y1="344.0" x2="475.0" y2="344.0" stroke="#3a3a3a" stroke-width="1"/>
<rect x="435.0" y="314.0" width="40.0" height="15.0" fill="#fff" stroke="none" stroke-width="1"/>
<text x="438.0" y="309.0" font-size="8" text-anchor="start" fill="#fff">参数</text>
<text x="438.0" y="324.0" font-size="8" text-anchor="start" fill="#000">定值</text>
<text x="438.0" y="339.0" font-size="8" text-anchor="start" fill="#fff">控制字</text>
<text x="438.0" y="354.0" font-size="8" text-anchor="start" fill="#fff">软压板</text>
<rect x="475.0" y="314.0" width="42.0" height="62.0" fill="#000" stroke="#fff" stroke-width="1.5"/>
<line x1="476.0" y1="330.0" x2="516.0" y2="330.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="476.0" y1="345.0" x2="516.0" y2="345.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="476.0" y1="360.0" x2="516.0" y2="360.0" stroke="#3a3a3a" stroke-width="1"/>
<rect x="476.0" y="315.0" width="40.0" height="15.0" fill="#fff" stroke="none" stroke-width="1"/>
<text x="479.0" y="324.0" font-size="8" text-anchor="start" fill="#000">参数</text>
<text x="479.0" y="339.0" font-size="8" text-anchor="start" fill="#fff">定值</text>
<text x="479.0" y="354.0" font-size="8" text-anchor="start" fill="#fff">控制字</text>
<text x="479.0" y="369.0" font-size="8" text-anchor="start" fill="#fff">软压板</text>
<line x1="430.0" y1="314.0" x2="592.0" y2="314.0" stroke="#e33" stroke-width="1.2" stroke-dasharray="5 3"/>
<text x="598.0" y="317.0" font-size="10" text-anchor="start" fill="#c22">顶边 = 选中行行顶</text>
<text x="430.0" y="462.0" font-size="10" text-anchor="start" fill="#000">y = 父.y + 1 + 父sel×15 → 26 = 10 + 1 + 1×15(屏内坐标)</text>
<text x="430.0" y="477.0" font-size="10" text-anchor="start" fill="#555">选中行以下空间够 → 子框原样落下,不上移</text>
<rect x="10.0" y="500.0" width="780.0" height="240.0" fill="none" stroke="#ddd" stroke-width="1"/>
<text x="18.0" y="518.0" font-size="12" text-anchor="start" fill="#000" font-weight="bold">④ Y 定位(溢出):整框上移,底边对齐“选中行”行底</text>
<rect x="40.0" y="530.0" width="160.0" height="160.0" fill="#000" stroke="#000" stroke-width="1.5"/>
<rect x="44.0" y="539.0" width="66.0" height="107.0" fill="#000" stroke="#fff" stroke-width="1.5"/>
<line x1="45.0" y1="555.0" x2="109.0" y2="555.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="45.0" y1="570.0" x2="109.0" y2="570.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="45.0" y1="585.0" x2="109.0" y2="585.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="45.0" y1="600.0" x2="109.0" y2="600.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="45.0" y1="615.0" x2="109.0" y2="615.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="45.0" y1="630.0" x2="109.0" y2="630.0" stroke="#3a3a3a" stroke-width="1"/>
<rect x="45.0" y="615.0" width="64.0" height="15.0" fill="#fff" stroke="none" stroke-width="1"/>
<text x="47.0" y="625.0" font-size="8" text-anchor="start" fill="#000">选中行</text>
<rect x="109.0" y="615.0" width="42.0" height="92.0" fill="none" stroke="#e33" stroke-width="1.2" stroke-dasharray="5 3"/>
<rect x="109.0" y="690.0" width="42.0" height="17.0" fill="#e33" stroke="none" stroke-width="1" opacity="0.35"/>
<rect x="109.0" y="538.0" width="42.0" height="92.0" fill="#000" stroke="#fff" stroke-width="1.5"/>
<line x1="110.0" y1="554.0" x2="150.0" y2="554.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="110.0" y1="569.0" x2="150.0" y2="569.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="110.0" y1="584.0" x2="150.0" y2="584.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="110.0" y1="599.0" x2="150.0" y2="599.0" stroke="#3a3a3a" stroke-width="1"/>
<line x1="110.0" y1="614.0" x2="150.0" y2="614.0" stroke="#3a3a3a" stroke-width="1"/>
<rect x="110.0" y="539.0" width="40.0" height="15.0" fill="#fff" stroke="none" stroke-width="1"/>
<text x="112.0" y="549.0" font-size="8" text-anchor="start" fill="#000">选中行</text>
<line x1="44.0" y1="630.0" x2="155.0" y2="630.0" stroke="#e33" stroke-width="1.2" stroke-dasharray="5 3"/>
<text x="158.0" y="627.0" font-size="10" text-anchor="start" fill="#c22">底边 = 选中行行底</text>
<line x1="101.0" y1="538.0" x2="101.0" y2="615.0" stroke="#444" stroke-width="1"/>
<line x1="98.0" y1="538.0" x2="104.0" y2="538.0" stroke="#444" stroke-width="1"/>
<line x1="98.0" y1="615.0" x2="104.0" y2="615.0" stroke="#444" stroke-width="1"/>
<text x="94.0" y="582.0" font-size="9" text-anchor="end" fill="#444">上移 77</text>
<text x="240.0" y="545.0" font-size="12" text-anchor="start" fill="#000" font-weight="bold">上移公式</text>
<text x="240.0" y="568.0" font-size="11" text-anchor="start" fill="#000">触发:y + h &gt; 160(顶对齐后底边超屏)</text>
<text x="240.0" y="589.0" font-size="11.5" text-anchor="start" fill="#000" font-weight="bold">y = 选中行行顶 + ROW_H − h</text>
<text x="240.0" y="609.0" font-size="10.5" text-anchor="start" fill="#555">本例(屏内坐标):85 + 92 = 177 &gt; 160 → y = 85 + 15 − 92 = 8</text>
<text x="240.0" y="637.0" font-size="11" text-anchor="start" fill="#000" font-weight="bold">为什么对齐“行底”而不贴屏底:</text>
<text x="240.0" y="657.0" font-size="10.5" text-anchor="start" fill="#000">子框挂在父框“选中行”下沿——选中行不动,子框位置就不动;</text>
<text x="240.0" y="673.0" font-size="10.5" text-anchor="start" fill="#000">若贴屏底,父框选中行一移,子框位置跟着乱跳(父子脱钩)。</text>
<text x="240.0" y="699.0" font-size="10" text-anchor="start" fill="#555">兜底:上移后 y &lt; 0 → 钳 0;仍溢出 → 框高截到可见区、行在框内滚动(§5.4 AUTO 滚动条)</text>
<text x="240.0" y="716.0" font-size="9.5" text-anchor="start" fill="#c22">虚线红框 = 顶对齐(会超屏 17px);实线框 = 上移 77 后</text>
<text x="240.0" y="730.0" font-size="9.5" text-anchor="start" fill="#555">红底 = 超屏部分(由截断/框内滚动兜底)</text>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

+100
View File
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 300" font-family="sans-serif">
<rect x="0" y="0" width="800" height="300" fill="#ffffff"/>
<text x="24" y="15" font-size="13" fill="#000000">横向级联子框总览(示例:主界面 → 参数定值 → 整定;按 §5.2 常量、§5.3 公式绘制,设备 px)</text>
<rect x="24" y="24" width="160" height="160" fill="#000000" stroke="#ffffff" stroke-width="1"/>
<line x1="31" y1="31" x2="40" y2="41" stroke="#ffffff" stroke-width="3"/>
<line x1="182" y1="31" x2="169" y2="41" stroke="#ffffff" stroke-width="3"/>
<line x1="40" y1="41" x2="169" y2="41" stroke="#ffffff" stroke-width="2"/>
<text x="41" y="55" font-size="11" fill="#ffffff">当前模式:无模式</text>
<rect x="24" y="68" width="66" height="107" fill="none" stroke="#ffffff" stroke-width="1"/>
<text x="26" y="81" font-size="12" fill="#ffffff">装</text>
<text x="38" y="81" font-size="12" fill="#ffffff">置</text>
<text x="50" y="81" font-size="12" fill="#ffffff">信</text>
<text x="62" y="81" font-size="12" fill="#ffffff">息</text>
<text x="26" y="96" font-size="12" fill="#ffffff">实</text>
<text x="38" y="96" font-size="12" fill="#ffffff">时</text>
<text x="50" y="96" font-size="12" fill="#ffffff">数</text>
<text x="62" y="96" font-size="12" fill="#ffffff">据</text>
<text x="76" y="96" font-size="10" fill="#ffffff">▶</text>
<rect x="25" y="99" width="64" height="15" fill="#ffffff"/>
<text x="26" y="111" font-size="12" fill="#000000">参</text>
<text x="38" y="111" font-size="12" fill="#000000">数</text>
<text x="50" y="111" font-size="12" fill="#000000">定</text>
<text x="62" y="111" font-size="12" fill="#000000">值</text>
<text x="76" y="111" font-size="10" fill="#000000">▶</text>
<text x="26" y="126" font-size="12" fill="#ffffff">三</text>
<text x="38" y="126" font-size="12" fill="#ffffff">遥</text>
<text x="50" y="126" font-size="12" fill="#ffffff">设</text>
<text x="62" y="126" font-size="12" fill="#ffffff">置</text>
<text x="76" y="126" font-size="10" fill="#ffffff">▶</text>
<text x="26" y="141" font-size="12" fill="#ffffff">装</text>
<text x="38" y="141" font-size="12" fill="#ffffff">置</text>
<text x="50" y="141" font-size="12" fill="#ffffff">维</text>
<text x="62" y="141" font-size="12" fill="#ffffff">护</text>
<text x="76" y="141" font-size="10" fill="#ffffff">▶</text>
<text x="26" y="156" font-size="12" fill="#ffffff">通</text>
<text x="38" y="156" font-size="12" fill="#ffffff">讯</text>
<text x="50" y="156" font-size="12" fill="#ffffff">参</text>
<text x="62" y="156" font-size="12" fill="#ffffff">数</text>
<text x="76" y="156" font-size="10" fill="#ffffff">▶</text>
<text x="26" y="171" font-size="12" fill="#ffffff">记</text>
<text x="38" y="171" font-size="12" fill="#ffffff">录</text>
<text x="50" y="171" font-size="12" fill="#ffffff">查</text>
<text x="62" y="171" font-size="12" fill="#ffffff">询</text>
<text x="76" y="171" font-size="10" fill="#ffffff">▶</text>
<rect x="89" y="99" width="42" height="32" fill="none" stroke="#ffffff" stroke-width="1"/>
<rect x="90" y="100" width="40" height="15" fill="#ffffff"/>
<text x="91" y="112" font-size="12" fill="#000000">整</text>
<text x="103" y="112" font-size="12" fill="#000000">定</text>
<text x="117" y="112" font-size="10" fill="#000000">▶</text>
<text x="91" y="127" font-size="12" fill="#ffffff">查</text>
<text x="103" y="127" font-size="12" fill="#ffffff">看</text>
<text x="117" y="127" font-size="10" fill="#ffffff">▶</text>
<rect x="130" y="100" width="42" height="62" fill="none" stroke="#ffffff" stroke-width="1"/>
<rect x="131" y="101" width="40" height="15" fill="#ffffff"/>
<text x="132" y="113" font-size="12" fill="#000000">参</text>
<text x="144" y="113" font-size="12" fill="#000000">数</text>
<text x="132" y="128" font-size="12" fill="#ffffff">定</text>
<text x="144" y="128" font-size="12" fill="#ffffff">值</text>
<text x="132" y="143" font-size="12" fill="#ffffff">控</text>
<text x="144" y="143" font-size="12" fill="#ffffff">制</text>
<text x="156" y="143" font-size="12" fill="#ffffff">字</text>
<text x="132" y="158" font-size="12" fill="#ffffff">软</text>
<text x="144" y="158" font-size="12" fill="#ffffff">压</text>
<text x="156" y="158" font-size="12" fill="#ffffff">板</text>
<line x1="89" y1="99" x2="89" y2="131" stroke="#888888" stroke-width="2"/>
<line x1="130" y1="100" x2="130" y2="162" stroke="#888888" stroke-width="2"/>
<circle cx="57" cy="68" r="2.5" fill="#888888"/>
<line x1="208" y1="33" x2="57" y2="68" stroke="#bbbbbb" stroke-width="1"/>
<text x="212" y="36" font-size="11" fill="#000000">① 根框 (0, 44)</text>
<text x="212" y="49" font-size="11" fill="#555555">w = 66(最宽行 = 4 字 + 箭头);h = 7×15 + 2 = 107</text>
<circle cx="57" cy="106" r="2.5" fill="#888888"/>
<line x1="208" y1="63" x2="57" y2="106" stroke="#bbbbbb" stroke-width="1"/>
<text x="212" y="66" font-size="11" fill="#000000">② 选中行 = 第 2 行“参数定值”</text>
<text x="212" y="79" font-size="11" fill="#555555">整行反色:白底黑字,箭头随反色变黑</text>
<circle cx="96" cy="99" r="2.5" fill="#888888"/>
<line x1="208" y1="93" x2="96" y2="99" stroke="#bbbbbb" stroke-width="1"/>
<text x="212" y="96" font-size="11" fill="#000000">③ y_mid = 44 + 1 + 2×15 = 75</text>
<text x="212" y="109" font-size="11" fill="#555555">= 父框“选中行”行顶(顶对齐)</text>
<circle cx="89" cy="115" r="2.5" fill="#888888"/>
<line x1="208" y1="123" x2="89" y2="115" stroke="#bbbbbb" stroke-width="1"/>
<text x="212" y="126" font-size="11" fill="#000000">④ x_mid = 0 + 66 − 1 = 65</text>
<text x="212" y="139" font-size="11" fill="#555555">子框左缘 = 父框右缘:共享边框列、零间隙</text>
<circle cx="136" cy="100" r="2.5" fill="#888888"/>
<line x1="208" y1="153" x2="136" y2="100" stroke="#bbbbbb" stroke-width="1"/>
<text x="212" y="156" font-size="11" fill="#000000">⑤ y_right = 75 + 1 + 0×15 = 76</text>
<text x="212" y="169" font-size="11" fill="#555555">= “整定”行顶(中框首行 → 本例与中框顶同高)</text>
<circle cx="110" cy="131" r="2.5" fill="#888888"/>
<line x1="208" y1="183" x2="110" y2="131" stroke="#bbbbbb" stroke-width="1"/>
<text x="212" y="186" font-size="11" fill="#000000">⑥ h = 行数 × 15 + 2(按内容,不强顶屏底)</text>
<text x="212" y="199" font-size="11" fill="#555555">中框 = 32;右框 = 62(底边 138 &lt; 160,无上移)</text>
<circle cx="148" cy="143" r="2.5" fill="#888888"/>
<line x1="208" y1="213" x2="148" y2="143" stroke="#bbbbbb" stroke-width="1"/>
<text x="212" y="216" font-size="11" fill="#000000">⑦ x_right = 65 + 42 − 1 = 106</text>
<text x="212" y="229" font-size="11" fill="#555555">w = 42(最宽行 = 3 字无箭头)</text>
<rect x="24" y="252" width="752" height="40" fill="#f5f5f5" stroke="#dddddd" stroke-width="1"/>
<text x="32" y="268" font-size="11" fill="#333333">x = x_parent + w_parent − 1  y = y_parent + 1 + sel × 15  h = 行数 × 15 + 2  w = 2 + 文本_w + [2 + 10] + 2 + 2</text>
<text x="32" y="284" font-size="11" fill="#333333">y + h &gt; 160 → 整框上移使底边对齐父框“选中行”行底(顶部不钳制)  行超框高 → 框内 ↑/↓ 滚动</text>
<text x="24" y="196" font-size="10" fill="#666666">屏幕 160×160(1bpp 单色,白 = 亮)</text>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

+493 -363
View File
@@ -1,13 +1,134 @@
# 菜单页面设计(逻辑层级定义)
# 菜单页面设计
> **用途**:定义 DTU 主菜单的**完整逻辑层级树与导航逻辑**。
> **用途**:定义 DTU 主菜单的**内容(42 节点层级树)、视觉规范、三层代码架构**(数据层 / 显示层 / 逻辑层)。
> **范围**:本阶段**只做菜单层级**(节点、父子、层级、进出导航);各叶子进入后的**内容页**(字段/交互/数据)另行设计,本文不含。
> **来源**:用户提供的主菜单截图 7 屏(根页 + 实时数据/参数定值/三遥设置/装置维护/通讯参数/记录查询各一级子页 + 参数定值·整定二级)。
> **导航模型**:树形**逐级进入**——`ENTER`/`→` 进入子页,`ESC`/`←` 返回上一级;最多 **4 级(L0~L3)**。
> **阅读指南**:
>
> | 你想… | 读 |
> |---|---|
> | 搞懂**哪段代码干什么、三层怎么配合** | **§一 架构总览**(职责速览 / 调用矩阵 / 一次按键的旅程 / 初始化时序 / 层间不变量),再按需深入 §三 / §五 / §六 |
> | 深入某一层 | §三 数据层 / §五 显示层 / §六 逻辑层(各章自包含:职责边界 + 字段 + API) |
> | **改菜单**(增删/改名/换序条目) | §二(内容)+ §3.7(只改文本源、重跑生成器,代码零改动) |
> | **改视觉**(框/行/几何) | §四(显示规范)+ §五(显示层实现) |
> | 搞懂**内存**(什么存、什么现算) | §7.3 + §7.5 |
> | **验证 / 调试** | §八 |
---
## 一、层级总览
## 一、架构总览:三层拆分与协作
### 1.1 为什么拆三层
"菜单表 + 导航状态 + LVGL 渲染" 三块职责若同处一个文件则互相耦合(改菜单要动导航代码、改渲染易破坏导航)。因此拆为三层,**一层一文件、各管一件事**:
- **数据层**——只回答"**有什么条目**":只读菜单表 + 4 个查询 API;
- **显示层**——只回答"**怎么画**":持有**全部** LVGL 对象,**零**导航状态;
- **逻辑层**——只回答"**用户在哪、选中哪个**":唯一 17 B 导航状态 + 键语义(移/进/退)。
3 个文件 **MCU / PC 模拟器双端共用**(双端注册:Keil `.uvprojx` + `simulator/CMakeLists.txt`,见 `UI_设计架构.md` §1.4 约束 5)。
### 1.2 三层职责速览
```mermaid
flowchart TD
K(["按键(远端键 / 模拟器键)"]) --> L["逻辑层 ui_menu.c<br/>唯一 17 B 导航状态(path/选择/深度/滚动)<br/>键语义:移动 / 进入 / 退出"]
L -- "单向驱动: frame_build / frame_destroy / frame_refresh / focus" --> V["显示层 ui_menu_view.c/h<br/>持全部 LVGL 对象,纯渲染,零导航状态"]
L -- "只读查询: children / valid" --> T["数据层 ui_menu_tab.c/h(生成物,只读)<br/>g_menu_tab[42] 父子索引表 + 4 个查询 API"]
V -- "只读查询: children / has_children / name" --> T
```
| 层 | 文件 | 一句话职责 | 持 LVGL 对象? | 持导航状态? |
|---|---|---|---|---|
| **数据层** | `Drivers/BSP/UI/ui_menu_tab.c/h`(生成物) | 回答"有什么条目":菜单表 + 查询 API | 否 | 否 |
| **逻辑层** | `Drivers/BSP/UI/ui_menu.c/h` | 回答"用户在哪、选中哪个":导航状态 + 键语义 | 否 | **是(唯一,17 B)** |
| **显示层** | `Drivers/BSP/UI/ui_menu_view.c/h` | 回答"怎么画":级联框渲染 | **是(全部)** | 否 |
> 记忆口诀:**数据层管"料",逻辑层管"账",显示层管"画"**。
### 1.3 依赖方向与调用矩阵
依赖**严格单向**:`逻辑 → 显示 → 数据`,数据层不依赖任何人;**禁止反向调用**(显示层不调逻辑层;数据/显示层不引用导航状态)。
| 调用方 ↓ / 被调 → | 数据层 | 显示层 | 逻辑层 |
|---|---|---|---|
| **数据层** `ui_menu_tab` | — | ✗ | ✗ |
| **显示层** `ui_menu_view` | ✓ 查询 API | — | ✗ |
| **逻辑层** `ui_menu` | ✓ 查询 API | ✓ 8 个显示接口 | — |
层间"词汇"只有两组接口(各层章节有完整签名):
| 方向 | 接口 |
|---|---|
| 逻辑 → 显示 | `ui_menu_view_init / container / frame_build / frame_destroy / frame_refresh / set_scroll / get_scroll / focus`(§5.5) |
| 逻辑/显示 → 数据 | `ui_menu_tab_children / has_children / name / valid`(§3.4) |
### 1.4 按键协作:一次按键的旅程
```mermaid
sequenceDiagram
participant K as 按键(远端键 / 模拟器键)
participant G as LVGL 默认输入组(lv_group)
participant L as 逻辑层 ui_menu.c
participant V as 显示层 ui_menu_view
participant T as 数据层 ui_menu_tab
K->>G: 键码
Note over G: 焦点 = 最右(最深)框容器 s_cont[d](同组 4 容器,仅最右获焦)
G->>L: LV_EVENT_KEY → menu_key_cb(user_data=depth)→ menu_key_handle
L->>L: 状态变更:移动 s_sel[d] / 进入 s_depth+1+s_path / 退出 s_depth−1
L->>V: frame_refresh(高亮+跟滚)/ frame_build(建新框)/ frame_destroy(拆最右框)+ focus(移焦点)
V->>T: children / has_children / name(条目现算不落盘)
V-->>L: get_scroll(读回滚动位置,逻辑层写入 s_scroll[d])
Note over V: LVGL 下一帧渲染 → 屏幕变化
```
两个要点:
- **逻辑层决定"改成什么状态",显示层决定"画面怎么变"**——显示层只执行、不决策;数据层全程不参与(只被显示层查询条目/名字/菜单叶子)。
- 全程**单向同步**:一次按键 = 一次完整的"状态变更 → 画面更新",无异步、无回调上行。
### 1.5 初始化时序(`ui_menu_init`)
`uiTask` 等 `SYS_EVT_NET_READY` 后调 `ui_menu_init()`,三层按此顺序配合:
```mermaid
sequenceDiagram
participant U as uiTask
participant L as 逻辑层 ui_menu.c
participant V as 显示层 ui_menu_view
participant T as 数据层 ui_menu_tab
U->>L: ui_menu_init()
L->>T: ui_menu_tab_valid() 表自检
alt 父索引越界(表损坏)
T-->>L: 不过 → 打 ERROR、放弃建菜单(返回 -1)
else 自检通过
T-->>L: 通过
end
L->>L: 清零 17 B 导航状态(s_path/s_sel/s_scroll=0,s_depth=0)
L->>V: ui_menu_view_init()(样式 → 屏幕根容器 → 4 层容器 → 根框装饰 + 状态行 → 根框本体)
L->>V: 各层容器 lv_group_add_obj(默认组)+ 挂 menu_key_cb(user_data=depth)
L->>V: ui_menu_view_frame_build(0) 建根框行 + focus(0)
Note over L: lv_mem_monitor 打印建前/建后 LVGL 池
L-->>U: 0(成功)
```
- 日志标记:`UI menu init: OK`(uiTask 打印)——`@test` 回归可作 Required 标记。
- LVGL 池实测(模拟器 40 KB 池):根框建完 `used=13312 B`;最深三级级联增量远小于池余量。
### 1.6 层间不变量(每层"绝不做")
| 层 | 绝不做 | 为什么 |
|---|---|---|
| 数据层 | 业务逻辑(4 个 API 是纯表过滤)、依赖 LVGL、修改表(`const`) | 表是唯一事实源;改表 = 改菜单(走生成器 §3.6) |
| 显示层 | 持导航状态(17 B 全在逻辑层)、解释按键(输入回调挂在逻辑层) | 显示层 = "状态的纯函数":同样状态必画同样画面 → 易验证、双端可复用 |
| 逻辑层 | 创建/修改 LVGL 对象(建对象全在显示层)、持 `lv_obj_t*` 句柄 | 唯一例外:输入接线 2 调 `lv_group_add_obj` / `lv_obj_add_event_cb`(容器须入输入组)+ 读事件参数 + `lv_mem_monitor` 内存监控 |
---
## 二、菜单内容:42 节点层级树
### 2.1 层级总览
主菜单为 4 级树:
@@ -27,9 +148,7 @@
> 根页默认选中**第 1 项"装置信息"**(整行反色白底黑字)。
---
## 二、完整层级树
### 2.2 完整层级树
```text
主界面 (L0)
@@ -78,11 +197,9 @@
**统计**:菜单节点 **9** 个(含根),叶子节点 **33** 个,共 **42** 个节点;最大深度 **4 级**。
---
### 2.3 节点定义表(逻辑层级定义)
## 三、节点定义表(逻辑层级定义)
> 扁平表,可直接映射 `g_menu_tab[]`:`#` = 数组下标,`父节点` = 父菜单的 `#`,`子项数` = 直接子项个数(叶子为 0)。
> 扁平表,与 §3.3 生成物 `g_menu_tab[]` 一一对应:`#` = 数组下标,`父节点` = 父菜单的 `#`,`子项数` = 直接子项个数(叶子为 0)。
| # | 名称 | 层级 | 类型 | 父节点 | 子项数 |
|---|---|---|---|---|---|
@@ -131,159 +248,12 @@
---
## 四、导航逻辑
## 三、数据层:ui_menu_tab(生成物,只读)
**页面模型**:每个"菜单节点"对应一个子页(其直接子项的列表页);"叶子节点"进入后为终点页(本阶段占位)。页面**按需构建**——进入时建、返回时毁,内存 = 当前导航路径。
> **一句话职责**:提供"有什么条目"——一张扁平父子索引表(42 节点,`const` 存 Flash、只读)+ 4 个查询 API,供逻辑层 / 显示层共用。
> **绝不做**:持有任何状态、依赖 LVGL、被手改(改菜单走 §3.6 生成器)。
**当前页 = 某菜单节点的直接子项列表**:
- L0 根页:7 个一级条目(#1、#2、#6、#17、#23、#28、#32)
- L1 子页:如"实时数据"页 = {交流量, 直流量, 遥信量}
- L2 子页:如"整定"页 = {参数, 定值, 控制字, 软压板}
**键位语义**:
| 键 | 行为 |
|---|---|
| `↑` / `↓` | 当前(最右)框内上/下移动选中(**首尾环绕**);到边缘且该框可滚则**滚动该框** |
| `→` 或 `ENTER` | 选中**菜单节点** → 其右侧**弹出子框**;选中**叶子节点** → 进入终点页 |
| `←` 或 `ESC` | 返回:**关最右框**(销毁其行)、焦点回父框、选中态保持(不跳) |
| 焦点 / 框头 | 焦点 = 最右(最深)框;**无逐框页头/返回按钮**(框即紧凑列表,返回靠 `←`);仅根框带主界面装饰 |
**选中态**:整行反色(白底黑字);菜单行有 **▶**,叶子行无。
**进入/返回示例**(路径):
```
主界面 → 参数定值 → 整定 → 参数
L0 L1 L2 L3
返回:主界面 ← 参数定值 ← 整定 ← 参数
```
### 导航走查:`g_menu_tab` 到底怎么驱动"移 / 进 / 退"
> **一句话**:导航**从不遍历树**,它只反复向 `g_menu_tab` 问一个问题——
> **"谁的 parent 等于当前节点?"** 答案(按表序)就是当前页要显示的条目。
> **进** = 把选中的子项记为新的"当前节点"再问一遍;**退** = 忘掉它、还原上一个"当前节点"。
> 表是**扁平**的(无嵌套指针),"树"只是"反复按 parent 过滤"营造出来的错觉。
>
> **注**:①~⑦ 讲"**数据怎么驱动**"(每级 = 对 `parent` 的一次过滤,此处称"页");这些"页"在**屏上如何摆**(横向级联子框、子框贴父框右缘、顶对齐/底对齐上移/滚动)见 **⑧ 显示模型**。
**① 当前页 = 对表的一次过滤**
```text
显示"节点 N 的页面" ⇔ 取 g_menu_tab 中所有 parent == N 的行(按表序 = 显示序)
```
| 你所在的页 | 当前节点 N | 执行的查询 | 页面上出现的条目 |
|---|---|---|---|
| 主界面(根页) | 0(主界面) | `parent == 0` | 装置信息、实时数据▶、参数定值▶、三遥设置▶、装置维护▶、通讯参数▶、记录查询▶(**7 项**)|
| 参数定值页 | 6(参数定值) | `parent == 6` | 整定▶、查看▶(**2 项**)|
| 整定页 | 7(整定) | `parent == 7` | 参数、定值、控制字、软压板(**4 项,全叶子**)|
> 行尾有没有 **▶** 也由同一张表判:`parent == 该节点` **非空** → 菜单(有 ▶);**为空** → 叶子(无 ▶)。
**② 移(↑ / ↓)**:只改当前页的选中下标 `sel`(**首尾环绕**),**不碰表**。
**③ 进(ENTER / →)**:选中条目 = `item_node[sel]`,记作子节点 **M**。向表问一句"**M 还有孩子吗?**"(`parent == M` 非空?)
- **有** → M 是菜单:**压一页**,新页 `node = M`,再按 ① 查一次显示 M 的孩子。
- **没有** → M 是叶子:进**终点页**(本阶段占位),不再下钻。
**④ 退(ESC / ←)**:**弹一页**——销毁当前页、回到 depth-1 的父页;父页选中项**保持**为"刚才那个 M 在父页里的下标"(= 按下 ENTER 前的位置,不跳)。
**⑤ 完整走查**(`#` = `g_menu_tab` 下标):主界面 → 参数定值 → 整定 → 参数,再一级级退回
```text
【根页 node=0】 parent==0 → 7 项;选中 [2]=参数定值(#6)
ENTER ─▶ 问"6 有孩子吗"→ parent==6 非空(整定/查看) → 压页
【参数定值页 node=6】 parent==6 → [整定#7, 查看#12];选中 [0]=整定
ENTER ─▶ 问"7 有孩子吗"→ parent==7 非空(参数/定值/控制字/软压板) → 压页
【整定页 node=7】 parent==7 → [参数#8, 定值#9, 控制字#10, 软压板#11];选中 [0]=参数
ENTER ─▶ 问"8 有孩子吗"→ parent==8 为空 → 叶子 → 进终点页
【参数终点页 node=8】(占位,无条目,到此为止)
ESC ─▶ 弹页 → 回【整定页】,选中仍 [0]=参数
ESC ─▶ 弹页 → 回【参数定值页】,选中仍 [0]=整定
ESC ─▶ 弹页 → 回【根页】,选中仍 [2]=参数定值
```
**⑥ 导航状态 = 一条"当前节点"链**(= 从根到当前页的路径)
```text
s_path[0]=0 ─▶ s_path[1]=6 ─▶ s_path[2]=7 ─▶ s_path[3]=8
根页 参数定值页 整定页 参数终点页
depth=0 depth=1 depth=2 depth=3
```
- 链上**每个 node 都是上一个的孩子**(parent 关系)→ 这条链天然就是路径,**无需额外记录"我从哪来"**。
- **进 = 链尾追加**(选中的子节点接上去);**退 = 链尾弹掉**。
- 内存只占"当前这条链"(`s_path[0..s_depth]`,共 9 B 历史)+ **屏上各框各一套行对象**(屏外框行销毁),退回即释放 → 即"框按需建毁、内存 = 路径"(详见 §七、⑧)。
**⑦ 对应到代码**(逻辑层 `ui_menu.c` 驱动显示层 `ui_menu_view.c`;三层拆分见 §八)
| 逻辑 | 函数(实现名) | 读 / 写的数据(见 §七)|
|---|---|---|
| ① 建框(显示某节点子项)| 显示层 `ui_menu_view_frame_build(d, node, sel, scroll)`(由逻辑层调用) | 读 `s_path[d]`+`g_menu_tab` 现算条目;算 x/顶边/框宽、建 `s_row[d][*]`、恢复 `s_scroll[d]` |
| ③ 进(压层 / 终点)| 逻辑层 `menu_open(child)` | 写 `s_depth+1`、`s_path[d+1]=M`、`s_sel[d+1]`(同节点还原 / 换节点清零);再 `frame_build` + `focus` |
| ④ 退(弹层)| 逻辑层 `menu_back()` | `ui_menu_view_frame_destroy(s_depth)`;`s_depth-1`、焦点回父框(父框不动、无需重建,`s_sel`/`s_scroll` 原样保留) |
| ⓦ 移选 | 逻辑层 `menu_key_handle(d, key)` → `menu_refresh_layer(d)` | 写 `s_sel[d]`(首尾环绕);`frame_refresh`(高亮 + 跟随滚动);滚动位回读入 `s_scroll[d]` |
**⑧ 显示模型:横向级联子框(右贴靠 + 自适应宽 + 无动画)**〔已按参考图 02~07 像素实测校准〕
> ①~⑦ 讲"**数据怎么驱动**"(每级 = 对 `parent` 的一次过滤);本节讲这些"页"在**屏上怎么摆**:每级一个**框**,贴靠在其**父框右侧**——一棵**横向的树**(往右生长)。
> 以下规则由参考图 02~07(`mockups/`)逐像素实测得出(设备 px,误差 ≤3px),非推测。
**水平(X):右贴靠 + 自适应宽**
- 子框**左缘 = 父框右缘**:共享边框列、零间隙;逐级向右生长(实测:各截图子框均恰好贴在父框右缘 x≈67/106 处)。
- 框宽 = 该框最宽条目(文字 + ▶ + 边距 + 边框),按内容自适应(公式见 §8.3)。参考图实测:4字+箭头 ≈68、4字无箭头 ≈60、3字 ≈46、2字+箭头 ≈40;**实现实测**(模拟器像素统计):66 / 54 / 46 / 42,另有空终点框 6(偏差 2/8/0/2 px,参考图为手工 mockup,见 §8.8 注 5)。
- 最深框**不钉屏幕右缘**(实测:实时数据子框止于 x≈113/160;三遥/装置维护/记录查询子框止于 x≈127/160)。
- 本菜单最深路径宽 = 实现实测 66+42+42+6−3(共享边框)= **153 < 160** → 级联不会超屏宽;>160 参考图未覆盖,实际行为 = 框被全屏容器在屏缘裁切(右缘截断),无特殊处理。
- 屏上几框 = 深度 × 各框宽 决定(不固定);**无滑动动画**(1bpp 推流,动画太费带宽)。
**垂直(Y):每框独立(顶对齐 + 底对齐上移)**
- 默认:顶边 = 父框"选中行"**行顶**(实时数据/整定两例实测 Δ≤2px);框高 = 条目数 × 行高(**按内容**,不强顶屏底)。
- (顶边 + 框高) 超出屏底 → 整框上移使**子框底边对齐父"选中行"行底**(不是贴屏底):参考图实测 三遥设置 top=26 / 装置维护 top=54 / 通讯参数 top=82 / 记录查询 top=12;**实现实测 28 / 58 / 88 / 13**(Δ=2/4/6/1 px——参考图为手工 mockup,四个样本不满足同一公式,实现取"底边对齐选中行行底"这一自洽规则,两端极例(三遥/记录查询)精确吻合,见 §8.8 注 5)。
- 上移后框可叠到屏顶状态行/装饰区(记录查询子框 top≈12)——**顶部不钳制**。
- 比整屏还高 → `↑`/`↓` 滚(初始对齐"选中行")。
**焦点 / 键**:焦点 = 最右框;`↑`/`↓` 框内移选(越界则滚)、`→`/`ENTER` 进子框、`←`/`ESC` 关最右框。
**内存**:**仅屏上框持行对象**,框关闭(返回)即毁其行 → 池有界(屏上框数 × 每框行数,与深度无关),详见 §七。
```text
┌──────────┬───────┐
│ 装置信息 │ │
│ 实时数据▶ │ │
│▸参数定值 ├───────┼───────┐
│ 三遥设置▶ │整定▶ │参数 │ ← 中框顶边 = "参数定值"行顶;右框顶边 = "整定"行顶(本例同高)
│ 装置维护▶ │查看▶ │定值 │
│ 通讯参数▶ ├───────┤控制字 │
│ 记录查询▶ │ │软压板 │
└──────────┴───────┘ └───────┘
子框左缘 = 父框右缘(共享边框列、零间隙);宽按内容自适应;不钉屏幕右缘。
超屏底 → 上移使子框底边对齐父"选中行"行底(可叠到屏顶状态行/装饰区)。
```
## 五、与旧实现的差异(2026-09-04 已实现)
> 旧 `ui_menu.c` 的 `g_menu_tab[]` 为"层级(level)+连续兄弟段"结构,现已整体替换为 `ui_menu_tab.c`(父子索引表,生成物)。下表为旧→新条目差异记录(已落地)。
| 一级 | 现代码子项 | 目标子项(本文) |
|---|---|---|
| 实时数据 | 交流量、直流量、**开入量** | 交流量、直流量、**遥信量** |
| 参数定值 | **定值**{定值/遥调/零压/电压}、查看{定值/遥调/零压/电压} | **整定**{参数/定值/控制字/软压板}、查看{参数/定值/控制字/软压板} |
| 三遥设置 | 测量值、遥调系数、遥测系数、双字节、装置遥调 | 遥测死区、遥测系数、遥信类型、遥信防抖、双点遥信 |
| 装置维护 | **时间设置**、强制复归、手动录波、**自动录波** | **时钟设置**、强制复归、手动录波、**清除记录** |
| 通讯参数 | **通讯参数**、**网络配置**、SNTP设置 | **通讯设置**、**网口设置**、SNTP设置 |
| 记录查询 | SOE记录、事故记录、操作记录、**告警记录、变位告警、遥信记录**、自检记录、**定值记录、录波记录** | SOE记录、事故记录、操作记录、**保护告警、保护启动、遥控记录**、自检记录、**运行记录、运行报告** |
| (根外) | 厂家设置/版本信息(旧表含,已排除) | 不列入 7 个一级条目 |
**新增层级**:参数定值下"整定/查看"为 L2 菜单,其子项(参数/定值/控制字/软压板)为 **L3**——节点表需重排。
---
## 六、菜单树数据结构(C,存储 + 导航解耦)
> 目标:把"菜单长什么样"(数据)与"怎么进出"(导航逻辑)彻底解耦;改菜单只改**一张表**,导航代码不动。
### 6.1 选型:扁平"父子索引表"(邻接表 / 关系表模型)
### 3.1 选型:扁平"父子索引表"(邻接表 / 关系表模型)
成熟树存储方案对比(参考 Wikipedia "Tree (abstract data type)"、文件系统/关系库的层级表示):
@@ -300,7 +270,7 @@ s_path[0]=0 ─▶ s_path[1]=6 ─▶ s_path[2]=7 ─▶ s_path[3]=8
- 子项 = 表中所有 `parent == 本下标` 的行,**按表序**排列(表序即显示序)。
- **菜单 vs 叶子由"是否有子项"自动推导**:加子项→自动出现 ▶;删光子项→自动变叶子。无需单独的类型字段,替换时零维护。
### 6.2 数据结构定义
### 3.2 数据结构定义
```c
#include <stdint.h>
@@ -315,11 +285,11 @@ typedef struct {
} ui_menu_node_t;
```
> 实现落在生成器产物 `Drivers/BSP/UI/ui_menu_tab.h`(见 §6.6);字段/语义与此一致。
> 实现落在生成器产物 `Drivers/BSP/UI/ui_menu_tab.h`(见 §3.6);字段/语义与此一致。
### 6.3 完整 42 节点表(按 §三 节点定义表落地)
### 3.3 完整 42 节点表(按 §2.3 节点定义表落地)
> 产物 `ui_menu_tab.c`(§6.6 生成):`const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT]`,内容与下表一致。
> 产物 `ui_menu_tab.c`(§3.6 生成):`const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT]`,内容与下表一致。
```c
const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT] = {
@@ -371,9 +341,9 @@ const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT] = {
> `UI_MENU_TAB_COUNT`(=42)与 `MENU_ROOT` 在 `ui_menu_tab.h` 定义。
### 6.4 导航 API(实现:`ui_menu_tab.h` 生成物,替换菜单零改调用方)
### 3.4 查询 API(逻辑层 / 显示层共用)
设计阶段拟名 `menu_get_children/…`;实现采用模块前缀 `ui_menu_tab_*`(避免与逻辑层 `menu_*` 函数名混淆),参数 `uint8_t`(2026-09-04 由 16→8 位决定,见变更记录),实现随表生成在 `ui_menu_tab.c`:
4 个查询 API 统一模块前缀 `ui_menu_tab_*`(与逻辑层 `menu_*` 函数名区分),参数 `uint8_t`(菜单上限 255 项),随表生成在 `ui_menu_tab.c`:
```c
/* ui_menu_tab.h(生成物;逻辑层与显示层共用) */
@@ -383,7 +353,16 @@ const char *ui_menu_tab_name(uint8_t node); /*
bool ui_menu_tab_valid(void); /* 启动自检:非根 parent 下标均有效 */
```
**建框 = 对"当前层节点"做一次过滤**(条目**现算不落地**,见 §七):
**各层用哪几个**(谁问数据层什么问题):
| API | 逻辑层(键分发) | 显示层(建框渲染) |
|---|---|---|
| `children` | ✓ 取子项/子项数(判空框、取选中子节点) | ✓ 取本框条目列表 |
| `has_children` | — | ✓ 判行尾有无 ▶ |
| `name` | — | ✓ 行文本 |
| `valid` | ✓ 初始化自检(§1.5 第 1 步) | — |
**建框 = 对"当前层节点"做一次过滤**(条目**现算不落地**,见 §7.3):
```c
/* 显示层 ui_menu_view_frame_build(depth, node, sel, scroll) 内: */
@@ -391,22 +370,22 @@ uint8_t kids[MENU_ITEM_MAX];
uint8_t cnt = ui_menu_tab_children(node, kids, MENU_ITEM_MAX); /* node = s_path[depth] */
for (uint8_t i = 0; i < cnt; i++) {
/* 在框体内建一行:文本 = ui_menu_tab_name(kids[i]);
箭头 = ui_menu_tab_has_children(kids[i)](有子项才带 ▶) */
箭头 = ui_menu_tab_has_children(kids[i])(有子项才带 ▶) */
}
```
> 旧"level + 连续兄弟段"推断已整体移除(其根页遇第二个 `level==0` 即截断的缺陷不复存在);导航/显示一律按 `parent == 节点` 过滤,**改名/换序/增删子项均不影响导航逻辑**。
> 导航/显示一律按 `parent == 节点` 过滤,**改名/换序/增删子项均不影响导航逻辑**。
### 6.5 启动自检(已实现:`ui_menu_tab_valid()`)
### 3.5 启动自检:`ui_menu_tab_valid()`
> 实现随表生成在 `ui_menu_tab.c`:校验**非根节点 parent 下标均 < `UI_MENU_TAB_COUNT`**(捕获表内笔误/生成器缺陷)。
> 由 `ui_menu_init()` 首行调用;失败 → 打 `DBG_ERROR("menu table: INVALID…")` 并放弃建菜单(返回 -1)。
> 设计稿曾列"无环/全连通"校验;实现从简为 parent 界校验——生成器已保证 parent 恒为"上级节点下标"(结构上无环),界校验足以兜底。
> 由 `ui_menu_init()` 首行调用(§1.5 第 1 步);失败 → 打 `DBG_ERROR("menu table: INVALID…")` 并放弃建菜单(返回 -1)。
> 校验取 parent 界校验即够——生成器保证 parent 恒为"上级节点下标"(结构上无环),界校验足以兜底。
### 6.6 声明式生成器(**已实现**,呼应字体生成 `ui_cn_chars.txt → make_font.py`)
### 3.6 声明式生成器:`ui_menu_tree.txt` → `ui_menu_tab.c/.h`(与字体生成 `ui_cn_chars.txt → make_font.py` 同模式)
菜单层级声明在**缩进即层级**的文本源 `ui_menu_tree.txt`(仓库根,每级 2 空格、`#` 注释),
脚本读源、推导每个节点 `parent` 下标、产出 C 表。**改菜单只编辑文本源,重跑脚本即可**,导航代码零改动。
脚本读源、推导每个节点 `parent` 下标、产出 C 表。**改菜单只编辑文本源,重跑脚本即可**,导航/显示代码零改动。
| 文件 | 说明 |
|---|---|
@@ -420,31 +399,22 @@ for (uint8_t i = 0; i < cnt; i++) {
python tools/menu_gen/gen_menu.py
```
> **已实现**(2026-09-04):`ui_menu_tab.c/.h` 已生成(42 节点:9 菜单 + 33 叶子,最深 L3),`ui_menu.c` 不再持有本地表,导航/显示一律走 `ui_menu_tab_*` 查询 API(§6.4)。
> 产物 `ui_menu_tab.c/.h` 含 42 节点(9 菜单 + 33 叶子,最深 L3);`ui_menu.c` 不持有本地表,导航/显示一律走 `ui_menu_tab_*` 查询 API(§3.4)。
> **编码陷阱(已踩)**:产物含中文**字符串字面量**,必须 **UTF-8 带 BOM**(同 `ui_menu.c`)。
> 无 BOM 时 cl.exe(代码页 936/GBK)会按 GBK 误读 UTF-8 字节 → 字符串解析错乱(C2001/C4819)。`gen_menu.py` 已用 `utf-8-sig` 输出。
> **编码陷阱(已踩)**:产物含中文**字符串字面量**,必须 **UTF-8 带 BOM**(同 `ui_menu.c`)。
> 无 BOM 时 cl.exe(代码页 936/GBK)会按 GBK 误读 UTF-8 字节 → 字符串解析错乱(C2001/C4819)。`gen_menu.py` 已用 `utf-8-sig` 输出。
### 6.7 与现有 `g_menu_tab[]` 的迁移映射
### 3.7 改菜单流程
| 现代码(`ui_menu.c`) | 新结构 | 说明 |
|---|---|---|
| `{ uint8_t level; const char *name; }` | `{ uint8_t parent; const char *name; }` | 用 `parent` 取代 `level`,父子关系显式 |
| `menu_page_build()` 按 level 连续段取子项 | `ui_menu_tab_children(node,…)` 按 `parent==node` 取 | 导航逻辑与数据解耦 |
| 根页遇第二个 `level==0` 截断 | 根页 = 主界面(node 0)子项(`parent==0`),走同一 `ui_menu_tab_children` 路径,天然无截断 | 消除旧表根外"厂家设置"隐患;根页无需特判 |
| 菜单/叶子靠 level 推断 | `ui_menu_tab_has_children()` 按"是否有子项"推导 | ▶ 自动出现/消失,零维护 |
```text
1. 编辑 ui_menu_tree.txt —— 缩进即层级(每级 2 空格),# 为注释;增/删/改名/换序条目都只改这里
2. python tools/menu_gen/gen_menu.py —— 重跑生成器:表 / UI_MENU_TAB_COUNT / 8 位护栏 全部同步更新
3. 完成:导航(逻辑层)与渲染(显示层)代码零改动
```
> **迁移已于 2026-09-04 完成**:`ui_menu.c` 不再含本地 `g_menu_tab`(level 版已删除),旧 `menu_page_build()` 的 level 段推断不复存在;新 `ui_menu.c` 为纯导航状态机(见 §八)。
> 注意:新增**中文汉字**时,须同步加入字体字符集 `ui_cn_chars.txt` 并重生成字体(见 `UI_中文字体生成与配置.md`),否则显示成方框。
---
## 七、导航运行时数据结构(完整清单)
> 导航用到的数据分**两层**:**静态层**(Flash/const,菜单树,只读查询)+ **运行时层**(RAM,导航状态 + LVGL 对象)。
> **核心原则:能推导的就不存**——某页的条目列表可由 `g_menu_tab` 的 `parent==节点` 随时重算;
> 故**只持久化"不可推导"的导航历史(路径 + 各层选中)**,页面内容进出时按需重建。
### 7.1 静态层(Flash,const,只读)
### 3.8 Flash 占用(const,只读)
| 符号 | 定义处 | 内容 | 占用 |
|---|---|---|---|
@@ -453,144 +423,57 @@ python tools/menu_gen/gen_menu.py
| 名字字面量 | 同上 | 42 个 `const char[]` | ≈ 400 B |
| `UI_MENU_TAB_COUNT` / `MENU_ROOT` | `ui_menu_tab.h` | 节点数 42 / 根哨兵 0xFF(8 位,上限 255 项) | 宏 |
> 导航**只读**它(`ui_menu_tab_children`/`ui_menu_tab_has_children`/`ui_menu_tab_name` 都是对它的过滤/查询),从不修改。
### 7.2 运行时层(RAM)
> 归属:**A 在逻辑层 `ui_menu.c`**(唯一持有导航状态的文件);**B~G 在显示层 `ui_menu_view.c`**(持有全部 LVGL 对象,无导航状态)。
**A. 导航历史 —— 唯一"必须持久"的本质状态(A 组 9 B,加 F 组 `s_scroll` 共 17 B,见 §7.3)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_path[]` | `uint8_t[MENU_PAGE_MAX]` | 4 B | **路径**:`s_path[d]`=第 d 层"展示的节点下标"(`s_path[0]=0` 主界面);即"从根到当前(最右)框"的节点链 |
| `s_sel[]` | `uint8_t[MENU_PAGE_MAX]` | 4 B | **各层选中下标**:返回时还原该层高亮(选中不跳) |
| `s_depth` | `uint8_t` | 1 B | **当前深度**(0=根页)= 路径长度−1 |
**B. 持久页面容器(每深度一个,一次创建、常驻、不销毁)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_cont[]` | `lv_obj_t*[MENU_PAGE_MAX]` | 16 B | 4 个全屏容器(每深度一个,显示层);透明常驻、**从不切换可见性**(框在容器内建/毁,容器永不删,防焦点对象悬空);焦点经 `lv_group_focus_obj` 在容器间移动 |
**C. 活跃框条目对象(按层:每个"屏上框"一套,进时建、退/关闭时毁)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_row[d][i]` | `lv_obj_t*[MENU_PAGE_MAX][MENU_ITEM_MAX]` | 256 B | 各框各行容器(整行反色高亮的载体),仅屏上框 `d` 有效 |
| `s_row_text[d][i]` | `lv_obj_t*[MENU_PAGE_MAX][MENU_ITEM_MAX]` | 256 B | 各行文本 label |
| `s_row_arrow[d][i]` | `lv_obj_t*[MENU_PAGE_MAX][MENU_ITEM_MAX]` | 256 B | 各行箭头 label(叶子=NULL) |
> **只给"屏上的框"建行对象**(每框 ≤ `MENU_ITEM_MAX`=16 行);返回时销毁被关闭的最右框行,既有屏上框不动、无需重建,
> **已关闭的框不留行对象** → LVGL 池只占**屏上各框**(2~3 框),比"4 页全常驻"省池内存。行句柄按层开(3 组共 768 B),仅填屏上框。
**D. 主界面(根框)装饰**
| 字段 | 类型 | 用途 |
|---|---|---|
| `s_root_deco` / `s_root_status` | `lv_obj_t*` ×2 | 根框装饰:顶部"浅 V"水平段(两腿为局部对象,不落句柄)+ 状态行 label("当前模式 : 无模式");根框框体复用 `s_frame_box[0]`(无独立句柄) |
> 子框**无逐框页头/返回按钮**(返回靠 `←` 键,框即紧凑列表)——较旧"单页 + 页头"模型省去 `s_header`/`s_back_btn`/`s_title`。
**E. 样式(一次性初始化,常驻)**
`s_style_flat`(透明无框,剥离主题 card)、`s_style_selected`(选中整行反色)、`s_style_frame`(框体 1px 白边框)、`s_style_scrollbar`(滚动条,4px 宽)。
**F. 每框显示状态(按层)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_scroll[]` | `uint16_t[MENU_PAGE_MAX]` | 8 B | 每框滚动偏移(用户 `↑`/`↓` 滚;该框关闭后再进入、行对象重建时按此还原位置) |
> 每框的 **x 坐标 / 顶边 / 框宽** 是**现算**的(右贴靠布局:子框左缘=父框右缘 + 顶对齐/底对齐上移,见 §四 ⑧),不落导航状态;只有"用户滚动到哪"(`s_scroll[]`)需持久。
**G. 框几何缓存(显示层,"现算"结果的按层缓存)**
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_frame_box[]` | `lv_obj_t*[MENU_PAGE_MAX]` | 16 B | 每深度框体对象(d=0 持久;d>0 进入时建、返回时毁) |
| `s_frame[]` | `menu_frame_t`(built/sel/item_cnt/x/y/w/h)×4 | ≈80 B | 每框几何缓存:子框对齐要用**父框刚算出**的 x/w/选中行,故按层留一份;随时可由 `s_path`/`s_sel` 重算,**不属于导航状态** |
### 7.3 存 vs 算(关键区分)
| 数据 | 存 / 算 | 依据 |
|---|---|---|
| 路径(在哪些层) | **存** `s_path` | 用户导航历史,不可推导 |
| 各层选中 | **存** `s_sel` | 用户选择,不可推导 |
| 每框滚动位 | **存** `s_scroll` | 用户 `↑`/`↓` 滚动,不可推导 |
| 某框条目列表 | **算** | `ui_menu_tab_children(s_path[d])` = 过滤 `parent==s_path[d]` |
| 条目是菜单/叶子(有无 ▶) | **算** | `ui_menu_tab_has_children(node)` = 存不存在 `parent==node` |
| 条目显示名 | **算** | `g_menu_tab[node].name` |
| 每框 x / 顶边 / 框宽 | **算** | 右贴靠布局(子框左缘=父框右缘)+ 顶对齐/底对齐上移(§四 ⑧),现算 |
> **结论:持久导航状态 = `s_path` + `s_sel` + `s_depth` + `s_scroll`(共 17 B)**;
> 框的条目/名字/菜单叶子/位置(框宽·x·顶边) 100% 现算,进/退时重建。
### 7.4 导航操作 × 字段读写
| 操作 | 读 | 写 |
|---|---|---|
| **建框**(进 / 初始化) | `s_path[d]`、`g_menu_tab`(现算条目)、`s_sel[d]`、`s_scroll[d]` | 算该框 x/顶边/框宽(§四 ⑧);建 `s_row[d][*]`;恢复滚动位 |
| **移**(↑/↓) | `s_sel[d]`、`s_scroll[d]`、`s_row[d][*]` | `s_sel[d]`(首尾环绕);到边缘则改 `s_scroll[d]`(滚);刷新高亮 |
| **进**(ENTER/→,菜单) | `s_sel[d]`→子节点 M、`ui_menu_tab_has_children(M)`=真 | `s_depth+1`、`s_path[d+1]=M`、`s_sel[d+1]=0`;新框贴父框右缘生成(既有框不动、不重排) |
| **进**(ENTER/→,叶子) | `ui_menu_tab_has_children(M)`=假 | `s_depth+1`、`s_path[d+1]=M`(终点页) |
| **退**(ESC/←) | `s_depth` | `s_depth-1`、销毁最右框行、焦点回父框(父框不动、无需重建,`s_sel[d-1]`/`s_scroll[d-1]` 原样保留) |
### 7.5 内存预算
**Flash(const)**:`g_menu_tab` 336 B + 名字 ~400 B ≈ **~0.7 KB**
**RAM(静态/常驻)**:
| 组 | 大小 |
|---|---|
| 导航历史 `s_path`+`s_sel`+`s_depth` | 9 B |
| 每框滚动 `s_scroll[4]` | 8 B |
| 容器 `s_cont[4]` | 16 B |
| 行句柄 `s_row[d][i]`+`s_row_text`+`s_row_arrow` `[4][16]` | 768 B |
| 根框装饰指针(2 个:水平段 + 状态行) | 8 B |
| 框体句柄 `s_frame_box[4]` | 16 B |
| 框几何缓存 `s_frame[4]`(`menu_frame_t`) | ≈80 B |
| 样式 `s_style_*`(4 个,一次性) | ~400 B |
| **小计** | **~1.4 KB** |
**LVGL 池(动态,`LV_MEM` 40 KB 预算内)**:占**屏上各框**对象 = 屏上框数(2~3) × 行数 ×(行容器+文本+箭头),
典型 ≈ **4–8 KB**,最坏 3 框满 16 行 ≈ 15 KB(仍远在 40 KB 内)。
> 对比现有 `s_pages[4]`(每页各带行对象、**4 页全常驻**):本方案行句柄按层开(768 B,与旧相当),
> 但 **LVGL 池只给屏上框建行**(2~3 框、关闭即毁)→ 池峰值比"4 页全常驻"省约 1/4;静态 RAM 因句柄按层略升(~0.75→~1.3 KB),仍在余量内。
### 7.6 与旧实现的对照(迁移已完成 2026-09-04)
| 旧实现(原 `ui_menu.c` 单文件模型) | 现实现(本节) | 实现结果 |
|---|---|---|
| `ui_menu_page_t s_pages[4]`(每页各带 `row[16]/row_text[16]/row_arrow[16]/item_node[16]/node/sel/depth`,4 页全常驻) | `s_path[]`+`s_sel[]`+`s_depth`+`s_scroll[]`(17 B)+ 按层 `s_row[d][i]` + `s_cont[4]`,**仅屏上框建行** | 路径/选中/滚动按层存;行只给屏上框(关闭即毁) |
| `page->item_node[16]`(存条目下标) | 不存,建框时 `ui_menu_tab_children(s_path[d])` 现算 | 去掉 `item_node[]` 存储 |
| `page->node`(本页节点) | `s_path[d]`(按层存) | 等价,改按层 |
| `page->sel` / `page->depth` | `s_sel[d]` / `s_depth` | 按层存选中 / 用深度代替 `s_cur` 指针 |
| `s_cur`(当前页指针) | `s_depth`(当前深度) | 深度索引替代指针 |
> **一句话**:导航**不需要**为每框存"条目列表/位置"——只存**路径 + 各层选中 + 各框滚动位**(17 B),
> 框的条目/名字/位置全现算、行只给屏上框;改菜单(§6.6)只动静态表,运行时结构零改动。
> 逻辑层/显示层**只读**它(§3.4 的 API 都是对它的过滤/查询),从不修改。合计 ≈ **0.7 KB Flash**。
---
## 八、实现架构与关键实施细节(2026-09-04 与代码同步)
## 四、显示规范:横向级联子框模型
> 本节把 §四/§六/§七 设计同步到**实际落地代码**(2026-09-04 实现;模拟器 7 状态截图对照参考图 01~07 + `@build` 0 错 0 警已验证)。
> **本章是显示层(§五)要实现的"图纸"**:讲"页"(每级的子项列表)在**屏上怎么摆**——每级一个**框**,贴靠在其**父框右侧**——一棵**横向的树**(往右生长)。
> 以下规则由参考图 02~07(`docs/UI 设计/mockups/`)逐像素实测得出(设备 px,误差 ≤3px);参考图为**手工 mockup**,与实现的偏差见 §8.2 注 5。
### 8.1 三层文件拆分(高内聚低耦合)
### 4.1 总览
| 层 | 文件 | 职责 | 持 LVGL 对象? |
|---|---|---|---|
| 数据层 | `Drivers/BSP/UI/ui_menu_tab.c/h`(生成物) | `g_menu_tab[]` 父子索引表 + 4 个查询 API(§6.4) | 否 |
| 显示层 | `Drivers/BSP/UI/ui_menu_view.c/h` | 级联框渲染:样式 / 4 层容器 / 框体 / 行 / 根框装饰;几何现算按层缓存(§7.2 G) | 是(全部) |
| 逻辑层 | `Drivers/BSP/UI/ui_menu.c/h` | 导航状态(`s_path/s_sel/s_depth/s_scroll`,17 B,§7.2 A/F)+ 键语义;单向驱动显示层建/毁/刷新;唯一公开入口 `int ui_menu_init(void)`(uiTask 调用) | 否 |
![横向级联子框总览(主界面 → 参数定值 → 整定)](images/级联框总览.svg)
- 依赖方向单向:逻辑 → 显示;两者都用数据层查询 API;显示层无导航状态、逻辑层无 LVGL 对象
- 3 个文件 MCU / PC 模拟器**共用**(双端注册:Keil `.uvprojx` + `simulator/CMakeLists.txt`,见 `UI_设计架构.md` §1.4 约束 5)
- 每级一个**框**,框 = 该级条目列表(紧凑列表,**无逐框页头/返回按钮**,返回靠 `←`);
- **无滑动动画**(1bpp 推流);
- 屏上几框 = 深度 × 各框宽 决定(不固定)。
### 8.2 布局常量(设备 px,定义于 `ui_menu_view.c`)
### 4.2 水平布局(X):右贴靠 + 自适应宽
- 子框**左缘 = 父框右缘**:共享边框列、零间隙;逐级向右生长(实测:各截图子框均恰好贴在父框右缘 x≈67/106 处)。
- 框宽 = 该框最宽条目(文字 + ▶ + 边距 + 边框),按内容自适应(公式见 §5.3)。参考图实测:4字+箭头 ≈68、4字无箭头 ≈60、3字 ≈46、2字+箭头 ≈40;**实现实测**(模拟器像素统计):66 / 54 / 46 / 42,另有空终点框 6(偏差 2/8/0/2 px)。
- 最深框**不钉屏幕右缘**(实测:实时数据子框止于 x≈113/160;三遥/装置维护/记录查询子框止于 x≈127/160)。
- 本菜单最深路径宽 = 实现实测 66+42+42+6−3(共享边框)= **153 < 160** → 级联不会超屏宽;>160 参考图未覆盖,实际行为 = 框被全屏容器在屏缘裁切(右缘截断),无特殊处理。
### 4.3 垂直布局(Y):每框独立(顶对齐 + 底对齐上移)
- 默认:顶边 = 父框"选中行"**行顶**(实时数据/整定两例实测 Δ≤2px);框高 = 条目数 × 行高(**按内容**,不强顶屏底)。
- (顶边 + 框高) 超出屏底 → 整框上移使**子框底边对齐父"选中行"行底**(不是贴屏底):参考图实测 三遥设置 top=26 / 装置维护 top=54 / 通讯参数 top=82 / 记录查询 top=12;**实现实测 28 / 58 / 88 / 13**(Δ=2/4/6/1 px——四个样本不满足同一公式,实现取"底边对齐选中行行底"这一自洽规则,两端极例(三遥/记录查询)精确吻合,见 §8.2 注 5)。
- 上移后框可叠到屏顶状态行/装饰区(记录查询子框 top≈12)——**顶部不钳制**。
- 比整屏还高 → `↑`/`↓` 滚(初始对齐"选中行")。
### 4.4 选中态与焦点
- **选中态**:整行反色(白底黑字);菜单行有 **▶**,叶子行无;**选中行保留 ▶**(箭头随反色变黑,参考图 02/04 实测);非选中行透明底白字。
- **焦点** = 最右(最深)框;框无页头/返回按钮(框即紧凑列表,返回靠 `←`);仅根框带主界面装饰(顶部"浅 V" + 状态行"当前模式 : 无模式")。
---
## 五、显示层:ui_menu_view(持有全部 LVGL 对象)
> **一句话职责**:按"状态 + 数据表"渲染级联框——持有**全部** LVGL 对象(样式 / 4 层容器 / 框体 / 行 / 箭头 / 根框装饰),由逻辑层经 8 个接口(§5.5)驱动建/毁/刷新。
> **绝不做**:持有导航状态(17 B 在逻辑层);解释按键(输入回调挂在逻辑层)。
### 5.1 职责边界
- 持有全部 LVGL 对象(样式/容器/框体/行/箭头/根框装饰)
- 按 `frame_build/destroy/refresh` 调用建/毁/刷新(依据"状态 + 数据表")
- 现算框几何(§5.3 公式)并按层缓存(`s_frame[]`)
- 提供滚动位读写(`set_scroll` / `get_scroll`)供逻辑层持久化
### 5.2 布局常量(设备 px,定义于 `ui_menu_view.c`)
| 常量 | 值 | 含义 |
|---|---|---|
@@ -605,80 +488,317 @@ python tools/menu_gen/gen_menu.py
字体度量(`ui_font_cn_12`,宋体 12px 1bpp):汉字步进 12 px/字、▶ 步进 10 px(adv_w=165/16)、行高 13 px(见 `UI_中文字体生成与配置.md`)。
### 8.3 几何计算(显示层 `ui_menu_view_frame_build()`)
### 5.3 几何计算(`ui_menu_view_frame_build()`)
`frame_build` 拿到 (depth, node, sel, scroll) 后,先**算三个数**——框宽 w、位置 (x, y)、框高 h——再照数建对象。四步各配一格图(下图 ①~④):
![级联框几何计算四步:① 行剖面定框宽 ② X 贴父框右缘 ③ Y 顶对齐选中行 ④ Y 溢出上移](images/级联框几何计算.svg)
**① 框宽 w = 最宽行 + 2 条边框**(图①)
一行横向构成:`边框1 | 留白2 | 文本 |(菜单行: 间距2 + ▶10)| 留白2 | 边框1`。文本宽按字体实际步进量(`lv_text_get_width`,⚠️ 见 §8.2 陷阱 1);同框各行宽度可以不同,**框宽只由最宽的一行决定**,窄行左对齐、右侧留空:
w = max_行( PAD_L + 文本_w + [GAP + ▶_w] + PAD_R ) + 2×BORDER
实测五类框宽(1× 原尺寸,px):
| 框 | 最宽行构成 | 框宽 |
|---|---|---|
| 根框(7 行,4 字 + ▶) | 2 + 48 + 2 + 10 + 2 | **66** |
| 4 字无箭头框 | 2 + 48 + 2 | 54 |
| 3 字无箭头框 | 2 + 36 + 2 | 46 |
| 2 字 + ▶ 框 | 2 + 24 + 2 + 10 + 2 | 42 |
| 空终点框(无行) | 仅留白 + 边框 | 6 |
**② 子框 X:贴在父框右缘,共享 1px 边框列**(图②)
x = 父.x + 父.w − 1
减掉的 1 = 父框右边框那列像素:子框的左边框与父框的右边框**是同一列**(边框重叠 1px),所以零间隙、不重画,逐级向右生长。
**③ 子框 Y(默认):顶边对齐父框"选中行"行顶**(图③)
y = 父.y + BORDER + 父sel × ROW_H
父框选中哪一行,子框顶边就落在那一行的**行顶**——父子垂直方向"肩并肩",选中行不动子框就不动。
**④ 子框 Y(溢出时):整框上移,底边对齐选中行行底**(图④)
y + h > 160 → y = 选中行行顶 + ROW_H − h
子框比"选中行以下到屏底"的空间高 → 整框上移,底边卡在**选中行行底**(不是贴屏底——贴屏底会让父子脱钩,选中行一移子框位置就乱跳)。上移后仍顶出屏顶(y<0)→ 钳到 0;再不够 → 框高截到可见区、行在框内滚动(§5.4 `LV_SCROLLBAR_MODE_AUTO`)。
**框高 h = 按内容,不强顶屏底**
h = 行数 × ROW_H + 2×BORDER
行数少框就矮(2 行 = 32 px)、行数多框就高;上限受屏高 160 约束(触发④的上移/截断)。
**公式汇总**(`ui_menu_view_frame_build()` 的实现即此五式):
```text
框宽 w = 各行 (PAD_L + 文本_w + [菜单: GAP + ▶_w] + PAD_R) 取最大 + 2×BORDER
文本/▶ 宽经 lv_text_get_width 取字体实际步进(⚠️ 见 8.8 陷阱 1)
实测:根框(7行4字) = 66 | 2字+箭头框 = 42 | 3字无箭头框 = 46
| 4字无箭头框 = 54 | 空终点框 = 6 (2×BORDER + 2×PAD)
子框 (d>0) X: x = 父.x + 父.w − 1 (左缘 = 父框右缘,共享边框列、零间隙)
子框 (d>0) Y: y = 父.y + BORDER + 父sel×ROW_H (顶对齐父框"选中行"行顶)
y + h > 160 → y = 选中行行顶 + ROW_H − h (上移使本框底边对齐选中行行底)
y < 0 → y = 0(顶钳制;仍溢出则框高截到可见区、行改框内滚动)
框高 h = 行数 × ROW_H + 2×BORDER (按内容,不强顶屏底)
w = max_行(PAD_L + 文本_w + [菜单: GAP + ▶_w] + PAD_R) + 2×BORDER (文本宽经 lv_text_get_width)
h = 行数 × ROW_H + 2×BORDER
子框 x = 父.x + 父.w − 1 (d>0,共享边框列)
子框 y = 父.y + BORDER + 父sel×ROW_H (d>0,顶对齐选中行行顶)
y + h > 160 → y = 选中行行顶 + ROW_H − h;y < 0 → y = 0(截断/框内滚动兜底)
```
### 8.4 对象模型与选中态
### 5.4 对象模型
- 屏幕根容器(全屏 flat)→ 4 层**持久全屏容器** `s_cont[0..3]`(每深度一个,`LV_OBJ_FLAG_IGNORE_LAYOUT` + 绝对定位,透明,永不删;级联框须同屏叠加,层间不用 flex 堆叠)
- 根框体 `s_frame_box[0]` 持久(位置 (0,44)、宽自适应);子框体进入时建 / 返回时毁(`lv_obj_create(s_cont[d])` + flat + 边框样式,`IGNORE_LAYOUT` 绝对定位)
- 框体:纵向 flex 堆行 + 自动滚动条(`LV_SCROLLBAR_MODE_AUTO`);行超框高才框内滚动
- 行 = `lv_obj` 容器(整行宽、高 15;整行反色高亮的载体)+ 文本 label(左留白 2)+ 箭头 label(紧跟文本、间距 2;叶子无箭头);文本/箭头 `IGNORE_LAYOUT` 行内绝对定位,静态字符串(`lv_label_set_text_static`)
- 选中行 = `LV_STATE_CHECKED` → 整行反色(白底黑字),**箭头随反色变黑**(参考图 02/04 实测:选中行保留 ▶);非选中行透明底白字
- 行 = `lv_obj` 容器(整行宽、高 15;整行反色高亮的载体)+ 文本 label(左留白 2)+ 箭头 label(紧跟文本、间距 2;叶子无箭头);文本/箭头 `IGNORE_LAYOUT` 行内绝对定位,静态字符串(`lv_label_set_text_static`);**反色颜色设在行容器上、文本/箭头继承**(`text_color` 可继承,label 指针不另存)
- 选中行 = `LV_STATE_CHECKED` → 整行反色(白底黑字),**箭头随反色变黑**(§4.4);非选中行透明底白字
- 根框装饰:两腿"浅 V"(`lv_line` 宽 3,点集 (7,7)-(16,17) / (158,7)-(145,17))+ 水平线(宽 2,(16,17)-(145,17))+ 状态行 label(17,20)
- 空终点框:叶子 ENTER 后建 w=6、h=2 的占位框(无行),仅 ESC 可返回
### 8.5 键路径(两端一致)
### 5.5 公开 API
| 接口 | 作用 | 调用方 |
|---|---|---|
| `int ui_menu_view_init(void)` | 样式 + 4 层容器 + 根框装饰 + 根框框体(一次性) | 逻辑层 `ui_menu_init` |
| `lv_obj_t *ui_menu_view_container(uint8_t depth)` | 取该深度持久容器(逻辑层在其上绑输入组/键回调) | 逻辑层 |
| `void ui_menu_view_frame_build(uint8_t depth, uint8_t node, uint8_t sel, uint16_t scroll)` | 建该深度框:现算几何 + 建行对象 + 应用选中/滚动(d=0 仅(重)建行) | 逻辑层(进 / 初始化) |
| `void ui_menu_view_frame_destroy(uint8_t depth)` | 毁该深度框(框体+行,容器保留;根框 d=0 不可毁) | 逻辑层(退) |
| `void ui_menu_view_frame_refresh(uint8_t depth, uint8_t sel)` | 刷选中高亮 + 跟随滚动(不重建对象) | 逻辑层(移选后) |
| `void ui_menu_view_set_scroll(uint8_t depth, uint16_t scroll)` | 应用滚动偏移(行重建后按此还原) | 逻辑层 |
| `uint16_t ui_menu_view_get_scroll(uint8_t depth)` | 读当前滚动偏移(供逻辑层回读持久化) | 逻辑层 |
| `void ui_menu_view_focus(uint8_t depth)` | 焦点移到该深度容器(焦点=最右框) | 逻辑层(进/退) |
> 公共常量:`MENU_PAGE_MAX 4`(深度上限 = 根框 + 3 级子框)、`MENU_ITEM_MAX 16`(单框最大条目数)、`MENU_SCREEN_W/H 160`(与 `ui_port.h` / 模拟器一致)。
### 5.6 RAM 字段(显示层)
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_cont[4]` | `lv_obj_t*`×4 | 16 B | 4 个全屏容器(每深度一个);透明常驻、**永不删**(框在容器内建/毁,防焦点对象悬空) |
| `s_row[4][16]` | `lv_obj_t*` | 256 B | 行容器(文本/箭头 label 是其子对象,不另存指针);**仅屏上框填** |
| `s_root_deco` / `s_root_status` | `lv_obj_t*`×2 | 8 B | 根框装饰:"浅 V"水平段(两腿为局部对象,不落句柄)+ 状态行 |
| `s_style_flat/selected/frame/scrollbar` | `lv_style_t`×4 | ~400 B | 样式(一次性初始化,常驻) |
| `s_frame_box[4]` | `lv_obj_t*`×4 | 16 B | 框体(d=0 持久;d>0 进入时建、返回时毁) |
| `s_frame[4]` | `menu_frame_t`(built/sel/item_cnt/x/y/w/h) | 48 B | 每框几何缓存:子框对齐要用**父框刚算出**的 x/w/选中行,故按层留一份;随时可由 `s_path`/`s_sel` 重算,**不属于导航状态** |
> **只给"屏上的框"建行对象**(每框 ≤16 行);返回时销毁被关闭最右框的行 → LVGL 池只占**屏上各框**(2~3 框),关闭即释放。
---
## 六、逻辑层:ui_menu(纯状态机)
> **一句话职责**:持有**唯一** 17 B 导航状态(路径/选中/深度/滚动)+ 键语义(移/进/退),单向驱动显示层建/毁/刷新级联框。
> **绝不做**:持 LVGL 对象句柄(唯一例外:输入接线 2 调 `lv_group_add_obj`/`lv_obj_add_event_cb`);渲染。
### 6.1 职责边界
- 持有 17 B 导航状态(唯一持有者,§6.2)
- 解释按键:移(↑↓)/ 进(→/ENTER)/ 退(←/ESC)(§6.4)
- 查询数据层(`children`/`valid`)判键行为(§3.4)
- 单向驱动显示层:`frame_build/destroy/refresh/focus` + 滚动位回读持久化
### 6.2 17 B 导航状态(唯一"必须持久"的状态)
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
| `s_path[4]` | `uint8_t`×4 | 4 B | **路径**:`s_path[d]`=第 d 层"展示的节点下标"(`s_path[0]=0` 主界面);即"从根到当前(最右)框"的节点链 |
| `s_sel[4]` | `uint8_t`×4 | 4 B | **各层选中下标**:返回时还原该层高亮(选中不跳) |
| `s_depth` | `uint8_t` | 1 B | **当前深度**(0=根框)= 焦点所在(最右)框 |
| `s_scroll[4]` | `uint16_t`×4 | 8 B | **每框滚动偏移**(px):该框销毁再进入、行对象重建时按此还原位置 |
| **合计** | | **17 B** | 其余(条目列表/名字/菜单叶子/框几何)全部**现算**,不落 RAM(见 §7.3) |
### 6.3 导航规则:数据如何驱动"移 / 进 / 退"
> **一句话**:导航**从不遍历树**,它只反复向 `g_menu_tab` 问一个问题——
> **"谁的 parent 等于当前节点?"** 答案(按表序)就是当前页要显示的条目。
> **进** = 把选中的子项记为新的"当前节点"再问一遍;**退** = 忘掉它、还原上一个"当前节点"。
> 表是**扁平**的(无嵌套指针),"树"只是"反复按 parent 过滤"营造出来的错觉。
**当前页 = 对表的一次过滤**(页面**按需构建**——进入时建、返回时毁,内存 = 当前导航路径,见 §7.2):
```text
远端按键 / SDL 按键 → keypad 输入设备(LV_INDEV_TYPE_KEYPAD)
→ 默认输入组 lv_group → 焦点对象 = 最右框的 s_cont[d]
→ LV_EVENT_KEY → menu_key_cb(user_data = depth)→ menu_key_handle(d, key)
UP/DOWN:s_sel[d] 环绕 → frame_refresh(高亮 + 跟随滚动)→ 滚动位回读 s_scroll[d]
ENTER/RIGHT:menu_open(选中子节点) —— 叶子同样压层(空终点框)
ESC/LEFT:menu_back()(毁最右框、焦点回父框;根框不响应)
显示"节点 N 的页面" ⇔ 取 g_menu_tab 中所有 parent == N 的行(按表序 = 显示序)
```
- 4 层容器同组,任一时刻仅最右容器获焦(进/退时 `lv_group_focus_obj` 显式移动),无焦点争抢
- 模拟器无头验证走同一路径:`lvgl_sim.exe --keys <seq> <shot.bin>` 经 `lv_group_send_data` 注键(seq 字符:u=↑ d=↓ l=← r=→ e=Enter s=Esc)
| 你所在的页 | 当前节点 N | 执行的查询 | 页面上出现的条目 |
|---|---|---|---|
| 主界面(根页) | 0(主界面) | `parent == 0` | 装置信息、实时数据▶、参数定值▶、三遥设置▶、装置维护▶、通讯参数▶、记录查询▶(**7 项**)|
| 参数定值页 | 6(参数定值) | `parent == 6` | 整定▶、查看▶(**2 项**)|
| 整定页 | 7(整定) | `parent == 7` | 参数、定值、控制字、软压板(**4 项,全叶子**)|
### 8.6 初始化时序(`ui_menu_init()`,uiTask 等 NET_READY 后调用)
> 行尾有没有 **▶** 也由同一张表判:`parent == 该节点` **非空** → 菜单(有 ▶);**为空** → 叶子(无 ▶)。
**移(↑ / ↓)**:只改当前页的选中下标 `s_sel[d]`(**首尾环绕**),**不碰表**。
**进(ENTER / →)**:选中条目 = 子节点 **M**(`kids[s_sel[d]]`)。向表问一句"**M 还有孩子吗?**"(`parent == M` 非空?)
- **有** → M 是菜单:**压一层**,`s_path[d+1]=M`,再按"过滤"显示 M 的孩子。
- **没有** → M 是叶子:进**终点**(空终点框占位,本阶段不展开内容页),不再下钻。
**退(ESC / ←)**:**弹一层**——销毁最右框、回到 depth-1 的父框;父框选中项**保持**为"刚才那个 M 在父框里的下标"(= 按下 ENTER 前的位置,不跳)。
**完整走查**(`#` = `g_menu_tab` 下标):主界面 → 参数定值 → 整定 → 参数,再一级级退回
```text
1. ui_menu_tab_valid() 表自检(parent 越界 → 打 ERROR 并放弃建菜单,返回 -1)
2. 清零导航状态(s_path=0 / s_sel=0 / s_scroll=0 / s_depth=0)
3. ui_menu_view_init():样式 → 屏幕根容器 → 4 层容器 → 根框装饰 + 状态行 → 根框框体
4. 各层容器:lv_group_add_obj(默认组) + 挂 menu_key_cb(user_data = depth)
5. ui_menu_view_frame_build(0) 建根框行 + focus(0)
6. lv_mem_monitor 打印建前/建后 LVGL 池(实测根框建完池占用 ~13 KB,见下)
【根页 node=0】 parent==0 → 7 项;选中 [2]=参数定值(#6)
ENTER ─▶ 问"6 有孩子吗"→ parent==6 非空(整定/查看) → 压层
【参数定值页 node=6】 parent==6 → [整定#7, 查看#12];选中 [0]=整定
ENTER ─▶ 问"7 有孩子吗"→ parent==7 非空(参数/定值/控制字/软压板) → 压层
【整定页 node=7】 parent==7 → [参数#8, 定值#9, 控制字#10, 软压板#11];选中 [0]=参数
ENTER ─▶ 问"8 有孩子吗"→ parent==8 为空 → 叶子 → 进终点(空框)
【参数终点 node=8】(占位,无条目,到此为止)
ESC ─▶ 弹层 → 回【整定页】,选中仍 [0]=参数
ESC ─▶ 弹层 → 回【参数定值页】,选中仍 [0]=整定
ESC ─▶ 弹层 → 回【根页】,选中仍 [2]=参数定值
```
日志标记:`UI menu init: OK`(uiTask 打印)——`@test` 回归可作 Required 标记。
LVGL 池实测(模拟器 40 KB 池):根框建完 `used=13312 B`,最深三级级联增量远小于池余量。
**导航状态 = 一条"当前节点"链**(= 从根到当前框的路径)
### 8.7 验证方法(分级)
```text
s_path[0]=0 ─▶ s_path[1]=6 ─▶ s_path[2]=7 ─▶ s_path[3]=8
根页 参数定值页 整定页 参数终点
depth=0 depth=1 depth=2 depth=3
```
- 链上**每个 node 都是上一个的孩子**(parent 关系)→ 这条链天然就是路径,**无需额外记录"我从哪来"**。
- **进 = 链尾追加**(选中的子节点接上去);**退 = 链尾弹掉**。
- 内存只占"当前这条链"(`s_path[0..s_depth]`)+ 屏上各框各一套行对象(屏外框行销毁),退回即释放(§7.2/§7.5)。
**对应到代码**(逻辑层决定、显示层执行;三层见 §一):
| 逻辑 | 函数 | 读 / 写的数据 |
|---|---|---|
| 建框(显示某节点子项) | 显示层 `ui_menu_view_frame_build(d, node, sel, scroll)`(由逻辑层调用) | 读 `s_path[d]`+`g_menu_tab` 现算条目;算 x/顶边/框宽、建 `s_row[d][*]`、恢复 `s_scroll[d]` |
| 进(压层 / 终点) | 逻辑层 `menu_open(child)` | 写 `s_depth+1`、`s_path[d+1]=M`、`s_sel[d+1]`(同节点还原 / 换节点清零);再 `frame_build` + `focus` |
| 退(弹层) | 逻辑层 `menu_back()` | `ui_menu_view_frame_destroy(s_depth)`;`s_depth-1`、焦点回父框(父框不动、无需重建,`s_sel`/`s_scroll` 原样保留) |
| 移选 | 逻辑层 `menu_key_handle(d, key)` → `menu_refresh_layer(d)` | 写 `s_sel[d]`(首尾环绕);`frame_refresh`(高亮 + 跟随滚动);滚动位回读入 `s_scroll[d]` |
### 6.4 键语义与分发
**键位语义**:
| 键 | 行为 |
|---|---|
| `↑` / `↓` | 当前(最右)框内上/下移动选中(**首尾环绕**);到边缘且该框可滚则**滚动该框** |
| `→` 或 `ENTER` | 选中**菜单节点** → 其右侧**弹出子框**;选中**叶子节点** → 进终点(空框) |
| `←` 或 `ESC` | 返回:**关最右框**(销毁其行)、焦点回父框、选中态保持(不跳) |
| 焦点 / 框头 | 焦点 = 最右(最深)框;**无逐框页头/返回按钮**(框即紧凑列表,返回靠 `←`);仅根框带主界面装饰 |
**进入/返回示例**(路径,`→`/`←` = 进/退动作方向):
```
主界面 → 参数定值 → 整定 → 参数
L0 L1 L2 L3
返回:主界面 ← 参数定值 ← 整定 ← 参数
```
**分发**(`menu_key_handle(d, key)`,`d` = 焦点所在层):
```text
cnt = ui_menu_tab_children(s_path[d], …) ← 先问数据层:本框几个条目
UP/DOWN : cnt>0 → s_sel[d] 首尾环绕 → menu_refresh_layer(高亮+跟随滚动+滚动位回读)
ENTER/RIGHT: cnt>0 且 d+1<4 → menu_open(kids[s_sel[d]]) (叶子也压层 → 空终点框)
ESC/LEFT : d>0 → menu_back() (根框 d=0 不响应;空框只响应返回)
```
### 6.5 公开 API
- `int ui_menu_init(void)` —— **唯一公开入口**(uiTask 调用);五步时序见 §1.5,表自检见 §3.5。
- 内部(`static`):`menu_open` / `menu_back` / `menu_key_handle` / `menu_key_cb` / `menu_refresh_layer`(见 §6.3/§6.4)。
---
## 七、层间配合细节
### 7.1 按键全序列(逐键分层动作)
> 通用管线见 §1.4;本表按键列出**逻辑层改什么状态、显示层做什么**。
| 键 | 逻辑层(17 B 状态) | 显示层(画面) |
|---|---|---|
| **移** ↑/↓ | `s_sel[d] = (s_sel[d] ∓ 1 + cnt) % cnt`(首尾环绕);`cnt=0`(空框)不变 | `frame_refresh(d, s_sel[d])`:改行状态+文字色+跟随滚动(不重建对象);随后逻辑层 `get_scroll(d)` 回读入 `s_scroll[d]` |
| **进** →/ENTER | `s_depth+1`;`s_path[d+1]=kids[s_sel[d]]`;`s_sel[d+1]`(同节点还原原选中/滚动,换节点清零) | `frame_build(d+1, …)`:现算几何(贴父框右缘、顶对齐/底对齐上移)→ 建行对象 → 应用选中/滚动;`focus(d+1)`;叶子 → 空终点框(w=6, h=2) |
| **退** ←/ESC | `s_depth−1`(根框 d=0 不响应) | `frame_destroy(d)`:毁框体+行(容器保留,池内存即回落);`focus(d−1)`;父框不动、无需重建,`s_sel`/`s_scroll` 原样保留 |
### 7.2 框生命周期(进建退毁)
- **进时建**(`frame_build`):现算几何(§5.3;父框几何取父层 `s_frame` 缓存)→ 建行对象(行 = 子项,现算不落地)→ 应用选中高亮 + 滚动位。
- **退时毁**(`frame_destroy`):框体连同行对象销毁、容器保留(永不删,防焦点对象悬空);LVGL 池内存随即回落。
- **根框持久**(`ui_menu_view_init` 建一次,永不毁);子框随进/退建/毁。
- **焦点移动**:4 层容器同组、任一时刻仅最右框容器获焦;进/退时 `focus()` 显式移动,无焦点争抢。
- **空终点框**:叶子 ENTER 后建 w=6、h=2 占位框(无行),仅 ESC 可返回。
### 7.3 存 vs 算(关键区分)
> **核心原则:能推导的就不存**——某页的条目列表可由 `g_menu_tab` 的 `parent==节点` 随时重算;
> 故**只持久化"不可推导"的导航历史(路径 + 各层选中 + 各框滚动)**,页面内容进出时按需重建。
| 数据 | 存 / 算 | 依据 |
|---|---|---|
| 路径(在哪些层) | **存** `s_path`(逻辑层) | 用户导航历史,不可推导 |
| 各层选中 | **存** `s_sel`(逻辑层) | 用户选择,不可推导 |
| 每框滚动位 | **存** `s_scroll`(逻辑层) | 用户 `↑`/`↓` 滚动,不可推导 |
| 某框条目列表 | **算** | `ui_menu_tab_children(s_path[d])` = 过滤 `parent==s_path[d]` |
| 条目是菜单/叶子(有无 ▶) | **算** | `ui_menu_tab_has_children(node)` = 存不存在 `parent==node` |
| 条目显示名 | **算** | `g_menu_tab[node].name` |
| 每框 x / 顶边 / 框宽 | **算** | 右贴靠布局 + 顶对齐/底对齐上移(§四),现算(按层缓存在 `s_frame[]`) |
> **结论:持久导航状态 = `s_path` + `s_sel` + `s_depth` + `s_scroll`(共 17 B,全在逻辑层)**;
> 框的条目/名字/菜单叶子/位置(框宽·x·顶边) 100% 现算,进/退时重建。
### 7.4 导航操作 × 字段读写
| 操作 | 读 | 写 |
|---|---|---|
| **建框**(进 / 初始化) | `s_path[d]`、`g_menu_tab`(现算条目)、`s_sel[d]`、`s_scroll[d]` | 算该框 x/顶边/框宽(§四);建 `s_row[d][*]`;恢复滚动位 |
| **移**(↑/↓) | `s_sel[d]`、`s_scroll[d]`、`s_row[d][*]` | `s_sel[d]`(首尾环绕);到边缘则改 `s_scroll[d]`(滚);刷新高亮 |
| **进**(ENTER/→,菜单) | `s_sel[d]`→子节点 M、`ui_menu_tab_has_children(M)`=真 | `s_depth+1`、`s_path[d+1]=M`、`s_sel[d+1]=0`;新框贴父框右缘生成(既有框不动、不重排) |
| **进**(ENTER/→,叶子) | `ui_menu_tab_has_children(M)`=假 | `s_depth+1`、`s_path[d+1]=M`(终点框) |
| **退**(ESC/←) | `s_depth` | `s_depth-1`、销毁最右框行、焦点回父框(父框不动、无需重建,`s_sel[d-1]`/`s_scroll[d-1]` 原样保留) |
### 7.5 内存预算
**Flash(const,数据层)**:`g_menu_tab` 336 B + 名字 ~400 B ≈ **~0.7 KB**
**RAM(静态/常驻)**:
| 组 | 层 | 大小 |
|---|---|---|
| 导航历史 `s_path`+`s_sel`+`s_depth` | 逻辑层 | 9 B |
| 每框滚动 `s_scroll[4]` | 逻辑层 | 8 B |
| 容器 `s_cont[4]` | 显示层 | 16 B |
| 行句柄 `s_row[d][i]` `[4][16]` | 显示层 | 256 B |
| 根框装饰指针(2 个:水平段 + 状态行) | 显示层 | 8 B |
| 框体句柄 `s_frame_box[4]` | 显示层 | 16 B |
| 框几何缓存 `s_frame[4]`(`menu_frame_t`) | 显示层 | 48 B |
| 样式 `s_style_*`(4 个,一次性) | 显示层 | ~400 B |
| **小计** | | **~1.4 KB**(逻辑层 17 B + 显示层 ~1.4 KB) |
**LVGL 池(动态,`LV_MEM` 40 KB 预算内)**:占**屏上各框**对象 = 屏上框数(2~3) × 行数 ×(行容器+文本+箭头),
典型 ≈ **4–8 KB**,最坏 3 框满 16 行 ≈ 15 KB(仍远在 40 KB 内)。
---
## 八、验证与实施陷阱
### 8.1 分级验证
| 环节 | 方法 |
|---|---|
| 模拟器无头 | `lvgl_sim.exe --screenshot shot.bin` / `--keys <seq> shot.bin`;I1 像素(102400 B)转 PNG(PIL)目检 |
| 对照参考图 | 7 状态(根页 + 6 个一级子框)逐一对照参考图 01~07;反色行/框几何用像素统计核验(2026-09-04 全通过,偏差见 §8.8 注 5) |
| 对照参考图 | 7 状态(根页 + 6 个一级子框)逐一对照参考图 01~07;反色行/框几何用像素统计核验(偏差见 §8.2 注 5) |
| 编译 | `@build`(ARMCC,0 错 0 警,强制) |
| 真机 | `@test`(Required 含 `UI port init: OK;UI menu init: OK`;屏幕内容仍需人工目检) |
### 8.8 实施陷阱与保真度说明(2026-09-04 踩坑记录)
### 8.2 陷阱与保真度说明
1. 本 LVGL v9.3 的 `lv_text_get_width(txt, length, font, letter_space)` **length=0 时返回 0**(不是"整串");必须传 `strlen`——否则框宽计算全 0(根框退化 8 px)。
2. 无头模式**不得**调 `lv_timer_handler()` / `lv_refr_now()`:I1 + `LV_DISPLAY_RENDER_MODE_FULL` 全屏刷新会进渲染死循环(截图因此走 `lv_snapshot`);`--keys` 注键是同步处理,无需驱动帧。
3. 选中行**保留 ▶**(随反色变黑):曾误读参考图实现成"选中隐箭头",重核原图 02/04 后纠正(§四"选中态"原文即正确)。
4. 字体缺字形 = 方框:菜单树更新后旧字符集缺 整/控/软/板/死/区/类/防/抖/点/钟/清/口/保/启/运/报 17 字;重跑 `extract_cn.py` 同步字符集(78 唯一字)+ 重生成字体修复(流程见 `UI_中文字体生成与配置.md`)。
3. 选中行**保留 ▶**(随反色变黑):参考图 02/04 原图即"选中行箭头仍在、仅随反色变黑",勿实现成"选中隐箭头"(§4.4)。
4. 字体缺字形 = 方框:菜单树新增/改名的汉字必须同步加入字符集 `ui_cn_chars.txt`(当前 78 唯一字)并重生成字体(重跑 `extract_cn.py` + `make_font.py`),否则新字显示为方框(流程见 `UI_中文字体生成与配置.md`)。
5. **参考图保真度**:参考图为手工 mockup,4 个样本(子框顶位置)不满足单一公式;实现采用自洽规则(顶对齐/底对齐上移),实测与参考图偏差:顶位置 2/4/6/1 px(三遥/装置维护/通讯参数/记录查询),框宽 2/8/0/2 px(4字+箭头/4字/3字/2字+箭头)。两端极例(三遥、记录查询)精确吻合;统一规则优于逐屏特调,偏差范围内不影响识读与操作。
6. 装饰"浅 V"两腿线宽必须 3(水平线 2):1bpp 下细斜线边缘被阈值吃掉,腿不粗于水平线即消失;且依赖 `LV_DRAW_SW_COMPLEX=1`(见 `UI_设计架构.md` §1.4 约束 7)。
---
## 变更记录
## 九、变更记录
| 日期 | 变更 |
|---|---|
@@ -697,3 +817,13 @@ LVGL 池实测(模拟器 40 KB 池):根框建完 `used=13312 B`,最深
| 2026-09-04 | **子页面显示模型定稿(横向级联子框)**:§四 新增 ⑧"显示模型"——横向树,每级一框在上一级**右侧**、顶边对齐上级"选中行"行顶;水平**右锚定**(最深框钉右缘、父框超宽滚出左、行按屏上/外建毁)+ 框宽文字自适应 + **无动画**;垂直每框独立(顶边=上级选中行行顶、框高按内容、**仅底边>160 才上移**贴屏底、比整屏高则 `↑`/`↓` 滚);焦点=最右框(`↑`/`↓` 移选/滚、`→`/`ENTER` 进、`←`/`ESC` 关最右框)。§七 同步:行对象按层 `s_row[d][i]` 仅屏上框建、去掉逐框页头(`s_header`/`s_back_btn`/`s_title`)、新增每框滚动 `s_scroll[]`(8 B)、"存 vs 算"增 滚动=存/位置=算、持久态 9→17 B、内存预算(静态 ~0.75→~1.3 KB、LVGL 池=屏上 2~3 框)、对照表更新 |
| 2026-09-04 | **§四⑧ 按参考图 02~07 像素实测校准**(设备 px,误差 ≤3px):层级树/命名/箭头/选中态全部与图一致 ✓;显示模型两处修正——水平由"右锚定(钉屏幕右缘)"改为"**子框左缘=父框右缘**(共享边框列、零间隙)+ 宽自适应"(实测各子框均贴父框右缘 x≈67/106,最深框止于 x≈113~127 而非屏右缘);垂直溢出由"上移使底边贴屏底"改为"**上移使子框底边对齐父选中行行底**"(实测三遥 top=26/装置维护 top=54/通讯参数 top=82/记录查询 top=12 均吻合,且可叠到屏顶状态行区、顶部不钳制)。本菜单最深路径 ≈155<160 不会溢出;>160 行为与"根页框宽 99 vs 68"列为待确认。§7.2/§7.3/§7.4 "右锚定"表述同步 |
| 2026-09-04 | **实现落地,本文档与代码同步**:三层拆分实现(`ui_menu_tab` 生成物 + 新增 `ui_menu_view` 显示层、`ui_menu` 瘦身为纯状态机,§8.1);§四⑦/§6.4/§6.5/§6.6/§6.7 函数与 API 名同步为实际 `ui_menu_tab_*`/`ui_menu_view_*`/`menu_open|menu_back|menu_key_handle`;§五"待实现"→"已实现";§七 修正为实际字段(容器从不切 HIDDEN、`s_style_frame`、新增框几何缓存 `s_frame[]`,§7.2 G);§四⑧ 实测值区分"参考图/实现"两套(实现:根框 66、子框 42/46/54/6,顶 28/58/88/13,偏差入 §8.8 注 5),>160 溢出"待确认"落定为实现行为"屏缘裁切";新增 **§八 实现架构与关键实施细节**(布局常量/几何公式/对象模型/键路径/初始化时序/分级验证/6 条陷阱与保真度说明);验证:模拟器 7 状态对照参考图 01~07 + `@build` 0 错 0 警 |
| 2026-09-07 | **全文结构重排(理清三层架构与协作)**:旧版三层描述分散在 §六(数据结构)/§七(运行时数据)/§八(实现架构) 三处、§四 又混装导航逻辑与显示模型,读者难以看清各层职责与层间配合;重排为十章——**一 架构总览**(1.1 为什么拆三层 / 1.2 职责速览+依赖图 / 1.3 调用矩阵 / 1.4 一次按键的旅程 / 1.5 初始化时序 / 1.6 层间不变量)→ **二 菜单内容** → **三 数据层**(含 3.7 改菜单流程)→ **四 显示规范**(自 §四⑧ 抽出,含 4.5 参考图偏差)→ **五 显示层**(5.1 职责边界 / 5.5 公开 API 8 接口表)→ **六 逻辑层**(6.2 17 B 状态 / 6.3 导航规则含完整走查 / 6.4 键语义与分发)→ **七 层间配合细节**(7.1 逐键分层动作 / 7.2 框生命周期 / 7.3 存vs算 / 7.4 读写矩阵 / 7.5 内存预算带层归属)→ **八 验证与陷阱** → **九 迁移历史**(结构/运行时/内容三表收拢)→ **十 变更记录**;**修正归属错误**:`s_scroll[]` 实为**逻辑层** `ui_menu.c` 字段(17 B 导航状态之一,见 ui_menu.c),旧 §7.2 误划给显示层,新文 §6.2/§7.5 已归正;全部交叉引用同步到新章号;技术内容零删减 |
| 2026-09-07 | §1.2/§1.4/§1.5 三幅架构图由 ASCII 转 Mermaid(1 张 flowchart + 2 张 sequenceDiagram,初始化时序含表自检 alt/else 分支);键源措辞统一为“远端键”(与 `UI_设计架构.md`“远端客户端”一致:MCU 端按键经 TCP 7003 远端客户端 → ui_net_sink → keypad);职责表/调用矩阵/“两个要点”文字保留 |
| 2026-09-07 | 正文现状化:删除正文过渡性历史叙述(“设计阶段拟名…/实现采用…/见变更记录”等,旧文见 git 历史),各节改写为现状描述;移除 §九“迁移历史”整章(旧→新对照保留于本变更记录),变更记录重编号为 §九;§8 去日期注记,陷阱 3/4 改写为现状约束 |
| 2026-09-07 | §4.1 总览图由 ASCII 框线图升级为 SVG 精确几何图(新增 `images/级联框总览.svg`,按 §5.2 常量 / §5.3 公式逐 px 绘制,含根框装饰 / 状态行 / 选中行反色 / 共享边框列标注 + ①~⑦ 位置标注);ASCII 版删除(旧图见 git 历史) |
| 2026-09-07 | 删除 §4.5“参考图与实现偏差”表(偏差数据已在 §8.2 注 5“参考图保真度”完整记载);正文两处引用(§四 引言、§8.1 验证表)改指 §8.2 注 5 |
| 2026-09-07 | §5.1 职责边界表去掉“不做”列、改为列表(负向约束已由章首“绝不做”行与 §1.6 层间不变量表承载,无信息损失) |
| 2026-09-07 | §6.1 职责边界表同样去掉“不做”列、改为列表(负向约束由章首“绝不做”行、§1.3 调用矩阵与 §1.6 层间不变量表承载,无信息损失) |
| 2026-09-08 | §5.3 几何计算重写为图文并茂:新增 `images/级联框几何计算.svg` 四格图(① 行剖面定框宽 ② X 共享边框列 ③ Y 顶对齐 ④ Y 溢出上移,含虚线幽灵框 + 超屏红区示意),正文改为四步叙述 + 实测框宽表 + 公式汇总(五式内容不变) |
| 2026-09-08 | `menu_frame_t` 瘦身:`built` bool→`uint8_t`(与系统存储类型一致);`x/y/w/h` `int32_t`→`int16_t`(值域 0..160 / ≤242,16 位够;y 需有符号——钳制前可为负);`s_frame[4]` 96 B→48 B,省 48 B RAM(§5.4 对象模型表 / §7.5 内存预算表同步) |
| 2026-09-08 | 行 label 指针合并:删 `s_row_text` / `s_row_arrow` 两数组(省 512 B);文本/箭头颜色改在行容器 `s_row` 上设置、子 label 继承(LVGL `text_color` 为可继承属性,`lv_style.c` 属性标志表证实);§5.4 对象模型表 / §7.5 内存预算表同步(768 B→256 B) |
@@ -0,0 +1,360 @@
# STM32F4-Base RAM 使用分析与优化方案
> 版本:v1.0 日期:2026-09-07
> 适用工程:STM32F4-Base(STM32F407ZGTx @168MHz,FreeRTOS V10.3.1 + CMSIS-RTOS2)
> 数据来源:`MDK-ARM/STM32F407-Demo/STM32F407-Demo.map`(2026-09-07 构建)+ 源码静态分析 + 网络调研
## 1. 背景与目标
`待处理内容.md` 已提出:"所有任务的内存都是往大的给的,导致内存不够用,要仔细排查内存的预算情况"。
当前状态(详见第 2 节):
- 主 SRAM 128KB 中静态区已占 **122.7KB**(余量仅 5.3KB)
- FreeRTOS 堆 48KB 里还要装下 **~33.5KB 的任务栈**,真正可分配余量只有 ~12KB
- F407 另有 **64KB CCM RAM 完全未用**
- 没有任何运行时测量手段:不知道每个任务实际用了多少栈,改栈全靠拍脑袋
本方案三个目标:
1. **可测量**:建立"每任务 RAM 消耗 + 每模块静态占用 + 堆余量"的常态化观测手段
2. **降消耗**:通过 LVGL 内存池、任务栈、CCM RAM、驱动缓冲等手段释放 RAM,把余量从 ~17KB 提升到 ~90KB+
3. **可持续**:建立 RAM 预算台账与门禁,防止再次"往大了给"
## 2. 现状测量
### 2.1 总预算
| 区域 | 大小 | 当前使用 | 来源 |
|---|---|---|---|
| 主 SRAM (0x20000000) | 128KB | 静态 122.7KB | scatter `STM32F407-Demo.sct:14`(RW_IRAM1 Max 0x20000) |
| CCM SRAM (0x10000000) | 64KB | **未使用** | STM32F4 RM0090 §3.1.1 |
| FreeRTOS 堆 (heap_4) | 48KB | 装栈 + 队列 + 余量 | `Inc/FreeRTOSConfig.h:71` `configTOTAL_HEAP_SIZE` |
| C 库堆/栈 (startup) | 512B / 1KB | 启动期 main() 用 | map `startup_stm32f407xx.o` HEAP/STACK 节 |
### 2.2 静态大项排行(map "Image component sizes",ZI/RW 合计)
| # | 对象 (map 行号) | 大小 | 内容 | 位置 |
|---|---|---|---|---|
| 1 | heap_4.o (map:16166) | **48.0KB** | FreeRTOS 堆(内含任务栈) | `FreeRTOSConfig.h:71` |
| 2 | lv_mem_core_builtin.o (map:16168) | **40.0KB** | LVGL 内存池 | `Lib/lv_conf.h:34` `LV_MEM_SIZE (40*1024)` |
| 3 | ui_net_sink.o (map:16171) | 6.4KB | `g_tx[3209]` + `g_lcdmem_data[3204]`(PC 远程显示) | `ui_net_sink.c:31,33` |
| 4 | ui_port.o (map:16169) | 6.4KB | `g_ui_fbuf[3200]` + `g_ui_draw_buf[3200]`(160×160 1bpp 全帧×2) | `ui_port.c:16,18` |
| 5 | nand_ftl.o (map:16160) | 6.1KB | 3×2048B 页缓冲(page/cache/copy)+ dhara | `nand_ftl.c:42-50` |
| 6 | ch395f.o (map:16155) | 4.1KB | SPI DMA 发/收缓冲 2×(1024×2+4) | `ch395f.h:119,137`、`ch395f.c:42,44` |
| 7 | gd5f2gq5ue.o (map:16156) | 2.6KB | BBT 页缓冲 2048 + BBT/best 位图 2×256 | `gd5f2gq5ue.c:28-39` |
| 8 | cmsis_os2.o (map:16165) | 1.7KB | CMSIS-RTOS2 封装静态区 | — |
| 9 | tasks.o (map:16163) | 1.2KB | 内核任务管理静态区 | — |
| 10 | 其余(HAL/queue/usart/lftpd/rs485/…) | ~4KB | 小项若干 | — |
| 11 | startup HEAP+STACK (map:16173-16174) | 1.5KB | C 库 512B 堆 + 1KB 主栈 | scatter |
**静态合计 125600B(122.7KB)/ 128KB**(map:16118 `Size: 0x0001ec78`,Max `0x00020000`)。
### 2.3 任务栈(从 48KB 堆动态分配)
`Src/freertos.c` 中 `osThreadAttr_t.stack_size` 单位为**字节**(CubeMX 生成代码,`512 * 4` 表示 2048 字节):
| 任务 | 配置栈 (freertos.c 行号) | 字节 | 实际职责 | 问题 |
|---|---|---|---|---|
| uiTask | `4096*4` (:99) | 16KB | LVGL 1bpp 离屏渲染(渲染缓冲在静态区,栈只走调用链) | 明显过大,无实测依据 |
| netTask | `1024*4` (:78) | 4KB | CH395F 轮询(DMA 搬运,栈压力小) | 偏大 |
| ftpTask | `1024*4` (:84) | 4KB | lftpd(有大栈局部变量,见 2.4) | 4KB 接近其峰值,需实测 |
| testTask | `1024*4` (:90) | 4KB | 产品构建下空转 | 产品构建可减到 1KB |
| defaultTask | `512*4` (:71) | 2KB | FatFS 挂载 + LED 闪烁 | 偏大 |
| timeSyncTask | `512*4` (:63) | 2KB | TCP 时间同步 | 偏大 |
| idle | `configMINIMAL_STACK_SIZE=128` | 0.5KB | 空闲 | 正常 |
| timer | `configTIMER_TASK_STACK_DEPTH=256` | 1KB | 软定时器 | 正常 |
**6 个应用任务栈合计 32KB,加 idle+timer 共 ~33.5KB,全部从 48KB 堆里扣** → 堆的真实余量 ≈ 48 − 33.5 − TCB(含 FPU 上下文 ~2.3KB) − 队列/事件组(~0.5KB) ≈ **11~12KB**。
### 2.4 动态分配点与隐患
1. **lftpd 用 C 库 `malloc`(不是 FreeRTOS 堆)**:`Drivers/BSP/NET/lftpd/lftpd_io.c:44,49,54,189` 调用 `malloc()`。工程没有把 C 库 malloc 重定向到 `pvPortMalloc`,C 库堆只有 startup 的 **512B**(map:16173)→ FTP 路径名稍长(>250 字符左右)就必然分配失败。**必须修**(见 4.4)。
2. **lftpd 大栈局部变量**:`lftpd.c:67-68`(message[256]+response[512])、`:109,134`(name[256]×2)、`:156,185`(**buffer[1024]×2**)、`:551`(read_buffer[512])、`:583`(arg_buf[512])。单次调用链栈峰值可达 2~3KB,是 ftpTask 需要 4KB 栈的主因。
3. **DMA 缓冲位置**:CH395F(SPI2 DMA)、GD5F(SPI1 DMA)、UART DMA 缓冲必须由 DMA 访问 → **只能放主 SRAM,不能放 CCM**(CCM 仅 CPU 可达,见 4.3)。
### 2.5 结论:紧张的三个根因
| 根因 | 占用 | 可回收性 |
|---|---|---|
| ① LVGL 内存池 40KB 对 1bpp 小屏菜单过大 | 40KB | 高(目标 8~16KB) |
| ② 任务栈"往大了给",32KB 无实测依据 | 32KB | 高(目标 ~15KB) |
| ③ 64KB CCM 未利用,UI 类大缓冲挤在主 SRAM | — | 高(可整体迁 CCM ~29KB) |
另有小项:FF_FS_TINY=0(每打开文件 512B 扇区窗)、ui_net_sink 调试缓冲常驻、lftpd 栈局部变量。
## 3. 网络调研摘要
| 主题 | 结论 | 来源 |
|---|---|---|
| 任务栈测量(高水位) | FreeRTOS 提供 stack "high water mark"(历史最小剩余栈),"you have data you can use to adjust the stack accordingly to avoid RAM being wasted" —— 官方创始人 Richard Barry 明确推荐的数据驱动定栈方法 | FreeRTOS 官方论坛 [Stack size calculation](https://forums.freertos.org/t/stack-size-calculation/4234)(rtel 2011-07-20 回复) |
| 栈溢出检测 | `configCHECK_FOR_STACK_OVERFLOW=2`:任务栈顶部预填特征值(0xEFFE...),每次上下文切换 + tick 时校验,溢出立即报告;方法 1 只查栈指针越界 | 论坛 [Stack Overflow Detection - Method 2](https://forums.freertos.org/t/stack-overflow-detection-method-2/17472);本工程内核源码 `Middlewares/.../include/stack_macros.h:36-122` 语义一致 |
| 堆测量 | `xPortGetFreeHeapSize()` / `xPortGetTotalHeapSize()` 随时可查;heap_4 支持 free 但有碎片化,需对比"理论余量 vs 实测 free"观察碎片 | FreeRTOS 内核源码 `heap_4.c`(工程内);[FreeRTOS 文档](https://docs.freertos.org/) |
| 运行时统计 | `vTaskGetRunTimeStats` 需 `configGENERATE_RUN_TIME_STATS=1` + `portCONFIGURE_TIMERS_FOR_RUN_TIME_STATS`(占用一个硬件定时器),给出每任务 CPU 占比 | 本工程内核 `task.h:1694-1756` 注释 |
| LVGL 缓冲 | 官方文档:PARTIAL 渲染模式"buffers can be smaller than the display to save RAM,**至少 1/10 屏**";单色 I1 格式缓冲 = `(W×H/8)+8`;双缓冲可并行渲染/搬运 | LVGL v9.2 文档 [Display interface](https://github.com/lvgl/lvgl/blob/release/v9.2/docs/porting/display.rst)(lvgl.io/docs/porting/display 的源文件) |
| STM32F4 CCM | F407 含 64KB CCM @0x10000000;**只能由 CPU 访问,DMA 不可达**,无 cache/ECC;适合纯 CPU 读写的大缓冲 | ST RM0090(STM32F405/407/415/417/42x 参考手册)§3.1.1 |
| FatFs | `FF_FS_TINY=1`:FIL 结构体去掉 `buf[FF_MAX_SS]` 私有扇区窗,改用卷内共享 9 字节窗口(FAT32),每打开文件省 512B | 本工程 `Lib/FatFs/ff.h` FIL 结构注释、`Lib/FatFs/ffconf.h:32` |
| 静态占用测量 | Keil map 文件 "Image component sizes" 给出每个 .o 的 RO/RW/ZI 精确字节数;`fromelf --image_info xxx.axf` 可输出各 Region 用量 | 本工程 `STM32F407-Demo.map:16179-16419` |
> 注:freertos.org 官网页面为 JS 动态渲染,直接抓取拿不到正文;上表结论通过官方论坛 JSON API、GitHub 文档源文件与**工程内内核源码**(V10.3.1 头文件自带完整 API 注释)三方交叉核实。
## 4. 方案
### 阶段 0:建立测量能力(先做,1~2 天)
> 原则:没有数据不动刀。本阶段全部是"只增不减"的改动,零功能风险。
#### 0.1 栈溢出检测(防身)
`Inc/FreeRTOSConfig.h` 增加:
```c
#define configCHECK_FOR_STACK_OVERFLOW 2 /* 特征值法,每次上下文切换+tick 校验 */
#define configUSE_MALLOC_FAILED_HOOK 1 /* 堆分配失败立即可见 */
```
`Src/main.c` USER CODE 区实现钩子:
```c
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
DBG_ERROR("STACK OVERFLOW: task=%s", pcTaskName);
/* 挂起或复位,按调试需要 */
for (;;);
}
void vApplicationMallocFailedHook(void)
{
DBG_ERROR("HEAP ALLOC FAILED (free=%u)", (unsigned)xPortGetFreeHeapSize());
for (;;);
}
```
成本:每次上下文切换几十周期,不占 RAM。
#### 0.2 新增 `app_mem_stats` 模块(每任务消耗 + 堆余量报告)
新文件 `App/util/app_mem_stats.h/.c`(**需手动加入 Keil 工程**,见 AGENTS.md;命名/头文件保护宏遵守代码规范 V1.0):
```c
/* 核心逻辑(骨架):枚举全部任务 → 逐个取栈配置/高水位 → 报告堆 */
void app_mem_stats_report(const char *tag)
{
osThreadId_t a_ids[16];
uint32_t n = osThreadEnumerate(NULL, a_ids, 16); /* configUSE_OS2_THREAD_ENUMERATE=1 已开 */
DBG_INFO("[MEM][%s] ---- task ram ----", tag);
for (uint32_t i = 0; i < n; i++) {
TaskHandle_t th = xTaskGetHandle(a_ids[i]);
if (th == NULL) continue;
uint32_t total = ulTaskGetStackDepth(th); /* 分配栈,单位 word */
uint32_t hwm = uxTaskGetStackHighWaterMark(th); /* 历史最小剩余,word */
DBG_INFO("[MEM] %-14s stack=%5uB used=%5uB free_min=%5uB",
pcTaskGetName(th), total * 4U,
(total - hwm) * 4U, hwm * 4U);
}
DBG_INFO("[MEM] heap free=%uB total=%uB",
(unsigned)xPortGetFreeHeapSize(), (unsigned)xPortGetTotalHeapSize());
}
```
要点:
- `uxTaskGetStackHighWaterMark` 返回**任务创建以来**的最小剩余栈(单位 word=4B),`used = (栈配置 − 高水位)×4` 即该任务历史峰值栈占用。API 已在 `FreeRTOSConfig.h:116` 启用。
- 报告时机:① 启动完成后一次;② 每 5 分钟周期性一次(挂在 defaultTask 的 LED 循环里);③ 关键动作后(FTP 连接/大文件传输、f_mkfs、菜单遍历到最深层级)。
- **测量必须覆盖全功能路径**(高水位是历史最小值,只反映跑过的路径):全菜单遍历、FTP 上传/下载大文件、CH395F 断链重连、NAND 格式化、时间同步。建议跑一轮 `@test` 全回归 + 1 小时浸泡。
#### 0.3 map 静态占用报告脚本
新增 `scripts/ram_report.ps1`:
- 解析 `MDK-ARM/STM32F407-Demo/STM32F407-Demo.map`:
- RW_IRAM1 Region 的 `Size` / `Max` → 主 SRAM 静态水位
- "Image component sizes" 表 → 每 .o 的 ZI/RW 排序 Top20
- 与基线 `scripts/ram_baseline.tsv` 做 diff,任何对象 ZI 增量 >1KB 即高亮
- 用法:每次 `@build` 后运行 `powershell scripts/ram_report.ps1`,把 diff 贴进改动说明
#### 0.4 (可选)运行时间统计
`configGENERATE_RUN_TIME_STATS=1` + `portCONFIGURE_TIMER_FOR_RUN_TIME_STATS` 用 TIM2(HAL 时基已占 TIM7,TIM2 当前空闲,需核对 `.ioc`)。`vTaskGetRunTimeStats()` 给出每任务 CPU 占比——用于发现"CPU 大户",与内存问题正交,优先级低于 0.1/0.2。
#### 0.5 (可选)main() 启动期栈峰值
启动期跑在 startup 的 1KB C 栈上(CubeMX 初始化 + 各驱动 init,含 BBT 全扫描)。ARMCC 可用 `__current_stack_usage()` 在 `app_main_init` 末尾打点,确认 1KB 够用;不够则调 scatter 的 `STACK` 节(与任务栈无关,单独预算)。
#### 阶段 0 验收
- 串口可见 `[MEM]` 报告:每任务 `stack/used/free_min` + 堆 free/total
- 人为制造栈溢出(临时把某任务栈改到 64 字节)能触发 `STACK OVERFLOW` 钩子
- `ram_report.ps1` 能输出 Top20 与基线 diff
### 阶段 1:静态大项削减(收益最大,3~5 天)
#### 1.1 LVGL 内存池 40KB → 16KB → 8KB(省 24~32KB)
- 依据:屏幕仅 160×160×1bpp,菜单为轻量控件树;LVGL 内存池只装对象/样式/字符串缓冲,与像素缓冲无关。40KB 来自模板默认值,无适配依据。
- 做法:`Lib/lv_conf.h:34` `LV_MEM_SIZE` 先改 `(16*1024)`,跑 UI 全量回归(PC 模拟器 + 实机),无 `LV_ASSERT_MALLOC`/分配失败再试 8KB。
- 同步在 `simulator/lv_conf.h:26` 保持一致(两端共用 UI 源码,配置必须同步)。
- 验证:UI 全菜单遍历 + 动画/滚动/输入框 + 中文字体渲染无缺字;LVGL 内置内存监控(`LV_USE_MEM_MONITOR`,调试构建开启)观察池占用峰值。
- 风险:个别复杂控件/长字符串可能吃紧 → 若 8KB 不够就停在 16KB,不勉强。
#### 1.2 启用 CCM RAM(+64KB 预算空间,迁出 ~29KB)
依据 RM0090 §3.1.1:F407 的 64KB CCM @0x10000000 仅 CPU 可访问(DMA 不可达、无 cache)。
**迁入 CCM 的缓冲(全部纯 CPU 读写):**
| 缓冲 | 大小 | 方式 |
|---|---|---|
| LVGL 内存池 `ucMemPool`(`LV_MEM_ATTR` 挂载 section) | 8~16KB | `Lib/lv_conf.h` 定义 `LV_MEM_ATTR __attribute__((section("CCM_DATA")))` |
| `g_ui_fbuf` + `g_ui_draw_buf`(ui_port.c:16,18) | 6.4KB | `__attribute__((section("CCM_DATA")))` |
| `g_tx` + `g_lcdmem_data`(ui_net_sink.c:31,33) | 6.4KB | 同上 |
| `s_bbt`/`s_bbt_best`(gd5f2gq5ue.c:28,39) | 0.5KB | 同上(小,顺手) |
**严禁迁入(DMA 缓冲,必须留主 SRAM):** CH395F SPI DMA 发/收缓冲(ch395f.c:42,44)、GD5F 页缓冲(nand_ftl.c:42-50,SPI DMA 传输)、所有 UART/SPI DMA 缓冲。
**scatter 文件**(`MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct`)增加:
```
RW_IRAM2 0x10000000 0x00010000 { ; CCM 64KB, CPU-only
*(CCM_DATA)
}
```
**风险门禁**:scatter 注释已提示"clone MCU 可能缺 SRAM3",同理 CCM 在克隆芯片上不可靠 → 加编译开关 `APP_CCM_ENABLE`(`App/app_cfg.h`),关闭时 `LV_MEM_ATTR` 等定义退化为空,缓冲全部落回主 SRAM(阶段 1.1 池缩到 16KB 后主 SRAM 也放得下)。真 ST 芯片默认开。
验证:map 中出现 `RW_IRAM2` 且 `*(CCM_DATA)` 各对象地址落在 0x10000000~0x100FFFF;`ram_report.ps1` 同时输出两个 Region 水位。
#### 1.3 任务栈按实测数据缩减(省 ~15KB,需阶段 0 数据)
定栈规则(写入台账,长期执行):
```
新栈 = max( 实测峰值 used × 1.5 , 1024B ) + ISR 余量
ISR 余量:该任务运行期间可能被中断打断的最深中断链(SPI/UART/CH395F EXTI),
统一按 +1KB 计;中断执行在当前任务栈上,必须计入。
```
初始目标(待阶段 0 实测修正,先按此改一轮再复测):
| 任务 | 现值 | 目标 | 依据(实测后修正) |
|---|---|---|---|
| uiTask | 16KB | 6KB | LVGL 渲染走静态缓冲,栈只走调用链;1bpp 绘制函数浅 |
| netTask | 4KB | 2KB | SPI DMA 搬运,栈压力小 |
| ftpTask | 4KB | 3KB | lftpd 有大局部变量,1.4 改静态后预期可降到 2KB |
| testTask | 4KB | 1KB | 产品构建空转;测试构建单独放大(测试模式允许临时加大) |
| defaultTask | 2KB | 1KB | 仅 f_mount + GPIO |
| timeSyncTask | 2KB | 1KB | 小报文解析 |
每改一批 → `@build` + 全功能回归 + 复测高水位(`free_min` 应 ≥ 新栈×30%),不满足则回调。
#### 1.4 lftpd 大栈局部变量 → 静态缓冲(ftpTask 栈峰值降 1~2KB)
`lftpd.c` 中 `buffer[1024]`×2(:156,185)、`response[512]`(:68)等改为文件级 `static`(lftpd 单客户端、由 ftpTask 串行处理,静态化安全)。
> lftpd 属第三方代码(pre-commit 排除范围),只做最小侵入改动,改动点在本台账登记。
#### 1.5 其他小项
- `Lib/FatFs/ffconf.h:32` `FF_FS_TINY` 0→1(每打开文件省 512B 扇区窗;FAT32 适用;`FF_FS_REENTRANT=0` 现状下安全)
- `ui_net_sink`(PC 远程显示,6.4KB):加 `APP_UI_NET_SINK` 编译开关,产品固件可关;保留时缓冲已迁 CCM(1.2)
- `CH395F_TCP_MSS=1024` 已是压缩值,DMA 缓冲 `MSS×2+4` 为协议决定,不动
### 阶段 2:堆与动态分配治理(1~2 天)
#### 2.1 修复 lftpd 的 malloc 隐患(必修)
`lftpd_io.c:44,49,54` 的 `malloc` / 对应 `free` 与 `:189` 的 `malloc(sizeof(DIR))` 改为 `pvPortMalloc` / `vPortFree`(加 `#include "FreeRTOS.h"`)。消除"512B C 堆"地雷,FTP 长路径不再随机失败。
#### 2.2 缩减 configTOTAL_HEAP_SIZE
阶段 1 完成后复测:堆内需容纳 ≈ 栈(15KB) + TCB(2.3KB) + 队列/事件组(0.5KB) + lftpd 临时分配(1KB) + 余量。`configTOTAL_HEAP_SIZE` 49152 → **32768**,要求全功能负载下 `heap free ≥ 8KB` 持续成立(浸泡 1 小时验证)。
#### 2.3 (可选,进阶)任务/队列全静态化
`configSUPPORT_STATIC_ALLOCATION=1` 已开。用 `osThreadNew` 的 `attr.cb_mem`(`StaticTask_t` + 静态栈数组)可把栈完全移出堆,预算 100% 透明、堆只剩小对象分配。改动面涉及 CubeMX 生成的 `freertos.c` 属性块,收益(~15KB 确定性)与改动成本权衡后决定,**不作为必做项**。
### 阶段 3:长效机制(0.5 天 + 长期)
1. **RAM 预算台账**(本节模板落地为 `docs/内存管理/RAM预算台账.md`):记录每个 Region/缓冲/栈的计划值、实测值、责任人、变更历史;每次改内存相关代码必须更新
2. **AGENTS.md 增补**:新增任务/缓冲/栈前必须先查台账余量(把现有"内存预算紧张"条目升级为可执行 checklist:先跑 `ram_report.ps1`,算总账,再动手)
3. **pre-commit 门禁**(可选):钩子解析最新 map,RW_IRAM1 使用率 >92% 或 RW_IRAM2 超限 → 提交拦截并提示
4. **@test 扩展**:`verify_log.ps1` 增加可选 Required 标记 `"[MEM] heap free"`,把堆余量下限纳入自动化回归
5. **map diff 常态化**:每次 `@build` 后跑 `ram_report.ps1`,ZI 增量 >1KB 的改动必须在提交说明中解释
## 5. 预期效果
| 项目 | 现状 | 优化后(1.1+1.2+1.3+2.2 全部落地) |
|---|---|---|
| 主 SRAM 静态占用 | 122.7KB / 128KB | **~71KB / 128KB**(LVGL 池、UI 缓冲迁 CCM,堆 48K→32K) |
| 主 SRAM 余量 | 5.3KB | **~57KB** |
| CCM 使用 | 0 / 64KB | ~29KB / 64KB(余 ~35KB) |
| 任务栈合计 | 32KB | ~15KB |
| FreeRTOS 堆 | 48KB(余 ~12KB) | 32KB(余 ≥8KB,全负载验证) |
| **总余量** | **~17KB** | **~90KB+** |
## 6. 实施顺序与验证
| 步骤 | 内容 | 验证 |
|---|---|---|
| S1 | 0.1 + 0.2(溢出钩子 + mem_stats 模块,加进 Keil 工程) | `@build` 0错0警;`@test -Required "[MEM]"` 串口出现任务报告 |
| S2 | 0.3 `ram_report.ps1` + 基线 | 脚本输出与 map 人工核对一致 |
| S3 | 全功能回归跑数(菜单/FTP/网络/格式化/浸泡 1h) | 拿到各任务 `free_min` 与堆 free 底值 |
| S4 | 1.4 lftpd 静态化 + 2.1 malloc 修复 | FTP STOR/RETR 大文件 + 长路径回归 |
| S5 | 1.1 LVGL 池 40K→16K | UI 全量回归(模拟器 + 实机),无断言失败 |
| S6 | 1.2 CCM(scatter + section + 门禁宏) | map 显示 RW_IRAM2;关 `APP_CCM_ENABLE` 也能编译通过 |
| S7 | 1.3 栈缩减(按 S3 数据)+ 复测 | 每任务 `free_min ≥ 新栈×30%`,无溢出钩子触发 |
| S8 | 2.2 堆 48K→32K + 复测 | 全负载 `heap free ≥ 8KB` |
| S9 | 1.5 小项 + S10 台账/门禁/AGENTS.md | `@build` + `@test` 全绿 |
每步都必须 `@build` 通过(0 错误 0 警告)后再进入下一步;涉及 UI 的步骤用 `simulator` 先过一遍再上实机。
## 7. 风险与注意事项
| 风险 | 说明 | 缓解 |
|---|---|---|
| 栈定小 → 溢出 | 高水位只覆盖跑过的路径;中断嵌套深度难预估 | 1.5× 系数 + 1KB ISR 余量 + 溢出钩子 + 全功能回归后才定稿 |
| CCM 依赖真芯片 | 克隆 MCU 可能无 CCM;DMA 缓冲误入 CCM → 传输全错 | `APP_CCM_ENABLE` 门禁;DMA 缓冲清单硬性排除;map 地址落点检查 |
| LVGL 池缩小 → 分配失败 | 复杂界面/长字符串吃池 | 分步降(40→16→8)+ 每步全 UI 回归 + `LV_USE_MEM_MONITOR` 观测 |
| 高水位低估 | 启动后未触发的路径(如格式化失败分支)没算进去 | 测试套件覆盖异常分支;保留 ≥30% free_min 底线 |
| 第三方代码改动 | lftpd 被 pre-commit 排除,改动不受检查 | 最小侵入、台账登记、升级 lftpd 时需重放补丁 |
| CubeMX 重生成 | `freertos.c`/`FreeRTOSConfig.h` 的 USER CODE 区受保护,但 CubeMX 改配置会覆盖部分生成区 | 所有内核宏改动集中在 USER CODE 区或 `app_cfg.h`;`.ioc` 与代码双向核对 |
| 编码安全 | 新增 .c/.h 为 UTF-8 无 BOM;`Drivers/BSP/UI/*` 为 UTF-8 带 BOM | 遵守 AGENTS.md"源码编辑与编码安全"铁律:只用 Edit 工具改,不整文件重写 |
## 8. 附录
### 8.1 参考链接
- FreeRTOS 论坛(官方):[Stack size calculation](https://forums.freertos.org/t/stack-size-calculation/4234) · [Stack Overflow Detection - Method 2](https://forums.freertos.org/t/stack-overflow-detection-method-2/17472) · [checking stack usage](https://forums.freertos.org/t/checking-stack-usage/7703)
- FreeRTOS 文档站:https://docs.freertos.org/ (官网旧页为 JS 渲染,正文以工程内 V10.3.1 内核头文件注释为准)
- LVGL v9.2 文档源:[docs/porting/display.rst](https://github.com/lvgl/lvgl/blob/release/v9.2/docs/porting/display.rst)(Draw buffers / Monochrome 章节)
- STM32F4 参考手册 RM0090 §3.1(Memory map / CCM SRAM):https://www.st.com/resource/en/reference_manual/cd00901901-stm32f40-5xx-and-stm32f42-7xx-advanced-connection-line-arm-based-32bit-mcus-st-stmicroelectronics.pdf
- FatFs(工程内 `Lib/FatFs/ff.h`、`ffconf.h` 配置注释)
### 8.2 RAM 预算台账模板
```
| Region/项目 | 计划值 | 实测值(map/串口) | 承载内容 | 变更历史(日期/人/原因) |
|---|---|---|---|---|
| RW_IRAM1 静态(除堆) | ≤75KB | | LVGL UI 缓冲、DMA 缓冲、FTL、BBT、内核 | |
| FreeRTOS 堆 | 32KB | | 6 任务栈+TCB+队列+lftpd 临时 | |
| └ 任务栈小计 | ~15KB | | 见 freertos.c 属性表 | |
| └ 堆余量底线 | ≥8KB | | 全负载 free 底值 | |
| RW_IRAM2(CCM) | ≤32KB | | LVGL 池、UI 帧缓冲、BBT 位图 | |
| C 库堆/栈 | 512B/1KB | | 仅启动期;lftpd 不得再用 | |
```
### 8.3 关键配置位置速查
| 配置 | 位置 |
|---|---|
| 堆大小 | `Inc/FreeRTOSConfig.h:71` |
| 栈溢出检测 | `Inc/FreeRTOSConfig.h`(新增,阶段 0) |
| 任务栈 | `Src/freertos.c:61-101`(各 `osThreadAttr_t.stack_size`,字节) |
| idle/timer 栈 | `FreeRTOSConfig.h:70,94` |
| LVGL 池 | `Lib/lv_conf.h:34`(`simulator/lv_conf.h:26` 同步) |
| UI 屏参/缓冲 | `Drivers/BSP/UI/ui_port.h:21-23`、`ui_port.c:16-18` |
| 远程 UI 缓冲 | `Drivers/BSP/UI/ui_net_sink.c:28-33` |
| CH395F MSS/DMA 缓冲 | `Drivers/BSP/CH395F/ch395f.h:119,137`、`ch395f.c:42-44` |
| NAND 页缓冲/BBT | `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c:42-50`、`gd5f2gq5ue.c:28-39` |
| FatFs | `Lib/FatFs/ffconf.h:32`(FF_FS_TINY) |
| scatter | `MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct` |
| lftpd 缓冲/malloc | `Drivers/BSP/NET/lftpd/lftpd.c:67-185,551,583`、`lftpd_io.c:44-54,189` |