임베디드 C++ 최적화: 플래시 크기, RAM 정적 할당, 전력 소모, 실시간성(WCET)

STM32F030처럼 플래시 64KB, RAM 8KB인 MCU에 C++ 프로젝트를 올리다 보면 세 가지 벽에 차례로 부딪힙니다. 링크 단계에서 region 'FLASH' overflowed가 나고, 겨우 들어가면 실행 중 HardFault가 나며, 배터리로 돌리면 예상보다 훨씬 빨리 방전됩니다. 1ms 주기 제어 루프를 돌리는 장치라면 가끔 주기를 놓치는 지터도 문제가 됩니다.

이 글은 플래시 크기, RAM, 전력, 실시간성 네 가지를 ARM Cortex-M과 GCC(arm-none-eabi-gcc) 기준으로 다룹니다. 예외·RTTI를 끄는 방법은 예외·RTTI 없이 임베디드 C++에서, volatile과 메모리 맵 I/O는 C++ volatile의 정확한 의미: MMIO 레지스터, ISR 공유 변수, std::atomic과의 차이에서 자세히 다룹니다.


플래시 크기 줄이기

컴파일·링크 옵션

가장 먼저 확인할 것은 빌드 옵션입니다.

add_compile_options(
    -Os                    # 크기 우선 최적화 (GCC 12 이상이면 -Oz도 검토)
    -ffunction-sections    # 함수마다 별도 섹션
    -fdata-sections        # 전역 데이터마다 별도 섹션
    -fno-exceptions
    -fno-rtti
    -flto
)
add_link_options(
    -flto
    -Wl,--gc-sections          # 참조되지 않는 섹션 제거
    -Wl,--print-memory-usage   # 링크 후 FLASH/RAM 사용률 출력
    --specs=nano.specs         # newlib 대신 newlib-nano
)

-ffunction-sections -fdata-sections는 함수와 데이터를 각각 독립 섹션에 넣고, 링커의 --gc-sections는 어디서도 참조되지 않는 섹션을 버립니다. 이 둘은 짝으로 써야 효과가 있습니다. 섹션 분리 없이 --gc-sections만 쓰면 한 파일 전체가 한 섹션이라 파일 안의 함수 하나만 쓰여도 파일 전체가 남습니다.

-flto는 링크 시점에 전체 프로그램을 다시 최적화해, 파일 경계를 넘는 인라인과 사용되지 않는 코드 제거를 가능하게 합니다. 작은 헬퍼 함수가 여러 파일에 흩어져 있을수록 효과가 크고, 이미 헤더 인라인 위주로 작성된 코드에서는 효과가 작습니다. 얼마나 줄어드는지는 코드베이스마다 다르므로 arm-none-eabi-size로 적용 전후를 비교해 봐야 합니다.

--specs=nano.specs는 표준 C 라이브러리를 크기를 줄인 newlib-nano로 바꿉니다. newlib-nano의 printf는 기본적으로 부동소수점 포맷을 지원하지 않아서, %f가 필요하면 -u _printf_float를 추가해야 합니다. 이 옵션을 켜는 순간 플래시가 눈에 띄게 늘어나는 것도 확인할 수 있습니다.

printf와 iostream

printf는 포맷 문자열 해석기와 정수·부동소수점 변환 코드를 끌고 들어오고, <iostream>은 로캘과 스트림 버퍼 초기화까지 들어와 소형 MCU에서는 그 자체로 플래시의 상당 부분을 차지할 수 있습니다. 디버그 출력이 필요하다면 필요한 형식만 직접 출력하는 작은 함수로 충분합니다.

#include <cstdint>

void uart_putchar(char c);  // 하드웨어 계층에서 구현

void uart_puts(const char* s) {
    while (*s) uart_putchar(*s++);
}

void uart_put_u32(std::uint32_t v) {
    char buf[10];
    int n = 0;
    do { buf[n++] = static_cast<char>('0' + v % 10); v /= 10; } while (v != 0);
    while (n > 0) uart_putchar(buf[--n]);  // 역순으로 출력
}

