Devin.KR

인터럽트 - 폴링에서 이벤트로

개발자KR 조회 7

이 장에서 배우는 것

컨베이어 제어기는 물체 감지 센서를 읽고 모터를 움직이며 운전 상태를 표시한다. 기본서에서는 반복문에서 입력을 읽는 방식으로 이런 동작을 만들 수 있었다. 그러나 통신 처리나 표시 작업이 길어지면 센서를 다시 읽기까지 시간이 벌어진다. 입력이 그 사이에 나타났다 사라지면 프로그램은 변화가 있었다는 사실조차 알지 못한다.

인터럽트(interrupt)는 주변장치의 요청에 따라 실행 흐름을 잠시 바꾸는 장치다. 이 장에서는 폴링(polling)과 인터럽트가 사건을 발견하는 방식의 차이를 살펴보고, 컨베이어의 감지 입력과 정지 요청을 처리한다. PC에서는 실제 인터럽트 제어기를 사용할 수 없으므로, 순서가 정해진 모의 계층으로 벡터 선택과 우선순위 동작을 확인한다.

  • 폴링 주기 사이의 짧은 입력을 놓치는 이유와 인터럽트 요청이 남는 조건을 설명한다.
  • 벡터 테이블(vector table)이 요청을 처리 함수에 연결하는 과정을 이해한다.
  • 인터럽트 서비스 루틴(Interrupt Service Routine, ISR)을 짧게 구성하고 volatile의 역할과 한계를 구별한다.
  • 대기 중인 요청의 선택과 실행 중인 처리의 선점을 우선순위로 설명한다.

문제 상황

컨베이어 입구의 광센서는 물체가 지나갈 때 짧은 펄스를 낸다. 제어기는 주기적으로 센서를 읽지만, 같은 반복문에서 운전 기록도 만든다. 센서가 켜졌다 꺼지는 시간이 두 번의 읽기 사이에 모두 들어가면, 두 번의 읽기는 모두 꺼짐을 반환한다. 반복문이 정상적으로 계속 실행되고 있다는 사실만으로 입력 검출이 보장되지는 않는다.

반복문을 빠르게 돌리면 놓칠 가능성을 줄일 수 있다. 다만 반복문에 작업이 추가될 때마다 가장 긴 읽기 간격을 다시 확인해야 한다. UART 전송 완료를 기다리는 코드 하나가 들어가도 그 간격은 달라진다. 반대로 센서의 변화가 발생한 순간 주변장치가 요청을 기록한다면, CPU가 조금 늦게 응답하더라도 기록된 요청을 보고 처리할 수 있다.

이 제어기에는 정지 요청 입력도 있다. 센서 처리 중 정지 요청이 들어왔을 때 센서 처리가 끝날 때까지 기다려도 되는지 결정해야 한다. 진단용 입력 역시 처리해야 하지만 정지보다 먼저 실행할 이유는 없다. 따라서 인터럽트를 쓴다는 결정에 더해, 요청별 처리량과 실행 순서를 설계해야 한다.

예제의 모터 출력은 정지 요청을 받으면 0이 되는 단순한 모의 레지스터다. 실제 설비에서는 소프트웨어가 출력을 바꾸는 시간과 기계가 멈추는 시간이 다르다. 여기서는 물리적인 정지 시간을 계산하지 않고, 어떤 처리 함수가 먼저 실행되는지에만 집중한다.

입력 변화가 처리 함수에 도착하기까지

폴링은 프로그램이 정한 시점에 입력의 현재 상태를 읽는다. 에지 검출 인터럽트는 입력의 변화 조건을 하드웨어가 감시하고, 조건이 맞으면 요청을 보류 상태로 남긴다. CPU는 인터럽트가 허용되는 지점에서 현재 실행을 중단하고 해당 처리 함수로 이동한다. 처리가 끝나면 중단했던 실행으로 돌아간다.

현재 입력값과 발생 기록은 다르다. 센서 입력이 이미 0으로 돌아왔더라도 상승 에지가 발생했다는 상태 비트는 남아 있을 수 있다. 이것이 짧은 입력을 처리하는 데 도움이 된다. 다만 하드웨어가 인식할 수 있는 최소 펄스 폭, 입력 필터, 에지 설정 등의 조건을 먼저 만족해야 한다.

두 폴링 사이에 끝난 펄스도 에지 요청이 보존되면 나중에 처리할 수 있다

요청 비트 하나가 모든 사건을 세어 주는 것은 아니다. 비트가 이미 1인 동안 같은 사건이 다시 발생하면 여전히 1일 수 있다. 이런 장치에서는 두 번의 발생과 한 번의 발생을 구분할 수 없다. 사건 횟수를 빠짐없이 알아야 한다면 주변장치의 계수 기능과 요청 보존 방식을 확인해야 한다. 인터럽트를 켰다는 사실만으로 사건 손실이 사라지지는 않는다.

벡터는 요청과 함수의 연결이다

인터럽트 제어기는 요청 번호를 기준으로 실행할 진입점을 선택한다. 벡터 테이블은 이 연결 정보를 제공한다. 예를 들어 센서 요청에는 sensor_isr, 정지 요청에는 stop_isr를 연결한다. 연결이 잘못되면 하드웨어가 요청을 올바르게 감지해도 엉뚱한 함수가 실행된다.

구체적인 테이블 형식은 프로세서에 따라 다르다. Cortex-M에서는 벡터 테이블의 첫 항목에 초기 주 스택 포인터 값이 놓이고, 이어서 리셋과 예외 처리 진입점들이 배치된다. 주변장치 인터럽트 항목은 그 구조 안의 정해진 위치를 사용한다. 따라서 실제 테이블 전체를 단순한 C 함수 포인터 배열과 같다고 생각하면 안 된다.

완성 코드의 배열은 요청 번호로 함수를 고르는 원리만 나타낸다. 실제 MCU에서는 시작 코드, 링커 설정, 벡터 테이블 위치, 제조사가 정한 처리 함수 이름이 함께 맞아야 한다. 일반 C 함수를 임의의 배열에 넣었다고 하드웨어 인터럽트에 연결되는 것은 아니다.

처리 후에는 요청 원인을 해제한다

함수에 진입했다는 사실만으로 모든 요청 원인이 사라지지는 않는다. 주변장치 상태를 읽거나 지정된 비트에 값을 써서 원인을 해제해야 하는 경우가 많다. 원인이 남으면 복귀하자마자 다시 인터럽트가 발생할 수 있다. 레벨 조건으로 발생하는 요청이라면 입력 자체가 계속 활성 상태인 동안 요청이 유지될 수도 있다.

해제 순서는 주변장치 설명서를 따라야 한다. 상태 비트에 1을 써서 지우는 장치, 특정 레지스터를 읽어야 지워지는 장치, 데이터를 먼저 꺼내야 하는 장치는 처리 방법이 다르다. 이 장의 hal_ack는 모의 요청을 지우는 함수이며 실제 장치의 해제 코드를 대신하는 범용 함수가 아니다.

ISR은 짧게, volatile은 정확하게 사용한다

ISR은 요청 원인을 확인하고, 필요한 최소 작업을 수행하고, 복귀하도록 구성한다. 센서가 감지되었다는 이유로 문자열을 만들거나 통신 응답 전체를 기다리면 다른 실행이 그 시간만큼 영향을 받는다. 특히 진행 조건이 다른 인터럽트에 의존하는 대기 코드는 해당 인터럽트가 실행되지 못하는 상황에서 빠져나오지 못할 수 있다.

예제의 정지 ISR은 요청을 해제하고 모터 출력을 0으로 쓴다. 센서 ISR은 요청을 해제하고 모의 확인 횟수를 올린다. 출력할 문장은 ISR이 만들지 않는다. 모의 계층이 진입과 복귀를 배열에 기록하고, 일반 실행 문맥이 그 배열을 나중에 출력한다. 처리 함수의 실행 경로에 콘솔 출력 시간을 넣지 않기 위해서다.

짧다는 기준은 줄 수 자체가 아니다. 한 줄의 함수 호출도 내부에서 오래 기다릴 수 있다. 반대로 몇 줄의 레지스터 접근이 일정한 시간에 끝날 수도 있다. 호출한 함수까지 포함해 반복 횟수가 제한되는지, 다른 실행의 진행을 기다리는지, 가장 오래 걸리는 경로가 무엇인지 살펴야 한다.

