上一篇聊环形缓冲区,说它和链表是一对好搭档:一个管流式数据,一个管离散节点。这篇就把链表请出来,讲它在嵌入式 GUI 里最值钱的一个用法——页面管理和数据流转。
做带屏项目的朋友应该都有体会:项目初期两三个页面,几个全局变量加 switch-case 就能搞定。可页面一多,问题全冒出来了。页面之间怎么跳转?返回键怎么处理?A 页面的参数怎么传给 B?B 改了数据怎么通知 A 刷新?这些事看着琐碎,处理不好代码就迅速腐化。我见过不少项目,GUI 部分占了整个工程一半以上代码量,大半还是各种跳转判断和数据搬运的重复逻辑。
后来我用链表统一管理页面生命周期和数据交互,效果出奇地好。链表天然适合"动态增减、顺序访问"的场景,在资源有限的 MCU 上也足够轻量。

先看"原始"写法有多难受
static uint8_t current_page = PAGE_HOME;
void key_event_handler(uint8_t key)
{
switch (current_page) {
case PAGE_HOME:
if (key == KEY_ENTER) {
page_home_exit();
page_setting_enter();
current_page = PAGE_SETTING;
}
break;
case PAGE_SETTING:
if (key == KEY_ENTER) {
page_setting_exit();
page_wifi_enter();
current_page = PAGE_WIFI;
} else if (key == KEY_BACK) {
page_setting_exit();
page_home_enter();
current_page = PAGE_HOME;
}
break;
/* ... 每加一个页面,这里就多一坨 */
}
}
这种写法的毛病一堆:每个页面都要"认识"其他页面,耦合死死的;返回路径硬编码,跳转链路一变就得到处改;数据传递靠全局变量,页面一多就分不清谁在读谁在写;没有统一的生命周期管理,资源申请释放全靠人记。
加一个页面,要在 switch 里加一坨 case,还要在所有能跳到它的页面里加跳转逻辑。页面数到十几个的时候,这种代码没人敢动,动一处崩三处。
为什么链表天然适合干这个
GUI 页面管理的核心需求就三条:动态打开关闭页面(数量不固定)、记住打开顺序(用于返回)、相邻页面间传数据。
这不就是链表最擅长的事吗?用数组也能做页面栈,但数组大小要预定义,RAM 紧张的 MCU 上不灵活。链表用多少申请多少,页面关了立刻释放,内存利用率高。而且链表的"头插法"天然就是一个栈——新页面插到头部当栈顶,关闭就摘掉栈顶露出下一个,返回逻辑零成本。
这是链表比数组香的关键:数组栈要预定义最大深度,链表栈深度随用随长。MCU 上 RAM 按字节抠,预定义大了浪费、小了溢出,链表把这个两难化解了。
数据结构:一个节点管一个页面
typedef void (*page_callback_t)(void *user_data);
typedef void (*page_event_handler_t)(uint8_t event, void *user_data);
typedef struct page_node {
struct page_node *next; /* 链表指针,指向下一个页面 */
const char *name; /* 页面名称,方便调试 */
page_callback_t on_create; /* 页面创建时调用 */
page_callback_t on_show; /* 页面显示时调用 */
page_callback_t on_hide; /* 页面隐藏时调用 */
page_callback_t on_destroy; /* 页面销毁时调用 */
page_event_handler_t on_event; /* 按键/触摸等事件处理 */
void *user_data; /* 页面私有数据,页面间交互载体 */
} page_node_t;
typedef struct {
page_node_t *top; /* 栈顶页面(当前显示的页面) */
uint8_t page_count;
} page_manager_t;
static page_manager_t g_page_mgr = { .top = NULL, .page_count = 0 };
重点看这个结构。next 指针把页面串成链表;四个生命周期回调(on_create/on_show/on_hide/on_destroy)管资源;on_event 处理按键触摸;user_data 是页面私有数据,也是页面间传数据的载体。
这套设计的关键是把"页面"抽象成一个带行为的节点,管理器只管链表操作和回调触发,不关心具体页面长啥样。页面自己实现回调,管理器统一调度,解耦干净。这其实就是上一篇事件驱动思路在 GUI 层的延伸——把页面也当成"事件 + 回调"来组织。

