안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

컴퓨터 구조 (Architecture)/CSAPP

[CSAPP] Linking

피그브라더 2020. 3. 22. 15:07

※ 링킹은 워낙 복잡한 과정이어서 CSAPP 서적에서도 아주 세부적인 내용까지는 설명하지 않고 있다. 그래서 분명 필자처럼 책을 다 읽고도 찝찝한 감정이 사라지지 않는 사람들이 있을 것이다. 그러한 사람들을 위해 링킹의 전 과정을 최대한 자세히 설명해보고자 한다(그래서 이번 포스팅은 조금 길다). 다만 책에 등장하지 않는 세부적인 내용은 필자 나름대로 이해한 것을 기준으로 설명하기 때문에 다소 부정확할 수도 있다는 점 감안해주기 바란다. (잘못된 내용 발견 시 댓글로 정정 부탁드립니다!)

 

1. Introduction

1-1. 링킹 (Linking)

링킹(Linking)이란 프로그램 코드 및 데이터의 조각들을 결합하여 메모리에 로드되어 실행될 수 있는 하나의 실행 파일을 만드는 과정을 의미한다. 이는 컴파일 타임에 수행될 수도 있고, 로드 타임(프로그램이 로더에 의해 실행되어 메모리에 로드될 때)에 수행될 수도 있으며, 혹은 애플리케이션 프로그램에 의해서 런타임에 수행될 수도 있다. 링커(Linker)는 링킹을 수행하는 프로그램을 의미한다.

 

링커는 각 모듈의 독립적인 컴파일을 가능하게 함으로써 소프트웨어 개발에 혁신적인 변화를 가져왔다. 커다란 프로그램을 하나의 소스 파일로 개발하는 것이 아니라, 관리하기 용이한 작은 단위의 모듈들로 나누어서 개발할 수 있게 된 것이다. 특정 모듈이 나중에 수정된다면, 해당 모듈만 다시 컴파일하여 이미 컴파일되어 있는 다른 모듈들과 링킹을 수행하면 된다.


1-2. 링킹을 공부해야 하는 이유

그렇다면 링킹은 왜 공부해야 할까? 크게 다섯 가지의 이유가 있다. 첫째, 큰 규모의 프로그램을 개발하게 되면 링킹과 관련된 에러들에 많이 부딪힐 수 있기 때문이다. 이때 링킹의 원리를 모른다면 디버깅에 곤란을 겪을 수 있다. 둘째, 위험한 프로그래밍 에러를 피하기 위해서이다. 프로그래머가 예상치 못한 결과가 나올 수 있는 코드임에도 링커가 에러나 워닝을 띄우지 않는 경우가 있다. 예를 들어, 링커가 동일한 이름의 Weak 심볼을 여러 개 선언하는 것을 허용한다는 것을 모른다면 디버깅에 어려움을 겪을 수 있다. 셋째, 프로그래밍 언어의 스코프 규칙이 어떻게 구현되는지 이해할 수 있기 때문이다. 예를 들어, 링킹의 원리를 알면 전역 변수와 지역 변수의 차이, 그리고 static 선언이 어떠한 의미를 지니는지 이해할 수 있게 된다. 넷째, 중요한 시스템 관련 개념들을 이해하기 위해서이다. 예를 들어, 링킹은 프로그램의 로딩 및 실행 과정, 가상 메모리 기술, 페이징, 메모리 맵핑 등의 개념들과 아주 밀접하게 연관되어 있다. 마지막으로, 공유 라이브러리를 활용할 줄 알아야 하기 때문이다. 과거에 비해 오늘날에는 공유 라이브러리의 동적 링킹이 핵심적인 프로그래밍 기술로 대두되고 있다. 링킹을 이해하면 더욱 능력을 인정받는 프로그래머가 될 수 있을 것이다.


1-3. 핵심 주제

이번 챕터의 핵심적인 주제는 다음과 같이 세 가지이다. 첫 번째는 컴파일 타임에 이뤄지는 정적 링킹(Static Linking)이고, 두 번째는 로드 타임 시 이뤄지는 공유 라이브러리의 동적 링킹(Dynamic Linking of Shared Libraries at Load Time)이며, 마지막은 런타임 시 이뤄지는 공유 라이브러리의 동적 링킹(Dynamic Linking of Shared Libraries at Run Time)이다. 그러나 동적 링킹은 아직 공부를 제대로 하지 않아서 정적 링킹까지만 먼저 포스팅할 예정. 우리는 이러한 내용을 x86-64 리눅스 시스템에서 오브젝트 파일 포맷으로서 ELF-64를 사용하는 환경을 기준으로 설명할 것이다. 그러나 ISA, 운영체제, 그리고 오브젝트 파일 포맷이 다른 환경에서도 링킹의 핵심적인 원리는 크게 달라지지 않는다는 것을 기억하도록 하자.

 

2. Compiler Drivers

[Figure 1] C 언어 예시 코드


2-1. 컴파일 방법

대부분의 컴파일 시스템은 선행 처리기, 컴파일러, 어셈블러, 링커를 호출하는 컴파일러 드라이버를 제공한다. 예를 들어, [Figure 1]의 예시 코드는 다음과 같이 GNU 컴파일 시스템의 GCC 드라이버를 호출함으로써 컴파일할 수 있다. 그러면 GCC 드라이버는 아래 그림과 같이 선행 처리기(cpp), C 컴파일러(cc1), 어셈블러(as), 링커(ld)를 차례대로 호출함으로써 최종적으로 prog라는 하나의 완전한 실행 파일을 만들어 낸다.

 

 

위 과정은 다음과 같이 개별적으로 진행할 수도 있다.

 


2-2. 실행 방법

그렇게 만들어진 실행 파일을 실행하는 방법은 다음과 같다. 그러면 쉘(Shell)은 로더라는 OS의 함수를 호출하는데, 이는 실행 파일의 코드와 데이터를 메모리에 로드한 뒤 CPU의 제어를 해당 프로그램의 시작 주소로 바꿔주게 된다.

 

 

3. Static Linking

리눅스의 LD 프로그램과 같은 정적 링커(Static Linker)는 여러 개의 재배치 가능 오브젝트 파일들을 입력으로 받아서 하나의 완전한 실행 파일을 만들어 낸다. 입력으로 들어오는 각각의 재배치 가능 오브젝트 파일들은 자신만의 코드 섹션과 데이터 섹션을 가진다. 이때 각 오브젝트 파일들은 그저 바이트 블록들의 배열에 지나지 않는다는 것을 기억하자. 어떤 바이트 블록은 프로그램 코드이고, 어떤 바이트 블록은 프로그램 데이터이며, 어떤 바이트 블록은 링커와 로더가 필요로 하는 정보들로 이뤄진 자료구조일 뿐인 것이다. 링커는 그러한 블록들을 적절히 합쳐주고, 각 블록에게 런타임에 필요한 가상 주소들을 알맞게 부여하며, 이에 맞게 코드 블록들과 데이터 블록들에 존재하는 메모리 주소들을 적절히 수정해준다. 그리고 링커가 이러한 작업을 수행하는 데 필요한 대부분의 정보들은 컴파일러와 어셈블러가 만들어서 재배치 가능 오브젝트 파일 안에 담아준다. 링커는 그 정보들을 읽어서 링킹을 수행할 뿐, 타겟 머신에 대해서는 잘 알지 못한다. 방금 말한 링커의 작업을 두 단계로 요약하면 다음과 같다. 각 단계에 대한 자세한 내용은 뒷부분에 나오는 "7. Symbol Resolution""8. Relocation"에서 설명이 될 것이다.


