Context Switch 원리 분석 — 레지스터 저장·복원·Stack Frame
#한 줄 요약
“Context switch = CPU의 모든 가시 상태를 task별 스택에 복제하는 일” — 무엇이 가시 상태인지 정확히 아는 게 출발점입니다.
#Context란 무엇인가
CPU 안에는 현재 실행 중인 코드만의 상태가 잔뜩 들어 있습니다. 다른 task로 넘어가려면 이걸 한 톨도 빠짐없이 보관해야 다음에 깨어났을 때 정확히 그 자리에서 이어갈 수 있습니다. 이 상태 전체를 묶어 context라고 부릅니다.
아키텍처별로 무엇이 포함되는지 봅니다.
| 항목 | Cortex-M | Cortex-A | RISC-V |
|---|---|---|---|
| 범용 레지스터 | R0-R12 | R0-R12 | x0-x31 (32개) |
| Stack Pointer | SP (R13) | mode별 SP_user / _irq / _svc | x2 (sp) |
| Link Register | LR (R14) | LR (R14) | x1 (ra) |
| Program Counter | PC (R15) | PC (R15) | pc |
| Status | xPSR | CPSR | mstatus |
| FPU | S0-S31, FPSCR | D0-D31, FPSCR | f0-f31 |
이걸 모두 지금 task의 스택에 push하고, 다음 task의 스택에서 pop 하면 context switch가 완성됩니다.
#언제 발생하는가
Context switch가 트리거되는 시점은 크게 세 가지입니다.
- Tick 인터럽트 — time slice 만료 또는 더 우선순위 높은 task가 ready
- Blocking 시스템 호출 —
vTaskDelay,xSemaphoreTake등이 block을 유발 - ISR 종료 시점 — ISR이 더 우선순위 높은 task를 wake 시켰을 때
세 경우 모두 결국 스케줄러가 호출되고, 스케줄러가 현재와 다른 task를 선택하면 switch가 일어납니다.
#Caller-saved vs Callee-saved
여기서 잠깐 짚어야 할 개념이 있습니다. ARM AAPCS 같은 호출 규약은 레지스터를 두 부류로 나눕니다.
| 의미 | ARM 레지스터 | |
|---|---|---|
| Caller-saved | 호출자가 필요하면 스스로 보존 | R0-R3, R12 |
| Callee-saved | 함수가 수정하면 복원 책임 | R4-R11 |
평범한 함수 호출 (BL) 시점에는 caller-saved만 위태합니다. 그래서 일반 함수는 callee-saved 레지스터를 건드릴 필요가 없으면 아무것도 저장하지 않아도 됩니다.
그러나 context switch는 다릅니다. 다른 task가 어떤 레지스터에 무엇이 들어 있다고 가정하는지 알 수 없으므로 모든 레지스터를 보존해야 합니다. 이 차이가 context save 코드를 길게 만듭니다.
#Cortex-M의 두 단계 save — HW + SW
ARM Cortex-M에는 영리한 최적화가 있습니다. 예외(인터럽트 포함)가 들어올 때 HW가 자동으로 8개 레지스터를 push합니다. caller-saved에 해당하는 것들입니다.
SP (낮은 주소) ↓[R0, R1, R2, R3, R12, LR, return PC, xPSR] ← HW가 자동 push (8 word) ↑ 새 SP
[R4, R5, R6, R7, R8, R9, R10, R11] ← SW가 마저 push (8 word)이렇게 하면 ISR 안에서 caller-saved를 자유롭게 써도 HW가 알아서 복원해 줍니다. RTOS port code는 callee-saved (R4-R11) 8개만 더 push하면 context 전체가 보존됩니다. 합쳐서 16 word, 64 byte입니다.
이 구조를 흔히 “half-saved frame” 이라고 부릅니다. 절반은 HW, 절반은 SW.
#전체 흐름
context switch 한 번이 진행되는 순서를 풀어보면 이렇게 됩니다.
1. HW 예외 진입 → R0-R3, R12, LR, PC, xPSR 자동 push (current task SP에)2. SW 핸들러 → R4-R11 push, 새 SP 값 확보3. TCB 갱신 → 현재 task의 pxTopOfStack = 새 SP4. 스케줄러 호출 → pxCurrentTCB를 다음 task로 교체5. 새 SP 로드 → 다음 task의 pxTopOfStack6. SW 핸들러 → R4-R11 pop7. HW 예외 종료 → R0-R3, R12, LR, PC, xPSR 자동 pop, PC가 새 task의 코드로3번에서 6번이 스택을 갈아끼우는 한순간입니다. 그 외에는 전부 push/pop의 대칭입니다.
#TCB가 들고 있는 것은 SP 하나뿐
typedef struct { StackType_t *pxTopOfStack; // ← context switch가 갱신하는 유일한 값 /* ... 그 외 priority, name, list item 등 */} TCB_t;여기서 본질적인 통찰이 있습니다. context의 모든 정보는 스택 안에 있고, TCB는 그 스택의 꼭대기 주소 하나만 들고 있습니다. 그래서 context switch가 갱신하는 메타데이터는 결국 4 byte 포인터 한 개뿐입니다.
#첫 시작 — 가짜 stack frame
task가 처음 schedule 될 때는 복원할 stack frame이 아직 없습니다. 그래서 task 생성 시 RTOS가 마치 이전에 한 번 빠져나간 듯 가짜 frame을 미리 쌓아 둡니다.
FreeRTOS의 pxPortInitialiseStack이 만드는 초기 stack은 대략 이렇습니다.
초기 task stack (높은 주소 → 낮은 주소)
[xPSR = 0x01000000] ← Thumb mode[PC = task_function] ← 첫 schedule 시 jump 할 주소[LR = task_exit_error] ← task가 return 하면 호출 (보통 panic)[R12 = 0xCCCCCCCC] ← 디버그용 패턴[R3 = 0xBBBBBBBB][R2 = 0xAAAAAAAA][R1 = 0x99999999][R0 = arg_ptr] ← task 함수에 전달할 인자[R11..R4 = 0x...] ← 임의 패턴 ↑ pxTopOfStack첫 schedule이 일어나면 R4-R11 pop, exception return으로 R0-R3 / R12 / LR / PC / xPSR pop, PC가 task_function을 가리키므로 자연스럽게 task 코드로 점프합니다. 마치 이전에 그 자리에서 빠져나간 것처럼 보이는 작은 트릭입니다.
#Cooperative vs Preemptive
context switch가 일어나는 주도권에 따라 두 모드로 나뉩니다.
| Cooperative | Preemptive | |
|---|---|---|
| 트리거 | task가 명시적으로 yield | tick / ISR이 강제 |
| 응답성 | task 의지에 의존 | RTOS가 보장 |
| 구현 단순도 | 매우 단순 | 복잡 |
| 사용 | 옛 Mac OS, Win 3.1 | 모든 현대 RTOS |
현대 임베디드 RTOS는 거의 모두 preemptive이며, cooperative만 쓰는 설정은 특수한 경우입니다 (configUSE_PREEMPTION = 0).
#비용 — 얼마나 걸리는가
Cortex-M4 @ 168 MHz 기준으로 한 번의 context switch가 쓰는 시간을 분해해 보면 이렇습니다.
| 단계 | cycle | 시간 |
|---|---|---|
| HW exception entry (push 8) | 12 | 72 ns |
| SW push R4-R11 | ~10 | 60 ns |
| 스케줄러 결정 | 20~100 | 0.1~0.6 µs |
| SW pop R4-R11 | ~10 | 60 ns |
| HW exception exit (pop 8) | 12 | 72 ns |
| 합계 | ~70 | ~0.4 µs |
ms 단위로 도는 task switch에서 0.4 µs는 무시할 만한 비용입니다. 다만 μs 단위 ISR 응답이 필요한 시스템에서는 이 비용도 무시할 수 없습니다.
#Stack size — 얼마나 잡아야 할까
context 자체는 17 word (68 byte)면 끝나지만, 실제 task는 그것보다 훨씬 많은 스택을 씁니다.
| 항목 | byte |
|---|---|
| Context (full save) | 17 × 4 = 68 |
| FPU full save | 33 × 4 = 132 |
| Nested IRQ × N | 8 × N |
| Local 변수 | 함수 깊이 × 평균 |
| printf 등 라이브러리 | 200+ |
권장 시작값은 256 word (1 KB) 입니다. printf나 snprintf 한 번이 200 byte 가까이 쓰는 경우가 흔하므로 여유를 둬야 합니다. uxTaskGetStackHighWaterMark()로 측정 후 조정하는 게 안전합니다.
#FPU — Lazy stacking
FPU는 S0-S31 + FPSCR 도합 33 word를 차지합니다. 매 switch마다 이걸 다 push하면 비용이 두 배가 됩니다.
Cortex-M4F / M7는 lazy stacking으로 이 비용을 회피합니다. FPU를 실제로 사용한 task만 FP regs를 push하고, FPU를 안 쓴 task는 FP context 자체를 건너뜁니다. CONTROL register의 FPCA bit로 사용 여부를 추적합니다.
#RISC-V는 어떻게 다른가
RISC-V에는 ARM의 HW auto-push가 없습니다. 예외가 들어와도 PC와 status만 잠깐 보관할 뿐, 모든 레지스터는 SW가 직접 push 해야 합니다.
csrrw t0, mscratch, t0 # 임시 레지스터 swapsw x1, 0(t0)sw x2, 4(t0)...sw x31, 124(t0)장점은 HW가 단순하고 ISR이 얼마나 push 할지 선택 가능하다는 점입니다. 단점은 latency 자체는 ARM보다 살짝 느림입니다. ARMv8-M Mainline의 “secure / non-secure stacking”이 RISC-V 방식과 비슷한 면이 있습니다.
#Cortex-A·Linux — 비교군
Cortex-A처럼 MMU를 가진 시스템에서는 context switch에 추가 비용이 붙습니다.
- TLB invalidate (process 전환 시)
- L1 cache flush 가능성
- ASID 갱신
수십 µs 단위로 늘어납니다. RTOS가 MMU 없는 Cortex-M에 머무는 한 이런 비용은 없습니다. 이게 임베디드 RTOS의 latency 우위 비결 중 하나입니다.
#자주 하는 실수
⚠️ Stack을 너무 작게
68 byte context + nested IRQ + local + printf로 256 byte도 부족할 수 있습니다. stack overflow 검출용 canary나 watermark 도구를 항상 켜둡니다.
⚠️ FPU enable 후 lazy stacking을 인지 못 함
ISR 안에서 FP 명령을 쓰면 task가 보유하던 FP context가 깨질 수 있습니다. 보통 FreeRTOS port가 자동 처리하지만, ISR에서 FP 사용은 일단 피하는 게 안전합니다.
⚠️ MSP / PSP 혼동
ISR은 MSP, task는 PSP를 씁니다. 이 구분을 잊고 ISR 안에서 task stack을 가정하면 전혀 다른 메모리를 건드리게 됩니다. Cortex-M 구체 사항은 다음 편에서 다룹니다.
#정리
- Context switch는 CPU의 모든 가시 레지스터를 task별 스택에 복제하는 일입니다.
- Cortex-M은 HW가 절반, SW가 절반 처리합니다. 합쳐서 16 word, 64 byte의 frame이 만들어집니다.
- TCB가 들고 있는 것은 pxTopOfStack 포인터 하나가 전부이고, 실제 context는 모두 스택 안에 있습니다.
- 첫 schedule을 위해 task 생성 시 가짜 stack frame을 미리 쌓아 둡니다.
- Cortex-M4 @ 168 MHz에서 switch 비용은 약 0.4 µs입니다.
- RISC-V는 HW auto-push가 없어 전부 SW로 저장합니다. 더 단순하지만 살짝 더 느립니다.
다음 편은 ARM Cortex-M Context Switch — PendSV 핸들러의 어셈블리를 한 줄씩 따라갑니다.
#관련 항목
Practical RTOS Internals · 15 of 53
- 1Practical RTOS Internals — 실시간 커널 내부 분석 시리즈 소개
- 2RTOS가 필요한 이유 — 일반 OS와의 결정적 차이
- 3Task와 Thread 개념 — TCB·상태 머신·생명 주기 분석
- 4실시간 스케줄링 알고리즘 비교 — RR·Priority·EDF·RMS
- 5Preemption과 Cooperation — 강제 전환 vs 자발 양보
- 6인터럽트와 RTOS — ISR Context·Deferred Processing·FromISR API
- 7동기화 기초 분석 — Critical Section·Mutual Exclusion·Race Condition
- 8Semaphore 개념 분해 — Counting·Binary·P/V 연산
- 9Mutex 개념 분해 — Ownership·Recursive·Priority Inheritance
- 10큐와 메시지 패싱 — Producer-Consumer·Ring Buffer·전달 의미
- 11실시간성 분석 — Latency·Jitter·Deadline·WCET·RMA
- 12Ready List 자료구조 분석 — Linked List·Bitmap·O(1) Scheduler
- 13Blocked List 자료구조 — Timeout 정렬·Delta List·Two-List Scheme
- 14Scheduler 알고리즘 구현 추적 — Next-Task Selection 로직
- 15Context Switch 원리 분석 — 레지스터 저장·복원·Stack Frame
- 16ARM Cortex-M Context Switch — PendSV·MSP/PSP 어셈블리 추적
- 17ARM Cortex-A Context Switch — Mode 전환·SVC·Banked Registers
- 18RISC-V Context Switch 분석 — ECALL·mret·CSR
- 19RTOS Tick과 타이머 — SysTick·Generic Timer·configTICK_RATE_HZ
- 20Tickless 모드 구현 — Idle Tick Suppression·Sleep·Wake 보정
- 21Scheduler Latency 측정 기법 — GPIO Toggle·DWT·ftrace·cyclictest
- 22RTOS Tracing과 Observability — Tracealyzer·SystemView·ITM/ETM
- 23Critical Section 구현 비교 — IRQ Disable·BASEPRI·Spinlock
- 24Semaphore 내부 구현 추적 — Counter·Wait List·ISR-Safe Variant
- 25Mutex 내부 구현 추적 — Owner·Recursion Count·ISR 금지
- 26Priority Inversion 문제 — Mars Pathfinder 사례·Bounded vs Unbounded
- 27Priority Inheritance 구현 — Inherit·Disinherit·Chain
- 28Priority Ceiling Protocol — Immediate vs Original 비교
- 29Queue 내부 구현 추적 — Ring Buffer·2 Wait Lists·Atomic Send/Receive
- 30Event Group 분석 — Bit Flag·AND/OR Wait·Sync Barrier
- 31ISR-Safe API 설계 — FromISR 패턴·Higher Priority Wake·Deferred Work
- 32Deadlock 분석 — 4 조건·Wait-for Graph·Lock Ordering·Timeout
- 33Stream Buffer와 Message Buffer — FreeRTOS 10의 Lock-Free SPSC
- 34실시간 메모리 요구사항 — Determinism·Fragmentation·WCET
- 35FreeRTOS Heap_1~5 분석 — 5종 Allocator의 구조와 트레이드오프
- 36TLSF Allocator 분석 — Two-Level Segregated Fit O(1)
- 37Static Allocation — 컴파일 타임으로 동적 위험 제거하기
- 38Memory Pool — Fixed-Size Block Allocator의 단순함과 강력함
- 39Stack Overflow 탐지 — Canary·MPU·Watermark 3중 방어
- 40SMP RTOS 설계 — Ready List·Affinity·IPI·Load Balancing
- 41SMP Spinlock 구현 — LDREX/STREX·Ticket Lock·MCS·WFE/SEV
- 42Software Timer 분석 — Daemon Task·자료구조·ISR-Safe API
- 43RTOS System Call — SVC·ECALL·User/Kernel 분리·FreeRTOS-MPU
- 44TrustZone과 TF-M — Secure/Non-Secure·NSC Veneer·PSA
- 45AMP와 OpenAMP — Heterogeneous SoC·RPMsg·remoteproc
- 46C++ in RTOS — RAII·std::thread·ETL·Coroutine
- 47FreeRTOS 소스 분석 — tasks.c·queue.c·port.c 추적
- 48Zephyr 커널 분석 — k_thread·k_sem·Driver Model
- 49RT-Thread 분석 — Object 모델·Components·Smart·Studio
- 50RTOS 포팅 가이드 — 새 아키텍처에 옮기는 절차
- 51RTOS 선택 가이드 — Footprint·License·Certification·Ecosystem
- 52Apache NuttX 분석 — POSIX·PX4·NASA Ingenuity
- 53PREEMPT_RT Linux — Mainline 6.12·Xenomai 4·EVL
관련 글
RTOS 포팅 가이드 — 새 아키텍처에 옮기는 절차
FreeRTOS와 Zephyr의 port 계층을 따라가며 새 아키텍처에 RTOS를 옮기는 절차를 정리합니다. initial stack frame, context switch assembly, tick source, critical section primitive까지 한 번에 잡습니다.
Practical RTOS Internals — 실시간 커널 내부 분석 시리즈 소개
RTOS를 사용하는 것이 아니라 이해하고 구현하는 법. Scheduler, context switch, memory allocator의 내부 동작을 소스 코드 수준에서 분석합니다.
PREEMPT_RT Linux — Mainline 6.12·Xenomai 4·EVL
2024년 9월 Linux 6.12 mainline에 합류한 PREEMPT_RT의 핵심 변경을 정리하고, Xenomai 4·EVL과 함께 RTOS와의 선택 기준을 비교합니다. threaded IRQ·sleeping spinlock·cyclictest까지 한 지도에 모읍니다.
이 글을 참조하는 글 (6)
- ARM Cortex-M Context Switch — PendSV·MSP/PSP 어셈블리 추적— Practical RTOS Internals
- Scheduler 알고리즘 구현 추적 — Next-Task Selection 로직— Practical RTOS Internals
- Ready List 자료구조 분석 — Linked List·Bitmap·O(1) Scheduler— Practical RTOS Internals
- Task와 Thread 개념 — TCB·상태 머신·생명 주기 분석— Practical RTOS Internals
- 인터럽트 누락·중복 진단 — Priority·Pending·Re-entry 추적— Modern Embedded Recipes
- RTOS Scheduler 동작 분석 — Tick·Context Switch·Yield— Modern Embedded Recipes