다음 이전 차례

4. 포팅과 컴파일링

4.1 자동적으로 정의되는 심볼들

여러분은 여러분이 갖고 있는 버전의 gcc가 -v 옵션을 붙임으로써 어떠한 심볼을 자동적으로 정의하는지 알아낼 수 있다. 예를 들어 본인의 것은 다음과 같다.

$ echo 'main(){printf("hello world\n");}' | gcc -E -v -
Reading specs from /usr/lib/gcc-lib/i486-box-linux/2.7.2/specs
gcc version 2.7.2
 /usr/lib/gcc-lib/i486-box-linux/2.7.2/cpp -lang-c -v -undef
-D__GNUC__=2 -D__GNUC_MINOR__=7 -D__ELF__ -Dunix -Di386 -Dlinux
-D__ELF__ -D__unix__ -D__i386__ -D__linux__ -D__unix -D__i386
-D__linux -Asystem(unix) -Asystem(posix) -Acpu(i386)
-Amachine(i386) -D__i486__ -

만약 여러분의 코드가 리눅스에만 관계되는 코드라면, 다음과 같이 해주는 것이 좋다.

#ifdef __linux__
/* ... funky stuff ... */
#endif /* linux */

__linux__라는 이름을 사용하라. linux아니다. 후자가 정의되어 있기는 하지만 POSIX 규격에는 맞지 않기 때문이다.

4.2 컴파일러 부르기

컴파일러 스위치들에 대한 문서는 gcc info 페이지를 보면 된다. (여러분이 Emacs를 사용하고 있다면 C-h i그리고 나서 gcc 옵션을 선택하라) 여러분이 갖고 있는 배포판을 만든 사람이 gcc info 페이지를 넣어지 않았을 수도 있고, 또는 옛 버전의 것이 들어가 있을 수도 있다. 가장 좋은 방법은 ftp://prep.ai.mit.edu/pub/gnu나 또는 미러 사이트로 가서 gcc 소스 코드를 받아오는 것이다. 그리고 그 소스 안에서 카피해온다.

gcc 에 대한 맨페이지(gcc.1)는 일반적으로 시대에 뒤떨어져 있다고 말할 수 있다. 맨페이지를 보려고 하면 그러한 경고 문구를 볼 수 있다.

컴파일러 플래그(flag)

gcc를 사용할 때, -On(여기서 n은 작은 양의 정수들, 생략해도 된다)을 커맨드 라인 옵션으로 넣어주면 출력 코드가 최적화된다. 여기서 사용되는 n 값 중에서 실제 의미를 갖는 값들은 gcc의 버전에 따라 다른데, 일반적으로 0 (최적화하지 않음)부터 시작해서 2(상당히 많이 최적화), 3(아주아주 많이 최적화)까지 쓰인다.

내부적으로 gcc는 이 옵션을 -f-m 이라는 옵션들로 바꾸어서 처리하게 된다. -O의 특정 레벨이 어떤 의미를 갖는지에 대해서는 gcc 실행시에 -v-Q(문서화되지 않았음)플래그를 붙여줌으로써 확인할 수 있다. 예를 들어 -O2는 다음과 같이 나타난다. (사람들마다 서로 다를 수 있다)

enabled: -fdefer-pop -fcse-follow-jumps -fcse-skip-blocks
-fexpensive-optimizations
         -fthread-jumps -fpeephole -fforce-mem -ffunction-cse -finline
         -fcaller-saves -fpcc-struct-return -frerun-cse-after-loop
         -fcommon -fgnu-linker -m80387 -mhard-float -mno-soft-float
         -mno-386 -m486 -mieee-fp -mfp-ret-in-387

여러분의 컴파일러가 지원하고 있는 최적화 레벨보다 큰 숫자를 사용한다면 (예를 들어 -O6), 그 컴파일러가 지원하는 최적의 레벨로 최적화시켜준다. 이런 식으로 컴파일되도록 세팅되어 있는 코드를 배포하는 것은 별로 좋은 생각은 아닌 것 같다. 더 많은 최적화 레벨들이 차후 gcc 버전에 생긴다면, 잘못하면 여러분의 소스 코드가 엉뚱하게 컴파일되는 수도 있다.

만약 여러분이 지금 -O3이 최고 레벨이라는 가정하에서 -O6를 사용했다고 치자. 하지만 다음 버전(예를 들어서 2.7.3.?)에서 -O8까지 지원하게 된다면 -O6는 전혀 엉뚱한 의미를 가질 수도 있다.