volatile은 프로그램 바깥의 요인으로 값이 달라지거나 접근 자체에 의미가 있는 객체를 다룰 때 사용하는 C 한정자다. MCU의 메모리 매핑 레지스터 선언에서 흔히 볼 수 있다. 해당 객체의 접근을 단순한 일반 변수 접근처럼 생략하거나 합쳐도 된다고 가정하지 않도록 컴파일러에 알린다. 구체적인 하드웨어 접근 방식은 컴파일러와 장치의 규약도 따라야 한다.

그러나 volatile은 잠금이 아니며 여러 실행 문맥 사이의 동기화를 만들지 않는다. volatile 변수에 대한 증가 연산도 읽기, 계산, 쓰기로 나뉠 수 있다. 두 실행 문맥이 같은 값을 변경할 때 생기는 문제나 사건 전달 방식은 다음 장에서 다룬다. 여기서는 volatile을 붙이면 데이터 공유 문제가 해결된다고 해석하지 않는 것이 중요하다.

PC 프로그램의 모의 레지스터에도 volatile을 붙이지만 실제 주변장치에 연결되지는 않는다. 모든 코드는 하나의 실행 흐름에서 순서대로 호출된다. 따라서 이 예제는 호스트 스레드 사이의 데이터 공유 실험이 아니며, volatile의 동기화 효과를 증명하는 코드도 아니다.

우선순위는 대기 순서와 선점을 결정한다

우선순위에는 서로 구분할 두 상황이 있다. 여러 요청이 대기 중일 때 어느 것을 먼저 시작할지 선택하는 상황과, 이미 실행 중인 ISR을 다른 ISR이 중단할 수 있는지 판단하는 상황이다. 실행 중인 처리를 잠시 중단하고 다른 처리를 먼저 수행하는 동작을 선점(preemption)이라고 한다.

예제에서는 작은 숫자가 높은 우선순위를 뜻하도록 정한다. 정지 요청은 0, 센서는 2, 진단은 3이다. 일반 실행 문맥에는 모든 요청보다 낮은 우선순위에 해당하는 내부 값 256을 사용한다. 이 값은 시뮬레이터의 구분값이며 실제 하드웨어에 설정하는 우선순위가 아니다.

진단과 센서 요청이 함께 대기하면 센서부터 시작한다. 센서 ISR 실행 중 정지 요청이 들어오면 정지 ISR이 센서 처리를 선점한다. 정지 ISR이 끝나면 센서 ISR의 남은 부분으로 돌아간다. 같은 우선순위의 요청은 이 예제에서 현재 ISR을 선점하지 못하며, 대기 요청끼리 값이 같다면 배열에서 먼저 찾은 요청을 선택한다.

센서 ISR을 정지 ISR이 선점하고 정지 처리 뒤에는 센서 ISR로 복귀한다

Cortex-M에서도 일반적으로 작은 우선순위 숫자가 높은 긴급도를 나타내지만, 구현된 우선순위 비트 수와 그룹 설정을 확인해야 한다. 일부 구성에서는 우선순위 필드가 선점 우선순위와 하위 우선순위로 나뉜다. 하위 우선순위는 대기 요청의 선택에 쓰이며 그 차이만으로 선점이 일어나지는 않는다. 예제에는 이런 그룹 구분을 넣지 않는다.

높은 우선순위를 부여해도 응답이 즉시 이루어진다는 뜻은 아니다. 인터럽트가 마스킹되어 있거나 더 높은 우선순위의 처리가 실행 중이면 기다릴 수 있다. 또한 낮은 우선순위의 ISR이라도 너무 자주 실행되면 일반 코드가 사용할 시간이 줄어든다. 우선순위 설정과 ISR 작업량은 함께 검토해야 한다.

PC 모의 계층과 실제 MCU 및 FreeRTOS 환경의 대응
예제 요소실제 환경의 대응적용 시 주의점
vectors 배열MCU 시작 코드의 벡터 테이블FreeRTOS의 범용 벡터 등록 API가 아니라 하드웨어와 포트의 영역이다.
priorities 배열Cortex-M의 CMSIS 함수 NVIC_SetPriorityFreeRTOS API가 아니다. 구현 비트 수와 우선순위 그룹을 확인한다.
hal_ack주변장치별 요청 원인 해제FreeRTOS가 모든 주변장치의 상태 비트를 대신 지우지 않는다.
ISR에서 일반 처리로 작업 전달예를 들어 vTaskNotifyGiveFromISR이 장의 코드에서는 호출하지 않는다. FromISR 계열도 해당 포트가 허용한 인터럽트 우선순위에서 사용한다.
ISR 종료 후 태스크 전환 요청지원 포트의 portYIELD_FROM_ISR포트별 매크로이며 인자와 사용 규약을 확인한다.

