ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F429上STemWin与FreeRTOS GUI移植及按钮控LED

STM32F429上STemWin与FreeRTOS GUI移植及按钮控LED

简介:面向嵌入式开发者的STM32F429 DISC1实战工程,聚焦在Cortex-M4平台上完成STemWin图形库与FreeRTOS实时操作系统的联合移植,并用GUIbuilder设计竖屏界面,通过两个按钮事件控制LED亮灭,适合已具备STM32基础、希望进阶GUI与RTOS协同开发的学习者。压缩包共530个文件、约18.26MB,包含176个h头文件、102个c源文件及75个o目标文件、75个d依赖文件、74个crf编译中间文件,另有uvproj工程、sct分散加载、hex与axf产物、map映射及readme说明等,完整保留了从源码、工程配置到编译输出的开发痕迹。该资源已有966人学习,价值在于可对照移植流程理解LCD与触摸驱动适配、FreeRTOS任务划分和GUI事件回调机制,也能借GUIbuilder生成的按钮事件把界面交互落到LED控制,便于复盘竖屏布局与工程组织。

1. 屏上两个按钮,点下去灯不亮——这事值得单独拆一遍

很多人第一次在 STM32F429 DISC1 上同时上手 STemWin 和 FreeRTOS,结局都很像:GUI_Init 返回了,屏上界面也出来了,两个按钮画得挺像样,手指戳上去没反应;运气差一点,屏幕一片白,程序卡在启动阶段不动。问题通常不在 GUIbuilder 生成的代码里,而在移植层那几百行胶水代码:显存怎么给、颜色格式对不对、GUI 和 RTOS 的锁谁来加、按钮事件从窗口消息怎么走到 GPIO。这篇就把这个项目从头拆一遍——STM32F429 平台、STemWin 图形库、FreeRTOS 内核,加 GUIbuilder 拖出来的两个按钮控制板载 LED,把每一步为什么这么配置、配置错了会怎样讲清楚。适合手里有这块板、跑通过点灯例程、想往上叠一层界面的开发者,也适合已经在裸机跑通 STemWin、准备把 GUI 塞进 RTOS 的人。

2. 方案全景:STemWin 与 FreeRTOS 为什么必须一起出现

先把这个项目的定位说死:它不是“学 GUI 库”,也不是“学 RTOS”,而是在一块资源有限的板子上,让一个带窗口管理器的图形库和一个抢占式内核同时活着,并且互不打架。理解这一点,后面所有配置项都能自己推出来。

2.1 硬件底子:F429 DISC1 这块板能撑起什么

STM32F429 这颗芯片对 GUI 来说有两个关键点:一是 180MHz 主频加 ART 加速器,刷一块 240×320 的屏不算吃力;二是片上带 LTDC 液晶控制器和 Chrom-ART 图形加速器,理论上可以不占 CPU 刷屏。DISC1 这块板子上的屏是 2.4 寸 240×320 的 ILI9341,接在 SPI 上,还带一片电阻触摸;板载的 SDRAM 也给得很足,做帧缓冲完全够用。

这里就出来了第一处选择:走 SPI 驱动 ILI9341,还是走 LTDC 配 SDRAM 做显存。SPI 方案改动小、和官方例程贴合、不用配 SDRAM 时序,缺点是刷全屏慢,一帧要几十毫秒;LTDC 方案刷屏快,但要把 SDRAM 控制器、GPIO 复用、时钟全都调对,新手很容易在 SDRAM 时序上卡一整天。这个项目的做法是前者——SPI + 局部刷新,因为界面只有两个按钮,改动区域很小,SPI 完全够用。如果你的目标界面复杂、有滑动列表,那就该老老实实上 LTDC。

内存账也要提前算。STemWin 的内存池、FreeRTOS 的堆、GUI 任务栈,三样加起来不能把 256KB SRAM 吃穿。我一般这么分:STemWin 内存池 32KB 到 64KB、FreeRTOS heap 给 16KB 到 24KB、GUI 任务栈 2KB 以上、其余给业务任务和中断栈。这个数字后面配 GUIConf.c 和 FreeRTOSConfig.h 时会直接用到。

2.2 为什么不是裸机画点:STemWin 在这套方案里干什么