3-1. 심볼 해석 (Symbol Resolution)

재배치 가능 오브젝트 파일들에서 등장하는 각각의 심볼 참조(Symbol Reference)를 정확히 하나의 심볼 정의(Symbol Definition)에 연결하는 작업을 말한다. 여기서 심볼(Symbol)이란 함수, 전역 변수, static 변수 등을 의미하며, non-static 지역 변수는 포함하지 않는다. 각 모듈은 특정 심볼을 정의하거나 참조하게 된다.


3-2. 재배치 (Relocation)

컴파일러와 어셈블러는 기본적으로 0번지에서 시작하는 코드 섹션과 데이터 섹션을 만들어 낸다. 링커는 이러한 섹션들을 적절한 가상 주소로 재배치하고, 이에 따라 각 심볼 정의에도 알맞은 가상 주소를 부여한다. 그리고 각 심볼 참조가 올바른 심볼 정의를 가리킬 수 있도록 수정해준다. 이러한 작업은 어셈블러에 의해 만들어져서 재배치 가능 오브젝트 파일 안에 담기는 재배치 엔트리(Relocation Entry)들의 정보를 바탕으로 수행된다.

 

4. Objects Files

4-1. 오브젝트 파일 유형

오브젝트 파일의 유형은 다음과 같이 세 가지이다. 첫째, 재배치 가능 오브젝트 파일(Relocatable Object File)이다. 이는 정적 링킹 시에 다른 재배치 가능 오브젝트 파일들과 결합되어 하나의 완전한 실행 파일을 만들어 내는 데 사용되는 오브젝트 파일을 의미한다. 다음은 실행 가능 오브젝트 파일(Executable Object File)이다. 간단하게 실행 파일이라고도 부르며, 코드와 데이터가 바로 메모리에 로드되어 실행될 수 있는 오브젝트 파일을 의미한다. 마지막은 공유 오브젝트 파일(Shared Object File)로, 로드 타임이나 런타임에 동적으로 메모리에 로드되어 링킹 될 수 있는 재배치 가능 오브젝트 파일을 의미한다. 이 중에 재배치 가능 오브젝트 파일(또는 공유 오브젝트 파일)은 컴파일러와 어셈블러에 의해 만들어지며, 실행 파일(= 실행 가능 오브젝트 파일)은 링커에 의해 만들어진다.


4-2. 오브젝트 파일 포맷

각 오브젝트 파일은 특정 오브젝트 파일 포맷을 가진다. 오브젝트 파일 포맷은 컴파일러와 어셈블러가 만들어내는 정보들을 재배치 가능 오브젝트 파일 안에 어떤 구조로 담을지 결정한다. 오브젝트 파일 포맷은 시스템마다 각기 다르며, 우리는 ELF(Executable and Linkable Format)라는 오브젝트 파일 포맷을 기준으로 설명할 것이다. 그러나 오브젝트 파일 포맷이 달라져도 기본적인 링킹의 원리는 크게 달라지지 않는다는 것을 기억하도록 하자.

 

5. Relocatable Objects Files

 

위 그림은 ELF 포맷을 따르는 재배치 가능 오브젝트 파일의 내부 구조를 나타낸다. 파일 내부의 바이트 블록들은 역할에 따라 위 그림과 같이 몇 개의 영역으로 구분된다. 각 영역의 역할은 다음과 같다.

 

구분 역할

ELF 헤더

(ELF Header)
시스템의 워드 사이즈나 바이트 오더링과 같은 시스템의 속성 정보가 저장된다. 그리고 링커가 이 파일을 읽어서 분석할 때 알아야 하는 정보들도 저장된다. 예를 들어, ELF 헤더의 크기, 오브젝트 파일의 유형(EX. 재배치 가능 오브젝트 파일, 실행 파일, 공유 오브젝트 파일), 타겟 머신의 유형(EX. x86-64), 섹션 헤더 테이블의 파일 오프셋, 섹션 헤더 테이블에 존재하는 엔트리의 개수와 각 엔트리의 크기 등이 이곳에 저장된다.
섹션
(Section)









.text 컴파일된 프로그램의 명령어에 해당하는 기계어 코드들이 저장된다.
.rodata 문자열 등의 상수나 switch 점프 테이블과 같은 읽기 전용 값들이 저장된다.
.data 0이 아닌 값으로 초기화되는 전역 변수 및 static 변수들이 저장된다. 참고로 non-static 지역 변수는 런타임 시에 스택 영역에서 저장 및 관리되므로, 오브젝트 파일에는 저장되지 않는다.
.bss 0으로 초기화되거나 초기화가 되지 않는 전역 변수 및 static 변수들이 저장된다. 이 섹션은 재배치 가능 오브젝트 파일에서는 전혀 공간을 차지하지 않으며, 실행 파일에서도 이곳에 저장될 데이터들의 총사이즈 정보를 저장할 만큼의 공간만 차지한다. 프로그램이 실행되면 그때서야 실행 파일에 저장되어 있던 사이즈 정보를 바탕으로 .bss 섹션을 메모리에 할당한다. 해당 섹션은 어차피 전부 0으로 초기화될 것이기 때문에 미리 공간을 차지하고 있을 필요가 없다.
.symtab 이 모듈에서 정의하거나 참조하는 모든 심볼(함수, 전역 변수, static 변수 등)들의 정보가 저장된다. 이는 모든 재배치 가능 오브젝트 파일들이 가지는 기본적인 섹션으로, -g 컴파일 옵션을 줘야 포함되는 컴파일러의 심볼 테이블과는 다른 것이다.
.rel.text 링킹 시 재배치가 필요한 .text 섹션 내 메모리 로케이션들의 정보가 저장된다. 예를 들어, 외부 함수를 호출하거나 전역 변수를 참조하는 명령어들을 재배치가 필요하므로 해당 재배치 작업을 위한 정보들을 이곳에 저장한다. 반면, 동일 모듈 함수를 호출하는 명령어의 경우 상대 주소를 이용하면 재배치가 필요 없으므로 별다른 정보를 저장하지 않는다. 이 섹션은 특별한 컴파일 옵션을 주지 않는 한 재배치 가능 오브젝트 파일에만 포함된다.
.rel.data 링킹 시 재배치가 필요한 .data 섹션 내 메모리 로케이션들의 정보가 저장된다. 예를 들어, 전역 변수나 외부 함수의 주소값으로 초기화되는 전역 변수들은 재배치가 필요하므로 해당 재배치 작업을 위한 정보들을 이곳에 저장한다. 참고로, .bss 섹션의 데이터들은 어차피 모두 0으로 초기화되므로 재배치 작업이 필요 없다(애초에 재배치 가능 오브젝트 파일과 실행 파일에는 .bss 섹션이 아직 할당되어 있지도 않음).
.debug 프로그램에서 정의된 지역 변수 및 typedef의 정보들을 저장하는 디버깅 심볼 테이블, 해당 프로그램에서 정의하거나 참조하는 전역 변수들, 원본 C 소스 파일 등과 같이 디버깅을 위한 정보가 저장된다. 이 섹션은 -g 컴파일 옵션을 줄 때만 포함된다.
.line 원본 C 소스 파일의 라인들과 .text 섹션에 존재하는 기계어 코드들의 맵핑 정보가 저장된다. 이 섹션은 -g 컴파일 옵션을 줄 때만 포함된다. 
.strtab .symtab 섹션과 .debug 섹션의 심볼 테이블에 존재하는 심볼 이름들에 해당하는 문자열들과 섹션 헤더 테이블에 존재하는 섹션 이름들에 해당하는 문자열들이 저장된다. 예를 들어, 심볼 테이블의 각 엔트리는 해당 심볼의 이름에 해당하는 문자열의 .strtab 섹션 내 오프셋을 저장한다. 물론 각 문자열은 널 문자로 끝나는 형태로 저장된다.
섹션 헤더 테이블 
(Section Header Table)
각 섹션의 크기와 위치 정보가 저장된다. 각 섹션에 대해 고정된 크기의 엔트리를 갖는다.

 

