第二讲:硬件抽象层(HAL)设计——GPIO与定时器抽象、PWM生成接口、ADC采样封装
各位同学,咱们接着聊。上一讲我讲了固件架构的整体分层思路,这一讲咱们深入到底层——硬件抽象层。
说白了,HAL层就是给上层应用穿的一件「防弹衣」。你想想看,今天用STM32,明天换GD32,后天可能用国产某芯。如果代码里到处都是寄存器操作,那换芯片就等于重写。所以,HAL层的核心目标就一个:让上层代码不关心底层硬件是谁。
2.1 GPIO抽象——别让上层碰寄存器
先说说GPIO。步进电机驱动里,GPIO主要干三件事:
- 控制使能信号(EN)
- 控制方向信号(DIR)
- 读取限位开关、霍尔传感器等状态
我见过很多工程师,直接在应用层写 GPIOA->ODR |= (1<<5)。嗯,这样写确实快,但换芯片时你就哭了。
我的做法是,定义一个抽象结构体:
/* hal_gpio.h */
typedef struct {
void (*init)(void);
void (*set_high)(void);
void (*set_low)(void);
uint8_t (*read)(void);
} hal_gpio_t;
/* 具体实现:以STM32为例 */
static void stm32_enable_pin_init(void) {
LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_5, LL_GPIO_MODE_OUTPUT);
}
static void stm32_enable_pin_high(void) {
LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_5);
}
static void stm32_enable_pin_low(void) {
LL_GPIO_ResetOutputPin(GPIOA, LL_GPIO_PIN_5);
}
const hal_gpio_t gpio_enable = {
.init = stm32_enable_pin_init,
.set_high = stm32_enable_pin_high,
.set_low = stm32_enable_pin_low,
.read = NULL, /* 输出引脚不需要读 */
};
gpio_enable.set_high(),一看就知道是拉高使能引脚。
你可能会问:「每个引脚都写一套函数,不累吗?」其实可以用宏来批量生成。但我个人建议,前期别过度优化。先写清楚,后期再考虑代码生成。
2.2 定时器抽象——步进电机的「心跳」
步进电机驱动,核心就是定时器。为什么?因为步进电机需要精确的脉冲时序。我早期做的一个项目,脉冲频率偏差5%,电机就开始抖动了。
定时器抽象,我一般分两层:
- 底层定时器驱动:负责配置硬件定时器,处理中断
- 上层定时器服务:提供启动、停止、设置频率等接口
看代码:
/* hal_timer.h */
typedef struct {
void (*init)(uint32_t freq_hz);
void (*start)(void);
void (*stop)(void);
void (*set_frequency)(uint32_t freq_hz);
void (*register_callback)(void (*cb)(void));
} hal_timer_t;
/* 实现示例 */
static void (*timer_callback)(void) = NULL;
static void tim2_irq_handler(void) {
if (LL_TIM_IsActiveFlag_UPDATE(TIM2)) {
LL_TIM_ClearFlag_UPDATE(TIM2);
if (timer_callback) {
timer_callback();
}
}
}
static void stm32_timer_init(uint32_t freq_hz) {
/* 配置TIM2,根据freq_hz计算预分频和重装载值 */
uint32_t psc = 0, arr = 0;
/* ... 计算逻辑略 ... */
LL_TIM_SetPrescaler(TIM2, psc);
LL_TIM_SetAutoReload(TIM2, arr);
LL_TIM_EnableIT_UPDATE(TIM2);
NVIC_EnableIRQ(TIM2_IRQn);
}
const hal_timer_t timer_pulse = {
.init = stm32_timer_init,
.start = stm32_timer_start,
.stop = stm32_timer_stop,
.set_frequency = stm32_timer_set_freq,
.register_callback = stm32_timer_reg_cb,
};
2.3 PWM生成接口——不只是输出方波
步进电机驱动里,PWM有两种用途:
- 细分驱动:用PWM控制电流大小,实现微步
- 速度控制:改变PWM频率,调节电机转速
我设计的PWM接口,包含三个核心参数:频率、占空比、相位。为什么要有相位?因为两相步进电机,A相和B相的PWM需要相差90度电角度。
/* hal_pwm.h */
typedef struct {
uint32_t frequency; /* PWM频率,单位Hz */
uint16_t duty_cycle; /* 占空比,0-1000对应0.0%-100.0% */
int16_t phase_shift; /* 相位偏移,单位0.1度 */
} pwm_config_t;
typedef struct {
void (*init)(pwm_config_t *cfg);
void (*update_duty)(uint16_t duty);
void (*update_freq)(uint32_t freq);
void (*start)(void);
void (*stop)(void);
} hal_pwm_t;
这里有个细节:占空比我用0-1000,而不是0-100。为什么?因为0-100的精度不够。比如你需要12.5%的占空比,用0-100只能取12或13,误差就大了。用0-1000,125就是12.5%,精确多了。
2.4 ADC采样封装——电流检测的「眼睛」
步进电机驱动里,ADC主要用来采样:
- 相电流(用于闭环控制)
- 母线电压(用于过压保护)
- 温度(用于过热保护)
ADC封装,我习惯用「通道+结果回调」的模式:
/* hal_adc.h */
typedef enum {
ADC_CHANNEL_PHASE_A,
ADC_CHANNEL_PHASE_B,
ADC_CHANNEL_VBUS,
ADC_CHANNEL_TEMP,
ADC_CHANNEL_MAX
} adc_channel_t;
typedef void (*adc_callback_t)(adc_channel_t ch, uint16_t raw_value);
typedef struct {
void (*init)(void);
void (*start_conversion)(adc_channel_t ch);
void (*register_callback)(adc_callback_t cb);
uint16_t (*read_raw)(adc_channel_t ch);
float (*read_voltage)(adc_channel_t ch); /* 直接返回电压值 */
} hal_adc_t;
注意看,我提供了两个读取接口:read_raw 返回原始ADC值,read_voltage 直接返回电压值。为什么保留原始值?因为有些算法需要原始值做滤波,直接转成电压反而丢失了精度。
2.5 接口设计总结——少即是多
最后,我想说说接口设计的心得。很多新手喜欢把接口设计得「大而全」,什么功能都往里塞。结果呢?接口臃肿,维护困难。
我的原则是:接口只暴露「做什么」,不暴露「怎么做」。
| 模块 | 暴露的接口 | 隐藏的细节 |
|---|---|---|
| GPIO | set_high, set_low, read | 寄存器地址、位操作、时钟使能 |
| 定时器 | init, start, stop, set_freq | 预分频值、重装载值、中断优先级 |
| PWM | update_duty, update_freq | CCR寄存器、ARR寄存器、极性配置 |
| ADC | start_conversion, read_voltage | 采样时间、通道选择、DMA配置 |
你看,每个模块的接口都控制在5个以内。这样上层代码调用时,思路非常清晰。换芯片时,只需要重新实现这5个函数,上层代码一行都不用改。
嗯,这一讲就到这里。下一讲咱们聊聊「电机控制层」的设计——怎么把HAL层的这些接口组合起来,实现真正的步进电机控制。到时候我会拿一个实际项目案例来讲,包括加减速曲线怎么算、怎么避免丢步,敬请期待。