
ESP32 藍牙傳統 SPP(Serial Port Profile)
ESP32 是一顆雙模藍牙晶片,同時支援 Bluetooth Classic(BR/EDR)與 Bluetooth Low Energy(BLE)。而在這兩者之間,最容易被低估、卻也最實用的其實是老派的藍牙傳統 SPP:它把無線藍牙連線模擬成一個虛擬序列埠,讓原本跑在有線 UART 上的設備,幾乎不用改程式就能變成無線的。
這件事的價值在於「不用重新發明協定」。你原本寫的序列埠讀寫邏輯、你儀器手冊上寫的 AT 指令、你的 Modbus 輪詢程式,通通可以照用 —— 只是那條線不見了。
藍牙傳統 SPP 最常見的應用場景:
- Arduino 無線燒錄與序列監控
- GPS 接收器資料串流
- 醫療設備(血壓計、體重計)資料傳輸
- 工業 PLC 無線除錯
- 取代 HC-05/HC-06 藍牙模組
Bluetooth Classic vs BLE
先解決一個很多人會卡住的問題:既然 ESP32 兩種都有,那我該用哪一種?答案不是「BLE 比較新所以選 BLE」,而是取決於你要傳什麼、以及對面那台裝置支不支援。

看這張表最快能抓到兩個關鍵:SPP 只有 Classic 有,BLE 沒有對應的序列埠 Profile;而 iOS 完全不支援 SPP。所以如果你的產品要接 iPhone,那不管 Classic 多適合透傳,你都只能走 BLE。反過來說,只要你有音頻串流或大流量資料的需求,Classic 的頻寬就是必要條件。
藍牙傳統協定棧
知道了要選 Classic,接著看它內部怎麼分層。SPP 站在最上層,往下每一層各司其職:

各層的職責是這樣切開的:
- Radio:79 個 1 MHz 頻道,以 1600 hops/s 跳頻
- Baseband:管理 ACL 非同步無連線邏輯傳輸
- Link Manager:配對、認證、加密、功率控制
- HCI:主機控制器介面(ESP32 內部使用,不開放給用戶)
- L2CAP:邏輯鏈結控制與適配協定,封裝/分段/重組
- RFCOMM:序列埠模擬協定,支援 RS-232 控制訊號
- SDP:服務發現協定,查詢遠端裝置支援的 Profile
- SPP:序列埠規範,定義 RFCOMM 如何模擬序列埠
寫程式時你只會碰到最上面的 SPP,但除錯時常常要往下想:連不上是 SDP 查不到服務?通道開不起來是 RFCOMM 的問題?還是根本卡在 Link Manager 的配對階段?知道分層,問題就有一半的輪廓了。
SPP 連線流程
從「我想連上對方」到「資料開始流動」,中間其實有五個階段:

- Inquiry(查詢):ESP32 發送查詢請求,掃描附近裝置(約 10~30 秒)
- Page(分頁):找到目標裝置後,發送 Page 訊息建立 ACL 連線
- SDP Query:查詢遠端裝置是否支援 SPP Profile
- RFCOMM Connect:建立 RFCOMM 通道(虛擬序列埠)
- Data Stream:雙向序列資料傳輸開始
注意第一步的時間尺度:Inquiry 動輒十幾二十秒。如果你已經知道對方的 MAC 位址,就直接跳過掃描,連線時間會從「秒」變成「毫秒」等級 —— 這也是為什麼正式產品幾乎都會把 MAC 寫死在程式裡。
RFCOMM 資料框架
資料送上無線之前會被包成框架。把它拆開來看,其實就是幾個欄位加上你的資料:

其中 Information 就是序列埠實際要傳的內容,其他欄位都是為了讓無線這段可靠傳輸而加上去的開銷。開銷是固定的,所以你的封包切得越小,被標頭吃掉的比率就越高 —— 這就是下一節效能數字背後的原因。
Arduino 程式設計
藍牙 SPP 從裝置(SerialToSerialBT)
從裝置最簡單,基本上就是把序列埠換成藍牙版本:
// ESP32 藍牙 SPP 從裝置(最簡範例)
// 手機可透過 Serial Bluetooth Terminal App 連線
#include "BluetoothSerial.h"
BluetoothSerial SerialBT;
void setup() {
Serial.begin(115200);
SerialBT.begin("ESP32_SPP_Device"); // 藍牙名稱
Serial.println("藍牙 SPP 已啟動,等待連線...");
}
void loop() {
// 讀取藍牙接收的資料,轉發到硬體序列埠
if (SerialBT.available()) {
char c = SerialBT.read();
Serial.write(c);
}
// 讀取硬體序列埠,轉發到藍牙
if (Serial.available()) {
char c = Serial.read();
SerialBT.write(c);
}
delay(5);
}
藍牙 SPP 主裝置(掃描並連線)
反過來,當 ESP32 要主動找對方連線時,就得走完整的掃描流程:
// ESP32 藍牙 SPP 主裝置 — 掃描並自動連線
#include "BluetoothSerial.h"
BluetoothSerial SerialBT;
String remoteName = "HC-05";
bool connected = false;
void setup() {
Serial.begin(115200);
SerialBT.begin("ESP32_Master");
Serial.println("開始掃描藍牙裝置...");
}
void loop() {
if (!connected) {
// 掃描並連線
BTScanResults *results = SerialBT.discover(15); // 掃描 15 秒
if (results) {
for (int i = 0; i < results->getCount(); i++) {
BTAdvertisedResult *device = results->getDevice(i);
if (device->getName().indexOf(remoteName) >= 0) {
Serial.printf("找到 %s (%s),正在連線...
",
device->getName().c_str(),
device->getAddress().toString().c_str());
if (SerialBT.connect(device)) {
connected = true;
Serial.println("連線成功!");
}
break;
}
}
delete results;
}
if (!connected) {
Serial.println("未找到目標裝置,10 秒後重試...");
delay(10000);
}
} else {
// 已連線,資料透傳
if (SerialBT.available()) {
Serial.write(SerialBT.read());
}
if (Serial.available()) {
SerialBT.write(Serial.read());
}
// 檢查連線狀態
if (!SerialBT.connected()) {
Serial.println("連線中斷!");
connected = false;
delay(1000);
}
}
}
指定 MAC 位址連線(跳過掃描)
如果裝置是固定的,直接填 MAC 位址最乾脆,也避開了那十幾秒的 Inquiry:
// ESP32 藍牙 SPP — 直接連線到指定 MAC 位址
#include "BluetoothSerial.h"
BluetoothSerial SerialBT;
const char* remoteAddress = "00:11:22:33:44:55"; // 目標裝置 MAC
void connectToKnownDevice() {
// 從 MAC 字串建立位址物件
uint8_t addr[6];
sscanf(remoteAddress, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx",
&addr[0], &addr[1], &addr[2],
&addr[3], &addr[4], &addr[5]);
if (SerialBT.connect(addr)) {
Serial.println("連線成功!");
} else {
Serial.println("連線失敗!");
}
}
自訂 UUID 與多重連線
當你要接的不是通用裝置,而是自己寫的服務,或是需要同時接多台,就得自己處理 UUID 與連線管理:
// ESP32 藍牙 SPP — 自訂 UUID 與多重連線
#include "BluetoothSerial.h"
BluetoothSerial SerialBT;
// 自訂 SPP UUID(預設為 0x1101)
// 可改用自訂 UUID 避免與其他 SPP 應用衝突
void customUUID() {
// 使用 SerialBT API 設定自訂 UUID
// SerialBT.registerSDPServiceRecord(uuid, name);
// UUID 格式: "00001101-0000-1000-8000-00805F9B34FB"
}
// 監控連線事件
void setup() {
Serial.begin(115200);
// 註冊連線回呼
SerialBT.register_callback([](esp_spp_cb_event_t event,
esp_spp_cb_param_t *param) {
switch (event) {
case ESP_SPP_SRV_OPEN_EVT: // 用戶端連入
Serial.printf("用戶端連線: %s
",
param->srv_open.rem_bda->address[0]); // 注意:此為簡化
break;
case ESP_SPP_CLOSE_EVT: // 連線中斷
Serial.println("連線中斷");
break;
default:
break;
}
});
SerialBT.begin("ESP32_SPP_Advanced");
}
ESP-IDF 原生 API
Arduino 框架底層其實就是包了這層 API。需要更細的控制,例如自己決定配對流程與 SDP 記錄時,就得直接用它:
// ESP-IDF 藍牙 SPP 範例
#include "esp_spp_api.h"
static void esp_spp_cb(esp_spp_cb_event_t event,
esp_spp_cb_param_t *param) {
switch (event) {
case ESP_SPP_SRV_OPEN_EVT:
ESP_LOGI("SPP", "Client Connected");
break;
case ESP_SPP_DATA_IND_EVT:
// 收到資料,回呼中處理
ESP_LOGI("SPP", "Data received: %d bytes",
param->data_ind.len);
// 回傳資料
esp_spp_write(param->data_ind.handle,
param->data_ind.len,
param->data_ind.data);
break;
case ESP_SPP_CLOSE_EVT:
ESP_LOGI("SPP", "Client Disconnected");
break;
default:
break;
}
}
void app_main(void) {
esp_bt_controller_mem_release(ESP_BT_MODE_BLE); // 僅使用 BT Classic
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
esp_bt_controller_init(&bt_cfg);
esp_bt_controller_enable(ESP_BT_MODE_CLASSIC_BT);
esp_bluedroid_init();
esp_bluedroid_enable();
esp_spp_register_callback(esp_spp_cb);
esp_spp_init(ESP_SPP_MODE_CB);
// 啟動 SPP 服務(自訂名稱)
esp_spp_start_srv(ESP_SPP_SEC_NONE, ESP_SPP_ROLE_SLAVE,
0, "ESP32_SPP_IDF");
ESP_LOGI("SPP", "SPP 服務已啟動,等待連線...");
}
藍牙配對與安全
序列埠一旦無線化,等於把 UART 暴露在空氣中,所以配對與加密不能省:
// ESP32 藍牙 SPP 配對模式設定
#include "esp_bt_device.h"
void setupSPPWithPairing() {
// 設定配對 PIN 碼
esp_bt_pin_type_t pin_type = ESP_BT_PIN_TYPE_FIXED;
esp_bt_pin_code_t pin_code;
memcpy(pin_code, "1234", 4); // 固定 PIN 碼
esp_bt_gap_set_pin(pin_type, 4, pin_code);
// 或使用變數 PIN(使用者輸入)
// esp_bt_pin_type_t pin_type = ESP_BT_PIN_TYPE_VARIABLE;
// esp_bt_gap_set_pin(pin_type, 0, NULL);
}
// 連線安全模式
// ESP_SPP_SEC_NONE = 0 (無安全)
// ESP_SPP_SEC_ENCRYPT = 1 (加密)
// ESP_SPP_SEC_AUTH = 2 (認證)
// ESP_SPP_SEC_AUTH_ENCRYPT = 3 (認證 + 加密)
// ESP_SPP_SEC_MODE4_LEVEL4 = 4 (最高安全)
實務上的取捨是:開發階段為了方便常常用「無需配對」,但產品出貨前一定要改成固定 PIN 或 SSP 配對,否則附近任何人都能連上你的裝置下指令。這不是理論風險,是真的會被掃到的。
效能測試
再來是大家最關心的:SPP 到底能跑多快?答案跟你的封包大小高度相關。