6. Symbols and Symbol Tables

6-1. 심볼의 종류

각각의 재배치 가능 오브젝트 파일들은 자신이 정의하거나 참조하는 심볼들의 정보를 담고 있는 심볼 테이블을 가지고 있다. 심볼 테이블에 저장되는 심볼의 종류는 다음과 같이 크게 전역 심볼(Global Symbol)지역 심볼(Local Symbol)로 구분된다.

 

종류 설명 예시
전역 심볼
(Global Symbol)
Case 1) 모듈 m에 의해 정의되고 다른 모듈에 의해 참조될 수 있는 심볼 non-static 함수
non-static 전역 변수
Case 2) 모듈 m에 의해 참조되지만 다른 모듈에 정의되어 있는 심볼
지역 심볼 
(Local Symbol)
모듈 m에 의해 정의되고 오직 모듈 m에 의해서만 참조될 수 있는 심볼 static 함수
static 전역/지역 변수

 

static 지역 변수 : 런타임 스택 영역에서 저장 및 관리되는 일반적인 지역 변수와 달리, static 지역 변수는 컴파일러에 의해 고유한 이름을 부여받고 심볼 테이블에 지역 심볼로서 저장이 된다. 그리고 초기화 여부에 따라 .data 섹션 또는 .bss 섹션에 들어가게 된다. 고유한 이름을 부여받는 이유는 서로 다른 함수 내에 동일한 이름의 static 지역 변수가 선언될 수 있기 때문이다.


6-2. 심볼 테이블 엔트리

어셈블러는 컴파일러가 .s 파일에 담은 심볼들의 정보를 바탕으로 심볼 테이블을 구성한 뒤, 이를 재배치 가능 오브젝트 파일의 .symtab 섹션에 저장한다. 심볼 테이블에 저장되는 각 엔트리의 구조를 C 언어의 구조체로 표현하면 다음과 같다.

 

 

name 필드는 심볼 이름에 해당하는 문자열의 .strtab 섹션 내 오프셋을 저장한다. type 필드는 Function 또는 Data를 저장한다. binding 필드는 심볼의 종류를 나타내는 것으로, Global 또는 Local을 저장한다. section 필드는 심볼이 할당될 섹션을 나타내는 것으로, 해당 섹션의 섹션 헤더 테이블 내 인덱스를 저장한다. value 필드는 심볼의 위치를 나타내는 것으로, 재배치 가능 오브젝트 파일이라면 섹션 내 오프셋을 저장하고 실행 파일이라면 절대 가상 주소를 저장한다. size 필드는 심볼 데이터의 바이트 단위 크기를 저장한다. 

 

section 필드는 실제로 존재하지 않는 Pseudo 섹션을 가리킬 수도 있다. Pseudo 섹션은 섹션 헤더 테이블에 엔트리가 없는 섹션을 의미하는 것으로, 재배치 가능 오브젝트 파일에만 존재하고 실행 파일에는 존재하지 않는다. 대표적인 Pseudo 섹션으로는 다음과 같이 세 가지이다. 첫 번째, 재배치(Relocation)가 이뤄지면 안 되는 심볼임을 나타내는 ABS 섹션이다. 두 번째, 다른 모듈에 정의되어 있는 심볼임을 나타내는 UNDEF 섹션이다. 마지막으로 세 번째, 초기화가 되지 않는 전역 변수들을 위한 COMMON 섹션이다. 이때 의아한 점이 생길 수도 있다. 위에서 ELF 포맷을 설명할 때는 초기화되지 않는 전역 변수들이 .bss 섹션에 저장되는 것이라고 했기 때문이다. COMMON 섹션과 .bss 섹션을 구분한 이유에 대해서는 뒷부분에 나오는 "7-2. 전역 심볼 중복 문제 처리 (COMMON vs .bss)"에서 설명하도록 하겠다.

 

※ 참고로 COMMON 섹션에 해당하는 심볼의 경우 value 필드가 정렬 요구 조건(Alignment Requirement)을 저장하며, size 필드는 심볼 데이터의 최소 크기를 저장한다고 알려져 있다. 그 이유에 대해서는 필자도 정확히 알지 못하기 때문에 설명에 포함하지 않았다.


6-3. 심볼 테이블 예시

다음은 [Figure 1]의 예시 코드에 해당하는 심볼 테이블을 보여준다. 전역 심볼인 main 함수는 24바이트의 크기를 가지고, .text 섹션(1)에 할당되며, .text 섹션 내에서도 0번째 위치에 할당된다는 것을 알 수 있다. 그리고 전역 변수인 array 배열은 총 8바이트의 크기를 가지고, .data 섹션(3)에 할당되며, .data 섹션 내에서도 0번째 위치에 할당된다는 것을 알 수 있다. 마지막으로 함수 sum은 해당 모듈 내에 정의되어 있지 않으므로 UNDEF 섹션으로 표기되어 있음을 볼 수 있다.

 

 

7. Symbol Resolution

7-1. 기본

심볼 해석(Symbol Resolution)이란 링커가 입력으로 들어오는 재배치 가능 오브젝트 파일들의 심볼 테이블 정보를 바탕으로 각각의 심볼 참조를 정확하게 하나의 심볼 정의에 연결시키는 작업을 뜻한다. 심볼 정의란 곧 해당 심볼을 정의하는 심볼 테이블 엔트리를 의미하며, 심볼 참조란 코드 상에서 해당 심볼을 참조하는 부분을 의미한다.


7-2. 지역 심볼의 해석

지역 심볼(Local Symbol)의 경우 심볼 해석이 아주 쉽다. 지역 심볼은 정의되는 모듈 내에서만 참조가 가능할 뿐만 아니라(= Scope가 모듈 내로 한정됨), 컴파일러는 한 모듈 내에서 각 지역 심볼의 정의가 고유하다는 것을 보장하기 때문이다. 실제로, static 전역 변수는 동일한 이름으로 여러 번 정의되면 컴파일 에러가 발생한다. 또한 static 지역 변수도 하나의 함수 내에서는 동일한 이름으로 여러 번 정의되면 컴파일 에러가 발생하며, 서로 다른 함수에서 동일한 이름으로 정의가 되더라도 컴파일러에 의해 각기 다른 이름을 부여받아 심볼 테이블에 들어가게 된다. 따라서, 지역 심볼의 참조는 동일한 모듈의 심볼 테이블 상에 존재하는 심볼 정의를 그대로 연결하기만 하면 된다.


