Devin.KR

C 컴파일·링크·실행 - 전처리 오브젝트 파일과 링크 오류 읽기 (C 기초 1장)

개발자 조회 1

이 장에서 배우는 것

이 책은 C 문법을 외우는 책이 아니라 코드 한 줄이 메모리에서 무슨 일을 하는지 그림으로 확인하는 책이다. 임베디드 펌웨어, 운영체제·드라이버, 고성능 서버처럼 C가 쓰이는 곳에서는 "값이 몇 바이트이고 어디에 놓이는가"를 모르면 버그를 찾을 수 없다. 그래서 장마다 같은 순서로 진행한다.

  1. 문제 상황 — 실제로 겪는 곤란한 장면
  2. 메모리 그림 — 스택·힙·정적 영역, 주소와 바이트를 글자 그림으로
  3. 완성 코드줄별 해설
  4. 실제 실행 결과 — 직접 컴파일해서 나온 출력 그대로
  5. 실무에서 자주 틀리는 것연습 문제 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)를 짝지어 실행 파일을 만든다.

헤더는 전처리로 각 소스에 붙고, 소스는 따로 오브젝트 파일이 되며, 링커가 필요함(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.ctotal_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 같은 기호는 컴파일러가 내부용으로 만든 이름이라 신경 쓰지 않아도 된다. 링커는 UT를 짝지어 주소를 채운다.

$ 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이 아닌 값을 돌려준다.

연습 문제

  1. price.c만 고쳤을 때 전체를 다시 빌드하지 않고 실행 파일을 새로 만드는 명령 두 줄을 써라.
  2. price.hint order_count = 0;을 추가하고 main.cprice.c가 둘 다 이 헤더를 인클루드한다. 어느 단계에서 어떤 문제가 생기는가? 어떻게 고치는가?
  3. 다음 두 오류 메시지는 각각 전처리·컴파일·링크 중 어느 단계에서 나오는가? (가) fatal error: 'prce.h' file not found (나) undefined reference to `total_price'

정답

  1. clang -std=c17 -Wall -Wextra -c price.cprice.o만 다시 만들고, clang main.o price.o -o shop으로 다시 링크한다. main.o는 그대로 재사용한다. 파일 단위 컴파일이 가능한 덕분에 큰 프로젝트에서도 바뀐 파일만 다시 번역하며, make가 하는 일이 바로 이 판단이다. 단, 헤더가 바뀌면 그 헤더를 인클루드한 모든 .c를 다시 컴파일해야 한다.
  2. 각 파일의 컴파일은 통과하지만, main.oprice.o에 모두 order_count의 정의가 생겨 링크 단계에서 중복 정의 오류가 난다. 헤더에는 extern int order_count;(선언)만 두고, price.c 한 곳에 int order_count = 0;(정의)을 둔다. 인클루드 가드는 한 파일 안에서의 중복만 막을 뿐 여러 파일 사이의 중복 정의는 막지 못한다.
  3. (가)는 전처리 단계다. #include가 파일을 찾지 못했다(철자 오류). (나)는 링크 단계다. 선언은 있었지만 정의가 담긴 오브젝트나 라이브러리가 링크에 빠졌다.

다음 장에서는 int가 정말 몇 바이트인지, -1이 메모리에 어떤 바이트로 저장되는지를 직접 찍어 본다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.