인터럽트 처리 패턴: ISR 최소화, deferred processing, DMA

EmbeddedFreeRTOS

ISR(Interrupt Service Routine)은 짧아야 한다. 긴 ISR은 다른 인터럽트의 응답 시간을 늘리고, RTOS 스케줄링을 방해한다. 이 글은 ISR을 최소화하는 원칙, 처리를 뒤로 미루는 패턴, DMA를 활용한 구체적 구현을 정리한다.

ISR 최소화 원칙

ISR이 길면 생기는 문제

  • 다른 인터럽트 지연: 같거나 낮은 우선순위의 인터럽트가 현재 ISR이 끝날 때까지 대기한다
  • 스택 사용량 증가: Cortex-M에서 인터럽트 진입 시 최소 32 bytes(레지스터 8개)를 스택에 push. 중첩이 깊어지면 스택 오버플로우 위험
  • RTOS 스케줄링 방해: ISR이 실행되는 동안 context switch가 발생하지 않는다

ISR 안에서 할 수 있는 것 / 없는 것

안전위험
하드웨어 레지스터 읽기/쓰기malloc() / free() — 재진입 불가
volatile 플래그 설정printf() — 블로킹, 재진입 불가
링 버퍼에 데이터 복사xSemaphoreTake() — 블로킹 가능
FromISR 계열 RTOS API비-FromISR FreeRTOS API
32-bit 정렬 단일 워드 읽기/쓰기다중 바이트 read-modify-write

타이밍 기준

168 MHz Cortex-M4에서 10 μs ≈ 1680 클럭 사이클.

시스템ISR 예산근거
오디오 (48 kHz)< 20 μs다음 샘플 전에 완료
모터 제어 (10 kHz PWM)< 10 μs제어 루프 주기
UART 115200 baud< 86 μs바이트 간 간격
FreeRTOS SysTick (1 kHz)< 100 μs틱 누락 방지

Deferred Processing 패턴

ISR에서는 최소한의 작업(데이터 복사, 플래그 설정)만 하고, 실제 처리는 나중에 수행한다.

패턴 1: 플래그 + 메인 루프 (bare metal)

volatile bool uart_rx_ready = false;
volatile uint8_t rx_byte;

void USART1_IRQHandler(void) {
    if (USART1->SR & USART_SR_RXNE) {
        rx_byte = USART1->DR;
        uart_rx_ready = true;
    }
}

int main(void) {
    while (1) {
        if (uart_rx_ready) {
            uart_rx_ready = false;
            process_byte(rx_byte);
        }
    }
}

가장 단순하다. 단점은 폴링 지연이 루프 순회 시간에 의존하고, 태스크 간 우선순위 구분이 없다는 것.

패턴 2: FreeRTOS FromISR API

FreeRTOS에서 ISR → Task로 작업을 넘기는 세 가지 방법:

xTaskNotifyFromISR — 가장 가볍다. 커널 객체 할당 없음. binary semaphore 대비 ~45% 빠름.

void USART1_IRQHandler(void) {
    BaseType_t woken = pdFALSE;
    rx_buf[rx_head++] = USART1->DR;
    vTaskNotifyGiveFromISR(uart_task_handle, &woken);
    portYIELD_FROM_ISR(woken);
}

void uart_task(void *p) {
    while (1) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);  // 알림까지 블록
        process_rx_buffer();
    }
}

xQueueSendFromISR — ISR에서 데이터를 함께 전달할 때.

void ADC_IRQHandler(void) {
    BaseType_t woken = pdFALSE;
    uint16_t val = ADC1->DR;
    xQueueSendFromISR(adc_queue, &val, &woken);
    portYIELD_FROM_ISR(woken);
}

xSemaphoreGiveFromISR — 여러 태스크가 같은 이벤트를 기다릴 때, 또는 counting이 필요할 때.

FromISR 패턴의 핵심: portYIELD_FROM_ISR

FromISR 호출이 높은 우선순위 태스크를 깨우면 wokenpdTRUE가 된다. portYIELD_FROM_ISR(woken)은 PendSV 예외를 pend한다.

// portYIELD_FROM_ISR가 하는 일:
if (woken == pdTRUE) {
    SCB->ICSR = SCB_ICSR_PENDSVSET_Msk;  // PendSV 요청
}

PendSV는 가장 낮은 우선순위로 설정된다. 모든 ISR이 완료된 후에야 실행되어 context switch를 수행한다. ISR 도중에 context switch가 발생하지 않는 것이 핵심이다.

하나의 ISR에서 여러 FromISR를 호출하면, woken 변수 하나를 공유하고 마지막에 한 번만 yield한다:

void DMA1_Stream5_IRQHandler(void) {
    BaseType_t woken = pdFALSE;
    xQueueSendFromISR(data_queue, &data, &woken);
    xSemaphoreGiveFromISR(done_sem, &woken);
    portYIELD_FROM_ISR(woken);  // 한 번만
}

NVIC 우선순위 주의점

FreeRTOS는 configMAX_SYSCALL_INTERRUPT_PRIORITY를 정의한다. FromISR API를 호출하는 인터럽트는 이 값 이상(숫자가 크거나 같은, 즉 논리적으로 낮거나 같은) 우선순위여야 한다.

// configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY = 5 일 때:
HAL_NVIC_SetPriority(USART1_IRQn, 5, 0);  // OK
HAL_NVIC_SetPriority(DMA1_Stream5_IRQn, 6, 0);  // OK
HAL_NVIC_SetPriority(TIM2_IRQn, 3, 0);  // FromISR 호출 시 스케줄러 손상

우선순위 0~4는 FreeRTOS가 마스킹하지 못하는 영역이다. 이 범위의 ISR에서 FromISR를 호출하면 configASSERT가 걸리거나, assert가 꺼져 있으면 스케줄러가 조용히 손상된다.

DMA 전송 완료 인터럽트

DMA는 ISR 최소화의 극단적 형태다. CPU 개입 없이 하드웨어가 데이터를 전송하고, 완료 시에만 인터럽트를 발생시킨다.

UART RX: 순환 버퍼 + HT/TC/IDLE

메시지 길이를 모를 때의 표준 패턴. DMA가 순환 버퍼를 연속으로 채우고, 세 가지 이벤트에서 처리한다.

[________________________________]
 0              HT=32          TC=64 (wrap)
  • Half-Transfer (HT): 버퍼 앞쪽 절반 완료
  • Transfer-Complete (TC): 버퍼 뒷쪽 절반 완료, 처음으로 wrap
  • UART IDLE: RX 라인이 1 프레임 동안 idle (불완전 전송 포착)
#define DMA_BUF_SIZE 64
static uint8_t dma_buf[DMA_BUF_SIZE];
static size_t old_pos = 0;

void dma_rx_check(void) {
    size_t pos = DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
    if (pos != old_pos) {
        if (pos > old_pos) {
            process_data(&dma_buf[old_pos], pos - old_pos);
        } else {  // wrap
            process_data(&dma_buf[old_pos], DMA_BUF_SIZE - old_pos);
            process_data(&dma_buf[0], pos);
        }
        old_pos = pos;
    }
}

HT, TC, IDLE 인터럽트 모두에서 dma_rx_check()를 호출한다. DMA 인터럽트와 UART 인터럽트의 preemption priority를 같게 설정해야 old_pos가 동시 접근으로 깨지지 않는다.

더블 버퍼링

DMA가 한쪽 버퍼를 채우는 동안 CPU가 다른 쪽을 처리한다. 두 가지 구현 방식이 있다:

1) HT/TC를 이용한 소프트웨어 더블 버퍼링 — 단일 버퍼의 앞/뒤 절반을 두 개의 버퍼처럼 사용. 위 UART 패턴과 동일.

2) 하드웨어 Double Buffer Mode — STM32 F4/F7/H7에서 DMA가 M0AR, M1AR 두 주소를 자동 교체.

static int16_t buf_0[FRAME_SIZE];
static int16_t buf_1[FRAME_SIZE];

HAL_DMAEx_MultiBufferStart_IT(&hdma_sai_tx,
    (uint32_t)buf_0, (uint32_t)&SAI1_Block_A->DR,
    (uint32_t)buf_1, FRAME_SIZE);

TC 콜백에서 현재 DMA가 읽고 있지 않은 버퍼를 채운다.

Cortex-M7 캐시 정합성

Cortex-M7(STM32F7, H7)은 D-cache가 있다. DMA는 캐시를 우회해 메인 메모리에 직접 접근하므로 정합성 문제가 발생한다.

상황문제해결
DMA → 메모리 (RX)CPU 캐시에 오래된 데이터TC 콜백에서 SCB_InvalidateDCache_by_Addr()
메모리 → DMA (TX)캐시에만 있고 메모리에 없는 데이터DMA 시작 전 SCB_CleanDCache_by_Addr()

캐시 연산은 32-byte 캐시 라인 단위다. DMA 버퍼가 32-byte 정렬되지 않으면 같은 캐시 라인의 다른 데이터가 날아갈 수 있다.

__attribute__((aligned(32)))
static uint8_t dma_rx_buf[64];  // 크기도 32의 배수로

또는 MPU로 DMA 버퍼 영역을 non-cacheable로 설정하면 수동 캐시 관리가 필요 없다.

메모

  • volatile은 컴파일러 최적화만 방지한다. 원자성(atomicity)은 보장하지 않는다. ISR과 메인 루프가 공유하는 변수의 read-modify-write에는 critical section 또는 atomic 연산이 필요하다
  • Cortex-M의 tail-chaining: ISR 종료 후 다른 인터럽트가 대기 중이면 unstacking/restacking을 건너뛰고 6 사이클만에 전환한다. 연속 인터럽트 처리가 효율적인 이유
  • STM32H7에서 DTCM은 캐시되지 않아 DMA 버퍼에 적합하지만, 일부 변형에서는 DMA가 DTCM에 접근할 수 없다. 레퍼런스 매뉴얼의 메모리 맵을 확인해야 한다
  • SPI DMA의 CS 해제 타이밍: DMA TC는 데이터가 SPI TXDR에 전달된 시점이다. SPI가 실제로 마지막 비트를 전송 완료한 시점이 아니므로, CS를 너무 빨리 해제하면 마지막 바이트가 잘린다