FreeRTOS를 사용해도 주변장치 ISR의 연결과 요청 해제 책임은 사라지지 않는다. 특히 일부 Cortex-M 포트에서는 높은 긴급도의 인터럽트가 커널 API를 호출하지 못하도록 제한한다. FromISR이라는 이름만 보고 어느 우선순위에서나 호출할 수 있다고 판단하면 안 된다. 여기서는 API의 위치만 구별하고, 태스크에 실제로 작업을 전달하는 설계는 다루지 않는다.

사실 확인에는 Arm CMSIS의 인터럽트 제어 설명과 FreeRTOS의 Cortex-M 인터럽트 우선순위 안내를 참고할 수 있다. 본문의 코드와 그림은 동작 원리를 설명하기 위해 별도로 구성한 예제다.

완성 코드

다음 프로그램을 interrupts.c로 저장한다. hal_로 시작하는 함수는 hal_sim 계층이다. 마지막 반복문은 모의 시간마다 요청 처리를 진행한 뒤 일반 작업 하나에 실행 기회를 주는 협동형 실행기의 최소 골격이다. 태스크 전환이나 운영체제 타이머는 사용하지 않는다.

시간 1에는 센서 펄스를 발생시키고, 센서 ISR 안의 지정된 관찰 지점에서 정지 요청을 주입한다. 시간 2에는 진단과 센서 요청을 함께 보류한다. 실제 CPU는 허용된 여러 명령 경계에서 인터럽트를 받아들일 수 있지만, 이 프로그램은 hal_checkpoint에서만 중첩 처리를 재현한다. 따라서 출력으로 실행 순서를 확인할 수는 있어도 실제 지연 시간을 측정할 수는 없다.

#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>

enum irq_id {
    IRQ_SENSOR,
    IRQ_STOP,
    IRQ_DIAG,
    IRQ_COUNT
};

enum { MAIN_PRIORITY = 256, TRACE_CAPACITY = 16 };

struct sim_registers {
    volatile unsigned sensor_level;
    volatile unsigned motor_enable;
    volatile unsigned sensor_ack;
    volatile unsigned diag_ack;
};

struct trace_entry {
    unsigned tick;
    enum irq_id irq;
    bool entering;
};

typedef void (*isr_fn)(void);

static struct sim_registers regs;
static bool pending[IRQ_COUNT];
static unsigned now;
static int active_priority = MAIN_PRIORITY;
static bool inject_stop;
static struct trace_entry trace[TRACE_CAPACITY];
static size_t trace_count;

static const int priorities[IRQ_COUNT] = {
    [IRQ_SENSOR] = 2,
    [IRQ_STOP] = 0,
    [IRQ_DIAG] = 3
};

static const char *const names[IRQ_COUNT] = {
    [IRQ_SENSOR] = "sensor",
    [IRQ_STOP] = "stop",
    [IRQ_DIAG] = "diag"
};

static void hal_dispatch(void);

static void hal_raise(enum irq_id irq)
{
    pending[irq] = true;
}

static void hal_ack(enum irq_id irq)
{
    pending[irq] = false;
}

static void hal_checkpoint(void)
{
    if (inject_stop) {
        inject_stop = false;
        hal_raise(IRQ_STOP);
        hal_dispatch();
    }
}

static void sensor_isr(void)
{
    hal_ack(IRQ_SENSOR);
    ++regs.sensor_ack;
    hal_checkpoint();
}

static void stop_isr(void)
{
    hal_ack(IRQ_STOP);
    regs.motor_enable = 0U;
}

static void diag_isr(void)
{
    hal_ack(IRQ_DIAG);
    ++regs.diag_ack;
}

static const isr_fn vectors[IRQ_COUNT] = {
    [IRQ_SENSOR] = sensor_isr,
    [IRQ_STOP] = stop_isr,
    [IRQ_DIAG] = diag_isr
};

static void hal_record(enum irq_id irq, bool entering)
{
    assert(trace_count < TRACE_CAPACITY);
    trace[trace_count++] = (struct trace_entry) {
        .tick = now,
        .irq = irq,
        .entering = entering
    };
}

