3. ESP32硬件基础

UART串口,嵌入式开发里最基础也最常用的通信方式。说它基础,是因为几乎所有MCU都自带UART外设;说它常用,是因为调试、传感器、GPS模块、蓝牙模块……哪哪都离不开它。

ESP32有3个UART控制器,但很多人只用默认的Serial.print()。其实用好双UART,能同时处理调试信息和业务数据。今天我就把UART协议、双UART配置、环形缓冲区这些硬骨头,掰开了讲清楚。

3.1 UART协议:你真的懂它吗?

UART全称是Universal Asynchronous Receiver/Transmitter。异步,意味着没有时钟线。收发双方靠约定的波特率来同步。

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

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

空闲时,TX线保持高电平。起始位拉低,告诉对方“我要发数据了”。然后按位发送数据,最后停止位拉高,结束一帧。

我踩过的坑: 波特率误差超过2%就会丢数据。尤其是115200以上,晶振偏差、分频误差都会累积。我习惯用逻辑分析仪实测TX脚的实际波特率,而不是只看代码里配的值。

ESP32的UART支持最高5Mbps。但实际项目中,超过921600就得小心走线长度和电平匹配了。

3.2 ESP32双UART配置:不只是Serial1和Serial2

ESP32的3个UART控制器,编号UART0、UART1、UART2。默认UART0连到USB转串口芯片,用于烧录和Serial输出。UART1和UART2的引脚可以任意映射到GPIO。

我个人习惯这样分配:

  • UART0:调试日志,固定接USB口
  • UART1:与主控或传感器通信
  • UART2:与无线模块(如GPS、LoRa)通信

配置代码其实不复杂,但有几个细节容易翻车:

// 初始化UART1,波特率115200,8N1
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,
    .source_clk = UART_SCLK_APB,
};
uart_param_config(UART_NUM_1, &uart_config);
uart_set_pin(UART_NUM_1, TXD_PIN, RXD_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
uart_driver_install(UART_NUM_1, BUF_SIZE * 2, BUF_SIZE * 2, 10, &uart_queue, 0);
注意: uart_driver_install的第三个参数是接收缓冲区大小。太小会丢包,太大浪费内存。我一般设为数据帧长度的2~3倍。另外,source_clk选APB(80MHz)还是REF_TICK(1MHz)会影响波特率精度,APB更灵活。

双UART同时收发时,中断优先级和任务调度要小心。我曾经在UART1中断里调用了延时函数,结果UART2的数据全丢了。后来改成用队列传递数据,中断里只做标记。

3.3 环形缓冲区:为什么不用数组?

很多人处理串口数据,直接用一个数组接收。数据来了就覆盖,或者用memcpy搬走。这样做的问题很明显:

  • 数据覆盖:处理不及时,新数据把旧数据冲了
  • 内存碎片:频繁申请释放,堆容易乱
  • 实时性差:必须等一帧收完才能处理

环形缓冲区(Ring Buffer)就是解决这些问题的。它用一块固定内存,通过头尾指针循环读写。生产者(中断)写数据,消费者(任务)读数据,互不干扰。

我常用的环形缓冲区实现:

typedef struct {
    uint8_t *buffer;
    uint32_t head;
    uint32_t tail;
    uint32_t size;
} ring_buf_t;

// 写一个字节
bool ring_buf_write(ring_buf_t *rb, uint8_t data) {
    uint32_t next = (rb->head + 1) % rb->size;
    if (next == rb->tail) {
        return false; // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return true;
}

// 读一个字节
bool ring_buf_read(ring_buf_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return false; // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % rb->size;
    return true;
}
关键点: 头尾指针相等表示空,头指针+1等于尾指针表示满。这样设计可以区分空和满两种状态。缓冲区大小必须是2的幂次方,这样取模可以用位运算优化。

在UART中断里,每收到一个字节就调用ring_buf_write。主循环或任务里,用ring_buf_read取出数据解析。这样即使中断频率很高,也不会阻塞主流程。

3.4 数据收发实战:一个完整的例子

下面是一个双UART同时收发的完整流程。UART1接收传感器数据,UART2发送到无线模块。

// 定义两个环形缓冲区
#define BUF_SIZE 256
ring_buf_t uart1_rb, uart2_rb;
uint8_t uart1_buf[BUF_SIZE];
uint8_t uart2_buf[BUF_SIZE];

// UART1中断处理
static void uart1_isr_handler(void *arg) {
    uint8_t data;
    while (uart_read_bytes(UART_NUM_1, &data, 1, 0)) {
        ring_buf_write(&uart1_rb, data);
    }
}

// 主任务
void uart_task(void *pvParameters) {
    uint8_t data;
    while (1) {
        // 从UART1缓冲区读数据
        if (ring_buf_read(&uart1_rb, &data)) {
            // 处理数据,比如协议解析
            process_data(data);
            // 转发到UART2
            uart_write_bytes(UART_NUM_2, &data, 1);
        }
        vTaskDelay(1); // 让出CPU
    }
}

这个结构的好处是:中断只做一件事——把数据塞进缓冲区。主任务按自己的节奏处理数据。即使UART1以1Mbps狂发数据,只要缓冲区够大,就不会丢。

避坑指南: 我曾经在中断里直接调用uart_write_bytes,结果死锁了。因为uart_write_bytes内部可能触发另一个中断。记住:中断里只做最轻量的事,读写缓冲区就够了。真正的数据收发放到任务里。

3.5 知识结构图

下面这张图展示了UART通信的完整数据流:

UART通信数据流图 UART通信数据流 发送设备 起始位 | 数据位 | 校验位 | 停止位 波特率:115200 8N1 接收设备 UART中断 环形缓冲区 head → tail 循环读写 主任务处理 核心原则 中断只写缓冲区,任务只读缓冲区,两者通过环形缓冲区解耦 缓冲区大小 = 最大数据帧 × 2,避免溢出

3.6 性能调优要点

双UART同时跑高速时,有几个地方容易成为瓶颈:

瓶颈点 表现 解决方案
中断频率过高 CPU占用率飙升 使用FIFO中断,一次收多个字节再触发
缓冲区太小 数据丢失 根据最大帧长×2设置缓冲区
任务优先级低 数据堆积 提高处理任务优先级,或使用DMA
波特率误差 乱码 用逻辑分析仪校准,或选REF_TICK时钟
我曾经踩过的坑: 用UART1接收GPS数据,波特率9600,缓冲区设了128字节。结果GPS每秒输出10条NMEA语句,每条80字节左右。缓冲区很快满了,数据开始丢失。后来改成512字节,问题解决。记住:缓冲区大小要按最坏情况算,别按平均情况算。

嗯,UART这块内容就这些。从协议到配置,从环形缓冲区到实战,每一步都有坑,但也都有解法。用好双UART和环形缓冲区,你的ESP32串口通信就能稳如老狗。