
前言
先講結論:如果你在做物聯網,遲早會撞上 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 的棧分成 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 管,順序大概是這樣:

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

我會想特別畫這張圖,是因為當初一直把「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 當檔案的屬性設定。而 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 是很常見的寫法,但它們的權限其實不太一樣:

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 協商常被列為 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 的地雷有個特色:它幾乎不會給你明確的錯誤訊息,只會安靜地不動。原文列的六個坑,我整理成症狀對照表:

我自己最常中的是第三個(忘記註冊 CCCD)跟第四個(TX Power 直接開到最大)。這兩個都不會報錯,只會讓你覺得「東西明明沒壞,怎麼就不對」—— 前者是收不到通知,後者是功耗莫名偏高,甚至有法規上的風險。至於配對與加密,我會留到產品化的階段處理,但那一步絕對不能省。
八、總結
BLE 之所以變成物聯網短距無線的主流,是因為它同時把三件事做好了:極低功耗、快速連線,以及很有彈性的 GATT 模型。
而 ESP32 的雙模藍牙支援,讓你可以先求有再求好:想快就用 Arduino 把廣播/掃描/GATT Server 跑一遍,要深度定製再往 IDF 走。從這篇的廣播與掃描範例,到 GATT Server/Client 的實作,其實手上的工具已經足夠做出一個完整的 BLE 產品了。
最後補一句我自己的心得:BLE 專案真正難的從來不是「連上」,而是連上之後那三件事 —— 功耗管理、安全加密、射頻共存。把這三個放在心上,就不會做到一半才發現要打掉重來。
文章評論