[OS]/Embedded

[펌] gcc 이야기(3)

하늘을닮은호수M 2005. 9. 6. 22:19
반응형

gcc 이야기(3)

글쓴이 : holelee (2002년 04월 14일 오후 04:56)

[ 임베디드강좌/이규명 ] @ KELP

=== 시작하기에 앞서
gcc라는 컴파일러를 이용하여 C 언어 프로그램을 컴파일 하기 위해서 알아야 할 기본적인 옵션 및 발생할 수 있는 에러에 대해 초보자를 대상으로 작성된 글입니다. 고급 사용자라면 읽으실 필요가 없을 것으로 생각됩니다. Architecture dependent한 부분은 가능한 배제하였습니다. 단 gcc의 사용은 Linux를 비롯한 Unix 계열의 OS에서 사용된다는 가정을 하였습니다. 또한 이 글에 대한 모든 내용은 본인이 사용하고 있는 alzza linux 6.1에서 gcc-2.91.66을 바탕으로 하고 있습니다. gcc에 대하여 좀더 많은 것을 알고 싶으신 분은 gcc manpage와 gcc manual을 참조하시기 바랍니다.
이 글에 나오는 모든 내용이 정확하다고 할 수는 없으며, 그 글에 나오는 내용을 따라 gcc를 사용하는데 있어서 문제가 발생할 경우, 본인은 책임을 지지 않습니다. 이 글에 대한 저작권은 본인(holelee)에게 있습니다. 글에 대해 잘못된 점이나 지적할 사항이 있으신 분은 저 위의 “holelee”를 클릭하여 메일을 보내 주시기 바랍니다.


=== 시작 및 복습
이제 컴파일 과정의 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 과정은 아는 것이 별로 없는 관계로 정말 짧을 것 같습니다.

반응형

'[OS] > Embedded' 카테고리의 다른 글

[펌] gcc 이야기(6)  (0) 2005.09.06
[펌] gcc 이야기(4)  (0) 2005.09.06
[펌] gcc 이야기(1)  (0) 2005.09.06
[펌] gcc 이야기(2)  (0) 2005.09.06
[펌] minicom과 sftp를 이용해서 타겟보드에 리눅스부트 이미지올리는법..  (0) 2005.07.06