C 컴파일·링크·실행 - 전처리 오브젝트 파일과 링크 오류 읽기 (C 기초 1장)
이 장에서 배우는 것
이 책은 C 문법을 외우는 책이 아니라 코드 한 줄이 메모리에서 무슨 일을 하는지 그림으로 확인하는 책이다. 임베디드 펌웨어, 운영체제·드라이버, 고성능 서버처럼 C가 쓰이는 곳에서는 "값이 몇 바이트이고 어디에 놓이는가"를 모르면 버그를 찾을 수 없다. 그래서 장마다 같은 순서로 진행한다.
- 문제 상황 — 실제로 겪는 곤란한 장면
- 메모리 그림 — 스택·힙·정적 영역, 주소와 바이트를 글자 그림으로
- 완성 코드와 줄별 해설
- 실제 실행 결과 — 직접 컴파일해서 나온 출력 그대로
- 실무에서 자주 틀리는 것과 연습 문제 3개(정답·해설 포함)
첫 장에서는 소스 파일이 실행 파일이 되기까지의 단계를 나눠 본다.
- 전처리 → 컴파일 → 링크가 각각 무엇을 만들고 어떤 오류를 내는지 구분한다.
- 헤더 파일(
.h)과 소스 파일(.c)을 나누는 이유를 이해한다. - "undefined symbol" 같은 링크 오류와 "undeclared function" 같은 컴파일 오류를 읽는다.
- 실행 파일이 메모리에 올라갔을 때의 영역 배치(코드·정적 데이터·힙·스택)를 그린다.
이 책의 실행 환경
모든 예제는 2026년 9월 24일 macOS(Darwin 25.6, Apple Silicon arm64)에서 Apple clang 17로 clang -std=c17 -Wall -Wextra 옵션을 주고 컴파일했다. 경고를 일부러 보여 주는 예제를 빼면 경고 0개로 컴파일된다. 출력은 손으로 옮겨 적지 않고 실행 결과를 그대로 붙였다. 자료형 크기, 주소, 오류 문구는 운영체제·CPU·컴파일러에 따라 달라지므로, 달라질 수 있는 곳마다 따로 적었다. 리눅스(x86-64, gcc 또는 clang)에서도 거의 같은 결과가 나오지만 링크 오류 문구나 기호 이름처럼 다른 부분은 본문에서 짚는다.
문제 상황
작은 쇼핑몰의 주문 계산을 C로 옮기는 과제를 받았다. 가격 계산 함수는 동료가 price.c에 만들어 두었고, 나는 main.c에서 그 함수를 불러 쓰면 된다. 그런데 빌드를 해 보면 오류가 두 종류로 나온다. 하나는 "call to undeclared function", 다른 하나는 "Undefined symbols ... ld: symbol(s) not found". 둘 다 "함수가 없다"는 말처럼 보여서 무엇을 고쳐야 할지 모르겠다.
두 오류는 서로 다른 단계에서 나온다. 앞의 것은 컴파일러가 main.c 한 파일을 번역하다가 함수 모양(선언)을 몰라서 멈춘 것이고, 뒤의 것은 번역이 끝난 조각들을 이어 붙이는 링커가 함수 본체(정의)를 찾지 못한 것이다. 이 차이를 알면 오류 메시지 한 줄만 보고도 어디를 고칠지 바로 안다.
메모리 그림: 소스에서 실행까지
hello.c ──전처리(-E)──▶ #include 가 펼쳐진 C 코드
│
컴파일(-c)
▼
price.c ─────────────▶ price.o (기계어 + "total_price 여기 있음")
main.c ─────────────▶ main.o (기계어 + "total_price 필요함")
│
링크(ld)
▼
shop (실행 파일)
실행하면 운영체제가 shop 을 메모리에 올린다 (주소는 개념도)
높은 주소 ┌──────────────────────┐
│ 스택 지역 변수·호출 기록 │ ↓ 아래로 자람
│ ... │
│ 힙 malloc 한 메모리 │ ↑ 위로 자람
├──────────────────────┤
│ 정적 데이터 전역·static │
│ 코드 main, total_price │
낮은 주소 └──────────────────────┘
코드 영역에는 기계어가, 정적 데이터 영역에는 프로그램이 끝날 때까지 사는 전역 변수가, 스택에는 함수가 불릴 때마다 생겼다 사라지는 지역 변수가, 힙에는 malloc으로 직접 빌린 메모리가 들어간다. 이 네 칸이 책 전체의 배경 그림이다. 실제 주소 배치는 운영체제마다 다르고 실행할 때마다 무작위로 옮겨지기도 하지만(주소 공간 배치 무작위화, ASLR), "어떤 값이 어느 칸에 사는가"는 C 문법이 결정한다.
헤더는 전처리로 각 소스에 붙고, 소스는 따로 오브젝트 파일이 되며, 링커가 필요함(U)과 정의(T)를 짝지어 실행 파일을 만든다.
실행 파일이 메모리에 올라가면 코드·정적 데이터·힙·스택 네 영역으로 나뉘고, 값이 어느 영역에 사는지가 수명을 결정한다.
완성 코드
먼저 가장 작은 프로그램이다.
#include <stdio.h>
int main(void) {
printf("안녕, C\n");
return 0;
}
다음은 파일 세 개로 나눈 주문 계산 프로그램이다. 헤더에는 함수의 선언만, 소스에는 정의를 둔다.
price.h
#ifndef PRICE_H
#define PRICE_H
int total_price(int unit_price, int quantity);
#endif
price.c
#include "price.h"
#define SHIPPING_FEE 3000
#define FREE_SHIPPING_FROM 50000
int total_price(int unit_price, int quantity) {
int subtotal = unit_price * quantity;
if (subtotal >= FREE_SHIPPING_FROM) {
return subtotal;
}
return subtotal + SHIPPING_FEE;
}
main.c
#include <stdio.h>
#include "price.h"
int main(void) {
printf("책 2권: %d원\n", total_price(18000, 2));
printf("책 3권: %d원\n", total_price(18000, 3));
return 0;
}
줄별 해설
#include <stdio.h>— 전처리기가 표준 입출력 헤더의 내용을 이 자리에 그대로 붙여 넣는다.printf의 선언이 여기 들어 있다. 꺾쇠(<>)는 시스템 헤더, 따옴표("price.h")는 내 프로젝트 헤더를 찾을 때 쓴다.int main(void)— 운영체제가 프로그램을 시작하면 호출하는 함수다.void는 인자를 받지 않는다는 뜻이다. C17에서 괄호를 비워main()이라고 쓰면 "인자를 확인하지 않는다"는 옛 의미가 되므로void를 적는 습관을 들인다.return 0;— 종료 코드다. 셸에서$?로 읽을 수 있고, 0은 성공, 0이 아니면 실패를 뜻하는 것이 관례다. 빌드 스크립트와 CI는 이 숫자로 성공 여부를 판단한다.#ifndef PRICE_H…#endif— 인클루드 가드. 같은 헤더가 한 파일에 두 번 들어와도 내용은 한 번만 들어가게 막는다.int total_price(int unit_price, int quantity);— 세미콜론으로 끝나는 선언이다. "이런 이름·인자·반환형을 가진 함수가 어딘가 있다"는 약속일 뿐 본체는 없다.#define SHIPPING_FEE 3000— 전처리기가 컴파일 전에 글자 그대로 치환하는 매크로다. 변수가 아니므로 메모리를 차지하지 않는다.price.c의total_price본체 — 정의다. 5만 원 이상이면 배송비 없이, 아니면 3천 원을 더한다.
실제 실행 결과
한 번에 빌드하고 실행하기
$ clang -std=c17 -Wall -Wextra hello.c -o hello
출력이 비어 있다는 것은 경고도 오류도 없다는 뜻이다. 이 책은 이 상태를 기본으로 삼는다.
$ ./hello; echo "종료 코드: $?"
안녕, C
종료 코드: 0
전처리 결과 들여다보기
-E는 전처리까지만 하고 결과를 보여 준다. 수천 줄이 나오므로 끝 8줄만 잘랐다.
$ clang -std=c17 -Wall -Wextra -E main.c | tail -n 8
int total_price(int unit_price, int quantity);
# 3 "main.c" 2
int main(void) {
printf("책 2권: %d원\n", total_price(18000, 2));
printf("책 3권: %d원\n", total_price(18000, 3));
return 0;
}
#include "price.h"가 선언 한 줄로 바뀌었고, 줄 번호를 알려 주는 # 표시가 끼어 있다. 컴파일러는 이렇게 펼쳐진 한 덩어리만 보고 번역한다. 다른 .c 파일의 내용은 보지 못한다.
파일마다 컴파일하고 링크하기
$ clang -std=c17 -Wall -Wextra -c price.c && clang -std=c17 -Wall -Wextra -c main.c && ls *.o
main.o
price.o
$ nm price.o main.o
price.o:
0000000000000000 T _total_price
0000000000000000 t ltmp0
0000000000000058 s ltmp1
main.o:
0000000000000000 T _main
U _printf
U _total_price
0000000000000074 s l_.str
0000000000000085 s l_.str.1
0000000000000000 t ltmp0
0000000000000074 s ltmp1
0000000000000098 s ltmp2
nm은 오브젝트 파일의 기호표를 보여 준다. price.o에서 T _total_price는 "코드 영역(Text)에 이 함수가 정의돼 있다", main.o에서 U _total_price는 "정의되지 않았고(Undefined) 밖에서 찾아야 한다"는 뜻이다. macOS는 C 함수 이름 앞에 밑줄을 붙인다. 리눅스에서는 total_price처럼 밑줄 없이 나온다. ltmp, l_.str 같은 기호는 컴파일러가 내부용으로 만든 이름이라 신경 쓰지 않아도 된다. 링커는 U와 T를 짝지어 주소를 채운다.
$ clang main.o price.o -o shop && ./shop
책 2권: 39000원
책 3권: 54000원
2권은 36,000원이라 배송비 3,000원이 붙고, 3권은 54,000원이라 붙지 않았다.
오류 두 가지 재현
price.o를 빼고 링크하면 링커가 짝을 못 찾는다.
$ clang main.o -o shop2
Undefined symbols for architecture arm64:
"_total_price", referenced from:
_main in main.o
_main in main.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
리눅스의 GNU 링커라면 같은 상황에서 undefined reference to `total_price'라고 나온다. 문구는 달라도 뜻은 같다. 선언은 있었고 컴파일은 통과했지만, 정의가 담긴 오브젝트가 링크 명령에 없다. 고칠 곳은 코드가 아니라 빌드 명령(또는 Makefile·CMake의 소스 목록)이다.
이번에는 헤더를 인클루드하지 않은 no_header.c를 컴파일한다.
#include <stdio.h>
int main(void) {
printf("%d원\n", total_price(18000, 2));
return 0;
}
$ clang -std=c17 -Wall -Wextra no_header.c -o no_header
no_header.c:4:23: error: call to undeclared function 'total_price'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration]
4 | printf("%d원\n", total_price(18000, 2));
| ^
1 error generated.
이것은 컴파일 단계의 오류다. 옛날 C(C89)는 모르는 함수를 만나면 "int를 돌려주는 함수겠지" 하고 넘어갔지만, C99부터 이 암묵적 선언은 표준에서 빠졌고 clang 16부터는 기본으로 오류다. 고칠 곳은 #include "price.h" 한 줄이다.
한눈에 보기
| 단계 | 명령 예 | 만드는 것 | 대표 오류 |
|---|---|---|---|
| 전처리 | clang -E | #include·#define 이 펼쳐진 C 코드 | 헤더 파일을 찾지 못함 |
| 컴파일 | clang -c | 오브젝트 파일 .o | 문법 오류, 선언 없는 함수 호출 |
| 링크 | clang a.o b.o | 실행 파일 | 정의 없음(undefined), 중복 정의 |
| 실행 | ./shop | 종료 코드 | 잘못된 메모리 접근, 틀린 결과 |
| 메시지에 보이는 말 | 단계 | 고칠 곳 |
|---|---|---|
file not found | 전처리 | #include 경로·철자, -I 옵션 |
call to undeclared function | 컴파일 | 헤더 인클루드나 함수 선언 추가 |
Undefined symbols / undefined reference | 링크 | 정의가 담긴 .c·라이브러리를 빌드 명령에 추가 |
duplicate symbol / multiple definition | 링크 | 헤더의 정의를 .c 한 곳으로 옮기고 헤더엔 extern 선언 |
실무에서 자주 틀리는 것
- 헤더에 함수 본체나 전역 변수 정의를 넣는다. 헤더는 여러
.c에 복사되므로, 정의가 들어 있으면 오브젝트마다 같은 기호가 생겨 링크 단계에서 "duplicate symbol"(리눅스는 "multiple definition") 오류가 난다. 헤더에는 선언과extern int count;같은 외부 선언만 두고, 정의는.c한 곳에 둔다. - 경고를 무시하고 실행 파일이 생겼으니 됐다고 생각한다. C 컴파일러의 경고는 대부분 실제 버그다. 2장부터 보겠지만 경고 하나가 부호 오류, 버퍼 넘침, 쓰레기 값으로 이어진다. 팀 빌드에는
-Wall -Wextra를 기본으로 넣고, 가능하면-Werror로 경고를 오류로 취급한다. - 링크 오류를 코드 오류로 오해한다. "undefined symbol"이 나오면 먼저 ① 그 함수가 정의된
.c가 빌드 목록에 있는지 ② 이름 철자·대소문자가 같은지 ③ 외부 라이브러리라면-lm같은 링크 옵션을 줬는지 확인한다. 예를 들어 리눅스에서sqrt를 쓰고-lm을 빠뜨리면 같은 종류의 오류가 난다. main()의 반환값을 신경 쓰지 않는다. 실패했는데 0을 돌려주면 배포 스크립트는 성공한 줄 알고 다음 단계로 넘어간다. 오류 경로에서는 반드시 0이 아닌 값을 돌려준다.
연습 문제
price.c만 고쳤을 때 전체를 다시 빌드하지 않고 실행 파일을 새로 만드는 명령 두 줄을 써라.price.h에int order_count = 0;을 추가하고main.c와price.c가 둘 다 이 헤더를 인클루드한다. 어느 단계에서 어떤 문제가 생기는가? 어떻게 고치는가?- 다음 두 오류 메시지는 각각 전처리·컴파일·링크 중 어느 단계에서 나오는가? (가)
fatal error: 'prce.h' file not found(나)undefined reference to `total_price'
정답
clang -std=c17 -Wall -Wextra -c price.c로price.o만 다시 만들고,clang main.o price.o -o shop으로 다시 링크한다.main.o는 그대로 재사용한다. 파일 단위 컴파일이 가능한 덕분에 큰 프로젝트에서도 바뀐 파일만 다시 번역하며,make가 하는 일이 바로 이 판단이다. 단, 헤더가 바뀌면 그 헤더를 인클루드한 모든.c를 다시 컴파일해야 한다.- 각 파일의 컴파일은 통과하지만,
main.o와price.o에 모두order_count의 정의가 생겨 링크 단계에서 중복 정의 오류가 난다. 헤더에는extern int order_count;(선언)만 두고,price.c한 곳에int order_count = 0;(정의)을 둔다. 인클루드 가드는 한 파일 안에서의 중복만 막을 뿐 여러 파일 사이의 중복 정의는 막지 못한다. - (가)는 전처리 단계다.
#include가 파일을 찾지 못했다(철자 오류). (나)는 링크 단계다. 선언은 있었지만 정의가 담긴 오브젝트나 라이브러리가 링크에 빠졌다.
다음 장에서는 int가 정말 몇 바이트인지, -1이 메모리에 어떤 바이트로 저장되는지를 직접 찍어 본다.