ARM Cortex-M Context Switch — PendSV·MSP/PSP 어셈블리 추적
#한 줄 요약
“PendSV가 Cortex-M context switch의 정석” — 우선순위를 최저로 두어 다른 ISR이 모두 끝난 뒤에만 전환이 일어나게 합니다.
#왜 PendSV인가
ISR 한가운데서 직접 context switch를 시도하면 곤란한 일이 생깁니다.
- 아직 처리 중이던 pending 인터럽트들이 밀려서 늦어집니다
- 예외 nesting이 깊어져 stack frame이 꼬입니다
- EXC_RETURN 값이 예상과 달라져 HW state가 깨질 위험이 있습니다
해결책은 context switch만을 위한 별도 예외를 두고, 그 예외의 priority를 가장 낮게 잡는 것입니다. 그게 PendSV입니다.
// port.c — PendSV와 SysTick을 우선순위 최저로portNVIC_SYSPRI2_REG = portNVIC_PENDSV_PRI; // PendSV = 0xFFportNVIC_SYSPRI3_REG = portNVIC_SYSTICK_PRI; // SysTick도 낮춤이렇게 두면 PendSV는 다른 모든 ISR이 끝난 뒤에만 실행됩니다. context switch가 예외 chain의 가장 끝에서 일어나므로 nesting이 깨질 일이 없습니다.
#PendSV trigger — yield의 정체
task가 yield 하면 RTOS는 PendSV를 pending 상태로 표시합니다.
#define portYIELD() \{ \ portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \ __dsb(portSY_FULL_READ_WRITE); \ __isb(portSY_FULL_READ_WRITE); \}ICSR (Interrupt Control and State Register)의 PENDSVSET bit에 1을 쓰면 PendSV가 pending 됩니다. 현재 ISR이 끝나는 순간 (또는 즉시) PendSV가 실행됩니다.
DSB / ISB는 write가 실제로 끝나고 pipeline이 flush 된 뒤에 진행하게 만드는 barrier입니다. 이게 없으면 CPU가 yield 호출을 순서 바꿔 처리할 수 있습니다.
#Dual-stack model — MSP와 PSP
Cortex-M에는 stack pointer가 두 개 있습니다.
| 용도 | |
|---|---|
| MSP (Main Stack Pointer) | Reset, 예외, OS 코드 |
| PSP (Process Stack Pointer) | task 코드 |
ISR이 진입할 때 SP는 자동으로 MSP로 전환되고, 예외 return 때 원래 (PSP)로 돌아갑니다. 이렇게 둘로 나눠 두면 task끼리는 PSP만 갈아끼우면 되고 ISR은 항상 같은 MSP를 씁니다. stack 관리가 깔끔해집니다.
전환 흐름은 이렇습니다.
Reset → MSP 사용 → main() ↓RTOS init → 첫 task 시작을 위해 SVC trigger ↓SVC handler → PSP 설정 + CONTROL.SPSEL = 1 ↓Exception return → PSP로 task code 진입
이후 ISR 진입 → 자동으로 MSP로 전환이후 ISR 종료 → 원래 (PSP)로 복귀CONTROL register의 bit 1 (SPSEL) 이 0이면 MSP, 1이면 PSP입니다.
전체 그림을 한 장으로 정리하면 이렇습니다. HW가 자동 push하는 8개 레지스터와 SW가 push하는 R4-R11이 어떻게 stack에 쌓이고, EXC_RETURN 값이 어떻게 return mode를 결정하는지가 한눈에 보입니다.
#PendSV 핸들러 — 한 줄씩 풀기
FreeRTOS의 xPortPendSVHandler를 따라가 봅니다. 16 줄이 채 안 되지만 일어나는 일은 많습니다.
xPortPendSVHandler: mrs r0, psp /* (1) 현재 task의 PSP를 r0에 */ isb
ldr r3, =pxCurrentTCB /* (2) pxCurrentTCB 변수 주소 */ ldr r2, [r3] /* (3) 현재 TCB 포인터 */
stmdb r0!, {r4-r11} /* (4) R4-R11을 task stack에 push */
str r0, [r2] /* (5) TCB->pxTopOfStack = 새 SP */
stmdb sp!, {r3, r14} /* (6) r3, lr 보존 (BL 위함) */ mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY msr basepri, r0 /* (7) IRQ mask */ dsb isb
bl vTaskSwitchContext /* (8) pxCurrentTCB 갱신 */
mov r0, #0 msr basepri, r0 /* (9) IRQ unmask */ ldmia sp!, {r3, r14} /* (10) r3, lr 복원 */
ldr r1, [r3] /* (11) 새 pxCurrentTCB */ ldr r0, [r1] /* (12) 새 pxTopOfStack */ ldmia r0!, {r4-r11} /* (13) R4-R11 pop */ msr psp, r0 /* (14) PSP 갱신 */ isb bx r14 /* (15) Exception return → HW가 R0-R3, R12, LR, PC, xPSR pop */핵심을 묶어 보면 이렇습니다.
- (1)~(5): 현재 task의 context 마무리 save. HW가 이미 R0-R3 / R12 / LR / PC / xPSR을 PSP에 push해 두었으므로, SW는 R4-R11만 더 push하고 새 SP를 TCB에 기록하면 끝납니다.
- (6)~(10): 스케줄러 호출.
vTaskSwitchContext()가 다음 task를 정하고pxCurrentTCB를 갱신합니다. 이 동안에는 BASEPRI로 RTOS critical IRQ만 mask합니다. - (11)~(15): 새 task의 context 복원. 새 TCB에서 pxTopOfStack을 가져와 R4-R11을 pop하고 PSP에 기록합니다.
bx r14(15)에서 HW가 자동으로 나머지 8 word를 pop하고 PC가 새 task 코드를 향합니다.
이 마지막 bx r14가 마법입니다. r14에는 EXC_RETURN 값이 들어 있는데, HW가 그 값을 보고 “thread mode + PSP + 가능하다면 FP frame까지” 알아서 처리합니다.
#EXC_RETURN — r14의 특수 값
예외 진입 시 LR에 들어오는 값은 일반 return address가 아닙니다. 0xFFFFFFF0 영역의 특수 패턴으로, 어떻게 return할지를 인코딩합니다.
| EXC_RETURN | 의미 |
|---|---|
| 0xFFFFFFF1 | Handler mode return (MSP, FP 없음) |
| 0xFFFFFFF9 | Thread mode + MSP |
| 0xFFFFFFFD | Thread mode + PSP |
| 0xFFFFFFE1 | Handler mode + FP frame |
| 0xFFFFFFE9 | Thread mode + MSP + FP frame |
| 0xFFFFFFED | Thread mode + PSP + FP frame |
RTOS task로 돌아갈 때 LR은 거의 항상 0xFFFFFFFD (또는 FP 쓰는 task면 0xFFFFFFED) 입니다. bx r14 한 줄이 이 정보를 그대로 HW에 전달합니다.
#첫 task 시작 — SVC를 쓰는 이유
부팅 직후에는 현재 실행 중인 task가 없습니다. context restore의 대상이 비어 있는 셈입니다. 그래서 FreeRTOS는 SVC를 통해 첫 task를 시작합니다.
vPortStartFirstTask: ldr r0, =0xE000ED08 @ VTOR address ldr r0, [r0] ldr r0, [r0] @ MSP 초기값 = vector table[0] msr msp, r0 @ MSP 초기화 (kernel 깨끗하게) cpsie i @ IRQ enable cpsie f dsb isb svc 0 @ SVC trigger nopSVC 핸들러가 첫 task의 context를 복원합니다.
vPortSVCHandler: ldr r3, =pxCurrentTCB ldr r1, [r3] ldr r0, [r1] @ 첫 task의 pxTopOfStack ldmia r0!, {r4-r11} @ R4-R11 pop msr psp, r0 @ PSP 설정 isb mov r0, #0 msr basepri, r0 orr r14, #0xd @ EXC_RETURN = 0xFFFFFFFD (thread mode + PSP) bx r14 @ HW가 R0-R3, R12, LR, PC, xPSR pop마지막 두 줄이 핵심입니다. LR에 0xFFFFFFFD를 만들어 두고 bx r14로 return 하면 HW가 PSP를 사용하는 thread mode로 떨어뜨립니다. 그 즉시 PC는 task 함수의 첫 명령을 가리키고 있습니다.
#SysTick — Time slice trigger
매 tick마다 SysTick ISR이 호출됩니다. tick count를 늘리고, preempt가 필요하면 PendSV를 trigger합니다.
void xPortSysTickHandler(void){ vPortRaiseBASEPRI(); /* RTOS IRQ만 mask */
if (xTaskIncrementTick() != pdFALSE) { /* 더 우선순위 높은 task가 ready → preempt */ portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; }
vPortClearBASEPRIFromISR();}SysTick 자체는 가벼운 ISR로 끝나고, 실제 context switch는 PendSV가 처리합니다. 이 분업이 핵심입니다.
#BASEPRI — 똑똑한 IRQ mask
cpsid i는 모든 IRQ를 mask 합니다. 너무 둔탁한 망치입니다. critical HW IRQ (안전 핵심)도 막혀 버립니다.
대신 BASEPRI는 priority가 N 이하인 IRQ만 mask 합니다.
#define configMAX_SYSCALL_INTERRUPT_PRIORITY 5
// priority 0~4: 절대 mask 안 됨 (안전 핵심 HW IRQ)// priority 5+ : RTOS critical section에서 maskFreeRTOS는 RTOS API를 호출 가능한 ISR만 mask 대상으로 둡니다. 자동차 ESC처럼 수 μs도 양보할 수 없는 IRQ는 RTOS에 관여하지 못하는 대신 언제든 즉시 실행됩니다.
#FPU — Lazy stacking
Cortex-M4F / M7는 FPU 사용을 task별로 추적합니다. CONTROL.FPCA bit이 1이면 그 task가 FPU를 썼다는 표시입니다.
PendSV는 FPCA bit를 보고 FPU regs도 push 할지 결정합니다.
tst r14, #0x10 @ EXC_RETURN bit 4 = FPCAit eqvstmdbeq r0!, {s16-s31} @ FPU 쓴 task만 S16-S31 pushS0-S15는 HW가 자동으로 push해 줍니다. S16-S31만 SW가 처리합니다. FPU 안 쓴 task는 이 코드를 건너뛰므로 추가 오버헤드가 0입니다.
#SVC vs PendSV — 한눈에
| SVC | PendSV | |
|---|---|---|
| Trigger | SVC #N 명령 | ICSR.PENDSVSET = 1 |
| Priority | 설정 가능 | 보통 최저 |
| 용도 | OS API entry, 첫 task 시작 | Context switch |
| 발생 시점 | 즉시 (sync) | 다른 ISR 모두 종료 후 |
SVC는 동기적 시스템 호출, PendSV는 비동기적 지연 context switch에 쓰입니다.
#Cortex-M0/M0+의 차이
ARMv6-M (M0, M0+)에는 Cortex-M3+에 있는 명령들이 없습니다.
- CLZ 명령 없음 — bitmap 스케줄러 최적화 불가
- STMDB / LDM with high regs 제한 — R8-R11을 직접 stack에 push 못 함
- BASEPRI 없음 — 거친
cpsid i로 IRQ 막아야 함
그래서 M0 port는 추가 명령 시퀀스가 들어갑니다.
@ Cortex-M0: R8-R11을 R0-R3에 옮긴 뒤 pushmov r4, r8mov r5, r9mov r6, r10mov r7, r11stmia r0!, {r4-r7}@ 그 다음 R4-R7 pushstmia r0!, {r4-r7}명령 수가 더 많지만 결과는 같습니다. RTOS port code의 95%는 동일하고, 몇 줄만 다른 형태입니다.
#자주 하는 실수
⚠️ PendSV priority를 잘못 설정
configKERNEL_INTERRUPT_PRIORITY보다 높게 두면 다른 ISR이 PendSV를 preempt하면서 context가 망가질 수 있습니다. 항상 최저로 둡니다.
⚠️ MSP에서 task code를 실행
CONTROL.SPSEL을 설정하지 않으면 task가 MSP 위에서 실행됩니다. 그러면 ISR과 stack을 공유하게 되어 nested IRQ로 stack overflow가 납니다.
⚠️ ISR에서 FPU를 쓰면서 lazy stacking을 무시
FP 명령을 ISR 안에서 쓰면 task의 FP context가 깨질 수 있습니다. 일반적으로는 ISR에서 FP 연산 자체를 피하는 게 안전합니다.
⚠️
cpsid i로 모든 IRQ mask
안전 핵심 HW IRQ까지 막힙니다. 가능하면 BASEPRI만 씁니다.
#정리
- Cortex-M context switch의 모든 길은 PendSV로 통합됩니다. 우선순위는 항상 최저로 둡니다.
- MSP는 ISR/OS, PSP는 task가 씁니다. CONTROL.SPSEL bit로 전환됩니다.
bx r14로 return 할 때r14에 들어 있는 EXC_RETURN 값이 HW에게 어떻게 unstack 할지 알려줍니다.- SVC는 첫 task 시작과 OS API entry용, PendSV는 context switch 전용입니다.
- BASEPRI mask가 RTOS critical section의 핵심입니다. 안전 핵심 IRQ는 절대 막지 않습니다.
- FPU lazy stacking으로 FP를 안 쓴 task의 switch 오버헤드는 0입니다.
- Cortex-M0/M0+는 CLZ와 BASEPRI가 없어 port code 몇 줄이 달라집니다.
다음 편은 ARM Cortex-A Context Switch — SVC, 모드 전환, MMU가 만드는 추가 비용을 다룹니다.
#관련 항목
Practical RTOS Internals · 16 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 System Call — SVC·ECALL·User/Kernel 분리·FreeRTOS-MPU
MPU/MMU로 user task와 kernel을 분리하는 RTOS의 syscall 구조를 정리합니다. Cortex-M의 SVC trap, RISC-V의 ECALL, FreeRTOS-MPU와 Zephyr USERSPACE의 차이, capability 검사, syscall overhead 측정까지 다룹니다.
ARM Cortex-A Context Switch — Mode 전환·SVC·Banked Registers
Cortex-A의 7 모드와 모드별 banked register. 모드 간 SP·LR 별도 — Cortex-M보다 복잡.
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까지 한 지도에 모읍니다.
이 글을 참조하는 글 (8)
- RTOS 포팅 가이드 — 새 아키텍처에 옮기는 절차— Practical RTOS Internals
- FreeRTOS 소스 분석 — tasks.c·queue.c·port.c 추적— Practical RTOS Internals
- RTOS System Call — SVC·ECALL·User/Kernel 분리·FreeRTOS-MPU— Practical RTOS Internals
- Stack Overflow 탐지 — Canary·MPU·Watermark 3중 방어— Practical RTOS Internals
- RTOS Tracing과 Observability — Tracealyzer·SystemView·ITM/ETM— Practical RTOS Internals
- Scheduler Latency 측정 기법 — GPIO Toggle·DWT·ftrace·cyclictest— Practical RTOS Internals
- ARM Cortex-A Context Switch — Mode 전환·SVC·Banked Registers— Practical RTOS Internals
- Context Switch 원리 분석 — 레지스터 저장·복원·Stack Frame— Practical RTOS Internals