3、UART串口通信:从协议基础到ESP32实战
UART串口,说白了就是嵌入式开发者的「生命线」。你想想看,从最初的调试信息输出,到与传感器、GPS模块、蓝牙模块通信,哪样离得开它?我个人习惯,拿到一块新板子,第一件事就是先把串口调通——串口通了,心就定了。
这一章,咱们就彻底把UART在ESP32上的玩法讲透。从协议基础到多串口配置,再到环形缓冲区的实现,最后和PC上位机联调。嗯,都是实战中每天都要用的东西。
3.1 UART协议基础:你真的懂串口吗?
UART(Universal Asynchronous Receiver/Transmitter),通用异步收发器。异步,意味着没有时钟线,靠双方约定好的波特率来同步。
一个完整的UART数据帧长这样:
| 起始位 | 数据位(5~8位) | 校验位(可选) | 停止位(1/1.5/2位) |
|---|---|---|---|
| 1位,低电平 | LSB先发 | 奇/偶/无 | 高电平 |
最常用的配置是:115200波特率、8数据位、无校验、1停止位,简写为115200-8-N-1。我在项目中遇到过有人把波特率配错成9600,结果上位机死活收不到正确数据,排查了半天才发现是波特率不匹配——这种低级错误,犯过一次就记住了。
波特率:双方必须一致,误差不超过±2%
数据位:通常8位,老设备可能用7位
停止位:1位最常用,2位用于抗干扰差的场景
3.2 ESP32多串口配置:三个UART怎么用?
ESP32一共有3个UART控制器:UART0、UART1、UART2。但要注意,UART0默认连接到了板载的USB转串口芯片(CP2102或CH340),所以它通常被用作日志输出和程序下载。
| UART编号 | 默认TX引脚 | 默认RX引脚 | 常用场景 |
|---|---|---|---|
| UART0 | GPIO1 | GPIO3 | 日志输出、程序下载 |
| UART1 | GPIO9 | GPIO10 | 外接传感器/模块 |
| UART2 | GPIO16 | GPIO17 | 外接设备 |
下面是一个标准的UART2初始化代码,我习惯把引脚映射到GPIO16和GPIO17:
#include "driver/uart.h"
#include "driver/gpio.h"
#define UART2_TX_PIN GPIO_NUM_16
#define UART2_RX_PIN GPIO_NUM_17
#define UART2_BAUD 115200
#define UART2_BUF_SIZE 1024
void uart2_init(void) {
uart_config_t uart_config = {
.baud_rate = UART2_BAUD,
.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_2, &uart_config);
uart_set_pin(UART_NUM_2, UART2_TX_PIN, UART2_RX_PIN,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
uart_driver_install(UART_NUM_2, UART2_BUF_SIZE,
UART2_BUF_SIZE, 0, NULL, 0);
}
这段代码里,uart_driver_install的第二个参数是接收缓冲区大小,第三个是发送缓冲区大小。我建议至少设到1024字节,不然数据稍微多点就容易丢包。
3.3 环形缓冲区实现:解决数据粘包和丢失
为什么要用环形缓冲区?你想想看,如果串口数据是一段一段来的,每次中断里只读一个字节,那主循环里怎么知道数据有没有收完?
环形缓冲区(Ring Buffer)就是解决这个问题的。它本质上是一个固定大小的数组,用两个指针(读指针和写指针)来管理数据。写指针追着读指针跑,永远不会溢出——除非你写入速度远大于读取速度。
下面是一个轻量级的环形缓冲区实现:
typedef struct {
uint8_t *buffer;
uint32_t head; // 写指针
uint32_t tail; // 读指针
uint32_t size; // 缓冲区大小
} ring_buf_t;
bool ring_buf_init(ring_buf_t *rb, uint32_t size) {
rb->buffer = malloc(size);
if (!rb->buffer) return false;
rb->head = 0;
rb->tail = 0;
rb->size = size;
return true;
}
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;
}
注意看,这里判断「满」和「空」的方式:head == tail表示空,(head + 1) % size == tail表示满。这意味着缓冲区实际能存的数据是size - 1个字节——牺牲一个位置来区分满和空状态。
在UART中断服务函数里,我们只需要把收到的字节塞进环形缓冲区:
static ring_buf_t uart_rb;
void uart2_isr_handler(void *arg) {
uint8_t data;
while (uart_read_bytes(UART_NUM_2, &data, 1, 0) > 0) {
if (!ring_buf_write(&uart_rb, data)) {
// 缓冲区满了,丢掉旧数据
// 我这里选择丢弃最旧的数据
ring_buf_read(&uart_rb, NULL);
ring_buf_write(&uart_rb, data);
}
}
}
3.4 与PC上位机联调:让数据流动起来
硬件和驱动都准备好了,怎么和PC通信?最常用的工具是串口调试助手,比如SSCOM、Putty、或者VSCode的串口插件。
联调时,我一般遵循这个流程:
- 硬件连接: ESP32的UART0通过USB线连到PC,驱动自动识别为COM口
- 参数匹配: 上位机设置115200-8-N-1,和ESP32保持一致
- 发送测试: 上位机发送"AT\r\n"或任意字符串,看ESP32能否回复
- 数据解析: 如果数据乱码,检查波特率;如果丢数据,检查缓冲区大小
下面是一个简单的ESP32回显程序,收到什么就返回什么:
void app_main(void) {
uart2_init();
ring_buf_init(&uart_rb, 256);
uint8_t data;
while (1) {
if (ring_buf_read(&uart_rb, &data)) {
uart_write_bytes(UART_NUM_2, &data, 1, portMAX_DELAY);
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
这个程序虽然简单,但已经构成了一个完整的串口通信链路。你可以用上位机发送"Hello ESP32",然后看到同样的字符串返回。
• 收不到数据:检查引脚映射、波特率、驱动是否安装
• 数据乱码:波特率不匹配,或者双方数据位/停止位不一致
• 数据丢包:环形缓冲区太小,或者主循环读取速度不够快
• 程序下载失败:按住BOOT键再按RESET,或者检查UART0是否被占用
3.5 知识体系总览
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个快速索引:
嗯,这一章的内容就到这里。UART串口看似简单,但真正用好它,需要理解协议、熟悉硬件、掌握缓冲区管理。我建议你动手把代码跑一遍,然后试着用环形缓冲区接收一帧完整的数据——比如GPS的NMEA语句。相信我,当你看到数据完整无误地出现在串口助手上的那一刻,你会觉得一切都值了。