static void hal_dispatch(void)
{
    for (;;) {
        int selected = -1;
        int best_priority = active_priority;

        for (int i = 0; i < IRQ_COUNT; ++i) {
            if (pending[i] && priorities[i] < best_priority) {
                selected = i;
                best_priority = priorities[i];
            }
        }

        if (selected < 0) {
            return;
        }

        enum irq_id irq = (enum irq_id)selected;
        int saved_priority = active_priority;

        active_priority = priorities[irq];
        hal_record(irq, true);
        vectors[irq]();
        hal_record(irq, false);
        active_priority = saved_priority;
    }
}

static void foreground_step(void)
{
    for (size_t i = 0; i < trace_count; ++i) {
        const struct trace_entry *entry = &trace[i];

        printf("t=%u %s %s\n",
               entry->tick,
               entry->entering ? "enter" : "leave",
               names[entry->irq]);
    }
    trace_count = 0U;

    if (now % 2U == 0U) {
        printf("t=%u poll sensor=%u motor=%u\n",
               now, regs.sensor_level, regs.motor_enable);
    }
}

int main(void)
{
    regs.motor_enable = 1U;

    for (now = 0U; now < 4U; ++now) {
        if (now == 1U) {
            regs.sensor_level = 1U;
            hal_raise(IRQ_SENSOR);
            regs.sensor_level = 0U;
            inject_stop = true;
        }

        if (now == 2U) {
            hal_raise(IRQ_DIAG);
            hal_raise(IRQ_SENSOR);
        }

        hal_dispatch();
        foreground_step();
    }

    printf("done motor=%u sensor_ack=%u diag_ack=%u\n",
           regs.motor_enable, regs.sensor_ack, regs.diag_ack);
    return 0;
}

この 예제의 sensor_ack와 diag_ack는 모의 처리 횟수를 관찰하는 값이다. 실제 MCU의 승인 레지스터 형식을 흉내 낸 것은 아니다. pending 역시 요청당 한 비트에 해당하는 상태이므로 처리 전에 같은 요청을 여러 번 올려도 횟수는 누적되지 않는다.

줄별 해설

요청 번호와 모의 레지스터

enum irq_id의 항목은 pending, priorities, names, vectors 배열에서 같은 요청을 가리키는 공통 색인이다. IRQ_COUNT는 요청의 수이며 실행할 요청 번호로 사용하지 않는다. 이름으로 배열 위치를 지정했으므로 정지 요청의 함수와 우선순위를 각각 읽어도 대응을 확인하기 쉽다.

struct sim_registers의 네 멤버에는 volatile을 붙였다. sensor_level은 현재 입력, motor_enable은 출력, 나머지 두 멤버는 관찰용 처리 횟수다. 정적 저장 기간을 가진 regs와 배열은 프로그램 시작 시 0으로 초기화된다. main의 첫 대입만 모터 출력을 1로 바꾸므로 초기 상태가 정해진다.

typedef void (*isr_fn)(void)는 인자와 반환값이 없는 함수 포인터 형식을 선언한다. vectors 배열의 각 원소는 이 형식을 따른다. 이 선언은 PC에서 호출할 함수의 형식을 통일할 뿐이며, 실제 MCU의 예외 진입 규약이나 레지스터 저장 동작까지 정의하지 않는다.

요청을 올리고 처리 원인을 지우는 부분

hal_raise의 대입은 요청을 보류 상태로 만든다. 이 함수 자체는 ISR을 호출하지 않는다. 요청 발생과 실행 허용 여부를 분리했기 때문에 두 요청을 함께 대기시킨 뒤 우선순위로 선택하는 실험을 할 수 있다. hal_ack는 해당 상태를 false로 바꾼다.

hal_checkpoint는 inject_stop이 참일 때만 정지 요청을 발생시킨다. 먼저 inject_stop을 거짓으로 바꾸므로 한 번의 실험에서 정지 요청이 반복 주입되지 않는다. 이어지는 hal_dispatch 호출은 현재 우선순위를 유지한 채 새 요청을 선택한다. 이 위치가 중첩 인터럽트를 보여 주는 모의 관찰 지점이다.

sensor_isr의 첫 줄은 보류 원인을 지우고, 다음 줄은 처리 횟수를 올린다. 마지막 호출은 PC 실험을 위한 장치이므로 실제 센서 ISR에 그대로 옮기지 않는다. stop_isr는 요청을 지운 뒤 출력을 내린다. diag_isr는 요청 해제와 횟수 기록만 수행한다.

