3238 단어
16 분
링커의 작동구조

링킹이란#

C++에서 실행 가능한 프로그램을 만들기 위해서는 일반적으로 다음과 같은 과정을 거친다.

  1. 전처리기 - 전처리 구문을 처리한다.
  2. 컴파일러 - 전처리된 소스 코드를 어셈블리어로 변환한다.
  3. 어셈블러 - 어셈블리어를 기계어로 바꾸어 재배치 가능한 바이너리 목적파일로 변환한다.
  4. 링커 - 필요한 시스템 목적파일들과 함께 실행 가능한 목적파일을 생성한다. (Windows에서는 exe)

이와 같은 과정에서 링커들은 재배치 가능한 목적 파일을 입력으로 받아 실행 가능한 목적 파일을 생성하는데 이것을 링킹이라 합니다.
입력인 재배치 가능한 목적파일들은 여러 섹션으로 나위어 있습니다. 링커는 실행 가능한 목적 파일, 실행 파일을 만들기 위해 두 가지 주요한 작업을 수행해야 합니다.

  1. 심볼 해석(symbol resolution) - 목적 파일들이 정의하고 참조한 심볼을 정확하게 하나의 심볼로 연결합니다.
  2. 재배치(resolution) - 하나의 심볼로 정의되면 그 심볼로 가는 모든 참조를 수정해서 한 메모리 위치로 특정짓습니다.

이러한 역할을 할 수 있는 목적파일(오브젝트 파일)은 재배치 가능하고(Relocatable), 내부에 코드(.text), 데이터(.rdata, .data) 섹션과 함께 심볼 테이블과 재배치 엔트리를 포함합니다.

목적파일은 형식에 따라 분류되며 OS마다 각자가 선택한 형식을 사용합니다. 현대의 x86-64 리눅스와 유닉스 시스템은 ELF(Executable and Linkable Format), Mac OS-X는 Mach-O 포맷, 윈도우는 PE(Portable Executable) 포맷을 사용합니다.

윈도우 PE#

링킹 과정#

윈도우 PE에서 링킹은 여러 오브젝트 파일과 정적 라이브러리(.lib 확장자 파일)를 하나의 실행 파일로 결합합니다. 링커는 각 모듈의 심볼 테이블을 참고해, 함수와 전역 변수 등의 참조를 올바른 정의와 연결합니다. 이 과정에서 재배치 정보가 사용되어 코드 내 주소들은 최종 메모리 배치에 맞게 수정됩니다. 마지막으로 이 링킹을 다 하고나면 남는 결과물은 DOS 헤더, PE 헤더, 섹션 테이블 및 각 섹션이 포함된 Windows PE 형식의 실행파일(.exe 확장자 파일)입니다.

심볼 테이블#

먼저 심볼이란 간단히 말해 함수나 변수, 객체 등을 식별할 수 있는 식별자입니다. 각 심볼은 해당 코드, 데이터의 주소(또는 오프셋), 크기, 그리고 기타 속성 등의 정보를 포함합니다.

심볼 테이블은 오브젝트 파일 내부에 존재하며, 모듈 내의 정의된 모든 심볼과 다른 모듈에 정의된 것을 참조하는 외부 심볼 정보를 포함합니다.

심볼 해석 과정#

링킹이 시작되면 링커는 각 오브젝트 파일의 심볼 테이블을 읽습니다. 링커는 모듈 간의 참조된 심볼에 대해 동일한 이름의 정의된 심볼을 찾습니다. 만약 어떤 심볼이 한 모듈에서 정의되고, 다른 모듈에서는 단순 참조만 있다면, 이 참조는 정의된 심볼의 주소로 해결됩니다.

이 때 한 모듈에서 강한 정의(함수들과 초기화된 전역변수)를 제공하고 다른 모듈에서 약한 정의(비초기화된 전역변수)를 제공한 경우 강한 정의가 우선됩니다. 만약 서로 다른 두 모듈에서 강한 전역 심볼을 정의한 경우 링커는 일반적으로 충돌 오류를 발생시키지만 인라인 함수나, 템플릿같은 특정 상황에서는 링커가 하나의 정의를 선택하도록 규칙(우선 순위, COMDAT 섹션 1 사용등)을 적용합니다.

정적 라이브러리 링킹 및 참조 해석#