gcc 버전 2.7.0 부터 2.7.2 까지의 사용자들은 -O2 최적화 플래그에 버그가 있다는 사실을 잘 알아두기 바란다. Strength Reduction이라고 하는 것이 제대로 작동하지 않는다. 이 문제를 해결할 수 있는 패치가 있고 다시 gcc 를 컴파일해야 할것이다. 또는 언제나 -fno-strength-reduce 라는 옵션을 주고 컴파일하기 바란다.

프로세서별 옵션

-O 옵션을 주어도 자동적으로 작동하지 않는 -m 플래그들이 있다. 하지만 이들은 상당히 유용하다. 중요한 것으로는 -m386-m486이 있다. 이 플래그들은 gcc더러 각각 386, 486중 어떤 것에 더 맞춰서 컴파일할 것인지를 알려주는 것이다. -m486으로 컴파일하였다고 하더라도 386 에서 실행되는데는 지장없다. 그러니 걱정할 필요없다. 486 코드가 조금 더 크지만 386 에서 느려지거나 하지는 않는다.

아직까지는 -mpentium이나 -m586과 같은 것은 없다. 리누스(Linus)는 486 코드옵티마이즈된 코드를 얻으면서도 펜티엄이 사용하지 않는 정렬방식과의 커다란 차이점이 없는 코드를 얻기 위해서는, -m486 -malign-loops=2 -malign-jumps=2 -malign-functions=2를 사용할 것을 제안하고 있다. Michael Meissner(Cygnus에 있는)는 다음과 같이 말하고 있다.

내 육감으로는 -mno-strength-reduce를 같이 쓰면 또한 x86 에서 더 빠른 코드를 얻어낼 수 있다는 것이다. (주의! 나는 지금 strength reduction 버그에 대해서 말하고 있는 것이 아니다. 그것은 전혀 다른 문제이다) 왜냐하면 x86은 다소 레지스터 숫자가 적기 때문이다. (그리고 다른 레지스터에 대하여 레지스터들을 그룹으로 묶어서 spill 레지스터 속으로 처리하는 GCC 의 처리방식은 전혀 도움이 되질 않는다) StrengthReduction은 전형적으로 곱셈을 덧셈으로 교체하기 위하여 다른 레지스터들을 사용하게 된다. -fcaller-saves 또한 이런 문제점이 있지 않나 생각하고 있다.

또 다른 예감은 이렇다. -fomit-frame-pointer는 도움이 될 수도 있고, 그렇지 않을 수도 있다는 것이다. 한 편으로는 또 다른 레지스터가 할당가능하다는 것을 의미할 수도 있고, 다른 한 편으로는 x86 이 연산지시(instruction)에 대하여 인코딩하는 방식으로서, 스택 상대적 주소가 프레임 상대적 주소보다도 더 많은 공간을 차지한다는 것을 의미하기도 한다. 이렇게 되면 프로그램에 사용될 수 있는 Icache이 약간 줄어든다. 또한 -fomit-frame-pointer는 컴파일러가 계속적으로 호출 후에도 스택 포인터를 조정해야 한다는 것을 뜻한다. 따라서 프레임을 갖는 경우, 몇 번의 호출만으로도 스택이 가득 차게 된다.

마지막 말은 리누스 또한 언급하고 있다.

만약 여러분이 최적화된 효율을 원한다면, 나를 믿지 말라. 실제로 테스트를 해봐야 한다. gcc 컴파일러의 옵션은 정말로 많다. 그리고 몇 개의 특정 조합이 가장 좋은 최적화를 이뤄줄 것이다.

Internal compiler error: cc1 got fatal signal 11

시그널 11번은 SIGSEGV, 즉 세그먼테이션 위반에 대한 시그널이다. 일반적으로 프로그램이 포인터를 잘못 썼다는 말이거나 자기가 소유하고 있지 않은 메모리에다 쓰기 작업을 하려고 할 때 발생한다. 그래서 이는 gcc의 버그일 수도 있다.

하지만 gcc는 대부분의 작업에서 매우 안정적이고 테스팅을 많이 거친 소프트웨어라는 사실을 기억하라. gcc는 또한 복잡한 자료 구조와 포인터를 엄청나게 많이 사용하고 있다. 간단히 말하자면 현재까지 소프트웨어 중에서 가장 뛰어난 램 테스팅 프로그램(RAM Tester)이라고 말할 수도 있다. 만약 매번 컴파일할 때마다 멈추는 위치가 다르다면 이는 거의 대부분 여러분 하드웨어의 문제라고 봐도 된다. (CPU, 메모리, 마더보드나 캐쉬) 여러분의 컴퓨터가 파워 온 체킹을 거쳐서 잘 부팅되었고 그리고 윈도우즈 같은 것도 잘 돌아간다고 해서 그것을 gcc의 버그로 돌리지는 말라. 이러한 사실은 무의미하다. 그리고 커널 컴파일하면서 make zImage에서 꼭 멈춘다고 해서 gcc의 버그라고 말할 수는 없다. make zImage는 무려 200개 이상의 화일을 컴파일하고 있다. 그것보다는 좀 작은 경우를 찾아보도록 하자.

