BLE 이벤트 로깅을 FreeRTOS 큐로 비동기 처리하기
BLEIoTEmbeddedFreeRTOS
모터 상태 변경, 커맨드 수신, WiFi 연결/해제 같은 이벤트를 BLE notification으로 모바일 앱에 전달한다. 이벤트 발생 코드에서 BLE API를 직접 호출하면 BLE 스택의 내부 lock이나 버퍼 대기로 블로킹이 발생할 수 있다. FreeRTOS 큐로 발생과 전송을 분리했다.
메시지 포맷
14바이트 고정 길이 구조체를 사용한다.
#define BLE_LOG_PROTOCOL_VERSION 1
#define BLE_LOG_DATA_SIZE 8
typedef enum EventType {
EventTypeNone = 0,
EventTypeCommandReceived = 1,
EventTypeMotionStateChanged = 2,
EventTypeWifiConnectionStatusChanged = 3,
} EventType;
typedef struct {
uint32_t timestamp; // 4B - Unix timestamp
uint16_t protocol_version; // 2B
uint8_t event_type; // 1B
uint8_t data[BLE_LOG_DATA_SIZE]; // 8B (최대) - 이벤트별 데이터
} BleLogMessage; // 15바이트 (패딩 포함 시 달라질 수 있음)
이벤트별 data 필드 사용:
| 이벤트 | data[0] | data[1] | data[2] | data[3] |
|---|---|---|---|---|
| CommandReceived | sender | command | - | - |
| MotionStateChanged | state_from | state_to | position | error_code |
| WifiConnected | status(1) | rssi | channel | - |
| WifiDisconnected | status(2) | rssi | - | reason |
생산자 측
이벤트가 발생하면 메시지를 구성해 큐에 넣는다.
void SendMotionStateChangedLogMessage(uint8_t from, uint8_t to,
uint8_t position, uint8_t error) {
BleLogMessage message = {0};
message.timestamp = time(NULL);
message.protocol_version = BLE_LOG_PROTOCOL_VERSION;
message.event_type = EventTypeMotionStateChanged;
message.data[0] = from;
message.data[1] = to;
message.data[2] = position;
message.data[3] = error;
xQueueSend(ble_log_queue, &message, 0); // timeout = 0
}
xQueueSend의 세 번째 인자가 0이다. 큐가 꽉 차면 블로킹하지 않고 즉시 반환한다. 메시지는 드롭된다.
소비자 측
전용 태스크가 큐에서 메시지를 꺼내 BLE notification으로 전송한다.
static void BleLoggerTaskHandler(void* param) {
BleLogMessage message;
while (1) {
if (xQueueReceive(ble_log_queue, &message, portMAX_DELAY) == pdTRUE) {
uint16_t conn = conn_handle_getter();
if (conn != BLE_HS_CONN_HANDLE_NONE) {
struct os_mbuf* mbuf =
ble_hs_mbuf_from_flat(&message, sizeof(BleLogMessage));
if (mbuf)
ble_gatts_notify_custom(conn, log_characteristic_val_handle, mbuf);
}
}
vTaskDelay(pdMS_TO_TICKS(1));
}
}
portMAX_DELAY로 큐가 비어 있으면 무한 대기한다. 큐에 메시지가 들어올 때까지 CPU를 소모하지 않는다.
BLE 연결이 없으면(conn == NONE) 메시지를 버린다. 연결되지 않은 상태에서 쌓인 메시지를 나중에 전송하는 것은 의미가 없다.
큐 설정
void InitBleLogger(uint16_t (*param_conn_handle_getter)(void)) {
conn_handle_getter = param_conn_handle_getter;
ble_log_queue = xQueueCreate(10, sizeof(BleLogMessage)); // 10개 슬롯
xTaskCreate(BleLoggerTaskHandler, "BleLoggerTask", 4096, NULL, 5, NULL);
}
큐 깊이 10개, 각 슬롯 14바이트. 총 약 140바이트 메모리 사용.
장단점
장점:
- 이벤트 발생 코드가 BLE 스택에 의존하지 않는다. 모터 제어 태스크에서
Send...()호출은 큐 삽입만 하므로 최대 수 마이크로초 - BLE 연결이 없어도 이벤트 발생 코드는 정상 동작한다
- 큐 깊이로 배압(backpressure) 제어. 10개 이상 쌓이면 자동 드롭
단점:
- 로그가 유실될 수 있다. 짧은 시간에 이벤트가 10개 이상 발생하면 일부가 드롭된다
- BLE notification 자체도 ACK가 없으므로 전송 실패 시 유실된다
- timestamp가 디바이스 로컬 시간이다. NTP 동기화 전에는 부정확하다
대안 검토
직접 호출 (큐 없이):
- 이벤트 발생 시 직접
ble_gatts_notify_custom()호출 - 장점: 구조가 단순하고 지연 없음
- 단점: BLE 스택 내부 mutex 때문에 호출자가 블로킹될 수 있다. 모터 제어처럼 실시간성이 중요한 태스크에서 호출하면 제어 주기를 놓칠 수 있다. 이 프로젝트에서 채택하지 않은 핵심 이유다
링 버퍼:
xQueueSend대신 링 버퍼에 쓰고, 소비자가 주기적으로 읽는 방식- 장점: 오래된 데이터를 자동 덮어쓰므로 최신 N개가 항상 유지된다
- 단점: FreeRTOS 큐가 제공하는 태스크 알림(큐에 데이터 도착 시 소비자 즉시 깨움)을 직접 구현해야 한다. 큐 깊이 10개로 충분했으므로 추가 구현의 필요성이 없었다
FreeRTOS Stream Buffer:
- 가변 길이 메시지에 적합한 FreeRTOS 기능
- 이 프로젝트에서는 메시지가 14바이트 고정이므로
xQueueSend가 더 직관적이다. Stream Buffer는 메시지 경계를 명시적으로 관리해야 하는 번거로움이 있다
로그 배칭:
- N개 메시지를 모아 한 번에 전송하면 BLE 호출 횟수를 줄일 수 있다
- MTU가 충분히 크면(예: 247바이트) 14바이트 메시지 17개를 한 번에 보낼 수 있다
- 이 프로젝트에서는 이벤트 빈도가 낮아(초당 1~2회 수준) 배칭의 이점이 크지 않았다