코드 최적화는 상당히 어려운 분야이고 그 내용도 역시 어렵다.

사실 어렵다기 보다는 눈에 잘 띄지 않는 내용이라서 생소하다고 보는 것이 더 맞을런지도 모르겠다. 예전에 386 PC에서 MS C/C++ 7.0 또는 볼랜드 C++ 3.1/4.0 가지고 프로그래밍할 때는 자연스럽게 다양한 컴파일러 최적화 옵션들을 요리조리 다루면서 설정을 했던 기억이 난다. 글로벌 최적화, 함수 호출 최적화, 분기 최적화, 루프 최적화, 등등... 최적화 옵션을 어떻게 설정하느냐에 따라 프로그램의 크기나 체감 실행 속도가 많이 차이가 났다. 요즘은 워낙에 CPU가 빨라서 최적화 옵션의 유용성이 많이 감소된 감이 없지 않다. 그저 디버그 빌드냐 릴리즈 빌드냐의 선택만을 한다.

 

그러나 임베디드 분야에서는 최적화의 요구가 필수적이다.

포스트 PC급의 임베디드 시스템에서는 CPU의 성능(보통 수백 Mhz의 동작 클럭)이나 탑재되는 메모리의 용량(보통 256MB 이상) 등이 상대적으로 여유롭기 때문에 컴파일러가 제공하는 최적화 정도만으로도 별 문제가 없을 수 있다. 그러나 임베디드 프로세서는 이런 포스트 PC급만 있는게 아니다. 8051, PIC, AVR 같은 마이크로 컨트롤러들이 여전히 많이 사용되고 있다. 이런 마이크로 컨트롤러들은 기껏해야 수십 Mhz의 동작 클럭으로 작동하며 내장된 메모리의 크기도 16KB~128KB 정도로 제한되어 있다. 요즘처럼 수백 MB의 메모리를 갖춘 PC에서 프로그램을 개발하는 사람들에게는 수십 KB의 메모리에서 프로그램을 개발해야 된다는 것이 상당한 압박으로 느껴질 수도 있다. 이러한 하드웨어적인 환경의 제약으로 인해 컴파일러 수준의 코드 최적화는 물론이고 소스 코드 수준에서도 최적의 소스 코드를 작성할 필요가 있다.

 

일반적으로 컴파일러가 제공하는 최적화의 종류는 크기 최적화와 속도 최적화의 두 부류로 나눠볼 수 있다. 임베디드 시스템 개발에서는 이 중에 크기 최적화를 통해 작은 크기의 실행 파일을 생성하는 것이 더 유리하다. 속도 향상은 알고리즘의 개선이나 소스 코드 튜닝을 통해 상당 부분 개선할 수 있는 여지가 있다. 또한 개발 툴에서 제공하는 프로파일러 등을 이용하면 속도 개선이 필요한 부분을 선정하는데 많은 도움을 받을 수 있다. 그러나 실행 파일의 크기를 소스 코드 레벨에서 수동으로 줄이기는 사실상 매우 어렵고 한계가 있다.

 

실행 시간을 줄이는 최적화

 

인라인 함수: C++에서는 모든 함수에 inline 키워드를 선언할 수 있다. 인라인 함수는 마치 매크로 확장처럼 함수 호출이 함수 자체의 구현 코드로 대체된다. 일반적인 함수 호출에는 리턴 어드레스와 파라미터들이 스택에 푸시되고 팝되는 과정이 수반되지만 인라인 함수가 사용되면 호출에 따른 이런 부수적인 처리들이 생략된다. 그러나 인라인 함수는 사용(호출)되는 회수에 비례해서 실행 파일의 크기가 늘어난다.

 

