
前言:看起來最簡單的介面,卡住的人卻最多
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 訊框長什麼樣
一個完整的訊框,順序是固定的:
- Idle(空閒):線路維持高電位(邏輯 1)
- Start Bit(起始位元):把線路拉低(邏輯 0),持續 1 個位元時間
- Data Bits(資料位元):5~9 位元,預設 8 位元,而且是 LSB first(最低有效位先送)
- Parity Bit(同位位元):可選,None/Even/Odd
- 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

看圖最清楚:閒置時線路在高電位,起始位元把它拉到低電位,接著 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):發送端輸入,確認之後才發送

在 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 對照一次,心裡有個底:

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 能不能整除。這件事花你三分鐘,可以省下三小時的除錯。
硬體接線的四個雷
軟體寫對,線接錯一樣不會動。我照踩雷頻率排:
- TX/RX 交叉:很多人第一次接 UART 會把 TX-TX、RX-RX 連在一起,結果什麼都收不到
- 共地:兩端必須共用 GND,否則 floating 電位會造成隨機亂碼
- 電壓位準:STM32 通常是 3.3V,某些 GPS 模組是 5V,中間要加電平轉換
- 閒置腳位:沒用到的 UART 腳位建議加外部 pull-up,避免 floating 觸發中斷
資料遺失:照這個順序查
發送長封包時掉資料,是最煩人的一種症狀。我的檢查順序是:

- Buffer 太小 → 加大 RX buffer
- 中斷優先級不足 → 檢查 NVIC 優先權設置
- 沒有啟用 FIFO → STM32 USART 有 16 byte FIFO,啟用後可以減少中斷頻率
- 流控制未啟用 → 對方發太快,接收端根本來不及處理
- 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 而已。
文章評論