본문 바로가기

자동차 속도 기반 IoT 장식 만들기 07 - ESP32-S3 FreeRTOS로 차량용 IoT 펌웨어 재설계하기

@밀양박씨!2026. 5. 22. 13:45

개요

Carpybara는 차량에 부착하는 ESP32 기반 IoT 디스플레이 장치입니다. GPS와 IMU 센서로 실시간 속도, 급제동, 과속방지턱 충격을 감지하고, 2.8인치 IPS TFT 화면에 캐릭터 애니메이션으로 시각화합니다. 폰과는 Wi-Fi AP + 캡티브 포털로 연결해 Companion PWA를 제공합니다.

5월 12일, 기존 Arduino loop() 단일 루프 구조를 FreeRTOS 멀티태스크 아키텍처로 전면 재설계했습니다. 이 글은 재설계의 배경, 구조 결정, 구현 세부 사항을 기록합니다.

 

 

재설계 배경

기존 구조는 loop() 안에서 GPS 파싱, IMU 읽기, HTTP 요청 처리, 화면 렌더링을 순차적으로 실행했습니다. GPS NMEA 문자열 파싱이 길어지면 화면이 끊기고, HTTP 요청이 들어오는 순간 애니메이션이 정지하는 문제가 있었습니다. ESP32-S3는 Xtensa LX7 듀얼 코어 구조임에도 코어 하나만 활용하고 있었고, 세 가지 도메인(센서, 네트워크, 디스플레이)이 하나의 실행 흐름 안에서 서로 지연을 유발했습니다.

 

기술 스택

  • MCU: Waveshare ESP32-S3-Zero (Xtensa LX7 듀얼 코어, 240 MHz, 4 MB Flash + 2 MB PSRAM)
  • Framework: Arduino on ESP-IDF (FreeRTOS 내장)
  • Build System: PlatformIO
  • Display: Hosyond 2.8" IPS ILI9341 320×240, SPI 20 MHz, 14P FPC
  • GPS: HGLRC Mini M100 (UART2, 115200 baud, TinyGPSPlus 라이브러리)
  • IMU: HiLetgo MPU-6050 (I2C 0x68, Adafruit MPU6050 라이브러리)
  • GIF 렌더링: bitbank2/AnimatedGIF

 

 

태스크 아키텍처

세 개의 FreeRTOS 태스크로 책임을 분리하고, 코어 단위로 배치했습니다.


  Core 0  |  SensorTask   (priority 2, 20 Hz)   — GPS NMEA 파싱 + IMU 가속도 읽기
          |  NetworkTask  (priority 1, 100 Hz)  — DNS 캡티브 포털 + HTTP WebServer
  ------------------------------------------------------------------------
  Core 1  |  DisplayTask  (priority 2, ~60 Hz)  — 애니메이션 프레임 결정 + TFT 렌더링
  

DisplayTask를 Core 1에 단독 배치한 이유는 SPI 버스 경쟁을 제거하기 위해서입니다. ILI9341 렌더링은 SPI를 점유하는 블로킹 연산이므로, Core 0의 네트워크 인터럽트와 같은 코어에서 실행되면 응답 지연이 발생합니다. 코어를 분리하면 두 도메인이 물리적으로 격리되어 상호 간섭이 없어집니다.


  // setup() 마지막에서 태스크 생성 후 loopTask 삭제
  xTaskCreatePinnedToCore(petSensorTaskFn, "sensor", 4096, nullptr, 2, nullptr, 0);
  xTaskCreatePinnedToCore(networkTask,     "net",    8192, nullptr, 1, nullptr, 0);
  xTaskCreatePinnedToCore(displayTask,     "disp",   6144, nullptr, 2, nullptr, 1);

  vTaskDelete(nullptr);  // Arduino loopTask 삭제 — loop()는 호출되지 않음

  void loop() {}  // 의도적으로 비워 둠
  

 

구현 세부: Mutex 보호 공유 상태

SensorTask가 쓰고 DisplayTask와 NetworkTask가 읽는 구조체가 필요합니다. 두 코어가 동시에 접근하면 race condition이 발생하므로 FreeRTOS 뮤텍스로 보호했습니다.


  // pet_gps_imu.h — 공유 상태 정의
  struct SensorState {
    float    speed_kmh;
    double   lat, lon;
    uint8_t  satellites;
    bool     gps_valid;
    float    accel_x;       // 전후 방향 (g) — 음수 = 감속
    float    accel_y;       // 좌우 방향 (g)
    float    accel_z;       // 수직 방향 (g)
    bool     brake_active;
    bool     bump_active;
    uint32_t last_brake_ms;
    bool     imu_ready;
  };

  static SemaphoreHandle_t mutex = nullptr;
  static SensorState       state = {};
  