참조 테이블: 컴파일러의 최적화 옵션에 따라 달라질 수도 있지만 일반적으로 switch 문의 case 문들은 어셈블러의 비교 명령과 점프(분기) 명령의 연속으로 구현된다. 각 case 문의 발생 빈도가 다르다면 상당한 비효율이 내재될 수 있다. 그러므로 상대적으로 발생 빈도가 높은 case 문을 선두에 위치시키고 빈도가 낮은 case 문을 후미에 배치시킨다. 만약 각 case 문의 상대적인 발생 빈도를 가늠하기 어려운 상황이라면 switch 문 전체를 함수 포인터 테이블로 바꾸어 단 한 번의 간접 함수 호출로 처리하는 것이 바람직하다. 예전에 볼랜드 C++ 컴파일러가 이런 방식으로 switch 문을 컴파일 했던 것으로 기억한다.

 

인라인 어셈블리어: 대부분의 C/C++ 컴파일러들은 syntax는 다소 다를 수 있지만 C/C++ 소스 코드 중간에 어셈블리어를 포함시킬 수 있는 방법을 제공한다. 이런 방법을 통해 별도의 어셈블러를 구동하지 않고도 C/C++ 함수 내에서 어셈블리어의 장점을 활용할 수 있다. 메모리 맵 I/O 장치가 아닌 포트 I/O 장치를 제어하기 위해서는 이런 인라인 어셈블리어가 필수다. 또한 이미지 필터링처럼 루프 구조를 통한 픽셀 작업 등의 경우에 레지스터의 활용을 극대화할 수 있는 어셈블리어가 실행 시간을 몇 배 이상 향상시킬 수 있다.

 

레지스터 변수: 지역 변수를 선언할 때 register 키워드를 추가하면 해당 변수는 스택에 할당되지 않고 레지스터에 할당된다. 그러나 CPU의 범용 레지스터 수에는 제한이 있기 때문에 register로 선언됐다고 모든 변수가 레지스터에 할당될 수 있는 것은 아니다. 일반적으로 RISC 계열의 CPU들이 더 많은 범용 레지스터를 가지고 있으므로 register 변수가 더 많이 할당될 수 있다. 동시에 지원되는 register 변수의 최대 수는 대상 CPU와 컴파일러마다 다를 수 있으므로 컴파일러의 리스트 파일을 생성하여 직접 확인해 볼 필요가 있다. 루프 카운터 변수처럼 함수 내에서 자주 참조되는 변수를 register 키워드로 선언하면 실행 시간 단축에 도움이 된다.

 

전역 변수: 함수에 파라미터를 전달하는 것보다 전역 변수를 사용하는 것이 함수 호출에 따른 오버헤드를 줄일 수 있어 효율적이다. 그러나 전역 변수의 사용은 일반적인 소프트웨어 개발론에서 극단적으로 피해야될 방법이다. 함수 단위의 모듈화를 불가능하게 만들며 재진입 가능한 함수의 구현 역시 불가능해진다. 절충안은 클래스의 구현을 통해 전역 변수의 효과를 얻는 방법이다. 공유되는 데이터를 클래스 안으로 숨기고 이 데이터에 접근해야되는 관련 함수들을 클래스 메소드로 구현한다.

 

폴링: 이벤트 드리븐 방식에 익숙해져 있는 사람들에게 폴링 방식은 상당히 비효율적인 방식으로 치부되곤 한다. 임베디드 시스템에서 이벤트 드리븐은 궁극적으로 인터럽트를 의미한다. 그런데 인터럽트 자체의 CPU 오버헤드로 인해 프로그램이 비효율적이 되는 경우가 있을 수 있다. 즉, 모니터해야 되는 인터럽트의 발생이 매우 빈번하여 그 발생 간격이 인터럽트 지연 시간(latency)에 육박하는 경우라면 차라리 폴링 방식이 더 효율적이다.

 

