티스토리 뷰

/* 본 포스팅은 최호성 저자의 '독하게 시작하는 C프로그래밍' 교재를 참조로 작성되었음을 먼저 알려드립니다. */


 함수의 이름도 배열의 이름처럼 주소상수에 부여한 식별자다. 그러므로 포인터 변수(함수 포인터)에 담을 수 있는 정보다. 만일 함수의 매개변수 구성을 알고 있고 이름 대신 주소를 알고 있다면 함수를 호출하는 데 전혀 문제가 없다. 같은 원리로 다른 함수에 내가 만든 함수의 주소를 알려줘서 호출하도록 코드를 만들 수도 있다.


 함수라는 것은 결국 기계어로 번역이 되어 메모리 어딘가에 저장이 될텐데, 쓰기는 안되고 읽기와 실행만 되게끔 구성되어 있다. 인위적으로 쓰기가 가능하게끔 만들 수도 있지만 기본적으로는 쓰기 변조를 차단하고 있다. 



# 성능 향상을 위한 이론


 함수를 호출하려면 비용(CPU + 메모리 사용)이 든다. 함수호출 그 자체가 이미 연산인데다 스택 메모리를 사용해야 한다. 또한, 프로그램의 흐름이 변경되기 위한 각종 연산이 수반된다. 그러므로 아주 간단한 작업을 함수로 만드는 것은 효율적이지 못하다.


 그래서 과거에는 외형상 함수지만 사실은 함수가 아닌 일반연산으로 처리될 수 있는 '매크로(Macro)'를 이용해 함수의 장점을 활용하면서도 효율적인 코드를 작성하고자 했다. 그러나 이제는 매크로의 단점을 보완한 __inline 함수가 도입되어 굳이 매크로가 아니어도 원하는 바를 이룰 수 있게 되었다.



# 컴파일러 최적화


 릴리즈 모드로 프로그램을 컴파일하면 '최적화' 규칙이 적용된다는 것은 volatile에 대한 포스팅에 설명이 되어 있다. 하지만 그것은 변수에 국한되는 것이다. 최근 컴파일러들은 과거와 비교하면 훨씬 더 강력한 최적화 기능을 제공한다. 불필요한 코드를 제거하여 연산을 줄이는 수준에서 함수를 한 줄짜리 구문 정도로 만들어버리는 수준까지 향상됐다.

#include <stdio.h>

int Add(int a, int b)
{
	int nResult = 0;
	nResult = a + b;
	return nResult;
}

int main(void)
{
	int nResult = 0;
	nResult = Add(3, 4);
	printf("%d\n", nResult);

	return 0;
}

 위 예제를 '릴리즈 모드'로 빌드(최적화 적용)하고 13번 행에 위치 중단점을 설정한 후 디버그 모드로 실행한다. 그리고 디스어셈블리창(Ctrl + Alt + D)을 열어 번역 상태를 확인해보면 아래와 같은 코드를 볼 수 있다.

       int nResult = 0;
       nResult = Add(3, 4);
       printf("%d\n", nResult);
00F51040  push        7 
00F51042  push        offset string "%d\n" (0F520F8h) 
00F51047  call        printf (0F51010h) 
00F5104C  add         esp,8 
       return 0;
00F5104F  xor         eax,eax 
}
00F51051  ret

Add( )함수는 아예 번역조차 되지 않았으며, 3과 4를 더한 결과가 7임을 인식한 컴파일러는 굳이 덧셈연산을 수행할 필요 없이 7을 인수로 printf( )함수를 호출해버린다. 이 과정에서 main( )함수의 지역변수인 nResult도 아예 처음부터 없는 것으로 처리되었다. 따라서 사실상 printf("%d\n", 7); 이라고 한 줄만 작성한 코드처럼 번역이 되었다.


 컴파일러가 이와 같이 최적화할 수 있었던 이유는 Add( )함수의 매개변수를 '상수'로 기술했기 때문이다. 변수는 말 그대로 변할 수 있는 속성을 가졌고, 프로그래머 관점에서 보면 '아직 결정되지 않은 숫자'이다. 이 사실을 컴파일러도 알고 있기에 확정되지 않은 숫자(변수)에 대한 연산은 그 변수에 대해 의존적일 수밖에 없다.


 특히, 그 숫자를 사용자로부터 입력받는 구조라면 프로그램을 실행하고 사용자가 값을 확정해주기 전에는 어떤 값이 연산에 참여할지 전혀 알 수 없다. 따라서 이는 최적화 규칙을 통해 생략할 수 없다. 아래 예제는 앞서 살펴본 Add( )함수의 인수로 전달되는 값을 사용자로부터 입력받는 구조로 변경한 것이다.

#include <stdio.h>

int Add(int a, int b)
{
	int nResult = 0;
	nResult = a + b;
	return nResult;
}

int main(void)
{
	int nResult = 0, x, y;
	scanf("%d %d", &x, &y);
	nResult = Add(x, y);
	printf("%d\n", nResult);

	return 0;
}

 이 예제를 릴리즈 모드 빌드하여 디버그 모드로 실행하면 아래와 같은 번역 결과를 확인할 수 있다.

