링킹은 여러 코드와 데이터를 메모리에 로드되고 실행될 수 있는 하나의 파일로 만드는 과정
총 3가지 경우
- 컴파일 타임(소스 코드가 머신코드로 바뀔 때)
- 로드 타임(프로그램이 메모리에 올라가고 로더에 의해 실행될때)
- 런타임(프로그램 동작)
예전에는 링킹을 직접 했다. (Manually)
현대 시스템은 링커라는 프로그램이 자동적으로 수행해줌
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 filemain.o
로 변환하는 프로그램- Linker:
main.o
와sum.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 파일에 있는 코드와 메모리를 메모리에 복사합니다. 그리고 프로그램 시작에 제어권을 넘깁니다.
오브젝트 파일은 각 섹션마다 서로 다른 정보들을 담고 있습니다. 한쪽에는 인스트럭션을 담고 있고 다른 섹션에는 초기화된 전역 변수를, 또 다른 섹션에는 초기화되지 않은 변수를 담고 있습니다. 이 각각의 오브젝트 파일들을 통해 실행가능한 오브젝트 파일을 만드는 것이 링커의 역할입니다. (여기서는 Static Linker)
링커가 실행가능한 오브젝트 파일을 만들기 위해서는 2가지 과정을 거쳐야 합니다.
symbol
을 정의하고 또한 참조합니다. symbol
이란 것은 함수, 전역 변수, static 변수들을 의미합니다. 이 symbol resolution
의 목표는 각 symbol reference
를 정확히 하나의 symbol definition
과 매칭시키는 것입니다. 자세한것은 조금 있다가 살펴보도록 하겠습니다.relocation entries
라 합니다.오브젝트 파일은 3가지 형태를 가집니다.
오브젝트 파일들은 특정한
object file format
을 가집니다.
현대 x86-64 Linux와 Unix 시스템은 Executable and Linkable Format(ELF)를 사용합니다.
대부분 다른 시스템에서도 ELF와 크게 다르지 않은 포맷을 사용합니다.
ELF는 위 그림과 같은 형태를 가집니다.
ELF에서 남은 부분을 살펴보도록 하겠습니다.
printf
문의 format string이나 switch
문의 jump table 같이 읽기만 가능한 데이터를 담고 있습니다.placeholder
역할입니다. 오브젝트 파일은 공간 효율성을 위하여 초기화된 변수와 초기화 되지 않은 변수를 구별하고 초기화 되지 않은 변수는 오브젝트 파일의 디스크 공간을 차지하지 않습니다. 런타임에서, 이 변수들은 0으로 초기화되서 메모리에 할당됩니다.symbol table
입니다. -g
옵션을 사용해야 symbol table 정보를 얻을 수 있다고 생각하는 프로그래머들이 종종 있는데, relocatable object 파일은 .symtab
에 symbol table을 가지고 있습니다. (프로그래머가 STRIP 명령어로 지우지 않는 이상 그렇습니다.) 하지만, 컴파일러의 symbol table과 다르게 .symtab
symbol table은 지역변수에 대한 entry를 담고 있지 않습니다..text
섹션 위치 리스트입니다. 일반적으로, external
함수 호출이나 전역 변수를 참조하는 인스트럭션은 수정되어야 합니다. 반면에, 지역변수를 호출하는 인스트럭션은 수정될 필요가 없습니다. relocation 정보는 실행가능한 오브젝트 파일에는 필요 없으며, 보통 생략됩니다. 다만 프로그래머가 명시적으로 링커에게 이를 포함해달라고 지시하면 포함됩니다.-g
옵션을 주어야 확인할 수 있습니다..text
섹션에 있는 기계어 인스트럭션 사이의 맵핑입니다. 이 또한 컴파일러시 -g
옵션을 통해 확인 가능합니다..symtab
과 .debug
섹션에서의 symbol table과 section header에 있는 섹션 이름들에 대한 string table입니다. string table은 null로 끝나는 문자열의 연속입니다.각각의 relocatable object module m
은 해당 모듈에서 정의되고 참조되어지는 symbol에 대한 정보를 symbol table에 담고 있습니다. 링커의 입장에서 symbol은 크게 3가지로 나뉘어질 수 있습니다.
externals
라 하고 다른 모듈에서 정의된 전역변수와 nonstatic C 함수들이 이에 해당합니다.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가지가 있습니다.
pseudosection은 relocatable 오브젝트 파일에만 존재합니다.
COMMON과 .bss 차이
COMMON: Uninitialized global variables
.bss: Uninitialized static variables, and static or global variables that are initialized to zero
링커는 각 심볼 참조마다 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들을 정의한 경우입니다. 이 경우, 링커는 에러를 발생시키거나 정의들 중 하나를 고르고 나머지를 버려야합니다. 리눅스 시스템에서 채택한 접근법은 컴파일러, 어셈블러, 링커간의 협력입니다. 그리고 부주의한 프로그래머에게는 버그를 보여줍니다.
앞서 살펴봤던 여러 오브젝트 모듈들이 같은 이름의 global symbol을 정의헀으면 어떻게 되는지 살펴보겠습니다.
컴파일 단계에서, 컴파일러는 각 global symbol에 strong
, weak
라는 딱지를 붙혀 어셈블러에게 보냅니다. 그리고 어셈블러는 이 정보를 implicit하게 relocatable 오브젝트 파일의 symbol table에 인코딩합니다. 함수와 초기화된 전역 변수들은 strong symbol이며 초기화 되지 않은 전역 변수들은 weak symbol입니다.
Linux 링커는 아래 규칙들을 가지고 이름이 같은 심볼들을 다룹니다.
여러 예제를 통해 이 규칙들을 살펴보겠습니다.
/* 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
옵션을 사용해 모든 경고를 에러로 변경할 수도 있습니다.