有人会问,两个按钮而已,自己画两个矩形加文字不就完了。短期确实可以,但只要你后面要加滑块、下拉框、多页面切换,手写绘制逻辑就会变成一堆坐标常量和重绘判断,改一个布局要动十处。STemWin 解决的核心是三件事:窗口管理器(谁在谁上面、谁需要重绘、裁剪区怎么算)、控件库(按钮、文本、进度条自带外观和状态机)、回调机制(触摸或按键进来,库替你定位到是哪个控件、什么事件)。

它还有个隐性价值:GUIbuilder 是配套的。你在 PC 上把控件拖好、设好 ID 和坐标,它能直接吐出一个 C 文件,里面有对话框资源表和默认回调骨架,拿到工程里改几行就能跑。这对“先出界面再补逻辑”的开发节奏非常友好,也是这个项目选它而不是选裸绘的根本原因——不是画不出来,是不想每次都重画。

代价也要认:STemWin 是商业库,随芯片原厂 SDK 提供的版本需要看授权范围;它的内存管理是自己那套 GUI_ALLOC,和 malloc 不是一回事;它默认不是线程安全的,必须靠 GUI_OS 开关加锁。这三点后面都会碰到。

2.3 任务怎么切:GUI 任务、LED 任务和消息通道

接上 FreeRTOS 之后第一个要决定的是:GUI 在哪个上下文里跑。常见的错误做法是在 main 里初始化完 GUI 就开始跑业务,然后在别的任务里直接调 GUI 函数——短期能显示,跑久了必炸,因为窗口管理器的内部链表被两个任务同时改了。

这个项目用的是最稳的结构:单独一个 GUI 任务,独占所有 STemWin 调用;再一个 LED 任务,只碰 GPIO;两者之间用 FreeRTOS 队列传消息。GUI 回调里绝不直接操作硬件,只往队列里丢一条“哪个灯、开还是关”。这样 GUI 的响应时间和 LED 的动作时间彻底解耦,按钮按下去界面立刻回弹,灯哪怕晚几毫秒亮也没人看得出来。

优先级怎么排?GUI 任务给中等偏上,因为它是人机交互,卡顿最容易被感知;LED 任务给低,反正只是翻个引脚。但注意 GUI 任务不能给到最高,否则它一忙起来,喂狗、通信这些任务全被压住。我一般把 GUI 放在业务任务之上、通信任务之下,具体数值看你的任务数量,重要的是留出优先级梯度,别所有任务一个优先级。

2.4 工程目录长什么样

把库和业务分开,是后面能改得动的前提。我习惯这么组织:

Project/ ├── Drivers/ # 原厂 HAL 与 CMSIS,不动 ├── Middlewares/ │ ├── ST/STM32_USB... # 与本项目无关,可裁掉 │ ├── Third_Party/FreeRTOS/# 内核源码 + port │ └── STemWin/ # STemWin 库文件与配置 │ ├── Lib/ # 预编译的 .a 库,按编译器选 │ ├── Config/ # GUIConf.c/h、LCDConf.c/h、GUI_X_*.c │ └── Inc/ # GUI.h、WM.h、BUTTON.h 等头文件 ├── BSP/ │ ├── lcd_ili9341.c # 屏的底层读写、初始化序列 │ ├── led.c # LED 初始化与开关 │ └── touch.c # 触摸采样与校准参数 ├── App/ │ ├── gui_task.c # GUI 任务入口 │ ├── led_task.c # LED 任务入口 │ └── WindowDLG.c # GUIbuilder 生成,后加事件处理 └── User/ ├── main.c └── FreeRTOSConfig.h

这个结构的关键是Middlewares/STemWin/Config单独一层。STemWin 的移植,90% 的工作量集中在这三个文件:GUIConf.c决定内存池多大,LCDConf.c决定屏幕怎么驱动,GUI_X_FreeRTOS.c决定它怎么和内核共存。把它们和业务代码隔开,将来换屏、换 RTOS,改的都是这一层。

3. 移植落地:从裸机工程到 STemWin 与 FreeRTOS 双跑

这一章是真正的体力活。所有“玄学白屏”“卡在 GUI_Init”的根子,基本都能在这四步里找到。

3.1 引入 STemWin 库与目录裁剪

