3. 分层架构设计:让每一层各司其职

做嵌入式GUI,最怕什么?我最怕的是「牵一发而动全身」。

早期我做一个小型仪表盘项目,显示逻辑和底层驱动全揉在一个文件里。客户说「换个屏幕型号」,好家伙,我改了三天,还引入了两个新bug。那时候我就意识到——不分层,迟早要还债。

分层架构,说白了就是把系统切成几块,每块只管自己的事。上层不关心下层怎么实现,下层也不依赖上层。这样改一处,不会炸一片。

3.1 三层架构:HAL、中间件、应用

我习惯把嵌入式GUI分成三层:硬件抽象层(HAL)、中间件层、应用层。层与层之间单向依赖——上层可以调用下层,下层绝不能反向调用上层。这是铁律。

核心原则:上层依赖下层,下层对上层一无所知。改上层不影响下层,换下层不影响上层。

先看一张整体结构图,后面我们逐层拆解。

嵌入式GUI三层架构图 嵌入式GUI三层架构 依赖方向:上层 → 下层(单向) 应用层(Application Layer) 页面管理 · 业务逻辑 · 用户交互 · 数据绑定 依赖:中间件层接口 中间件层(Middleware Layer) 控件库 · 事件管理 · 字体渲染 · 布局引擎 依赖:HAL层接口 硬件抽象层(HAL) LCD驱动 · 触摸输入 · 帧缓冲 · 定时器 不依赖任何上层

3.2 硬件抽象层(HAL)—— 最底层的「翻译官」

HAL层是直接和硬件打交道的。它把千奇百怪的硬件接口,封装成统一的API。上层不需要知道你的LCD是SPI还是8080并口,也不需要关心触摸芯片是GT911还是FT6336。

我的经验:HAL层的接口设计,一定要「够薄」。只做寄存器读写、时序控制、数据搬运,不要掺和任何业务逻辑。我曾经在一个HAL里塞了画面刷新策略,结果换屏时全得重写——血的教训。

一个典型的HAL接口长这样:

/* hal_display.h - 硬件抽象层显示接口 */
#ifndef HAL_DISPLAY_H
#define HAL_DISPLAY_H

#include <stdint.h>

/* 初始化显示控制器 */
void HAL_Display_Init(void);

/* 设置像素点 (x, y) 的颜色 */
void HAL_Display_SetPixel(uint16_t x, uint16_t y, uint16_t color);

/* 填充矩形区域 */
void HAL_Display_FillRect(uint16_t x, uint16_t y, 
                          uint16_t w, uint16_t h, uint16_t color);

/* 刷新整屏(用于双缓冲模式) */
void HAL_Display_Flush(void);

/* 获取屏幕尺寸 */
uint16_t HAL_Display_GetWidth(void);
uint16_t HAL_Display_GetHeight(void);

#endif /* HAL_DISPLAY_H */

你看,这些函数只做一件事:把数据送到屏幕。至于画的是按钮还是图表,HAL不关心。

3.3 中间件层 —— 承上启下的「工具箱」

中间件层是GUI的核心。它基于HAL提供的画点、画块能力,构建出控件、字体、布局、事件等高级功能。

我个人的习惯是,中间件层再细分成几个模块:

模块 职责 典型接口
控件库 按钮、标签、列表、进度条等基础控件 Button_Create(), Label_SetText()
事件管理 触摸/按键事件的捕获、分发、回调 Event_Register(), Event_Dispatch()
字体引擎 点阵/矢量字体的加载、缓存、渲染 Font_Load(), Font_DrawString()
布局引擎 相对定位、自动换行、对齐、边距 Layout_SetFlex(), Layout_Arrange()
图形基元 画线、画圆、画弧、图像裁剪 GUI_DrawLine(), GUI_DrawCircle()

注意:中间件层不能直接操作硬件寄存器。它只能调用HAL提供的接口。如果你发现中间件里出现了GPIO_SetLevel()或者SPI_WriteByte(),说明分层已经破了,赶紧重构。

举个例子,中间件层的按钮控件,内部是这样调用HAL的:

/* 按钮绘制函数(中间件层) */
static void Button_Draw(Button_t *btn) {
    /* 绘制按钮背景 - 调用HAL填充矩形 */
    HAL_Display_FillRect(
        btn->x, btn->y, 
        btn->width, btn->height, 
        btn->isPressed ? 0x841F : 0x0000
    );
    
    /* 绘制按钮文字 - 调用字体引擎(也是中间件模块) */
    Font_DrawString(
        btn->x + 10, btn->y + 5,
        btn->label,
        btn->isPressed ? 0xFFFF : 0x841F
    );
}

