BLE GAP/GATT 구조

IoTBLEEmbedded

GAP은 BLE 디바이스가 서로를 발견하고 연결하는 절차를, GATT는 연결 후 데이터를 주고받는 구조를 정의한다. BLE 하드웨어 계층(PHY, Link Layer)은 별도 문서에서 다룬다.


GAP (Generic Access Profile)

디바이스의 역할, 발견, 연결 절차를 정의한다.

역할

역할설명연결전형적 디바이스
PeripheralAdvertising을 보내고 연결을 수락O센서, 액추에이터
CentralAdvertising을 수신하고 연결을 시작O스마트폰, 게이트웨이
BroadcasterAdvertising만 보냄X비콘
ObserverAdvertising만 수신X비콘 수신기

BLE 5.0부터 하나의 디바이스가 동시에 여러 역할을 수행할 수 있다. 예: Peripheral로 스마트폰과 연결하면서 Observer로 주변 비콘을 스캔.

Advertising

Peripheral이 자신의 존재를 알리는 방법이다.

Advertising 패킷에 실을 수 있는 데이터:

  • 디바이스 이름 (전체 또는 축약)
  • 서비스 UUID 목록
  • TX Power Level
  • Manufacturer Specific Data (자유 형식)
  • 총 31바이트까지 (BLE 4.x). Scan Response로 추가 31바이트 가능

Advertising 타입과 용도:

타입연결스캔 응답언제 쓰는가
ADV_INDOO가장 일반적. 스마트폰 앱에서 연결할 디바이스
ADV_DIRECT_INDO (특정 대상)X이미 페어링된 디바이스에 빠르게 재연결
ADV_NONCONN_INDXX비콘. 연결 필요 없는 브로드캐스트
ADV_SCAN_INDXO비콘이지만 추가 정보 요청 가능

Advertising Interval: 20ms ~ 10.24s. 짧으면 발견이 빠르지만 전력 소모 증가. 일반적으로 100ms~1s 사이를 쓴다.

연결 과정

Peripheral                Central
    │                        │
    │── ADV_IND ───────────▶│ 디바이스 발견
    │◀── CONNECT_REQ ───────│ (연결 파라미터 포함)
    │                        │
    │   Connection Established (Data 채널로 전환)
    │                        │
    │  ┌ Connection Interval마다 ┐
    │◀─│─ Data / Empty PDU ─────│
    │──│─ Data / Empty PDU ────▶│
    │  └────────────────────────┘
  1. Peripheral이 advertising 채널(37, 38, 39)에서 ADV_IND를 브로드캐스트
  2. Central이 수신하고 CONNECT_REQ를 보냄 (CI, latency, timeout 포함)
  3. 연결 성립. advertising 중단, data 채널에서 양방향 통신 시작
  4. 매 connection interval마다 Master(Central)가 먼저 패킷을 보내고, Slave(Peripheral)가 응답

Central이 CONNECT_REQ를 보내는 순간 연결이 성립된다. Peripheral의 승인이 필요 없다. Peripheral은 다음 advertising event에서 CONNECT_REQ를 수신하면 바로 connection 상태로 전환한다.

STM32WB — GAP 초기화 및 Advertising

/* app_ble.c */

static void Ble_Hci_Gap_Gatt_Init(void) {
    uint16_t gap_service_handle, gap_dev_name_char_handle, gap_appearance_char_handle;

    aci_gatt_init();

    /* Peripheral 역할로 GAP 초기화 */
    aci_gap_init(GAP_PERIPHERAL_ROLE, 0,
                 strlen(CFG_GAP_DEVICE_NAME),
                 &gap_service_handle,
                 &gap_dev_name_char_handle,
                 &gap_appearance_char_handle);

    /* 디바이스 이름 설정 — Scan 시 이 이름이 보인다 */
    aci_gatt_update_char_value(gap_service_handle,
                               gap_dev_name_char_handle,
                               0,
                               strlen(CFG_GAP_DEVICE_NAME),
                               (uint8_t *)CFG_GAP_DEVICE_NAME);
}

