모두의 코드
씹어먹는 C++ - <20 - 2. 코드 부터 실행 파일 까지 - 컴파일 (Compile)>

작성일 : 2020-10-20 이 글은 336 번 읽혔습니다.

이번 강좌에서는

  • 유일 정의 규칙 (One Definition Rule)

  • 정의와 선언의 차이

  • 목적 코드 생성

에 대해서 다루어 보겠습니다.

먼저 앞선 강의 에서 7 번째 단계가 끝난 다면 각 코드 별로 해석 유닛 (TU) 를 생성한다고 하였습니다. 물론 이 TU 가 제대로 생성되기 위해서는 우리가 흔히 생각하는 C++ 상에서의 문법 오류가 없어야 겠지요. 예를 들어서 변수들과 함수들의 타입이 맞아야 하고, 또 적절한 연산자를 호출해야 합니다.

이러한 자질 구레한 것들은 빼고도 우리가 잘 인지하지 못하는 TU 에 적용되는 중요한 규칙 하나가 있습니다. 바로 각 TU 에 존재하는 모든 변수, 함수, 클래스, enum, 템플릿 등등의 정의(Definition) 은 유일해야 하고 inline 이 아닌 모든 함수의 변수들의 정의는 전체 프로그램에서 유일해야 한다 라는 유일 정의 규칙 (One Definition Rule - 줄여서 ODR) 입니다.

그렇다면 C++ 에서 이야기 하는 정의 란 과연 무엇일까요?

정의 (Definition) 와 선언 (Declaration)

우리는 종종 정의와 선언을 혼동해서 사용하곤 합니다. 하지만 C++ 에서 이 둘은 엄연히 다른 개념입니다. 먼저 선언 (Declaration) 이란 TU 에 새로운 이름을 도입하거나, 기존에 선언된 이름을 재선언 하는 것입니다.

예를 들어서

int f();

의 경우 f 라는 함수를 선언 하였습니다.

그리고 정의는 선언을 포함하는 개념으로, 선언된 개체를 완전히 정의함을 뜻합니다. 따라서 모든 정의는 선언입니다. 예를 들어서

int a;

의 경우 a 라는 int 변수를 정의 한 것입니다.

뿐만 아니라 아래 몇 가지 경우를 제외하면, 모든 선언도 정의 입니다. 선언이지만 정의가 아닌 경우를 살펴보자면

int f();

위에서 보았듯이 f 라는 함수를 선언하였지만, 정의한 것은 아닙니다. f 를 정의하기 위해서는 반드시 함수의 몸체를 제공해야 합니다. 예를 들어서

int f() { return 0; }

의 경우 f 를 정의한 것입니다. 클래스의 경우도 비슷합니다.

class A;  // A 를 선언

의 경우 클래스 A 를 선언하였지만 정의하지는 않았습니다. 반면에

class A {};

의 경우 A 를 선언한 것입니다.

일반적인 변수의 경우 선언과 정의는 동일합니다. 예를 들어서

int a;

a 라는 변수를 정의한 것입니다. 하지만, extern 지정자가 들어간 선언의 경우 명시적으로 초기화 되지 않는다면 선언입니다.

extern const int a;      // a 를 선언하였지만 정의하지 않음
extern const int b = 1;  // b 를 정의함

위 경우 a 는 선언이지만 정의가 아닙니다. 반면에 b 의 경우 1 로 초기화 되었으므로 정의 입니다.

클래스 정의 내부에 inline 이 아닌 static 멤버의 경우 정의 입니다.

struct S {
  int n;                // S::n 정의
  static int i;         // S::i 를 선언하지만 정의는 아님
  inline static int x;  // S::x 를 정의
};                      // S 를 정의
int S::i;               // S::i 를 정의

그 외에도 선언이지만 정의가 아닌 경우가 몇 가지 있습니다. 자세한 내용은 여기여기 를 참조해주시기 바랍니다.

유일 정의 규칙 (One Definition Rule)

그렇다면 앞서 언급했던 유일 정의 규칙 (ODR) 을 다시 살펴보도록 하겠습니다.

각 TU 에 존재하는 모든 변수, 함수, 클래스, enum, 템플릿 등등의 정의(Definition) 은 유일 해야 하고 inline 이 아닌 모든 함수의 변수들의 정의는 전체 프로그램에서 유일해야 한다

이 말은 즉슨 다음과 같은 두 사실을 내포하고 있습니다.

먼저 첫 번째 문장 부터 살펴봅시다. 각 TU 에 존재하는 모든 변수, 함수, 클래스 등등의 정의는 유일해야 한다는 점입니다. 이 말을 다시 보자면, TU 안에 같은 선언은 여러개 있어도 괜찮다는 의미 입니다. 실제로

int f();  // f 의 정의
int f();  // f 의 정의
int f();  // f 의 정의

int main() {}

와 같은 코드는 아무런 문제 없이 컴파일 됩니다. 왜냐하면 int f()f 의 선언이지 정의가 아니기 때문이죠. 아주 올바른 C++ 코드 입니다.

그렇다면 아래와 같은 코드는 어떨까요?

int f() {  // f 의 정의
  return 0;
}

int f();  // f 의 선언
int f();  // f 의 선언

int main() {}

마찬가지로 ODR 규칙에 위배되지 않고 잘 컴파일 됨을 알 수 있습니다. 왜냐하면 f 의 정의는 유일하기 때문이죠. 문제는 f 의 정의가 여러개일 경우 입니다.

int f() {  // f 의 정의
  return 0;
}

int f() {  // f 의 정의 <-- ODR 위반
  return 0;
}

int f();  // f 의 선언

위와 같은 경우 컴파일 하였다면 아래와 같은 오류가 발생하게 됩니다.

컴파일 오류

test.cc:5:5: error: redefinition of ‘int f()’
    5 | int f() {
      |     ^
test.cc:1:5: note: ‘int f()’ previously defined here
    1 | int f() {
      |     ^

위와 같이 f 가 여러번 정의되었다고 컴파일러가 이야기 합니다.

그렇다면 두 번째 문장을 다시 살펴봅시다. inline 이 아닌 모든 함수의 변수들의 정의는 전체 프로그램에서 유일 해야 한다. 이 말은 즉슨, inline 으로 정의되지 않는 모든 함수들과 변수들의 경우 프로그램을 구성하는 모든 TU 에서 정의가 단 하나 있어야 합니다. 반면에 inline 인 변수나 함수의 경우 이를 사용하고자 하는 TU 안에 반드시 정의되어 있어야 합니다.

예를 들어서 첫 번째 TU 안에

// TU 1
int f();  // 선언

가 있고 두 번째 TU 안에도

// TU 2
int f() {  // 정의
  return 1;
}

가 있다고 해봅시다. 이는 ODR 위반이 아닙니다. 왜냐하면 나중에 TU 1 과 TU 2 가 합쳐졌을 때 정의는 딱 하나가 있게 되기 때문이죠. 반면에

// TU 1
int f() {  // 정의
  return 1;
}

가 있고 두 번째 TU 안에도

// TU 2
int f() {  // 정의
  return 1;
}

와 같이 정의가 있다면 TU 1 과 TU 2 가 합쳐졌을 때, ODR 을 위반하게 됩니다.

따라서 이와 같은 이유로 보통 다른 파일들에서 사용하는 함수를 정의하려면, 헤더파일에 함수의 선언을 써놓고, 단 한개의 소스 파일에 함수의 정의를 쓰게 됩니다. 예를 들어서

// a.h
int SomeFunction();

과 같이 헤더파일에 함수를 선언을 하고

// a.cc
int SomeFunction() {  // 정의
  return 0;
}

와 같이 SomeFunction 을 유일하게 정의해놓는다면

// b.h
#include "a.h"
int main() { SomeFunction(); }

을 하더라도 문제가 되지 않습니다. 왜냐하면 b.h 의 TU 는

// b.cc
int SomeFunction();  // 선언
int main() { SomeFunction(); }

와 같이 될 것이기 때문이죠. 반면에 아래와 같이

// a.h
int SomeFunction() { return 0; }

헤더파일에 함수의 정의를 적어놓고 다른 파일에서 이 헤더파일을 include 한다면

// b.cc
#include "a.h"
int main() { SomeFunction(); }

결국 두 개의 서로 다른 TU 에 SomeFunction 의 정의가 들어가게 되서 ODR 규칙을 위반하게 됩니다.

inline 키워드의 의미

앞서 ODR 에서 inline 인 변수나 함수의 경우 이를 사용하고자 하는 TU 안에 반드시 정의되어 있어야 한다고 하였습니다. 원래 inline 키워드가 처음 도입되었을 때 의미는 컴파일러에게 이 함수를 호출하는 문장을 그냥 이 함수의 내용으로 치환시켜도 된다 라는 의미였습니다. (단 한 번도 이 함수를 반드시 인라인 화 시켜야 한다 라는 의미 였던 적은 없습니다)

하지만 현재의 C++ 컴파일러는 굉장히 똑똑해졌기 때문에 우리가 굳이 inline 이라고 명시 하지 않아도 만일 인라인 하는게 성능 면에서 낫다고 생각하는 경우 그냥 함수를 인라인 해버립니다. 반대로 inline 인 함수여도 컴파일러가 생각했을 때 인라인 하는 것이 오히려 효율이 낫다고 판단한다면 인라인화 하지 않습니다. 따라서 inline 키워드는 그냥 다음과 같은 의미를 나타낸다고 보시는 것이 낫습니다.

이 함수는 여러개의 TU 에 정의되어 있어도 괜찮음!

쉽게 말해 inline 인 함수의 경우 전체 프로그램에서 여러 군데에 정의가 되어 있어도 상관이 없습니다만 해당 함수를 사용하는 TU 안에서는 인라인 함수의 정의가 반드시 들어 있어야만 합니다. 이렇게 inline 키워드의 의미가 변질되었기 때문에 C++ 17 에서는 일반적인 변수 자체도 여러 정의를 허용한다 라는 의미에서 inline 으로 사용할 수 있습니다.

이와 반대로 inline 이 아닌 함수의 경우 사용하기 위해서 반드시 TU 에 해당 함수의 정의가 있을 필요가 없지만, 대신 전체 TU 에 정의가 반드시 단 한개 있어야만 합니다.

예를 들어서 inline 함수는 헤더파일에 정의해도 상관 없습니다.

// a.h
inline int SomeFunction() { return 0; }
// b.cc
#include "a.h"
int main() { SomeFunction(); }

예를 들어 위 같이 a.hSomeFunction 을 정의하고 다른 파일들에서 a.hinclude 하더라도 문제될 것이 없습니다. 반면에 일반적인 함수의 정의 처럼 아래와 같이 헤더에만 선언을 해놓고 구현을 다른데에서 한다면

// a.h
inline int SomeFunction();
// a.cc
inline int SomeFunction() { return 0; }
// b.cc
#include "a.h"
int main() { SomeFunction(); }

이 경우 b.cc 의 TU 에 SomeFunction 의 정의가 없기 때문에

컴파일 오류

In file included from main.cc:1:
a.h:4:13: warning: inline function ‘int a()’ used but never defined
    4 | inline int a();
      |             ^

와 같은 오류가 발생하게 됩니다.

한 가지 재미있는 점은 클래스 내부에 정의되어 있는 함수들은 자동으로 inline 이란 점입니다. 즉

class A {
 public:
  int Func() { return 0; }
};

위와 같이 클래스 A 에 정의된 멤버 함수 Func 은 굳이 inline 으로 명시하지 않아도 컴파일러가 알아서 inline 으로 취급합니다. 따라서 위 클래스 파일을 여러 파일들에 include 해도 문제될 것이 없죠. 반면에

class A {
 public:
  int Func();
};

int A::Func() { return 0; }

위와 같이 클래스 안에 함수의 정의가 없고 선언만 있을 경우 inline 으로 분류되지 않습니다. 따라서 함수의 정의 부분인

int A::Func() { return 0; }

이 부분의 경우 inline 이 아닌 함수로 취급되어서 모든 TU 전체에 정의가 딱 하나 있어야겠죠. 따라서 멤버 함수를 클래스 밖에 따로 정의할 때에는 대부분의 경우 cc 파일에 정의하게 됩니다.

Code Generation

주의 사항

아래 부분은 리눅스 상에서 적용되는 내용입니다. 이전 강좌에서 이야기 하였듯이 윈도우즈의 경우 PE 파일을 생성하며 해당 파일 포맷은 리눅스에서 주로 사용되는 ELF 파일 포맷과는 조금 차이가 있습니다. (그래도 큰 틀에서는 비슷합니다.) 따라서 아래 제공하는 예제들과 파일들을 분석하는데 사용하는 툴들 (objdump, readelf) 는 모두 리눅스 상에서만 사용할 수 있습니다.

물론 윈도우에서도 WSL 를 통해서 우분투 리눅스와 같은 유명한 리눅스 배포판들을 간단히 사용할 수 있기 때문에 아래 예제들을 실행해보는데 큰 문제가 없을 것이라 생각합니다.

앞서 TU 에서 적용되는 중요한 규칙인 ODR 에 대해서 살펴보았습니다. 각각의 TU 에서 문법이 맞는지 확인하고 ODR 규칙들을 적용하고 나면 컴파일러는 각각의 TU 별로 실제 어셈블리 코드를 생성 (Code Generation)하게 됩니다. 한 가지 중요한 점은 컴파일러가 어셈블리 코드를 생성할 때에는 모든 TU 들을 독립적으로 생성하게 됩니다.

따라서 TU 1 과 TU 2 가 있을 때 TU 1 의 어셈블리는 딱 TU 1 만을 보고 결정되지 다른 TU 들은 보지 않는다는 것입니다.

그렇다면 한 가지 문제가 있습니다. 앞서 ODR 규칙에 따르면 inline 이 아닌 함수의 정의는 전체 TU 들에 대해 유일하다고 하였습니다. 예를 들어서

// TU 1
int SomeFunction();  // 선언

int main() { SomeFunction(); }

와 TU 2 에 아래와 같이 있다고 해봅시다.

// TU 2
int SomeFunction() { return 0; }

만일 TU 1 에서 TU 2 의 정의된 함수 SomeFunction 사용한다고 해봅시다. 그렇다면 TU 1 의 코드 생성 단계에서는 함수를 호출 할 때 해당 함수가 어디 있는지 알아야 하는데 해당 함수는 TU 2 에 정의되어 있기 때문에 도무지 알 수 없기 때문이지요.

그렇다면 TU 1 을 컴파일 한 목적 코드에는 도대체 뭐가 들어 있을까요?

$ file b.o
b.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

리눅스 상에서 file 프로그램을 사용하면, 해당 파일의 대략적인 정보를 알 수 있습니다. file 프로그램에 따르면 우리가 생성한 목적 파일은 사실 일반적인 ELF 파일 입니다. 다만 나와 있듯이 LSB (리틀 엔디안) 형식의 relocatable 파일 이죠. 이 재배치 가능 하다 (Relocatable) 라는 의미는 이 ELF 파일을 특정 위치에 배치할 수 있다는 의미 입니다.

사실 곰곰히 생각해보면 그럴 수 밖에 없는 것이 실행 파일을 만들 때에서 비로소 우리가 정의하였던 함수들의 위치가 정해지게 됩니다. 따라서 링킹 단계에서 이 생성된 목적파일들을 재배치 시켜서 정확한 위치에 자리잡게 하기 위함이지요.

그렇다면 해당 목적파일을 조금 뜯어보도록 합시다.

$ objdump -S b.o

b.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <main>:
   0:	f3 0f 1e fa          	endbr64 
   4:	55                   	push   %rbp
   5:	48 89 e5             	mov    %rsp,%rbp
   8:	e8 00 00 00 00       	callq  d <main+0xd>
   d:	b8 00 00 00 00       	mov    $0x0,%eax
  12:	5d                   	pop    %rbp
  13:	c3                   	retq   

위는 TU 1 의 목적 코드를 objdump 라는 프로그램을 사용해서 그 어셈블리를 출력해본 것입니다. SomeFunction() 을 호출하는 부분이 바로

   8:	e8 00 00 00 00       	callq  d <main+0xd>

이 부분인데, 원래는 e8 뒤에 현재 위치로 부터 얼마만큼 떨어져 있는 곳에 있는 함수를 실행할지 그 오프셋 값이 들어가 있어야 합니다. 하지만 지금은 위와 같이 그냥 0 으로 채워져있죠. 왜냐하면 컴파일 단계에서는 SomeFunction 이 도대체 어디에 배치될 지 알 수 없기 때문에 링킹 과정이 이루어지기 전 까지 e8 뒤에 어떤 오프셋 값을 넣어야 하는지는 알 수 없습니다. 그래서 위 처럼 그냥 0 으로 채워놓게 됩니다.

만약에 링킹 과정에서 SomeFunction 을 찾을 수 없다면 해당 부분을 채울 수 없겠죠. 따라서 아래와 같이 종종 보이는 함수를 찾을 수 없다 라는 오류가 컴파일러 단에서 발생하는 것이 아니라 링크 단에서 발생하는 이유도 그것 입니다.

컴파일 오류

/usr/bin/ld: /tmp/ccvTlhA3.o: in function `main':
b.cc:(.text+0x9): undefined reference to `SomeFunction()'
collect2: error: ld returned 1 exit status

위와 같이 TU 2 를 포함하지 않고 TU 1 만 가지고 실행파일을 생성하고 한다면 링커 (위 경우 ld) 에서 오류가 발생한 것을 볼 수 있습니다.

물론 링커 입장에서 어셈블러가 생성한 명령이 정말로 e8 00 00 00 00 일 수 도 있기 때문에 목적 파일에 링커에게 알려주기 위해서 이 부분을 이런 식으로 고쳐라 라는 정보를 남겨놓게 됩니다. 이 정보를 보기 위해서는 readelf 프로그램을 사용하면 됩니다.

$ readelf -r b.o

Relocation section '.rela.text' at offset 0x230 contains 1 entry:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000000009  000b00000004 R_X86_64_PLT32    0000000000000000 _Z12SomeFunctionv - 4

Relocation section '.rela.eh_frame' at offset 0x248 contains 1 entry:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000000020  000200000002 R_X86_64_PC32     0000000000000000 .text + 0

readelf 프로그램은 리눅스에서 ELF 파일 정보를 보기 좋게 출력해주는 프로그램입니다. -r 옵션을 주게 되면 해당 파일의 재배치 테이블(Relocation Table) 정보를 출력하게 됩니다. 이 재배치 테이블 중 .rela.text 에선 해당 목적 파일에서 링킹 시에 수정해야할 곳의 위치와 어떠한 식으로 수정해야 할 지에 대해서 설명하고 있습니다.

위 경우 오프셋 9 의 위치에 (정확히 e8 바로 뒤에 00 00 00 00 부분을 의미 합니다) _Z12SomeFunctionv 심볼의 정보를 R_X86_64_PLT32 방식으로 덮어 씌우라고 링커에게 알려주는 것입니다.

이 부분에 대해서 두 가지 궁금증이 있는데

  1. 도대체 왜 우리 SomeFunction_Z12SomeFunctionv 와 같은 괴상한 이름으로 바뀌었는지

  2. R_X86_64_PLT32 은 도대체 뭔지

에 대해서는 다음 강의에서 다루어보도록 하겠습니다.

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <20 - 2.코드 부터 실행 파일 까지 - 컴파일 (Compile)> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 1 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요