인터럽트와 NVIC — 하던 일을 멈추고 달려가기
슈퍼루프만으로는 "지금 당장" 에 답할 수 없다. 센서 값을 1ms 마다 읽어야 하는데 루프 한 바퀴가 10ms 라면 아홉 번을 놓친다.
인터럽트는 하던 일을 멈추고 즉시 처리한 뒤 원래 자리로 돌아오는 장치다. 그리고 임베디드 버그의 상당수가 여기서 나온다 — 눈에 안 보이는 곳에서 코드가 끼어들기 때문이다.
무슨 일이 일어나나
메인 루프 실행 중
│
├── 하드웨어가 IRQ 신호를 올린다
│
├── ① 현재 명령을 끝낸다
├── ② R0-R3, R12, LR, PC, xPSR 을 스택에 자동 저장 ← 하드웨어가 한다
├── ③ 벡터 테이블에서 핸들러 주소를 읽어 점프
│
│ ISR 실행
│
├── ④ 스택에서 자동 복원
└── 원래 자리로 복귀
②가 자동이라는 점이 Cortex-M 의 특징이다. 다른 아키텍처는 어셈블리로 레지스터를 저장해야 하는데, Cortex-M 은 하드웨어가 해 주므로 ISR 을 평범한 C 함수로 쓸 수 있다.
void TIM2_IRQHandler(void) { // 특별한 키워드가 필요 없다
TIM2->SR &= ~TIM_SR_UIF;
tick++;
}
저장되는 여덟 개는 호출 규약(AAPCS)상 "함수가 마음대로 써도 되는" 레지스터들이다. 나머지(R4-R11)는 컴파일러가 필요할 때 알아서 저장하므로 결과적으로 모든 문맥이 보존된다.
지연 시간이 정해져 있다
| 사이클 | |
|---|---|
| 인터럽트 진입 | 12 (무대기 메모리 기준) |
| 테일체이닝 | 6 |
12 사이클이 고정이라는 것이 실시간 시스템에 중요하다. "대체로 빠르다" 가 아니라 "항상 이만큼" 이라 최악 시간을 계산할 수 있다.
테일체이닝은 ISR 이 끝나는 순간 다른 인터럽트가 기다리고 있으면, 복원했다가 다시 저장하는 낭비를 건너뛰고 바로 다음 핸들러로 넘어가는 최적화다. 그래서 6 사이클로 줄어든다.
일반: [저장 12] ISR-A [복원] [저장 12] ISR-B [복원]
테일체이닝: [저장 12] ISR-A [ 6 ] ISR-B [복원]
우선순위 — 숫자가 작을수록 높다
NVIC_SetPriority(TIM2_IRQn, 2); // 우선순위 2
NVIC_SetPriority(USART1_IRQn, 5); // 우선순위 5 — 더 낮다
NVIC_EnableIRQ(TIM2_IRQn);
직관과 반대라 자주 헷갈린다. 0 이 가장 높고 숫자가 커질수록 낮아진다.
그리고 벤더가 우선순위 비트 수를 정한다. 아키텍처는 8비트를 허용하지만 실제로는 일부만 구현한다.
// STM32F4 는 4비트 — 0~15 만 유효하다
NVIC_SetPriority(TIM2_IRQn, 20); // ✗ 상위 4비트만 남아 엉뚱한 값이 된다
최악 응답 시간을 실제로 계산해 보기
실시간 요구를 만족하는지 판단하려면 가장 나쁜 경우에 얼마나 늦게 반응하는가를 세야 한다. 84MHz Cortex-M4 에서 어떤 인터럽트의 최악 응답은 이렇게 쌓인다.
| 지연 요인 | 시간 | 설명 |
|---|---|---|
| 임계 구역이 인터럽트를 막고 있는 시간 | 2,000 ns | 내가 만든 __disable_irq 구간 중 가장 긴 것 |
| 더 높은 선점 우선순위 ISR 이 먼저 도는 시간 | 1,500 ns | 그 ISR 의 최악 실행 시간 |
| 하드웨어 진입 오버헤드 (12 사이클) | 143 ns | 84MHz 에서 고정 |
| 합계 — 최악 응답 | 약 3.6 µs |
지배적인 항은 하드웨어가 아니라 내가 쓴 임계 구역이라는 점이 요점이다. 진입 오버헤드
143ns 를 줄이려 애쓰는 것보다, __disable_irq 구간을 2µs 에서 200ns 로 줄이는 것이
10배 효과가 크다. 그래서 3편에서 "임계 구역은 가능한 짧게" 를 강조한 것이다.
// 구현된 비트 수를 확인하는 법
NVIC_SetPriority(TIM2_IRQn, 0xFF);
uint32_t bits = __builtin_popcount(NVIC_GetPriority(TIM2_IRQn));
선점 우선순위와 서브 우선순위
우선순위 비트를 둘로 나눌 수 있다.
NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2); // 상위 2비트 선점 / 하위 2비트 서브
- 선점(preemption) 우선순위 — 이게 높으면 실행 중인 ISR 을 중단시키고 끼어든다
- 서브(sub) 우선순위 — 선점은 못 하고, 둘이 동시에 대기 중일 때 먼저 처리될 순서만 정한다
선점 우선순위가 같으면 → 서로 끼어들 수 없다. 먼저 온 것이 끝나야 다음
선점 우선순위가 다르면 → 높은 쪽이 낮은 쪽을 중단시킨다 (중첩)
중첩(nesting)을 원하지 않으면 모든 인터럽트의 선점 우선순위를 같게 두면 된다. 그러면 ISR 이 서로 끼어들지 않아 공유 데이터 문제가 크게 줄어든다.
4비트를 어떻게 쪼개느냐에 따라 쓸 수 있는 단계 수가 달라진다(STM32 처럼 4비트 구현 기준).
| PRIGROUP | 선점 비트 | 서브 비트 | 선점 단계 | 서브 단계 |
|---|---|---|---|---|
| 0 | 4 | 0 | 16 | 1 |
| 2 | 2 | 2 | 4 | 4 |
| 4 | 0 | 4 | 1 | 16 |
PRIGROUP=4(선점 1단계)는 어떤 인터럽트도 서로를 못 끊는 설정이다 — 중첩을 원천 봉쇄하고
싶을 때 쓰는 손쉬운 방법이고, 단순한 시스템에서는 이쪽이 오히려 안전하다.
함정 — "우선순위를 분명히 다르게 줬는데 왜 안 끊고 들어오지?" 의 답이 대개 이것이다. 서브 우선순위만 다르게 준 것이다. 중첩이 필요하면 선점 비트가 남아 있는 그룹핑을 고른 뒤, 그 안에서 선점 값을 다르게 줘야 한다.
ISR 안에서 지켜야 할 것
① 빨리 나온다
// ✗ ISR 에서 오래 걸리는 일
void ADC_IRQHandler(void) {
ADC1->SR &= ~ADC_SR_EOC;
float v = adc_to_voltage(ADC1->DR);
printf("v=%.2f\r\n", v); // 수 ms — 그동안 다른 인터럽트가 막힌다
save_to_flash(v); // 더 오래 걸린다
}
// ✓ 신호만 남기고 나온다
volatile uint16_t adc_raw;
volatile bool adc_ready;
void ADC_IRQHandler(void) {
ADC1->SR &= ~ADC_SR_EOC;
adc_raw = ADC1->DR;
adc_ready = true; // 실제 처리는 메인 루프에서
}
ISR 이 길면 같은 우선순위의 다른 인터럽트가 전부 지연되고, 같은 인터럽트가 다시 들어오면 놓친다.
② 플래그를 먼저 클리어한다 — 그리고 확인한다
void EXTI0_IRQHandler(void) {
// ✗ 마지막에 클리어하면 그 사이 또 들어온 이벤트를 지워 버린다
handle();
EXTI->PR = (1 << 0);
}
더 고약한 함정이 있다.
void EXTI0_IRQHandler(void) {
EXTI->PR = (1 << 0); // 클리어했는데
} // ISR 이 또 불린다?
쓰기가 버스에 실리는 데 시간이 걸린다. ISR 이 끝나 복귀하는 시점에 아직 클리어가 반영되지 않았으면, NVIC 가 "아직 대기 중" 으로 보고 다시 부른다.
void EXTI0_IRQHandler(void) {
EXTI->PR = (1 << 0);
(void)EXTI->PR; // ✓ 읽어서 쓰기가 끝나기를 기다린다
// 또는 __DSB();
}
ISR 이 두 번씩 불리는 증상의 전형적인 원인이다.
③ ISR 과 공유하는 변수는 volatile
volatile bool flag; // ✓ ISR 이 바꾼다는 것을 컴파일러에게 알린다
while (!flag) { } // volatile 없으면 무한 루프로 최적화된다
다만 volatile 은 원자성을 주지 않는다.
volatile uint32_t counter;
counter++; // ✗ 읽기·수정·쓰기 — ISR 과 겹치면 증가가 사라진다
volatile uint64_t us;
uint64_t t = us; // ✗ 32비트 두 번에 나뉘어 읽혀 찢어진 값이 나온다
32비트 정렬된 값의 단순 읽기·쓰기만 원자적이다. 그 밖에는 임계 구역이 필요하다.
④ 하면 안 되는 것들
void ISR(void) {
printf(...); // ✗ 재진입 안전하지 않고 느리다
malloc(...); // ✗ 힙 자료구조가 깨질 수 있다
HAL_Delay(10); // ✗ 내부적으로 틱을 기다리는데, 틱 ISR 보다 우선순위가 높으면 영원히 멈춘다
osDelay(10); // ✗ RTOS 블로킹 API 는 ISR 에서 못 쓴다
}
HAL_Delay 가 특히 위험하다. 틱 인터럽트를 기다리는데 자기가 그보다 높은 우선순위면 틱이 영원히 못 들어와 시스템이 멈춘다.
링 버퍼 — ISR 과 메인이 데이터를 주고받는 정석
#define RB_SIZE 64 // 2의 거듭제곱이어야 마스킹이 통한다
static volatile uint8_t buf[RB_SIZE];
static volatile uint16_t head, tail; // head 는 ISR 만, tail 은 메인만 건드린다
// ISR 에서 넣는다
void USART1_IRQHandler(void) {
if (USART1->SR & USART_SR_RXNE) {
uint16_t next = (head + 1) & (RB_SIZE - 1);
if (next != tail) { // 가득 차지 않았으면
buf[head] = USART1->DR; // DR 을 읽으면 RXNE 가 자동 클리어된다
head = next; // ← 데이터를 넣은 뒤에 head 를 옮긴다
} else {
(void)USART1->DR; // 버린다 (플래그는 지워야 한다)
overflow++;
}
}
}
// 메인에서 꺼낸다
bool rb_pop(uint8_t *out) {
if (head == tail) return false; // 비었다
*out = buf[tail];
tail = (tail + 1) & (RB_SIZE - 1);
return true;
}
락이 없는데 안전한 이유가 셋이다.
head는 ISR 만,tail은 메인만 쓴다 — 한 변수에 쓰는 쪽이 하나뿐이라 경쟁이 없다- 데이터를 먼저 넣고 인덱스를 나중에 옮긴다 — 인덱스가 보이는 시점엔 데이터가 이미 유효하다
- 인덱스가 32비트 이하 정렬 값 — 읽기·쓰기가 원자적이다
이 구조를 단일 생산자·단일 소비자 큐라 하고, 임베디드에서 가장 많이 쓰이는 패턴이다.
크기를 2의 거듭제곱으로 잡는 이유는
% RB_SIZE대신& (RB_SIZE-1)을 쓰기 위해서다. 나눗셈 명령이 없거나 느린 MCU 에서 차이가 크다.
우선순위를 잘못 잡으면
모터 제어 ISR 우선순위 5 (1ms 마다, 반드시 제때)
UART 수신 ISR 우선순위 2 (초당 수천 번)
UART 가 더 높으므로 모터 ISR 을 계속 중단시킨다. 통신이 몰리면 모터 제어가 밀려 진동이 생긴다.
모터 제어 ISR 우선순위 1 ← 실시간성이 걸린 것을 높게
UART 수신 ISR 우선순위 5
원칙은 "시간을 놓치면 안 되는 것" 을 높게 두는 것이다. 자주 들어오는 것이 아니라.
그리고 우선순위가 높은 ISR 은 반드시 짧아야 한다. 높은 우선순위 + 긴 실행 시간의 조합이 다른 모든 것을 굶긴다.
확인하는 법
// 지금 어떤 인터럽트가 대기 중인가
uint32_t pending = NVIC->ISPR[0];
// 특정 인터럽트가 켜져 있나
if (NVIC->ISER[0] & (1 << TIM2_IRQn)) { }
// 대기 중인 것을 강제로 지운다 (초기화 시 유용)
NVIC_ClearPendingIRQ(TIM2_IRQn);
// ISR 실행 시간을 재는 정석 — GPIO 토글 + 오실로스코프
void TIM2_IRQHandler(void) {
GPIOA->BSRR = (1 << 6); // 측정 시작
TIM2->SR &= ~TIM_SR_UIF;
control_loop();
GPIOA->BSRR = (1 << (6 + 16)); // 측정 끝
}
파형의 폭이 실행 시간, 간격이 주기다. 폭이 주기에 가까워지면 CPU 가 그 ISR 로 가득 찼다는 뜻이다.
// 인터럽트를 놓치고 있는지 세어 본다
void TIM2_IRQHandler(void) {
if (TIM2->SR & TIM_SR_UIF) {
if (in_isr) missed++; // 이전 실행이 안 끝났는데 또 왔다
in_isr = true;
...
in_isr = false;
}
}
초기화 순서
// ✓ 이 순서를 지킨다
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // ① 클럭
TIM2->PSC = 8399; TIM2->ARR = 9999; // ② 주변장치 설정
TIM2->SR = 0; // ③ 묵은 플래그를 지운다
NVIC_ClearPendingIRQ(TIM2_IRQn); // ④ 묵은 대기를 지운다
NVIC_SetPriority(TIM2_IRQn, 3); // ⑤ 우선순위
NVIC_EnableIRQ(TIM2_IRQn); // ⑥ NVIC 허용
TIM2->DIER |= TIM_DIER_UIE; // ⑦ 주변장치 인터럽트 허용
TIM2->CR1 |= TIM_CR1_CEN; // ⑧ 시작
③·④를 빼면 켜자마자 인터럽트가 한 번 들어온다. 설정 과정에서 플래그가 세워졌기 때문이고, 초기화가 덜 끝난 상태에서 ISR 이 도는 결과가 된다.
⑥과 ⑦의 순서도 의미가 있다 — 주변장치 쪽을 마지막에 여는 편이 안전하다.
한눈에 정리
- R0-R3·R12·LR·PC·xPSR 8개를 하드웨어가 자동 저장해 ISR 을 평범한 C 함수로 쓸 수 있다
- 진입 12사이클 · 테일체이닝 6사이클 — 고정값이라 최악 시간을 계산할 수 있다
- 우선순위는 숫자가 작을수록 높다. 벤더가 비트 수를 정하므로(STM32F4 는 4비트=0~15) 범위를 확인한다
- 선점 우선순위가 같으면 서로 끼어들지 못한다 — 중첩을 원치 않으면 전부 같게 둔다
- ISR 은 신호만 남기고 나온다 — 처리는 메인 루프에서
- 플래그 클리어 후 읽어 확인하지 않으면 쓰기가 늦게 반영돼 ISR 이 두 번 불린다
volatile은 원자성이 아니다 —++도 64비트 읽기도 안전하지 않다HAL_Delay·printf·malloc은 ISR 금지 — 특히HAL_Delay는 자기보다 낮은 틱을 기다려 영원히 멈춘다- 링 버퍼는
head/tail을 각각 한쪽만 쓰게 하면 락 없이 안전하다. 크기는 2의 거듭제곱 - 우선순위는 "시간을 놓치면 안 되는 것" 을 높게 — 자주 오는 것이 아니라
- 초기화 시 묵은 플래그·대기를 지운다 — 안 그러면 켜자마자 한 번 들어온다
꼬리질문 대비
- "Cortex-M 에서 ISR 을 평범한 C 함수로 쓸 수 있는 이유는?" → R0-R3·R12·LR·PC·xPSR 을 하드웨어가 자동 스태킹하기 때문
- "우선순위를 다르게 줬는데 왜 선점이 안 되나?" → 서브 우선순위만 다른 것이다. 선점은 선점 비트(PRIGROUP)가 다를 때만 일어난다
- "최악 인터럽트 응답을 줄이려면 무엇부터 손대나?" → 임계 구역 길이. 진입 오버헤드(12사이클)보다 내가 만든
__disable_irq구간이 지배적이다- "ISR 과 메인이 데이터를 주고받는 안전한 구조는?" → 링 버퍼 — head 는 ISR 만, tail 은 메인만 써서 경쟁을 없앤다
출처 — Arm, Cortex-M4 Technical Reference Manual(100166) — 예외 처리·테일체이닝 6사이클 · Arm, Cortex-M4 Devices Generic User Guide(DUI 0553) §4.2 NVIC · §2.3 예외 모델 · Arm Community, Beginner guide on interrupt latency of the Arm Cortex-M processors — 진입 12사이클 · Arm, Procedure Call Standard for the Arm Architecture(AAPCS) — 저장 대상 레지스터