原厂 SDK 里的 STemWin 通常跟着一个庞大的示例包,不能整个拖进来。要拿的是三样:预编译库文件(Keil 下是.lib,GCC 下是.a,按编译器和是否带 OS 支持分开选)、Inc目录下的头文件、以及Config目录下的四个模板文件。

编译器选项要跟着改。库是用特定 ABI 编的,如果你的工程开了--fpu或者用了不兼容的浮点 ABI,链接阶段就会报一堆 undefined。我一般先把这三个符号加进链接脚本或者直接用__attribute__((used))占位,确认库能被拉进来:

/* 确认 STemWin 库已被链接进来的最小验证代码 */ #include "GUI.h" void GUI_LinkCheck(void) { /* 只调用一个最轻的函数,能在 map 文件里看到 GUI_Init 被引用即可 */ GUI_GetVersionString(); }

函数里只调GUI_GetVersionString()是有讲究的:它不进初始化流程、不碰硬件、不需要内存池,纯粹验证“库能不能链上、头文件路径对不对”。这一步过了再往下走,能省掉后面一半的排查时间。

同时把 FreeRTOS 的源码和 port 加进来。port 选ARM_CM4F,因为 F429 是 Cortex-M4 带 FPU;如果用了 Keil 的 ARMCC,注意port.c和portmacro.h的浮点上下文保存选项要和工程设置一致,否则任务切换时 FPU 寄存器不保存,浮点运算会算出乱值——这种 bug 表现为“界面上的数字偶尔变成天文数字”,非常难查。

3.2 GUIConf.c:内存池和 OS 开关

GUIConf.c里最重要的两件事:给 STemWin 划一块静态内存池,以及打开 OS 支持。模板大概长这样:

#include "GUI.h" #include "FreeRTOS.h" #include "semphr.h" /* STemWin 自己的内存池,从 SRAM 里静态划走,不走 malloc */ static U32 _aGUIHeap[GUI_HEAP_WORDS]; /********************************************************************* * GUI_X_Config * 在 GUI_Init() 内部被第一个调用,负责把内存交给库 */ void GUI_X_Config(void) { /* 32KB 内存池:两个按钮 + 文本 + 窗口对象,实测占用不到 8KB, 留足余量是为了后面加页面时不用改这里 */ GUI_ALLOC_AssignMemory(_aGUIHeap, sizeof(_aGUIHeap)); /* 默认字体,界面上的中文要先自己加字库,这里先用 ASCII */ GUI_SetDefaultFont(GUI_FONT_16_1); }

GUI_ALLOC_AssignMemory是关键。不调它,GUI_Init()会在第一次分配窗口对象时直接进GUI_X_ErrorOut死循环,表现就是“程序卡住不动、调试器暂停在 HardFault 或者死循环里”。内存池给多大合适?两个按钮加一个对话框大概几百字节到 1KB,但 STemWin 内部还有窗口管理器结构、字体缓存、剪贴板,给 8KB 是底线,32KB 比较安心。别给太大,SRAM 就那么多。

GUIConf.h里要改的是 OS 开关:

#define GUI_OS 1 /* 打开后 STemWin 内部会调用 GUI_X_Lock/Unlock */ #define GUI_MAXTASK 4 /* 最多几个任务同时访问 GUI,按实际填 */ #define GUI_SUPPORT_TOUCH 1 /* 用触摸就必须开 */ #define GUI_SUPPORT_MOUSE 0 /* 没有鼠标,关掉省代码 */ #define GUI_WINSUPPORT 1 /* 不用窗口管理器可以关,这里要用,必须开 */ #define GUI_SUPPORT_MEMDEV 1 /* 内存设备,局部刷新防闪烁,视 SRAM 余量决定 */

GUI_OS置 1 之后,STemWin 在每个对外 API 的入口自动加锁、出口解锁。这意味着你在任意任务里调WM_SendMessage都是安全的——但前提是你得把GUI_X_Lock和GUI_X_Unlock实现出来,否则链接不过。这就是下一节的事。

3.3 LCDConf.c:屏参、颜色格式与显存

LCDConf.c决定 STemWin 怎么和你的屏对话。走 SPI 方案时,用的是灵活颜色驱动,配置项集中在这几个宏:

#include "GUI.h" #include "lcd_ili9341.h" #define XSIZE_PHYS 240 /* 屏的物理宽度 */ #define YSIZE_PHYS 320 /* 屏的物理高度 */ #define COLOR_CONVERSION GUICC_M565 /* ILI9341 原生 16 位色,RGB565 */ #define DISPLAY_DRIVER GUIDRV_FLEXCOLOR /* 第一行和最后一行、第一列和最后一列的偏移,触摸屏贴合公差靠它修 */ #define LCD_XSIZE 240 #define LCD_YSIZE 320 /********************************************************************* * LCD_X_Config * GUI_Init() 内部调用,负责把驱动和屏接上 */ void LCD_X_Config(void) { GUI_DEVICE * pDevice; CONFIG_FLEXCOLOR Config = {0}; GUI_PORT_API PortAPI = {0}; /* 1. 建一个显示设备,绑定颜色转换和驱动 */ pDevice = GUI_DEVICE_CreateAndLink(DISPLAY_DRIVER, COLOR_CONVERSION, 0, 0); /* 2. 告诉驱动屏幕尺寸和位序,ILI9341 是 16 位一个像素 */ LCD_SetSizeEx(0, XSIZE_PHYS, YSIZE_PHYS); LCD_SetVSizeEx(0, XSIZE_PHYS, YSIZE_PHYS); /* 3. 把底层读写函数注册给 STemWin */ PortAPI.pfWrite16_A0 = LCD_WriteData16; /* 命令/数据切换脚拉高 */ PortAPI.pfWrite16_A1 = LCD_WriteData16; PortAPI.pfWriteM16_A1 = LCD_WriteDataM16; /* 批量写,刷矩形时用 */ PortAPI.pfReadM16_A1 = LCD_ReadDataM16; GUIDRV_FlexColor_SetFunc(pDevice, &PortAPI, GUIDRV_FLEXCOLOR_F66709, GUIDRV_FLEXCOLOR_M16C0B16); }

这里有两个坑点值得单独说。COLOR_CONVERSION必须是GUICC_M565,如果你的屏实际接收的是 BGR 顺序(有些模组的初始化序列里 RGB/BGR 位设反了),界面颜色会整体偏蓝——红按钮变蓝按钮,非常直观,但很多人会先去怀疑代码。改法只有两个:要么改屏的 MADCTL 寄存器,要么在 STemWin 里用GUICC_M565SWAP。别两个一起改,会转回去。

GUIDRV_FLEXCOLOR_F66709这个函数码对应 ILI9341 的寄存器组。用错了会表现为“初始化没报错但屏幕没反应”,因为驱动往里写的那些命令屏根本不认。函数码从驱动手册里查,别凭记忆。

LCD_X_Config里没有显存配置,是因为 SPI 方案下 STemWin 直接用一块小行缓冲(默认几百字节)逐行刷。这块缓存在LCDConf.h里通过GUI_NUM_LAYERS和LCD_BUFFER_SIZE控制。屏小、刷新少的时候够用;一旦界面有大面积背景色变化,就会看到明显的从上往下刷的撕裂感,那时候就得上内存设备和多缓冲。

还有一行不能忘:GUI_Init()之前要把屏的 GPIO、SPI、背光都初始化好,并且清一次屏。不清屏的话,STemWin 认为屏上是随机内容,第一次全屏重绘之前会有一瞬间的花屏。

3.4 GUI_X_FreeRTOS.c:五个必须实现的钩子函数

打开GUI_OS后,链接器会找你要下面这几样东西。少一个都编不过,实现了但写错就会随机死机。

#include "FreeRTOS.h" #include "task.h" #include "semphr.h" #include "GUI.h" static SemaphoreHandle_t _hGuiMutex = NULL; /********************************************************************* * GUI_X_Init * 在 GUI_Init() 最开始被调用,此时调度器通常已经启动 */ void GUI_X_Init(void) { /* 必须是递归互斥量:STemWin 内部存在 GUI 函数嵌套调用, 用普通二值信号量会自己把自己锁死 */ _hGuiMutex = xSemaphoreCreateRecursiveMutex(); configASSERT(_hGuiMutex != NULL); } void GUI_X_Lock(void) { xSemaphoreTakeRecursive(_hGuiMutex, portMAX_DELAY); } void GUI_X_Unlock(void) { xSemaphoreGiveRecursive(_hGuiMutex); } /********************************************************************* * GUI_X_GetTime / GUI_X_Delay * STemWin 的时间基准,动画、超时、双击判定都靠它 */ int GUI_X_GetTime(void) { /* 用 tick 换算成毫秒,注意 tick 溢出回绕时这里会出现负数, GUI 内部按有符号处理,短时间回绕不影响 */ return (int)(xTaskGetTickCount() * portTICK_PERIOD_MS); } void GUI_X_Delay(int Period) { if (Period > 0) { vTaskDelay(pdMS_TO_TICKS(Period)); } else { taskYIELD(); } }

