engineering/System Eng.2007. 2. 26. 09:48

1회 | C 복습! 컴파일, 그리고 소스 분리하기
2회 | 포인터는 우스워
3회 | 에러없는 프로그램에 도전한다
4회 | 소스 분석하기
5회 | 디버깅하기
6회 | C로 임베디드 시스템 맛보기
7회 | 임베디드 리눅스 애플리케이션 개발하기



많은 프로그래머 지망생들이 꼭 배워야 하는 프로그래밍의 관문이라면 C와 C++를 꼽을 수 있습니다. 요즘은 훌륭한 툴 덕분에 아주 간편하게 프로그래밍을 할 수 있는 방법이 많지만 제대로 컴퓨터를 이해하기 위해서는 C와 C++는 한번은 꼭 거쳐야 할 관문입니다. 임베디드 프로그래밍 시대를 살아갈 독자들을 위해 준비한 이번 연재가 실제 프로그래밍을 하면서 사용하는 많은 테크닉을 배우는 ‘결정적’ 기회가 되기를 바랍니다.

언어처리에 관심이 많으며 맹목적으로 유닉스와 PDA를 추종하는 동물적 감각을 지녔다는 필자는 PDA는 비싼 장난감의 일종이라고 굳게 믿고 있다. 기계에 묻혀 살다보니 얼리 어댑터가 아니냐는 이야기를 자주 듣지만 결코 아니다! 필자는 유닉스 관련 동호회와 PDA 관련 동호회 몇 군데에 자주 출몰한다고 한다.

많은 사람들이 C를 배우기는 했지만 제대로 사용하지 못하는 경우를 많이 접하게 됩니다. 무언가 해보고는 싶지만 무엇을 해야 할 것인가, 또 어떻게 시작해야 할지, 하고는 싶지만 그저 막막한 기분에 멍하게 앉아 있다가 웹 서핑으로 시간을 보내는 경우가 대부분일 것입니다. 필자는 PHP를 이용해 웹 프로그래밍을 하기 위해서는 꼭 C를 이용해 처음부터 CGI를 만들어 본 경험이 있어야 한다고 생각합니다. 훌륭한 GUI 프로그램을 만들기 위해서는 MFC나 GTK+, Qt와 같은 툴킷을 사용해 보기 전에 먼저 WIN32 API나 Xlib와 같은 저수준의 라이브러리를 이용해 처음부터 C로 작성해 볼 필요가 있습니다. 그리고 훌륭한 IDE(Intergrated Development Environment)가 갖춰져 있는 개발툴을 이용해 프로그램을 작성하려면 반드시 에디터와 컴파일러만을 이용해 프로그램을 개발해 본 경험이 꼭 필요합니다. C 언어로 직접 작업을 하게 되는 시스템 프로그래머가 아니라도 이런 의미에서 C는 프로그래밍을 하려면 알아야 할 언어인 것입니다.