從測試數據可以總結:
- 最大吞吐量:約 85 KB/s(封包大小 672 bytes)
- 最佳封包大小:256~512 bytes(效能/延遲平衡)
- 最小封包 (16 bytes):僅 18 KB/s(封包頭部開銷佔比過高)
- 延遲:0.9~7.9 ms,取決於封包大小
- 實際限制:L2CAP MTU = 672 bytes,RFCOMM 最大資料承載
換句話說,把資料累積到 256~512 bytes 再一次送,會比每筆資料都立刻送出去快上不少。這在寫資料記錄器或 GPS 轉發時特別有感。
HC-05 相容模式
如果你的專案原本是用 HC-05 模組,想換成 ESP32 又不想改上層程式,可以模擬它的 AT 指令介面:
// ESP32 完全相容 HC-05 AT 指令模式(遷移用)
// HC-05 使用 AT 指令設定,ESP32 原生不支援
// 但可自行實作 AT 指令解析器
void processATCommand(String cmd) {
if (cmd == "AT") {
SerialBT.println("OK");
} else if (cmd.startsWith("AT+NAME")) {
String name = cmd.substring(7);
SerialBT.end();
SerialBT.begin(name.c_str());
SerialBT.println("OKsetname");
} else if (cmd.startsWith("AT+PSWD")) {
String pwd = cmd.substring(7);
SerialBT.println("OKsetPSWD");
} else if (cmd == "AT+ROLE") {
SerialBT.println("+ROLE: 0"); // Slave mode
} else {
SerialBT.println("ERROR");
}
}
多工應用:藍牙 + Wi-Fi 共存
接下來是 ESP32 最容易被誤解的一點:它雖然同時支援 Wi-Fi 與藍牙,但兩者共用同一顆 2.4 GHz 射頻。

