diff --git a/Drivers/BSP/UI/ui_menu.c b/Drivers/BSP/UI/ui_menu.c index 8642f1bd..923eba53 100644 --- a/Drivers/BSP/UI/ui_menu.c +++ b/Drivers/BSP/UI/ui_menu.c @@ -7,24 +7,27 @@ * 作者:王建锋 * 创建日期:2026-08-29 * 修改记录: - * 2026-08-29 王建锋 创建初始版本 - * 2026-09-02 王建锋 弃用已废弃的 lv_menu 控件,改为基元控件页面导航 - * 2026-09-02 王建锋 修复选中行文字不可见(白底白字) - * 2026-09-03 王建锋 主界面按设计稿重构(根页 7 项/装饰/状态行) * 2026-09-04 王建锋 重构为"横向级联框"模型(菜单页面设计.md):拆出显示层 * ui_menu_view 与数据层 ui_menu_tab;本层只留导航状态机 + * 2026-09-08 王建锋 按代码规范 V1.0 补全分区注释与关键行注释;函数定义括号改 K&R 风格(同行) */ + +/* 头文件包含区 */ +#include + #include "ui_menu.h" #include "ui_menu_view.h" + #include "ui_menu_tab.h" -#include #include "lvgl.h" #define DBG_TAG "[UI_MENU]" #include "dbg_log.h" +/* 静态变量定义区 */ + /* * 导航状态:唯一"必须持久"的导航历史(共 17 B,见设计 §7.2); * 条目列表/框几何均可由 g_menu_tab + 本状态随时重算,不落 RAM @@ -34,6 +37,8 @@ static uint8_t s_sel[MENU_PAGE_MAX]; /* 各层选中下标(返回时还原 static uint8_t s_depth; /* 当前深度(0=根框)= 焦点所在(最右)框 */ static uint16_t s_scroll[MENU_PAGE_MAX]; /* 各框滚动偏移(px);框销毁再进入时还原 */ +/* 函数定义区 */ + /* * 函数功能:移选后的层刷新(高亮 + 跟随滚动 + 滚动位回读持久化) * 入口参数:depth - 层深度 uint8_t @@ -41,8 +46,7 @@ static uint16_t s_scroll[MENU_PAGE_MAX]; /* 各框滚动偏移(px);框销 * 限定条件:该层框已建 * 函数说明:显示层跟随滚动后,滚动位写回 s_scroll 持久(框重建时还原) */ -static void menu_refresh_layer(uint8_t depth) -{ +static void menu_refresh_layer(uint8_t depth) { ui_menu_view_frame_refresh(depth, s_sel[depth]); s_scroll[depth] = ui_menu_view_get_scroll(depth); } @@ -55,8 +59,7 @@ static void menu_refresh_layer(uint8_t depth) * 函数说明: 1. 叶子(无子项)同样压层,生成空白终点框(占位,无条目) * 2. 同节点再次进入:还原上次选中与滚动位;换节点则清零 */ -static void menu_open(uint8_t child) -{ +static void menu_open(uint8_t child) { uint8_t d = s_depth; bool same = (s_path[d + 1u] == child); @@ -77,8 +80,7 @@ static void menu_open(uint8_t child) * 函数说明: 1. 销毁最右框行对象(既有屏上框不动、无需重建) * 2. 焦点回父框;父层选中/滚动原样保留(选中不跳) */ -static void menu_back(void) -{ +static void menu_back(void) { ui_menu_view_frame_destroy(s_depth); s_depth--; ui_menu_view_focus(s_depth); @@ -95,38 +97,40 @@ static void menu_back(void) * 3. ESC/LEFT:关最右框(根框不响应) * 4. 空框(终点页 0 条目)只响应返回 */ -static void menu_key_handle(uint8_t depth, lv_key_t key) -{ - uint8_t kids[MENU_ITEM_MAX]; - uint8_t cnt = ui_menu_tab_children(s_path[depth], kids, MENU_ITEM_MAX); +static void menu_key_handle(uint8_t depth, lv_key_t key) { + uint8_t kids[MENU_ITEM_MAX]; /* 本框条目列表:由 g_menu_tab 按 parent 现算,不落 RAM */ + uint8_t cnt = ui_menu_tab_children(s_path[depth], kids, MENU_ITEM_MAX); /* 条目数;0 = 空白终点框(进入叶子) */ switch (key) { case LV_KEY_UP: - if (cnt > 0u) { + if (cnt > 0u) { /* 空框(0 条目)不移选;同时防对 0 取模 */ + /* 移选上移、首尾环绕:先加 cnt 再减 1 保证无符号下不为负,% cnt 绕回首行 */ s_sel[depth] = (uint8_t)((s_sel[depth] + (uint8_t)cnt - 1u) % cnt); - menu_refresh_layer(depth); + menu_refresh_layer(depth); /* 刷新高亮 + 跟随滚动 + 滚动位回读持久化 */ } break; case LV_KEY_DOWN: - if (cnt > 0u) { + if (cnt > 0u) { /* 空框(0 条目)不移选;同时防对 0 取模 */ + /* 移选下移、首尾环绕:% cnt 越尾自动绕回首行 */ s_sel[depth] = (uint8_t)((s_sel[depth] + 1u) % cnt); - menu_refresh_layer(depth); + menu_refresh_layer(depth); /* 刷新高亮 + 跟随滚动 + 滚动位回读持久化 */ } break; case LV_KEY_ENTER: case LV_KEY_RIGHT: - if (cnt > 0u && depth + 1u < MENU_PAGE_MAX) { - menu_open(kids[s_sel[depth]]); + /* 进子框:空框无条目可进;最深层(depth=MAX-1)无子层可再开 */ + if (cnt > 0u && depth < MENU_PAGE_MAX - 1u) { + menu_open(kids[s_sel[depth]]); /* 压层:建新框 + 焦点移到新框 */ } break; case LV_KEY_ESC: case LV_KEY_LEFT: - if (depth > 0u) { - menu_back(); + if (depth > 0u) { /* 根框(d=0)不响应返回 */ + menu_back(); /* 弹层:毁最右框,焦点回父框(选中/滚动不跳) */ } break; default: - break; + break; /* 其余键值忽略 */ } } @@ -137,8 +141,7 @@ static void menu_key_handle(uint8_t depth, lv_key_t key) * 限定条件:容器已加入默认输入组并获焦 * 函数说明:键值经默认输入组送达焦点容器;统一分发到 menu_key_handle */ -static void menu_key_cb(lv_event_t *e) -{ +static void menu_key_cb(lv_event_t *e) { uint8_t depth = (uint8_t)(uintptr_t)lv_event_get_user_data(e); if (depth < MENU_PAGE_MAX) { @@ -157,26 +160,29 @@ static void menu_key_cb(lv_event_t *e) * 4. 建根框(主界面 node 0 子项)并置焦点 * 5. 构建前后监控 LVGL 池内存并打印 */ -int ui_menu_init(void) -{ +int ui_menu_init(void) { + /* 数据表自检:各节点 parent 下标必须有效,提前捕获表内笔误(失败即返回、不建任何对象) */ if (!ui_menu_tab_valid()) { DBG_ERROR("menu table: INVALID (parent index out of range)"); return -1; } + /* 重置导航状态:各层从主界面(node 0)第 0 行开始、无滚动 */ for (uint8_t d = 0u; d < MENU_PAGE_MAX; d++) { s_path[d] = 0u; s_sel[d] = 0u; s_scroll[d] = 0u; } - s_depth = 0u; + s_depth = 0u; /* 起始于根框 */ + /* 取 LVGL 池内存基线(与构建后对照,量化菜单对象开销) */ lv_mem_monitor_t mon; lv_mem_monitor(&mon); DBG_INFO("menu build start: total=%u free=%u big=%u", (unsigned)mon.total_size, (unsigned)mon.free_size, (unsigned)mon.free_biggest_size); + /* 初始化显示层:样式 + 4 层持久容器 + 根框框体/装饰(失败 = 对象创建失败、池耗尽) */ if (ui_menu_view_init() != 0) { return -1; } @@ -187,14 +193,16 @@ int ui_menu_init(void) if (cont == NULL) { return -1; } - lv_group_add_obj(lv_group_get_default(), cont); + lv_group_add_obj(lv_group_get_default(), cont); /* 容器入组:焦点可轮到它、能收键事件 */ + /* 容器挂键回调;层深度 d 装入 user_data 指针(menu_key_cb 内拆回) */ lv_obj_add_event_cb(cont, menu_key_cb, LV_EVENT_KEY, (void *)(uintptr_t)d); } /* 根框:主界面(node 0)子项;建一次、持久 */ - ui_menu_view_frame_build(0u, 0u, 0u, 0u); - ui_menu_view_focus(0u); + ui_menu_view_frame_build(0u, 0u, 0u, 0u); /* depth=0、node=0(主界面)、sel=0、scroll=0 */ + ui_menu_view_focus(0u); /* 焦点置根框:按键首先进根框 */ + /* 读构建后内存、打印增量(对照上方基线) */ lv_mem_monitor(&mon); DBG_INFO("menu build done: total=%u free=%u big=%u used=%u", (unsigned)mon.total_size, diff --git a/Drivers/BSP/UI/ui_menu_view.c b/Drivers/BSP/UI/ui_menu_view.c index 047115f4..1ad0e38c 100644 --- a/Drivers/BSP/UI/ui_menu_view.c +++ b/Drivers/BSP/UI/ui_menu_view.c @@ -9,18 +9,25 @@ * 创建日期:2026-09-04 * 修改记录: * 2026-09-04 王建锋 创建:自旧 ui_menu.c 单页模型拆分出显示层(高内聚低耦合) + * 2026-09-08 王建锋 按代码规范 V1.0 补全:分区注释、公共函数注释、关键行注释 + * 2026-09-08 王建锋 menu_frame_t 瘦身(uint8_t/int16_t,96→48 B/层); + * s_row_text/s_row_arrow 并入 s_row(省 512 B,行 label 颜色继承) */ + +/* 头文件包含区 */ +#include + #include "ui_menu_view.h" #include "ui_menu_tab.h" #include "lvgl.h" -#include - #define DBG_TAG "[UI_MENU_VIEW]" #include "dbg_log.h" +/* 私有宏定义区 */ + /* * 布局常量(设备像素;参考图 02~07 实测 + 字体度量速查推导): * 汉字步进 12px/字、▶ 步进 10px、行高 15(7 行×15+2 边框=107,与参考图根框一致); @@ -41,25 +48,27 @@ #define MENU_DECO_LEG_W 3 /* 顶部"浅 V"两腿线宽(1bpp 下斜线边缘易被阈值吃掉,需加粗) */ #define MENU_DECO_HORIZ_W 2 /* 顶部"浅 V"水平线宽 */ +/* 私有类型定义区 */ + /* * 框几何缓存:位置/尺寸是"现算"的(不落导航状态),但子框对齐要用父框刚算出的值, * 故按层缓存一份;sel 为最近一次应用的高亮(子框垂直对齐依据),由逻辑层经 * build/refresh 保持同步 */ typedef struct { - bool built; /* 框已建(框体 + 行对象存在) */ + uint8_t built; /* 框已建(框体 + 行对象存在):0/1(uint8_t 与系统存储一致) */ uint8_t sel; /* 最近应用的选中行 */ uint8_t item_cnt; /* 行数 */ - int32_t x, y; /* 框左上角(含边框) */ - int32_t w, h; /* 框外廓尺寸 */ + int16_t x, y; /* 框左上角(含边框;16 位够:y 钳制前可为负) */ + int16_t w, h; /* 框外廓尺寸(16 位够:最大 16×15+2=242) */ } menu_frame_t; +/* 静态变量定义区 */ + static lv_obj_t *s_cont[MENU_PAGE_MAX]; /* 4 层持久容器(每深度一个;焦点/键入口,永不删) */ static lv_obj_t *s_frame_box[MENU_PAGE_MAX]; /* 框体:d=0 持久(根框);d>=1 进入时建、返回时毁 */ -static lv_obj_t *s_row[MENU_PAGE_MAX][MENU_ITEM_MAX]; /* 行容器(整行反色高亮的载体) */ -static lv_obj_t *s_row_text[MENU_PAGE_MAX][MENU_ITEM_MAX]; /* 行文本 label */ -static lv_obj_t *s_row_arrow[MENU_PAGE_MAX][MENU_ITEM_MAX]; /* 行箭头 label(叶子=NULL) */ -static menu_frame_t s_frame[MENU_PAGE_MAX]; +static lv_obj_t *s_row[MENU_PAGE_MAX][MENU_ITEM_MAX]; /* 行容器(整行反色高亮的载体;文本/箭头是其子对象,不单独存) */ +static menu_frame_t s_frame[MENU_PAGE_MAX]; /* 每层框几何缓存:位置/尺寸/选中行(现算不落状态,供子框对齐;见上方类型注释) */ static lv_obj_t *s_root_deco = NULL; /* 根框装饰:顶部"浅 V"水平段(两腿为临时对象,样式同源) */ static lv_obj_t *s_root_status = NULL; /* 根框状态行 */ @@ -70,6 +79,8 @@ static lv_style_t s_style_selected; /* 选中态:整行反色(白底) static lv_style_t s_style_frame; /* 框体:透明底 + 1px 白边框 */ static lv_style_t s_style_scrollbar; /* 滚动条:白色、4px 宽 */ +/* 函数定义区 */ + /* * 函数功能:初始化显示层样式(静态样式,仅执行一次) * 入口参数:无 @@ -79,14 +90,16 @@ static lv_style_t s_style_scrollbar; /* 滚动条:白色、4px 宽 */ * 2. frame 为所有级联框(含根框)的 1px 白边框 * 3. selected 为选中行整行反色(1bpp 白底);文字色在刷新时显式置黑 */ -static void view_styles_init(void) -{ +static void view_styles_init(void) { static bool inited = false; + + /* 一次性初始化:已初始化则直接返回 */ if (inited) { return; } inited = true; + /* flat:透明、无边框/圆角/内边距(剥离主题附加的卡片样式) */ lv_style_init(&s_style_flat); lv_style_set_bg_opa(&s_style_flat, LV_OPA_TRANSP); lv_style_set_border_width(&s_style_flat, 0); @@ -94,11 +107,13 @@ static void view_styles_init(void) lv_style_set_pad_all(&s_style_flat, 0); lv_style_set_pad_gap(&s_style_flat, 0); + /* selected:选中行整行反色(白底黑字) */ lv_style_init(&s_style_selected); lv_style_set_bg_opa(&s_style_selected, LV_OPA_COVER); lv_style_set_bg_color(&s_style_selected, lv_color_white()); lv_style_set_text_color(&s_style_selected, lv_color_black()); + /* frame:透明底 + 1px 白边框(所有级联框含根框) */ lv_style_init(&s_style_frame); lv_style_set_bg_opa(&s_style_frame, LV_OPA_TRANSP); lv_style_set_border_width(&s_style_frame, MENU_FRAME_BORDER); @@ -107,6 +122,7 @@ static void view_styles_init(void) lv_style_set_pad_all(&s_style_frame, 0); lv_style_set_pad_gap(&s_style_frame, 0); + /* scrollbar:白色 4px 宽(行超框高时自动出现) */ lv_style_init(&s_style_scrollbar); lv_style_set_bg_opa(&s_style_scrollbar, LV_OPA_COVER); lv_style_set_bg_color(&s_style_scrollbar, lv_color_white()); @@ -123,22 +139,30 @@ static void view_styles_init(void) * 文本/▶ 宽取字体实际步进(汉字 12px、▶ 10px); * 注意本 LVGL 版 lv_text_get_width 的 length=0 表示"不测量",必须传 strlen */ -static int32_t view_frame_width(const uint8_t *kids, uint8_t cnt) -{ +static int32_t view_frame_width(const uint8_t *kids, uint8_t cnt) { const lv_font_t *font = LV_FONT_DEFAULT; int32_t w = 0; + /* 遍历各行,取内容宽最大值 */ for (uint8_t i = 0; i < cnt; i++) { const char *name = ui_menu_tab_name(kids[i]); + + /* 量文本实际步进(本 LVGL 版 length=0 意为"不测量",须传 strlen) */ int32_t tw = lv_text_get_width(name, (uint32_t)strlen(name), font, 0); int32_t cw = MENU_ROW_PAD_L + tw + MENU_ROW_PAD_R; + + /* 菜单行(有子项):加箭头间距 + ▶ 宽 */ if (ui_menu_tab_has_children(kids[i])) { cw += MENU_ARROW_GAP + lv_text_get_width("▶", (uint32_t)strlen("▶"), font, 0); } + + /* 取最宽行 */ if (cw > w) { w = cw; } } + + /* 加左右边框 */ return w + 2 * MENU_FRAME_BORDER; } @@ -153,19 +177,22 @@ static int32_t view_frame_width(const uint8_t *kids, uint8_t cnt) * 限定条件:d < MENU_PAGE_MAX,i < MENU_ITEM_MAX * 函数说明: 1. 行容器整行宽、高 MENU_ROW_H,承载选中整行反色高亮 * 2. 文本左留白 MENU_ROW_PAD_L;箭头紧跟文本(间距 MENU_ARROW_GAP,自适应) - * 3. 文本/箭头子 label 加 IGNORE_LAYOUT 做行内绝对定位 + * 3. 文本/箭头子 label 加 IGNORE_LAYOUT 做行内绝对定位,是行的子对象、不单独存 */ -static bool view_add_row(lv_obj_t *box, uint8_t d, uint8_t i, uint8_t node, bool has_arrow) -{ +static bool view_add_row(lv_obj_t *box, uint8_t d, uint8_t i, uint8_t node, bool has_arrow) { + /* 行容器:整行宽、行高 MENU_ROW_H(整行反色高亮的载体) */ lv_obj_t *row = lv_obj_create(box); if (row == NULL) { return false; } lv_obj_add_style(row, &s_style_flat, 0); + + /* 挂选中行反色样式(CHECKED 态时触发白底) */ lv_obj_add_style(row, &s_style_selected, LV_STATE_CHECKED); lv_obj_set_width(row, lv_pct(100)); lv_obj_set_height(row, MENU_ROW_H); + /* 文本 label:左留白,行内绝对定位(IGNORE_LAYOUT) */ lv_obj_t *lbl = lv_label_create(row); if (lbl == NULL) { lv_obj_delete(row); @@ -174,11 +201,16 @@ static bool view_add_row(lv_obj_t *box, uint8_t d, uint8_t i, uint8_t node, bool lv_obj_add_style(lbl, &s_style_flat, 0); lv_obj_add_flag(lbl, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_set_pos(lbl, MENU_ROW_PAD_L, MENU_ROW_TXT_TOP); + + /* 静态字符串:直接指向 ROM 表,不拷贝 */ lv_label_set_text_static(lbl, ui_menu_tab_name(node)); + /* 箭头:仅菜单行(有子项),紧跟文本(间距 MENU_ARROW_GAP) */ lv_obj_t *ar = NULL; if (has_arrow) { const char *name = ui_menu_tab_name(node); + + /* 箭头 x = 左留白 + 实测文本宽 + 间距 */ int32_t tw = lv_text_get_width(name, (uint32_t)strlen(name), LV_FONT_DEFAULT, 0); ar = lv_label_create(row); if (ar != NULL) { @@ -189,9 +221,8 @@ static bool view_add_row(lv_obj_t *box, uint8_t d, uint8_t i, uint8_t node, bool } } + /* 只存行容器:文本/箭头是其子对象(颜色在行上设置、子对象继承) */ s_row[d][i] = row; - s_row_text[d][i] = lbl; - s_row_arrow[d][i] = ar; return true; } @@ -203,11 +234,12 @@ static bool view_add_row(lv_obj_t *box, uint8_t d, uint8_t i, uint8_t node, bool * 限定条件:该层行对象已建 * 函数说明:1. 选中行整行反色(白底黑字),箭头随反色变黑(参考图实测:选中行保留 ▶) * 2. 非选中行透明底白字、白色箭头 + * 3. 文本/箭头颜色设在行容器上,子 label 继承(text_color 为可继承属性) */ -static void view_apply_sel(uint8_t depth, uint8_t sel) -{ +static void view_apply_sel(uint8_t depth, uint8_t sel) { menu_frame_t *f = &s_frame[depth]; + /* 逐行置/清 CHECKED 态(选中行经样式整行反色) */ for (uint8_t i = 0; i < f->item_cnt; i++) { bool is_sel = (i == sel); if (is_sel) { @@ -216,12 +248,10 @@ static void view_apply_sel(uint8_t depth, uint8_t sel) else { lv_obj_clear_state(s_row[depth][i], LV_STATE_CHECKED); } - lv_obj_set_style_text_color(s_row_text[depth][i], + + /* 文本色设在行上:文本/箭头子对象继承(选中黑、非选中白,箭头随反色) */ + lv_obj_set_style_text_color(s_row[depth][i], is_sel ? lv_color_black() : lv_color_white(), 0); - if (s_row_arrow[depth][i] != NULL) { - lv_obj_set_style_text_color(s_row_arrow[depth][i], - is_sel ? lv_color_black() : lv_color_white(), 0); - } } } @@ -232,16 +262,30 @@ static void view_apply_sel(uint8_t depth, uint8_t sel) * 限定条件:box 已创建 * 函数说明:行内容超框高时框内滚动(本菜单最深 9 行不触发,机制为更长列表保留) */ -static void view_box_layout(lv_obj_t *box) -{ +static void view_box_layout(lv_obj_t *box) { + /* 纵向 flex 堆行、顶对齐(行自框顶依次排列) */ lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(box, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + /* 自动滚动条:仅当行内容超框高时出现(框内滚动) */ lv_obj_set_scrollbar_mode(box, LV_SCROLLBAR_MODE_AUTO); lv_obj_add_style(box, &s_style_scrollbar, LV_PART_SCROLLBAR); } -void ui_menu_view_frame_build(uint8_t depth, uint8_t node, uint8_t sel, uint16_t scroll) -{ +/* + * 函数功能:构建指定深度的框(几何现算 + 行对象创建 + 选中/滚动应用) + * 入口参数:depth - 深度(0=根框) uint8_t + * node - 本框展示节点(行 = 其子项) uint8_t + * sel - 本框选中行 uint8_t + * scroll - 本框滚动偏移(px,0=顶部) uint16_t + * 返回值:无 + * 限定条件:depth>0 时 depth-1 层框已建(父框几何是其对齐依据) + * 函数说明: 1. d=0:根框框体已存在,仅(重)建行对象 + * 2. d>0:右贴靠父框右缘;顶边=父框选中行行顶,超屏底则 + * 上移使本框底边对齐父框选中行行底,仍超顶则钳制顶部并改框内滚动 + * 3. 框宽 = 最宽行(文本 + ▶ + 左右留白)+ 边框;条目现算不落地 + */ +void ui_menu_view_frame_build(uint8_t depth, uint8_t node, uint8_t sel, uint16_t scroll) { menu_frame_t *f = &s_frame[depth]; lv_obj_t *box; uint8_t kids[MENU_ITEM_MAX]; @@ -291,17 +335,20 @@ void ui_menu_view_frame_build(uint8_t depth, uint8_t node, uint8_t sel, uint16_t s_frame_box[depth] = box; } - f->built = true; + /* 更新框几何缓存(子框对齐的依据) */ + f->built = 1u; f->item_cnt = cnt; f->sel = sel; /* 行对象:条目现算(= parent==node 过滤),进时建、退时毁 */ lv_obj_clean(box); + + /* 清空旧行指针(旧对象已随框体删除) */ for (uint8_t i = 0; i < MENU_ITEM_MAX; i++) { s_row[depth][i] = NULL; - s_row_text[depth][i] = NULL; - s_row_arrow[depth][i] = NULL; } + + /* 逐行建行对象 */ for (uint8_t i = 0; i < cnt; i++) { if (!view_add_row(box, depth, i, kids[i], ui_menu_tab_has_children(kids[i]))) { break; /* LVGL 池耗尽:停止建行(池有界,见设计 §7.5) */ @@ -311,15 +358,23 @@ void ui_menu_view_frame_build(uint8_t depth, uint8_t node, uint8_t sel, uint16_t /* 选中高亮 + 滚动位 */ view_apply_sel(depth, sel); if (scroll > 0u) { + /* 有保存的滚动位:还原 */ lv_obj_scroll_to_y(box, (int32_t)scroll, LV_ANIM_OFF); } else if (cnt > 0u) { + /* 无保存滚动位:选中行对齐到可见区 */ lv_obj_scroll_to_view(s_row[depth][sel], LV_ANIM_OFF); } } -void ui_menu_view_frame_destroy(uint8_t depth) -{ +/* + * 函数功能:销毁指定深度的框(框体连同行对象,容器保留) + * 入口参数:depth - 深度 uint8_t + * 返回值:无 + * 限定条件:depth>0(根框持久不可销毁) + * 函数说明:返回(关最右框)时调用,LVGL 池内存随即回落 + */ +void ui_menu_view_frame_destroy(uint8_t depth) { menu_frame_t *f = &s_frame[depth]; if (depth == 0u || !f->built) { @@ -327,12 +382,12 @@ void ui_menu_view_frame_destroy(uint8_t depth) } lv_obj_delete(s_frame_box[depth]); /* 框体连行对象整体删除(容器保留) */ s_frame_box[depth] = NULL; + + /* 清空行指针与几何缓存 */ for (uint8_t i = 0; i < MENU_ITEM_MAX; i++) { s_row[depth][i] = NULL; - s_row_text[depth][i] = NULL; - s_row_arrow[depth][i] = NULL; } - f->built = false; + f->built = 0u; f->item_cnt = 0u; f->sel = 0u; f->x = 0; @@ -341,10 +396,18 @@ void ui_menu_view_frame_destroy(uint8_t depth) f->h = 0; } -void ui_menu_view_frame_refresh(uint8_t depth, uint8_t sel) -{ +/* + * 函数功能:刷新指定深度框的选中高亮 + * 入口参数:depth - 深度 uint8_t + * sel - 新选中行 uint8_t + * 返回值:无 + * 限定条件:ui_menu_view_frame_build(depth,…) 已完成 + * 函数说明:只改行状态与文字色 + 跟随滚动,不重建对象 + */ +void ui_menu_view_frame_refresh(uint8_t depth, uint8_t sel) { menu_frame_t *f = &s_frame[depth]; + /* 未建或越界直接返回 */ if (!f->built || depth >= MENU_PAGE_MAX || sel >= f->item_cnt) { return; } @@ -354,31 +417,59 @@ void ui_menu_view_frame_refresh(uint8_t depth, uint8_t sel) lv_obj_scroll_to_view(s_row[depth][sel], LV_ANIM_OFF); } -void ui_menu_view_set_scroll(uint8_t depth, uint16_t scroll) -{ +/* + * 函数功能:应用指定深度框的滚动偏移 + * 入口参数:depth - 深度 uint8_t + * scroll - 滚动偏移(px) uint16_t + * 返回值:无 + * 限定条件:该层框已建 + * 函数说明:行对象重建后按此还原用户滚动位置 + */ +void ui_menu_view_set_scroll(uint8_t depth, uint16_t scroll) { + /* 未建框直接返回 */ if (depth >= MENU_PAGE_MAX || !s_frame[depth].built || s_frame_box[depth] == NULL) { return; } lv_obj_scroll_to_y(s_frame_box[depth], (int32_t)scroll, LV_ANIM_OFF); } -uint16_t ui_menu_view_get_scroll(uint8_t depth) -{ +/* + * 函数功能:读取指定深度框当前滚动偏移 + * 入口参数:depth - 深度 uint8_t + * 返回值:滚动偏移(px) uint16_t 未建框返回 0 + * 限定条件:无 + * 函数说明:跟随滚动后由逻辑层读回,写入导航状态持久化 + */ +uint16_t ui_menu_view_get_scroll(uint8_t depth) { + /* 未建框返回 0 */ if (depth >= MENU_PAGE_MAX || !s_frame[depth].built || s_frame_box[depth] == NULL) { return 0u; } return (uint16_t)lv_obj_get_scroll_y(s_frame_box[depth]); } -void ui_menu_view_focus(uint8_t depth) -{ +/* + * 函数功能:焦点移至指定深度的容器 + * 入口参数:depth - 深度 uint8_t + * 返回值:无 + * 限定条件:ui_menu_view_init() 已完成且容器已加入输入组 + * 函数说明:焦点=最右框;进/退时由逻辑层调用 + */ +void ui_menu_view_focus(uint8_t depth) { if (depth < MENU_PAGE_MAX && s_cont[depth] != NULL) { + /* 焦点移到目标深度容器 */ lv_group_focus_obj(s_cont[depth]); } } -lv_obj_t *ui_menu_view_container(uint8_t depth) -{ +/* + * 函数功能:取指定深度的持久容器 + * 入口参数:depth - 深度 0..MENU_PAGE_MAX-1 uint8_t + * 返回值:容器对象 lv_obj_t * 越界返回 NULL + * 限定条件:ui_menu_view_init() 已完成 + * 函数说明:逻辑层在容器上绑定输入组与键事件(显示层不碰输入) + */ +lv_obj_t *ui_menu_view_container(uint8_t depth) { return (depth < MENU_PAGE_MAX) ? s_cont[depth] : NULL; } @@ -389,8 +480,7 @@ lv_obj_t *ui_menu_view_container(uint8_t depth) * 限定条件:line 已创建且已设点集 * 函数说明:绝对定位于 (0,0)、忽略布局、白色实线、指定线宽 */ -static void view_deco_line_apply(lv_obj_t *line, int32_t width) -{ +static void view_deco_line_apply(lv_obj_t *line, int32_t width) { lv_obj_add_flag(line, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_set_pos(line, 0, 0); lv_obj_set_size(line, MENU_SCREEN_W, 20); @@ -399,14 +489,27 @@ static void view_deco_line_apply(lv_obj_t *line, int32_t width) lv_obj_set_style_line_opa(line, LV_OPA_COVER, 0); } -int ui_menu_view_init(void) -{ +/* + * 函数功能:初始化显示层(样式 + 4 层持久容器 + 根框装饰 + 根框框体) + * 入口参数:无 + * 返回值:0 - 成功,其他 - 失败(对象创建失败) + * 限定条件:LVGL 已初始化且活动屏幕存在(ui_port_init / sim_port_init 已完成) + * 函数说明:1. 4 层容器每深度一个、全屏覆盖、持久不删(焦点/键入口,防悬空) + * 2. 根框装饰(浅 V + 状态行)与根框框体只属根层(d=0) + * 3. 行对象不在此建,由逻辑层调 ui_menu_view_frame_build 按需建 + */ +int ui_menu_view_init(void) { + /* 取活动屏幕:NULL = ui_port_init/sim_port_init 未完成、LVGL 未就绪 */ lv_obj_t *scr = lv_screen_active(); + if (scr == NULL) { return -1; } + + /* 样式一次性初始化(静态样式、常驻,仅此处建一次) */ view_styles_init(); + /* 读屏幕实际分辨率(不硬编码,MCU/模拟器两端共用本代码) */ lv_display_t *disp = lv_display_get_default(); int32_t sw = lv_display_get_horizontal_resolution(disp); int32_t sh = lv_display_get_vertical_resolution(disp); @@ -416,7 +519,7 @@ int ui_menu_view_init(void) if (menu == NULL) { return -1; } - lv_obj_add_style(menu, &s_style_flat, 0); + lv_obj_add_style(menu, &s_style_flat, 0); /* 透明、无边框:纯定位层,不可见 */ lv_obj_set_size(menu, sw, sh); /* 4 层持久容器:每深度一个、全屏覆盖、永不删(焦点/键入口,防悬空) */ @@ -425,34 +528,35 @@ int ui_menu_view_init(void) if (s_cont[d] == NULL) { return -1; } - lv_obj_add_style(s_cont[d], &s_style_flat, 0); - lv_obj_add_flag(s_cont[d], LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_add_style(s_cont[d], &s_style_flat, 0); /* 透明、无边框 */ + lv_obj_add_flag(s_cont[d], LV_OBJ_FLAG_IGNORE_LAYOUT); /* 绝对定位,不参与父级 flex 布局 */ lv_obj_set_pos(s_cont[d], 0, 0); - lv_obj_set_size(s_cont[d], sw, sh); - lv_obj_add_flag(s_cont[d], LV_OBJ_FLAG_CLICK_FOCUSABLE); + lv_obj_set_size(s_cont[d], sw, sh); /* 全屏覆盖 */ + lv_obj_add_flag(s_cont[d], LV_OBJ_FLAG_CLICK_FOCUSABLE); /* 可获焦:键事件入口 */ } /* 根框装饰:顶部"开口浅 V"(两腿加粗、水平线细)+ 状态行(绝对定位于 d=0 容器) * 1bpp 下斜线边缘易被阈值吃掉,腿须比水平线粗才接近参考图 */ { - static const lv_point_precise_t deco_leg_l[] = { {7, 7}, {16, 17} }; - static const lv_point_precise_t deco_hoz[] = { {16, 17}, {145, 17} }; - static const lv_point_precise_t deco_leg_r[] = { {158, 7}, {145, 17} }; + static const lv_point_precise_t deco_leg_l[] = { {7, 7}, {16, 17} }; /* 左腿:自顶端斜下 */ + static const lv_point_precise_t deco_hoz[] = { {16, 17}, {145, 17} }; /* 水平线:接两腿底端 */ + static const lv_point_precise_t deco_leg_r[] = { {158, 7}, {145, 17} }; /* 右腿 */ lv_obj_t *leg_l = lv_line_create(s_cont[0]); lv_obj_t *leg_r = lv_line_create(s_cont[0]); - s_root_deco = lv_line_create(s_cont[0]); + s_root_deco = lv_line_create(s_cont[0]); /* 仅水平线留存句柄;两腿建后无需再引用 */ if (leg_l == NULL || leg_r == NULL || s_root_deco == NULL) { return -1; } lv_line_set_points(leg_l, deco_leg_l, 2); lv_line_set_points(leg_r, deco_leg_r, 2); lv_line_set_points(s_root_deco, deco_hoz, 2); - view_deco_line_apply(leg_l, MENU_DECO_LEG_W); + view_deco_line_apply(leg_l, MENU_DECO_LEG_W); /* 腿:3px 粗 */ view_deco_line_apply(leg_r, MENU_DECO_LEG_W); - view_deco_line_apply(s_root_deco, MENU_DECO_HORIZ_W); + view_deco_line_apply(s_root_deco, MENU_DECO_HORIZ_W); /* 水平线:2px 细 */ } + /* 状态行:当前模式(顶部区域,根框上方) */ s_root_status = lv_label_create(s_cont[0]); if (s_root_status == NULL) { return -1; @@ -460,26 +564,27 @@ int ui_menu_view_init(void) lv_obj_add_style(s_root_status, &s_style_flat, 0); lv_obj_add_flag(s_root_status, LV_OBJ_FLAG_IGNORE_LAYOUT); lv_obj_set_pos(s_root_status, MENU_ROOT_STATUS_X, MENU_ROOT_STATUS_Y); - lv_label_set_text_static(s_root_status, "当前模式 : 无模式"); + lv_label_set_text_static(s_root_status, "当前模式 : 无模式"); /* 静态字符串,不拷内存 */ /* 根框框体:持久;宽 = 内容自适应(主界面 node 0 的子项) */ uint8_t kids[MENU_ITEM_MAX]; - uint8_t cnt = ui_menu_tab_children(0u, kids, MENU_ITEM_MAX); - int32_t w = view_frame_width(kids, cnt); - int32_t h = (int32_t)cnt * MENU_ROW_H + 2 * MENU_FRAME_BORDER; + uint8_t cnt = ui_menu_tab_children(0u, kids, MENU_ITEM_MAX); /* 主界面子项(7 个 L1 条目) */ + int32_t w = view_frame_width(kids, cnt); /* 宽 = 最宽行内容 + 左右边框(当前 66) */ + int32_t h = (int32_t)cnt * MENU_ROW_H + 2 * MENU_FRAME_BORDER; /* 高 = 行数×行高 + 上下边框(当前 107) */ s_frame_box[0] = lv_obj_create(s_cont[0]); if (s_frame_box[0] == NULL) { return -1; } - lv_obj_add_style(s_frame_box[0], &s_style_flat, 0); - lv_obj_add_style(s_frame_box[0], &s_style_frame, 0); + lv_obj_add_style(s_frame_box[0], &s_style_flat, 0); /* 透明底 */ + lv_obj_add_style(s_frame_box[0], &s_style_frame, 0); /* 1px 白边框 */ lv_obj_add_flag(s_frame_box[0], LV_OBJ_FLAG_IGNORE_LAYOUT); - lv_obj_set_pos(s_frame_box[0], MENU_ROOT_BOX_X, MENU_ROOT_BOX_Y); + lv_obj_set_pos(s_frame_box[0], MENU_ROOT_BOX_X, MENU_ROOT_BOX_Y); /* 左上 (0,44):装饰/状态行之下 */ lv_obj_set_size(s_frame_box[0], w, h); - view_box_layout(s_frame_box[0]); + view_box_layout(s_frame_box[0]); /* 纵向 flex 堆行 + 自动滚动条(行对象后由 frame_build 添加) */ - s_frame[0].built = true; /* 框体就绪;行对象由逻辑层 frame_build(0) 创建 */ + /* 写根框几何缓存:子框右贴靠其右缘、顶对齐其选中行,均以此为准 */ + s_frame[0].built = 1u; /* 框体就绪;行对象由逻辑层 frame_build(0) 创建 */ s_frame[0].x = MENU_ROOT_BOX_X; s_frame[0].y = MENU_ROOT_BOX_Y; s_frame[0].w = w; diff --git a/docs/UI 设计/UI_界面制作流程.md b/docs/UI 设计/UI_界面制作流程.md index 20f8fb41..e61d2a59 100644 --- a/docs/UI 设计/UI_界面制作流程.md +++ b/docs/UI 设计/UI_界面制作流程.md @@ -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 需求录入
→ 屏幕台账 + 问题清单"] + P0 -- "Gate: 屏幕定位/极性已确认 + 阻塞性问题有答案" --> P1["P1 图 → HTML 样机
→ mockups/NN.html + NN_说明.md"] + P1 --> C1{"渲染样机 ↔ 对照原图
收敛?"} + C1 -- "否: 修 HTML(浏览器确认 ↔ 修 HTML)" --> P1 + C1 -- "是(Gate: 样机定稿 + 原图校验过 + 问题有答案 + 字集核对)" --> P2["P2 HTML → LVGL 代码
→ Drivers/BSP/UI 代码 + 字体(重生成)"] + P2 -- "Gate: 参数逐项对账 + 字体重生成 + 内存预算 + 双端注册" --> P3["P3 模拟器验证
→ shot.png(与样机渲染比对)"] + P3 --> C3{"输出图 ↔ 样机渲染
同构?"} + C3 -- "否: 依 HTML 修代码 → 再出图" --> P3 + C3 -- "是(Gate: 导航符合按键语义)" --> P4["P4 MCU 验证
→ 日志 + 上机目检结论"] + P4 -- "Gate: @build 0 错 0 警 + @test PASS + 上机目检通过" --> P5(["P5 归档
→ 变更记录/台账 + 随代码提交(代码+字体+字集+样机+文档)"]) ``` > 小改动(文案/间距/纯交互/字体重生成)走 §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 条件与产物标注保留、语义不变 | diff --git a/docs/UI 设计/images/级联框几何计算.svg b/docs/UI 设计/images/级联框几何计算.svg new file mode 100644 index 00000000..ec9f94d5 --- /dev/null +++ b/docs/UI 设计/images/级联框几何计算.svg @@ -0,0 +1,152 @@ + + + +级联框几何计算 —— ui_menu_view_frame_build():先算三个数(宽 w / 位置 x,y / 高 h),再照数建对象 + +① 框宽 w = 最宽行 + 2×边框:同框各行宽可以不同,框宽只跟“最宽的一行”走 + + + + + + + + + + +2 行框(1× 原尺寸):行 1 = 4字+▶(最宽),行 2 = 3字 + + + +框宽 66 + +行 1 剖面(×4 放大) + + + + + + + + + + + + + +边框1 + + + +留白2 + + + +文本 4×12=48 + + + +间距2 + + + +▶10 + + + +留白2 + + + +边框1 +w = 2 + 48 + 2 + 10 + 2 + 2×1 = 66(最宽行决定) +窄行(如 3 字)左对齐、右侧留空,框宽不变 + +② X 定位:子框左缘 = 父框右缘(共享 1px 边框列) + + + + + + + + + + + + + +65 = 父.w − 1 +减的 1 = 父框右边框列:子框左边框与它重叠为同一 1px +→ 零间隙、边框不重画,逐级向右“生长” + + +父框 +子框 +共享边框列 1px(×25 放大) + + +③ Y 定位(默认):顶边 = 父“选中行”行顶 + +屏 160×160 + + + + + +参数 +定值 +控制字 +软压板 + + + + + +参数 +定值 +控制字 +软压板 + +顶边 = 选中行行顶 +y = 父.y + 1 + 父sel×15 → 26 = 10 + 1 + 1×15(屏内坐标) +选中行以下空间够 → 子框原样落下,不上移 + +④ Y 定位(溢出):整框上移,底边对齐“选中行”行底 + + + + + + + + + +选中行 + + + + + + + + + +选中行 + +底边 = 选中行行底 + + + +上移 77 +上移公式 +触发:y + h > 160(顶对齐后底边超屏) +y = 选中行行顶 + ROW_H − h +本例(屏内坐标):85 + 92 = 177 > 160 → y = 85 + 15 − 92 = 8 +为什么对齐“行底”而不贴屏底: +子框挂在父框“选中行”下沿——选中行不动,子框位置就不动; +若贴屏底,父框选中行一移,子框位置跟着乱跳(父子脱钩)。 +兜底:上移后 y < 0 → 钳 0;仍溢出 → 框高截到可见区、行在框内滚动(§5.4 AUTO 滚动条) +虚线红框 = 顶对齐(会超屏 17px);实线框 = 上移 77 后 +红底 = 超屏部分(由截断/框内滚动兜底) + diff --git a/docs/UI 设计/images/级联框总览.svg b/docs/UI 设计/images/级联框总览.svg new file mode 100644 index 00000000..43ccdb7e --- /dev/null +++ b/docs/UI 设计/images/级联框总览.svg @@ -0,0 +1,100 @@ + + + +横向级联子框总览(示例:主界面 → 参数定值 → 整定;按 §5.2 常量、§5.3 公式绘制,设备 px) + + + + +当前模式:无模式 + +装 +置 +信 +息 +实 +时 +数 +据 +▶ + +参 +数 +定 +值 +▶ +三 +遥 +设 +置 +▶ +装 +置 +维 +护 +▶ +通 +讯 +参 +数 +▶ +记 +录 +查 +询 +▶ + + +整 +定 +▶ +查 +看 +▶ + + +参 +数 +定 +值 +控 +制 +字 +软 +压 +板 + + + + +① 根框 (0, 44) +w = 66(最宽行 = 4 字 + 箭头);h = 7×15 + 2 = 107 + + +② 选中行 = 第 2 行“参数定值” +整行反色:白底黑字,箭头随反色变黑 + + +③ y_mid = 44 + 1 + 2×15 = 75 += 父框“选中行”行顶(顶对齐) + + +④ x_mid = 0 + 66 − 1 = 65 +子框左缘 = 父框右缘:共享边框列、零间隙 + + +⑤ y_right = 75 + 1 + 0×15 = 76 += “整定”行顶(中框首行 → 本例与中框顶同高) + + +⑥ h = 行数 × 15 + 2(按内容,不强顶屏底) +中框 = 32;右框 = 62(底边 138 < 160,无上移) + + +⑦ x_right = 65 + 42 − 1 = 106 +w = 42(最宽行 = 3 字无箭头) + +x = x_parent + w_parent − 1  y = y_parent + 1 + sel × 15  h = 行数 × 15 + 2  w = 2 + 文本_w + [2 + 10] + 2 + 2 +y + h > 160 → 整框上移使底边对齐父框“选中行”行底(顶部不钳制)  行超框高 → 框内 ↑/↓ 滚动 +屏幕 160×160(1bpp 单色,白 = 亮) + diff --git a/docs/UI 设计/菜单页面设计.md b/docs/UI 设计/菜单页面设计.md index 54c84e27..bd03665a 100644 --- a/docs/UI 设计/菜单页面设计.md +++ b/docs/UI 设计/菜单页面设计.md @@ -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
唯一 17 B 导航状态(path/选择/深度/滚动)
键语义:移动 / 进入 / 退出"] + L -- "单向驱动: frame_build / frame_destroy / frame_refresh / focus" --> V["显示层 ui_menu_view.c/h
持全部 LVGL 对象,纯渲染,零导航状态"] + L -- "只读查询: children / valid" --> T["数据层 ui_menu_tab.c/h(生成物,只读)
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 @@ -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 ` 经 `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 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) | diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md new file mode 100644 index 00000000..7bebacec --- /dev/null +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -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` |