다시 C 언어에 도전하기
이번 연재는 C를 배우고 싶었지만 중도에 포기해 버렸던 독자들, C를 배우기는 했지만 아직 무언가 부족하다고 생각하는 독자들, 실제로 프로그래밍을 하면서 사용하는 많은 테크닉을 배우고 싶다고 생각하는 독자들을 위해 마련되었습니다. C에서 비교적 이해하기 힘든 내용들을 간단히 다시 살펴보고, 실제로 프로그래밍을 하면서 접하게 되는 상황에서 자주 사용하게 되는 테크닉을 적어보려고 합니다. 그리고 관심이 있을 독자들을 위해 임베디드 시스템에서의 프로그래밍에 대해서도 간략히 소개하겠습니다.
대부분의 기사는 리눅스 환경에서 vi, gcc, make를 이용하는 것을 전제로 합니다. 윈도우를 이용하는 사용자라면 cygwin을 이용하면 비슷한 환경을 이용할 수 있겠지요. 물론 비주얼 C++를 사용하는 독자도 cl.exe와 nmake.exe만을 이용한 콘솔 환경에 익숙해질 수 있도록 연습해보면 많은 도움이 되지만 지면 관계상 cl.exe와 nmake.exe의 사용에 대해서는 따로 다루지 않겠습니다. IDE라는 것은 이런 콘솔 환경을 뒷받침해주기 위한 도구에 불과하다는 것을 항상 기억하기 바랍니다. cygwin의 설치에 대해서는 cygwin 홈페이지(http://www.cygwin.com)를 참조하세요. 물론 필자도 윈도우 XP에서 MS 워드를 이용해 글을 쓰면서 cygwin을 이용해 샘플을 작성하고 있습니다.
이번 호에서는 C로 작성한 프로그램이 실행 파일이 되는 과정과 소스를 분리해 프로그램을 작성하는 방법에 대해 간략히 다루고자 합니다.

[ 정말 고급 개발자가 되고 싶다면! ]
gcc의 매뉴얼 페이지를 숙지하는 것이 좋습니다. 매뉴얼 페이지는 방대한 양도 양이지만, 컴파일러와 바이너리 구조에 대한 상세한 지식 없이는 이해가 불가능한 내용들이 대부분이죠. gcc의 매뉴얼 페이지를 제대로 이해할 수 있다는 생각이 든다면 그때는 자신의 프로그래밍 실력에 대해 어느 정도의 확신을 가져도 좋습니다. 물론 cl.exe의 MSDN 문서도 꼭 한번 읽어볼 것을 권합니다.



Gcc로 만든 C 프로그램이 실행되기까지
C를 언급하면 가장 먼저 시작하는 프로그램은 그 유명한 ‘hello world’라는 간단한 프로그램이지요(hello world 대신 ‘hello jiny’로 변경했습니다). 하지만 오늘은 이 프로그램을 조금 다른 용도로 사용하려고 합니다. 콘솔 환경에서 소스 코드를 컴파일하고 프로그램을 여러 개의 파일로 분리하는 방법, 그리고 여러 개의 파일로 되어 있는 프로젝트를 make를 이용해 관리하는 방법, 프로그램이 컴파일되어 실행되는 과정에 대해 간략히 알아보겠습니다.


자주 사용하게 되는 또 아주 가장 기본적인 gcc의 옵션 몇 가지를 적어 보았습니다. 보통은 <리스트 1>의 옵션들을 여러 가지로 조합하여 사용하지만 <리스트 1>에 적어둔 옵션들은 가장 ‘기본적’이고 많이 사용하는 옵션들이니 항상 숙지하고 있어야 합니다. 물론 I, -L, -l과 같은 옵션도 꼭 암기하고 있어야 합니다.
프로그램을 컴파일한다는 것은 대체 무슨 뜻일까요? 컴파일러는 어떤 과정을 거쳐서 실행 파일을 생성해주는 것일까요? 만일 처음 보는 환경에서 gcc를 이용해 실행 파일을 생성했는데 무언가 에러를 하나 보여주면서 실행이 되지 않는다면 이때 개발자들은 대체 무얼 해야 할까요? 이런 일은 ‘실제로도’ 자주 일어납니다. 이제 gcc의 옵션들을 무기로 이 hellojiny.c라는 프로그램이 컴파일되는 과정을 한번 살펴보도록 하겠습니다.
C 또는 C++는 기본적으로 Perl이나 파이썬, LISP, 베이직 등의 언어와 같이 소스 상태에서 바로 실행되는 언어가 아니라, 컴퓨터가 알아들을 수 있는 기계어로 변환된 이후에만 실행되는 언어입니다(물론 C 인터프리터도 없는 것은 아닙니다). C로 되어 있는 소스 형태를 어셈블리 언어로 바꾸는 프로그램을 컴파일러, 어셈블리 언어를 기계어로 바꾸는 프로그램을 어셈블러라고 합니다. 하지만 어셈블리 언어를 기계어로 변경했다고 바로 프로그램이 실행될 수 있는 것은 아닙니다. 자, 바로 여기에서 퀴즈 두 개를 내겠습니다.

① Hello world 프로그램에서 우리는 printf라는 함수를 사용했습니다. 이 함수는 어디에 있는 것일까요?
짿 C 프로그램은 main()이라는 함수에서 시작됩니다. 하지만 함수는 다른 함수가 호출해주기 전에는 실행되지 않습니다. 그러면 main 함수는 어떻게 시작되는 것일까요?

이 퀴즈의 답을 적기 전에 답을 이해하기 위해 필요한 내용을 좀더 적어보려고 합니다. 조금만 더 읽어 보세요. 소스 코드는 가장 먼저 전처리기(preprocessor)라는 프로그램을 이용해 다른 형태의 소스 코드로 변환됩니다. 헤더 파일과 소스 코드를 합쳐 새로운 소스 코드를 만들어 내는 것이 전처리기의 역할이지요. 프로그래머가 사용한 매크로나 매크로 함수 등도 전처리기를 통해 숫자나 코드로 치환됩니다. C 컴파일러는 전처리기를 통해 처리된 소스 코드만을 컴파일 할 수 있습니다. 그리고 C 컴파일러는 입력된 소스 코드를 몇 가지 마법(?)을 사용해 어셈블리 소스 코드로 변신시킵니다. 컴파일러가 프로그램을 최적화해 주는 것도 바로 이 컴파일러의 역할입니다.
어셈블리로 변신한 C 소스 코드는 다시 어셈블러를 통해 기계어 모음으로 변신합니다. 이렇게 태어난 기계어 파일을 우리는 오브젝트 파일이라고 부릅니다. 하지만 이 오브젝트 파일은 아직도 실행되지 않습니다. 물론 이 오브젝트 파일에는 00101110 따위의 기계어 실행 코드 말고도 아주 여러 가지의 암호가 함께 적혀 있습니다. 프로그램이 실행되려면 다시 몇 가지 정보가 더 필요합니다.
프로그램이 실행되기 위해서는 프로그램에서 어떤 라이브러리를 사용하는지, 프로그램에서 사용하는 함수들은 라이브러리에 다 있는지 확인해 이 정보들을 실행 파일에 포함시키는 과정이 필요합니다. 이 부분을 담당하는 것이 링커(linker)라는 프로그램입니다. 그리고 이런 정보를 써 넣는 형식을 실행 파일의 포맷이라고 부릅니다. 흔히 이야기하는 윈도우에서의 PE나 리눅스에서의 elf, a.out과 같은 포맷들이라고 생각하면 됩니다.
gcc는 이런 일을 하기 위한 프로그램의 모음이라고 생각할 수 있습니다. gcc 그 자체는 실제로는 소스 코드를 분석해 내부 정보로 변환하고(프론트엔드라고 합니다), 이 내부 정보를 어셈블리로 변환시키는 부분(백엔드라고 합니다)을 모아놓은 프로그램의 집합이고, 이후의 부분은(GNU의 경우) as, ld와 같은 별도의 프로그램이 담당합니다. 바이너리를 생성해 내기 위한 이런 툴들의 모음이 바로 GNU binutil이라는 것입니다. gcc에서 설명한 각 단계를 처리해주는 옵션들이 E, -S, -c 등이며 gcc 자체는 이 단계를 자동으로 처리해주는 스크립트라고 볼 수 있습니다. 즉 앞에서 설명한 E, -S, -c 옵션들을 이용하면 각 단계에서의 결과물을 얻어낼 수 있습니다. 자. 이제 퀴즈의 답을 적어 보겠습니다.

짾 printf 함수는 C의 기본 라이브러리입니다. C에서 기본적으로 제공되어야 하는 라이브러리들을 standard C library라고 하는데, 보통 리눅스나 freebsd 등의 운영체제에서는 GNU libc의 형태로 제공됩니다.
짿 윈도우에서 API를 이용해 프로그램을 해본 경험이 있는 독자라면 프로그램은 반드시 main에서 시작해야 하는 것이 아니라는 사실을 알고 있을 것입니다. C 프로그램이 main에서 시작해야 하는 이유는 그렇게 정해져 있기 때문입니다. 프로그램이 실행되기 위한 몇몇 환경을 맞추어 주는 코드를 startup 코드라고 보통 CRT라고 되어 있습니다. gcc든 VC++이든 모두 CRT 코드가 없으면 프로그램이 실행될 수 없습니다! 메인 함수는 이 CRT 코드에서 호출해 주게 되어 있습니다. 확인하고 싶은 독자가 있다면 약간의 어셈블리를 배워보는 것이 좋습니다. 임베디드 시스템에 관심이 있다면 CRT라는 이름을 접하게 되는 일이 종종 생길지도 모릅니다.

프로그램을 컴파일하는 과정에서 사용하게 되는 일은 거의 없다고 해도 좋지만 gcc는 정말 유용한 옵션을 하나 가지고 있습니다. ‘gcc의 실행과정 하나하나를 살펴볼 수 있게 해주는 v라는 옵션인데…’까지만 이야기했지만 호기심이 많은 독자들은 이미 실행을 해보고 있겠지요. 자, 필자가 지금의 글을 작성하고 있는 환경(윈도우 XP, cygwin) 에서 gcc v를 실행한 결과를 여기에 옮겨 적습니다. 자세한 옵션들은 환경에 따라 달라질 수도 있지만 중요한 내용과는 관련이 없습니다.

gcc -v hellojiny.c

Reading specs from /usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/specs
Configured with: /GCC/gcc-3.3.1-3/configure --with-gcc (생략) --
build=i686-pc-cygwin
Thread model: posix
gcc version 3.3.1 (cygming special)
여기에서부터 컴파일이 시작됩니다. 결과물로 cckh80cG.s라는 어셈블리 파일이 생성되는 것을 알 수 있을 것입니다. 물론 include 파일에 대한 검색을 하고 있는 것도 확인할 수 있습니다.

/usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/cc1.exe -quiet -v -D__GNUC__=3 -D__GNUC_MINOR__=3 -D__GNUC_PATCHLEVEL__=1 -D__CYGWIN32__ -D__CYGWIN__ -Dunix -D__unix__ -D__unix -idirafter /usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/../../../../include/w32api -idirafter /usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/../../../../i686-pc-cygwin/lib/../../include/w32api hellojiny.c -quiet -dumpbase hellojiny.c -auxbase hellojiny -version -o /cygdrive/c/DOCUME~1/saxboy/LOCALS~1/Temp/cckh80cG.s
GNU C version 3.3.1 (cygming special) (i686-pc-cygwin)
compiled by GNU C version 3.3.1 (cygming special).
GGC heuristics: --param ggc-min-expand=62 --param ggc-min-heapsize=61374
ignoring nonexistent directory “/usr/local/include”
ignoring nonexistent directory “/usr/i686-pc-cygwin/include”
ignoring duplicate directory “/usr/i686-pc-cygwin/lib/../../include/w32api”
#include “...” search starts here:
#include <...> search starts here:
/usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/include
/usr/include
/usr/include/w32api
End of search list.
여기에서부터 어셈블러가 작동합니다. 앞에서 생성한 cckh80cG.s라는 어셈블리 파일을 ccaKWInj.o라는 오브젝트 파일로 변환했습니다.

/usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/../../../../i686-pc-cygwin/bin/as.exe --traditional-format -o /cygdrive/c/DOCUME~1/saxboy/LOCALS~1/Temp/ccaKWInj.o /cygdrive/c/DOCUME~1/saxboy/
LOCALS~1/Temp/cckh80cG.s

이곳이 링커로 오브젝트 파일과 각종 라이브러리들을 이용해 최종 결과물인 실행 파일을 생성해내는 부분입니다. ‘Crt 어쩌고’ 라고 적혀 있는 부분에서 필자가 거짓말을 하고 있는 것이 아니라는 것은 증명됐습니다. Collect2라는 프로그램은 주어진 옵션으로 링커를 호출해 주는 프로그램입니다.

/usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/collect2.exe -Bdynamic --dll-search-prefix=cyg /usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/../../../crt0.o /usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/crtbegin.o -L/usr/lib/gcc-lib/i686-pc-cygwin/3.3.1 -L/usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/../../.. /cygdrive/c/DOCUME~1/saxboy/LOCALS~1/Temp/ccaKWInj.o -lgcc -lcygwin -luser32 -lkernel32 -ladvapi32 -lshell32 -lgcc /usr/lib/gcc-lib/i686-pc-cygwin/3.3.1/crtend.o

아주 간단하게 프로그래머가 만든 소스 코드가 바이너리 파일로 바뀌는 과정을 살펴보았지만 실제로 이 내용을 자세히 이해하기 위해서는 어셈블리 언어와 컴파일러의 구조, OS의 바이너리 로딩 등에 대해 약간의 지식이 더 필요합니다. 관심이 있는 독자들은 운영체제에서의 바이너리 포맷이나 바이너리 로딩에 관한 부분에 대해 좀더 깊게 공부해 보면 도움이 될 것입니다.

[ '관심이 있는' 독자에게 던지는 제언 ]
gcc hellojiny.c를 위해 gcc를 바로 사용하지 않고 직접 하나하나 명령을 실행해서 a.out으로 변환시켜 볼 것을 권하고 싶습니다. 특히 gcc -S를 이용해 생성된 어셈블리 결과물을 as와 ld를 이용해 직접 바이너리로 변환하는 과정을 직접 해 보면 프로그램이 만들어지는 과정과 실행되는 과정에 대해 많은 것을 얻을 수 있게 됩니다. 또 이렇게 얻어진 경험에 대해서 혹시라도 임베디드 시스템에서 바이너리를 만들어야 할 필요가 있다면 필자에게 감사하게 될 것입니다. 혹시 좀더 시간이 남는다면 gcc가 아닌 다른 컴파일러와 GNU as가 아닌 다른 어셈블러 등을 이용해 여러 가지를 실험해보는 것은 어떨까요. 미리 경고해두는데, 절대 간단하지 않습니다.



함수 사용, 파일 분리, 그리고 linkage
시작부터 조금 당혹스러운 내용이 되었을지도 모르겠습니다. 다시 눈높이를 약간만 낮춰 함수와 분할 컴파일, 그리고 분할 컴파일을 위한 make에 대해서 조금만 알아보도록 하겠습니다.
아주 당연한 질문이지만 왜 소스 코드를 여러 파일에 분리해서 작성해야 할까요? 꼭 이렇게 해야 할 필요는 없습니다. 프로그램을 작성하는 것은 결국 실행 파일을 만들어내기 위해서이고 따라서 1만 라인 정도 되는 프로그램이라 하더라도 소스 파일 하나에 프로그래밍해도 별로 관계는 없습니다. 실행 파일만 제대로 생성되면 상관없다는 의미입니다. 문제는 이것이 간단한 프로그램 몇 개를 만드는 정도라면 상驩平嗤?조금 프로젝트의 규모가 커지기 시작하면 관리가 불가능해진다는 데에 있습니다. 1만 라인 정도 되는 소스 코드를 한 파일에 작성해놓고 찾아다니면서 프로그램을 수정하는 것은 거의 불가능에 가깝습니다.
하지만 또 하나 중요한 이유가 있습니다. 컴파일에 걸리는 시간 때문입니다. 프로그램을 개발하면서 소스 코드를 컴파일하기 위해 사용하는 시간은 결코 무시할 수 없습니다. 컴파일에 걸리는 시간을 줄이기 위해서 빠른 컴파일러를 개발하는 것은 물론이고, 분산 컴파일, 헤더만 미리 컴파일하기(precom piled header) 등의 갖가지 방법이 개발됩니다. 그만큼 컴파일 시간은 프로그램의 개발에 중요한 요소입니다. 당장 필자가 문서를 작성하고 있는 워드프로그램을 컴파일하려면 시간이 얼마나 필요할까요? 한두 라인만을 수정하기 위해서 워드의 소스 코드를 전부 컴파일해야 한다면 얼마나 비효율적인 일이 될까요!
이제부터 독자들은 가능한 한 프로그램을 분할해서 작성하도록 노력해야 합니다. 이런 작업을 하기 가장 좋은 툴 중의 하나가 바로 make라는 것이다. 비주얼 C++에는 nmake. exe는 툴이 있습니다. Make와 문법은 약간 다르지만 사용하는 방법이 크게 다르지는 않습니다.
먼저 hellojiny 프로그램(<리스트 2>)을 함수를 이용해 바꿔 보았습니다. 아주 간단한 코드이니 독자들에게 전혀 무리가 없으리라 생각합니다. 하지만 다시 퀴즈입니다. 왜 print_hello() 함수의 앞에 static을 사용했을까요? 답은 파일을 분리한 hellojiny에서 설명하려고 합니다. 이제 두 함수를 각각의 파일로 분리해 보았습니다(<리스트 3>).
자, 이제 hellojiny 프로그램을 두 개의 파일로 나눠 보았다. 하지만 이 파일들은 몇 가지 심각한 문제가 있습니다. 이 문제를 발견하기 위해서 두 파일을 컴파일합니다.





gcc -Wall main.c s2b.c
main.c: In function `main’:
main.c:5: warning: implicit declaration of function `print_hello’
main.c:6: warning: control reaches end of non-void function

gcc의 -Wall 옵션은 컴파일하는 과정에서 발견된 문법적인 오류를 검사해 줍니다(더 자세한 문법 오류를 검사하기 위해서는 lint라는 별도의 프로그램을 사용할 수도 있습니다). 컴파일하면서 오류 메시지를 두 개 발견했는데 첫번째는 main.c에서 print_hello()라는 함수에 대해 알고 있지 못하다는 내용이고, 두번째는 main() 함수에서 리턴값을 돌려주지 않는다는 내용입니다. 두 가지 다 중요한 내용이지만 지금은 첫번째 문제에 관해서 좀더 생각해보겠습니다.
C는 선언(declaration)이 꼭 필요한 언어입니다. 함수를 호출하는 부분은 컴파일러에게는 함수 자체가 변수와 마찬가지로 생각한다고 해도 과언이 아닙니다. 함수의 반환형(return type)이 제대로 선언되지 않은 경우 컴파일러는 무조건 int를 반환한다고 생각하기 때문에 심각한 오류의 원인이 되기도 합니다.
프로그램의 실행이 완전히 틀린 결과를 내놓을 수 있는 소스 코드를 첨부합니다. 실제로 복잡한 프로그램을 작성하면서 헤더를 제대로 작성해 주지 않고 컴파일러의 경고 메시지를 무시하다 보면 이런 사소한 실수에서 잘못된 결과를 얻어 디버깅에 어마어마한 시간을 소비하게 되는 경험을 여러 번 하게 됩니다. 선임자가 만들던 몇 만 라인짜리 프로그램이 이렇게 작성되어 있다면 이 프로그램은 언제 터질지 모르는 폭탄이라고 생각해도 좋습니다.


<리스트 4>는 정상적으로 컴파일되고 실행도 가능합니다. 왜 그런 것일까요? 앞에서 선언되지 않는 함수에 대해서는 int를 가정한다고 설명한 바 있습니다. 따라서 이 프로그램은 경고 메시지를 출력하기는 하지만 문법적으로 전혀 하자가 없습니다. 하지만 출력되는 결과는 어떤 값이 될지 예측할 수 없을 뿐만 아니라 이 값을 직접 이용하는 루틴이 있다면 심각한 오류의 원인이 된다는 것을 다시 강조합니다.
다시 원래의 소스 코드에 함수의 선언을 추가했습니다(<리스트 5>). 이제 컴파일러의 경고 메시지가 하나 없어졌다는 것을 알 수 있을 것입니다. 앞으로도 분리한 모듈에 대해서는 꼭 이렇게 함수의 선언을 추가해야 합니다. 하지만, 만일 sub.c를 다른 프로젝트에서도 계속 사용하고 싶은데 이렇게 함수를 선언해 줘야 한다면 아주 귀찮은 일이 될 것입니다. 이런 귀찮은 역할을 대신 해주는 것이 헤더라는 사실은 이미 잘 알고 있으리라 생각합니다. 헤더를 이용해서 hello jiny 프로그램을 다시 수정해보겠습니다(<리스트 6>).
이렇게 헤더가 완성되었습니다. 하지만 함수의 선언은 그렇다고 해도 시작과 끝에 적혀 있는 #ifndef는 왜 필요한 것일까요? sub.h에 다음 한 줄을 추가하고 #ifndef 등을 삭제해 컴파일해 보면 쉽게 답을 얻을 수 있을 것입니다.










sub.h
//#ifndef __SUB_H__
//#define __SUB_H__

#include “sub.h”
void print_hello(void);
//#endif /* __SUB_H__ */
#ifndef는 같은 헤더 파일이 프로그래머가 알지 못하는 사이에 재귀적으로 포함되어 문제가 생기는 것을 방지해 주는 역할을 합니다. 필자가 매우 놀랐던 것 중 하나는 비교적 경험이 많은 프로그래머들도 헤더의 시작에 ‘recursive inclusion’을 방지하기 위한 장치를 사용하는 것을 간과하는 경우가 많다는 것입니다. 독자들은 헤더를 작성하는 경우에 이렇게 쓰는 것을 공식이라고 생각하고 잊지 않았으면 합니다.
헤더 파일은 보통 파일로 분리된 모듈에 대해서 인터페이스를 제공해주기 위해 사용합니다. 하지만 헤더는 컴파일러의 입장에서는 C 소스 코드와 전혀 다르지 않습니다. #include에는 헤더만 포함될 수 있는 것이 아니므로 #include “sub.c”와 같은 문장을 사용해도 문법적으로는 전혀 문제가 없습니다. 하지만 이렇게 사용하는 것은 적어도 필자의 관점에서는 바람직하지 않습니다.
많은 초심자들이 자주 범하는 실수 중 하나가 헤더에 함수를 정의(definition)해 사용하는 것입니다. 이러한 방식은 코드를 작성하면서 자신이 그 내용에 대해 이미 알고 있다면, 그리고 편리한 프로그래밍을 위해 일부러 이렇게 작성한 것이라면 전혀 문제가 되지 않습니다. 하지만 작성자가 이 사실을 인지하지 못한 상태라면 심각한 문제가 될 가능성이 많습니다. 좋은 헤더를 만들기 위해서는 많은 경험이 필요합니다. 헤더 파일과 소스 파일에 대해 명확한 기준을 세워 놓지 않고 프로그래밍을 하게 되면 어느 한 순간 “symbol multiply defined”나 “include nested too deeply” 같은 피곤한 에러들을 만나게 될 것입니다. 헤더가 열 개가 넘고 서로의 포함 관계가 복잡한 경우 이런 문제를 해결하려면 소스 코드를 여러 곳 수정해야 하는 경우가 많습니다.
그러면 다시 여기에서 퀴즈를 내겠습니다. 이번에는 sub.c의 print_hello()에서 static을 사용하지 않았습니다. 만일 print_hello() 함수를 static void print_hello()로 바꾼다면 이 프로그램은 작동하지 않습니다. 정확하게 말하면 컴파일을 할 수 없습니다. 좀더 정확하게 말하면 컴파일된 sub.o와 main.o를 링크해 실행 파일을 생성하지 못합니다. 왜 그런 것일까요?
Static은 두 가지의 조금 다른 의미를 지닙니다. 함수에 사용되는 경우에는 “이 함수가 적혀 있는 이 파일 이외의 파일에서는 이 함수를 사용할 수 없다”는 의미를 가지게 됩니다. 아울러 C로 작성한 대형 프로젝트에서는 보통 C를 이용해 객체지향의 개념을 도입하게 되는데, 이때 C++에서의 private을 비슷하게 구현하기 위해 사용되기도 합니다.
Static을 변수에 사용하는 경우에는 “이 변수는 이 프로그램이 끝날 때까지 다른 변수와는 다른 특별한 곳에 저장되어 함수가 끝나도 값이 변형되지 않는다”라는 의미를 가지게 됩니다. Sub.c를 static을 사용해 약간 변형시켜 보았습니다. Print_text() 함수는 main.c에서는 직접 사용할 수 없는 함수로 선언되어 있으며 동시에 print_text() 내에서 사용하는 print_cout라는 변수는 실행될 때마다 값이 변하지 않고 계속 유지되는 것을 확인할 수 있습니다. 이 프로그램의 실행 결과는 다음과 같습니다.

./a.exe
[1] hello, world
[2] hello, world
[3] hello, world

[ 'The C Programming Language'를 권하는 이유 ]
먼저 고백해두자면 필자가 C를 배운 것은 아주 오래 전의 일이고, C에 관한 책을 마지막으로 읽어 본 것은 컴파일러를 공부하면서였습니다. 그렇다보니 필자는 최근에 출간되고 있는 C에 관한 서적을 접해본 적이 없을 뿐만 아니라 어떤 내용이 담겨 나오는지도 사실은 잘 모르고 있습니다. 하지만, 그럼에도 불구하고 좋은 책이란 변하지 않는 법입니다. 처음 C를 배우던 시절 몇 권의 C 관련 서적을 접해본 기억이 있고, 그 후에도 여러 가지의 다른 컴퓨터 관련 서적을 읽고 있지만 가장 기억에 남는 책 중의 하나라면 그 중에서도 C에 한정지어서 이야기한다면 단연코 Brian Kernigan과 Dennis Ritchie의 「The C Programming Language」를 꼽아야 하겠습니다. 잘 알고 있는 독자들도 많이 있겠지요. 이 책을 이해하지 않고 C에 대해 이야기하는 것은 말도 안되는 이야기입니다. 이 책이 좋은 이유를 필자가 느낀 대로 간단히 적어보겠습니다.

① 이 책의 저자들은 바로 C를 개발한 주인공들입니다. 저자들은 유닉스를 개발한 사람들이고, C는 유닉스를 개발하기 위해 개발된 언어입니다.

② 이 책에는 간결하면서도 명확하고, C의 철학에 잘 맞는 주옥같은 코드가 많이 실려 있습니다.

③ 이 책에는 처음 접하는 개발자에게는 결코 해결하기 쉽지 않은, 그러나 해결하고 나면 많은 것을 생각하게 해주는 연습문제가 많이 실려 있습니다.

④ 짧고 간결하게 모든 것을 설명해주고 있습니다. 필자가 포인터를 여전히 어렵게 느끼고 있던 시절, 겨우 열 페이지에 불과한 이 책의 포인터 설명 부분은 모든 것을 명확하게 정리해 주었습니다. 다음 기사에서 꼭 소개하겠지만 단 한 줄의 코드가 복잡한 머릿속을 깔끔하게 정리해 주었던 그 때의 흥분을 필자는 항상 기억하고 있습니다. 하지만 이 책은 참 어렵습니다. 200페이지 남짓한 아주 짧은 책이지만 이 책의 모든 내용을 이해하기 위해서는 전산과학의 중요한 내용을 대부분 이해하고 있어야 한다 해도 과언이 아닙니다. 우스갯 소리지만 이 책을 완전히 이해하려면 전산과 4학년은 되어야 한다고 말하기도 합니다. C를 좀더 공부해 보려 한다면, 그리고 좋은 책을 찾고 있다면 이 책을 꼭 정독해 보기 바랍니다. 이해하기 어려운 내용도 있겠지만 아주 좋은 경험이 될 것입니다.


자, 그러면 이때 static int print_count에서 static을 없애면 어떻게 될까요? 답은 ‘보장할 수 없다’입니다. Print_count라는 변수가 초기화되어 있지 않기 때문입니다. static으로 선언되는 변수는 선언되는 시점에서 자동으로 0으로 초기화가 되지만 다른 변수들은 그렇지 못합니다.
또 하나 소스 코드를 파일로 분리하면서 꼭 알고 있어야 하는 것은 extern과 global의 개념입니다. 자세한 설명은 따로 하지 않으려고 합니다. 다만 독자들에게 숙제로 남겨 놓고 해답은 다음 기사의 첫 부분에 싣도록 하겠습니다.
Static을 사용하지 않고 전역 변수를 이용해 프린트 함수를 호출한 횟수를 기억하기 위해서는 어떻게 하면 될까요? 그리고 main.c에서도 이 횟수를 알기 위해서는 sub.h에 어떤 내용이 추가되어야 할까요?
이 문제를 스스로 해결할 수 있다면 그 시점에서 static, extern, global에 대해 다시 한번 깊게 생각해 보기 바랍니다. Variable의 scope와 linkage라는 단어로 이 내용을 기술하게 되는데 프로그래밍 경험이 많아도 정확한 개념을 알지 못하고 대강 넘어가는 경우가 많기 때문입니다. 또 초심자들에게 가장 어렵게 느껴지는 개념 중 하나이기도 합니다.
이제 마지막으로 Makefile을 만들어 보겠습니다. Makefile을 이용하면 여러 개의 파일로 구성되어 있는 프로젝트를 손쉽게 관리할 수 있습니다.
BIN = hellojiny
OBJS = main.o sub.o

all: $(OBJS)
gcc -o ${BIN} ${OBJS}


clean:
rm ${OBJS}

.c.o:
gcc -c $<
Makefile은 개념과 작성하는 방법만으로도 소책자가 될 정도이므로 간단히라도 설명하기 위해서는 꽤 많은 지면을 필요로 합니다. 다만 makefile을 쓰면 몇 가지 편리한 점이 있습니다. 가장 좋은 점은 make라고 네 글자만 타이핑하면 된다는 것이고, 모든 작업을 target을 정해 기술해 둘 수 있으므로 rm과 같은 민감한 작업을 하는 경우 실수를 줄일 수 있습니다. 더 좋은 점은 아직 컴파일이 되지 않았거나 소스 코드가 변경된 파일에 대해 선택적으로 알아서 해당되는 작업을 수행해주기 때문에 전체 소스 코드를 컴파일하는 시간도 훨씬 짧아집니다. 또한 파일의 변경에 대해 의존성을 기술할 수 있기 때문에 sub.h가 변경된 경우 자동으로 sub.c를(sub.o가 존재하더라도) 새로 컴파일할 수 있습니다.
makefile은 단순히 소스를 컴파일하기 위해 사용할 수 있는 것만은 아닙니다. Makefile의 문법을 보며 눈치가 빠른 독자들은 느낄 수 있으리라 생각하지만 의존성을 가지고 하나의 파일을 다른 파일로 변경해야 하는 대부분의 작업에 makefile을 사용할 수 있습니다. 아주 좋은 예로는 latex 파일을 dvi로 컴파일하거나 오픈소스 프로젝트에서 문서를 생성하기 위해 makefile에 의존한다는 사실을 들 수 있겠지요.
좀 더 오픈소스 프로젝트에 가까운 방법은 GNU autoconf/ automake를 이용하는 것입니다. 많은 오픈소스 프로그램은 ./configure; make; make install만으로 컴파일과 설치를 마칠 수 있습니다. 이것을 가능하게 해주는 것이 autoconf와 automake이며 간단하게는 makefile을 간단한(?) 설정만으로 자동으로 생성할 수 있도록 해주는 프로그램입니다. 이 패키지의 사용에 대해서는 여기서는 생략할 수밖에 없지만 오픈소스와 GNU에 관심이 있는 독자라면 꼭 한번 관심을 가지고 배워보면 좋습니다. 물론 오픈소스 개발툴인 anjuta, kdevelop 등의 IDE 환경에서는 자동으로 automake, autoconf에 필요한 파일들을 생성해 줍니다.

자신감은 첫 프로그램으로부터!
이제 막 C 언어를 배웠지만 더 고급 프로그래밍을 하고 싶은 독자를 위해 첫 기사를 마쳤습니다. 필자는 어린 시절 터보 C라는 좋은 프로그램으로 처음 C를 배웠습니다. 무작정 책을 사 놓고 페이지를 뒤적이고 나서 문법은 이해가 되었다고 생각했던 바로 그 때쯤의 당혹스러움을 아직도 잊지 못하고 있다. 이곳저곳에서 몇 가지 언어로 여러 프로그램을 때로는 일로 때로는 재미삼아 만들고 있는 지금까지도 어느 순간 프로그래밍에 대해 두려움이 없어졌는지 잘 기억해 낼 수가 없嗤?확실한 것은 첫번째 프로그램 하나가 엄청난 자신감을 안겨준다는 사실입니다. 스스로 동기를 부여해 쓸모없어 보이는 간단한 프로그램이라도 많은 것을 만들어 보기를 권하고 싶습니다.
가능하면 C에 관한 내용보다는 C를 이용해 프로그래밍하면서 만나는 여러 가지 문제나 일부러 신경을 쓰지 않으면 간과하기 쉬운 문제들에 대해 다루려고 생각합니다. 특히 어느 정도 막막한 느낌을 떨쳐내고 나면 고민하게 되는 코딩 스타일의 문제나 버그를 적게 만들어 낼 수 있는 코드의 작성 등에 관해서도 다뤄보려 합니다.
다음 호에서는 C에서 가장 어려운 부분이라고 누구나 느끼게 되는 포인터에 대해 다루고자 합니다. 기본적인 포인터의 개념보다는 구조체의 포인터, 함수 포인터, 함수 포인터를 이용한 플러그인의 구현 방법 등에 대해 다룰 것입니다. 기사의 내용에 관한 궁금한 점이 있으면 언제든지 마소 사이트(www.imaso.co.kr)를 이용해 질문해 주기 바랍니다.

[ 프로그래밍,그 막막함을 없애자]
제대로 된 첫 프로그램을 만들어 보는 것은 굉장히 어려운 일입니다. 특히 프로그램을 만들어 본 경험이 없는데, 이제 막 첫 언어를 배운 후라면 그 답답함이란 이루 말할 수 없습니다. 혹시라도 도움이 될 지도 모를 이야기 몇 가지를 두서없이 풀어보려고 합니다.
프로그래밍이란 무엇일까요? 어렵고 거창할 필요는 없습니다. 사람이 하는 말을 기계가 알아듣게 번역해 주는 일에 불과합니다. 컴퓨터가 사람이 하는 말을 배우는 것은 아직 어려운 일이니 사람이 컴퓨터가 할 줄 아는 말을 배워서 일하는 법을 가르쳐 주는 것이지요. 한국어는 너무 익숙하니 영어를 처음 배우던 시절을 한 번 생각해 보면 어떨까요? 관사, 정관사에 부정사 따위의 문법은 어느 정도 알고 있다고 생각하지만 영어로 말을 하려면 막막하기만 한 느낌, 바로 그 느낌이 프로그래밍을 시작하지 못하고 있는 독자의 느낌과 같은 것입니다.

‘자료의 구조’ 표현에 익숙해지기
아주 간단한 프로그램을 제외하면 대부분의 프로그램은 자료구조로 이뤄집니다. 여기에서 트리, 리스트 같은 자료구조를 떠올리면 아직은 조금 성급한 판단입니다. 필자가 이야기하고 싶은 것은 단어 그대로 순수한 ‘자료의 구조’입니다. 트리나 리스트, 그래프와 같은 자료구조는 컴퓨터에게 알려주어야 할 자료의 구조를 컴퓨터에게 알려주는 가장 기본적인 방법입니다. 자료구조에 대해 배운다는 것은 사람이 생각하는 방식을 컴퓨터로 생각하는 방식으로 바꾸는 기초적인 방법을 배운다는 것과 비슷한 이야기가 됩니다. 프로그래밍은 이 자료의 구조를 표현하는 방식과 표현한 여러 가지의 구조를 조합하여 원하는 일을 하는 방법을 적는 방법에만 익숙해지면 생각보다 그렇게 어려운 일은 아닙니다.

결정해야 할 의문들에 스스로 답하기
프로그래밍이 어렵게 느껴지는 이유 중의 또 하나는 아무리 간단해 보이는 것이라도 결정해야 할 것이 매우 많은데, 자신이 만든 프로그램이 작동하는 것을 본 경험이 적은 프로그래머의 경우에는 너무 깊게(또는 너무 단순하게) 이 문제를 생각하기 때문입니다.
간단한 주소록 관리 프로그램을 예로 들어보겠습니다. 주소록의 용도는 아주 단순합니다. 친구들의 이름과 전화번호를 기록해 찾을 수만 있다면 이미 주소록은 제 역할을 다 하게 되는 것이지요. 이름과 전화번호만을 기록하고 찾을 수 있는 주소록을 만들고 싶다면 어떻게 하면 좋을까요? 먼저 이름과 전화번호는 어떻게 표현하면 좋을까요? 이름은 char name[10]이 될 수도 있고, 전화번호는 char phone[10]이 될 수도 있습니다.
하지만 성과 이름을 분리하려고 한다면 이름을 표현하기 위해 char firstname[10]과 char lastname[10]이 필요할 수도 있습니다. 전화번호의 국가번호와 지역번호, 국번을 표현하기 위해 각기 다른 변수를 사용할 수도 있습니다. 이름과 전화번호를 연결해 저장하기 위해서는 어떻게 표현하는 것이 좋을까요? 저장할 수 있는 인원의 수에 한계를 두지 않으려면 어떤 방법으로 만드는 것이 좋을까요? 저장은 파일에 직접 하는 것이 좋을까요? 데이터베이스를 사용하는 것이 좋을까요? 파일에 저장한다면 어떤 방법으로 파일에 저장하는 것이 좋을까요? 이 방법은 저장하기와 읽어오기의 효율이 얼마나 좋을까요? 저장한 정보에서 이름으로 전화번호를 검색하려면 어떤 방법을 사용하면 좋을까요? 이 검색을 더 빠르게 하고 싶다면 저장하는 구조를 어떻게 바꾸는 것이 좋을까요? 내가 사용하는 검색방법은 얼마나 메모리를 사용할까요? 메모리가 아주 작은 컴퓨터에서도 작동하게 하기 위해서 메모리 사용량을 줄이려면 어떻게 하면 좋을까요? 프로그래머는 이런 많은 의문에 스스로 확신할만한 답을 얻어내야 합니다. 그리고 그 과정에서 컴퓨터의 사고방식을 조금 더 잘 이해할 수 있게 되고, 이 과정에 대해 답을 얻기 위해서 많은 것을 스스로 공부하게 됩니다.
처음 프로그램을 시작하려고 할 때 가장 좋은 예로 들게 되는 것은 바로 앞에서 이야기한 주소록이 됩니다. 자료구조의 표현과 검색, 정렬 등 프로그래밍에서 자주 사용하는 대부분의 방법들이 포함되기 때문이지요. 아직 주소록을 C로 직접 구현해 본 경험이 없는 독자라면 꼭 한번 시도해 볼 것을 권합니다.


Posted by theYoungman