RTOS — 선점과 블로킹을 되찾는다
베어메탈에서 가장 아쉬운 것 둘이 있다. 급한 일이 덜 급한 일을 밀어낼 수 없고,
wait_for_data()처럼 기다리는 코드를 그대로 쓸 수 없다.RTOS 가 파는 것이 정확히 이 둘이다. 그리고 그 대가를 알고 사야 한다.
태스크는 각자 스택을 갖는다
void task_sensor(void *arg) {
for (;;) { // 태스크도 끝나지 않는다
read_sensor();
vTaskDelay(pdMS_TO_TICKS(10)); // ← 여기서 CPU 를 놓는다
}
}
int main(void) {
xTaskCreate(task_sensor, "sensor", 256, NULL, 3, NULL);
xTaskCreate(task_display, "display", 512, NULL, 1, NULL);
// ^^^ 스택 크기(워드) ^ 우선순위
vTaskStartScheduler(); // 여기서 돌아오지 않는다
}
베어메탈과의 결정적 차이가 vTaskDelay 다. delay_ms 는 CPU 를 태우며 기다렸지만, vTaskDelay 는 CPU 를 다른 태스크에 넘긴다. 그래서 "기다리는 코드" 를 그대로 써도 낭비가 없다.
베어메탈 delay_ms(10) 10ms 동안 CPU 를 붙잡고 아무것도 안 한다
RTOS vTaskDelay(10) 10ms 동안 다른 태스크가 돌고, 시간이 되면 깨어난다
대가 — 태스크마다 스택
태스크 5개 × 스택 512워드 × 4바이트 = 10KB
+ 커널 자체 · 큐 · 세마포어 ...
SRAM 이 32KB 인 칩에서는 무시할 수 없는 양이다
RTOS 의 진짜 비용은 코드 크기가 아니라 RAM 이다. 각 태스크가 독립적으로 실행되려면 자기 문맥(스택)이 있어야 하기 때문이다.
스택 크기를 어떻게 정하나
가장 자주 겪는 문제이고, 너무 작으면 조용히 다른 태스크의 메모리를 망친다. 실제로 얼마나 썼는지 재는 방법을 스택 워터마크라 하고, 이 값을 보고 줄여 나간다.
// ① 넉넉히 잡고 실제 사용량을 측정한다
xTaskCreate(task_x, "x", 1024, NULL, 3, &h);
// ② 한동안 돌린 뒤 남은 여유를 본다
UBaseType_t left = uxTaskGetStackHighWaterMark(h);
printf("남은 스택: %lu 워드\r\n", left); // 이 값이 0 에 가까우면 위험하다
// ③ 최대 사용량 + 여유 30% 로 줄인다
// 스택을 먹는 것들 — 이 셋을 조심한다
void bad_task(void *arg) {
char buf[2048]; // ✗ 지역 배열이 스택에 잡힌다
printf("%f\r\n", x); // ✗ printf 는 스택을 수백 바이트 쓴다
recurse(depth); // ✗ 재귀는 깊이를 예측할 수 없다
}
printf 를 태스크에서 쓰면 스택 요구가 급증한다. 그래서 로그 태스크를 하나 두고 큐로 문자열을 넘기는 구조가 흔하다.
// 오버플로 감지를 켠다 — 조용히 망가지는 것보다 훨씬 낫다
#define configCHECK_FOR_STACK_OVERFLOW 2
void vApplicationStackOverflowHook(TaskHandle_t t, char *name) {
// 여기 들어오면 그 태스크의 스택이 넘쳤다
log_fault(name);
NVIC_SystemReset();
}
스케줄링 — 우선순위 선점
태스크를 바꿀 때 레지스터와 스택 포인터를 저장하고 복원하는 과정을 컨텍스트 스위칭이라 한다. 공짜가 아니라서 태스크를 잘게 쪼갤수록 오버헤드가 늘어난다.
태스크 상태 — 넷뿐이고, 전이 조건이 전부다
읽는 법이 있다. Running 은 코어당 하나뿐이고, 나머지는 전부 대기 상태다.
- Ready → Running 은 스케줄러만 결정한다. 내가 "지금 돌아라" 할 수 없다.
- Running → Blocked 는 내가 스스로 부르는 API 로만 일어난다(
vTaskDelay, 큐 대기). 그래서 Blocked 되지 않는 태스크는 CPU 를 놓지 않는다 — 같은/낮은 우선순위 태스크가 굶는다. - Blocked → Ready 는 시간이나 이벤트가 만든다. 이 전이가 더 높은 우선순위에서 일어나면 즉시 현재 태스크를 **선점**한다.
함정 —
while(1) { }만 있고vTaskDelay가 없는 태스크를 최고 우선순위로 만들면 다른 태스크가 영원히 못 돈다(기아). 워치독까지 굶어 리셋으로 나타난다. "가장 중요한 일이니 우선순위를 최고로" 라는 판단이 시스템을 멈추는 전형적 경로다.
taskYIELD()는 같은 우선순위끼리만 양보한다 — 낮은 우선순위에게는 양보되지 않는다. 낮은 쪽에 시간을 주려면 반드시 Blocked 로 내려가야(vTaskDelay) 한다.
태스크 우선순위 상태
control 3 Ready ← 이것이 돈다
sensor 2 Blocked (vTaskDelay 중)
display 1 Ready
idle 0 Ready
"Ready 인 것 중 가장 높은 우선순위" 가 돈다. 그리고 더 높은 것이 Ready 가 되는 순간 즉시 뺏는다 — 이것이 선점이다.
void ISR(void) {
BaseType_t woken = pdFALSE;
xSemaphoreGiveFromISR(sem, &woken); // 태스크를 깨운다
portYIELD_FROM_ISR(woken); // ★ 깨운 태스크가 더 높으면 즉시 전환
}
portYIELD_FROM_ISR 을 빠뜨리는 것이 흔한 실수다. 없으면 깨운 태스크가 다음 틱까지 기다린다 — 최대 1ms 지연이 생겨 "인터럽트는 오는데 반응이 느리다" 가 된다.
같은 우선순위면 라운드로빈
#define configUSE_TIME_SLICING 1 // 틱마다 같은 우선순위끼리 돌려 쓴다
우선순위를 같게 주는 것도 설계다. 서로 선점하지 않아 공유 데이터 문제가 줄어들고, 대신 응답성 보장이 약해진다.
태스크 사이 통신
큐 — 데이터를 넘긴다
QueueHandle_t q = xQueueCreate(10, sizeof(Sample));
// 보내는 쪽
xQueueSend(q, &s, pdMS_TO_TICKS(100)); // 가득 차면 100ms 기다린다
// 받는 쪽
Sample s;
if (xQueueReceive(q, &s, portMAX_DELAY)) { } // 올 때까지 잔다
큐가 값을 복사한다는 점이 중요하다. 보낸 쪽의 변수가 스택에 있어도 안전하고, 두 태스크가 같은 메모리를 만지지 않으므로 락이 필요 없다.
// 큰 데이터는 포인터를 넘기지만, 소유권 규칙을 정해야 한다
xQueueSend(q, &ptr, 0); // 보낸 쪽은 이제 그 버퍼를 건드리지 않는다
세마포어 — 신호를 보낸다
SemaphoreHandle_t sem = xSemaphoreCreateBinary();
// ISR 에서 "데이터 왔다"
xSemaphoreGiveFromISR(sem, &woken);
// 태스크는 올 때까지 잔다 — 폴링하지 않는다
xSemaphoreTake(sem, portMAX_DELAY);
process();
폴링을 없애는 것이 세마포어의 값어치다. 베어메탈에서 while(!flag){} 로 돌던 것을 CPU 를 놓고 자게 만든다.
뮤텍스 — 공유 자원을 지킨다
SemaphoreHandle_t mtx = xSemaphoreCreateMutex();
xSemaphoreTake(mtx, portMAX_DELAY);
i2c_write(...); // 두 태스크가 동시에 쓰면 안 된다
xSemaphoreGive(mtx);
뮤텍스와 세마포어는 이름이 비슷하지만 다르다.
ISR 에서 뮤텍스를 쓰면 안 된다. 뮤텍스는 "잡은 태스크가 풀어야" 하는데 ISR 은 태스크가 아니다.
우선순위 역전 — RTOS 의 대표적 함정
낮은 태스크 L 이 뮤텍스를 잡고 있다
↓
높은 태스크 H 가 그 뮤텍스를 원한다 → 기다린다
↓
중간 태스크 M 이 Ready 가 된다 → L 을 선점한다
↓
결과: H(가장 높음)가 M(중간) 때문에 기다린다 ← 역전
M 은 뮤텍스와 아무 상관이 없는데 H 를 막는다. 화성 탐사선 Pathfinder 가 이 문제로 재부팅을 반복한 유명한 사례가 있다.
// 해법 — 우선순위 상속. FreeRTOS 의 뮤텍스는 이것을 지원한다
xSemaphoreCreateMutex(); // ✓ 상속 있음
xSemaphoreCreateBinary(); // ✗ 상속 없음 — 상호 배제에 쓰면 역전이 난다
상호 배제에는 반드시 뮤텍스를 쓴다. 이진 세마포어로 락을 만들면 우선순위 상속이 없어 역전을 막지 못한다.
우선순위 상속: L 이 뮤텍스를 잡고 있는 동안 L 의 우선순위를 H 만큼 올린다
→ M 이 L 을 선점할 수 없다 → H 가 빨리 받는다
ISR 과 태스크의 경계
// ISR 에서 쓸 수 있는 것은 FromISR 접미사가 붙은 것만
xQueueSendFromISR(q, &v, &woken); // ✓
xQueueSend(q, &v, 0); // ✗ 블로킹 가능성이 있는 API
// 그리고 인터럽트 우선순위 제한이 있다
configMAX_SYSCALL_INTERRUPT_PRIORITY // 이보다 높은 ISR 은 커널 API 를 못 쓴다
이 제약을 모르고 API 를 부르면 커널 자료구조가 깨져 나중에 엉뚱한 곳에서 터진다. 그래서 진단이 극히 어렵다.
#define configASSERT(x) if(!(x)) { taskDISABLE_INTERRUPTS(); for(;;); }
configASSERT 를 켜 두면 커널이 이 위반을 즉시 잡아 준다. 개발 중에는 반드시 켠다.
틱과 시간
#define configTICK_RATE_HZ 1000 // 1ms 틱
vTaskDelay(pdMS_TO_TICKS(10)); // 10 틱 = 10ms
// ✗ 주기가 밀린다 — 실행 시간이 누적된다
for (;;) { work(); vTaskDelay(pdMS_TO_TICKS(10)); }
// ✓ 정확한 주기
TickType_t last = xTaskGetTickCount();
for (;;) { work(); vTaskDelayUntil(&last, pdMS_TO_TICKS(10)); }
vTaskDelayUntil 은 "마지막 깨어난 시각 + 주기" 를 기준으로 하므로 지터가 쌓이지 않는다. 제어 루프에는 반드시 이쪽을 쓴다.
틱을 높이면(예: 10kHz) 시간 분해능은 좋아지지만 틱 ISR 자체가 CPU 를 먹는다. 1kHz 가 실무 기본값이고, 더 정밀한 타이밍은 하드웨어 타이머로 처리한다.
RTOS 를 쓰면 안 되는 경우
- SRAM 이 아주 작다(8~16KB) — 태스크 스택을 감당할 수 없다
- 최악 시간을 손으로 증명해야 한다 — 인증 요구가 있는 경우 스케줄러가 감춘 것이 증명을 어렵게 한다
- 작업이 셋뿐이고 주기가 같다 — 슈퍼루프가 더 단순하고 더 안전하다
"RTOS 를 쓰면 더 전문적" 이라는 이유로 도입하면 RAM 이 부족하고 디버깅이 어려워지기만 한다. 앞 편에서 만든 부하율 표를 근거로 판단한다.
디버깅이 달라진다
// 태스크 상태를 덤프한다
char buf[512];
vTaskList(buf);
printf("%s", buf);
// Name State Prio Stack Num
// control R 3 142 1 ← R=Running B=Blocked S=Suspended
// sensor B 2 198 2
// display R 1 301 3
// IDLE R 0 84 4
// CPU 를 누가 쓰나
vTaskGetRunTimeStats(buf);
// control 41213 12%
// display 102882 31%
// IDLE 187221 56% ← IDLE 이 낮으면 여유가 없다
IDLE 비율이 곧 여유다. 10% 아래로 내려가면 마감을 놓칠 위험이 있다는 신호다.
RTOS 를 쓰면 스택 오버플로·우선순위 역전·ISR 우선순위 위반이라는 새 실패 모드가 생긴다. 그래서
configCHECK_FOR_STACK_OVERFLOW·configASSERT·런타임 통계를 켜 두는 것이 선택이 아니라 필수다.
한눈에 정리
- RTOS 가 파는 것은 선점과 "기다리는 코드를 그대로 쓰기" 다. 기능이 많아서가 아니다
- 비용은 코드가 아니라 RAM — 태스크마다 스택이 필요하다
- 스택은 넉넉히 잡고
uxTaskGetStackHighWaterMark로 줄인다.printf·지역 배열·재귀가 범인 configCHECK_FOR_STACK_OVERFLOW를 켠다 — 조용히 망가지는 것보다 훨씬 낫다portYIELD_FROM_ISR을 빠뜨리면 다음 틱까지 지연된다- 큐는 값을 복사하므로 락이 필요 없다. 포인터를 넘기면 소유권 규칙을 정한다
- 상호 배제에는 반드시 뮤텍스 — 이진 세마포어는 우선순위 상속이 없어 역전을 막지 못한다
- 우선순위 역전 — 무관한 중간 태스크가 최고 우선순위를 막는다. Pathfinder 사례
- ISR 에서는
FromISRAPI 만, 그리고configMAX_SYSCALL_INTERRUPT_PRIORITY제약을 지킨다 - 제어 루프에는
vTaskDelayUntil—vTaskDelay는 실행 시간이 주기에 누적된다 - IDLE 비율이 여유다. 10% 아래면 위험 신호
- RTOS 는 새 실패 모드를 가져온다 — 스택 오버플로·역전·ISR 위반. 진단 옵션을 켜 두는 것이 필수
꼬리질문 대비
- "태스크 스택 크기를 어떻게 정하나?" → high-water mark 실측 + 최악 호출 경로 합산. ISR 이 그 스택을 쓰는지도 확인
- "우선순위 역전은 무엇이고 어떻게 막나?" → 낮은 우선순위가 뮤텍스를 쥔 채 중간 우선순위에 선점당하는 것 — 우선순위 상속 뮤텍스로 완화
- "
taskYIELD()로 낮은 우선순위에 양보되나?" → 안 된다. 같은 우선순위끼리만 양보된다 — 내려가려면 Blocked(vTaskDelay)로 가야 한다- "ISR 에서 큐에 넣을 때 쓰는 API 는?" →
…FromISR계열. 깨어난 태스크가 더 높으면portYIELD_FROM_ISR로 즉시 전환
출처 — FreeRTOS 공식 문서(태스크·큐·세마포어·뮤텍스·
vTaskDelayUntil·configASSERT) · Arm, Cortex-M4 Devices Generic User Guide(DUI 0553) §2.3 예외 우선순위 · NASA JPL, What really happened on Mars Pathfinder — 우선순위 역전 사례 · Liu & Layland(1973), 주기 태스크 스케줄링 이론