C 언어 빌드 과정
빌드 과정 한눈에 보기
C 언어로 작성된 소스 코드가 실행 파일이 되기까지의 빌드 과정은 4단계로 이루어진다.
- 전처리 (Preprocessing)
- 컴파일 (Compilation)
- 어셈블 (Assembling)
- 링크 (Linking)
간략히 설명할 때 컴파일과 링크, 2단계로 구분하기도 한다.
이때 ’컴파일’은 전처리·컴파일·어셈블을 아우르는 넓은 의미로 쓰인다.
세 단계 모두 코드를 단계적으로 변환·번역하는 과정으로 보기 때문이다.
링크는 앞선 단계들과 성격이 다르다.
이미 번역된 오브젝트 파일들을 하나로 묶고, 미해결 심볼(unresolved symbol)을 실제 정의와 연결해 최종 실행 파일을 만들어낸다.
전체 흐름을 요약하면 다음과 같다.
소스 코드 (Source Code) (.c / .h)
|
v
전처리 (Preprocessing)
|
v
전처리된 소스 (Preprocessed Source) (.i)
|
v
컴파일 (Compilation)
|
v
어셈블리 코드 (Assembly Code) (.s)
|
v
어셈블 (Assembling)
|
v
오브젝트 파일 (Object File) (.o / .obj)
|
v
링크 (Linking)
|
v
실행 파일 (Executable File)
전처리 (Preprocessing)
전처리 단계에서는 전처리기(preprocessor)가 소스 코드를 입력받아 전처리된 소스(.i)를 출력한다.
실제 번역은 일어나지 않으며, 다음 단계를 위한 텍스트 가공, 치환이 이루어진다.
주요 처리 내용은 다음과 같다.
- 주석 제거: 모든 주석을 제거한다.
- 매크로 치환:
#define으로 정의한 매크로를 해당 값으로 대체한다. #include: 지정한 헤더 파일의 내용을 해당 위치에 그대로 삽입한다.- 조건부 컴파일:
#if,#ifdef등의 조건에 따라 포함할 코드 블록을 결정한다.
아래 소스 코드를 예시로 살펴보자.
#include <stdio.h>
int main(void)
{
/* Hello World */
printf("Hello World!");
}
gcc -E main.c -o main.i
-E 옵션은 전처리 단계까지만 수행하고 그 결과를 표준 출력(stdout)으로 출력한다.
위 명령어를 실행하면 #include <stdio.h>는 stdio.h의 내용으로 대체되고, 주석은 제거된다.
전처리가 완료된 결과물은 두 가지 표현으로 부른다.
Preprocessed Source는 전처리 결과를 파일 형태로 본 표현으로, 보통 .i 파일을 가리킨다.
Translation Unit은 컴파일러가 하나의 단위로 컴파일하는 코드 덩어리를 가리킨다.
컴파일 (Compilation)
컴파일 단계에서는 컴파일러(compiler)가 전처리된 소스(.i)를 입력받아 어셈블리 코드(.s)를 만든다.
소스 코드의 문법과 의미를 분석하고, 그 결과를 대상 아키텍처의 어셈블리 명령으로 변환한다.
아래 세 파일을 예시로 살펴보자.
/* counter.h */
extern int g_count;
void increment(void);
void decrement(void);
/* counter.c */
#include "counter.h"
int g_count = 0;
void increment(void)
{
g_count++;
}
void decrement(void)
{
g_count--;
}
/* main.c */
#include <stdio.h>
#include "counter.h"
int main(void)
{
increment();
decrement();
printf("%d\n", g_count);
return 0;
}
gcc -S main.c -o main.s
-S 옵션은 컴파일 단계까지만 수행하고 결과를 출력한다.
위 명령어를 실행하면 main.c를 컴파일한 결과로 main.s가 생성된다.
생성된 어셈블리 코드에서 핵심 부분만 보면 다음과 같다.
call increment@PLT
call decrement@PLT
movl g_count(%rip), %eax
call printf@PLT
main.c는 헤더 파일만 #include하고 있다.
헤더 파일에는 선언(declaration)만 있고, 실제 구현인 정의(definition)는 counter.c에 있다.
따라서 main.c를 컴파일하는 시점에는 g_count, increment, decrement, printf가 어디에 정의되어 있는지, 메모리 주소가 무엇인지 모른다.
그래도 컴파일은 가능하다.
컴파일러는 다른 소스 파일의 정의를 직접 찾지 않고, 어셈블리 코드 안에 심볼 이름을 그대로 남겨둔다.
미해결 심볼(unresolved symbol)을 실제 정의와 연결하고 주소를 확정하는 일은 링크 단계에서 처리된다.
한편 C 언어는 크로스 플랫폼(cross-platform) 언어라고 말한다.
이는 실행 파일이 아니라 소스 코드가 크로스 플랫폼이라는 의미다.
C 컴파일러가 지원되지 않는 플랫폼이 드물기 때문에, 동일한 소스 코드를 여러 플랫폼에서 각각 컴파일해 사용할 수 있다.
다만 컴파일을 거쳐 어셈블리 코드가 생성된 시점부터는 이야기가 달라진다.
어셈블리 코드는 특정 아키텍처와 운영체제를 기준으로 만들어지기 때문에, 그 시점부터는 해당 환경에 종속된다.
위 예시의 어셈블리 코드도 x86-64 Linux 기준이며, 다른 아키텍처에서는 다른 결과가 생성된다.
어셈블 (Assembling)
어셈블 단계에서는 어셈블러(assembler)가 어셈블리 코드(.s)를 입력받아 오브젝트 파일을 만든다.
오브젝트 파일의 확장자는 환경마다 다르다.
Linux와 macOS에서는 .o, Windows와 MSVC에서는 .obj를 사용한다.
어셈블러는 어셈블리 명령을 CPU가 이해할 수 있는 머신 코드(machine code)로 변환하고, 그 결과를 오브젝트 파일에 기록한다.
오브젝트 파일에는 머신 코드가 들어 있지만, 그 자체가 곧바로 실행 파일인 것은 아니다.
미해결 심볼이 남아 있고, 최종 메모리 주소도 확정되지 않았기 때문이다.
오브젝트 파일에는 다음과 같은 정보가 담긴다.
- 머신 코드: 실제 명령어가 담긴 코드 섹션(.text)
- 데이터: 초기화된 전역 변수 등이 담긴 데이터 섹션(.data), 0으로 초기화되거나 초기화되지 않은 전역 변수 등이 담긴 BSS 섹션(.bss)
- 심볼 테이블: 정의된 심볼과 미해결 심볼 정보
- 재배치 정보: 링커가 주소를 확정할 때 어느 위치를 수정해야 하는지 기록한 정보
이 정보들을 바탕으로 실행 가능한 형태를 만드는 일은 링크 단계에서 처리된다.
gcc -c main.c -o main.o
-c 옵션은 어셈블 단계까지만 수행하고 오브젝트 파일을 생성한다.
위 명령어를 실행하면 main.c를 어셈블한 결과로 main.o가 생성된다.
오브젝트 파일은 바이너리이기 때문에 텍스트 편집기로 읽기 힘들다.
내용을 직접 확인하려면 xxd 같은 헥스 에디터가 필요하다.
nm 명령어를 사용하면 오브젝트 파일의 심볼 테이블을 확인할 수 있다.
nm main.o
U increment
U decrement
U g_count
U printf
0000000000000000 T main
T는 해당 심볼이 코드 섹션(.text)에 정의되어 있음을 나타낸다.
따라서 main은 main.o 안에 정의되어 있다.
U는 미해결(unresolved) 심볼을 나타낸다.
main.o는 increment, decrement, g_count, printf를 참조하지만, 이 파일 안에는 그 정의가 없다.
심볼 앞의 숫자는 최종 메모리 주소가 아니다.
오브젝트 파일 단계에서는 각 심볼이 자신이 속한 섹션 안에서 어느 위치(offset)에 있는지를 나타낸다.
여기서는 main이 .text 섹션의 시작 위치에 있기 때문에 0으로 표시된다.
이번에는 counter.o의 심볼 테이블을 보자.
nm counter.o
0000000000000000 T increment
000000000000001a T decrement
0000000000000000 B g_count
increment와 decrement는 함수이므로 코드 섹션(.text)에 정의되어 있고, T로 표시된다.
decrement 앞의 주소는 .text 섹션 안에서 decrement가 시작되는 위치를 나타낸다.
B는 해당 심볼이 BSS 섹션(.bss)에 정의되어 있음을 나타낸다.
초기값이 0이거나 명시적으로 초기화되지 않은 전역 변수는 보통 .bss에 들어간다.
그래서 서로 다른 파일이나 서로 다른 섹션에 있는 심볼은 같은 위치 값을 가질 수 있다.
예를 들어 counter.o에서 increment와 g_count가 모두 0으로 표시되지만,
하나는 .text 섹션의 시작 위치이고 다른 하나는 .bss 섹션의 시작 위치다.
이처럼 main.o에는 미해결 심볼이 남아 있지만, counter.o에는 그중 increment, decrement, g_count의 정의가 들어 있다.
따라서 링크 단계에서 main.o의 미해결 심볼 중 일부가 counter.o의 정의와 연결된다.
미해결 심볼을 실제 정의와 연결하고, 각 심볼의 최종 주소를 확정하는 일은 링크 단계에서 처리된다.
링크 (Linking)
링크 단계에서는 링커(linker)가 여러 오브젝트 파일을 입력받아 하나의 실행 파일을 만든다.
각 오브젝트 파일에 남겨진 미해결 심볼을 가능한 정의와 연결하고, 실행 파일 이미지 안에서 섹션과 심볼의 위치를 배치한다.
동적 링크가 필요한 심볼은 실행 시점에 해석할 수 있도록 동적 심볼 정보와 재배치 정보를 남긴다.
gcc main.o counter.o -o main
위 명령어를 실행하면 main.o와 counter.o를 링크한 결과로 실행 파일 main이 생성된다.
링크 전후의 심볼 테이블을 비교해보자.
nm main.o
U increment
U decrement
U g_count
U printf
0000000000000000 T main
nm counter.o
0000000000000000 T increment
000000000000001a T decrement
0000000000000000 B g_count
main.o에서 U로 표시된 심볼들이 counter.o에는 T, B로 정의되어 있다.
링커는 이 두 파일을 합쳐 미해결 심볼을 실제 정의와 연결한다.
nm main
0000000000001183 T increment
000000000000119d T decrement
0000000000004014 B g_count
0000000000001149 T main
U printf@GLIBC_2.2.5
링크가 완료된 실행 파일에서는 increment, decrement, g_count, main의 위치가 배치된 것을 확인할 수 있다.
오브젝트 파일에서는 각 심볼의 주소가 섹션 시작점을 기준으로 한 offset으로 표시되었지만, 링크 후에는 실행 파일 안에서의 가상 주소로 바뀐다.
printf는 여전히 U로 남아있다.
일반적인 Linux 환경에서 GCC는 기본적으로 C 표준 라이브러리인 libc를 동적으로 링크한다.
그래서 printf의 실제 코드는 실행 파일 안에 복사되지 않고, 실행 시 동적 링커가 공유 라이브러리에서 찾아 연결한다.
@GLIBC_2.2.5는 printf가 glibc의 GLIBC_2.2.5 심볼 버전에 대한 참조임을 나타낸다.
동적 링커는 실행 시 이 버전을 제공하는 공유 라이브러리에서 심볼을 해석한다.
다만 nm으로 확인한 가상 주소는 링크 시점에 결정된 값이다.
실제 실행 시에는 각 심볼의 실제 주소가 기준 주소(base address)에 nm이 보여준 가상 주소를 더한 값이 된다.
심볼 간의 offset은 실행할 때마다 동일하지만, 절대 주소는 매번 달라진다.
단, 이것은 PIE가 활성화된 실행 파일에 해당하는 이야기다.
ASLR(Address Space Layout Randomization)은 실행마다 base address를 랜덤하게 바꾸는 기법이다.
PIE가 꺼진 실행 파일은 링크 시점에 고정된 로드 주소를 전제로 만들어진다.
따라서 ASLR이 활성화되어 있어도 실행 파일 본체의 위치는 보통 랜덤화되지 않는다.
다만 공유 라이브러리, 스택, 힙 등 다른 영역은 여전히 ASLR의 영향을 받을 수 있다.