4. UART驱动封装实战(HAL层)

UART,说白了就是串口。做嵌入式开发的人,没人离得开它。打印日志、跟传感器通信、跟PC机交互,全得靠它。但很多工程师写UART驱动,就是直接操作寄存器,中断来了就往全局变量里塞数据。小项目还行,项目一复杂,问题就全冒出来了。

我个人习惯,在HAL层把UART封装成一套标准接口。不管底层是STM32、GD32还是NXP,上层调用的API长一个样。这样换芯片平台,驱动层一换,应用层代码几乎不用动。

本章核心目标:定义一套通用的UART HAL接口,实现环形缓冲区,并在STM32上完成具体移植。

4.1 UART标准接口定义

接口设计,我遵循一个原则:够用就好。别搞几十个API,没人记得住。核心就四个:初始化、发送、接收、中断/DMA控制。

接口函数 功能描述 参数说明
uart_init() 初始化UART外设,配置波特率、数据位、停止位、校验位 uart_id, baud, config结构体指针
uart_send() 发送一帧数据,支持阻塞/非阻塞模式 uart_id, data_buf, len, timeout
uart_receive() 接收一帧数据,从环形缓冲区读取 uart_id, recv_buf, len, timeout
uart_irq_handler() 中断服务函数,由底层中断向量表调用 uart_id

你看,就这四个。初始化配置硬件,发送和接收处理数据流,中断函数负责把数据搬进搬出环形缓冲区。简洁、清晰、好维护。

我的经验:接口参数里一定要加timeout。我在一个项目里吃过亏——没有超时机制,结果某次通信卡死,整个系统都挂了。从那以后,所有阻塞操作我都加了超时保护。

4.2 环形缓冲区设计与实现

为什么要用环形缓冲区?你想想看,UART接收数据是异步的。中断随时可能来,而主循环可能在处理其他任务。如果没有缓冲区,数据就丢了。

环形缓冲区,本质上就是一个固定大小的数组,加上两个指针:读指针和写指针。写指针追着读指针跑,数据写满了就覆盖旧数据——当然,我们一般会做防覆盖保护。

/* 环形缓冲区结构体 */
typedef struct {
    uint8_t *buffer;      /* 数据存储区 */
    uint32_t size;        /* 缓冲区大小 */
    volatile uint32_t head;  /* 写指针 */
    volatile uint32_t tail;  /* 读指针 */
} ring_buffer_t;

这里有个关键点:head和tail必须加volatile。为什么?因为中断和主循环会同时访问它们。不加volatile,编译器优化后可能把变量值缓存在寄存器里,导致数据不同步。我见过有人因为这个bug调了三天...

环形缓冲区的核心操作就三个:

  1. 写数据:检查缓冲区是否已满,未满则写入并移动head指针
  2. 读数据:检查缓冲区是否为空,非空则读取并移动tail指针
  3. 查询状态:获取已用空间、剩余空间
/* 写入一个字节到环形缓冲区 */
uint32_t ring_buf_write(ring_buffer_t *rb, uint8_t data) {
    uint32_t next_head = (rb->head + 1) % rb->size;
    
    /* 缓冲区满,丢弃数据 */
    if (next_head == rb->tail) {
        return 0;  /* 写入失败 */
    }
    
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 1;  /* 写入成功 */
}

/* 从环形缓冲区读取一个字节 */
uint32_t ring_buf_read(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return 0;  /* 缓冲区空 */
    }
    
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % rb->size;
    return 1;  /* 读取成功 */
}
注意:环形缓冲区的大小必须是2的幂次方。这样可以用位运算代替取模运算,效率高很多。比如size=256,那(head + 1) & 0xFF就等价于(head + 1) % 256。

4.3 基于STM32的UART HAL实现

好了,理论说完了,咱们上真家伙。以STM32F4为例,把上面的接口和环形缓冲区落地。

先定义UART设备结构体,把硬件外设、环形缓冲区、配置参数都封装在一起:

/* UART设备结构体 */
typedef struct {
    USART_TypeDef *instance;      /* STM32 UART外设基地址 */
    ring_buffer_t tx_rb;          /* 发送环形缓冲区 */
    ring_buffer_t rx_rb;          /* 接收环形缓冲区 */
    uint32_t baudrate;            /* 波特率 */
    uint8_t data_bits;            /* 数据位 */
    uint8_t stop_bits;            /* 停止位 */
    uint8_t parity;               /* 校验位 */
    void (*irq_callback)(uint8_t); /* 用户回调函数 */
} uart_device_t;

初始化函数,说白了就是配置GPIO、配置UART参数、使能中断:

