第三章 硬件抽象层(HAL)设计:外设驱动的标准化封装

好,咱们进入正题。这一章聊的是硬件抽象层,也就是HAL。说实话,很多点钞机开发团队在这一块栽过跟头。我见过不少项目,代码写得飞起,结果换个MCU平台,整个软件架构就得重写。为什么会这样?说白了,就是硬件依赖没剥离干净。

我个人习惯,在项目一开始就把HAL层搭好。哪怕后面不改芯片,这个习惯也能让代码清晰很多。你想想看,点钞机里用到的外设其实就那几样:GPIO、定时器、PWM、ADC、UART、SPI、I2C。咱们一个一个来拆解。

3.1 GPIO的抽象封装

GPIO看似简单,但坑最多。我记得有一次,一个同事直接操作寄存器来控制电机使能脚,结果换了个引脚号,满世界找代码改。嗯,这就是典型的反面教材。

我的做法是,定义一个结构体,把引脚号、模式、上下拉这些都封装进去:

/* gpio_hal.h */
typedef struct {
    uint8_t port;      /* GPIO端口号 */
    uint8_t pin;       /* 引脚号 */
    uint8_t mode;      /* 输入/输出/复用 */
    uint8_t pull;      /* 上拉/下拉/浮空 */
    uint8_t speed;     /* 输出速度 */
} gpio_cfg_t;

/* 标准接口 */
void gpio_hal_init(gpio_cfg_t *cfg);
void gpio_hal_set(gpio_cfg_t *cfg, uint8_t level);
uint8_t gpio_hal_get(gpio_cfg_t *cfg);
void gpio_hal_toggle(gpio_cfg_t *cfg);
我的小技巧: 把GPIO的初始化参数做成宏定义,放在一个单独的头文件里。比如 #define MOTOR_ENABLE_GPIO {GPIOA, 5, GPIO_MODE_OUTPUT, GPIO_PULLUP, GPIO_SPEED_HIGH}。这样改硬件时,只改这一个地方就行。

3.2 定时器的抽象

点钞机里定时器用得特别多。电机控制、传感器采样、通信超时,哪个都离不开它。我建议把定时器抽象成两类:周期定时器和单次定时器。

/* timer_hal.h */
typedef void (*timer_callback_t)(void *arg);

typedef struct {
    uint8_t timer_id;          /* 定时器编号 */
    uint32_t period_us;        /* 周期,单位微秒 */
    uint8_t mode;              /* 周期模式或单次模式 */
    timer_callback_t callback; /* 回调函数 */
    void *arg;                 /* 回调参数 */
} timer_cfg_t;

int timer_hal_init(timer_cfg_t *cfg);
int timer_hal_start(timer_cfg_t *cfg);
int timer_hal_stop(timer_cfg_t *cfg);
int timer_hal_reload(timer_cfg_t *cfg, uint32_t period_us);
注意: 回调函数里不要做耗时操作。我曾经在回调里直接调用UART发送函数,结果导致定时器中断超时,整个系统卡死。后来改成用消息队列,把数据扔给任务去处理,问题就解决了。

3.3 PWM的标准化

PWM在点钞机里主要控制电机速度和蜂鸣器。抽象起来其实不复杂,关键是频率和占空比要能独立调节。

/* pwm_hal.h */
typedef struct {
    uint8_t pwm_id;        /* PWM通道号 */
    uint32_t freq_hz;      /* 频率 */
    uint16_t duty_cycle;   /* 占空比,0-1000对应0.0%-100.0% */
} pwm_cfg_t;

int pwm_hal_init(pwm_cfg_t *cfg);
int pwm_hal_set_duty(pwm_cfg_t *cfg, uint16_t duty);
int pwm_hal_set_freq(pwm_cfg_t *cfg, uint32_t freq);
int pwm_hal_start(pwm_cfg_t *cfg);
int pwm_hal_stop(pwm_cfg_t *cfg);

这里有个细节。占空比我用的是千分比,不是百分比。为什么?因为千分比精度更高,调电机转速时更细腻。我在项目中试过百分比,电机低速时抖动得厉害,换成千分比就好多了。

3.4 ADC的封装

点钞机用ADC来检测传感器信号,比如红外对管、磁头信号。我一般提供两种接口:单次采集和连续采集。

/* adc_hal.h */
typedef struct {
    uint8_t adc_id;       /* ADC编号 */
    uint8_t channel;      /* 通道号 */
    uint8_t resolution;   /* 分辨率,如12位 */
    uint32_t sample_time; /* 采样时间 */
} adc_cfg_t;

