링킹(Linking)이란 코드와 데이터의 여러 조각을 합침으로써 메모리에 복사해 바로 실행할 수 있는 하나의 파일로 만드는 과정이다. 이 과정은 compile time에, load time (loader에 의해 프로그램이 메모리로 옮겨질 때), 또는 심지어는 run time에 애플리케이션 프로그램에 의해 일어날 수도 있다. 현대 컴퓨터 시스템에서 링킹은 주로 링커(linker)에 의해서 수행된다.
분할 컴파일(separate compilation)과 같은 개념은 전적으로 링커가 있기에 가능한 방법론이다. 링커가 없었더라면, 커다란 프로그램에서 모듈 하나에 조그만 변화만 있어도 프로그램 전체를 재컴파일해야 했을 것이다.
컴파일러 드라이버란 전처리기(preprocessor), 컴파일러, 어셈블러, 그리고 링커를 사용자의 요구사항에 따라서 차례대로 실행시켜주는 프로그램이다. 대표적으로 gcc
가 바로 컴파일러 드라이버에 해당한다.
예를 들어서, main.c
가 sum.c
에서 정의된 함수 sum()
을 참조(reference)하는 상황을 가정해보자. 터미널에서
gcc -Og -o prog main.c sum.c
라고 입력하면, gcc
는 다음의 네 과정을 거쳐서 executable object를 생성한다.
1. cpp [other arguments] main.c /tmp/main.i
와 같이 전처리기 cpp
(C Preprocessor)를 호출해 main.i
파일을 만든다.
2. cc1 /tmp/main.i -Og [other arguments] -o tmp/main.s
로 컴파일러 cc1
을 실행시켜서 어셈블리 파일 main.s
를 작성한다.
3. as [other arguments] -o /tmp/main.o /tmp/main.s
로 어셈블러 as
를 호출해 어셈블리 파일 main.s
를 relocatable object file main.o
로 번역한다.
4. sum.c
에 대해서도 위 1~3의 과정이 일어나 relocatable object file sum.o
가 있을 것이다. 이 두개를 링커 ld
가 링킹해준다. ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o
이로써 바로 실행이 가능한 executable object file prog
가 만들어지게 된다. 이러한 네 가지 과정을 간편하게 할 수 있도록 묶어주는 프로그램이 컴파일러 드라이버이다.
뒤에서 살펴보겠지만 링킹에는 정적 링킹(static linking)과 동적 링킹(dynamic linking)이 있다. 참고로 동적 링킹이 훨씬 복잡하고 이해하기 까다로운 편이다.
우선 정적 링커란 relocatable object들을 입력으로 받아서 executable object를 생성해주는 프로그램을 말한다. 정적 링커가 하는 일을 설명하려면 먼저 relocatable object file의 구조에 대해 알아야 하는데, 우선은 그냥 code와 data가 섹션별로 잘 정리되어 있으며 linking에 필요한 정보들이 포함되어 있다는 정도로만 이해하면 될 것 같다.
정적 링킹은 두 단계로 나뉘어 일어난다.
1. symbol resolution: 코드에서 어떤 변수나 함수를 참조한 부분을 모두 찾아서 그 정의를 찾아 묶어주는 과정이다. 이때 변수와 함수는 cnt
나func1
과 같은 이름으로 참조하므로 symbol이라고 부른다.
2. relocation: 컴파일러와 어셈블러가 만든 code/data section들은 주소가 0부터 시작하게 되어 있다. 링커는 이들을 재배치하여 실행가능한 코드로 만들어야 한다. 재배치가 완료되면 symbol을 정의하는 코드들은 각자의 고유한 주소를 갖게 된다. 코드에서 symbol을 참조한 부분을 그 정의의 주소를 참조하는 것으로 바꿔준다.
오브젝트 파일에는 세 가지 종류가 있다.
컴파일러와 어셈블러가 relocatable object를 만들면, 링커는 이들을 이어붙여 executable object file을 만든다. 오브젝트 파일의 형식은 시스템마다 다른데, Linux는 Executable and Linkable Format(ELF)라는 형식을 사용한다. 윈도우에서는 portable executable(PE), Mac에서는 mach-o 형식을 사용한다.
Relocatable object file은 주소 0에서부터 차례대로 다음의 섹션들로 구성되어 있다.
먼저 ELF header는 word size와 byte ordering(little endian, big endian) 등의 규격을 명시한다. 링커가 오브젝트 파일을 parsing하고 해석할 때 필요한 정보들이다. 맨 마지막에는 section header file이 오는데, 이는 각 section의 위치와 크기에 대한 정보들을 담고 있다. 두 header 사이에 오는 섹션들을 간단히 설명하면 다음과 같다.
.text
: 프로그램의 기계어 코드.rodata
: read-only data. printf의 format string (%d %d %s
와 같은 것), switch의 jump table과 같은 부분들..data
: 초기화된 global 변수와 static 변수.bss
: 초기화되지 않았거나 0으로 초기화된 global/static 변수들로, 실제 공간을 차지하지는 않는 일종의 placeholder.symtab
: 함수와 global 변수에 대한 symbol table.rel.text
: rel은 relocation을 의미하며, 링커가 오브젝트들을 합치는 작업을 수행할 때 수정되어야 할 참조 위치들의 목록. 외부 함수나 global variable을 참조하는 코드는 모두 수정이 필요함.rel.data
: 해당 파일의 모듈이 참조하거나 정의하는 global 변수들의 relocation 정보로, 다른 global 변수/함수의 주소를 저장하는 global 변수들이 여기에 해당됨.debug
: 디버깅을 위한 옵션을 줄 때 생성되는 부분.line
: 원본 C 파일과 .text
의 기계어 코드의 줄 번호 사이의 매핑.strtab
: string table. .symtab
의 심볼 이름과 section header의 섹션 이름을 여기에 저장이 장에서 global과 static과 같은 용어가 계속 나오게 된다. 여기에서 global이라 함은 local variable(함수나 코드 블럭 내에서만 참조할 수 있는 변수)의 반댓말인 전역변수(global variable)와는 조금 다른 개념으로, 모든 파일에서 참조할 수 있는 변수를 의미한다. 반대로 static variable은 해당 파일에서만 참조할 수 있는 변수이다. 혼동을 줄이기 위해서 앞으로 static variable의 반댓말로는 global variable이 아닌 nonstatic variable으로 사용하겠다.
모든 relocatable object file은 자신이 정의하거나 참조하는 심볼에 대한 정보를 심볼 테이블에 저장해둔다. 어떤 relocatable object file 이 있을 때, 을 처리하는 링커의 입장에서 심볼은 세 가지로 나눌 수 있다.
1. 이 정의하고 다른 모듈이 참조하는 글로벌 심볼: nonstatic인 function/global variable이 여기 해당한다.
2. 다른 모듈이 정의하고 이 참조하는 글로벌 심볼: "externals"라 부르며, 다른 모듈에서 정의한 nonstatic인 function/global variable이 여기 해당한다.
3. 에서 정의하고 에서만 참조하는 로컬 심볼: static function이나 static으로 정의한 global/local variable.
한편, static인 local variable들은 runtime stack이 아닌 .data
나 .bss
(initialize 여부에 따라)에서 관리한다.
심볼 테이블은 다음과 같이 구성되어 있다.
name
은 .strtab
(string table)에서 symbol의 이름을 담은 string의 위치 offset을 저장한다. value
는 심볼의 주소이며section
은 section header table의 index를 저장하는 변수인데, 특수한 케이스 3가지가 있다.ABS
: relocate되면 안되는 심볼UNDEF
: undefined symbol. 다른 모듈에서 정의된 심볼을 현재의 모듈에서 참조하는 경우 컴파일러는 해당 심볼의 정의를 찾을 수 없기 때문에 이렇게 처리된다.COMMON
: 초기화가 되지 않은 object인데 아직 할당이 되지 않은 경우정리하자면, 각 variable의 관리는 다음과 같이 이루어진다.
.data
에 저장된 후 linker가 심볼 테이블을 이용하여 관리section
값을 COMMON
으로 설정해 저장 (나중에 나오겠지만 이는 약한 심볼이기 때문이다).bss
에 저장(“better save storage”로 외우자).data
에 저장. .bss
에 저장COMMON과 .bss
의 저 애매한 구분에 대해서는 다음 절에 그 이유가 나온다.
링커는 심볼의 참조마다 그 유일한 정의를 연결시켜주어야 한다. 이를 symbol resolution이라고 한다. 여기서 심볼의 정의는 input으로 들어오는 relocatable object file의 symbol table에서 찾게 된다.
앞에서 초기화되지 않은 global symbol만 symbol table에 COMMON으로 정의했던 이유가 바로 이것이다. 초기화되지 않은 global symbol은 약한 심볼이므로, 다른 모듈에 동명의 강한 심볼이 있을지도 모르니 일단 판단을 보류하고 링커에 맡기기 위해 symbol table에 (section=COMMON
으로) entry를 만들어주는 것이다. 반면 0으로 초기화되면 강한 심볼이 되므로 그냥 .bss
에 저장해줄 수 있게 된다.
컴파일러는 연관된 object module들을 정적 라이브러리(Static Library)라는 하나의 파일로 합치는 기능을 제공한다. 자주 사용되는 함수들은 그 object file을 하나하나 찾아서 링커에 넣어주기 귀찮으므로, 전부 합쳐서 하나의 object file로 합쳐놓고 그걸 링킹하는걸로 퉁치는 셈이다. 즉, 정적 라이브러리는 서로 연관 있는 함수들의 object file을 하나로 합쳐놓아, 링커에 입력으로 제공할 수 있도록 만들어놓은 파일이다. 리눅스에서 이는 archive(.a
) 형식으로 저장한다.
addvec.c
와 multvec.c
를 합쳐서 정적 라이브러리를 만들고 싶다면, 컴파일러로 둘을 각각 .o
파일로 만들어준 후Ar rcs libvector.a addvec.o multvec.o
를 실행하면 libvector.a
라는 정적 라이브러리가 만들어진다.
링커는 정적 라이브러리를 링킹할 때 다음과 같은 알고리즘을 사용한다.
먼저 집합 , , 를 준비한다. 는 합쳐줄 object file들의 집합, 는 resolve되지 않은 symbol들의 집합, 는 이전에 살펴본 파일들에서 이미 정의된 심볼들의 집합이다. 처음에는 를 모두 공집합으로 초기화한다.
링커에 입력으로 들어온 파일 들을 순서대로 살펴보면서,
모든 입력 파일들을 살펴본 후에도 이면, 즉 unresolved reference가 있다면 에러를 발생시킨다.
이러한 알고리즘 때문에 링커에 입력 파일을 전달해줄 때는 순서를 잘 생각해야 한다. 정적 라이브러리는 커맨드라인의 맨 마지막에 등장해야 하며, 라이브러리간의 의존성이 있다면 항상 각 심볼의 참조가 속한 파일 다음에 정의가 포함된 파일이 나오도록 순서를 잘 배치해야 한다.
위의 과정을 거쳐 Symbol Resolution 단계가 끝나면, 심볼에 대한 각각의 참조가 모두 정확히 하나의 정의와 연결되어 있는 상태이다. Relocation은 이 상태에서 시작하며, 두 단계로 구성된다.
Relocating section and symbol definitions
링커는 입력 파일을 순회하면서, 같은 타입의 섹션끼리는 합쳐서 executable object의 각 섹션을 구성한다. Relocatable object file A와 B의 .data
를 합쳐서 출력 executable object file의 .data
를 구성하는 식이다. 이 과정이 끝나면 모든 instruction과 global variable이 순서대로 배치되며, 다르게 말하면 이들에게 고유한 runtime address가 부여된다고 할 수 있다.
Relocating symbol references within sections
지금까지는 각 symbol들의 runtime에서의 주소를 가지게 될 지 알 수 없었으나, 이제는 runtime address를 알게 되었으므로 이제 code와 data 섹션에서 발생하는 참조들을 runtime address를 가리키도록 수정해주어야 한다. 이때 relocation entry라는 자료구조가 사용된다.
어셈블러가 object 모듈을 만들 때에는, 각 code와 data가 relocation을 거친 후에 어떤 주소를 가지게 될 지 알 수 없다. 따라서 relocation entry라는 것을 만들어 임시로 조치를 해준다. 앞에서 나온 .rel.text
와 .rel.data
라는 섹션이 이를 저장하는 공간이다.
Linux의 ELF에서, relocation entry는 다음 항목들을 포함한다.
R_X86_64_PC32
(PC-relative 32b address)와 R_X86_64_32
(absolute 32b address)만 다룸.%rip
+ (상대주소)로 표현되는데 %rip
는 현재 instruction이 아닌, 다음 instruction을 가리키고 있기 때문에 이를 보정해주는 역할을 한다.링커는 relocation entry의 항목들을 보고, type
에 따라 절대주소 혹은 상대주소로 각 참조의 주소들을 수정한다.
앞서 object file에는 링커의 입력으로 들어오는 relocatable object file과, 출력으로 나오는 executable object file이 있다고 했다. 이 또한 relocatable object file과 구조가 크게 다르지 않으나, 이미 relocation이 완료되었으므로 .rel
섹션이 없고 ELF header에 entry point, 즉 프로그램을 시작하는 지점이 명시되어 있다는 점에 차이가 있다. 또한, initialization code에 의해 호출되는 __init
이라는 작은 함수가 .init
에 위치한다.
Executable object file은 memory에 그대로 load되기 알맞은 형식으로, 코드의 여러 (연속적인) 덩어리가 그대로 virtual memory의 연속적인 덩어리들로 매핑된다. 이때 매핑은 program header에 표로 명시된다. (p. 732 참조)
off
를 가졌었는데 링커에 의해 vaddr
라는 가상메모리 위치로 매핑되었다면,vaddr mod align = off mod align
align
은 2의 거듭제곱으로, 정해져 있는 수이다. 즉, executable object file의 각 코드 덩어리는 2의 거듭제곱수 align
의 배수만큼 이동해 메모리에 로드된다. 이는 9장에 나올 가상메모리에서, 메모리가 2의 거듭제곱 크기의 청크로 구성되기 때문으로 효율적인 로드를 위한 규칙이다.Linux shell에서 ./prog
라고 입력한다고 가정하자. 키보드로 엔터를 누르는 순간, 로더(loader)가 실행된다. (사실 Shell 뿐만 아니라 모든 리눅스 프로그램은 execve
라는 시스템 함수를 사용하여 프로그램을 로드해올 수 있는데, 이는 8장에서 설명한다.) 로더는 executable object file에서 코드와 데이터를 메모리로 복사해와서 entry point부터 차례대로 프로그램을 실행하는 역할을 한다.
로딩이 완료되면 가상메모리의 배치는 위와 같은 그림이 된다.
메모리가 로딩된 후에는 entry point에 진입한다. 이는 모든 프로그램에서 _start
라는 함수의 위치로 동일하다. _start
는 __libc_start_main
을 실행하는데, 이는 실행 환경을 setup하고 사용자가 정의한 main 함수를 실행하며 그 반환값을 처리하는 등의 역할을 한다.
앞에서 살펴본 정적 라이브러리는 몇 가지 문제점을 가지고 있다.
printf
, scanf
처럼 거의 모든 C 프로그램이 사용하는 함수들의 경우에는, 모든 프로그램 내에 그 복사본이 존재하게 된다. 이는 비효율적이다.이를 해결하기 위해 존재하는 것이 동적 라이브러리(shared library)이다. 동적 라이브러리는 runtime 또는 load time에 임의의 메모리 주소에 로드되어 프로그램과 링킹시킬 수 있는 라이브러리이다. 이 때,
.so
(shared object) 확장자로 저장되며, 윈도우에서는 .so
대신 .dll
(dynamic link library)를 사용한다.공유 라이브러리를 “공유”라고 이름붙인 데에는 두 가지 이유가 있다.
1. 먼저, 하나의 파일 시스템에는 동일한 .so
파일이 하나만 존재한다. 해당 파일을 참조하는 모든 executable object file은 이를 공유한다.
2. 공유 라이브러리가 메모리에 로드되면, 그 .text
섹션은 실행중인 모든 프로그램이 공유할 수 있다.
공유 라이브러리를 만들어 링킹하려면 다음과 같이 커맨드라인 명령어를 입력하면 된다.
gcc -shared -fpic -o libvector.so addvec.c multvec.c
gcc -o prog main.c ./libvector.so
-fpic
옵션은 후술할 position-independent code(PIC)로 컴파일하라는 명령이다.libvector.so
를 main.c와 링킹시키는 부분이다. 문법은 정적 라이브러리와 동일하나 이 경우 libvector.so
가 executable object file로 복사되지 않고, .rel
과 심볼 테이블만을 일부 복사해서 load time에 resolve될 수 있도록 내버려둔다.Load time에 prog
를 실행하면 일반적인 로딩 과정을 거치다가, 로더가 .interp
섹션에 동적 링커의 경로가 있는 것을 발견하여 동적 링커에 control을 넘겨준다. 동적 링커는 다음 작업으로 링킹을 진행시킨다.
libc.so
와 libvector.so
의 .text
와 .data
를 메모리로 불러온다prog
의 참조를 relocation시킨다.앞 절에서는 공유 라이브러리를 load 후, execute 전에 링킹하였다. 한편, 공유라이브러리는 프로그램이 실행되는 도중에도 불러와 링킹할 수 있다.
Linux는 dlfcn.h
에서 동적 링킹을 위한
void *dlopen(const char *filename, int flag)
라는 인터페이스를 제공한다. 이를 사용하여 runtime에서 링킹을 수행할 수 있다(p.739 참고).
앞서, 공유 라이브러리는 여러 프로세스가 한 copy의 프로그램을 공유한다고 했다. 이것이 가능하게 하기 위한 방법으로, 우선 각 라이브러리마다 약속된 위치의 주소에만 로드되도록 하는 방법을 생각할 수 있을 것이다. 그런데 이는 당연히 매우 비효율적이다! 따라서 그 대신, 메모리 상의 아무 위치에나 로드되어도 실행이 가능하도록 코드를 바꿔주는 방법이 있는데, 이를 위치독립 코드(Position-Independent Code, PIC)라고 한다.
.data
와 .code
세그먼트의 거리, 즉 주소의 차이가 항상 같다는 점을 이용한다.이를 위해 .data
섹션의 맨 앞부분에 Global Offset Table(GOT)라는 표를 작성해, 각 global variable마다 8바이트씩을 할당해 각 변수와 함수의 주소를 저장한다. 각 변수/함수를 참조할 때에는, 참조하는 instruction에서 GOT까지의 거리가 일정하다는 성질을 사용해 %rip + 상수
로 GOT의 항목에 접근한다. GOT의 해당 항목에는 접근하고자 하는 변수/함수의 주소가 있으므로, 이를 다시 dereference해 변수/함수에 접근할 수 있다.
공유 라이브러리에 정의된 함수를 호출한다면, 컴파일 시간에서는 이 함수가 어디 로드될 것인지 알 수 있는 방법이 없다. 단순하게 생각한다면, 해당 함수를 참조한 곳의 목록을 기록해둔 후, 함수가 로드된 후에 동적 링크가 각 참조마다 주소를 바꿔 써주면 되겠지만, 이러면 PIC가 아니게 된다. (로드 후에 relocation이 불필요해야 PIC라는 점을 생각하자)
이 때문에 lazy binding이라는 개념이 사용된다. Lazy binding의 핵심은 각 함수의 binding을 첫 호출때까지 미뤄두고 있는 것이다. Lazy binding을 사용하면 사용되지 않는 함수들까지 load time에 relocate할 필요가 없어진다.
Lazy binding은 GOT와 .text
(코드) 세그먼트의 일부인 PLT(procedure linkage table)의 상호작용으로 구현된다. (p. 742 참고)
프로그램의 machine code가 공유 라이브러리의 addvec()
을 호출하는 상황을 가정한다.
addvec()
의 주소를 찾아 호출하는 것이 아니라, 이에 대응되는 PLT[2]
를 호출하도록 되어 있다. PLT[2]
를 호출(즉 현재 주소를 push하고 PLT[2]
의 주소로 점프)한다.PLT[2]
의 첫 줄은 GOT[4]
에 저장된 주소로 점프하도록 되어 있다. GOT[4]
를 보면, 초기 상태에는 그냥 PLT[2]
의 두 번째 줄의 주소가 저장되어 있다. 따라서 PC가 그냥 PLT[2]
의 다음 줄로 넘어간다.pushq $0x1
으로 addvec()
의 id인 1이 스택에 푸시된다.addvec()
의 id(1)가 저장되어 있는 상태이다. 이 때 동적 링커가 실행되면 addvec()
에 대응되는 항목인 GOT[4]
가 실제 addvec()
의 주소로 바뀐다. 다음 번의 호출을 위해 준비가 완료되었다!PLT[2]
를 호출하면 jmpq *GOT[4]
를 하자마자 addvec()
이 바로 실행된다.개인적으로 동적 링킹은 CSAPP에서 가장 어려운 부분 중 하나인 것 같다(이 게시물의 설명도 완벽하지 않을 수 있다) 책을 여러 번 반복해서 읽고 실제 코드를 보며 복습하는 것이 필요할 것 같다.
Library interpositioning은 공유 라이브러리의 함수를 가로채어 사용자가 정의한 다른 함수가 호출되게 하는 기능이다. 공유 라이브러리를 사용하는 만큼 이 또한 compile time, link time, run time 중 아무 때에나 가능하다.
Library interpositioning의 전형적인 사용법 중 하나는 기존의 함수에 print나 counter 등의 기능을 더한 “wrapper function”을 만들어 대신 호출되게 하는 것이다. Wrapper function을 사용하면 함수의 호출 시간이나 횟수 등을 알 수 있어 디버깅에 용이하다.