第二讲:硬件抽象层(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引脚都单独定义一个结构体实例。这样上层调用时,语义非常清晰——gpio_enable.set_high(),一看就知道是拉高使能引脚。

你可能会问:「每个引脚都写一套函数,不累吗?」其实可以用宏来批量生成。但我个人建议,前期别过度优化。先写清楚,后期再考虑代码生成。

2.2 定时器抽象——步进电机的「心跳」

步进电机驱动,核心就是定时器。为什么?因为步进电机需要精确的脉冲时序。我早期做的一个项目,脉冲频率偏差5%,电机就开始抖动了。

定时器抽象,我一般分两层:

  1. 底层定时器驱动:负责配置硬件定时器,处理中断
  2. 上层定时器服务:提供启动、停止、设置频率等接口

看代码:

/* 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%,精确多了。

核心原则:PWM接口的精度,至少要满足电机细分驱动的要求。比如16细分,每个微步对应的电流变化是1/16,那么占空比分辨率至少需要1/1000以上。

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 直接返回电压值。为什么保留原始值?因为有些算法需要原始值做滤波,直接转成电压反而丢失了精度。

我的经验:ADC采样时机很重要。步进电机PWM周期里,最好在PWM的「中点」采样,这时候电流最稳定。我一般用定时器的触发功能,让ADC在PWM的中间时刻自动采样,这样软件不用操心时序问题。

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层的这些接口组合起来,实现真正的步进电机控制。到时候我会拿一个实际项目案例来讲,包括加减速曲线怎么算、怎么避免丢步,敬请期待。