코드를 느껴바라

[최적화] 실행속도 관점에서 보는 C 코드 최적화 본문

개발/임베디드(Embedded)

[최적화] 실행속도 관점에서 보는 C 코드 최적화

feelTheCode 2026. 3. 3. 09:31

앞선 글에서는 ROM과 RAM 관점에서 코드 최적화를 정리했다.
이번에는 완전히 다른 축으로 넘어가 보자.

임베디드에서 “빠르다”는 건 단순히 체감 성능이 아니라,

  • 제어 주기(예: 1ms 루프)를 지킬 수 있는가
  • 인터럽트 지연이 허용 범위인가
  • 데드라인을 넘기지 않는가

같은 시간 제약과 직결된다.

즉 실행속도 최적화는 “더 빠르게”가 아니라
“정해진 시간 안에 끝나게”를 목표로 하는 경우가 많다.


임베디드에서 실행시간이 왜 중요한가

PC는 CPU도 빠르고 OS가 완충 역할을 해준다.
하지만 MCU는 자원이 제한되고, 작업이 대부분 실시간 이벤트에 의해 결정된다.

  • ADC 샘플링 주기
  • PWM 업데이트 타이밍
  • CAN/LIN 통신 타이밍
  • 센서 필터링 주기
  • 모터 제어 루프 주기

이런 코드에서 “조금 느린 코드”는 곧 “제어 실패”로 이어질 수 있다.


실행속도 최적화의 큰 방향

속도를 올리는 방법은 크게 두 갈래다.

  • CPU가 하는 일을 줄인다 (연산/분기/메모리 접근 감소)
  • CPU가 일을 더 효율적으로 하게 만든다 (캐시/정렬/레지스터 활용 유도)

중요한 건, 이 과정이 ROM/RAM 최적화와 충돌할 수 있다는 점이다.
빠르게 하려고 펼치면(인라인/언롤링) 코드가 커지고,
코드를 줄이려고 모아두면(함수화) 호출 오버헤드가 생길 수 있다.


“느린 연산”을 알아두면 절반은 먹고 들어간다

임베디드에서 상대적으로 비용이 큰 것들부터 의심하는 게 좋다.

정수 나눗셈과 나머지

정수 연산 중에서 나눗셈(/)과 나머지(%)는 보통 비싸다.
특히 루프 안에서 반복되면 체감이 확 난다.

  • 분모가 2의 거듭제곱이면 시프트로 대체 가능
  • 상수가 고정이면 곱셈+시프트 형태로 최적화되는 경우도 있음(컴파일러가 해주기도 함)

단, “무조건 곱셈으로 바꿔라”는 위험하다.
오버플로우/정밀도/부호 처리 때문에 결과가 달라질 수 있고,
컴파일러 최적화 옵션에 따라 이미 바뀌어 있을 수도 있다.

부동소수점 연산

FPU가 없는 MCU에서는 float/double이 특히 무겁다.
있더라도 변환이 섞이면 비싸질 수 있다.

  • 가능하면 fixed-point(고정소수점)로 설계
  • 변환은 마지막 단계에서 한 번만
  • 상수는 0.2f처럼 타입을 명확히 해서 불필요한 승격을 줄임

메모리 접근 패턴이 속도를 좌우한다

CPU는 연산 자체보다 메모리를 건드리는 비용이 커서 느려지는 경우가 많다.

전역/volatile 접근 최소화

메모리-mapped I/O나 인터럽트로 바뀌는 값은 volatile을 붙인다.
하지만 volatile은 컴파일러 최적화를 억제해서, 같은 값을 여러 번 읽게 만들 수 있다.

그래서 레지스터/volatile 값을 여러 번 쓸 거면 로컬 변수에 한 번 담아 쓰는 습관이 유효하다.

// 예시: volatile 레지스터를 여러 번 읽지 않도록
uint32_t status = STATUS_REG;   // STATUS_REG는 volatile이라고 가정
if (status & ERR_MASK)
{
    ...
}
if (status & READY_MASK)
{
    ...
}

이건 “무조건 빠르다”가 아니라, 불필요한 메모리 접근을 줄일 가능성이 크다는 전략이다.

데이터 정렬과 구조체 패딩

정렬이 깨지면 CPU가 한 번에 못 읽어서 여러 번 접근하거나 예외 경로를 타는 MCU도 있다.
특히 통신 패킷 구조체를 그대로 메모리에 얹어 처리할 때 체감이 난다.


분기(조건문)는 생각보다 비싸다

