임베디드 학습 노트 목차

최적화 — 크기와 속도를 재고 줄인다

"Flash 가 2KB 모자라서 기능을 못 넣는다", "제어 루프가 1ms 를 못 지킨다". 임베디드에서는 이런 벽에 자주 부딪힌다. PC 처럼 메모리를 더 사거나 서버를 늘릴 수 없다.

그런데 최적화의 첫 규칙은 언제나 같다 — 재기 전에는 손대지 않는다. 임베디드에서는 그 측정 수단이 앞 편에서 본 DWT·map 파일이다.


순서

① 재고 나서 시작한다        arm-none-eabi-size · DWT 사이클 카운터
② 알고리즘을 먼저 본다       O(n²)를 O(n log n)으로 바꾸는 것이 미세 조정 100번보다 크다
③ 컴파일러에게 맡긴다        -Os · -O2 · LTO
④ 그다음에 손으로            여기까지 와야 손댈 값어치가 있다

크기 줄이기

무엇이 먹고 있나

arm-none-eabi-nm -S --size-sort -td firmware.elf | tail -15
# 0000006692 T _vfprintf_r          ← printf 계열
# 0000002104 T __aeabi_dadd         ← double 연산 (소프트웨어 구현)
# 0000001256 T process_frame

상위 몇 개가 대부분을 차지한다. 아래쪽 100개를 아무리 다듬어도 위의 하나만 못하다.

printf 가 가장 큰 범인

# ① nano 라이브러리 — 가장 쉬운 큰 절감
arm-none-eabi-gcc ... --specs=nano.specs
# 6KB → 약 2KB

# ② 부동소수점 포맷을 뺀다 (기본이 빠져 있으니 넣지만 않으면 된다)
#    -u _printf_float 를 쓰지 않는다
// ③ 직접 만든다 — 정수만 지원하면 수백 바이트로 끝난다
void log_int(const char *tag, int32_t v) {
    char buf[12]; int i = 0;
    bool neg = v < 0; uint32_t u = neg ? -v : v;
    do { buf[i++] = '0' + u % 10; u /= 10; } while (u);
    if (neg) buf[i++] = '-';
    uart_write_str(tag);
    while (i--) uart_putc(buf[i]);
    uart_putc('\n');
}

대부분의 임베디드 로그에 부동소수점이 필요 없다. 고정소수점으로 1234 를 찍고 "밀리볼트" 라고 해석하면 된다.

double 을 없앤다

float f = 1.5;          // ✗ 1.5 는 double 리터럴 → double 연산 라이브러리가 딸려 온다
float f = 1.5f;         // ✓

double x = a * b;       // ✗ Cortex-M4 FPU 는 단정도만 — 소프트웨어로 떨어진다
float  x = a * b;       // ✓ 하드웨어 한 명령
# double 이 섞였는지 확인 — __aeabi_d* 심볼이 있으면 쓰고 있다
arm-none-eabi-nm firmware.elf | grep __aeabi_d

f 접미사 하나로 수 KB 가 줄어드는 경우가 흔하다. 그리고 math.hsin·sqrt 는 double 판이므로 sinf·sqrtf 를 쓴다.

죽은 코드를 버린다

arm-none-eabi-gcc -ffunction-sections -fdata-sections -Wl,--gc-sections
# LTO — 파일 경계를 넘어 최적화하고 안 쓰는 것을 더 잘 지운다
arm-none-eabi-gcc -flto -Os ...

LTO 는 대개 5~15% 를 더 줄인다. 다만 volatile 이 빠진 코드나 링커 스크립트에 의존하는 부분에서 예상 밖 동작이 나올 수 있으므로, 켠 뒤 반드시 실기 검증을 한다.

상수는 Flash 에 둔다

char *msg = "hello";              // ✗ 포인터는 RAM, 문자열은 Flash
const char msg[] = "hello";       // ✓ 전부 Flash

// 룩업 테이블도 마찬가지
const uint16_t sin_table[256] = { ... };    // ✓ const 를 빼면 RAM 1KB 를 먹는다

const 를 빠뜨리면 .data 로 가서 Flash 와 RAM 을 둘 다 먹는다. 큰 테이블에서 이 차이가 크다.

구조체를 정리한다

struct { uint8_t a; uint32_t b; uint8_t c; };    // 12바이트
struct { uint32_t b; uint8_t a, c; };            // 8바이트 — 큰 것부터

배열이 1,000개면 4KB 차이다.

속도 올리기

먼저 어디가 느린지 잰다

빠르게 만드는 수단 중 가장 흔한 것이 인라인이다. 호출 비용을 없애고 상수 전파까지 열어 주지만 그만큼 코드 크기가 늘어난다.

