STM32F429上STemWin与FreeRTOS按钮事件队列化

发布时间:2026/10/26 4:57:37
STM32F429上STemWin与FreeRTOS按钮事件队列化

简介:这是一份面向具备一定嵌入式基础的开发者的STM32F429 DISC1实战工程,聚焦STemwin图形库与FreeRTOS在该开发板上的联合移植,并通过GUIbuilder生成两个按钮事件控制LED亮灭,适合学习GUI事件驱动、任务调度与竖屏界面适配。压缩包共530个文件、约18.26MB,含176个h头文件、102个c源文件以及o、d、crf等编译中间文件,并附uvproj工程、sct分散加载文件、hex/axf产物和readme说明,便于直接打开与对照分析。目前已有966人学习下载。工程保留完整的LCD与触摸屏配置、GUIbuilder界面代码和FreeRTOS任务框架,可帮助读者理解按钮回调如何联动硬件、竖屏布局如何调整,以及STemwin移植中的内存分配与驱动适配思路,适合作为课程设计、项目原型和移植排错的参考。

1. 两个按钮背后的真问题:为什么裸机跑得通的 GUI,接了 RTOS 就卡住

带触摸屏的 STM32F429 DISC1 开发板拿来做毕设,绝大多数人卡住的地方不是点亮屏幕,而是把 STemWin 和 FreeRTOS 塞进同一块板子之后,两个按钮的事件到底该由谁来处理。裸机时代在GUI_Delay里塞个死循环也能转,一旦接了 FreeRTOS,GUI 任务在等触摸消息、LED 任务在等队列,调度器一抢占,界面就定住了。GUIbuilder 生成的按钮回调默认是在 GUI 任务的上下文里执行的,你在这个回调里点个灯还好,一旦要做延时、要发信号量、要等队列,整个绘图线程就被自己挂起来了。这套方案要解决的就是这条链路:触摸按下 → STemWin 识别到按钮点击 → 回调把事件投进队列 → LED 任务取出来翻转 PG13/PG14,全过程不阻塞绘图。适合正在做嵌入式毕设、课程设计,或者要把一块现成评估板改成带界面的小产品原型的同学。

2. 板级准备与选型:LTDC、SDRAM、STemWin、FreeRTOS 四者怎么排座次

2.1 显示通路:为什么显存必须放在 SDRAM 而不是内部 SRAM

STM32F429 的内部 SRAM 是 256KB,其中 64KB 还是 CCM 区,CPU 之外的总线根本访问不到。一块 240×320 的 RGB565 帧缓冲是 240×320×2 = 153600 字节,差不多 150KB,光一帧就把可用 SRAM 吃掉大半。要是再开双缓冲做无闪烁刷新,300KB 直接超了。所以这块板子上,STemWin 的显存、绘制缓存、窗口存储区,通通得放到板载的那颗 8MB SDRAM 里,这是硬约束,不是优化选项。

LTDC 控制器的取数地址必须指向 SDRAM 的某个固定偏移,而 SDRAM 又要靠 FMC 控制器配一堆时序参数才能稳定工作。这两个东西的初始化顺序不能反:先 FMC 时钟和 GPIO 复用,再 SDRAM 上电时序(预充电、刷新计数、模式寄存器),最后才是 LTDC 层配置。我一般会在 SDRAM 初始化之后立刻插一段读写测试,全片写 0x55AA55AA 再逐个读回比对,8MB 扫一遍,通过了才往下走。这一步不做,后面花屏你根本分不清是 SDRAM 时序没配对还是 LTDC 参数算错了。

/* bsp_lcd.h —— 显存与 GUI 缓存在 SDRAM 中的地址划分 */ #define SDRAM_BANK_ADDR ((uint32_t)0xC0000000) /* FMC Bank2 起始 */ #define LCD_FRAME_BUFFER ((uint32_t)0xC0000000) /* LTDC 层1 显存 */ #define GUI_RAM_ADDR ((uint32_t)0xC0200000) /* STemWin 动态内存池 */ #define GUI_RAM_SIZE (1024 * 1024) /* 划 1MB 给 GUI 用 */

