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层的整体结构串起来。这张图是我做移植时必画的,贴在工位上,改代码前先看一眼,思路就清晰了。
从这张图可以看得很清楚:GUI引擎在最上层,它只认识HAL层暴露的几个接口。HAL层往下,才是真正的硬件。移植时,你只需要把中间这一层「翻译官」换掉,上下两层都不用动。
2.6 小结
这一章咱们把HAL层的三个核心模块拆开揉碎了讲了一遍。显示驱动负责「画」,输入设备负责「听」,定时器负责「拍子」。三者配合,GUI才能跑起来。
嗯,这里我要强调一句:HAL层的质量直接决定了移植的成败。接口设计得合理,移植就是填空;接口设计得糟糕,移植就是重构。我见过太多人图省事,把硬件相关的代码直接塞进GUI引擎,结果换平台时欲哭无泪。