0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 未分類
  3. 正文

UART 通訊協定完整教學:從原理到 STM32/ESP32 實作

2026 年 5 月 24 日 940點熱度 0人點贊 0條評論

UART 通訊協定完整教學封面

前言:看起來最簡單的介面,卡住的人卻最多

UART 是嵌入式開發裡最老的東西之一。現在 SPI、I2C、RS485、CAN 百花齊放,但講到除錯輸出、GPS 模組、藍牙模組、工業感測器,最後還是得回到 UART。

很多初學者覺得 UART 就是「隨便接一接就能通」。我一開始也這樣想,結果被三件事卡了整整兩天:baud rate 誤差、中斷設計、資料遺失。它簡單的是協定本身,難的全都藏在細節裡。

所以這篇我打算從訊號層級一路講到 STM32 與 ESP32 的實戰程式碼,中間穿插我自己踩過的坑。

兩條線就能全雙工:TX 與 RX

UART 用兩條資料線做全雙工通訊:

  • TX(Transmit):發送資料
  • RX(Receive):接收資料

接線的時候要交叉:A 的 TX 接 B 的 RX,A 的 RX 接 B 的 TX。這句話我曾經寫在紙上貼在桌前,因為它真的太常接錯。

還有一個重點:UART 是非同步通訊,沒有獨立的時脈線。雙方靠約定好的 baud rate(每秒傳輸的位元數)來對齊節奏。省下一條線的好處很實在,代價是兩邊的節奏必須自己想辦法對準。

一個 UART 訊框長什麼樣

一個完整的訊框,順序是固定的:

  1. Idle(空閒):線路維持高電位(邏輯 1)
  2. Start Bit(起始位元):把線路拉低(邏輯 0),持續 1 個位元時間
  3. Data Bits(資料位元):5~9 位元,預設 8 位元,而且是 LSB first(最低有效位先送)
  4. Parity Bit(同位位元):可選,None/Even/Odd
  5. Stop Bit(停止位元):把線路拉回高電位,持續 1、1.5 或 2 個位元時間

最常見的組合是 8N1:8 個資料位元、不檢查同位、1 個停止位元。

Start Bit: 0
Data:      D0 D1 D2 D3 D4 D5 D6 D7 (LSB first)
Stop Bit:  1

UART 8N1 訊框時序

看圖最清楚:閒置時線路在高電位,起始位元把它拉到低電位,接著 D0 到 D7 一個一個送出去,最後用停止位元拉回高電位。

至於同位檢查(Even/Odd),邏輯其實很單純:資料位元與同位位元裡 1 的總數要維持一致。Even Parity 讓總數是偶數,Odd Parity 讓它是奇數。

Baud Rate:兩邊節奏不對,後面什麼都不用談

baud rate 直接決定通訊可不可靠。理論上雙方要完全一致,但實務上允許 ±2%~±3% 的誤差。常見的幾種速率:

Baud Rate 位元時間 適用場景
300 3.33 ms GPS / 舊設備
1200 833 μs 工業長距離
9600 104 μs 標準工業 / 感測器
115200 8.68 μs 除錯 / STM32 預設
921600 1.09 μs ESP32 高吞吐
2000000 0.5 μs 高速傳輸(ESP32)

鮑率換算:一個位元組要傳多久

速率越高、每個位元越窄,對時脈精度的要求就越嚴。我自己的習慣是先用 115200 打通,確認資料正確之後再往上加。

如果你要用非標準的 baud rate(例如 250000、500000),務必先確認 MCU 的 UART clock 能不能整除出目標值。STM32 的 USART 時脈公式是:

USART_DIV = UART_CLK / (8 × (2 - OVER8) × BaudRate)

OVER8=0 代表 16 倍取樣,OVER8=1 代表 8 倍。除不盡的話誤差會一路累積,最後就是你看到的資料錯誤。

硬體流量控制:請對方等我一下

接收端來不及處理的時候,總得有個辦法叫發送端先停下來。這就是流控制存在的理由:

  • RTS(Request To Send):接收端輸出,通知對方「我可以接收」
  • CTS(Clear To Send):發送端輸入,確認之後才發送

RTS/CTS 硬體流控制流程

在 STM32 裡啟用硬體流控制只要一行:

// HAL_UART_Init 中的 UART_HandleTypeDef
huart1.Init.HwFlowCtl = UART_HWCONTROL_RTS_CTS;

ESP32 Arduino 這邊要看仔細,Serial2.begin() 的最後一個參數並不是流控制:

Serial2.begin(115200, SERIAL_8N1, RX_PIN, TX_PIN, false, 256);
// 最後一個參數為 invert 信號,不表示 RTS/CTS
// ESP32 硬體 RTS/CTS 需用 Serial2.setPins(RX, TX, RTS, CTS)

ESP32 的硬體 RTS/CTS 要用 Serial2.setPins(RX, TX, RTS, CTS) 另外指定。這個坑我踩過,盯著參數說明看了半天才發現最後那個 false 指的是 invert 訊號。

STM32 實作:從 HAL 開始