읽기 시에는 구조체 전체를 로컬 변수로 복사한 뒤 뮤텍스를 즉시 해제합니다. 뮤텍스를 잡은 채로 렌더링 로직을 실행하면 SensorTask가 블로킹되기 때문에, critical section은 메모리 복사 하나로 최소화합니다.


  // DisplayTask에서 센서 값 읽기
  void petSensorRead(SensorState* out) {
    xSemaphoreTake(mutex, portMAX_DELAY);
    *out = state;           // 구조체 복사 (critical section 최소화)
    xSemaphoreGive(mutex);
  }

  // SensorTask에서 브레이크 이벤트 쓰기
  xSemaphoreTake(mutex, portMAX_DELAY);
  state.brake_active  = true;
  state.last_brake_ms = millis();
  xSemaphoreGive(mutex);
  

 

 

구현 세부: IMU 급제동 감지 — Debounce

MPU-6050의 accel_x(차량 전후 방향)는 도로 진동으로 인해 순간적인 노이즈가 많습니다. 임계값 초과 시 즉시 급제동으로 판정하면 오탐이 빈번하게 발생합니다. 이를 해결하기 위해 80 ms 이상 지속될 때만 브레이크 이벤트로 확정하는 debounce 로직을 구현했습니다.


  // pet_config.h — 조정 가능한 임계값
  constexpr float    IMU_BRAKE_G  = 0.35f;  // 감속 감지 기준 (g)
  constexpr uint32_t IMU_BRAKE_MS = 80;     // 지속 시간 기준 (ms)

  // pet_gps_imu.h — debounce 구현
  static void checkBrake(float ax) {
    uint32_t now = millis();
    if (ax < -IMU_BRAKE_G) {
      if (!brakeCandidate) {
        brakeCandidate = true;
        brakeStartMs   = now;
      } else if (now - brakeStartMs >= IMU_BRAKE_MS) {
        xSemaphoreTake(mutex, portMAX_DELAY);
        state.brake_active  = true;
        state.last_brake_ms = now;
        xSemaphoreGive(mutex);
      }
    } else {
      brakeCandidate = false;  // 임계값 이하로 돌아오면 후보 초기화
    }
  }
  

 

 

트러블슈팅

1. ESP32-S3 업로드 절차

원인: Waveshare ESP32-S3-Zero에는 CP2102 등의 USB-UART 브리지 칩이 없어 PlatformIO의 자동 DTR 리셋 업로드가 동작하지 않습니다.

해결: BOOT 버튼을 누른 채 USB-C를 연결해 다운로드 모드로 진입 후 PlatformIO Upload를 실행합니다. 완료 후 RESET 버튼으로 재부팅합니다.

 

 

2. NetworkTask 스택 오버플로우

원인: 초기에 NetworkTask 스택을 4096 bytes로 설정했는데, LittleFS 파일 서빙 + WebServer + String 객체 누적으로 스택이 소진되어 패닉 재부팅이 발생했습니다.

해결: uxTaskGetStackHighWaterMark()로 각 태스크의 스택 여유를 측정한 후 NetworkTask를 8192 bytes로 증설했습니다. 최종 스택 배분은 아래와 같습니다.


  // SensorTask  : 4096 bytes
  // NetworkTask : 8192 bytes
  // DisplayTask : 6144 bytes
  

 

 

3. 실내 GPS Fix 불가 — 디버그 속도 주입

원인: GNSS 콜드 스타트는 실외 개방 환경에서 수 분이 소요되어 실내 개발 환경에서는 GPS 데이터를 얻을 수 없습니다.

해결: Serial Monitor에서 숫자를 입력하면 디버그 속도를 소프트웨어적으로 주입하는 기능을 추가했습니다. GPS Fix 이후에는 실측 데이터로 자동 전환됩니다.


  // Serial 입력 예: "30\n" → 30 km/h 주입
  if (line[0] == '-' || isDigit(line[0])) {
    float v = line.toFloat();
    petSensorSetDebugSpeed(constrain(v, 0, SPEED_MAX));
  }
  

 

 

결과

  • 세 태스크가 독립적인 주기로 실행되어 센서 지연이 화면 끊김에 영향을 주지 않습니다.
  • DisplayTask의 Core 1 단독 배치로 SPI 렌더링과 네트워크 인터럽트 간 경쟁이 제거되었습니다.
  • IMU 급제동 감지 결과가 /api/state JSON을 통해 Companion PWA에 실시간으로 전달됩니다.
  • Rivian 실차 테스트에서 GPS 속도 데이터의 정확도를 검증했습니다.

 

마무리

Arduino loop()의 단순함에서 벗어나 FreeRTOS를 도입하면서, 스택 크기 계산, critical section 최소화, 태스크 간 데이터 공유 설계 등 RTOS 특유의 고민을 경험했습니다. 뮤텍스 critical section을 구조체 복사 하나로 한정하는 패턴이 단순하면서도 교착 없이 안전하게 동작했습니다.

다음 단계는 실차 장착 후 IMU 축 방향을 실측해 checkBrake(ax)의 입력 축을 실제 차량 전후 방향으로 교정하는 것입니다.

 

 

 

밀양박씨!
@밀양박씨! :: 박씨의 개발블로그

lovebotw049 님의 블로그 입니다.

목차