정수 연산: 아주 예외적인 경우가 아닌 한, 거의 모든 경우의 부동소수점 연산은 정수(고정소수점) 연산으로 대체 가능하다. CPU에 매스코프로세서가 내장된 경우가 아니라면 부동소수점과 고정소수점의 성능 차이는 수십에서 많게는 수백배까지 차이가 날 수 있다. 설령 매스코프로세서가 내장된 경우라하더라도 일반적으로(대상 CPU마다 조금식 다를 수 있다) 고정소수점 연산이 몇 배 정도 더 빠르다. 아무 생각없이 double, float 타입을 사용하는 것은 무조건 피해야 되며 고정소수점 연산으로의 변환 가능성을 심도있게 고려해야 한다. 본인의 글 [알고리즘]고정소수점(fixed point) 연산을 참조하자.

 

코드 크기를 줄이는 최적화

 

"사용하지 않는 코드 제거" 최적화: 컴파일러에 의해 수행되는 최적화 기법들 중의 하나로 C/C++ 언어의 volatile 키워드의 용도를 설명할 때 자주 설명되는 기법이다. 이 최적화 기법에 의해 전후 문맥상 없어도 되는 코드들이 자동으로 제거된다. 이런 최적화를 통해 코드의 크기를 줄일 수 있지만 컴파일러는 코드의 syntax만을 보고 semantics를 보지 못한다는데 문제가 있다. 예를 들면 일정 시간의 지연 효과를 위해 더미 코드를 반복시키는 루틴을 생각할 수 있다. 물론 시간 지연을 위해 더 좋은 방법이 있을 수도 있지만 펌웨어 수준의 임베디드 프로그래밍에서는 종종 이런 더미 코드 루틴이 사용된다. 컴파일러는 이런 더미 코드가 전후 문맥상 결과 값에 아무 영향을 미치지 않기 때문에 제거해 버리는 최적화를 수행한다. 또 아래의 예제 코드같은 경우에 "*pCtrl"의 값이 세번째 라인에서 변경될 때까지 사용되지 않기 때문에 첫번째 라인을 최적화 과정에서 제거해 버린다.

    *pCtrl = DISABLE;

    *pData = 'A';

    *pCtrl = ENABLE;

그러나 만약에 pCtrl이 메모리 맵 방식의 장치 레지스터에 대한 포인터라면 문제가 심각해진다. 첫번째 라인과 세번째 라인 모두 장치를 구동시키는 분명한 동작 코드들이기 때문에 최적화 과정에서 제거가 되면 오동작이 유발될 수 밖에 없다. 최적화로 인한 이런 문제를 방지하기 위해 메모리 맵 방식의 모든 포인터 변수, 쓰레드 간에 또는 스레드와 ISR 간에 공유되는 모든 공유 데이터(변수), 그리고 논리적으로(semantics상으로) 반드시 수행되어야 하는 루틴의 변수들에 대해 volatile 키워드로 선언을 해야한다. volatile 키워드로 선언된 변수에 대해서는 컴파일러가 "사용되지 않는 코드 제거" 최적화를 수행하지 않는다.

 

표준 라이브러리를 사용하지 않는다: 코드 크기를 줄이기 위한 가장 간단한 방법은 표준 C/C++ 라이브러리를 사용하지 않는 것이다. 대부분의 표준 C 라이브러리 함수들은 발생 가능한 모든 경우에 대비하도록 구현되어 있기 때문에 상당히 크기가 크다. 예를 들어 sprintf, sscanf 류의 함수는 다양한 타입의 포맷팅을 처리하는 기능을 가지고 있다. 만일 프로그램에서 몇 가지 정형화된 타입의 포맷팅만이 사용된다면(거의 대부분의 프로그램이 그렇다) 필요한 포맷팅만을 처리하는 함수를 직접 구현하는 것이 코드 크기를 줄이는데 큰 도움이 된다. 또한 C++의 STL 같은 경우는 템플리트 기반으로 구현되어 있기 때문에 아무 생각없이 사용하다가는 코드 크기가 엄청나게 증가하게 된다. 표준 라이브러리의 소스 코드는 어렵지 않게 구할 수 있다. 필요없는 기능을 잘라낸 스몰 라이브러리를 구축하는 것이 적어도 임베디드 프로그래밍에 있어서는 의미없는 일은 아니다.

 

