1. 프롤로그 - undefined reference 에러의 공포
처음 C 프로젝트를 만들다가 이런 에러를 봤다.
undefined reference to 'calculate_sum'
collect2: error: ld returned 1 exit status
분명 함수를 만들었는데 "정의되지 않았다"는 게 무슨 소리지? 코드 실행도 안 돼서 결과를 볼 수가 없었다. 컴파일러가 뭐라고 하는지 하나도 모르겠고, gcc main.c라고 치면 되는 줄 알았는데 왜 안 되는지 알 수 없었다.
나중에 알고 보니 이건 컴파일 에러가 아니라 링킹 에러였다. 그리고 이 차이를 이해하려면 소스 코드가 실행 파일이 되는 전체 과정을 알아야 한다는 걸 깨달았다. 컴파일이랑 빌드가 같은 거라고 생각했던 과거의 나에게 이 글을 바친다.
2. 고민 - 컴파일이랑 빌드가 같은 거 아니야?
많은 초보 개발자들이 이렇게 생각한다. "컴파일하면 실행 파일 나오는 거 아니야?" 틀렸다. 컴파일은 빌드 과정의 일부일 뿐이다.
내가 이해한 방식으로 정리해본다. 빌드는 자동차 공장과 같다. 자동차 한 대를 만들기 위해서는:
- 설계도를 가져온다 (전처리) - 각 부품의 도면을 준비한다
- 부품을 만든다 (컴파일) - 엔진, 바퀴, 차체를 각각 제작한다
- 부품을 검사한다 (어셈블리) - 각 부품이 규격에 맞는지 확인한다
- 조립한다 (링킹) - 모든 부품을 합쳐서 완성된 자동차를 만든다
우리가 gcc main.c라고 치는 순간, 이 4단계가 자동으로 일어난다. 하지만 각 단계를 분리해서 볼 수 있다. 그리고 에러가 어느 단계에서 났는지 알면 문제를 빠르게 찾을 수 있다.
3. 아하 모멘트 - 빌드는 4단계 파이프라인이다
결국 이거였다. 빌드는 하나의 작업이 아니라 4개의 독립적인 프로그램이 차례로 실행되는 파이프라인이다. 이걸 이해하고 나니까 모든 게 명확해졌다.
main.c → [Preprocessor] → main.i → [Compiler] → main.s
↓
main.s → [Assembler] → main.o → [Linker] → a.out (실행 파일)
↓
라이브러리 (.a, .so)
ASCII로 표현하면 이렇다:
┌──────────┐ ┌──────────────┐ ┌────────┐
│ main.c │ --> │ cpp (전처리) │ --> │ main.i │
│ (소스) │ │ #include 처리 │ │ (순수C) │
└──────────┘ └──────────────┘ └────────┘
│
▼
┌──────────────┐ ┌────────┐
│ cc1 (컴파일) │ <-- │ main.i │
│ C -> ASM │ └────────┘
└──────────────┘
│
▼
┌────────┐ ┌──────────────┐
│ main.s │ --> │ as (어셈블) │
│ (어셈) │ │ ASM -> 기계어 │
└────────┘ └──────────────┘
│
▼
┌────────┐ ┌──────────────┐
│ main.o │ --> │ ld (링커) │
│ (목적) │ │ Object 합체 │
└────────┘ └──────────────┘
│ │
┌────────┴──────┐ │
│ lib.o, libc.so│ ------┘
└───────────────┘
│
▼
┌────────┐
│ a.out │
│ (실행) │
└────────┘
이제 각 단계를 직접 실행하면서 무슨 일이 일어나는지 확인해보자.
4. 깊이 파기 - 4단계를 하나씩 뜯어보자
간단한 C 프로그램으로 실험해보자.
// main.c
#include <stdio.h>
#define MAX_VALUE 100
int main() {
int x = MAX_VALUE;
printf("Value: %d\n", x);
return 0;
}
4.1. 전처리 (Preprocessing) - 복사+붙여넣기 기계
gcc -E main.c -o main.i
이 명령어는 전처리만 수행한다. main.i를 열어보면:
// 앞에 stdio.h의 내용이 몽땅 들어간다 (약 800줄)
// ... (생략) ...
typedef unsigned long size_t;
extern int printf(const char *, ...);
// ... (생략) ...
int main() {
int x = 100; // MAX_VALUE가 100으로 치환됨
printf("Value: %d\n", x);
return 0;
}
무슨 일이 일어났나?
#include <stdio.h>→stdio.h파일 내용 전체를 복사해서 붙여넣기#define MAX_VALUE 100→ 코드에서 MAX_VALUE를 모두 100으로 치환- 주석 제거
- 조건부 컴파일 처리 (
#ifdef,#ifndef)
결과적으로 순수한 C 코드가 된다. 더 이상 #으로 시작하는 전처리 지시자가 없다. 하지만 파일 크기가 10줄에서 1000줄이 넘게 커진다. 왜? stdio.h가 엄청 크기 때문이다.
와닿았던 비유: 전처리기는 복사기다. #include는 "이 파일을 복사해서 여기에 붙여넣어"라는 명령일 뿐이다.
4.2. 컴파일 (Compilation) - C를 어셈블리로 번역
gcc -S main.c -o main.s
이 명령어는 전처리 + 컴파일까지만 수행한다. main.s를 열어보면:
.file "main.c"
.section .rodata
.LC0:
.string "Value: %d\n"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $100, -4(%rbp) # x = 100
movl -4(%rbp), %eax
movl %eax, %esi
leaq .LC0(%rip), %rdi # printf 문자열 로드
movl $0, %eax
call printf@PLT # printf 호출
movl $0, %eax
leave
ret
무슨 일이 일어났나?
- C 언어를 어셈블리어(Assembly Language)로 번역
- CPU 명령어로 표현됨:
movl,pushq,call등 - 최적화가 여기서 일어남 (죽은 코드 제거, 루프 펼치기 등)
- 아직 사람이 읽을 수 있는 텍스트 파일
이 단계에서 문법 에러(Syntax Error)가 잡힌다. 세미콜론을 빼먹거나 변수 이름을 잘못 쓰면 여기서 에러가 난다.
4.3. 어셈블리 (Assembly) - 어셈블리를 기계어로
gcc -c main.c -o main.o
이 명령어는 어셈블까지 수행한다 (전처리 + 컴파일 + 어셈블). main.o는 바이너리 파일이라 텍스트 에디터로 열면 깨진 문자가 보인다.
hexdump -C main.o | head
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 01 00 3e 00 01 00 00 00 00 00 00 00 00 00 00 00 |..>.............|
무슨 일이 일어났나?
- 어셈블리 명령어를 기계어(Machine Code, 0과 1)로 1:1 변환
- CPU가 직접 실행할 수 있는 Opcode로 인코딩됨
- ELF (Executable and Linkable Format) 포맷으로 저장
.text(코드),.data(데이터),.bss(초기화 안 된 변수) 섹션으로 나뉨
중요: 이 파일은 아직 실행 불가능하다! 왜? printf 함수가 어디 있는지 모르기 때문이다. call printf라는 명령어는 있지만, printf의 실제 메모리 주소는 0x00000000으로 비어있다. 이걸 채워주는 게 다음 단계다.
4.4. 링킹 (Linking) - 조각들을 합친다
gcc main.o -o main
또는 링커를 직접 호출:
ld -o main main.o -lc -dynamic-linker /lib64/ld-linux-x86-64.so.2
무슨 일이 일어났나?
-
심볼 해석 (Symbol Resolution)
main.o에서printf를 호출하는데, 정의가 없다- 링커가 C 표준 라이브러리(
libc.so)에서printf를 찾는다 printf의 실제 주소를 연결한다
-
재배치 (Relocation)
- 여러
.o파일을 하나의 메모리 주소 공간으로 합친다 - 각 함수와 변수에 최종 메모리 주소를 할당한다
- GOT (Global Offset Table), PLT (Procedure Linkage Table) 설정
- 여러
-
실행 파일 생성
- ELF 헤더 추가 (OS가 이 파일을 어떻게 실행할지 정보)
- Entry Point 설정 (
_start함수, 나중에main을 호출함)
결과적으로 main (또는 a.out) 실행 파일이 생성되고, 이제 실행 가능하다:
./main
# 출력: Value: 100
받아들였던 깨달음: 컴파일러는 파일 단위로 일한다. 링커는 프로젝트 전체를 본다. 그래서 여러 파일로 나뉜 프로젝트에서는 링커가 필수다.
5. 적용 - 정적 vs 동적 라이브러리
처음에 이 개념을 받아들이기 어려웠다. "라이브러리를 연결한다"는 게 도대체 무슨 뜻일까?
도서관 비유로 이해했다:
-
정적 라이브러리 (
.a,.lib): 도서관에서 필요한 책을 복사해서 내 가방에 넣는다. 집에 가져가도 읽을 수 있다. 하지만 가방이 무겁다. 그리고 같은 책을 100명이 복사하면 종이 낭비다. -
동적 라이브러리 (
.so,.dll): 도서관에서 도서관 카드만 받는다. 집에서 책을 읽으려면 도서관이 열려있어야 한다. 가방은 가볍지만, 도서관이 문을 닫으면 못 읽는다 (dll not found에러).
정적 링킹 예제
# 정적 라이브러리 만들기
gcc -c mylib.c -o mylib.o
ar rcs libmylib.a mylib.o
# 정적 링킹
gcc main.c -L. -lmylib -static -o main_static
# 파일 크기 확인
ls -lh main_static
# 결과 - 약 800KB (libc가 통째로 들어감)
장점: 이 파일만 있으면 어디서든 실행된다. 의존성 걱정 없음. 단점: 파일 크기가 크다. 라이브러리 버그를 고쳐도 실행 파일을 다시 빌드해야 한다.
동적 링킹 예제
# 동적 라이브러리 만들기
gcc -shared -fPIC mylib.c -o libmylib.so
# 동적 링킹
gcc main.c -L. -lmylib -o main_dynamic
# 파일 크기 확인
ls -lh main_dynamic
# 결과 - 약 8KB (참조만 저장)
# 실행 시 필요한 라이브러리 확인
ldd main_dynamic
# linux-vdso.so.1
# libmylib.so => ./libmylib.so
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
장점: 파일 작다. 여러 프로그램이 하나의 .so를 공유해서 메모리 절약.
단점: 실행 시 라이브러리가 없으면 프로그램이 안 돌아간다. 윈도우의 "DLL Hell" 문제.
리눅스에서는 /lib, /usr/lib에 공통 라이브러리가 저장되고, LD_LIBRARY_PATH로 경로를 지정할 수 있다.
6. 빌드 시스템 - Makefile이 왜 필요한가
파일이 10개면 gcc a.c b.c c.c ... j.c라고 칠 수 있다. 파일이 1000개면? 손가락이 부러진다.
더 큰 문제는 증분 빌드(Incremental Build)다. a.c 하나만 수정했는데 1000개를 다 다시 컴파일하면 시간이 너무 오래 걸린다. Chrome 소스 코드는 컴파일하는 데 2시간 걸린다.
Make는 이 문제를 해결한다:
- 파일의 수정 시간(timestamp)을 확인한다
- 변경된 파일과 그 파일에 의존하는 파일만 다시 빌드한다
실제 Makefile 예제
# 컴파일러 설정
CC = gcc
CFLAGS = -Wall -Wextra -O2 -g
LDFLAGS = -lm
# 타겟 설정
TARGET = my_app
OBJS = main.o utils.o calculator.o
HEADERS = utils.h calculator.h
# 기본 타겟 ($ make)
all: $(TARGET)
# 링킹 단계 - 모든 .o 파일을 합쳐서 실행 파일 생성
# $@ = 타겟 이름 (my_app)
# $^ = 모든 의존성 (main.o utils.o calculator.o)
$(TARGET): $(OBJS)
@echo "Linking $@..."
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
@echo "Build successful!"
# 컴파일 단계 - .c 파일을 .o 파일로 변환
# $< = 첫 번째 의존성 (*.c)
# $@ = 타겟 이름 (*.o)
%.o: %.c $(HEADERS)
@echo "Compiling $<..."
$(CC) $(CFLAGS) -c $< -o $@
# 청소 ($ make clean)
clean:
@echo "Cleaning up..."
rm -f $(OBJS) $(TARGET)
@echo "Clean done!"
# 전체 재빌드 ($ make rebuild)
rebuild: clean all
# 디버그 빌드 ($ make debug)
debug: CFLAGS += -DDEBUG -O0
debug: clean all
# 릴리즈 빌드 ($ make release)
release: CFLAGS += -O3 -DNDEBUG
release: clean all
# Phony 타겟 (파일이 아닌 명령어)
.PHONY: all clean rebuild debug release
동작 원리:
make를 치면all타겟 실행all은$(TARGET)에 의존 →my_app이 필요my_app은$(OBJS)에 의존 →main.o,utils.o,calculator.o가 필요- Make가 각
.o파일의 타임스탬프를 확인:main.c가main.o보다 최신이면 → 다시 컴파일main.o가 최신이면 → 스킵
- 모든
.o가 준비되면 링킹 수행
실행 예:
$ make
Compiling main.c...
Compiling utils.c...
Compiling calculator.c...
Linking my_app...
Build successful!
# utils.c만 수정하고 다시 빌드
$ make
Compiling utils.c... # 이것만 다시 컴파일됨
Linking my_app...
Build successful!
정리해본다. Makefile은 의존성 그래프(Dependency Graph)를 정의한다. Make는 이 그래프를 보고 최소한의 작업만 수행한다. 이게 엔지니어링의 생산성이다.
CMake: 메타 빌드 시스템
Makefile의 문제점: 플랫폼마다 다르다. 리눅스 Makefile은 윈도우에서 안 돌아간다.
CMake는 Makefile을 자동으로 생성해주는 도구다:
# CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(MyApp)
set(CMAKE_C_STANDARD 11)
set(CMAKE_BUILD_TYPE Release)
add_executable(my_app main.c utils.c calculator.c)
target_link_libraries(my_app m) # -lm
사용법:
mkdir build && cd build
cmake ..
make
CMake가 현재 OS를 감지해서 적절한 Makefile (또는 Visual Studio 프로젝트, Xcode 프로젝트)을 생성한다. 크로스 플랫폼 개발의 표준이다.
7. 고급 주제 - 컴파일러 최적화
gcc -O3를 붙이면 왜 빨라질까? 컴파일러는 코드의 의미를 바꾸지 않으면서 더 빠른 코드로 변환한다.
최적화 레벨
// 원본 코드
int sum = 0;
for (int i = 0; i < 4; i++) {
sum += i;
}
-O0 (최적화 없음)
movl $0, -8(%rbp) # sum = 0
movl $0, -4(%rbp) # i = 0
jmp .L2
.L3:
movl -4(%rbp), %eax
addl %eax, -8(%rbp) # sum += i
addl $1, -4(%rbp) # i++
.L2:
cmpl $3, -4(%rbp) # i < 4?
jle .L3 # 루프 반복
-O2 (중간 최적화)
- Loop Unrolling: 루프를 펼쳐서 점프 줄임
- Register Allocation: 변수를 메모리 대신 레지스터에 저장
movl $0, %eax # sum = 0
addl $0, %eax # sum += 0
addl $1, %eax # sum += 1
addl $2, %eax # sum += 2
addl $3, %eax # sum += 3
-O3 (최대 최적화)
- Constant Folding: 컴파일 타임에 계산
movl $6, %eax # sum = 6 (컴파일러가 미리 계산)
컴파일러가 0 + 1 + 2 + 3 = 6을 미리 계산해버렸다!
LTO (Link Time Optimization)
일반적으로 컴파일러는 파일 단위로만 최적화한다. a.c를 컴파일할 때는 b.c를 모른다.
// utils.c
int add(int a, int b) {
return a + b;
}
// main.c
extern int add(int, int);
int main() {
return add(3, 4);
}
일반 컴파일:
gcc -O2 -c utils.c -o utils.o
gcc -O2 -c main.c -o main.o
gcc utils.o main.o -o main
이 경우 main.c에서 add 함수를 인라인할 수 없다. 컴파일 시점에 add의 구현을 모르기 때문이다.
LTO 사용:
gcc -O2 -flto -c utils.c -o utils.o
gcc -O2 -flto -c main.c -o main.o
gcc -flto utils.o main.o -o main
링킹 단계에서 모든 코드를 다시 분석해서 최적화한다. add 함수가 인라인되어:
main:
movl $7, %eax # return 7 (컴파일러가 미리 계산)
ret
대형 프로젝트에서 10-20% 성능 향상을 볼 수 있다. 단점: 링킹 시간이 매우 길어진다.
PGO (Profile Guided Optimization)
1단계: 프로파일링 빌드
gcc -fprofile-generate -O2 main.c -o main
./main # 실행하면 main.gcda 생성 (프로파일 데이터)
2단계: 프로파일 기반 최적화 빌드
gcc -fprofile-use -O2 main.c -o main_optimized
컴파일러가 "어떤 함수가 자주 호출되는지", "어떤 분기가 자주 선택되는지" 알고 최적화한다. Chrome, Firefox 같은 브라우저가 이렇게 빌드된다.
8. 크로스 컴파일 - 다른 플랫폼을 위한 빌드
내 맥북(ARM64)에서 라즈베리 파이(ARM32)용 프로그램을 빌드하려면?
# ARM32용 크로스 컴파일러 설치
brew install arm-linux-gnueabihf-gcc
# 크로스 컴파일
arm-linux-gnueabihf-gcc -o hello_arm hello.c
# 파일 확인
file hello_arm
# hello_arm: ELF 32-bit LSB executable, ARM
이 파일은 맥북에서 실행 안 되지만, 라즈베리 파이로 복사해서 실행하면 된다.
안드로이드 NDK도 크로스 컴파일러다. 내 PC(x86)에서 안드로이드 폰(ARM)용 네이티브 코드를 빌드한다.
# Android NDK 사용
$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang \
-o app.so app.c -shared
9. 현대 언어들의 빌드: JavaScript, Rust, Go
C/C++ 빌드는 복잡하다. 그래서 현대 언어들은 빌드 시스템을 언어에 통합했다.
JavaScript (Node.js, Webpack)
자바스크립트는 인터프리터 언어지만, 웹팩은 번들링(Bundling)을 수행한다:
npx webpack
- 모든
.js파일을 하나로 합친다 (C의 링킹과 비슷) - 트리 쉐이킹(Tree Shaking): 안 쓰는 코드 제거 (C의 Dead Code Elimination)
- 미니피케이션(Minification): 공백 제거, 변수 이름 짧게
C와의 차이: 컴파일 단계가 없다. 소스 코드 그대로 실행되기 때문.
Rust (cargo)
cargo build --release
- 의존성 관리 자동 (Cargo.toml에 명시)
- 최적화 빌드 기본 제공
- 크로스 컴파일 지원 (
cargo build --target aarch64-unknown-linux-gnu)
C와의 차이: 빌드 시스템이 표준화되어 있다. Makefile 안 써도 됨.
Go (go build)
go build main.go
- 의존성 자동 다운로드
- 정적 링킹이 기본 (하나의 실행 파일)
- 컴파일 속도가 C++보다 10배 빠름 (헤더 파일 모델 안 씀)
C와의 차이: 링킹이 간단하다. 모든 것을 하나의 바이너리로 만든다.
정리해본다. 현대 언어들은 C의 빌드 복잡성을 보고 "이건 언어 차원에서 해결하자"고 결정했다. 그래서 빌드 시스템이 언어 툴체인에 통합되어 있다.
10. ELF 파일 구조 - 실행 파일 내부
리눅스 실행 파일(a.out)은 ELF (Executable and Linkable Format) 포맷을 따른다.
readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00
Class: ELF64
Data: 2's complement, little endian
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x401040
ELF 파일 구조:
┌─────────────────┐
│ ELF Header │ <- 파일 메타데이터
├─────────────────┤
│ Program Header │ <- 메모리 로딩 정보
├─────────────────┤
│ .text │ <- 실행 코드 (READ-ONLY)
├─────────────────┤
│ .rodata │ <- 문자열 리터럴 (READ-ONLY)
├─────────────────┤
│ .data │ <- 초기화된 전역 변수
├─────────────────┤
│ .bss │ <- 초기화 안 된 전역 변수 (파일에 공간 안 차지)
├─────────────────┤
│ .symtab │ <- 심볼 테이블 (디버깅용)
└─────────────────┘
섹션별 역할:
.text: 기계어 코드. 읽기 전용. 여기에 쓰려고 하면 Segfault..rodata:"Hello, World!\n"같은 문자열. 읽기 전용..data:int count = 10;같은 초기화된 전역 변수..bss:int buffer[1000];같은 초기화 안 된 변수. 파일에는 크기만 기록되고, 실행 시 0으로 채워짐.
size a.out
text data bss dec hex filename
1234 200 8000 9434 24da a.out
흥미로운 점: .bss 섹션은 파일 크기를 차지하지 않는다. 그래서 int arr[1000000]을 선언해도 실행 파일이 커지지 않는다. 실행 시 메모리만 할당된다.
11. 디버깅 팁 - 빌드 에러 해결
undefined reference 에러
undefined reference to 'foo'
원인: 링킹 에러. 함수 선언은 있지만 정의가 없다.
해결:
- 함수 구현을 작성했는지 확인
- 해당
.c파일을 빌드에 포함했는지 확인 - 라이브러리를 링크했는지 확인 (
-lm,-lpthread)
multiple definition 에러
multiple definition of 'count'
원인: 같은 심볼(함수 또는 전역 변수)이 여러 곳에서 정의됨.
해결:
- 헤더 파일에 함수 구현을 넣지 말 것 (선언만)
- 전역 변수는 한 곳에서만 정의하고, 다른 곳에서는
extern사용
세그멘테이션 폴트
Segmentation fault (core dumped)
원인: 잘못된 메모리 접근. 문자열 리터럴 수정, 널 포인터 역참조 등.
디버깅:
gcc -g main.c -o main # 디버그 심볼 포함
gdb ./main
(gdb) run
(gdb) backtrace # 에러 발생 위치 확인
12. 요약
빌드 과정을 받아들인 방식으로 정리해본다:
- 전처리 (cpp): 복사기.
#include를 파일 내용으로 치환.#define을 값으로 치환. - 컴파일 (cc1): 번역가. C 언어를 어셈블리어로 번역. 최적화 수행.
- 어셈블 (as): 기계공. 어셈블리를 기계어로 변환.
.o파일 생성. - 링킹 (ld): 조립공. 여러
.o파일과 라이브러리를 합쳐서 실행 파일 생성.
결국 이거였다: 빌드는 4개의 독립적인 프로그램이 파이프라인으로 연결된 과정이다. 각 단계의 입력과 출력을 이해하면 에러를 빠르게 해결할 수 있다.
정적 라이브러리는 도서관에서 책을 복사하는 것, 동적 라이브러리는 도서관 카드를 받는 것. Makefile은 의존성 그래프를 정의해서 최소한의 재컴파일만 수행한다. 컴파일러 최적화는 코드의 의미를 바꾸지 않으면서 더 빠른 기계어를 생성한다.
이 모든 걸 이해하고 나니, undefined reference 에러가 더 이상 무섭지 않다. 그냥 링커가 심볼을 못 찾았다는 뜻이니까. 빌드 과정을 이해하는 게 좋은 엔지니어의 시작이라고 받아들였다.