跳转至

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 配置

  1. Connectivity → USART1,Mode 选 Asynchronous
  2. 参数:Baud Rate 115200,Word Length 8 Bits,Parity None,Stop Bits 1
  3. 引脚:PA9 (TX),PA10 (RX) 自动分配
  4. DMA(推荐):在 DMA Settings 中添加 USART1_TX 和 USART1_RX
  5. 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 中断,彻底避免丢数据