BLE GAP/GATT 구조
GAP은 BLE 디바이스가 서로를 발견하고 연결하는 절차를, GATT는 연결 후 데이터를 주고받는 구조를 정의한다. BLE 하드웨어 계층(PHY, Link Layer)은 별도 문서에서 다룬다.
GAP (Generic Access Profile)
디바이스의 역할, 발견, 연결 절차를 정의한다.
역할
| 역할 | 설명 | 연결 | 전형적 디바이스 |
|---|---|---|---|
| Peripheral | Advertising을 보내고 연결을 수락 | O | 센서, 액추에이터 |
| Central | Advertising을 수신하고 연결을 시작 | O | 스마트폰, 게이트웨이 |
| Broadcaster | Advertising만 보냄 | X | 비콘 |
| Observer | Advertising만 수신 | 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_IND | O | O | 가장 일반적. 스마트폰 앱에서 연결할 디바이스 |
ADV_DIRECT_IND | O (특정 대상) | X | 이미 페어링된 디바이스에 빠르게 재연결 |
ADV_NONCONN_IND | X | X | 비콘. 연결 필요 없는 브로드캐스트 |
ADV_SCAN_IND | X | O | 비콘이지만 추가 정보 요청 가능 |
Advertising Interval: 20ms ~ 10.24s. 짧으면 발견이 빠르지만 전력 소모 증가. 일반적으로 100ms~1s 사이를 쓴다.
연결 과정
Peripheral Central
│ │
│── ADV_IND ───────────▶│ 디바이스 발견
│◀── CONNECT_REQ ───────│ (연결 파라미터 포함)
│ │
│ Connection Established (Data 채널로 전환)
│ │
│ ┌ Connection Interval마다 ┐
│◀─│─ Data / Empty PDU ─────│
│──│─ Data / Empty PDU ────▶│
│ └────────────────────────┘
- Peripheral이 advertising 채널(37, 38, 39)에서
ADV_IND를 브로드캐스트 - Central이 수신하고
CONNECT_REQ를 보냄 (CI, latency, timeout 포함) - 연결 성립. advertising 중단, data 채널에서 양방향 통신 시작
- 매 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 | 언제 쓰는가 |
|---|---|---|---|---|
| Read | Server → Client | Client | - | 설정값 조회, 디바이스 정보 |
| Write | Client → Server | Client | O | 설정 변경, 명령 전달 |
| Write Without Response | Client → Server | Client | X | OTA 데이터 전송 (빠름) |
| Notify | Server → Client | Server | X | 센서 데이터 실시간 push |
| Indicate | Server → Client | Server | O | 중요한 상태 변경 (에러 등) |
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 Declarationchar_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는 이를 처리해야 한다
- 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)는 현실적으로 30
50개, nRF52840(256KB RAM)은 50100개 SHCI_C2_BLE_Init의AttrValueArrSize가 모든 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에 수 초 걸릴 수 있다