본문 바로가기

컴파일과 실행 과정

g++ 한 번에 묶여 있던 전처리, 컴파일, 어셈블, 링크를 -E, -S, -c 옵션으로 나누어 실행하며 각 단계가 만드는 파일을 확인하고, 정적 링크와 동적 링크의 차이와, 컴파일 오류와 링크 오류, 실행 중의 오류를 멈춘 단계로 구별합니다.

CPU가 직접 실행할 수 있는 것은 0과 1로 된 기계어(machine code)뿐이어서, 사람이 읽고 쓰는 C++ 코드는 기계어로 번역해야 실행할 수 있습니다. 앞 글에서 g++ 명령 한 번으로 실행 파일을 만들었지만, 그 안에서는 여러 단계가 차례로 일어나고 단계마다 다른 파일이 만들어집니다.

이 글은 그 단계를 하나씩 나누어 실행하며 각 단계의 입력과 출력을 확인합니다. 이어서 라이브러리를 실행 파일에 넣는 두 가지 방식을 비교하고, 오류가 났을 때 어느 단계에서 멈췄는지로 원인의 범위를 좁히는 방법을 봅니다.

네 단계와 각 단계의 파일

소스에서 실행 파일까지의 논리 단계

main.cpp→main.ii→main.s→main.o→hello.exe의 산출물 전달이 명확해 전처리·컴파일·어셈블·링크를 같은 영역에서 추적할 수 있다.

C++ · TRANSLATION PIPELINE

드라이버는 네 빌드 단계를 산출물로 연결한다

아래는 GCC 드라이버의 단계별 중지 옵션입니다. 앞 단계의 산출물이 다음 단계의 입력이 됩니다.

네 단계와 파일의 전달앞 단계의 산출물이 다음 단계의 입력이다. .ii는 전처리 소스, .s는 어셈블리, .o는 목적 파일이고 마지막 링크가 Windows 예시의 hello.exe를 만든다.
1 · 전처리main.cpp → main.ii
연결 관계1 · 전처리 · main.cpp → main.ii → 2 · 컴파일 · main.ii → main.s
2 · 컴파일main.ii → main.s
연결 관계2 · 컴파일 · main.ii → main.s → 3 · 어셈블 · main.s → main.o
3 · 어셈블main.s → main.o
연결 관계3 · 어셈블 · main.s → main.o → 4 · 링크 · main.o → hello.exe
4 · 링크main.o → hello.exe

1 · 전처리

g++ -E main.cpp -o main.ii

#include와 매크로를 처리합니다. 산출물: C++ 전처리 소스 main.ii

2 · 컴파일

g++ -S main.ii -o main.s

언어·타입을 검사하고 어셈블리로 번역합니다. 산출물: main.s

3 · 어셈블

g++ -c main.s -o main.o

어셈블리에서 목적 코드를 만듭니다. 산출물: 목적 파일 main.o · 아직 링크하지 않습니다.

4 · 링크

g++ main.o -o hello.exe

목적 파일과 필요한 라이브러리의 참조를 해결합니다. 산출물: 이 Windows 예시의 실행 파일 hello.exe

g++ main.cpp -o hello.exe는 위 단계를 한 번에 조정합니다. 중간 파일이 항상 남는 것은 아니며, 파일 형식·이름은 대상과 툴체인에 따라 달라집니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
전처리

main.cpp를 전처리한 입력으로 바꾼다.

  • 명령 옵션: -E
  • 산출물: main.ii
  • include와 매크로의 처리가 번역의 입력을 준비한다.
컴파일·어셈블

어셈블리와 목적 코드를 만든다.

  • -S 산출물: main.s
  • -c 산출물: main.o
  • 어셈블리는 목적 코드로 변환되어 링크 입력이 된다.
링크

목적 코드와 라이브러리를 연결한다.

  • 입력: main.o
  • 산출물: hello.exe
  • 목적 파일만으로 해결되지 않은 연결을 링크 단계가 완성한다.

소스에서 실행 파일까지의 논리 단계

main.cpp→main.ii→main.s→main.o→hello.exe의 산출물 전달이 명확해 전처리·컴파일·어셈블·링크를 같은 영역에서 추적할 수 있다.

C++ · TRANSLATION PIPELINE

드라이버는 네 빌드 단계를 산출물로 연결한다

