링킹(Linking)은 다양한 코드와 데이터 조각들을 수집하고 결합하여, 메모리에 로드(복사)하고 실행할 수 있는 단일 파일로 만드는 과정입니다.
링킹은 소스 코드가 기계어 코드로 변환되는 컴파일 시간, 프로그램이 로더에 의해 메모리에 로드되어 실행되는 로드 시간, 심지어는 응용 프로그램에 의해 런타임에도 수행될 수 있습니다. 현대 시스템에서는 링커(linker)라는 프로그램에 의해 자동으로 수행됩니다.
링커는 분할 컴파일(separate compilation)을 가능하게 하여 소프트웨어 개발에서 핵심적인 역할을 합니다. 덕분에 거대한 프로그램을 단일 소스 파일로 관리하는 대신, 더 작고 관리하기 쉬운 여러 모듈로 나누어 개별적으로 수정하고 컴파일할 수 있습니다.
초급 프로그래밍 수업에서 작은 프로그램을 만들 때는 링킹이 눈에 보이지 않아 중요하지 않게 느껴질 수 있습니다. 하지만 링킹을 배워야 하는 이유는 다음과 같이 명확합니다.
static 속성으로 정의하는 것이 실제로 무엇을 의미하는지 등을 명확히 알 수 있습니다.이 챕터에서는 전통적인 정적 링킹(static linking)부터 로드 시간 및 런타임에 이루어지는 공유 라이브러리의 동적 링킹(dynamic linking)까지 링킹의 모든 측면을 깊이 있게 다룹니다. 논의는 x86-64 리눅스 시스템과 표준 ELF(Executable and Linkable Format) 오브젝트 파일 형식을 기준으로 구체적인 예시를 통해 진행되지만, 링킹의 기본 개념은 운영체제, ISA, 오브젝트 파일 형식에 관계없이 보편적으로 적용됩니다.
대부분의 컴파일 시스템은 사용자를 대신하여 언어 전처리기(preprocessor), 컴파일러(compiler), 어셈블러(assembler), 링커(linker)를 필요에 따라 호출하는 컴파일러 드라이버(compiler driver)를 제공합니다.
예를 들어, GNU 컴파일 시스템을 사용하여 예제 프로그램을 빌드하려면 셸에 다음과 같은 명령어를 입력하여 gcc 드라이버를 호출할 수 있습니다.
linux> gcc -Og -o prog main.c sum.c
gcc 드라이버는 C 소스 파일을 실행 가능한 목적 파일로 변환하기 위해 다음과 같은 단계를 거칩니다.
main.c)을 아스키(ASCII) 중간 파일(main.i)로 변환합니다.cpp [other arguments] main.c /tmp/main.imain.i)을 아스키(ASCII) 어셈블리어 파일(main.s)로 변환합니다.cc1 /tmp/main.i -Og [other arguments] -o /tmp/main.smain.s)을 바이너리 형태의 재배치 가능한 목적 파일(relocatable object file)인 main.o로 변환합니다.as [other arguments] -o /tmp/main.o /tmp/main.s
- 드라이버는 `sum.c`에 대해서도 동일한 과정을 거쳐 `sum.o`를 생성합니다.
- 마지막으로 **링커 프로그램(ld)**을 실행하여 `main.o`와 `sum.o`를 필요한 시스템 목적 파일들과 함께 묶어 최종적으로 **실행 가능한 목적 파일(executable object file)**인 `prog`를 생성합니다.
- `ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o`
생성된 실행 파일 prog를 실행하기 위해 셸 명령어 라인에 파일명을 입력합니다.
linux> ./prog
그러면 셸은 운영체제 내의 로더(loader)라는 함수를 호출합니다. 로더는 실행 파일 prog에 있는 코드와 데이터를 메모리에 복사한 뒤, 프로그램의 시작점으로 제어를 넘겨 프로그램을 실행시킵니다.
리눅스의 ld 프로그램과 같은 정적 링커(static linker)는 여러 개의 재배치 가능한 목적 파일(relocatable object files)과 커맨드 라인 인자를 입력으로 받아, 로드하고 실행할 수 있는 완전한 형태의 실행 가능한 목적 파일(executable object file)을 출력으로 생성합니다.
입력으로 사용되는 재배치 가능한 목적 파일들은 다양한 코드와 데이터 섹션으로 구성되며, 각 섹션은 연속된 바이트의 시퀀스입니다. 예를 들어, 명령어는 코드 섹션에, 초기화된 전역 변수는 다른 섹션에, 그리고 초기화되지 않은 변수는 또 다른 섹션에 위치합니다.
실행 파일을 만들기 위해 링커는 두 가지 주요 작업을 수행해야 합니다.
static 변수 등에 해당합니다.목적 파일(Object file)은 세 가지 형태로 존재합니다.
참고: 기술적으로 목적 모듈(object module)은 바이트의 순서를 의미하고, 목적 파일(object file)은 디스크 파일에 저장된 목적 모듈을 의미하지만, 이 둘은 보통 혼용해서 사용합니다.
목적 파일은 시스템마다 다른 특정 목적 파일 포맷(object file formats)에 따라 구성됩니다.
a.out 파일이라고 부르곤 합니다.)이 책의 설명은 ELF 포맷에 초점을 맞추지만, 기본적인 개념은 포맷의 종류와 관계없이 유사합니다.
전형적인 ELF(Executable and Linkable Format) 재배치 가능 목적 파일의 포맷은 다음과 같은 구조를 가집니다.
ELF 헤더와 섹션 헤더 테이블 사이에는 아래와 같은 실제 섹션들이 위치합니다.