LCD_FRAME_BUFFER占了前 2MB,足够放两层显存;从 0xC0200000 开始划 1MB 给 STemWin 当堆,用GUI_Init()之前的GUI_SetDefaultFont那句之前调GUI_AssignRAM或者直接改GUIConf.c里的GUI_NUMBYTES,把内存池指过去。剩下的 5MB 留着以后放字库、图片资源,别一次吃干净。

LTDC 的时序参数是最容易翻车的一环。240×320 面板的典型值大概是 HSYNC 宽度 10、HBP 20、HFP 10,VSYNC 宽度 2、VBP 2、VFP 4,但具体得翻你手上那块屏的手册。参数对不上不会报错,只会给你一屏慢慢滚动的条纹,很多同学以为是代码问题,其实是消隐区算错了。

/* ltdc_layer.c —— 层配置的关键字段,寄存器级细节按参考手册补齐 */ LTDC_LayerCfgTypeDef layer = {0}; layer.WindowX0 = 0; layer.WindowX1 = 240; layer.WindowY0 = 0; layer.WindowY1 = 320; layer.PixelFormat = LTDC_PIXEL_FORMAT_RGB565; layer.FBStartAdress = LCD_FRAME_BUFFER; /* 指向 SDRAM,不能是内部 SRAM */ layer.Alpha = 255; /* 不透明 */ layer.Alpha0 = 0; layer.BlendingFactor1 = LTDC_BLENDING_FACTOR1_CA; layer.BlendingFactor2 = LTDC_BLENDING_FACTOR2_CA; layer.ImageWidth = 240; layer.ImageHeight = 320; HAL_LTDC_ConfigLayer(&hltdc, &layer, 0);

Alpha给 255 是因为单层显示不需要混合,给了混合因子反而增加带宽开销。ImageWidth是显存里一行的像素数,不是屏幕宽度,如果你为了对齐做了 256 字节的行宽,这里要跟着改,否则画面会斜着撕裂。

2.2 职责边界:STemWin 管画图,FreeRTOS 管调度,谁也别越界

这套组合最容易出的架构性问题,是让 FreeRTOS 和 STemWin 互相插手对方的地盘。STemWin 在默认配置下不是线程安全的,WM_开头的函数、GUI_开头的绘图函数,都必须在同一个任务里调用,一旦有两个任务同时调WM_SendMessage,窗口管理器内部链表就废了。所以规矩要提前定死:所有 GUI 操作只允许在 GUI 任务里发生,其它任务想改界面,只能通过队列或者WM_SendMessage这种间接手段。

反过来,STemWin 也不该直接碰硬件。它的 OS 抽象层GUI_X_*.c里那几个空函数,是专门留给你接 RTOS 的。GUI_X_Delay要接到vTaskDelay,GUI_X_GetTime要接到xTaskGetTickCount,GUI_X_ExecIdle必须真的让出 CPU。这几个函数对接错了,表现就是系统看着能跑,但 CPU 占用率 100%,LED 任务永远轮不上。

/* GUI_X_RTOS.c —— STemWin 与 FreeRTOS 的对接层,四个函数缺一不可 */ #include "FreeRTOS.h" #include "task.h" #include "GUI.h" int GUI_X_GetTime(void) { /* 返回毫秒计数,STemWin 内部超时、动画都靠它 */ return (int)(xTaskGetTickCount() * portTICK_PERIOD_MS); } void GUI_X_Delay(int ms) { /* 必须真正挂起,不能是空循环 */ vTaskDelay(pdMS_TO_TICKS(ms)); } void GUI_X_ExecIdle(void) { /* GUI_Exec 无事可做时调用,至少让出 1 个 tick, 否则 GUI 任务会以最高优先级空转,饿死低优先级任务 */ vTaskDelay(1); } void GUI_X_ErrorOut(const char *s) { (void)s; taskDISABLE_INTERRUPTS(); for (;;) { } /* 出错停在这里,方便调试器抓现场 */ }

GUI_X_ExecIdle这一句vTaskDelay(1)是整个对接里性价比最高的改动。少了它,GUI 任务在空闲时就是死循环调用GUI_Exec,同优先级或更低优先级的 LED 任务一个时间片都拿不到,你会以为是 LED 代码写错了,其实是调度器根本没机会切过去。

