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_once和hal_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 小结
这一章我们做了四件事:
- GPIO封装:用枚举定义引脚,查表法映射到具体硬件
- 定时器封装:分系统定时器和通用定时器,支持回调
- UART封装:支持多端口,中断接收回调
- PWM封装:0.1%精度占空比,适合照明调光
有了这层HAL,后面移植RTOS就轻松多了。你想想看,RTOS的时钟节拍直接调用hal_systick_get,任务里控制灯光调用hal_pwm_set_duty,多清爽。
下一章,我们就要开始真正的RTOS移植了。准备好了吗?