int32_t uart_init(uint8_t uart_id, uart_config_t *config) {
    uart_device_t *dev = &uart_devices[uart_id];
    
    /* 1. 配置GPIO复用功能 */
    gpio_init_for_uart(dev->instance);
    
    /* 2. 配置UART参数 */
    dev->instance->CR1 = 0;
    dev->instance->CR1 |= (config->data_bits << 12);  /* 数据位 */
    dev->instance->CR2 |= (config->stop_bits << 12); /* 停止位 */
    dev->instance->CR1 |= (config->parity << 10);    /* 校验位 */
    
    /* 3. 设置波特率 */
    uint32_t usartdiv = SystemCoreClock / config->baudrate;
    dev->instance->BRR = usartdiv;
    
    /* 4. 使能接收中断 */
    dev->instance->CR1 |= USART_CR1_RXNEIE;
    
    /* 5. 使能UART */
    dev->instance->CR1 |= USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
    
    /* 6. 初始化环形缓冲区 */
    ring_buf_init(&dev->rx_rb, dev->rx_buffer, RX_BUF_SIZE);
    ring_buf_init(&dev->tx_rb, dev->tx_buffer, TX_BUF_SIZE);
    
    return 0;
}

中断处理函数,这是整个驱动的核心。数据来了,第一时间存进环形缓冲区:

void uart_irq_handler(uint8_t uart_id) {
    uart_device_t *dev = &uart_devices[uart_id];
    
    /* 接收中断 */
    if (dev->instance->SR & USART_SR_RXNE) {
        uint8_t data = (uint8_t)(dev->instance->DR & 0xFF);
        ring_buf_write(&dev->rx_rb, data);
        
        /* 调用用户回调 */
        if (dev->irq_callback) {
            dev->irq_callback(data);
        }
    }
    
    /* 发送中断(发送缓冲区非空时触发) */
    if (dev->instance->SR & USART_SR_TXE) {
        uint8_t data;
        if (ring_buf_read(&dev->tx_rb, &data)) {
            dev->instance->DR = data;
        } else {
            /* 发送缓冲区空,关闭发送中断 */
            dev->instance->CR1 &= ~USART_CR1_TXEIE;
        }
    }
}

发送函数,支持两种模式:

int32_t uart_send(uint8_t uart_id, uint8_t *data, uint32_t len, uint32_t timeout) {
    uart_device_t *dev = &uart_devices[uart_id];
    uint32_t tick_start = get_tick();
    
    for (uint32_t i = 0; i < len; i++) {
        /* 等待发送缓冲区有空位 */
        while (ring_buf_is_full(&dev->tx_rb)) {
            if (get_tick() - tick_start > timeout) {
                return -1;  /* 超时 */
            }
        }
        ring_buf_write(&dev->tx_rb, data[i]);
    }
    
    /* 使能发送中断,开始发送 */
    dev->instance->CR1 |= USART_CR1_TXEIE;
    
    return 0;
}

接收函数,从环形缓冲区读取数据:

int32_t uart_receive(uint8_t uart_id, uint8_t *buf, uint32_t len, uint32_t timeout) {
    uart_device_t *dev = &uart_devices[uart_id];
    uint32_t tick_start = get_tick();
    uint32_t received = 0;
    
    while (received < len) {
        if (ring_buf_read(&dev->rx_rb, &buf[received])) {
            received++;
        } else {
            if (get_tick() - tick_start > timeout) {
                return received;  /* 返回已接收的字节数 */
            }
        }
    }
    
    return received;
}
避坑指南:我曾经在中断里直接调用printf打印日志,结果系统直接死机。为什么?printf是阻塞的,中断里调用它,发送还没完成,中断又来了,直接嵌套死锁。记住:中断里只做数据搬运,不做数据处理。

4.4 整体架构图

下面这张图,把UART驱动的分层结构和数据流画清楚了。应用层调用标准API,HAL层管理环形缓冲区,底层驱动处理硬件寄存器:

UART驱动分层架构与数据流 应用层 (Application) HAL层:标准接口 (uart_init/send/receive/irq_handler) 发送环形缓冲区 接收环形缓冲区 底层驱动:寄存器操作 + 中断向量 硬件:STM32 UART外设 数据流方向:应用层 → HAL接口 → 环形缓冲区 → 底层驱动 → 硬件外设

4.5 使用示例

最后,看看实际怎么用。主循环里调用API,清爽得很:

int main(void) {
    uart_config_t config = {
        .baudrate = 115200,
        .data_bits = UART_DATA_8BITS,
        .stop_bits = UART_STOP_1BIT,
        .parity = UART_PARITY_NONE
    };
    
    /* 初始化UART1 */
    uart_init(1, &config);
    
    uint8_t recv_buf[64];
    
    while (1) {
        /* 接收数据,超时100ms */
        int32_t len = uart_receive(1, recv_buf, 64, 100);
        
        if (len > 0) {
            /* 收到数据,原样回显 */
            uart_send(1, recv_buf, len, 1000);
        }
        
        /* 其他任务... */
    }
}

你看,应用层完全不知道底层是STM32还是别的芯片。它只管调用uart_init、uart_send、uart_receive。这就是分层封装的价值。

最后提醒一句:中断优先级一定要配好。UART中断优先级不能太高,否则会阻塞其他更紧急的中断;也不能太低,否则数据可能丢失。我一般把UART中断设为中等优先级,够用。