컴파일과 실행 과정
C++ 소스가 전처리·컴파일·어셈블·링크를 거쳐 실행 파일이 되는 과정과 단계별 산출물 및 오류를 구분합니다.
이전 장에서는 첫 C++ 프로그램의 작성, 컴파일, 실행 흐름을 확인했습니다.
단순히 코드를 작성하고 실행하는 것을 넘어 우리가 작성한 소스 코드가 컴퓨터에서 실제로 동작하는 실행 파일이 되기까지 어떤 과정을 거치는지 이해하는 것은 C++ 프로그래밍을 깊이 있게 이해하는 데 매우 중요합니다.
이 장에서는 C++ 소스 코드가 프로그램으로 변환되고 실행되는 전반적인 과정, 즉 컴파일(Compilation)과 링크(Linking) 과정의 단계와 산출물을 정리합니다.
소스 코드가 전처리, 컴파일, 어셈블, 링크를 거쳐 실행 파일로 완성되는 흐름과 각 단계의 산출물을 한눈에 정리합니다.
소스에서 실행 파일까지의 논리 단계
main.cpp→main.ii→main.s→main.o→hello.exe의 산출물 전달이 명확해 전처리·컴파일·어셈블·링크를 같은 영역에서 추적할 수 있다.
C++ · TRANSLATION PIPELINE
드라이버는 네 빌드 단계를 산출물로 연결한다
아래는 GCC 드라이버의 단계별 중지 옵션입니다. 앞 단계의 산출물이 다음 단계의 입력이 됩니다.
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를 전처리한 입력으로 바꾼다. |
|
|
| 컴파일·어셈블 어셈블리와 목적 코드를 만든다. |
|
|
| 링크 목적 코드와 라이브러리를 연결한다. |
|
|
소스에서 실행 파일까지의 논리 단계
main.cpp→main.ii→main.s→main.o→hello.exe의 산출물 전달이 명확해 전처리·컴파일·어셈블·링크를 같은 영역에서 추적할 수 있다.
C++ · TRANSLATION PIPELINE
드라이버는 네 빌드 단계를 산출물로 연결한다
아래는 GCC 드라이버의 단계별 중지 옵션입니다. 앞 단계의 산출물이 다음 단계의 입력이 됩니다.
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를 전처리한 입력으로 바꾼다. |
|
|
| 컴파일·어셈블 어셈블리와 목적 코드를 만든다. |
|
|
| 링크 목적 코드와 라이브러리를 연결한다. |
|
|
왜 컴파일이 필요한가?
우리가 작성하는 C++ 코드는 사람이 이해하기 쉬운 고급 언어(High-level Language)로 표현됩니다.
하지만 컴퓨터의 중앙처리장치(CPU)는 0과 1로 이루어진 기계어(Machine Code)만을 직접 이해하고 실행할 수 있습니다.
따라서 우리가 작성한 C++ 코드를 컴퓨터가 이해할 수 있는 기계어로 변환해 주는 과정이 반드시 필요합니다.
이 역할을 수행하는 것이 바로 컴파일러(Compiler)입니다.
컴파일러는 C++ 소스 코드를 읽어 들여 문법 오류를 검사하고, 오류가 없다면 이를 기계어로 번역하여 목적 파일(Object File)을 생성합니다.
목적 파일은 아직 완전한 실행 파일이 아니며, 여러 조각의 기계어 코드와 참조 정보들을 담고 있습니다.
컴파일 과정의 단계별 이해
C++ 컴파일 과정은 크게 네 가지 단계로 나누어 볼 수 있습니다.
여러분이 g++ hello_world.cpp -o hello_world와 같은 명령어를 입력할 때, 내부적으로 이 모든 과정이 순차적으로 진행됩니다.
- 역할: 소스 코드를 컴파일러가 처리하기 전에 미리 처리하는 단계입니다.
#으로 시작하는 전처리기 지시자(Preprocessor Directive)를 처리합니다. -
주요 작업
#include: 지정된 헤더 파일(*.h,*.hpp또는 확장자가 없는 표준 라이브러리 파일)의 내용을 현재 소스 코드에 삽입합니다. 예를 들어,<iostream>을 포함하면iostream헤더 파일의 내용이 우리 코드에 실제로 복사됩니다.#define: 매크로를 정의하고 치환합니다. 예를 들어#define PI 3.141592가 있다면, 매크로가 정의된 뒤의PI식별자 토큰을3.141592로 치환합니다. 문자열"PI"의 글자를 검색해 바꾸는 작업은 아닙니다.- 주석 제거:
//나/* ... */로 작성된 모든 주석을 제거합니다. 전처리 단계에서 주석을 공백으로 바꾸므로 이후 문법 분석에는 주석의 설명 문구가 전달되지 않습니다.
- 결과물: 전처리된 소스 코드 파일(GCC의 C++ 전처리 입력은
.ii확장자를 사용합니다..i는 C 전처리 입력으로 인식됩니다). 이 파일은 아직 C++ 언어 형태를 유지하고 있습니다.
예시 명령어 (전처리까지만 수행)
g++ -E hello_world.cpp -o hello_world.ii- 역할: 전처리된 소스 코드를 어셈블리어(Assembly Language)로 번역합니다. 어셈블리어는 기계어와 1:1로 대응되는 저수준 언어이지만, 사람이 읽고 이해할 수 있는 형태를 가집니다.
- 주요 작업: C++ 문법에 오류가 없는지 검사하고, 코드를 어셈블리어 명령어로 변환합니다.
- 결과물: 어셈블리 코드 파일(일반적으로
.s확장자를 가집니다).
예시 명령어 (컴파일까지만 수행)
g++ -S hello_world.ii -o hello_world.s또는 (전처리부터 컴파일까지 한 번에)
g++ -S hello_world.cpp -o hello_world.s- 역할: 어셈블리 코드를 기계어 명령어로 직접 번역하여 목적 파일(Object File)을 생성합니다. 목적 파일은 CPU가 이해할 수 있는 이진 형태의 코드이지만, 아직 완전한 실행 파일은 아닙니다. 다른 목적 파일이나 라이브러리와 연결되어야 합니다.
- 주요 작업: 어셈블리 코드를 컴퓨터가 직접 실행할 수 있는 바이너리 형식으로 변환합니다.
- 결과물: 목적 파일(GCC에서는 Windows에서도 일반적으로
.o를 사용하고, MSVC에서는.obj를 사용합니다).
예시 명령어 (어셈블까지만 수행)
g++ -c hello_world.s -o hello_world.o또는 (전처리, 컴파일, 어셈블까지 한 번에)
g++ -c hello_world.cpp -o hello_world.o- 역할: 하나 이상의 목적 파일과 필요한 라이브러리(표준 라이브러리, 외부 라이브러리 등)를 하나로 묶어 최종 실행 가능한 프로그램(Executable File)을 생성합니다.
-
주요 작업
- 목적 파일들 간의 참조를 해결합니다. 예를 들어,
main에서 표준 출력 객체std::cout을 사용하는 출력 코드는 관련 객체와 연산의 정의에 대한 참조를 연결해야 합니다. - 프로그램에서 사용하는 표준 라이브러리 객체와 함수(예:
std::cout객체,std::endl함수 템플릿)에 필요한 외부 정의를 연결합니다. - 이 과정에서 필요한 모든 코드가 연결되지 않거나, 중복되는 정의가 있을 경우 링크 오류가 발생합니다.
- 목적 파일들 간의 참조를 해결합니다. 예를 들어,
- 결과물: 최종 실행 파일(Windows에서는
.exe, macOS/Linux에서는 확장자 없음).
예시 명령어 (링크까지만 수행)
g++ hello_world.o -o hello_world이전에 우리가 사용했던 g++ hello_world.cpp -o hello_world 명령어는 위에서 설명한 네 가지 과정을 모두 포함하고 있습니다.
컴파일러 드라이버(compiler driver)가 내부적으로 이 모든 단계를 자동으로 처리해 주는 것입니다.
오류가 날 때는 네 단계 중 어디에서 멈췄는지를 먼저 잡아야 합니다.
정적 링크와 동적 링크
링크 과정에서 라이브러리를 연결하는 방식은 크게 두 가지로 나눌 수 있습니다.
-
정적 링크 (Static Linking)
- 라이브러리 코드가 최종 실행 파일 안에 직접 포함됩니다.
- 장점: 정적으로 포함한 라이브러리의 별도 파일을 배포할 필요가 줄어듭니다. 다른 동적 의존성과 운영체제·ABI 요구까지 없어지는 것은 아닙니다.
- 단점: 실행 파일의 크기가 커집니다. 동일한 라이브러리를 사용하는 여러 프로그램이 있더라도 각각의 실행 파일에 라이브러리 코드가 중복되어 포함됩니다. 라이브러리 업데이트를 반영하려면 적어도 다시 링크해야 하며, 인터페이스 변경은 재컴파일도 요구할 수 있습니다.
-
동적 링크 (Dynamic Linking)
- 라이브러리 코드가 실행 파일에 직접 포함되지 않고, 실행 시점에 운영체제에 의해 메모리에 로드됩니다. 실행 파일에는 필요한 심벌과 동적 의존성·재배치 정보 등이 남습니다.
- 장점: 실행 파일의 크기가 작습니다. 동일한 라이브러리를 여러 프로그램이 공유할 수 있어 메모리 사용이 효율적입니다. 호환되는 ABI를 유지한 라이브러리는 실행 파일을 다시 빌드하지 않고 교체할 수 있습니다. 배포 경로와 버전 호환은 확인해야 합니다.
- 단점: 실행 파일이 동작하려면 해당 라이브러리 파일(Windows의
.dll, macOS의.dylib, Linux의.so)이 시스템에 존재해야 합니다. 라이브러리 버전에 따라 호환성 문제가 발생할 수도 있습니다 (DLL Hell).
대부분의 현대 운영체제에서는 동적 링크를 기본으로 사용합니다.
이는 디스크 공간과 메모리 효율성, 그리고 라이브러리 업데이트의 용이성 때문입니다.
정적 링크와 동적 링크는 “무엇을 실행 파일 안에 넣을 것인가”를 정하는 배포 전략이기도 합니다.
실행 파일 크기, 의존 파일 관리, 업데이트 방식, 실행 환경 진단을 함께 비교하면 링크 방식을 더 실무적으로 판단할 수 있습니다.
C++ · LINKAGE BOUNDARY
정적 코드는 실행 파일에, 동적 코드는 의존 파일에 남는다
두 방식 모두 링크 단계의 심벌 참조와 실행 환경의 배포 계약을 확인합니다.
정적 링크
- 링크 입력
목적 파일과 정적 라이브러리가 정의와 심벌을 제공합니다.
- 실행 파일 생성
필요한 정적 코드가 선택되어 실행 파일에 포함됩니다.
- 배포와 업데이트
포함한 라이브러리 파일은 별도 배포하지 않아도 되지만, 다른 동적 의존성은 남을 수 있습니다. 업데이트를 반영하려면 다시 링크합니다.
동적 링크
- 링크 입력
공유 라이브러리 정보 또는 import library로 심벌 참조와 의존성을 기록합니다.
- 실행 파일 생성
실행 파일은 라이브러리 구현 전체를 포함하지 않고 필요한 동적 의존성을 유지합니다.
- 실행 시 연결
로더가 DLL·SO·dylib 등을 적재합니다. 라이브러리 경로·버전·ABI가 맞아야 하며 호환되는 파일로 교체할 수 있습니다.
Windows에서는 import library를 쓰는 암시적 DLL 연결 외에, 코드에서 LoadLibrary로 명시적으로 적재하는 방식도 있습니다. 링크 성공과 실행 시 의존성 충족을 따로 확인합니다.
오류의 종류: 컴파일 오류 vs 런타임 오류
프로그래밍을 하다 보면 필연적으로 오류(Error)를 만나게 됩니다.
먼저 번역 중 발생하는 컴파일 오류와 실행이 시작된 뒤의 문제를 비교합니다. 그 사이의 링크 오류도 별도로 구분해야 합니다.
- 발생 시점: 컴파일러가 소스 코드를 기계어로 번역하는 과정에서 발생합니다.
- 원인: 주로 C++ 언어의 문법 규칙을 위반했거나, 오타, 선언되지 않은 변수 사용 등 컴파일러가 코드를 이해할 수 없을 때 발생합니다.
- 특징: 컴파일러가 오류 메시지를 통해 어떤 파일의 몇 번째 줄에서 어떤 종류의 오류가 발생했는지 상세하게 알려줍니다. 이번 소스로 새 실행 파일을 만들려면 컴파일 오류를 해결한 뒤 링크도 성공해야 합니다. 이전 빌드의 실행 파일이 남아 있다는 사실과 이번 빌드 성공은 다릅니다.
- 예시: 세미콜론(
;) 누락, 오타(std::cout을std::coutt으로 작성), 변수 선언 없이 사용 등.
- 발생 시점: 프로그램이 컴파일을 통과해 실행된 후에 발생합니다.
- 원인: 주로 프로그램의 논리적인 오류, 잘못된 데이터 처리, 메모리 접근 오류, 0으로 나누기, 존재하지 않는 파일 열기 등 프로그램이 예상치 못한 상황에 직면했을 때 발생합니다.
- 특징: 번역을 통과해도 입력값과 실제 실행 경로에 따라 문제가 드러날 수 있습니다. 컴파일러 경고나 분석 도구가 일부 원인을 미리 발견할 수 있지만 모든 문제를 검출한다는 보장은 없습니다. 실행 중 실패나 잘못된 결과가 생길 수도 있고, 정의되지 않은 동작은 겉으로 정상인 것처럼 보일 수도 있습니다.
- 예시: 배열 범위 밖 접근이나 잘못된 포인터 사용은 정의되지 않은 동작을 일으킬 수 있습니다. 메모리 누수는 자원 관리 문제이며 즉시 종료를 보장하지 않습니다. 존재하지 않는 파일을 여는 일은 실패 상태를 검사하고 처리할 수 있는 입출력 실패입니다.
컴파일 오류는 컴파일러의 친절한 메시지를 통해 비교적 쉽게 수정할 수 있지만, 런타임 오류는 프로그램의 동작을 분석하고 디버깅(Debugging) 도구를 사용하여 원인을 찾아내야 하므로 해결하기가 더 까다로울 수 있습니다.
오류를 만났을 때는 메시지에 포함된 단어와 실행 파일 생성 여부를 기준으로 전처리, 컴파일, 링크, 런타임 중 어디에서 멈췄는지 먼저 나누면 수정 위치를 빠르게 좁힐 수 있습니다.
단계별 명령은 GCC의 출력 제어 옵션의 -E, -S, -c에 대응합니다. 위 명령은 컴파일러의 기본 언어 표준을 사용합니다. 개발 환경 설정 절처럼 C++20을 명시하려면 전처리 명령과 .ii를 컴파일하는 명령에 모두 -std=c++20을 추가합니다. 전처리 때 선택한 표준에 따라 헤더 내용도 달라질 수 있으므로 두 단계의 조건을 맞춥니다. 이미 작성한 hello_world.cpp로 위 명령을 차례대로 실행하고, 각 명령 뒤에 .ii, .s, .o, 실행 파일 중 무엇이 새로 생겼는지 확인해 보세요. .o까지 성공해도 아직 실행 파일은 없다는 것이 핵심입니다.
예시
다음 파일은 twice라는 함수의 이름과 사용 방법만 선언하고, 실제 계산을 수행하는 정의는 제공하지 않습니다. 뒤의 함수 장에서 선언과 정의를 자세히 다루지만, 지금은 ‘이런 함수를 쓸 수 있다’는 정보와 ‘그 함수가 수행할 코드’가 다르다는 점을 관찰합니다.
int twice(int value);
int main() {
return twice(2);
}g++ -c missing_definition.cpp -o missing_definition.o는 목적 파일을 만들 수 있습니다. 그러나 g++ missing_definition.o -o missing_definition은 함수의 정의를 찾지 못해 링크에서 실패합니다. 진단 표현은 툴체인마다 다르지만 GCC에서는 undefined reference가 대표적입니다. 이 사례에서는 실행이 시작되지 않았으므로 런타임 오류로 분류하지 않습니다.
혼자 분류해 보세요. 출력문에서 세미콜론을 빼서 번역이 멈춘 경우, 위처럼 함수 정의가 빠진 경우, 정상 빌드된 프로그램이 합계 대신 다른 계산 결과를 출력하는 경우는 각각 어느 단계의 문제일까요? 마지막 경우에 빌드 옵션부터 바꾸어야 하는지도 생각해 보세요.
첫째는 컴파일 단계, 둘째는 링크 단계, 셋째는 실행 결과를 요구와 비교해 찾는 논리 오류입니다. 셋째는 프로그램이 정상 상태로 종료할 수도 있으므로, 종료 상태 0만으로 계산이 맞았다고 판단할 수 없습니다.