C 파일을 컴파일할 때는 다음 네 가지 요소로 구성된 파이프라인으로 진입한다.
C 소스 파일의 모음(collection)을 빌드하면 하나 또는 여러 개의 목적 파일을 만들 수 있다. 목적 파일의 종류로는 다음과 같은 것들이 있다.
플랫폼(platform)이란 특정 하드웨어(또는 아키텍처)에서 실행되는 운영체제의 결합(combination)이다. 플랫폼에서 운영체제는 소프트웨어, 아키텍처는 하드웨어 컴포넌트를 의미한다.
크로스 플랫폼 소프트웨어는 상이한 플랫폼에 따라 상이한 이진 파일(최종 목적 파일)과 인스톨러를 사용한다. 하지만 이식 가능한(portable) 소프트웨어는 모든 플랫폼에서 같은 이진 파일과 인스톨러를 사용한다.
C 언어 코드베이스는 두 종류의 파일로 구성된다.
.h인 파일.c인 파일함수 선언부는 반환형(return type)과 함수 시그니처(function signature)로 구성된다.
코드 박스 2-1 average 함수의 선언
double average(int*, int);
코드 박스 2-2 average 함수의 정의
double average(int* array, int length) {
if (length <= 0) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; ++i) {
sum += array[i];
}
return sum / length;
}
모든 함수, 구조체, 전역 변수는 변환 단위(transition unit)에서 특정 선언에 관한 정의가 둘 이상일 때 컴파일 오류를 발생시킨다.
헤더 파일 1개, 소스 파일 2개로 구성된 예제를 통해 헤더 파일이 두 소스 파일을 연결하는 관계를 확인해 보자.
코드 박스 2-3 [예제 2-1]의 헤더 파일
#ifndef EXTREMEC_EXAMPLES_CHAPTER_2_1_H
#define EXTREMEC_EXAMPLES_CHAPTER_2_1_H
typedef enum {
NONE,
NORMAL,
SQUARED
} average_type_t;
// function declaration
double avg(int*, int, average_type_t);
#endif
헤더 파일에는 열거형과 avg 함수의 전방 선언(forward declaration)이 포함되며, 헤더 가드문(header guard statement)에 의해 보호된다.
코드 박스 2-4 avg 함수의 정의를 포함하는 소스 파일
#include "2_1.h"
double avg(int* array, int length, average_type_t type) {
if (length <= 0 || type == NONE) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; ++i) {
if (type == NORMAL) {
sum += array[i];
} else if (type == SQUARED) {
sum += array[i] * array[i];
}
}
return sum / length;
}
코드 박스 2-5 [예제 2-1]의 main 함수
#include <stdio.h>
#include "2_1.h"
int main(int argc, char** argv) {
// array declaration
int array[5];
// fill the array with values
array[0] = 2;
array[1] = -3;
array[2] = 5;
array[3] = -7;
array[4] = 11;
// calculate the average of the array with 'avg' function
double average = avg(array, 5, NORMAL);
printf("The average: %f\n", average);
average = avg(array, 5, SQUARED);
printf("The squared average: %f\n", average);
return 0;
}
C/C++ 프로젝트의 빌드는 코드베이스 내의 모든 소스 파일을 컴파일해 재배치 가능한 목적 파일(relocatable object file)을 생성하고, 이를 결합해 정적 라이브러리(static library) 또는 실행 이진 파일(executable binary)과 같은 최종 결과물을 생성하는 것이다.
빌드 과정에서 두 가지 중요한 규칙이 있다.
C/C++ 모범 사례를 준수한 코드의 경우 소스 파일이 다른 소스 파일을 포함하지 않기 때문에 각 파일의 컴파일이 독립적으로, 동시에 실행될 수 있다.
컴파일 전 전처리기는 헤더 파일의 내용을 종합해 하나의 C 코드 몸체로 만든다. 이 단계에서 전처리기 지시자(preprocessor directive)가 변환 단위(또는 컴파일 단위)로 변환된다.
gcc의 -E 옵션을 사용하면 소스 코드를 변환 단위로 덤프하여 전처리된 코드를 확인할 수 있다.
셀 박스 2-1 ExtremeC_examples_chapter2_1.c를 컴파일해 생성한 변환 단위
$ gcc -E 2_1.c
# 0 "2_1.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "2_1.c"
# 1 "2_1.h" 1
typedef enum {
NONE,
NORMAL,
SQUARED
} average_type_t;
double avg(int*, int, average_type_t);
# 2 "2_1.c" 2
double avg(int* array, int length, average_type_t type) {
if (length <= 0 || type == NONE) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; ++i) {
if (type == NORMAL) {
sum += array[i];
} else if (type == SQUARED) {
sum += array[i] * array[i];
}
}
return sum / length;
}
헤더 파일의 모든 선언, 선언에 의해 포함된 헤더 파일의 내용은 재귀적으로 변환 단위에 복사된다.
컴파일 단계는 변환 단위를 입력으로 받아 어셈블리 코드(assembly code)를 출력한다.
어셈블리 코드는 .s 확장자가 붙으며, -S 옵션을 사용해 덤프할 수 있다.
셀 박스 2-2 ExtremeC_examples_chapter2_1.c를 컴파일해 생성된 어셈블리 코드
$ gcc -S 2_1.c
$ cat 2_1.s
.file "2_1.c"
.text
.globl avg
.type avg, @function
avg:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movq %rdi, -24(%rbp)
movl %esi, -28(%rbp)
movl %edx, -32(%rbp)
cmpl $0, -28(%rbp)
jle .L2
cmpl $0, -32(%rbp)
jne .L3
.L2:
pxor %xmm0, %xmm0
jmp .L4
.L3:
pxor %xmm0, %xmm0
movsd %xmm0, -8(%rbp)
movl $0, -12(%rbp)
jmp .L5
.L8:
cmpl $1, -32(%rbp)
jne .L6
movl -12(%rbp), %eax
cltq
leaq 0(,%rax,4), %rdx
movq -24(%rbp), %rax
addq %rdx, %rax
movl (%rax), %eax
pxor %xmm0, %xmm0
cvtsi2sdl %eax, %xmm0
movsd -8(%rbp), %xmm1
addsd %xmm1, %xmm0
movsd %xmm0, -8(%rbp)
jmp .L7
.L6:
cmpl $2, -32(%rbp)
jne .L7
movl -12(%rbp), %eax
cltq
leaq 0(,%rax,4), %rdx
movq -24(%rbp), %rax
addq %rdx, %rax
movl (%rax), %edx
movl -12(%rbp), %eax
cltq
leaq 0(,%rax,4), %rcx
movq -24(%rbp), %rax
addq %rcx, %rax
movl (%rax), %eax
imull %edx, %eax
pxor %xmm0, %xmm0
cvtsi2sdl %eax, %xmm0
movsd -8(%rbp), %xmm1
addsd %xmm1, %xmm0
movsd %xmm0, -8(%rbp)
.L7:
addl $1, -12(%rbp)
.L5:
movl -12(%rbp), %eax
cmpl -28(%rbp), %eax
jl .L8
pxor %xmm1, %xmm1
cvtsi2sdl -28(%rbp), %xmm1
movsd -8(%rbp), %xmm0
divsd %xmm1, %xmm0
.L4:
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size avg, .-avg
.ident "GCC: (Ubuntu 15.2.0-16ubuntu1) 15.2.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
컴파일러는 변환 단위를 구문 분석(parse)하고 이를 대상 아키텍처(또는 호스트 아키텍처)에 맞는 어셈블리 코드로 변환한다.
어셈블리(assembly) 단계의 목적은 어셈블리 코드에 기반해 기계 수준 명령어(machine-level instruction) 또는 기계어 코드(machine code)를 생성하는 것이다. 즉, 컴파일러가 생성한 어셈블리 코드로부터 재배치 가능한 목적 파일을 생성해야 한다.
재배치 가능한 목적 파일은 실행 불가능하며, 함수에 해당하는 기계 수준의 명령어 및 전역 변수와 같이 정적으로 결정된 값만을 포함할 수 있다.
아키텍처마다 고유한 어셈블러(assembler)를 가진다. 유닉스 계열의 어셈블러 도구는 as이다.
셀 박스 2-4 [예제 2-1]의 하나의 소스 파일의 어셈블리에서 목적 파일 생성
$ as 2_1.s -o 2_1.o
재배치 가능한 목적 파일은 대개 .o 확장자(Windows는 .obj)이고 -o 옵션을 사용해 목적 파일의 이름을 지정할 수 있다.
거의 모든 컴파일러가 -c 옵션 전달 시 컴파일 3단계를 수행해 소스 파일을 즉시 목적 파일로 변환한다.
셀 박스 2-5 [예제 2-1]의 소스 중 하나를 컴파일해 그에 해당하는 재배치 가능한 목적 파일 생성
$ gcc -c 2_1.c
셀 박스 2-6 [예제 2-1]의 소스 파일에서 재배치 가능한 목적 파일 생성
$ gcc -c 2_1.c -o impl.o
$ gcc -c 2_1_main.c -o main.o
엄밀하게 정의하면, C의 컴파일 파이프라인은 3단계까지 진행한 후 재배치 가능한 목적 파일을 생성하는 과정을 의미한다. 4단계를 모두 진행하는 것은 빌드 파이프라인을 의미한다.
링크(linking) 단계에서는 재배치 가능한 목적 파일을 결합해 실행 가능한 목적 파일을 생성한다.
모든 아키텍처는 제조사가 정의한 일련의 프로세서와 명령어 집합, 어셈블리어 등을 갖는다. 새로운 아키텍처를 위한 프로그램은 다음 선행 조건 두 가지를 충족하면 빌드할 수 있다.
선행 조건을 만족하면 소스 코드로부터 기계 수준의 명령어를 생성할 수 있다. 그러면 목적 파일 포맷(object file format)을 사용해 목적 파일 안에 기계 수준의 명령어를 저장할 수 있다.
그러므로 새로운 아키텍처를 위해 필요한 두 가지 도구는 C 컴파일러, 링커이다. 운영체제에서 이러한 도구를 지원할 시 새로운 플랫폼을 만들 수 있다.
유닉스 계열 운영체제는 모듈러 디자인이 지원되어 어셈블러, 컴파일러, 링커와 같은 기초 모듈을 정의하고, 그것들을 활용해 다른 모듈들도 정의하여 전체 시스템이 새로운 아키텍처에서 동작하도록 만들 수 있다.
셀 박스 2-7 ld 유틸리티를 직접 사용해 목적 파일을 링크하기
$ ld impl.o main.o
/usr/bin/x86_64-linux-gnu-ld.bfd: warning: cannot find entry symbol _start; defaulting to 0000000000401000
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':
2_1_main.c:(.text+0x7d): undefined reference to `printf'
/usr/bin/x86_64-linux-gnu-ld.bfd: 2_1_main.c:(.text+0xb9): undefined reference to `printf'
/usr/bin/x86_64-linux-gnu-ld.bfd: 2_1_main.c:(.text+0xd2): undefined reference to `__stack_chk_fail'
유닉스 계열 운영체제의 기본 링커는 ld이다. 오류가 발생하면서 printf, __stack_chk_fail 프로시저에 대한 정의를 요구하고 있다. 기본 링커를 사용하고자 한다면 이것들이 정의되어 있는 다른 목적 파일을 직접 링크해야 한다.
그래서 일반적으로 gcc를 사용해 목적 파일의 링크 과정을 더 간단하게 해결한다.
셀 박스 2-8 목적 파일을 링크하기 위해 gcc를 사용하기
$ gcc impl.o main.o
$ ./a.out
The average: 1.600000
The squared average: 41.600000
C 빌드 파이프라인은 크게 두 단계로 구성된다. 소스 코드를 재배치 가능한 목적 파일로 컴파일하는 단계, 그리고 재배치 가능한 목적 파일들을 링크해 실행 가능한 목적 파일을 빌드하는 단계이다.
전처리기는 컴파일 시점 전 파서를 기반으로 입력 파일을 구문 분석하고 단순한 텍스트 치환(substitution)을 기반으로 매크로를 확장하여 포함(inclusion)하는 작업을 수행한다. 그러므로 C 언어 문법 오류를 탐지할 수는 없다.
코드 박스 2-6 텍스트를 포함하는 C 코드
#include <stdio.h>
#define file 1000
Hello, this is just a simple text file but ending with .c extension!
This is not a C file for sure!
But we can preprocess it!
셀 박스 2-9 전처리를 거친 [코드 박스 2-6]의 예제 C 코드
$ gcc -E sample.c
...
Hello, this is just a simple text 1000 but ending with .c extension!
This is not a C 1000 for sure!
But we can preprocess it!
대부분의 유닉스 계열 운영체제는 cpp(C Pre-Processor) 도구를 지원한다. 이는 백그라운드에서 gcc 같은 C 컴파일러가 전처리를 위해 사용한다.
셀 박스 2-10 소스 코드를 전처리하기 위해 cpp 유틸리티 사용하기
$ cpp 2_1.c
# 0 "2_1.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "2_1.c"
# 1 "2_1.h" 1
typedef enum {
NONE,
NORMAL,
SQUARED
} average_type_t;
double avg(int*, int, average_type_t);
# 2 "2_1.c" 2
double avg(int* array, int length, average_type_t type) {
if (length <= 0 || type == NONE) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; ++i) {
if (type == NORMAL) {
sum += array[i];
} else if (type == SQUARED) {
sum += array[i] * array[i];
}
}
return sum / length;
}
전처리를 마친 소스 코드는 .i 확장자를 갖게 된다. 그러므로 이 파일을 다시 전처리하려고 시도하면 컴파일러는 경고를 발생시킨다.
셀 박스 2-11 .i 확장자를 가진, 이미 전처리된 파일을 clang 컴파일러로 보내기
$ clang -E 2_1.c > 2_1.i
$ clang -E 2_1.i
clang: warning: 2_1.i: previously preprocessed input [-Wunused-command-line-argument]
as, ld와 같은 도구는 플랫폼에 따라 호환 가능한 목적 파일을 생성할 때 주로 사용된다. 그리고 이 도구는 gcc 또는 다른 컴파일러와는 독립적으로 외부에 존재한다. 그러므로 컴파일러 안에 포함되지 않고, 컴파일러가 컴파일 단계에서만 사용하는 도구이다.
컴파일러는 변환 단위를 어셈블리 명령어로 번역한다. 아키텍처마다 프로세서와 명령어 집합이 상이하기 때문에 컴파일러는 아키텍처에 대응되는 올바른 어셈블리 코드를 생성할 책임을 갖는다.
C 컴파일러는 이 복잡한 컴파일 과정을 2단계로 구분했다.
컴파일러는 C 문법에 근거해 소스 코드를 분석해 중간 단계 자료 구조를 생성하고, 아키텍처에 의존적이지 않은 AST에 결과를 저장한다. 그러므로 AST 구조는 특정 언어의 문법에 종속적이지 않게 충분히 추상화되어야 한다.
그러므로 컴파일러 프론트엔드는 다른 언어를 지원할 수 있도록 충분한 변경이 가능하다. GCC(GNU Compiler Collection), LLVM(Low-Level Virtual Machine)은 C, C++, Java, Fortran 등 다양한 언어의 컴파일러에 포함된다.
컴파일러 백엔드는 완성된 AST를 최적화하고, 이를 기반으로 대상 아키텍처에 대응되는 어셈블리 코드를 생성한다.
코드 박스 2-7 AST를 생성하는 간단한 C 코드
int main() {
int var1 = 1;
double var2 = 2.5;
int var3 = var1 + var2;
return 0;
}
그림 2-1 [예제 2-2]에서 생성하고 덤프한 AST

clang은 llvm 컴파일러 백엔드를 위한 LLVM 개발자 그룹이 개발한 C 컴파일러 프론트엔드이다. LLVM 컴파일러 기반 프로젝트(LLVM Compiler Infrastructure Project)는 중간 표현(Intermediate representation) 또는 LLVM IR을 프론트엔드와 백엔드 사이의 추상 데이터 구조로 사용했다. 그러므로 위 출력은 IR이 생성한 결과이다.
유닉스 계열 운영체제는 as 유틸리티 프로그램으로 어셈블러를 불러올 수 있다. 하지만 같은 아키텍처에 상이한 유닉스 계열 운영체제들을 설치할 경우 이들 간 설치된 어셈블러는 다를 수도 있다. 그러므로 같은 하드웨어를 갖고 있어 기계 수준의 명령어 집합을 공유하더라도 생성된 목적 파일은 다를 수 있다.
즉, 운영체제마다 고유한 특정 이진 파일 포맷 또는 목적 파일 포맷(object file format)을 정의한다. 그러므로 아키텍처(또는 하드웨어)와 운영체제(또는 소프트웨어)는 목적 파일의 내용을 특정하는 두 가지 요소이다. 이 둘을 결합해 플랫폼이라는 용어로 지칭한다.
리눅스에서는 ELF(Executable and Linking Format) 파일 형식을 사용한다. 모든 실행 파일, 목적 파일, 공유 라이브러리가 이 형식을 공유한다. 즉, 리눅스에서는 어셈블러가 ELF 목적 파일을 생성한다.
C/C++ 프로젝트의 결과물(product) 또는 아티팩트(artifact)로 가능한 형태는 다음과 같은 것들이 있다.
.out, 윈도우에서는 .exe 확장자를 가진다..a, 윈도우에서는 .lib 확장자를 가진다..so, macOS에서는 .dylib, 윈도우에서는 .dll 확장자를 가진다.위 3가지 결과물은 모두 목적 파일로 지칭될 수 있기 때문에, 어셈블러가 중간 단계 결과물로 생성한 목적 파일을 지칭할 때는 재배치 가능한 목적 파일(relocatable object file)이라 지칭하는 것이 좋다.
실행 가능한 목적 파일은 메모리에 적재되어 프로세스(process)로 실행될 수 있다. 이 파일에는 기계 수준의 명령어가 실행되기 위한 진입점이 정의되어 있어야 하며, C 프로그램의 진입점은 main 함수이다. main 함수는 플랫폼에 따라 링커가 링크 단계에서 다른 명령어 묶음으로 대체한 후 호출된다.
정적 라이브러리는 재배치 가능한 목적 파일 몇 개를 포함하는 아카이브 파일에 불과하므로 유닉스 계열 시스템의 ar과 같은 기본 아카이브 프로그램에 의해 생성된다. 정적 라이브러리는 링크 시(link time) 다른 실행 파일에 연결되어 해당 실행 파일의 일부가 된다.
반면 동적 라이브러리(공유 목적 파일)은 링커가 직접 생성한다. 사용하기 전 실행 시(at runtime) 실행 중인 프로세스에 직접 적재되어야 한다. 그리고 동시에 서로 다른 여러 프로세스에서 사용할 수 있다.
목적 파일은 변환 단위에 대응되는 기계 수준의 명령어를 포함한다. 그리고 이 명령어는 심볼(symbol) 섹션 하위로 묶인다. 심볼은 링커가 목적 파일 여러 개를 하나의 큰 파일로 결합하는 방식을 설명하는 컴포넌트이다.
코드 박스 2-8 [예제 2-3]의 함수 2개를 정의하는 코드
int average(int a, int b) {
return (a + b) / 2;
}
int sum(int* numbers, int count) {
int sum = 0;
for (int i = 0; i < count; ++i) {
sum += numbers[i];
}
return sum;
}
셀 박스 2-12 [예제 2-3]의 소스 파일 컴파일하기
$ gcc -c 2_3.c -o target.o
우선 gcc를 사용해 코드를 목적 파일로 컴파일한다.
셀 박스 2-13 재배치 가능한 목적 파일에서 정의된 심볼을 보게 해주는 nm 유틸리티 사용하기
$ nm target.o
0000000000000000 T average
0000000000000021 T sum
다음으로 nm을 사용해 목적 파일의 심볼을 확인하면 정의했던 함수의 이름을 확인할 수 있다.
셀 박스 2-14 재배치 가능한 목적 파일의 심볼 테이블을 보기 위해 readelf 유틸리티 사용하기
$ readelf -s target.o
Symbol table '.symtab' contains 5 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS 2_3.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 33 FUNC GLOBAL DEFAULT 1 average
4: 0000000000000021 73 FUNC GLOBAL DEFAULT 1 sum
readelf 유틸리티를 사용하면 목적 파일에 존재하는 심볼 테이블(symbol table)을 확인할 수 있다.
셀 박스 2-15 재배치 가능한 목적 파일에서 정의된 심볼의 명령어를 보기 위한 objdump 유틸리티 사용하기
$ objdump -d target.o
target.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <average>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 55 fc mov -0x4(%rbp),%edx
11: 8b 45 f8 mov -0x8(%rbp),%eax
14: 01 d0 add %edx,%eax
16: 89 c2 mov %eax,%edx
18: c1 ea 1f shr $0x1f,%edx
1b: 01 d0 add %edx,%eax
1d: d1 f8 sar $1,%eax
1f: 5d pop %rbp
20: c3 ret
0000000000000021 <sum>:
21: f3 0f 1e fa endbr64
25: 55 push %rbp
26: 48 89 e5 mov %rsp,%rbp
29: 48 89 7d e8 mov %rdi,-0x18(%rbp)
2d: 89 75 e4 mov %esi,-0x1c(%rbp)
30: c7 45 f8 00 00 00 00 movl $0x0,-0x8(%rbp)
37: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
3e: eb 1d jmp 5d <sum+0x3c>
40: 8b 45 fc mov -0x4(%rbp),%eax
43: 48 98 cltq
45: 48 8d 14 85 00 00 00 lea 0x0(,%rax,4),%rdx
4c: 00
4d: 48 8b 45 e8 mov -0x18(%rbp),%rax
51: 48 01 d0 add %rdx,%rax
54: 8b 00 mov (%rax),%eax
56: 01 45 f8 add %eax,-0x8(%rbp)
59: 83 45 fc 01 addl $0x1,-0x4(%rbp)
5d: 8b 45 fc mov -0x4(%rbp),%eax
60: 3b 45 e4 cmp -0x1c(%rbp),%eax
63: 7c db jl 40 <sum+0x1f>
65: 8b 45 f8 mov -0x8(%rbp),%eax
68: 5d pop %rbp
69: c3 ret
objdump 도구를 사용하면 기계 수준 명령어의 디스어셈블리를 확인할 수 있다.
그러므로 링커는 재배치 가능한 목적 파일들로부터 모든 심볼을 수집하고, 실행 파일을 생성할 때 활용할 수 있다.
코드 박스 2-9 [예제 2-4]의 함수 선언
#ifndef EXTREMEC_EXAMPLES_CHAPTER_2_4_DECLES_H
#define EXTREMEC_EXAMPLES_CHAPTER_2_4_DECLES_H
int add(int, int);
int multiply(int, int);
#endif
코드 박스 2-10 add 함수의 정의
int add(int a, int b) {
return a + b;
}
코드 박스 2-11 multiply 함수의 정의
int multiply(int a, int b) {
return a * b;
}
코드 박스 2-12 [예제 2-4]의 main 함수
#include "2_4.decls.h"
int main(int argc, char** argv) {
int x = add(4, 5);
int y = multiply(9, x);
return 0;
}
마지막 소스 파일은 함수 두 개의 선언을 얻기 위해 헤더 파일을 포함해야 한다. 이때 함수 정의부를 알지 못하더라도 추후 링커가 컴파일 후 재배치 가능한 목적 파일들로부터 심볼을 수집하고 연결하는 과정에서 정의를 연결한다.
셀 박스 2-16 [예제 2-4]에 해당하는 재배치 가능한 목적 파일의 모든 소스 파일 컴파일하기
$ gcc -c 2_4_add.c -o add.o
$ gcc -c 2_4_multiply.c -o multiply.o
$ gcc -c 2_4_main.c -o main.o
셀 박스 2-17 add.o에서 정의된 심볼 목록
$ nm add.o
0000000000000000 T add
셀 박스 2-18 multiply.o에서 정의된 심볼 목록
$ nm multiply.o
0000000000000000 T multiply
셀 박스 2-19 main.o에서 정의된 심볼 목록
$ nm main.o
U add
0000000000000000 T main
U multiply
main.o의 심볼 테이블에서 add, multiply 심볼은 U 또는 미해결된(unresolved) 상태로 표기된다. 이는 컴파일만으로 심볼의 정의를 찾을 수 없었다는 의미이다.
만약 링커가 미해결된 심볼의 정의를 찾을 수 없다면 링크 오류(linkage error)를 발생시킨다.
셀 박스 2-20 모든 목적 파일을 함께 링크하기
$ gcc add.o multiply.o main.o
셀 박스 2-21 add.o와 main.o 파일만으로 링크하기
$ gcc add.o main.o
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':
2_4_main.c:(.text+0x30): undefined reference to `multiply'
collect2: error: ld returned 1 exit status
셀 박스 2-22 main.o와 multiply.o 파일만으로 링크하기
$ gcc main.o multiply.o
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':
2_4_main.c:(.text+0x1e): undefined reference to `add'
collect2: error: ld returned 1 exit status
셀 박스 2-23 add.o와 multiply.o 파일만으로 링크하기
$ gcc add.o multiply.o
/usr/bin/x86_64-linux-gnu-ld.bfd: /usr/lib/gcc/x86_64-linux-gnu/15/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
(.text+0x1b): undefined reference to `main'
collect2: error: ld returned 1 exit status
아무 옵션 없이 gcc 실행 시 인자로 주어진 목적 파일을 사용해 링크 단계를 수행하여 실행 목적 파일을 생성한다.
main 심볼은 /usr/lib/gcc/x86_64-Linux-gnu/7/../../../x86_64-Linux-gnu/Scrtl.o 파일에 정의된 _start 함수가 생성한다. 이것은 기본 C 목적 파일 그룹에 속하는 파일로, gcc 번들에 속하여 리눅스용으로 컴파일되어 있다.
계획대로 링크 단계가 실행되어도 최종 이진 파일 단계가 기대처럼 되지 않는 경우가 드물게 있다.
코드 박스 2-13 [예제 2-5]의 add 함수의 정의
int add(int a, int b, int c, int d) {
return a + b + c + d;
}
코드 박스 2-14 [예제 2-5]의 main 함수
#include <stdio.h>
int add(int, int);
int main(int argc, char** argv) {
int x = add(5, 6);
printf("Result: %d\n", x);
return 0;
}
위 예제에서 add 함수는 오버로드(overload)되어 있다. main 함수가 사용하는 add 함수는 매개변수가 2개이고, 별도로 정의한 add 함수는 4개이다.
셀 박스 2-24 [예제 2-25] 빌드하기
$ gcc -c 2_5_add.c -o add.o
$ gcc -c 2_5_main.c -o main.o
$ gcc add.o main.o -o 2_5.out
셀 박스 2-25 [예제 2-5]를 두 번 실행한 뒤의 이상한 결과
$ ./2_5.out
Result: -1334046765
$ ./2_5.out
Result: 2139763251
함수 시그니처에 관한 정보는 심볼 수준에서 남아 있지 않기 때문에 잘못 링크를 수행하면 예상치 못한 동작이 발생할 수 있다.
코드 박스 2-15 [예제 2-6]의 add 함수에 관한 첫 번째 정의
int add(int a, int b, int c, int d) {
return a + b + c + d;
}
코드 박스 2-16 [예제 2-6]의 add 함수에 관한 두 번째 정의
int add(int a, int b) {
return a + b;
}
셀 박스 2-26 [예제 2-6]의 소스 파일을 목적 파일로 컴파일하기
$ gcc -c 2_6_add_1.c -o add_1.o
$ gcc -c 2_6_add_2.c -o add_2.o
셀 박스 2-27 add_1.o에서 add 심볼의 디스어셈블리를 보기 위해 objdump 사용하기
$ objdump -d add_1.o
add_1.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 89 55 f4 mov %edx,-0xc(%rbp)
11: 89 4d f0 mov %ecx,-0x10(%rbp)
14: 8b 55 fc mov -0x4(%rbp),%edx
17: 8b 45 f8 mov -0x8(%rbp),%eax
1a: 01 c2 add %eax,%edx
1c: 8b 45 f4 mov -0xc(%rbp),%eax
1f: 01 c2 add %eax,%edx
21: 8b 45 f0 mov -0x10(%rbp),%eax
24: 01 d0 add %edx,%eax
26: 5d pop %rbp
27: c3 ret
셀 박스 2-28 add_2.o에서 add 심볼의 디스어셈블리를 보기 위해 objdump 사용하기
$ objdump -d add_2.o
add_2.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 55 fc mov -0x4(%rbp),%edx
11: 8b 45 f8 mov -0x8(%rbp),%eax
14: 01 d0 add %edx,%eax
16: 5d pop %rbp
17: c3 ret
함수가 호출되면 스택 프레임(stack frame)이 생성된다. 디스어셈블리 내용으로부터 스택 프레임을 생성하기 위한 코드를 확인할 수 있다.
코드 박스 2-17 스택 프레임에서 첫 번째 add 함수의 레지스터로 인수를 복사하는 어셈블리 명령어
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 89 55 f4 mov %edx,-0xc(%rbp)
11: 89 4d f0 mov %ecx,-0x10(%rbp)
%rbp 레지스터는 현재 스택 프레임을 가리키고 있다. 이를 활용해 4개의 값을 메모리 주소로부터 복사하고 있음을 확인할 수 있다.
코드 박스 2-18 스택 프레임에서 두 번째 add 함수의 레지스터로 인수를 복사하는 어셈블리 명령어
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
매개변수가 2개인 add 함수의 경우 스택 프레임에 2개의 값을 복사하고 있다. 그러므로 이 함수를 호출하기를 기대했지만 매개변수가 4개인 add 함수가 호출될 경우, 스택 프레임의 경계를 넘어선 접근이 발생하면서 예측 불가능한 출력이 발생한다.
이 현상은 입력 자료형에 따라 함수 심볼 이름을 변경하는 방식인 네임 맹글링(name mangling)으로 방지할 수 있다. 주로 C++에서 함수 오버로딩(overloading) 특성을 위해 사용된다.
GNU C++ 컴파일러인 g++을 사용해 C++의 네임 맹글링 작동 방식을 확인할 수 있다.
셀 박스 2-29 C++ 컴파일러로 생성한 목적 파일의 심볼 테이블을 보기 위해 readelf 사용하기
$ g++ -c 2_6_add_1.c -o add_1.o
$ g++ -c 2_6_add_2.c -o add_2.o
$ readelf -s add_1.o
Symbol table '.symtab' contains 4 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS 2_6_add_1.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 40 FUNC GLOBAL DEFAULT 1 _Z3addiiii
$ readelf -s add_2.o
Symbol table '.symtab' contains 4 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS 2_6_add_2.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 24 FUNC GLOBAL DEFAULT 1 _Z3addii
add 함수의 상이한 오버로드에 관해 상이한 심볼 이름을 부여했다. 매개변수 4개 오버로드는 _Z3addiiii, 2개 오버로드는 _Z3addii이다. 심볼 이름의 i는 정수 입력 매개변수 중 하나를 의미한다.