4、移植前的硬件抽象层设计:GPIO、定时器、UART、PWM的HAL层封装,为RTOS移植铺路

好,咱们进入第四章。这一章很关键,说白了就是给RTOS移植打地基。

你想想看,RTOS要跑起来,得跟硬件打交道吧?GPIO要控制、定时器要调度、UART要打印、PWM要调光。如果每次换芯片都重写一遍驱动,那不得累死?

所以,我们需要一个硬件抽象层——HAL。它的作用就是:把硬件细节藏起来,给上层一个统一的接口

我个人习惯,在移植RTOS之前,先把这四样东西封装好。这样后面改芯片,只需要改HAL底层,上层代码纹丝不动。

4.1 为什么需要HAL层?

先说说我的血泪史。几年前我做一款智能路灯项目,主控从STM32F103换到GD32F103。按理说兼容性不错,但GPIO的库函数名字全变了。我花了整整两天改代码,改完还出了几个bug。

从那以后,我学乖了。不管用什么芯片,先封装一层HAL。上层代码只调用HAL接口,底层驱动随便换。

HAL层的好处,我总结了几点:

  • 隔离变化:芯片换了,只改HAL底层,上层不动
  • 方便测试:可以写模拟的HAL层,在PC上跑单元测试
  • 代码复用:同一个HAL接口,可以适配N种芯片
  • RTOS友好:RTOS的驱动模型,天然需要这种分层

核心思想:HAL层就是硬件和软件之间的翻译官。上层说“我要点亮LED”,HAL层去操作寄存器。上层不需要知道LED接在哪个引脚上。

4.2 GPIO的HAL封装

GPIO是最基础的。照明系统里,控制继电器、读取按键状态、驱动指示灯,都离不开它。

我一般这样设计GPIO的HAL接口:

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

#include <stdint.h>

/* GPIO引脚编号,用枚举定义,清晰明了 */
typedef enum {
    HAL_GPIO_LED_RED    = 0,
    HAL_GPIO_LED_GREEN  = 1,
    HAL_GPIO_RELAY_1    = 2,
    HAL_GPIO_RELAY_2    = 3,
    HAL_GPIO_KEY_1      = 4,
    HAL_GPIO_KEY_2      = 5,
    /* 根据需要继续添加 */
    HAL_GPIO_MAX
} hal_gpio_pin_t;

/* GPIO方向 */
typedef enum {
    HAL_GPIO_DIR_INPUT  = 0,
    HAL_GPIO_DIR_OUTPUT = 1
} hal_gpio_dir_t;

/* GPIO电平 */
typedef enum {
    HAL_GPIO_LEVEL_LOW  = 0,
    HAL_GPIO_LEVEL_HIGH = 1
} hal_gpio_level_t;

/* 初始化GPIO引脚 */
void hal_gpio_init(hal_gpio_pin_t pin, hal_gpio_dir_t dir);

/* 设置GPIO输出电平 */
void hal_gpio_set(hal_gpio_pin_t pin, hal_gpio_level_t level);

/* 读取GPIO输入电平 */
hal_gpio_level_t hal_gpio_get(hal_gpio_pin_t pin);

/* 翻转GPIO输出电平 */
void hal_gpio_toggle(hal_gpio_pin_t pin);

#endif /* _HAL_GPIO_H_ */

嗯,这里要注意。为什么用枚举而不是直接用数字?

因为可读性。你想想看,代码里写hal_gpio_set(2, 1),鬼知道2是哪个引脚?但写成hal_gpio_set(HAL_GPIO_RELAY_1, HAL_GPIO_LEVEL_HIGH),一目了然。

底层实现呢?以STM32为例:

/* hal_gpio_stm32.c */
#include "hal_gpio.h"
#include "stm32f1xx_hal.h"

/* 引脚映射表:把枚举映射到具体的GPIO端口和引脚 */
typedef struct {
    GPIO_TypeDef *port;
    uint16_t      pin;
} gpio_map_t;

static const gpio_map_t gpio_table[HAL_GPIO_MAX] = {
    {GPIOB, GPIO_PIN_0},   /* HAL_GPIO_LED_RED   */
    {GPIOB, GPIO_PIN_1},   /* HAL_GPIO_LED_GREEN */
    {GPIOC, GPIO_PIN_0},   /* HAL_GPIO_RELAY_1   */
    {GPIOC, GPIO_PIN_1},   /* HAL_GPIO_RELAY_2   */
    {GPIOA, GPIO_PIN_0},   /* HAL_GPIO_KEY_1     */
    {GPIOA, GPIO_PIN_1},   /* HAL_GPIO_KEY_2     */
};