int main(void)
{
00151080  push        ebp 
00151081  mov         ebp,esp 
00151083  sub         esp,8 
       int nResult = 0, x, y;
       scanf("%d %d", &x, &y);
00151086  lea         eax,[y] 
00151089  push        eax 
0015108A  lea         eax,[x] 
0015108D  push        eax 
0015108E  push        offset string "%d %d" (01520F8h) 
00151093  call        scanf (0151050h) 
       nResult = Add(x, y);
00151098  mov         eax,dword ptr [x] 
0015109B  add         eax,dword ptr [y] 
       printf("%d\n", nResult);
0015109E  push        eax 
0015109F  push        offset string "%d\n" (0152100h) 
001510A4  call        printf (0151020h) 
001510A9  add         esp,14h 
       return 0;

 Add( )함수는 번역되지 않았다. 따라서 호출할 수도 없다. 그래서인지 함수를 호출하는 코드 대신 add edx, dword ptr [y] 라는 어셈블리어 코드가 존재함을 확인할 수 있다. 즉, 사용자가 입력한 정보를 덧셈만 해서 결과를 계산하도록 번역된 것이다. 따라서 결과적으로는 함수를 호출한 것과 마찬가지가 된다. 이와 같이 컴파일러의 최적화 수준은 상당히 뛰어나다.


 그리고 최적화 과정에서 Add( ) 함수가 번역되지 않은 이유는 최적화와 관련된 VisualStudio 인라인 함수 확장 기본값 설정이 '적합한 것 모두 확장'이기 때문이다. 이렇게 설정하면 컴파일러는 모든 함수를 인라인 함수로 만들어도 상관없는지 검사한다. // 프로젝트 설정창을 누르면 아래와 같은 창이 등장한다. 기본값 설정은 '컴파일러가 알아서 판단해서 필요가 있으면 해준다'는 설정으로 되어 있다.



 /* 컴파일러가 최적화할 수 있게끔 돕는 코드가 좋은 코드다. 형한정어 const를 많이 쓰면 좋은 이유가 여기에 있다. */



# __inline 함수


 C99 표준부터 지원하기 시작한 '인라인(__inline)함수'의 장점은 기존의 매크로와 같다. 그렇다면 왜 굳이 '인라인 함수'의 도입이 필요했을까? 그건 바로 매크로는 사용시 오류를 유발하기 쉽기 때문이다.


 매크로의 가장 심각한 오류는 실제로 함수가 아니면서 함수인 척하고 있다는 것이다. 이 자체적으로도 이미 논리적 오류는 예정되어 있는 것과 같다. 그리고 매개변수의 자료형을 명시할 수 없다는 점과 여러 구문을 묶거나 제어문을 포함한다든지 혹은 지역 변수를 선언하는 일이 불가능하므로 논리구조를 만들어내는 데 한계가 있다.


 그러나 인라인 함수는 매크로의 장점을 그대로 살리면서도 매개변수의 자료형 문제, 괄호 문제, 지역변수, 제어문 문제 등 다양한 문법적 단점을 극복했다. 그렇다고 일반 함수처럼 모든 것이 다 허용되는 것은 아니다. 명시적으로 인라인 함수를 선언했더라도, 컴파일러가 판단하기에 인라인 처리가 어렵다고 판단하면 그냥 일반 함수로 처리한다.

#include <stdio.h>

int Add(int a, int b)
{
	return a + b;
}

__inline int NewAdd(int a, int b)
{
	return a + b;
}

int main(void)
{
	printf("%d\n", Add(3, 4));
	printf("%d\n", NewAdd(3, 4));

	return 0;
}

 위의 코드는 Add( )함수를 일반 함수와 인라인 함수로 구별하여 선언 및 정의한 것이다. 두 함수 모두 코드의 길이가 짧아서 충분히 인라인으로 확장될 수 있는 코드다. 그러므로 이 예제를 릴리즈 모드로 빌드하면 Add( ), NewAdd( ) 함수 모두 인라인 처리된다. 


 그러나 프로젝트의 속성을 수정하여 '__inline만 확장'으로 변경한 후 빌드해 실행하고 디스어셈블리 화면을 확인하면 아래와 같다. // 프로젝트 속성에 들어가면 위의 설정을 할 수 있다.

       printf("%d\n", Add(3, 4));
00291050  call        Add (0291040h) 
00291055  push        eax 
00291056  push        offset string "%d\n" (02920F8h) 
0029105B  call        printf (0291010h) 
       printf("%d\n", NewAdd(3, 4));
00291060  push        7 
00291062  push        offset string "%d\n" (02920F8h) 
00291067  call        printf (0291010h) 
0029106C  add         esp,10h 
       return 0;
0029106F  xor         eax,eax 
}
00291071  ret

__inline 선언된 NewAdd( )함수는 인라인 처리가 되었으나, Add( )함수는 일반 함수로 번역되었음을 알 수 있다. // 함수를 호출하는 call 명령이 왼쪽에 있는 것을 확인할 수 있다.


 사실 대부분 컴파일러들이 기본적으로 인라인 확장을 시도한다. 그래야 효율적이기 때문이다. // 소스코드 상에 함수로 기술된 코드라 해서 기계어로도 함수일 것이라고 확신하면 절대 안된다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday