임베디드 학습 노트 목차

인터럽트와 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 ns84MHz 에서 고정
합계 — 최악 응답약 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선점 비트서브 비트선점 단계서브 단계
040161
22244
404116

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;
}

락이 없는데 안전한 이유가 셋이다.

  • headISR 만, 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) — 저장 대상 레지스터

GPIO — 핀 하나에 담긴 전기타이머와 PWM — 시간을 만드는 하드웨어