MCU 인터럽트와 NVIC 우선순위 처리

폴링이 입력을 놓치는 원인을 짚고 인터럽트의 기본 동작 원리, 우선순위 선점 및 안전한 핸들러 작성 규칙을 알아봅니다.

무료 공개 · 최근 수정

폴링 루프는 짧은 버튼 입력을 놓친다

버튼이나 센서 신호처럼 외부에서 언제 들어올지 모르는 입력은 거의 모든 전자기기에 들어 있습니다. 사용자가 버튼을 누르거나 통신 데이터가 도착하는 순간은 프로그램이 미리 알 수 없습니다.

하지만 코드가 길어지면 버튼이 가끔 눌리지 않는 현상이 생깁니다. 화면을 갱신하거나 긴 계산을 하느라 루프가 바빠진 탓입니다. 답답한 마음에 인터럽트를 추가해 보지만 이번에는 보드가 갑자기 멈추는 새 문제가 생기기도 합니다.

입력을 놓치지 않는 프로그램 구조를 고르고 문제가 생겼을 때 무엇부터 점검해야 하는지 차례대로 알아봅니다.

while (1) {
    read_temperature_sensor(); // 통신 대기에 20ms 소요
    calculate_pid_output();    // 제어 계산에 15ms 소요
    update_oled_display();     // 화면 출력에 25ms 소요
 
    // 메인 루프 한 바퀴에 총 60ms 소요
    if (read_emergency_pin()) {
        emergency_stop();
    }
}

작업자가 위험을 감지하고 10밀리초 동안 비상 정지 버튼을 눌렀다가 뗐다고 가정해 봅니다. CPU가 화면을 갱신하는 25밀리초 동안에는 버튼 검사 코드에 닿지 못합니다. 뒤늦게 검사문에 도착했을 때는 이미 신호가 사라진 뒤입니다.

이처럼 CPU가 반복문 안에서 핀의 상태를 주기적으로 확인하는 방식을 폴링(Polling)이라고 부릅니다. 폴링은 CPU를 쉬지 않고 쓰면서도 짧은 신호를 영구히 놓치는 문제를 안고 있습니다.

인터럽트는 하던 일을 잠시 멈추고 핸들러를 먼저 실행한다

집에서 책을 읽거나 공부할 때 문밖을 계속 내다볼 필요는 없습니다. 손님이 오면 현관문 초인종을 누르면 되기 때문입니다. 초인종이 울리면 읽던 책에 책갈피를 꽂아 두고 나가서 문을 열어 줍니다.

마이크로컨트롤러에서 초인종 역할을 하는 하드웨어 기술이 바로 인터럽트(Interrupt)입니다. 핀의 전압이 변하면 하드웨어가 CPU 코어에 신호를 보냅니다. CPU는 실행 중이던 코드를 잠깐 멈추고 해당 사건을 먼저 처리합니다.

외부 핀에서 들어온 신호가 CPU로 전달되기까지는 세 장치를 거칩니다.

  1. SYSCFG (시스템 구성 제어기): 어떤 포트의 몇 번 핀을 인터럽트 신호선에 연결할지 고릅니다.
  2. EXTI (외부 인터럽트 제어기): 핀 전압이 올라갈 때 반응할지 내려갈 때 반응할지 엣지를 감지합니다.
  3. NVIC (중첩 인터럽트 제어기): 코어 안에서 여러 사건의 우선순위를 따져 누구를 먼저 처리할지 판정합니다.

인터럽트가 발생하면 CPU는 하던 작업의 상태를 메모리에 자동으로 저장해 둡니다. 그리고 사건 전담 함수로 즉시 이동합니다. 처리가 끝나면 저장해 둔 위치로 돌아와 원래 작업을 마저 이어갑니다.

[참고] 하드웨어 자동 상태 보존

Arm Cortex-M 코어는 인터럽트가 걸리는 순간 실행 위치와 기본 연산 레지스터들을 하드웨어 회로가 스택 메모리에 자동으로 저장합니다. 덕분에 일반 C 함수를 어셈블리어 포장 없이 바로 핸들러로 쓸 수 있습니다.

시간의 흐름에 따라 메인 루프와 두 개의 인터럽트가 어떻게 전환되는지 도면으로 확인합니다.

이벤트 A와 B의 발생에 따라 3개 레인 사이에서 일어나는 인터럽트 선점과 실행 복귀 타임라인

그림: 3개 실행 레인에서 일어나는 인터럽트 발생과 우선순위 선점 및 순차 복귀 시퀀스

메인 코드가 돌던 중 이벤트 A가 들어오면 하던 일을 보존하고 낮은 우선순위 ISR로 이동합니다. 처리 도중 더 긴급한 이벤트 B가 도착하면 CPU는 현재 함수를 중단하고 최상위 ISR을 먼저 완결합니다. 그 뒤 중단되었던 낮은 ISR을 마치고 메인 루프로 복귀합니다.

NVIC는 우선순위 숫자가 작은 인터럽트를 먼저 처리한다

여러 인터럽트가 동시에 오거나 처리 중에 새 신호가 도착하면 누구를 먼저 실행할지 순서를 정해야 합니다. 이 역할을 맡는 핵심 장치가 NVIC(Nested Vectored Interrupt Controller)입니다.

우선순위의 가장 중요한 기본 원칙은 숫자가 작을수록 순위가 높다는 점입니다. 0번이 가장 긴급한 최상위 순위이고 숫자가 커질수록 우선순위가 낮아집니다.

