본문으로 건너뛰기
ESP32-C3 Mastering · 8/12

ESP32-C3 BLE 5.0 분석 — GAP·GATT·Coded PHY

· Hawk · 9분 읽기

#한 줄 요약

“BLE 5.0의 두 가지 무기는 2M PHYCoded PHY입니다. 전자는 처리량 2배, 후자는 거리 4배. C3는 둘 다 지원합니다.” GATT는 데이터 모델이고 GAP는 연결 절차입니다. 두 개를 섞으면 헷갈리고, 분리해서 보면 단순합니다.

ESP32-C3는 Bluetooth 5.0 LE만 지원합니다. 클래식 BR/EDR은 없습니다. 단일 라디오가 WiFi와 BLE를 시분할하므로, 둘을 동시에 켜면 각자의 throughput이 절반 이하로 떨어집니다.

이번 장에서는 BLE의 두 축인 GAPGATT를 정리하고, NimBLE 스택으로 Battery Service를 구현해 봅니다. BLE 5.0이 새로 들고 온 2M PHY·Coded PHY·Extended Advertising도 다룹니다. 마지막에 Pairing 모델NimBLE vs Bluedroid 선택을 정리합니다.

#GAP와 GATT — 두 축의 분리

BLE를 처음 만지면 GAP와 GATT가 섞여 보입니다. 분리하면 단순해집니다.

영역다루는 것비유
GAP (Generic Access Profile)어떻게 만나는가 — advertising, scanning, connection, pairing명함 교환과 통화 연결
GATT (Generic Attribute Profile)무엇을 주고받는가 — service, characteristic, descriptor통화 내용

advertise → scan → connect 까지는 GAP의 영역입니다. 일단 연결이 서면 그때부터 GATT로 데이터가 흐릅니다. 역할도 다릅니다.

GAP role설명
Central스캔하고 연결을 시작스마트폰
Peripheral광고하고 연결을 수락센서, 시계, ESP32-C3
Broadcaster광고만, 연결 없음iBeacon, Eddystone
Observer스캔만, 연결 없음비콘 수집기

ESP32-C3는 네 역할 모두 동시에 가능합니다(라디오 한 개를 시분할). 가장 흔한 패턴은 Peripheral입니다.

#BLE 5.0이 가져온 변화

BLE 4.2 → 5.0의 핵심 차이입니다.

기능BLE 4.2BLE 5.0C3 지원
1M PHYyesyesyes
2M PHY (2 Mbps)noyesyes
Coded PHY S=2 (500 kbps)noyesyes
Coded PHY S=8 (125 kbps)noyesyes
Legacy Advertising (31B)yesyesyes
Extended Advertising (255B)noyesyes
Periodic Advertisingnoyesyes
LE Audio (Auracast)no5.2 부터no (C3는 5.0)

Coded PHY가 이 시리즈에서 가장 흥미로운 항목입니다. FEC(Forward Error Correction)를 S=2 또는 S=8 배 적용해 링크 budget을 12 dB 늘립니다. 실제 옥외 환경에서 거리가 4배로 늘어납니다. 대신 데이터 비율은 1/2 또는 1/8입니다.

PHYData RateRange (옥외)용도
1M1 Mbps~30 m기본
2M2 Mbps~25 m고대역
Coded S=2500 kbps~60 m중간
Coded S=8125 kbps~120 mlong range
// NimBLE: 연결 후 PHY 변경
struct ble_gap_set_phy_args args = {
.tx_phys = BLE_GAP_LE_PHY_CODED_MASK,
.rx_phys = BLE_GAP_LE_PHY_CODED_MASK,
.phy_opts = BLE_HCI_LE_PHY_CODED_S8_PREF,
};
ble_gap_set_prefered_le_phy(conn_handle, args.tx_phys, args.rx_phys, args.phy_opts);

Central쪽도 Coded PHY를 지원해야 합니다. iPhone은 12 이후, Android는 8.0 이후 일부 칩에서 지원합니다. 지원하지 않는 폰이 많은 시장에는 사용 못 합니다.

#NimBLE vs Bluedroid

ESP-IDF는 두 BLE 스택을 제공합니다.

항목NimBLEBluedroid
출신Apache MynewtAndroid AOSP
RAM 사용~25 KB~70 KB
Flash 사용~150 KB~430 KB
클래식 BR/EDR미지원지원 (C3는 어차피 없음)
API 스타일콜백 위주, 깔끔이벤트+상태머신, 복잡
문서ESP-IDF + MynewtEspressif 자체 wrap
권장C3·S3·H2 (BLE-only)원본 ESP32 (BT/BLE dual)

ESP32-C3에서는 NimBLE이 기본 권장입니다. RAM 풋프린트 차이가 50 KB에 가까운데, C3는 SRAM이 400 KB뿐이라 체감 차이가 큽니다. 새 프로젝트라면 NimBLE을 고르는 것이 거의 항상 옳습니다.

#Advertising — 자기를 알리기

