gcc 이야기(1)
원문 : http://kelp.or.kr/korweblog/stories.php?story=02/03/31/9629811
시작하기에 앞서
이곳 KELP에 이런 글을 올려도 되는지 의심스럽습니다. gcc를 비롯한 개발 툴은 embedded linux뿐만 아니라 일반적인 linux 시스템 및 상용 Unix 시스템에도 널리 사용되고 있습니다. 따라서 embedded linux 시스템을 주로 다루는 KELP에 이런 글을 올린다는 것은 조금은 어울리지 않습니다만, 다른 곳에 제가 쓰는 허술한 글을 올린다는 것이 허락되지 않기 때문에…(그렇다고 KELP를 폄하하는 것은 아닙니다.)
원래는 제목을 “gcc 이야기”가 아닌 “개발툴 이야기”나 다른 것으로 정하려고 했습니다. gcc 한가지만 이야기 하고 싶어도 관련된 assembler나 linker등 binutils에 있는 툴 들의 이야기도 빠질 수 없기 때문입니다. 하지만 개발툴이라고 하면 정말 다양한 것들이 있고(예를 들어 다른 언어 개발 툴을 비롯해 make, IDE환경들, yacc & lex 등…) 그것들을 제가 모두 아는 것이 아니기 때문에 C언어만을 주로 생각하는 “gcc 이야기”로 정했습니다. 실제로 gcc는 예전에는 GNU C Compiler의 약자였으나 지금은 GNU Compiler Collection의 약자로 다양한(?) 언어의 컴파일러들의 집합체입니다. 하지만 이 글은 C 언어만 주로 다루도록 하겠습니다.
이야기 하고 싶은 부분은 gcc가 하는 일이 무엇이고 어떤 중요한 옵션들이 그런 일을 하는데 영향을 미치고 잘못된 코딩 때문에 나타날 수 있는 에러나 경고(Warning)는 무엇인지에 관한 내용입니다. gcc의 사용자로서의 일반적인 이야기를 주로 하겠으며 특정 architecture에 dependent한 내용은 없습니다. 이 글은 C 언어를 이해하고 있고 gcc를 사용해 본 적이 있는 초보자를 대상으로 작성되었으므로 고급 사용자는 읽어봐야 도움이 안될 겁니다. 그리고 gcc의 역사나 누가 개발 했고 등등의 사용하고는 전혀 관련 없는 이야기는 직접 찾아보시길 바랍니다.
gcc 일반
gcc 사용해 보셨나요? 주로 make를 이용해 linux kernel을 비롯해 기존에 제공된 패키지를 컴파일해 보셨을 것으로 생각됩니다. gcc는 한마디로 GNU에서 개발된 ANSI C 표준을 따르는 C 언어 컴파일러라고 말할 수 있습니다. gcc는 ANSI C 표준에 따르기는 하지만 ANSI C 표준에는 없는 여러 가지 확장 기능이 있습니다. 또한 gcc는 통합개발환경(IDE)을 가지고 있지 않은 command line 컴파일러입니다. Visual C++을 사용하시는 분들은 cl.exe, 옛날 turbo-C를 주로 사용해 보셨던 분들은 tcc.exe와 비슷한 녀석이라 보시면 되겠습니다.
이제 직접 gcc를 실행해 보면서 이야기를 계속 하도록 하겠습니다. 다음과 같이 shell상에서 입력하면 결과가 나옵니다.
$ gcc –v
Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66/specs
gcc version egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)
당연히 “$”은 shell prompt이므로 혼동하시지 마십시오.
(*) –v 옵션
현재 사용되고 있는 gcc의 버전을 나타내는 옵션입니다. 간혹 특정 소프트웨어 패키지를 컴파일하기 위해 어느 버전 이상의 gcc를 쓰도록 권장하는 경우가 있는데 시스템에 깔려있는 gcc의 버전을 파악하려면 위와 같이 하면 되겠습니다. 위의 결과는 alzza linux 6.1(이제는 더 이상 나오지 않는 linux distribution이죠.)에 깔려있는 gcc의 버전을 나타냅니다. 당연히 여러분들의 시스템에는 다른 결과가 나올 수 있습니다. 위의 결과는 “/usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66/specs 파일을 읽어보니 2.91.66 버전이다.” 정도로 해석할 수 있겠습니다.
이제 직접 프로그램 하나를 컴파일 해 보도록 하겠습니다. 정말 간단한 hello.c를 해보죠.
#include <stdio.h>
int main()
{
printf(“hello gcc\n”);
return 0;
}
$ gcc –o hello hello.c
로 컴파일 하면 실행파일 hello가 만들어 집니다. 어떻게 실행하고 그것이 어떤 결과를 주는지는 설명 안 해도 아시겠죠?
(*) –o 파일이름 옵션
gcc의 수행 결과 파일의 이름을 지정하는 옵션입니다. 위의 예제를 단순히
$ gcc hello.c
로 컴파일 하면 hello라고 하는 실행파일이 만들어 지는 것이 아니라 보통의 경우 a.out이라는 이름의 실행파일이 만들어 집니다. –o hello 옵션을 줌으로써 결과(여기서는 실행파일)를 hello라는 이름의 파일로 만들어 주었습니다.
위의 컴파일 과정을 외부적으로 보기에는 단순히 hello.c파일이 실행파일 hello로 바뀌는 것만 보이지만 내부적으로는 다음과 같은 단계를 거쳐 컴파일이 수행됩니다.
(1) C Preprocessing
(2) C 언어 컴파일
(3) Assemble
(4) Linking
C Preprocessing은 C 언어 배우실 때 배운 #include, #define, #ifdef 등 #으로 시작하는 여러 가지를 처리해 주는 과정이라는 것을 아실 겁니다. 그 다음 C 언어 컴파일은 C Preprocessing이 끝난 C 소스 코드를 assembly 소스코드를 변환하는 과정입니다. Assemble은 그것을 다시 object 코드(기계어)로 변환하고 printf()함수가 포함되어 있는 라이브러리와 linking을 하여 실행파일이 되는 것입니다.
위의 네 가지 과정을 모두 gcc라는 실행파일이 해 주는 것일까요? 겉으로 보기에는 그렇게 보일지 모르지만 실제로는 gcc는 소위 말해 front-end라고 하여 껍데기에 지나지 않고 각각을 해 주는 다른 실행파일을 gcc가 부르면서 수행됩니다.
C Preprocessing을 전담하고 있는 실행파일은 cpp라고 하여 /usr/bin 디렉토리와 /usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66 디렉토리(당연히 gcc버전과 시스템에 따라 디렉토리 위치가 다릅니다. gcc –v로 확인해보세요.)에 존재합니다. C 언어 컴파일은 cc1이라는 실행파일이 담당하는데 /usr/lib/gcc-lib/i386-redhat-linux/egcs-2.91.66 디렉토리(당연히 gcc버전과 시스템에 따라 디렉토리 위치가 다릅니다. gcc –v로 확인해보세요.)에 존재합니다. Assemble과 linking은 각각 as와 ld라는 실행파일이 담당하고 /usr/bin 디렉토리에 존재하는 파일입니다. (참고 : 시스템에 따라 /usr/bin이 아니라 /bin또는 /usr/local/bin 디렉토리에 존재할 수도 있습니다.)
재미있는 것을 한 번 더 해보도록 하죠. 다음과 같이 입력을 해보죠. (당연히 “//”은 주석이니깐 입력하지 말아야겠죠?)
$ mv hello.c hello.s // hello.c파일의 이름을 hello.s로 바꿉니다.
$ gcc –o hello hello.s // 컴파일
어떤 결과가 나오나요? Assembler error라고 메시지가 뜨죠? (참고 : gcc가 혁신적인 버전업을 했을 경우 에러가 안 나고 제대로 컴파일 될 수도 있습니다.) 파일명만 바뀌었을 뿐 똑 같은 입력을 주었는데 이번에는 에러가 납니다. 이것은 gcc라는 실행파일이 주어진 입력 파일명의 확장자를 보고 이것이 C 언어 소스코드가 아니라 assembly 코드(확장자는 .S또는 .s입니다.)로 인식하고 위에서 설명한 (1)번과 (2)번을 건너 뛰고 (3)을 바로 수행했기 때문입니다. 당연히 assembler(as 실행파일)는 assembly 문법과 다르기 때문에 에러를 냅니다.
이제 gcc라는 실행파일이 하는 일을 정리해 보면 다음과 같습니다.
(1) 사용자에게 옵션과 소스 파일명들의 입력을 받는다.
(2) 소스 파일명의 확장자를 보고 어떤 단계를 처리해야 할지 결정합니다.
(3) 사용자의 옵션을 각각의 단계를 맡고 있는 실행파일의 옵션으로 변경합니다.
(4) 각각의 단계를 맡고 있는 실행파일을 호출(fork와 exec이겠죠?)하여 단계를 수행하도록 한다.
이제까지 gcc라는 이름의 실행파일이 하는 일을 알아보았고, 중요한 옵션 두 가지(-o, -v)에 대해서 살펴보았습니다. 다음에는 C Preprocessing을 맡고 있는 cpp가 하는 일과 그에 해당하는 gcc옵션, 수행 중에 일어날 수 있는 에러 등에 대해서 알아보도록 하겠습니다.
gcc 이야기(2)
원문 :
http://kelp.or.kr/korweblog/stories.php?story=02/04/06/0717825
시작 및 복습
좀 딱딱한 말로 글이 시작되었습니다. 양해 바랍니다.
전 글에서 우리는 C 언어 소스가 gcc를 사용하여 컴파일 될 때 거치는 네 단계에 대해서 알아보았습니다. 하나의 C 언어 소스가 실행파일(executable file)로 바뀌는데 C Preprocessing, C 언어 Compile, Assemble, Linking을 거치게 됩니다. 각각의 단계를 cpp, cc1, as, ld라고 하는 gcc와는 다른 실행파일 들이 담당한다고 알게 되었습니다. 이제 그 네 단계 중에 첫번째 단계인 C Preprocessing이 하는 일이 무엇이고, 어떤 gcc의 옵션이 그 수행에 영향을 미치며, 어떤 에러나 경고(Warning)가 날 수 있는지를 알아볼 차례입니다.
C Preprocessing(cpp)
C preprocessing을 우리말로 하면 "C 언어 전처리"라고 할 수 있겠죠? 모든 C 언어 문법책에서 정도의 차이는 있지만 C preprocessing에 대한 내용을 다루고 있습니다. C preprocessing에 대한 문법은 C 언어 문법의 한 부분으로 가장 간단한 예제인 hello.c에도 나오니 당연하겠죠. C preprocessing에 관한 문법은 모두 '#'으로 시작됩니다. 또한 정확하게는 '#'은 그 줄(line)의 선두 문자이어야 합니다. 즉, '#' 앞에는 어떠한 문자(공백 문자 포함)도 오면 안되죠. 하지만 대부분의 compiler가 '#'앞에 공백 문자가 오는 경우에도 처리를 해주는 것으로 알고 있습니다. 아무튼, 문법에 대해서는 가지고 있는 문법책을 참조하시길 바랍니다.
그럼 C preprocessing이 하는 일을 자세히(?) 알아보도록 하죠.
(1) 입력 : C 언어 소스 코드
(2) 출력 : C 언어 소스 코드(C preprocessing된)
(3) 하는 일
- 파일 포함(file inclusion : 직역이 어색하네요)
- 매크로(macro) 치환
- 선택적 컴파일(conditional compile)
- 기타(#line, #error, #pragma)
너무 간단한가요? 말로 하면 cpp는 C 언어 소스코드를 입력 받아서 C preprocessing에 관련된 문법 사항을 적절히 처리하고 결과로 C 언어 소스코드를 출력하는 프로그램입니다. 입력은 작성된 C 언어 소스 코드이므로 굳이 설명을 안 해도 될 듯하고 출력으로 나온 C 언어 소스 코드에는 C preprocessing 문법에 관련된 어떠한 것도 남아있지 않습니다. 즉, #define, #include 등을 찾을 수 없다는 이야기입니다. 단, 남아 있는 정보는 있습니다. 그것은 file 이름과 줄수(line number)에 관한 정보는 여전히 남아 있습니다. 그 이유는 추후의 컴파일 과정에서 에러가 날 때 그 정보를 이용해서 error를 리포팅할 수 있도록 하기 위해서 입니다. 그럼 C preprocessing을 직접 해보도록 하죠. shell command line에 다음과 같이 입력하세요.(당연히 $은 shell prompt이므로 입력하지 말아야 하고, hello.c는 전에 입력했던 그 소스코드 파일입니다.)
$ gcc -E -o hello.i hello.c
결과로 hello.i라는 파일이 생깁니다. 그 파일 내용이 너무 길어 여기에 싣지는 못합니다만 에디터로 한번 열어보세요. hello.c의 첫번째 줄에 있는 #include <stdio.h>를 처리한 결과가 보입니까?
(*) -E 옵션
-E 옵션은 gcc의 컴파일 과정 중에서 C preprocessing까지만 처리하고 나머지 단계는 처리하지 말라는 것을 지시하는 것입니다. 평소에는 별로 쓸모가 있는 옵션이 아니지만 다음과 같은 경우에 유용하게(?) 사용할 수 있습니다.
(1) C 언어 소스 코드가 복잡한 선택적 컴파일을 하고 있을 때, 그 선택적 컴파일이 어떻게 일어나고 있는지 알고 싶은 경우.
(2) preprocessing의 문제가 C 언어 에러로 나타날 경우. 다음과 같은 소스코드를 고려해 보죠.
#define max(x, y) ((x) > (y) ? (x) : (y) /* 마지막에 ")"가 없다!!! */
int myMax(int a, int b)
{
return max(a, b);
}
$ gcc -c cpperr.c
다음과 같은 에러가 납니다.(>>는 에러메시지를 나타내는 기호이며 실제로 출력되지 않습니다.)
>> cpperr.c: In function `myMax':
>> cpperr.c:4: parse error before `;'
cpperr.c파일의 4번째 줄에서 ';'가 나오기 전에 parse error(뒤를 참조)가 났다고 하는 군요. 하지만 실제 에러는 #define에 있었으므로 그것을 확인하려면 -E 옵션으로 preprocessing을 하여 살펴 보면 쉽게(?) 알 수 있습니다.
(*) 참고 : parse error before x(어떤 문자) 에러는 그야말로 parsing을 할 때 발생한 에러를 말합니다. parsing이란 syntax analysis라는 과정인데 쉽게 말하면 C 언어 소스코드를 읽어드려 문법적 구성요소 들을 분석하는 과정이라고 할 수 있습니다. 보통 gcc에서 parse error라고 하면 괄호가 맞지 않았거나 아니면 ';'를 빼먹거나 했을 때 발생합니다. 보통의 경우 before x라고하여 x라는 것이 나오기 전에 parse error가 발생하였음을 알려주기 때문에 그 x가 나오기 전에 있는 C 소스 코드를 뚫어지게 바라보면 문제를 찾을 수 있습니다.
C preprocessing의 문법과 나머지 C 언어의 문법과는 거의 관계가 없습니다. 관계가 있는 부분이 있다면 정의된 macro가 C 언어의 문법 상의 char string literal에는 치환되지 않는다는 정도입니다. (좀더 쉽게 이야기 하면 큰 따옴표 안에서는 macro 치환이 되지 않습니다.) 또한 c preprocessing은 architecture dependent하지 않습니다. 즉, i386용으로 컴파일된 cpp를 다른 architecture에서 사용해도 무방합니다. 조금 문제가 있을 수 있는 부분이 있다면 gcc의 predefined macro(i386의 경우 i386용 자동으로 define된다.)가 다를 수 있다는 점 뿐입니다. 따라서 cpp를 C 언어 소스코드가 아닌 다른 부분에서 사용하는 경우도 있습니다. 대표적으로 assembly 소스 코드에서도 사용합니다. assembler가 사용하고 있는 macro 문법이 c preprocessing의 macro문법 보다는 배우기 쉽기 때문이죠.(정확히는 쉽다고 하기 보다는 새로 assembler macro를 배우지 않아도 되므로...)
파일 포함(file inclusion)
#include <stdio.h>
#include "config.h"
위와 같이 많은 C 언어 소스코드에서 헤더 파일을 포함하죠. <>와 ""의 차이는 아실테고...(혹 모르신다면 C 언어 문법책을 참조하세요.) 그런데 여기서 한가지 의문이 생깁니다. "include한 헤더 파일을 어느 디렉토리에서 찾는가?"입니다. 보통은 default로 특정 디렉토리를 찾게 됩니다. Linux 시스템의 경우 /usr/include가 default 디렉토리죠.(실제로도 그곳에 stdio.h라는 파일이 있습니다.) 그 다음은 현재 디렉토리를 찾게 됩니다.(<>와 ""에 따라서 다릅니다만...) 파일이 없으면 당연히 에러가 나겠죠. gcc의 경우 다음과 같은 에러가 납니다. (>>은 에러메시지를 나타내는 기호로 실제로는 출력되지 않습니다.)
>>소스코드파일명:line number: 헤더파일명: No such file or directory
또는(LANG=ko일때)
>>소스코드파일명:line number: 헤더파일명: 그런 파일이나 디렉토리가 없음
그렇다면 include하고 싶은 파일이 default 디렉토리와 현재 디렉토리에 없으면 어떻게 할까요? 그런 문제를 해결하기 위해서 다음과 같은 옵션이 존재합니다.
(*) -Idir 옵션
여기서 dir은 디렉토리 이름이고 -I와 디렉토리 이름을 붙여 써야 합니다. 그럼 include한 헤더 파일을 그 디렉토리에서도 찾아 주게 됩니다. 당연히 옵션을 여러 번 다른 디렉토리 이름으로 줄 수도 있어서 헤더 파일을 찾을 디렉토리를 여러 개로 지정할 수 있습니다. 꼭 알아 두어야 할 옵션입니다.
관련 옵션을 하나만 더 알아보죠.
(*) -nostdinc
이 옵션은 default 디렉토리(standard include 디렉토리)를 찾지말라고 지시하는 옵션입니다. 어플리케이션 프로그래머는 관심을 둘 필요가 없지만 kernel 프로그래머는 관심 있게 볼 수 있는 옵션이죠.
macro 치환
macro 치환에 대해서는 특별히 일어날만한 에러는 없습니다. 가끔 문제가 되는 부분이 macro 정의가 한 줄을 넘어선 경우 역슬레쉬('\')로 이어져야 하는데 그 소스 파일이 windows용 에디터로 편집 되었으면 parse error가 나는 경우가 있습니다. 그것은 개행문자(new line character)가 서로 달라서 그런 것인데...음 자세히 이야기하자면 끝이 없으므로 그냥 넘어가도록 하죠. 또한 macro가 define된 상황에서 macro를 undef하지 않고 다시 define하면 다음과 같은 Warning이 납니다.
>> 'xxxx' redefined
macro 치환에서 대한 옵션 두개를 알아보도록 하죠.
(*) -Dmacro 또는 -Dmacro=defn 옵션
gcc의 command line에서 macro를 define할 수 있도록 하는 옵션입니다. 예를 들어 -D__KERNEL__이라는 옵션을 주면 컴파일 과정 중에 있는 C 언어 소스코드의 맨 처음에 #define __KERNEL__이라고 해준 것과 같이 동작합니다. 또한 -DMAXLEN=255라고하면 C 언어 소스코드의 맨 처음에 #define MAXLEN 255 라고 한 것과 동일한 결과를 줍니다. 선택적 컴파일을 하는 경우에 많이 이용하는 옵션으로 꼭 알아야 할 옵션입니다.
(*) -Umacro 옵션
이 옵션은 #undef하고 하는 일이 똑같은데 C 언어 소스코드와는 하등의 관계가 없습니다. -Dmacro옵션처럼 C 언어 소스코드의 맨처음에 #undef macro를 해주는 것은 아무런 의미가 없기 때문이죠.(어짜피 #define은 그 이후에 나올 것이므로...) 이 옵션의 목적은 위의 -Dmacro옵션으로 define된 macro를 다시 undef하고자 할 때 쓰는 옵션입니다. 평상시에는 별로 쓸 일이 없는 옵션이지만 그냥 -Dmacro와 같이 짝으로 알아 두시길 바랍니다.
선택적 컴파일
#if 시리즈와 #else, #elif, #endif 등으로 선택적 컴파일을 수행할 수 있는 것은 모두 아실 것으로 생각됩니다. 위에서 설명한 -Dmacro 옵션과 같이 쓰는 경우가 많죠. 암튼 특별히 설명할 옵션은 없고 #if와 #else, #endif의 짝이 잘 맞아야 합니다. 안 그러면 당연히 에러가 발생합니다. 단순히 parse error라고 나오는 경우는 드물고, #else, #if 에 어쩌고 하는 에러가 납니다. 많이 경우의 수가 있으므로 직접 에러가 발생되도록 코딩을 해보고 확인해 보셔도 좋을 듯 합니다.
기타(#line, #error, #pragma)
#line, #error, #pragma라는 것이 있는지도 모르는 분들이 꽤나 있을 듯 싶습니다. 자세한 것은 당연히 C 언어 문법 책을 찾아 봐야 겠죠. #line의 경우 C 언어 소스코드 직접 쓰이는 경우는 거의 없으니까 무시하고 #pragma는 compiler에 dependent하고 gcc에서 어떤 #pragma를 사용하는지도 알 수 없으므로 그냥 넘어가도록 하겠습니다. #error의 경우 C preprocessing 과정에서 강제로 에러를 만드는 지시어입니다. 선택적 컴파일 과정에서 도저히 선택되어서는 안 되는 부분에 간혹 쓰입니다. 당연히 #error를 만나면 에러가 생깁니다. linux kernel 소스코드에서 include 디렉토리를 뒤져 보시면 사용하는 예를 만나실 수 있습니다.
predefined macro
사용자가 C 언어 소스코드에서 #define을 하지 않아도 이미 #define된 macro가 있습니다. ANSI C에서는 __LINE__, __FILE__, __TIME__, __DATE__, __STDC__ 다섯 가지는 이미 define되어 있는 macro로 강제적인 사항입니다.(당연히 모르면 문법책 참조) gcc도 당연히 다섯 가지 macro를 predefine합니다. 뿐만 아니라 GCC의 버전에 대한 macro, architecture에 관한 사항 등을 -Dmacro 옵션 없이도 predefine합니다. 전에 말씀드린 -v 옵션을 실행하면 출력되는 specs파일을 열어보시면 감을 잡으실 수 있을 겁니다.(specs파일이 어떻게 해석되는지는 저도 잘 모르니까 묻지 마시길...)
꼭 알아두면 좋은 옵션 한가지
다음과 같이 shell 상에 입력해 보세요.(hello.c는 계속되는 그 녀석입니다.)
$ gcc -M hello.c
어떤 것이 출력되나요? "hello.o: hello.c /usr/include/stdio.h 어쩌구저쩌구"가 출력될 것입니다. 어디서 많이 본 듯한 형식 아닌가요?
(*) -M 옵션
-M 옵션은 cpp에게 makefile로 만들 수 있는 rule을 만들어달라고 하는 요청을 보내는 명령입니다. file을 include하는 녀석은 cpp이므로 rule은 cpp가 만들 수 있겠죠. 당연히 -Dmacro, -Umacro, -Idir 옵션 등을 같이 사용할 수 있고 그에 따라 결과가 달라질 수도 있습니다. makefile을 좀 쉽고 정확하게 만들 때 쓰는 옵션이므로 알아두면 좋습니다. 단지 안 좋은 점은 default 디렉토리에 있는 보통 사용자는 고칠 수도 없는 파일까지 무식(?)하게 만들어 준다는 것입니다.
이제 총 정리 해 보도록 하겠습니다. C preprocessing은 C 언어 소스코드를 입력으로 받아 file inclusion, macro 치환, 선택적 컴파일, 기타를 처리하고 C 언어 소스코드를 출력하는 과정입니다. 그 중에 몇 가지 에러가 날 수 있는 부분이 있고(물론 사용자의 잘못으로) 몇 가지 중요한 옵션을 알아봤습니다.
이상 장황한 C preprocessing에 관한 gcc 이야기를 끝냅니다. 넘 길었나요? 다음에는 C 언어 compile과정에 대해서 알아보도록 하겠습니다.(다음 편은 매우 짧게 할 예정입니다.)
gcc 이야기(3)
원문 :
http://kelp.or.kr/korweblog/stories.php?story=02/04/14/1014649
시작 및 복습
이제 컴파일 과정의 4단계 중에 C 언어 컴파일 과정을 알아볼 차례입니다. 컴파일러를 만드는 사람의 입장에서는 이 과정이 핵심이라고 할 수 있습니다. Parsing(또는 syntax analysis)에서부터 각종 코드 최적화에 관한 과정들이 모두 들어 있죠. 특히 최적화 부분은 컴파일러에 관한 연구의 핵심 부분이며 최적화를 잘 하면 훨씬 빨리 동작하는 소프트웨어를 만들어 낼 수 있습니다. 하지만 사용자의 입장에서 보면 다른 과정과 별로 다를 바는 없습니다. 그냥 컴파일 과정의 4단계 중에 한 단계로 이해하시면 편할 것 같습니다.
C 언어 컴파일 과정
처음에 말씀 드린 바대로 C 언어 컴파일 과정은 gcc라고 하는 frontend가 cc1이라는 다른 실행파일을 호출(fork & exec 이겠죠?)하여 수행하게 됩니다. 사용자가 cc1이라는 실행파일을 직접 실행해야 할 하등의 이유도 없고 권장되지도 않습니다.
지난 두 번의 이야기에서 미처 말하지 못한 내용이 있는데, 여기서 잠시 하도록 하겠습니다. gcc의 입력으로 여러 개의 파일(C 소스 파일, object 파일 등)을 준다고 하더라도 컴파일 과정 중 앞 3단계, 즉 cpp, C 컴파일, assemble은 각각의 파일 단위로 수행됩니다. 서로 다른 파일의 영향을 받지 않고 진행됩니다. 당연한 거죠. 특정 C소스 코드에서 #define된 macro가 다른 파일에는 당연히 반영되면 안됩니다. header 파일의 존재 의미를 거기서 찾을 수 있습니다.
이제 C 언어 컴파일 과정이 하는 일을 자세히(?) 알아보도록 하겠습니다.
C 언어 컴파일 과정이 하는 일
(1) 입력 : C 언어 소스 코드(C preprocessing된)
(2) 출력 : Assembly 소스 코드
(3) 하는 일 : 컴파일(너무 간단한가요?)
C preprocessing과 마찬가지로 너무 간단합니다. 하지만 위의 “컴파일” 과정은 cc1 내부에서는 여러 단계로 나누어져 다음과 같은 순서로 일어납니다. Parsing(syntax analysis)이라고 하여 C 언어 소스 코드를 파일로부터 읽어 들여 컴파일러(여기서는 cc1)가 이해하기 쉬운 구조로 바꾸게 됩니다. 그 다음에 그 구조를 컴파일러가 중간 형태 언어(Intermediate Language)라고 하는 다른 언어로 변환하고 그 중간 형태 언어에 여러가지 최적화를 수행하여 최종 assembly 소스 코드를 만들게 됩니다.
우선 직접 수행해 보도록 하겠습니다. 다음과 같이 shell의 command line에 입력해 보죠. 역시 지긋지긋한 hello.c를 이용하도록 하겠습니다.
$ gcc –S hello.c
결과로 출력된 hello.s를 에디터로 열어서 살펴보세요 (혹시 위의 command로 hello.s가 만들어 지지 않는다면 gcc –S –o hello.s hello.c로 다시 해보세요.). “.”으로 시작하는 assembler directive와 “:”로 끝나는 label명, 그리고 몇 개의 assembly mnemonic이 보이나요? Assembly 소스를 읽을 줄 몰라도 그게 assembly 소스 코드구나 하시면 됩니다.
(*) –S 옵션
-S 옵션은 gcc의 컴파일 과정 중에서 C 언어 컴파일 과정까지만 처리하고 나머지 단계는 처리하지 말라는 것을 지시하는 것입니다. 평소에는 별로 쓸모가 있는 옵션이 아니지만 다음과 같은 경우에 유용하게(?) 사용할 수 있습니다.
(1) 어셈블리 코드가 어떻게 생겼는지 볼 수 있는 보고 싶은 호기심이 발동한 경우(그런 경우 별로 없죠?)
(2) C calling convention을 알아보거나 stack frame이 어떻게 관리되고 있는 지 보고 싶은 경우(조금 어려운 가요?)
보통의 경우는 아니지만 사용자가 직접 assembly 코딩을 하는 경우가 종종 있습니다. 아무래도 사람이 기계보다는 훨씬 똑똑(?)하기 때문에 사람이 직접 assembly 코딩을 해서 최적화를 시도하여 소프트웨어의 수행 시간을 단축시키거나, 아니면 linux kernel이나 bootloader 등과 같이 꼭 assembly가 필요한 경우가 있습니다. 이때도 보통의 경우는 소프트웨어의 전 부분을 assembly 코딩하는 것이 아니라 특정 부분만 assembly 코딩을 하고 나머지는 C 언어나 다른 high-level 프로그래밍 언어를 써서 서로 연동을 하도록 하죠. 그럼 C 언어에서 assembly 코딩된 함수를 호출할 때(반대의 경우도 마찬가지), 함수의 argument는 어떻게 전달되는 지, 함수의 return 값은 어떻게 돌려지는지 등을 알아볼 필요가 있습니다. 이렇게 argument와 return 값의 전달은 CPU architecture마다 다르고 그것을 일정한 약속(convention)에 따라서 처리해 주게 됩니다. 위의 hello.s를 i386용 gcc로 만들었다면 파일 중간에 xorl %eax,%eax라는 것이 보일 겁니다. 자기 자신과 exclusive or를 수행하면 0(zero)이 되는데 이것이 바로 hello.c에서 return 0를 assembly 코드로 바꾼 것이죠. 결국 i386 gcc에서는 %eax 레지스터에 return 값을 넣는 다는 convention이 있는 겁니다.(실제로는 gcc뿐 아니라 i386의 convention으로 convention을 따르는 모든 compiler가 %eax 레지스터를 이용하여 return값을 되돌립니다.) argument의 경우도 test용 C 소스를 만들어서 살펴볼 수 있겠죠. 물론 해당 CPU architecture의 assembly 소스코드를 어느 정도 읽을 수 있는 분 들에게만 해당하는 이야기 입니다.(그럼 이 글을 읽을 만한 초보자가 아니겠지만…) stack frame도 비슷한 얘기 쯤으로 알아 두시면 됩니다.
Parsing(Syntax Analysis)
위에서 cc1이 컴파일을 수행하는 과정 중에 맨 첫 과정으로 나온 Parsing에 대해서는 좀더 언급을 해야 겠습니다.(나머지 과정은 설명 안 합니다.) Parsing과정은 그야말로 구문(Syntax)을 분석(Analysis)하는 과정입니다. Parsing의 과정은 파일의 선두에서 뒤쪽으로 한번 읽어 가며 수행됩니다.(중요!!!) Parsing 중에 컴파일러는 구문의 에러를 찾는 일과 뒤에 수행될 과정을 위해서 C 언어 소스 코드를 내부적으로 다루기 쉬운 형태(보통은 tree형식을 이용합니다.)로 가공하는 일을 수행합니다. 이 중에 구문의 에러를 찾는 과정은 (1) 괄호 열고 닫기, 세미콜론(;) 기타 등등의 문법 사항을 체크하는 것 뿐만 아니라 (2) identifier(쉽게 말해 변수나 함수 이름 들)의 type을 체크해야 합니다.
(1) 괄호 열고 닫기, 세미콜론(;) 기타 등등의 문법 사항에 문제가 생겼을 때 발생할 수 있는 에러가 전에 이야기한 parse error입니다. 보통 다음과 같이 발생합니다.
>> 파일명과 line number: parse error before x
당연히 에러를 없애려면 ‘x’ 앞 부분에서 괄호, 세미콜론(;) 등을 눈 빠지게 보면서 에러를 찾아 없애야 합니다.
(2) type checking
구문 에러를 살필 때 type 체크를 왜 해야 할까요? 다음과 같은 예를 보도록 하겠습니다.
var3 = var1 + var2;
앞 뒤에서 parse error가 없다면 위의 C 언어 expression은 문법적인 문제가 없습니까? 하지만 var1이 파일의 앞에서 다음과 같이 정의(definition)되었다면 어떻게 될까요?
struct point { int x; int y; } var1;
당연히 ‘+’ 연산을 수행할 수 없습니다.(C 언어 문법상) 결국은 에러가 나겠죠. 이렇게 identifier(여기서는 var1, var2, var3)들의 type을 체크하지 않고서는 구문의 에러를 모두 찾아낼 수 없습니다.
만약 var1과 var3가 파일의 앞에서 int var1, var3;로 정의되어 있고 var2가 파일의 앞에 어떠한 선언(declaration)도 없이 파일의 뒤에서 int var2;로 정의되어 있다면 에러가 발생할까요? 정답은 “예”입니다. 위에서 언급했듯이 Parsing은 파일의 선두에서 뒤쪽으로 한번만(!!!) 읽으면서 진행하기 때문입니다.(모든 C 컴파일러가 그렇게 동작할지는 의심스럽지만 ANSI C 표준에서는 그렇게 되어 있는 것으로 알고 있습니다. Assembler는 다릅니다.)
그렇다면 어떤 identifier를 사용하려면 반드시 파일 중에 사용하는 곳 전에 identifier의 선언(declaration) 또는 정의(definition)가 있어야 겠군요. 하지만 identifier가 함수 이름일 경우(즉 identifier뒤에 (…)가 올 경우)는 조금 다릅니다. C 컴파일러는 함수 이름 identifier의 경우는 int를 return한다고 가정하고 Error가 아닌 Warning만 출력합니다.(Warning옵션에 따라 Warning조차 출력되지 않을 때도 있습니다.) 그럼 다음과 같은 소스 코드를 생각해 보겠습니다.
int var3, var2;
….
var3 = var1() + var2;
….
struct point var1(void) { … }
위와 같은 경우도 문제가 생깁니다. 맨 처음 var1이라는 함수 이름 identifier를 만났을 때 var1 함수는 int를 return한다고 가정했는데 실제로는 struct point를 return하므로 에러 또는 경고를 냅니다.
결국 권장하는 것은 모든 identifier는 사용하기 전(파일 위치상)에 선언이나 정의를 해 주는 것입니다.
다음과 같은 에러 메시지들을 짧막하게 설명하죠.
>>파일명 line number: ‘x’ undeclared …. 에러
‘x’라는 이름의 identifier가 선언되어 있지 않았다는 것이죠.
>>파일명 line number: warning: implicit declaration of function `x' … 경고
‘x’라는 이름의 함수가 선언되어 있지 않아 int를 return한다고 가정했다는 경고(Warning) 메시지입니다.
(*) 여기서 잠깐
변수나 함수의 선언(declaration)과 정의(definition)에 대해서 알지 못한다면 C 언어 문법책을 찾아서 숙지하시길 바랍니다. 그런 내용이 없다면 그 문법책을 휴지통에 버리시길 바랍니다.
Parsing 과정에는 위의 identifier 에러 및 경고를 비롯한 수많은 종류의 에러와 경고 등이 출력될 수 있습니다. 에러는 당연히 잡아야 하고 경고도 무시하지 않고 찾아서 없애는 것이 좋은 코딩 습관이라고 할 수 있겠습니다. 경고 메시지에 대한 gcc 옵션을 살펴보도록 하겠습니다.
(*) –W로 시작하는 거의 모든 옵션
이 옵션들은 어떤 상황 속에서 경고 메시지를 내거나 내지 말라고 하는 옵션들입니다. –W로 시작하는 가장 강력한 옵션은 –Wall 옵션으로 모든 경고 메시지를 출력하도록 합니다. 보통은 –Wall 옵션을 주고 컴파일 하는 것이 좋은 코딩 습관입니다.
Parsing 이후 과정
특별한 경우가 아닌 이상 Parsing을 정상적으로 error나 warning없이 통과한 C 소스 코드는 문법적으로 완벽하다고 봐야 합니다. 물론 논리적인 버그는 있을 수 있지만 이후 linking이 되기 전까지의 과정에서 특별한 error나 warning이 나면 안됩니다. 그런 경우가 있다면 이제는 사용자의 잘못이 아니라 gcc의 문제로 추정해도 무방합니다. Parsing이후에 assembly 소스 코드가 생성되는데, 당연히 이 과정에는 특별히 언급할 만한 error나 warning은 없습니다. 그냥 중요한 옵션 몇 가지만 집고 넘어가도록 하겠습니다.
(*) –O, -O2, -O3 등의 옵션
이 옵션은 컴파일러 최적화를 수행하라는 옵션입니다. –O 뒤의 숫자가 올라갈수록 더욱 많은 종류의 최적화를 수행하게 됩니다. 최적화를 수행하면 당연히 코드 사이즈도 줄어 들고 속도도 빨라지게 됩니다. 대신 컴파일 수행 시간은 길어지게 되겠죠. 그리고 linux kernel을 위해 언급하고 싶은 것은 inline 함수들은 이 옵션을 주어야 제대로 inline됩니다.
(*) –g 옵션
이 옵션은 소스 레벨 debugger인 gdb를 사용하기 위해 debugging 정보(파일명, line number, 변수와 함수 이름들과 type 등)를 assembly code와 같이 생성하라는 옵션입니다. 당연히 gdb를 이용하고 싶으면 주어야 합니다. –g 옵션을 주지 않고 컴파일한 프로그램을 gdb로 디버깅하면 C 소스 레벨이 아닌 assembly 레벨 디버깅이 됩니다. 즉 C 소스 코드에 존재하는 변수 이름, line number 등이 없는 상황에서 디버깅을 해야 합니다. 또한 –g 옵션을 –O 옵션과 같이 사용할 수도 있습니다. 단 그런 경우 최적화 결과, C 소스 코드에 존재하는 심볼(symbol; 쉽게 말해 함수와 변수 이름)중에 없어지는 녀석들도 존재합니다.
(*) 또 여기서 잠깐
이런 것까지 알아야 할지 의심스럽지만… identifier와 symbol이 모두 “쉽게 말해 함수와 변수 이름”이라고 했는데 어떻게 차이가 날까요? 엄밀히 말하면 차이가 조금 있습니다. symbol이 바로 “쉽게 말해 함수와 변수 이름”이며 각 symbol은 특정 type과 연계되어 있습니다. 하지만 identifier는 그냥 “이름” 또는 “인식어”일 뿐입니다. 예를 들어 struct point { int x; int y; };라는 것이 있을 때 point는 symbol은 아니지만 identifier입니다. 보통 identifier라는 말은 parsing에서만 쓰인다는 정도만 알아두시면 좋겠습니다. 이후에 symbol이나 identifier라는 말이 나오면 “쉽게 말해 함수와 변수 이름”이라고 표기하지 않겠습니다.
(*) –p 옵션과 –pg 옵션
profiling을 아십니까? 수행시간이 매우 중요한 프로그램(real time 프로그램이라고 해도 무방할 듯)을 작성할 때는 프로그램의 수행 시간을 함수 단위로 알아야 할 필요가 있는 경우가 많습니다. 프로그램의 수행 시간을 함수 단위나 더 작은 단위로 알아보는 과정을 profiling이라고 하는데, profiling은 프로그램 최적화에 있어서 중요한 기능을 담당합니다. 대부분의 개발 툴이 지원하고 Visual C++에도 존재합니다. 옛날 turbo C에는 있었는지 모르겠군요.(제가 turbo C를 사용 할 때는 profiling을 해야 할 프로그램을 작성한 적이 없어서) 아무튼 gcc도 역시 profiling을 지원합니다. –p 옵션 또는 –pg 옵션을 주면 프로그램의 수행 결과를 특정 파일에 저장하는 코드를 생성해 주게 됩니다. 그 특정 파일을 적당한 툴(prof또는 gprof 등)로 분석하면 profiling 결과를 알 수 있게 해 줍니다. 당연히 linux kernel 등에서는 사용할 수 없습니다.(이유는 특정 파일에 저장이 안되므로…) 초보자 분들은 이런 옵션도 존재하고 profiling을 할 수 있다는 정도만 알아 두시면 좋을 듯 싶습니다. 나중에 필요하면 좀 더 공부해서 사용하시길.
(*) 기타 옵션(-m과 –f시리즈)
중요한 옵션들이기는 하지만 초보자가 알아둘 필요가 없는 옵션 중에 f또는 m으로 시작하는 옵션들이 있습니다. f로 시작되는 옵션은 여러 가지 최적화와 assembly 코드 생성에 영향을 주는 architecture independent한 옵션입니다.(assembly 코드 생성이 architecture dependent이므로 정확히 말하면 f로 시작되는 옵션이 architecture independent라고 할 수는 없습니다.) m으로 시작되는 옵션은 보통 architecture dependent하며 주로 CPU의 종류를 결정하는 옵션으로 assembly 코드 생성에 영향을 주게 됩니다. 하지만 대부분은 초보자는 그런 것이 있다는 정도만 알아두면 되고 특별히 신경 쓸 필요는 없다고 생각됩니다. m으로 시작되는 옵션 중에 KELP 사이트의 사용자가 관심을 둘만한 옵션 중에 –msoft-float옵션이 있습니다.(물론 특정 architecture에만 존재하는 옵션입니다.) –msoft-float 옵션은 CPU에 FPU(floating point unit)가 없고, kernel에서 floating-point emulation을 해 주지 않을 때 C 소스 코드 상에 있는 모든 floating-point 연산을 특정 함수 호출로 대신 처리하도록 assembly 코드를 생성하라고 지시하는 옵션입니다. 이 옵션을 주고 라이브러리를 linking시키면 FPU가 없는 CPU에서도 floating 연산을 할 수 있습니다.(대신 엄청 느리죠. 어찌보면 kernel floating-point emulation보다는 빠를 것 같은데 확실하지는 않습니다.)
이상 C 언어 컴파일 과정에 대해서 알아보았습니다. 다음에는 assemble 과정에 대해서 알아보겠습니다. 예고하기로는 짧게 하겠다고 했는데, 글 솜씨가 없어서 그런지 상당히 길어졌습니다. 하지만 Assemble 과정은 아는 것이 별로 없는 관계로 정말 짧을 것 같습니다
gcc 이야기(4)
원문 :
http://kelp.or.kr/korweblog/stories.php?story=02/04/22/6981171
시작 및 복습
이제 Assemble 과정입니다. 이제 중간을 지나왔네요. Assemble 과정에는 별로 지적할 만한 것은 없습니다. 그럼 시작합니다.
Assemble 과정
Assemble 과정은 앞선 과정과 동일하게 gcc라는 frontend가 as라는 실행 파일을 호출(?)하여 수행됩니다. 그런데
as는 cpp와 cc1과는 달리 gcc 패키지 안에 존재하는 것이 아니라 별도의 binutils라고 하는 패키지에 존재합니다. binutils 패키지 안에는 as를 비롯해 linking을 수행하는 ld, library 파일을 만드는 ar, object 파일을 보거나 복사할 수 있는 objdump, objcopy 등 여러 가지 툴이 들어 있습니다.
Assemble 과정이 하는 일
(1) 입력 : Assembly 소스 코드
(2) 출력 : relocatable object 코드
(3) 하는 일 : assemble(너무 간단한가요?)
역시나 간단하네요. 입력은 당연히 C 언어 컴파일 과정을 거치면 나오는 Assembly 소스 코드입니다. Assemble 과정을 거치면 소위 기계어(machine language)라는 결과가 relocatable object 형식으로 나오겠죠. “relocatable”이라는 말이 어려우면 그냥 object 코드라고 해 두죠. 어짜피 나중에 설명이 나올테니깐.
이제 직접 수행해봐야 겠죠? shell의 command line에 다음과 같이 입력하면 됩니다.
$ gcc –c hello.c
지겨운 hello.c를 썼습니다. 결과는 hello.o라고 하는 파일이 나옵니다. hello.o는 binary형식의 파일이니깐 editor로 열어봐야 정보를 얻기 힘듭니다. 당연히 위의 예는 assemble 과정만 수행한 것이 아니라 C preprocessing 과정, C 언어 컴파일 과정, Assemble 과정을 수행했겠죠. Assemble 과정만 수행하고 싶으면 다음과 같이 입력하면 됩니다.
$ gcc –c hello.s
역시 hello.o가 생기겠죠. hello.s는 C 언어 컴파일 과정에서 –S 옵션으로 만들었던 그 파일입니다. 별로 관심이 안 생기면 as를 직접 수행할 수도 있습니다. 다음과 같습니다.
$ as –o hello.o hello.s
역시 hello.o가 생기죠?
Assemble 과정 이야기가 짧기 때문에 늘려서 써 봤습니다(이해해 주시길…).
(*) –c 옵션
많이 쓰는 옵션이죠. Assemble 과정까지의 과정만 수행하고 linking 과정을 수행하지 말라는 옵션입니다. 여러 개의 C 소스 파일로 이루어진 프로그램을 컴파일 할 때 모든 소스 파일을 assemble 과정까지 수행하고 맨 마지막에 linking하죠. 보통은 Makefile을 많이 이용하는데 그 때 많이 쓰이는 옵션이죠.
Assemble 과정에서는 더 이상 기억해야 하는 옵션도 없고 이게 끝입니다. C 언어 컴파일 과정에서 말씀 드린 바대로 C 언어 컴파일 과정이 끝난 C 소스 파일은 문법적으로 완전하다고 볼 수 있으므로 assemble 과정에서 Error나 Warning 나는 경우는 없습니다. 만약 Error나 Warning이 나는 경우가 있다면 gcc의 inline assemble을 이용했을 때, 그 inline assemble 소스 코드에 있는 문제 때문에 생길 수 있습니다. 안타깝지만 error나 warning 메시지가 나온 다면 C 소스 파일과 line number 정보는 없습니다. 잘 알아서 처리하는 수 밖에 다른 방법은 없는 것 같습니다. inline assemble 같은 것을 사용하지 않았는데도 error나 warning이 난다면 gcc의 버그라고 생각하셔도 무방합니다.
여기서 끝내면 너무 짧으니까 재미있는(?) 이야기나 하도록 하죠. 이 이야기는 linking 과정을 이해하기 위해서 필요할 지 모르니 조금 어렵더라도 참고 읽어 두시길 바랍니다.
relocatable object 코드 파일 내용
어떤 정보가 object 파일 안에 들어있을까요? 당연히 code와 data가 들어 있습니다. C 컴파일 과정에서 C 언어 함수 안에 있는 내용들이 assembly mnemonic 들로 바뀌었고 그것이 assemble되어 기계어(machine language)가 되었을 겁니다. 그 부분이 code를 이루겠죠. C 언어 소스 코드에 있는 나머지는 전역 변수(external variable)와 정적 변수(static variable)들이 data를 이룰 겁니다. 또한 문자열 상수를 비롯한 상수도 data에 들어 있겠죠. 또한 프로그램 수행에 쓰이지는 않고 단순한 정보로서 들어 있는 data들도 있습니다. 예를 들어 –g 옵션을 주고 컴파일 하면 프로그램의 디버깅 정보(변수, 함수 이름, C 소스 파일이름, line number 등)가 data에 속한다고 볼 수 있습니다. 그런데 code와 data가 무질서하게 섞여 있는 것은 아니고 section이라고 불리우는 단위로 서로 구분되어 저장되어 있습니다. Code는 text section에 들어 있고, data는 성격에 따라 data section, bss section, rodata section 등에 나누어져 저장되어 있습니다.(text, data, bss, rodata 등의 section 이름은 그냥 관습적인 것입니다.) 아무튼 section 이야기는 이 정도만 우선 알아두시면 될 듯 싶습니다. 좀 더 복잡한 이야기를 할 수도 있지만 초보자에게는 별 필요 없을 듯 하여 그만 두겠습니다.
Symbol 이야기
relocatable object code안에 code와 data가 들어 있다고 했는데, 아주 중요한 것을 빠뜨렸습니다. 이 이야기는 linking 과정을 이해하기 위해 꼭 필요한 부분이므로 반드시 읽어 두셔야 할 듯 싶습니다.
우선 Symbol이 무엇인지 아시죠? C 언어 컴파일 과정에서 identifier와 함께 설명 드렸는데 잠시 다시 말씀 드리면 Symbol은 함수와 변수 이름입니다. 변수 중에 특히 관심두어야 할 것 들은 자동 변수(?,auto variable)들이 아닌 전역 변수(external variable)와 정적 변수(static variable) 입니다. 자동 변수는 함수의 stack frame에 존재하는 변수이기 때문에 현재 stack pointer(sp, 보통의 CPU의 register중에 하나)에 대한 offset으로 표현됩니다. 즉 현재 함수에서 자동 변수(auto variable)를 access(read/write)하고 싶으면 sp+상수의 어드레스를 access하면 되죠. 하지만 전역 변수와 정적 변수는 그냥 32bit(32bit CPU기준) 어드레스를 읽어야 합니다. stack pointer랑은 전혀 관계 없죠. 아무튼 여기서 관심을 두는 Symbol은 함수, 전역 변수와 정적 변수의 이름이라고 할 수 있습니다.
이제 생각해 볼 것은 C 언어 소스 파일을 C preprocessing, C 언어 컴파일, assemble 과정을 거치면 완전한 기계어로 바꿀 수 있느냐 하는 점입니다. 완전히 기계어로 바꿀 수 있을 까요? C 언어 소스 파일 하나로 이루어지는 프로그램이라면 완전히 기계어로 바꾸는 것이 가능하겠지만 일반적으로는 불가능 합니다. 다음과 같은 예제를 살펴보죠.
[test1.c]
int func3(void); /* func3 선언 */
extern int mydata; /* mydata 선언 */
int func2(void) /* func2 정의 */
{
// …
}
int func1(void) /* func1 정의 */
{
int i;
// …
func2();
// …
func3();
// …
i= mydata+3;
// …
}
[test2.c]
int mydata = 3; /* mydata 정의 */
int func3(void) /* func3 정의 */
{
// …
}
위의 예제를 컴파일 한다고 생각해보죠. test1.c에서 func1()의 내용을 기계어로 바꾸고 싶은데 func2()를 호출하는 시점에서는 별로 문제가 안됩니다. func2()는 같은 소스 코드 내에 존재하고 func2()를 호출하는 instruction과 func2()의 실제 위치(어드레스)의 차이를 계산해 낼 수 있으므로 상대 어드레스를 이용하는 함수 호출 instruction으로 완전히 기계어로 바꿀 수 있습니다. 그런데 문제는 func3()를 호출할 때는 func3()의 실제 위치(address)를 계산할 수 없다는 문제점이 있습니다. 당연히 동일한 파일에 존재하는 함수가 아니므로 그 함수가 존재하게 될 어드레스를 계산할 수 없겠죠. 어드레스를 모르는데 함수 호출 instruction을 완전히 만들 수 있을까요? 만들 수 없죠. 당연히 전역 변수 mydata를 access하는 부분도 마찬가지로 mydata의 어드레스를 모르므로 완전히 instruction으로 바꿀 수 없습니다. 그럼 어떻게 해야 될까요?
그때 assembler는 그냥 함수 어드레스 없는 함수 호출 instruction을 기계어로 바꾸어 놓습니다. 그런 다음에 그 instruction에 “func3()를 호출한다”라는 표지를 붙여 놓습니다. 그럼 그 후의 과정(linking 이겠죠?)에서 func3()의 address를 계산했을 때 그 빈 공간을 채워 넣게 됩니다. mydata와 같은 전역 변수도 마찬가지로 동작합니다. 그럼 test1.c을 컴파일할 때는 “func3()”, “mydata” 라는 표지를 사용해야 겠죠? 그럼 test2.c를 컴파일 할 때는 무엇이 필요할까요? 상식적으로 생각하면 “func3()”, “mydata”가 여기 있다라는 정보를 가지고 있어야 겠죠?
정리하면 object 파일 안에는 그 object 파일에 들어있는 symbol들(test1.o에서는 func1과 func2, test2.o에서는 func3와 mydata)에 대한 정보가 들어있고, 그 object 파일이 reference하고 있는 symbol들(test1.o에서 func3와 mydata 사용)에 대한 정보가 들어 있습니다. 이해 되시나요?
Relocatable의 의미
위에서 object 코드라고 하지 않고 relocatable object 코드라고 지칭했는데 relocatable이 뜻하는 것을 잠시 집고 넘어 가겠습니다. Relocatable을 사전에서 찾아보면 “재배치가 가능한” 정도의 뜻입니다. “재배치가 가능한” 이라는 의미는 상당히 모호합니다. 좀 더 구체적으로 말씀드리면 위에서 설명된 symbol들의 절대 어드레스가 정해지지 않았다는 뜻입니다. 즉 test1.c의 func1()이 절대 어드레스 0x80000000에 존재해야 한다라고 정해지지 않고 어떤 절대 어드레스에 존재해도 관계 없다는 뜻입니다. 그런데 이 말과 헷갈리는 말이 한가지 더 있는데 그것은 position independent code입니다. C 언어 컴파일 과정에서 설명한 옵션중에 –f 시리즈가 있었습니다. 그 중에
–fpic라는 position independent code를 만들라고 강제하는 옵션이 있습니다. position independent code도 역시 절대 어드레스상에 어느 위치에 있어도 무방한 code를 지칭합니다. 하지만 두 가지는 분명 차이가 있는데… 에이, 그냥 넘어 가도록 하죠. 설명을 하려면 상당히 복잡하기 때문에… 그냥 relocatable은 절대 어드레스가 결정되지 않았다는 뜻, 그러나 position independent code와는 다른 말임을 알아 두세요.
이상 assemble 과정에 대한 이야기가 끝났습니다. assemble 과정 자체는 짧았는데 linking 과정에 대한 이야기가 약간 나오는 바람에 역시 길어졌습니다. 다음에는 linking과정에 대해서 알아 보도록 하겠습니다. linking 과정은 상당히 재미있고(?) 긴 이야기가 될 것 같습니다. 두 번에 나누어 적어야 되지 않을까 싶을 정도로…
오랜만에 납땜을 했더니 flux remover 냄새 때문인지 머리가 조금 아프네요. 그래도 납땜한 board가 잘 동작하여 기분은 좋습니다.
gcc 이야기(5)
원문 : http://kelp.or.kr/korweblog/stories.php?story=02/05/13/5782879
시작 및 복습
개인적 사정으로 linking과정의 글을 적는데 조금 오래 걸렸습니다. 그리고 alzza linux 6.1 리눅스 머신을 더 이상 사용할 수 없게 되었습니다. 이 글에 있는 내용은 redhat 7.2 리눅스 머신을 기준으로 작성되었습니다.(어짜피 별 차이도 없습니다.)
마지막 linking과정입니다. Linking 과정의 기본에 대해서는 gcc 이야기(4)에서 맛보기로 살펴보았습니다. 이제 좀 더 구체적인 이야기를 살펴보도록 하겠습니다. 조금은 긴 이야기가 될 것 같아 지루할 것도 같지만, 많은 생략을 통해 핵심만 간단히 적도록 노력하겠습니다.
Linking 과정
Linking 과정은 ld라고 하는 실행파일이 담당하고 있습니다. Assemble을 담당하는 as와 마찬가지로 binutils 패키지의 일부분이죠. 보통 어플리케이션을 컴파일하는 경우에는 gcc(실행파일)를 이용하여 ld를 호출하나, 특별한 경우에 있어서는 ld를 직접 수행하여 linking을 하는 경우가 종종 있습니다.
Linking 과정이 하는 일
(1) 입력 : 하나 이상의 relocatable object 코드 와 library
(2) 출력 : 실행파일(executable) 또는 relocatable object 코드
(3) 하는 일 : symbol reference resolving & location
좀 복잡한가요? Linking 과정은 하나 또는 그 이상의 object 파일과 그에 따른 library를 입력으로 받습니다. 출력은 보통의 경우는 실행파일(executable file)이지만, 경우에 따라서 object 파일을 생성하게 할 수도 있습니다. 여러 개의 object 파일을 합쳐서 하나의 object 파일로 만드는 과정을 partial linking이라고 부르기도 합니다. Linking 과정이 하는 일은 symbol reference resolving하고 location이라고 했는데, 저도 정확한 단어를 적은 것인지 의심스럽습니다. 정확한 용어를 사용한다면 좋겠지만 그렇지 못하더라도 내용을 정확히 이해하는 것이 중요하니깐 내용에 대해서 살펴보도록 하겠습니다.
symbol reference resolving
gcc 이야기(4)의 마지막 부분에 나오는 예제인 test1.c, test2.c에서 다룬 내용입니다. 우선 그것을 안 보셨다면 살펴보시길 권합니다. 어떤 C 소스 파일에서 다른 파일에 있는 함수와 전역 변수(symbol)에 대한 참조(reference)를 하고 있다면 assemble 과정에서 완전한 기계어로 바꿀 수 없습니다.(실제로는 같은 소스 파일에 있는 전역 변수를 참조하는 것도 보통의 경우, 완전한 기계어로 바꿀 수 없습니다.) 그 이유는 당연히 assemble 까지의 과정은 단일 파일에 대해서만 진행되고, 다른 파일에 있는 해당 함수와 전역 변수의 address가 상대적이든 절대적이든 결정될 수 없기 때문입니다. 따라서 완전히 기계어로 바꿀 수 없는 부분은 그대로 “공란”으로 남겨두고 표시만 해 두게 됩니다.
Linking 과정에서 그 “공란”을 채워 넣게 됩니다. 그 과정을 보통 “resolve한다”라고 말합니다. 어떻게 할까요? 당연히 실행 파일을 이루는 모든 object 파일을 입력으로 받기 때문에 object 파일들을 차곡 차곡 쌓아 나가면(아래 location 참조) object 파일 안에 있는 모든 symbol(함수나 전역 변수 이름)의 address를 상대적이든 절대적이든 계산할 수 있습니다. 이제 각 symbol의 address가 계산되었으므로 표시가 남아 있는 “공란”에 해당하는 symbol의 address를 잘 넣어주면 됩니다. 어떻습니까? 간단한가요?
linking 과정에서 나올 수 있는 에러는 대부분 여기에서 발생합니다. 표시가 남아 있는 “공란”을 채울 수 없는 경우가 있습니다. 크게 두 가지로 나누어지는데요 우선 reference하고 있는 symbol을 찾을 수 없는 경우와 reference하고 있는 symbol의 정의가 여러 군데에 있는 경우죠. 이해하기 쉽죠?
>> object파일명: In function ‘func’:
>> object파일명: undefined reference to ‘symbolname’
위의 에러 메시지는 함수 func 안에서 사용되고 있는 symbolname이란 이름의 symbol이 어디에도 정의되지 않아서 “공란”을 채울 수 없다는 뜻입니다. 당연히 symbolname을 잘못 입력하였던지 아니면 그 symbol이 속해있는 object 파일이나 library와 linking되지 않았기 때문입니다.
>> object파일명1: multiple definition of ‘symbolname’
>> object파일명2: first defined here
위의 에러 메시지는 symbolname이란 이름의 symbol이 여러 번 정의되고 있다는 뜻입니다. object파일1에서 정의가 있는데 이미 object파일2에서 정의된 symbol이므로 그 symbol을 reference하고 있는 곳에서 정확하게 “공란”을 채울 수 없다는 뜻입니다. 당연히 두 symbol중에 하나는 없애거나 static으로 바꾸거나 해야 해결될 것입니다.
location(용어 정확하지 않을 수 있음)
이전 까지 object 코드를 모두 relocatable이라고 표현했습니다. 아직 절대 address가 결정되지 않았다는 의미로 사용된다고 gcc 이야기(4)에서 말씀드렸죠.(position independent code와는 다른 의미라는 말씀과 함께) object 코드의 절대 address를 결정하는 과정이 “location”입니다. Symbol reference resolving과정에서 입력으로 받은 모든 object 파일들을 차곡 차곡 쌓아 나간다고 했습니다. 그런데 object 파일이 무슨 벽돌도 아닌데 차곡 차곡 쌓는 다는 것이 말이 되나요? 여기서 쌓는 다는 말을 이해하기 위해서 다음과 같은 그림(?)을 살펴 보도록 하죠.(처음으로 그림(?)을 그리는 것 같네요.)
많은 object code들
----------------- address(0xAAAAAAAA+0x5000)
test2.o(size 0x3000)
----------------- address(0xAAAAAAAA+0x2000)
test1.o(size 0x2000)
----------------- address(0xAAAAAAAA)
(그림)
절대 address 0xAAAAAAAA에 test1.o의 내용을 가져다 놓습니다. test1.o의 크기(파일 크기와는 의미가 조금 다르지만 그냥 무시하고 파일 크기라고 생각하기 바람)가 0x2000이므로 다음에 test2.o를 쌓을 수 있는 address는 0xAAAAAAAA+0x2000가 되죠. 그곳에 다시 test2.o를 쌓고 또 test2.o의 크기를 보고 새로운 address 계산하고 또 object 코드 쌓고, 계속 반복이죠. 이렇게 쌓을 때 초기 절대 address 0xAAAAAAAA가 무슨 값을 가지게 되면 모든 object 파일에 있는 symbol의 절대 address도 계산해 나갈 수 있겠죠. 그걸로 symbol reference를 resolve하게 되죠. 그 초기 절대 address 0xAAAAAAAA의 값을 정하는 것을 location이라고 합니다. 그럼 왜 절대 address를 결정해야 할까요? 꼭 그래야 할 필요는 없습니다만 CPU의 instruction이 대부분의 경우 절대 address를 필요로 하는 경우가 많기 때문이라고 할 수 있습니다.
(주의) object 를 쌓는 것은 위의 예처럼 단순하지는 않습니다. 실제로는 object 전체를 쌓지 않고 object안에 있는 section별로 쌓게 됩니다. gcc 이야기(4)에서 잠시 나왔던 section 기억하시죠?
그럼 이제 직접 수행해 봐야겠죠.
$ gcc –o hello hello.o
간단하죠? object 파일이 하나라서 너무 단순하다고 생각하십니까? 물론 hello.o 하나만 command line에 나타나지만 실제로는 조금 많은 object 파일이 linking되고 있습니다.(아래에서 좀더 자세한 이야기를 하죠.) 지겹지만 hello를 실행해 보세요. 제대로 동작합니까? 제대로 동작한다면 그 사이 어떤 일이 벌어졌을까요? 그 사이에 벌어진 일을 간단히 적어보면 다음과 같습니다. shell이 fork() 시스템콜을 호출하고 자식 process는 exec() 시스템콜을 통해 hello라는 파일 이름을 kernel에 넘깁니다. kernel에서는 hello파일을 보고 linking할 때 location된 address(여기서는 absolute virtual address입니다.)상의 메모리로 hello 파일을 복사하고 PC(program counter)값을 바꾸면 수행되기 시작합니다. 간단하죠?
(주의) 실제로 위의 hello가 수행되는 과정은 많은 생략과 누락이 있었습니다. 실제로는 hello 파일을 완전히 메모리로 복사하는 것도 아니고, dynamic linking & loading 등의 개념이 완전히 빠져 있습니다만 그냥 이해하기 쉽게 하기 위해서 간단하게 적어 본 겁니다. 딴지 걸지 마시길…
library
hello.o를 linking하여 hello라고 하는 실행파일을 만드는데 command line에서는 아무것도 없지만 library가 같이 linking되고 있습니다. 그것은 지극히 당연합니다. hello.c의 main함수에서 printf함수를 호출(linking이니깐 참조 혹은 reference라고 해야 좋겠습니다.)하고 있는데 printf함수 자체는 소스 중에 그 어디에도 없습니다.(물론 stdio.h에 printf함수의 선언은 있습니다만 정의는 어디에도 없습니다.) 잘 알다시피 printf함수는 C standard library 안에 있는 함수입니다. C standard library가 같이 linking되었기 때문에 제대로 동작하는 hello 실행파일이 생긴 것이죠.
library라는 것은 아주 간단한 것입니다. relocatable object 파일들을 모아 놓은 파일이죠. 소스로 제공할 수도 있으나 그러면 매번 cpp, c 컴파일, assemble 과정을 거쳐야 하므로 컴파일 시간이 매우 증가하게 되겠죠. 그래서 그냥 relocatable object 파일로 제공하는 것이 컴파일 시간 단축을 위해서 좋습니다. 그런데 필요한 relocatable object 파일이 너무 많으면 귀찮으니까 그것을 묶어서 저장해 놓은 녀석이 바로 library라고 할 수 있습니다.
Linux를 비롯한 unix 계열에서는 대부분의 library 파일의 이름이 lib로 시작됩니다. 확장자는 두 가지가 있는데, 하나는 .a이고 또 하나는 .so입니다.(뒤에 library 버전 번호가 붙는 경우가 많이 있습니다.) .a로 끝나는 library를 보통 archive형식의 library라고 말하며 .so로 끝나는 library를 보통 shared object라고 부릅니다. /lib 디렉토리와 /usr/lib 디렉토리에 가면 많이 볼 수 있습니다.
archive library 안에 있는 symbol를 reference하게 되면 library중에 해당 부분(object 파일 단위)을 실행 파일 안에 포함시켜 linking을 수행합니다. 즉 해당 object 파일을 가지고 linking을 수행하는 것과 동일한 결과를 가집니다. 보통 이런 linking을 static linking이라고 부릅니다.
그런데 시스템 전체에 현재 수행되고 있는 실행파일(우리는 실행파일이 수행되고 있는 하나의 단위를 process라고 부르죠.)들에서 printf함수를 사용하고 있는 녀석들이 매우 많으므로 그것이 모두 실행 파일에 포함되어 있다면 그것은 심각한 메모리 낭비를 가져온다는 문제점을 가지고 있습니다. 그래서 생각해 낸 것이 dynamic linking이라는 개념입니다. 예를 들어 실행파일이 printf함수를 사용한다면 실행파일이 메모리로 loading될 때 printf가 포함되어 있는 library가 메모리 상에 있는 지 검사를 해 보고 있으면 reference resolving만 수행하고 아니라면 새로 loading과 reference resolving을 하게 됩니다. 그렇게 되면 printf가 포함되어 있는 library는 메모리 상에 딱 하나만 loading되면 되고 메모리 낭비를 막을 수 있죠. 그런 일을 할 수 있도록 도입된 것이 shared object입니다. MS Windows쪽의 프로그래밍을 하시는 분이라면 DLL과 동일한 개념이라고 보시면 됩니다.
그런 shared object를 이용하여 dynamic linking을 하면 실행파일의 크기가 줄어 듭니다. 반면에 당연히 실행파일이 메모리에 loading될 때는 reference resolving을 위해서 CPU의 연산력을 사용하죠. 하지만 MS Windows의 DLL과는 달리 shared object 파일과 static linking을 할 수도 있습니다.(반대로 archive library를 이용하여 dynamic linking을 수행할 수는 없습니다.) 암튼 각설하고 여기서 gcc 옵션 한 가지를 살펴 보죠.
(*) –static 옵션
dynamic linking을 지원하고 있는 시스템에서 dynamic linking을 수행하지 않고 static linking을 수행하라는 옵션입니다. dynamic linking을 지원하고 있는 시스템에서는 dynamic linking이 default입니다.
직접 수행해 보도록 하겠습니다.
$ gcc –o hello_static –static hello.o
실행파일 hello, hello_static 을 수행하면 결과는 똑같습니다. 파일의 크기를 비교해 보세요.
여기서 의문점이 또 생기는군요. /lib, /usr/lib에는 엄청 많은 library 파일들이 존재합니다. 그럼 linker가 찾아야 하는 symbol을 모든 library 파일에 대해서 검사를 해 볼까요? CPU하고 HDD가 워낙 빠르면 그래도 무방하겠지만, 그렇게 하지 않습니다.(“사용자가 쉽게 할 수 있는 일을 컴퓨터에게 시키지 말라.”라는 컴퓨터 사용 원칙이죠.) 우선 gcc는 기본적인 library만 같이 linking을 하게 되어 있습니다. 나머지 library는 사용자의 요구가 있을 때만 같이 linking을 시도하도록 되어 있습니다. 그럼 기본적인 library가 무엇인지 알아야 하고 gcc에게 사용자의 요구를 전달할 옵션을 있어야 겠죠? 기본적인 library는 당연히 C standard library입니다. C standard library의 이름은 libc.a또는 libc.so입니다. 최근의 linux 머신을 가지고 계신 분은 /lib/libc.so.6이라는 파일을 찾아 보실 수 있을 겁니다(symbolic link되어 있는 파일이지만). 그리고 libgcc라고 하는 것이 있는데…생략하고. 이제 옵션을 알아보죠.
(*) –nostdlib 옵션
이름에서 의미하는 바대로 standard library를 사용하지 말고 linking을 수행하라는 뜻입니다. 실제로는 standard library뿐 아니라 startup file이란 녀석도 포함하지 않고 linking이 수행됩니다. startup file에 대해서는 좀 있다가 알아보도록 하겠습니다.
(*) –l라이브러리이름 옵션
특정 이름의 library를 포함하여 linking을 수행하라는 뜻입니다. 예를 들어 –lmyarchive라고 하면 libmyarchive.a(또는 libmyarchive.so)라는 library파일과 같이 linking을 수행하는 겁니다. library 파일 이름은 기본적으로 lib로 시작하니깐 그것을 빼고 지정하도록 되어 있습니다.
library에 대해서 또 하나의 옵션을 알아야 할 필요가 있습니다. 다름 아닌 “어느 디렉토리에서 library를 찾는가”입니다. 모든 library가 /lib와 /usr/lib에 있으라는 보장이 없잖아요. 그 디렉토리를 정하는 방법은 두 가지 인데 LD_LIBRARY_PATH라고 하는 이름의 환경 변수를 셋팅하는 방법이 있고 또 한 가지는 gcc의 옵션으로 넘겨 주는 방법이 있습니다.
(*) –Ldir 옵션
library 파일을 찾는 디렉토리에 “dir”이란 디렉토리를 추가하라는 옵션입니다.(-Idir 옵션처럼 –L과 dir을 붙여서 적습니다.) 예를 들어 –L/usr/local/mylib 라고 하면 /usr/local/mylib라는 디렉토리에서 library 파일을 찾을 수 있게 됩니다.
가야 할 길은 한참 남았는데 벌써 너무 길어진 것 같습니다. 다음 글에서 남겨진 부분에 대해서 말씀 드리도록 하겠습니다. 남겨진 부분도 꽤나 길 것으로 생각됩니다…쩝..
gcc 이야기(6)
원문 :
http://kelp.or.kr/korweblog/stories.php?story=02/06/29/7679007
시작 및 복습
gcc 이야기(5)에서 이미 linking 과정에서 하는 일의 본질과 library에 대해서 알아보았습니다. 그리고 흔히 발생하는 에러 메시지 두 가지가 발생하는 이유와 대처 방법에 대해서 알아보고 library와 관련된 옵션 몇가지를 알아보았습니다. 하지만 아직도 linking 과정 전부를 알기 위해서는 좀더 검토해야 할 것들 있습니다. 그것들에 대해서 차근 차근 알아보도록 하겠습니다.
entry 이야기
application을 작성하고 compile, linking 과정이 지나면 실행 파일이 만들어집니다. 그리고 그 실행 파일이 수행될 때는 메모리로 load되어 수행이 시작된다는 사실을 알고 있습니다. 여기서 한가지 의문이 생기는데, “과연 코드의 어떤 부분에서 수행이 시작되는가?”입니다. 답이 너무 뻔한가요? main함수부터 수행된다고 답하시겠죠? 다소 충격적이겠지만 “땡”입니다. main함수부터 수행되지 않고 그전에 수행되는 코드가 존재합니다. 그 먼저 수행되는 코드에서 하는 일은 여러 가지가 있는데 그냥 건너 뛰도록 하겠습니다. 아무튼 그 코드에서 main함수를 호출해 주고 main함수가 return하면 exit 시스템호출을 불러 줍니다. 그래서 main이 맨 처음 수행되는 것처럼 보이고 main이 return하면 프로그램 수행이 종료되는 겁니다. 그럼 그 코드는 어디 있을까요? 시스템에 따라서 다르겠지만 일반적으로 /lib혹은 /usr/lib 디렉토리에 crt1.o라는 이름의 object 파일이 있는데 그 object 파일 안에 있는 _start라는 이름의 함수(?)가 맨 먼저 수행되는 녀석입니다. 결국 보통 application의 entry는 _start함수가 됩니다.
그럼 crt1.o object 파일 역시 같이 linking되어야 겠죠? gcc를 이용해 linking을 수행할 때 command line에 아무 이야기를 해주지 않아도 자동으로 crt1.o 파일이 함께 linking됩니다. 실제로는 crt1.o 뿐 아니라 비슷한 crt*.o 파일들도 같이 linking되는데요. 그렇게 같이 linking되고 있는 object파일들을 startup file이라고 부르는 것 같습니다.(-nostdlib 옵션 설명할 때 잠시 나왔던 startup file이 바로 이 녀석들입니다.)
여기서 한 가지 의문사항이 떠오를만 합니다. 그럼 ld는 _start파일이 entry인지 어떻게 알고, 다른 이름의 함수를 entry로 할 수는 없는걸까요? 의문의 해결은 아래 linking script부분에서 해결될 겁니다.
실행 파일에 남아 있는 정보
linking의 결과 실행파일이 생겼는데, 보통 linux에서는 실행파일 형식이 ELF라는 포멧을 가집니다.(linux 시스템에 따라 다를 수 있는지 모르겠네요.) ELF는 Executable and Linkable Format의 약자입니다. 보통 linux 시스템에서의 relocatable object 파일의 형식도 ELF인데요, 실제로 실행파일과 relocatable object 파일과는 조금 다른 형식을 가집니다. 암튼 그건 상식으로 알아두고, 그럼 실행파일에 있는 정보는 무엇일까요?
이제까지의 알아낸 정보들을 모두 종합하면 알 수 있습니다. 우선 실행 파일이라는 녀석이 결국은 relocatable object를 여러 개 쌓아놓은 녀석이므로 원래 relocatable object 파일이 가지고 있던 code와 data 정보는 모두 남아있을 겁니다. 그리고 entry를 나타내는 address가 있어야 수행을 할 수 있겠죠? 또, dynamic linking을 했을 경우 관련된 shared object 정보도 남아있어야 하겠죠.
실행 파일 속에 남아있는 data는 relocatable object에 있는 data처럼 프로그램 수행에 필요한 data가 있고 그냥 실행 파일을 설명하는 정보로서의 data가 있습니다. 예를 들어 –g 옵션을 주고 컴파일한 실행파일에서 디버깅 정보들은 실행과는 전혀 관계 없죠. 따라서 그러한 정보들은 실행 파일 수행시에 메모리에 load될 필요도 없습니다.(load하면 메모리 낭비니깐) 실행 파일 속에 남아있는 code와 data는 relocatable object처럼 특별한 단위로 저장되어 있습니다. ELF 표준에서는 segment라고 부르는데 보통의 경우는 object 파일처럼 section이라는 말이 쓰입니다. reloctable object 파일과 마찬가지로 code는 text section에 저장되고 프로그램 수행 중에 필요한 data가 성격에 따라 나누어져 data, rodata, bss section이란 이름으로 저장되어 있습니다. 그 section단위로 메모리로 load될 필요가 있는지에 대한 flag정보가 있고 각 section이 load될 address(location과정에서 정했겠죠?)가 적혀 있어야 정확하게 loading을 할 수 있습니다.
기타로 symbol reference resolving이 끝났는데도 ELF형식의 실행파일은 보통의 경우 많은 symbol 정보를 그냥 가지고 있는 경우가 있습니다. symbol 정보 역시 수행에는 하등 관계가 없으므로 없애도 되는데, strip이라고 하는 binutils안에 있는 tool로 없앨 수 있습니다.
linking script
흠 이제 좀 어려운 이야기를 할 차례입니다. Location과정에서 어떤 절대 address를 기준으로 각 section들을 쌓는지, 그리고 entry는 어떤 symbol인지에 대한 정보를 linker에게 알려줄 필요가 있습니다. 보통 application의 경우는 시스템 마다 표준(?, 예를 들어 entry는 _start다 하는 식)이 있는지라 별로 문제될 것은 없는데, bootloader나 kernel을 만들 때는 그런 정보를 사용자가 넘겨 주어야 할 필요가 있습니다. 그런 것들을 ld의 command line argument로 넘길 수도 있지만 보통의 경우는 linking script라고 하는 텍스트 형식의 파일 안에 저장하여 그 script를 참조하라고 알려 줍니다.(아무래도 command line argument로 넘겨 줄 수 있는 정보가 한계가 있기 때문이라고 생각이 듭니다. location과 entry에 관한 내용 중에 ld의 command line argument로 줄 수 있는 옵션이 몇가지 있으나 한계가 있습니다.) ld의 옵션 –T으로 linking script 파일 이름을 넘겨 주게 됩니다.(gcc의 옵션 아님) linux kernel source를 가지고 있는 분은 arch/*/*.lds 파일을 함 열어 보세요. 그게 linking script고, 초기 절대 address하고 section별로 어떻게 쌓으라는 지시어와 entry, 실행 파일의 형식 등을 적어 놓은 내용이 보일 겁니다. 물론 한 줄 한 줄 해석이 된다면 이런 글을 읽으실 필요가 없습니다. 그 script를 한 줄 한 줄 정확히 해석해 내려면 GNU ld manual 등을 읽으셔야 할 것입니다.
linux의 insmod
이곳 KELP 사이트에 오시는 많은 분들은 특성상 linux kernel을 구성하고 device driver 등은 linux kernel module(이하 module) 형식으로 run-time에 올릴 수 있다는 것을 아실 겁니다. module을 run-time에 kernel에 넣기 위해서 사용하는 명령어가 insmod죠.(modprobe도 가능)
이 module이라는 것이 만들어 지는 과정을 잘 살펴 보시면 gcc의 옵션중에 -c옵션으로 컴파일만 한다는 것을 알 수 있습니마. 확장자는 .o를 사용하구요. 그럼 relocatable object 파일이겠네요. 당연히 ELF형식이겠구요.
그럼 이 module이 linux kernel과 어떻게 합쳐질까요? 당연히 linking 과정을 거쳐야 됩니다. 일종의 run-time linking인데요. 당연히 module은 kernel내의 많은 함수와 전역 변수를 참조합니다. 그렇지 않다면 그 module은 linux kernel의 동작과는 전혀 관계 없는 의미 없는 module이 될테니까요. 그럼 참조되고 있는 symbol을 resolving하기 위해서는 symbol의 절대 address를 알아야 겠네요. 그 내용은 linux kernel 내부에 table로 존재합니다. /proc/ksyms라고 하는 파일을 cat해보시면 절대 address와 symbol 이름을 살펴보실 수 있을 겁니다. 살펴보시면 아시겠지만 생각보다 적은 양이죠? 적은 이유는 그 table이 linux kernel source에 있는 전역 symbol의 전부를 포함한 것이 아니라 kernel source 내부나 module 내부에서 EXPORT_SYMBOL()과 같은 특별한 방법으로 선언된(?, 이 선언은 C 언어 문법의 declaration과는 다릅니다.) symbol들만 포함하기 때문입니다. 다른 전역 symbol 들은 module 프로그래밍에 별 필요가 없다고 생각되어 지는 녀석들이기 때문에 빠진 겁니다. 따라서 EXPORT_SYMBOL()등으로 선언된 symbol들만 사용하여 module을 작성해야 합니다.
당연히 linking 과정을 거치기 때문에 앞서 설명드린 linking에서 발생할 수 있는 에러들이 발생할 수 있습니다. 제일 많이 발생할 수 있는 것은 역시 undefined reference 에러일 겁니다. gcc의 에러와는 조금 다른 메시지가 나오겠지만 결국은 같은 내용입니다.
map 파일
linking 과정을 끝내면 당연히 모든 symbol에 대한 절대 address가 정해지게 됩니다. 그 정보를 알면 프로그램 디버깅에 도움이 될 수도 있으니 알았으면 좋겠죠. ld의 옵션중에 '-Map 파일이름'이라는 옵션이 있는데 우리가 원하는 정보를 문서 파일 형식으로 만들어 줍니다. 그 파일을 보통 map 파일이라고 부르죠. symbol과 address 정보 말고 section에 대한 정보도 있고 많은 정보가 들어 있습니다.
linux kernel을 컴파일을 하고 나면 나오는 결과 중에 System.map이라는 파일이 있는데 이 녀석이 바로 ld가 만들어 준 map 파일의 내용 중에 symbol과 symbol의 절대 address가 적혀 있는 파일입니다. linux kernel panic으로 특정 address에서 kernel이 죽었다는 메시지가 console에 나오면 이 System.map 파일을 열어서 어떤 함수에서 죽었는지 알아볼 수도 있습니다.
옵션 넘기기
gcc의 이야기 맨 처음에 gcc는 단순히 frontend로 command line으로 받은 옵션을 각 단계를 담당하고 있는 tool로 적절한 처리를 하여 넘겨준다고 말씀드렸습니다. 위에서 나온 ld의 옵션 -T와 -Map 과 같은 옵션은 gcc에는 대응하는 옵션이 존재하지 않습니다. 이런 경우 직접 ld를 실행할 수도 있고 gcc에게 이런 옵션을 ld에게 넘겨 주라고 요청할 수 있습니다. 하지만 application을 컴파일할 때는 ld를 직접 실행하는 것은 조금 부담이 되므로, gcc에 옵션을 넘기라고 요청하는 방법이 조금 쉽다고 볼 수 있습니다. 그런 경우 사용되는 것이 -Wl 옵션인데 간단히 이용해 보도록 하겠습니다.
$ gcc -o hello -static -Wl,-Map,hello.map hello.c
그럼 hello.map이라는 매우 큰 문서 파일이 만들어 집니다. 한번 살펴 보세요.(-static 옵션을 안 넣으면 살펴볼 내용이 별로 없을까봐 추가했습니다.)
실제로는 -Wl 옵션처럼 as에게도 옵션을 넘겨 줄 수 있는 -Wa와 같은 옵션이 있는데 쓰는 사람을 본 적이 없습니다.
끝
이상 gcc를 사용할 때 필요한 지식에 대해서 간략히 알아보았습니다. 더 많은 정보를 얻고 싶은 분들은 gcc, cpp, as, ld 등의 manpage와 manual을 참조하시길 바랍니다.