[CS:APP] Chapter 7. Linking

l_nerd6·2023년 6월 11일
0

CS:APP 정리

목록 보기
2/3

링킹(Linking)이란 코드와 데이터의 여러 조각을 합침으로써 메모리에 복사해 바로 실행할 수 있는 하나의 파일로 만드는 과정이다. 이 과정은 compile time에, load time (loader에 의해 프로그램이 메모리로 옮겨질 때), 또는 심지어는 run time에 애플리케이션 프로그램에 의해 일어날 수도 있다. 현대 컴퓨터 시스템에서 링킹은 주로 링커(linker)에 의해서 수행된다.
분할 컴파일(separate compilation)과 같은 개념은 전적으로 링커가 있기에 가능한 방법론이다. 링커가 없었더라면, 커다란 프로그램에서 모듈 하나에 조그만 변화만 있어도 프로그램 전체를 재컴파일해야 했을 것이다.

7.1. 컴파일러 드라이버

컴파일러 드라이버란 전처리기(preprocessor), 컴파일러, 어셈블러, 그리고 링커를 사용자의 요구사항에 따라서 차례대로 실행시켜주는 프로그램이다. 대표적으로 gcc가 바로 컴파일러 드라이버에 해당한다.
예를 들어서, main.csum.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.srelocatable 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가 만들어지게 된다. 이러한 네 가지 과정을 간편하게 할 수 있도록 묶어주는 프로그램이 컴파일러 드라이버이다.

7.2. 정적 링킹

뒤에서 살펴보겠지만 링킹에는 정적 링킹(static linking)동적 링킹(dynamic linking)이 있다. 참고로 동적 링킹이 훨씬 복잡하고 이해하기 까다로운 편이다.

우선 정적 링커relocatable object들을 입력으로 받아서 executable object를 생성해주는 프로그램을 말한다. 정적 링커가 하는 일을 설명하려면 먼저 relocatable object file의 구조에 대해 알아야 하는데, 우선은 그냥 code와 data가 섹션별로 잘 정리되어 있으며 linking에 필요한 정보들이 포함되어 있다는 정도로만 이해하면 될 것 같다.

정적 링킹은 두 단계로 나뉘어 일어난다.
1. symbol resolution: 코드에서 어떤 변수나 함수를 참조한 부분을 모두 찾아서 그 정의를 찾아 묶어주는 과정이다. 이때 변수와 함수는 cntfunc1과 같은 이름으로 참조하므로 symbol이라고 부른다.
2. relocation: 컴파일러와 어셈블러가 만든 code/data section들은 주소가 0부터 시작하게 되어 있다. 링커는 이들을 재배치하여 실행가능한 코드로 만들어야 한다. 재배치가 완료되면 symbol을 정의하는 코드들은 각자의 고유한 주소를 갖게 된다. 코드에서 symbol을 참조한 부분을 그 정의의 주소를 참조하는 것으로 바꿔준다.

7.3. 오브젝트 파일

오브젝트 파일에는 세 가지 종류가 있다.

  • Relocatable Object File: 다른 relocatable object file과 합쳐서 executable object file이 될 수 있도록 코드와 데이터를 가지고 있는 오브젝트 파일
  • Executable Object File: 메모리에 바로 복사해 실행시킬 수 있는 코드와 데이터를 가지고 있는 오브젝트 파일
  • Shared Object File: load time 또는 run time에 메모리에 로드시켜, "동적 링킹"을 시킬 수 있는 특별한 relocatable object file

컴파일러와 어셈블러가 relocatable object를 만들면, 링커는 이들을 이어붙여 executable object file을 만든다. 오브젝트 파일의 형식은 시스템마다 다른데, Linux는 Executable and Linkable Format(ELF)라는 형식을 사용한다. 윈도우에서는 portable executable(PE), Mac에서는 mach-o 형식을 사용한다.

7.4. Relocatable Object File

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으로 사용하겠다.

7.5. 심볼과 심볼 테이블

모든 relocatable object file은 자신이 정의하거나 참조하는 심볼에 대한 정보를 심볼 테이블에 저장해둔다. 어떤 relocatable object file mm이 있을 때, mm을 처리하는 링커의 입장에서 심볼은 세 가지로 나눌 수 있다.
1. mm이 정의하고 다른 모듈이 참조하는 글로벌 심볼: nonstatic인 function/global variable이 여기 해당한다.
2. 다른 모듈이 정의하고 mm이 참조하는 글로벌 심볼: "externals"라 부르며, 다른 모듈에서 정의한 nonstatic인 function/global variable이 여기 해당한다.
3. mm에서 정의하고 mm에서만 참조하는 로컬 심볼: static function이나 static으로 정의한 global/local variable.

한편, static인 local variable들은 runtime stack이 아닌 .data.bss(initialize 여부에 따라)에서 관리한다.

심볼 테이블은 다음과 같이 구성되어 있다.

  • name.strtab(string table)에서 symbol의 이름을 담은 string의 위치 offset을 저장한다.
  • value는 심볼의 주소이며
    • relocatable object file에서는 해당 심볼이 정의된 섹션의 시작으로부터의 상대주소를,
    • executable object file에서는 run-time의 절대주소를 저장한다
  • section은 section header table의 index를 저장하는 변수인데, 특수한 케이스 3가지가 있다.
    • ABS: relocate되면 안되는 심볼
    • UNDEF: undefined symbol. 다른 모듈에서 정의된 심볼을 현재의 모듈에서 참조하는 경우 컴파일러는 해당 심볼의 정의를 찾을 수 없기 때문에 이렇게 처리된다.
    • COMMON: 초기화가 되지 않은 object인데 아직 할당이 되지 않은 경우

정리하자면, 각 variable의 관리는 다음과 같이 이루어진다.

  • (nonstatic) global variable
    • 0이 아닌 값으로 초기화된 경우: .data에 저장된 후 linker가 심볼 테이블을 이용하여 관리
    • 초기화되지 않은 경우: 심볼 테이블에 section값을 COMMON으로 설정해 저장 (나중에 나오겠지만 이는 약한 심볼이기 때문이다)
    • 0으로 초기화된 경우: .bss에 저장(“better save storage”로 외우자)
  • (nonstatic) local variable
    • 당연하지만 runtime stack이 관리한다
  • static variable과 static function
    • nonzero로 초기화된 경우 .data에 저장.
    • 0으로 초기화되었거나 초기화되지 않은 경우 .bss에 저장

COMMON과 .bss의 저 애매한 구분에 대해서는 다음 절에 그 이유가 나온다.

7.6. Symbol Resolution

링커는 심볼의 참조마다 그 유일한 정의를 연결시켜주어야 한다. 이를 symbol resolution이라고 한다. 여기서 심볼의 정의는 input으로 들어오는 relocatable object file의 symbol table에서 찾게 된다.

  • local symbol의 경우 한 파일에 정의와 참조가 모두 존재하니 컴파일러가 알아서 처리해준다.
  • global symbol의 경우 조금 더 복잡하다. 컴파일러가 모듈을 처리할 때, 현재 모듈에 정의되지 않은 심볼을 발견하면 링커가 처리할 수 있도록 symbol table에 entry를 만들어준다. 이때 서로 다른 모듈에서 같은 이름의 global symbol을 정의할 수 있으니 문제가 되는데, GCC의 경우 다음의 방법을 사용한다.
    • 먼저 심볼을 강한(strong) 심볼과 약한(weak) 심볼으로 나누어 구분한다. 강한 심볼에는 함수, 그리고 초기화된 global variable이 들어가며 약한 심볼에는 초기화되지 않은 global variable이 속한다. 그리고 다음의 규칙을 적용한다.
    • 규칙 1: 여러 개의 강한 심볼이 같은 이름을 가져서는 안된다.
    • 규칙 2: 여러 개의 약한 심볼과 하나의 강한 심볼이 같은 이름을 가진다면, 강한 심볼을 택한다.
    • 규칙 3: 여러 개의 약한 심볼이 같은 이름을 가진다면, 그 중 아무거나 하나 택한다.
    • 약한 심볼은 초기화되지 않은 global variable이므로, 어차피 초기화된 값이 없으니 아무거나 택해도 된다는 것이 어렵지 않게 받아들여질 것이다!

앞에서 초기화되지 않은 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.cmultvec.c를 합쳐서 정적 라이브러리를 만들고 싶다면, 컴파일러로 둘을 각각 .o 파일로 만들어준 후
Ar rcs libvector.a addvec.o multvec.o