아래는 GCC 드라이버의 단계별 중지 옵션입니다. 앞 단계의 산출물이 다음 단계의 입력이 됩니다.

네 단계와 파일의 전달앞 단계의 산출물이 다음 단계의 입력이다. .ii는 전처리 소스, .s는 어셈블리, .o는 목적 파일이고 마지막 링크가 Windows 예시의 hello.exe를 만든다.
1 · 전처리main.cpp → main.ii
연결 관계1 · 전처리 · main.cpp → main.ii → 2 · 컴파일 · main.ii → main.s
2 · 컴파일main.ii → main.s
연결 관계2 · 컴파일 · main.ii → main.s → 3 · 어셈블 · main.s → main.o
3 · 어셈블main.s → main.o
연결 관계3 · 어셈블 · main.s → main.o → 4 · 링크 · main.o → hello.exe
4 · 링크main.o → hello.exe

1 · 전처리

g++ -E main.cpp -o main.ii

#include와 매크로를 처리합니다. 산출물: C++ 전처리 소스 main.ii

2 · 컴파일

g++ -S main.ii -o main.s

언어·타입을 검사하고 어셈블리로 번역합니다. 산출물: main.s

3 · 어셈블

g++ -c main.s -o main.o

어셈블리에서 목적 코드를 만듭니다. 산출물: 목적 파일 main.o · 아직 링크하지 않습니다.

4 · 링크

g++ main.o -o hello.exe

목적 파일과 필요한 라이브러리의 참조를 해결합니다. 산출물: 이 Windows 예시의 실행 파일 hello.exe

g++ main.cpp -o hello.exe는 위 단계를 한 번에 조정합니다. 중간 파일이 항상 남는 것은 아니며, 파일 형식·이름은 대상과 툴체인에 따라 달라집니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
전처리

main.cpp를 전처리한 입력으로 바꾼다.

  • 명령 옵션: -E
  • 산출물: main.ii
  • include와 매크로의 처리가 번역의 입력을 준비한다.
컴파일·어셈블

어셈블리와 목적 코드를 만든다.

  • -S 산출물: main.s
  • -c 산출물: main.o
  • 어셈블리는 목적 코드로 변환되어 링크 입력이 된다.
링크

목적 코드와 라이브러리를 연결한다.

  • 입력: main.o
  • 산출물: hello.exe
  • 목적 파일만으로 해결되지 않은 연결을 링크 단계가 완성한다.

그림처럼 빌드는 전처리, 컴파일, 어셈블, 링크의 네 단계로 이루어지며, 앞 단계가 만든 파일이 다음 단계의 입력이 됩니다. GCC는 -E, -S, -c 옵션으로 각 단계에서 멈추게 할 수 있으므로, 첫 번째 C++ 프로그램의 코드를 main.cpp로 저장해 단계별로 실행해 봅니다.

g++ -std=c++20 -E main.cpp -o main.ii
g++ -std=c++20 -S main.ii -o main.s
g++ -c main.s -o main.o
g++ main.o -o hello.exe

첫 명령의 전처리(preprocessing)는 #으로 시작하는 지시자를 처리합니다. #include가 가리키는 헤더의 내용을 그 자리에 끌어오고, #define으로 정한 매크로를 바꿔 넣으며, 주석을 공백으로 바꿉니다. 결과인 main.ii는 여전히 C++ 코드이지만, <iostream>의 내용이 모두 들어와 수만 줄로 늘어나 있습니다. GCC는 C++의 전처리 결과에 .ii를, C의 전처리 결과에 .i를 씁니다.

둘째 명령의 컴파일(compilation)은 전처리된 코드의 문법과 타입을 검사하고, 기계어와 거의 일대일로 대응하는 어셈블리어(assembly language)로 번역해 main.s를 만듭니다. 문법 오류나 선언하지 않은 이름 같은 대부분의 오류가 이 단계에서 나옵니다. 전처리한 표준과 컴파일하는 표준이 다르면 헤더의 내용이 어긋날 수 있으므로, 두 명령에 같은 -std=c++20을 줍니다.

셋째 명령의 어셈블(assembly)은 어셈블리어를 기계어로 바꾸어 목적 파일(object file) main.o를 만듭니다. 목적 파일에는 이 소스의 기계어와 함께, 이 소스가 정의한 이름과 다른 곳에서 찾아야 하는 이름의 목록이 들어 있지만, 아직 실행할 수는 없습니다. GCC는 Windows에서도 .o를 쓰고, MSVC는 .obj를 씁니다.

