
简介基于STM32F429 DISC1开发板的STemwinFreeRTOS移植工程面向嵌入式GUI与实时操作系统初学者演示如何用GUIbuilder设计按钮并联动LED控制。工程完整包含FreeRTOS任务调度、STemwin图形库适配及LCD驱动配置采用竖屏显示布局有助于理解事件驱动编程与多任务协作。资源共530个文件以C源码.c、头文件.h为主附带编译生成的.o、.d中间文件以及.uvproj工程配置、.hex和.axf烧写调试文件压缩包约18.26MB目录结构便于按模块查阅。已有960人学习适合在官方DISC1板卡上对照移植。项目覆盖STM32F429硬件特性、FreeRTOS任务创建与同步、STemwin窗口和控件使用、GUIbuilder界面生成等关键点并给出两个按钮控制LED亮灭的完整事件链路可直接编译烧写观察现象对入门GUIRTOS组合开发很有参考价值。 把 FreeRTOS 和 STemWin 放到同一块 STM32F429-DISC1 上再用 GUI Builder 画两个按钮去点 LED听起来像是教科书里的经典组合但真到自己从头搭一遍你会发现大量问题都出在“协同”两个字上GUI 任务和 LED 控制任务之间怎么通信、没有触摸屏的板子怎么触发按钮事件、显存放在哪里才不花屏——这些才是实际动手时最消耗时间的部分。这篇文章我就把这个项目的完整过程拆开来讲从 CubeMX 建工程、FreeRTOS 任务划分、STemWin 移植参数到 GUI Builder 生成两个按钮控件、按钮事件通过队列控制板载 LED最后附上我实际调试中踩过的坑和排查思路。适合正在学 STemWin 移植、FreeRTOS 多任务 GUI 架构或者想在 F429 Discovery 这类板子上快速做出界面 demo 的嵌入式开发者参考。1. 项目拆解为什么是 FreeRTOS STemWin GUI Builder 这个组合1.1 先看清 DISC1 这块板子的脾气STM32F429-DISC1 的硬件配置对这个项目影响很大动手前一定要先把板载资源摸清楚。它用的是 STM32F429ZIT6Cortex-M4 内核主频可以跑到 180MHz内部 SRAM 有 256KB外挂一片 SDRAM具体容量和映射地址以 ST 官方原理图为准常见例程里映射在 0xD0000000 区域。板载一块 2.4 英寸、240x320 分辨率的 TFT LCD由 MCU 内部 LTDC 控制器直接驱动接口是 RGB 并口屏幕本身不带触摸。这个板子最特别的地方是它没有触摸屏。很多移植 STemWin 的教程默认用触摸点击按钮但在 DISC1 上你没法直接摸。另外板上有两个可以控制的 LED一个绿色 LED LD4接在 PA6低电平点亮还有一颗 RGB LED LD5红绿蓝分别接在 PG13、PJ14、PJ15具体信号名看板子丝印不同批次可能略有差异。板载用户按键 B1 接 PA0低电平有效。这些硬件细节直接影响后面的事件链路设计。LCD 分辨率决定了 GUI Builder 里窗口尺寸SDRAM 决定了显存放哪LED 引脚决定了控制代码的方向。我见过不少人在工程里把 PA6 当高电平点亮来写结果灯一直不亮其实就是没看原理图。1.2 事件链路设计GUI 按钮怎么变成 LED 动作这个项目的核心不是“点亮 LED”而是打通一条完整的 GUI 事件链路。我的设计是这样FreeRTOS 里跑两个任务一个是 GuiTask负责 STemWin 的初始化和界面刷新另一个是 LedCtrlTask专门负责操作 GPIO。两个按钮分别命名为“LED ON”和“LED OFF”点击后不直接在按钮回调里写 GPIO而是往一个 FreeRTOS 队列里发送事件消息LED 控制任务阻塞在队列上收到消息后再调用 HAL_GPIO_WritePin 改变 LED 状态。为什么绕这一圈因为 STemWin 的回调函数运行在 GUI 消息处理的上下文中如果在这里直接操作 GPIO对于点灯这种简单操作看不出来问题但一旦以后按钮事件要做耗时的事情读写 Flash、处理数据、驱动外设就会把 GUI 任务卡住界面掉帧、点击没反应。用队列解耦之后GUI 只管发消息具体谁来执行、执行多久都不管两个任务各干各的这是 FreeRTOS 和 GUI 配合最常见的架构模式。整个链路就是GUI Builder 生成两个按钮 → 按钮点击触发 WM_NOTIFY_PARENT 消息 → 回调中构造事件结构体 → xQueueSend 发送到队列 → LedCtrlTask 从队列中取到消息 → 根据事件类型点亮或熄灭 LED。这条链路清晰、可单独调试也是这个项目最有价值的地方。1.3 需要的软件清单与版本搭配STM32CubeMX用来生成基础工程、配置时钟、GPIO、LTDC、FMCSDRAM和 FreeRTOS。版本不用太新但建议用 6.x 以上对 F4 系列支持很成熟。STM32CubeF4 固件包里面带了 STemWin 中间件和例程路径一般在Middlewares/ST/STemWin下有库文件也有配置模板。GUI BuilderSEGGER独立的小工具和 STemWin 配套使用。注意它跟新一代的 AppWizard 不是一回事老工程用 GUI Builder 更直接不要买错。Keil MDK 或者 STM32CubeIDE我习惯用 Keil但 CubeIDE 一样能跑只要把 STemWin 库路径加对。版本匹配这件事很多人忽略。STemWin 本质是 emWin 的 ST 授权版库文件有 ARMCC、GCC、IAR 之分选错编译器对应版本链接阶段直接报一堆未定义符号。GUI Builder 生成的代码是通用的 C 文件跟 STemWin 版本不要求完全一致但建议尽量匹配避免生成的高版本宏在老库上不认识。2. 第一步用 CubeMX 把 FreeRTOS 和底层外设拉起来2.1 时钟、GPIO 与 LTDC 的基础配置我自己习惯先在 CubeMX 里把 FreeRTOS 使能、把外设配置好再手动添加 STemWin 库文件因为 CubeMX 生成的中间件集成虽然方便但有时候宏定义冲突会让排错变得复杂。时钟树配置的关键是给 LTDC 提供一个合适的像素时钟。在 CubeMX 的 Clock Configuration 页面里选择外部高速晶振 HSEPLL 输出主频设为 180MHz再配置 PLLSAI 让 LCD-TFT 时钟落在 6MHz~20MHz 之间。240x320 的小屏幕不需要太高像素时钟太高反而容易花屏。CubeMX 会自动把 PLL48CK 分频到 48MHz 给 USB/SDIO 等其他外设尽量别动这个。GPIO 配置方面LTDC 的引脚是由 CubeMX 根据 LTDC 使能自动分配的你只需要在 Pinout 视图里把 LTDC 打开系统会自动生成 RGB 数据引脚和行场同步引脚。LED 引脚要手动配置PA6 设置为 GPIO_Output用户按钮 PA0 设置为 GPIO_Input内部上拉打开。FMC 要打开 SDRAM Bank并把数据线、地址线、控制线对应到板载 SDRAM 的引脚。这里有一个很容易踩的坑FMC SDRAM 时序参数。CubeMX 给出的默认参数不一定能直接在你的板子上稳定工作如果 SDRAM 初始化失败STemWin 一申请内存就 HardFault。建议先用 ST 官方例程里的时序参数做模板再根据自己的板子微调别一上来就乱改。2.2 显存与 GUI 内存放哪是第一个关键决策STemWin 在 LTDC 驱动下所有的绘制操作最终都写进一层帧缓冲区framebuffer这个缓冲区就是显存。240x320 的屏幕如果用 RGB565 格式一帧需要 240 * 320 * 2 153600 字节也就是 150KB。F429 内部 SRAM 虽然有 256KB但还要跑 FreeRTOS 内核、任务栈、STemWin 自己的绘图内存如果把 150KB 显存放在内部 SRAM整个内存几乎就满了FreeRTOS 的堆很容易不够用任务创建都可能失败。所以正确的做法是把显存放到外接 SDRAM。DISC1 板载 SDRAM 映射在 FMC 地址空间一般在 0xD0000000 附近。STemWin 的 LCDConf 配置文件里会有一个宏定义比如#define LCD_LAYER0_FRAME_BUFFER 0xD0000000这样 STemWin 初始化 LTDC 层的时候直接将 SDRAM 地址作为帧缓冲区地址既能放下 150KB 显存还能顺便给 GUI 的绘图内存留出空间。STemWin 内部还需要一段内存来管理窗口、控件、字体等对象这个内存大小由 GUI_NUMBYTES 决定我建议设成 64KB 或 128KB。如果工程整体内存紧张可以像下面这样直接从 SDRAM 地址划分一块给 STemWinvoid GUI_X_Config(void) { static unsigned char *gui_mem (unsigned char *)0xD0200000; GUI_ALLOC_AssignMemory(gui_mem, 64 * 1024); }这样 GUI 绘图内存和显存互相独立内部 SRAM 留给 FreeRTOS各不干扰。2.3 FreeRTOS 任务划分与栈设置我用 CubeMX 的 FreeRTOS 中间件创建了两个任务参数如下表任务名优先级栈大小words职责GuiTask2Normal1024STemWin 初始化、窗口创建、消息循环LedCtrlTask1Below Normal256阻塞等待队列消息控制 LED GPIOBtnSimTask3Above Normal256轮询板载 B1 按键模拟鼠标点击 GUI 按钮GuiTask 的栈一定要给足。STemWin 在绘制窗口、处理字体、创建控件时会在当前任务栈上分配临时变量如果栈太小会直接 HardFault 或者界面画一半就死掉。我的经验是 1024 words4KB是最低底线如果对话框复杂或者用了大字体建议直接翻倍到 2048 words。LedCtrlTask 比较轻128~256 words 就够。BtnSimTask 这是无触摸屏板子的特殊设计后面会专门讲。CubeMX 生成的 FreeRTOS 默认用动态内存分配configTOTAL_HEAP_SIZE 我设置为 32KB实际跑下来完全够用。也可以根据自己的应用调整但要注意在 STemWin 使用外部 SDRAM 后FreeRTOS 堆就不需要很大了。3. STemWin 移植比想象中少比文档中多3.1 拿到 STemWin 库并让 CubeMX 工程认识它STemWin 三个部分缺一不可库文件.lib/.a、头文件inc 目录、配置文件Config 目录下的 GUIConf.c、LCDConf.c 等。从 STM32CubeF4 固件包复制Middlewares/ST/STemWin整个目录到工程目录下。如果是 Keil 工程选择 ARMCC 对应的库文件比如STemWin_CM4_OS_Keil.lib注意名字里的 OS 表示这个库支持操作系统接口多任务项目必须用带 OS 的版本。在 Keil 的 C/C Include Paths 里添加STemWin/inc然后把STemWin/Lib下的库路径加上。STemWin 的配置文件我建议直接从 ST 官方的 F429-Discovery 例程里复制 GUIConf.c 和 LCDConf.c再根据自己工程改。自己从零写这两个文件对刚接触的人来说坑太多了LTDC 层初始化参数、GUIDRV_FLEXCOLOR 驱动配置、行中断回调少了任一个屏幕要么黑屏要么刷新方向不对。3.2 GUIConf 和 LCDConf 里必须改的三处配置GUIConf.c 里的头号配置是 GUI_NUMBYTES控制 STemWin 内部堆内存大小。我前面建议设 64KB具体取决于你这个界面的复杂度。改法类似#define GUI_NUMBYTES (64 * 1024)第二个关键配置是操作系统支持宏。STemWin 如果要跑在 FreeRTOS 上必须开启 OS 相关选项#define GUI_OS (1) #define GUI_SUPPORT_TOUCH (0) /* 没有触摸屏关掉后面我们会用鼠标模拟 */GUI_OS 开启后STemWin 内部的绘制操作会用锁保护防止多个任务同时调用 GUI API 造成资源竞争。如果不开两个任务同时画界面就会出现花屏、控件错乱。LCDConf.c 里的重头戏是 LTDC 驱动的初始化和像素格式。ST 官方 F429 例程里已经写好了 LCD_X_Config 和 LCD_X_DisplayDriver你只需要关注几个宏#define LCD_XSIZE (240) #define LCD_YSIZE (320) #define LCD_BITSPERPIXEL (2) /* RGB565 */ #define LCD_USE_DMA2D (1)第三个容易被忽视的配置是行中断。STemWin 的 FLEXCOLOR 驱动在 LTDC 下为了提高刷新效率会使用行中断作为绘制同步点实现“跟着屏幕扫描线走”的局部刷新。如果你用的是 ST 例程的 LCDConf.c那里通常已经定义好了行中断处理函数但如果你的工程里没有使能 LTDC 全局中断这个机制就不会工作后果是整个 GUI 刷新性能明显下降拖动窗口时能看到撕裂感。3.3 没有触摸屏怎么让按钮能按下去这是 STM32F429-DISC1 上最需要变通的地方。STemWin 的输入设备不仅支持触摸还支持通用指针设备PID我们可以用 GUI_PID_StoreState 函数来上报鼠标坐标和按键状态把这个指针映射成屏幕上的点击事件。我的做法是单独写一个 BtnSimTask轮询板载 B1 按键。每次按下 B1就往 STemWin 上报一次“鼠标点击”并且轮流让鼠标落在两个 GUI 按钮的中心位置。这样按一次 B1 相当于点击“LED ON”按钮再按一次相当于点击“LED OFF”按钮视觉上你能在屏幕上看到一个鼠标箭头在按钮间切换。核心代码骨架如下static void BtnSimTask(void *argument) { GUI_PID_STATE state {0}; uint8_t target 0; for (;;) { if (HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin) GPIO_PIN_RESET) { vTaskDelay(pdMS_TO_TICKS(30)); // 消抖 if (HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin) GPIO_PIN_RESET) { if (target 0) { state.x 65; state.y 255; } // 按钮1中心 else { state.x 175; state.y 255; } // 按钮2中心 state.Pressed 1; GUI_PID_StoreState(state); vTaskDelay(pdMS_TO_TICKS(30)); state.Pressed 0; GUI_PID_StoreState(state); target ^ 1; while (HAL_GPIO_ReadPin(B1_GPIO_Port, B1_Pin) GPIO_PIN_RESET) vTaskDelay(pdMS_TO_TICKS(10)); } } vTaskDelay(pdMS_TO_TICKS(10)); } }如果你后续给 LCD 加了触摸屏模块只需要把触摸驱动里的坐标通过 GUI_TOUCH_StoreState 上报即可界面工程和事件链路完全不用动这也是这种分层设计的另一个好处。4. GUI Builder 生成按钮界面并把事件接进 FreeRTOS 队列4.1 GUI Builder 十分钟生成对话框GUI Builder 是个所见即所得的小软件。打开后新建一个对话框在属性里把对话框尺寸改成 240x320正好对应 DISC1 的屏幕。然后从控件栏拖两个 Button 到对话框底部分别设置文本为 “LED ON” 和 “LED OFF”调整大小位置让两个按钮横向排列并留出足够的间距。保存时勾选生成 C 文件和头文件它会输出两个文件一个是对话框资源表一个是回调函数模板。生成的 C 文件核心是两个部分一个静态的_aDialogCreate数组里面描述了对话框和按钮的创建参数另一个是_cbDialog回调函数负责处理 WM_INIT_DIALOG 和 WM_NOTIFY_PARENT 消息。static const GUI_WIDGET_CREATE_INFO _aDialogCreate[] { { FRAMEWIN_CreateIndirect, FrmMain, 0, 0, 0, 240, 320, 0, 0 }, { BUTTON_CreateIndirect, LED ON, ID_BUTTON_ON, 20, 240, 90, 30 }, { BUTTON_CreateIndirect, LED OFF, ID_BUTTON_OFF, 130, 240, 90, 30 }, };注意GUI Builder 生成的代码里按钮的“点击”并没有直接写在 Button 控件的回调里而是全部通过WM_NOTIFY_PARENT消息上抛给父对话框处理。这是初学者最容易懵的地方改按钮文本、样式是在_aDialogCreate数组里处理点击事件却要在父对话框的_cbDialog里不是在 Button 自己的回调里。4.2 读懂生成的回调结构别在按钮回调里写死 GPIO生成的回调函数模板大概长这样static void _cbDialog(WM_MESSAGE *pMsg) { WM_HWIN hItem; int Id, NCode; switch (pMsg-MsgId) { case WM_INIT_DIALOG: hItem WM_GetDialogItem(pMsg-hWin, ID_BUTTON_ON); BUTTON_SetText(hItem, LED ON); break; case WM_NOTIFY_PARENT: Id WM_GetId(pMsg-hWinSrc); NCode pMsg-Data.v; if (NCode WM_NOTIFICATION_RELEASED) { switch (Id) { case ID_BUTTON_ON: /* 在这里发送“点亮”消息 */ break; case ID_BUTTON_OFF: /* 在这里发送“熄灭”消息 */ break; } } break; default: WM_DefaultProc(pMsg); break; } }我建议只在WM_NOTIFICATION_RELEASED按钮松开时触发事件不要在WM_NOTIFICATION_CLICKED按下时触发。否则用户按住按钮还没松手LED 就已经动作了视觉上不太符合按钮的操作习惯。另外一次点击 STemWin 可能会派发多个通知码你在 switch 里只处理 RELEASED就能避免一个按钮发两次消息的问题。4.3 队列消息让 GUI 和 LED 任务各自专心接下来就是把事件真正发出去。我用一个简单的结构体作为队列消息typedef struct { uint8_t cmd; } LedMsg_t; #define LED_CMD_ON 1 #define LED_CMD_OFF 2在按钮回调里不再直接操作 GPIO而是发队列消息case WM_NOTIFY_PARENT: Id WM_GetId(pMsg-hWinSrc); NCode pMsg-Data.v; if (NCode WM_NOTIFICATION_RELEASED) { LedMsg_t msg; if (Id ID_BUTTON_ON) { msg.cmd LED_CMD_ON; xQueueSend(LedCtrlQueueHandle, msg, 0); } else if (Id ID_BUTTON_OFF) { msg.cmd LED_CMD_OFF; xQueueSend(LedCtrlQueueHandle, msg, 0); } } break;LedCtrlTask 这边就比较简单了static void LedCtrlTask(void *argument) { LedMsg_t msg; for (;;) { if (xQueueReceive(LedCtrlQueueHandle, msg, portMAX_DELAY) pdPASS) { if (msg.cmd LED_CMD_ON) { HAL_GPIO_WritePin(LD4_GPIO_Port, LD4_Pin, GPIO_PIN_RESET); // 低电平点亮 } else if (msg.cmd LED_CMD_OFF) { HAL_GPIO_WritePin(LD4_GPIO_Port, LD4_Pin, GPIO_PIN_SET); } } } }这里值得强调一点WM_NOTIFY_PARENT回调运行在 GuiTask 的上下文中所以这里用xQueueSend就够了不需要使用带 FromISR 后缀的中断安全版本。只有当按钮事件来自中断或定时器回调时才必须用xQueueSendFromISR。不少文章把这两个搞混导致信号量或队列操作不稳定。GuiTask 的主循环也比较固定static void GuiTask(void *argument) { GUI_Init(); CreateFrmMain(); while (1) { GUI_Exec(); GUI_Delay(10); } }GUI_Exec 处理所有待发的窗口消息和控件事件GUI_Delay(10) 在开启 OS 支持后会把 CPU 让给其他任务 10ms。这两个配合界面刷新和任务切换都很平滑。5. 联调、常见问题与我把过的坑5.1 一次正常演示的完整运行链路如果一切顺利上电后的现象是这样的LTDC 先点亮背光屏幕可能短暂白屏随后 STemWin 绘制出对话框框架和两个按钮。此时按一下板载 B1BtnSimTask 上报鼠标位置到“LED ON”按钮中心并触发一次点击按钮会有一个按下松开的视觉效果紧接着绿色 LED LD4 点亮。再按一次 B1鼠标移动到“LED OFF”按钮触发点击后 LD4 熄灭。从按下物理按钮到 LED 状态翻转中间经过了 BtnSimTask → GUI_PID_StoreState → STemWin 事件系统 → 按钮通知 → xQueueSend → LedCtrlTask → GPIO 写引脚整条链路每一步都可以单独验证。比如你在按钮回调里加一个串口打印就能确认是事件没触发还是 LED 驱动逻辑写反了。5.2 常见问题速查表现象可能原因排查思路屏幕黑屏或白屏LTDC 像素时钟配置不对或层参数未使能检查 PLLSAI 分频用官方例程的 LTDC 参数对比GUI_Init 后 HardFaultGUI 内存不足或 SDRAM 未初始化成功确认 FMC SDRAM 时序放大 GUI_NUMBYTES显存放到 SDRAM界面能出来但按钮点击无反应PID 坐标不对或只处理了 CLICKED 没处理 RELEASED打开 STemWin 调试宏打印 WM_NOTIFY_PARENT检查坐标是否在按钮范围内LED 不亮引脚配置错或低电平/高电平逻辑反了先用 HAL_GPIO_WritePin 单独测试 GPIO不通过 STemWin 直接翻转GUI 刷新卡顿、有撕裂行中断未使能或显存放在了内部 SRAM开启 LTDC 全局中断确认帧缓冲区在 SDRAM编译链接报未定义符号STemWin 库版本和编译器不匹配换成 ARMCC 对应的带 OS 库文件确认路径包含正确最典型的问题是 HardFault。我遇到的基本都是内存问题一是 GUI_NUMBYTES 太小STemWin 内部分配不到足够的堆资源二是任务栈太小画按钮时的局部数组把栈撑爆。这两种情况的处理方向完全不同前者调 GUIConf.c后者调 CubeMX 里任务栈大小一定要分辨清楚。5.3 几个值得长期保持的调试习惯STemWin 本身提供了一套调试接口可以在 GUIConf.c 或 LCDConf.c 里打开断言和日志输出。建议在移植阶段把 GUI_ASSERT 和 GUI_DEBUG_LEVEL 打开很多内存越界、参数错误会直接提示在哪一行出问题比自己在 Keil 里打断点高效得多。另外我在联调时习惯在按钮回调里加一个串口打印把 Id 值和 NCode 打出来。STemWin 的通知事件不是只发一次按下、释放、聚焦变化都会触发如果不先看清 NCode很容易以为按钮被按了两次。打印法是最直接的确认方式。最后再分享一个和整件事相关的小技巧如果后续你想把 240x320 的界面扩展到更大的屏幕比如 800x480只需改 LCDConf.c 里的分辨率和 LTDC 层参数GUI Builder 里的对话框尺寸一起改就行FreeRTOS 队列和 LED 控制任务一行都不用动。事件链路这种松耦合的设计在前面多花十分钟后面扩展时能省下非常多的时间。本文还有配套的精品资源点击获取