기본 워드 크기: C/C++에서 int 타입은 유일하게 플랫폼 디펜던트한 데이터 타입이다. 즉, 사용되는 프로세서에 따라 16비트, 32비트, 64비트 크기로 가변한다. ANSI C/C++ 표준에서 int 타입은 프로세서의 기본 워드 크기를 사용하도록 규정하고 있다. 반면에 short, long 같은 타입은 플랫폼에 상관없이 각각 16비트, 32비트로 크기가 고정되어 있는 타입이다. 어셈블리어 레벨에서는 프로세서의 레지스터와 동일한 크기의 데이터를 다룰 때 가장 적은 코드가 사용된다. 프로세서의 기본 워드 크기라 함은 레지스터의 데이터 크기(비트수)를 말하며 C/C++에서 int 타입의 크기가 이에 해당한다. 즉, 프로그램에서 short나 long을 사용하게 되면 프로세서에 따라서(기본 워드 크기와 다를 경우) 부수적인 코드들이 더 추가될 수 있다. 일례로 "long lVal = n;" 같은 단순한 할당문이 32비트 워드 크기의 프로세서에서는 두 개의 어셈블러 명령만으로 처리될 수 있지만 16비트 워드 크기의 프로세서에서는 4개 이상의 어셈블러 명령을 사용해야만 처리가 된다. "lVal += n;" 같은 연산문이라면 6개 이상의 어셈블러 명령이 사용될 수도 있다. 꼭 short나 long 타입을 써야만 되는 경우가 아니라면 int 타입을 일관되게 사용함으로써 코드 크기를 최적화할 수 있다.

 

goto 문: goto 문은 전역 변수와 함께 일반적으로 사용하지 말아야 될 방법들이다. 스파게티 로직이 뭔지를 아는 프로그래머라면 goto 문의 폐해를 잘 알 것이다. 그러나 크지않은 함수 단위의 블럭 내에서는 간간히 요긴하게 사용될 수 있다. goto에서 다시 goto로 연결되는 구조는 바람직하지 않지만 여러 겹으로 중첩된 제어문에서 한번에 빠져 나오기 위해 goto를 사용하는 것은 매우 유용하다. 또한 그렇게 하는 것이 정상적인 제어 구조를 완벽하게 구현하는 것보다 종종 더 적은 크기의 코드를 사용한다.

 

램 사용량 줄이기

 

앞서 설명한 코드 크기를 줄이는 최적화는 결국 롬의 사용량을 줄이기 위한 방법이다. 그런데 임베디드 프로세서에서는 롬뿐만 아니라 램도 알뜰하게 사용해야 될 매우 제한된 자원이다. 프로그램에서 램은 전역 데이터, 스택 그리고 동적 메모리 할당을 위한 힙의 용도로 사용되므로 이들의 사용량을 줄여야 한다.

 

전역 데이터 줄이기: 프로그램이 실행되는 동안 값이 바뀌지 않는 전역 데이터들은 const 키워드를 추가하여 상수로 선언한다. 대부분의 C/C++ 컴파일러들은 상수로 선언된 데이터들을 일반 데이터들과는 다른 세그먼트에 위치시켜 링커/로케이터로 하여금 롬의 주소 영역으로 배치하도록 만든다.

 