우선순위를 비교하고 복귀하는 부분

hal_dispatch의 바깥 반복문은 현재 실행 가능한 요청이 없어질 때까지 선택을 반복한다. selected의 초기값 -1은 선택된 요청이 없다는 뜻이다. best_priority를 active_priority로 시작하므로 현재 실행보다 높은 요청만 첫 후보가 될 수 있다.

안쪽 조건의 < 비교는 숫자가 더 작아야 한다는 규칙을 구현한다. 후보가 선택되면 best_priority도 갱신한다. 이후에는 그 후보보다 더 높은 요청만 선택을 바꿀 수 있다. 같은 숫자에는 비교가 성립하지 않으므로 먼저 찾은 후보를 유지한다.

saved_priority는 인터럽트 처리 전에 실행하던 문맥의 우선순위를 보관한다. active_priority를 바꾸고 진입 기록을 남긴 다음 vectors[irq]()로 처리 함수를 호출한다. 함수가 반환하면 복귀 기록을 남기고 이전 우선순위를 되살린다. 중첩 호출에서는 지역 변수 saved_priority가 호출마다 따로 존재하기 때문에 정지 처리 후 센서의 우선순위로 돌아간다.

hal_record는 정해진 크기의 배열에 기록 하나를 추가한다. assert는 실험 조건이 바뀌어 배열 용량을 넘기는 일을 발견하기 위한 모의 계층의 검사다. 실제 ISR의 오류 처리 정책을 제안하는 코드는 아니다. 이 실행에서는 일반 작업 사이에 쌓이는 기록이 최대 여섯 개이므로 용량 16 안에 들어간다.

일반 실행 문맥에서 관찰하는 부분

foreground_step은 쌓인 기록을 순서대로 출력한 뒤 trace_count를 0으로 되돌린다. 그다음 짝수 시간에만 입력을 읽는다. 요청 처리와 출력은 같은 실행 흐름에서 순차적으로 이루어지므로 여기에는 동시에 배열을 읽고 쓰는 상황이 없다.

main의 시간 1 구간은 입력을 1로 만들고 요청을 남긴 뒤 다시 0으로 만든다. 요청이 현재 입력과 별도로 남는다는 점을 보이기 위한 설정이다. 시간 2의 센서 요청은 대기 순서 실험을 위해 직접 주입한다. 따라서 마지막 sensor_ack 값은 센서 요청을 처리한 횟수이며 물체 수라고 해석하지 않는다.

매 시간의 hal_dispatch 다음에 foreground_step을 호출하는 두 줄이 실행기의 전부다. ISR이 일반 작업보다 먼저 진행되고, 일반 작업은 반환해야 다음 시간으로 넘어간다. 이 최소 구조만으로 필요한 관찰이 가능하므로 실제 시간 대기나 pthread 실행 흐름은 추가하지 않는다.

실행 결과

macOS와 Linux에서 다음 명령으로 빌드하고 실행한다. 프로그램은 C11 표준 기능만 사용하며 추가 라이브러리 소스가 필요하지 않다. -pthread는 지정된 빌드 조건에 포함하지만 코드에서 별도 스레드를 생성하지 않는다. 제시한 옵션으로 컴파일할 때 경고가 발생하지 않도록 구성했다.

cc -std=c11 -Wall -Wextra -pthread interrupts.c -o interrupts
./interrupts

예상 출력은 다음과 같다. 모의 시간은 실제 밀리초가 아니라 입력 순서를 구별하는 정수다.

t=0 poll sensor=0 motor=1
t=1 enter sensor
t=1 enter stop
t=1 leave stop
t=1 leave sensor
t=2 enter sensor
t=2 leave sensor
t=2 enter diag
t=2 leave diag
t=2 poll sensor=0 motor=0
done motor=0 sensor_ack=2 diag_ack=1

시간 0과 2의 폴링 결과는 모두 sensor=0이다. 폴링 결과만 보면 시간 1의 펄스를 알아낼 수 없다. 그러나 센서 요청은 보류 상태에 남았기 때문에 sensor 진입 기록이 출력된다. 입력의 현재값을 읽는 것과 발생 기록을 처리하는 것의 차이가 드러난다.