int adc_hal_init(adc_cfg_t *cfg);
uint16_t adc_hal_read(adc_cfg_t *cfg);           /* 单次读取 */
int adc_hal_start_continuous(adc_cfg_t *cfg, 
                             uint16_t *buffer, 
                             uint32_t length);    /* 连续采集 */
int adc_hal_stop_continuous(adc_cfg_t *cfg);
避坑指南: 我曾经在连续采集模式下,忘了检查ADC转换完成标志就直接读数据寄存器,结果读到的全是上一次的值。嗯,从那以后我每次读ADC数据前,都会先确认转换状态。

3.5 UART的抽象

UART在点钞机里用来和上位机通信,或者和传感器模块交互。我习惯把它抽象成收发缓冲区+回调的方式。

/* uart_hal.h */
typedef void (*uart_rx_callback_t)(uint8_t data);

typedef struct {
    uint8_t uart_id;           /* UART编号 */
    uint32_t baudrate;         /* 波特率 */
    uint8_t data_bits;         /* 数据位 */
    uint8_t stop_bits;         /* 停止位 */
    uint8_t parity;            /* 校验位 */
    uart_rx_callback_t rx_cb;  /* 接收回调 */
} uart_cfg_t;

int uart_hal_init(uart_cfg_t *cfg);
int uart_hal_send_byte(uart_cfg_t *cfg, uint8_t data);
int uart_hal_send_buffer(uart_cfg_t *cfg, uint8_t *data, uint32_t len);
int uart_hal_receive_byte(uart_cfg_t *cfg, uint8_t *data);

我个人建议,发送用阻塞方式,接收用中断+回调。为什么?因为点钞机对实时性要求高,发送时CPU等一会儿没关系,但接收时不能错过任何一个字节。

3.6 SPI和I2C的封装

这两个接口在点钞机里用得相对少一些,主要接一些传感器或者存储芯片。它们的抽象思路类似,都是主从模式。

/* spi_hal.h */
typedef struct {
    uint8_t spi_id;        /* SPI编号 */
    uint32_t freq;         /* 时钟频率 */
    uint8_t mode;          /* 模式0-3 */
    uint8_t bit_order;     /* MSB或LSB优先 */
} spi_cfg_t;

int spi_hal_init(spi_cfg_t *cfg);
int spi_hal_transfer(spi_cfg_t *cfg, uint8_t *tx, uint8_t *rx, uint32_t len);

/* i2c_hal.h */
typedef struct {
    uint8_t i2c_id;        /* I2C编号 */
    uint32_t freq;         /* 时钟频率,标准100k或快速400k */
} i2c_cfg_t;

int i2c_hal_init(i2c_cfg_t *cfg);
int i2c_hal_write(i2c_cfg_t *cfg, uint8_t dev_addr, uint8_t *data, uint32_t len);
int i2c_hal_read(i2c_cfg_t *cfg, uint8_t dev_addr, uint8_t *data, uint32_t len);
经验之谈: SPI的片选信号不要放在SPI驱动里管理,而是交给上层应用。因为不同设备对片选时序要求不一样,有的需要拉低后等一会儿再传数据,有的则不需要。把片选交给上层,灵活性更高。

3.7 驱动接口标准化总结

好了,上面这些接口,其实遵循的都是同一个套路:

  • 初始化函数:传入配置结构体,返回状态码
  • 控制函数:启动、停止、设置参数
  • 数据交换函数:读写操作
  • 回调机制:异步事件通知上层

这样做的好处很明显。你想想看,如果有一天要换MCU,从STM32换成GD32,或者从NXP换成国产芯片,你只需要重写HAL层的这几个文件。上层的业务逻辑代码,一行都不用动。

我在一个点钞机项目里验证过这个思路。当时客户要求从STM32F103换到AT32F403,我花了三天时间重写HAL层,然后整个应用层代码直接编译通过。嗯,那种感觉,真的很爽。

外设 核心接口数 关键点 常见坑
GPIO 4 配置结构体封装 引脚号硬编码
定时器 4 回调函数不要耗时 中断中调用阻塞函数
PWM 4 千分比精度更高 频率突变导致电机抖动
ADC 4 检查转换完成标志 读取未完成的数据
UART 4 发送阻塞,接收中断 接收缓冲区溢出
SPI 2 片选交给上层 片选时序不匹配
I2C 3 注意应答信号 总线锁死

最后说一句。HAL层不是越薄越好,也不是越厚越好。我的原则是:刚好够用,不多不少。太薄了,换平台时还得改上层代码;太厚了,性能损耗大,调试也麻烦。这个度,得靠项目经验去把握。

下一章,咱们聊聊操作系统抽象层。点钞机要不要上RTOS?上了之后怎么封装?这些都是很有意思的话题。