简介:这份资源是面向嵌入式开发者的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 时,Step=2大约 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,可以加简单的过渡动画。这个习惯帮我省了很多次「界面写完才发现跑不动」的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取