로그 호출 자체를 빌드 설정에 따라 없애고 싶다면 if constexpr로 걸러 냅니다. 호출이 컴파일 시점에 버려지므로 로그 문자열도 이미지에 남지 않습니다.

enum class LogLevel { None, Error, Warn, Info, Debug };

#ifndef FW_LOG_LEVEL
#define FW_LOG_LEVEL LogLevel::Warn
#endif

template <LogLevel L>
inline void log(const char* msg) {
    if constexpr (FW_LOG_LEVEL != LogLevel::None && L <= FW_LOG_LEVEL) {
        uart_puts(msg);
    }
}

// log<LogLevel::Debug>("sensor ready");  → Warn 빌드에서는 코드가 생성되지 않음

const 데이터를 플래시에 두기

상수 초기화식을 가진 const·constexpr 전역 데이터는 .rodata, 즉 플래시에 놓이고 RAM을 쓰지 않습니다. 반면 const가 빠진 전역 배열은 초기값이 플래시에 저장된 뒤 시작 시 RAM(.data)으로 복사되므로 플래시와 RAM을 모두 씁니다.

// 포인터 배열 자체도 const여야 .rodata에 들어감
const char* const kErrorMessages[] = {"OK", "Bus Error", "Timeout", "Invalid"};

// const char* kErrorMessages[] 로 쓰면 문자열은 플래시에 있어도
// 포인터 배열은 수정 가능한 전역이라 .data(RAM)에 놓임

constexpr std::uint16_t kCrcTable[256] = {0x0000, 0x1021, 0x2042 /* ... */};

생성자가 있는 클래스 객체는 const여도 생성자가 constexpr가 아니면 시작 시 실행되어야 하므로 RAM에 놓입니다. 룩업 테이블을 클래스로 감쌌다면 생성자를 constexpr로 만들고 constexpr 변수로 선언하는지 확인합니다.

크기 확인

arm-none-eabi-size -A firmware.elf
# 가장 큰 심볼 20개
arm-none-eabi-nm --size-sort -S -C firmware.elf | tail -20

.text와 .rodata, .data의 합이 플래시 사용량이고, .data와 .bss에 스택·힙을 더한 것이 RAM 사용량입니다. nm --size-sort로 큰 심볼을 보면 예상하지 못한 라이브러리 함수(예: _dtoa_r, __aeabi_ddiv)가 끌려 들어온 것을 찾기 쉽습니다. 부동소수점 연산 하나 때문에 소프트웨어 부동소수점 라이브러리가 통째로 들어오는 경우가 흔합니다.


RAM: 스택과 정적 할당

스택 크기와 사용량 분석

베어메탈 Cortex-M에서 스택 크기는 링커 스크립트가 정합니다. STM32CubeIDE가 생성하는 링커 스크립트라면 _Min_Stack_Size와 _Min_Heap_Size가 그 값이고, 링커는 .data + .bss + 힙 + 스택이 RAM을 넘으면 에러를 냅니다. 하지만 이것은 예약 크기의 합을 검사할 뿐이고, 실행 중 실제 스택 사용이 예약을 넘는지는 검사하지 않습니다. Cortex-M에는 기본적으로 스택 한계 검사가 없어서(ARMv8-M의 MSPLIM 제외), 스택이 넘치면 조용히 .bss의 전역 변수를 덮어쓰다가 나중에 엉뚱한 곳에서 HardFault가 납니다.

# 함수별 스택 프레임 크기를 .su 파일로 생성
arm-none-eabi-g++ -fstack-usage -c source.cpp -o source.o
cat source.su
# source.cpp:12:6:void process_packet()    272    static

.su 파일은 함수 하나의 프레임 크기만 알려 주므로, 최악의 스택 깊이는 호출 그래프를 따라 합산해야 합니다. 간접 호출과 재귀가 있으면 정적 합산이 불가능하므로, 임베디드 코드에서 재귀와 함수 포인터 남용을 피하는 이유 중 하나가 이것입니다. 실행 중 확인이 필요하면 시작 시 스택 영역을 알려진 패턴(예: 0xDEADBEEF)으로 채워 두고, 나중에 패턴이 얼마나 덮였는지 보는 방식(stack painting)을 씁니다.