核心操作:push 和 pop
push 用头插法:先把当前栈顶隐藏,新页面插到头部成栈顶,再依次调 on_create、on_show。
int page_push(page_node_t *page, void *user_data)
{
if (page == NULL) return -1;
/* 先隐藏当前栈顶 */
if (g_page_mgr.top && g_page_mgr.top->on_hide) {
g_page_mgr.top->on_hide(g_page_mgr.top->user_data);
}
page->user_data = user_data;
page->next = g_page_mgr.top; /* 头插法:新页面成栈顶 */
g_page_mgr.top = page;
g_page_mgr.page_count++;
if (page->on_create) page->on_create(user_data);
if (page->on_show) page->on_show(user_data);
return 0;
}
pop 反过来:隐藏并销毁栈顶,栈顶指向下一个,恢复显示上一个页面(再调一次 on_show)。
int page_pop(void)
{
page_node_t *top = g_page_mgr.top;
if (top == NULL) return -1;
if (top->on_hide) top->on_hide(top->user_data);
if (top->on_destroy) top->on_destroy(top->user_data);
g_page_mgr.top = top->next;
top->next = NULL;
g_page_mgr.page_count--;
/* 恢复显示上一个页面 */
if (g_page_mgr.top && g_page_mgr.top->on_show) {
g_page_mgr.top->on_show(g_page_mgr.top->user_data);
}
return 0;
}
事件分发更简单,直接丢给栈顶页面的 on_event:
void page_dispatch_event(uint8_t event)
{
if (g_page_mgr.top && g_page_mgr.top->on_event) {
g_page_mgr.top->on_event(event, g_page_mgr.top->user_data);
}
}
注意 push 里"先隐藏旧的再创建新的"、pop 里"先销毁再恢复上一个"的顺序。这俩顺序是生命周期正确性的关键,反了会出现两个页面同时显示、或者资源没释放的 bug。我自己就踩过 pop 时先恢复上一个再销毁当前页的坑,结果新页面的 on_show 里访问的资源被当前页 on_destroy 释放了,直接野指针。
![页面生命周期回调时序:create→show→[hide→show]→hide→destroy](/https://i-blog.csdnimg.cn/direct/aba81995aa6b4732ae1aa273bb71424f.png)
数据交互:正向传参和反向传参
页面间传数据是 GUI 的老大难。这套方案用一个 user_data 字段搞定两个方向。
正向传参(打开页面时带数据):push 时把数据指针塞进 user_data,目标页面的 on_create 里取出来用。
typedef struct {
char ssid[32];
char password[64];
uint8_t signal_strength;
bool is_connected;
} wifi_cfg_t;
void on_home_enter_wifi(void)
{
static wifi_cfg_t wifi_data;
strcpy(wifi_data.ssid, "MyRouter_5G");
wifi_data.signal_strength = 75;
wifi_data.is_connected = true;
page_push(&page_wifi_setting, &wifi_data);
}
void wifi_page_on_create(void *user_data)
{
wifi_cfg_t *cfg = (wifi_cfg_t *)user_data;
label_set_text(lbl_ssid, cfg->ssid);
progressbar_set_value(bar_signal, cfg->signal_strength);
}
反向传参(子页面把结果带回父页面)是最巧妙的地方。子页面 pop 后,父页面的 on_show 会被重新触发。在这个时机去读子页面留下的数据就行——子页面把结果写到一个父页面能访问到的结构体里,on_show 里刷新 UI。
这个设计把"返回刷新"变成了生命周期回调的自然副产品,不用专门搞个回调注册机制,不用发事件,不用全局变量。父页面只要设计成"on_show 里重新读数据刷新",就自动支持子页面回传。这是我觉得这套方案最优雅的地方。

落地建议
第一,页面超 5 个再上这套,两三个页面 switch-case 更直白,别过度设计。第二,user_data 尽量传结构体指针别传零散变量,类型清楚好维护,别用 void* 一把梭然后到处强转。第三,on_create 申请的资源必须在 on_destroy 释放,配对写,别漏,漏一个就是内存泄漏。第四,反向传参靠 on_show 重新触发,父页面要设计成"可重复进入刷新"的,别假设只 on_create 一次,状态别在 on_show 里重置。第五,链表节点可以是静态分配(每个页面一个全局静态节点),不一定动态 malloc,RAM 可控还省去内存管理。
这套方案不依赖特定 GUI 库,LVGL、emWin 都能套,核心代码两三百行。它真正解决的不是"怎么画界面",而是"怎么让页面和数据有组织地流动"——这才是嵌入式 GUI 做到后面真正拼的东西。画界面是美工的事,组织好页面和数据才是工程师的活。
下一篇聊聊数组和链表的选型,把这对搭档的取舍讲透。
有用的话点个在看,让更多被 GUI 跳转逻辑绕晕的嵌入式工程师看到。
标签:嵌入式 链表 GUI 页面管理 数据结构

245

被折叠的 条评论
为什么被折叠?