.text문의 포맷 문자열이나 switch`문의 점프 테이블(jump table)과 같이 읽기 전용 데이터(read-only data)를 저장합니다..data초기화된 전역 변수와 정적(static) 변수가 저장됩니다. (지역 변수는 런타임 시 스택에서 관리되므로 이 섹션에 포함되지 않습니다.).bss.symtabg 옵션 없이도 모든 재배치 가능 목적 파일은 이 테이블을 가지고 있으며, 지역 변수에 대한 정보는 포함하지 않습니다.).rel.data.debugtypedef, 프로그램에서 정의하고 참조하는 전역 변수, 원본 C 소스 파일에 대한 정보를 포함하는 디버깅용 심볼 테이블입니다. 컴파일러 드라이버가 g 옵션으로 호출될 때만 생성됩니다..line.text 섹션의 기계어 명령어 사이의 매핑 정보입니다. g 옵션으로 컴파일할 때만 생성됩니다.과 .debug` 섹션의 심볼 이름이나 섹션 헤더의 섹션 이름을 위한 문자열 테이블(string table)입니다. NULL로 끝나는 문자열들의 시퀀스로 구성됩니다.각각의 재배치 가능 목적 모듈(m)은 m에 의해 정의(define)되고 참조(reference)되는 심볼에 대한 정보를 담고 있는 심볼 테이블(symbol table)을 가지고 있습니다. 링커의 관점에서 심볼은 세 가지 종류로 나뉩니다.
m에 의해 정의되고, 다른 모듈에서 참조할 수 있는 심볼입니다.static이 아닌 함수와 전역 변수에 해당합니다.m에서 참조하지만, 다른 모듈에 정의되어 있는 심볼입니다. 이러한 심볼을 외부 심볼(externals)이라고 부릅니다.static이 아닌 C 함수와 전역 변수에 해당합니다.m 내부에서만 정의되고 참조되는 심볼입니다.static 속성으로 선언된 C 함수와 전역 변수에 해당하며, 다른 모듈에서는 참조할 수 없습니다.중요: 링커의 지역 심볼과 프로그램의 지역 변수는 다릅니다. 심볼 테이블(.symtab)은 함수 내의 static이 아닌 지역 변수에 대한 심볼은 포함하지 않습니다. 이러한 변수들은 런타임에 스택에서 관리되며 링커의 관심사가 아닙니다.
흥미롭게도, 함수 내에 static으로 선언된 지역 변수는 스택에서 관리되지 않습니다. 대신 컴파일러는 각 변수를 위해 .data 또는 .bss 섹션에 공간을 할당하고, 심볼 테이블에 고유한 이름을 가진 지역 링커 심볼을 생성합니다.