先初始化。這一段用 CubeMX 產也一樣,但知道它到底設了什麼很重要:

#include "stm32f4xx_hal.h"

UART_HandleTypeDef huart2;

void MX_USART2_UART_Init(void)
{
    huart2.Instance = USART2;
    huart2.Init.BaudRate = 115200;
    huart2.Init.WordLength = UART_WORDLENGTH_8B;
    huart2.Init.StopBits = UART_STOPBITS_1;
    huart2.Init.Parity = UART_PARITY_NONE;
    huart2.Init.Mode = UART_MODE_TX_RX;
    huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart2.Init.OverSampling = UART_OVERSAMPLING_16;
    HAL_UART_Init(&huart2);
}

輪詢模式發送最直覺,代價是 CPU 會在原地等:

HAL_UART_Transmit(&huart2, (uint8_t *)"Hello UART\r\n", 14, HAL_MAX_DELAY);

中斷模式接收才是日常主力 —— 收到一個位元組就進一次回呼:

#define BUF_SIZE 256
uint8_t rx_buffer[BUF_SIZE];
uint16_t rx_index = 0;

void start_reception(void)
{
    HAL_UART_Receive_IT(&huart2, &rx_char, 1);
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART2)
    {
        rx_buffer[rx_index++] = rx_char;
        if (rx_index >= BUF_SIZE) rx_index = 0;

        // 收到換行字元時觸發處理
        if (rx_char == '\n')
        {
            process_line(rx_buffer, rx_index);
            rx_index = 0;
        }

        HAL_UART_Receive_IT(&huart2, &rx_char, 1);
    }
}

中斷接收流程

這段的重點在 rx_buffer[rx_index++] 那行:指標一直往前走,滿了就繞回開頭。

環形緩衝區方塊圖

要收任意長度的封包,DMA 加 IDLE 中斷是最實用的組合,因為你不用預先知道對方會送多少:

uint8_t tx_data[] = "DMA Transfer Test\r\n";
uint8_t rx_dma_buffer[128];

// 發送
HAL_UART_Transmit_DMA(&huart2, tx_data, sizeof(tx_data));

// 接收(IDLE line 中斷 + DMA)
HAL_UART_Ex_ReceiveToIdle_DMA(&huart2, rx_dma_buffer, sizeof(rx_dma_buffer));
__HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);

// 回呼
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART2) {
        HAL_UART_Transmit_DMA(&huart2, rx_dma_buffer, Size);
        HAL_UART_Ex_ReceiveToIdle_DMA(&huart2, rx_dma_buffer, 128);
    }
}

收到完整一包才進一次回呼,這就是它跟逐字元中斷最大的差別。速度拉高之後,你會很感謝這一行。

ESP32 實作:Arduino 與 ESP-IDF 兩條路

換平台之前,我建議先把兩邊的 API 對照一次,心裡有個底:

STM32 與 ESP32 的 UART 資源對照

Arduino 框架最快能跑起來:

#define RX2_PIN 16
#define TX2_PIN 17

void setup()
{
    Serial.begin(115200);
    Serial2.begin(115200, SERIAL_8N1, RX2_PIN, TX2_PIN);
    Serial2.setTimeout(50);
}

void loop()
{
    if (Serial2.available())
    {
        String line = Serial2.readStringUntil('\n');
        line.trim();
        Serial2.print("Echo: ");
        Serial2.println(line);
        Serial.println("RX: " + line);
    }
}

ESP-IDF 的寫法繁瑣一些,但控制力也高一些:

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

#define UART_PORT        UART_NUM_2
#define TXD_PIN          GPIO_NUM_17
#define RXD_PIN          GPIO_NUM_16
#define BUF_SIZE         1024

