상세 컨텐츠

본문 제목

C 컴파일의 4단계와 링커 — gcc -c는 뭐고 -o는 뭐가 다른가

개발

by 코드스키 2026. 7. 24. 07:43

본문

 C 컴파일 4단계와 링커 대표 이미지
C 컴파일 4단계와 링커 대표 이미지

시스템 프로그래밍 수업에서 컴파일러, 링커, POSIX 같은 단어가 한꺼번에 쏟아진 날이 있었다. 그동안은 그냥 gcc main.c만 치고 살았는데, 수업 자료에 gcc -c, gcc -o, gcc -S, gcc -E가 따로 나오는 걸 보고 처음으로 이게 뭔 차이인지 궁금해졌다. 나중에 알고 보니 이유는 단순했다. gcc는 사실 컴파일러 그 자체가 아니라 컴파일러 드라이버였고, 내부적으로 네 단계짜리 도구 체인을 순서대로 불러주는 놈이었다.

같은 시간에 "요즘 컴파일러는 LLVM 기반"이라는 얘기도 들었는데, gcc랑 clang 차이까지 이번 기회에 같이 정리해봤다.

hello.c가 실행 파일이 되기까지

한 줄짜리 hello.c를 실행 파일로 만드는 데도 사실 네 단계를 거친다.

hello.c
   │  ① Preprocessing  (cpp)
   ▼
hello.i
   │  ② Compilation    (cc1)
   ▼
hello.s        ← 어셈블리 코드
   │  ③ Assembly       (as)
   ▼
hello.o        ← 오브젝트 파일 (relocatable machine code)
   │  ④ Linking        (ld)
   ▼
hello          ← 실행 파일

gcc hello.c -o hello 한 줄로 이 네 단계가 순서대로 다 돌아간다. 중간에 멈추고 싶으면 그때 옵션을 따로 준다.

옵션 어디서 멈추나 출력
-E 전처리 후 .i (전처리된 C 코드)
-S 컴파일 후 .s (어셈블리)
-c 어셈블 후 .o (오브젝트 파일)
(없음) 링크까지 실행 파일

전처리는 #include, #define, #if 같은 걸 처리하는 단계다. #include <stdio.h>를 만나면 진짜로 그 헤더 파일 전체를 그 자리에 끼워넣는다.

gcc -E hello.c -o hello.i

이거 한 번 돌려보면 결과 파일이 수천 줄로 불어난다. stdio.h 안의 선언이 죄다 박혀서다.

그다음 컴파일 단계에서 전처리된 코드를 어셈블리로 번역한다. 우리가 흔히 "컴파일러가 하는 일"이라고 생각하는 게 사실 이 단계다.

gcc -S hello.c -o hello.s

hello.s를 열어보면 pushq %rbp, movq %rsp, %rbp 같은 진짜 어셈블리가 보인다. x86-64 리눅스 환경이면 인텔 문법이 아니라 AT&T 문법이 기본으로 나온다.

세 번째는 어셈블. 어셈블리를 진짜 기계어로 바꾼다.

gcc -c hello.c -o hello.o

여기까지 와도 아직 실행은 안 된다. 오브젝트 파일은 "재배치 가능한 기계어"라서 함수 주소가 아직 확정 안 됐다. printf 같은 외부 함수 주소도 비어있는 상태다.

마지막 링킹이 그 빈칸을 채운다. 오브젝트 파일들을 모아서 실행 파일로 만드는 단계인데, 이게 아마 처음 들으면 제일 헷갈리는 부분일 거다. 링커가 하는 일은 크게 두 가지다. 하나는 심볼 해소로, 한 파일에서 부른 함수가 다른 파일이나 라이브러리에 있으면 그걸 찾아 연결하는 거다. printf 호출이 libc 안의 진짜 printf로 이어지는 게 이 과정이다. 다른 하나는 재배치인데, 오브젝트 파일 안에서 가짜 주소로 남아있던 함수 위치에 실제 주소를 박아넣는 작업이다.

gcc hello.o -o hello

왜 파일을 나눠서 컴파일하나

main.c, util.c, math.c 세 파일짜리 프로젝트는 한 줄로도 컴파일된다.

gcc main.c util.c math.c -o app

근데 실제 프로젝트에서는 이렇게 잘 안 한다. 파일 하나만 고쳤는데 매번 전부 다시 컴파일하는 게 시간 낭비이기 때문이다. 그래서 각 파일을 오브젝트로 따로 만들고 마지막에 링크만 다시 돌리는 패턴을 쓴다.

gcc -c main.c -o main.o
gcc -c util.c -o util.o
gcc -c math.c -o math.o
gcc main.o util.o math.o -o app

util.c만 고쳤으면 util.o만 새로 만들고 링크 단계만 다시 하면 된다. Makefile이 자동화하는 게 딱 이 부분이다.

app: main.o util.o math.o
    gcc main.o util.o math.o -o app

main.o: main.c
    gcc -c main.c -o main.o

util.o: util.c
    gcc -c util.c -o util.o

