0x6A Logbook

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

BLE(藍牙低功耗)通訊協定完整教學:從原理到 ESP32 實作

2026 年 6 月 1 日 965點熱度 0人點贊 0條評論

BLE 藍牙低功耗通訊協定完整教學封面

前言

先講結論:如果你在做物聯網,遲早會撞上 BLE。從小米手環、智慧門鎖、感測器信標到醫療設備,背後幾乎都是同一套東西 —— BLE(藍牙低功耗,Bluetooth Low Energy),它用極低的功耗就把可靠的資料交換做完了。

而我手上的 ESP32 剛好內建雙模藍牙(Classic + BLE),支援藍牙 5.0 標準。老實說,這大概是它最被低估的一塊能力,很多人的 ESP32 買回來只拿它跑 WiFi。

這篇我照自己的學習順序寫:先看協定棧的架構,再拆廣播/掃描/連線這三個機制,接著進到 GATT 服務模型,最後給兩套可以直接動的 ESP32 Arduino 與 IDF 範例。

一、BLE 技術概述

1.1 什麼是 BLE?

BLE 的身世有點曲折:它最早是 Nokia 在 2006 年用「Wibree」這個名字提出來的,2010 年才被正式納入 Bluetooth 4.0 標準。

我當初最困惑的就是「它跟經典藍牙(BR/EDR)到底差在哪」,後來整理成四個我覺得最關鍵的點:

  • 極低功耗:峰值電流僅 ~15 mA,休眠可達 1 μA
  • 快速連線:從廣播到建立連線只需 3~6 ms
  • 不同拓撲:支援廣播(Broadcast)、星狀(Piconet)、網狀(Mesh)
  • 彈性資料格式:透過 GATT Profile 自訂服務與特徵值

其中最有感的是前兩項:做完東西塞進鈕扣電池裡還能撐,這件事在經典藍牙的時代幾乎不用想。至於完整的差異,我習慣把它跟架構一起攤開來看:

BLE 與經典藍牙差異對照表

二、BLE 協定棧架構

協定棧是我當年卡最久的地方:網路上的圖每張都長得不太一樣。後來我抓到一個訣竅 —— 先看它被切成哪兩半,再往裡面看。BLE 的棧分成 Controller(底層硬體)與 Host(協定層),兩者中間由 HCI 介面分隔。

我自己的記法是:Controller 是「靠硬體做即時事」的那一半,Host 是「用軟體管邏輯」的那一半,而 HCI 就是兩邊講話的那條電話線。

2.1 Controller 層(硬體)

先從最底下這三層開始,它們決定了無線訊號怎麼送出去、什麼時候送:

層級 功能 說明
LE PHY 物理層 1M/2M/LE Coded 三種調變方式,2.4 GHz GFSK
Link Layer 鏈結層 5 種狀態機:Standby/Advertising/Scanning/Initiating/Connected
HCI 主機控制器介面 Host 與 Controller 之間的通訊協定(UART/USB/SDIO)

我在這裡踩過的坑是 Link Layer 那五個狀態機(Standby/Advertising/Scanning/Initiating/Connected)—— 我當初覺得那只是一張學術表格,後來才發現看日誌時,它往往就在告訴你裝置現在卡在哪一個狀態。

2.2 Host 層(軟體)

Host 這一半才是我們寫程式時真正會碰到的地方,五個協定各有分工:

層級 功能 說明
L2CAP 邏輯鏈結控制與適應協定 封裝/分段/重組上層資料,提供 MTU 協商
ATT 屬性協定 Client-Server 架構,支援 Read/Write/Notify/Indicate
GATT 通用屬性規範 定義 Service → Characteristic → Descriptor 層級
GAP 通用存取規範 控制廣播、掃描、連線建立與安全管理
SMP 安全管理協定 配對(Pairing)、綁定(Bonding)、加密(Encryption)

其中你一定會直接打交道的是 GATT 與 GAP:GATT 決定資料長什麼樣子,GAP 決定裝置怎麼被找到、怎麼連上。把這兩件事分清楚,後面的程式碼就好讀很多。

三、BLE 廣播與掃描

還沒連線之前,BLE 裝置靠「廣播」告訴附近的人自己在這裡。BLE 把 2.4 GHz 頻段劃成 40 個頻道(0~39),其中 37/38/39 三個是主要廣播頻道 —— 也就是說,廣播封包只會在這三個頻道上輪流出現。

而整個「被發現 → 被連上」的過程都歸 GAP 管,順序大概是這樣:

GAP 廣播與掃描到連線的流程圖

這裡我想先提醒一件事:廣播的型態會直接決定對方能不能連你。這是下一節的重點。

3.1 廣播類型

四種廣播類型用一個 byte 的類型欄位區分,實際寫程式時最常遇到的就是 0x00 與 0x02 這兩種:

類型 名稱 說明
0x00 可連線廣播 (Connectable) 掃描者可發起連線請求
0x01 可連線定向廣播 指定特定裝置可連線
0x02 不可連線廣播 (Non-connectable) 僅廣播,不接受連線(信標)
0x03 可掃描廣播 (Scannable) 可回應掃描請求但不接收連線

信標(Beacon)就是 0x02 的典型:它只廣播、不接受連線,所以永遠不會佔用到誰的連線名額。

3.2 ESP32 BLE 廣播程式碼(Arduino)

先來做一個「只會喊、不接客」的裝置,順便當 iBeacon 的骨架:

// esp32_ble_beacon.ino — BLE 廣播範例(iBeacon)
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEBeacon.h>

// iBeacon 資料格式
// UUID: 廠商自定義(此處使用範例 UUID)
#define BEACON_UUID_REV "e42c4fee-9a77-4b9a-b1b3-7f5b5f1e3d2c"
#define MAJOR 1
#define MINOR 100
#define TX_POWER -59  // 1 公尺處 RSSI 參考值

void setup() {
    Serial.begin(115200);
    BLEDevice::init("MyBLEDevice");

    // 建立 iBeacon 廣播資料
    BLEAdvertisementData advData;
    advData.setFlags(0x06);  // LE General Discoverable + BR/EDR not supported

    // 自訂 iBeacon 格式
    std::string iBeaconData = "";
    iBeaconData += (char)0x02;  // Apple Company ID (低字節)
    iBeaconData += (char)0x15;  // Apple Company ID (高字節)
    iBeaconData += (char)0x15;  // Proximity UUID (16 bytes)
    // 填入自訂 UUID(簡化範例)
    uint8_t uuid[16] = { 0xE4, 0x2C, 0x4F, 0xEE, 0x9A, 0x77, 0x4B, 0x9A,
                          0xB1, 0xB3, 0x7F, 0x5B, 0x5F, 0x1E, 0x3D, 0x2C };
    for (int i = 0; i < 16; i++) iBeaconData += (char)uuid[i];
    iBeaconData += (char)(MAJOR >> 8);    // Major (高)
    iBeaconData += (char)(MAJOR & 0xFF);  // Major (低)
    iBeaconData += (char)(MINOR >> 8);    // Minor (高)
    iBeaconData += (char)(MINOR & 0xFF);  // Minor (低)
    iBeaconData += (char)TX_POWER;        // TX Power

    advData.setManufacturerData(iBeaconData);
    advData.setName("MyBLEDevice");

    // 開始廣播
    BLEServer *pServer = BLEDevice::createServer();
    BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
    pAdvertising->setAdvertisementData(advData);
    pAdvertising->start();

    Serial.println("BLE Beacon broadcasting...");
}

void loop() {
    delay(1000);
}

幾個我自己會圈起來看的地方:那組 UUID 只是範例值;TX_POWER 是 1 公尺處的 RSSI 參考值(-59 dBm);還有 setFlags(0x06),我當初根本沒看懂,後來才搞清楚它是在宣告「LE General Discoverable + BR/EDR not supported」,等於替這台裝置貼上「歡迎被發現」的標籤。

3.3 ESP32 BLE Scanner — 掃描附近藍牙裝置

有廣播者,就要有掃描者。下面這段會把掃到的每一台裝置的名稱、位址、RSSI 與 TX Power 全部印出來:

// esp32_ble_scanner.ino — BLE 掃描範例
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>

class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
    void onResult(BLEAdvertisedDevice advertisedDevice) {
        Serial.printf("Device: %s | Addr: %s | RSSI: %d dBm | TX: %d\n",
                      advertisedDevice.getName().c_str(),
                      advertisedDevice.getAddress().toString().c_str(),
                      advertisedDevice.getRSSI(),
                      advertisedDevice.getTXPower());
    }
};

void setup() {
    Serial.begin(115200);
    BLEDevice::init("");

    BLEScan *pBLEScan = BLEDevice::getScan();
    pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
    pBLEScan->setActiveScan(true);   // 主動掃描(請求 Scan Response)
    pBLEScan->setInterval(100);      // 掃描間隔 (ms)
    pBLEScan->setWindow(99);         // 掃描視窗 (ms)

    Serial.println("Scanning for BLE devices...");
    while (1) {
        BLEScanResults results = pBLEScan->start(5);  // 掃描 5 秒
        Serial.printf("Found %d devices\n", results.getCount());
        pBLEScan->clearResults();
    }
}

這個版本的重點在三個設定值:setActiveScan(true) 是主動掃描(會另外請求 Scan Response)、setInterval(100) 是掃描間隔、setWindow(99) 是掃描視窗,單位都是 ms。而 onResult() 印出來的那四個欄位,大概就是除錯時最有價值的一組資訊。

四、BLE 連線與資料交換

連線建立之後,兩端的關係就固定下來了:一邊是 GATT Server,一邊是 GATT Client。

GATT Server 與 Client 角色方塊圖

我會想特別畫這張圖,是因為當初一直把「Server」誤會成「比較厲害的那一端」。其實角色只跟連線方向有關:被連上的那端是 Server,主動去連的那端是 Client,跟效能好壞完全無關。

4.1 連線參數

連上之後,資料交換的節奏由三個參數決定,而這三個值也直接決定了功耗與延遲:

參數 範圍 說明
Connection Interval 7.5 ms ~ 4.0 s 兩個連線事件之間的時間間隔(1.25 ms 步進)
Slave Latency 0 ~ 499 Slave 可跳過的連線事件數(節省功耗)
Supervision Timeout 10 ms ~ 32 s 若逾時未收到封包,視為連線中斷

我自己的心得是:Connection Interval 是「多久見一次面」,Slave Latency 是「可以翹幾次課」,Supervision Timeout 是「幾次沒出現就當你走了」。三個一起調,才是省電的真本事。

4.2 GATT 服務模型

GATT 最漂亮的地方是它的資料模型只有三層,而且關係很單純:

Service、Characteristic 與 Descriptor 階層圖

Service 是一組功能的集合,Characteristic 是真正存資料的地方,Descriptor 則是附加的說明。以一個溫度感測器為例,我的習慣是這樣記:Service 當資料夾、Characteristic 當檔案、Descriptor 當檔案的屬性設定。而 Client 就是透過 Read/Write/Notify 去操作這些 Characteristic。

4.3 ESP32 BLE GATT Server(Arduino)

接著讓 ESP32 當 Server,提供一個可讀、可寫、還會主動通知的 Characteristic:

// esp32_ble_gatts.ino — BLE GATT Server 範例
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>

#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

BLECharacteristic *pCharacteristic;
bool deviceConnected = false;

class MyServerCallbacks : public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
        deviceConnected = true;
        Serial.println("Device connected!");
    }
    void onDisconnect(BLEServer* pServer) {
        deviceConnected = false;
        Serial.println("Device disconnected, restarting advertising...");
        pServer->getAdvertising()->start();
    }
};

class MyCharCallbacks : public BLECharacteristicCallbacks {
    void onWrite(BLECharacteristic *pChar) {
        std::string rxValue = pChar->getValue();
        if (rxValue.length() > 0) {
            Serial.print("Received: ");
            for (int i = 0; i < rxValue.length(); i++) { Serial.print(rxValue[i]); }
            Serial.println();
        }
    }
};