만약 계속적으로 버그가 똑같이 나타나고 자그마한 프로그램 컴파일에서도 그러하다면, FSF에다가 버그 리포트를 해도 되고, 또는 linux-gcc 메일링 리스트에 글을 올려도 된다. 그러기 위해서는 우선 gcc 문서를 읽어보고 어떤 절차가 필요한지 숙지한 다음 하기 바란다.

4.3 포팅(Portability)

요즘은 만약 그 소프트웨어가 리눅스로 포팅될 수 없다면 그 소프트웨어는 가치가 없는 프로그램이라고 말한다. :-)

진지하게 말하자면, 일반적으로 리눅스의 100% POSIX 호환성을 이루기 위해서는 아주 약간의 수정작업만이 필요하다. 또한 단지 make 라고만 하면 실행화일이 만들어질 수 있도록 하기 위하여 코드의 원저자에게 수정 코드를 보내는 것이야말로 가치있는 일이다.

BSDisms (bsd_ioctl, daemon 그리고 <sgtty.h>)

여러분은 여러분의 프로그램을 -I/usr/include/bsd를 넣어서 컴파일한 후, -lbsd 옵션을 넣고 링크할 수도 있다. (즉 Makefile 안에서 -I/usr/include/bsdCFLAGS 변수에 넣고, -lbsdLDFLAGS에 넣음으로써) 이젠 BSD 타입의 시그널 행동을 얻어내기 위해서 -D__USE_BSD_SIGNAL덧붙일 필요가 없다. 왜냐하면 -I/usr/include/bsd라고 해주고 <signal.h>를 소스 안에서 포함하면 모든 일이 제대로 이루어진다.

없어진 시그널들(SIGBUS, SIGEMT, SIGIOT, SIGTRAP, SIGSYS 등)

리눅스는 POSIX를 준수하고 있다. 이러한 시그널들은 POSIX 정의 시그널들이 아니다. 이는 ISO/IEC 9945-1:1990 (IEEE Std 1003.1-1990), paragraph B.3.3.1.1 에서 다음과 같이 말하고 있는 바이다.

SIGBUS, SIGEMT, SIGIOT, SIGTRAP, 그리고 SIGSYS와 같은 시그널들은 POSIX.1으로부터 제외되었다. 왜냐하면 그들의 행동은 함축적이고 어떻게 부르느냐에 따라 다르기 때문에 적절하게 범주화시킬 수가 없다. 이러한 시그널들을 없애버리는 것이 규약 준수일 수도 있지만, 왜 그 시그널들을 제외해버렸는지에 대해서 문서화해야 한다. 그리고 그 시그널들을 어떻게 처리할 것인가에 대해서는 아무런 강제 규정도 없다.

이 문제를 해결할 수 있는 가장 간단한 방법은 이러한 시그널들을 모두 SIGUNUSED로 재정의하는 것이다. 바른방법은 물론 이러한 시그널을 처리하는 부분을 #ifdef 문장을 써서 처리하도록 하는 것이다.

#ifdef SIGSYS
/* ... POSIX 규정이 아닌 SIGSYS 코드가 여기에 온다 .... */
#endif

K & R 코드

GCC는 ANSI 컴파일러이다. 하지만 아주 많은 코드들이 ANSI가 아니다. 이럴 때는 컴파일러 플래그에 -traditional 이라고만 붙여주면 된다고 할 수 있다. 물론 괴롭게 수작업을 해줘야 하는 부분도 많이 있다. gcc info 페이지를 살펴보기 바란다.

-traditional라는 옵션은 gcc 가 이용하려고 하는 C 언어 방식을 바꾸는 것 말고도 다른 효과를 지니고 있다. 예를 들어 그 옵션은 -fwritable-strings을 작동시키는데, 문자열 상수를 데이타 영역으로 보내는 역할을 한다. (텍스트 영역, 즉 그들이 쓸 수 없는 영역을 말한다) 이런 경우 프로그램의 메모리 사용흔적(footprint)이 증가하게 된다.

전처리기 심볼이 코드의 프로토타입과 충돌할 때

