4. UART 串口通信:驱动配置、环形缓冲区与实战通信

UART 串口,在嵌入式开发里就像人的嘴巴和耳朵。你想想看,设备要跟 PC 聊天,要跟传感器传数据,最直接、最常用的就是串口。ESP32 有 3 个 UART 控制器,每个都能独立配置,这给了我们很大的灵活性。

我个人习惯,拿到一个新板子,第一件事就是先把串口调通。为什么?因为它是调试的命脉。printf 打印、日志输出、AT 指令交互,全得靠它。要是串口都跑不起来,后面的工作根本没法开展。

4.1 UART 硬件基础与引脚分配

ESP32 的 UART 模块挺有意思。它支持 5-8 位数据位,可配置的停止位,还有奇偶校验。但最让我觉得方便的是它的 引脚映射功能——几乎任意 GPIO 都能当 TX/RX 用。

我在一个项目里就吃过亏。当时 PCB 打样回来,发现 UART2 的默认引脚跟 SPI Flash 冲突了。要是换别的芯片,这板子基本就废了。但 ESP32 只需要在代码里重新映射一下引脚,问题就解决了。

UART 控制器 默认 TX 默认 RX 常用场景
UART0 GPIO1 GPIO3 固件下载、控制台日志
UART1 GPIO10 GPIO9 外接传感器、GPS 模块
UART2 GPIO17 GPIO16 RS485 通信、蓝牙串口透传
注意:UART0 默认连接了 USB 转串口芯片(CP2102/CH340)。如果你要用 UART0 跟外部设备通信,记得把日志输出重定向到别的接口,否则两边数据会打架。

4.2 UART 驱动配置实战

ESP-IDF 的 UART 驱动配置,说白了就是填一个结构体。我刚开始用的时候,觉得参数太多,后来发现核心就几个:波特率、数据位、停止位、校验位。

下面这段代码是我项目里常用的配置模板。注意看,我习惯把波特率设成 115200,因为这是大部分传感器模块的默认值。

#include "driver/uart.h"
#include "driver/gpio.h"

// 定义 UART 参数
#define UART_PORT_NUM      UART_NUM_1
#define UART_TX_GPIO       GPIO_NUM_4
#define UART_RX_GPIO       GPIO_NUM_5
#define UART_BAUD_RATE     115200
#define UART_BUF_SIZE      1024

void uart_init(void) {
    // 配置 UART 参数
    uart_config_t uart_config = {
        .baud_rate = UART_BAUD_RATE,
        .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_NUM, &uart_config);

    // 设置引脚
    uart_set_pin(UART_PORT_NUM, UART_TX_GPIO, UART_RX_GPIO, 
                 UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);

    // 安装驱动,分配缓冲区
    uart_driver_install(UART_PORT_NUM, UART_BUF_SIZE, 
                        UART_BUF_SIZE, 0, NULL, 0);
}
我的经验:波特率别设太高。虽然 ESP32 理论上支持到 5Mbps,但实际线缆长度、干扰环境都会影响稳定性。工业现场我一般用 9600 或 19200,实验室环境用 115200 足够了。

4.3 环形缓冲区设计——为什么需要它?

你想想看,串口数据是异步到达的。CPU 正在忙着算东西,突然串口来了一堆字节。如果不用缓冲区,数据就丢了。

环形缓冲区(Ring Buffer)就是解决这个问题的。它像一个循环队列,写指针追着读指针跑。我当年第一次自己手写环形缓冲区,是在一个温湿度采集项目里。传感器每 100ms 发一次数据,主循环里还要处理 WiFi 连接和 MQTT 上报。没有缓冲区,数据根本收不全。

下面是我常用的环形缓冲区实现。注意看,我特意留了一个空位来区分「满」和「空」——这是经典做法。

#define RING_BUF_SIZE  256

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} ring_buf_t;

