링커 다루기 [CSAPP Chap 7]

우리가 C언어로 hello.c 같은 소스 코드를 작성하고 터미널에서 gcc -o hello hello.c 명령을 내리면, hello라는 실행 파일이 만들어진다.하지만 이 간단한 명령어 뒤에는 사실 네 가지 단계가 숨어있는데, 컴파일러 '드라이버'인 gcc가 이 모든 과정을 조율하는 역할을 한다.


서론

그 과정은 네 가지로 구성된다.

  1. 전처리 (Preprocessing): 전처리기(cpp)가 소스 코드(hello.c)를 읽어서 #include 같은 지시문을 처리한다. 예를 들어 #include <stdio.h>stdio.h 파일의 내용을 그대로 가져와 코드에 붙여넣는데, 그 결과로 확장자가 .i인 파일(hello.i)이 만들어진다.
  2. 컴파일 (Compilation): 컴파일러(cc1)가 전처리된 코드(hello.i)를 사람이 읽기 쉬운 어셈블리어(hello.s)로 번역한다. 이 단계에서 코드의 문법 오류 등을 대부분 잡아낸다.
  3. 어셈블 (Assembly): 어셈블러(as)가 어셈블리어(hello.s)를 기계가 직접 이해할 수 있는 이진 코드, 즉 목적 파일(Object File) (hello.o)로 변환한다. 하지만 이 파일은 아직 완전한 실행 파일은 아니다.
  4. 링킹 (Linking): 마지막으로 링커는 우리가 만든 목적 파일(hello.o)과, printf 함수처럼 이미 만들어져 있는 라이브러리 목적 파일을 하나로 합쳐서 최종 실행 파일(hello)을 완성한다.

이 일련의 과정에서 목적 파일이라는 중간 단계를 두는 이유는 분할 컴파일라는 명명하에 이루어진다. 정말 많은 파일을 컴파일 해서 만들어지는 프로그램을 고려해보면 :

  • 목적 파일이 없는 경우, 소스 파일의 오타를 수정하면 프로젝트 전체를 처음부터 다시 컴파일 해야 한다.
  • 목적 파일이 있다면, typo.c 를 하나만 다시 컴파일해서 typo.o를 새로 만들면 된다. 변경되지 않은 기존 목적 파일들과 링크만 하면 된다. 이 덕분에 빌드 시간의 극적 단축이 달성된다.

 

결국 링커의 존재는 이점 두 가지를 정의 할 수 있다.

  • 효율성 : 전체 프로젝트를 다시 컴파일 할 필요 없이, 변경된 부분만 다시 컴파일하여 시간을 절약한다.
  • 모듈성 : 여러 개발자가 각자 맡은 소스 파일을 독립적으로 작업하고 컴파일하여 목적 파일로 만들어 공유 할 수 있다.

컴퓨터의 모든 시스템은 트레이드 오프를 동반한다. 효율성과 모듈성을 이룰 수 있는 링커 조차도 트레이드 오프가 있다 :

printf 를 쓸 때, 그 코드는 어디에 있다가 프로그램에 합쳐질까? 크게 두 가지 방법이 있는데, 각각은 명확한 장단점을 가진다.

  • 정적 링킹 : printf 함수의 기계어 코드를 복사해서 실행 파일에 그대로 집어넣는 방식이다.
    • 장점
      • 실행 파일 하나에 필요한 모든 내용이 들어가 있기 때문에, 다른 시스템에서 실행하더라도 의존성 문제가 없다. 배포에 뒷수습이 필요하지 않다.
    • 단점
      • 여러 프로그램이 이런식으로 작성되어있는 와중에 모두 같은 식으로 정적 링킹이 되어있다면 디스크 용량을 더 점유하고, 또 램 점유율도 높아진다.
      • printf 의 중대한 결함으로 인해 수정이 필요하면, 여러 프로그램이 각자의 주체를 통해 수정되어야한다. 이는 유지보수에 대한 피로도를 높인다.
  • 동적 링킹 : 실행 파일에는 printf 를 호출하는 방법만 기록해둔다. 그리고 프로그램이 실제로 실행 될 때, OS가 시스템에 이미 존재하는 printf 코드와 연결시켜준다.
    • 장점
      • 정적 링킹에 대한 단점을 모두 극복하는데 의의가 있다. printf라는 공유 라이브러리 하에서 다 같이 이용한다. 디스크와 메모리 사용량이 유의미하게 줄어든다.
      • printf 의 문제가 있었고 그게 패치로 개선되면 프로그램 단위의 추가 패치를 수행 할 필요가 없다.
    • 단점
      • 의존성 지옥이라고 불리는 문제로, 제 컴에선 잘되는데용 ㅎㅎ 을 말한다.
        • 배포를 받아 실행 할 다른 PC는 사전에 라이브러리가 설치 되어있어야하고 또 요구하는 버전 이상(또는 동일)해야 한다는 점이다.
        • 프로그램을 하나 만들었는데 다른 PC에서 작동되지 않는 경우는 생각보다 흔하기 때문에, 이러한 의존성을 명시적으로 해결해주는게 중요하다.
        • 우리가 git에서 뭘 하나 받아와도 의존성 해결부터 하고, 게임을 설치 할 때 DirectX 부터 깔라고 하는게 다 여기서 출발하는 내용이다.

목적 파일

링커가 주로 다루는 목적 파일을 다루어보자.

목적 파일은 꽤 체계적 구조로 나뉜다. 또, 현대의 리눅스의 경우 ELF라는 표준 형식을 사용하고 있다. 이 파일의 내용을 나누는 단위는 섹션이다.

  • ELF 헤더 : 파일의 맨 처음에 위치하며, 이 파일이 어떤 종류의 파일인지, 어떤 아키텍처 용인지의 전반적인 정보를 가지고 있다.
  • .text : 컴파일 된 프로그램의 기계어 코드가 포함되어 있다.
  • .rodata : 읽기 전용 데이터가 저장되어 있다. printf("Hello") 에서 "Hello" 와 같은 문자열 상수가 대표적이다.
  • .data : 초기화된 전역 변수가 static 변수가 위치한다.
  • .bss : 초기화되지 않은 전역변수나 static 변수를 위한 공간이다. 이 섹션의 경우 실제 공간을 차지하지 않고 이만큼의 공간이 필요하다라는 정보만 가지고 있어 파일의 크기를 줄여준다.
  • .symtab : 링킹 과정의 핵심을 가리키는 심볼 테이블로, 이 파일이 정의하거나 참조하는 모든 함수와 전역 변수의 목록과 정보를 담고 있다.

 

main.c 를 컴파일한 main.o 가 있고, printf 함수가 들어있는 라이브러리 목적 파일이 있다고 상상해보자. 링커가 이 둘을 합치려면 main.o 의 심볼 테이블은 printf 에 대해 어떤 정보를 가지고 있어야 할까?

심볼 테이블은 printf 라는 외부 심볼을 사용하는데, 이후 연결이 필요하다 로 기록이 이루어진다. 한편 자신의 소스 파일에 대해서는 main 은 .text의 0xnnnn에 위치한다. 식으로 기록된다.

결국 심볼 테이블은 링커에게 두 가지 정보를 제공하기 위해 존재한다 :

  • 제공 목록 : 내가 다른 파일에게 제공 할 수 있는 함수나 변수를 말한다.
  • 요청 목록 : 내가 다른 파일로부터 받아야만 하는 함수나 변수를 말한다.

 

심볼 해석

심볼 해석은 링커가 하는 핵심적인 일 중 하나이다. 각 목적 파일의 요청 목록과 제공 목록을 연결해주는 역할을 한다.

만약 요청에 대응하는 제공을 찾지 못하면 한번쯤 봤을만한 undefined reference to 'your_defined_func' 을 확인 할 수 있다.

중요한 것은 요청 ← 에 대응 하는 → 제공 이다. 제공이 먼저 이루어지고, 요청이 이후에 들어오는 경우에도 오류가 나타나게 된다.

이 상황은 실제 목적 파일을 연결하는 커맨드에서도 뚜렷하다.

gcc main.o lib.o 라는 원본 형태의 커맨드를 생각해보자. 여기에 조건을 하나 달아본다. 오류가 날 수 있는 커맨드는 무엇일까?

  • main.ofunc 를 호출한다. 그리고 lib.ofunc 를 정의한다.
  1. gcc main.o lib.o -o my_prog
  2. gcc lib.o main.o -o my_prog → 답은 이거

정답은 드래그 ㄱㄱ.

 

 

 

해설

링커가 첫 번째 파일인 lib.o 를 본다. 이 때 링커의 요청 목록은 비어있다. 즉 func 가 필요하다는 것을 모른다. 그래서 제공한다는 것은 알지만 요청자가 없으니 대응시키지 않는다.

두 번째 파일인 main.o 를 본다. func 를 요청 목록에 추가했다.

모든 파일을 읽었는데 요청이 해결되지 못한 상황이 되었다. 다시 되돌아가서 찾아보는 경우는 없기 때문에, 오류가 발생한다.

빠르게 요약하면, 요청하는 파일과 제공하는 파일 중 앞에 두어야 할 것은 요청하는 파일이다.

 

재배치

앞 문단에서 이야기한 심볼 해석을 통해 링커는 main.o 가 호출하는 printf 가 어디에 있는지 찾아 낼 수 있었다. 하지만 이것은 이름표를 찾은 것이지, 그 함수의 코드가 어디에 위치하는지는 아직 모른다.

재배치는 이 주소 문제를 해결하는 과정이다. 링커는 다음과 같은 두 단계로 이 작업을 수행한다 :

  1. 섹션 합치기 : 여러 목적 파일에 흩어져 있던 같은 종류의 섹션들을 하나로 합친다. 예를 들어, main.o.text 섹션과 라이브러리의 .text 섹션을 합쳐서 거대한 단일 섹션을 만든다. .data 섹션도 마찬가지이다.
  2. 주소 확정 및 수정 : 합쳐진 섹션들에 실제 메모리 주소를 할당한다. 예를 들면 새로운 .text 섹션은 메모리 0x400500 번지부터 시작한다고 결정한다. 그리고 main 함수가 printf 를 호출하는 코드를 찾아서, printf 의 최종 주소를 계산하여 위치를 요구하는 부분에 채우게 된다. 이 과정이 재배치이다.

컴파일러가 목적 파일을 만드는 과정에서 재배치 항목을 표시하기 된다. 링커는 이 표시들을 따라가며 주소를 정확히 수정하게 된다.

링커는 심볼 해석과 재배치를 한다는 부분에서 중요한 주체이다.