
简介这份资源是面向嵌入式开发者的ZLG/GUI图形用户界面学习包尤其适合使用easyarm2200等开发板、需要在240×128像素等有限屏幕上构建交互界面的工程师与学生。内容围绕窗口管理、控件库、事件处理、布局管理、图形绘制与移植性等核心知识点展开帮助读者理解如何在资源受限的硬件上实现功能丰富且视觉良好的界面。压缩包共74个文件约596KB以c与h源码文件为主辅以o目标文件、axf可执行文件、s汇编文件、scf分散加载文件及hex烧录文件另含一份PDF实验说明便于对照源码与文档学习。目前已有774人学习下载。包内Gui实验工程包含示例代码与工程配置读者可据此快速上手窗口创建、控件使用与事件响应并借鉴其图形渲染与布局策略降低嵌入式GUI开发难度。1. ZLG/GUI 图形用户界面从裸机绘图到事件驱动的分水岭很多做嵌入式的人第一次接触 ZLG/GUI是在一块 240×320 的 TFT 屏上用打点函数一个像素一个像素地刷出一个按钮。刷完之后你会发现屏幕闪烁、按键响应迟钝、界面一多代码就乱成一团。这不是你代码写得差而是裸机绘图和 GUI 框架之间隔着一道分水岭——前者是「我命令屏幕画什么」后者是「屏幕告诉我发生了什么我决定画什么」。ZLG/GUI 图形用户界面的核心价值就是把这套事件驱动的消息机制搬进资源受限的单片机里。它不需要操作系统不需要外部 RAM甚至可以在 8KB RAM 的芯片上跑起来。适合谁适合那些用 STM32、GD32、NXP 等 Cortex-M 系列芯片做工业 HMI、仪器仪表、智能家居面板的工程师。你不需要 Linux不需要 Qt只需要一个能打点的 LCD 驱动剩下的窗口管理、消息分发、控件绘制它帮你兜住。2. 窗口与消息ZLG/GUI 到底怎么把界面跑起来2.1 从「打点」到「窗口」的抽象逻辑裸机绘图和 GUI 框架最本质的区别在于「谁拥有屏幕」。裸机绘图里你的主循环直接调用LCD_DrawPoint()屏幕是全局资源谁都能画。ZLG/GUI 的做法是引入「窗口」作为绘制单元每个窗口有自己的坐标、大小、回调函数和消息队列。主循环不再直接画图而是把「按键按下」「定时器到期」「触摸坐标」这些事件封装成消息投递给当前活动窗口。窗口的结构体通常长这样typedef struct { GUI_RECT Rect; // 窗口在屏幕上的矩形区域 GUI_HWIN hParent; // 父窗口句柄NULL 表示桌面 WM_CALLBACK* cb; // 消息回调函数 void* pUserData; // 用户私有数据 U8 Status; // 窗口状态可见/隐藏/禁用 } WM_Obj;这个结构体是 ZLG/GUI 窗口管理的核心。Rect决定了窗口的绘制边界和触摸命中区域cb是消息处理入口所有按键、触摸、定时消息都从这里进来pUserData让你把业务数据挂到窗口上避免全局变量满天飞。消息回调函数的签名一般是void OnWindowMessage(WM_MESSAGE* pMsg) { switch (pMsg-MsgId) { case WM_PAINT: // 重绘请求在这里调用绘制函数 break; case WM_TOUCH: // 触摸事件pMsg-Data.p 指向触摸坐标 break; case WM_KEY: // 按键事件 break; case WM_TIMER: // 定时器消息 break; default: WM_DefaultProc(pMsg); // 不处理的消息交回默认处理 break; } }逻辑说明WM_PAINT是系统在窗口需要重绘时自动发出的你不能在别的地方直接画图否则会被下一次重绘覆盖。WM_TOUCH和WM_KEY是输入事件pMsg-Data.p里装着具体数据。WM_DefaultProc是默认处理函数不处理的消息必须交回去否则窗口管理器的内部状态会乱。参数说明WM_MESSAGE结构体通常包含MsgId消息类型、hWin目标窗口句柄、Data联合体存触摸坐标、按键码或定时器 ID。不同版本的 ZLG/GUI 在字段命名上可能有差异但核心字段就这三个。2.2 最小可运行工程三个文件跑通第一个窗口我一般会用一个最小工程来验证移植是否成功只需要三个文件LCD驱动、GUI配置、主循环。下面以常见的 SPI 屏为例。第一步实现 LCD 底层打点和读点函数// lcd_port.c void LCD_DrawPoint(U16 x, U16 y, U16 color) { LCD_SetCursor(x, y); LCD_WriteData(color); } U16 LCD_ReadPoint(U16 x, U16 y) { LCD_SetCursor(x, y); return LCD_ReadData(); } void LCD_FillRect(U16 x0, U16 y0, U16 x1, U16 y1, U16 color) { U32 n (x1 - x0 1) * (y1 - y0 1); LCD_SetCursor(x0, y0); LCD_WriteReg(0x2C, 0); // 进入连续写模式 while (n--) { LCD_WriteData(color); } }逻辑说明LCD_DrawPoint是 ZLG/GUI 最底层的依赖必须实现。LCD_FillRect是可选优化如果不实现GUI 会用打点函数逐点填充速度会慢很多。LCD_ReadPoint在需要读回屏幕内容时用到比如窗口移动时的背景保存。参数说明x、y是屏幕坐标原点在左上角。color是 RGB565 格式ZLG/GUI 默认使用 16 位色。如果你的屏是 RGB888需要在配置里改颜色深度否则颜色会错乱。第二步配置 GUI 的裁剪区域和内存// gui_conf.c #define GUI_NUM_LAYERS 1 #define GUI_XSIZE 240 #define GUI_YSIZE 320 #define GUI_COLOR_DEPTH 16 static U8 gui_mem[GUI_XSIZE * GUI_YSIZE * 2]; // 全屏缓冲可选 void GUI_Config(void) { GUI_Init(); GUI_SetOrg(0, 0); GUI_SetClipRect(0, 0, GUI_XSIZE - 1, GUI_YSIZE - 1); }逻辑说明GUI_Init()初始化内部数据结构。GUI_SetClipRect设置裁剪区域防止绘制越界。如果 RAM 不够可以不分配全屏缓冲ZLG/GUI 支持直接写屏模式但窗口重叠时会有闪烁。参数说明GUI_XSIZE和GUI_YSIZE必须和你的 LCD 分辨率一致。GUI_COLOR_DEPTH通常设为 16如果你的屏支持 18 位或 24 位需要改这个值并重新实现打点函数。第三步创建窗口并进入主循环// main.c WM_HWIN hMainWin; void OnMainWinMessage(WM_MESSAGE* pMsg) { switch (pMsg-MsgId) { case WM_PAINT: GUI_SetBkColor(GUI_BLUE); GUI_Clear(); GUI_SetColor(GUI_WHITE); GUI_DispStringAt(Hello ZLG/GUI, 10, 10); break; case WM_TOUCH: if (pMsg-Data.p-Pressed) { GUI_SetColor(GUI_RED); GUI_FillCircle(pMsg-Data.p-x, pMsg-Data.p-y, 5); } break; default: WM_DefaultProc(pMsg); break; } } int main(void) { LCD_Init(); GUI_Config(); hMainWin WM_CreateWindow(0, 0, 240, 320, WM_CF_SHOW, OnMainWinMessage, 0); while (1) { WM_Exec(); // 处理消息队列 GUI_Delay(10); // 延时并刷新 } }逻辑说明WM_CreateWindow创建一个覆盖全屏的窗口WM_CF_SHOW表示立即显示。WM_Exec()是消息泵必须周期性调用否则消息不会分发。GUI_Delay内部会调用WM_Exec所以也可以只调GUI_Delay。参数说明WM_CreateWindow的前四个参数是x0, y0, x1, y1注意是右下角坐标而不是宽高。WM_CF_SHOW可以改成WM_CF_HIDE先隐藏后续用WM_ShowWindow显示。回调函数最后一个参数是用户数据指针可以传结构体进去。2.3 消息队列的深度与定时器精度怎么定消息队列深度和定时器精度是两个容易被忽略但直接影响体验的参数。队列太浅快速触摸时消息会丢定时器精度太低界面动画会卡顿。常见做法是消息队列深度设为 16 或 32。在GUI_Conf.h里找到GUI_MSG_QUEUE_SIZE改成 32。如果你的界面有大量滑动操作可以加到 64但每个消息占 8 到 12 字节32 条大约 384 字节对 RAM 的压力不大。定时器精度取决于你的硬件定时器。ZLG/GUI 需要一个 1ms 或 10ms 的时基。我一般用 SysTick 配 1ms在中断里调用GUI_TIMER_Exec()。如果界面没有动画10ms 也够用但触摸长按检测会变迟钝。// stm32f1xx_it.c 中的 SysTick_Handler void SysTick_Handler(void) { GUI_TIMER_Exec(); // 驱动 GUI 内部定时器 }逻辑说明GUI_TIMER_Exec()会检查所有注册的定时器是否到期到期后向对应窗口发送WM_TIMER消息。这个函数必须周期性调用间隔决定了定时器的最小分辨率。参数说明如果你用 1ms 时基GUI_TIMER_Exec()每 1ms 执行一次定时器精度就是 1ms。如果改成 10ms精度就是 10ms但 CPU 占用会降低。权衡点是触摸长按检测需要 10ms 以内的精度界面动画需要 20ms 以内的刷新率。3. 控件与绘图按钮、文本、进度条的落地写法3.1 按钮控件的三种状态与触摸命中判定ZLG/GUI 的按钮控件通常不是独立文件而是通过窗口加回调实现的。一个按钮至少要有三种状态正常、按下、禁用。触摸命中判定用矩形区域判断。typedef struct { GUI_RECT Rect; U8 State; // 0正常, 1按下, 2禁用 char Text[16]; } Button_t; void Button_Draw(Button_t* btn) { U16 bgColor (btn-State 1) ? GUI_DARKGRAY : GUI_LIGHTGRAY; GUI_SetColor(bgColor); GUI_FillRect(btn-Rect.x0, btn-Rect.y0, btn-Rect.x1, btn-Rect.y1); GUI_SetColor(GUI_BLACK); GUI_DispStringInRect(btn-Text, btn-Rect, GUI_TA_CENTER); } int Button_HitTest(Button_t* btn, int x, int y) { return (x btn-Rect.x0 x btn-Rect.x1 y btn-Rect.y0 y btn-Rect.y1); }逻辑说明Button_Draw根据状态选背景色按下时用深灰正常时用浅灰。Button_HitTest是纯几何判断不涉及 GUI 内部状态方便在WM_TOUCH里调用。参数说明GUI_TA_CENTER是文本对齐方式还有GUI_TA_LEFT、GUI_TA_RIGHT。GUI_FillRect的坐标是闭区间所以x1和y1要减 1 才是正确的宽高。3.2 文本显示字库裁剪与中文支持ZLG/GUI 默认只带 ASCII 字库要显示中文必须自己生成字库。常见做法是用取模软件生成点阵数组然后注册到 GUI 里。// 16x16 汉字点阵示例取模方式纵向取模字节倒序 const U8 Font16x16_Chinese[] { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // 中 字点阵 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // ... 省略后续字节 }; void GUI_DispChinese16(U16 x, U16 y, const U8* font, U16 color) { GUI_SetColor(color); for (int i 0; i 16; i) { U16 row (font[i * 2] 8) | font[i * 2 1]; for (int j 0; j 16; j) { if (row (0x8000 j)) { LCD_DrawPoint(x j, y i, color); } } } }逻辑说明每个汉字占 32 字节16 行每行 2 字节。row取出一行的 16 位数据逐位判断是否打点。这种方式直接写屏不经过 GUI 的裁剪和窗口管理适合在WM_PAINT里调用。参数说明取模方式必须和字库生成时一致否则汉字会错位。常见取模方式有「纵向取模字节正序」和「纵向取模字节倒序」用错了字会上下颠倒或左右镜像。3.3 进度条与滑动条用定时器驱动动画进度条的本质是一个矩形填充宽度随时间变化。用WM_TIMER驱动最自然。typedef struct { GUI_RECT Rect; int Value; // 0-100 int Step; // 每次增加的步长 } ProgressBar_t; void ProgressBar_OnTimer(ProgressBar_t* bar) { bar-Value bar-Step; if (bar-Value 100) { bar-Value 100; bar-Step -bar-Step; // 反向 } else if (bar-Value 0) { bar-Value 0; bar-Step -bar-Step; } WM_InvalidateRect(bar-Rect); // 请求重绘 }逻辑说明WM_InvalidateRect标记区域为无效下一次WM_Exec时会触发WM_PAINT。不要在定时器里直接画图否则会和窗口重绘冲突。参数说明Step控制动画速度正数表示增加负数表示减少。定时器周期设为 50ms 时Step2大约 2.5 秒走完全程。4. 避坑与排查移植 ZLG/GUI 时最容易翻车的五个地方4.1 屏幕花屏或颜色错乱现象移植后屏幕显示雪花点或者颜色完全不对红色变蓝色。原因颜色格式不匹配。ZLG/GUI 默认 RGB565如果你的 LCD 初始化成了 RGB888 或者 BGR 顺序颜色就会错。另外打点函数里高低字节顺序写反也会导致花屏。解决先确认 LCD 数据手册里的颜色格式然后在LCD_DrawPoint里做字节交换。如果是 BGR 顺序把 RGB565 的 R 和 B 通道互换U16 RGB565_SwapRB(U16 color) { return ((color 0xF800) 11) | (color 0x07E0) | ((color 0x001F) 11); }4.2 窗口重绘时闪烁严重现象每次触摸或定时器触发整个屏幕闪一下。原因没有使用内存设备或者裁剪区域设置过大。ZLG/GUI 在直接写屏模式下重绘时会先清背景再画内容中间过程被人眼捕捉到。解决启用内存设备Memory Device。在GUI_Conf.h里打开GUI_SUPPORT_MEMDEV然后用GUI_MEMDEV_Create创建缓冲。如果 RAM 不够至少把WM_SetCreateFlags(WM_CF_MEMDEV)打开让窗口在内存里合成后再刷到屏幕。4.3 触摸坐标偏移或反向现象点击按钮左边实际响应的是右边或者点击上方响应的是下方。原因触摸屏的坐标原点和 LCD 不一致或者 X/Y 轴接反了。电阻屏还需要校准。解决在触摸驱动里做坐标变换。常见做法是int Touch_Calibrate(int rawX, int rawY, int* outX, int* outY) { // 根据实测的两个校准点计算比例和偏移 *outX (rawX - xMin) * LCD_WIDTH / (xMax - xMin); *outY (rawY - yMin) * LCD_HEIGHT / (yMax - yMin); return 0; }如果 X/Y 反了交换outX和outY的赋值即可。4.4 消息队列溢出导致界面卡死现象快速点击或滑动后界面不再响应但屏幕还在刷新。原因消息产生速度大于处理速度队列满了之后新消息被丢弃窗口状态机卡在某个中间状态。解决加大队列深度同时在WM_Exec里一次处理多条消息。如果某个消息处理耗时太长比如刷大图把它拆成多个小消息分帧处理。4.5 定时器回调里调用阻塞函数现象界面动画卡顿触摸响应变慢。原因在WM_TIMER回调里调用了GUI_Delay或者等待某个标志位导致消息泵被阻塞。解决定时器回调里只做状态更新和WM_InvalidateRect所有耗时操作放到主循环里做。记住一个原则回调函数执行时间不要超过 5ms。5. 进阶技巧用内存设备和局部刷新把帧率提上去如果你已经跑通了基本窗口下一步大概率会遇到帧率问题。全屏重绘在 240×320 的屏上SPI 速率 30MHz 时大约需要 40ms也就是 25fps 左右。如果界面复杂一点掉到 10fps 以下触摸就会感觉「粘手」。我的做法是两步第一用内存设备做双缓冲第二只刷新变化区域。内存设备的创建方式GUI_MEMDEV_Handle hMem GUI_MEMDEV_Create(0, 0, 240, 320); GUI_MEMDEV_Select(hMem); // 在这里绘制所有内容 GUI_MEMDEV_CopyToLCD(hMem); // 一次性刷到屏幕 GUI_MEMDEV_Select(0);逻辑说明GUI_MEMDEV_Create在 RAM 里分配一块和屏幕等大的缓冲区。所有绘制操作先写到内存最后GUI_MEMDEV_CopyToLCD一次性传输。这样屏幕不会看到中间绘制过程闪烁基本消失。参数说明240×320×2 字节 150KB对大多数 Cortex-M3/M4 来说太大了。如果 RAM 不够可以只对局部区域创建内存设备比如只对按钮区域做缓冲。局部刷新的做法是记录「脏矩形」GUI_RECT dirtyRect; int hasDirty 0; void MarkDirty(int x0, int y0, int x1, int y1) { if (!hasDirty) { dirtyRect.x0 x0; dirtyRect.y0 y0; dirtyRect.x1 x1; dirtyRect.y1 y1; hasDirty 1; } else { if (x0 dirtyRect.x0) dirtyRect.x0 x0; if (y0 dirtyRect.y0) dirtyRect.y0 y0; if (x1 dirtyRect.x1) dirtyRect.x1 x1; if (y1 dirtyRect.y1) dirtyRect.y1 y1; } } void FlushDirty(void) { if (hasDirty) { GUI_MEMDEV_Handle hMem GUI_MEMDEV_Create(dirtyRect.x0, dirtyRect.y0, dirtyRect.x1 - dirtyRect.x0 1, dirtyRect.y1 - dirtyRect.y0 1); GUI_MEMDEV_Select(hMem); // 只绘制脏矩形内的内容 GUI_MEMDEV_CopyToLCD(hMem); GUI_MEMDEV_Select(0); GUI_MEMDEV_Delete(hMem); hasDirty 0; } }逻辑说明MarkDirty合并所有需要重绘的区域FlushDirty只对合并后的区域创建内存设备并刷新。这样即使界面很大每次刷新的像素数也控制在几千以内。参数说明GUI_MEMDEV_Create的坐标是绝对屏幕坐标不是相对坐标。GUI_MEMDEV_Delete必须调用否则内存泄漏。如果频繁创建删除内存设备可以考虑预分配几个固定大小的缓冲池。一个我踩过的坑内存设备创建时如果坐标超出屏幕范围某些版本的 ZLG/GUI 不会报错但CopyToLCD会花屏。所以MarkDirty里一定要做边界裁剪if (dirtyRect.x0 0) dirtyRect.x0 0; if (dirtyRect.y0 0) dirtyRect.y0 0; if (dirtyRect.x1 240) dirtyRect.x1 239; if (dirtyRect.y1 320) dirtyRect.y1 319;最后说一个习惯我每次移植完 ZLG/GUI第一件事不是写界面而是写一个「帧率测试窗口」——一个全屏矩形每帧改变颜色用示波器或者 GPIO 翻转来测实际刷新率。这个数字决定了后面所有动画和触摸响应的设计上限。如果只有 15fps就不要做滑动列表如果有 30fps可以加简单的过渡动画。这个习惯帮我省了很多次「界面写完才发现跑不动」的后悔药。希望帮到你。本文还有配套的精品资源点击获取