2、硬件抽象层(HAL)设计:MCU外设驱动抽象、时钟与定时器抽象、中断管理抽象、GPIO与UART抽象层接口定义。

好,咱们进入第二章。这一章,说白了就是整个协议栈的「地基」。你想想看,一个物联网设备,可能今天用STM32,明天换GD32,后天又换成ESP32。如果每次换芯片都要重写整个协议栈,那不得累死?

所以,我们需要一个硬件抽象层(HAL)。它的作用,就是把MCU的具体差异「包起来」,向上层提供一套统一的接口。我个人的习惯是,先定义好接口,再考虑具体实现。这样,上层代码根本不用关心底层是什么芯片。

核心原则:上层代码只调用HAL接口,绝不直接操作寄存器。这样,换MCU时,只需要重写HAL层的实现,上层协议栈纹丝不动。

2.1 MCU外设驱动抽象

外设驱动抽象,是HAL层最基础的部分。每个外设,比如SPI、I2C、定时器,都应该有一个对应的抽象结构体。这个结构体里,放的是函数指针。

我在项目中遇到过一个问题:某款芯片的SPI发送函数叫 SPI_SendData(),另一款叫 spi_write()。如果不用抽象层,每次移植都得满世界找这些函数名,改得头大。

所以,我一般这样定义外设驱动的抽象接口:

/* 外设驱动抽象结构体 */
typedef struct {
    int32_t (*init)(void *handle, void *config);
    int32_t (*deinit)(void *handle);
    int32_t (*read)(void *handle, uint8_t *buf, uint32_t len);
    int32_t (*write)(void *handle, const uint8_t *buf, uint32_t len);
    int32_t (*ioctl)(void *handle, uint32_t cmd, void *arg);
} hal_driver_t;

你看,每个外设都统一成 initdeinitreadwriteioctl 这五个操作。具体实现时,再把这些函数指针指向MCU的库函数。

小技巧: ioctl 函数特别有用。它可以处理一些非标准的操作,比如设置波特率、配置SPI极性等。用一个大大的switch-case,把各种命令都包进去。

2.2 时钟与定时器抽象

时钟和定时器,是嵌入式系统的「心跳」。没有它,整个系统就乱套了。但不同MCU的时钟树、定时器模式千差万别。有的用SysTick,有的用TIM1,有的用RTC。

嗯,这里要注意:时钟抽象的核心,是提供一个统一的「时间基准」。上层协议栈不需要知道你是用哪个定时器,它只需要知道「现在是什么时间」和「等多久」。

我建议这样定义时钟抽象接口:

/* 时钟与定时器抽象接口 */
typedef struct {
    uint64_t (*get_tick)(void);          /* 获取系统滴答,单位ms */
    int32_t  (*delay_ms)(uint32_t ms);   /* 毫秒级延时 */
    int32_t  (*delay_us)(uint32_t us);   /* 微秒级延时 */
    int32_t  (*timer_start)(uint32_t id, uint32_t period, void (*callback)(void));
    int32_t  (*timer_stop)(uint32_t id);
} hal_timer_t;

这里有个坑,我曾经踩过:get_tick() 返回的是 uint64_t,而不是 uint32_t。为什么?因为32位的计数器,在72MHz的MCU上,大约59秒就溢出了。如果你的设备要长时间运行,比如几个月,那必须用64位。否则,时间一长,协议栈里的超时判断全乱套。

避坑指南: 我曾经在一个NB-IoT项目中,因为用了32位的tick计数器,导致设备运行了3天后,所有定时任务全部失效。排查了整整两天,才发现是tick溢出了。从那以后,我所有项目的tick都用64位。

2.3 中断管理抽象

中断管理,是HAL层里最「敏感」的部分。为什么?因为中断的优先级、嵌套、临界区保护,每个MCU的处理方式都不一样。Cortex-M系列有NVIC,RISC-V有PLIC,老一点的51单片机就简单多了。

我个人习惯,把中断管理抽象成三个核心操作:

  1. 开/关全局中断:用于临界区保护
  2. 注册中断服务函数:把用户回调函数挂到指定中断向量上
  3. 设置中断优先级:控制中断的抢占行为

代码示例:

/* 中断管理抽象接口 */
typedef struct {
    void    (*global_irq_enable)(void);
    void    (*global_irq_disable)(void);
    int32_t (*irq_register)(uint32_t irq_num, void (*handler)(void *arg), void *arg);
    int32_t (*irq_unregister)(uint32_t irq_num);
    int32_t (*irq_set_priority)(uint32_t irq_num, uint32_t priority);
} hal_irq_t;

你想想看,如果没有这个抽象层,你的协议栈里到处都是 __disable_irq()__enable_irq()。换一个平台,这些内嵌汇编全得改。有了抽象层,只需要改 global_irq_disableglobal_irq_enable 这两个函数就行。

关键点: 中断服务函数里,尽量只做「标记」工作,不要做耗时操作。把真正的处理逻辑放到主循环或任务中。这是嵌入式系统设计的铁律。

2.4 GPIO与UART抽象层接口定义

GPIO和UART,是物联网设备最常用的外设。GPIO用来控制LED、读取按键、驱动继电器。UART用来和Wi-Fi模块、GPS模块、调试串口通信。

GPIO的抽象,我一般这样设计:

/* GPIO抽象接口 */
typedef struct {
    int32_t (*pin_init)(uint32_t pin, uint32_t mode);   /* mode: 输入/输出/复用等 */
    int32_t (*pin_write)(uint32_t pin, uint8_t level);  /* level: 0或1 */
    int32_t (*pin_read)(uint32_t pin);                  /* 返回电平值 */
    int32_t (*pin_toggle)(uint32_t pin);                /* 翻转电平 */
} hal_gpio_t;

UART的抽象,稍微复杂一点。因为UART有发送、接收、中断、DMA等多种模式。我建议这样定义:

/* UART抽象接口 */
typedef struct {
    int32_t (*init)(uint32_t uart_id, uint32_t baud, uint32_t config);
    int32_t (*send)(uint32_t uart_id, const uint8_t *data, uint32_t len);
    int32_t (*receive)(uint32_t uart_id, uint8_t *data, uint32_t len);
    int32_t (*send_async)(uint32_t uart_id, const uint8_t *data, uint32_t len, 
                          void (*callback)(void *arg));
    int32_t (*receive_async)(uint32_t uart_id, uint8_t *data, uint32_t len,
                             void (*callback)(void *arg));
} hal_uart_t;

这里有个细节:send_asyncreceive_async 是异步接口。它们启动传输后立即返回,传输完成时通过回调通知上层。这在物联网协议栈中非常有用,比如发送一个AT指令后,不用死等,可以去做别的事。

个人经验: 我在做4G Cat.1模块驱动时,UART的异步接收特别重要。因为模块随时可能主动上报数据,如果用同步接收,主循环会被卡死。用异步接收+回调,数据一来就能及时处理。

2.5 总结一下

这一章的内容,说白了就是「定义规矩」。把GPIO、UART、定时器、中断这些硬件资源,都抽象成统一的接口。上层协议栈只认这些接口,不认具体的寄存器。

这样做的好处,你想想看:

  • 移植快:换MCU时,只需要实现一套新的HAL接口,协议栈代码一个字都不用改。
  • 可测试:可以在PC上模拟HAL接口,用单元测试验证协议栈逻辑。
  • 可复用:这套HAL接口,可以在多个项目中反复使用,积累成自己的「轮子库」。

嗯,下一章我们会讲「内存管理与缓冲区设计」。到时候你会看到,HAL层定义好的这些接口,是如何被上层协议栈优雅地调用的。