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]
CommandReceivedsendercommand--
MotionStateChangedstate_fromstate_topositionerror_code
WifiConnectedstatus(1)rssichannel-
WifiDisconnectedstatus(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회 수준) 배칭의 이점이 크지 않았다