static void Adv_Request(void) {
    const char local_name[] = { AD_TYPE_COMPLETE_LOCAL_NAME, 'M', 'y', 'B', 'L', 'E' };

    aci_gap_set_discoverable(
        ADV_IND,                         /* 연결 가능 advertising */
        CFG_FAST_CONN_ADV_INTERVAL_MIN,  /* min interval (× 0.625ms) */
        CFG_FAST_CONN_ADV_INTERVAL_MAX,  /* max interval */
        CFG_BLE_ADDRESS_TYPE,
        NO_WHITE_LIST_USE,
        sizeof(local_name), (uint8_t *)local_name,
        0, NULL,                         /* service UUID list */
        0, 0);                           /* conn interval hint */
}

local_name의 첫 바이트가 AD_TYPE_COMPLETE_LOCAL_NAME인 것에 주의. BLE advertising 데이터는 [Length][Type][Value] 형식이지만, STM32_WPAN API가 length를 자동으로 붙여준다.


GATT (Generic Attribute Profile)

연결된 두 디바이스 간 데이터 교환 구조를 정의한다. BLE 데이터 통신의 핵심이다.

역할

  • Server: 데이터를 가지고 있는 쪽. 주로 Peripheral (센서, 액추에이터)
  • Client: 데이터를 읽고/쓰는 쪽. 주로 Central (스마트폰, 게이트웨이)

GAP 역할과 독립적이다. Peripheral이 GATT Client가 될 수도 있다 (예: 시간 동기화를 위해 Central에서 현재 시간을 읽는 경우).

데이터 계층

Profile
└── Service (UUID로 구분)
    └── Characteristic
        ├── Value (실제 데이터)
        ├── Properties (read/write/notify/indicate)
        └── Descriptor (메타데이터)
            └── CCCD (Client Characteristic Configuration Descriptor)

Service — 관련 기능을 묶는 컨테이너. UUID로 식별한다.

  • 표준 서비스: 16-bit UUID. 0x180F = Battery Service, 0x180A = Device Information
  • 커스텀 서비스: 128-bit UUID. 자체 프로토콜을 만들 때 사용

Characteristic — 하나의 데이터 포인트. 센서 값 하나, 설정 하나가 각각 characteristic이다.

Descriptor — Characteristic에 대한 부가 정보.

  • CCCD (Client Characteristic Configuration Descriptor): Notification/Indication을 켜고 끄는 스위치. Client가 써야 Server가 push할 수 있다

실무에서의 서비스 설계

하나의 IoT 디바이스에 보통 2~4개의 커스텀 서비스를 만든다.

예: 스마트 환풍기
├── Device Information Service (0x180A) — 표준
│   ├── Manufacturer Name
│   ├── Model Number
│   └── Firmware Revision
├── Fan Control Service (커스텀 UUID)
│   ├── Fan Speed (R/W) — 0~100%
│   ├── Mode (R/W) — auto/manual/sleep
│   └── Status (R/Notify) — 현재 RPM, 온도
└── OTA Service (커스텀 UUID)
    ├── OTA Control Point (W) — 시작/중단/확인
    └── OTA Data (W without response) — 펌웨어 데이터

서비스별 UUID를 base UUID + offset으로 설계하면 관리가 편하다:

Base:    xxxxxxxx-0000-1000-8000-00805f9bAAAA
Service: xxxxxxxx-0001-...
Char 1:  xxxxxxxx-0002-...
Char 2:  xxxxxxxx-0003-...

데이터 교환 방식

방식방향시작 주체ACK언제 쓰는가
ReadServer → ClientClient-설정값 조회, 디바이스 정보
WriteClient → ServerClientO설정 변경, 명령 전달
Write Without ResponseClient → ServerClientXOTA 데이터 전송 (빠름)
NotifyServer → ClientServerX센서 데이터 실시간 push
IndicateServer → ClientServerO중요한 상태 변경 (에러 등)

Notify vs Indicate 선택 기준:

  • 센서 데이터처럼 주기적으로 보내는 것 → Notify. ACK 없어서 빠르고 throughput이 높다
  • 에러 발생, 설정 완료 확인 등 유실되면 안 되는 것 → Indicate. 하지만 ACK 대기로 느리다
  • 실무에서는 95% Notify를 쓴다. BLE 자체가 CRC + retransmission이 있어서 패킷 유실이 극히 드물다

