第三章:硬件抽象层设计——GPIO、定时器、PWM、ADC的底层封装与接口定义

好,咱们进入第三章。这一章,说白了就是给电机控制软件打地基。

你想想看,电机控制离不开硬件。你要控制电机转,就得用PWM发脉冲;要知道电机转没转,就得用ADC采样电流;要控制时序,就得用定时器。这些硬件资源,每个芯片厂家都有自己的寄存器操作方式。如果代码里到处都是直接操作寄存器,那换个芯片,整个项目就得重写。

所以,我们需要一个硬件抽象层(HAL)。它的作用,就是把底层硬件细节藏起来,向上层提供统一的接口。我个人的习惯是,哪怕项目初期只用一个芯片,也要把HAL层做出来。为什么?因为后期维护和移植会轻松很多。

3.1 GPIO的封装:从寄存器到位操作

GPIO是最基本的。电机控制里,GPIO用来控制使能信号、刹车信号、故障指示灯等等。别看它简单,封装不好,代码会很难看。

我见过不少项目,代码里到处都是 GPIOA->ODR |= (1<<5) 这种写法。嗯,这样写没问题,但可读性差,而且换芯片就得全改。

我的做法是,定义一组结构体和函数,把GPIO的操作抽象成「对象」。

核心思路:把GPIO的端口、引脚、模式封装成一个结构体,然后提供初始化、置位、复位、读取等接口。

来看一个典型的GPIO抽象层接口定义:

/* gpio_hal.h */
#ifndef __GPIO_HAL_H
#define __GPIO_HAL_H

#include "stdint.h"

/* GPIO引脚状态 */
typedef enum {
    GPIO_PIN_RESET = 0,
    GPIO_PIN_SET = 1
} GPIO_PinState_t;

/* GPIO模式枚举 */
typedef enum {
    GPIO_MODE_OUTPUT_PP,    /* 推挽输出 */
    GPIO_MODE_OUTPUT_OD,    /* 开漏输出 */
    GPIO_MODE_INPUT_FLOAT,  /* 浮空输入 */
    GPIO_MODE_INPUT_PU,     /* 上拉输入 */
    GPIO_MODE_INPUT_PD,     /* 下拉输入 */
    GPIO_MODE_AF_PP,        /* 复用推挽 */
    GPIO_MODE_AF_OD         /* 复用开漏 */
} GPIO_Mode_t;

/* GPIO对象结构体 */
typedef struct {
    void       *port;       /* 端口基地址,例如GPIOA */
    uint16_t    pin;        /* 引脚号,例如GPIO_PIN_5 */
    GPIO_Mode_t mode;       /* 工作模式 */
    uint8_t     speed;      /* 输出速度 */
} GPIO_Handle_t;

/* 接口函数 */
void GPIO_HAL_Init(GPIO_Handle_t *gpio);
void GPIO_HAL_WritePin(GPIO_Handle_t *gpio, GPIO_PinState_t state);
GPIO_PinState_t GPIO_HAL_ReadPin(GPIO_Handle_t *gpio);
void GPIO_HAL_TogglePin(GPIO_Handle_t *gpio);

#endif

你看,上层代码只需要关心 GPIO_Handle_t 这个结构体,以及那几个接口函数。至于底层是STM32还是GD32还是国产某M0内核,都由具体的实现文件去处理。

我的小技巧:GPIO_Handle_t 里用 void *port 而不是具体的寄存器指针。这样移植时,只需要改底层实现,上层结构体定义完全不用动。

3.2 定时器的封装:从计数到回调

定时器在电机控制里太重要了。PWM的周期、ADC的触发、速度环的周期计算,全都要靠定时器。我建议把定时器抽象成两个层面:

  • 基础定时器:提供延时、计时功能
  • 高级定时器:提供PWM生成、编码器接口、霍尔传感器接口等

这里我们先讲基础定时器的封装。高级定时器放到PWM那部分一起说。

定时器封装的核心,是提供「定时回调」机制。比如,我想每1ms执行一次速度计算,那就在定时器中断里调用一个注册好的回调函数。

/* timer_hal.h */
#ifndef __TIMER_HAL_H
#define __TIMER_HAL_H

#include "stdint.h"

/* 定时器句柄 */
typedef struct {
    void     *instance;    /* 定时器实例,如TIM2 */
    uint32_t  period;      /* 自动重装载值 */
    uint32_t  prescaler;   /* 预分频系数 */
    void    (*callback)(void); /* 溢出回调函数指针 */
} Timer_Handle_t;

/* 接口 */
void Timer_HAL_Init(Timer_Handle_t *htim);
void Timer_HAL_Start(Timer_Handle_t *htim);
void Timer_HAL_Stop(Timer_Handle_t *htim);
void Timer_HAL_SetPeriod(Timer_Handle_t *htim, uint32_t period_us);

/* 中断处理函数(由底层调用) */
void Timer_HAL_IRQHandler(Timer_Handle_t *htim);

#endif

这里有个关键点:callback 函数指针。我在项目中遇到过一个问题——多个定时器共用同一个中断向量,结果回调函数互相覆盖。后来我改成在中断处理函数里遍历所有注册的句柄,才彻底解决。

避坑指南:我曾经在回调函数里做太多事情,导致定时器中断还没退出,下一个中断又来了。记住,回调函数里只做标记或数据拷贝,真正的计算放到主循环或任务里去做。

