第2章:硬件抽象层设计——MCU外设驱动封装、BMS专用GPIO抽象、ADC与定时器抽象
各位同学,咱们接着聊。上一章我们把RTOS移植的骨架搭起来了,但说实话,那只是个空壳子。真正让系统跑起来、让BMS能干活的关键,在于硬件抽象层——也就是HAL。我做了这么多年BMS开发,见过太多人栽在这一步:直接操作寄存器,代码写死,换个MCU就得重写。嗯,今天我们就来聊聊怎么把这事儿做得漂亮。
2.1 为什么BMS需要硬件抽象层?
你想想看,一个BMS项目,从方案选型到量产,中间很可能换MCU。比如前期用STM32做原型验证,量产时换成国产GD32或者AT32。如果没有硬件抽象层,那代码基本等于重写。我当年第一个BMS项目就吃了这个亏——老板说换个便宜芯片,我加班了整整两周改驱动代码。
硬件抽象层的核心思想就一句话:上层代码不关心底层是什么芯片。说白了,就是给MCU外设穿上一件“通用外衣”。BMS里常用的外设无非就是GPIO、ADC、定时器、PWM、SPI这些。我们只需要给每个外设定义一套标准接口,底层实现根据具体MCU去填坑就行。
核心原则:
- 接口统一:所有MCU用同一套函数名、参数结构体
- 实现隔离:驱动代码放在单独的目录,不跟业务逻辑混在一起
- 配置分离:引脚、时钟等参数放在配置文件中,不改核心代码
2.2 MCU外设驱动封装——从寄存器到函数
我个人习惯,先把MCU外设分成两类:通用外设和BMS专用外设。通用外设比如UART、I2C、SPI,这些跟BMS业务关系不大,但调试和通信离不开。BMS专用外设就是ADC(采集电压电流温度)、GPIO(控制继电器、检测状态)、定时器(生成PWM、做时序控制)。
先看一个通用外设的封装例子。以UART为例,我通常会这样设计:
/* bsp_uart.h - 硬件抽象层UART接口 */
#ifndef __BSP_UART_H__
#define __BSP_UART_H__
#include <stdint.h>
/* UART配置结构体 - 所有MCU通用 */
typedef struct {
uint32_t baudrate; /* 波特率 */
uint8_t data_bits; /* 数据位:5/6/7/8 */
uint8_t stop_bits; /* 停止位:1/2 */
uint8_t parity; /* 校验位:0无/1奇/2偶 */
uint8_t flow_ctrl; /* 流控:0无/1硬件 */
} uart_config_t;
/* 标准接口 - 上层代码只调用这些 */
int32_t bsp_uart_init(uint8_t port, uart_config_t *cfg);
int32_t bsp_uart_send(uint8_t port, uint8_t *data, uint32_t len);
int32_t bsp_uart_recv(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout);
void bsp_uart_irq_handler(uint8_t port); /* 中断处理函数 */
#endif /* __BSP_UART_H__ */
你看,这个头文件里没有任何跟具体MCU相关的东西。上层代码(比如BMS的通信协议栈)只需要调用bsp_uart_init、bsp_uart_send就行了。底层实现呢?放在bsp_uart_stm32.c或者bsp_uart_gd32.c里。换芯片?换个.c文件重新编译就行。
我的经验:每个外设的初始化函数一定要返回错误码。我曾经遇到一个项目,UART初始化失败但没检查返回值,结果通信时断时续,排查了三天才发现是时钟配置错了。从那以后,我所有驱动函数都带int32_t返回值,0表示成功,负数表示错误码。
2.3 BMS专用GPIO抽象——不只是高低电平
BMS里的GPIO,跟普通嵌入式项目不太一样。普通项目可能就点个灯、读个按键。BMS里呢?控制预充继电器、主正继电器、主负继电器,检测绝缘电阻,读取碰撞信号,驱动风扇PWM……每个GPIO都有明确的“业务含义”。
我建议的做法是:给每个BMS功能引脚起一个“业务名字”,而不是直接用PA0、PB1这种硬件名字。举个例子:
/* bms_gpio.h - BMS专用GPIO抽象层 */
#ifndef __BMS_GPIO_H__
#define __BMS_GPIO_H__
#include <stdint.h>
/* BMS GPIO功能枚举 - 每个枚举代表一个业务引脚 */
typedef enum {
BMS_GPIO_RELAY_PRE, /* 预充继电器控制 */
BMS_GPIO_RELAY_MAIN_P, /* 主正继电器控制 */
BMS_GPIO_RELAY_MAIN_N, /* 主负继电器控制 */
BMS_GPIO_FAN_PWM, /* 散热风扇PWM */
BMS_GPIO_CRASH_IN, /* 碰撞信号输入 */
BMS_GPIO_INSUL_ALARM, /* 绝缘报警输出 */
BMS_GPIO_LED_RUN, /* 运行指示灯 */
BMS_GPIO_LED_ALARM, /* 报警指示灯 */
BMS_GPIO_COUNT /* 总数 - 用于数组大小 */
} bms_gpio_func_t;
/* GPIO方向 */
typedef enum {
BMS_GPIO_DIR_OUT,
BMS_GPIO_DIR_IN
} bms_gpio_dir_t;
/* GPIO电平 */
typedef enum {
BMS_GPIO_LOW = 0,
BMS_GPIO_HIGH = 1
} bms_gpio_level_t;
/* 标准接口 */
int32_t bms_gpio_init(bms_gpio_func_t func, bms_gpio_dir_t dir);
int32_t bms_gpio_set(bms_gpio_func_t func, bms_gpio_level_t level);
int32_t bms_gpio_get(bms_gpio_func_t func, bms_gpio_level_t *level);
int32_t bms_gpio_toggle(bms_gpio_func_t func);
#endif /* __BMS_GPIO_H__ */
这样做的好处很明显:业务代码里全是bms_gpio_set(BMS_GPIO_RELAY_PRE, BMS_GPIO_HIGH)这种调用,一看就知道是在控制预充继电器。而不是GPIO_SetBits(GPIOA, GPIO_Pin_0)这种鬼知道在干嘛的代码。
注意:BMS的继电器控制GPIO,初始化时一定要确保输出状态是确定的。我曾经遇到过:MCU复位后GPIO默认是浮空输入,导致继电器误动作,电池包直接短路保护。嗯,从那以后我所有BMS项目的GPIO初始化,都会先设置输出电平,再配置方向,最后锁定输出状态。
2.4 ADC抽象——BMS的“眼睛”
ADC是BMS最重要的外设,没有之一。你要采集每节电芯的电压、总电压、充放电电流、NTC温度……一个典型的BMS可能有16路甚至更多ADC通道。如果每个通道都单独写配置,代码会变得又臭又长。
我习惯把ADC抽象成“通道”的概念。每个通道绑定一个物理ADC引脚,但上层只认通道号:
/* bms_adc.h - BMS ADC抽象层 */
#ifndef __BMS_ADC_H__
#define __BMS_ADC_H__
#include <stdint.h>
/* ADC通道枚举 - 每个通道对应一个BMS采样点 */
typedef enum {
BMS_ADC_CH_VCELL_0, /* 电芯0电压 */
BMS_ADC_CH_VCELL_1, /* 电芯1电压 */
/* ... 最多16路电芯 */
BMS_ADC_CH_VCELL_15,
BMS_ADC_CH_VPACK, /* 电池包总电压 */
BMS_ADC_CH_ICURRENT, /* 充放电电流(通过霍尔或分流器) */
BMS_ADC_CH_TEMP_0, /* 温度传感器0 */
BMS_ADC_CH_TEMP_1, /* 温度传感器1 */
BMS_ADC_CH_TEMP_2, /* 温度传感器2 */
BMS_ADC_CH_COUNT
} bms_adc_channel_t;
/* ADC配置结构体 */
typedef struct {
uint32_t sampling_time; /* 采样时间(单位:ADC时钟周期) */
uint8_t resolution; /* 分辨率:8/10/12/16位 */
float ref_voltage; /* 参考电压(伏特) */
} bms_adc_config_t;
/* 标准接口 */
int32_t bms_adc_init(bms_adc_config_t *cfg);
int32_t bms_adc_read(bms_adc_channel_t ch, uint16_t *raw_value);
int32_t bms_adc_read_voltage(bms_adc_channel_t ch, float *voltage);
int32_t bms_adc_start_dma(uint16_t *buffer, uint32_t len);
#endif /* __BMS_ADC_H__ */
这里有个细节:bms_adc_read_voltage直接返回电压值(单位伏特),而不是原始ADC码。为什么?因为上层业务(比如SOC计算、过压保护)需要的是物理量,不是原始码。原始码转电压的计算放在底层驱动里,上层不用关心参考电压是多少、分压电阻是多少。
避坑指南:我曾经在ADC采样时序上栽过跟头。BMS的电压采样,特别是多路电芯切换,需要足够的建立时间。如果采样时间太短,前一个通道的残余电荷会影响当前通道的测量值。我的做法是:每个通道采样前先丢弃前两次结果,取第三次以后的数据。或者用DMA连续采样,软件做均值滤波。
2.5 定时器抽象——BMS的“心跳”
BMS里定时器用途太多了:PWM生成(驱动风扇、加热膜)、周期采样(比如每10ms采集一次电压)、延时控制(预充继电器闭合后等待200ms再闭合主继电器)、看门狗喂狗……
我通常把定时器抽象成两类:硬件定时器和软件定时器。硬件定时器用于PWM生成和精确延时,软件定时器用于周期性任务调度(基于RTOS的tick)。
先看硬件定时器的抽象:
/* bms_timer.h - BMS定时器抽象层 */
#ifndef __BMS_TIMER_H__
#define __BMS_TIMER_H__
#include <stdint.h>
/* 定时器功能枚举 */
typedef enum {
BMS_TIMER_PWM_FAN, /* 风扇PWM */
BMS_TIMER_PWM_HEATER, /* 加热膜PWM */
BMS_TIMER_DELAY_PRE, /* 预充延时 */
BMS_TIMER_SAMPLE, /* 采样触发 */
BMS_TIMER_WATCHDOG, /* 独立看门狗 */
BMS_TIMER_COUNT
} bms_timer_func_t;
/* PWM配置结构体 */
typedef struct {
uint32_t frequency; /* PWM频率(Hz) */
uint16_t duty_cycle; /* 占空比(0-1000,表示0.0%-100.0%) */
uint8_t polarity; /* 极性:0高有效/1低有效 */
} bms_pwm_config_t;
/* 标准接口 */
int32_t bms_timer_init_pwm(bms_timer_func_t func, bms_pwm_config_t *cfg);
int32_t bms_timer_set_duty(bms_timer_func_t func, uint16_t duty);
int32_t bms_timer_start(bms_timer_func_t func);
int32_t bms_timer_stop(bms_timer_func_t func);
int32_t bms_timer_delay_us(uint32_t us); /* 阻塞式微秒延时 */
#endif /* __BMS_TIMER_H__ */
软件定时器呢?我一般直接用RTOS的定时器API,再包一层BMS的业务语义:
/* 基于RTOS的软件定时器封装 */
typedef void (*bms_timer_callback_t)(void *arg);
int32_t bms_soft_timer_create(bms_timer_func_t func,
uint32_t period_ms,
bms_timer_callback_t callback);
int32_t bms_soft_timer_start(bms_timer_func_t func);
int32_t bms_soft_timer_stop(bms_timer_func_t func);
我的习惯:BMS里的PWM频率不要随便选。风扇PWM一般用25kHz(避开人耳可听范围),加热膜PWM用低频(100Hz-1kHz)就行。我见过有人把风扇PWM设成1kHz,结果整个电池包都在嗡嗡响,客户投诉噪音太大。嗯,从那以后我每个PWM频率都会用示波器确认一下。
2.6 总结——硬件抽象层的“三要三不要”
最后,我总结一下硬件抽象层设计的几个要点,都是血泪教训换来的:
| 原则 | 要这样做 | 不要那样做 |
|---|---|---|
| 接口设计 | 用业务语义命名函数和参数 | 直接用寄存器名或引脚号 |
| 配置管理 | 所有硬件参数放在单独配置文件 | 在驱动代码里硬编码引脚和时钟 |
| 错误处理 | 每个函数返回错误码,上层检查 | 假设初始化一定成功,不检查返回值 |
| 移植性 | 驱动代码按MCU分目录存放 | 所有MCU的代码混在一个文件里 |
| 时序控制 | ADC采样留足建立时间,做滤波 | 采样间隔随意,不做数据有效性检查 |
| 调试支持 | 保留调试打印接口,可开关 | 发布版本里还留着大量printf |
好了,这一章的内容就到这儿。硬件抽象层设计好了,后续的BMS业务逻辑开发就会非常顺畅。下一章我们聊聊RTOS任务划分——怎么把BMS的各个功能模块映射到RTOS任务里。到时候我会分享一个我踩过的坑:任务优先级设错了,导致电池过充保护没来得及触发……嗯,下回再说。