Ch7. Linking

Park Choong Ho·2021년 9월 20일
0

Ch7. Linking

Intro

  • 링킹은 여러 코드와 데이터를 메모리에 로드되고 실행될 수 있는 하나의 파일로 만드는 과정

  • 총 3가지 경우
    - 컴파일 타임(소스 코드가 머신코드로 바뀔 때)
    - 로드 타임(프로그램이 메모리에 올라가고 로더에 의해 실행될때)
    - 런타임(프로그램 동작)

  • 예전에는 링킹을 직접 했다. (Manually)

  • 현대 시스템은 링커라는 프로그램이 자동적으로 수행해줌

7.1 Compiler Drivers

main.c

int sum(int *a, int n);

int array[2] = {1, 2};

int main() {
	int val = sum(array, 2);
	return val;
}

sum.c

int sum(int *a, int n) {
	int i, s = 0;
	
	for (i = 0; i < n; i++) {
		s += a[i];
	}
	
	return s;
}

이렇게 2개의 파일이 있다고 가정해 봅시다. 이 파일들은 전처리기, 컴파일러, 어셈블러를 거쳐 오브젝트 파일이 되고 최종적으로 링커에서 링킹이라는 작업을 거쳐 최종적인 실행가능한 오브젝트 파일이 됩니다.

대부분 시스템들은 컴파일러 드라이버를 제공합니다.
컴파일러 드라이버는 크게 4가지로 구성.

  • Preprocessor(전처리기): C언어 소스 코드 파일인 main.c를 ASCII 파일인 main.i로 변환시켜주는 프로그램
  • Compiler: main.i 파일을 ASCII 텍스트 어셈블리어로 구성된 main.s 변환하는 프로그램
  • Assembler: main.s를 binary reloacatable object file main.o로 변환하는 프로그램
  • Linker: main.osum.o를 합쳐서 binary executable object file을 생성하는 프로그램

main.c와 sum.c C 언어 소스 파일들이 전처리기, 컴파일러, 어셈블러 프로그램을 거치면 오브젝트 파일이 됩니다. 링커는 이 오브젝트 파일들을 합쳐서 실행가능한 오브젝트 파일로 만들어줍니다.

위 모든 과정을 축약해서 모여주는 명령어

linux> gcc -Og -o prog main.c sum.c

이렇게 생성된 executable object 파일인 prog 아래와 같이 실행가능합니다.

linux> ./prog

쉘은 운영체제 함수에 있는 함수인 loader를 호출하면 (execve함수) prog 파일에 있는 코드와 메모리를 메모리에 복사합니다. 그리고 프로그램 시작에 제어권을 넘깁니다.

7.2 Static Linking

오브젝트 파일은 각 섹션마다 서로 다른 정보들을 담고 있습니다. 한쪽에는 인스트럭션을 담고 있고 다른 섹션에는 초기화된 전역 변수를, 또 다른 섹션에는 초기화되지 않은 변수를 담고 있습니다. 이 각각의 오브젝트 파일들을 통해 실행가능한 오브젝트 파일을 만드는 것이 링커의 역할입니다. (여기서는 Static Linker)

링커가 실행가능한 오브젝트 파일을 만들기 위해서는 2가지 과정을 거쳐야 합니다.

  1. Symbol resolution: 오브젝트 파일은 symbol을 정의하고 또한 참조합니다. symbol이란 것은 함수, 전역 변수, static 변수들을 의미합니다. 이 symbol resolution의 목표는 각 symbol reference를 정확히 하나의 symbol definition과 매칭시키는 것입니다. 자세한것은 조금 있다가 살펴보도록 하겠습니다.
  2. Relocation: 컴파일러와 어셈블러는 오브젝트 파일을 생성할 때 주소 0에서 시작하는 코드와 데이터 섹션을 생성합니다. 링커는 각 세션들을 재위치시킵니다.(relocate) 각 symbol 정의들을 특정 메모리에 위치시키고 해당 symbol들에 대한 참조가 위치한 메모리를 가리키게끔 수정합니다. 링커는 이러한 relocation을 어셈블러에 의해 생성된 자세한 인스트럭션을 통해 작업하는데, 이 인스트 럭션을 relocation entries라 합니다.

7.3 Object Files

오브젝트 파일은 3가지 형태를 가집니다.

  1. Relocatable object file: binary 코드와 데이터를 포함하고 있습니다. 컴파일 단계에서 다른 relocatable object file과 결합되서 실행가능한 오브젝트 파일을 만들 수 있습니다.
  2. Executable object file: 메모리에 바로 복사되고 실행될 수 있는 형태의 binary 코드와 데이터를 포함하고 있습니다.
  3. Shared object file: relocatable object file의 또 다른 형태로서 로드 타임 또는 런타임에 메모리에 로드되고 동적으로 링킹될 수 있는 파일입니다.

오브젝트 파일들은 특정한 object file format을 가집니다.
현대 x86-64 Linux와 Unix 시스템은 Executable and Linkable Format(ELF)를 사용합니다.
대부분 다른 시스템에서도 ELF와 크게 다르지 않은 포맷을 사용합니다.

7.4 Relocatable Object Files

ELF는 위 그림과 같은 형태를 가집니다.

  • ELF 헤더: 주소 0부터 시작하는 ELF 헤더는 해당 파일을 생성한 시스템의 워드 크기와 바이트 ordering에 대한 정보를 담고 있습니다. 헤더의 남은 부분은 링커가 오브젝트 파일을 파싱하고 해석하는데 필요한 정보들(ELF header 크기, 오브젝트 파일 타입, 컴퓨터 타입, header table file offset, header table에 있는entry 갯수와 사이즈)이 들어가 있습니다.
  • Section header table: 각 섹션의 크기와 위치가 저장되어 있습니다.

ELF에서 남은 부분을 살펴보도록 하겠습니다.

  • .text: 컴파일된 프로그램의 기계어 코드가 들어 있습니다. (code 부분을 의미)
  • .rodata: printf문의 format string이나 switch문의 jump table 같이 읽기만 가능한 데이터를 담고 있습니다.
  • .data: 초기화된 전역 변수 또는 static 변수가 담겨 있습니다. (지역 변수는 Stack에서 관리되며 .data나 .bss에서 관리되지 않습니다.)
  • .bss: 전역변수 또는 static 변수중 초기화 되지 않았거나 0으로 초기화된 변수들이 들어갑니다. 해당 섹션은 오브젝트 파일상에서 실제 공간을 차지하지는 않습니다. 일종의 placeholder 역할입니다. 오브젝트 파일은 공간 효율성을 위하여 초기화된 변수와 초기화 되지 않은 변수를 구별하고 초기화 되지 않은 변수는 오브젝트 파일의 디스크 공간을 차지하지 않습니다. 런타임에서, 이 변수들은 0으로 초기화되서 메모리에 할당됩니다.
  • .symtab: 프로그램상에서 정의되고 참조되는 함수와 전역변수들에 대한 정보를 담은 symbol table입니다. -g 옵션을 사용해야 symbol table 정보를 얻을 수 있다고 생각하는 프로그래머들이 종종 있는데, relocatable object 파일은 .symtab에 symbol table을 가지고 있습니다. (프로그래머가 STRIP 명령어로 지우지 않는 이상 그렇습니다.) 하지만, 컴파일러의 symbol table과 다르게 .symtab symbol table은 지역변수에 대한 entry를 담고 있지 않습니다.
  • .rel .text: 링커가 오브젝트 파일들을 합치면서 변화해야 될 .text 섹션 위치 리스트입니다. 일반적으로, external 함수 호출이나 전역 변수를 참조하는 인스트럭션은 수정되어야 합니다. 반면에, 지역변수를 호출하는 인스트럭션은 수정될 필요가 없습니다. relocation 정보는 실행가능한 오브젝트 파일에는 필요 없으며, 보통 생략됩니다. 다만 프로그래머가 명시적으로 링커에게 이를 포함해달라고 지시하면 포함됩니다.
  • .rel .data: 모듈에 의해서 참조되고 정의되는 전역 변수들의 relocation 정보가 담겨 있습니다. 일반적으로, 초기 값이 전역변수의 주소이거나 외부적으로 정의된 함수인 초기화된 전역변수는 수정되어야 합니다.
  • .debug: 기존 C프로그램 소스파일과 프로그램에서 정의되고 참조되어지는 전역 변수, 프로그램에 정의된 지역변수나 typedef을 포함한 debugging symbol table입니다. 컴파일러 드라이버에 -g 옵션을 주어야 확인할 수 있습니다.
  • .line: 기존 C 프로그램 소스파일 줄 번호과 .text 섹션에 있는 기계어 인스트럭션 사이의 맵핑입니다. 이 또한 컴파일러시 -g 옵션을 통해 확인 가능합니다.
  • .strtab: .symtab.debug 섹션에서의 symbol table과 section header에 있는 섹션 이름들에 대한 string table입니다. string table은 null로 끝나는 문자열의 연속입니다.

7.5 Symbols and Symbol Tables

각각의 relocatable object module m은 해당 모듈에서 정의되고 참조되어지는 symbol에 대한 정보를 symbol table에 담고 있습니다. 링커의 입장에서 symbol은 크게 3가지로 나뉘어질 수 있습니다.

  • m에서 정의되지만 다른 모듈에서 참조되는 전역 symbol. 이러한 심볼은 nonstatic C 함수와 전역 변수들이 있습니다.
  • m에서 참조되지만 다른 모듈에서 정의된 전역 symbol. 이러한 심볼들을 externals라 하고 다른 모듈에서 정의된 전역변수와 nonstatic C 함수들이 이에 해당합니다.
  • 지역 symbol은 모두 m에서만 정의되고 참조되어지는 심볼들입니다. 이는 static C 함수와 static 특성으로 정의된 전역 변수에 상응합니다. 이 심볼들은 m 모듈내에서 확인가능하지만 다른 모듈에 의해서 참조될 수 없습니다.

local linker symbol들은 지역변수와 다른 심볼들입니다. .symtab에서는 local nonstatic 변수에 대한 정보를 가지고 있지 않습니다. local nonstatic 변수는 런타임시 스택에서 관리됩니다.
다만 static 특성과 함꼐 정의된 local procedure 변수는 스택에서 관리하지 않습니다. 대신에, 컴파일러가 .data.bss에 각 정의마다 공간을 할당해 놓고 유일한 이름과 함께 symbol table에 local linker symbol을 생성합니다.

예시를 한번 보겠습니다. 예를 들어 아래와 같은 코드를 포함한 모듈이 있다고 가정해 봅시다.

int f() {
	static int x = 0;
	return x;
}

int g() {
	static int x = 1;
	return x;
}

이 경우, 컴파일러는 x에 대해 서로 다른 이름을 가진 local linker symbol을 어셈블러에게 전달합니다. f 함수에 있는 것은 x.1, g 함수에 있는 것은 x.2 이런식으로 말이죠.

심볼 테이블은 컴파일러에서 어셈블리 언어 .s 파일로 내보낸 기호를 사용하여 어셈블러에 의해 작성됩니다. ELF 심볼 테이블은 .symtab 섹션에 포함되어 있습니다. 심볼 테이블은 entry 배열을 가지고 있습니다. 아래 그림은 entry 구성 형태 입니다.

name은 해당 심볼 이름인 null로 끝나는 문자열을 가리키는 string table로의 byte offset입니다. value는 심볼의 주소입니다. Relocatable 모듈에서는, value가 해당 섹션이 시작하는 부분에서부터 의 offset이고 executable 오브젝트 파일에서는 value는 절대적인 런타임 주소입니다. size는 해당 오브젝트의 크기를 나타냅니다. type은 보통 data 또는 function입니다. symbol table은 또한 각 섹션과 원 소스 파일 경로에 대한 entry를 가지고 있습니다. binding 필드는 해당 심볼이 local이냐 global이냐를 나타냅니다.

section header table에 없는 section인 pseudosection 3가지가 있습니다.

  1. ABS: relocate 되서는 안되는 symbol
  2. UNDEF: 정의되지 않은 symbol. 즉, 해당 모듈에서 참조되지만 다른 모듈에 정의되어 있는 symbol
  3. COMMON: 아직 할동되지않은 초기화안된 데이터 객체. COMMON 객체에는 ELF symbol table entry에서 value 멤버는 alignment requirement 요청이 들어가며 size 멤버는 최소 사이즈가 들어갑니다.

pseudosection은 relocatable 오브젝트 파일에만 존재합니다.

COMMON.bss 차이
COMMON: Uninitialized global variables
.bss: Uninitialized static variables, and static or global variables that are initialized to zero

7.6 Symbol Resolution

링커는 각 심볼 참조마다 input으로 받는 relocatable 오브젝트 파일들의 symbol table들 에서의 단 하나의 심볼 정의와 매칭되게끔 작업합니다. 이를 Symbol Resolution이라 합니다.

같은 모듈에서만 정의되고 참조하는 local symbol들은 symbol resolution 작업이 다소 순조롭습니다. 컴파일러는 각 local symbol마다 정의를 하나씩 맵핑해줍니다. 같은 모듈안에서만 찾으면 되고 컴파일러가 유일한 정의임을 보장해 줄 수 있기에 크게 어려운 작업이 아니라 할 수 있습니다. 하지만 global symbol에 대한 참조를 resolving하는 것은 다소 어렵습니다.

컴파일러가 현재 모듈에서 정의되지 않은 심볼을 만나면 다른 모듈에서 정의되어 있을 것이라 가정하고 symbol table entry를 생성하고 linker에게 남은 작업을 넘깁니다. 만약 링커가 다른 모듈에서 해당 참조 심볼에 대란 정의를 찾지 못하면 에러 메세지를 출력하고 종료됩니다.

아래와 같은 소스 파일을 컴파일하고 링킹한다고 가정해 봅시다.

void foo(void);
  
int main() {
	foo();
	return 0;
}

Executable object 파일을 만들려고 하면,

❯❯❯ gcc -o linkerror linkerror.c
Undefined symbols for architecture x86_64:
  "_foo", referenced from:
      _main in linkerror-ca58e0.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

위와 같은 결과가 발생합니다. foo 함수에 대한 정의를 찾을 수 없으니 링킹 과정에서 에러가 발생한 것입니다.

또 symbol resolution의 어려운 점은 여러개의 오브젝트 모듈이 같은 이름의 global symbol들을 정의한 경우입니다. 이 경우, 링커는 에러를 발생시키거나 정의들 중 하나를 고르고 나머지를 버려야합니다. 리눅스 시스템에서 채택한 접근법은 컴파일러, 어셈블러, 링커간의 협력입니다. 그리고 부주의한 프로그래머에게는 버그를 보여줍니다.

7.6.1 How Linkers Resolve Duplicate Symbol Names

앞서 살펴봤던 여러 오브젝트 모듈들이 같은 이름의 global symbol을 정의헀으면 어떻게 되는지 살펴보겠습니다.

컴파일 단계에서, 컴파일러는 각 global symbol에 strong, weak라는 딱지를 붙혀 어셈블러에게 보냅니다. 그리고 어셈블러는 이 정보를 implicit하게 relocatable 오브젝트 파일의 symbol table에 인코딩합니다. 함수와 초기화된 전역 변수들은 strong symbol이며 초기화 되지 않은 전역 변수들은 weak symbol입니다.

Linux 링커는 아래 규칙들을 가지고 이름이 같은 심볼들을 다룹니다.

  • Rule 1. 같은 이름을 가진 strong symbol 여러개는 허용되지 않습니다.
  • Rule 2. strong symbol 하나와 weak symbol 여러개가 있으면 strong symbol을 선택합니다.
  • Rule 3. 같은 이름의 여러 weak symbol이 있으면 그 중에 아무거나 하나 선택합니다.

여러 예제를 통해 이 규칙들을 살펴보겠습니다.

/* foo1.c */

int main() {
	return 0;
}
/* bar1.c */

int main() {
	return 0;
}

이 경우, strong symbol인 main이 여러번 정의되었기에 링커가 에러를 발생시킵니다.

❯❯❯ gcc -o main bar1.c foo1.c
duplicate symbol '_main' in:
    /var/folders/g2/grgrt59s2rbdz7_lnxrg2kt40000gn/T/bar1-6efa92.o
    /var/folders/g2/grgrt59s2rbdz7_lnxrg2kt40000gn/T/foo1-47f6b2.o
ld: 1 duplicate symbol for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
/* foo1.c */

int x = 15213;

int main() {
	return 0;
}
/* bar1.c */
int x = 15213;

int f() {
	return 0;
}

strong symbol인 x를 각 모듈에서 정의했기에 에러가 발생합니다.

❯❯❯ gcc -o main bar1.c foo1.c
duplicate symbol '_x' in:
    /var/folders/g2/grgrt59s2rbdz7_lnxrg2kt40000gn/T/bar1-a73dc7.o
    /var/folders/g2/grgrt59s2rbdz7_lnxrg2kt40000gn/T/foo1-433ff2.o
ld: 1 duplicate symbol for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

이번에는 한쪽에는 strong symbol을 다른 모듈에는 weak symbol을 정의해 보도록 하겠습니다.

/* foo1.c */

#include <stdio.h>

void f();

int x = 15213;

int main() {
    printf("x = %d\n", x);
    f();
    printf("x = %d\n", x);
    return 0;
}
/* bar1.c */

int x;

void f() {
    x = 15212;
}

런타임에서 f 함수가 x의 값을 15213에서 15212로 변경했습니다. 이런 변화는 main 함수 작성자 입장에서는 원치않은 결과일 수 있습니다. 기억해야 될 것은 링커가 여러개의 정의를 확인했다는 어떤 표시도 주지 않는다는 것입니다.

❯❯❯ gcc -o main bar1.c foo1.c
❯❯❯ ./main
x = 15213
x = 15212

여러개의 weak definition인 경우도 보겠습니다.

/* foo1.c */

#include <stdio.h>

void f();

int x;

int main() {
    x = 15213;
	printf("x = %d\n", x);
    f();
    printf("x = %d\n", x);
    return 0;
}
/* bar1.c */

int x;

void f() {
    x = 15212;
}

같은 결과를 가져옵니다.

❯❯❯ gcc -o main bar1.c foo1.c
❯❯❯ ./main
x = 15213
x = 15212

규칙 2번과 3번으로 인해 프로그래머에게 런타임에 발생하는 이해할 수 없는 버그를 발생시킬 수 있습니다. 특히 다른 타입의 중복된 symbol definition이 있는 경우 두드러집니다. 아래 예시에서 x 변수를 한쪽에서는 int로 다른 쪽에서는 double로 정의해보도록 하겠습니다.

/* foo1.c */

#include <stdio.h>

void f();

int x = 15213;
int y = 15212;

int main() {
    printf("x = 0x%x y = 0x%x\n", x, y);
    f();
    printf("x = 0x%x y = 0x%x\n", x, y);
    return 0;
}
/* bar1.c */

double x;

void f() {
    x = -0.0;
}

x86-64/Linux 컴퓨터에서 double 8바이트이고 int는 4바이트 입니다. 이 시스템에서 x 주소는 0x601020이며 y 주소는 0x601024입니다. 따라서 x = -0.0은 메모리 위치상에서 음의 0을 double-precision floating-point로 표현한 것으로 x와 y을 모두 덮어씁니다.

❯❯❯ gcc -o main bar1.c foo1.c
ld: warning: tentative definition of '_x' with size 8 from '/var/folders/g2/grgrt59s2rbdz7_lnxrg2kt40000gn/T/bar1-189f23.o' is being replaced by real definition of smaller size 4 from '/var/folders/g2/grgrt59s2rbdz7_lnxrg2kt40000gn/T/foo1-3de4c4.o'

❯❯❯ ./main
x = 0x3b6d y = 0x3b6c
x = 0x0 y = 0x80000000

이러한 버그는 링커가 오직 경고만을 내고 프로그램 실행 후에 프로그램이 스스로를 변경해 실제 에러가 발생한 지점으로부터 꽤 지난 시점에서 버그가 발생하므로 발견하기가 쉽지 않습니다. 몇 백개의 모듈이 존재하는 큰 시스템에서 이러한 버그는 바로 잡기가 쉽지 않습니다. 특히 많은 프로그래머들이 링커가 어떻게 동작하는지 잘 몰라서 종종 컴파일러 경고를 무시하기에 더더욱 버그를 잡기 어렵습니다. 신중을 기해, -fno-common gcc flag를 추가함으로써 global symbol을 여러 번 정의한 경우 링커가 에러를 유발하게 할 수 있습니다. 또는 -Werror 옵션을 사용해 모든 경고를 에러로 변경할 수도 있습니다.

profile
백엔드 개발자 디디라고합니다.

0개의 댓글