많이 발생하는 문제들 중에 하나가 바로 몇몇 함수들이 이미 리눅스 헤더화일들에 매크로로 정의되어 있고 전처리기가 코드 내에서 유사한 프로토타입에 대하여 처리 거부를 하는 경우이다. 보통 atoi()atol()인 경우가 많다.

sprintf()

sprintf(string, fmt, ...)이 많은 유닉스 시스템에서는 문자열에 대한 포인터를 반환하는 반면에 ANSI를 따르는 리눅스는 문자열에 삽입된 문자의 갯수를 반환한다. 이는 특히나 SunOS와 같은 것으로부터 포팅하는 경우에 더욱 주의해야 한다.

FD_* 같은 것들? fcntl과 그 비슷한 녀석들. 도대체 정의부분이 어디에 있는가?

<sys/time.h>에 있다. 만약 fcntl을 이용하고자 한다면 실제 프로토타입을 위하여 <unistd.h> 또한 포함시키고 싶을 것이다.

일반적으로 말하자면 어떤 함수에 대한 맨페이지를 보면 SYNOPSYS 부분에서 어떤 헤더화일을 #include 해야하는지 자세히 나타내주고 있으니 그것을 참고하기 바란다.

select()에서 타임아웃이 걸리고 프로그램이 계속 기다리기만 한다.

예전에는 select()에 대한 타임아웃 파라미터가 읽기전용으로만 사용되었다. 그리고 그 때에도 맨페이지에는 다음과 같은 경고가 있었다.

select()는 아마도 적절한 곳에 있는 시간값을 변경함으로써 만약에 그러한 일이 발생한다면 원래의 타임아웃부터 남은 시간을 반환해야 할 것이다. 하지만 이 기능은 차기 버전에서나 구현될 것이다. 따라서 타임 아웃 포인터가 select() 호출에 의하여 수정되지 않을 것이라고 생각하는 것은 바람직하지 못하다.

바로 그 날이 왔다! 최소한 그것이 이루어지고 있다. select()호출로부터 돌아올 때, 타임아웃 인수는 데이터가 도착하지 않는다면 기다리려고 했던 잔류 시간으로 세팅된다. 만약 아무 데이터도 도착하지 않았었다면 이 값은 0(zero)이 되었을 것이다. 그리고 같은 타임아웃 구조체를 가지고 호출을 하게 되면 호출 즉시 되돌아올 것이다.

이 문제를 해결하기 위해서는 타임아웃 값을 매번 select()를 호출할 때마다 관련 구조체에 적어주어야 한다. 다음과 같은 코드가 있다면,

      struct timeval timeout;
      timeout.tv_sec = 1; timeout.tv_usec = 0;
      while (some_condition)
            select(n,readfds,writefds,exceptfds,&timeout); 
아래와 같이 바꾸도록 하라.
      struct timeval timeout;
      while (some_condition) {
            timeout.tv_sec = 1; timeout.tv_usec = 0;
            select(n,readfds,writefds,exceptfds,&timeout);
      }

모자익(Mosaic)의 몇몇 버전이 한 때 이러한 문제로 떠들썩했었다. 회전하는 지구 애니매이션의 속도가 네트워크를 통해 들어오는 자료의 속도에 반비레하는 일이 벌어진 것이다!

시스템 호출이 인터럽트될 때

증상:

프로그램이 Ctrl+Z로 서스펜드되고 다시 시작되어 버린다. 또는 다른 때에는 Ctrl+C와 같은 시그널을 발생시키고 자식 프로세스들을 죽인다 등등... "interrupted system calls" 또는 "write: unknown error" 또는 그런 것 비슷한 에러를 낸다.

문제점:

POSIX 시스템은 다른 구식 유닉스 체제에서보다 약간 더 많이 시그널에 대해서 체킹을 행한다. 리눅스는 시그널 핸들러들(signal handler)을 실행시킬 것이다.

다른 운영체제의 경우에는 다음과 같은 시스템 호출에 대해서도 체크할 것이다. 위에서 말한 것 이외에도 다음과 같은 시스템 호출들: creat(), close(), getmsg(), putmsg(), msgrcv(), msgsnd(), recv(), send(), wait(), waitpid(), wait3(), tcdrain(), sigpause(), semop()

만약 시그널(프로그램에서 핸들러를 인스톨한 경우)이 시스템 호출 중에 발생한다면, 그에 대한 핸들러가 호출된다. 그리고 핸들러가 반환되면 (시스템 호출로), 시스템 호출은 중간에 가로채기를 당했는지 살펴보고 즉시 -1 값을 가지고 반환된다. 그리고errno 를 EINTR 로 세팅한다. 프로그램은 그러한 일이 있을 것이라고 예상하지 못하고 죽는 것이다.

