3、UART串口通信:从协议到实战

UART串口,嵌入式开发里最基础也最常用的通信接口。说实话,我做了这么多年项目,几乎每个板子上都会用到它——调试打印、与传感器通信、甚至做固件升级。这一章,我们就把它彻底讲透。

3.1 UART协议基础

UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。它只需要两根线:TX(发送)和RX(接收),就能实现全双工通信。

为什么叫“异步”?因为没有独立的时钟线。收发双方靠约定的波特率来同步。你想想看,就像两个人约定好每秒说一个字,不需要额外喊“开始”和“结束”。

一个完整的UART数据帧长这样:

起始位 数据位(5~8位) 校验位(可选) 停止位(1/1.5/2位)
1位,低电平 LSB先发 奇/偶/无 高电平

空闲时,TX线保持高电平。当要发送数据时,先拉低一个位时间——这就是起始位。接收方检测到这个下降沿,就知道“要来了”。然后按波特率采样数据位,最后检查停止位。

我的习惯: 数据位固定用8位,无校验,1位停止位(8N1)。这是最通用的配置,兼容性最好。除非特殊需求,否则别折腾。

3.2 ESP32 UART控制器

ESP32有3个UART控制器:UART0、UART1、UART2。每个都是独立的硬件外设,支持硬件流控(RTS/CTS)、RS485模式,甚至红外通信。

关键参数一览:

参数 范围 说明
波特率 300 ~ 5 Mbps 常用115200、921600
数据位 5/6/7/8 一般用8
停止位 1/1.5/2 一般用1
硬件FIFO 128字节 收发各128字节
中断 可配置 FIFO阈值、超时等

这里有个坑——引脚映射。ESP32的UART引脚不是固定的,你可以任意配置GPIO。但默认情况下:

  • UART0:TX=GPIO1,RX=GPIO3(通常连接USB转串口芯片)
  • UART1:TX=GPIO10,RX=GPIO9(注意!这两个引脚在大多数开发板上被PSRAM占用)
  • UART2:TX=GPIO17,RX=GPIO16
我曾经踩过的坑: 用UART1做通信,结果死活收不到数据。查了半天才发现GPIO9和GPIO10被板上的PSRAM占用了。后来改用UART2,一切正常。所以,选引脚前一定先看原理图。

3.3 串口打印与日志系统

ESP-IDF的日志系统非常强大。它基于UART0实现,但你可以重定向到任何输出接口。

日志级别从低到高:

  • ESP_LOGE — 错误,红色
  • ESP_LOGW — 警告,黄色
  • ESP_LOGI — 信息,绿色
  • ESP_LOGD — 调试,默认关闭
  • ESP_LOGV — 详细,默认关闭

用法很简单:

ESP_LOGI(TAG, "Hello, UART! Baud rate: %d", 115200);
ESP_LOGE(TAG, "Error: %s", esp_err_to_name(err));

你可以在menuconfig里设置全局日志级别。项目开发阶段设成DEBUG,发布时改成INFO或WARN,避免日志影响性能。

个人经验: 我习惯在每个模块开头定义一个TAG:static const char *TAG = "uart_demo";。这样日志里能清楚看到是哪个模块输出的,调试时一目了然。

3.4 自定义串口收发

光会打印不够,我们得学会自己收发数据。ESP-IDF提供了两套API:

方式 特点 适用场景
阻塞式 简单,但会卡住任务 偶尔收发,不要求实时性
中断+队列 高效,不阻塞 高频通信,实时响应
DMA CPU零参与,最快 大数据量,如文件传输

先看最简单的阻塞式发送:

// 初始化UART
uart_config_t uart_config = {
    .baud_rate = 115200,
    .data_bits = UART_DATA_8_BITS,
    .parity = UART_PARITY_DISABLE,
    .stop_bits = UART_STOP_BITS_1,
    .flow_ctrl = UART_HW_FLOWCTRL_DISABLE
};
uart_param_config(UART_NUM_2, &uart_config);
uart_set_pin(UART_NUM_2, TX_PIN, RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
uart_driver_install(UART_NUM_2, BUF_SIZE, 0, 0, NULL, 0);

// 发送数据
const char *data = "Hello from UART2!";
uart_write_bytes(UART_NUM_2, data, strlen(data));

// 接收数据
uint8_t rx_buf[128];
int len = uart_read_bytes(UART_NUM_2, rx_buf, sizeof(rx_buf), pdMS_TO_TICKS(100));
if (len > 0) {
    ESP_LOGI(TAG, "Received %d bytes", len);
}

注意uart_read_bytes的最后一个参数是超时时间。设为0表示不等待,有数据就返回;设为portMAX_DELAY则一直等到有数据为止。

我建议: 接收时不要用portMAX_DELAY,否则任务会被挂起。设个合理的超时时间,比如100ms,这样既能及时响应,又不会浪费CPU。

3.5 环形缓冲区应用

串口通信最头疼的问题是什么?数据来了,但CPU正在忙别的事。这时候就需要环形缓冲区(Ring Buffer)来救场。

ESP-IDF提供了现成的环形缓冲区实现,就在freertos/ringbuf.h里。但更常用的是UART驱动自带的环形缓冲区——你在安装驱动时指定的那个BUF_SIZE。

来看一个实际例子:用环形缓冲区实现“接收不定长数据”。

#define UART_BUF_SIZE (1024)
#define UART_RX_TIMEOUT_MS (10)

// 安装驱动时启用环形缓冲区
uart_driver_install(UART_NUM_2, UART_BUF_SIZE, 0, 0, NULL, 0);

// 接收任务
void uart_rx_task(void *arg) {
    uint8_t rx_buf[256];
    while (1) {
        int len = uart_read_bytes(UART_NUM_2, rx_buf, sizeof(rx_buf), 
                                  pdMS_TO_TICKS(UART_RX_TIMEOUT_MS));
        if (len > 0) {
            // 数据已经在rx_buf里了
            process_data(rx_buf, len);
        }
    }
}

这里的关键是:uart_read_bytes从硬件FIFO里读数据,如果一次没读完,剩下的会暂存在驱动内部的环形缓冲区里。下次调用时继续读。这样即使CPU被高优先级任务抢占了,数据也不会丢。

避坑指南: 我曾经遇到一个bug——接收大数据包时总是丢后半段。排查后发现是环形缓冲区太小,数据被覆盖了。解决办法:把BUF_SIZE从256改成1024,问题解决。记住,缓冲区大小要大于你期望的最大数据包长度。

下面这张图展示了UART数据从硬件到应用层的完整流程:

UART数据流:从硬件到应用层 UART硬件FIFO 128字节 驱动环形缓冲区 可配置大小 应用任务 处理数据 UART中断 关键流程说明: 1. 硬件FIFO收到数据后触发中断 2. 中断服务程序将数据搬入驱动环形缓冲区 3. 应用任务通过uart_read_bytes从环形缓冲区读取 4. 环形缓冲区防止数据丢失,允许CPU处理其他任务

看到这个流程你就明白了——硬件FIFO是第一道缓存,驱动环形缓冲区是第二道。两层缓冲保证了数据不会因为CPU来不及处理而丢失。

3.6 实战:构建可靠的串口收发

最后,我把前面讲的知识点串起来,写一个完整的串口收发示例。这个代码我在多个项目里复用过,很稳定。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/uart.h"
#include "esp_log.h"

#define UART_PORT UART_NUM_2
#define TX_PIN GPIO_NUM_17
#define RX_PIN GPIO_NUM_16
#define BUF_SIZE 1024
#define TAG "uart_demo"

void uart_init(void) {
    uart_config_t config = {
        .baud_rate = 115200,
        .data_bits = UART_DATA_8_BITS,
        .parity = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
        .source_clk = UART_SCLK_APB,
    };
    
    uart_param_config(UART_PORT, &config);
    uart_set_pin(UART_PORT, TX_PIN, RX_PIN, 
                 UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
    uart_driver_install(UART_PORT, BUF_SIZE, 0, 0, NULL, 0);
}

void uart_send(const char *data, size_t len) {
    uart_write_bytes(UART_PORT, data, len);
}

int uart_recv(uint8_t *buf, size_t max_len, TickType_t timeout) {
    return uart_read_bytes(UART_PORT, buf, max_len, timeout);
}

void app_main(void) {
    uart_init();
    
    uint8_t rx_buf[256];
    while (1) {
        int len = uart_recv(rx_buf, sizeof(rx_buf), pdMS_TO_TICKS(100));
        if (len > 0) {
            ESP_LOGI(TAG, "收到 %d 字节", len);
            // 回显收到的数据
            uart_send((char *)rx_buf, len);
        }
    }
}
注意: 这个例子是轮询方式,适合数据量不大的场景。如果数据量很大或者要求低延迟,建议用中断+队列的方式。我会在后面的章节专门讲中断驱动设计。

好了,UART串口通信的核心内容就这些。从协议基础到ESP32的硬件特性,从日志打印到自定义收发,再到环形缓冲区的应用——这套知识足够你应对大部分串口通信场景了。下次遇到串口问题,记得先检查引脚配置,再看缓冲区大小,最后确认波特率是否匹配。这三个地方出问题的概率最高。