void setup() {
    Serial.begin(115200);
    BLEDevice::init("ESP32 BLE Sensor");
    BLEServer *pServer = BLEDevice::createServer();
    pServer->setCallbacks(new MyServerCallbacks());

    BLEService *pService = pServer->createService(SERVICE_UUID);

    pCharacteristic = pService->createCharacteristic(
        CHARACTERISTIC_UUID,
        BLECharacteristic::PROPERTY_READ |
        BLECharacteristic::PROPERTY_WRITE |
        BLECharacteristic::PROPERTY_NOTIFY
    );
    pCharacteristic->setCallbacks(new MyCharCallbacks());
    pCharacteristic->setValue("Hello BLE");

    pService->start();

    BLEAdvertising *pAdvertising = pServer->getAdvertising();
    pAdvertising->addServiceUUID(SERVICE_UUID);
    pAdvertising->start();

    Serial.println("BLE GATT Server ready!");
}

void loop() {
    if (deviceConnected) {
        // 模擬感測器資料推送
        static int counter = 0;
        char buf[16];
        snprintf(buf, sizeof(buf), "val=%d", counter++);
        pCharacteristic->setValue(buf);
        pCharacteristic->notify();  // 主動通知 Client
    }
    delay(1000);
}

這段程式有兩件事我特別想圈出來。第一,onDisconnect() 裡會重新廣播(不然對方一斷線,你就從世界上消失了);第二,loop() 每 1000 ms 用 notify() 主動把資料推出去 —— 這就是 Server 主動推送的樣子,Client 完全不必輪詢。

另外,把三個屬性掛在同一個 Characteristic 是很常見的寫法,但它們的權限其實不太一樣:

Characteristic 屬性與讀寫權限對照表

4.4 ESP32 BLE GATT Client(IDF)

同一個角色換到 ESP-IDF/NimBLE 這一側,寫法就完全是 C 的樣子了:

// esp32_ble_gattc.c — ESP-IDF BLE Client 範例 (片段)
#include "esp_nimble_hci.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "services/gap/ble_svc_gap.h"

// 掃描並連線到指定名稱的裝置
static void ble_scan_and_connect(void)
{
    struct ble_gap_disc_params disc_params;
    memset(&disc_params, 0, sizeof(disc_params));
    disc_params.filter_duplicates = 1;
    disc_params.passive = 0;           // 主動掃描
    disc_params.itvl = 100;             // 掃描間隔 (0.625ms 單位)
    disc_params.window = 99;            // 掃描視窗

    ble_gap_disc(BLE_OWN_ADDR_PUBLIC, 10000, &disc_params,
                 ble_gap_disc_cb, NULL);
}

// 發現服務後讀取特徵值
static int ble_gatt_svc_access_cb(uint16_t conn_handle,
    const struct ble_gatt_error *error,
    const struct ble_gatt_svc *service, void *arg)
{
    // 枚舉所有特徵值,找到目標後執行讀取或註冊 Notification
    return 0;
}

這裡我只放了兩個片段:掃描並連線(ble_scan_and_connect),以及發現服務後的回呼(ble_gatt_svc_access_cb)。請注意 disc_params.itvl = 100 那行註解 —— NimBLE 的單位是 0.625 ms,跟 Arduino 版直接寫 ms 不一樣,這種單位差異就是換平台時最容易寫錯的地方。

五、BLE 省電策略

BLE 的核心優勢就在超低功耗,但我覺得「省電」不是一個開關,而是一組取捨。原文整理的六招我照抄如下:

策略 效果 實作方式
增大 Connection Interval 減少每秒收發次數 從 30ms 改為 100ms 可降低 70% 功耗
啟用 Slave Latency 允許跳過部分事件 Latency=9 可跳過 9/10 的事件
使用 Notify 代替 Polling Server 主動推送 Client 不需定期輪詢
縮短廣播時間 減少空中的時間 連線後停止廣播,或使用快速廣播後休眠
資料長度擴展 (DLE) 減少封包數量 MTU 從 23 bytes 擴展到 251 bytes
WiFi 共存管理 避免射頻衝突 ESP32 內建 Coexistence 機制,自動協調 BLE/WiFi