math.o: math.c
    gcc -c math.c -o math.o

make는 각 타깃의 의존성을 보고 바뀐 파일만 다시 빌드한다.

gcc, clang, LLVM은 무슨 관계인가

수업에서 "요즘은 gcc 말고 LLVM 쓴다"는 말을 듣고 찾아봤다. GCC는 1987년부터 있던 GNU 프로젝트고, C·C++·Fortran 등을 다 지원하는 모놀리식 구조다. LLVM은 컴파일러 자체가 아니라 컴파일러를 만들기 위한 라이브러리 모음, 그러니까 컴파일러 인프라다. Clang은 그 LLVM 위에 올라간 C/C++/Objective-C 프론트엔드고, 우리가 평소에 "LLVM 쓴다"고 할 때는 대체로 Clang을 의미한다.

LLVM의 핵심은 프론트엔드/미들엔드/백엔드 분리다.

C 소스   ──[Clang 프론트엔드]──┐
C++ 소스 ──[Clang 프론트엔드]──┤
Rust 소스──[rustc 프론트엔드]──┼──> LLVM IR ──[옵티마이저]──> LLVM IR ──[백엔드]──> 기계어
Swift 소스─[Swift 프론트엔드]──┘                                                ├──> x86
                                                                                ├──> ARM
                                                                                └──> RISC-V

여러 언어가 같은 LLVM IR로 모이고, 하나의 옵티마이저와 하나의 백엔드 세트를 공유한다. N개 언어 곱하기 M개 아키텍처만큼 컴파일러가 필요한 게 아니라 N+M개로 줄어드는 구조다. GCC는 모놀리식이라 새 언어나 새 아키텍처를 추가하려면 내부 구조를 잘 알아야 하는데, 이 진입장벽 때문에 Apple도 GCC를 버리고 LLVM/Clang으로 갈아탔고(Clang이 사실상 Apple 프로젝트다), Rust나 Swift 같은 신생 언어들도 처음부터 LLVM 위에서 시작했다.

실용적으로는 명령줄에서 gccclang으로 바꿔도 -c, -o, -Wall 같은 옵션이 거의 그대로 통하고, 성능은 케이스마다 미세하게 갈리며, Clang이 에러 메시지가 더 친절하다는 평이 많다. 참고로 macOS에서 gcc를 쳐도 사실은 Clang이 불려나온다.

실제로 헷갈렸던 것들

-o-c가 배타적인 옵션인 줄 알았는데 아니었다. -o는 그냥 "출력 파일 이름"을 지정하는 옵션이라 -c, -S, -E 어느 단계에서든 같이 쓸 수 있다.

gcc -c hello.c -o hello.o     # 컴파일만 + 출력 이름 지정
gcc hello.o -o hello          # 링크 + 출력 이름 지정

clang을 쳤는데 명령을 못 찾은 적도 있는데, Ubuntu/Kali는 기본으로 gcc만 깔려 있고 clang은 따로 설치해야 한다.

sudo apt install clang

헤더 파일 hello.h만 따로 gcc -c hello.h 식으로 컴파일하는 건 안 된다. 헤더는 컴파일 단위가 아니라 .c 파일이 #include로 끌어와서 전처리 단계에 끼워 넣는 대상이기 때문이다.

수학 함수(sin, cos 등)를 쓸 때는 libm을 따로 링크해야 한다.

gcc main.c -o app -lm

-lm은 명령줄 끝에 두는 게 안전하다. 링커는 왼쪽에서 오른쪽으로 심볼을 해소하기 때문에, -lm을 앞에 두면 그 시점에 아직 등장하지 않은 심볼이라 그냥 무시되고 나중에 missing reference 에러로 돌아온다.

참고한 자료는 GCC 공식 매뉴얼의 Overall Options 섹션, man gcc, LLVM 공식 문서의 아키텍처 개요, Tufts CS40의 "Stages of Program Building", 그리고 Jonathan Cook의 "C/C++: Basic Compiling and Linking"이다.

옵션을 단계별 산출물과 연결하기

-E는 전처리 결과, -S는 어셈블리, -c는 아직 링크하지 않은 오브젝트 파일을 만든다. -o는 단계를 정하는 옵션이 아니라 출력 파일 이름을 지정한다. 이 둘을 구분하면 “gcc -c와 -o 중 무엇을 써야 하나”라는 질문 자체가 풀린다.

GCC Overall Options 공식 문서
GCC Overall Options 공식 문서
Clang 사용자 매뉴얼
Clang 사용자 매뉴얼
GNU linker ld 공식 문서
GNU linker ld 공식 문서
C 번역 단계 정리
C 번역 단계 정리
gcc -E main.c -o main.i
gcc -S main.i -o main.s
gcc -c main.s -o main.o
gcc main.o -o app

실제 오류를 볼 때도 전처리·컴파일·어셈블·링크 중 어느 단계에서 멈췄는지 먼저 분류하면 메시지가 훨씬 읽기 쉬워진다.

공식 자료

관련글 더보기