스택 줄이기: 프로그램에서 사용될 스택의 주소와 크기는 링킹 과정에서 파라미터로 링커에게 전달된다. 스택의 크기를 줄이려면 프로그램에서 사용하는 스택의 최대 사용량을 먼저 알아내야 한다. 스택 영역을 임의의 초기값(예를 들면 0xCD)으로 채우고 나서 일정 시간 동안 일반 조건과 최악의 조건 두 가지 경우로 프로그램을 동작시킨다. 디버거를 통해 스택 영역의 변경된 값을 확인하면 최대 스택 사용량을 예측할 수 있다. 이런 예측이 의미를 갖기 위해서는 테스트가 충분히 길어야 하면 동작 가능한 모든 시나리오를 시험해야만 한다. 이렇게 예측된 최대 스택 사용량에 약간의 여분을 더 추가하여 스택 크기를 설정하는 것이 안전하다. 특히 RTOS를 사용하는 경우, 태스크마다 별도의 스택이 할당되므로 태스크 단위로 스택 사용량 예측을 따로 해야 된다. 이 태스크 단위의 스택은 태스크 내의 함수 호출과 지역변수 그리고 ISR(Interrupt Service Routine)을 위해 사용된다. 태스크의 수를 줄이거나 모든 ISR에 대해 독립된 하나의 스택을 별도로 운영함(실제로 이렇게 ISR 스택을 따로 운영하는 RTOS도 있다)으로써 스택 사용량을 많이 줄일 수 있다.

 

힙 사용량 줄이기: 힙 영역은 전체 램 영역에서 전역 데이터와 스택 영역을 제외한 나머지 영역으로 제한된다. 그러므로 프로그램에서 사용되는 전역 데이터나 스택의 사용량이 커지면 커질 수록 힙 영역은 작아질 수밖에 없다. 프로그램에서 malloc(), new 등으로 할당받는 동적 메모리는 바로 힙 영역에서 할당된다. 앞이 두 방법을 통해 힙의 크기를 최대로 확보했음에도 불구하고 malloc()과 new의 결과가 NULL인 경우가 발생한다면 동적 메모리의 사용량을 줄이도록 프로그램을 튜닝하는 수밖에 없다.

 

C++의 단점을 피하는 방법

 

C++이 처음 소개되던 시절에 C++은 순수 C에 비해 컴파일된 코드의 크기는 커지고 속도는 느려진다고 알려진 적이 있었다. 그 때는 그런 면도 있었다. 그러나 인간사 모든게 다 그렇듯 C++도 잘 쓰면 장점은 유지하면서 성능상의 단점은 배제하거나 타협할 수가 있다. 골라쓰는 재미 바로 그것이다.

 

class 정의: C++에서 struct와 class는 컴파일된 결과물(코드)을 놓고 봤을 때 동일하다. 멤버 데이터, 멤버 함수, 그리고 public, protected, private 등의 키워드는 컴파일 시에 사용되는 syntax일뿐 성능상의 어떤 단점도 없다. 안쓸 이유가 있나?

 

디폴트 파라미터: 디폴트 파라미터는 주로 이미 만들어진 함수의 기능을 확장할 때 요긴하게 사용된다. 함수를 호출할 때 생략된 파라미터는 컴파일 시에 자동적으로 디폴트 값이 추가된다. 단순히 모든 파라미터를 다 사용한 함수 호출과 성능상 100% 동일하다.

 

함수 오버로딩: 함수 이름은 같지만 전달되는 파라미터(즉, 프로토타입)의 개수와 타입이 다른 함수들을 오버로딩 함수라고 말한다. 소스코드 상에서는 함수 이름이 같지만 컴파일러는 프로토타입에 따라 서로 다른 이름을 부여한다. 일반 함수와 성능상 100% 동일하다.

 

연산자 오버로딩: C언어의 연산자(+, -, *, /, =, ++, --, ==, !=, <, >, <=, >=, 등)를 새로운 데이터 타입(즉, 클래스)에 사용할 수 있도록 재정의하는 것이다. 연산자를 사용한다는 표기법만 다를 뿐 함수 오버로딩과 개념과 성능상 동일하다.

 