7-3. 전역 심볼의 해석

반면 전역 심볼(Global Symbol)은 심볼 해석이 다소 까다롭다. 전역 심볼의 경우 정의되는 모듈 외부에서도 참조가 가능하기 때문이다. 이로 인해 전역 심볼의 참조에 대응되는 심볼 정의를 현재 모듈의 심볼 테이블에서 찾지 못할 수도 있다. 이러한 경우 컴파일러는 해당 전역 심볼이 다른 모듈에 정의된 것이라고 가정하고, 섹션이 UNDEF인 심볼 테이블 엔트리를 만들어서 심볼 테이블에 저장한다. 그리고 나중에 링커가 섹션이 UNDEF인 각각의 심볼 테이블 엔트리에 대해 올바른 심볼 정의를 다른 모듈의 심볼 테이블에서 찾아 심볼 해석을 진행하게 된다. 이때 만약 링커가 입력으로 들어오는 재배치 가능 오브젝트 파일의 심볼 테이블들을 전부 확인했는데도 해당 심볼 참조에 대한 심볼 정의를 찾지 못하면, 에러를 발생시키고 즉시 종료한다.

 

또한, 전역 심볼의 경우 여러 모듈이 동일한 이름으로 정의하는 것도 가능하다. 컴파일 단계에서는 다른 모듈에서 동일한 이름의 전역 심볼을 정의했는지 알 방법이 없기 때문이다. 만약 여러 모듈이 동일한 이름의 전역 변수를 정의했다면, 링커는 심볼 해석을 위해 전역 심볼의 중복 문제를 먼저 처리해야 한다. 상황에 따라서 에러를 발생시키고 즉시 종료할 수도 있고, 동일한 이름으로 정의된 여러 개의 전역 심볼들 중 하나를 선택하고 나머지는 폐기할 수도 있다. 전역 심볼 중복 문제를 처리하고 나면, 각 전역 심볼은 고유한 이름을 가진다는 것이 보장되므로 해당 이름의 심볼 정의를 심볼 테이블에서 찾아 심볼 해석을 진행하면 된다.


7-4. 전역 심볼 중복 문제 처리 (COMMON vs .bss)

그렇다면 링커는 전역 심볼의 중복 문제를 어떻게 처리하는 것일까? 이를 이해하기 위해서는 먼저 Strong 심볼과 Weak 심볼의 개념을 알아야 한다. 다음 그림과 같이 전역 심볼(Global Symbol)은 초기화 여부에 따라 다시 Strong 심볼과 Weak 심볼로 구분된다. non-static 함수와 초기화되는 non-static 전역 변수는 Strong 심볼에 해당하며, 초기화되지 않는 non-static 전역 변수는 Weak 심볼에 해당한다. 그리고 Strong 심볼과 달리 Weak 심볼은 동일한 이름으로 여러 번 정의될 수 있다는 특징이 있다.

 

 

컴파일러는 컴파일 단계에서 각 심볼들의 정보를 파악한 뒤 이를 .s 파일에 담아서 어셈블러에게 전달한다. 이때, 자기 자신 모듈에서 정의되는 전역 심볼의 경우에는 Strong 심볼인지, 아니면 Weak 심볼인지를 파악하여 이 정보도 함께 어셈블러에게 전달한다. 그러면 어셈블러는 그렇게 전달받은 심볼들의 정보를 바탕으로 심볼 테이블을 구성하여 재배치 가능 오브젝트 파일을 만들게 된다. 따라서 나중에 링커가 링킹을 수행하는 시점에는 이미 심볼 테이블에 각 전역 심볼의 유형(Strong 또는 Weak) 정보가 담겨 있기 때문에, 링커는 이러한 정보를 바탕으로 전역 심볼 중복 문제를 처리하게 된다.

 

우리가 전제한 리눅스 시스템의 링커는 다음과 같은 세 가지 기준으로 전역 심볼 중복 문제를 처리한다. 첫째, 동일한 이름의 Strong 심볼이 여러 개 정의되어 있다면 에러를 발생시키고 즉시 종료한다. 둘째, 동일한 이름의 Strong 심볼 하나와 Weak 심볼 여러 개가 정의되어 있다면 Weak 심볼들은 전부 폐기하고 Strong 심볼을 선택한다. 셋째, 동일한 이름의 Weak 심볼이 여러 개 정의되어 있다면 그중에 아무거나 선택한 뒤 나머지 Weak 심볼들은 전부 폐기한다. 심볼을 선택한다 함은 그 이름의 심볼 참조들을 해당 심볼 정의에 연결하게 된다는 것을, 심볼을 폐기한다 함은 심볼 테이블에서 해당 심볼 정의를 삭제한다는 것을 의미한다고 이해하면 된다.

 

 

※ Weak 심볼로 인해 프로그래머가 겪을 수 있는 어려움

더보기

Weak 심볼이 여러 개 정의되는 경우, 링커는 그것들 중 하나를 임의로 선택하며 이 과정에서 특별한 메시지를 만들지 않는다. 따라서 링커의 원리를 알지 못하는 프로그래머는 이와 같은 상황에서 디버깅에 큰 어려움을 겪을 수 있다. 심지어, 동일한 이름으로 정의되는 Weak 심볼들이 자료형까지 다르다면 문제는 더욱 심각해진다. 예를 들어, A 모듈에서 int형 변수 x를 선언하고 B 모듈에서 double형 변수 x를 선언했는데 둘 다 초기화를 하지 않았다고 해보자. 그리고 B 모듈에 "x = -0.0;"과 같은 코드가 있었다고 해보자. 이때 링커에 의해 심볼 x로서 A 모듈의 변수 x가 선택이 된다면 무슨 일이 일어날까? A 모듈을 컴파일할 때 컴파일러는 x가 double 변수일 것이라고 가정하여 명령어를 만들어냈을 것이다. 그러나 실제로는 A 모듈의 변수 x가 선택되었으므로, 특정 실수를 표현하는 8바이트 배열이 4바이트 공간에 덮어쓰기 될 것이다. 이 경우 링커가 워닝 메시지를 만들긴 하지만, 대부분의 프로그래머는 큰 규모의 프로그램을 개발할 때 작은 워닝들은 무시하는 경향이 있기 때문에 디버깅에 어려움을 겪을 수 있다. 그래서 링커의 원리를 어느 정도 알아야 하는 것이다.

 

그렇다면 어셈블러는 심볼 테이블에 각 전역 심볼의 유형(Strong 또는 Weak) 정보를 어떤 방식으로 저장할까? 이는 곧 링커가 심볼 테이블을 보고 각 전역 심볼의 유형을 어떻게 파악할 수 있는지와 동일한 물음이다. 어셈블러는 컴파일러로부터 전달받은 심볼들의 정보를 바탕으로 심볼 테이블을 구성할 때, 각 심볼 테이블 엔트리에 해당 심볼이 가상 주소 공간의 어떤 섹션에 할당될 것인지 표기해야 한다. 예를 들어, 함수들은 .text 섹션으로 표기하면 되고, 0이 아닌 값으로 초기화가 되는 전역 변수들은 .data 섹션으로 표기하면 된다.

 

