예외가 발생할 때 Cortex-M 하드웨어가 레지스터 일부를 자동으로 스택에 저장하는 기능(automatic stacking).
무엇을 저장하나
스택 (높은 주소 → 낮은 주소로 쌓임)
┌──────────┐ ← 예외 전 SP
│ xPSR │
│ PC │ ← 복귀 주소
│ LR │
│ R12 │
│ R3 │
│ R2 │
│ R1 │
│ R0 │
└──────────┘ ← 예외 진입 후 SP (총 32바이트)
FPU를 쓰면 S0~S15 + FPSCR이 추가되어 총 104바이트가 된다.
왜 하필 이 8개인가 — AAPCS 규약
ARM의 함수 호출 규약(AAPCS)은 레지스터를 두 종류로 나눈다.
- caller-saved — (R0-R3, R12, LR) — 호출하는 쪽이 필요하면 저장
- callee-saved — (R4-R11) — 호출받는 함수가 저장·복원할 책임
하드웨어가 caller-saved만 저장하면, 나머지 R4~R11은 C 함수가 자기 규약에 따라 알아서 저장한다.
하드웨어: R0-R3, R12, LR, PC, xPSR 저장
C 함수 : 필요하면 R4-R11 을 프롤로그에서 저장
────────────────────────────────────────
합치면 모든 레지스터가 보존된다
이 조합 덕분에 ISR을 평범한 C 함수로 쓸 수 있다.
void USART1_IRQHandler(void) { // 특수 키워드가 전혀 필요 없다
…
}
다른 아키텍처(AVR, PIC)는 __interrupt 같은 키워드로
컴파일러에게 "전체 레지스터를 저장하라"고 알려야 한다.
부수 효과 — 인터럽트 지연이 짧고 일정하다
Cortex-M3/M4 의 인터럽트 지연 = 12 사이클 (고정)
스태킹과 벡터 인출이 파이프라인에서 병렬로 진행되므로 오버헤드가 작다. 게다가 항상 같은 사이클 수라 실시간성 분석이 가능하다.
테일체이닝 — 자동 스태킹의 최적화
-
인터럽트 A 처리 완료 → 인터럽트 B가 대기 중
-
일반적이라면: 복원(pop) → 다시 저장(push) — = 낭비
-
테일체이닝 — : 복원과 저장을 생략하고 바로 B의 핸들러로 ← 6 사이클
스택에 있는 프레임을 그대로 재사용하는 것이다.
늦은 도착 (late arrival)
인터럽트 A의 스태킹 진행 중
→ 더 높은 우선순위 B가 도착
→ 스태킹은 이미 시작됐으니 그대로 두고, 벡터만 B의 것으로 교체
→ B를 먼저 처리
스태킹을 버리지 않고 재활용해 지연을 줄인다.
스택 사용량 계산에 반영해야 한다
인터럽트 하나당 최소 32바이트 (FPU 사용 시 104바이트) 중첩 5단계면 → 160 ~ 520 바이트
- 각 핸들러의 지역 변수
MSP 크기를 정할 때 이것을 빠뜨리면 인터럽트가 몰릴 때 스택 오버플로가 난다. 특히 FPU를 쓰는 ISR이 중첩되면 순식간에 커진다.
FPU 컨텍스트는 lazy stacking으로 최적화할 수 있다. FPU 레지스터 자리는 잡아 두되 실제 저장은 미룬다
- ISR이 FPU를 안 쓰면 저장이 아예 일어나지 않는다 FPU->FPCCR |= FPU_FPCCR_LSPEN_Msk; (기본 활성) 자리는 잡으므로 스택 크기 계산에는 104바이트로 넣어야 한다.
HardFault 분석에 쓰인다
크래시가 났을 때 스택에 쌓인 프레임에서 PC를 꺼내면 어느 명령에서 죽었는지 알 수 있다.
void HardFault_Handler(void) {
__asm volatile (
"TST LR, #4 \n" // EXC_RETURN 의 bit2 로
"ITE EQ \n" // MSP인지 PSP인지 판단
"MRSEQ R0, MSP \n"
"MRSNE R0, PSP \n"
"B hard_fault_c \n"
);
}
void hard_fault_c(uint32_t *sp) {
uint32_t pc = sp[6]; // 스택 프레임의 7번째가 PC
uint32_t lr = sp[5];
// pc 값을 map 파일과 대조하면 어느 함수인지 나온다
}
이 코드를 쓸 수 있으면 HardFault 디버깅이 훨씬 수월해진다.