본문으로 건너뛰기
Practical RTOS Internals · 46/53

FreeRTOS 소스 분석 — tasks.c·queue.c·port.c 추적

· Hawk · 7분 읽기

#한 줄 요약

“FreeRTOS 커널은 세 파일만 이해하면 전체가 보입니다.”tasks.c가 스케줄러, queue.c가 IPC, port.c가 아키텍처 경계입니다.

#어떤 문제를 푸는가

FreeRTOS는 1만 줄 안팎의 작은 커널입니다. 그래도 처음 소스를 열면 어디부터 읽어야 할지 막막합니다. 파일 수십 개, 매크로 수백 개, #if 분기가 함수 한 줄 단위로 박혀 있습니다.

이 글의 목표는 세 핵심 파일만 골라 읽는 길을 만드는 것입니다. tasks.c에서 스케줄러의 자료구조와 진입점을 따라가고, queue.c에서 큐·세마포어·뮤텍스가 같은 구현을 공유하는 모습을 보고, port.c에서 아키텍처에 의존하는 경계가 어디까지인지 확인합니다. 이 흐름을 한 번 잡아 두면 SMP, MPU, tickless 같은 확장 옵션도 같은 지도 위에서 자연스럽게 읽힙니다.

저장소는 github.com/FreeRTOS/FreeRTOS-Kernel입니다. 커널만 분리되어 있어 빌드 시스템과 BSP에 끌려다니지 않고 본체만 읽기 좋습니다.

#저장소 구조와 진입점

FreeRTOS-Kernel/
├── include/ # public API
│ ├── FreeRTOS.h # 모든 컴파일 단위의 시작
│ ├── task.h
│ ├── queue.h
│ └── semphr.h
├── tasks.c # 스케줄러 본체 (~5000 lines)
├── queue.c # 큐·세마포어·뮤텍스 통합
├── timers.c # software timer
├── event_groups.c
├── stream_buffer.c
├── list.c # 양방향 list 자료구조
├── portable/ # 아키텍처별 port
│ ├── GCC/ARM_CM4F/
│ ├── GCC/ARM_CM33_NTZ/
│ ├── GCC/RISC-V/
│ └── MemMang/ # heap_1 ~ heap_5
└── License/

읽는 순서는 FreeRTOS.hlist.ctasks.cqueue.cportable/<your-arch>/port.c가 자연스럽습니다. list.c를 먼저 보는 이유는 ready list와 wait list의 모든 연결이 같은 자료구조 위에 얹혀 있기 때문입니다.

#tasks.c — 스케줄러 본체

tasks.c의 첫 줄에 가까운 곳에 모든 것의 출발점이 있습니다.

PRIVILEGED_DATA TCB_t * volatile pxCurrentTCB = NULL;

지금 어느 CPU에서 어느 task가 돌고 있는지를 가리키는 단일 포인터입니다. 컨텍스트 스위치는 결국 이 포인터를 바꾸고 그 안의 pxTopOfStack을 새 PSP로 옮기는 일입니다.

TCB는 task의 모든 상태를 담는 구조체입니다.

typedef struct tskTaskControlBlock {
volatile StackType_t * pxTopOfStack; /* MUST be first */
#if (portUSING_MPU_WRAPPERS == 1)
xMPU_SETTINGS xMPUSettings;
#endif
ListItem_t xStateListItem; /* ready/delay/suspend */
ListItem_t xEventListItem; /* queue/semaphore wait */
UBaseType_t uxPriority;
StackType_t *pxStack;
char pcTaskName[configMAX_TASK_NAME_LEN];
#if (configUSE_MUTEXES == 1)
UBaseType_t uxBasePriority; /* PI base */
UBaseType_t uxMutexesHeld;
#endif
/* ... 다른 필드 */
} tskTCB;

pxTopOfStack반드시 첫 필드여야 합니다. 컨텍스트 스위치 어셈블리가 TCB 포인터를 받으면 offset 0에서 SP를 꺼내고 새 SP를 다시 그 자리에 저장합니다. 이 필드를 옮기면 어셈블리와 C 구조가 어긋나면서 첫 스위치 직후 모든 task가 깨집니다.

ready list는 priority별로 분리되어 있습니다.

PRIVILEGED_DATA static List_t pxReadyTasksLists[configMAX_PRIORITIES];

각 priority가 FIFO list입니다. 같은 priority 안에서 round-robin이 자연스럽게 돌아가는 이유입니다. 최상위 priority를 찾는 일은 별도의 비트맵으로 가속됩니다.

#if (configUSE_PORT_OPTIMISED_TASK_SELECTION == 1)
static volatile UBaseType_t uxTopReadyPriority;
#endif
#define portRECORD_READY_PRIORITY(uxPriority, uxTopReadyPriority) \
(uxTopReadyPriority) |= (1U << (uxPriority))
#define portGET_HIGHEST_PRIORITY(uxTopPriority, uxReadyPriorities) \
uxTopPriority = (31U - __CLZ(uxReadyPriorities))

Cortex-M의 CLZ 한 명령으로 최상위 ready priority가 한 사이클에 나옵니다. 32개 priority 안에서는 O(1) 결정입니다.

#xTaskCreate부터 PendSV까지

새 task 하나가 만들어져서 실제로 실행되기까지의 흐름을 함수 이름으로만 추리면 다음과 같습니다.

BaseType_t xTaskCreate(TaskFunction_t pxTaskCode,
const char *pcName,
configSTACK_DEPTH_TYPE usStackDepth,
void *pvParameters,
UBaseType_t uxPriority,
TaskHandle_t *pxCreatedTask)
{
StackType_t *pxStack = pvPortMalloc(usStackDepth * sizeof(StackType_t));
TCB_t *pxNewTCB = pvPortMalloc(sizeof(TCB_t));
prvInitialiseNewTask(pxTaskCode, pcName, usStackDepth, pvParameters,
uxPriority, pxCreatedTask, pxNewTCB, NULL);
prvAddNewTaskToReadyList(pxNewTCB);
return pdPASS;
}

prvInitialiseNewTask 안에서 initial stack frame이 만들어집니다. 이 부분이 port 계층으로 위임됩니다.

portable/GCC/ARM_CM4F/port.c
StackType_t *pxPortInitialiseStack(StackType_t *pxTopOfStack,
TaskFunction_t pxCode,
void *pvParameters)
{
pxTopOfStack--; *pxTopOfStack = portINITIAL_XPSR;
pxTopOfStack--; *pxTopOfStack = (StackType_t)pxCode;
pxTopOfStack--; *pxTopOfStack = (StackType_t)prvTaskExitError;
pxTopOfStack -= 5; /* R12, R3, R2, R1 */
*pxTopOfStack = (StackType_t)pvParameters; /* R0 */
pxTopOfStack -= 8; /* R4-R11 */
return pxTopOfStack;
}

이렇게 가짜 컨텍스트 스위치가 stack 위에 한 번 펼쳐져 있어야 첫 PendSV가 pop할 때 자연스럽게 task의 진입점으로 점프합니다.

스케줄러는 vTaskSwitchContext에서 다음 실행 대상을 결정합니다.

void vTaskSwitchContext(void)
{
if (uxSchedulerSuspended != pdFALSE) {
xYieldPending = pdTRUE;
return;
}
xYieldPending = pdFALSE;
taskSELECT_HIGHEST_PRIORITY_TASK();
}
#define taskSELECT_HIGHEST_PRIORITY_TASK() \
UBaseType_t uxTopPriority; \
portGET_HIGHEST_PRIORITY(uxTopPriority, uxTopReadyPriority); \
listGET_OWNER_OF_NEXT_ENTRY(pxCurrentTCB, \
&(pxReadyTasksLists[uxTopPriority]))

listGET_OWNER_OF_NEXT_ENTRY가 같은 priority list 안에서 다음 항목을 가리키므로, 같은 priority의 task들은 자연스럽게 round-robin으로 순환합니다.

실제 레지스터 교체는 PendSV 핸들러가 합니다.

PendSV_Handler:
mrs r0, psp
isb
ldr r3, =pxCurrentTCB
ldr r2, [r3]
tst lr, #0x10
it eq
vstmdbeq r0!, {s16-s31}
stmdb r0!, {r4-r11, lr}
str r0, [r2] ; save SP into TCB
push {r3}
cpsid f
bl vTaskSwitchContext
cpsie f
pop {r3}
ldr r1, [r3] ; new pxCurrentTCB
ldr r0, [r1] ; new SP
ldmia r0!, {r4-r11, lr}
tst lr, #0x10
it eq
vldmiaeq r0!, {s16-s31}
msr psp, r0
isb
bx lr ; HW pops {R0-R3, R12, LR, PC, xPSR}

Cortex-M4 168 MHz에서 한 번 스위치에 30~50 사이클입니다. 300 ns 안쪽으로 마무리됩니다.

#queue.c — 하나의 구현으로 세 가지 IPC

queue.c를 처음 보면 놀라는 부분이 있습니다. 큐, 세마포어, 뮤텍스가 같은 자료구조를 공유합니다.

typedef struct QueueDefinition {
int8_t *pcHead;
int8_t *pcWriteTo;
union {
int8_t *pcReadFrom; /* 큐 모드 */
UBaseType_t uxRecursiveCallCount; /* recursive mutex */
} u;
List_t xTasksWaitingToSend;
List_t xTasksWaitingToReceive;
volatile UBaseType_t uxMessagesWaiting;
UBaseType_t uxLength;
UBaseType_t uxItemSize;
volatile int8_t cRxLock;
volatile int8_t cTxLock;
UBaseType_t uxQueueType;
} Queue_t;
typedef Queue_t Semaphore_t;
typedef Queue_t Mutex_t;

세마포어는 길이 1, item 크기 0인 큐이고, 뮤텍스는 추가로 owner와 recursion count를 들고 다니는 큐입니다. 한 구현을 셋이 공유하므로 버그 수정과 검증이 한 곳에 집중됩니다.

송신 경로는 3-07: Queue 구현에서 더 자세히 다루지만, 골격만 보면 critical section과 event list 패턴이 그대로 드러납니다.

BaseType_t xQueueGenericSend(QueueHandle_t xQueue,
const void *pvItemToQueue,
TickType_t xTicksToWait,
BaseType_t xCopyPosition)
{
Queue_t *pxQueue = xQueue;
for (;;) {
taskENTER_CRITICAL();
{
if (pxQueue->uxMessagesWaiting < pxQueue->uxLength) {
prvCopyDataToQueue(pxQueue, pvItemToQueue, xCopyPosition);
if (listLIST_IS_EMPTY(&pxQueue->xTasksWaitingToReceive) == pdFALSE) {
if (xTaskRemoveFromEventList(&pxQueue->xTasksWaitingToReceive) != pdFALSE) {
queueYIELD_IF_USING_PREEMPTION();
}
}
taskEXIT_CRITICAL();
return pdPASS;
}
if (xTicksToWait == 0) {
taskEXIT_CRITICAL();
return errQUEUE_FULL;
}
vTaskPlaceOnEventList(&pxQueue->xTasksWaitingToSend, xTicksToWait);
}
taskEXIT_CRITICAL();
portYIELD_WITHIN_API();
}
}

vTaskPlaceOnEventList는 현재 task를 event list에 끼우고 ready list에서 빼는 작업입니다. 깨우는 쪽은 xTaskRemoveFromEventList로 빼서 ready로 돌립니다. 큐, 세마포어, 뮤텍스가 모두 이 한 쌍의 함수에 의존합니다.

#port.c — 아키텍처 경계

portable/<toolchain>/<arch>/port.c가 아키텍처에 의존하는 모든 동작을 떠맡습니다. Cortex-M4F를 예로 보면, 스케줄러의 시작 자체가 SVC 한 줄로 압축됩니다.

BaseType_t xPortStartScheduler(void)
{
portNVIC_SHPR3_REG |= portNVIC_PENDSV_PRI;
portNVIC_SHPR3_REG |= portNVIC_SYSTICK_PRI;
vPortSetupTimerInterrupt(); /* SysTick */
vPortEnableVFP();
*(portFPCCR) |= portASPEN_AND_LSPEN_BITS;
__asm volatile ("svc 0"); /* 첫 task로 진입 */
return 0;
}

svc 0SVC_Handler로 떨어지면 그 안에서 pxCurrentTCB가 가리키는 task의 stack을 PSP로 옮기고 bx lr로 빠져나오면서 첫 task가 시작됩니다.

매 tick의 진입점은 SysTick 핸들러입니다.

void xPortSysTickHandler(void)
{
portDISABLE_INTERRUPTS();
{
if (xTaskIncrementTick() != pdFALSE) {
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
}
}
portENABLE_INTERRUPTS();
}

xTaskIncrementTick이 time slice 만료와 delay 카운트다운을 모두 처리하고, 더 높은 priority의 task가 깨어났다면 PendSV bit를 set해서 핸들러 복귀 직후에 컨텍스트 스위치가 일어나도록 합니다.

critical section은 BASEPRI를 사용합니다.

#define portDISABLE_INTERRUPTS() \
__asm volatile ( \
"msr basepri, %0\n" \
"isb\n" "dsb\n" \
: : "r"(configMAX_SYSCALL_INTERRUPT_PRIORITY) \
)
#define portENABLE_INTERRUPTS() __set_BASEPRI(0)

configMAX_SYSCALL_INTERRUPT_PRIORITY보다 낮은 priority의 IRQ만 막힙니다. 그보다 높은 hard-RT IRQ는 critical section 안에서도 그대로 통과하므로, 안전 회로처럼 응답 시간이 절대적으로 중요한 IRQ는 FreeRTOS의 영향을 받지 않게 설계할 수 있습니다.

#흥미로운 세 곳

소스를 끝까지 따라가 보면 의외로 인상적인 코드가 모입니다. 세 곳을 꼽으면 다음과 같습니다.

첫째, uxTopReadyPriority 비트맵과 CLZ 결합입니다. 평범한 정수 한 워드가 32 priority에 대한 O(1) lookup을 만들어 냅니다. 비트맵의 단순함과 명령어 한 줄이 합쳐졌습니다.

둘째, Queue_t가 세 IPC를 동시에 표현하는 union 설계입니다. 큐의 read 포인터와 뮤텍스의 recursion count가 같은 union 자리를 공유합니다. 코드가 늘지 않은 채 기능이 셋으로 갈라집니다.

셋째, 첫 task의 진입을 위한 가짜 stack frame입니다. pxPortInitialiseStack이 만든 모양은 PendSV가 어떻게 pop할지를 정확히 모사합니다. 실행과 자료구조가 서로를 거울처럼 비추는 부분입니다.

#SMP — FreeRTOS 11

FreeRTOS 11에서 SMP가 공식화되면서 pxCurrentTCB배열로 바뀌었습니다.

TCB_t * volatile pxCurrentTCBs[configNUMBER_OF_CORES];
#define pxCurrentTCB pxCurrentTCBs[xPortGetCoreID()]

매크로 한 줄로 단일 코어 코드가 그대로 동작합니다. ready list는 여전히 하나이고, task/ISR 두 단계 spinlock으로 보호됩니다. 구조의 자세한 비교는 4-07: SMP RTOS에서 다룹니다.

#빌드 — CMake 모듈로 묶기

최근 FreeRTOS는 CMake 통합이 깔끔해졌습니다.

add_subdirectory(FreeRTOS-Kernel)
target_link_libraries(my_firmware PRIVATE
freertos_kernel
freertos_config # FreeRTOSConfig.h 가진 INTERFACE 타깃
)
target_include_directories(freertos_config INTERFACE
${CMAKE_SOURCE_DIR}/config)

freertos_config는 사용자 측에서 정의하는 INTERFACE 타깃입니다. 여기에 FreeRTOSConfig.h의 위치를 알려 주면 커널이 그 헤더를 끌어다 씁니다.

#자주 보는 함정

경고 — pxTopOfStack을 첫 필드에서 옮김

TCB 구조체 안에서 pxTopOfStack이 첫 필드가 아니면 컨텍스트 스위치 어셈블리가 엉뚱한 주소를 SP로 사용합니다. 첫 PendSV 직후 hard fault로 죽습니다.

경고 — Cortex-M3 binary에 Cortex-M4F port 링크

portable/GCC/ARM_CM3ARM_CM4F는 FPU 처리와 BASEPRI 사용이 다릅니다. 디렉터리 한 단계 잘못 잡으면 빌드는 통과해도 런타임에 무한 fault가 납니다.

경고 — critical section 안에서 긴 작업

taskENTER_CRITICAL이 BASEPRI로 IRQ를 막는 동안은 SysTick도 멈춥니다. 안에서 hash 계산이나 printf를 호출하면 수 ms 동안 모든 RT IRQ가 막힙니다. critical은 수 µs 안에 끝나는 작업에만 씁니다.

경고 — heap_1에서 vTaskDelete 반복

heap_1은 free가 동작하지 않으므로 vTaskDelete를 호출해도 메모리가 돌아오지 않습니다. 동적 생성/삭제가 있는 시스템은 heap_4 이상으로 옮겨야 합니다.

#정리

  • FreeRTOS 커널은 tasks.c + queue.c + port.c 세 파일을 중심으로 읽으면 전체 구조가 잡힙니다.
  • pxCurrentTCB는 시스템 전체에서 현재 실행 중인 task를 가리키는 단일 포인터이며, 컨텍스트 스위치의 회전축입니다.
  • ready list는 priority별 FIFO이며, uxTopReadyPriority 비트맵과 CLZ로 최상위 priority를 한 사이클에 찾습니다.
  • 큐·세마포어·뮤텍스는 Queue_t 하나를 공유하므로 검증과 버그 수정이 한 곳에 모입니다.
  • port 계층은 pxPortInitialiseStack, xPortStartScheduler, xPortSysTickHandler, PendSV 핸들러, BASEPRI 매크로로 좁혀집니다.
  • SMP는 pxCurrentTCB를 배열로 바꾸고 spinlock을 추가한 확장이며, 단일 코어 구조와 같은 지도 위에서 읽힙니다.
  • critical section은 BASEPRI 기반이므로 configMAX_SYSCALL_INTERRUPT_PRIORITY보다 높은 IRQ는 그대로 통과합니다.

다음 편은 5-02 Zephyr 커널 분석에서 devicetree와 driver model 위에서 동작하는 더 큰 RTOS를 봅니다.

#관련 항목

Practical RTOS Internals · 47 of 53

  1. 1Practical RTOS Internals — 실시간 커널 내부 분석 시리즈 소개
  2. 2RTOS가 필요한 이유 — 일반 OS와의 결정적 차이
  3. 3Task와 Thread 개념 — TCB·상태 머신·생명 주기 분석
  4. 4실시간 스케줄링 알고리즘 비교 — RR·Priority·EDF·RMS
  5. 5Preemption과 Cooperation — 강제 전환 vs 자발 양보
  6. 6인터럽트와 RTOS — ISR Context·Deferred Processing·FromISR API
  7. 7동기화 기초 분석 — Critical Section·Mutual Exclusion·Race Condition
  8. 8Semaphore 개념 분해 — Counting·Binary·P/V 연산
  9. 9Mutex 개념 분해 — Ownership·Recursive·Priority Inheritance
  10. 10큐와 메시지 패싱 — Producer-Consumer·Ring Buffer·전달 의미
  11. 11실시간성 분석 — Latency·Jitter·Deadline·WCET·RMA
  12. 12Ready List 자료구조 분석 — Linked List·Bitmap·O(1) Scheduler
  13. 13Blocked List 자료구조 — Timeout 정렬·Delta List·Two-List Scheme
  14. 14Scheduler 알고리즘 구현 추적 — Next-Task Selection 로직
  15. 15Context Switch 원리 분석 — 레지스터 저장·복원·Stack Frame
  16. 16ARM Cortex-M Context Switch — PendSV·MSP/PSP 어셈블리 추적
  17. 17ARM Cortex-A Context Switch — Mode 전환·SVC·Banked Registers
  18. 18RISC-V Context Switch 분석 — ECALL·mret·CSR
  19. 19RTOS Tick과 타이머 — SysTick·Generic Timer·configTICK_RATE_HZ
  20. 20Tickless 모드 구현 — Idle Tick Suppression·Sleep·Wake 보정
  21. 21Scheduler Latency 측정 기법 — GPIO Toggle·DWT·ftrace·cyclictest
  22. 22RTOS Tracing과 Observability — Tracealyzer·SystemView·ITM/ETM
  23. 23Critical Section 구현 비교 — IRQ Disable·BASEPRI·Spinlock
  24. 24Semaphore 내부 구현 추적 — Counter·Wait List·ISR-Safe Variant
  25. 25Mutex 내부 구현 추적 — Owner·Recursion Count·ISR 금지
  26. 26Priority Inversion 문제 — Mars Pathfinder 사례·Bounded vs Unbounded
  27. 27Priority Inheritance 구현 — Inherit·Disinherit·Chain
  28. 28Priority Ceiling Protocol — Immediate vs Original 비교
  29. 29Queue 내부 구현 추적 — Ring Buffer·2 Wait Lists·Atomic Send/Receive
  30. 30Event Group 분석 — Bit Flag·AND/OR Wait·Sync Barrier
  31. 31ISR-Safe API 설계 — FromISR 패턴·Higher Priority Wake·Deferred Work
  32. 32Deadlock 분석 — 4 조건·Wait-for Graph·Lock Ordering·Timeout
  33. 33Stream Buffer와 Message Buffer — FreeRTOS 10의 Lock-Free SPSC
  34. 34실시간 메모리 요구사항 — Determinism·Fragmentation·WCET
  35. 35FreeRTOS Heap_1~5 분석 — 5종 Allocator의 구조와 트레이드오프
  36. 36TLSF Allocator 분석 — Two-Level Segregated Fit O(1)
  37. 37Static Allocation — 컴파일 타임으로 동적 위험 제거하기
  38. 38Memory Pool — Fixed-Size Block Allocator의 단순함과 강력함
  39. 39Stack Overflow 탐지 — Canary·MPU·Watermark 3중 방어
  40. 40SMP RTOS 설계 — Ready List·Affinity·IPI·Load Balancing
  41. 41SMP Spinlock 구현 — LDREX/STREX·Ticket Lock·MCS·WFE/SEV
  42. 42Software Timer 분석 — Daemon Task·자료구조·ISR-Safe API
  43. 43RTOS System Call — SVC·ECALL·User/Kernel 분리·FreeRTOS-MPU
  44. 44TrustZone과 TF-M — Secure/Non-Secure·NSC Veneer·PSA
  45. 45AMP와 OpenAMP — Heterogeneous SoC·RPMsg·remoteproc
  46. 46C++ in RTOS — RAII·std::thread·ETL·Coroutine
  47. 47FreeRTOS 소스 분석 — tasks.c·queue.c·port.c 추적
  48. 48Zephyr 커널 분석 — k_thread·k_sem·Driver Model
  49. 49RT-Thread 분석 — Object 모델·Components·Smart·Studio
  50. 50RTOS 포팅 가이드 — 새 아키텍처에 옮기는 절차
  51. 51RTOS 선택 가이드 — Footprint·License·Certification·Ecosystem
  52. 52Apache NuttX 분석 — POSIX·PX4·NASA Ingenuity
  53. 53PREEMPT_RT Linux — Mainline 6.12·Xenomai 4·EVL