큰 버퍼를 스택에서 빼기

void process_packet() {
    static std::uint8_t buffer[256];  // .bss에 256바이트, 스택 사용 없음
    receive(buffer, sizeof buffer);
    parse(buffer);
}

static 지역 버퍼는 스택을 쓰지 않는 대신 함수가 재진입 불가능해집니다. 이 함수를 ISR과 메인 루프에서 동시에 호출하거나 RTOS 태스크 두 개에서 부르면 버퍼가 섞입니다. 호출 경로가 하나라는 것을 보장할 수 없다면 호출자별 버퍼를 따로 두거나 아래의 풀을 씁니다.

고정 크기 풀

동적 할당이 필요한 구조라면 malloc 대신 고정 크기 블록 풀을 씁니다. 블록 크기가 모두 같으므로 단편화가 생기지 않고, 할당 시간의 상한도 N으로 정해집니다.

#include <cstddef>
#include <cstdint>
#include <new>

template <typename T, std::size_t N>
class Pool {
    alignas(T) std::uint8_t storage_[N][sizeof(T)];
    bool used_[N] = {};
public:
    template <typename... Args>
    T* allocate(Args&&... args) {
        for (std::size_t i = 0; i < N; ++i) {
            if (!used_[i]) {
                used_[i] = true;
                return new (storage_[i]) T(static_cast<Args&&>(args)...);
            }
        }
        return nullptr;  // 풀 고갈: 호출자가 반드시 처리
    }
    void deallocate(T* p) {
        auto offset = reinterpret_cast<std::uint8_t*>(p) - &storage_[0][0];
        std::size_t i = static_cast<std::size_t>(offset) / sizeof(T);
        p->~T();
        used_[i] = false;
    }
};

struct Packet {
    std::uint8_t data[64];
    std::uint8_t len;
};
Pool<Packet, 4> packet_pool;  // .bss에 4 × 65바이트 + 사용 표시 4바이트

이 풀은 ISR과 메인 루프에서 동시에 쓰면 used_ 갱신이 경합하므로, 공유한다면 할당·해제 구간에서 인터럽트를 잠깐 막아야 합니다. 풀이 고갈됐을 때 nullptr을 돌려주는 설계라서 호출자가 이 경우를 반드시 처리해야 한다는 점도 중요합니다. malloc이 실패하지 않을 것이라고 가정하는 코드가 풀로 바뀌면서 조용히 널 포인터를 역참조하는 사고가 생기기 쉽습니다.


전력 소비 줄이기

슬립 진입과 잃어버린 웨이크업

Cortex-M은 WFI(Wait For Interrupt) 명령으로 코어 클럭을 멈추고 인터럽트를 기다립니다. SCB->SCR의 SLEEPDEEP 비트를 켜면 MCU 제조사가 정의한 더 깊은 저전력 모드(STM32의 Stop 등)로 들어갑니다. 폴링 루프를 인터럽트 + 슬립 구조로 바꾸는 것이 전력 절감의 출발점입니다.

#include <atomic>

std::atomic<bool> button_event{false};

extern "C" void EXTI0_1_IRQHandler() {
    clear_exti_pending();
    button_event.store(true, std::memory_order_relaxed);  // ISR은 플래그만
}

void main_loop() {
    while (true) {
        __disable_irq();                  // PRIMASK=1: 인터럽트 처리 보류
        if (!button_event.load(std::memory_order_relaxed)) {
            __WFI();                      // 보류 중인 인터럽트가 있으면 즉시 깨어남
        }
        __enable_irq();                   // 여기서 보류됐던 ISR이 실행됨

        if (button_event.exchange(false)) {
            do_work();
        }
    }
}

