Troubleshooting

[임베디드 C] volatile 키워드 누락으로 인한 컴파일러 최적화 오류 및 무한 루프 해결

임베디드 친구 2026. 6. 10. 21:22
반응형

[Quick Summary (TL;DR) - For Global Developers]

  • Symptom: 인터럽트 서비스 루틴(ISR) 또는 하드웨어 레지스터 상태 변경 시, 메인 루프(Main Loop)의 조건문이 갱신되지 않고 시스템이 무한 루프(Infinite Loop) 또는 행(Hang) 상태에 빠짐.
  • Cause: volatile 키워드 누락으로 인해 컴파일러 최적화(Compiler Optimization, -O2/-O3) 과정에서 하드웨어 레지스터 및 전역 변수의 RAM 재참조 로직이 제거되고 CPU 레지스터(Register) 값만 반복 조회하도록 잘못 최적화됨.
  • Solution: 메모리 매핑 I/O(MMIO) 레지스터 주소 포인터 및 인터럽트 공유 전역 변수 선언문에 volatile 한정자를 명시하여 컴파일러의 캐싱 최적화를 차단하고 매번 실제 RAM/레지스터 메모리를 참조하도록 강제함.

전역 변수 및 인터럽트 서비스 루틴(ISR) 환경에서 volatile 누락 시 발생하는 시스템 행(Hang) 증상

임베디드 시스템 개발에서 디버그 빌드(-O0) 모드일 때는 정상적으로 동작하던 펌웨어가, 양산용 릴리즈 빌드 및 컴파일러 최적화 옵션(-O2, -O3, -Os)을 적용하는 순간 특정 루프 구문에서 탈출하지 못하고 시스템이 먹통이 되는 현상이 종종 발생합니다.

특히 하드웨어 인터럽트 서비스 루틴(ISR, Interrupt Service Routine) 내부에서 전역 변수를 변경했으나, 메인 스레드(while 루프)가 이 변경 사항을 인식하지 못하는 증상이 대표적입니다. 하드웨어 타이머나 외부 GPIO 인터럽트가 물리적으로 정상 유입되고 인터럽트 핸들러 함수가 주기적으로 호출됨에도 시스템이 특정 블로킹 구문에 멈춰 있는 상태를 보게 됩니다. 해외 포럼 및 Stack Overflow 등에서는 이를 Compiler Optimization Bug, Infinite Loop due to Missing volatile, 혹은 Stuck in While Loop 상태로 정의합니다.

컴파일러 최적화(Compiler Optimization) 메커니즘과 CPU 레지스터 캐싱의 원인 분석

문제의 근본 원인은 컴파일러가 소스 코드를 기계어로 번역할 때 수행하는 레지스터 할당 최적화(Register Allocation Optimization)에 있습니다.

컴파일러는 루프 내부의 조건 문을 분석할 때, 루프 내부에서 해당 변수를 변경하는 코드가 없다면 "이 변수의 값은 루프 내에서 변하지 않는다"고 판단합니다. 이로 인해 연산 속도가 느린 RAM 영역으로 접근하여 데이터를 읽어오는 기계어 명령(예: ARM Cortex-M의 LDR 명령어)을 매번 실행하지 않고, CPU 내부의 범용 레지스터(예: R0 ~ R12)에 최초 1회만 값을 로드한 뒤 루프 내내 해당 레지스터 값만 비교합니다.
임베디드 아키텍처에서 하드웨어 레지스터(MMIO)나 인터럽트 서비스 루틴(ISR)은 CPU의 일반적인 코드 흐름(실행 컨텍스트) 외부에서 비동기적으로 메모리 값을 변경합니다. 컴파일러는 이러한 하드웨어적 혹은 비동기적 메모리 변화를 예측할 수 없어, volatile 한정자가 선언되어 있지 않은 변수는 메모리 검증 과정을 생략하고 레지스터 캐싱 데이터를 그대로 사용하게 됩니다. 결과적으로 RAM 상의 실제 변수 값은 인터럽트에 의해 변경되었지만, CPU는 이미 구버전 데이터가 저장된 레지스터만 계속 확인하므로 논리적 무한 루프에 빠지게 됩니다.

무한 루프를 유발하는 잘못된 volatile 누락 C 코드 예시

다음은 컴파일러 최적화 활성화 시 메인 루프에서 영원히 빠져나오지 못하는 전형적인 오류 코드입니다.

#include <stdint.h>

