임베디드 학습 노트 목차

베어메탈 설계 — RTOS 없이 여러 일을 하기

while(1) 안에 할 일을 순서대로 넣는 것이 슈퍼루프다. 일이 셋일 때는 잘 돌아간다. 그런데 열이 되고, 각각 주기가 다르고, 어떤 것은 5ms 안에 반드시 응답해야 한다면?

RTOS 를 쓰기 전에 베어메탈로 어디까지 갈 수 있는지를 알아야 한다. 대부분의 제품이 실제로 여기서 끝난다.


슈퍼루프가 무너지는 지점

while (1) {
    read_sensor();          // 2ms
    update_display();       // 30ms  ← 이게 문제다
    check_button();         // 0.1ms
    send_telemetry();       // 5ms
}
// 한 바퀴 37ms → 버튼 반응이 최악 37ms 늦다

가장 느린 것이 전체를 지배한다. 디스플레이 갱신은 초당 10번이면 충분한데, 그것 때문에 버튼과 센서가 함께 밀린다.

① 시간 기반 분할 — 주기별로 나눈다

static uint32_t t_1ms, t_10ms, t_100ms;

while (1) {
    uint32_t now = tick_ms;                       // SysTick 이 올리는 값

    if ((int32_t)(now - t_1ms) >= 0) {
        t_1ms = now + 1;
        read_sensor();                             // 1ms 주기
    }
    if ((int32_t)(now - t_10ms) >= 0) {
        t_10ms = now + 10;
        check_button();  control_loop();
    }
    if ((int32_t)(now - t_100ms) >= 0) {
        t_100ms = now + 100;
        update_display();  send_telemetry();
    }
}

(int32_t)(now - t) >= 0 이 관용구다. 부호 있는 정수로 캐스팅해 비교하면 tick_ms 가 32비트를 넘어 되돌아가도(약 49일) 정상 동작한다.

// ✗ 오버플로에서 깨진다
if (now >= t_1ms) { }

// ✗ 주기가 밀린다 — 실행 시간이 누적된다
t_1ms = tick_ms + 1;          // 함수가 0.3ms 걸리면 주기가 1.3ms 가 된다

// ✓ 목표 시각을 기준으로 더한다
t_1ms += 1;                    // 주기가 정확히 유지된다

세 번째가 중요하다. "지금 + 주기" 가 아니라 "목표 + 주기" 로 해야 지터가 누적되지 않는다.

다만 한 사이클이 주기보다 오래 걸리면 목표 시각이 과거로 밀려 계속 즉시 실행된다. 그때는 밀린 것을 건너뛰는 처리가 필요하다.

② 상태 기계 — 기다리지 않고 나눈다

// ✗ 여기서 3초를 묶어 둔다
void measure(void) {
    trigger_sensor();
    delay_ms(3000);              // 3초 동안 아무것도 못 한다
    read_result();
}
// ✓ 상태로 쪼갠다 — 매 루프에 조금씩 진행한다
typedef enum { IDLE, TRIGGERED, WAITING, READING } State;
static State st = IDLE;
static uint32_t t0;

void measure_step(void) {
    switch (st) {
    case IDLE:
        if (!request) break;
        trigger_sensor();  t0 = tick_ms;  st = TRIGGERED;
        break;
    case TRIGGERED:
        if ((int32_t)(tick_ms - t0 - 3000) < 0) break;   // 아직 안 됐으면 그냥 나온다
        st = READING;
        break;
    case READING:
        result = read_result();  request = false;  st = IDLE;
        break;
    }
}

"기다린다" 를 "아직 안 됐으면 나온다" 로 바꾸는 것이 핵심이다. 함수가 즉시 반환하므로 루프가 계속 돌고, 다음 바퀴에 이어서 진행한다.

이 변환은 기계적이다 — 블로킹 함수의 delay 자리마다 상태를 하나 만든다. 코드는 길어지지만 CPU 가 묶이지 않는다.

// 타임아웃도 자연스럽게 들어간다
case WAITING:
    if (sensor_ready())                       { st = READING; break; }
    if ((int32_t)(tick_ms - t0 - 5000) >= 0)  { st = IDLE; error++; }  // 5초 넘으면 포기
    break;

블로킹 방식에서는 타임아웃을 넣기가 까다로운데 상태 기계에서는 조건 하나를 더하면 끝난다.

③ 협조적 스케줄러 — 작업을 표로 관리한다

typedef struct {
    void (*fn)(void);
    uint32_t period_ms;
    uint32_t next;
    uint32_t max_us;          // 관측된 최악 실행 시간
} Task;

static Task tasks[] = {
    { read_sensor,     1,   0, 0 },
    { control_loop,   10,   0, 0 },
    { update_display,100,   0, 0 },
    { send_telemetry,100,   0, 0 },
};

void scheduler_run(void) {
    for (Task *t = tasks; t < tasks + ARRAY_LEN(tasks); t++) {
        if ((int32_t)(tick_ms - t->next) < 0) continue;
        t->next += t->period_ms;

        uint32_t s = DWT->CYCCNT;              // 사이클 카운터로 시간 측정
        t->fn();
        uint32_t us = (DWT->CYCCNT - s) / (SystemCoreClock / 1000000);
        if (us > t->max_us) t->max_us = us;    // 최악값을 갱신
    }
}