uint32_t t0 = DWT->CYCCNT;
suspect_function();
uint32_t cycles = DWT->CYCCNT - t0;

추측으로 최적화하면 대개 틀린다. 앞 편의 PC 표본 프로파일링을 쓰면 어느 함수인지 몰라도 찾아낸다.

나눗셈과 모듈로를 피한다

// Cortex-M4 는 나눗셈이 2~12 사이클, 곱셈은 1 사이클
x / 8            // ✗
x >> 3           // ✓ 1 사이클

x % 64           // ✗
x & 63           // ✓  (2의 거듭제곱일 때만)

x / 10           // 상수 나눗셈은 컴파일러가 곱셈+시프트로 바꿔 준다 (그대로 둬도 된다)

변수로 나누는 것이 진짜 비싸다. 링 버퍼 크기를 2의 거듭제곱으로 잡는 관행이 여기서 나온다.

부동소수점 대신 고정소수점

// FPU 가 없는 칩(Cortex-M0/M3)에서는 float 한 번이 수십~수백 사이클
float scaled = raw * 0.0806f;

// Q16.16 고정소수점 — 정수 연산만 쓴다
#define Q16(x)  ((int32_t)((x) * 65536.0f))
int32_t scaled = ((int64_t)raw * Q16(0.0806f)) >> 16;

FPU 가 있는 M4/M7 이면 float 를 그냥 쓴다. 고정소수점은 FPU 가 없을 때의 수단이지, 있는데도 쓰면 코드만 어려워진다.

룩업 테이블 — 계산을 미리 해 둔다

// 매번 계산
float y = sinf(2 * M_PI * i / N);              // 수백 사이클

// 미리 계산해 둔 표
static const int16_t sin_q15[256] = { 0, 804, 1608, ... };
int16_t y = sin_q15[phase >> 8];                // 몇 사이클

Flash 를 써서 시간을 사는 것이다. 임베디드에서 흔한 거래이고, Flash 는 남는데 CPU 가 모자란 경우가 많아 잘 맞는다.

자주 도는 함수를 인라인

static inline uint32_t clamp(uint32_t v, uint32_t lo, uint32_t hi) {
    return v < lo ? lo : (v > hi ? hi : v);
}

함수 호출 자체가 몇 사이클이라, 짧고 자주 불리는 것은 인라인이 이득이다. 다만 남용하면 코드가 커져 Flash 대기와 캐시 효율이 나빠진다.

메모리 접근을 줄인다

// ✗ 매번 구조체 멤버를 읽는다
for (int i = 0; i < n; i++) buf[i] = cfg->gain * data[i] + cfg->offset;

// ✓ 지역 변수로 빼면 레지스터에 올라간다
float g = cfg->gain, o = cfg->offset;
for (int i = 0; i < n; i++) buf[i] = g * data[i] + o;

volatile 이 붙은 것은 컴파일러가 이 최적화를 못 한다. 레지스터 값을 루프 안에서 반복해 읽으면 매번 버스를 탄다.

하드웨어에 넘긴다

// ✗ CPU 로 복사
for (int i = 0; i < 512; i++) dst[i] = src[i];

// ✓ DMA 에 맡기고 CPU 는 다른 일
dma_start(dst, src, 512);

가장 큰 최적화는 대개 "CPU 가 안 하게 만드는 것" 이다. DMA·타이머·하드웨어 CRC 를 쓰면 그 시간이 통째로 사라진다.

// CRC 도 하드웨어가 있다
RCC->AHB1ENR |= RCC_AHB1ENR_CRCEN;
CRC->CR = CRC_CR_RESET;
for (int i = 0; i < n; i++) CRC->DR = data[i];
uint32_t crc = CRC->DR;                          // 소프트웨어 구현보다 훨씬 빠르다

Flash 대기 상태 — 클럭만 올린다고 빨라지지 않는다

// 168MHz 로 올리려면 Flash 대기 상태를 먼저 늘려야 한다
FLASH->ACR = FLASH_ACR_LATENCY_5WS               // 5 대기
           | FLASH_ACR_PRFTEN                     // 프리페치
           | FLASH_ACR_ICEN | FLASH_ACR_DCEN;     // 명령·데이터 캐시

Flash 는 CPU 보다 느리다. 168MHz 에서 Flash 는 그 속도를 못 따라가므로 대기 사이클이 필요하고, 이것이 실효 성능을 깎는다.

프리페치와 캐시를 켜는 것을 잊는 경우가 많다. 켜면 순차 실행에서 대기가 대부분 감춰져 체감 성능이 크게 오른다 — 코드 한 줄로 얻는 가장 큰 이득 중 하나다.