Peripheral은 advertising으로 자기 존재를 알립니다. 주기와 형식이 핵심입니다.

// NimBLE advertising 시작
static int gap_event_handler(struct ble_gap_event *event, void *arg)
{
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
ESP_LOGI("ble", "connected handle=%d", event->connect.conn_handle);
}
break;
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI("ble", "disconnected reason=%d", event->disconnect.reason);
ble_advertise_start();
break;
case BLE_GAP_EVENT_SUBSCRIBE:
ESP_LOGI("ble", "notify enabled=%d", event->subscribe.cur_notify);
break;
}
return 0;
}
void ble_advertise_start(void)
{
struct ble_hs_adv_fields fields = {0};
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
fields.tx_pwr_lvl_is_present = 1;
fields.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
fields.name = (uint8_t*)"ESP32-C3-Sensor";
fields.name_len = strlen("ESP32-C3-Sensor");
fields.name_is_complete = 1;
ble_gap_adv_set_fields(&fields);
struct ble_gap_adv_params adv_params = {
.conn_mode = BLE_GAP_CONN_MODE_UND,
.disc_mode = BLE_GAP_DISC_MODE_GEN,
.itvl_min = BLE_GAP_ADV_FAST_INTERVAL1_MIN, // 30 ms
.itvl_max = BLE_GAP_ADV_FAST_INTERVAL1_MAX, // 60 ms
};
ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER,
&adv_params, gap_event_handler, NULL);
}

광고 주기전류와 발견 시간의 trade-off입니다.

주기평균 전류발견 시간용도
30~60 ms~5 mA< 1 s빠른 페어링이 필요한 첫 부팅
100~200 ms~2 mA1~2 s일반 IoT
500 ms~1 s< 1 mA5~10 s절전 우선 비콘
2~10 s< 200 µA매우 느림장기 비콘 (배터리 1년+)

Legacy advertising은 31 byte까지만 실립니다. Extended Advertising255 byte까지 가능해, 디바이스 이름이 길거나 manufacturer data가 풍부하면 유용합니다. 단, Central이 Extended를 지원해야 받습니다.

#GATT 서비스 — 데이터 모델

GATT는 Service → Characteristic → Descriptor 3계층입니다.

Battery Service (0x180F)
├── Battery Level Characteristic (0x2A19)
│ ├── value (uint8, 0~100)
│ ├── property: READ | NOTIFY
│ └── descriptor: CCCD (0x2902) — notify 활성화 비트
└── (다른 characteristic은 옵션)

표준 service UUID는 16-bit 짧은 형식입니다(예: 0x180F). 자체 정의 service는 128-bit UUID가 필수입니다(12345678-1234-1234-1234-1234567890AB 같은 형식).

static uint8_t battery_level = 87;
static int battery_level_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
os_mbuf_append(ctxt->om, &battery_level, sizeof(battery_level));
return 0;
}
return BLE_ATT_ERR_UNLIKELY;
}
static const struct ble_gatt_svc_def gatt_svcs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = BLE_UUID16_DECLARE(0x180F), // Battery Service
.characteristics = (struct ble_gatt_chr_def[]) {
{
.uuid = BLE_UUID16_DECLARE(0x2A19),
.access_cb = battery_level_access,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY,
},
{ 0 } // terminator
},
},
{ 0 } // terminator
};
void gatt_init(void)
{
ble_svc_gap_init();
ble_svc_gatt_init();
ble_gatts_count_cfg(gatt_svcs);
ble_gatts_add_svcs(gatt_svcs);
}

NOTIFY연결된 클라이언트가 CCCD를 켰을 때만 발송됩니다. 끄면 무시됩니다.

