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 通信、蓝牙串口透传 |
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);
}
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;
}
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);
}
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 通信知识体系。你可以把它当作一个检查清单,看看自己掌握了哪些。
4.7 低功耗场景下的 UART 调优
做物联网项目,功耗是绕不开的话题。UART 在空闲时,TX 引脚是高电平,RX 引脚也是高电平。如果一直让 UART 模块上电,它会消耗几毫安的电流。在电池供电的设备里,这几毫安可能就是压死骆驼的最后一根稻草。
我分享几个低功耗调优的技巧:
- 空闲时关闭 UART 时钟:调用
uart_driver_delete()释放资源,需要时再重新初始化。代价是重新初始化需要几毫秒时间。 - 使用唤醒引脚:配置 RX 引脚为 GPIO 中断,当有数据到达时唤醒 ESP32。这样 UART 模块可以完全断电。
- 降低波特率:波特率越低,接收数据时 CPU 被中断唤醒的次数越少。9600 波特率下,每字节约 1ms,CPU 有更多时间睡大觉。
嗯,UART 串口通信这部分,说到底就是三个字:稳、快、省。稳是数据不丢,快是响应及时,省是功耗低。把这三点做好了,串口通信这块就算拿下了。