정적 라이브러리란 여러 오브젝트 파일을 아카이브한 파일 형식으로 링커는 실행 파일 내에 필요한 심볼이 참조될 때, 해당 심볼을 포함하는 오브젝트 파일만을 정적 라이브러리에서 추출해 삽입합니다. 오브젝트 파일 내 참조(외부 심볼)는 링커가 정적 라이브러리에서 해당 심볼을 가진 오브젝트 파일을 찾아 연결합니다. 이 때, 재배치 정보가 함께 적용되어, 라이브러리에서 가져온 코드의 주소가 실행 파일의 메모리 배치에 맞게 수정됩니다.

링커는 Optional Header에 지정된 Preferred Image Base(예: EXE는 보통 0x400000)를 기준으로, 각 섹션의 최종 가상 주소(Virtual Address, VA) 를 계산합니다.

예를 들어, .text 섹션은 Image Base + 섹션 정렬에 따른 오프셋에 배치되고, .data, .rdata 등 다른 섹션도 각각의 오프셋이 계산되어 배정됩니다. 이 때, 오브젝트 파일 내 상대 주소에 해당 섹션의 가상 주소를 더하여 절대 주소로 변환합니다.

재배치(Relocation)#

오브젝트 파일은 일반적으로 메모리 주소가 절대 주소 2가 아닌 상대 주소 3로 작성됩니다. 재배치 엔트리(Relocation entries)는 코드 내 주소가 수정되어야 할 위치와 수정 방법을 명시합니다.

링커는 모든 오브젝트 파일의 재배치 엔트리를 참고해, 각 심볼의 가상 주소를 계산합니다. 계산된 주소로 각 참조 위치를 수정하고, 필요시 PE파일의 베이스 리로케이션(Base Relocation) 섹션에 해당 정보를 기록합니다.

만약 실행 파일이 기본 주소대신 다른 주소에 로드된다면, OS 로더가 재배치 정보를 참고하여 매모리에 매핑된 주소들을 다시 조정합니다.

PE 파일 구조#

  1. DOS 헤더 및 DOS Stub:
    • 초기 DOS 실행 코드와 “This program cannot be run in DOS mode.” 메시지를 포함합니다.
  2. PE Signature:
    • “PE\0\0”라는 매직 넘버로 PE 파일임을 표시합니다.
  3. COFF File Header:
    • 머신 유형, 오브젝트 파일 수, 타임스탬프, 심볼 테이블 정보 등 기본 정보를 담습니다.
  4. Optional Header:
    • 실행에 필요한 중요한 정보(예: 진입점 주소, 이미지 베이스, 섹션 정렬, 데이터 디렉토리 정보)를 포함합니다.
  5. 섹션 테이블(Section Table):
    • 각 섹션(.text, .data, .rdata, .rsrc 등)의 가상 주소, 파일 내 오프셋, 크기 등을 기록합니다.
  6. 섹션 데이터:
    • 실제 코드, 데이터, 리소스 등이 담긴 섹션들이 위치합니다.
  7. 데이터 디렉토리:
    • Import Table, Export Table, 리로케이션 정보, 디버그 정보 등 추가 데이터에 대한 포인터들을 포함합니다.

PE 파일 로딩 과정#

  1. PE 헤더 파싱 및 메모리 매핑:
    • OS 로더는 PE 헤더를 읽어, 실행 파일의 섹션들을 적절한 메모리 영역(가상 메모리)에 매핑합니다.
  2. 재배치 적용:
    • 지정된 베이스 주소로 매핑되지만, 만약 충돌이 발생하면 재배치 정보를 사용하여 코드와 데이터의 주소들을 수정합니다.
  3. Import Table 처리:
    • 실행 파일이 참조하는 DLL 목록과 각 DLL에서 필요한 함수의 주소 정보를 확인하여, 각 DLL을 메모리에 로드합니다. Import Address Table(IAT)에 각 DLL의 함수 주소를 채워 넣습니다.
  4. 실행 준비:
    • 초기화 루틴 수행 및 진입점(Entry Point)으로 제어가 전달되어 실행이 시작됩니다.

동적 링킹과 위치 독립 코드(PIC; Position Independent Code)#

동적 링킹에 필요한 윈도우의 .dll 파일 역시 Windows PE 파일 형식이며 DLL 내부 Export Table에 내보낼 함수와 변수 목록이 기재되어 있습니다.

동적 링킹 과정#

실행 파일은 내부 Import Table에 DLL의 함수와 데이터에 대한 참조를 기록합니다. OS 로더는 실행시 Import Table 기반으로 DLL을 로드한 후, 각 함수에 실제 메모리 주소를 IAT(Import Address Table) 4에 채웁니다.

응용 프로그램은 LoadLibrary 함수를 통해 DLL을 로드하고, GetProcAddress로 특정 심볼의 주소를 가져와 호출할 수 있습니다.

위치 독립 코드#

PIC는 코드가 고정된 주소에 의존하지 않고, 실행시 실제 메모리 배치에 맞게 상대 주소로 참조를 이루어지게 도와주는 것입니다.

DLL은 기본적으로 PIC 형태로 작성되어, 만약 기본 주소 충돌이 발생하면 재배치를 최소화하거나, 재배치 엔트리의 수정 없이도 올바르게 동작할 수 있도록 설계됩니다. 보통, PIC를 위해 레지스터 기반의 상대 주소 계산 또는 글로벌 오프셋 테이블(GOT) 방식을 사용합니다.

Windows에서는 DLL이 미리 지정된 Preferred Base Address를 가지지만, 충돌 시 OS 로더가 재배치를 수행합니다. 최신 컴파일러는 PIC를 최대한 활용하여 재배치 비용을 낮추도록 최적화합니다.

GOT#

GOT는 PLT(Procedure Linkage Table) 5가 참조하는 테이블로 외부 프로시저들의 실제 메모리 주소를 저장합니다.
동적 링킹을 통해 프로그램 시작 시 주소가 결정됩니다.

GOT를 이용하면 외부 라이브러리 함수 호출 시 PLT가 GOT를 참조하여 해당 함수의 실제 주소로 점프할 수 있습니다.

프로그램을 처음 실행하면 GOT 엔트리가 초기화되어 있지 않고 함수가 처음 호출될 때 동적 링커가 실제 주소를 GOT에 기록합니다.

어셈블리 단에서의 흐름#

개발자는 어셈블리 언어(또는 고급 언어에서 생성된 어셈블리 코드)를 통해 함수, 변수 등을 정의하며, 각 항목에 대해 레이블을 사용합니다. 예를 들어, 함수 시작 부분에는 func_label: 같은 레이블이 기록되고, 이 레이블은 심볼 테이블에 정의됩니다.

함수 호출 또는 데이터 접근 시, 어셈블리 명령어 내에 심볼(레이블) 이름이 사용됩니다. 만약 호출 대상이 다른 모듈에 있다면, 해당 명령어는 외부 심볼로 기록되고, 재배치 엔트리에 포함됩니다. 링커는 이 정보를 기반으로 실제 메모리 주소로 치환합니다.

최종적으로 어셈블리 코드로 작성된 명령어들은 .text 섹션에, 초기화된 데이터는 .data, 읽기 전용 데이터는 .rdata 섹션에 위치하게 됩니다. 각 섹션은 PE 파일의 섹션 테이블에 의해 메모리 내의 위치와 크기가 결정되며, OS 로더에 의해 적절히 매핑됩니다. 재배치 엔트리들은 해당 섹션 내에서 수정되어야 할 주소들을 지정하고, 링커 및 로더가 이를 참조하여 주소를 올바르게 수정합니다.

참고#

MS_PE_설명서
Randel E Bryant, David R O’Hallaron의 컴퓨터 시스템

Footnotes#

  1. COMDAT 섹션은 섹션 헤더 특성 필드에 플래그가 설정되어있고 해당 플래그에 따라 어떻게 선택할지 판단합니다.

  2. 메모리에서 특정 위치를 직접적으로 가르키는 고유 주소로 물리적 메모리의 실제 위치를 나타냅니다.

  3. 기준점을 기준으로 계산한 상대적인 주소로, 특정 기준점에서 얼마나 떨어져 있는지를 나타냅니다.

  4. IAT는 실행 파일들이 사용하는 외부 함수의 주소를 저장하는 것입니다. PE 파일 형식의 일부로 디스크에선 의미가 없고 메모리에 로드되었을 때 실제 함수 주소가 채워지며 의미가 있게 됩니다.

  5. PLT는 외부 프로시저를 연결해주는 테이블로 다른 라이브러리에 있는 함수를 호출하게 해줍니다. 이것은 프로그램 텍스트 세션에 위치합니다.