자동차에 장착하는 카피바라 대시보드, Carpybara를 만들면서 가장 고민했던 부분 중 하나가 동시성 처리였습니다.
ESP32 하나로 이 네 가지를 동시에 해야 했습니다:
- GPS에서 실시간 속도 받기
- IMU로 브레이크 / 과속방지턱 감지
- 2.8인치 LCD에 카피바라 애니메이션 렌더링
- WiFi AP 열고 폰에 JSON API 응답하기
단순히 loop() 하나에 다 우겨넣으면 WiFi 응답 처리 중 화면이 멈추고, GPS 파싱 중 HTTP가 늦어집니다. 이걸 해결한 게 FreeRTOS입니다.
ESP32의 듀얼코어 구조
ESP32-S3는 Xtensa LX7 코어가 물리적으로 2개입니다.
| Core 0 (PRO_CPU) | Core 1 (APP_CPU) | |
|---|---|---|
| 기본 용도 | WiFi/BT 스택 | 사용자 앱 |
Arduino loop() 실행 위치 |
❌ | ✅ |
| WiFi 드라이버 인터럽트 | 여기서 처리 | 영향 없음 |
WiFi 스택이 Core 0을 간헐적으로 선점하기 때문에, 레이턴시에 민감한 렌더링 작업은 Core 1에 두는 게 핵심입니다.
3태스크 아키텍처
Carpybara 펌웨어는 FreeRTOS 태스크를 3개로 분리했습니다.
│ GPS NMEA 파싱 ILI9341 SPI 렌더링
│ IMU 가속도 읽기 카피바라 애니메이션
│ brake/bump 이벤트 감지 HUD 속도 표시
│
└── NetworkTask (Priority 1)
WiFi AP 유지
HTTP 클라이언트 처리
/api/state JSON 응답
DisplayTask를 Core 1에 단독 배치한 이유가 중요합니다. HTTP 요청 처리로 Core 0이 잠깐 블로킹되어도 화면 렌더링은 독립적으로 계속 돌아가야 하기 때문입니다.
// setup() 안에서 태스크 3개 생성
xTaskCreatePinnedToCore(
petSensorTaskFn, "Sensor",
4096, // 스택 4KB
nullptr, 2, nullptr,
0 // Core 0
);
xTaskCreatePinnedToCore(
petNetworkTaskFn, "Network",
8192, // HTTP 버퍼 필요 → 스택 8KB
nullptr, 1, nullptr,
0 // Core 0 (WiFi 친화)
);
xTaskCreatePinnedToCore(
petDisplayTaskFn, "Display",
4096,
nullptr, 2, nullptr,
1 // Core 1 (렌더링 전용)
);
// Arduino loop()는 완전히 비움
vTaskDelete(nullptr);
setup() 끝에서 vTaskDelete(nullptr)로 Arduino의 loopTask를 삭제합니다. 이후 loop() 함수는 빈 상태로 두고, 모든 실행 흐름은 FreeRTOS 스케줄러가 전담합니다.
공유 데이터와 Mutex
세 태스크가 공통으로 읽고 쓰는 데이터가 있습니다.
struct SensorState {
float speed_mph;
double lat, lon;
uint8_t satellites;
bool gps_valid;
float accel_x, accel_y, accel_z;
bool brake_active;
bool bump_active;
uint32_t last_brake_ms;
uint32_t last_bump_ms;
};
SensorTask는 쓰고, NetworkTask와 DisplayTask는 읽습니다. 멀티코어에서 아무 보호 없이 이러면 어떤 일이 생길까요?
Mutex 없는 경우의 문제
SensorTask: speed_mph = 42.7f ← float 4바이트 쓰기 시작
[1바이트 씀]
NetworkTask: speed_mph 읽기 ← 절반만 쓰인 쓰레기값!
→ JSON에 0.00 또는 이상한 값이 나감
SensorTask: [나머지 3바이트 씀]
Mutex로 해결
// 초기화
static SemaphoreHandle_t mutex = xSemaphoreCreateMutex();
// SensorTask — 쓰기 전 잠금
xSemaphoreTake(mutex, portMAX_DELAY);
state.speed_mph = (float)gps.speed.mph();
state.gps_valid = gps.location.isValid();
xSemaphoreGive(mutex);
// NetworkTask — 읽기 전 잠금
inline void petSensorRead(SensorState* out) {
xSemaphoreTake(mutex, portMAX_DELAY);
*out = state; // 전체 구조체 스냅샷 복사
xSemaphoreGive(mutex);
}
Take로 잠그면 다른 태스크는 Give가 불릴 때까지 대기합니다. 임계 구역이 짧아서 체감 지연은 없습니다.
GPS + IMU 센서 태스크 (20 Hz)
static void task(void*) {
for (;;) {
// 1) GPS NMEA 문장 파싱
while (Serial2.available())
gps.encode(Serial2.read());
if (gps.speed.isUpdated()) {
xSemaphoreTake(mutex, portMAX_DELAY);
state.speed_mph = (float)gps.speed.mph();
state.satellites = (uint8_t)gps.satellites.value();
xSemaphoreGive(mutex);
}
// 2) IMU 가속도 읽기 + 이벤트 감지
if (mpuOk) {
sensors_event_t a, g_ev, temp;
mpu.getEvent(&a, &g_ev, &temp);
float ax = a.acceleration.x / 9.81f;
float az = a.acceleration.z / 9.81f;
checkBrake(ax); // 전후 가속도로 브레이크 판정
checkBump(az); // 수직 가속도로 과속방지턱 판정
}
vTaskDelay(pdMS_TO_TICKS(50)); // 50ms = 20 Hz
}
}
vTaskDelay가 중요합니다. delay(50)은 CPU를 점유하며 기다리지만, vTaskDelay는 해당 태스크를 블로킹 상태로 전환해 스케줄러가 다른 태스크에 CPU를 줍니다.
브레이크 감지 로직
단순히 "가속도가 임계값을 넘었다"로 판정하면 노이즈에 민감합니다. 80ms 지속 확인 후 래치 방식을 씁니다.
static void checkBrake(float ax_g) {
uint32_t now = millis();
if (ax_g < -IMU_BRAKE_G) { // -0.35g 미만 (전방 감속)
if (!brakeCandidate) {
brakeCandidate = true;
brakeStartMs = now;
} else if (now - brakeStartMs >= IMU_BRAKE_MS) { // 80ms 지속
xSemaphoreTake(mutex, portMAX_DELAY);
state.brake_active = true;
state.last_brake_ms = now;
xSemaphoreGive(mutex);
}
} else {
brakeCandidate = false; // 임계값 벗어나면 리셋
}
// 2초 후 자동 해제
xSemaphoreTake(mutex, portMAX_DELAY);
if (state.brake_active && millis() - state.last_brake_ms > EVENT_LATCH_MS)
state.brake_active = false;
xSemaphoreGive(mutex);
}
임계값은 pet_config.h에서 조정 가능합니다:
constexpr float IMU_BRAKE_G = 0.35f; // g 단위, 낮추면 민감
constexpr float IMU_BUMP_G = 1.8f; // 과속방지턱 높이에 따라
constexpr uint32_t IMU_BRAKE_MS = 80; // debounce 시간 (ms)
constexpr uint32_t EVENT_LATCH_MS = 2000; // 이벤트 유지 시간 (ms)
속도 → 캐릭터 상태 매핑
const char* state =
s.brake_active ? "brake" // IMU 이벤트 최우선
: (spd <= 0) ? "sleep"
: (spd < SPEED_RUN_MIN) ? "walk" // 1~29 mph
: (spd < SPEED_TURBO_MIN) ? "run" // 30~54 mph
: "turbo"; // 55+ mph
이 상태값이 /api/state JSON에 포함되어 폰 앱으로 전송되면, 앱에서 해당 애니메이션(mp4/webp)을 재생합니다.
전체 데이터 흐름
GPS (UART2, 115200)
└─ SensorTask ─┬─ speed, lat, lon, sats
IMU (I2C) │ brake_active, bump_active
└─ SensorTask ─┘
│ [Mutex 보호]
├──► NetworkTask ─► GET /api/state ─► 폰 앱 (1Hz 폴링)
└──► DisplayTask ─► ILI9341 LCD HUD + 카피바라 애니메이션
마치며
FreeRTOS를 쓰기 전에는 loop() 안에 GPS 읽기, 화면 그리기, HTTP 처리를 순서대로 넣었습니다. WiFi 응답이 늦으면 화면이 버벅이고, 화면 렌더링이 길면 HTTP 타임아웃이 났습니다.
태스크로 분리하고 나서 세 가지가 해결됐습니다:
- DisplayTask는 WiFi 처리 중에도 끊김 없이 렌더링
- SensorTask는 20Hz 고정 주기로 안정적인 IMU 샘플링
- NetworkTask는 HTTP 블로킹이 다른 태스크에 영향 없음
핵심은 두 가지입니다:
- 어느 코어에 배치할지 — 렌더링은 Core 1, 네트워크는 WiFi와 같은 Core 0
- 공유 데이터는 반드시 Mutex — 멀티코어에서 보호 없는 공유 구조체는 반드시 문제가 됩니다
'개인 개발 > 자동차 속도 기반 IoT 장식 만들기' 카테고리의 다른 글
| 자동차 속도 기반 IoT 장식 만들기 10 - 생산 단가 정하기 (0) | 2026.06.01 |
|---|---|
| 자동차 속도 기반 IoT 장식 만들기 08 - Web Bluetooth로 GIF를 ESP32 디스플레이에 전송하기 (0) | 2026.05.22 |
| 자동차 속도 기반 IoT 장식 만들기 07 - ESP32-S3 FreeRTOS로 차량용 IoT 펌웨어 재설계하기 (0) | 2026.05.22 |
| 자동차 속도 기반 IoT 장식 만들기 06 - 실차 테스트 결과와 방향 전환, OBD2에서 GPS+가속도 센서로 (0) | 2026.05.01 |
| 자동차 속도 기반 IoT 장식 만들기 05 - ESP32 블루투스 OBD2 연동 구현 (0) | 2026.05.01 |