FreeRTOS 这边,堆方案建议用heap_4,它带相邻空闲块合并,长期运行不容易碎。configTOTAL_HEAP_SIZE给 48KB 起步,队列、任务栈、定时器都从这里出。

/* FreeRTOSConfig.h —— 与本方案相关的关键项 */ #define configUSE_PREEMPTION 1 #define configUSE_TIME_SLICING 1 #define configMAX_PRIORITIES 7 #define configMINIMAL_STACK_SIZE ( 128 ) #define configTOTAL_HEAP_SIZE ( ( size_t ) ( 48 * 1024 ) ) #define configCHECK_FOR_STACK_OVERFLOW 2 /* 方法2:检查栈末魔数 */ #define configUSE_MALLOC_FAILED_HOOK 1

configCHECK_FOR_STACK_OVERFLOW设成 2 而不是 1,是因为方法 2 会检查栈末尾的填充字节有没有被改写,能提前发现"差一点就溢出"的临界状态,比方法 1 只看栈指针位置更灵敏。代价是每次任务切换多几条指令,对这个项目完全无所谓。

2.3 工程骨架:从裸机例程改造成 RTOS 工程的四个动作

拿到的裸机工程通常是一个大main.c,里面while(1)里刷屏。改造成 RTOS 工程,动作可以拆成四步,顺序别乱。

第一步,把 SDRAM、LTDC、触摸、LED 四块硬件初始化抽成独立的 BSP 文件,每个文件只暴露BSP_XXX_Init()和少量操作函数。第二步,把main()精简成只做时钟、外设、内核启动,所有业务逻辑搬到任务里。第三步,建App/目录放任务函数和事件定义,建GUIbuilder/目录放生成代码,两者不互相包含头文件,只通过事件枚举通信。第四步,改启动文件和中断优先级分组,HAL_Init()里把NVIC_PRIORITYGROUP_4设好,因为 FreeRTOS 要求所有优先级位都给抢占优先级用。

目录结构大概是这样:

/Drivers bsp_sdram.c / .h SDRAM 初始化与自检 bsp_ltdc.c / .h LTDC 层配置、背光 bsp_led.c / .h PG13 / PG14 推挽输出 bsp_touch.c / .h I2C 触摸控制器读点 /Middlewares FreeRTOS/Source STemWin/Lib CM4 内核版本的库文件 STemWin/Config GUIConf.c、LCDConf.c、GUI_X_RTOS.c /App app_tasks.c / .h GUI 任务、LED 任务 app_event.h 事件枚举与队列句柄声明 /GUIbuilder GUIBuilder.c / .h GUIbuilder 生成的窗口与回调

编译选项上有个小坑:勾了 MicroLIB 之后sprintf之类的函数行为会变,STemWin 内部有些格式化代码依赖标准库,一般建议不勾,改用标准 C 库,代价是 Flash 多几 KB,对 2MB 的 F429 来说不算事。优化等级用-O1比较稳,-O2有时会把 GUI 回调里的局部变量优化掉,调试时看到的值全是乱的。

3. 总体架构设计:四层结构与三个任务之间的通信链路

3.1 分层:硬件层、BSP 层、中间件层、应用层各管什么

整个系统我习惯切成四层,每层只认下一层的接口,越界调用一律视为设计错误。最底下是硬件层,F429 芯片、8MB SDRAM、240×320 面板、两颗用户 LED 和触摸控制器。往上是 BSP 层,负责把这些硬件包装成BSP_LED_Toggle()、BSP_Touch_Read()、BSP_LCD_Init()这样的函数,屏蔽寄存器细节。再往上是中间件层,FreeRTOS 提供任务与队列,STemWin 提供窗口管理与绘图,两者通过GUI_X_RTOS.c这一层薄薄的胶水粘起来。最上面是应用层,只有两个任务和一个队列,加一个事件枚举。

这么分的收益在调试阶段特别明显。界面花了,你只需要在 BSP 层和中间件层之间划线;按钮没反应,先看应用层的事件有没有投出去,再看 GUI 层的回调有没有被调用,责任边界清楚,不用满工程撒网。很多同学把 LED 操作直接写进 GUIbuilder 生成的回调里,刚开始能跑,等到要加第三个按钮、要加蜂鸣器、要加串口上报,代码就彻底糊成一团了。

