3、硬件抽象层设计:MCU外设驱动封装、UART/DMA配置、定时器与超时管理、GPIO控制

好,咱们进入第三章。这一章我打算聊聊硬件抽象层,也就是HAL层。说实话,很多做协议栈移植的朋友,最后都卡在这一步。不是协议栈本身多难,而是底层硬件没封装好,导致上层代码改来改去,越改越乱。

我个人习惯,做任何协议栈移植之前,先把硬件抽象层搭好。这就像盖房子先打地基,地基歪了,后面装修再漂亮也没用。Modbus协议栈对硬件的依赖其实不算重,无非就是串口收发、定时器超时、还有几个控制引脚。但就是这几样东西,封装得好不好,直接决定了你后续的移植工作量。

3.1 MCU外设驱动封装:别让硬件细节污染上层

我见过太多人,直接在协议栈里写寄存器操作。比如:

// 错误示范:直接操作寄存器
UART1->DR = data;
while(!(UART1->SR & UART_SR_TXE));

这样写,换个MCU就得重写一遍。你想想看,这哪是移植,简直是重写。

我的做法是,定义一组接口函数,把硬件操作全部藏起来。比如这样:

/* hal_uart.h */
typedef struct {
    void (*init)(uint32_t baud, uint8_t parity, uint8_t stop_bits);
    void (*send_byte)(uint8_t data);
    uint8_t (*recv_byte)(void);
    uint8_t (*is_tx_idle)(void);
    uint8_t (*is_rx_data)(void);
} hal_uart_t;

extern const hal_uart_t UART_DRIVER;

然后在具体的MCU文件里实现这个结构体。比如stm32_uart.c里:

static void stm32_uart_init(uint32_t baud, uint8_t parity, uint8_t stop_bits) {
    // 配置USART1
    RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
    USART1->BRR = SystemCoreClock / baud;
    // ... 其他配置
}

const hal_uart_t UART_DRIVER = {
    .init = stm32_uart_init,
    .send_byte = stm32_uart_send,
    .recv_byte = stm32_uart_recv,
    .is_tx_idle = stm32_uart_tx_idle,
    .is_rx_data = stm32_uart_rx_avail,
};

这样,协议栈里只需要调用 UART_DRIVER.send_byte(data) 就行了。换MCU?换个文件重新实现一遍接口,上层代码一行都不用改。我在一个项目里,用这套方法把Modbus从STM32移植到GD32,只花了半天时间。

我的小技巧: 接口函数尽量用结构体指针传递,不要用全局变量。这样方便做多实例,比如一个MCU有3个串口,每个串口一个实例,互不干扰。

3.2 UART/DMA配置:别让CPU忙着搬砖

Modbus RTU是字节流协议,一帧数据通常也就几十个字节。用中断收发完全够用。但如果你要跑高速(比如115200以上),或者CPU还要干别的活,那DMA就派上用场了。

我建议这样配置DMA:

/* hal_dma.h */
typedef struct {
    void (*start_tx)(uint8_t* buf, uint16_t len);
    void (*start_rx)(uint8_t* buf, uint16_t len);
    uint8_t (*is_tx_done)(void);
    uint16_t (*get_rx_count)(void);
} hal_dma_t;

具体实现时,注意几个坑。我曾经在DMA配置上栽过跟头,这里分享给你:

避坑指南:
  • DMA传输完成中断里,一定要清标志位。我见过有人忘了清,结果进一次中断就死机一次。
  • 串口接收用DMA时,记得开启空闲中断。这样才知道一帧数据什么时候结束。
  • DMA缓冲区大小要设置合理。Modbus最大256字节,我一般设512,留点余量。

这里给个DMA接收的典型配置流程:

void dma_rx_config(void) {
    // 1. 使能DMA时钟
    RCC->AHBENR |= RCC_AHBENR_DMA1EN;
    
    // 2. 配置DMA通道
    DMA1_Channel5->CPAR = (uint32_t)&USART1->DR;
    DMA1_Channel5->CMAR = (uint32_t)rx_buffer;
    DMA1_Channel5->CNDTR = RX_BUF_SIZE;
    
    // 3. 设置循环模式,这样不用每次重新配置
    DMA1_Channel5->CCR = DMA_CCR_CIRC | DMA_CCR_MINC | DMA_CCR_TCIE;
    
    // 4. 使能DMA
    DMA1_Channel5->CCR |= DMA_CCR_EN;
    
    // 5. 使能串口DMA接收
    USART1->CR3 |= USART_CR3_DMAR;
}

嗯,这里要注意,DMA的循环模式和普通模式不一样。循环模式下,传输完一轮会自动从头开始,你只需要读走数据就行。普通模式传完就停了,得手动重新启动。

