The C Compilation Process
바이너리 코드(binary code)란 컴퓨터 시스템에 의해 실행되는 0과 1의 조합만으로 표현되는 일련의 기계어 명령의 집합을 말한다.
프로그램 = 바이너리 코드(기계어 명령어) + 데이터(변수, 상수 등)
일반적으로 binary executable file
을 바이너리(binary)
라고 부른다.
컴파일이란 C/C++과 같은 프로그래밍 언어로 쓰인 소스코드를 컴퓨터 프로세서가 이해할 수 있는 기계어 코드로 바꾸는 과정을 말한다. 전체 컴파일 과정은 같이 전처리, 컴파일, 어셈블리, 링킹으로 구성되어 있다.
- The C compilation process
이 중 두 번째 단계의 이름이 컴파일이기 때문에 혼동을 야기하는데, 보통 전체 과정을 컴파일이라고 한다.
Preprocessing
C언어로 작성된 소스코드 파일에는 매크로 #define과 #include 지시어가 있다. 전처리 단계에서 이런 명령어 부분을 미리 처리해 순수하게 컴파일할 C언어 코드만을 남긴다.
Compilation
컴파일 단계에서는 전처리된 코드를 어셈블리 언어로 변환한다.
바로 기계어로 바꾸지 않는 이유는 다른 여러 언어도 컴파일이 필요할 수 있기 때문이다. 다른 언어들도 공통적인 기계어까지 변환만 한다면 같은 컴파일러로 컴파일할 수 있다.
Assembly
어셈블 단계에서는 어셈블리 언어로 변환된 코드를 목적 파일(object file)로 변환한다.
file로 목적 파일을 확인해보면 재배치 가능(relocatable
)하다는 것을 알 수 있다. 즉 파일이 메모리의 특정 주소에 국한되어 배치되지 않음을 의미하며, 코드에 정의된 규칙 하에 위치를 옮길 수 있다는 의미다.
relocatable은 목적 파일임을 나타낸다. 단 위치 독립 실행 파일(PIE: Position-Independent Executable) 바이너리는 재배치 가능 파일이 아닌 공유 객체를 사용해 구성된다.
Linking
링킹 단계는 모든 목적 파일들을 하나의 실행 가능한 바이너리로 연결한다.
최신 시스템에서는 LTO(Link-Time Optimization)이라는 추가 최적화 기능을 포함한다.
보통 링킹 단계 이전까지를 컴파일러가 담당하고 링킹 단계는 분리되어 있다.
- 기호 참조 (symbolic reference)
목적 파일들은 서로 독립적으로 컴파일되므로 재배치 가능한 속성을 가진다. 링크 수행 전에는 참조하려는 코드 및 데이터가 어느 주소에 배치돼 있는지 알 수 없으므로 목적 파일에는 각 함수 및 변수를 참조하고자 재배치 심벌을 명시하는 방식으로 이 문제를 해결한다.
링커는 목적 파일들을 병합해 실행 가능한 형태로 만들고, 프로그램 실행 시 메모리의 특정 주소 공간에 로드되도록 하는 역할을 수행한다.
링커를 통해 바이너리 내부 모듈들의 배치 구조를 알 수 있으므로 대부분의 심벌을 참조할 수 있다. 다만 외부 라이브러리 참조는 해당 라이브러리의 의존성 여부에 따라 불가능할 수도 있다. 정적 라이브러리는 문제없이 참조할 수 있다.
동적(공유) 라이브러리는 모든 프로그램이 함께 공유하는 메모리 공간에 배치된다. 따라서 메모리 공간에 한번 로드돼 있다면 다음에 필요할 때마다 기존의 복사된 내용을 공유하는 방식을 사용한다.
링킹 과정에서는 이런 주소를 모르므로 참조를 할 수 없다. 대신 해당 라이브러리를 참조하기 위한 심벌 정보만 바이너리에 남겨 둔다. 이런 방식의 참조는 바이너리가 실제로 실행돼 메모리에 로드될 때 확인할 수 있다.
Symbols and Stripped Binaries
- 심벌(symbol)
컴파일러는 함수와 변수명들을 심벌이라는 일종의 기호를 이용해 각 이름들을 처리하고, 바이너리 코드와 데이터를 각 심벌의 관계로 기록한다.
예를 들어 함수 심벌은 각 함수명과 시작 주소, 전체 크기에 대한 정보로 연결된다. 심벌은 보통 링커에 의해 목적 파일들을 결합할 때 활용된다.
- 바이너리 스트립(strip)
스트립 된 바이너리를 일부 심벌만 남아있다. 남은 심벌들은 바이너리를 메모리에 로드할 때 동적으로 의존성 문제를 해결하기 위해 필요한 정보들이다.
Loading and Executing a Binary
이번에는 바이너리가 메모리에 로드되고 실행되는 과정을 살펴보자.
- Loading an ELF binary on a Linux-based system
하드디스크에 있는 바이너리가 메모리에 항상 그대로 적재되는 것은 아니다. 예를 들어 0으로 초기화된 데이터가 많다면 축소돼 저장된다. 또한 영역들의 순서가 바뀌거나 메모리에 로드되지 않는 부분도 있다.
바이너리를 실행하면 OS는 새로운 프로세스를 설정하고 이를 위한 가상 메모리 주소를 준비한다. 그리고 인터프리터를 해당 프로세스의 가상 메모리와 연결한다.
일반 사용자 프로그램은 바이너리를 로드하고 필수적인 재배치 작업을 수행하는 방식이 정해져 있다. 리눅스의 인터프리터는 보통 ld-linux.so
라는 공유 라이브러리를 사용하고, 윈도우는 ntdll.dll
파일 내부에 인터프리터 기능이 구현되어 있다.
인터프리터 구동 이후 커널이 제어 권한을 부여하여 인터프리터가 해당 바이너리를 사용자 영역에서 실행된다. 인터프리터는 바이너리를 가상 메모리 공간에 적재한다. 그리고 바이너리가 필요로 하는 동적 라이브러리를 판단한다.
인터프리터는 mmap과 같은 함수를 이용해 라이브러리를 가상 메모리 공간에 연결하고, 바이너리의 코드 영역에 동적 라이브러리 참조를 위한 주솟값을 채워 넣어 재배치 과정을 마무리한다.
하지만 실제로는 라이브러리 함수의 참조 과정을 이렇게 즉각적으로 진행하지는 않고, 함수 호출이 필요한 첫 순간에 인터프리터가 참조 절차를 수행하는 지연 바인딩(lazy binding) 방식을 수행한다.
참고 및 인용
[1] https://practicalbinaryanalysis.com/
[2] http://www.acornpub.co.kr/book/binary-analysis
[3] https://hexterisk.github.io/blog/posts/
Uploaded by Notion2Tistory v1.1.0