层与层之间的数据流是单向的:触摸中断 → BSP 读点 → STemWin 触摸层 → 窗口管理器派发 → 按钮回调 → 应用队列 → LED 任务 → BSP 写引脚。反方向只有一条,就是 LED 任务偶尔要改界面上一个状态文本,这时候也是通过WM_SendMessage把消息丢回 GUI 任务,绝不在 LED 任务里直接调TEXT_SetText。

3.2 事件模型:按钮回调只投队列,不碰 GPIO

应用层和 GUI 层之间的契约就是一个小小的枚举。GUIbuilder 生成的代码知道按钮 ID,但它不该知道"按钮 1 对应绿灯"这种业务含义;LED 任务知道怎么点灯,但它不该认识WM_NOTIFICATION_CLICKED这种 GUI 消息。中间用枚举隔开,两边都能独立演化。

/* app_event.h —— 应用层唯一认识的事件类型 */ typedef enum { LED_EVT_NONE = 0, LED_EVT_TOGGLE_GREEN, /* 按钮 1:翻转 LD3(PG13) */ LED_EVT_TOGGLE_RED, /* 按钮 2:翻转 LD4(PG14) */ LED_EVT_ALL_OFF /* 预留:长按或组合键全部熄灭 */ } LedEvent_t; extern QueueHandle_t xLedQueue; /* 定义在 app_tasks.c */

队列长度给 8 就够,深度太大会掩盖"消费不过来"的问题,太小又容易在快速连点时丢事件。xQueueSend的超时参数在 GUI 回调里必须给 0,因为回调运行在 GUI 任务上下文,一旦阻塞,整个界面就冻住。

这套模型的额外好处是可测试。你可以在没有触摸屏的情况下,用串口命令往xLedQueue里塞事件,单独验证 LED 任务;也可以把 LED 任务换成一个只打印日志的空壳,单独验证 GUI 回调。两块分开调通了再合,比一上来就联调省一半时间。

3.3 栈与堆的预算:给 GUI 任务留够 4KB 的理由

任务栈是最容易估少的地方。STemWin 在GUI_Exec里会递归调用窗口回调,回调里又可能调WM_GetDialogItem、BUTTON_GetText这些,每一层都吃栈。再加上字库渲染时的临时缓冲,GUI 任务的栈峰值经常到 2KB 以上。我给的经验值是 GUI 任务 1024 个字(4KB),LED 任务 256 个字(1KB),空闲任务用默认的 128 字。

任务优先级栈大小(字)典型峰值占用说明
GUI 任务31024约 60%窗口回调递归、字库渲染都在这里
LED 任务2256约 25%只做队列接收和 GPIO 翻转
空闲任务0128极低顺带做内存回收

优先级上 GUI 给 3、LED 给 2,是有意的:界面刷新对延迟敏感,触摸按下到按钮变色的响应要在一帧内完成;LED 翻转晚个几毫秒人眼根本看不出来。但优先级差不能拉太大,也没必要用configMAX_PRIORITIES的顶格值,留出余量给以后可能加的串口任务。

实测栈峰值用uxTaskGetStackHighWaterMark(),它返回的是任务运行至今栈剩余的最小值(单位是字)。跑一段时间后读一次,如果剩余不到总大小的 25%,就该加栈了。这个数值不用一直盯着,答辩前跑个十分钟读一次,写进验收表里,比空口说"栈够用"有说服力得多。

4. GUIbuilder 工程落地:两个按钮到两颗 LED 的完整代码链路

4.1 GUIbuilder 生成的代码结构,以及必须动手改的三处

GUIbuilder 生成的GUIBuilder.c结构很固定:一个CreateWindow()负责建框架窗口和两个按钮,一个_cbDialog()是窗口回调,WM_INIT_DIALOG分支里设按钮的初始属性(位置、大小、文本、ID)。默认生成的 ID 是GUI_ID_BUTTON0和GUI_ID_BUTTON1,如果你在 GUIbuilder 里没改,那就直接用这两个宏。