3.3 PWM的封装:从频率到占空比

PWM是电机控制的灵魂。你要控制电机转速,本质上就是控制PWM的占空比。但底层实现时,不同芯片的PWM配置差异很大。

比如,STM32的PWM需要配置TIM的CCR寄存器,还要设置极性、对齐模式。而有些国产芯片,PWM和定时器是分开的模块。

我的抽象思路是:把PWM看作一个「信号发生器」,它有三个核心参数——频率、占空比、极性。

/* pwm_hal.h */
#ifndef __PWM_HAL_H
#define __PWM_HAL_H

#include "stdint.h"

/* PWM通道枚举 */
typedef enum {
    PWM_CHANNEL_1 = 0,
    PWM_CHANNEL_2,
    PWM_CHANNEL_3,
    PWM_CHANNEL_4
} PWM_Channel_t;

/* PWM句柄 */
typedef struct {
    void          *timer;        /* 关联的定时器句柄 */
    PWM_Channel_t  channel;      /* 通道号 */
    uint32_t       frequency;    /* 频率,单位Hz */
    uint16_t       duty_cycle;   /* 占空比,0-1000对应0.0%-100.0% */
    uint8_t        polarity;     /* 极性:0-高有效,1-低有效 */
} PWM_Handle_t;

/* 接口 */
void PWM_HAL_Init(PWM_Handle_t *hpwm);
void PWM_HAL_Start(PWM_Handle_t *hpwm);
void PWM_HAL_Stop(PWM_Handle_t *hpwm);
void PWM_HAL_SetDuty(PWM_Handle_t *hpwm, uint16_t duty);
void PWM_HAL_SetFrequency(PWM_Handle_t *hpwm, uint32_t freq_hz);

#endif

注意占空比我用的是 uint16_t,范围0-1000。为什么不用浮点数?因为嵌入式里浮点运算慢,而且容易有精度问题。0-1000对应0.0%到100.0%,精度0.1%,对电机控制来说足够了。

关键设计决策:PWM的频率和占空比分开设置。因为电机控制中,频率通常固定(比如20kHz),只调节占空比。如果每次设置占空比都要重新计算定时器参数,效率太低了。

3.4 ADC的封装:从采样到转换

ADC用来采样电流和电压。电机控制对ADC的要求很高:采样要快、要同步、要精准。

我建议把ADC抽象成两种模式:

  • 单次采样:用于调试或低速检测
  • 连续/触发采样:用于实时控制,通常由定时器触发

来看接口定义:

/* adc_hal.h */
#ifndef __ADC_HAL_H
#define __ADC_HAL_H

#include "stdint.h"

/* ADC通道配置 */
typedef struct {
    void      *instance;    /* ADC实例,如ADC1 */
    uint8_t    channel;     /* 通道号,如ADC_CHANNEL_0 */
    uint8_t    resolution;  /* 分辨率:8/10/12/16位 */
    uint32_t   sampling_time; /* 采样时间,单位时钟周期 */
} ADC_ChannelConfig_t;

/* ADC句柄 */
typedef struct {
    ADC_ChannelConfig_t  config;
    uint16_t             raw_value;   /* 原始ADC值 */
    float                voltage;     /* 转换后的电压值 */
    void               (*conv_cplt_callback)(uint16_t raw);
} ADC_Handle_t;

/* 接口 */
void ADC_HAL_Init(ADC_Handle_t *hadc);
void ADC_HAL_StartSingle(ADC_Handle_t *hadc);
void ADC_HAL_StartContinuous(ADC_Handle_t *hadc);
void ADC_HAL_Stop(ADC_Handle_t *hadc);
uint16_t ADC_HAL_GetRaw(ADC_Handle_t *hadc);
float ADC_HAL_GetVoltage(ADC_Handle_t *hadc);

/* 中断处理 */
void ADC_HAL_IRQHandler(ADC_Handle_t *hadc);

#endif

这里我特意保留了 raw_valuevoltage 两个字段。为什么?因为有些算法需要原始值(比如过流检测),有些需要电压值(比如速度计算)。让上层自己选。

经验之谈:ADC的采样时间不要设得太短。我曾经为了追求速度,把采样时间设到最小,结果采出来的值跳得厉害。后来查手册才发现,ADC输入阻抗和采样电容需要足够的充电时间。嗯,这个坑我踩过。

3.5 接口设计原则:统一、简洁、可移植

最后,我总结一下硬件抽象层接口设计的几个原则:

原则 说明 我的做法
统一命名 所有HAL接口用 模块_HAL_函数名 格式 GPIO_HAL_InitPWM_HAL_SetDuty
句柄化 每个外设用结构体句柄管理状态 避免全局变量满天飞
回调机制 中断事件通过回调通知上层 解耦中断处理和业务逻辑
参数抽象 用枚举和宏代替魔法数字 比如 GPIO_PIN_SET 而不是 1
分层实现 头文件放接口,C文件放具体实现 换芯片只换C文件,头文件基本不动

说白了,硬件抽象层就是给上层软件一个「干净」的接口。你想想看,如果上层代码里到处都是 *(volatile uint32_t *)0x40020014 |= 0x20 这种写法,那这个项目基本就废了——没人敢改,也没人能看懂。

好了,这一章的内容就到这里。下一章,我们会基于这个HAL层,搭建电机控制的核心逻辑——FOC算法框架。到时候你会发现,有了这一层的基础,写算法就像搭积木一样简单。