3、硬件抽象层(HAL)设计:GPIO、定时器、ADC、PWM的封装原则与接口规范

好,咱们进入第三讲。这一讲,我打算聊聊硬件抽象层,也就是HAL层。很多刚入行的朋友,一上来就对着芯片手册写寄存器,代码跟芯片型号绑得死死的。换个MCU,整个项目几乎重写。这活儿,我干过,太累了。

硬件抽象层,说白了就是给上层软件一个统一的“假象”。不管底层是STM32还是GD32,是英飞凌还是NXP,上层调用的函数名、参数、行为,都得一样。这才是架构的价值。

3.1 封装原则:别让上层知道你在用哪个寄存器

我个人习惯,HAL层有三大原则:

  • 接口统一化:同样的功能,接口签名必须一致。比如初始化函数,参数结构体统一。
  • 行为可预测:函数执行时间、副作用、错误码,都得有明确约定。
  • 硬件无关性:头文件里不能出现任何芯片厂商特有的宏或类型。

核心思想:上层代码只依赖HAL接口,不依赖具体实现。这样,换芯片时只需要换HAL层的源文件,应用层一行代码都不用改。

我在项目中遇到过一件事:有个同事把GPIO的拉电流配置直接写死在应用层。后来换了个低功耗芯片,GPIO驱动能力不够,整个项目排查了两天。嗯,这就是没做好抽象的代价。

3.2 GPIO封装:别只给个Set/Get

GPIO看似简单,但封装不好,坑很多。你想想看,一个引脚可能做输出、输入、中断、模拟功能。如果只封装成GPIO_SetPin()GPIO_GetPin(),那复用功能怎么办?

我建议这样设计:

// gpio_hal.h
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_ANALOG        // 模拟模式
} gpio_mode_t;

typedef struct {
    uint8_t  port;      // 端口号,0=A, 1=B ...
    uint8_t  pin;       // 引脚号,0-15
    gpio_mode_t mode;   // 工作模式
    uint8_t  speed;     // 速度等级
} gpio_config_t;

void HAL_GPIO_Init(gpio_config_t *cfg);
void HAL_GPIO_DeInit(uint8_t port, uint8_t pin);
void HAL_GPIO_WritePin(uint8_t port, uint8_t pin, uint8_t state);
uint8_t HAL_GPIO_ReadPin(uint8_t port, uint8_t pin);
void HAL_GPIO_TogglePin(uint8_t port, uint8_t pin);

避坑指南:我曾经把端口和引脚合并成一个uint16_t,结果不同芯片的引脚编号规则不一样,移植时吃了大亏。后来我坚持用port+pin分开,虽然参数多了点,但清晰多了。

3.3 定时器封装:别暴露计数器寄存器

定时器是电机控制的灵魂。PWM周期、死区时间、触发ADC采样,全得靠它。但不同芯片的定时器结构差异很大——有的16位,有的32位,有的带互补输出,有的不带。

我的做法是:把定时器抽象成几个基本功能模块。

// timer_hal.h
typedef enum {
    TIMER_MODE_BASIC,       // 基本定时
    TIMER_MODE_PWM,         // PWM输出
    TIMER_MODE_INPUT_CAPTURE, // 输入捕获
    TIMER_MODE_ENCODER      // 编码器模式
} timer_mode_t;

typedef struct {
    uint8_t  tim_id;        // 定时器编号
    timer_mode_t mode;      // 工作模式
    uint32_t period;        // 周期(单位:定时器时钟周期数)
    uint16_t prescaler;     // 预分频
    uint8_t  channel;       // 通道号(PWM/捕获时使用)
} timer_config_t;

void HAL_TIM_Init(timer_config_t *cfg);
void HAL_TIM_Start(uint8_t tim_id);
void HAL_TIM_Stop(uint8_t tim_id);
void HAL_TIM_SetCompare(uint8_t tim_id, uint8_t channel, uint32_t value);
uint32_t HAL_TIM_GetCounter(uint8_t tim_id);

这里要注意:periodprescaler的数值范围,不同芯片不一样。我习惯在HAL层内部做范围检查,如果超出硬件能力,返回错误码,而不是默默截断。

