STM32F429上STemWin+FreeRTOS图形界面移植与按钮解耦
简介:面向STM32F429 DISC1开发板学习者与嵌入式GUI入门者,这是一份将STemwin图形库与FreeRTOS实时操作系统结合移植的完整工程,重点演示用GUIbuilder设计竖屏界面、生成两个按钮并绑定LED亮灭事件的处理流程。包内共530个文件,以h头文件、c源文件为主,辅以o、d、crf等编译中间文件及sct、uvproj等工程配置,压缩包约18.26MB,便于直接打开对照目录结构、理解GUI与任务调度如何协同。已有966人学习下载,说明该案例在开发板移植与事件驱动交互方面具有参考价值。读者可从中获得STemwin在STM32F429上的适配思路、FreeRTOS任务划分方式、按钮事件回调与硬件LED控制的连接方法,以及竖屏布局调整和工程编译排错线索,适合作为课程设计、毕业设计或自学嵌入式GUI的实操底稿。
1. stm32f429 DISC1 上的图形界面移植:先把「能显示」变成「能交付」
接手 stm32f429 DISC1 这块板子的人,第一反应通常是先把屏点亮,裸机跑得好好的,一旦挂上 FreeRTOS 就开始白屏、HardFault、按钮点了没反应。这套方案要解决的不是「能不能显示」,而是把 STemWin 图形库、FreeRTOS 任务模型、GUIbuilder 生成的按钮控件这三件事拼成一条能稳定交付的链路:两个按钮各绑一颗 LED,按下抬手的瞬间消息不乱、界面不卡、显存不炸。它适合正在做嵌入式方向的毕设、课程设计,或者要给一块带屏的开发板补一套人机界面的读者。后面按需求边界、资源规划、驱动移植、事件绑定、故障排查、验收答辩的顺序推进,每一步都落到能直接编译的代码上。
2. 选型与职责划分:STemWin、FreeRTOS 和板级驱动各管什么
2.1 三层的边界:谁负责画、谁负责调度、谁负责时序
移植失败的项目里,很大一部分不是代码写错,而是职责没分清。STemWin 是一套「被动式」的图形库——它自己不创建任何任务,也不会偷偷起中断,所有绘制、重绘、控件消息处理都要靠调用者在合适的时机去驱动GUI_Exec()。FreeRTOS 负责的是时间片和优先级,它不知道屏幕长什么样。板级驱动(BSP)负责的是 FMC 时序、SDRAM 初始化、GPIO 方向这些和芯片强相关的东西,跟图形库没有直接耦合。
把这三层摆正之后,任务划分就变得很自然:
| 层次 | 负责内容 | 不该做的事 |
|---|---|---|
| STemWin | 控件树、窗口管理、字体、重绘 | 不创建任务、不碰 GPIO |
| FreeRTOS | 任务调度、队列、互斥、延时 | 不直接操作显存 |
| BSP 驱动 | SDRAM、FMC 时序、LCD 背光、LED | 不调用 GUI 接口 |
| 应用层 | 把事件从 GUI 传到业务,把状态回写 GUI | 不在回调里做耗时操作 |
我一般会把它落成三个任务:GUI 任务负责GUI_Init()和周期性的GUI_Exec();输入任务负责触摸采样或者按键扫描;业务任务负责 LED、继电器这类执行机构。三者之间只通过队列和消息通信,谁也不直接调用谁的回调。这样做的好处是,换一块屏或者换一个执行器,改的永远只有一层。
2.2 显存与内存预算:240×320×2 字节到底放在哪
F429 的片上 SRAM 有 256KB,另加 64KB CCM。听起来不少,但一旦要跑 RTOS、要给 emWin 留动态内存池、还要放显存,很快就见底。以 240×320 的 RGB565 屏为例,一帧显存就是 240×320×2 = 150KB,单缓冲就已经吃掉一半多的 SRAM,双缓冲直接 300KB,片上根本放不下。
所以方案里的第一笔账必须算清楚,通常的做法是:显存放外部 SDRAM,emWin 的动态内存池也放 SDRAM,FreeRTOS 堆和任务栈留在内部 SRAM(因为中断和调度器对访问速度敏感)。
| 用途 | 典型大小 | 建议位置 | 说明 |
|---|---|---|---|
| 显存(单缓冲) | 150KB | SDRAM 起始地址 | 双缓冲翻倍到 300KB |
| emWin 内存池 | 48~96KB | SDRAM | 控件、字符串、字体位图 |
| FreeRTOS 堆 | 24~32KB | 内部 SRAM | heap_4,放任务栈和队列 |
| GUI 任务栈 | 1.5~2KB | 内部 SRAM | emWin 调用链较深 |
| 输入任务栈 | 512B~1KB | 内部 SRAM | 逻辑简单 |
| 业务任务栈 | 512B | 内部 SRAM | 只有 GPIO 操作 |
注意:CCM 虽然速度最快,但不能被 DMA 访问。如果后面打算上 DMA2D 做加速,emWin 内存池和显存都不能放 CCM,否则会出现「单步正常、全速花屏」这种很难查的现象。
显存地址还有个细节:LCD_SetVRAMAddrEx()传进去的地址最好按 4 字节对齐,甚至按 cache line 对齐。地址没对齐时,某些驱动版本会在最后一列或最后一行写出界,表现出来就是屏幕边缘一条竖线或者横线在闪。
2.3 工程模板与库文件:从 Cube 包到可编译的最小工程
STemWin 在 ST 的固件包里是以预编译库的形式提供的,没有源码。这意味着不能像普通中间件那样改内部实现,只能在配置层做文章。工程里真正需要动的文件其实只有三个:GUIConf.c、LCDConf.c和GUI_X(操作系统依赖层)。其余都是库文件和头文件。
搭最小工程时,我习惯按下面的顺序确认,少一步后面都要返工:
- 确认内核版本对得上:F429 是 Cortex-M4,必须选 CM4 对应的库文件,选成 CM3 或 CM7 版本链接阶段就会报一堆未定义符号。
- 确认编译器匹配:Keil、IAR、GCC 的库文件互不通用,混用会报 ABI 不兼容。
- 确认头文件路径里
GUI.h、WM.h、DIALOG.h都能找到,缺DIALOG.h时 GUIbuilder 生成的代码必然编译不过。 - 确认 FreeRTOS 的
FreeRTOSConfig.h里configUSE_MUTEXES至少为 1,后面要用到互斥量。 - 先编译一个空的
main,确认链接通过、能进while(1),再往里加GUI_Init()。
这一步看起来啰嗦,但比在几千行代码里找链接错误省时间得多。很多「移植不成功」的案例,最后发现是库文件和内核版本不匹配,跟代码一点关系都没有。
3. 移植落地:GUI 内存池、LCD 驱动与 RTOS 任务骨架
3.1 GUIConf.c:内存池大小与 GUI_OS 开关
GUIConf.c决定 emWin 能用多少动态内存。库内部所有控件、字符串、字体缓存都从这个池子里分配,池子给少了会在创建窗口时返回 0 句柄,界面上什么都不显示;给多了又挤占其他区域。
/* GUIConf.h —— 全局配置,只改这几行 */ #define GUI_OS (0) /* 单 GUI 任务,不开启内部多任务保护 */ #define GUI_SUPPORT_TOUCH (1) /* 用触摸就打开,纯按键可以关掉 */ #define GUI_DEFAULT_FONT &GUI_Font16_1 #define GUI_NUM_LAYERS (1) /* 只做一层,省内存 */ /* GUIConf.c —— 内存池分配 */ #define GUI_NUMBYTES (64 * 1024) /* 64KB,两个按钮加一个窗口完全够 */ static U32 aMemory[GUI_NUMBYTES / 4]; /* 4 字节对齐的静态数组 */ void GUI_X_Config(void) { /* 把池子交给 emWin 管理,必须在 GUI_Init 之前完成 */ GUI_ALLOC_AssignMemory(aMemory, GUI_NUMBYTES); /* 小块碎片控制:控件创建/删除频繁时能显著降低碎片率 */ GUI_ALLOC_SetAvBlockSize(128); }GUI_OS这个宏很关键。设成 0 表示所有 GUI 调用都发生在同一个任务里,库内部不加锁,速度快、代码小;设成 1 则会调用GUI_X_Lock/GUI_X_Unlock,允许多任务同时操作控件,但每次绘制都要抢一次互斥量,开销明显上升。这套方案里只有 GUI 任务碰控件,输入任务只往队列里发数据,所以GUI_OS保持 0 就够了。什么时候必须开 1?当你有第二个任务直接调用WM_SetText、BUTTON_SetText这类接口时,不开就会偶发死机,而且极难复现。
GUI_ALLOC_SetAvBlockSize(128)是按经验给出的值。默认块大小在频繁创建销毁窗口的场景下容易把池子切得很碎,虽然单次分配都能成功,但总容量不知不觉就掉下去了。设成 128 字节后,碎片出现的时间点会明显推后。
3.2 LCDConf.c:显存地址、颜色转换和驱动型号
LCDConf.c是移植里改动最大的一处。ST 提供的模板是给评估板用的,换分辨率、换显存位置都要从这里入手。核心思路只有三件事:选对驱动型号、告诉驱动屏幕多大、告诉驱动显存在哪。
/* LCDConf.c —— 保留 ST 模板结构,只改标注的四处 */ #define XSIZE_PHYS 240 /* ① 列数,必须和屏的实际分辨率一致 */ #define YSIZE_PHYS 320 /* 行数 */ #define COLOR_CONVERSION GUICC_M565 /* ② RGB565,和屏的像素格式对齐 */ #define DISPLAY_DRIVER GUIDRV_LIN_16/* ③ 线性显存驱动,16bpp 选 _16 */ #define VRAM_ADDR (0xD0000000UL) /* ④ 显存放在 SDRAM 首地址 */ static void _InitController(void) { /* 背光、复位脚已经由 BSP 初始化,这里只做驱动需要的空实现 */ } /* 线性驱动的「控制器地址」回调,返回 FMC 映射的 LCD 数据口 */ static void * _GetVRAMAddr(void) { return (void *)VRAM_ADDR; } void LCD_X_Config(void) { /* 创建并链接显示设备,这行必须在所有 LCD_SetXxx 之前 */ GUI_DEVICE_CreateAndLink(DISPLAY_DRIVER, COLOR_CONVERSION, 0, 0); /* 告诉驱动物理尺寸,和显存里一行的宽度对应 */ LCD_SetSizeEx(0, XSIZE_PHYS, YSIZE_PHYS); LCD_SetVSizeEx(0, XSIZE_PHYS, YSIZE_PHYS); /* 把显存地址指到 SDRAM,不再用库默认的片上缓冲 */ LCD_SetVRAMAddrEx(0, (void *)VRAM_ADDR); /* 注册控制器初始化回调和地址回调 */ LCD_SetDevFunc(0, LCD_DEVFUNC_INIT, (void (*)(void))_InitController); LCD_SetDevFunc(0, LCD_DEVFUNC_CONTRADDRESS, (void (*)(void))_GetVRAMAddr); }LCD_SetVRAMAddrEx是整段代码里最容易出错的一行。地址写错的表现不是编译报错,而是上电后屏幕一片雪花或者纯色,用调试器看内存又确实写进去了数据。遇到这种情况,先确认 SDRAM 的映射区间,再把VRAM_ADDR打印出来和链接脚本里的地址比一遍。
GUIDRV_LIN_16里的LIN指线性显存,意思是显存在内存里是连续的、按行排列的,驱动直接往这块内存写像素,再由 LCD 控制器(F429 上是 LTDC,或者外挂的并口屏控制器)读走。这个模式对内存带宽要求高,所以显存放在 SDRAM 之后要留意 SDRAM 的时钟配置——时钟给低了会看到刷新跟不上,界面切换时有明显的从上往下刷的痕迹。
还有一个容易忽略的点:XSIZE_PHYS和YSIZE_PHYS必须和 GUIbuilder 里画的对话框尺寸一致。GUIbuilder 里做的窗口是 240×320,而LCDConf.c里写成 320×240,编译没问题,运行时窗口会被裁掉一半或者出现滚动条,很多人会误以为是控件坐标算错了。
3.3 GUI_X 对接:把 emWin 的延时和时间戳接到 FreeRTOS 上
emWin 需要几个操作系统相关的函数才能跑在 RTOS 上。ST 的模板里给了裸机版本,用的是空循环和 SysTick 计数,直接拿来跑 FreeRTOS 会让低优先级任务饿死。
/* GUI_X_FreeRTOS.c —— emWin 的 OS 依赖层,GUI_OS = 0 时只需这四个 */ #include "GUI.h" #include "FreeRTOS.h" #include "task.h" /* emWin 用它做超时判断和动画计时,必须是单调递增的毫秒数 */ int GUI_X_GetTime(void) { return (int)(xTaskGetTickCount() * portTICK_PERIOD_MS); } /* 库内部等延时的时候调用,必须让出 CPU */ void GUI_X_Delay(int Period) { if (Period > 0) { vTaskDelay(pdMS_TO_TICKS(Period)); } } /* GUI_Exec 没有重绘任务时回调这里,让出一个节拍 */ void GUI_X_ExecIdle(void) { vTaskDelay(1); } /* 目前没有需要初始化的资源,保留空实现,方便以后开启 GUI_OS 时加锁 */ void GUI_X_Init(void) { }这里有个血泪经验:GUI_X_Delay千万别写成for循环空转。emWin 在GUI_Delay()里会反复调用它,空转的话 CPU 占用率直接拉满,串口打印都会丢字符。改成vTaskDelay之后,GUI 任务在等待期间主动让出 CPU,其他任务的响应立刻正常。
四个函数接好之后,任务骨架就可以搭起来了。注意GUI_Init()的位置——它必须在调度器启动之后、在 GUI 任务里调用,原因在第 5 章会展开。
/* app_tasks.c —— 三个任务,各司其职 */ static QueueHandle_t s_ledQueue; void GuiTask(void *pv) { GUI_Init(); /* 必须在任务里初始化,不能放 main */ CreateMainDialog(); /* GUIbuilder 生成的入口函数 */ for (;;) { GUI_Exec(); /* 处理重绘和控件消息,非阻塞 */ vTaskDelay(pdMS_TO_TICKS(10)); /* 10ms 一轮,相当于 100Hz 上限 */ } } void InputTask(void *pv) { for (;;) { GUI_TOUCH_Exec(); /* 触摸采样,纯按键方案换成按键扫描 */ vTaskDelay(pdMS_TO_TICKS(20)); /* 50Hz 采样率,够用且不浪费 CPU */ } } void LedTask(void *pv) { uint8_t cmd; for (;;) { /* 阻塞等队列,没有事件时完全不占 CPU */ if (xQueueReceive(s_ledQueue, &cmd, portMAX_DELAY) == pdTRUE) { switch (cmd) { case LED_CMD_1: BSP_LED_Toggle(1); break; case LED_CMD_2: BSP_LED_Toggle(2); break; default: break; } } } }GUI_Exec()的调用周期直接决定界面流畅度。10ms 一轮意味着最快 100Hz 的重绘节拍,按钮按下的高亮反馈基本是即时的;如果图省事写成 100ms,按下去要等小半秒才有反应,评委会第一眼就看出不对。但也不能不加延时地死循环调用,那样 GUI 任务会一直占着 CPU,串口和按键任务都拿不到时间片。
4. GUIbuilder 出图与按钮事件:两个控件怎么控制两颗 LED
4.1 在 GUIbuilder 里画对话框:尺寸、控件和 ID 命名
GUIbuilder 是 emWin 自带的所见即所得工具,输出的是一个 C 文件,里面包含控件资源表和回调函数骨架。用它做两个按钮的界面,十分钟能出图,但有几个地方必须提前定好,否则后面改起来很麻烦。
第一步是新建对话框时把尺寸设成 240×320,和LCDConf.c里的宏保持一致。第二步是从控件栏拖两个 BUTTON 上去,位置分别放在上半区和下半区,大小做到手指或鼠标都容易点中,宽度 160、高度 60 是比较稳妥的比例。第三步是改 ID:默认的ID_BUTTON_0、ID_BUTTON_1虽然能跑,但回调里判断起来毫无可读性,直接改成ID_BTN_LED1、ID_BTN_LED2,后面维护的人一眼就知道对应关系。第四步是改按钮文字,如果是英文,用默认字体就能显示;如果一定要中文,得先确认字体文件里包含对应字模,否则屏幕上出现的是一串方块或者空白。
保存成.c文件之后,得到的资源表大概长这样:
/* MainDialog.c —— GUIbuilder 导出的资源表,改了 ID 和文字 */ static const GUI_WIDGET_CREATE_INFO _aDialogCreate[] = { /* 创建函数 文字 ID x y w h 标志 */ { WINDOW_CreateIndirect, "MainWin", ID_WINDOW_MAIN, 0, 0, 240, 320, 0, 0x0, 0 }, { TEXT_CreateIndirect, "LED Ctrl",ID_TEXT_TITLE, 40, 12, 160, 30, 0, 0x0, 0 }, { BUTTON_CreateIndirect, "LED1", ID_BTN_LED1, 40, 80, 160, 60, 0, 0x0, 0 }, { BUTTON_CreateIndirect, "LED2", ID_BTN_LED2, 40, 180, 160, 60, 0, 0x0, 0 }, };资源表里每个字段的含义是固定的:创建函数指针、显示文字、控件 ID、左上角坐标、宽高、创建标志、附加参数、额外字节数。创建标志填 0 就是普通按钮,填WM_CF_SHOW之类会改变显示行为,初学者不要乱填。附加参数和额外字节数一般保持 0,除非要给控件挂自定义数据。
4.2 生成代码接进工程:函数改名与回调改造
GUIbuilder 生成的代码不能原样塞进工程,有两个地方必须改。一是函数名,默认的CreateWindow()太通用,工程里只要还有第二个对话框就会重名;二是回调函数里的分支,默认只给了空壳,需要自己往里填业务逻辑。
/* 改名后的入口,供 GUI 任务调用 */ WM_HWIN CreateMainDialog(void) { WM_HWIN hWin; /* 第三个参数是父窗口,0 表示桌面窗口;第四个是回调函数指针 */ hWin = GUI_CreateDialogBox(_aDialogCreate, GUI_COUNTOF(_aDialogCreate), _cbMainDialog, 0, 0, 0); return hWin; }回调函数是事件处理的中枢,所有按钮动作都在这里被识别。写的时候要记住一件事:这个函数运行在 GUI 任务的上下文里,它执行多久,整个界面就冻结多久。
/* 回调:只做识别和转发,绝不做耗时操作 */ static void _cbMainDialog(WM_MESSAGE *pMsg) { int nCode, id; switch (pMsg->MsgId) { case WM_INIT_DIALOG: /* 对话框创建完成,此时才能安全地按句柄取控件 */ WM_SetFocus(WM_GetDialogItem(pMsg->hWin, ID_BTN_LED1)); break; case WM_NOTIFY_PARENT: id = WM_GetId(pMsg->hWinSrc); /* 事件来自哪个控件 */ nCode = pMsg->Data.v; /* 具体是什么事件 */ /* 用 RELEASED 而不是 CLICKED,长按不会连发,手感更接近真实按键 */ if (nCode == WM_NOTIFICATION_RELEASED) { uint8_t cmd = 0; if (id == ID_BTN_LED1) cmd = LED_CMD_1; if (id == ID_BTN_LED2) cmd = LED_CMD_2; if (cmd != 0) { /* 超时传 0:回调里不能等,队列满就丢弃这一次 */ xQueueSend(s_ledQueue, &cmd, 0); } } break; default: /* 漏掉这一行,窗口重绘、焦点切换、拖动都会出问题 */ WM_DefaultProc(pMsg); break; } }pMsg->MsgId里最常打交道的两个值是WM_INIT_DIALOG和WM_NOTIFY_PARENT。前者在对话框创建完成后触发一次,适合改字体、设初始值、设焦点;后者是子控件向父窗口通报事件,按钮按下、抬起、文本变化都走这里。pMsg->hWinSrc是事件源句柄,WM_GetId()把它转成资源表里定义的 ID,这一步是识别按钮的关键。
4.3 事件解耦:回调只发队列,LED 由独立任务执行
很多人图省事,直接在回调里写BSP_LED_Toggle(1),简单 demo 里确实能跑。但只要 LED 那边加了呼吸效果、加了几百毫秒的延时,或者以后换成继电器、电机,界面立刻变卡——因为回调是在 GUI 任务里同步执行的,它阻塞多久,重绘就停多久。
解耦的做法就是前面代码里那三行:回调只往队列里丢一个命令字,真正的 GPIO 操作交给独立的 LedTask。这样做的好处很实际:
- 回调的执行时间从毫秒级降到微秒级,界面永远跟手;
- LED 侧可以随便加逻辑,不影响 UI 帧率;
- 以后要加第三个、第四个按钮,只需要扩命令字,不用改任务结构;
- 单步调试时可以单独停掉 GUI 任务,验证 LED 逻辑是否独立正确。
如果板子上没有触摸屏,只有几个独立按键,输入入口换成GUI_StoreKeyMsg()即可,把键值映射成GUI_KEY_ENTER或者方向键,让焦点在按钮之间移动、回车触发。触摸和按键两种方案最终都汇到同一个消息链上,业务层代码一行都不用改。这也是为什么第 2 章强调职责边界——输入方式可以换,事件出口保持不变。
5. 避坑与排查:移植路上最容易翻车的四个点
5.1 上电白屏或直接 HardFault:GUI_Init 放错位置
现象很典型:程序下载进去,屏幕背光亮着但没有任何内容,调试器一跑就停在 HardFault 处理函数里,调用栈顶上是GUI_Init。或者有的场合能进main的while(1),但 GUI 任务一启动就死。
原因在于GUI_Init()内部会调用操作系统依赖层。如果GUI_X_Delay被移植成vTaskDelay,而GUI_Init()又写在main里、调度器还没启动,那么vTaskDelay里的断言会直接把系统挂住。裸机版本感觉不到问题,是因为同步换成了空循环。
解决方式只有一个:把GUI_Init()放到 GUI 任务的第一行,在调度器启动之后执行。同时检查main里的顺序——外设初始化、SDRAM 初始化、创建队列和任务、vTaskStartScheduler(),中间不能提前调用任何 GUI 接口。实在需要在初始化阶段配置控件,用WM_CreateTimer或者等WM_INIT_DIALOG消息,别在main里硬来。
5.2 花屏、撕裂、只显示一半:SDRAM 时序和显存对齐
现象是屏幕上半部分正常、下半部分雪花,或者切换界面时能看到明显的从左到右的刷新痕迹,再严重一点,颜色整体偏红或偏蓝。
原因通常有三个。一是 SDRAM 根本没初始化或者时序参数不对——F429 上的 SDRAM 时钟由 HCLK 分频得到,行刷新计数、CAS 延迟这类参数必须按芯片手册算,抄别的板子的参数很容易翻车。二是显存地址没对齐,LCD_SetVRAMAddrEx传了个非 4 字节倍数的地址,驱动按 16 位写像素时就会错位。三是颜色转换配错,GUICC_M565配了GUIDRV_LIN_16是 RGB565,如果屏实际是 BGR 排列,颜色就会整体偏移。
排查顺序建议固定下来:先写一个 SDRAM 读写测试,往首地址和末尾地址各写一个特征值再读回,对不上就先把时序问题解决;然后打印VRAM_ADDR确认对齐;最后用纯色填充测试(GUI_SetBkColor+GUI_Clear)验证颜色通道,红蓝反了就换GUICC_M888或者调整驱动型号。
5.3 创建窗口后随机死机:内存池和任务栈超了
现象是第一个窗口正常,创建第二个、或者关闭再打开几次之后,系统随机卡死,重启后又正常,很难复现。
原因有两类。一类是 emWin 的GUI_NUMBYTES给得太大,把内部 SRAM 吃光,FreeRTOS 的堆和任务栈没地方分配,xQueueCreate返回 NULL 没人检查,后面一用就炸。另一类是 GUI 任务的栈太小,emWin 的调用链是GUI_Exec→WM_Exec→ 控件回调,中间还有重绘和字体渲染,1KB 栈很容易溢出。
解决方式是把内存账算清楚,然后加自检。GUI 任务栈先给 2KB,跑起来之后每 5 秒用uxTaskGetStackHighWaterMark(NULL)看水位,如果剩余不到 25%,就往上加。emWin 池子用GUI_ALLOC_GetNumFreeBytes()观察,如果长时间运行后持续下降,说明有窗口或控件没删,属于内存泄漏,要回头查创建和销毁是否配对。
5.4 按钮点了没反应:消息链断在哪一段
现象是按钮按下时有高亮反馈(说明控件本身活着、触摸或按键也进去了),但 LED 一点动静都没有。
原因几乎都在消息链上。常见的有三种:一是回调函数里default分支漏了WM_DefaultProc(pMsg),父窗口收不到WM_NOTIFY_PARENT;二是WM_GetId(pMsg->hWinSrc)取到的是父窗口 ID,而不是按钮 ID,于是所有if都不成立;三是资源表里的 ID 重复,两个按钮用了同一个 ID,回调永远只认第一个。
排查办法很土但有效:在回调开头把pMsg->MsgId、WM_GetId(pMsg->hWinSrc)、pMsg->Data.v三个值打到串口,按一次按钮看一次输出。正常情况下应该看到MsgId = WM_NOTIFY_PARENT、ID 等于资源表里定义的值、NCode 等于WM_NOTIFICATION_CLICKED后跟RELEASED。缺哪一项就顺着往上查——收不到WM_NOTIFY_PARENT查回调挂载,ID 不对查资源表,NCode 不对查控件是否被禁用。
6. 验收与答辩:用六个可复现的测试证明这套界面真的跑起来了
移植完能点亮屏幕只是开始,验收要证明的是「稳定」和「可维护」。我一般会准备一张验收表,每一项都能当场复现,评委提问时直接翻表,比口头描述可信得多。
| 验收项 | 操作方法 | 通过标准 |
|---|---|---|
| 任务栈水位 | 串口每 5 秒打印一次高水位 | GUI 任务剩余栈 > 25% |
| emWin 内存 | 打印GUI_ALLOC_GetNumFreeBytes() | 连续操作 30 分钟后不持续下降 |
| 事件响应 | 示波器测按钮沿到 GPIO 翻转 | 延迟 < 50ms |
| 刷新稳定 | 连续点击两个按钮 200 次 | 无花屏、无残影、无卡顿 |
| 长稳运行 | 上电跑 2 小时不干预 | 不复位、不进 HardFault |
| 可移植性 | 只改XSIZE_PHYS/YSIZE_PHYS宏 | 业务代码零改动,界面自动适配 |
自检代码不用写得多复杂,放在 GUI 任务里挂个软件定时器就够:
/* 健康检查:每 5 秒输出一次,验收时直接看串口 */ static void vHealthReport(void *pv) { for (;;) { printf("heap free : %u\r\n", (unsigned)xPortGetFreeHeapSize()); printf("gui stack : %u words left\r\n", (unsigned)uxTaskGetStackHighWaterMark(NULL)); printf("emwin free : %d bytes\r\n", GUI_ALLOC_GetNumFreeBytes()); vTaskDelay(pdMS_TO_TICKS(5000)); } }答辩时真正拉开差距的不是界面多花哨,而是能不能把三个「为什么」讲清楚:为什么 GUI 用单任务而不是多任务,为什么显存放 SDRAM 而不是片上 SRAM,为什么按钮事件走队列而不是直接操作 GPIO。这三个问题背后分别是并发安全、内存预算和响应延迟,答得出来说明真的调过,答不出来说明只是照着教程抄了一遍。
还有一个我常用的演示技巧:提前把GUI_Init()故意挪到main里,现场演示一次白屏,再用调试器指出卡在哪个函数,然后改回来恢复正常。这种「故障复现 + 定位 + 修复」的完整链条,比任何 PPT 上的架构图都有说服力。
从那以后我每次做带屏项目,都会在第一次上电前先把内存账算一遍、把GUI_Init的位置和任务划分写在纸上核对,宁可多花半小时,也不愿在评审前一天晚上对着花屏的板子熬夜。希望这套流程能帮你少走几段弯路。
本文还有配套的精品资源,点击获取