Troubleshooting

[임베디드 C] 인터럽트(ISR)와 메인 루프 간 전역 변수 오염 해결 (Critical Section 설정)

임베디드 친구 2026. 6. 14. 19:41
반응형

[Quick Summary - For Global Developers]

  • Symptom: 메인 루프(Main Loop) 내에서 전역 변수를 기반으로 제어되는 조건문이 간헐적으로 오동작하거나, 센서 데이터 혹은 카운터 변수의 값이 예측 불가능하게 깨지는 데이터 오염 현상이 발생함.
  • Cause: 공유 전역 변수(Shared Global Variable)를 수정하는 연산이 원자성(Atomicity)을 보장받지 못해, 비동기적으로 발생한 인터럽트 서비스 루틴(ISR)이 연산 중간에 개입하여 컨텍스트(Context)를 침범함.
  • Solution: 비동기 인터럽트 진입을 일시적으로 차단하는 크리티컬 섹션(Critical Section)을 설정하여 인터럽트 비활성화 레지스터(PRIMASK)를 제어하고 연산의 원자성을 강제 확보함.

레이스 컨디션(Race Condition) 변수 오염의 실무 발생 증상

임베디드 펌웨어 개발 중 디버깅하기 까다로운 버그 중 하나는 간헐적으로 데이터가 변경되는 현상입니다. 멀티태스킹 환경이나 인터럽트 기반 베어메탈(Bare-metal) 아키텍처에서 여러 실행 흐름이 하나의 전역 변수를 공유할 때 Race Condition(경쟁 상태)이 발생됩니다.
실무에서는 센서의 패킷을 카운트하는 변수가 더 이상 증가하지 않고 비정상적인 값으로 초기화되거나, 메인 루프 플래그 조건문이 분명히 참(True)인 상태임에도 불구하고 조건문 진입에 실패하는 오동작을 겪게 됩니다. 이런 현상는 항상 발생하는 것이 아니라 수 시간 혹은 수일간 장기 신뢰성 시험을 진행할 때 불규칙하게 발생되어 디버깅이 어렵고, 소스 코드가 복잡해질수록 시스템이 먹통이 되는 HardFault_Handler 예외나 무한 루프 상태로 오해하기 쉽습니다.

비원자적 연산(Non-atomic Operation)과 인터럽트 개입의 근본 원인 분석

이 문제의 원인은 C 언어 관점의 한 줄짜리 코드가 MCU 레지스터 상에서 Atomicity(원자성)를 보장받지 못하기 때문입니다.
C 언어로 작성된 g_counter++ 연산은 컴파일러 최적화 및 툴체인 과정을 거치며 최소 3개 이상의 어셈블리 기계어 명령어로 분할됩니다.

  • Read: 메모리(RAM) 주소에 저장된 값을 코어의 범용 레지스터로 읽어옴 (LDR 명령어)
  • Modify: 레지스터 내부의 값을 1 증가시킴 (ADD 명령어)
  • Write: 수정된 레지스터 값을 다시 원래 메모리 주소에 저장함 (STR 명령어)
    만약 메인 루프가 1번(Read)을 수행하고 2번(Modify)으로 넘어가려는 극히 짧은 타이밍(몇 나노초 단위)에 타이머나 UART 인터럽트(ISR)가 비동기적으로 트리거되면, CPU는 하드웨어 컨텍스트 스위칭을 통해 현재 상태를 스택으로 옮기고, 해당 ISR 벡터로 강제 분기합니다.
    이때 인터럽트 루틴 내부에서 동일한 전역 변수인 g_counter 값을 수정하고 메인 루프로 복귀(Return)하면, 메인 루프는 인터럽트가 변경한 최신 값을 인지하지 못한 채 이전에 1번 과정에서 백업해 두었던 과거의 레지스터 상태를 기반으로 2번, 3번 연산을 그대로 수행해 버립니다. 결과적으로 인터럽트 루틴이 수행한 메모리 변경 내역은 완전히 유실되고 데이터 오염(Data Corruption)이 발생하게 됩니다.

데이터 오염을 유발하는 잘못된 공유 변수 참조 C 코드 예시 (Bad Case)

아래 코드는 초급 개발자들이 방어적 프로그래밍 핸들러 없이 구현하여 인터럽트 간 경쟁 상태를 유발하는 잘못된 소프트웨어 구조입니다.

#include <stdint.h>

/* Shared global variables between Main Loop and ISR */
volatile uint32_t g_shared_counter = 0;
volatile uint8_t g_process_flag = 0;

void Timer_Peripheral_ISR(void) 
{
    /* ISR Context */
    g_shared_counter++; 
    g_process_flag = 1;
}

int main(void) 
{
    /* Hardware Initialization */
    System_Initialize();

    while (1) 
    {
        /* NON-ATOMIC SECTION: Vulnerable to race conditions */
        if (g_process_flag == 1) 
        {
            /* Read-Modify-Write assembly sequence can be interrupted here */
            if (g_shared_counter >= 100) 
            {
                g_shared_counter = g_shared_counter - 100;
                Execute_System_Task();
            }
            g_process_flag = 0;
        }
    }
}

원자성 확보를 위한 크리티컬 섹션(Critical Section) 방어적 C 코드 구현법 (Good Case)