넷째 명령의 링크(linking)는 목적 파일들과 표준 라이브러리를 묶어 실행 파일을 만듭니다. main.o가 쓰는 std::cout 같은 이름을 라이브러리에서 찾아 연결하며, 찾지 못하거나 같은 이름의 정의가 둘 있으면 링크가 실패합니다. 지금까지 쓴 g++ main.cpp -o hello.exe는 이 네 단계를 한 번에 실행하는 명령이고, 중간 파일은 임시로만 만들었다가 지웁니다. 각 옵션은 GCC의 출력 종류 제어 옵션에 정리되어 있습니다.

정적 링크와 동적 링크

링크할 때 라이브러리의 코드를 다루는 방식은 두 가지입니다.

C++ · LINKAGE BOUNDARY

정적 코드는 실행 파일에, 동적 코드는 의존 파일에 남는다

두 방식 모두 링크 단계의 심벌 참조와 실행 환경의 배포 계약을 확인합니다.

코드의 포함과 실행 시 의존성정적 링크는 필요한 구현을 실행 파일 안에 넣는다. 동적 링크는 실행 파일이 별도 의존성을 기록하고 로더가 의존 파일을 적재해야 한다.정적 링크 · 포함 경계동적 링크 · 별도 파일실행 파일필요한 정적 코드 포함실행 파일동적 의존성 기록로더실행 시 의존 파일 적재DLL · SO · dylib구현 코드 · 경로 · ABI

정적 링크

  1. 링크 입력

    목적 파일과 정적 라이브러리가 정의와 심벌을 제공합니다.

  2. 실행 파일 생성

    필요한 정적 코드가 선택되어 실행 파일에 포함됩니다.

  3. 배포와 업데이트

    포함한 라이브러리 파일은 별도 배포하지 않아도 되지만, 다른 동적 의존성은 남을 수 있습니다. 업데이트를 반영하려면 다시 링크합니다.

동적 링크

  1. 링크 입력

    공유 라이브러리 정보 또는 import library로 심벌 참조와 의존성을 기록합니다.

  2. 실행 파일 생성

    실행 파일은 라이브러리 구현 전체를 포함하지 않고 필요한 동적 의존성을 유지합니다.

  3. 실행 시 연결

    로더가 DLL·SO·dylib 등을 적재합니다. 라이브러리 경로·버전·ABI가 맞아야 하며 호환되는 파일로 교체할 수 있습니다.

Windows에서는 import library를 쓰는 암시적 DLL 연결 외에, 코드에서 LoadLibrary로 명시적으로 적재하는 방식도 있습니다. 링크 성공과 실행 시 의존성 충족을 따로 확인합니다.

정적 링크(static linking)는 필요한 라이브러리 코드를 실행 파일 안에 복사해 넣습니다. 그 라이브러리의 파일을 따로 배포하지 않아도 되지만, 실행 파일이 커지고, 같은 라이브러리를 쓰는 프로그램마다 그 코드를 따로 가지며, 라이브러리를 고치면 다시 링크해야 그 변경이 반영됩니다. 정적으로 넣은 라이브러리 말고 다른 동적 의존성은 여전히 남을 수 있습니다.

동적 링크(dynamic linking)는 실행 파일에 라이브러리의 코드 대신 어떤 라이브러리가 필요한지만 기록하고, 실행할 때 운영체제의 로더(loader)가 Windows의 .dll, Linux의 .so, macOS의 .dylib 파일을 찾아 메모리에 올립니다. 실행 파일이 작고 여러 프로그램이 한 라이브러리를 나누어 쓰며, 호환되는 라이브러리로 바꾸면 다시 빌드하지 않고도 고친 내용이 반영됩니다. 대신 실행하는 컴퓨터에 그 라이브러리가 맞는 경로와 버전으로 있어야 하며, 버전이 맞지 않아 프로그램이 시작하지 못하는 문제를 흔히 DLL 지옥(DLL hell)이라고 부릅니다.

