Stack Overflow 탐지 — Canary·MPU·Watermark 3중 방어
#한 줄 요약
“Stack overflow는 즉시 죽지 않는 silent corruption입니다.” — canary·MPU·watermark 세 층으로 막아야 안전합니다.
#어떤 문제를 푸는가
스택 오버플로우는 임베디드에서 재현이 가장 어려운 버그입니다. C에서 stack은 그냥 메모리 영역이고 hardware는 경계를 모릅니다. SP가 region 밖으로 내려가도 CPU는 그냥 다음 word에 push할 뿐입니다.
문제가 가시화되는 시점은 훨씬 나중입니다. 침범당한 영역이 다른 task의 TCB라면 다음 context switch에서 깨지고, heap 영역이라면 몇 ms 뒤 다른 task의 alloc이 이상해집니다. 원인과 결과 사이 수십 ms가 벌어져 디버거로 잡기가 거의 불가능합니다.
방어는 한 층으로 부족합니다. FreeRTOS의 canary, MPU 기반 hardware boundary, 운영 중 watermark monitoring, 그리고 컴파일 단계 정적 분석을 겹쳐 적용해야 합니다. 이번 편은 각 방어 층의 원리와 함정을 정리합니다.
#Stack의 방향과 침범 양상
Cortex-M의 stack은 높은 주소에서 낮은 주소로 자랍니다. SP가 stack base보다 작아지는 순간 인접한 메모리 영역을 덮어쓰기 시작합니다.
침범 첫 byte부터 fault가 나는 것은 아닙니다. read/write가 그냥 성공합니다. CPU는 침범을 모릅니다. 그래서 소프트웨어 또는 MPU가 명시적으로 검사해야 합니다.
#Canary 패턴 — FreeRTOS Method 2
가장 보편적인 방어가 stack 끝에 magic value를 심어 두고 주기적으로 검사하는 것입니다. FreeRTOS는 configCHECK_FOR_STACK_OVERFLOW에 세 단계를 제공합니다.
#define configCHECK_FOR_STACK_OVERFLOW 2값이 0이면 검사 없음, 1이면 SP 위치 검사, 2이면 canary 검사입니다. Method 2는 task 생성 시 stack을 0xA5 패턴으로 채웁니다.
/* task 생성 시 */memset(stack, 0xA5, stack_size);
/* 매 context switch 시 */uint32_t *bottom = (uint32_t*)task->pxStack;if (bottom[0] != 0xA5A5A5A5 || bottom[1] != 0xA5A5A5A5 || bottom[2] != 0xA5A5A5A5 || bottom[3] != 0xA5A5A5A5) { vApplicationStackOverflowHook(task, task->pcTaskName);}stack 끝 16 byte가 깨졌다면 최소한 그만큼은 침범했다는 뜻입니다. 16 byte보다 작은 침범은 놓치지만, 그 정도라도 대부분의 overflow는 잡힙니다.
Method 1은 더 가볍습니다. context switch 시 SP가 stack base 이하인지만 봅니다. 이미 침범이 발생한 뒤에 잡힌다는 한계가 있습니다. Method 2는 경계를 살짝 침범한 순간까지도 잡습니다. 양산 빌드도 최소 Method 1, 가능하면 Method 2로 둡니다.
#Application Hook
overflow가 검출되면 RTOS가 application hook을 호출합니다. 시스템이 이미 corrupt 상태이므로 hook 안에서는 최소한의 작업만 합니다.
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { /* 로컬 변수는 호출 측 stack — overflow된 task의 stack */ /* 가능한 가벼운 작업만 */
log_critical_isr("stack overflow: %s", pcTaskName);
/* 양산 — 즉시 reset, watchdog에게 맡기는 것이 가장 안전 */ NVIC_SystemReset();
/* debug — halt for inspection */ __BKPT(0); for (;;);}hook 안에서 큰 stack을 쓰는 함수 (printf, malloc 등)를 호출하면 2차 overflow가 납니다. 메시지는 최소 길이로, 가능하면 ITM trace로만 떨어뜨립니다. 그리고 watchdog reset이나 NVIC_SystemReset()으로 깨끗한 부팅을 유도합니다.
#High Water Mark — 운영 중 측정
uxTaskGetStackHighWaterMark가 지금까지 사용하지 않은 stack의 최소량을 반환합니다. canary가 깨지지는 않았지만 얼마나 가까이 갔는지를 측정합니다.
UBaseType_t hw = uxTaskGetStackHighWaterMark(task);/* hw = 사용하지 않은 word 수의 *최솟값* */운영 중 주기적으로 모든 task의 watermark를 telemetry로 보냅니다.
void monitor_stacks(void) { TaskStatus_t status[MAX_TASKS]; UBaseType_t n = uxTaskGetSystemState(status, MAX_TASKS, NULL); for (UBaseType_t i = 0; i < n; i++) { UBaseType_t hw = uxTaskGetStackHighWaterMark(status[i].xHandle); if (hw < THRESHOLD_WORDS) { log_warn("task %s: only %u words free", status[i].pcTaskName, (unsigned)hw); } }}watermark가 총 stack의 10% 이하로 떨어지면 위험입니다. stack을 늘리거나 해당 task의 worst path를 다시 분석합니다.
#정적 분석 — -fstack-usage
운영 시 측정과 컴파일 타임 분석은 서로 보완합니다. GCC -fstack-usage는 함수별 최악 stack 사용량을 파일로 떨어뜨립니다.
$ gcc -fstack-usage -c handler.c$ cat handler.suhandler.c:42:6:task_entry 128 statichandler.c:55:6:process 256 statichandler.c:78:6:compute 512 staticcall graph를 따라 worst path를 합산합니다.
| 항목 | Byte |
|---|---|
task_entry(128) → process(256) → compute(512) | 896 |
| ISR worst case path | +192 |
| context switch overhead | +64 |
| safety margin (25%) | +288 |
| total | ≈ 1440 → 2048 |
수동으로 트리를 따라가는 것이 번거롭다면 Memfault puncover 같은 도구가 자동화해 줍니다. ELF와 .su 파일을 입력으로 받아 call graph + stack 합산을 보여 줍니다.
#Stack을 패턴으로 채워 측정
운영 watermark와 별개로 개발 단계 측정에는 stack을 0xDEADBEEF로 채우고 worst case 시나리오를 돌립니다.
void fill_stack(uint32_t *stack, size_t words) { for (size_t i = 0; i < words; i++) stack[i] = 0xDEADBEEF;}
size_t measure_stack_used(uint32_t *stack, size_t words) { size_t i; for (i = 0; i < words; i++) { if (stack[i] != 0xDEADBEEF) break; } return words - i; /* 깨진 위치부터 끝까지 = 사용량 */}stress test 후 몇 word까지 패턴이 살아 있는지를 보면 그때까지의 최대 사용량을 정확히 알 수 있습니다. canary는 침범 여부만, 이 방법은 침범 거리까지 알려 줍니다.
#MPU로 Hardware 보호
가장 강력한 방어는 *MPU(Memory Protection Unit)*입니다. task stack 바로 아래에 no-access region을 두면, overflow 시 즉시 MemManageFault가 발생합니다.
/* task stack 직전 32 byte를 no-access region으로 */MPU->RNR = MPU_REGION_NUMBER;MPU->RBAR = (uint32_t)(stack_base - 32);MPU->RASR = MPU_REGION_SIZE_32B | MPU_REGION_NO_ACCESS | MPU_REGION_ENABLE;stack을 1 byte라도 넘기는 순간 MemManageFault가 발생합니다. canary처럼 주기 검사가 필요 없고, 침범과 동시에 검출됩니다.
FreeRTOS는 MPU 지원 port가 별도로 있습니다(port_mpu.c). task 생성 시 각 task의 stack region을 MPU로 보호하고, context switch 시 region을 갱신합니다. ARMv7-M (Cortex-M3/M4/M7)와 ARMv8-M (Cortex-M23/M33)에서 지원됩니다.
xTaskCreateRestricted(&task_params, &task_handle);/* task_params.xRegions에 MPU region 정의 */MPU region 수가 제한적(보통 8 또는 16개)이라는 점을 감안해 핵심 task에만 우선 적용하는 것이 현실적입니다.
#GCC Stack Protector — Per-Function Canary
함수 단위 보호가 필요하면 GCC의 stack protector를 켭니다. 각 함수가 진입 시 canary를 stack에 두고 exit 시 검증합니다.
gcc -fstack-protector-strong source.cvoid some_function(void) { uint32_t __stack_chk_guard_copy = __stack_chk_guard; /* local 변수들 */ /* ... */ if (__stack_chk_guard != __stack_chk_guard_copy) { __stack_chk_fail(); }}buffer overrun이 함수 내부에서 canary를 덮어쓰는 즉시 잡힙니다. RTOS canary가 task 단위 침범을 잡는다면, 이쪽은 함수 단위 침범을 잡습니다. 두 방어가 겹치지 않는 영역을 막습니다.
#ISR Stack 분리
Cortex-M은 MSP(Main Stack Pointer, ISR용)와 PSP(Process Stack Pointer, task용)를 분리합니다. ISR이 task stack을 침범하지 않는다는 보장입니다.
2-05편에서 본 것처럼 task는 PSP, ISR은 MSP를 씁니다. MSP 크기는 모든 nested ISR worst case를 합산해 정합니다.
MSP 분석
| 항목 | Byte |
|---|---|
| outer ISR worst | 128 |
| nested ISR worst | +64 |
| nested ISR worst | +64 |
| context switch frame | +64 |
| margin (25%) | +80 |
| MSP size | 400 → 512 |
MSP 크기는 linker script의 _estack 심볼로 정합니다. MSP overflow는 PSP 검사로는 잡히지 않습니다. 별도로 MSP base 부근에 canary를 두어 부팅 후 주기적으로 검사하는 패턴이 안전합니다.
#Stack Probe — -fstack-clash-protection
큰 stack frame을 잡는 함수가 guard page를 건너뛰고 침범할 가능성이 있습니다. GCC의 -fstack-clash-protection은 큰 frame을 작은 조각으로 나누어 단계적으로 probe하는 코드를 삽입합니다.
void big_func(void) { char large_buffer[16384]; /* 16 KB */ /* 컴파일러가 자동으로 매 4 KB마다 stack 접근 명령 삽입 */}guard page 또는 MPU no-access region이 반드시 건드려지므로 overflow가 즉시 검출됩니다. desktop에서는 표준 옵션이지만 embedded toolchain에서도 최근 GCC는 지원합니다.
#Recursion과 printf의 위험
embedded에서 recursion은 사실상 금기입니다. 깊이를 컴파일 타임에 알 수 없으면 worst stack 분석이 불가능합니다.
void recursive(int n) { char local[1024]; if (n == 0) return; recursive(n - 1);}recursive(10); /* 10 KB stack — 추정 불가 영역 */비슷하게 위험한 것이 *newlib printf*입니다. format string 처리, float 변환, locale 처리에 256~512 byte stack을 소모합니다.
printf("value: %f\n", fp_val); /* 512+ byte stack */embedded에서는 tinyprintf / mini-printf / embedded-printf 같은 작은 구현으로 교체합니다. stack 사용량이 64 byte 수준으로 줄어듭니다. ISR 안에서 출력이 필요하다면 ITM_SendChar 또는 ring buffer + 별도 task가 답입니다.
#자동차·항공 표준
safety-critical 도메인은 stack 분석을 증명해야 합니다.
ASIL-D / DO-178C Level A
- 모든 함수의 stack 사용량 정적 분석
- worst path 산출 및 문서화
- canary + MPU 둘 다 활성화
- 운영 중 watermark monitoring
- recursion 금지
KSLV-II 누리호 비행 컴퓨터
- stack size 고정 + 50% margin
- 매 task 종료 시 watermark check
- telemetry로 ground에 전송
- canary 깨짐 시 즉시 redundant unit 전환
3중 4중 방어가 과하다고 느껴질 정도로 겹쳐 있습니다. 한 층의 실패가 다른 층으로 흡수되어야 시스템 신뢰성이 만들어집니다.
#자주 보는 함정과 안티패턴
⚠️ Stack size를 감으로 정함
xTaskCreate(..., 256, ...) 같이 적당히 정하면 언젠가 overflow합니다. -fstack-usage 정적 분석과 watermark 실측을 모든 task에 적용합니다.
⚠️ 양산 빌드에서 canary 끔
성능 이유로 configCHECK_FOR_STACK_OVERFLOW = 0으로 두는 경우가 있습니다. canary 검사는 context switch 당 십 cycle 수준이라 영향이 미미합니다. 양산에서도 최소 Method 1은 유지합니다.
⚠️ ISR 안에서 큰 local 변수
void some_isr(void) { char buf[4096]; /* MSP 4 KB 침범 */}ISR 안의 local은 MSP를 직접 갉아먹습니다. ISR에서 buffer가 필요하면 static buffer로 두거나, ISR을 짧게 만들고 task에서 처리합니다.
⚠️ ISR 안에서 printf
newlib printf는 256+ byte stack을 씁니다. MSP가 작으면 MSP overflow가 즉시 발생합니다. ISR 출력은 ITM 또는 ring buffer 패턴으로 옮깁니다.
⚠️ Recursion 사용
깊이를 분석할 수 없는 recursion은 worst stack 분석을 불가능하게 합니다. iterative 변형이나 explicit stack 자료구조로 바꿉니다.
⚠️ MSP canary 누락
PSP만 canary로 보호하면 ISR overflow는 silent입니다. MSP에도 부팅 시 magic pattern을 채우고 주기적으로 검사합니다.
#정리
- Stack overflow는 즉시 fault가 나지 않는 silent corruption이며 임베디드에서 가장 재현이 어려운 버그입니다.
- FreeRTOS
configCHECK_FOR_STACK_OVERFLOW = 2는 stack 끝의 canary 패턴을 매 context switch마다 검사합니다. uxTaskGetStackHighWaterMark로 운영 중 사용량을 측정하고 임계 이하 시 alarm을 띄웁니다.- MPU region으로 task stack을 보호하면 침범과 동시에 MemManageFault가 발생해 hardware 수준 방어가 됩니다.
- GCC
-fstack-usage로 컴파일 타임 worst path 분석,-fstack-protector-strong으로 함수 단위 canary를 추가합니다. - ISR stack(MSP)은 task stack(PSP)과 별도이므로 별도 분석과 별도 canary가 필요합니다.
- recursion과 newlib
printf는 stack 큰손이므로 embedded에서는 피하거나 대체합니다. - safety-critical 도메인은 정적 분석 + canary + MPU + watermark monitoring을 모두 겹쳐 적용합니다.
다음 편은 4-07 SMP RTOS에서 멀티코어 RTOS 스케줄링을 다룹니다.
#관련 항목
Practical RTOS Internals · 39 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 측정까지 다룹니다.
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까지 한 지도에 모읍니다.
Apache NuttX 분석 — POSIX·PX4·NASA Ingenuity
NuttX의 POSIX-compliant 구조를 따라가며 PX4 autopilot과 NASA Ingenuity 화성 헬리콥터 채택 배경을 정리합니다. Flat/Protected/Kernel 빌드, VFS, 네트워크, NSH, micro-ROS 통합까지 한 지도로 모읍니다.
이 글을 참조하는 글 (8)
- TrustZone과 TF-M — Secure/Non-Secure·NSC Veneer·PSA— Practical RTOS Internals
- RTOS System Call — SVC·ECALL·User/Kernel 분리·FreeRTOS-MPU— Practical RTOS Internals
- SMP RTOS 설계 — Ready List·Affinity·IPI·Load Balancing— Practical RTOS Internals
- Memory Pool — Fixed-Size Block Allocator의 단순함과 강력함— Practical RTOS Internals
- Static Allocation — 컴파일 타임으로 동적 위험 제거하기— Practical RTOS Internals
- FreeRTOS Heap_1~5 분석 — 5종 Allocator의 구조와 트레이드오프— Practical RTOS Internals
- 임베디드 스택 분석 — high-water·overflow 탐지— Modern Embedded Recipes
- RTOS 디버깅 기법 — Tracealyzer·SystemView·Stack 추적— Modern Embedded Recipes