// 1초마다 battery level notify
void battery_notify_task(void *param)
{
while (1) {
if (current_conn_handle != BLE_HS_CONN_HANDLE_NONE) {
struct os_mbuf *om = ble_hs_mbuf_from_flat(&battery_level, 1);
ble_gatts_notify_custom(current_conn_handle,
battery_level_attr_handle, om);
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}

#표준 service와 자체 service

자주 쓰는 표준 service UUID입니다.

ServiceUUID비고
Generic Access0x1800디바이스 이름, appearance
Generic Attribute0x1801서비스 변경 통지
Battery Service0x180F배터리 잔량
Device Information0x180A제조사, 모델, 펌웨어
Heart Rate0x180D심박수
Health Thermometer0x1809체온계
HID over GATT0x1812키보드·마우스·게임패드
Nordic UART (NUS)6E400001-…시리얼 over BLE (de facto)

표준을 그대로 따르면 스마트폰의 표준 앱이 바로 해석합니다. 자체 service는 전용 앱이 필요합니다.

#Pairing과 Bonding

연결 자체는 암호화 없이도 동작합니다. 보안이 필요하면 pairing을 합니다. Pairing 결과를 영구 저장하면 bonding입니다.

모드I/O 요구보안비고
Just Works없음MITM 취약가장 흔함
Passkey Entry디스플레이 또는 키패드MITM 방어6자리 숫자
Numeric Comparison양쪽 디스플레이MITM 방어LE Secure Connections
Out-of-Band (OOB)NFC 또는 카메라강력별도 채널로 키 교환

ESP32-C3는 모든 모드를 지원합니다. 펌웨어에서 I/O capability를 선언합니다.

ble_hs_cfg.sm_io_cap = BLE_SM_IO_CAP_NO_IO; // Just Works
// ble_hs_cfg.sm_io_cap = BLE_SM_IO_CAP_DISP_ONLY; // Passkey display
// ble_hs_cfg.sm_io_cap = BLE_SM_IO_CAP_KEYBOARD_ONLY;
ble_hs_cfg.sm_bonding = 1; // bond 저장
ble_hs_cfg.sm_mitm = 1; // MITM 방어 요구
ble_hs_cfg.sm_sc = 1; // LE Secure Connections (ECDH P-256)

Bond 정보는 NVS의 ble_hs_store 영역에 저장됩니다. 펌웨어 업데이트로 NVS partition table이 바뀌면 모두 날아갑니다. 펌웨어 OTA 절차에 bond 보존 케어가 필요합니다.

#WiFi와 BLE 동시 운영

C3는 라디오가 하나입니다. WiFi 패킷과 BLE 패킷이 시분할됩니다.

시간 ──────────────────────────────────────►
WiFi: ███ ██ ████ ██ ███
BLE: ██ ███ ██ ██ ██
양쪽 모두 약 50%씩 시간 점유
// menuconfig: Component config → Bluetooth → Bluetooth controller
// CONFIG_BT_CTRL_COEX_PHY_CODED_TX_RX_TIME_LIMIT (BLE Coded PHY 우선)
// Component config → Wi-Fi → CONFIG_ESP_COEX_SW_COEXIST_ENABLE

기본 설정으로도 동작은 합니다. 다만 Coded PHY S=8을 쓰는 BLE long-rangeWiFi 고대역을 같이 켜면 둘 다 처참하게 떨어집니다. 운영 단계에서는 주로 BLE 쓸 때 WiFi disconnect, WiFi 쓸 때 BLE advertise 멈춤 같은 명시적 시분할이 안전합니다.

#자주 하는 실수와 troubleshooting

증상원인해결
폰이 디바이스를 발견 못 함advertising 안 시작ble_gap_adv_start 호출 확인
연결 직후 끊김MTU 협상 실패 또는 power 부족MTU = 247, decoupling cap 확인
notify가 폰에 도착 안 함CCCD 안 켬클라이언트에서 notify 활성화
characteristic value 길이 초과MTU 23 (default)에서 20 byte 한계ble_att_set_preferred_mtu(247)
페어링 후 reconnect에 다시 페어링bond 저장 안 됨sm_bonding=1, NVS partition 확인
Coded PHY 안 잡힘Central이 미지원Central 측 chipset 확인
WiFi+BLE 동시에 짙은 끊김SW coexistence 미활성CONFIG_ESP_COEX_SW_COEXIST_ENABLE

가장 흔한 함정은 MTU입니다. BLE 기본 MTU는 23 byte이고, 헤더 빼면 20 byte의 payload만 됩니다. ble_att_set_preferred_mtu(247)로 키워야 224 byte의 payload가 한 packet에 실립니다. 클라이언트도 동의해야 협상이 성공합니다.

#정리

  • BLE는 *GAP(연결 절차)*과 *GATT(데이터 모델)*의 두 축으로 분리해서 봐야 명확합니다.
  • BLE 5.0의 무기는 *2M PHY(처리량 2배)*와 *Coded PHY(거리 4배)*입니다. C3는 둘 다 지원합니다.
  • C3에서는 NimBLE 스택이 사실상 표준입니다. RAM 25 KB, Flash 150 KB로 Bluedroid의 1/3 수준입니다.
  • Advertising 주기는 전류와 발견 속도의 trade-off입니다. 30 ms면 빠르지만 5 mA, 1 s면 느려도 1 mA 이하입니다.
  • 표준 service UUID는 16-bit, 자체 service는 128-bit입니다. 표준을 쓰면 표준 앱이 바로 해석합니다.
  • Pairing 모드는 Just Works·Passkey·Numeric Comparison·OOB입니다. 양산 IoT는 Numeric Comparison + bonding이 안전합니다.
  • WiFi와 BLE는 라디오 하나를 시분할합니다. 둘 다 고대역을 동시에 쓰면 양쪽 처리량이 처참합니다.
  • MTU는 기본 23 byte입니다. 247로 올려야 한 packet에 224 byte payload가 들어갑니다.

#다음 편

Ch 9: ESP-IDF — 빌드 시스템과 컴포넌트 구조에서는 무선 코드를 어떻게 빌드해서 칩에 올리는지를 다룹니다. idf.py CLI, CMake 컴포넌트, Kconfig, Component Manager까지 한 번에 풉니다.

#관련 항목