대부분의 운영체제는 표준 라이브러리 같은 시스템 라이브러리를 동적으로 링크합니다. 예를 들어 MSYS2의 GCC로 만든 hello.exe를 다른 Windows 컴퓨터에서 실행하면 libstdc++-6.dll을 찾지 못한다는 오류가 날 수 있는데, 이것은 빌드한 컴퓨터에는 있던 동적 라이브러리가 실행할 컴퓨터에는 없기 때문입니다. 어느 방식으로 링크할지는 실행 파일의 크기, 함께 배포할 파일, 라이브러리를 고치는 방법을 함께 따져 정합니다.

더 깊이: 링크 명령에서 라이브러리를 적는 자리

GNU 링커(ld)는 목적 파일을 묶어 둔 정적 라이브러리인 아카이브(archive, .a)를 명령줄에 적힌 자리에서 한 번만 훑습니다. 그 자리에서는 앞에 나온 목적 파일이 아직 찾지 못한 이름만 아카이브에서 꺼내 오고, 아카이브보다 뒤에 적힌 목적 파일이 새로 필요로 하는 이름 때문에 다시 돌아가 찾지는 않습니다. GCC 문서의 예로 foo.o -lz bar.o는 z 라이브러리를 foo.o 다음, bar.o 앞에서 훑으므로, bar.o만 쓰는 z의 함수는 링크되지 않을 수 있습니다. 그래서 같은 파일들이라도 적는 순서에 따라 undefined reference가 나기도 하므로, 라이브러리는 그것을 쓰는 목적 파일보다 뒤에 적습니다.

두 아카이브가 서로의 함수를 부르는 경우처럼 한 번 훑는 것으로 부족하면, 링커 옵션 --start-group과 --end-group 사이에 두 아카이브를 넣습니다. 그러면 링커는 찾지 못한 이름이 더 생기지 않을 때까지 그 묶음을 되풀이해 훑습니다. g++에서는 -Wl,--start-group처럼 앞에 -Wl,을 붙여 이 옵션을 링커에 전달합니다.

표준 라이브러리를 실행 파일에 넣을지도 링크 옵션으로 정합니다. -static-libstdc++는 g++가 libstdc++만 정적으로 링크하게 할 뿐 다른 라이브러리까지 정적으로 바꾸지는 않고, -static-libgcc는 GCC의 저수준 런타임 라이브러리인 libgcc를 정적 버전으로 쓰게 합니다. -static은 동적 링크를 지원하는 시스템에서 공유 라이브러리와 링크하지 않도록 합니다. 각 옵션의 정확한 설명은 GCC의 링크 옵션 문서에 있으며, 어느 DLL이 여전히 필요한지는 옮겨 갈 컴퓨터에서 직접 실행해 확인합니다.

멈춘 단계로 오류 구별하기

오류가 나면 먼저 어느 단계에서 멈췄는지를 봅니다. 그러면 고쳐야 할 곳의 범위가 크게 줄어듭니다.

컴파일 오류(compile-time error)는 전처리나 컴파일 단계에서 소스가 C++의 규칙에 맞지 않을 때 납니다. 세미콜론을 빠뜨리거나, std::cout을 std::coutt로 잘못 쓰거나, 선언하지 않은 변수를 쓰면 컴파일러가 파일 이름과 줄 번호와 함께 진단을 냅니다. 이번 소스로 새 실행 파일을 만들려면 이 오류를 모두 고쳐야 하며, 예전 빌드의 실행 파일이 남아 있다고 이번 빌드가 성공한 것은 아닙니다.

링크 오류(link error)는 각 소스는 컴파일되었지만, 쓰는 함수나 변수의 정의를 어디서도 찾지 못하거나 같은 정의가 두 곳에 있을 때 납니다. 다음 파일은 함수 twice를 쓸 수 있다고 선언만 하고, 실제로 계산하는 정의는 두지 않았습니다.

missing_definition.cpp
int twice(int value);

int main() {
    return twice(2);
}

g++ -c missing_definition.cpp -o missing_definition.o는 성공합니다. 컴파일러는 선언만 보고도 twice를 부르는 코드를 만들 수 있기 때문입니다. 하지만 g++ missing_definition.o -o missing_definition은 링커가 twice의 정의를 찾지 못해 실패하며, GCC는 이것을 undefined reference to 'twice(int)' 모양의 진단으로 알립니다. 실행 파일이 만들어지지 않았으므로 이것은 실행 중의 오류가 아닙니다.