“플래그를 확인하고, 없으면 WFI”를 인터럽트를 켠 채로 하면, 확인과 WFI 사이에 인터럽트가 들어와 플래그를 세운 뒤 코어가 잠들어 버리는 경쟁이 생깁니다. 이 경우 다음 인터럽트가 올 때까지 이벤트 처리가 미뤄집니다. 위처럼 PRIMASK로 인터럽트를 막은 상태에서 확인하고 WFI를 실행하면, 그 사이에 발생한 인터럽트는 보류 상태로 남고 WFI는 보류된 인터럽트가 있으면 잠들지 않고 바로 돌아옵니다. (Cortex-M0처럼 원자적 읽기-수정-쓰기 명령이 없는 코어에서 exchange는 컴파일러가 인터럽트 차단 등으로 구현하거나 라이브러리 호출이 필요할 수 있으니, 그런 코어에서는 인터럽트를 막은 구간 안에서 읽고 지우는 방식이 단순합니다.)

깊은 슬립에서는 대부분의 주변기기 클럭이 멈추므로, 웨이크업 소스(EXTI 라인, RTC 알람 등)가 그 모드에서 동작하는지 데이터시트에서 확인해야 합니다. WFI 이후 영원히 깨어나지 않는다면 대개 웨이크업 소스가 해당 모드에서 지원되지 않거나 EXTI/NVIC 설정이 빠진 경우입니다. 또 디버거를 연결하면 저전력 모드 중에도 디버그 클럭이 유지되도록 설정되는 경우가 많아, 디버거를 붙인 채 잰 전류는 실제보다 높게 나옵니다.

슬립 전 주변기기 정리

저전력 모드에 들어가도 전류가 기대만큼 줄지 않는 원인은 대부분 켜 둔 주변기기입니다. ADC, UART, 쓰지 않는 타이머의 클럭을 끄고, 떠 있는(floating) 입력 핀은 아날로그 모드나 풀업·풀다운으로 고정합니다. 떠 있는 입력은 입력 버퍼가 중간 전압에서 계속 스위칭하며 전류를 흘릴 수 있어서, 코드상으로는 아무것도 안 하는데 수십~수백 μA가 새는 흔한 원인입니다.

DMA

UART 송수신이나 ADC 샘플링을 DMA로 넘기면 CPU가 바이트마다 인터럽트를 처리하지 않아도 되므로, 전송하는 동안 코어를 슬립시킬 수 있습니다. 완료 인터럽트에서 다음 작업을 시작하는 구조로 짜면 CPU가 깨어 있는 시간이 전송 시간이 아니라 처리 시간으로 줄어듭니다.

배터리 수명 계산

평균 전류를 구하면 배터리 수명을 대략 추정할 수 있습니다. 아래 수치는 계산 방법을 보이기 위한 가정이며, 실제 값은 해당 MCU의 데이터시트와 실측으로 정해야 합니다.

  • 1분마다 10ms 동안 5mA로 동작 (센서 읽기, 무선 송신 포함)
  • 나머지 59.99초는 2μA로 Stop 모드

평균 전류는 (5mA × 0.01s + 0.002mA × 59.99s) / 60s ≈ 0.0028mA, 즉 약 2.8μA입니다. 용량 220mAh인 CR2032라면 산술적으로 220 / 0.0028 ≈ 78,000시간이 나오지만, 실제로는 이 값에 한참 못 미칩니다. 코인 셀은 무선 송신 같은 큰 펄스 전류에서 전압이 크게 떨어져 실효 용량이 줄고, 자가 방전도 있기 때문입니다. 이 계산에서 더 중요한 교훈은 평균 전류의 대부분이 대기 전류에서 나온다는 점입니다. 위 가정에서는 동작 구간이 0.05mA·s, 대기 구간이 약 0.12mA·s라서, 대기 전류를 2μA에서 1μA로 줄이는 것이 동작 시간을 절반으로 줄이는 것보다 효과가 큽니다. 계산보다 전류가 훨씬 크게 나온다면 앞에서 말한 주변기기와 떠 있는 핀부터 확인합니다.


실시간성: ISR과 WCET

ISR은 짧게