그러나 Weak 심볼을 마주쳤을 때 문제가 발생한다. 초기화되지 않는 전역 변수니까 .bss 섹션으로 표기하면 되지 않냐고 반문할 수도 있다. 하지만 그렇지 않다. 어셈블러는 심볼 테이블을 만드는 시점에 해당 Weak 심볼이 나중에 링커에 의해 선택될지 미리 알 수 없다. 링커에 의해 선택된다는 것이 보장되면 .bss 섹션으로 표기해도 되지만, 선택되지 않을 수도 있기 때문에 그렇게 함부로 단정하면 안 된다. 따라서, 어셈블러와 링커는 다음과 같은 약속을 하게 된다. 어셈블러는 Weak 심볼에 해당하는 심볼 테이블 엔트리에 COMMON 섹션으로 표기하고, 나중에 링커는 COMMON 섹션으로 표기되어 있는 동일한 이름의 심볼들을 따로 모아서 (위에서 설명한 방식에 근거하여) 전역 심볼 중복 문제를 처리하도록 하는 것이다. 결국, 어셈블러는 각 전역 심볼의 유형을 명시적으로(Explicitly) 각 심볼 테이블 엔트리에 저장하는 것이 아니라 섹션 정보를 빌려서 링커에게 Weak 심볼의 존재를 암묵적으로(Implicitly) 알리게 되며, 이 과정은 컴파일러, 어셈블러, 그리고 링커가 입을 모아서 맞춘 하나의 약속에 기반한다는 것을 알 수 있다.

 

이쯤에서 다시 한번 상기해야 할 중요한 사실이 하나 있다. 이는 전부 전역 심볼(Global Symbol)에만 해당하는 내용이라는 것이다. 앞서 한 차례 설명했듯이, 지역 심볼(Local Symbol)에 해당하는 static 지역/변수들은 Scope가 해당 모듈 내로 한정되며 각기 고유한 이름을 가지고 있다는 것이 보장되기 때문에 (COMMON 섹션으로 표기할 필요가 없이) 바로 .data 또는 .bss 섹션으로 표기해도 된다. COMMON 섹션은 폐기될 수도 있는 심볼들을 처리하기 위한 수단일 뿐이기 때문이다. 지역 심볼은 폐기될 일이 없다.

 

위와 같이 COMMON 섹션으로 표기된 전역 심볼들의 중복 문제를 처리하고 나면, 최종적으로 선택된 심볼 정의들의 총사이즈가 실행 파일의 .bss 섹션에 저장될 값에 더해지고, 실행 파일에는 더 이상 COMMON 섹션이 존재하지 않게 된다. (단, 동적 라이브러리의 코드/데이터에 대한 심볼 참조가 있는 경우에는 여전히 COMMON 심볼이 남아있을 수도 있다.) 실행 파일의 .bss 섹션에는 데이터들 자체가 아니라 그 데이터들의 총사이즈가 저장되기 때문이다.

 

⇒ 링크 결과 실행 파일의 .bss 섹션에 저장될 값 = .bss 심볼 정의들의 총사이즈 + 선택된 COMMON 심볼 정의들의 총사이즈


7-5. 정적 라이브러리 링킹

대부분의 컴파일 시스템은 서로 연관된 여러 오브젝트 모듈들을 하나의 정적 라이브러리(Static Library) 파일로 패키지화하는 기능을 제공한다. 그렇게 만들어지는 정적 라이브러리 파일은 링킹을 수행할 때 링커에 입력으로 들어가게 되며, 실제로 프로그램이 참조하는 심볼을 정의하는 오브젝트 모듈만 실행 파일 안에 포함된다. 리눅스 시스템을 기준으로 정적 라이브러리는 Archive라는 이름의 파일 형식으로 저장되며, 파일 확장자는 .a이다. 그리고 Archive 파일은 각 멤버 오브젝트 모듈의 크기와 위치 정보를 명시하는 헤더를 가진다.

 

7-5-1. 정적 라이브러리의 필요성

정적 라이브러리는 왜 필요할까? 설명을 위해 실제 C 언어 정적 라이브러리를 두 개 소개하겠다. 하나는 표준 입출력, 문자열 조작, 수학과 관련된 함수들을 포함하는 libc.a 파일 이고, 다른 하나는 실수를 다루는 수학과 관련된 함수들을 포함하는 libm.a 파일이다. 만약 이러한 정적 라이브러리가 존재하지 않는다고 하면 해당 라이브러리가 포함하는 함수들을 어떻게 사용할 수 있을지 생각해보자. 다음과 같이 크게 세 가지 방법을 떠올릴 수 있다.

 

먼저, 컴파일러가 특정 함수에 대한 호출 문을 발견하면 해당 함수의 코드를 자신이 직접 만들어 내는 방법이 있다. 실제로 표준 함수의 개수가 적은 Pascal 등의 언어에서는 이 방식을 채택한다. 그러나 C 언어의 경우 적합하지 않다. C 언어 표준 함수의 개수가 상당히 많을뿐더러, 컴파일러의 무게가 지나치게 무거워지며, 함수가 추가/수정/삭제될 때마다 새로 컴파일러를 만들어야 하기 때문이다. 물론 프로그래머 입장에서는 편할 수도 있다. 코드를 작성하기만 하면 알아서 필요한 함수들의 코드를 만들어 주기 때문이다.

 

다음으로, C 언어의 모든 표준 함수들을 하나의 재배치 가능 오브젝트 파일 안에 담는 방법이 있다. 이렇게 하면 프로그래머들은 실행 파일을 만들 때 단 하나의 파일만 입력시켜주면 되기 때문에 편할 것이다. 또한 첫 번째 방법과 달리 표준 함수들의 구현과 컴파일러의 구현을 분리한다는 점에서 긍정적이다. 그러나 너무나도 큰 문제가 하나 있다. 바로 디스크 및 메모리 낭비가 너무나도 심하다는 것이다. 그 많은 함수들의 코드가 모든 실행 파일에 포함될 뿐만 아니라, 프로그램이 실행될 때도 메모리에 적재되기 때문이다. 또한 함수를 하나 추가/수정/삭제할 때마다 전체를 다시 컴파일해야 한다는 번거로움으로 인해 유지보수 및 개발 효율이 상당히 저하될 수 있다.

 

마지막으로, 각 표준 함수별로 재배치 가능 오브젝트 파일들을 만들어 놓고 그것들을 잘 알려진 디렉토리에 저장해두는 방식이다. 하지만 이렇게 하면 프로그래머들은 실행 파일을 만들 때마다 필요한 함수에 해당하는 모듈들을 직접 찾아서 입력으로 넣어줘야 한다. 이는 에러에 상당히 취약한 방법일 뿐만 아니라 시간이 굉장히 많이 소요되는 번거로운 작업이므로 좋다고 평가하기 힘들 것이다.

 