를 실행하면 libvector.a라는 정적 라이브러리가 만들어진다.

정적 라이브러리의 Resolution

링커는 정적 라이브러리를 링킹할 때 다음과 같은 알고리즘을 사용한다.

  1. 먼저 집합 EE, UU, DD를 준비한다. EE는 합쳐줄 object file들의 집합, UU는 resolve되지 않은 symbol들의 집합, DD는 이전에 살펴본 파일들에서 이미 정의된 심볼들의 집합이다. 처음에는 E,D,UE, D, U를 모두 공집합으로 초기화한다.

  2. 링커에 입력으로 들어온 파일 ff 들을 순서대로 살펴보면서,

    • ff가 object인 경우 EEff를 바로 추가하고, U,DU, D는 이에 맞춰서 적절하게 업데이트한다.
    • ff가 정적 라이브러리인 경우 라이브러리를 구성하는 오브젝트 파일들 mm을 하나씩 순회하면서, mmUU에 포함된(즉 정의를 찾아야하는) 심볼의 정의가 있는지 찾아본다. 만약 있다면 mm은 필요한 object이므로 EEmm을 추가한다. 또한 U,DU, D는 이에 맞춰서 적절하게 업데이트한다.
  3. 모든 입력 파일들을 살펴본 후에도 UϕU \ne \phi이면, 즉 unresolved reference가 있다면 에러를 발생시킨다.

이러한 알고리즘 때문에 링커에 입력 파일을 전달해줄 때는 순서를 잘 생각해야 한다. 정적 라이브러리는 커맨드라인의 맨 마지막에 등장해야 하며, 라이브러리간의 의존성이 있다면 항상 각 심볼의 참조가 속한 파일 다음에 정의가 포함된 파일이 나오도록 순서를 잘 배치해야 한다.

7.7. Relocation

위의 과정을 거쳐 Symbol Resolution 단계가 끝나면, 심볼에 대한 각각의 참조가 모두 정확히 하나의 정의와 연결되어 있는 상태이다. Relocation은 이 상태에서 시작하며, 두 단계로 구성된다.

  1. Relocating section and symbol definitions
    링커는 입력 파일을 순회하면서, 같은 타입의 섹션끼리는 합쳐서 executable object의 각 섹션을 구성한다. Relocatable object file A와 B의 .data를 합쳐서 출력 executable object file의 .data를 구성하는 식이다. 이 과정이 끝나면 모든 instruction과 global variable이 순서대로 배치되며, 다르게 말하면 이들에게 고유한 runtime address가 부여된다고 할 수 있다.

  2. Relocating symbol references within sections
    지금까지는 각 symbol들의 runtime에서의 주소를 가지게 될 지 알 수 없었으나, 이제는 runtime address를 알게 되었으므로 이제 code와 data 섹션에서 발생하는 참조들을 runtime address를 가리키도록 수정해주어야 한다. 이때 relocation entry라는 자료구조가 사용된다.

Relocation Entry

어셈블러가 object 모듈을 만들 때에는, 각 code와 data가 relocation을 거친 후에 어떤 주소를 가지게 될 지 알 수 없다. 따라서 relocation entry라는 것을 만들어 임시로 조치를 해준다. 앞에서 나온 .rel.text.rel.data라는 섹션이 이를 저장하는 공간이다.

Linux의 ELF에서, relocation entry는 다음 항목들을 포함한다.

  • offset: 수정해야 할 참조의 section offset, 즉 해당 코드가 속하는 섹션의 시작 주소로부터의 상대 주소
  • symbol: 가리켜야 하는 심볼의 symbol table index
  • type: 32가지가 존재하나 여기에서는 R_X86_64_PC32(PC-relative 32b address)와 R_X86_64_32(absolute 32b address)만 다룸.
  • append: type에 따라서 다른 값으로, 상대주소의 경우 %rip + (상대주소)로 표현되는데 %rip는 현재 instruction이 아닌, 다음 instruction을 가리키고 있기 때문에 이를 보정해주는 역할을 한다.

링커는 relocation entry의 항목들을 보고, type에 따라 절대주소 혹은 상대주소로 각 참조의 주소들을 수정한다.

7.8. Executable Object File