警告:千万别把定时器的ARR、PSC寄存器直接暴露给上层。我见过有人把ARR寄存器地址直接传进函数,换芯片后ARR地址变了,代码编译通过但跑飞了。这种bug极难排查。

3.4 ADC封装:采样序列与触发源是关键

电机控制里,ADC通常用来采样相电流和母线电压。但ADC的玩法很多:单次采样、连续采样、注入采样、同步采样。不同芯片的ADC寄存器差异巨大。

我建议这样抽象:

// adc_hal.h
typedef enum {
    ADC_TRIGGER_SOFTWARE,   // 软件触发
    ADC_TRIGGER_TIMER,      // 定时器触发
    ADC_TRIGGER_EXT         // 外部引脚触发
} adc_trigger_t;

typedef struct {
    uint8_t  adc_id;        // ADC编号
    uint8_t  channel;       // 模拟通道
    adc_trigger_t trigger;  // 触发源
    uint8_t  sample_time;   // 采样时间(抽象等级)
} adc_config_t;

void HAL_ADC_Init(adc_config_t *cfg);
void HAL_ADC_Start(uint8_t adc_id);
void HAL_ADC_Stop(uint8_t adc_id);
uint16_t HAL_ADC_GetValue(uint8_t adc_id, uint8_t channel);
void HAL_ADC_StartDMA(uint8_t adc_id, uint16_t *buf, uint32_t len);

为什么要把sample_time抽象成等级而不是具体时钟周期?因为不同芯片的采样时间单位不一样。有的以ADC时钟周期为单位,有的以系统时钟周期为单位。用等级(0-7)表示,HAL层内部做映射,上层就不用关心这些细节了。

经验之谈:电机控制中,ADC采样时刻非常关键。我习惯用定时器的更新事件或触发事件来启动ADC,这样采样点与PWM周期严格同步。HAL层要提供这种触发配置,而不是让上层自己去操作定时器和ADC的交叉寄存器。

3.5 PWM封装:死区与互补输出必须考虑

PWM是电机控制的输出环节。对于三相逆变器,需要6路PWM,分成3对互补输出,每对之间还要插入死区时间。

我的封装思路:

// pwm_hal.h
typedef struct {
    uint8_t  tim_id;        // 定时器编号
    uint8_t  channel;       // 通道号(0-2对应U/V/W)
    uint16_t period;        // PWM周期
    uint16_t dead_time;     // 死区时间(单位:定时器时钟周期数)
    uint8_t  polarity;      // 输出极性:0=高有效,1=低有效
} pwm_config_t;

void HAL_PWM_Init(pwm_config_t *cfg);
void HAL_PWM_SetDuty(uint8_t tim_id, uint8_t channel, uint16_t duty);
void HAL_PWM_Start(uint8_t tim_id);
void HAL_PWM_Stop(uint8_t tim_id);
void HAL_PWM_SetDeadTime(uint8_t tim_id, uint16_t dead_time);

这里有个细节:duty的值范围是0到period。为什么不用百分比?因为百分比在底层计算时会引入浮点或除法,影响实时性。用计数值,上层可以直接传递FOC算法算出的比较值。

小技巧:我曾经在PWM封装里加了一个HAL_PWM_UpdateAll()函数,用于一次性更新所有通道的占空比。这样可以在FOC计算完成后,统一写入,避免逐通道更新时产生的时序错乱。这个设计后来被多个项目沿用,效果很好。

3.6 接口规范:错误处理与初始化顺序

最后,聊聊接口规范。HAL层的函数,我建议统一返回int32_t,0表示成功,负数表示错误码。这样上层可以用if (HAL_xxx() != 0)统一处理错误。

初始化顺序也很重要。我的习惯是:

  1. 先初始化GPIO(引脚功能配置)
  2. 再初始化定时器(PWM、编码器接口)
  3. 然后初始化ADC(依赖定时器触发)
  4. 最后初始化PWM输出(确保所有配置就绪后再输出波形)

这个顺序,我在多个项目里验证过。如果先启动PWM再初始化ADC,可能会在第一个PWM周期里采到无效数据,导致电机抖动一下。嗯,这种问题,示波器都抓不到,只能靠代码规范来避免。

再次提醒:HAL层是架构的基石。如果HAL层设计得不好,上层再怎么优化也白搭。花时间把HAL层做扎实,后面20多个章节的内容才能站得住脚。