// 극한 성능이 필요한 함수는 RAM 에서 실행한다 (대기 상태 0)
__attribute__((section(".ramfunc"))) void critical_isr(void) { ... }

컴파일러 옵션 정리

-Os                     크기 우선 (임베디드 기본)
-O2                     속도 우선
-O3                     더 공격적 — 코드가 커져 오히려 느려질 수 있다
-flto                   링크 시점 최적화 — 5~15% 추가 절감
-ffast-math             ✗ 부동소수점 규칙을 어긴다. 대개 쓰지 않는다
-fno-exceptions -fno-rtti   C++ 이면 필수 수준
# 함수별로 다른 최적화를 줄 수 있다
__attribute__((optimize("O3"))) void hot_loop(void) { ... }

최적화가 코드를 깨뜨릴 때

// -O0 에서는 되는데 -O2 에서 안 되는 코드
uint32_t *reg = (uint32_t *)0x40004400;
while ((*reg & 0x80) == 0) { }             // ✗ volatile 이 없어 무한 루프가 된다

"최적화 버그" 의 대부분은 컴파일러가 아니라 우리 코드의 미정의 동작이나 volatile 누락이다. 최적화를 낮춰 덮지 말고 원인을 찾는다.

// 순서가 중요한 하드웨어 접근은 배리어로 명시한다
GPIOA->BSRR = (1 << 5);
__DSB();                                    // 이 쓰기가 끝난 뒤에
start_conversion();

무엇을 최적화하지 않을 것인가

초기화 코드        한 번만 돈다 — 크기만 신경 쓴다
오류 처리 경로     거의 안 탄다
로그·진단          제품에서 빼거나 조건부로

전체의 몇 %를 차지하는지 모른 채 다듬는 것이 가장 흔한 낭비다. 앞 편의 프로파일러가 상위 3개를 알려 주면 거기만 손대면 된다.

가독성을 잃는 최적화는 마지막 수단이다. 몇 사이클을 위해 읽을 수 없는 코드를 만들면, 그 대가는 이후 모든 유지보수에서 계속 지불한다.


한눈에 정리

  • 재기 전에는 손대지 않는다size·nm -S·DWT 사이클 카운터
  • 알고리즘 → 컴파일러 옵션 → 손 순서. 순서를 뒤집으면 노력 대비 이득이 없다
  • 크기의 최대 범인은 printfdouble — nano 라이브러리와 f 접미사로 수 KB
  • __aeabi_d* 심볼이 있으면 double 을 쓰고 있다
  • const 를 빠뜨리면 Flash 와 RAM 을 둘 다 먹는다
  • LTO 는 5~15% 추가 절감 — 켠 뒤 실기 검증은 필수
  • 변수로 나누는 것이 비싸다링 버퍼를 2의 거듭제곱으로 잡는 이유
  • FPU 가 있으면 float 를 그냥 쓴다 — 고정소수점은 FPU 가 없을 때의 수단
  • 룩업 테이블은 Flash 로 시간을 사는 거래 — 임베디드에 잘 맞는다
  • 가장 큰 최적화는 "CPU 가 안 하게 만드는 것"DMA·하드웨어 CRC·타이머
  • Flash 프리페치·캐시를 켜는 한 줄이 큰 이득을 준다
  • -O2 에서 깨지면 대개 volatile 누락이나 미정의 동작 — 최적화를 낮춰 덮지 않는다
  • 가독성을 잃는 최적화는 마지막 수단

꼬리질문 대비

  • "최적화를 어디서 시작하나?" → 재는 것부터 — size 로 용량, DWT 로 시간. 추측으로 고치면 대개 헛수고다
  • "const 를 빠뜨리면 무엇이 달라지나?" → .rodata 가 아니라 .data 로 가서 Flash 와 RAM 을 둘 다 먹는다
  • "LTO 를 켤 때 주의할 점은?" → volatile 누락이나 링커 스크립트 의존 코드에서 예상 밖 동작 — 켠 뒤 실기 검증 필수

출처 — GCC 매뉴얼 Optimize Options·LTO · newlib-nano 문서 · Arm, Cortex-M4 Technical Reference Manual(DDI0439) — 명령 사이클 수 · STMicroelectronics, RM0090 §3 FLASH(ACR·대기 상태·프리페치·캐시) · Arm, Procedure Call Standard(AAPCS) — __aeabi_* 런타임 함수

테스트 — 하드웨어 없이, 그리고 하드웨어와 함께저전력 설계 — 배터리로 몇 년을 버티기