앞서 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 참조)

  • 여기서 mapping에는 alignment requirement라는 규칙이 있다. 각각의 코드(또는 데이터) 덩어리가 object file에서 offset off를 가졌었는데 링커에 의해 vaddr라는 가상메모리 위치로 매핑되었다면,
    vaddr mod align = off mod align
    이 성립한다. 이때 align은 2의 거듭제곱으로, 정해져 있는 수이다. 즉, executable object file의 각 코드 덩어리는 2의 거듭제곱수 align의 배수만큼 이동해 메모리에 로드된다. 이는 9장에 나올 가상메모리에서, 메모리가 2의 거듭제곱 크기의 청크로 구성되기 때문으로 효율적인 로드를 위한 규칙이다.

7.9. Executable Object File의 로딩

Linux shell에서 ./prog라고 입력한다고 가정하자. 키보드로 엔터를 누르는 순간, 로더(loader)가 실행된다. (사실 Shell 뿐만 아니라 모든 리눅스 프로그램은 execve라는 시스템 함수를 사용하여 프로그램을 로드해올 수 있는데, 이는 8장에서 설명한다.) 로더는 executable object file에서 코드와 데이터를 메모리로 복사해와서 entry point부터 차례대로 프로그램을 실행하는 역할을 한다.

로딩이 완료되면 가상메모리의 배치는 위와 같은 그림이 된다.

  • 실제로는 alignment requirement에 의해서 각 부분 사이에 갭이 존재하지만 이는 고려되어 있지 않다. 또, ASLR(address space layout randomization) 또한 고려되지 않은 그림이다.

메모리가 로딩된 후에는 entry point에 진입한다. 이는 모든 프로그램에서 _start라는 함수의 위치로 동일하다. _start__libc_start_main을 실행하는데, 이는 실행 환경을 setup하고 사용자가 정의한 main 함수를 실행하며 그 반환값을 처리하는 등의 역할을 한다.

7.10. 공유 라이브러리와 동적 링킹

앞에서 살펴본 정적 라이브러리는 몇 가지 문제점을 가지고 있다.

  • 라이브러리가 업데이트될 때마다 수동으로 링킹을 다시 실행해서 executable object를 다시 만들어주어야 한다.
  • printf, scanf처럼 거의 모든 C 프로그램이 사용하는 함수들의 경우에는, 모든 프로그램 내에 그 복사본이 존재하게 된다. 이는 비효율적이다.

이를 해결하기 위해 존재하는 것이 동적 라이브러리(shared library)이다. 동적 라이브러리는 runtime 또는 load time에 임의의 메모리 주소에 로드되어 프로그램과 링킹시킬 수 있는 라이브러리이다. 이 때,

  • 링킹시키는 과정을 동적 링킹(dynamic linking)이라 부르며
  • 동적 링커(dynamic linker)에 의해 수행된다.
  • 동적 라이브러리는 .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
  • 첫 번째 줄은 addvec.c와 multvec.c를 컴파일해 공유 라이브러리를 만드는 역할을 한다. -fpic 옵션은 후술할 position-independent code(PIC)로 컴파일하라는 명령이다.
  • 둘째 줄은 만들어진 공유 라이브러리 libvector.so를 main.c와 링킹시키는 부분이다. 문법은 정적 라이브러리와 동일하나 이 경우 libvector.so가 executable object file로 복사되지 않고, .rel과 심볼 테이블만을 일부 복사해서 load time에 resolve될 수 있도록 내버려둔다.

Load time에 prog를 실행하면 일반적인 로딩 과정을 거치다가, 로더가 .interp 섹션에 동적 링커의 경로가 있는 것을 발견하여 동적 링커에 control을 넘겨준다. 동적 링커는 다음 작업으로 링킹을 진행시킨다.

  • 기본 C 함수들을 담은 libc.solibvector.so.text.data를 메모리로 불러온다
  • 두 파일의 심볼에 대한 prog의 참조를 relocation시킨다.

7.11. 응용프로그램에서 공유 라이브러리 로딩/링킹하기

앞 절에서는 공유 라이브러리를 load 후, execute 전에 링킹하였다. 한편, 공유라이브러리는 프로그램이 실행되는 도중에도 불러와 링킹할 수 있다.

Linux는 dlfcn.h에서 동적 링킹을 위한

