임베디드 · 심화
인터럽트·RTOS·실시간 설계
RTOS 태스크와 스케줄링
선점형 스케줄링, 우선순위, 스택 크기, FreeRTOS 대응표
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 만든 협동형 스케줄러는 각 작업이 실행권을 돌려준다는 약속 위에서 동작했다. 작업을 짧게 나누면 구조가 단순하지만, 어느 작업이 오래 실행되면 다른 작업도 기다려야 한다. 컨베이어 제어기에 검사와 기록 기능이 늘어날수록 이 약속을 모든 실행 경로에서 지키기 어려워진다.
실시간 운영체제(RTOS)는 실행할 작업을 태스크(task)로 관리하고, 실행 가능한 태스크 가운데 누구에게 프로세서를 줄지 결정한다. 이 장에서는 선점형 스케줄링(preemptive scheduling), 우선순위(priority), 태스크별 스택(stack)을 연결해 이해한다. PC 예제는 실행 순서를 재현하는 협동형 시뮬레이터이며, 실제 태스크 문맥을 강제로 교체하는 커널은 아니다. 이 경계를 분명히 해야 예제에서 관찰한 결과를 실제 장치에 올바르게 적용할 수 있다.
- 준비, 실행, 대기 상태를 구분하고 실행 순서를 설명한다.
- 더 높은 우선순위의 태스크가 준비되었을 때 선점이 일어나는 이유를 이해한다.
- 태스크의 역할에 맞춰 우선순위를 정하고 낮은 우선순위의 실행 기회를 점검한다.
- 스택 크기의 단위와 사용량 확인 방법을 구분한다.
- PC 시뮬레이터의 동작을 FreeRTOS의 설정과 API에 대응시킨다.
문제 상황
공장 컨베이어 제어기는 모터 운전 조건을 확인하고, 제품 검사 결과를 처리하고, 운전 이력을 기록한다. 세 작업을 하나의 반복문에 넣으면 기록 작업이 길어지는 동안 모터 조건 확인도 늦어진다. 앞 장의 방식으로 기록 작업을 여러 조각으로 나눌 수 있지만, 나중에 추가한 문자열 변환 함수가 예상보다 오래 걸리면 확인 간격이 다시 늘어난다.
세 기능의 실행 요구는 서로 다르다. 모터 조건 확인은 자주 실행해야 하며, 실행할 차례가 되었을 때 기록 작업보다 먼저 처리할 필요가 있다. 제품 검사는 검사 입력의 발생 간격에 맞춰 진행한다. 기록 작업은 상대적으로 기다릴 수 있지만, 계속 밀리면 기록할 내용이 쌓인다. 기능 이름만으로 순서를 정할 것이 아니라 기다려도 되는 정도와 필요한 실행량을 함께 살펴야 한다.
이 장의 모델에서는 프로세서가 하나이고 태스크가 세 개다. 시간을 틱(tick)이라는 이산 단위로 나누며, 작업 조각 하나가 틱 하나를 차지한다고 가정한다. 이 가정은 실행 순서를 설명하기 위한 것이다. PC에서 함수가 실제로 실행된 시간이나 대상 MCU의 처리 시간과 같다는 뜻은 아니다.
| 태스크 | 역할 | 우선순위 | 첫 준비 / 주기 / 실행량 |
|---|---|---|---|
| control | 모터 운전 조건 확인 | 3 | 0 / 4 / 1틱 |
| inspect | 제품 검사 처리 | 2 | 2 / 6 / 2틱 |
| log | 운전 기록 처리 | 1 | 0 / 8 / 3틱 |
예제의 정지 요청은 논리 시각 12부터 참이 되는 모의 입력이다. 제어 태스크는 그 값을 읽어 모터 상태를 갱신한다. 이는 일반적인 운전 조건 확인을 설명하는 모델이며, 물리적인 비상 정지 회로를 구현하는 예제는 아니다. 제품 검사와 기록도 실제 장치 접근 대신 남은 실행 조각의 수로 표현한다.
준비된 태스크와 선점
태스크가 존재한다는 사실과 지금 실행할 수 있다는 사실은 다르다. 준비 상태는 실행 조건을 충족했지만 프로세서를 아직 받지 못한 상태다. 실행 상태는 프로세서를 받아 명령을 수행하는 상태다. 대기 상태는 시간이 지나거나 어떤 조건이 충족될 때까지 실행 후보에서 빠져 있는 상태다. 대기 중인 높은 우선순위 태스크는 준비된 낮은 우선순위 태스크의 실행을 막지 않는다.
단일 코어에서 한 순간에 실행되는 태스크는 하나다. 선점형 스케줄러는 실행 중인 태스크보다 높은 우선순위의 태스크가 준비되면 실행 주체를 바꿀 수 있다. 기존 태스크는 일을 모두 끝내지 않았어도 준비 상태로 돌아가며, 다시 선택되면 중단된 지점부터 이어서 실행한다. 이를 위해 커널은 레지스터와 실행 위치 등 문맥(context)을 보존한다.
선점의 계기를 주기적인 타이머 인터럽트에만 한정해서 생각하면 안 된다. 실제 RTOS에서는 인터럽트 처리 결과나 커널 API 호출 때문에 더 높은 우선순위의 태스크가 준비될 수도 있다. 언제 전환하는지는 커널 설정과 포트의 동작에 따른다. 또한 인터럽트를 가리거나 스케줄링을 잠시 제한한 구간에서는 전환이 지연될 수 있다. 높은 우선순위가 곧 지연 시간 0을 뜻하지는 않는다.
그림에서 기록은 시각 1에 한 조각을 실행한다. 시각 2에 검사가 준비되므로 검사가 먼저 실행되고, 시각 4에는 제어가 실행된다. 기록은 시각 5와 6에 남은 두 조각을 처리한다. 기록이 가진 총 실행량은 그대로이며 완료 시각만 뒤로 이동한다. 스케줄러가 실행 순서를 바꾼다고 필요한 계산 자체가 줄어들지는 않는다.
완성 코드에서는 각 틱의 시작에 대기 조건을 확인하고, 준비된 태스크를 선택해 조각 하나를 호출한다. 함수 실행 중에는 전환하지 않는다. 따라서 실제 커널의 선점을 구현하는 대신, 틱 경계에서 관찰한 우선순위 기반 실행 순서를 재현한다. 모든 작업이 정해진 크기의 조각으로 돌아온다는 협동형 모델의 제한은 여전히 남아 있다.
우선순위는 준비된 작업 사이의 순서다
FreeRTOS에서는 태스크 우선순위 값이 클수록 높은 우선순위를 나타낸다. 사용 가능한 값은 0부터 configMAX_PRIORITIES - 1까지다. 이 숫자는 중요도를 기록하는 등급표가 아니라, 여러 태스크가 동시에 준비되었을 때 실행 순서를 결정하는 입력이다. 이 장의 모델도 같은 방향으로 숫자를 해석한다.
제어 태스크가 가장 높더라도 항상 실행되는 것은 아니다. 제어가 한 번의 확인을 마치고 다음 주기까지 대기하면 검사나 기록이 실행된다. 반대로 높은 우선순위 태스크가 준비 상태를 유지하며 끝없이 반복하면 낮은 우선순위 태스크는 실행되지 못할 수 있다. 이러한 기아(starvation)는 우선순위를 높이는 것만으로 해결되지 않는다. 반복 작업에는 실행 후 대기할 조건이 필요하다.
같은 우선순위의 태스크에 대해서는 별도의 정책을 확인해야 한다. FreeRTOS는 선점과 시간 분할(time slicing)에 관한 설정을 제공한다. configUSE_PREEMPTION이 1이면 선점형 스케줄링을 사용하며, configUSE_TIME_SLICING이 활성화된 경우 같은 우선순위의 준비된 태스크 사이에서 틱에 따른 실행 기회를 나눌 수 있다. 이것이 모든 태스크에 동일한 실행 시간을 보장한다는 뜻은 아니다.
예제에는 서로 다른 우선순위를 부여한다. 선택 함수는 우선순위가 같은 후보가 있으면 배열에서 먼저 발견한 후보를 유지한다. 이 규칙은 결과를 재현하기 위한 단순한 규칙이며 FreeRTOS의 같은 우선순위 처리 방식을 재현하지 않는다. 우선순위만 같게 고쳐 놓고 시간 분할 실험이라고 해석하면 안 된다.
주기도 두 방식으로 생각할 수 있다. 작업을 끝낸 시점부터 일정 시간 쉬면 실제 반복 간격에는 실행 시간도 더해진다. 반면 기준 시각에 주기를 계속 더하면 원래 계획한 준비 시각을 유지할 수 있다. 예제는 후자를 사용한다. 검사는 시각 2, 8, 14에 준비되며, 시각 8에는 제어가 먼저 실행되어 실제 검사 시작은 시각 9가 된다. 준비 시각과 실행 시작 시각은 서로 다르다.
다음 표의 대응은 개념 수준이다. 태스크 생성과 시간 대기 API를 바꿔 적는 것만으로 시뮬레이터가 실제 RTOS 프로그램이 되지는 않는다. 실제 포트에서는 태스크 진입 함수, 보드 초기화, 커널 설정, 인터럽트 설정을 함께 맞춰야 한다.
| PC 모델 | FreeRTOS 대응 | 확인할 차이 |
|---|---|---|
| Task 구조체와 초기값 | xTaskCreate(), xTaskCreateStatic() | 실제 커널은 태스크 제어 정보와 전용 스택을 준비한다. |
| priority와 pick_ready() | 생성 시 우선순위, vTaskPrioritySet() | 선점 여부는 configUSE_PREEMPTION 설정도 확인한다. |
| next_release에 period를 더함 | xTaskDelayUntil() | 기준 틱을 갱신하며 주기적인 대기를 구성한다. |
| 완료 뒤 일정 시간 기다리는 다른 설계 | vTaskDelay() | 호출 시점을 기준으로 상대적인 대기를 요청한다. |
| stack_budget_bytes | 스택 깊이 인자, StackType_t | PC 값은 바이트 단위 메모다. 표준 API의 깊이는 원소 수다. |
| 모델에는 없는 실제 스택 계측 | uxTaskGetStackHighWaterMark() | 지금까지 관찰한 최소 미사용 스택을 원소 수로 돌려준다. |
API의 사실 확인에는 FreeRTOS 태스크 생성 설명, 주기 대기 설명, 스택 사용량 확인 설명을 참고할 수 있다. 이 장의 코드와 그림은 해당 문서의 예제를 옮긴 것이 아니라 컨베이어 모델을 위해 구성한 것이다.
태스크 스택의 크기와 확인 방법
일반적인 FreeRTOS 태스크는 자신만의 스택을 가진다. 함수 호출에 필요한 정보, 자동 지역 변수, 문맥 보존에 필요한 일부 데이터가 스택을 사용한다. 정확한 배치와 인터럽트 처리 때 사용하는 스택은 프로세서와 포트에 따라 달라진다. 그러므로 태스크 함수에 선언된 배열 크기만 합산해서 스택 크기를 결정할 수는 없다.
스택 예산을 정할 때는 가장 깊은 호출 경로와 그 경로에 놓인 지역 변수를 살핀다. 평상시 경로보다 오류 메시지를 만드는 경로가 더 많은 스택을 쓸 수도 있다. 서식 출력이나 라이브러리 함수의 사용량은 구현에 영향을 받으며, 빌드 최적화와 디버그 설정에 따라서도 달라질 수 있다. 정해 둔 바이트 수는 검증할 가설이지 안전성을 입증하는 숫자가 아니다.
표준 FreeRTOS의 태스크 생성 API에서 스택 깊이는 바이트가 아니라 StackType_t 원소 수다. 예를 들어 한 원소가 4바이트인 포트에서 깊이 256은 스택 영역 1,024바이트에 해당한다. 태스크 제어 블록 등의 메모리는 여기에 포함되지 않는다. 특정 업체가 수정한 API에서는 단위가 다를 수 있으므로 실제로 사용하는 배포본의 선언과 설명을 확인해야 한다.
uxTaskGetStackHighWaterMark()는 태스크가 생성된 뒤 관찰된 최소 미사용 스택 공간을 알려 준다. 남은 양이 클수록 지금까지는 여유가 있었다고 해석한다. 측정값에 sizeof(StackType_t)를 곱하면 바이트 단위로 환산할 수 있다. 관련 API를 사용하려면 해당 기능의 설정도 활성화해야 한다.
이 값이 충분히 크더라도 아직 실행하지 않은 경로까지 검증된 것은 아니다. 초기화, 정상 운전, 오류 처리, 긴 문자열 처리 등 서로 다른 경로를 실행한 뒤 측정해야 한다. 스택을 채워 둔 패턴으로 사용 흔적을 판단하는 방식에도 한계가 있으므로 컴파일러의 스택 사용 보고서와 호출 경로 검토를 함께 활용한다. 스택 넘침 검사 설정 역시 사용량 검토를 대신하지 않는다.
완성 코드의 stack_budget_bytes는 이 설계 정보를 적어 두는 메타데이터다. 실제 메모리를 할당하지 않으며 스택 사용량도 측정하지 않는다. control에 768, inspect에 1,024, log에 1,536바이트를 적어 두지만 이는 연습용 가정이다. 이 값을 그대로 제품의 권장 크기로 사용해서는 안 된다.
완성 코드
다음 내용을 conveyor_tasks.c로 저장한다. 한 파일 안에 hal_sim 함수들과 협동형 스케줄러 시뮬레이터를 둔다. 실제 시간을 기다리거나 운영체제 스레드를 만들지 않으므로 실행 순서는 호스트의 부하와 관계없이 일정하다. -pthread 옵션은 지정된 빌드 환경에 맞춰 포함하며, 이 프로그램에서 병렬 실행을 만들지는 않는다.
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
enum { SIM_TICKS = 16 };
typedef enum {
TASK_WAITING,
TASK_READY,
TASK_RUNNING
} TaskState;
typedef struct {
unsigned tick;
bool motor_enabled;
unsigned inspected;
} HalSim;
typedef struct Task Task;
typedef void (*TaskStep)(Task *, HalSim *);
struct Task {
const char *name;
unsigned priority;
unsigned period;
unsigned next_release;
unsigned work_ticks;
unsigned remaining;
size_t stack_budget_bytes;
TaskState state;
TaskStep step;
};
static bool hal_sim_stop_requested(const HalSim *hal)
{
return hal->tick >= 12U;
}
static void hal_sim_set_motor(HalSim *hal, bool enabled)
{
hal->motor_enabled = enabled;
}
static void control_step(Task *task, HalSim *hal)
{
(void)task;
hal_sim_set_motor(hal, !hal_sim_stop_requested(hal));
}
static void inspect_step(Task *task, HalSim *hal)
{
if (task->remaining == 1U) {
++hal->inspected;
}
}
static void log_step(Task *task, HalSim *hal)
{
(void)task;
(void)hal;
/* Scheduling model only: no physical log device. */
}
static void release_tasks(Task tasks[], size_t count,
unsigned now)
{
for (size_t i = 0; i < count; ++i) {
Task *task = &tasks[i];
if (task->state == TASK_WAITING &&
now >= task->next_release) {
task->remaining = task->work_ticks;
task->state = TASK_READY;
}
}
}
static Task *pick_ready(Task tasks[], size_t count)
{
Task *best = NULL;
for (size_t i = 0; i < count; ++i) {
Task *candidate = &tasks[i];
if (candidate->state != TASK_READY) {
continue;
}
if (best == NULL ||
candidate->priority > best->priority) {
best = candidate;
}
}
return best;
}
static void run_one_tick(Task *task, HalSim *hal)
{
task->state = TASK_RUNNING;
task->step(task, hal);
--task->remaining;
printf("%02u %-7s left=%u motor=%s\n",
hal->tick, task->name, task->remaining,
hal->motor_enabled ? "on" : "off");
if (task->remaining == 0U) {
task->next_release += task->period;
task->state = TASK_WAITING;
} else {
task->state = TASK_READY;
}
}
int main(void)
{
HalSim hal = {
.tick = 0U,
.motor_enabled = false,
.inspected = 0U
};
Task tasks[] = {
{
.name = "control",
.priority = 3U,
.period = 4U,
.next_release = 0U,
.work_ticks = 1U,
.remaining = 0U,
.stack_budget_bytes = 768U,
.state = TASK_WAITING,
.step = control_step
},
{
.name = "inspect",
.priority = 2U,
.period = 6U,
.next_release = 2U,
.work_ticks = 2U,
.remaining = 0U,
.stack_budget_bytes = 1024U,
.state = TASK_WAITING,
.step = inspect_step
},
{
.name = "log",
.priority = 1U,
.period = 8U,
.next_release = 0U,
.work_ticks = 3U,
.remaining = 0U,
.stack_budget_bytes = 1536U,
.state = TASK_WAITING,
.step = log_step
}
};
const size_t count = sizeof tasks / sizeof tasks[0];
puts("stack budgets: metadata only");
for (size_t i = 0; i < count; ++i) {
printf("%s: %zu bytes\n",
tasks[i].name, tasks[i].stack_budget_bytes);
}
for (hal.tick = 0U; hal.tick < SIM_TICKS; ++hal.tick) {
release_tasks(tasks, count, hal.tick);
Task *selected = pick_ready(tasks, count);
if (selected != NULL) {
run_one_tick(selected, &hal);
} else {
printf("%02u idle motor=%s\n", hal.tick,
hal.motor_enabled ? "on" : "off");
}
}
printf("inspected=%u\n", hal.inspected);
printf("log remaining=%u\n", tasks[2].remaining);
return 0;
}
줄별 해설
先頭の、ではなく、코드의 첫 세 헤더는 참과 거짓, size_t, 표준 출력을 제공한다. SIM_TICKS는 실제 실행 시간이 아니라 시뮬레이션할 논리 구간의 길이다. 따라서 16틱을 실행한다고 해서 16밀리초가 흐르거나 그만큼 잠드는 것은 아니다.
TaskState의 세 값은 스케줄러가 관리하는 상태다. HalSim은 모의 장치의 시각, 모터 출력, 완료한 검사 횟수를 보관한다. 모터 출력의 초기값은 false지만 시각 0에 제어가 실행되므로 첫 출력 행부터 on으로 표시된다. 시각 12부터는 정지 요청이 참이 되어 off로 바뀐다.
typedef struct Task Task는 구조체의 전체 정의보다 먼저 이름을 사용할 수 있게 한다. 이어지는 TaskStep은 태스크 정보와 모의 장치를 받는 함수 포인터 형식이다. 각 태스크가 실행할 함수의 주소를 구조체에 넣으므로 선택 함수는 제어나 검사의 세부 구현을 알 필요가 없다.
period는 준비 시각 사이의 간격이고 next_release는 다음에 준비될 기준 시각이다. work_ticks는 한 번 준비되었을 때 필요한 전체 조각 수이며 remaining은 그중 아직 실행하지 않은 조각 수다. 둘을 구분해야 다른 태스크가 먼저 실행되어도 진행 상태를 보존할 수 있다.
control_step은 정지 요청을 읽고 모터 상태를 바꾼다. 사용하지 않는 task 인자를 void로 변환하는 문장은 공통 함수 형식을 유지하면서 미사용 인자 경고를 피한다. inspect_step은 remaining이 1일 때만 검사 횟수를 증가시킨다. 감소는 함수 호출 뒤에 수행되므로 이 조건은 현재 조각이 마지막 조각이라는 뜻이다.
log_step의 빈 본문은 의도적이다. 기록 장치를 구현하지 않고 실행량 세 조각만 모델링한다. 이 함수가 빨리 반환하더라도 논리 모델에서는 한 틱을 사용한 것으로 센다. 실제 처리 시간을 측정하거나 로그 전송 성능을 비교하는 데 이 결과를 사용할 수는 없다.
release_tasks는 TASK_WAITING인 태스크만 살핀다. 준비 조건을 만족하면 remaining을 전체 실행량으로 채우고 TASK_READY로 바꾼다. 이미 준비된 태스크의 remaining은 건드리지 않는다. 이 구분이 없으면 기다리는 동안 매 틱 진행량이 초기화되어 작업이 끝나지 않을 수 있다.
pick_ready는 실행 후보를 순회하며 가장 높은 우선순위를 고른다. best가 NULL일 때 첫 후보를 저장하고 이후에는 더 큰 값만 받아들인다. 비교 연산이 >이므로 동률이면 앞의 후보를 유지한다. 준비된 태스크가 하나도 없으면 NULL을 반환하며 메인 반복문이 유휴 상태를 출력한다.
run_one_tick은 상태를 실행으로 바꾼 뒤 작업 함수 하나를 호출한다. 실행량이 줄어든 다음 출력하므로 left는 해당 조각을 처리한 뒤 남은 양이다. 남은 양이 0이면 기준 시각에 주기를 더하고 대기 상태로 보낸다. 그렇지 않으면 준비 상태로 돌아간다. 실제 RTOS는 실행 가능한 태스크를 이런 식으로 매 틱 반드시 함수 밖으로 반환시키지 않는다.
초기화 목록은 생략된 의미를 줄이기 위해 필드 이름을 모두 적었다. 세 태스크의 실행량과 주기는 양수이며 시각 계산은 16틱 범위에 머문다. 이 작은 모델은 카운터의 순환이나 입력값 검증을 구현하지 않는다. 실행량을 0으로 바꾸면 unsigned 감소가 의도와 다르게 동작하므로 설정을 확장할 때는 유효성 검사도 추가해야 한다.
메인 반복문의 순서는 준비 조건 확인, 후보 선택, 한 조각 실행이다. 이 순서 덕분에 시각 8에 준비된 제어가 그 틱부터 선택된다. 실행 도중 다음 준비 시각을 넘기는 경우에도 새 작업을 별도로 쌓지는 않는다. 현재 작업을 끝낸 뒤 이미 지난 기준 시각을 확인해 다음 반복을 시작하는 단순 모델이다. 누적된 모든 입력을 처리하는 모델로 해석하면 안 된다.
실행 결과
macOS 또는 Linux에서 다음 명령으로 빌드하고 실행한다. 완성 코드는 C11의 표준 기능만 사용한다. 아래 출력은 주어진 초기값과 선택 규칙으로 결정되는 예상 결과다.
cc -std=c11 -Wall -Wextra -pthread conveyor_tasks.c -o conveyor_tasks
./conveyor_tasks
stack budgets: metadata only
control: 768 bytes
inspect: 1024 bytes
log: 1536 bytes
00 control left=0 motor=on
01 log left=2 motor=on
02 inspect left=1 motor=on
03 inspect left=0 motor=on
04 control left=0 motor=on
05 log left=1 motor=on
06 log left=0 motor=on
07 idle motor=on
08 control left=0 motor=on
09 inspect left=1 motor=on
10 inspect left=0 motor=on
11 log left=2 motor=on
12 control left=0 motor=off
13 log left=1 motor=off
14 inspect left=1 motor=off
15 inspect left=0 motor=off
inspected=3
log remaining=1
시각 7에는 세 태스크가 모두 대기하므로 idle이 출력된다. 실제 FreeRTOS에서는 실행 가능한 일반 태스크가 없으면 커널의 유휴 태스크가 실행된다. 예제의 idle 출력은 그 상황을 나타내지만 유휴 태스크의 기능이나 저전력 진입까지 구현하지는 않는다.
시각 8에는 세 태스크가 함께 준비되지만 제어가 먼저 실행된다. 검사는 시각 9와 10에 실행되고 기록은 시각 11에 첫 조각을 얻는다. 이후에도 제어와 검사가 먼저 선택되어 기록은 마지막 한 조각을 남긴다. inspected=3은 세 번의 검사 완료를 뜻하며, log remaining=1은 관찰 구간이 끝났을 때 두 번째 기록 작업이 아직 진행 중임을 뜻한다.
남은 작업이 있다는 사실만으로 기아나 주기 초과라고 결론 내리지는 않는다. 관찰 구간이 중간에서 끝났기 때문이다. 또한 첫 번째 반복이 잘 실행되었다는 사실만으로 모든 운전 상황의 실행 시간을 보장할 수도 없다. 여기서는 준비 시각, 실제 실행 순서, 미완료 실행량을 서로 구분하는 데 집중한다.
실무에서 자주 틀리는 것
높은 우선순위이면 계속 실행해도 된다고 생각한다
다음은 FreeRTOS 환경을 가정한 부분 코드다. 제어 함수가 즉시 돌아와도 반복문 전체는 계속 준비 상태를 유지한다. 함수가 짧다는 사실과 태스크가 대기한다는 사실은 다르다.
/* Wrong: remains ready continuously. */
for (;;) {
sample_and_update_motor();
}
주기적인 확인이라면 기준 시각을 유지하는 대기를 넣는다. 아래의 period는 유효한 양수여야 한다. 태스크의 실제 실행 시간이 주기를 계속 넘는다면 대기 호출을 넣어도 실행 기회 문제가 해결되지 않으므로 실행량을 다시 검토해야 한다.
/* FreeRTOS fragment, not part of the PC program. */
const TickType_t period = pdMS_TO_TICKS(4U);
configASSERT(period > 0);
TickType_t previous = xTaskGetTickCount();
for (;;) {
sample_and_update_motor();
(void)xTaskDelayUntil(&previous, period);
}
밀리초와 틱을 같은 단위로 취급한다
vTaskDelay의 인자는 틱 수다. 아래 코드는 10밀리초라는 의도로 적었지만 커널의 틱 주파수가 바뀌면 의도와 달라진다. 틱 주기가 10밀리초라면 인자 10은 10밀리초가 아니라 10틱을 요청한다.
/* Wrong if the intention is 10 milliseconds. */
vTaskDelay(10U);
시간 단위를 변환하고 표현 가능한 값인지 확인한다. pdMS_TO_TICKS의 결과가 0이 되면 원하는 대기 시간을 표현하지 못한다. 변환 뒤에도 호출 시점과 틱 경계의 관계, 다시 준비된 뒤의 우선순위 경쟁 때문에 정밀한 벽시계 지연으로 볼 수는 없다.
/* FreeRTOS fragment. */
const TickType_t delay_ticks = pdMS_TO_TICKS(10U);
configASSERT(delay_ticks > 0);
vTaskDelay(delay_ticks);
스택 깊이를 바이트 수라고 적는다
표준 FreeRTOS에서 깊이 1,024는 StackType_t 원소 1,024개다. 다음 선언은 1,024바이트를 의도했다면 단위가 잘못되었다. 크기를 과하게 잡아도 전체 메모리 예산이 흐트러진다.
/* Wrong if 1024 bytes were intended. */
static StackType_t log_stack[1024];
바이트 예산에서 필요한 원소 수를 올림 계산한다. 실제 할당 바이트는 원소 크기의 배수로 늘어날 수 있다. 이 배열을 xTaskCreateStatic에 전달한다면 깊이 인자에도 LOG_STACK_WORDS를 사용하고 별도의 태스크 제어 블록 저장 공간을 제공해야 한다.
/* FreeRTOS fragment. */
enum {
LOG_STACK_BYTES = 1024,
LOG_STACK_WORDS =
(LOG_STACK_BYTES + sizeof(StackType_t) - 1U)
/ sizeof(StackType_t)
};
static StackType_t log_stack[LOG_STACK_WORDS];
매 선택 때 작업의 진행 상태를 초기화한다
협동형 모델에서 태스크의 중간 상태는 다음 호출까지 보존되어야 한다. 선택 직후 남은 실행량을 다시 채우면 기록은 실행할 때마다 첫 조각부터 반복하게 된다. 실제 커널에서 스택과 레지스터를 보존하는 이유도 작업을 이어서 실행하기 위해서다.
/* Wrong: discards progress every time it is selected. */
selected->remaining = selected->work_ticks;
run_one_tick(selected, &hal);
초기화는 새 작업이 준비되는 순간에만 한다. 완성 코드의 release_tasks가 그 역할을 맡으며, 선택된 작업에는 보존된 remaining을 그대로 전달한다. 호출 사이에 상태를 유지하는 필드와 한 번의 호출에서만 쓰는 지역 변수를 구분해야 한다.
/* Correct: release_tasks initializes each new job. */
if (selected != NULL) {
run_one_tick(selected, &hal);
}
한눈에 보기
| 항목 | 핵심 의미 | 확인할 것 |
|---|---|---|
| 준비 상태 | 실행 조건을 만족해 선택 후보가 됨 | 준비 시각과 실제 시작 시각을 구분한다. |
| 선점 | 실행 중인 작업을 보존하고 다른 작업을 실행함 | 설정과 전환이 지연되는 구간을 확인한다. |
| 우선순위 | 준비된 태스크 사이의 실행 순서 | 높은 태스크가 적절히 대기하는지 확인한다. |
| 같은 우선순위 | 별도의 선택 정책이 필요함 | PC 배열 순서와 RTOS 시간 분할을 혼동하지 않는다. |
| 주기 대기 | 기준 시각을 따라 다음 실행 조건을 만듦 | 시간 단위와 실제 실행량을 확인한다. |
| 스택 깊이 | 태스크가 사용할 스택 영역의 원소 수 | 바이트 환산과 제어 정보의 별도 비용을 확인한다. |
| 최소 미사용 스택 | 관찰한 실행 경로에서 남았던 최소 공간 | 시험하지 않은 경로와 계측 한계를 고려한다. |
| PC 모델 | 틱 경계의 선택 순서를 재현함 | 실제 선점, 전용 스택, 처리 시간은 검증하지 않는다. |
연습 문제
- 시각 8에 준비된 태스크를 모두 적고 시각 8부터 11까지의 실행 순서를 설명하라. 검사 시작이 준비 시각보다 늦어진 이유도 적어라.
- control의 work_ticks만 4로 바꾸고 나머지 설정을 유지한다고 하자. 시각 0부터 15까지 어떤 태스크가 실행되는지 예측하고 낮은 우선순위 태스크가 기다리는 이유를 설명하라.
- StackType_t가 4바이트인 포트에서 스택 깊이가 320이고 최소 미사용 스택 측정값이 72라고 하자. 할당한 스택 바이트 수와 측정상 남았던 최소 바이트 수를 계산하라. 이 수치만으로 크기를 줄여도 되는지도 설명하라.
- SIM_TICKS를 18로 늘린다면 추가되는 두 실행 행과 최종 log remaining 값을 적어라. 기록의 우선순위만 올리는 방법 외에 기록 완료를 앞당길 수 있는 설계 변경을 하나 제안하라.
정답과 해설
- control, inspect, log가 모두 준비된다. 실행 순서는 control, inspect, inspect, log다. control은 우선순위 3으로 가장 높고 한 조각만 필요하다. 그 뒤 우선순위 2인 inspect가 두 조각을 실행한다. 검사는 시각 8에 준비되지만 높은 우선순위의 제어 때문에 시각 9에 시작한다. 대기 상태에서 벗어났다는 사실이 즉시 실행된다는 뜻은 아니다.
- 16틱 모두 control이 실행된다. 첫 작업은 시각 0부터 3까지 네 조각을 사용하고 완료하면서 next_release가 4가 된다. 시각 4에는 바로 다음 작업이 준비되어 다시 가장 높은 후보가 된다. 같은 상황이 반복되므로 inspect와 log는 준비되어도 선택되지 않는다. 제어 작업 하나가 모든 실행 기회를 사용한 결과이며, 낮은 태스크의 우선순위 숫자만 조금 바꾸어서는 해결되지 않는다.
- 할당한 스택 영역은 320 × 4 = 1,280바이트이며, 측정상 최소 미사용 공간은 72 × 4 = 288바이트다. 이 값은 시험한 경로에서의 관찰 결과다. 아직 거치지 않은 오류 경로, 라이브러리 호출, 빌드 변경 등을 검토하지 않은 상태에서 288바이트를 모두 제거할 수는 없다. 관련 경로를 실행하고 분석 결과와 필요한 여유를 함께 판단해야 한다.
- 시각 16의 행은 “16 control left=0 motor=off”다. 시각 17의 행은 태스크 이름 뒤의 정렬 공백을 포함해 “17 log left=0 motor=off”이며 최종 값은 log remaining=0이다. 검사는 여전히 세 번 완료된 상태다. 기록 한 회의 처리량을 줄여 필요한 실행 조각 수를 낮추는 변경을 검토할 수 있다. 다만 실제 장치에서는 무엇을 줄일 수 있는지 측정과 요구사항으로 확인해야 하며, PC 모델의 숫자만 바꾸는 것으로 실제 개선이 입증되지는 않는다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.