简介:基于STM32F103的STemWin多页面显示工程,是一套面向嵌入式开发者与GUI入门学习者的可运行源码。STemWin是意法半导体针对STM32资源受限环境优化的图形库,本工程围绕多页面切换这一高频需求,提供从LCD初始化、内存分配、窗口创建、控件布局到事件回调的完整工程实现,有助于理解嵌入式图形界面的整体运转流程。
压缩包为RAR格式,共462个文件,约12.37MB,主要包括153个C头文件、84个C源文件、2个库文件,以及Keil工程配置、编译中间文件、hex固件与清理脚本等,构成一套可直接打开并烧录验证的完整嵌入式工程。已有417人参与学习。
源码内容覆盖了页面管理的关键环节:通过创建多个窗口与控件构成独立页面,借助栈结构维护页面历史,在事件回调中完成界面跳转,同时利用控件动态创建和销毁机制缓解内存压力。随包还可见GUIDEMO系列缩放旋转、图标视图、洗衣机界面等演示,对系统学习STemWin以及快速落地多界面项目均有直接参考价值。
1. 把STemWin多页面当成窗口管理问题,而不是画图问题
在 STM32F103 上做 STemWin(SEGGER emWin 的 ST 授权版)多页面显示,很多新手一开始就把精力花在绘制每个页面的图形上,结果页面能画出来,却切不动:按钮点了没反应、切页后背景花掉、或者跑一会儿直接进 HardFault。这个标题真正要解决的不是“怎么在屏幕上画东西”,而是“在只有 20~64KB SRAM 的 Cortex-M3 上,怎么管理多个页面的创建、切换、销毁和重绘”。STemWin 的多页面能力由窗口管理器(WM)承载,核心是窗口回调、WM_ShowWindow/WM_HideWindow这对显隐接口,以及内存在GUI_ALLOC堆里的分配策略。本文从移植配置讲起,落到一套可复现的源码骨架,再把内存排错和真机验证的技巧一起说清楚,适合正在用 F103 做完整界面、而不只是跑个 demo 的嵌入式工程师。
2. STemWin在STM32F103上的移植与显示驱动关键配置
2.1.1 先分清emWin、STemWin和源码包三者的关系
STemWin 是 ST 从 SEGGER 获得授权后随 MCU 一起分发的 emWin 版本,API 与 emWin 逐函数兼容,所以网上搜 emWin 的窗口管理教程,结论基本可以直接用在 STemWin 上。区别在于:emWin 官方源码包里有GUI/inc、GUI/Lib、GUI/ConvertColor等目录,而 STemWin 随 CubeF1 或者旧版 ST 固件库里通常以库文件加Config模板的形式给出。下载到的源码包里,真正需要你改的通常只有这几个文件:
GUIConf.h(内存池大小与功能开关)、GUIConf.c(GUI_X_Config里分配 STemWin 堆)、LCDConf_FlexColor.c(屏幕尺寸、颜色格式、底层读写函数)、GUIDRV_Template.c(可选的驱动模板)。至于GUI_X_Touch.c和GUI_X_F103.c这类文件,不同版本的包名略有出入,前者提供触摸坐标采样,后者提供GUI_X_Delay和GUI_X_GetTime这两个 OS 相关函数。
2.1.2 Keil工程里最小必要文件的组织方式
常见做法是把 STemWin 源码的inc头文件目录整个加进 include path,同时只把需要的.c文件参与编译。对于 F103,我一般这样组织:
Project/ ├─ STemWin/ │ ├─ inc/ # 全部头文件 │ ├─ Lib/ # STemWin 库文件(Keil 下是 .lib) │ ├─ Config/ │ │ ├─ GUIConf.h │ │ ├─ GUIConf.c │ │ ├─ LCDConf_FlexColor.c │ │ └─ GUITouchConf.h │ └─ GUI_X/ │ ├─ GUI_X_F103.c # 时钟与延时 │ └─ GUI_X_Touch.c # 触摸采样 ├─ User/ │ ├─ main.c │ ├─ stm32f10x_it.c │ └─ page.c # 多页面源码 └─ Startup/ └─ startup_stm32f10x_hd.s要注意 F103 根据 Flash/RAM 容量分 CL、MD、HD 等型号,启动文件必须对应器件,startup_stm32f10x_hd.s是 512KB 以上大容量型号用的。如果用的是stm32f103c8t6,它属于中容量,应使用startup_stm32f10x_md.s。启动文件里Stack_Size和Heap_Size建议至少保持默认值,Stack 不要小于 0x400,因为 STemWin 窗口回调里如果开了浮点或大局部结构体,栈很容易被撑爆。
2.1.3 库文件选择与预定义宏
Keil 工程中要把 STemWin 的库添加为Library或在 C/C++ 的 Linker 里Add进去。ST 在 F103 相关的包中提供的库文件名带编译器标识,比如 MDK 下以_CM3结尾,GCC 场景则是.a后缀。无论选哪个,都要在全局 Define 里给出对应配置:
USE_STDPERIPH_DRIVER STM32F10X_HD其中STM32F10X_HD要与启动文件对应,库函数版本的 F103 外设驱动靠这个宏选择寄存器映射范围。另外,GUIConf.h中如果启用了GUI_SUPPORT_MEMDEV,库内部会维护额外内存结构,F103 上不建议开太大的 memdev,这点后面第 4 章详述。
2.1.4 LCDConf_FlexColor.c 中的像素格式与底层读写
STemWin 的显示驱动在 F103 上最常见的组合是:FSMC 并口直连 ILI9341/ST7789,像素格式用GUICC_565。对应的LCDConf_FlexColor.c里核心是LCD_X_Config和LCD_X_DisplayDriver这两个函数。LCD_X_Config里按下面的方式把驱动和设备链接起来:
#include "GUI.h" #include "LCD.h" #include "GUIDRV_FlexColor.h" #define XSIZE_PHYS 240 #define YSIZE_PHYS 320 static void LCD_X_Config(void) { GUI_DEVICE *pDevice; CONFIG_FLEXCOLOR Config = {0}; GUI_PORT_API PortAPI = {0}; pDevice = GUI_DEVICE_CreateAndLink(GUIDRV_FLEXCOLOR, GUICC_565, 0, 0); LCD_SetSizeEx(0, XSIZE_PHYS, YSIZE_PHYS); LCD_SetVSizeEx(0, XSIZE_PHYS, YSIZE_PHYS); PortAPI.pfWrite8_A0 = LCD_WRITE_A0_REG; /* 写寄存器 */ PortAPI.pfWrite8_A1 = LCD_WRITE_A1_DATA; /* 写数据 */ PortAPI.pfWriteM8_A1 = LCD_WRITE_MULTI_DATA; /* 连续写多字节 */ GUIDRV_FLEXCOLOR_Config(pDevice, &Config); GUIDRV_FLEXCOLOR_SetFunc(pDevice, &PortAPI, GUIDRV_FLEXCOLOR_F66708, GUIDRV_FLEXCOLOR_M16C0B8); }F66708是驱动内部对 ILI9341 的配置项,表示该款屏以 RGB565 方式刷新。M16C0B8表示显存连续、单个像素两字节、按行写。LCD_SetVSizeEx必须调用,STemWin 需要知道虚拟坐标范围,否则WM_InvalidateWindow的裁剪矩形可能算错。
2.1.5 FSMC 读写函数与总线超时问题
底层十六位并口访问,直接操作 FSMC 的 bank 地址。F103 的 FSMC 地址映射中,Bank1 的 NE1 片选对应0x60000000起始地址,习惯上把 A0~A15 地址线接到 LCD 的 RS 引脚:
#define LCD_REG (*(volatile uint16_t *)0x60000000) #define LCD_RAM (*(volatile uint16_t *)0x60020000) void LCD_WRITE_A0_REG(uint8_t data) { LCD_REG = data; } void LCD_WRITE_A1_DATA(uint8_t data) { LCD_RAM = data; } void LCD_WRITE_MULTI_DATA(uint8_t *pData, int NumBytes) { uint16_t *p = (uint16_t *)pData; while (NumBytes > 0) { LCD_RAM = *p++; NumBytes -= 2; } }0x60020000的偏移来自 A0 地址线:0x60000000时 A0=0,0x60000000 | (1 << 17)时 A0=1,因为 16 位总线地址按半字对齐,A0 对应 CPU 地址的 bit17。这个映射关系不对,LCD 会完全没有任何反应。LCD_WRITE_MULTI_DATA里 NumBytes 必须保证偶数,STemWin 以 16bpp 刷屏时长度总是偶数,但如果自己调用底层接口写字符串,就要小心奇数长度。
2.1.6 GUIConf.c 内存池与初始化顺序
GUIConf.c里GUI_X_Config负责把所有 STemWin 对象的内存集中管理。F103 上常遇到的GUI_ALLOC_ERROR、窗口创建失败、WM_PAINT里访问空指针,多半都是这里给的内存太小。
#include "GUI.h" #define GUI_NUMBYTES (12 * 1024) /* F103C8 只能给这么多 */ static U32 aMemory[GUI_NUMBYTES / 4]; void GUI_X_Config(void) { GUI_ALLOC_AssignMemory(aMemory, GUI_NUMBYTES); GUI_ALLOC_SetMemDev(GUI_ALLOC_GetNumBytes() / 4); }GUI_ALLOC_AssignMemory指定 STemWin 动态内存区,GUI_ALLOC_SetMemDev设置 memdev 最多占用的字节数。F103C8 只有 20KB SRAM,给 STemWin 12KB 后,剩下 8KB 给任务栈和库内部使用。GUI_ALLOC_GetNumBytes()返回的是配置的总大小。用GUI_ALLOC_SetMemDev限制 memdev 容量,是防止切页时突然申请 150KB 导致崩溃的关键手段。
3. STemWin多页面切换:WM_ShowWindow与WM_HideWindow的消息驱动实现
3.1.1 对话框和顶层窗口的选择
STemWin 里做多页面,第一反应是用GUI_ExecDialogBox一页一页弹。这个方案在 PC 模拟器上很顺,但在 F103 上有个隐患:GUI_ExecDialogBox是阻塞式循环,执行完当前对话框回调才会返回。连续切换多个页面时,主循环里其他任务全被卡死。更稳妥的多页面实现是以WM_CreateWindow创建多个顶层窗口,配合WM_ShowWindow和WM_HideWindow在窗口间切换。每个页面有自己的窗口句柄和回调函数,STemWin 的窗口管理器保证同一时刻只有可见窗口参与重绘和触摸命中。
3.1.2 创建三个页面窗口的最小代码
下面是一个三页面(主界面、设置页、信息页)的骨架,这一套代码放在单独的page.c中,逻辑与main.c解耦:
#include "GUI.h" #include "WM.h" #include "BUTTON.h" #define ID_BTN_NEXT 0x10 #define ID_BTN_PREV 0x11 #define ID_BTN_INFO 0x12 #define PAGE_MAIN 0 #define PAGE_SETUP 1 #define PAGE_INFO 2 static WM_HWIN hPage[3]; static void _cbPageMain(WM_MESSAGE *pMsg); static void _cbPageSetup(WM_MESSAGE *pMsg); static void _cbPageInfo(WM_MESSAGE *pMsg); void Page_Init(void) { hPage[PAGE_MAIN] = WM_CreateWindow(0, 0, 240, 320, WM_CF_SHOW, _cbPageMain, 0); hPage[PAGE_SETUP] = WM_CreateWindow(0, 0, 240, 320, 0, _cbPageSetup, 0); hPage[PAGE_INFO] = WM_CreateWindow(0, 0, 240, 320, 0, _cbPageInfo, 0); }WM_CreateWindow参数依次是位置、尺寸、创建标志、回调函数、附加数据。注意只有PAGE_MAIN带WM_CF_SHOW,后两页默认隐藏。WM_CF_SHOW让窗口在创建后立刻可见,如果三个页面都加了,切换之前就会叠在一起,底层窗口被上层盖住后还会白白参与裁剪计算,所以只让初始页可见。
3.1.3 回调函数与WM_NOTIFY_PARENT消息链
窗口回调的职责是处理绘制和子控件通知。在按钮页面里,WM_NOTIFY_PARENT是 STemWin 向上层窗口报告子控件事件的通道。点击按钮后,BUTTON 控件会把通知发给父窗口:
static void _cbPageMain(WM_MESSAGE *pMsg) { switch (pMsg->MsgId) { case WM_PAINT: GUI_SetBkColor(GUI_WHITE); GUI_Clear(); GUI_SetColor(GUI_BLACK); GUI_SetFont(&GUI_Font24_ASCII); GUI_DispStringAt("Main Page", 60, 100); break; case WM_NOTIFY_PARENT: if (pMsg->Data.v == WM_NOTIFICATION_CLICKED) { int Id = WM_GetId(pMsg->hWinSrc); if (Id == ID_BTN_NEXT) { _PageSwitch(PAGE_SETUP); } } break; default: WM_DefaultProc(pMsg); } }pMsg->hWinSrc是发出通知的控件句柄,WM_GetId取出创建控件时赋予的 ID。pMsg->Data.v里装的是通知类型,WM_NOTIFICATION_CLICKED和WM_NOTIFICATION_RELEASED分别对应按下和抬起。不要在WM_NOTIFICATION_CLICKED里做耗时操作,切页动作太快会被下一次触摸事件打断,一般切页放在WM_NOTIFICATION_RELEASED里更稳。
3.1.4 页面切换函数的状态管理
切换函数要处理两个问题:目标页显示、其余页隐藏。最简单的是轮询句柄数组:
static void _PageSwitch(int index) { int i; if (index < PAGE_MAIN || index > PAGE_INFO) { return; } for (i = 0; i < 3; i++) { if (i == index) { WM_ShowWindow(hPage[i]); WM_BringToTop(hPage[i]); } else { WM_HideWindow(hPage[i]); } } }WM_BringToTop把目标窗口移到 z-order 顶部,保证它是最后一个被绘制的窗口,避免出现隐藏窗口残留像素。WM_HideWindow会把窗口连同子控件一起从可见集合中移除,但不会销毁窗口对象,所以下次WM_ShowWindow时按钮状态、编辑框内容都还在。
3.1.5 在页面上创建按钮控件
按钮要挂在页面窗口上,作为它的子窗口。用WM_CreateWindowAsChild配合WM_BUTTON_CREATE,或者用更高层的BUTTON_Create:
void Page_CreateButtons(void) { BUTTON_Create(150, 280, 70, 30, hPage[PAGE_MAIN], WM_CF_SHOW, ID_BTN_NEXT); BUTTON_SetText(hItem, "Next"); }BUTTON_Create的最后一个参数是资源 ID,这个 ID 会从WM_GetId读出来。按钮文本默认用 ASCII 字体,要显示中文需设置包含中文字符的字体(如GUI_FontHZ_SimSun_16或外挂 XBF 字库)。所有子窗口都跟随父窗口的显隐状态,父窗口隐藏时子控件不会收到任何触摸或重绘消息,这一点是多页面方案能成立的基础。
3.1.6 切页闪烁的根因与WM_CF_MEMDEV的取舍
闪烁的本质是旧窗口消失后、新窗口重绘完成前,屏幕有一段时间显示的是背景色。F103 上最直接的解决方式是用WM_CF_MEMDEV建窗口:把每个页面的绘制结果缓存到内存。但这个方案在 F103 上几乎不可行——一帧 240x320x2 字节就是 150KB,流程上必须先把窗口内容画进内存再整体拷贝到显存,这对 20KB SRAM 的 F103C8 是天花板以外的需求。因此切页闪烁的常见取舍是:
提示:启用
WM_SetCreateFlags(WM_CF_MEMDEV)只对单个页面的局部小窗口生效,不要对整屏顶层窗口开 MEMDEV。F103 上更实际的策略是“先隐藏、后显示”,并确保新页面 WM_PAINT 里只用一次GUI_Clear完成背景填充,避免绘制过程中背景色先露出来。
4. STM32F103内存极限下的STemWin页面资源管理
4.1.1 一页一窗口还是复用单个窗口
上一章的方案是“三个页面三个窗口”,但 F103 的资源不总是允许这样。每个顶层窗口在GUI_ALLOC堆里都占用独立的窗口结构体、裁剪区域和控件列表,创建 3~4 个页面窗口通常需要 2~4KB 的堆空间。如果工程里还要跑 FreeRTOS 任务栈、Modbus 协议栈或者 FATFS,STemWin 内存池就不可能给到 12KB。
另一个常见做法是复用单个窗口:只有一个顶层窗口,切换时靠重置窗口内容和回调数据。比如用一个current_page变量记录当前页,窗口回调的WM_PAINT里根据这个变量画不同界面。这个方案省内存,但是WM_NOTIFY_PARENT需要自己区分按钮 ID 对应的页面,回调里条件分支会越来越多。对页数与资源,建议按下面的表格估算后选择方案:
| 内存场景 | 推荐方案 | 代码结构 |
|---|---|---|
| STemWin堆 ≥ 12KB,页面 ≤ 4 | 多窗口 + Show/Hide | 每页独立回调,消息清晰 |
| STemWin堆 8KB~12KB,页面 ≤ 6 | 多窗口,但动态创建销毁 | 切页时WM_DeleteWindow旧页 |
| STemWin堆 < 8KB | 单窗口 + 状态机 | 回调里switch(current_page) |
单窗口方案里注意GUI_SetBkColor和GUI_Clear要在每次WM_PAINT开头执行,因为窗口内容没有任何缓存。
4.1.2 动态创建与静态创建的内存差异
静态创建(初始化时全部建好)的优点是运行期没有分配失败的风险,缺点是所有窗口同时占内存。动态创建则把窗口对象的生存期缩短:切到设置页时才创建设置页,离开时WM_DeleteWindow立刻释放。代价是每次创建都要重新设置控件内容和文本,而且频繁分配释放会在GUI_ALLOC堆里产生碎片。
WM_DeleteWindow会递归删除窗口及所有子控件,但它不回卷GUIConf.h里GUI_ALLOC_AssignMemory的释放顺序。碎片多了以后,会出现“总剩余内存够,但连续区段不够”的情况。因此动态方案里要监控GUI_ALLOC_GetNumFreeBytes()长时间运行后的变化。这个方法放到最后第 5 章讲验证时会给出具体写法。
4.1.3 GUI_ALLOC堆水位与峰值统计
STemWin 提供了几个查询内存状态的函数,可用来在开发阶段打印堆水位:
#include "GUI.h" void Mem_PrintStatus(void) { int Total, FreeBytes; Total = GUI_ALLOC_GetNumBytes(); FreeBytes = GUI_ALLOC_GetNumFreeBytes(); printf("ALLOC Total=%d Free=%d MaxUsed=%d\r\n", Total, FreeBytes, GUI_ALLOC_GetMaxUsed()); }GUI_ALLOC_GetMaxUsed()返回历史最高内存占用,这个值能直接告诉你能否把GUI_NUMBYTES调小。GUI_ALLOC_GetNumFreeBytes()是当前剩余值,页面切换后在页面上停留几秒,等所有延迟重绘执行完再读,才是真实水位。不要在WM_PAINT里打印,那会使重绘时间拉长,内存占用比稳态高很多。
4.1.4 位图与字体的内存优化点
多页面界面上最常见的非窗口内存开销是位图和字体。F103 上放 240x320 整屏位图是不现实的,一张就 150KB。常见做法是:
- 小图标用 4bpp/1bpp 压缩位图,
GUI_DrawBitmap配合LCD_BITMAP结构体显示; - 中文界面用 XBF 外部字体从 SPI Flash 读取,不要在内部 SRAM 放二级字库;
- 对话框内的 JPEG/PNG 图片用
GUI_JPEG_Draw边解码边显示,但它要求GUIDRV支持多缓冲,F103 上还是建议用 BMP 转换工具生成 C 数组或 XBF。
4.1.5 屏幕亮度与背光的电源侧考虑
严格说背光控制不属于 STemWin 窗口管理,但多页面切换若伴随 PWM 调光,页面切换时 PWM 占空比突变会造成肉眼可见的闪屏。通常的做法是把 PWM 更新放到单独的低优先级代码里,不要在WM_NOTIFY_PARENT里直接改TIM_SetCompare后立刻刷屏。更不要在每个页面的WM_PAINT里重置背光变量,那样页面切换时会看到亮度跳变。
5. STemWin多页面在真机上的验证与关键排错细节
5.1.1 测量页面切换耗时的硬件计时法
把切换耗时量出来,比盯着屏幕看流畅度可靠。F103 内核自带 DWT 周期计数器,不占用定时器资源:
#include "core_cm3.h" static volatile uint32_t tStart; void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } void _PageSwitch(int index) { tStart = DWT->CYCCNT; /* 原有切换逻辑 */ // ... } void Page_GetSwitchTimeUs(uint32_t *us) { *us = (DWT->CYCCNT - tStart) / (SystemCoreClock / 1000000); }切换完成后,在目标页的第一个WM_PAINT末尾再读一次DWT->CYCCNT,差值除以主频就是消耗的时间。不要把这个调用放在隐藏窗口的WM_PAINT里,隐藏窗口不会触发绘制,读到的时长会偏小。F103 在 72MHz 下,一页 240x320 纯文字界面刷新目标应控制在 30ms 以内,超过 100ms 就需要检查 FSMC 时序是否偏慢或GUI_Exec频度不够。
5.1.2 切换后屏幕无响应的排查顺序
切过去以后页面不刷新,或者点按钮没反应,按这个顺序查:先看新窗口回调是否收到WM_PAINT。在这些代码里加打印。连WM_PAINT都没收到,说明窗口还是隐藏状态,检查_PageSwitch里WM_BringToTop是否被漏调。收到WM_PAINT但屏幕没变,问题在 FSMC 写显存的方向或首地址偏移。触摸点不到按钮,则要验证触摸层坐标是否跟屏幕同原点:很多电容触摸屏的 Y 轴方向和 LCD 相反,这时要用GUI_TOUCH_Calibrate做坐标变换。
5.1.3 GUI_ALLOC 碎片与多页面长期运行的稳定性验证
多页面长时间切来切去,内存碎片会积累。一个有效的验证手法是:让设备在 3 个页面间以固定节奏循环切换 1000 次,每隔 50 次打印一次GUI_ALLOC_GetNumFreeBytes()。如果剩余内存持续下降,说明有窗口被反复创建却没有删除。如果剩余内存在某次切页后突然变少几 KB,多半是某个页面创建了大量控件且WM_DeleteWindow没有正确执行。
static void _PageStressTest(void) { static int cnt = 0; static int page = 0; if (++cnt % 50 == 0) { Mem_PrintStatus(); } page = (page == PAGE_INFO) ? PAGE_MAIN : page + 1; _PageSwitch(page); }在main里用软件定时器每 500ms 调一次_PageStressTest,跑 10 分钟看打印,碎片问题在开发阶段就能暴露。注意这个测试函数只在调试编译里使用,发布版本要移除,否则会影响正常触摸响应节奏。
5.1.4 STemWin页面停留在某页不再刷新的特定场景
还有一种情况是:切到设置页后,编辑框光标还在闪,但界面不再响应其他按钮。这通常是因为某个事件处理函数里写了一个长循环等待标志位,把 WM 的重绘消息全堵住了。STemWin 的WM_Exec是靠主循环反复调用来驱动消息分发的,任何阻塞都会导致整个界面冻结。把耗时操作拆成状态机,或者用定时器消息WM_TIMER分段完成,是这类问题的标准解法。
最后补充一个很隐蔽的坑:F103 的启动文件里堆大小设置过小时,GUI_ALLOC_AssignMemory的静态数组没问题,但malloc族函数会被底层库使用,如果你在页面上用了GUI_ALLOC_Alloc之外的标准库内存(例如某些字库加载代码里调malloc),堆不足会返回 NULL,然后 STemWin 收到 NULL 指针后直接崩溃。用工程内搜索把所有的标准库malloc换成GUI_ALLOC_Alloc,或者在启动文件里把Heap_Size明确改到 0x800 以上,这一步能避免一大类只在真机复现的 HardFault。
本文还有配套的精品资源,点击获取