void *dlopen(const char *filename, int flag)

라는 인터페이스를 제공한다. 이를 사용하여 runtime에서 링킹을 수행할 수 있다(p.739 참고).

7.12. 위치독립 코드

앞서, 공유 라이브러리는 여러 프로세스가 한 copy의 프로그램을 공유한다고 했다. 이것이 가능하게 하기 위한 방법으로, 우선 각 라이브러리마다 약속된 위치의 주소에만 로드되도록 하는 방법을 생각할 수 있을 것이다. 그런데 이는 당연히 매우 비효율적이다! 따라서 그 대신, 메모리 상의 아무 위치에나 로드되어도 실행이 가능하도록 코드를 바꿔주는 방법이 있는데, 이를 위치독립 코드(Position-Independent Code, PIC)라고 한다.

PIC Data Segments

  • Global variable을 “PIC한” 방법으로 참조하기 위해서는 공유 라이브러이에서 .data.code 세그먼트의 거리, 즉 주소의 차이가 항상 같다는 점을 이용한다.

이를 위해 .data 섹션의 맨 앞부분에 Global Offset Table(GOT)라는 표를 작성해, 각 global variable마다 8바이트씩을 할당해 각 변수와 함수의 주소를 저장한다. 각 변수/함수를 참조할 때에는, 참조하는 instruction에서 GOT까지의 거리가 일정하다는 성질을 사용해 %rip + 상수로 GOT의 항목에 접근한다. GOT의 해당 항목에는 접근하고자 하는 변수/함수의 주소가 있으므로, 이를 다시 dereference해 변수/함수에 접근할 수 있다.

PIC Function Calls

공유 라이브러리에 정의된 함수를 호출한다면, 컴파일 시간에서는 이 함수가 어디 로드될 것인지 알 수 있는 방법이 없다. 단순하게 생각한다면, 해당 함수를 참조한 곳의 목록을 기록해둔 후, 함수가 로드된 후에 동적 링크가 각 참조마다 주소를 바꿔 써주면 되겠지만, 이러면 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()을 호출하는 상황을 가정한다.

  1. 첫 번째 호출
    1. 어셈블리 코드부터가 addvec()의 주소를 찾아 호출하는 것이 아니라, 이에 대응되는 PLT[2]를 호출하도록 되어 있다. PLT[2]를 호출(즉 현재 주소를 push하고 PLT[2]의 주소로 점프)한다.
    2. PLT[2]의 첫 줄은 GOT[4]에 저장된 주소로 점프하도록 되어 있다. GOT[4]를 보면, 초기 상태에는 그냥 PLT[2]의 두 번째 줄의 주소가 저장되어 있다. 따라서 PC가 그냥 PLT[2]의 다음 줄로 넘어간다.
    3. pushq $0x1으로 addvec()의 id인 1이 스택에 푸시된다.
    4. 이제 스택에는 relocation entry의 주소와 addvec()의 id(1)가 저장되어 있는 상태이다. 이 때 동적 링커가 실행되면 addvec()에 대응되는 항목인 GOT[4]가 실제 addvec()의 주소로 바뀐다. 다음 번의 호출을 위해 준비가 완료되었다!
  2. 두 번째 호출
    • 이제 두 번째 호출부터는 PLT[2]를 호출하면 jmpq *GOT[4]를 하자마자 addvec()이 바로 실행된다.

개인적으로 동적 링킹은 CSAPP에서 가장 어려운 부분 중 하나인 것 같다(이 게시물의 설명도 완벽하지 않을 수 있다) 책을 여러 번 반복해서 읽고 실제 코드를 보며 복습하는 것이 필요할 것 같다.

7.13. Library Interpositioning

Library interpositioning은 공유 라이브러리의 함수를 가로채어 사용자가 정의한 다른 함수가 호출되게 하는 기능이다. 공유 라이브러리를 사용하는 만큼 이 또한 compile time, link time, run time 중 아무 때에나 가능하다.
Library interpositioning의 전형적인 사용법 중 하나는 기존의 함수에 print나 counter 등의 기능을 더한 “wrapper function”을 만들어 대신 호출되게 하는 것이다. Wrapper function을 사용하면 함수의 호출 시간이나 횟수 등을 알 수 있어 디버깅에 용이하다.

0개의 댓글