Notify/Indicate를 쓰려면 Client가 CCCD에 값을 써야 한다:

  • 0x0001 → Notification 활성화
  • 0x0002 → Indication 활성화
  • 0x0000 → 비활성화

STM32WB — 커스텀 GATT Service

/* custom_stm.c */

static const uint8_t CUSTOM_SVC_UUID[16] = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01
};
static const uint8_t CUSTOM_CHAR_UUID[16] = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02
};

static uint16_t svc_handle;
static uint16_t char_handle;

void Custom_STM_Init(void) {
    /* 서비스 등록 */
    Char_UUID_t svc_uuid = { .Char_UUID_128 = CUSTOM_SVC_UUID };
    aci_gatt_add_service(UUID_TYPE_128,
                         (Service_UUID_t *)&svc_uuid,
                         PRIMARY_SERVICE,
                         4,          /* max attribute records */
                         &svc_handle);

    /* Characteristic 추가 — Read + Notify */
    Char_UUID_t char_uuid = { .Char_UUID_128 = CUSTOM_CHAR_UUID };
    aci_gatt_add_char(svc_handle,
                      UUID_TYPE_128,
                      (Char_UUID_t *)&char_uuid,
                      20,                               /* max value length */
                      CHAR_PROP_READ | CHAR_PROP_NOTIFY, /* properties */
                      ATTR_PERMISSION_NONE,
                      GATT_NOTIFY_ATTRIBUTE_WRITE,       /* CCCD 변경 이벤트 수신 */
                      10,                                /* encryption key size */
                      1,                                 /* variable length */
                      &char_handle);
}

max attribute records를 4로 잡은 이유: Service 선언(1) + Characteristic 선언(1) + Value(1) + CCCD(1) = 4. Characteristic을 추가할 때마다 2~3씩 늘려야 한다. 이 값이 부족하면 aci_gatt_add_char가 에러 없이 실패한다.

STM32WB — Notification 전송

/* 센서 데이터가 바뀔 때 호출 */
void Custom_STM_Send_Notification(uint8_t *data, uint8_t len) {
    aci_gatt_update_char_value(svc_handle,
                               char_handle,
                               0,       /* offset */
                               len,
                               data);
}

aci_gatt_update_char_value는 CCCD가 활성화되어 있으면 자동으로 notification을 보내고, 아니면 값만 업데이트한다. 별도로 notify/indicate를 구분하는 API가 아니다.

STM32WB — GATT 이벤트 처리

SVCCTL_EvtAckStatus_t Custom_STM_Event_Handler(void *event) {
    aci_gatt_attribute_modified_event_rp0 *attr_modified;
    hci_event_pckt *event_pckt = (hci_event_pckt *)((hci_uart_pckt *)event)->data;

    if (event_pckt->evt == HCI_VENDOR_SPECIFIC_DEBUG_EVT_CODE) {
        evt_blecore_aci *blecore = (evt_blecore_aci *)event_pckt->data;

        switch (blecore->ecode) {
        case ACI_GATT_ATTRIBUTE_MODIFIED_VSEVT_CODE:
            attr_modified = (aci_gatt_attribute_modified_event_rp0 *)blecore->data;

            /* CCCD 변경 감지 — handle + 2가 CCCD */
            if (attr_modified->Attr_Handle == char_handle + 2) {
                uint16_t cccd_val = *(uint16_t *)attr_modified->Attr_Data;
                if (cccd_val == 0x0001) {
                    /* Client가 Notification을 켰다 → 데이터 전송 시작 */
                } else {
                    /* Notification 꺼짐 → 전송 중단 */
                }
            }

            /* Characteristic Value에 Write 감지 */
            if (attr_modified->Attr_Handle == char_handle + 1) {
                uint8_t *value = attr_modified->Attr_Data;
                uint16_t length = attr_modified->Attr_Data_Length;
                /* Client가 보낸 데이터 처리 */
            }
            break;
        }
    }
    return SVCCTL_EvtNotAck;
}

