Step 63. 컴파일 과정 — 네 명의 일꾼이 바톤을 넘긴다
Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★☆☆ | 예상 소요 시간 3시간
전제: Step 56~62를 마쳤다. gcc로 C 프로그램을 컴파일할 수 있다.
- 준비물: WSL 우분투 터미널, gcc, nano(또는 cat). 실측 환경은 Ubuntu 24.04, gcc 13.3.0, x86-64입니다.
- 주의: 오늘 실습은 100% 안전합니다. 파일을 만들고 변환할 뿐, 지우거나 바꾸는 시스템 작업은 없습니다.
우리는 지금까지 gcc hello.c -o hello 한 줄로 컴파일했습니다. 마법의 주문처럼요. 그런데 그 한 줄의 뒤에서는 네 명의 일꾼이 바톤을 넘기는 작업이 벌어집니다. 오늘은 그 작업대의 뚜껑을 엽니다. 오류 메시지를 읽는 눈이 달라지는 것이 첫 번째 수확이고, "실행 파일이 어떻게 태어나는가"를 아는 것이 나중에 실행 파일을 해독하는 공부의 밑바탕이 되는 것이 두 번째 수확입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
gcc hello.c -o hello한 줄 뒤의 네 단계(전처리, 컴파일, 어셈블, 링크)를 순서대로 말한다- 각 단계를
-E,-S,-c옵션으로 따로 재현하고 산출물(.i, .s, .o, 실행 파일)을 보관한다 - 각 산출물을 열어 보고 역할을 설명한다
- "undefined reference" 오류가 어느 단계의 말인지 알고 처방을 내린다
- 두 파일 프로젝트를 부품별로 번역하고 마지막에 링크한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C 언어, WSL 우분투 bash, gcc 13.x (실측: 13.3.0, x86-64) |
| 오늘의 명령어 | gcc -E(전처리만), gcc -S(어셈블리까지), gcc -c(오브젝트까지), gcc -save-temps(중간 산출물 보관), file(파일 신분 확인), nm(부품 속 이름 보기), ldd(라이브러리 목록) |
| 필요한 개념 | 전처리, 컴파일, 어셈블, 링크, 오브젝트 파일, 라이브러리, ELF 형식 |
2-1. 네 단계의 작업대
C 소스가 실행 파일이 되기까지의 여정입니다.
- 전처리(preprocessing):
#으로 시작하는 지시문을 처리합니다.#include는 해당 파일의 내용을 그 자리에 펼쳐 넣고,#define은 치환합니다. 결과물은.i파일. - 컴파일(compilation): 펼쳐진 C 코드를 어셈블리어(기계어의 인간 판독판)로 번역합니다. 결과물은
.s파일. 좁은 의미의 "컴파일"은 이 단계만을 가리킵니다. - 어셈블(assembly): 어셈블리어를 기계어(0과 1)로 옮깁니다. 결과물은
.o(오브젝트) 파일. 아직 실행은 못 하는, 부품 상태의 기계어입니다. - 링크(linking): 여러 오브젝트 파일과 라이브러리(printf 같은 남의 부품)를 합쳐 실행 가능한 완성품을 만듭니다. 부품들 사이의 "이 함수는 저기 있습니다" 연결을 잇는 단계입니다.
2-2. 오브젝트 파일 — 부품으로서의 기계어
.o 파일은 번역은 끝났지만 혼자서는 못 도는 기계어 덩어리입니다. main이 있는 부품, add가 있는 부품, printf가 있는 라이브러리 부품 — 이것들을 링커(linker)가 한 덩어리로 묶을 때 비로소 실행 파일이 됩니다.
큰 프로그램이 파일 수백 개로 나뉘어 있어도 되는 이유가 이 구조입니다.
2-3. 왜 나눠서 도는가
부품별로 나누면, 고친 파일만 다시 번역하고 나머지는 재사용할 수 있습니다. 수백만 줄짜리 프로그램을 매번 통째로 번역하면 하루가 갑니다.
그리고 이 구조 덕에 "남이 만든 부품(라이브러리)을 내 프로그램에 붙이는" 일이 가능합니다.
2-4. ELF — 리눅스 실행 파일의 형식
리눅스의 오브젝트 파일과 실행 파일은 ELF(Executable and Linkable Format)라는 공통 형식을 따릅니다. "부품 상태"와 "완성품"이 같은 형식의 다른 상태인 셈입니다.
오늘 file 명령으로 이 형식 이름을 직접 확인합니다.
3. 따라 하기
3-1. 실험용 소스 준비
입력 (stage.c)
#include <stdio.h>
#define VERSION 2
int main(void) {
printf("단계별 실험 버전 %d\n", VERSION);
return 0;
}
읽는 법: #include와 #define 둘을 담았습니다. 전처리 단계에서 둘 다 어떻게 되는지 보기 위한 실험 재료입니다.
3-2. 1단계: 전처리만 하기
gcc -E stage.c -o stage.i
wc -l stage.i
tail -5 stage.i
820 stage.i
# 4 "stage.c"
int main(void) {
printf("단계별 실험 버전 %d\n", 2);
return 0;
}
(2026-09-09 실측. 줄 수는 환경에 따라 다릅니다.)
새 명령: gcc -E는 "전처리만 하고 멈춰라". wc -l은 "줄 수를 세라", tail -5는 "마지막 다섯 줄을 보여 줘"입니다.
출력 읽는 법: 우리 코드는 여덟 줄이었는데 산출물은 820줄입니다. stdio.h의 내용이 전부 펼쳐져 들어왔기 때문입니다. 그리고 맨 아래 우리의 main을 보면 VERSION 자리에 2가 박혀 있습니다 — #define은 이미 치환됐습니다. grep -c "define VERSION" stage.i로 찾아 보면 0건입니다. 전처리가 끝난 뒤 # 지시문은 남지 않습니다.
왜: #include가 "복사-붙여넣기"였다는 것을 수백 줄의 산출물로 확인하는 순간, 헤더 파일의 정체가 명확해집니다.
3-3. 2단계: 어셈블리로 번역
gcc -S stage.i -o stage.s
grep -nE "main:|call" stage.s
9:main:
22: call printf@PLT
(2026-09-09 실측.)
출력 읽는 법: -S는 "어셈블리까지만 번역". 우리의 C가 mov, call 같은 명령어 나열로 바뀌었습니다. 전부 이해할 필요는 없습니다. "아, call printf라고 적혀 있구나. 우리가 부른 그 함수구나" 정도만 찾아보세요. (@PLT는 라이브러리 함수를 부르는 관문이라는 표시입니다 — 지금은 "printf를 부른다"까지만 읽으면 충분합니다.)
3-4. 3단계: 기계어 부품 만들기
gcc -c stage.s -o stage.o
file stage.o
stage.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
(2026-09-09 실측.)
출력 읽는 법: -c는 "오브젝트까지만". file 명령이 이 파일의 신분을 알려 줍니다. ELF(2-4의 형식), relocatable(아직 주소가 확정되지 않은 부품 상태). 기계어지만 혼자는 못 도는 반제품입니다. 이 파일을 열어 봐야 깨진 글자만 나옵니다 — 사람용이 아니니까요.
3-5. 4단계: 링크로 완성
gcc stage.o -o stage
./stage
단계별 실험 버전 2
(2026-09-09 실측.)
출력 읽는 법: 오브젝트 파일 하나를 링크해 실행 파일을 만들고 실행했습니다. 우리의 부품과 printf가 사는 표준 라이브러리 부품이 여기서 합쳐진 것입니다. 네 단계를 전부 손으로 돌린 것입니다. gcc stage.c -o stage 한 줄은 이 넷의 연속 호출이었습니다.
3-6. 두 파일로 나눠 보기 — 링크의 존재 이유
입력 (math_ops.c)
int add(int a, int b) {
return a + b;
}
입력 (main2.c)
#include <stdio.h>
int add(int a, int b);
int main(void) {
printf("40 + 2 = %d\n", add(40, 2));
return 0;
}
컴파일과 실행
gcc -c math_ops.c -o math_ops.o
gcc -c main2.c -o main2.o
gcc main2.o math_ops.o -o calc
./calc
40 + 2 = 42
(2026-09-09 실측.)
출력 읽는 법: 두 소스를 각각 부품으로 만들고(각각 -c), 마지막에 둘을 함께 링크했습니다. main2.c 위쪽의 int add(int a, int b);는 "이런 생김새의 함수가 어딘가에 있다"는 선언(declaration)입니다. main2.o는 그 말을 믿고 번역됐고, 링커가 math_ops.o에서 그 어딘가를 찾아 연결했습니다.
예측: 마지막 링크에서 math_ops.o를 빼고
gcc main2.o -o calc만 하면 어떤 오류가 날까요? 예측하고 직접 확인해 보세요. 이것이 벽 1의 정체입니다.
왜: 큰 프로그램의 작업 방식이 이것입니다. 파일 수백 개가 각각 .o로 번역되고 마지막에 한꺼번에 링크됩니다.
3-7. 원스텝 확인 — -save-temps로 넷을 한꺼번에
네 단계를 일일이 돌리는 것이 귀찮다면, gcc에 중간 산출물을 전부 남기라는 옵션이 있습니다.
gcc -save-temps stage.c -o stage2
ls stage2*
stage2 stage2-stage.i stage2-stage.o stage2-stage.s
(2026-09-09 실측. -o stage2를 붙이면 산출물 이름이 stage2-stage.* 형태가 됩니다. -o 없이 gcc -save-temps stage.c만 하면 stage.i, stage.s, stage.o로 남습니다.)
출력 읽는 법: 실행 파일과 함께 .i, .s, .o가 전부 남았습니다. 네 일꾼이 각자 산출물을 작업대에 두고 간 것입니다. 지금 각 파일이 어느 단계의 것인지 스스로 말해 보세요. 말할 수 있으면 오늘을 이해한 것입니다.
왜: 도구의 지름길을 아는 것과 과정을 아는 것은 다릅니다. 오늘 우리는 과정을 먼저 배웠고, 지름길은 그 다음입니다. 순서가 바뀌면 지름길이 마법이 됩니다.
3-8. 라이브러리라는 부품 상자
printf는 어디서 왔을까요. 우리가 짠 적이 없는데 링크만 하면 작동합니다. 정답은 "표준 C 라이브러리라는 거대한 부품 상자가 이미 설치되어 있고, 링커가 거기서 printf 부품을 찾아 붙인 것"입니다.
nm stage.o
ldd stage
0000000000000000 T main
U printf
linux-vdso.so.1 (0x00007fd6fd414000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd6fd000000)
/lib64/ld-linux-x86-64.so.2 (0x00007fd6fd416000)
(2026-09-09 실측. ldd의 주소값은 실행할 때마다 다릅니다.)
출력 읽는 법:
nm은 부품 안의 이름 목록을 보여 줍니다.T main은 "main이라는 부품이 여기 들어 있다(T=정의됨)",U printf는 "printf는 여기 없고 어딘가에서 가져와야 한다(U=미정의)"는 뜻입니다.ldd는 "이 실행 파일이 어떤 부품 상자들을 쓰는가"를 보여 줍니다.libc.so.6이 보이죠. printf가 사는 표준 라이브러리입니다.
파이썬의 import가 실행할 때 도구를 찾는 것처럼, C의 실행 파일도 필요한 상자 목록을 품고 있습니다. 이것이 "링크"라는 단어의 진짜 의미 — 합치는 것이 아니라 연결하는 것입니다.
4. 미션과 연습문제
미션 — 네 단계 재현 리포트
- 자신만의 소스(함수 두 개 포함, 예:
int square(int n)와main)를report.c로 만든다 - 네 단계를 각각 명령으로 재현하고, 산출물 네 개(.i, .s, .o, 실행 파일)를 모두 보관한다
.s파일을 열어 자신의 함수 이름과call명령을 찾아 표시한다- 일부러
gcc main2.o -o calc2처럼 부품 하나를 빼고 링크해 "undefined reference" 오류를 재현하고, 오류 전문을 노트에 옮긴다 - 리포트에 답한다: 네 단계 각각의 (가) 입력 (나) 산출물 (다) 하는 일을 한 줄씩
연습문제
문제 1. 3-2에서 여덟 줄짜리 소스가 820줄짜리 .i가 되었습니다. 무엇이 들어온 것이며, #define VERSION 2 줄은 왜 .i에 남아 있지 않나요?
문제 2. gcc -E, gcc -S, gcc -c는 각각 어느 단계까지이며, 산출물의 확장자는 무엇인가요?
문제 3. nm stage.o에서 printf 옆에 U가 붙어 있었습니다. 이것은 무슨 뜻이며, 그 printf는 결국 어느 단계에서 어디서 가져와 지나요?
문제 4. "undefined reference to ‘add’" 오류는 네 단계 중 어느 일꾼의 말인가요? 그리고 대표적인 원인 두 가지를 들어 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
재현 명령의 예:
gcc -E report.c -o report.i # 1단계: 전처리
gcc -S report.i -o report.s # 2단계: 어셈블리로 번역
gcc -c report.s -o report.o # 3단계: 오브젝트 부품
gcc report.o -o report # 4단계: 링크
./report
일부러 낸 오류의 실측 전문 (2026-09-09):
/usr/bin/ld: main2.o: in function `main':
main2.c:(.text+0x13): undefined reference to `add'
collect2: error: ld returned 1 exit status
검증하는 법: ① 네 개의 산출물이 폴더에 전부 남아 있어야 합니다. ② grep -nE "main:|call" report.s로 함수 이름과 call 줄이 찾아져야 합니다. ③ 오류 전문에 ld(링커)와 undefined reference가 보여야 합니다. ④ 네 단계 표를 채우면: 전처리 — 입력 .c, 산출물 .i, 하는 일 "지시문 처리(펼치기·치환)" / 컴파일 — 입력 .i, 산출물 .s, 하는 일 "어셈블리로 번역" / 어셈블 — 입력 .s, 산출물 .o, 하는 일 "기계어 부품화" / 링크 — 입력 .o들+라이브러리, 산출물 실행 파일, 하는 일 "부품 연결".
연습문제 해답
문제 1 해답. #include <stdio.h>가 그 파일의 내용 전부를 그 자리에 펼쳐 넣은 것입니다. #define은 전처리 단계에서 이미 치환(VERSION → 2)이 끝났기 때문에 지시문 자체는 산출물에 남지 않습니다.
문제 2 해답. -E는 전처리까지(산출물 .i), -S는 어셈블리 번역까지(.s), -c는 오브젝트 부품까지(.o)입니다. 마지막 링크 단계에는 별도 옵션이 없습니다.
문제 3 해답. U는 "미정의(undefined) — 이 부품 안에는 없고 어딘가에서 가져와야 한다"는 뜻입니다. printf는 링크 단계에서 표준 C 라이브러리(libc.so.6)라는 부품 상자에서 가져와 연결됩니다. ldd로 그 상자를 확인할 수 있습니다.
문제 4 해답. 링커의 말입니다. "이 함수가 어딘가에 있을 것"이라고 믿고 번역했는데 링크 때 그 정의를 못 찾은 것입니다. 대표 원인은 ① 함께 넘겨야 할 .o 부품을 빼먹은 경우, ② 함수 이름의 철자가 선언·정의·호출 삼곳에서 다른 경우입니다.
완료 기준 체크리스트
- [ ] 컴파일 네 단계(전처리·컴파일·어셈블·링크)를 순서대로 말할 수 있다
- [ ] 각 단계를
-E,-S,-c, 옵션 없음으로 재현하고 산출물을 보관했다 - [ ] .i에서 치환 결과를, .s에서 call 명령을 찾을 수 있다
- [ ]
file,nm,ldd로 부품과 실행 파일의 신분을 확인할 수 있다 - [ ] "undefined reference" 오류의 단계(링크)와 처방을 설명할 수 있다
- [ ] 미션: 네 단계 재현 리포트와 오류 재현 기록을 완성했다
6. 흔한 실수와 해결
벽 1. undefined reference to ‘함수이름’
증상: 링크할 때 이런 오류로 실패합니다 (2026-09-09 실측):
main2.c:(.text+0x13): undefined reference to `add'
collect2: error: ld returned 1 exit status
원인: 링크 단계에서 함수의 실제 정의를 못 찾았습니다. 부품을 빼먹었거나, 함수 이름이 선언과 정의에서 다릅니다.
해결: "모든 .o를 함께 넘겼는가", "함수 이름 철자가 선언·정의·호출 삼곳에서 같은가"를 확인하세요.
벽 2. 산출물이 사람 글이 아니라서 당황한다
증상: .o 파일을 열었다가 깨진 글자 잔뜩 보고 놀랍니다.
원인: .o는 기계어입니다. 사람용이 아닙니다.
해결: 정상입니다. 읽는 법은 file(신분 확인), nm(들어 있는 함수 이름 보기) 같은 도구로 보는 것입니다.
벽 3. -E, -S, -c 옵션이 헷갈린다
증상: 어느 옵션이 어느 단계까지인지 섞입니다.
원인: 세 옵션이 비슷하게 생겼습니다.
해결: 산출물로 기억하세요. -E는 .i(펼침), -S는 .s(어셈블리), -c는 .o(부품). 알파벳 순서가 아니라 여정의 순서입니다. "펼치고(E), 번역하고(S), 부품화한다(c), 마지막에 묶는다(옵션 없음)."
벽 4. 헤더 파일 없음 오류
증상: fatal error: stdio.h: 그런 파일이나 디렉터리가 없습니다가 뜹니다.
원인: 개발용 헤더 파일이 안 깔린 환경입니다(최소 설치 리눅스에서 발생). 전처리 단계의 오류입니다.
해결: sudo apt install build-essential -y로 개발 도구 묶음을 설치하세요. gcc와 필수 헤더들이 함께 옵니다.
벽 5. -save-temps 산출물 이름이 기대와 다르다
증상: stage2.i를 기대했는데 stage2-stage.i가 생겼습니다 (2026-09-09 실측).
원인: -o stage2를 붙이면 gcc가 산출물 이름을 출력 파일 이름에서 따 만듭니다.
해결: -o 없이 gcc -save-temps stage.c만 하면 stage.i, stage.s, stage.o로 남습니다. 둘 다 정상이니 ls로 실제 이름을 확인하세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 전처리 | #include를 펼치고 #define을 치환하는 단계 → .i |
| 컴파일(좁은 뜻) | C를 어셈블리어로 번역하는 단계 → .s |
| 어셈블 | 어셈블리를 기계어 부품으로 옮기는 단계 → .o |
| 링크 | 부품들과 라이브러리를 연결해 완성하는 단계 → 실행 파일 |
| 오브젝트 파일 | 혼자는 못 도는 부품 상태의 기계어 (relocatable) |
| 라이브러리 | printf 같은 남의 부품이 들어 있는 상자 (libc) |
| ELF | 리눅스 오브젝트·실행 파일의 공통 형식 |
오늘의 명령어
| 명령 | 하는 일 |
|---|---|
gcc -E 소스.c -o 결과.i |
전처리만 (헤더 펼치기) |
gcc -S 결과.i -o 결과.s |
어셈블리까지 |
gcc -c 결과.s -o 결과.o |
오브젝트(부품)까지 |
gcc 결과.o -o 실행파일 |
링크해서 완성 |
gcc -save-temps 소스.c |
중간 산출물 전부 남기기 |
file 파일 |
파일의 신분 확인 |
nm 부품.o |
부품 안의 이름 목록 (T=정의, U=미정의) |
ldd 실행파일 |
필요한 라이브러리 목록 |
명령어보다 중요한 감각
오늘부터 C 오류 메시지를 보면 첫 질문은 "어느 일꾼의 말인가"입니다. "파일이 없다(stdio.h)"는 전처리의 말, "expected ;"는 컴파일의 말, "undefined reference"는 링커의 말. 일꾼을 알면 처방이 갈립니다. 오류는 길고 영어지만, 소속만 알면 읽어야 할 부분이 확 줄어듭니다.
하나만 더. 파일이 수십 개가 되면 3-6의 명령 나열을 매번 칠 수 없습니다. 그래서 "고친 것만 다시 번역하고 링크까지 해 주는" 자동화 도구 make가 있습니다. 지금은 존재만 알아 두세요 — 파일이 다섯 개를 넘어가는 날, 스스로 찾게 될 것입니다.
전부 체크되면 Step 63 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.