逐条解释。GUI_X_Init里必须建递归互斥量,这是最容易翻车的一处:WM_Exec()内部会调多个带锁的 API,用二值信号量的话第二次 take 直接阻塞,整个 GUI 任务挂死,表现为“界面画到一半不动了”。递归互斥量允许同一个任务重复拿锁,释放次数匹配即可。

GUI_X_GetTime用 tick 换算有个细节:FreeRTOS 的 tick 是 32 位,按 1ms 算 49 天回绕一次,(int)转换后变成负数。STemWin 内部做时间差运算时用的是有符号减法,短时间回绕不会出错,但如果你的界面需要长时间运行(比如跑几个月的设备),建议换成 RTC 或者单独的毫秒计数器。

GUI_X_Delay一定要用vTaskDelay而不是空循环。用空循环的话,GUI 任务在等待期间会一直占着 CPU,低优先级的 LED 任务永远得不到调度,表现就是“按下按钮界面有反应,灯死活不亮”——这个现象和 GPIO 配错一模一样,很容易查错方向。

还剩两个:GUI_X_ErrorOut和GUI_X_Log。前者在库内部检测到致命错误时调用,产品里别让它死循环,我一般点亮一个错误灯然后复位;后者可以空实现,调试期建议重定向到串口,STemWin 的警告信息挺有用。

最后,GUI_X_Init的调用时机要注意:它是在GUI_Init()内部调的,而GUI_Init()必须在调度器启动之后调用(因为要建互斥量)。所以标准做法是把GUI_Init()放进 GUI 任务的第一行,而不是放在main()里osKernelStart()之前。

4. GUIbuilder 出界面:两个按钮怎么变成可编译的 C 代码

界面部分其实是最省事的,前提是你知道它生成的东西该怎么接。

4.1 在 GUIbuilder 里拖两个按钮并保存

GUIbuilder 是个绿色小工具,跟着 STemWin 包一起发。打开后先设分辨率 240×320,然后从控件栏拖两个 BUTTON 上去,摆好位置和大小,把标题改成 “LED1” 和 “LED2”。右边属性面板里可以改字体、背景色、按下时的效果。

关键是 ID。默认情况下 GUIbuilder 会给控件分配GUI_ID_BUTTON0、GUI_ID_BUTTON1,它们本质上是GUI_ID_USER + 0和+1这样的偏移量。这个 ID 就是你后面在回调里分辨“是哪个按钮”的唯一依据,所以别嫌麻烦,在属性面板里改成有意义的名字,比如ID_BTN_LED1、ID_BTN_LED2,工具会同步生成对应的宏。

保存时选 “Save as C file”,得到一个WindowDLG.c,里面有两块内容:一个_aDialogCreate[]资源数组,描述每个控件的类型、位置、ID;一个_cbDialog回调函数,里面已经帮你写好了WM_INIT_DIALOG(初始化控件外观)和WM_NOTIFY_PARENT(子控件事件上报)的骨架。

4.2 生成的 WindowDLG.c 到底写了什么

生成的文件结构大致如此,我做了简化,只留关键部分:

#include "DIALOG.h" /* 控件资源表:每一项描述一个控件,最后一项 ID 必须是 GUI_ID_DIALOG */ static const GUI_WIDGET_CREATE_INFO _aDialogCreate[] = { { WINDOW_CreateIndirect, "MainWindow", GUI_ID_DIALOG, 0, 0, 240, 320, 0, 0x0, 0 }, { BUTTON_CreateIndirect, "LED1", GUI_ID_BUTTON0, 40, 80, 160, 50, 0, 0x0, 0 }, { BUTTON_CreateIndirect, "LED2", GUI_ID_BUTTON1, 40, 160, 160, 50, 0, 0x0, 0 }, }; /********************************************************************* * _cbDialog * 对话框回调,所有子控件的事件都汇总到这里 */ static void _cbDialog(WM_MESSAGE * pMsg) { WM_HWIN hItem; int NCode, Id; switch (pMsg->MsgId) { case WM_INIT_DIALOG: /* 这里可以改控件属性,比如设置文本、字体、颜色 */ hItem = WM_GetDialogItem(pMsg->hWin, GUI_ID_BUTTON0); BUTTON_SetText(hItem, "LED1"); break; case WM_NOTIFY_PARENT: Id = WM_GetId(pMsg->hWinSrc); /* 谁报的事件 */ NCode = pMsg->Data.v; /* 报的什么事件 */ switch (Id) { case GUI_ID_BUTTON0: switch (NCode) { case WM_NOTIFICATION_CLICKED: /* 按下:这里要接我们的灯 */ break; case WM_NOTIFICATION_RELEASED: break; } break; } break; default: WM_DefaultProc(pMsg); /* 不处理的全部还给默认过程,不写这句界面不刷新 */ } }

两个细节决定成败。WM_DefaultProc(pMsg)那一行必须保留,它负责重绘、焦点、裁剪这些基础动作,删掉之后界面会“按钮在但按下去没视觉反馈”。WM_NOTIFICATION_CLICKED和RELEASED是两个不同的事件,触摸屏上按下就会触发 CLICKED,抬手触发 RELEASED。做“按下亮、抬起灭”就用这两个;做“按一下切换状态”就在 CLICKED 里翻转,别在 RELEASED 里再翻一次,否则等于没按。

WM_GetDialogItem只能在WM_INIT_DIALOG里用,因为那时控件才刚建好。别在回调外面拿句柄去操作控件,容易拿到 NULL。

4.3 在 WM_NOTIFY_PARENT 里接事件

把事件处理填进去。注意这里只做一件事——往队列里丢消息,绝不在回调里点灯、更不调HAL_Delay:

/* 消息结构:哪个灯 + 开还是关 */ typedef enum { LED_ID_1 = 0, LED_ID_2 } LedId_t; typedef struct { LedId_t id; uint8_t on; } LedMsg_t; extern QueueHandle_t xLedQueue; /* 在 led_task.c 里创建 */ /* 单个灯的状态缓存,用来做"按一下翻转" */ static uint8_t _ledState[2] = {0, 0}; static void _PushLedCmd(LedId_t id) { LedMsg_t msg; msg.id = id; _ledState[id] = !_ledState[id]; msg.on = _ledState[id]; /* 超时给 0:GUI 不能被阻塞,队列满了就丢,界面优先 */ xQueueSend(xLedQueue, &msg, 0); }

事件分支改成这样:

case GUI_ID_BUTTON0: switch (NCode) { case WM_NOTIFICATION_CLICKED: _PushLedCmd(LED_ID_1); break; } break; case GUI_ID_BUTTON1: switch (NCode) { case WM_NOTIFICATION_CLICKED: _PushLedCmd(LED_ID_2); break; } break;

xQueueSend的超时参数给 0 是有意为之。给portMAX_DELAY的话,一旦 LED 任务优先级比 GUI 任务高并且队列恰好满,GUI 回调就会阻塞,触摸响应直接卡住。UI 线程里永远不要有阻塞调用,这条在嵌入式和在 PC 上是一回事。

4.4 LED 侧:队列解耦与点灯任务

LED 任务很轻,一个死循环等队列:

#include "led.h" #include "FreeRTOS.h" #include "task.h" #include "queue.h" QueueHandle_t xLedQueue = NULL; /* 板载两个灯,PG13 / PG14,低电平点亮 */ static void _LedWrite(LedId_t id, uint8_t on) { GPIO_PinState s = on ? GPIO_PIN_RESET : GPIO_PIN_SET; if (id == LED_ID_1) { HAL_GPIO_WritePin(GPIOG, GPIO_PIN_13, s); } else { HAL_GPIO_WritePin(GPIOG, GPIO_PIN_14, s); } } void LedTask(void *pv) { LedMsg_t msg; for (;;) { /* 没有消息就永久阻塞,不占 CPU */ if (xQueueReceive(xLedQueue, &msg, portMAX_DELAY) == pdTRUE) { _LedWrite(msg.id, msg.on); } } } void LedTaskInit(void) { xLedQueue = xQueueCreate(8, sizeof(LedMsg_t)); configASSERT(xLedQueue != NULL); xTaskCreate(LedTask, "LED", 128, NULL, 2, NULL); }