Handle 규칙:

  • char_handle → Characteristic Declaration
  • char_handle + 1 → Characteristic Value (실제 데이터)
  • char_handle + 2 → CCCD (Notify/Indicate 속성이 있을 때만 자동 생성)

메모

  • GAP과 GATT의 관계
    • GAP은 “어떻게 만나고 연결하는가”, GATT는 “연결 후 데이터를 어떻게 주고받는가”
    • GAP이 연결을 만들어야 GATT가 동작한다. 예외: GATT over advertising (BLE 5.0 periodic advertising)
  • ATT MTU
    • 기본 ATT MTU는 23바이트 (실제 페이로드 20바이트)
    • BLE 4.2+에서 MTU negotiation으로 최대 512바이트까지 가능
    • 연결 직후 aci_gatt_exchange_config(conn_handle)를 호출해서 MTU를 협상한다
    • MTU를 키우면 한 번에 보내는 데이터가 커져 throughput이 크게 개선된다
  • CCCD는 Client가 써야 한다
    • Server가 아무리 aci_gatt_update_char_value를 호출해도, Client가 CCCD에 0x0001을 쓰지 않으면 notification이 나가지 않는다
    • 모바일 앱에서 characteristic을 subscribe하면 내부적으로 CCCD write가 발생한다
    • CCCD 값은 bonded 디바이스에서 persist된다. 재연결 시 이전 CCCD 값이 복원되므로, Server는 이를 처리해야 한다
  • UUID 충돌 방지
    • 커스텀 서비스에 16-bit UUID를 쓰면 Bluetooth SIG 표준 서비스와 충돌할 수 있다
    • 커스텀 서비스는 반드시 128-bit UUID를 사용한다
    • UUID 생성기를 써서 고유한 base UUID를 만들고, 서비스/characteristic은 오프셋으로 구분
  • Write vs Write Without Response
    • Write: Server가 ACK를 보낸다. 확실하지만 느리다 (1 CI당 1회)
    • Write Without Response: ACK 없이 연속 전송. OTA 데이터 전송에 필수
    • Write Without Response는 flow control이 없으므로, 전송 속도를 앱에서 조절해야 한다
  • STM32WB max_attr_records 계산
    • Service 선언: 1
    • Characteristic당: 2 (선언 + 값) + Notify/Indicate면 1 (CCCD) = 2~3
    • 예: Characteristic 3개(모두 Notify) = 1 + 3×3 = 10
    • 넉넉하게 잡는 것이 안전하다. 메모리는 SRAM2에서 소비된다
  • Characteristic 수의 제약
    • BLE 스펙상 ATT handle이 16-bit이므로 65,535개 attribute까지 가능. 스펙 자체는 제약이 아니다
    • 실제 병목은 MCU 메모리다. Notify characteristic 1개당 약 68B (선언 28B + value 20B + CCCD 20B)
    • STM32WB55(64KB SRAM2)는 현실적으로 3050개, nRF52840(256KB RAM)은 50100개
    • SHCI_C2_BLE_InitAttrValueArrSize가 모든 characteristic value의 max_length 합계를 커버해야 한다. 부족하면 silent fail
    • 대부분의 IoT 디바이스는 10~20개면 충분하다
  • Characteristic을 늘릴 것인가, 바이너리 프로토콜로 묶을 것인가
    • 늘리면: 자기 서술적, 개별 Read/Write/Notify 가능, nRF Connect 같은 도구로 바로 디버깅 가능
    • 묶으면: 메모리 절약, Service Discovery가 빠름, 대신 양쪽에 파서가 필요
    • 10~20개 이내라면 나누는 것이 거의 항상 낫다. 메모리 비용이 미미하고 파서를 안 만들어도 된다
    • 그 이상이 필요하면 관련 데이터를 하나의 Notify characteristic에 구조체로 묶는 하이브리드 방식을 쓴다 (예: 센서 10개 값을 하나에 묶어서 push)
    • Characteristic이 50개를 넘으면 연결 직후 Service Discovery에 수 초 걸릴 수 있다