生成代码不能原样扔进 RTOS 工程,有三处必须动手。第一处,CreateWindow()里的WM_HWIN hWin = FRAMEWIN_CreateEx(...)这个局部变量要提成文件级静态变量,否则窗口句柄出了函数就丢了,后面想主动重绘都找不到人。第二处,_cbDialog()里WM_NOTIFY_PARENT分支原本是空的(或者只有一句WM_DefaultProc),这里要填事件投递逻辑。第三处,GUIbuilder 默认会在main里调GUI_Init()然后GUI_Exec()死循环,这两句要挪进 GUI 任务,main里只保留xTaskCreate。

/* GUIBuilder.c —— 改造后的窗口创建与回调骨架 */ static WM_HWIN hMainWin = 0; /* 提成静态,方便外部重绘 */ WM_HWIN CreateMainWindow(void) { hMainWin = FRAMEWIN_CreateEx(0, 0, 240, 320, 0, WM_CF_SHOW, FRAMEWIN_CF_MOVEABLE, GUI_ID_FRAME0, "LED Control", _cbDialog); FRAMEWIN_SetFont(hMainWin, GUI_FONT_16B_ASCII); FRAMEWIN_SetTitleHeight(hMainWin, 24); /* 两个按钮:ID 必须与回调里判断的一致 */ BUTTON_CreateEx( 40, 60, 160, 56, hMainWin, WM_CF_SHOW, 0, GUI_ID_BUTTON0); BUTTON_SetText(WM_GetDialogItem(hMainWin, GUI_ID_BUTTON0), "LED GREEN"); BUTTON_CreateEx( 40, 150, 160, 56, hMainWin, WM_CF_SHOW, 0, GUI_ID_BUTTON1); BUTTON_SetText(WM_GetDialogItem(hMainWin, GUI_ID_BUTTON1), "LED RED"); return hMainWin; }

WM_CF_SHOW不能漏,漏了控件建出来是隐藏的,你点空气当然没反应。父句柄给hMainWin而不是 0,是为了让按钮跟着框架窗口移动和裁剪。按钮宽度 160 是给 240 宽屏幕留的边距,太小了手指按不准,太大了布局难看,这个数值后面调触摸校准的时候还会微调。

4.2 按钮回调:把 WM_NOTIFICATION_CLICKED 翻译成应用事件

回调函数是整个链路的中枢,也是最容易写错的地方。它运行在 GUI 任务里,被GUI_Exec层层调用,所以里面的代码必须"短、快、不阻塞"。判断点击只看两个东西:消息 ID 是WM_NOTIFY_PARENT,数据是WM_NOTIFICATION_CLICKED,前者说明是子控件发来的,后者说明是点击而不是按下或释放。

/* GUIBuilder.c —— 只做翻译和投递,不做任何耗时动作 */ static void _cbDialog(WM_MESSAGE *pMsg) { switch (pMsg->MsgId) { case WM_INIT_DIALOG: /* 控件属性在这里设,也可以在上面的 CreateEx 之后设 */ break; case WM_NOTIFY_PARENT: { int id = WM_GetId(pMsg->hWinSrc); /* 用 hWinSrc,不是 hWin */ if (pMsg->Data.v == WM_NOTIFICATION_CLICKED) { LedEvent_t ev = LED_EVT_NONE; if (id == GUI_ID_BUTTON0) ev = LED_EVT_TOGGLE_GREEN; else if (id == GUI_ID_BUTTON1) ev = LED_EVT_TOGGLE_RED; if (ev != LED_EVT_NONE) { /* 超时给 0:队列满就丢弃,绝不阻塞 GUI 任务 */ if (xQueueSend(xLedQueue, &ev, 0) != pdPASS) { /* 可以在这里累加一个丢帧计数,调试时打印 */ } } } break; } default: WM_DefaultProc(pMsg); /* 其余消息交回默认处理,别自己吞掉 */ break; } }

这里有个细节值得单独说:判断是哪个控件发出的通知,要用pMsg->hWinSrc(消息源窗口),不是pMsg->hWin(接收消息的窗口,这里是框架窗口)。用错了hWin,WM_GetId拿到的是框架窗口的 ID,两个按钮点下去行为一模一样,这个坑我在第一次做的时候踩了整整一个下午。另外default分支里的WM_DefaultProc千万不能省,窗口的重绘、焦点的切换、框架窗口的拖动全靠它,吞掉之后界面看着能显示,但一拖动就花。