이러한 문제점들을 모두 해결해주는 것이 바로 정적 라이브러리이다. 정적 라이브러리를 사용하면 프로그래머들은 필요한 함수가 포함되어 있는 라이브러리 파일의 이름만 커맨드 라인에 적으면 된다. 정적 라이브러리 파일을 입력해도 실제로 실행 파일에 복사되어 들어가는 부분은 프로그램이 실제로 참조한 심볼이 존재하는 오브젝트 모듈뿐이기 때문에 디스크와 메모리의 낭비를 막을 수 있다. 또한 많이 사용되는 표준 함수들을 포함하는 libc.a 등의 중요한 정적 라이브러리 파일들은 대부분 컴파일러 드라이버가 알아서 입력시켜주기 때문에 프로그래머의 수고도 덜어줄 수 있다.

 

7-5-2. 정적 라이브러리 파일을 만드는 방법

addvec 함수를 포함하는 addvec.c 파일과 multvec 함수를 포함하는 multvec.c 파일을 가지고 libvector.a라는 이름의 정적 라이브러리 파일을 만드는 방법은 다음과 같다. 참고로 -static은 메모리에 로드된 뒤 더 이상의 동적 링킹이 필요 없이 완전하게 실행될 수 있는 실행 파일을 만들어야 한다는 것을 나타내는 옵션이고, -lvectorlibvector.a의 약칭이며, -Llibvector.a가 현재 디렉토리에 위치함을 나타내는 옵션이다.

 

 

이때 만약 main2.o 모듈이 addvec.oaddvec 함수만 참조한다면, addvec.o는 실행 파일에 포함되지만 multvec.o은 실행 파일에 포함되지 않게 된다. 지금까지의 과정을 그림으로 나타내면 다음과 같다.

 


7-6. 심볼 해석 전체 과정

이제 본격적으로 심볼 해석(Symbol Resolution)의 진행 과정을 자세히 알아보도록 하자. 링커는 커맨드 라인에 입력되는 순서대로(왼쪽 → 오른쪽) 재배치 가능 오브젝트 파일들과 아카이브 파일들을 하나씩 스캔한다. (참고로 커맨드 라인에 확장자가 .c인 파일이 입력되면 그것은 자동으로 .o가 확장자인 파일로 먼저 번역된다.) 링커는 스캔 과정에서 다음과 같은 세 개의 집합을 내부적으로 관리한다. 초기에는 셋 다 빈 집합이다.

 

 

링커는 하나의 파일을 스캔할 때마다 다음과 같은 동작을 수행한다. 읽은 파일이 재배치 가능 오브젝트 파일인지, 아니면 아카이브 파일인지 판단한다. 그리고 그 판단 결과에 따라 다음 그림과 같은 동작을 수행하는 것을 반복하면서 모든 심볼 참조들을 정확히 하나의 심볼 정의(하나의 심볼 테이블 엔트리)에 연결하는 것을 시도한다. 만약 커맨드 라인 입력 순서대로 모든 파일을 스캔했음에도 해석되지 않은 심볼 참조가 있다면 링커는 에러 메시지와 함께 즉시 종료한다. 그렇지 않고 모든 심볼 참조를 성공적으로 하나의 심볼 정의에 연결했다면 이제 E의 모듈들을 대상으로 재배치(Relocation)를 진행한다.

 

 

이러한 링커의 동작 방식으로 인해 주의해야 할 점이 하나 있다. 바로 커맨드 라인의 입력 순서이다. 심볼 정의가 포함된 정적 라이브러리 파일을 심볼 참조가 포함된 재배치 가능 오브젝트 파일보다 앞에(왼쪽) 위치시키면 해당 심볼 참조를 해석하는 데 실패한다. 재배치 가능 오브젝트 파일과 달리, 정적 라이브러리 파일은 U에 존재하는 심볼 참조에 대응되는 심볼 정의를 가지고 있는 모듈이 가지고 있는 심볼 정의들만 D에 넣기 때문이다. 따라서 정적 라이브러리 파일들은 맨 뒤에 두는 것을 원칙으로 한다. 그리고 정적 라이브러리 파일들끼리의 의존성도 존재한다면 마찬가지 원리로 순서를 맞춰줘야 할 것이다. 참고로, 순환적 의존성 문제를 해결하기 위해 커맨드 라인에 동일한 파일을 두 번 이상 입력하는 것도 허용된다.

 

EX) p.olibx.a의 심볼을 참조, libx.aliby.a의 심볼을 참조, liby.alibx.a의 심볼을 참조, libx.ap.o의 심볼을 참조

 

※ 마지막에 p.o을 안 써도 되는 이유 : 정적 라이브러리 파일과 달리 p.o가 정의하는 심볼들은 이미 D에 들어가 있기 때문 


7-7. 심볼 해석 보충 설명 (참고)

지금부터는 위에서 설명한 심볼 해석 전체 과정을 조금 더 구체적으로 분석해 보자. 여기서 설명하는 것은 서적에 나와 있지 않지만 필자가 공부하면서 나름대로 유추해본 내용들이다. 따라서 다소 부정확한 내용이 있을 수 있다는 점 참고해주기 바란다.

 

우선, 지금까지 이야기했던 '심볼 정의'와 '심볼 참조'라는 것들은 재배치 가능 오브젝트 파일 안에 어떤 방식으로 저장이 될까? 링커는 재배치 가능 오브젝트 파일의 정보만으로도 무엇이 심볼 참조이고 무엇이 심볼 정의인지 명확히 알아야 이를 바탕으로 심볼 해석을 진행할 수 있을 것이다. 예상컨대 링커의 관점에서 심볼 정의(Symbol Definition)는 .symtab 섹션의 심볼 테이블에 존재하는 심볼 테이블 엔트리를 가리키며, 심볼 참조(Symbol Resolution)는 .rel.data 섹션과 .rel.text 섹션에 존재하는 재배치 엔트리(Relocation Entry)를 가리키는 것 같다.

 

재배치 엔트리는 특정 심볼 참조의 정보를 저장하며, 해당 심볼 참조가 가리키는 심볼 정의의 최종적인 가상 주소를 확정할 수 없는 경우에만 만들어진다. 컴파일 단계에서 최종적인 가상 주소가 어떻게 결정될지 확정할 수 있는 심볼 참조는 링커가 처리할 필요가 없으므로 굳이 재배치 엔트리를 만들지 않는다. 그리고 각 재배치 엔트리는 해당 심볼 참조가 어떤 심볼 정의를 가리키는지를 나타내는 symbol 필드를 가지고 있으며, 여기에는 가리키는 심볼 정의에 대응되는 심볼 테이블 엔트리의 심볼 테이블 내 인덱스가 저장된다. 결국, 심볼 해석의 목표는 모든 재배치 엔트리들의 symbol 필드 값을 올바르게 채워주는 것이 아닐까 하고 유추해볼 수 있다. 이 작업이 끝나고 나면 모든 심볼 참조는 정확히 하나의 심볼 정의만을 가리키게 되므로 이제 재배치(Relocation)만 수행해주면 링킹은 끝이 나게 된다.

 