// ESP32 藍牙 + Wi-Fi 同時運行
// ESP32 的 Bluetooth 和 Wi-Fi 共用同一個 2.4 GHz 天線
// 硬體協調器自動切換時槽
#include <WiFi.h>
#include "BluetoothSerial.h"
BluetoothSerial SerialBT;
WiFiServer server(80);
void setup() {
Serial.begin(115200);
// 同時啟用 Wi-Fi 和藍牙
WiFi.begin("SSID", "PASSWORD");
SerialBT.begin("ESP32_DualMode");
server.begin();
Serial.println("Wi-Fi + BT Classic 同時運行");
}
void loop() {
// 藍牙資料處理
if (SerialBT.available()) {
String data = SerialBT.readString();
Serial.printf("[BT] %s
", data.c_str());
}
// Wi-Fi HTTP 伺服器處理
WiFiClient client = server.available();
if (client) {
// 處理 HTTP 請求
}
delay(5);
}
共存的代價就是「誰都不能全速跑」。實務上要記得:同時開啟時,藍牙與 Wi-Fi 的吞吐量都會下降,而且如果 Wi-Fi 通道選得不好,藍牙跳頻撞上 Wi-Fi 的機率會明顯上升。
雙模藍牙:BT Classic + BLE 共存
但如果你的需求是「Classic 做透傳、BLE 做手機連線」,那 ESP32 可以同時跑:
// ESP32 雙模藍牙範例(BT Classic SPP + BLE 同時)
// 注意:需要足夠的記憶體(建議使用 PSRAM)
// 使用 Arduino 框架時,BluetoothSerial 預設使用 BT Classic
// BLE 使用 BLEDevice / BLEServer 函式庫
// 兩者可同時初始化
// 同時啟用:
// BluetoothSerial SerialBT; // BT Classic SPP
// BLEDevice::init("ESP32_BLE"); // BLE
// 記憶體注意事項:
// - BT Classic + BLE 同時啟用需要約 1.2 MB ROM + 200 KB RAM
// - 如果 OOM,請關閉不需要的功能(例如 BT Classic 的 A2DP)
// - 在 ESP-IDF menuconfig 中可以精細控制
// 天線共用:
// ESP32 內建 RF 交換器,BT 和 Wi-Fi 共用天線
// 不需要外部切換電路
實務注意事項
下面這張表幾乎涵蓋了 SPP 專案會遇到的九成問題,建議遇到狀況先來掃一遍:
| 問題 | 原因 | 解法 |
|---|---|---|
| 連線不穩定 | 2.4 GHz 干擾(Wi-Fi、微波爐) | 增加重傳機制,使用跳頻 |
| 配對失敗 | PIN 碼不一致 | 設定固定 PIN 碼,或使用 SSP |
| iOS 無法連線 | iOS 不支援 SPP(MFi 限制) | 改用 BLE,或使用 MFi 認證晶片 |
| Android 需位置權限 | Android 6+ 藍牙掃描需位置權限 | 在 App 中請求 ACCESS_FINE_LOCATION |
| Wi-Fi 斷線 | BT + Wi-Fi 共存干擾 | 降低藍牙連線間隔,使用共存模式 |
| ESP32 重啟 | 藍牙堆疊記憶體不足 | 增加 FreeRTOS heap,關閉不需要的 BT Profile |
| HC-05 無法連線 | HC-05 預設為從裝置模式 | 確保 HC-05 角色設定正確 |
| 資料遺失 | RFCOMM 流量控制未設定 | 啟用 RTS/CTS 硬體流控 |
常見應用
最後用一張表看看大家實際上拿 SPP 做什麼,以及對應的流量等級:
| 應用 | 主/從 | 資料流量 | 備註 |
|---|---|---|---|
| 無線序列監控 | 從 | 低 (< 1 KB/s) | 取代 USB 線,方便除錯 |
| Arduino 無線燒錄 | 從 | 中 (~10 KB/s) | 需搭配 ESP32 燒錄工具 |
| GPS 資料串流 | 主 | 低 (~4800 bps) | NMEA 0183 協定 |
| 工業感測器資料收集 | 主 | 中 (~115200 bps) | Modbus RTU over SPP |
| 音頻串流 (APTX) | 從 | 高 (~300 KB/s) | 使用 A2DP Profile,非 SPP |
| HC-05 替代 | 主/從 | 可設定 | 完全相容,功能更強 |
總結
ESP32 的藍牙傳統 SPP 是 IoT 開發中非常實用的功能,特別是當你要把一條有線 UART 變成無線的時候。跟市面上常見的 HC-05/HC-06 模組相比,ESP32 內建藍牙不僅成本更低、功耗更優,而且還能同時跑 Wi-Fi + BT Classic + BLE 三種模式。

把決策流程攤成文字,選型參考就是這幾句:
- 最簡序列透傳:BluetoothSerial + SerialBT.begin()(一行初始化)
- 主裝置主動連線:SerialBT.discover() + SerialBT.connect()
- 雙模共存:BT Classic SPP + BLE 同時運行
- 工業應用:ESP-IDF esp_spp_api + 加密配對 + 流量控制
- HC-05 遷移:AT 指令相容層 + 相同腳位配置
我自己的判斷順序很簡單:先看對面那台裝置支援什麼,再看資料量有多大,最後才挑 Profile。先把「能不能連上」確認清楚,再談「跑得多快」,會省下很多白做的工。
文章評論