현재 실행 중인 함수를 중단시키고 즉시 끼어들 수 있는 권한을 선점(Preemption)이라고 부릅니다.

  • 선점 우선순위가 높은 인터럽트는 이미 실행 중인 다른 인터럽트를 가로채고 먼저 실행됩니다.
  • 선점 우선순위가 같은 인터럽트끼리는 실행 도중 새치기할 수 없으며 앞선 작업이 끝날 때까지 대기합니다.

모터 비상 정지나 전원 이상 감지처럼 즉시 반응해야 하는 신호에는 0번이나 1번 같은 높은 우선순위를 줍니다. 반면 주기적인 센서 읽기나 통신 수신에는 2번이나 3번 같은 낮은 우선순위를 배정합니다.

핸들러는 짧게 쓰고 플래그를 지운다

하드웨어 신호에 맞춰 자동으로 실행되는 함수를 인터럽트 서비스 루틴(ISR; Interrupt Service Routine) 또는 인터럽트 핸들러라고 부릅니다. 핸들러는 일반 함수와 실행 환경이 다르므로 다음 규칙을 지킵니다.

첫째, 핸들러 안에서는 오래 걸리는 작업을 하지 않습니다. 문자열 출력(printf)이나 복잡한 계산은 루프에 맡깁니다. 핸들러는 신호가 왔다는 사실만 깃발 변수(플래그)에 표시하고 즉시 빠져나옵니다.

둘째, 지연 대기 함수를 호출하지 않습니다. HAL_Delay처럼 타이머 인터럽트를 쓰는 함수를 핸들러 안에서 부르면 타이머 카운터가 오르지 않아 보드가 멈춰 버립니다.

셋째, 핸들러와 메인 루프가 함께 읽고 쓰는 변수에는 반드시 volatile을 붙입니다. 컴파일러가 변수 값을 레지스터에 보관하지 않고 매번 메모리에서 새로 읽어오도록 만들기 위함입니다.

넷째, 핸들러가 끝나기 전에 보류 플래그를 지워 줍니다. 신호가 처리되었다는 표시를 레지스터에서 지우지 않으면 함수가 끝나자마자 같은 인터럽트가 무한히 다시 호출됩니다.

[참고] 1을 써서 지우는 보류 플래그

STM32의 외부 인터럽트 플래그 레지스터(EXTI_PR)는 해당 비트에 1을 써야 지워지는 특성을 가집니다. 0을 쓰면 비트가 지워지지 않으므로 EXTI->PR = (1U << 0); 처럼 명시적으로 1을 기록해 초기화합니다.

버튼 인터럽트를 레지스터로 설정한다

다음은 PA0 핀에 연결된 버튼 신호를 인터럽트로 감지하여 누른 횟수를 세는 예제입니다.

#include <stdint.h>
 
// 메인 루프와 핸들러가 함께 공유하는 변수
volatile uint32_t g_button_count = 0;
 
// 레지스터 주소 정의 (STM32F4 기준)
#define RCC_AHB1ENR   (*(volatile uint32_t *)0x40023830)
#define RCC_APB2ENR   (*(volatile uint32_t *)0x40023844)
#define GPIOA_MODER   (*(volatile uint32_t *)0x40020000)
#define SYSCFG_EXTICR1 (*(volatile uint32_t *)0x40013808)
#define EXTI_IMR      (*(volatile uint32_t *)0x40013C00)
#define EXTI_RTSR     (*(volatile uint32_t *)0x40013C08)
#define EXTI_PR       (*(volatile uint32_t *)0x40013C14)
#define NVIC_ISER0    (*(volatile uint32_t *)0xE000E100)
 
void exti0_init(void) {
    // 1. GPIOA 및 SYSCFG 클럭 켜기
    RCC_AHB1ENR |= (1U << 0);
    RCC_APB2ENR |= (1U << 14);
 
    // 2. PA0 핀을 입력 모드(00)로 설정
    GPIOA_MODER &= ~(3U << 0);
 
    // 3. EXTI 0번 라인에 PA0 연결
    SYSCFG_EXTICR1 &= ~(0xFU << 0);
 
    // 4. 전압이 올라갈 때 감지하도록 설정하고 신호 허용
    EXTI_RTSR |= (1U << 0); // 상승 엣지 감지 활성화
    EXTI_IMR  |= (1U << 0); // 인터럽트 마스크 해제
 
    // 5. NVIC에서 6번 인터럽트 라인(EXTI0) 활성화
    NVIC_ISER0 = (1U << 6);
}
 
// 0번 라인 전담 하드웨어 핸들러 함수
void EXTI0_IRQHandler(void) {
    if (EXTI_PR & (1U << 0)) {
        // 처리 완료 표시 (해당 비트에 1을 써서 클리어)
        EXTI_PR = (1U << 0);
 
        // 카운트 1 증가 후 즉시 복귀
        g_button_count++;
    }
}

이 코드를 적용하면 버튼이 눌리는 순간 CPU가 하던 일을 멈추고 EXTI0_IRQHandler로 들어갑니다. 숫자를 1 올리고 즉시 원래 루프로 복귀합니다. 메인 루프의 실행 속도와 상관없이 모든 입력을 빠짐없이 감지합니다.

인터럽트로 외부 신호를 놓치지 않고 감지할 수 있게 되었다면, 타이머 카운터와 PWM 제어를 통해 일정한 주기의 펄스 신호를 직접 만들어 봅니다.

참고 문서

설명이 어렵거나 잘못된 부분을 발견하셨나요?

문서 수정 의견 보내기