3.3 定时器与超时管理:Modbus的命脉

Modbus RTU有个关键参数:3.5个字符时间的超时。说白了,就是接收完一个字节后,如果在3.5个字符时间内没收到下一个字节,就认为一帧结束了。

这个超时怎么算?以9600波特率为例,一个字符大约1ms(10位/9600 ≈ 1.04ms),3.5个字符就是3.5ms左右。我一般取4ms,留点余量。

我的做法是用一个硬件定时器,专门做超时管理:

/* hal_timer.h */
typedef struct {
    void (*init)(uint32_t us);  // 初始化定时器,单位微秒
    void (*start)(void);
    void (*stop)(void);
    void (*reset)(void);
    uint8_t (*is_timeout)(void);
} hal_timer_t;

实现的时候,我习惯用定时器的自动重装载模式。比如:

void timer_init(uint32_t us) {
    // 假设定时器时钟72MHz,预分频72-1,得到1MHz计数频率
    TIM2->PSC = 72 - 1;
    TIM2->ARR = us;  // 直接设置微秒数
    TIM2->CR1 |= TIM_CR1_OPM;  // 单脉冲模式,只计数一次
}

void timer_start(void) {
    TIM2->CNT = 0;
    TIM2->CR1 |= TIM_CR1_CEN;
}

uint8_t timer_is_timeout(void) {
    return (TIM2->SR & TIM_SR_UIF) ? 1 : 0;
}

为什么用单脉冲模式?因为Modbus的超时是一次性的,触发一次就够了。如果用连续模式,你还得手动关定时器,多此一举。

核心要点: 每次收到一个字节,就调用 timer_reset() 重新计时。如果定时器超时了,说明帧结束了,可以开始解析了。这个逻辑在中断里实现,效率很高。

3.4 GPIO控制:RS485的方向切换

如果你用的是RS485总线,那GPIO控制就绕不过去了。RS485是半双工的,发送时要把方向引脚拉高,接收时拉低。

这个操作看起来简单,但时机很重要。我曾经遇到过一个问题:发送完最后一个字节后,立刻把方向引脚拉低,结果最后一个字节没发完就被截断了。

正确的做法是:

void uart_send_frame(uint8_t* data, uint16_t len) {
    // 1. 拉高方向引脚,进入发送模式
    RS485_DE_HIGH();
    
    // 2. 发送数据
    for(uint16_t i = 0; i < len; i++) {
        UART_DRIVER.send_byte(data[i]);
    }
    
    // 3. 等待发送完成
    while(!UART_DRIVER.is_tx_idle());
    
    // 4. 拉低方向引脚,回到接收模式
    RS485_DE_LOW();
}

这里的关键是第3步,一定要等发送移位寄存器空了再切换方向。我建议用 is_tx_idle() 而不是简单的延时,因为不同波特率下,最后一个字节的发送时间不一样。

GPIO的封装也很简单:

/* hal_gpio.h */
typedef struct {
    void (*set_high)(void);
    void (*set_low)(void);
    uint8_t (*read)(void);
} hal_gpio_t;

具体实现时,我习惯用宏定义,这样效率高:

#define RS485_DE_HIGH()  GPIOA->BSRR = GPIO_BSRR_BR1
#define RS485_DE_LOW()   GPIOA->BSRR = GPIO_BSRR_BS1

当然,如果你要支持多个RS485口,还是用函数封装比较好。

3.5 把这些串起来:一个完整的硬件抽象层

好了,上面讲了UART、DMA、定时器、GPIO。现在把它们组合起来,就是一个完整的硬件抽象层。我一般会提供一个初始化函数:

void hal_init(void) {
    // 1. 初始化GPIO(RS485方向引脚)
    hal_gpio_init();
    
    // 2. 初始化UART(波特率、校验位等)
    UART_DRIVER.init(9600, UART_PARITY_NONE, UART_STOP_1);
    
    // 3. 初始化DMA(如果需要)
    hal_dma_init();
    
    // 4. 初始化定时器(3.5字符超时)
    TIMER_DRIVER.init(4000);  // 4ms超时
    
    // 5. 使能UART接收中断
    NVIC_EnableIRQ(USART1_IRQn);
}

这样,上层Modbus协议栈只需要调用 hal_init(),完全不用关心底层用的是STM32还是GD32,是中断还是DMA。

最后说一句,硬件抽象层不是越复杂越好。够用就行,别过度设计。我见过有人封装了七八层,结果一个简单的串口收发要调用十几个函数,调试起来头都大了。

记住:抽象的目的是简化,不是复杂化。