4.3 队列消费者:LED 任务如何安全地翻转 PG13 与 PG14

LED 任务写起来简单,但有两个地方要用对。第一,xQueueReceive的超时给portMAX_DELAY,这样没有事件时任务真正挂起,不占 CPU。第二,翻转引脚用HAL_GPIO_TogglePin或者自己异或 ODR 寄存器,不要用"读当前状态再取反再写回"的三步走,那三步之间如果被中断打断,状态就乱了——虽然在这个项目里 LED 只有这一个任务在写,但养成习惯没坏处。

/* app_tasks.c —— LED 任务:唯一的 GPIO 写入口 */ void vLedTask(void *pvArg) { LedEvent_t ev; (void)pvArg; BSP_LED_Init(); /* PG13 / PG14 推挽输出,初始熄灭 */ for (;;) { if (xQueueReceive(xLedQueue, &ev, portMAX_DELAY) == pdPASS) { switch (ev) { case LED_EVT_TOGGLE_GREEN: HAL_GPIO_TogglePin(GPIOG, GPIO_PIN_13); break; case LED_EVT_TOGGLE_RED: HAL_GPIO_TogglePin(GPIOG, GPIO_PIN_14); break; case LED_EVT_ALL_OFF: HAL_GPIO_WritePin(GPIOG, GPIO_PIN_13 | GPIO_PIN_14, GPIO_PIN_RESET); break; default: break; } } } }

portMAX_DELAY在configUSE_16_BIT_TICKS为 0 时等于 0xFFFFFFFF,任务会一直阻塞到队列有数据,期间不参与调度,CPU 全给 GUI 任务和空闲任务。如果这里图省事写个xQueueReceive(..., 10)然后循环,LED 任务每 10ms 醒一次,虽然功耗影响不大,但逻辑上就不对了——它变成了轮询,队列的意义没了一半。

GUI 任务本身反而更简单,初始化一次,然后循环调GUI_Exec加延时:

/* app_tasks.c —— GUI 任务:所有 WM_ 调用的唯一合法场所 */ void vGuiTask(void *pvArg) { (void)pvArg; GUI_Init(); /* 内部会调 LCDConf 和 GUI_X_Init */ CreateMainWindow(); WM_SetFocus(hMainWin); for (;;) { GUI_Exec(); /* 处理消息队列,非阻塞 */ vTaskDelay(pdMS_TO_TICKS(10)); /* 主动让出,10ms 约合一帧 */ } }

有人会问为什么不直接用GUI_Delay(10),它内部也是GUI_Exec加GUI_X_Delay。能跑,但GUI_Delay的语义是"延时这么久",GUI_Exec的语义是"处理一轮消息",分开写意图更清楚,而且延时值可以独立调,不影响消息处理的节奏。10ms 对应 100Hz 的消息轮询,对按钮这种交互足够了,想更跟手可以降到 5ms,再低就纯属浪费 CPU。

4.4 触摸对接:GUI_TOUCH_X_MeasureX/Y 的缓存写法

STemWin 的触摸层会定期调GUI_TOUCH_Exec(),它内部去问GUI_TOUCH_X_MeasureX()和GUI_TOUCH_X_MeasureY()要坐标。问题在于这两个函数是分开调的,先 X 后 Y,如果你的实现里每次调用都去 I2C 读一遍触摸控制器,两次读之间隔了几十微秒,手指稍微动一下,X 和 Y 就不是同一个时刻的坐标,光标会飘。

正确做法是在 BSP 层做一次读点缓存,两个函数共享同一份结果,用一个"已读"标志控制刷新时机。

/* bsp_touch.c —— 一次 I2C 事务读出 XY,两个测量函数共享 */ static uint16_t s_x = 0, s_y = 0; static uint8_t s_fresh = 0; void BSP_Touch_Scan(void) { uint16_t x, y; if (BSP_Touch_ReadRaw(&x, &y) == 0) { /* I2C 读寄存器,带 touched 判断 */ s_x = x; s_y = y; s_fresh = 1; } else { s_fresh = 0; } } int GUI_TOUCH_X_MeasureX(void) { if (!s_fresh) BSP_Touch_Scan(); return (int)s_x; } int GUI_TOUCH_X_MeasureY(void) { /* 复用 X 那次扫描的结果,本次读完就作废,下次重新采样 */ int y = (int)s_y; s_fresh = 0; return y; }

