2、硬件抽象层(HAL)解析

好,咱们进入第二章。上一章我聊了嵌入式GUI的整体架构,很多朋友听完觉得「嗯,大概知道怎么回事了」。但真要动手移植,第一个拦路虎就是——硬件抽象层,也就是HAL层。

说白了,HAL层就是给GUI引擎和底层硬件之间加了一层「翻译官」。你想想看,不同厂家的屏幕驱动、触摸芯片、按键扫描逻辑,千奇百怪。GUI引擎不想管这些破事,它只想要一个统一的接口。谁来做这个脏活?HAL层。

核心观点:HAL层存在的唯一目的,就是让GUI引擎「看不见」硬件差异。移植到一个新平台,你只需要重写HAL层,GUI引擎代码一行都不用动。

2.1 HAL层的作用与设计思想

我刚开始做GUI移植时,犯过一个低级错误——把LCD驱动直接写死在GUI引擎里。结果换了个屏幕,整个引擎都得改。后来我学乖了,老老实实抽出一层HAL。

HAL层到底管什么?我归纳了三点:

  • 屏蔽硬件差异:同样的「画一个点」,STM32的FSMC接口和Linux的framebuffer,实现天差地别。但HAL层对外暴露的接口签名是一样的。
  • 提供统一时序:GUI引擎需要心跳(Tick)来驱动动画和事件轮询。HAL层负责把硬件定时器的中断包装成标准接口。
  • 解耦输入设备:触摸屏可能是I2C的、SPI的,甚至电阻屏需要ADC采样。HAL层把这些统统抽象成「坐标+事件」。

设计思想上,我个人习惯遵循一个原则:最小接口原则。能暴露3个函数,绝不暴露5个。接口越多,移植工作量越大,出bug的概率也越高。

设计原则 说明 我的经验
最小接口 只暴露GUI引擎真正需要的函数 少即是多,接口每多一个,移植时就要多写一个实现
单向依赖 HAL层可以被GUI引擎调用,但HAL层不能反向依赖GUI引擎 否则会出现循环依赖,编译都过不了
可测试性 HAL接口应该能在PC上模拟测试 我习惯在Windows上先用SDL模拟一遍,再烧到板子上

2.2 显示驱动接口

显示驱动是HAL层最核心的部分。GUI引擎最终要呈现给用户的就是画面,所以显示接口的效率直接决定了UI的流畅度。

2.2.1 打点接口

最基础的接口——在指定坐标画一个像素点。原型通常长这样:

void HAL_Display_DrawPixel(uint16_t x, uint16_t y, uint32_t color);

参数很简单:x坐标、y坐标、颜色值。但这里有个坑——颜色格式。有的屏幕是RGB565,有的是RGB888,还有的是索引色。我建议在HAL层统一用uint32_t传递颜色,内部再做转换。这样GUI引擎不用关心底层颜色格式。

我曾经踩过的坑:有一次我直接传了RGB565的16位颜色值给HAL层,结果换了个RGB888的屏幕,所有颜色都偏色。后来我改成统一用uint32_t传RGB888,HAL内部自己转,再也没出过问题。

2.2.2 区域填充接口

打点接口虽然能用,但效率太低。画一个矩形区域,如果逐点画,性能惨不忍睹。所以需要区域填充接口:

void HAL_Display_FillRect(uint16_t x, uint16_t y, 
                          uint16_t w, uint16_t h, uint32_t color);

这个接口的作用是:在指定矩形区域内填充单一颜色。很多LCD控制器硬件上就支持区域填充,直接写寄存器就行,比软件循环快几十倍。

你想想看,GUI里的按钮、背景、进度条,哪个不是矩形?这个接口用好了,性能直接起飞。

2.2.3 刷屏接口

刷屏接口负责把一整块显存数据刷新到屏幕上。原型:

void HAL_Display_Flush(uint16_t x, uint16_t y, 
                       uint16_t w, uint16_t h, uint8_t *data);

参数里多了一个data指针,指向要刷新的像素数据。这个接口通常配合双缓冲使用:GUI引擎先在后台缓冲区绘制,绘制完成后调用Flush一次性刷到屏幕。

我个人习惯在Flush接口里做两件事:

  • 启动DMA传输(如果硬件支持),让CPU从数据传输中解放出来
  • 设置一个「正在刷屏」的标志位,防止GUI引擎在刷屏过程中再次调用Flush

小技巧:如果屏幕支持TE(Tearing Effect)信号,可以在Flush里等待TE信号到来再启动传输,避免画面撕裂。我做过一个项目,没加TE检测,画面滚动时总有一条撕裂线,加了之后完美解决。

2.3 输入设备接口