void uart_init(void)
{
    const uart_config_t config = {
        .baud_rate  = 115200,
        .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, &config);
    uart_set_pin(UART_PORT, TXD_PIN, RXD_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
    uart_driver_install(UART_PORT, BUF_SIZE * 2, 0, 0, NULL, 0);
}

void app_main(void)
{
    uart_init();
    uint8_t buf[256];

    while (1)
    {
        int len = uart_read_bytes(UART_PORT, buf, sizeof(buf)-1, pdMS_TO_TICKS(100));
        if (len > 0)
        {
            buf[len] = '\0';
            uart_send((char *)buf, len);
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

如果要處理錯誤狀態,就開事件佇列,讓驅動把事件丟給你:

QueueHandle_t uart_queue;

uart_driver_install(UART_PORT, BUF_SIZE * 2, BUF_SIZE * 2, 20, &uart_queue, 0);

void uart_event_task(void *pvParameters)
{
    uart_event_t event;
    uint8_t data[256];

    while (1) {
        if (xQueueReceive(uart_queue, &event, portMAX_DELAY))
        {
            switch (event.type) {
                case UART_DATA:
                    uart_read_bytes(UART_PORT, data, event.size, portMAX_DELAY);
                    break;
                case UART_FIFO_OVF:
                    uart_flush_input(UART_PORT);
                    break;
                case UART_BUFFER_FULL:
                    uart_flush_input(UART_PORT);
                    break;
                case UART_PARITY_ERR:
                    // 同位檢查錯誤
                    break;
                case UART_FRAME_ERR:
                    // 幀錯誤
                    break;
                default:
                    break;
            }
        }
    }
}

注意 UART_FIFO_OVF 與 UART_BUFFER_FULL 這兩個事件 —— 它們出現就代表有資料被丟掉了,該考慮加大緩衝或啟用流控制。

Baud Rate 誤差:動手算一次

以 STM32F407 @ 84 MHz APB2 clock、目標 115200 bps 為例:

USARTDIV = 84000000 / (16 × 115200) = 45.5729
整數部分 = 45, 小數部分 = 0.5729 × 16 ≈ 9
實際 = 84000000 / (16 × (45 + 9/16)) = 115226
誤差 = (115226 - 115200) / 115200 × 100% = 0.023%

0.023% 相當安全,離 ±2% 的邊界還很遠。但換成 250000 bps 就更漂亮了:

USARTDIV = 84000000 / (16 × 250000) = 21.0 → 精確

黃金法則:先查 MCU 的 UART clock,再用公式驗算目標 baud rate 能不能整除。這件事花你三分鐘,可以省下三小時的除錯。

硬體接線的四個雷

軟體寫對,線接錯一樣不會動。我照踩雷頻率排:

  1. TX/RX 交叉:很多人第一次接 UART 會把 TX-TX、RX-RX 連在一起,結果什麼都收不到
  2. 共地:兩端必須共用 GND,否則 floating 電位會造成隨機亂碼
  3. 電壓位準:STM32 通常是 3.3V,某些 GPS 模組是 5V,中間要加電平轉換
  4. 閒置腳位:沒用到的 UART 腳位建議加外部 pull-up,避免 floating 觸發中斷

資料遺失:照這個順序查

發送長封包時掉資料,是最煩人的一種症狀。我的檢查順序是:

UART 除錯排查表

  1. Buffer 太小 → 加大 RX buffer
  2. 中斷優先級不足 → 檢查 NVIC 優先權設置
  3. 沒有啟用 FIFO → STM32 USART 有 16 byte FIFO,啟用後可以減少中斷頻率
  4. 流控制未啟用 → 對方發太快,接收端根本來不及處理
  5. DMA 配置錯誤 → DMA 循環模式與增量位址有沒有設對

把這張表存下來,之後遇到掉資料就不用從頭猜。

延伸:RS-232 和 TTL UART 不是同一件事

名字裡都有 UART,但電氣規格完全不同:

特性 TTL UART RS-232
邏輯 1 3.3V / 5V -3V ~ -15V
邏輯 0 0V +3V ~ +15V
最大距離 ~1m ~15m
常見應用 MCU 內部通訊 工業電腦、DB9連接器
電平轉換 不需要 MAX3232 晶片

所以拿 RS-232 的 DB9 直接接 MCU 的 TX/RX,是有機會燒東西的 —— 這點要先記住。

上層自己包一層協定

裸發資料容易出錯,我後來都習慣在 UART 上層包一層簡單協定:

[Header 0xAA][Length][Payload...][CRC16][Footer 0x55]

用 C 的結構描述就是這樣:

typedef struct {
    uint8_t  header;    // 0xAA
    uint8_t  length;
    uint8_t  payload[256];
    uint16_t crc;
    uint8_t  footer;   // 0x55
} __attribute__((packed)) UartPacket;

接收端則用狀態機逐位元組解析:

typedef enum {
    WAIT_HEADER,
    WAIT_LENGTH,
    WAIT_PAYLOAD,
    WAIT_CRC,
    WAIT_FOOTER,
} ParserState;

ParserState state = WAIT_HEADER;

void parse_byte(uint8_t byte)
{
    switch (state)
    {
        case WAIT_HEADER:
            if (byte == 0xAA) {
                state = WAIT_LENGTH;
            }
            break;
        // ... 其餘狀態同理
    }
}

狀態機 + CRC 這個組合看起來囉唆,但它是我唯一敢在工業現場跑的東西。

總結

UART 看似簡單,但從 baud rate 計算、中斷設計、DMA 配置到自訂協定,每個環節都會影響最後的穩定度。幾個我認為最該記住的點:

  • 訊號層級:搞清楚 Start Bit、Data Bit、Stop Bit 的時序
  • 速率匹配:算一下 baud rate 誤差,確保落在 ±2% 以內
  • 接收策略:輪詢 < 中斷 < DMA+IDLE(效率由低到高)
  • 容錯設計:加上 CRC、超時重傳與狀態機解析
  • 硬體考慮:共地、電壓位準、接線極性,這三件事決定你看到的是資料還是亂碼

不論是 STM32 還是 ESP32,UART 的原理都一模一樣 —— 理解原理之後,換平台只是換 API 而已。

標籤: RS485 工業通訊
最後更新:2026 年 9 月 30 日

shi6a

這個人很懶,什麼都沒留下

點贊
下一篇 >

文章評論

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回覆

COPYRIGHT © 2026 0x6A Logbook. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang