UART / USART¶
UART(Universal Asynchronous Receiver/Transmitter)是嵌入式世界中最基础、最常用的串行通信协议。无论是调试打印、连接 GPS 模块还是驱动舵机总线,UART 是第一个必须掌握的协议。
一、原理¶
基本概念¶
UART 是异步通信协议——没有独立时钟线,收发双方提前约定好波特率(Baud Rate,bps),依靠各自内部时钟采样数据。
空闲(高) ───┐ START D0 D1 D2 D3 D4 D5 D6 D7 [PARITY] STOP ───
└──0──────────────────────────────────────────────────1────
↑ ↑
电平下降沿触发接收 1或2个停止位
| 字段 | 说明 |
|---|---|
| 起始位 | 1 bit 低电平,通知接收方帧开始 |
| 数据位 | 5~9 bit(通常 8 bit),LSB 先发 |
| 校验位 | 可选:无 / 奇校验 / 偶校验 |
| 停止位 | 1 或 2 bit 高电平,帧结束标志 |
波特率与误差¶
常用波特率:9600 / 19200 / 38400 / 57600 / 115200 / 230400 / 460800 / 921600 / 1Mbps
波特率误差
收发双方波特率误差 < 2% 才能可靠通信。时钟源频率与波特率的整除性会引入误差,CubeMX 会自动计算并提示误差率。
TTL vs RS232 vs RS485 电平¶
| 标准 | 电平 | 典型应用 | 说明 |
|---|---|---|---|
| TTL 3.3V | 0/3.3V | MCU ↔ 模块 | STM32 原生电平 |
| TTL 5V | 0/5V | Arduino ↔ 模块 | 注意 3.3V MCU 不能直接接 |
| RS232 | ±3V ~ ±15V | PC COM 口 ↔ 工控设备 | 需要电平转换芯片(MAX232) |
| RS485 | 差分 ±0.2V ~ ±6V | 工业总线 | 参见 RS485 专页 |
二、STM32 使用¶
CubeMX 配置¶
- Connectivity → USART1,Mode 选
Asynchronous - 参数:Baud Rate
115200,Word Length8 Bits,ParityNone,Stop Bits1 - 引脚:PA9 (TX),PA10 (RX) 自动分配
- DMA(推荐):在 DMA Settings 中添加 USART1_TX 和 USART1_RX
- NVIC:勾选 USART1 global interrupt
graph LR
STM32["STM32\nPA9(TX) → PA10(RX)"]
USB["USB-TTL\nCH340/CP2102"]
PC["PC\n串口助手"]
STM32 -->|"TX → RX"| USB
USB -->|"RX → TX"| STM32
USB <--> PC
HAL 库常用函数¶
/* ---- 阻塞发送(简单场景) ---- */
// 发送字符串
HAL_UART_Transmit(&huart1, (uint8_t*)"Hello\r\n", 7, 100);
// 发送结构体(二进制数据)
typedef struct {
float angle;
float speed;
uint8_t mode;
} MotorState;
MotorState state = {1.57f, 30.0f, 1};
HAL_UART_Transmit(&huart1, (uint8_t*)&state, sizeof(state), 100);
/* ---- 阻塞接收 ---- */
uint8_t buf[32];
HAL_UART_Receive(&huart1, buf, 32, 1000); // 超时1000ms
/* ---- 中断接收(推荐:不阻塞主循环)---- */
// 在 main 中开启一次接收
uint8_t rx_byte;
HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 每次接1字节
// 回调函数(stm32xx_it.c 中自动调用)
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
if (huart->Instance == USART1) {
// 处理 rx_byte
process_byte(rx_byte);
// 重新开启接收
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
}
}
/* ---- DMA 接收(最高效:零 CPU 开销)---- */
uint8_t dma_buf[256];
HAL_UART_Receive_DMA(&huart1, dma_buf, 256);
// 结合 IDLE 中断检测帧结束(HAL 高版本支持)
// 在 MX_USART1_UART_Init() 后调用:
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, dma_buf, sizeof(dma_buf));
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
if (huart->Instance == USART1) {
// Size = 本次实际接收字节数
parse_frame(dma_buf, Size);
// 重新开启
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, dma_buf, sizeof(dma_buf));
}
}
printf 重定向¶
/* 在 usart.c 或 main.c 中添加 */
#include <stdio.h>
int __io_putchar(int ch) {
HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, 10);
return ch;
}
// 使用(需要 MicroLib 或在工程设置中勾选 Use MicroLIB)
printf("angle = %.2f deg\r\n", angle);
自定义帧协议(防粘包)¶
裸 UART 流没有帧边界,需要自己设计协议:
/* 帧格式:帧头(2B) + 长度(1B) + 数据(nB) + 校验(1B) */
#define FRAME_HEADER_1 0xAA
#define FRAME_HEADER_2 0x55
typedef struct {
uint8_t h1, h2;
uint8_t len;
uint8_t data[64];
uint8_t checksum;
} UartFrame;
uint8_t calc_checksum(uint8_t *buf, uint8_t len) {
uint8_t sum = 0;
for (int i = 0; i < len; i++) sum ^= buf[i];
return sum;
}
// 状态机解析
typedef enum { WAIT_H1, WAIT_H2, WAIT_LEN, WAIT_DATA, WAIT_CS } ParseState;
void parse_byte(uint8_t byte) {
static ParseState state = WAIT_H1;
static UartFrame frame;
static uint8_t data_idx;
switch (state) {
case WAIT_H1:
if (byte == FRAME_HEADER_1) state = WAIT_H2;
break;
case WAIT_H2:
state = (byte == FRAME_HEADER_2) ? WAIT_LEN : WAIT_H1;
break;
case WAIT_LEN:
frame.len = byte;
data_idx = 0;
state = WAIT_DATA;
break;
case WAIT_DATA:
frame.data[data_idx++] = byte;
if (data_idx >= frame.len) state = WAIT_CS;
break;
case WAIT_CS:
if (byte == calc_checksum(frame.data, frame.len))
handle_frame(&frame);
state = WAIT_H1;
break;
}
}
三、Linux 使用¶
串口设备¶
| 接口类型 | 设备节点 | 说明 |
|---|---|---|
| 板载串口(UART) | /dev/ttyS0, /dev/ttyAMA0 |
树莓派原生串口 |
| USB 转串口(CH340/CP2102) | /dev/ttyUSB0 |
插 USB 转 TTL 模块 |
| USB ACM(Arduino等) | /dev/ttyACM0 |
USB CDC 虚拟串口 |
# 查看可用串口设备
ls /dev/tty*
# 查看 USB 串口是否识别
dmesg | tail -20
# 串口权限(将用户加入 dialout 组,免 sudo)
sudo usermod -aG dialout $USER
# 命令行测试串口(minicom)
sudo apt install minicom
minicom -D /dev/ttyUSB0 -b 115200
# 用 screen 快速测试
screen /dev/ttyUSB0 115200
# 退出:Ctrl+A 然后 K
pyserial 基础使用¶
import serial
import time
# 打开串口
ser = serial.Serial(
port='/dev/ttyUSB0',
baudrate=115200,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1.0 # 读超时,None = 阻塞
)
# 发送
ser.write(b'Hello\r\n')
ser.write(bytes([0xAA, 0x55, 0x01, 0x42, 0x17])) # 二进制帧
# 接收
line = ser.readline() # 读到 \n 为止
data = ser.read(10) # 读固定长度
data = ser.read(ser.in_waiting) # 读缓冲区所有数据
# 关闭
ser.close()
非阻塞接收(线程方案)¶
import serial
import threading
import queue
class SerialReader:
def __init__(self, port: str, baudrate: int):
self.ser = serial.Serial(port, baudrate, timeout=0.1)
self.rx_queue: queue.Queue[bytes] = queue.Queue()
self._running = True
self._thread = threading.Thread(target=self._read_loop, daemon=True)
self._thread.start()
def _read_loop(self):
while self._running:
if self.ser.in_waiting:
data = self.ser.read(self.ser.in_waiting)
self.rx_queue.put(data)
def send(self, data: bytes):
self.ser.write(data)
def recv(self, timeout: float = 0.01) -> bytes | None:
try:
return self.rx_queue.get(timeout=timeout)
except queue.Empty:
return None
def close(self):
self._running = False
self._thread.join()
self.ser.close()
# 使用
reader = SerialReader('/dev/ttyUSB0', 115200)
reader.send(b'\xAA\x55\x01\x00\x56')
while True:
data = reader.recv()
if data:
print(f"收到: {data.hex()}")
帧解析(状态机 Python 版)¶
from enum import Enum
from dataclasses import dataclass
class State(Enum):
WAIT_H1 = 0
WAIT_H2 = 1
WAIT_LEN = 2
WAIT_DATA = 3
WAIT_CS = 4
@dataclass
class Frame:
data: bytes
class FrameParser:
HEADER = (0xAA, 0x55)
def __init__(self, on_frame):
self.on_frame = on_frame # 回调函数
self._state = State.WAIT_H1
self._buf = bytearray()
self._expected_len = 0
def feed(self, data: bytes):
for byte in data:
self._process(byte)
def _process(self, b: int):
if self._state == State.WAIT_H1:
if b == self.HEADER[0]: self._state = State.WAIT_H2
elif self._state == State.WAIT_H2:
self._state = State.WAIT_LEN if b == self.HEADER[1] else State.WAIT_H1
elif self._state == State.WAIT_LEN:
self._expected_len = b
self._buf.clear()
self._state = State.WAIT_DATA
elif self._state == State.WAIT_DATA:
self._buf.append(b)
if len(self._buf) >= self._expected_len:
self._state = State.WAIT_CS
elif self._state == State.WAIT_CS:
expected_cs = 0
for x in self._buf:
expected_cs ^= x
if b == expected_cs:
self.on_frame(Frame(bytes(self._buf)))
self._state = State.WAIT_H1
四、常见应用场景¶
Dynamixel / 飞特舵机总线¶
这类舵机使用半双工 UART(单线收发),需要控制收发方向。
import serial
import time
class DynamixelBus:
"""简易 Dynamixel Protocol 2.0 封装"""
def __init__(self, port: str, baudrate: int = 57600):
# pyserial 的半双工模式:RS485 方向控制
self.ser = serial.Serial(
port, baudrate,
timeout=0.1,
# 如果使用 USB2Dynamixel 或 U2D2,驱动自动控制方向
)
def _calc_crc(self, data: bytes) -> int:
crc_table = [0x0000, 0x8005, 0x800F, 0x000A, ...] # 省略表
crc = 0
for b in data:
# ... CRC16 计算
pass
return crc
def ping(self, servo_id: int) -> bool:
# Header(4B) + ID + LEN_L + LEN_H + INST(0x01) + CRC_L + CRC_H
packet = bytes([0xFF, 0xFF, 0xFD, 0x00, servo_id, 0x03, 0x00, 0x01])
# 计算并追加 CRC
self.ser.write(packet)
resp = self.ser.read(14)
return len(resp) > 0
GPS 模块(NMEA 协议)¶
import serial
gps = serial.Serial('/dev/ttyUSB0', 9600, timeout=1)
while True:
line = gps.readline().decode('ascii', errors='ignore').strip()
if line.startswith('$GPGGA'):
parts = line.split(',')
lat = parts[2] # 纬度
lon = parts[4] # 经度
print(f"位置: {lat} {parts[3]}, {lon} {parts[5]}")
五、注意事项与调试技巧¶
常见问题
- TX 接 TX:新手最常犯的错误。正确接法是:设备A的 TX → 设备B的 RX
- 电平不匹配:3.3V MCU 的 TX 直接接 5V 设备的 RX 一般没问题(5V 容忍),但 5V 设备的 TX 接 3.3V MCU 的 RX 需要电平转换
- 共地:两个设备必须 GND 相连,否则通信失败
- 波特率计算误差:高波特率(如 1Mbps)时,时钟误差可能超标,查看 CubeMX 的误差提示
调试技巧
- 用逻辑分析仪(或示波器)测量 TX 引脚波形,验证数据是否正确发出
- Linux 下用
cat /dev/ttyUSB0快速查看原始数据 - 发送数据时先测 回环(TX 接 RX),验证收发通路正常
- 长时间通信出现乱码:检查接地线阻抗、布线走向(远离电机)
- STM32 串口接收时推荐始终使用 DMA + IDLE 中断,彻底避免丢数据