표로 만들면 두 가지가 생긴다. 작업 추가가 한 줄이 되고, 각 작업의 최악 실행 시간을 자동으로 기록할 수 있다.

주기가 겹치는 순간이 최악이다 — 최소공배수를 보라

협조적 스케줄러의 지터는 평소가 아니라 "여러 작업이 같은 틱에 모이는 순간" 에 터진다. 그 순간은 주기의 최소공배수(LCM) 마다 정확히 찾아온다.

주기 10 / 50 / 100 ms 인 세 작업이 있다면:

LCM(10, 50, 100) = 100 ms   ← 100ms 마다 세 작업이 같은 틱에 모인다
그 틱의 부하 = 1.5 + 3.0 + 8.0 = 12.5 ms

1ms 틱 기준으로 그 순간만 12틱이 밀린다. 평소 1.5ms 로 잘 돌던 시스템이 100ms 마다 한 번씩 12.5ms 지연을 내는 것이다. 로그 평균만 보면 절대 안 보이고, "가끔 튄다" 로만 나타난다.

대응은 주기를 바꾸는 게 아니라 위상(offset)을 어긋나게 두는 것이다.

// ✗ 전부 tick % period == 0 에서 실행 → 0ms 지점에 모두 몰린다
if (tick_ms % t->period_ms == 0) t->fn();

// ✓ 작업마다 오프셋을 줘 분산한다
static const uint32_t offset[] = { 0, 3, 7 };      // 서로 다른 틱에 뜨게
if (tick_ms % t->period_ms == offset[i]) t->fn();

왜 주기를 서로소로 만드는 것만으로는 부족한가 — 서로소여도 LCM 은 여전히 존재하고 (10과 21의 LCM은 210), 그 시점에 겹친다. 겹침 자체를 없앨 수는 없고 빈도를 낮출 뿐이다. 확실한 방법은 위 오프셋처럼 실행 위상을 명시적으로 분산하는 것이다.

함정t->next += t->period_ms 방식(위 코드)은 밀린 만큼 따라잡으려 연속 실행한다. 8ms 짜리 작업이 한 번 밀리면 다음 틱에 또 실행되어 더 밀리는 눈덩이가 생길 수 있다. 실시간성이 중요하면 t->next = tick_ms + t->period_ms(밀린 회차는 버림)로 바꾼다 — "정확한 주기"와 "밀림 방지" 중 무엇이 중요한지에 따라 고른다.

// 진단 출력 — 이 값이 곧 설계 검증이다
for (Task *t = tasks; ...; t++)
    printf("%-16s period=%lums worst=%luus load=%.1f%%\r\n",
           name(t->fn), t->period_ms, t->max_us,
           100.0 * t->max_us / (t->period_ms * 1000));

부하율 합이 100% 에 가까워지면 마감을 놓치기 시작한다. 이 숫자를 보면 "RTOS 로 넘어갈 때" 를 데이터로 판단할 수 있다.

협조적의 한계

작업 A 가 30ms 걸리는 동안 → 1ms 주기 작업이 30번 밀린다

협조적 스케줄러선점하지 못한다. 각 작업이 스스로 빨리 끝나 줘야 성립하는 신사협정이다. 규칙은 하나 — 모든 작업이 최소 주기보다 훨씬 짧아야 한다.

④ 인터럽트를 어디까지 쓸 것인가

인터럽트에서    ─ 시간이 급한 것 (엣지 캡처, 바이트 수신, 제어 루프)
메인 루프에서   ─ 오래 걸리는 것 (통신 프로토콜, 파일 쓰기, 화면 갱신)

"급한 것은 인터럽트, 무거운 것은 루프" 가 원칙이고, 둘 사이는 링 버퍼나 플래그로 잇는다.

// ISR — 짧게, 신호만
void TIM3_IRQHandler(void) {
    TIM3->SR &= ~TIM_SR_UIF; (void)TIM3->SR;
    adc_start();                       // 시작만 걸어 둔다
}
void ADC_IRQHandler(void) {
    ring_push(&samples, ADC1->DR);     // 버퍼에 넣고 나온다
}

// 메인 — 무거운 처리
while (1) {
    uint16_t v;
    while (ring_pop(&samples, &v)) filter_and_log(v);
    scheduler_run();
}

공유 데이터는 임계 구역으로

// ✗ 32비트를 넘는 구조체는 원자적이지 않다
typedef struct { uint32_t x, y, z; } Vec;
volatile Vec latest;                   // ISR 이 쓰고 메인이 읽는다
Vec v = latest;                        // 중간에 ISR 이 끼어들면 찢어진 값

// ✓ 임계 구역
uint32_t s = enter_critical();
Vec v = latest;
exit_critical(s);

// ✓✓ 더 나은 방법 — 이중 버퍼
static volatile Vec buf[2];
static volatile uint8_t idx;           // ISR 이 쓰는 쪽