s_fresh这个标志是整段代码的关键。X 被调用时如果缓存是旧的,触发一次采样;Y 紧接着被调用,直接拿缓存里的值,读完把标志清掉。这样一次触摸事件只产生一次 I2C 事务,XY 严格同源。如果采样失败(手指抬起),X 会重新扫,返回的还是上一帧的坐标,GUI_TOUCH层自己有一套抬起判定逻辑,不用你操心。

坐标映射是另一个必做项。触摸控制器报的原始值和屏幕像素不是一回事,得线性映射到 0~239 和 0~319。如果装反了(上下颠倒、左右镜像),最省事的办法是改LCDConf.c里的GUI_TOUCH_SWAP_XY、GUI_TOUCH_MIRROR_X、GUI_TOUCH_MIRROR_Y三个宏,别在测量函数里做符号运算,那样以后换屏就找不着北了。

5. 避坑与排查:白屏、卡死、按钮无响应、HardFault 的定位顺序

5.1 上电白屏或花屏:先怀疑 SDRAM,再怀疑 LTDC

现象是背光亮着,屏幕全白或者满屏彩色条纹滚动,串口没有任何输出。原因通常有两个:SDRAM 初始化时序不对,写进去的数据读出来是错的;或者 LTDC 的FBStartAdress指向了一片根本没初始化的地址空间。这两个原因表现完全一样,但排查顺序不能反。

先做 SDRAM 全片读写测试,从 0xC0000000 开始按 4 字节步进写递增值,再读回比对。如果第一个字节就对不上,问题在 FMC 的时序参数(刷新计数、CAS 延迟、写恢复时间),对照 SDRAM 手册一项项核,别抄别的板子的参数。如果前 2MB 对、后面开始错,多半是地址线复用配错或者 Bank 边界算错。全片通过了再去看 LTDC,重点核对AccumulatedActiveW/H和TotalWidth/Heigh这几个累加值,它们是相对值不是绝对值,算错一位就差出几十个像素。

5.2 界面正常但一点按钮就卡死:回调里干了不该干的事

现象是主窗口和两个按钮都显示正常,手指按下去按钮有凹陷反馈,然后整个界面就定住,LED 也不动。原因是按钮回调里调用了阻塞函数,最常见的是xQueueSend超时给了portMAX_DELAY,队列满的时候把 GUI 任务挂起;或者回调里直接调了GUI_Delay,试图做个按下动画。

解决办法是把回调彻底改成非阻塞:xQueueSend的超时写 0,队列长度给足 8 个,正常情况下永远不会满;需要视觉反馈就用BUTTON_SetBkColor改个颜色再WM_InvalidateWindow,不要用延时。如果确实需要在点击后延迟做某事,把"延迟"这件事打包进事件里,让 LED 任务去做vTaskDelay,GUI 任务只管投递。

5.3 按钮按下去没反应:ID 与触摸映射两头查

现象是界面正常,按按钮没有任何反应,但用手指在屏幕上划能感觉到有东西在响应(比如框架窗口被拖动了)。原因大概率是 ID 对不上:GUIbuilder 里给按钮设的 ID 是GUI_ID_BUTTON0,但代码里WM_GetId(pMsg->hWinSrc)拿到的却是别的值,if判断全部落空。

定位方法很直接,在回调的WM_NOTIFY_PARENT分支里加一句串口打印,把WM_GetId(pMsg->hWinSrc)和pMsg->Data.v都打出来。正常点击应该看到 ID 是 0x500 左右(GUI_ID_BUTTON0的基值)加上偏移,Data.v是WM_NOTIFICATION_CLICKED的值 2。如果Data.v一直是 0,说明触摸层根本没识别到点击,去查触摸校准和GUI_TOUCH_Exec有没有被定期调用——它挂在GUI_Exec里,GUI 任务不转,它就不跑。

5.4 跑几分钟就 HardFault:GUI 任务的栈被吃光