显示是输出,输入设备就是GUI的「耳朵」和「手」。没有输入,GUI就是个静态图片。

2.3.1 触摸接口

触摸屏的HAL接口,我一般设计成这样的结构体:

typedef struct {
    uint16_t x;        // 当前触摸点X坐标
    uint16_t y;        // 当前触摸点Y坐标
    uint8_t  pressed;  // 是否按下:1按下,0抬起
    uint8_t  touched;  // 是否有新触摸事件
} HAL_Touch_State_t;

HAL_Touch_State_t HAL_Touch_GetState(void);

GUI引擎会周期性地调用HAL_Touch_GetState,获取当前触摸状态。这里有个关键点——坐标校准。触摸屏的物理坐标和屏幕的显示坐标往往不是一一对应的,需要做线性变换。

我记得有个项目,触摸屏装歪了,点左上角结果响应在右上角。后来我在HAL层加了一个校准矩阵,把物理坐标映射到显示坐标,问题解决。

2.3.2 按键接口

有些嵌入式设备没有触摸屏,只有物理按键。按键接口相对简单:

typedef enum {
    KEY_NONE = 0,
    KEY_UP,
    KEY_DOWN,
    KEY_LEFT,
    KEY_RIGHT,
    KEY_ENTER,
    KEY_BACK
} HAL_Key_Code_t;

HAL_Key_Code_t HAL_Key_GetPressed(void);

这里要注意消抖。机械按键按下时会有抖动,不消抖的话一次按下可能被识别成多次。我习惯在HAL层做10-20ms的软件消抖,或者直接用硬件消抖电路。

避坑指南:按键的「长按」和「短按」逻辑,我建议放在HAL层之上,由GUI引擎的事件处理模块去判断。HAL层只负责告诉上层「哪个按键被按下了」,不负责语义解析。这样HAL层保持简单,移植时工作量最小。

2.4 定时器与心跳接口

GUI引擎需要「心跳」来驱动动画、定时刷新、超时检测。这个心跳通常由一个硬件定时器提供,周期一般是1ms或10ms。

接口设计:

uint32_t HAL_Tick_GetMs(void);
void     HAL_Tick_DelayMs(uint32_t ms);

HAL_Tick_GetMs返回系统启动至今的毫秒数,GUI引擎用它来计算时间差。比如一个按钮按下后0.5秒触发长按事件,引擎会记录按下时的时间戳,然后不断查询当前时间,差值超过500ms就触发。

HAL_Tick_DelayMs是阻塞延时,主要用于初始化阶段的等待。注意,GUI引擎的主循环里不要用这个函数,否则整个UI会卡死。

实现上,我通常用SysTick(Cortex-M内核自带的24位递减定时器)来产生1ms中断,在中断服务函数里累加一个全局变量:

volatile uint32_t g_sys_tick_ms = 0;

void SysTick_Handler(void) {
    g_sys_tick_ms++;
}

uint32_t HAL_Tick_GetMs(void) {
    return g_sys_tick_ms;
}

注意:如果GUI引擎和RTOS共用同一个SysTick,要小心冲突。我建议让RTOS管理SysTick,然后通过RTOS的API获取系统时间,再封装成HAL接口。否则两个模块抢同一个定时器,系统会乱套。

2.5 HAL层整体架构图

说了这么多,咱们用一张图把HAL层的整体结构串起来。这张图是我做移植时必画的,贴在工位上,改代码前先看一眼,思路就清晰了。

HAL层整体架构图 GUI 引擎 硬件抽象层 (HAL) 显示驱动接口 DrawPixel(x, y, color) FillRect(x, y, w, h, color) Flush(x, y, w, h, data) 输入设备接口 Touch_GetState() Key_GetPressed() (坐标校准 / 消抖) 定时器与心跳接口 Tick_GetMs() / Tick_DelayMs() 底层硬件(LCD / 触摸芯片 / 按键 / 定时器) GUI引擎只与HAL层交互 不直接操作硬件

从这张图可以看得很清楚:GUI引擎在最上层,它只认识HAL层暴露的几个接口。HAL层往下,才是真正的硬件。移植时,你只需要把中间这一层「翻译官」换掉,上下两层都不用动。

2.6 小结

这一章咱们把HAL层的三个核心模块拆开揉碎了讲了一遍。显示驱动负责「画」,输入设备负责「听」,定时器负责「拍子」。三者配合,GUI才能跑起来。

嗯,这里我要强调一句:HAL层的质量直接决定了移植的成败。接口设计得合理,移植就是填空;接口设计得糟糕,移植就是重构。我见过太多人图省事,把硬件相关的代码直接塞进GUI引擎,结果换平台时欲哭无泪。