void ISR(void) {
    uint8_t w = idx ^ 1;
    buf[w] = new_value;                // 쓰지 않는 쪽에 채우고
    idx = w;                            // 인덱스를 한 번에 바꾼다 (원자적)
}
Vec read_latest(void) { return buf[idx]; }   // 항상 완성된 값을 읽는다

이중 버퍼가 임계 구역보다 낫다 — 인터럽트를 막지 않으므로 실시간성을 해치지 않는다.

베어메탈로 충분한가 — 판단 기준

상황판단
작업이 5개 이하, 주기가 비슷하다슈퍼루프로 충분
주기가 다양하고 10개 정도협조적 스케줄러
한 작업이 다른 작업 주기보다 길다상태 기계로 쪼개기 시도 → 안 되면 RTOS
블로킹 라이브러리(TCP/IP, 파일시스템)를 써야 한다RTOS
우선순위가 다른 작업이 서로 선점해야 한다RTOS

RTOS 로 넘어가는 진짜 이유는 "선점" 과 "블로킹 코드를 그대로 쓰기" 다. 기능이 많아서가 아니다.

베어메탈의 이점도 분명하다.

  • RAM 을 아낀다태스크마다 스택이 필요 없다
  • 동작을 전부 안다 — 스케줄러가 감춘 것이 없어 최악 시간을 손으로 계산할 수 있다
  • 의존성이 없다 — 커널 버전·설정에 얽히지 않는다

실무 골격

int main(void) {
    clock_init();
    watchdog_init();
    peripherals_init();
    scheduler_init();

    while (1) {
        scheduler_run();               // 주기 작업들
        drain_queues();                // ISR 이 넣은 것 처리
        if (all_tasks_alive())         // 모든 작업이 돌았음을 확인한 뒤에만
            watchdog_feed();
        maybe_sleep();                 // 할 일이 없으면 잠깐 잔다
    }
}

static void maybe_sleep(void) {
    if (queues_empty() && !any_task_due())
        __WFI();                        // 인터럽트가 올 때까지 잔다 — 전력을 아낀다
}

__WFI()(Wait For Interrupt) 를 넣는 것이 중요하다. 할 일이 없을 때 CPU 를 재우면 전류가 크게 준다. 어차피 인터럽트가 깨워 주므로 응답성 손실이 거의 없다.

워치독은 "모든 작업이 돌았음" 을 확인한 뒤에만 먹인다. 루프가 도는 것만 확인하면 한 작업이 멈춰도 리셋이 안 걸린다.


한눈에 정리

  • 슈퍼루프는 가장 느린 작업이 전체를 지배한다. 이것이 무너지는 지점이다
  • 시간 기반 분할(int32_t)(now - t) >= 0 이 오버플로에 안전한 관용구. 목표 시각에 주기를 더해야 지터가 안 쌓인다
  • 상태 기계 — "기다린다" 를 "아직 안 됐으면 나온다" 로 바꾼다. delay 자리마다 상태 하나
  • 블로킹에서 어려운 타임아웃이 상태 기계에서는 조건 하나
  • **협조적 스케줄러**를 표로 만들면 작업별 최악 실행 시간과 부하율을 기록할 수 있다 → RTOS 전환 시점을 데이터로 판단
  • 협조적은 선점하지 못한다 — 모든 작업이 최소 주기보다 훨씬 짧아야 성립하는 신사협정
  • 급한 것은 인터럽트, 무거운 것은 루프, 사이는 링 버퍼
  • 이중 버퍼가 임계 구역보다 낫다 — 인터럽트를 막지 않는다
  • RTOS 로 가는 이유는 선점과 블로킹 코드 — 기능이 많아서가 아니다
  • 할 일이 없으면 __WFI() 로 재운다. 전류가 크게 준다

꼬리질문 대비

  • "슈퍼루프에서 응답 시간을 어떻게 계산하나?" → 모든 단계의 최악 실행 시간의 합 — 평균이 아니라 최악을 더한다
  • "협조적 스케줄러의 지터가 언제 최대가 되나?" → 주기의 최소공배수 시점 — 여러 작업이 같은 틱에 모인다. 오프셋으로 위상을 분산
  • "next += periodnext = now + period 의 차이는?" → 앞은 밀린 회차를 따라잡아 눈덩이가 될 수 있고, 뒤는 회차를 버려 주기가 흔들린다
  • "RTOS 로 넘어가야 하는 신호는?" → 블로킹 대기가 많고 우선순위가 다른 작업이 섞일 때 — 상태 기계로 쪼개는 비용이 더 클 때

출처 — Michael J. Pont, Patterns for Time-Triggered Embedded Systems(협조적 스케줄러) · Arm, Cortex-M4 Devices Generic User Guide(DUI 0553) §2.5 전력 관리(WFI)·§4.4 SysTick · Arm, CoreSight DWT — CYCCNT 사이클 카운터

LIN — 한 가닥으로 싸게, 느리게RTOS — 선점과 블로킹을 되찾는다