문제를 해결하기 위해 공유 변수를 다루는 연산 단계를 단 하나의 실행 흐름으로 격리해야 합니다. 인터럽트 진입을 일시적으로 전역 차단하여 원자성을 확보하는 Critical Section(임계 구역) 설정 코드를 구현합니다

#include <stdint.h>

/* Core architecture specific intrinsic header (e.g., ARM CMSIS) */
#include "core_cm4.h" 

volatile uint32_t g_shared_counter = 0;
volatile uint8_t g_process_flag = 0;

/* Defensive function to safely read and modify shared variable */
void Process_Shared_Data(void)
{
    uint32_t primask_backup;
    uint32_t local_counter;
    uint8_t local_flag;

    /* 1. Enter Critical Section: Read and backup current interrupt mask register */
    primask_backup = __get_PRIMASK();
    __disable_irq(); /* Set PRIMASK to disable all configurable interrupts */

    /* 2. Execute Atomic Operation inside Critical Section */
    local_flag = g_process_flag;
    if (local_flag == 1)
    {
        if (g_shared_counter >= 100)
        {
            g_shared_counter -= 100;
            local_flag = 2; /* Status token indicating task readiness */
        }
        else
        {
            g_process_flag = 0;
        }
    }

    /* 3. Exit Critical Section: Restore original interrupt mask state */
    __set_PRIMASK(primask_backup);

    /* 4. Execute external block functions outside the critical area */
    if (local_flag == 2)
    {
        Execute_System_Task();

        /* Re-enter minimal critical section to clear flag status */
        primask_backup = __get_PRIMASK();
        __disable_irq();
        g_process_flag = 0;
        __set_PRIMASK(primask_backup);
    }
}

void Timer_Peripheral_ISR(void) 
{
    /* ISR Context remains highly deterministic and minimal */
    g_shared_counter++; 
    g_process_flag = 1;
}

int main(void) 
{
    System_Initialize();

    while (1) 
    {
        Process_Shared_Data();
    }
}

핵심 수정 포인트 설명

  • __get_PRIMASK() 및 __disable_irq() 활용: 단순히 인터럽트를 전역 차단(__disable_irq)하는 방식은 이전에 다른 레이어에서 인터럽트가 이미 비활성화되어 있었을 경우, 크리티컬 섹션을 빠져나갈 때 의도치 않게 인터럽트를 강제로 켜버리는 부작용이 있습니다. 따라서 현재 레지스터 마스크 상태(PRIMASK)를 백업해 둔 뒤 차단하는 것이 커널 레벨의 방어적 설계 공식입니다.
  • 크리티컬 섹션 최소화(Minimal Footprint): 인터럽트가 차단된 상태에서 오랜 시간 소요되는 무거운 함수(Execute_System_Task)를 실행하면 시스템의 리얼타임 지연 시간(Interrupt Latency)이 급격히 증가하여 다른 중요한 하드웨어 인터럽트가 누락됩니다. 변수 연산만 빠르게 처리한 뒤 즉시 마스크를 복원(__set_PRIMASK)해야 합니다.
  • 로컬 복사본 활용: 크리티컬 섹션 내부에서 전역 변수 값을 로컬 변수(local_counter, local_flag)로 신속히 복사 및 가공한 후, 무거운 비즈니스 로직은 임계 영역 밖에서 로컬 변수를 기준으로 처리하도록 구조화했습니다.

레이스 컨디션 디버깅 및 트러블슈팅 가이드

  • 디스어셈블리(Disassembly) 명령어 시퀀스 검증: 데이터가 오염되는 특정 소스 코드 라인에 브레이크포인트(Breakpoint)를 설정하고 IDE의 Disassembly View를 활성화하십시오. 해당 변수를 업데이트하는 로직이 원자적 한 줄 명령어(예: LDREX / STREX 등의 독점 액세스 아키텍처)로 구현되어 있는지, 아니면 일반 LDR-ADD-STR 연산 시퀀스로 쪼개져 분할되어 있는지 육안으로 판별해야 합니다.
  • 오동작 임계 시점 로깅: 하드웨어 디버거(J-Link, ST-LINK)의 Live Watch 기능을 통해 인터럽트 발생 주기와 메인 루프의 실행 처리 시간 주기를 오실로스코프 또는 가상 로깅 프로파일러로 비교하십시오. 두 주기의 최소공배수 타이밍이나 시스템 부하가 90% 이상 집중되는 순간 레이스 컨디션이 집중 발생한다면 100% 임계 구역 누락의 가능성이 있습니다.
  • 소프트웨어 가상 가드(S/W Mutex Flag) 도입 배제: 초보 개발자들이 흔히 인터럽트를 끄는 대신 전역 변수로 g_lock = 1; 과 같은 가상 락을 만들어 레이스 컨디션을 막으려고 시도하지만, 그 g_lock 변수를 변경하는 행위 자체가 다시 원자성 위반에 직면하게 되므로, 하드웨어 레벨의 인터럽트 마스킹(Interrupt Masking) 또는 MCU 전용 독점 메모리 접근 명령어(Exclusive Memory Access Instruction)를 무조건 채택해야 안전합니다.
반응형