0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 程式開發
  3. 正文

ESP32 藍牙傳統 SPP(Serial Port Profile)完整教學:BT Classic 通訊、RFCOMM 框架、主從架構與 HC-05 相容

2026 年 6 月 21 日 780點熱度 0人點贊 0條評論

ESP32 藍牙傳統 SPP 完整教學封面

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」,而是取決於你要傳什麼、以及對面那台裝置支不支援。

Bluetooth Classic 與 BLE 比較表

看這張表最快能抓到兩個關鍵:SPP 只有 Classic 有,BLE 沒有對應的序列埠 Profile;而 iOS 完全不支援 SPP。所以如果你的產品要接 iPhone,那不管 Classic 多適合透傳,你都只能走 BLE。反過來說,只要你有音頻串流或大流量資料的需求,Classic 的頻寬就是必要條件。

藍牙傳統協定棧

知道了要選 Classic,接著看它內部怎麼分層。SPP 站在最上層,往下每一層各司其職:

BT 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 連線流程

從「我想連上對方」到「資料開始流動」,中間其實有五個階段:

SPP 連線建立流程

  1. Inquiry(查詢):ESP32 發送查詢請求,掃描附近裝置(約 10~30 秒)
  2. Page(分頁):找到目標裝置後,發送 Page 訊息建立 ACL 連線
  3. SDP Query:查詢遠端裝置是否支援 SPP Profile
  4. RFCOMM Connect:建立 RFCOMM 通道(虛擬序列埠)
  5. Data Stream:雙向序列資料傳輸開始

注意第一步的時間尺度:Inquiry 動輒十幾二十秒。如果你已經知道對方的 MAC 位址,就直接跳過掃描,連線時間會從「秒」變成「毫秒」等級 —— 這也是為什麼正式產品幾乎都會把 MAC 寫死在程式裡。

RFCOMM 資料框架

資料送上無線之前會被包成框架。把它拆開來看,其實就是幾個欄位加上你的資料:

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 到底能跑多快?答案跟你的封包大小高度相關。

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 藍牙 + 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 三種模式。

該用 SPP 還是 BLE 的選型決策

把決策流程攤成文字,選型參考就是這幾句:

  • 最簡序列透傳:BluetoothSerial + SerialBT.begin()(一行初始化)
  • 主裝置主動連線:SerialBT.discover() + SerialBT.connect()
  • 雙模共存:BT Classic SPP + BLE 同時運行
  • 工業應用:ESP-IDF esp_spp_api + 加密配對 + 流量控制
  • HC-05 遷移:AT 指令相容層 + 相同腳位配置

我自己的判斷順序很簡單:先看對面那台裝置支援什麼,再看資料量有多大,最後才挑 Profile。先把「能不能連上」確認清楚,再談「跑得多快」,會省下很多白做的工。

標籤: 教學
最後更新: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