/* Bad Case: Missing volatile qualifier for a shared global flag */
uint8_t g_interrupt_flag = 0;

void Timer_ISR_Handler(void) {
    /* This ISR is invoked asynchronously by hardware timer */
    g_interrupt_flag = 1; 
}

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

    /* * Compiler optimization (-O2 or higher) caches g_interrupt_flag into a CPU register.
     * Since g_interrupt_flag is not modified within this while loop body, 
     * the compiler converts this check into an infinite branch instruction.
     */
    while (g_interrupt_flag == 0) {
        /* Wait for Timer ISR to set the flag */
    }

    /* This logic is never reached under high optimization levels */
    Process_Target_Data(); 

    while (1);
}

메모리 무조건 재참조(Reload)를 강제하는 방어적 volatile C 코드 구현법

문제를 해결하기 위해, 비동기적으로 값이 변경되는 전역 플래그 선언부에 volatile 키워드를 사용하여 컴파일러가 매 루프 진입 시점마다 변수의 주소 공간(RAM)을 직접 읽도록 제어합니다.

#include <stdint.h>

/* Good Case: Properly qualified with volatile to prevent register caching */
volatile uint8_t g_interrupt_flag = 0;

void Timer_ISR_Handler(void) {
    /* Correctly updates the actual RAM address location */
    g_interrupt_flag = 1; 
}

int main(void) {
    System_Init();

    /* * With volatile keyword, compiler generates an explicit memory read instruction (e.g., LDR) 
     * for every iteration of the loop, accurately capturing changes made by the ISR.
     */
    while (g_interrupt_flag == 0) {
        /* Safely blocks until the memory content changes to 1 */
    }

    /* Executes successfully regardless of compiler optimization levels */
    Process_Target_Data(); 

    while (1);
}

핵심 수정 포인트 설명

  1. volatile 키워드 위치: volatile uint8_t g_interrupt_flag와 같이 데이터 타입 앞 또는 뒤에 명시합니다. 이를 통해 해당 변수가 가리키는 메모리 공간은 '언제든지 예측 불가능하게 변경될 수 있음'을 컴파일러에게 알립니다.
  2. 최적화 억제: 컴파일러는 volatile 변수를 참조하는 기계어 코드를 생성할 때, 레지스터 캐싱 기법을 강제로 해제하고 메모리 버스(Memory Bus)를 통해 실제 주소 영역의 최신 데이터를 직접 읽어오는 명령어를 고정 할당합니다.

어셈블리 레지스터 및 컴파일러 디버깅 팁

volatile 누락으로 추정되는 버그를 직면했을 때, 하드웨어 디버거와 툴체인을 활용하여 내부 상태를 검증하는 정밀 트러블슈팅 기법입니다.

  • 디스어셈블리(Disassembly) 분기 명령 분석: 무한 루프가 의심되는 타겟 라인에 브레이크포인트를 걸고 IDE의 Disassembly View를 활성화합니다. volatile이 누락된 경우 메모리 로드 명령(LDR) 없이 자기 자신으로 곧바로 점프하는 무조건 분기 명령(B ) 형식의 기계어로 최적화되어 있는 것을 확인할 수 있습니다.
  • 컴파일러 최적화 레벨 교차 검증: 이슈 원인이 코드 자체의 논리적 결함인지 컴파일러 최적화 특성인지 가려내기 위해 프로젝트 빌드 환경 설정(GCC 옵션의 경우 -O0로 임시 변경)을 조정한 뒤 바이너리를 재빌드합니다. 최적화를 껐을 때 정상 동작하고, 최적화를 켰을 때(-Os, -O2)만 시스템이 다운된다면 100% volatile 한정자 누락 또는 메모리 배리어(Memory Barrier) 부재로 판단할 수 있습니다.
  • 하드웨어 구조체 매핑 검증: MCU 제조사(ST, TI, NXP 등)의 주변장치 헤더 파일(stm32f4xx.h 등)을 분석해 보면 모든 하드웨어 레지스터 구조체 멤버에 __IO 매크로가 지정되어 있습니다. 이 매크로의 원형은 결국 #define __IO volatile 입니다. 직접 하드웨어 메모리 매핑 I/O(MMIO) 주소를 포인터로 핸들링할 때도 반드시 *(volatile uint32_t *) 형태로 캐스팅 구조를 설계해야 컴파일러의 레지스터 오판독 예외를 예방할 수 있습니다.
반응형