ISR 안에서 오래 머물면 같은 우선순위 이하의 다른 인터럽트가 그만큼 늦어지고, 그것이 제어 루프의 지터로 나타납니다. ISR에서는 하드웨어 레지스터를 읽고 데이터를 큐에 넣는 것까지만 하고, 파싱이나 계산은 메인 루프나 태스크에서 합니다. ISR과 메인 루프가 데이터를 주고받는 데는 단일 생산자·단일 소비자 링 버퍼가 잘 맞습니다.

#include <atomic>
#include <cstddef>

template <typename T, std::size_t N>
class SpscRing {
    static_assert(N > 0 && (N & (N - 1)) == 0, "N must be a power of two");
    T buffer_[N];
    std::atomic<std::size_t> head_{0};  // 소비자만 씀
    std::atomic<std::size_t> tail_{0};  // 생산자만 씀
public:
    bool push(const T& v) {  // 생산자(ISR)
        std::size_t t = tail_.load(std::memory_order_relaxed);
        if (t - head_.load(std::memory_order_acquire) == N) return false;  // 가득 참
        buffer_[t & (N - 1)] = v;
        tail_.store(t + 1, std::memory_order_release);  // 데이터 쓰기 후 공개
        return true;
    }
    bool pop(T& v) {  // 소비자(메인 루프)
        std::size_t h = head_.load(std::memory_order_relaxed);
        if (h == tail_.load(std::memory_order_acquire)) return false;  // 비어 있음
        v = buffer_[h & (N - 1)];
        head_.store(h + 1, std::memory_order_release);
        return true;
    }
};

SpscRing<std::uint8_t, 64> uart_rx;

extern "C" void USART1_IRQHandler() {
    if (uart_rx_ready()) uart_rx.push(uart_read_byte());
}

void process_uart() {
    std::uint8_t b;
    while (uart_rx.pop(b)) handle_byte(b);
}

인덱스를 volatile로만 선언하는 구현이 흔한데, volatile은 인덱스 접근끼리의 순서만 지킬 뿐 volatile이 아닌 buffer_ 쓰기가 인덱스 갱신 뒤로 옮겨지는 것은 막지 못합니다. 그러면 소비자가 아직 채워지지 않은 칸을 읽을 수 있습니다. std::atomic의 release/acquire가 이 순서를 보장합니다. 이 구조는 각 인덱스를 한쪽만 쓰므로 원자적 읽기-수정-쓰기 명령이 필요 없고, 32비트 원자 load/store만으로 동작해 Cortex-M0에서도 잠금 없이 쓸 수 있습니다. 인덱스는 계속 증가시키고 배열 접근 때만 & (N - 1)로 감싸므로, 가득 참(차이가 N)과 비어 있음(차이가 0)을 칸 하나 낭비 없이 구분합니다.

우선순위와 크리티컬 섹션

1ms 제어 루프를 타이머 인터럽트로 돌린다면 그 인터럽트의 우선순위를 통신 인터럽트보다 높게 두어, 통신 ISR이 제어 ISR을 늦추지 못하게 합니다. __disable_irq()로 전체 인터럽트를 막는 크리티컬 섹션은 그 길이만큼 모든 인터럽트의 지연을 늘리므로 몇 줄 이내로 짧게 유지합니다. Cortex-M3 이상이라면 BASEPRI로 특정 우선순위 이하만 막아, 가장 급한 인터럽트는 계속 받을 수 있게 할 수 있습니다.

WCET

실시간 시스템에서는 평균이 아니라 최악 실행 시간(WCET)이 데드라인 안에 들어와야 합니다. WCET를 정적으로 분석하거나 측정으로 신뢰할 수 있게 하려면 실행 시간이 입력에 따라 크게 흔들리는 요소를 제어 경로에서 빼야 합니다. 힙 할당은 탐색 시간이 힙 상태에 따라 달라지고, 상한 없는 루프와 재귀는 최악을 정할 수 없게 만들며, 예외는 스택 되감기 시간이 호출 깊이에 따라 달라집니다. 플래시 대기 상태와 캐시가 있는 MCU라면 같은 코드도 캐시 적중 여부에 따라 시간이 달라지므로, 제어 루프의 핵심 코드를 RAM이나 TCM에서 실행하도록 배치하기도 합니다.