void hal_gpio_init(hal_gpio_pin_t pin, hal_gpio_dir_t dir)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    if (pin >= HAL_GPIO_MAX) return;
    
    GPIO_InitStruct.Pin = gpio_table[pin].pin;
    if (dir == HAL_GPIO_DIR_OUTPUT) {
        GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
        GPIO_InitStruct.Pull = GPIO_NOPULL;
    } else {
        GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
        GPIO_InitStruct.Pull = GPIO_PULLUP;
    }
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(gpio_table[pin].port, &GPIO_InitStruct);
}

void hal_gpio_set(hal_gpio_pin_t pin, hal_gpio_level_t level)
{
    if (pin >= HAL_GPIO_MAX) return;
    HAL_GPIO_WritePin(gpio_table[pin].port, gpio_table[pin].pin, 
                      (GPIO_PinState)level);
}

/* 其他函数类似,不一一列举 */

个人经验:引脚映射表一定要用const修饰,放在Flash里。别用变量,浪费RAM。我曾经见过有人用switch-case做映射,代码又长又慢,后来改成查表法,清爽多了。

4.3 定时器的HAL封装

定时器是RTOS的心跳。FreeRTOS的SysTick、uC/OS的时钟节拍,都依赖定时器。

照明系统里,定时器还用来做PWM的时基、按键消抖、延时控制等。

我设计的定时器HAL接口,分两类:

  • 系统定时器:给RTOS提供心跳,一般是1ms中断一次
  • 通用定时器:给应用层用,比如定时1秒后关灯
/* hal_timer.h */
#ifndef _HAL_TIMER_H_
#define _HAL_TIMER_H_

#include <stdint.h>

/* 定时器回调函数类型 */
typedef void (*hal_timer_callback_t)(void *arg);

/* 初始化系统定时器,用于RTOS心跳 */
void hal_systick_init(uint32_t tick_hz);

/* 获取当前系统滴答计数 */
uint32_t hal_systick_get(void);

/* 通用定时器初始化 */
void hal_timer_init(void);

/* 启动一个单次定时器,单位ms */
int hal_timer_start_once(uint32_t ms, hal_timer_callback_t cb, void *arg);

/* 启动一个周期定时器,单位ms */
int hal_timer_start_periodic(uint32_t ms, hal_timer_callback_t cb, void *arg);

/* 停止定时器 */
void hal_timer_stop(int timer_id);

#endif /* _HAL_TIMER_H_ */

这里有个设计细节。为什么hal_timer_start_oncehal_timer_start_periodic要返回一个timer_id

因为你需要能停止它。如果不返回ID,你怎么知道要停哪个定时器?

我曾经在项目里犯过这个错。一开始没设计ID,结果想停定时器的时候,只能全部停掉,搞得其他功能也跟着停了。后来老老实实加了ID管理。

4.4 UART的HAL封装

UART在嵌入式开发里,就像人的嘴巴和耳朵。打印调试信息、跟电脑通信、甚至做AT指令控制,都离不开它。

照明系统里,UART常用于:

  • 调试日志输出
  • 与WiFi/蓝牙模块通信
  • 接收上位机控制指令

我一般这样封装:

/* hal_uart.h */
#ifndef _HAL_UART_H_
#define _HAL_UART_H_

#include <stdint.h>

/* UART端口号 */
typedef enum {
    HAL_UART_DEBUG = 0,   /* 调试串口 */
    HAL_UART_WIFI  = 1,   /* WiFi模块 */
    HAL_UART_BLE   = 2,   /* 蓝牙模块 */
    HAL_UART_MAX
} hal_uart_port_t;

/* UART配置参数 */
typedef struct {
    uint32_t baudrate;     /* 波特率 */
    uint8_t  data_bits;    /* 数据位:5/6/7/8 */
    uint8_t  stop_bits;    /* 停止位:1/2 */
    uint8_t  parity;       /* 校验位:0无/1奇/2偶 */
} hal_uart_config_t;

/* 初始化UART */
void hal_uart_init(hal_uart_port_t port, hal_uart_config_t *config);

/* 发送数据,阻塞模式 */
void hal_uart_send(hal_uart_port_t port, const uint8_t *data, uint32_t len);