시간 1에서는 sensor 진입과 복귀 사이에 stop의 진입과 복귀가 들어 있다. 이것이 중첩 처리 순서다. 시간 2에서는 진단 요청을 먼저 올렸는데도 sensor가 먼저 실행된다. 호출한 순서보다 설정한 우선순위가 선택을 결정하기 때문이다. 시간 3에는 기록도 폴링도 없어 출력 줄이 없다.

이 출력의 시간 1은 두 ISR의 실행 시간이 같다는 뜻이 아니다. 모의 계층이 ISR 실행 중 now를 증가시키지 않기 때문에 같은 표시가 붙는다. 실제 처리 시간과 응답 지연은 타이머나 계측 핀 등 별도의 관찰 수단으로 확인해야 한다.

실무에서 자주 틀리는 것

다음 코드는 앞의 완성 프로그램을 기준으로 한 교체용 부분 코드다. 틀린 코드를 완성 프로그램에 추가해서 실행하는 방식으로 사용하지 않는다.

요청 원인을 남긴 채 복귀한다

다음 코드는 모터 출력을 내리지만 보류 요청을 지우지 않는다. 예제의 선택기는 stop_isr가 반환해도 같은 요청을 다시 선택한다. 실제 장치에서도 원인이 남아 있으면 재진입이 반복될 수 있다.

static void stop_isr(void)
{
    regs.motor_enable = 0U;
}

모의 계층에서는 hal_ack를 넣어 고친다. 실제 코드에서는 주변장치가 요구하는 해제 순서를 적용해야 하므로 아래 순서를 모든 장치에 그대로 적용하지 않는다.

static void stop_isr(void)
{
    hal_ack(IRQ_STOP);
    regs.motor_enable = 0U;
}

ISR 안에서 입력이 바뀔 때까지 기다린다

센서가 꺼질 때까지 반복해서 읽으면 ISR의 길이를 입력 유지 시간에 맡기게 된다. 입력이 계속 켜져 있으면 복귀도 계속 늦어진다. volatile은 반복해서 읽게 하는 데 관련될 뿐, 입력을 바꾸거나 대기 시간을 제한하지 않는다.

static void sensor_isr(void)
{
    hal_ack(IRQ_SENSOR);
    while (regs.sensor_level != 0U) {
    }
    ++regs.sensor_ack;
}

이 예제의 요구는 감지 요청을 확인하는 것이므로 현재 요청만 처리하고 복귀한다. 입력이 꺼지는 사건도 필요하다면 지원되는 에지 검출 조건을 별도로 설계해야 한다.

static void sensor_isr(void)
{
    hal_ack(IRQ_SENSOR);
    ++regs.sensor_ack;
}

큰 숫자를 높은 우선순위로 해석한다

다음 설정에서는 정지 요청의 숫자 3이 가장 크지만 긴급도는 가장 낮다. 센서 처리 중 정지 요청이 들어와도 선점할 수 없다. 설정을 읽을 때 숫자의 크기와 긴급도를 따로 말하면 혼동을 줄일 수 있다.

static const int priorities[IRQ_COUNT] = {
    [IRQ_SENSOR] = 0,
    [IRQ_STOP] = 3,
    [IRQ_DIAG] = 2
};

정지가 센서를 선점해야 한다면 정지에 더 작은 숫자를 배정한다. 실제 장치에서는 숫자뿐 아니라 구현 비트와 그룹 설정도 함께 확인한다.

static const int priorities[IRQ_COUNT] = {
    [IRQ_SENSOR] = 2,
    [IRQ_STOP] = 0,
    [IRQ_DIAG] = 3
};

volatile이면 ISR에서 출력해도 된다고 생각한다

volatile은 표준 입출력 함수가 ISR에서 사용하기 적합한지와 관계가 없다. 다음 코드를 실제 MCU로 옮기면 printf가 사용하는 잠금, 버퍼, 출력 대기 경로까지 ISR의 실행 조건에 포함된다. PC에서 일반 함수로 호출해 동작했다는 결과만으로 MCU에서도 적합하다고 판단할 수 없다.

static void stop_isr(void)
{
    hal_ack(IRQ_STOP);
    regs.motor_enable = 0U;
    printf("stopped motor=%u\n", regs.motor_enable);
}

ISR에는 필요한 출력 변경만 남긴다. 완성 프로그램처럼 관찰용 출력은 일반 실행 문맥에서 수행한다. 실제 ISR에서 기록을 일반 코드로 전달할 때 필요한 데이터 공유 규칙은 다음 장의 주제다.