분기는 파이프라인/분기예측(있는 코어라면)에 영향을 준다.
MCU 종류에 따라 정도는 다르지만, “자주 도는 루프에서의 분기”는 최적화 후보 1순위다.

조건을 단순화하고, 루프 안에서 고정되는 건 밖으로 뺀다

루프 내부에서 매번 같은 계산을 하면 그만큼 느려진다.
상수/고정값/루프마다 변하지 않는 값은 밖에서 미리 계산하는 게 유리할 때가 많다.

switch가 유리한 경우

하나의 값으로 다중 분기하는 경우 if-else 연쇄보다 switch가 점프 테이블로 떨어져 빠를 수 있다.
단, 케이스의 분포/범위에 따라 컴파일러가 점프 테이블을 만들지 않을 수도 있다.
결국 “코드로 추측”하지 말고 생성된 어셈블리/맵으로 확인하는 게 확실하다.


함수 호출 비용과 인라인의 트레이드오프

함수 호출은 오버헤드가 있다.

  • 레지스터 저장/복원
  • 스택 사용
  • 분기

그래서 “자주 호출되는 짧은 함수”는 인라인이 속도에 도움이 된다.
하지만 인라인은 ROM을 늘릴 수 있고, I-cache가 작은 환경에서는 오히려 역효과도 날 수 있다.

실무적으로는 이런 기준이 현실적이다.

  • ISR/타이트 루프 내부: 짧은 함수는 인라인 고려
  • 덜 자주 호출: 함수로 유지해서 코드 크기/가독성 확보
  • 성능이 애매하면 disassembly 확인

루프 최적화는 임베디드에서 체감이 크다

임베디드에서 시간 먹는 코드는 대개 루프다.

루프 언롤링

반복 횟수가 작고 루프 오버헤드가 큰 경우 언롤링이 빨라질 수 있다.
대신 코드가 커진다.

카운트다운 루프

일부 아키텍처는 0 비교 형태가 더 효율적인 분기를 제공하는 경우가 있다.
이런 건 “아키텍처 특성”이라, 강의 슬라이드가 말하는 최적화 포인트랑도 연결되는 부분이다.


테이블(lookup table)은 “연산을 메모리로 바꾸는” 전략

복잡한 계산을 매번 하는 대신, 미리 계산한 결과를 테이블로 만들어 두고 인덱싱만 한다.

  • 삼각함수/감마/보정값
  • CRC 테이블
  • 상태 전이 테이블

이건 속도에 매우 효과적이지만,

  • ROM이 늘고
  • 캐시/메모리 대역폭 영향을 받을 수 있다

즉 실행속도 최적화의 대표적인 “ROM과의 교환”이다.


폴링 vs 인터럽트는 정답이 없다

흔히 “인터럽트가 더 효율적”이라고 생각하지만, 상황에 따라 반대가 된다.

  • 이벤트가 너무 잦으면 ISR 오버헤드가 커져서 전체가 느려질 수 있음
  • 폴링은 단순하지만 바쁜 대기(busy wait)가 되면 전력/성능 둘 다 악화

결국 핵심은 “주기/빈도/데드라인”이다.
실시간 루프 안에서 폴링이 더 안정적인 경우도 충분히 있다.


최적화는 측정 없이 하면 추측이 된다

실행속도 최적화는 특히 그렇다.
“빨라 보이는 코드”가 실제로는 느릴 수 있다.

확인할 때 많이 쓰는 방법은 두 가지다.

  • disassembly로 실제 명령어 수/메모리 접근 패턴 확인
  • 타이밍 측정(사이클 카운터, 타이머 핀 토글, 트레이스 등)

임베디드에서는 IDE에 따라 트레이스/프로파일링 도구 지원이 제한될 수도 있어서,
현실적으로는 “어셈블리 확인 + 간단한 타이밍 측정”만으로도 충분히 의미 있는 결론을 낼 수 있다.


정리

실행속도 최적화에서 가장 중요한 질문은 이거다.

  • 이 코드는 어디에서 시간을 쓰는가?
  • 그 시간이 연산 때문인가, 메모리 접근 때문인가, 분기 때문인가?
  • 그리고 그 최적화가 ROM/RAM에는 어떤 대가를 요구하는가?

임베디드 최적화는 결국 속도, ROM, RAM의 균형 문제다.
한쪽만 보고 밀어붙이면 다른 쪽에서 터진다.

그래서 가장 좋은 순서는 보통 이렇다.

  • 병목을 찾는다
  • 가장 큰 병목부터 줄인다
  • 변경 전/후를 측정한다
  • ROM/RAM 증가가 허용 범위인지 확인한다
반응형