실행 중의 오류(run-time error)는 빌드가 성공하고 프로그램이 실행된 뒤에 드러납니다. 존재하지 않는 파일을 여는 것처럼 프로그램이 실패를 검사하고 처리할 수 있는 오류도 있고, 계산이 틀린 논리 오류도 있으며, 배열의 범위 밖에 접근하는 것처럼 표준이 결과를 정하지 않는 정의되지 않은 동작(undefined behavior)도 있습니다. 정의되지 않은 동작은 프로그램을 멈추게 할 수도 있지만 겉으로는 정상처럼 동작할 수도 있어서, 경고와 분석 도구, 디버거로 원인을 찾아야 합니다.

정리

  • 빌드는 전처리, 컴파일, 어셈블, 링크의 네 단계로 이루어지며, GCC에서는 -E, -S, -c로 각 단계에서 멈추게 할 수 있습니다.
  • 전처리는 .ii를, 컴파일은 어셈블리 .s를, 어셈블은 목적 파일 .o를 만들고, 링크가 목적 파일과 라이브러리를 묶어 실행 파일을 만듭니다.
  • 목적 파일까지 만들어도 아직 실행할 수 없으며, 쓰는 이름의 정의를 모두 찾아 연결하는 것은 링크의 일입니다.
  • 정적 링크는 라이브러리의 코드를 실행 파일에 넣고, 동적 링크는 실행할 때 로더가 라이브러리 파일을 찾아 올리므로 실행하는 컴퓨터에 그 파일이 있어야 합니다.
  • 오류는 멈춘 단계로 구별하며, 컴파일 오류는 소스의 규칙을, 링크 오류는 정의의 위치를, 실행 중의 오류는 입력과 실행 경로를 살펴봅니다.

연습 문제

1. 단계마다 생기는 파일

본문의 네 명령을 차례로 실행했습니다. 셋째 명령까지만 실행했다면 폴더에 어떤 파일이 생겼고, 그중 실행할 수 있는 파일이 있나요?

해설 보기

main.ii, main.s, main.o가 생깁니다. 목적 파일 main.o에는 기계어가 들어 있지만 std::cout 같은 이름을 아직 라이브러리와 연결하지 않았으므로 실행할 수 없고, 넷째 명령의 링크를 거쳐야 hello.exe가 생깁니다.

2. 세 가지 실패의 단계

출력 문장의 세미콜론을 빼서 빌드가 멈춘 경우, 본문의 twice처럼 정의가 빠진 함수를 부른 경우, 빌드와 실행은 되지만 합계 대신 다른 값을 출력하는 경우는 각각 어느 단계의 문제인가요? 마지막 경우에 빌드 옵션부터 바꿔야 하나요?

해설 보기

첫째는 컴파일 단계의 오류, 둘째는 링크 단계의 오류, 셋째는 실행 결과를 요구와 비교해 찾아야 하는 논리 오류입니다. 셋째는 빌드에는 문제가 없으므로 빌드 옵션이 아니라 계산하는 코드를 디버거와 출력으로 확인하며, 프로그램이 0을 돌려주며 끝나더라도 계산이 맞았다는 뜻은 아닙니다.

3. 다른 컴퓨터에서 실행되지 않는 프로그램

MSYS2의 GCC로 만든 hello.exe를 GCC가 없는 다른 Windows 컴퓨터로 옮겼더니, libstdc++-6.dll이 없어 실행할 수 없다는 메시지가 나왔습니다. 무엇이 원인이고, 어떻게 해결할 수 있나요?

해설 보기

hello.exe가 C++ 표준 라이브러리를 동적으로 링크해, 실행할 때 libstdc++-6.dll을 찾는데 그 컴퓨터에는 그 파일이 없기 때문입니다. 필요한 DLL을 실행 파일과 함께 배포하거나, 표준 라이브러리를 정적으로 링크하는 옵션으로 다시 빌드해 실행 파일에 넣는 방법이 있습니다. 어느 쪽이든 실행할 컴퓨터에서 직접 실행해 확인합니다.

이것으로 1장의 C++ 소개와 개발 환경을 마칩니다. 다음 장의 기본 데이터 타입에서는 정수와 실수, 문자, 논리 값을 담는 C++의 기본 타입과, 그 크기와 범위가 구현에 따라 어떻게 달라지는지를 다룹니다.