队列长度 8 是拍出来的,两个按钮的人手速不可能超过这个,给了也不浪费多少 RAM。任务栈 128 个字(Cortex-M4 上是 512 字节)够用,因为这个任务没有局部大数组。要是你在这个任务里加浮点运算或者printf,栈要翻倍。

GUI 任务那边最后是这样:

void GuiTask(void *pv) { GUI_Init(); /* 内部会调 GUI_X_Init 建互斥量 */ CreateWindow(); /* GUIbuilder 生成的对话框 */ for (;;) { GUI_Exec(); /* 处理窗口消息、执行重绘 */ vTaskDelay(pdMS_TO_TICKS(10)); } }

GUI_Exec()加 10ms 延时的组合,等效于GUI_Delay(10),但语义更清楚:前者是“把待处理的消息跑完”,后者是“跑完再睡一会儿”。后者会顺带处理动画和光标闪烁,如果你后面加了进度条这类需要定时刷新的控件,换成GUI_Delay(10)更省事。10ms 这个数值不是随便给的——触摸采样周期通常是 10 到 20ms,GUI 循环比它快就够,再快只是空转耗电。

5. 避坑排查:这几处我栽过,别重复

5.1 现象:GUI_Init 不返回,调试器停在死循环

原因基本是两个。一是GUIConf.c里没调GUI_ALLOC_AssignMemory,STemWin 第一次分配内存就失败,落到GUI_X_ErrorOut的for(;;)里;二是内存池给了但太小,建对话框时分配窗口对象失败,同样是这个表现。

排查方法:在GUI_X_ErrorOut的第一行打断点或者点亮一个错误灯,看它到底有没有被调用。被调用就是内存问题,把内存池从 8KB 加到 32KB 再看。另外注意内存池数组要声明成static,别放在函数栈上——栈上的数组在函数返回后就没了,STemWin 后面再去访问就是野指针。

5.2 现象:白屏,或者颜色整体偏色

白屏先分两种:屏完全没反应(背光亮但纯白),和屏上有内容但颜色不对。

纯白通常是 SPI 没通。查三处:屏的复位时序(有些模组要求复位脚拉低至少 10ms,太快了屏不认)、SPI 分频(F429 的 SPI5 挂在 APB2 上,分频太小会超过 ILI9341 的写入上限,表现为偶尔能显示但大面积花)、以及LCD_X_Config里注册的pfWrite16_A0/A1回调有没有真的把 DC 脚拉对。

颜色不对就是COLOR_CONVERSION和屏的 RGB/BGR 设置不匹配。红蓝互换用GUICC_M565SWAP修正,整体发灰发暗多半是背光 PWM 占空比太低或者屏的伽马没初始化。

5.3 现象:按钮画出来了,手指按下去毫无反应

先确认触摸这条链路通不通:在 GUI 任务里读一次原始触摸坐标打串口,如果读不到,问题在touch.c的 I2C/SPI 或中断配置,跟 STemWin 无关。

能读到坐标但按钮不响应,多半是没把触摸事件喂给窗口管理器。开了GUI_SUPPORT_TOUCH之后,还需要在 GUI 循环里调GUI_TOUCH_Exec(),并且用GUI_TOUCH_Calibrate()做过校准。没校准的坐标是原始 AD 值,和屏幕像素差着数量级,窗口管理器算出来的命中区域永远是错的。

还有一种:.c文件里_cbDialog的分支写对了,但GUI_ID_BUTTON0这个宏和 GUIbuilder 里实际分配的 ID 不一致。改过 ID 名字之后忘了重新生成,就会这样。

5.4 现象:加了 FreeRTOS 之后,偶发 HardFault,位置随机

随机 HardFault 排起来最烦,但方向就那么几个。

一是中断优先级。FreeRTOS 要求所有调用FromISR系列 API 的中断,优先级数值不能高于configMAX_SYSCALL_INTERRUPT_PRIORITY。触摸中断如果在里面调了xQueueSendFromISR,优先级配错了就会在临界区被打断,破坏内核数据结构。把触摸中断优先级调到 5 以上(数值越大优先级越低)通常就好了。