static void stop_isr(void)
{
    hal_ack(IRQ_STOP);
    regs.motor_enable = 0U;
}

한눈에 보기

인터럽트를 설계할 때 구분해야 하는 역할과 한계
요소하는 일확인할 한계
폴링읽는 시점의 상태를 확인한다.읽기 사이에 끝난 변화를 놓칠 수 있다.
인터럽트 요청처리가 필요한 사건을 보류한다.한 비트가 여러 발생 횟수를 보존하지는 않는다.
벡터 테이블요청을 처리 진입점에 연결한다.실제 형식과 위치는 프로세서 및 시작 코드에 따른다.
ISR원인을 처리하고 필요한 최소 작업을 수행한다.대기와 긴 호출은 다른 실행을 지연시킨다.
volatile특별한 의미가 있는 객체 접근을 표현한다.원자성이나 실행 문맥 사이의 동기화를 제공하지 않는다.
우선순위대기 요청 선택과 선점 가능 여부에 관여한다.마스킹과 더 높은 우선순위의 실행도 영향을 준다.

컨베이어 입력을 인터럽트로 바꾸는 핵심은 모든 작업을 ISR로 옮기는 것이 아니다. 사건을 발견하는 경로를 마련하고, 그 경로가 짧고 예측 가능한 작업만 수행하도록 정하는 데 있다. 실제 장치로 옮길 때는 벡터 연결, 요청 원인 해제, 우선순위 설정을 각각 확인한다. 다음 장에서는 ISR에서 알아낸 사실을 메인 루프가 안전하게 사용하는 방법을 다룬다.

연습 문제

  1. 시간 1에 센서 입력이 1이 되었는데도 두 폴링 출력이 모두 0인 이유를 설명하라. 요청을 보류하는 기능까지 제거하면 시간 1의 사건을 나중에 알 수 있는지도 답하라.
  2. 정지 우선순위만 3으로 바꾸고 센서는 2로 유지하라. 시간 1의 진입과 복귀 기록 네 줄이 어떤 순서가 되는지 실행 전에 적어라.
  3. 시간 2의 hal_raise(IRQ_SENSOR)를 같은 위치에서 두 번 연속 호출하라. 마지막 sensor_ack 값이 어떻게 되는지 예상하고, 그 이유를 pending의 자료형과 연결해 설명하라.
  4. 동료가 “sensor_ack에 volatile이 붙었으므로 실제 ISR과 메인 루프에서 모두 ++해도 안전하다”고 주장한다. 이 주장의 문제와 현재 PC 예제가 그 주장을 검증하지 못하는 이유를 설명하라.

정답과 해설

  1. 폴링은 시간 0과 2에만 수행된다. 시간 1의 입력은 그 사이에 1이 되었다가 0으로 돌아오므로 어느 읽기에서도 1을 만나지 않는다. 예제에서는 입력과 별개인 pending 상태가 사건을 남긴다. 다른 기록 수단도 없고 요청도 보류하지 않는다면, 이후의 현재 입력값만으로 그 사건을 알아낼 수 없다.
  2. enter sensor, leave sensor, enter stop, leave stop 순서다. 센서 실행 중 active_priority는 2이므로 숫자 3인 정지 요청은 중첩 호출에서 선택되지 않는다. 센서가 복귀하고 일반 실행 문맥의 값 256이 복원된 뒤 정지 요청이 선택된다. 정지 요청이 사라지는 것이 아니라 대기하는 것이다.
  3. sensor_ack는 여전히 2다. 시간 1에 한 번, 시간 2에 한 번 처리한다. pending은 bool 배열이고 hal_raise는 true를 대입하므로 true에 다시 true를 써도 보류 상태 하나만 남는다. 요청을 올린 횟수와 처리 횟수가 항상 같은 것은 아니라는 점을 보여 준다.
  4. volatile은 증가 연산 전체의 원자성을 보장하지 않는다. 읽기와 쓰기 사이에 다른 실행이 끼어들 수 있으므로 실제 공유 접근에는 별도 규칙이 필요하다. 현재 예제에서는 하나의 실행 흐름이 정해진 위치에서만 중첩 호출을 수행하고, 일반 작업도 ISR 호출이 끝난 뒤 실행한다. 따라서 실제 동시 접근의 안전성을 검증한 것이 아니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.