裡面我最常用的是前兩招,因為它們不需要動到任何資料格式,只要調參數就會有感。不過在調參數之前,得先知道「一個封包到底能裝多少資料」,不然很容易調了半天卻不知道瓶頸在哪:

MTU 與資料長度對照表

這也是為什麼 MTU 協商常被列為 BLE 效能的第一件事:MTU 越小,同一筆資料要切的封包就越多,功耗也跟著變差。而預設值偏偏只有 23 bytes,不主動協商就一直是這個數字。

六、STM32WB BLE(簡介)

如果你之後想換平台,STM32WB 系列(例如 STM32WB55)是 ST 推出的雙核無線 MCU,整合了 BLE 5.0 與 802.15.4(Thread/Zigbee)。它跟 ESP32 最大的差別在架構:ESP32 是用外部 RAM 跑 BLE 協定棧,STM32WB 則是雙核心分工:

  • Cortex-M4(主核):運行應用程式
  • Cortex-M0+(網絡核):運行 BLE 協定棧(RF Stack)

兩個核心透過 IPCC(跨處理器通訊控制器)做 IPC 通訊,M4 這邊只要透過 ST BLE API,就能操作 M0+ 上的協定棧:

// STM32WB — BLE 初始化(使用 STM32Cube FW_WB)
#include "stm32wbxx_hal.h"
#include "ble_common.h"

// BLE 初始化(在 M0+ 核心上執行)
void BLE_Init(void)
{
    // 啟動 M0+ 網絡核心
    LL_C2_PWR_SetPowerMode(LL_PWR_MODE_STOP1);
    LL_HSEM_1StepLock(HSEM, CFG_HW_RCC_SEMID);
    LL_RCC_ForceBackupDomain();
    LL_RCC_SelectLSEMode();
    LL_RCC_SetLSEDrive();
    LL_RCC_LSE_Enable();

    // 載入 BLE 協定棧(由 STM32Cube 自動生成)
    // 透過 hci_init() 啟動 BLE Controller
    // 並透過 aci_gap_init() 建立 GAP/GATT 服務

    // 註冊事件回呼
    hci_register_evt_callback(ble_evt_callback);
}

// 發送 BLE 資料
void BLE_Send(uint8_t *data, uint16_t len)
{
    aci_gatt_notify(conn_handle, service_handle, char_handle, len, data);
}

我第一次看到這段時有個疑問:為什麼初始化要看起來這麼底層?後來才想通,因為那些動作其實是在「把另一顆核心叫醒,並且把協定棧載進去」。這也是雙核架構的代價 —— 乾淨,但要先付一筆開機的工。

七、常見問題與陷阱

BLE 的地雷有個特色:它幾乎不會給你明確的錯誤訊息,只會安靜地不動。原文列的六個坑,我整理成症狀對照表:

BLE 除錯排查表

我自己最常中的是第三個(忘記註冊 CCCD)跟第四個(TX Power 直接開到最大)。這兩個都不會報錯,只會讓你覺得「東西明明沒壞,怎麼就不對」—— 前者是收不到通知,後者是功耗莫名偏高,甚至有法規上的風險。至於配對與加密,我會留到產品化的階段處理,但那一步絕對不能省。

八、總結

BLE 之所以變成物聯網短距無線的主流,是因為它同時把三件事做好了:極低功耗、快速連線,以及很有彈性的 GATT 模型。

而 ESP32 的雙模藍牙支援,讓你可以先求有再求好:想快就用 Arduino 把廣播/掃描/GATT Server 跑一遍,要深度定製再往 IDF 走。從這篇的廣播與掃描範例,到 GATT Server/Client 的實作,其實手上的工具已經足夠做出一個完整的 BLE 產品了。

最後補一句我自己的心得:BLE 專案真正難的從來不是「連上」,而是連上之後那三件事 —— 功耗管理、安全加密、射頻共存。把這三個放在心上,就不會做到一半才發現要打掉重來。

標籤: ESP32 工業通訊
最後更新: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