측정은 루프 시작과 끝에서 GPIO를 토글해 오실로스코프로 보거나, Cortex-M3 이상의 DWT 사이클 카운터(DWT->CYCCNT)로 최댓값을 기록하는 방식이 간단합니다. 최댓값은 부하가 가장 큰 조건(통신 폭주, 모든 인터럽트 활성)에서 오래 돌려 얻어야 의미가 있습니다.


자주 만나는 링크·실행 오류

region ‘FLASH’ overflowed

arm-none-eabi-ld: firmware.elf section `.text' will not fit in region `FLASH'

앞의 옵션(-Os, 섹션 분리 + --gc-sections, LTO, nano.specs)이 모두 적용됐는지 확인하고, nm --size-sort로 큰 심볼을 찾아 원인이 되는 라이브러리 호출을 제거합니다.

region ‘RAM’ overflowed

.data + .bss + 힙 + 스택 예약 합계가 RAM을 넘은 것입니다. 수정 가능한 전역 테이블을 const로 바꿔 플래시로 보내고, 힙을 쓰지 않는다면 _Min_Heap_Size를 0으로 줄이고, 큰 버퍼의 크기가 정말 필요한 만큼인지 다시 봅니다. 스택 예약을 줄이는 것은 가장 쉽지만 가장 위험한 방법입니다.

LTO 빌드에서 undefined reference

LTO는 C++ 쪽에서 참조되지 않는 함수를 제거하거나 이름을 바꿀 수 있습니다. 어셈블리 시작 코드나 링커 스크립트에서만 참조되는 함수가 사라졌다면 __attribute__((used))로 보존하고, C 링키지가 필요하면 extern "C"를 붙입니다. 인터럽트 벡터 테이블의 핸들러 이름이 C++ 이름 맹글링 때문에 기본 약한 핸들러로 연결되는 실수도 흔한데, 이 경우 링크는 성공하고 인터럽트가 기본 핸들러(보통 무한 루프)로 빠집니다. 앞 예제의 ISR에 extern "C"를 붙인 이유입니다.


부트로더·OTA와 워치독

/* 부트로더 16KB를 뺀 나머지를 앱 영역으로 */
MEMORY
{
    FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 48K
    RAM (xrw)  : ORIGIN = 0x20000000, LENGTH = 8K
}

부트로더를 두면 앱의 링크 시작 주소와 크기를 그에 맞게 바꾸고, 앱 시작 시 벡터 테이블 위치(SCB->VTOR, Cortex-M0에는 없어 RAM 재매핑을 씀)도 옮겨야 합니다. OTA를 안전하게 하려면 두 슬롯(A/B)을 두고, 새 이미지를 비활성 슬롯에 쓴 뒤 해시나 서명으로 검증하고 나서야 부트 플래그를 바꿉니다. 검증을 통과했어도 새 이미지가 실제로 정상 동작하는지는 부팅해 봐야 알 수 있으므로, 부트로더는 새 이미지를 “시험 부팅” 상태로 실행하고, 앱이 정상 동작을 확인해 플래그를 확정하기 전에 워치독 리셋이 나면 이전 슬롯으로 되돌리는 구조가 일반적입니다.

void bootloader_main() {
    Slot slot = read_boot_slot();
    if (is_trial_boot(slot) && trial_boot_failed()) {  // 시험 부팅 중 리셋됨
        slot = other(slot);
        write_boot_slot(slot);
    }
    if (!verify_image(slot)) {
        slot = other(slot);
        if (!verify_image(slot)) enter_recovery_mode();
    }
    jump_to_app(slot_address(slot));
}

워치독은 메인 루프가 멈추는 것을 잡는 마지막 안전망입니다. 다만 타이머 인터럽트에서 워치독을 갱신하면 메인 루프가 멈춰도 인터럽트는 계속 돌아 워치독이 무의미해지므로, 갱신은 메인 루프에서 모든 태스크가 정상적으로 한 바퀴 돌았음을 확인한 뒤에 합니다.


같이 보면 좋은 글