二是 GUI 任务栈溢出。GUI_Init()内部递归不浅,加上窗口管理器的局部变量,1KB 栈很容易爆。把栈提到 2KB 到 3KB,并且在FreeRTOSConfig.h里开configCHECK_FOR_STACK_OVERFLOW = 2,溢出时能进钩子函数而不是随机崩。

三是在中断里调了 GUI 函数。STemWin 的所有 API 都不是中断安全的,互斥量在中断里也用不了。中断里只能置标志位或者发队列,GUI 操作一律回任务里做。

5.5 现象:界面能显示,但按按钮有明显延迟,或者动画一顿一顿

先看 GUI 任务的优先级。如果它比某个 CPU 密集的任务低,那个任务一跑起来 GUI 就饿着。把 GUI 优先级提到中等偏上,但别给到最高。

再看回调里有没有耗时操作。WM_NOTIFY_PARENT里只要出现HAL_Delay、printf、SPI 读写屏这类动作,界面必然卡。所有耗时的事都该丢给队列,让别的任务去做。

最后看GUI_Exec的调用周期。如果循环里没有vTaskDelay,GUI 任务会一直占着 CPU,看起来是“很快”,实际上把别的任务全饿死了,整体表现反而更卡。10ms 是个好起点。

6. 二次开发:把两个按钮扩成一套控制面板

两个按钮跑通之后,真正的活才开始——绝大多数人接下来要做的是四路、八路开关,加上状态回读、加上页面切换。硬编码的if (id == 0) ... else if (id == 1)到第三个按钮就撑不住了。我在这种场景下会把事件处理抽成一张表:

控件 ID目标设备动作类型反馈方式
ID_BTN_LED1LED1翻转按钮文本切 ON/OFF
ID_BTN_LED2LED2翻转按钮文本切 ON/OFF
ID_BTN_FAN风扇长按持续图标颜色变化
ID_SLD_PWM背光值变更数值实时显示

表的每一行对应一个(id, handler)结构,回调里只做一次查表:

typedef void (*CtrlHandler_t)(WM_HWIN hBtn, uint8_t on); typedef struct { int id; /* GUIbuilder 分配或改名的控件 ID */ CtrlHandler_t onToggle; /* 点击后干什么 */ } CtrlEntry_t; static const CtrlEntry_t _aCtrlTable[] = { { GUI_ID_BUTTON0, _CtrlLed1 }, { GUI_ID_BUTTON1, _CtrlLed2 }, }; /* 回调里只留这一句,新增控件只改表 */ static void _Dispatch(int id) { size_t i; for (i = 0; i < GUI_COUNTOF(_aCtrlTable); i++) { if (_aCtrlTable[i].id == id) { _aCtrlTable[i].onToggle(WM_HWIN_NULL, 0); return; } } }

改造时有几个点要提前想清楚。第一,状态从哪来。按钮的 ON/OFF 文本必须跟着真实硬件状态走,不能点一下翻一下——设备如果因为保护逻辑自己关了,界面还显示 ON,就是骗人。稳妥做法是让 LED 任务在动作完成后回发一条确认消息,GUI 收到再更新控件。第二,长按和单击要区分,WM_NOTIFICATION_CLICKED只给按下,时长靠自己用GUI_X_GetTime()算,别在回调里阻塞等待。第三,控件多了以后GUI_Exec的周期要重新评估,8 个按钮的对话框,10ms 可能不够,改到 5ms 或者把重绘拆到空闲时做。

验证方法很土但有效:写一个只在调试版编译的测试任务,每两秒往队列里随机塞一条消息,让它连续跑一晚上。如果第二天早上界面还在正常刷新、灯的状态和界面显示一致,说明锁和内存这两块是真的过关了。这比手点几下靠谱得多,因为大多数 GUI 加 RTOS 的崩溃都是几小时才出一次的慢性的坑。

从那以后我每次往 RTOS 里塞图形库,都会先做两件事:把GUI_X_ErrorOut改成点灯加串口打印,以及跑一遍那个随机消息的压测任务。这两个动作花不了半小时,能省掉后面几天盯着屏幕猜的功夫。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进