이를 바탕으로 위의 심볼 해석 전체 과정에서 ③에 해당하는 심볼 참조 해석 방법도 조금만 더 구체적으로 알아보자. 현재 모듈 m을 E에 추가했고, 모듈 m이 정의하는 심볼 정의들도 D에 추가한 상태라고 가정하자. 이때 링커가 해석을 시도할 심볼 참조들은 U에 들어가 있는 상태이다. 그러면 U에 존재하는 각각의 심볼 참조들에 대해 연결할 심볼 정의는 다음과 같이 탐색할 것이다. 먼저 해당 심볼 참조에 대응되는 심볼 테이블 엔트리를 현재 모듈의 심볼 테이블에서 찾고(D의 심볼 정의들 중 현재 모듈에 해당하는 것들을 탐색), 그것의 binding 필드를 확인하는 것이다. 만약 지역 심볼이라면(binding == Local) 해당 심볼 정의에 바로 연결하고, 전역 심볼이라면(binding == Global) section 필드를 확인할 것이다. 만약 UNDEF가 아니라면 해당 심볼 정의에 바로 연결할 것이다. 이미 Weak 심볼과 관련한 전역 심볼 중복 문제를 처리했다고 가정할 때, 모든 전역 신볼들은 각기 고유한 이름을 가지고 있을 것이기 때문이다. 그러나 UNDEF라면 다른 모듈의 심볼 테이블들을 확인하여(D의 심볼 정의들 중 다른 모듈에 해당하는 것들을 탐색) 올바른 심볼 테이블 엔트리를 찾아 연결할 것이다.

 

8. Relocation

8-1. 기본

심볼 해석이 완료되면, 각 심볼 참조는 정확히 하나의 심볼 정의(심볼 테이블 엔트리)에 연결되어 있다. 이제 재배치(Relocation) 작업을 수행할 준비가 된 것이다. 재배치는 다음과 같이 두 단계로 진행된다.

 

먼저, 섹션들과 그 안의 심볼 정의들을 재배치한다. 같은 유형의 섹션들은 한 섹션으로 합쳐서 실행 파일에 담고, 그렇게 탄생하는 새로운 섹션들에 런타임 가상 주소를 할당한다. 그리고 이에 맞춰 각 섹션 안에 존재하는 심볼 정의들에도 알맞은 가상 주소를 부여한다. 따라서 이 단계가 끝나면 모든 함수, 전역 변수, 그리고 static 변수들의 고유한 런타임 가상 주소를 알게 된다.

 

다음으로, 심볼 참조들을 재배치한다. 앞서 심볼 정의들에게 부여한 가상 주소 정보들을 활용하여, 각 섹션 안에 존재하는 심볼 참조들이 올바른 가상 주소를 가리키도록 수정해준다. 이 단계에서 필요한 정보는 재배치 가능 오브젝트 파일의 .rel.text 섹션과 .rel.data 섹션에 담기는 재배치 엔트리(Relocation Entry)들에 담긴다.


8-2. 재배치 엔트리 (Relocation Entry)

어셈블러는 가리키는 심볼 정의가 무엇인지 모르거나, 가리키는 심볼 정의의 최종적인 가상 주소를 확정할 수 없는 심볼 참조를 마주칠 때마다 재배치 엔트리(Relocation Entry)를 만들어서 재배치 가능 오브젝트 파일에 담는다. 여기에는 나중에 링커가 해당 심볼 참조를 어떻게 수정(재배치)해줘야 하는지에 대한 정보들이 담긴다. .text 섹션에 존재하는 심볼 참조에 해당하는 재배치 엔트리는 .rel.text 섹션에 담기며, .data 섹션에 존재하는 심볼 참조에 해당하는 재배치 엔트리는 .rel.data 섹션에 담긴다. ELF 포맷의 재배치 가능 오브젝트 파일에 저장되는 재배치 엔트리의 구조를 C 언어의 구조체로 표현하면 다음과 같다.

 

 

offset 필드는 해당 심볼 참조의 섹션 내 오프셋을 저장한다. type 필드는 해당 심볼 참조를 어떻게 수정(재배치)해줘야 하는지에 대한 정보를 저장한다. symbol 필드는 해당 심볼 참조가 가리켜야 하는 심볼 정의를 나타낸다. addend 필드는 특정 재배치 타입에 한하여 심볼 참조를 수정(재배치)해줄 때 사용해야 하는 부호 있는 상수 값을 저장한다. 여기서 type 필드에 의해 결정되는 재배치 타입의 경우 ELF 포맷을 기준으로 32개나 되지만, 우리는 다음과 같은 두 개의 기본적인 재배치 타입만 살펴볼 것이다.

 

재배치 타입 (type 필드) 의미
R_X86_64_PC32 32비트 PC-relative 주소 방식을 사용하여 재배치(주소를 계산)해야 하는 심볼 참조
R_X86_64_32 32비트 절대 주소 방식을 사용하여 재배치(주소를 계산)해야 하는 심볼 참조

※ 위 두 개의 재배치 타입은 코드와 데이터의 총사이즈가 2GB보다 작아서 32비트 PC-relative 주소 방식으로 런타임에 접근이 가능한 프로그램의 경우에만 사용할 수 있다. 만약 사이즈가 2GB보다 큰 프로그램이라면 별도의 컴파일 옵션을 줘서 링킹 해야 한다.


8-3. 심볼 참조 재배치 알고리즘

다음 Pseudo 코드는 심볼 참조를 재배치하는 알고리즘을 나타낸다. 각 섹션 s는 링커의 메모리 상에서 바이트 배열로 표현되어 있고, 각 재배치 엔트리 r은 위에서 보여주었던 Elf64_Rela 타입의 구조체로 표현되어 있다고 가정하자. 또한 아래 알고리즘을 수행할 때 링커는 이미 각 섹션 s의 런타임 가상 주소(ADDR(s))와 각 심볼 정의의 런타임 가상 주소(ADDR(r.symbol))를 계산한 상태라고 가정하자.

 

 

각 섹션 s의 각 재배치 엔트리 r에 대하여, 다음과 같은 동작을 수행한다. 먼저, 수정되어야 하는 심볼 참조가 s 배열 내에서 어디에 위치하는지 계산한다. 이는 곧 재배치 작업에 의해 수정될 부분을 나타낸다. 이후 재배치 엔트리의 type 필드를 통해 해당 심볼 참조를 어떻게 수정(재배치)해줘야 하는지 파악한다. 각 경우에 대해 심볼 참조를 수정할 값을 계산하는 방법은 다음과 같다.

 

① 32비트 PC-relative 주소 방식

⇒ [심볼 정의의 가상 주소] + [재배치 엔트리의 addend 필드 값] - [심볼 참조의 가상 주소]

 

② 32비트 절대 주소 방식

⇒ [심볼 정의의 가상 주소] + [재배치 엔트리의 addend 필드 값]


8-4. 재배치 예시

다음은 [Figure 1]의 예시 코드가 링커에 의해 재배치되기 직전의 모습을 보여준다.

 

 

빨간색으로 표시된 부분은 함수의 인자로 배열 array를 전달하기 위해 %edi에 배열 array의 주소를 집어넣는 명령어에 존재하는 심볼 참조이다. 재배치 엔트리의 정보에 의하면 재배치 타입이 32비트 절대 주소 방식이기 때문에, 빨간색으로 표시된 4바이트(= 32비트) 부분에는 배열 array의 절대 주소가 들어가야 한다는 것을 유추할 수 있다. 그 값을 계산하는 과정은 다음과 같다. 참고로 계산된 값을 거꾸로 쓰는 이유는 X86-64의 바이트 오더링이 Little Endian 방식이기 때문이다. 바이트 오더링은 값을 메모리에 쓰는 방식을 말하는 것으로, MSB가 주소가 작은 쪽에 위치하면 Big Endian 방식, 큰 쪽에 위치하면 Little Endian 방식이다.

 

 