看到了吗?Button_Draw()只调用了HAL_Display_FillRect()和Font_DrawString()。它不知道也不关心这些函数背后是ILI9341还是ST7789。

3.4 应用层 —— 真正的「业务主场」

应用层是写业务逻辑的地方。页面跳转、数据刷新、用户交互响应,全在这里。

应用层只能调用中间件层的接口,绝对不能直接碰HAL。为什么?你想想看,如果应用层直接调了HAL_SetPixel(),那中间件层的控件状态管理、事件队列就全白干了。而且以后换硬件,应用层代码也得跟着改——分层就失去了意义。

分层铁律:应用层 → 中间件层 → HAL层。箭头方向就是依赖方向,永远不能反向。

一个典型应用层代码片段:

/* 应用层 - 主页面 */
void Page_Main_OnLoad(void) {
    /* 创建控件 - 调用中间件层接口 */
    Button_t *btnStart = Button_Create("开始", 50, 100, 120, 40);
    Button_t *btnStop  = Button_Create("停止", 200, 100, 120, 40);
    Label_t  *lblStatus = Label_Create("状态: 就绪", 50, 50, 200, 30);
    
    /* 注册事件回调 - 也是中间件层 */
    Event_Register(btnStart, EVENT_CLICK, Page_Main_OnStartClick);
    Event_Register(btnStop,  EVENT_CLICK, Page_Main_OnStopClick);
}

void Page_Main_OnStartClick(void *sender) {
    /* 业务逻辑:启动设备 */
    Motor_Start();  /* 假设Motor_Start是应用层自己的函数 */
    Label_SetText(lblStatus, "状态: 运行中");
}

你看,Page_Main_OnLoad()里没有出现任何HAL_开头的函数。它只和Button_Create()、Event_Register()这些中间件接口打交道。哪天我要把屏幕从320x240换成800x480,应用层代码一行都不用改——改HAL和中间件的适配参数就行。

3.5 接口规范:定义清楚,少踩坑

分层架构光有层还不够,层与层之间的接口必须定义清楚。我总结了几条硬规矩:

  • 接口文件独立:每层提供一个头文件(如hal_display.h、middleware_gui.h),只暴露必要的函数。内部实现细节全部隐藏。
  • 参数类型统一:颜色用uint16_t(RGB565),坐标用uint16_t,句柄用void*。别在HAL层用int,中间件层用short,应用层用long——转换起来烦死人。
  • 返回值规范:所有接口返回int32_t,0表示成功,负数表示错误码。错误码定义在各自层的头文件里。
  • 禁止跨层调用:应用层不能include hal_display.h,中间件层不能include app_config.h。编译时就要卡死。

避坑指南:我曾经在一个项目里,中间件层为了「性能优化」,直接调了HAL的SPI写函数。结果后来换了SPI Flash做存储,HAL的SPI接口改了参数,中间件层炸了一片。从那以后,我要求所有跨层调用必须走接口头文件,任何「抄近路」的代码都不允许合入。

3.6 单向依赖:为什么不能回头?

有人可能会问:「我让HAL层回调应用层的函数,行不行?」

我的回答是:不行。一旦下层依赖上层,分层就变成了循环依赖。你改上层,下层也得跟着改;你换下层,上层也得适配。说白了,又回到了「牵一发而动全身」的老路。

正确的做法是:如果下层需要通知上层,用回调函数注册机制。上层把回调函数指针注册到中间件层,中间件层在合适的时候调用它。这样依赖方向仍然是上层→中间件层,没有反向。

/* 中间件层 - 事件回调注册 */
typedef void (*EventCallback)(void *sender, void *userData);

void Event_Register(void *widget, uint32_t eventType, 
                    EventCallback callback, void *userData);

/* 应用层注册回调 */
Event_Register(btnStart, EVENT_CLICK, Page_Main_OnStartClick, NULL);
/* 依赖方向:应用层 → 中间件层,没有反向 */

嗯,这里要注意:回调函数的执行上下文是在中间件层,但回调函数的实现是在应用层。中间件层只是「存」了一个函数指针,并不知道它指向谁。这样既实现了通知,又没有破坏单向依赖。

3.7 小结

分层架构不是什么高深理论,它就是一套「各扫门前雪」的规矩。HAL层管硬件,中间件层管控件,应用层管业务。每层只和相邻层通过接口通信,绝不越级。

我做了这么多年嵌入式,见过太多项目因为不分层而陷入维护泥潭。早期多花两天把分层搭好,后期能省下两个月的改bug时间。这笔账,怎么算都划算。