예를 들어, 같은 모듈 내의 두 함수가 각각 static int x를 정의하면, 컴파일러는 f 함수의 x는 x.1로, g 함수의 x는 x.2와 같이 서로 다른 이름의 지역 심볼을 만들어 어셈블러에게 전달합니다.
ELF 심볼 테이블은 .symtab 섹션에 있으며, 여러 항목(entry)들의 배열로 구성됩니다. 각 항목은 다음과 같은 정보를 포함합니다.

.strtab) 내의 오프셋으로, 심볼의 실제 이름을 가리킵니다.재배치 가능 파일에만 존재하는 세 가지 특수 섹션이 있습니다.
COMMON vs .bss최신 gcc는 다음과 같은 규칙에 따라 심볼을 할당합니다.
COMMON: 초기화되지 않은 전역 변수.bss: 초기화되지 않은 정적 변수 및 0으로 초기화된 전역/정적 변수readelf를 이용한 심볼 테이블 확인 예제GNU의 readelf 프로그램을 사용하면 목적 파일의 내용을 쉽게 확인할 수 있습니다. 아래는 main.o 파일의 심볼 테이블 항목 예시입니다.
Num: Value Size Type Bind Vis Ndx Name 8: 0000000000000000 24 FUNC GLOBAL DEFAULT 1 main 9: 0000000000000000 8 OBJECT GLOBAL DEFAULT 3 array 10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sum
main: 전역 심볼(GLOBAL)이며, 24바이트 크기의 함수(FUNC)입니다. .text 섹션(Ndx=1)의 오프셋 0에 위치합니다.array: 전역 심볼이며, 8바이트 크기의 객체(OBJECT)입니다. .data 섹션(Ndx=3)의 오프셋 0에 위치합니다.sum: 이 모듈에서 정의되지 않은(Ndx=UND) 외부 심볼입니다.링커는 입력으로 받은 재배치 가능 목적 파일들의 심볼 테이블을 참조하여, 각각의 심볼 참조(reference)를 정확히 하나의 심볼 정의(definition)와 연결하는 방식으로 심볼을 해석합니다.
지역 심볼(Local Symbol)에 대한 참조를 해석하는 과정은 매우 간단합니다.
반면, 전역 심볼(Global Symbol)을 해석하는 과정은 더 까다롭습니다.
정의를 찾지 못하는 경우 (Undefined Reference)C
// linkerror.c
void foo(void); // 선언만 있고 정의가 없음
int main() {
foo();
return 0;
}
위 코드를 컴파일하면 링커 단계에서 foo에 대한 정의를 찾을 수 없어 다음과 같은 오류가 발생합니다.
undefined reference to ‘foo’
동일한 이름의 전역 심볼이 여러 개 정의된 경우
C++과 Java는 소스 코드에서 이름은 같지만 매개변수 리스트(parameter list)가 다른 메소드 오버로딩(overloaded methods)을 허용합니다. 그렇다면 링커는 이 서로 다른 오버로딩된 함수들을 어떻게 구별할까요?
C++과 Java의 메소드 오버로딩이 가능한 이유는 컴파일러가 각각의 고유한 메소드와 매개변수 리스트 조합을 링커를 위한 고유한 이름으로 인코딩(encoding)하기 때문입니다. 이 인코딩 과정을 맹글링(mangling)이라 하고, 그 반대 과정을 디맹글링(demangling)이라고 합니다.
다행히 C++과 Java는 호환되는 맹글링 방식을 사용합니다.
Foo는 3Foo로 인코딩됩니다.__ + [맹글링된 클래스 이름] + [각 인자의 단일 문자 인코딩] 순서로 구성됩니다.Foo::bar(int, long) 메소드는 bar__3Fooil로 인코딩됩니다. (int는 i로, long은 l로 인코딩)전역 변수나 템플릿 이름에도 비슷한 방식이 사용됩니다.
링커는 여러 목적 모듈을 입력으로 받는데, 만약 여러 모듈이 같은 이름의 전역 심볼을 정의하고 있다면 어떻게 처리할까요? 리눅스 컴파일 시스템은 다음과 같은 접근 방식을 사용합니다.
컴파일 시, 컴파일러는 각 전역 심볼을 강한(strong) 심볼 또는 약한(weak) 심볼로 구분하여 어셈블러에게 전달하고, 어셈블러는 이 정보를 목적 파일의 심볼 테이블에 기록합니다.
리눅스 링커는 강한 심볼과 약한 심볼의 개념을 바탕으로 다음과 같은 규칙에 따라 중복된 심볼 이름을 처리합니다.
이러한 규칙, 특히 규칙 2와 3은 프로그래머가 예상치 못한 미묘한 런타임 버그를 유발할 수 있습니다.
x가 다른 모듈의 초기화되지 않은 약한 심볼 x의 값을 덮어쓰게 됩니다. 이로 인해 main 함수 작성자의 의도와 다른 결과를 초래할 수 있습니다. (foo3.c, bar3.c 예제)foo5.c, bar5.c 예제)foo5.c의 int x = 15213; (4바이트, 강한 심볼)bar5.c의 double x; (8바이트, 약한 심볼)int x를 선택하고 주소를 할당합니다.bar5.c의 코드(x = -0.0;)가 실행되면, 링커가 할당한 주소에 8바이트 크기의 double 값을 덮어쓰게 됩니다.int x(4바이트)의 공간뿐만 아니라, 그 뒤에 위치한 변수 y의 메모리 공간까지 침범하여 값을 훼손시키는 치명적인 버그가 발생합니다.이런 버그는 링커가 오류가 아닌 경고만 띄우고, 문제가 발생한 위치와 멀리 떨어진 곳에서 증상이 나타나기 때문에 원인을 찾기가 매우 어렵습니다. 이를 방지하려면 -fno-common 플래그(중복 정의를 오류로 처리)나 -Werror 플래그(모든 경고를 오류로 처리)를 사용하여 링크하는 것이 좋습니다.
이러한 링커의 동작 방식 때문에 컴파일러는 심볼을 COMMON과 .bss에 할당할 때 독특한 규칙을 따릅니다.
COMMON 블록에 할당합니다.static 변수는 유일함이 보장되므로, 컴파일러는 확신을 갖고 이를 .bss나 .data에 직접 할당할 수 있습니다.실제로 모든 컴파일 시스템은 연관된 목적 모듈들을 정적 라이브러리(static library)라는 단일 파일로 묶는 메커니즘을 제공합니다. 링커는 실행 파일을 빌드할 때, 애플리케이션 프로그램이 참조하는(referenced) 라이브러리 내의 목적 모듈들만 복사하여 포함시킵니다.
printf, strcpy, sin, cos와 같은 수많은 표준 함수들을 어떻게 사용자에게 제공할 수 있을까요? 정적 라이브러리가 없다면 다음과 같은 방법들을 고려할 수 있지만, 모두 단점이 명확합니다.
libc.o): 모든 표준 함수를 하나의 거대한 목적 파일에 넣어두고 링크하는 방식입니다.printf.o, scanf.o ...): 각 함수를 별도의 목적 파일로 만들어 제공하는 방식입니다.정적 라이브러리는 위 문제점들을 해결하기 위해 개발되었습니다.
.o)로 컴파일한 뒤, ar 도구를 사용하여 하나의 아카이브(archive) 파일(.a)로 묶습니다.linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a리눅스 시스템에서 정적 라이브러리는 아카이브(archive)라는 특별한 파일 형식으로 저장되며, .a 확장자를 가집니다.
linux> gcc -c addvec.c multvec.c (소스 파일을 목적 파일로 컴파일)linux> ar rcs libvector.a addvec.o multvec.o (ar 도구로 목적 파일들을 묶어 아카이브 생성)linux> gcc -static -o prog2c main2.o ./libvector.a (직접 파일 경로 지정)linux> gcc -static -o prog2c main2.o -L. -lvector (플래그 사용)static: 완전하게 링크된 실행 파일을 생성하라는 옵션입니다.lvector: libvector.a 라이브러리를 링크하라는 의미의 축약형입니다.L. : 현재 디렉토리(. )에서 라이브러리를 찾으라는 의미입니다.위 예시에서 링커는 다음과 같이 동작합니다.
main2.o가 addvec 심볼을 참조하는 것을 확인합니다.libvector.a 라이브러리 내부를 탐색하여 addvec.o가 addvec 심볼을 정의하고 있음을 발견합니다.addvec.o 모듈 전체를 실행 파일에 복사합니다.multvec.o의 심볼은 전혀 참조하지 않으므로, multvec.o 모듈은 실행 파일에 포함시키지 않습니다.정적 라이브러리는 유용하지만, 링커가 외부 참조를 해결하는 방식 때문에 프로그래머에게 혼란을 주기도 합니다. 핵심은 링커의 순차적 처리 방식에 있습니다.
링커는 컴파일러 드라이버의 커맨드 라인에 명시된 순서대로 재배치 가능 목적 파일(.o)과 아카이브(.a)를 왼쪽에서 오른쪽으로 스캔합니다. 이 과정에서 링커는 세 가지 집합을 유지 관리합니다.
E: 최종 실행 파일을 구성할 목적 파일들의 집합U: 아직 정의를 찾지 못한, 해결되지 않은 심볼(Unresolved symbols)들의 집합D: 이전 파일들에서 이미 정의된 심볼(Defined symbols)들의 집합링커는 커맨드 라인의 각 파일 f에 대해 다음과 같이 동작합니다.
f가 목적 파일(.o)일 경우:f를 E에 추가합니다.f의 심볼 정의와 참조를 반영하여 U와 D를 업데이트합니다.f가 아카이브(.a, 라이브러리)일 경우:U에 있는 미해결 심볼들과 아카이브 내 멤버(.o 파일)들이 정의하는 심볼들을 맞춰봅니다.m이 U에 있는 심볼의 정의를 제공한다면, 그 멤버 m을 E에 추가하고 U와 D를 업데이트합니다.U에 있는 심볼을 해결할 수 없을 때까지 아카이브 내에서 반복됩니다.E에 포함되지 않은 나머지 멤버들은 그냥 버려집니다.U가 비어있지 않다면, 링커는 오류를 출력하고 종료합니다. 그렇지 않으면 E에 있는 목적 파일들을 병합하고 재배치하여 최종 실행 파일을 생성합니다.이 알고리즘 때문에 커맨드 라인에 라이브러리와 목적 파일의 순서를 어떻게 지정하는지가 매우 중요해집니다.
만약 심볼을 정의하는 라이브러리가 그 심볼을 참조하는 목적 파일보다 먼저 나오면, 참조가 해결되지 않아 링크 오류가 발생합니다.
# 잘못된 순서
linux> gcc -static ./libvector.a main2.c
... undefined reference to ‘addvec’
오류 원인: 링커가 libvector.a를 처리할 시점에는 U(미해결 심볼 집합)가 비어있습니다. 따라서 libvector.a의 어떤 멤버도 E에 추가되지 않고 그냥 넘어갑니다. 그 후에 main2.c를 처리하면서 addvec 심볼이 U에 추가되지만, 이미 libvector.a 스캔이 끝났기 때문에 addvec의 정의를 찾을 수 없게 됩니다.
linux> gcc [모든 .o 파일들] [모든 .a 라이브러리들]libx.a가 liby.a의 함수를 호출한다면: ... libx.a liby.a 순서여야 합니다.libx.a가 liby.a를 호출하고, liby.a가 다시 libx.a를 호출하는 경우, 라이브러리를 반복해서 명시해야 할 수 있습니다.linux> gcc foo.c libx.a liby.a libx.a여기서 RB Tree 정리 확인할 수 있다!