第三章:硬件抽象层设计——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_value 和 voltage 两个字段。为什么?因为有些算法需要原始值(比如过流检测),有些需要电压值(比如速度计算)。让上层自己选。
经验之谈:ADC的采样时间不要设得太短。我曾经为了追求速度,把采样时间设到最小,结果采出来的值跳得厉害。后来查手册才发现,ADC输入阻抗和采样电容需要足够的充电时间。嗯,这个坑我踩过。
3.5 接口设计原则:统一、简洁、可移植
最后,我总结一下硬件抽象层接口设计的几个原则:
| 原则 | 说明 | 我的做法 |
|---|---|---|
| 统一命名 | 所有HAL接口用 模块_HAL_函数名 格式 |
如 GPIO_HAL_Init、PWM_HAL_SetDuty |
| 句柄化 | 每个外设用结构体句柄管理状态 | 避免全局变量满天飞 |
| 回调机制 | 中断事件通过回调通知上层 | 解耦中断处理和业务逻辑 |
| 参数抽象 | 用枚举和宏代替魔法数字 | 比如 GPIO_PIN_SET 而不是 1 |
| 分层实现 | 头文件放接口,C文件放具体实现 | 换芯片只换C文件,头文件基本不动 |
说白了,硬件抽象层就是给上层软件一个「干净」的接口。你想想看,如果上层代码里到处都是 *(volatile uint32_t *)0x40020014 |= 0x20 这种写法,那这个项目基本就废了——没人敢改,也没人能看懂。
好了,这一章的内容就到这里。下一章,我们会基于这个HAL层,搭建电机控制的核心逻辑——FOC算法框架。到时候你会发现,有了这一层的基础,写算法就像搭积木一样简单。