分层架构基础:经典三层架构(HAL、OSAL、APP)概念解析

做嵌入式开发久了,你会发现一个有意思的现象:很多项目一开始跑得挺欢,一旦要换芯片、换编译器、或者从裸机切到 RTOS,代码就崩得稀里哗啦。我早年吃过这个亏——一个智能家居项目,前期在 STM32F4 上调得稳稳当当,客户突然说要换成国产芯片降成本。结果呢?底层寄存器全得重写,中断向量表对不上,连 GPIO 的宏定义都得改。那段时间我就在想:有没有一种架构,能让应用代码跟硬件解耦,换芯片就像换衣服一样简单?

答案就是——分层架构。今天咱们就聊聊嵌入式领域最经典的“三层架构”:HAL、OSAL 和 APP。

为什么需要分层?

说白了,分层就是把“变”和“不变”拆开。硬件会变(芯片、外设、引脚),操作系统会变(裸机、FreeRTOS、RT-Thread),但你的业务逻辑——比如温度采集、PID 控制、数据上报——这些核心功能不应该跟着变。

我个人的习惯是:先画层,再写代码。哪怕项目再小,我也会在脑子里把这三层分清楚。你想想看,如果所有代码都揉在一个 main.c 里,那叫“意大利面架构”——看着热闹,改起来要命。

核心原则:上层依赖下层,但下层绝不依赖上层。每一层只通过接口跟相邻层通信。

三层架构全景图

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

嵌入式经典三层架构 APP 层(应用层) 业务逻辑 · 任务调度 · 协议栈 · 用户界面 OSAL 层(操作系统抽象层) 任务管理 · 信号量 · 队列 · 时间管理 · 内存管理 HAL 层(硬件抽象层) GPIO · UART · SPI · I²C · ADC · 定时器 · 中断 箭头方向:上层调用下层接口,下层不反向依赖上层

这张图我建议你存下来。每次写代码前看一眼,问问自己:这段逻辑该放哪一层?

HAL 层:硬件抽象层

HAL 层是离硬件最近的一层。它的任务就一个:把芯片厂商的寄存器操作封装成统一的接口。

举个例子,同样是点亮一个 LED:

  • STM32 上可能是 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET)
  • GD32 上可能是 gpio_bit_set(GPIOB, GPIO_PIN_0)
  • ESP32 上可能是 gpio_set_level(GPIO_NUM_0, 1)

如果没有 HAL 层,你的应用代码里就会到处是这些厂商特有的 API。换芯片?那得把所有文件翻一遍,改到手软。

我的经验:HAL 层接口尽量设计成“无状态”的——传入参数,返回结果,不依赖内部全局变量。这样测试起来特别方便。

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

/* hal_gpio.h */
#ifndef HAL_GPIO_H
#define HAL_GPIO_H

#include <stdint.h>

/* GPIO 方向枚举 */
typedef enum {
    HAL_GPIO_DIR_INPUT,
    HAL_GPIO_DIR_OUTPUT
} hal_gpio_dir_t;

/* GPIO 电平枚举 */
typedef enum {
    HAL_GPIO_LOW  = 0,
    HAL_GPIO_HIGH = 1
} hal_gpio_level_t;

/* HAL 接口声明 */
void    hal_gpio_init(uint8_t pin, hal_gpio_dir_t dir);
void    hal_gpio_set(uint8_t pin, hal_gpio_level_t level);
hal_gpio_level_t hal_gpio_get(uint8_t pin);

#endif /* HAL_GPIO_H */

你看,这个头文件里没有任何芯片相关的宏或寄存器。具体的实现放在 hal_gpio_stm32.c 或 hal_gpio_gd32.c 里,编译时通过链接文件选择。

注意:HAL 层不要包含任何业务逻辑。我曾经见过有人在 HAL 层里写延时等待——结果换芯片后延时参数全变了,排查了一整天。HAL 层只做“读写操作”,不做“决策”。

OSAL 层:操作系统抽象层

OSAL 层夹在中间,位置有点尴尬,但作用非常关键。它负责把操作系统的核心服务——任务创建、信号量、消息队列、时间管理——封装成一套统一的接口。

为什么要多这一层?因为嵌入式项目经常在“裸机”和“RTOS”之间切换。我做过一个项目,前期用裸机轮询跑原型,后期性能不够上了 FreeRTOS。如果没有 OSAL 层,所有任务相关的代码都得重写。

OSAL 层通常封装这些功能:

功能模块 典型接口 说明
任务管理 osal_task_create() 创建并启动一个任务/线程
信号量 osal_sem_create() / osal_sem_take() 同步与互斥
消息队列 osal_msg_send() / osal_msg_recv() 任务间通信
时间管理 osal_delay_ms() / osal_get_tick() 延时与时间戳
内存管理 osal_malloc() / osal_free() 动态内存分配

来看一个 OSAL 信号量的接口定义:

/* osal_sem.h */
#ifndef OSAL_SEM_H
#define OSAL_SEM_H

#include <stdint.h>

/* 信号量句柄(不透明指针) */
typedef void* osal_sem_t;

/* OSAL 信号量接口 */
osal_sem_t osal_sem_create(uint32_t initial_count);
int32_t    osal_sem_take(osal_sem_t sem, uint32_t timeout_ms);
int32_t    osal_sem_give(osal_sem_t sem);
void       osal_sem_delete(osal_sem_t sem);

#endif /* OSAL_SEM_H */

底层实现时,如果是 FreeRTOS,就映射到 xSemaphoreCreateBinary();如果是 RT-Thread,就映射到 rt_sem_create()。APP 层根本不需要知道底层用的是哪个 RTOS。

关键点:OSAL 层的接口参数要尽量简单。比如超时时间统一用毫秒,不要暴露操作系统的 tick 配置。这样上层代码的可移植性才高。

APP 层:应用层

APP 层是业务逻辑的所在地。温度采集、PID 控制、数据上报、按键处理、显示刷新——这些都在这里。

APP 层的代码有一个特点:它只调用 HAL 和 OSAL 的接口,绝不直接操作寄存器或 RTOS 的原生 API。这样做的好处是:

  • 换芯片时,APP 层代码一个字都不用改
  • 换 RTOS 时,APP 层代码一个字都不用改
  • 单元测试时,可以 mock 掉 HAL 和 OSAL 的接口,纯测业务逻辑

举个例子,一个简单的温度采集任务:

/* app_temperature.c */
#include "hal_i2c.h"
#include "osal_task.h"
#include "osal_time.h"

/* 温度传感器地址 */
#define TEMP_SENSOR_ADDR  0x48

void app_temp_task(void *param)
{
    uint8_t  buf[2];
    int16_t  raw;
    float    temp_c;

    (void)param;

    while (1) {
        /* 通过 HAL 层读取传感器数据 */
        hal_i2c_read(TEMP_SENSOR_ADDR, 0x00, buf, 2);

        /* 解析温度值(业务逻辑) */
        raw = (int16_t)((buf[0] << 8) | buf[1]);
        temp_c = raw * 0.0625f;

        /* 这里可以调用其他 APP 模块处理温度值 */
        /* app_data_report(temp_c); */

        /* 通过 OSAL 层延时 */
        osal_delay_ms(1000);
    }
}

你看,这个文件里没有 HAL_I2C_Mem_Read(),没有 vTaskDelay(),全是抽象的接口。换芯片?换 RTOS?APP 层不动。

一个小技巧:APP 层内部也可以继续分层。比如拆成“业务模块层”和“服务层”。但对外部(HAL 和 OSAL)的依赖关系要保持不变。

三层之间的协作关系

这三层不是各自为政的,它们之间有明确的协作规则:

  1. APP 调用 OSAL 和 HAL:APP 层通过 OSAL 获取系统服务,通过 HAL 操作硬件。
  2. OSAL 调用 HAL:OSAL 层有时也需要访问硬件——比如系统 tick 定时器、串口控制台。这时候 OSAL 会调用 HAL 的接口。
  3. HAL 不调用任何上层:HAL 层是“最底层”,它只操作寄存器,不依赖 OSAL 或 APP。
  4. 中断处理:中断服务函数通常放在 HAL 层,但可以通过回调函数通知 APP 层。回调函数注册在 APP 层初始化时完成。

我个人的习惯是,在项目启动时用一个表格把这三层的依赖关系列清楚:

层 依赖 不依赖
APP HAL 接口、OSAL 接口 具体芯片、具体 RTOS
OSAL HAL 接口(少量) APP 业务逻辑
HAL 芯片寄存器、厂商 SDK OSAL、APP

避坑指南

讲了这么多好处,我也得说说实际落地时容易踩的坑。这些都是我亲身经历过的:

  • 过度抽象:我曾经见过有人把 HAL 层封装了七八层,结果一个 GPIO 翻转要调用 5 个函数。抽象要适度,够用就行。
  • 接口膨胀:HAL 和 OSAL 的接口不是越多越好。每增加一个接口,移植时就多一份工作。保持接口最小化。
  • 性能妥协:有些场景——比如高速 SPI 通信、中断响应——直接操作寄存器可能比通过 HAL 快 10 倍。这时候可以在 HAL 层提供“快速路径”接口,绕过不必要的封装。
  • 层间耦合:最怕的是 APP 层偷偷调用厂商 SDK。代码审查时要特别注意这一点。
我曾经踩过的坑:一个项目里,APP 层直接调用了 FreeRTOS 的 xTaskGetTickCount(),后来切到 RT-Thread 时,所有用到这个函数的地方都得改。如果当时用了 OSAL 的 osal_get_tick(),一行代码都不用动。

小结

经典三层架构不是什么高深的理论,它就是一套“约定”。HAL 管硬件,OSAL 管系统服务,APP 管业务。各司其职,互不越界。

刚开始你可能觉得多写一层接口很麻烦。但等你经历过一次芯片换型、一次 RTOS 迁移,你就会明白——这些“麻烦”其实是在给未来的自己铺路。