现象是系统能正常跑一段时间,短则几十秒长则几分钟,然后突然死机,调试器停在 HardFault_Handler。原因九成是 GUI 任务栈溢出,溢出的数据踩坏了相邻任务的 TCB 或者队列控制块。STemWin 的栈消耗不是恒定的,打开一个对话框、切换一次字体、重绘一次带透明度的控件,峰值都不一样,平时看着够用,某个操作一触发就爆。

解决分两步。先开configCHECK_FOR_STACK_OVERFLOW = 2并实现vApplicationStackOverflowHook,在里面打个断点,能第一时间抓到是哪个任务溢出。然后把 GUI 任务的栈从 1024 字加到 1536 或 2048 字,跑十分钟再用uxTaskGetStackHighWaterMark读剩余量,确认稳定在 30% 以上。别心疼那几 KB RAM,F429 有 256KB,SDRAM 还有 8MB,栈给足了系统才稳。

5.5 GUI 任务霸占 CPU:GUI_X_ExecIdle 不能是空函数

现象是界面响应正常,但 LED 反应迟钝,或者串口打印断断续续,用调试器看任务状态,LED 任务长期处于 Ready 却拿不到运行时间。原因是GUI_X_ExecIdle被实现成了空函数,GUI 任务在没有消息处理时就是死循环空转,同优先级和低优先级的任务永远轮不上。

这个问题的隐蔽之处在于它不报错,系统"看着能用"。修法就是第 2 章里那句vTaskDelay(1)。如果想验证是不是这个问题,临时把 GUI 任务的优先级从 3 降到 1,如果 LED 立刻变灵敏了,那就实锤了。反过来,如果降了优先级 LED 还是慢,那就是 LED 任务的队列接收写错了,或者根本没建任务。

6. 验收与答辩:用一张检查表证明这套移植真的成立

答辩现场最怕的不是老师问难题,而是演示到一半界面卡住,你只能说"刚才还好好的"。这套方案的验收不能靠"我看着能跑",得有一张能逐项打勾的表,每项都有明确的判定方法。

验收项操作方法通过判据
显示通路上电冷启动,观察 3 秒主窗口完整出现,无花屏、无滚动条纹
按钮 1点击 "LED GREEN"LD3(PG13)状态翻转,界面无卡顿
按钮 2点击 "LED RED"LD4(PG14)状态翻转,界面无卡顿
并发压力两个按钮交替快速点击 20 次界面不冻结,最终 LED 状态与点击次数奇偶一致
任务独立性调试器挂起 LED 任务界面仍可正常响应点击,队列积压不超过 8
栈余量连续运行 10 分钟后读高水位线各任务剩余栈 > 25%
内存余量同上,读xPortGetFreeHeapSize()空闲堆 > 初始值的 40%
冷启动一致性断电重启 10 次每次都进入主界面,无偶发白屏

这张表里我最看重"并发压力"和"任务独立性"两项。前者能暴露队列深度不够或者回调阻塞的问题,后者能证明 GUI 和 LED 确实是两个独立任务,而不是假 RTOS——有些同学的工程名义上建了两个任务,实际上 LED 是 GUI 回调里直接点的,演示时看不出来,一挂起任务就露馅。

答辩讲解有个三步法,比从头念代码有效得多。第一步,指着屏幕说"这是 STemWin 的窗口,跑在 GUI 任务里,优先级 3";第二步,点一下按钮,说"这次点击被翻译成一个枚举,投进了这个长度 8 的队列";第三步,指着 LED 说"这颗灯是另一个任务翻转的,优先级 2,它只认队列不认界面"。三句话把分层、通信、调度全讲完了,老师基本不会再追问架构。

如果还想再加分,可以准备一个"故意失败"的演示:把队列长度临时改成 1,快速连点,让事件被丢弃,然后展示丢帧计数器在涨、但界面依然不卡。这能说明你理解"非阻塞投递"的取舍——宁可丢事件也不冻结界面,这个判断在真实产品里是对的。

我第一次做这套移植的时候,卡在 HardFault 上整整两天,最后发现是 GUI 任务栈只给了 512 字,GUI_Init里加载字库那一步就爆了。从那以后我每次新建 GUI 任务,都强制先给 1024 字,跑十分钟读一次高水位线再往下调,再也不敢凭感觉估栈大小。希望这套结构能帮你少走那两天弯路。

本文还有配套的精品资源,点击获取