여러분은 다음 2 가지 해결책 중에 하나를 고르면 된다.

(1) 여러분이 설치한 모든 시그널 핸들러에 대하여 SA_RESTART를 sigaction 플래그에 첨가한다. 다음과 같은 것이 있다면,

  signal (sig_nr, my_signal_handler);
를 다음과 같이 바꾼다.
  signal (sig_nr, my_signal_handler);
  { struct sigaction sa;
    sigaction (sig_nr, (struct sigaction *)0, &sa);
#ifdef SA_RESTART
    sa.sa_flags |= SA_RESTART;
#endif
#ifdef SA_INTERRUPT
    sa.sa_flags &= ~ SA_INTERRUPT;
#endif
    sigaction (sig_nr, &sa, (struct sigaction *)0);
  }

이 방법이 대부분의 시스템 호출에 적용되기는 하지만, read(), write(), ioctl(), select(), pause(), connect()에 대해서는 여러분 스스로 EINTR를 체크해주어야 한다. 다음을 살펴보자.

(2) 여러분이 직접 명시적으로 EINTR을 체크해준다.

read()를 사용하는 코드가 원래 이렇게 되어 있다고 치자.

int result;
while (len > 0) { 
  result = read(fd,buffer,len);
  if (result < 0) break;
  buffer += result; len -= result;
}
이 코드를 다음과 같이 바꾸어주면 된다.

int result;
while (len > 0) { 
  result = read(fd,buffer,len);
  if (result < 0) { if (errno != EINTR) break; }
  else { buffer += result; len -= result; }
}
이번에 이런 코드가 있다면,

int result;
result = ioctl(fd,cmd,addr);
그것은 또한 다음과 같이 바뀌어야 한다.
int result;
do { result = ioctl(fd,cmd,addr); }
while ((result == -1) && (errno == EINTR));

BSD 유닉스의 몇몇 버전에서는 시스템 호출을 재개하는 것이 기본 행동으로 되어 있는 경우도 있으므로 주의하자. 시스템 호출이 가로채기를 허용하기 위해서는 SV_INTERRUPT 또는 SA_INTERRUPT 플래그를 사용하도록 하자.

쓰기 가능 문자열 (프로그램이 랜덤하게 세그폴트를 낸다)

GCC는 gcc를 사용하는 사람들이 문자열 상수에 대하여 정확히 상수로서 계속 사용할 것이라고 낙관하고 있는 듯 하다. 따라서 그 문자열 상수를 프로그램의 텍스트 영역에 집어넣는다. 이렇게 함으로써 스왑 영역을 사용하는 것이 아니라 프로그램의 디스크 이미지로부터 페이지 인 & 아웃을 행할 수 있도록 해준다. 그러므로 문자열 상수에 대하여 다시 쓰기 작업을 하게 되면 세그멘테이션 폴트를 일으키게 되는 것이다.

예를 들어서 문자열 상수를 인수로 하여 mktemp()를 호출하는 옛날 프로그램들에서는 문제가 발생할 것이다. mktemp()는 주어진 인수에 다시 쓰려고 하기 때문이다.

이 문제를 고치기 위해서는 (a) -fwritable-strings 이라는 옵션을 주어서 컴파일한다. 이렇게 해주면 gcc는 문자열 상수를 데이타 영역에 넣게 된다. 또는 (b) 문제가 되는 부분을 수정해서 상수가 아니라 변수로 주어지게 만들고 호출 전에 strcpy 를 사용하여 데이터를 그곳으로 카피해준다.

execl()호출이 실패하는가?

원인은 간단하다. 제대로 호출을 하지 않았기 때문이다. execl에 대한 첫번째 인수는 실행하고자 하는 프로그램이다. 그리고 두번째부터는 호출하는 프로그램에 전달할 argv배열이다. 기억하라! argv[0]는 전통적으로 아무런 인수 없이 실행되더라도 세팅이 된다는 사실을! 따라서 다음과 같이 코드를 써야한다.

execl("/bin/ls","ls",NULL);
절대로 다음과 같이 쓰면 안된다.
execl("/bin/ls", NULL);

아무런 전달인수 없이 실행시키는 경우에도 실행형식은 자신의 동적 라이브러리 의존성을 나타낼 수 있는 방식으로 구문을 맞춰준 형태라야 한다. 최소한도 a.out의 경우는 그러하다. ELF는 좀 다른 방식으로 작동한다.

(만약 이러한 라이브러리 정보를 원한다면 아주 간단한 인터페이스가 있다. 동적 로딩Dynamic Loading에 대한 섹션을 보거나 ldd에 대한 맨페이지를 참고하라)


다음 이전 차례