/* 接收数据,非阻塞模式(需要配合中断) */
int hal_uart_receive(hal_uart_port_t port, uint8_t *buf, uint32_t max_len);

/* 注册接收回调(中断中调用) */
void hal_uart_set_rx_callback(hal_uart_port_t port, 
                               void (*cb)(uint8_t data));

#endif /* _HAL_UART_H_ */

避坑指南:UART的接收回调是在中断里执行的。千万不要在回调里做复杂操作,比如打印、malloc、延时。我曾经在回调里调了printf,结果死锁了,查了一天才发现是中断里调了不可重入函数。

4.5 PWM的HAL封装

PWM是照明系统的核心。调光、调色温、呼吸灯效果,全靠它。

不同芯片的PWM实现方式不一样。有的用定时器输出比较,有的用专用PWM模块。HAL层要统一接口。

/* hal_pwm.h */
#ifndef _HAL_PWM_H_
#define _HAL_PWM_H_

#include <stdint.h>

/* PWM通道 */
typedef enum {
    HAL_PWM_CH_LIGHT_1 = 0,   /* 灯光通道1 */
    HAL_PWM_CH_LIGHT_2 = 1,   /* 灯光通道2 */
    HAL_PWM_CH_LIGHT_3 = 2,   /* 灯光通道3 */
    HAL_PWM_CH_MAX
} hal_pwm_channel_t;

/* 初始化PWM通道 */
void hal_pwm_init(hal_pwm_channel_t ch, uint32_t freq_hz);

/* 设置占空比,0-1000对应0.0%-100.0% */
void hal_pwm_set_duty(hal_pwm_channel_t ch, uint16_t duty);

/* 启动PWM输出 */
void hal_pwm_start(hal_pwm_channel_t ch);

/* 停止PWM输出 */
void hal_pwm_stop(hal_pwm_channel_t ch);

#endif /* _HAL_PWM_H_ */

为什么占空比用0-1000而不是0-100?

因为精度。0-100只能到1%的精度,0-1000可以到0.1%。照明调光时,人眼对低亮度变化很敏感,1%的步进可能感觉太突兀。0.1%的步进就平滑多了。

底层实现时,要注意PWM频率的选择。照明系统一般用1kHz-10kHz。太低了会看到闪烁,太高了MOS管开关损耗大。

4.6 HAL层的集成与测试

封装好了,怎么验证?我有个习惯:写一个hal_test.c,把所有接口都跑一遍。

/* hal_test.c */
#include "hal_gpio.h"
#include "hal_timer.h"
#include "hal_uart.h"
#include "hal_pwm.h"

void hal_test_all(void)
{
    /* 测试GPIO */
    hal_gpio_init(HAL_GPIO_LED_RED, HAL_GPIO_DIR_OUTPUT);
    hal_gpio_set(HAL_GPIO_LED_RED, HAL_GPIO_LEVEL_HIGH);
    
    /* 测试UART */
    hal_uart_config_t uart_cfg = {115200, 8, 1, 0};
    hal_uart_init(HAL_UART_DEBUG, &uart_cfg);
    hal_uart_send(HAL_UART_DEBUG, (uint8_t*)"HAL OK\r\n", 8);
    
    /* 测试PWM */
    hal_pwm_init(HAL_PWM_CH_LIGHT_1, 1000);
    hal_pwm_set_duty(HAL_PWM_CH_LIGHT_1, 500);  /* 50% */
    hal_pwm_start(HAL_PWM_CH_LIGHT_1);
    
    /* 测试定时器 */
    hal_timer_init();
    hal_timer_start_once(1000, my_callback, NULL);
}

这个测试函数,每次移植完新芯片,第一个跑。如果所有接口都正常,说明HAL层移植成功了。后面RTOS移植就省心多了。

4.7 小结

这一章我们做了四件事:

  1. GPIO封装:用枚举定义引脚,查表法映射到具体硬件
  2. 定时器封装:分系统定时器和通用定时器,支持回调
  3. UART封装:支持多端口,中断接收回调
  4. PWM封装:0.1%精度占空比,适合照明调光

有了这层HAL,后面移植RTOS就轻松多了。你想想看,RTOS的时钟节拍直接调用hal_systick_get,任务里控制灯光调用hal_pwm_set_duty,多清爽。

下一章,我们就要开始真正的RTOS移植了。准备好了吗?