파란색으로 표시된 부분은 sum 함수를 호출하는 명령어에 존재하는 심볼 참조이다. 재배치 엔트리 정보에 의하면 재배치 타입이 32비트 PC 주소 방식이기 때문에, 파란색으로 표시된 4바이트(= 32비트) 부분에는 호출하고자 하는 함수(sum)와의 상대적인 주소가 들어가야 한다는 것을 유추할 수 있다. 그 값을 계산하는 과정은 다음과 같다. 참고로 callq 명령어를 실행하는 순간에는 (CPU 내부적으로 명령어를 처리하는 절차적 특성에 의해) PC 값이 이미 다음 명령어(add)의 주소로 증가해 있다. 따라서 파란색 부분의 값은 (sum 함수의 주소 - add 함수의 주소)가 되는 것이 맞다.

 

 

9. Executable Object Files

ASCII 텍스트 파일들의 집합으로 시작한 C 프로그램은 컴파일러, 어셈블러, 그리고 링커를 거쳐서 하나의 완전한 실행 파일로 변환된다. 실행 파일은 메모리에 로드되어 실행되기 위한 정보들을 담은 하나의 이진 파일이다. 다음 그림은 ELF 포맷을 따르는 실행 파일의 구조를 나타낸다.

 

 

전반적으로 재배치 가능 오브젝트 파일과 유사한 구조를 가지고 있음을 볼 수 있다. ELF 헤더는 해당 오브젝트 파일의 전반적인 포맷 정보를 저장하며, 프로그램을 실행할 때 실행해야 하는 첫 번째 명령어의 주소인 Entry Point도 이곳에 저장된다. .text, .rodata, .data 섹션은 재배치 가능 오브젝트 파일과 거의 유사하다. 다만 이러한 섹션들이 링커에 의해 최종적인 런타임 가상 주소로 재배치가 이뤄졌다는 것만 다르다. .init 섹션_init이라는 이름의 작은 함수를 하나 정의하는데, 이는 프로그램의 초기화 코드에 의해 호출되는 함수이다. 한편 실행 파일은 Fully Linked, 즉 이미 재배치가 수행된 완전한 실행 파일이기 때문에 .rel.text 섹션.rel.data 섹션은 존재하지 않는다.

 

ELF 실행 파일은 메모리에 로드되기 쉽도록 디자인된다. 즉, 실행 파일 내의 연속적인 바이트 청크들이 연속적인 메모리 세그먼트에 맵핑이 되도록 하는 것이다. 이러한 맵핑 정보는 프로그램 헤더 테이블(세그먼트 헤더 테이블)에 저장된다. 다음은 [Figure 1] 예시 코드에 해당하는 프로그램 prog의 프로그램 헤더 테이블 일부이다.

 

 

코드 세그먼트는 읽기 및 실행 권한이 부여되고, 메모리 상에서 가상 주소 0x400000에서 시작하며, 메모리에서 차지하는 총사이즈는 0x69c 바이트이며, 실행 파일의 0x000 ~ 0x69c 바이트 부분으로 초기화된다는 것을 알 수 있다. ELF 헤더부터 시작하여 .rodata 섹션까지가 이 세그먼트에 해당한다.

 

데이터 세그먼트는 읽기 및 쓰기 권한이 부여되고, 메모리 상에서 가상 주소 0x600df8에서 시작하며, 메모리에서 차지하는 총사이즈는 0x230 바이트이며, 실행 파일의 0xdf8 ~ 0x228 바이트 부분으로 초기화된다는 것을 알 수 있다. 나머지 8바이트는 .bss 섹션에 해당하는 8바이트이며, 런타임 시에 모두 0으로 초기화된다.

 

10. Loading Executable Object Files

10-1. 로딩 (Loading)

 

리눅스 쉘에 위와 같이 실행 파일 이름을 입력하면 해당 프로그램을 실행할 수 있다. prog는 시스템 내장 쉘 커맨드가 아니기 때문에, 쉘은 그것이 실행 파일의 이름이라 판단한다. 그리고 로더(Loader)라고 불리는 (메모리에 언제나 상주하는) OS의 코드를 실행함으로써 해당 프로그램의 실행을 개시한다. 리눅스 프로그램은 execve라는 함수를 호출함으로써 로더를 실행하도록 되어 있다. 로더는 디스크에 있는 실행 파일로부터 프로그램의 코드와 데이터를 메모리에 복사하고, Entry Point에 해당하는 첫 번째 명령어의 주소로 점프함으로써 해당 프로그램을 실행한다. 이러한 과정을 로딩(Loading)이라고 부른다.


10-2. x86-64 리눅스 프로그램 런타임 이미지

x86-64 리눅스에서 실행되는 모든 프로그램은 다음 그림과 같은 런타임 메모리 이미지를 가진다.

 

 

코드 세그먼트는 0x400000에서 시작하며, 그 위에는 데이터 세그먼트가 등장한다. 그리고 데이터 세그먼트 위에는 등장하는 영역은 런타임 힙(Heap)으로, malloc 라이브러리의 함수들을 호출할 때마다 주소가 큰 방향으로 확장된다. 그리고 그 위는 공유 라이브러리(Shared Libraries)들을 위해 예약해둔 영역이다. 유저 스택(User Stack) 영역은 유저가 접근할 수 있는 가장 큰 주소(2^48-1)부터 시작하여 주소가 작은 방향으로 확장된다. 2^48부터 시작하는 영역은 커널의 코드와 데이터를 위해 예약되어 있으며, 언제나 메모리에 상주하는 운영체제에 해당한다.


10-3. 로더의 역할

로더는 실행되면 위 그림과 유사한 메모리 이미지를 만들어 낸다. 그리고 실행 파일의 프로그램 헤더 테이블에 적힌 정보를 바탕으로 실행 파일의 연속적인 바이트 청크들을 코드 세그먼트와 데이터 세그먼트에 복사한다. 다음으로, 로더는 _start 함수(시스템 오브젝트 파일인 crt1.o에서 정의됨)의 시작 주소에 해당하는 Entry Point로 점프함으로써 해당 프로그램의 실행을 개시한다. 그리고 _start 함수는 system startup function__libc_start_main 함수를 호출한다. 이는 실행 환경을 초기화하고, 유저 프로그램의 main 함수를 호출하며, 그것의 반환 값을 처리하고, 필요한 경우에는 제어를 커널로 옮기는 역할을 수행한다.

'컴퓨터 구조 (Architecture) > CSAPP' 카테고리의 다른 글

[CSAPP] Virtual Memory  (28) 2020.03.27
[CSAPP] Exceptional Control Flow  (10) 2020.03.24
[CSAPP] Cache Memory  (6) 2020.03.22
[CSAPP] Memory Hierarchy  (6) 2020.03.17
[CSAPP] Pipelining - Performance  (0) 2020.03.15