// 初始化
void ring_buf_init(ring_buf_t *rb) {
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
int ring_buf_put(ring_buf_t *rb, uint8_t data) {
    uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
    if (next == rb->tail) {
        return -1;  // 缓冲区满了
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return 0;
}

// 读取一个字节
int ring_buf_get(ring_buf_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空了
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 0;
}
核心要点:环形缓冲区的大小必须是 2 的幂次方?不一定。但用 2 的幂次方可以用位运算代替取模,速度更快。在低功耗场景下,每一点 CPU 开销都值得优化。

4.4 与 PC 通信:数据收发与解析

跟 PC 通信,最常用的就是串口调试助手。但实际项目里,我们往往需要自定义协议。我见过太多人直接用 printf 发字符串,然后 PC 端用字符串解析——这种方式在数据量小的时候还行,一旦数据量大或者有二进制数据,就乱套了。

我建议用 帧头 + 长度 + 数据 + 校验 的格式。下面是我在一个数据采集项目里用的协议结构:

字节偏移 内容 说明
0 0xAA 帧头
1 数据长度 (N) 有效数据字节数
2 ~ N+1 有效数据 传感器读数、状态等
N+2 校验和 前面所有字节的累加和

发送端代码很简单:

void send_packet(uint8_t *data, uint16_t len) {
    uint8_t checksum = 0;
    uint8_t header[2] = {0xAA, len & 0xFF};
    
    // 发送帧头
    uart_write_bytes(UART_PORT_NUM, header, 2);
    checksum += 0xAA + (len & 0xFF);
    
    // 发送数据
    uart_write_bytes(UART_PORT_NUM, data, len);
    for (int i = 0; i < len; i++) {
        checksum += data[i];
    }
    
    // 发送校验
    uart_write_bytes(UART_PORT_NUM, &checksum, 1);
}
曾经踩过的坑:校验和别用简单的累加。有一次我在强干扰环境下测试,数据连续出错,累加和竟然碰巧对了。后来我改用 CRC8,误码率才降下来。如果数据安全性要求高,直接用 CRC16 或 CRC32。

4.5 与传感器通信:以 Modbus RTU 为例

工业传感器很多都支持 Modbus RTU 协议。它本质上就是 UART 通信,只是加了一层主从式协议。ESP32 做主站,传感器做从站。

我记得第一次调 Modbus 的时候,死活读不到数据。后来用逻辑分析仪一看,发现是发送完请求后,没有等够 3.5 个字符时间的间隔就收数据了。Modbus 协议规定,帧与帧之间要有 3.5 个字符的静默时间。

下面是一个读取保持寄存器的例子:

// 读取从站地址 1 的保持寄存器,起始地址 0,读取 2 个寄存器
uint8_t modbus_cmd[] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0xC4, 0x0B};

void read_sensor_data(void) {
    // 清空接收缓冲区
    uart_flush(UART_PORT_NUM);
    
    // 发送 Modbus 请求
    uart_write_bytes(UART_PORT_NUM, modbus_cmd, sizeof(modbus_cmd));
    
    // 等待 3.5 个字符时间(115200 波特率下约 3ms)
    vTaskDelay(pdMS_TO_TICKS(5));
    
    // 读取响应
    uint8_t response[32];
    int len = uart_read_bytes(UART_PORT_NUM, response, sizeof(response), 
                              pdMS_TO_TICKS(100));
    
    if (len > 0) {
        // 解析响应,提取数据
        uint16_t value = (response[3] << 8) | response[4];
        printf("传感器值: %d\n", value);
    }
}

4.6 知识结构总览

下面这张图,是我梳理的 UART 通信知识体系。你可以把它当作一个检查清单,看看自己掌握了哪些。

UART 串口通信知识体系 UART 通信 硬件配置 驱动编程 环形缓冲区 通信协议 引脚映射 波特率选择 参数配置 中断处理 读写指针 溢出处理 自定义协议 Modbus RTU 核心:数据不丢、协议不乱、时序不错 —— 这是串口通信的三个黄金法则

4.7 低功耗场景下的 UART 调优

做物联网项目,功耗是绕不开的话题。UART 在空闲时,TX 引脚是高电平,RX 引脚也是高电平。如果一直让 UART 模块上电,它会消耗几毫安的电流。在电池供电的设备里,这几毫安可能就是压死骆驼的最后一根稻草。

我分享几个低功耗调优的技巧:

  • 空闲时关闭 UART 时钟:调用 uart_driver_delete() 释放资源,需要时再重新初始化。代价是重新初始化需要几毫秒时间。
  • 使用唤醒引脚:配置 RX 引脚为 GPIO 中断,当有数据到达时唤醒 ESP32。这样 UART 模块可以完全断电。
  • 降低波特率:波特率越低,接收数据时 CPU 被中断唤醒的次数越少。9600 波特率下,每字节约 1ms,CPU 有更多时间睡大觉。
我的实测数据:在一个温湿度采集项目中,UART 常开时系统功耗约 3.2mA。改成按需开启 + 唤醒引脚后,平均功耗降到 0.4mA。电池续航从 3 天延长到了 20 天。

嗯,UART 串口通信这部分,说到底就是三个字:稳、快、省。稳是数据不丢,快是响应及时,省是功耗低。把这三点做好了,串口通信这块就算拿下了。