생성자와 소멸자: 클래스 객체가 선언될 때 그리고 선언된 객체가 스코프(중괄호 { }로 묶인 영역)를 벗어날 때 눈에 보이지는 않지만 생성자와 소멸자를 호출하는 코드를 컴파일러가 자동으로 추가한다. 즉, 묵시적으로 호출되는 함수다. 그래서 루프 구조(for, while 등)의 로컬 스코프 내에서 임시 변수로 클래스 객체를 사용하게 되면 루프를 매번 돌 때마다 생성자와 소멸자가 호출되는 성능상의 오버헤드가 생길 수 있다. 가능하면 클래스 객체는 루프 구조 밖에 선언하여 루프 내에서는 재사용되도록 하는 것이 상책이다. 그러나 이런 약간의 단점에도 불구하고 생성자와 소멸자를 원칙적으로 구현하는 노력만 한다면 초기화 문제로 인한 버그나 메모리 누수 같은 문제를 원천적으로 방지하는 매우 강력한 부수 효과를 얻을 수 있다.

 

가상함수: 가상함수는 C++을 객체지향적 언어로 만들어 주는 핵심이다. 가상함수에 대한 호출은 컴파일 시에 static하게 결정되는 것이 아니라 런타임 시에 테이블 룩업을 통해 다이나믹하게 찾아가도록 코드가 생성된다. 즉, 매번 함수를 호출하기 전에 테이블 룩업이라는 과정을 거치게 되므로 성능상의 오버헤드가 있다. 그러나 클래스에 가상함수가 선언되더라도 다른 일반 멤버 함수에 대한 호출은 성능상의 어떤 영향도 받지 않는다. 과하지 않게 꼭 필요한 경우만 가상함수로 설계하는 센스가 필요하다.

 

절대적으로 피할 것: 템플리트, 예외처리(try, catch, throw), 런타임 타입확인(RTTI). 솔직히 말해 이 3가지는 없어도 견고한 프로그램을 만드는데 아무 지장이 없다. 과유불급이란 말이 이 3가지에 딱 어울리는 말이다. 템플리트는 선언하는 데이터 타입의 수에 비례해서 코드 크기가 딱 정비례해서 늘어난다. 예외처리와 RTTI는 코드 크기를 증가시킬뿐만 아니라 CPU의 성능까지도 많이 잡아 먹는다. 적어도 임베디드 프로그래밍에서 C++을 사용하겠다면 이 3가지 기능은 거들떠 보지도 말자. 공공의 적이라 하겠다.

 

임베디드 C++ 표준: 간단히 말하면 방대한 C++의 원래 표준에서 객체지향적 언어로서의 특징을 해치지 않고 제거할 수 있는 상당한 부분을 생략한 버전이다. 다중상속, Pure 가상 클래스, RTTI, 예외처리, 템플리트, 네임스페이스, 새로우 방식의 캐스트(const_cast, dynamic_cast, reinterpret_cast, static_cast) 등이 제거됐다. 결과적으로 객체지향적이며 C언어를 포함하고 런타임 오버헤드가 적으며 런타임 라이브러리의 크기가 작은 단순한 C++ 버전이다. 이미 많은 임베디드용 C++들이 임베디드 C++ 표준을 지원하거나 수동으로 개개의 언어적 특징을 금지할 수 있는 방법을 제공한다.

(사실 난 아직 이 버전의 C++을 사용해볼 기회는 없었다. 하지만 어떠랴, 생략된 저 기능들을 거의 사용하지 않고도 10수년간 프로그래밍하는데 아무 불편을 못느끼는 걸...)

 

참고)

[Programming Embedded Systems in C and C++], 1999, O'Reilly, Michael Barr

(2000년, 한빛미디어, 이석주 역)

출처 : Tong - naghun님의 기술자료통

'C/C++언어' 카테고리의 다른 글

Lex와 Yacc의 사용법 강좌  (0) 2008.06.07
고정 소수점 (C++언어 버젼)  (0) 2008.05.09
[알고리즘]고정소수점(fixed point) 연산  (0) 2008.05.09
음 고정소수점 만들기..  (0) 2008.05.09
고정 소수점  (0) 2008.05.09

+ Recent posts