Proven C BookEnglish GitHub

16 일반적인 컴파일 과정(compilation)

먼저 알아야 할 것

15장 헬로 월드 · 첫 프로그램의 모양
4장 단순한 기계 모델 · 기계는 기계어만 실행한다

돌아보기

4장에서 추상화의 층계 — 트랜지스터에서 언어까지 겹겹이 쌓기 — 를 이야기했다. 그러면 컴파일러는 그 층계에서 몇 층부터 몇 층까지를 내려가는 번역인가?

답. “C 언어” 층에서 “기계 명령” 층까지다. 그런데 이 번역은 한 번에 건너뛰는 도약이 아니라, 중간 층계참을 밟아 내려가는 릴레이다 — 텍스트를 다듬는 층, C를 어셈블리라는 사람이 읽을 수 있는 기계어 표기로 바꾸는 층, 그것을 진짜 기계 명령으로 찍는 층, 그리고 조각들을 한 덩어리로 잇는 층. 이 장이 그 네 층계참의 견학이다.

이 장의 필요성과 맥락

첫 프로그램을 돌려 봤으니 그 사이에 무슨 일이 있었는지를 갚을 차례다. 이 장이 17장(도구 설치)보다 에 오는 것이 이 책의 선택이다 — 무엇을 하는 도구인지 알고 깔아야, 설치 화면의 낱말들이 뜻을 갖는다. 그리고 여기서 익힌 네 단계가 54장 링크와 16부의 빌드 이야기까지 곧장 이어진다.

이 장이 끝나면

hello.c라는 텍스트가 실행 파일이 되기까지의 네 단계 릴레이 — 전처리, 컴파일, 어셈블, 링크 — 를 구경한다. 어느 플랫폼, 어느 컴파일러(gcc·clang)에서든 통하는 일반적인 그림이다. 15장의 “주문” 두 개(#include의 정체, printf의 출처)가 여기서 풀린다.

이 장에서 답할 질문

  1. 네 단계를 매번 직접 시켜야 하는가?

16.1 겉보기 — 명령 한 줄

먼저 겉모습부터. gcc나 clang 같은 컴파일러에게 소스 파일을 주면 실행 파일이 나온다.

$ cc hello.c -o hello      (cc 자리는 gcc 또는 clang)
$ ./hello
안녕, 세상!

-o hello는 “결과물의 이름을 hello로 하라”는 뜻이다. 명령 한 줄로 끝나 보이지만, 이 한 줄 뒤에서 서로 다른 일꾼 네 명이 릴레이를 뛴다. 컴파일러에게 “중간 결과를 보여 달라”고 부탁하는 선택지들(-E, -S, -c)로 각 주자를 세워서 구경할 수 있다.

16.2 1주자 — 전처리: 텍스트를 짜깁기한다

전처리기(preprocessor)(preprocessor)는 아직 C를 모른다. 그저 텍스트를 다루는 가위와 풀이다. #으로 시작하는 줄들 — 15장의 주문 — 이 전처리기에게 내리는 지시다.

#include <stdio.h>의 정체가 여기서 풀린다. 지시의 뜻은 놀랄 만큼 우직하다 — “stdio.h라는 파일을 찾아서, 그 내용물을 이 자리에 통째로 붙여 넣어라.” 그게 전부다. 실제로 전처리만 시켜 보면(cc -E hello.c) 여섯 줄짜리 hello.c가 수만 줄로 불어난 결과가 나온다 — printf를 비롯한 표준 도구들의 선언(이런 이름의 함수가 존재한다는 예고 — 정식으로는 24장)이 줄줄이 붙어 들어온 것이다.

전처리기는 이 밖에도 글자 바꿔치기(매크로), 조건에 따라 코드 일부를 넣고 빼기 같은 텍스트 작업을 한다. 8장에서 만난 삼중자를 풀어 주던 것도(그 시절에는) 이 단계였다 — 전처리기는 C 컴파일의 첫 주자이자, 역사의 흉터가 모이는 곳이기도 하다.

16.3 2주자 — 컴파일: C를 어셈블리로

이제부터가 본편이다. 컴파일러 본체가 전처리된 C 코드를 읽고 — 문법을 검사하고, 뜻을 해석하고, 13장의 편집(최적화)을 가한 뒤 — 어셈블리라는 표기로 옮긴다. 어셈블리는 4장에서 스친 그 낮은 층의 언어다: 기계 명령과 거의 일대일이되, 사람이 읽을 수 있게 이름이 붙어 있다. cc -S hello.c로 이 중간 결과(hello.s)를 구경할 수 있는데, 대략 이런 모습의 조각이 들어 있다(기계와 컴파일러마다 다르다):

        lea     rdi, [rip + .L.str]    ; 인사말의 주소를 준비하고
        call    printf                 ; printf를 부른다
        xor     eax, eax               ; 0을 만들어
        ret                            ; 돌려주며 퇴장

우리가 쓴 네 문장의 골격이 그대로 비친다 — 그러나 13장에서 배웠듯, 이 대응이 언제나 이렇게 얌전히 남는 것은 아니다(최적화를 켜면 문장 대 문장 대응은 무너진다).

16.4 2주자의 안쪽 — 렉서와 파서

“C를 어셈블리로 바꾼다”는 한 줄 안에는 여러 단계가 들어 있다. 전통적인 구성은 이렇게 굳어져 있고, 오늘의 gcc·clang도 큰 틀은 같다.

소스 텍스트
   → [렉서]   글자 열을 토큰 열로
   → [파서]   토큰 열을 구문 나무로
   → [의미 분석] 타입 검사·이름 해석
   → [중간 표현] 최적화가 일어나는 자리
   → [코드 생성] 대상 기계의 명령으로

렉서(lexer, 어휘 분석기)는 글자를 낱말로 자른다. int x = 42;라는 열두 글자를 int / x / = / 42 / ;라는 다섯 개의 토큰으로 바꾸는 일이다. 공백과 주석은 이 단계에서 사라진다.

파서(parser, 구문 분석기)는 그 낱말들이 문법에 맞게 놓였는지 보고, 맞다면 구문 나무로 엮는다. “선언 = 타입 + 이름 + (초기화)”같은 규칙에 비추어 구조를 세우는 것이다. 문법에 어긋나면 여기서 오류가 나온다 — expected ';' before ... 같은 메시지가 파서의 목소리다.

C 표준은 토큰을 여섯 갈래로 나눈다.

갈래메모
키워드(keyword)int if return sizeof struct이름으로 쓸 수 없다
식별자(identifier)x main buffer_len이름. 첫 글자는 숫자가 아니다
상수(constant)42 0x1f 3.14 'a'문자 상수도 여기에 든다
문자열 리터럴"hello"배열이지 상수가 아니다(42장)
구두점(punctuator)+ ; { -> <<=연산자와 괄호·세미콜론
헤더 이름<stdio.h>전처리 단계에서만 쓰인다

표 16.1

두 가지를 알아 두면 오류 메시지를 읽는 눈이 생긴다.

첫째, 렉서는 가장 긴 것을 먼저 문다. a+++ba ++ + b로 잘린다 — +를 하나씩이 아니라 붙을 수 있는 만큼 붙여 읽기 때문이다. 이 규칙을 “최대 우물”(maximal munch)이라 부르고, x/*p처럼 나눗셈과 주석 시작이 붙는 자리에서 사람을 놀래킨다.

둘째, 파서는 이름의 뜻을 모른다. C 문법에는 유명한 모호함이 있다 — A * B;는 “A와 B의 곱”일 수도 있고 “A 타입의 포인터 B를 선언”일 수도 있다. 어느 쪽인지는 A가 타입 이름인지를 알아야 정해지므로, C 컴파일러는 파서와 이름표(심벌 테이블)를 주고받으며 이 문제를 푼다. C의 선언 문법이 읽기 어렵다는 평판의 한 뿌리가 여기 있다.

57장에서 다룰 번역 단계는 이 그림의 앞쪽 절반을 표준의 언어로 못박은 것이다 — 전처리가 끝나고 나면 남는 것이 위의 토큰 열이고, 컴파일러는 그 뒤부터 일을 시작한다.

16.5 3주자 — 어셈블: 기계 명령으로 찍어 내다

어셈블러가 어셈블리 텍스트를 진짜 기계 명령 — 비트들 — 로 찍어 낸다. 결과물이 목적 파일(object file, cc -c로 만들면 hello.o)이다. 이제 내용물은 사람이 읽는 텍스트가 아니라 4장의 세계, 기계 명령의 비트 열이다.

그런데 목적 파일은 아직 실행할 수 없다. 구멍이 뚫려 있기 때문이다call printf라고 찍기는 했는데, 정작 printf라는 것이 어디에 있는지 이 파일은 모른다. 우리 소스에는 printf의 선언(존재 예고)만 들어왔지, 그 몸통(실제 코드)은 들어온 적이 없다.

16.6 4주자 — 링크: 조각을 잇고 구멍을 메운다

마지막 주자 링커(linker)가 그 구멍을 메운다. printf의 몸통은 표준 라이브러리라는, 시스템에 미리 컴파일되어 놓여 있는 목적 파일들의 꾸러미 안에 있다. 링커는 우리의 목적 파일과 그 꾸러미를 이어 붙이고, “printf는 여기”라고 주소를 채워 넣어, 비로소 실행 파일 하나를 완성한다. 15장의 두 번째 주문 — printf는 어디서 오는가 — 의 답이 이것이다: 내가 쓴 적 없는 코드가, 링크 단계에서 내 프로그램에 이어져 들어온다.

실제 사례. 가장 낯선 오류 — undefined reference

초보 시절 가장 어리둥절한 오류가 링크 단계에서 나온다. 함수 이름의 철자를 틀리거나 라이브러리를 빠뜨리면, 컴파일은 멀쩡히 통과하고 마지막에 undefined reference to ...(정의를 찾을 수 없음) 같은 메시지가 나온다. “컴파일러는 통과시켰는데 왜?”가 어리둥절함의 정체인데 — 이제 답할 수 있다. 컴파일 단계는 선언(예고)만 있으면 만족하고, 몸통을 실제로 찾아 잇는 것은 링커의 일이라서다. 오류를 낸 일꾼이 다르면 오류의 종류도 다르다 — 메시지를 읽을 때 “네 주자 중 누가 넘어졌는가”부터 가리는 습관이 문제 해결의 절반이다(정식은 54장).

흔한 오해. “컴파일러가 소스를 실행 파일로 한 번에 번역한다”

겉보기(명령 한 줄)가 심어 주는 자연스러운 그림이지만, 실제로는 성격이 전혀 다른 네 도구의 릴레이다 — 텍스트 짜깁기(전처리), 진짜 번역과 편집(컴파일), 비트로 찍기(어셈블), 조각 잇기(링크). 이 구별이 실무 감각의 뿌리가 된다: 오류가 어느 단계의 것인지 가려 읽게 되고, 여러 소스 파일을 각각 목적 파일로 만들어 두었다가 링크만 다시 하는 큰 프로젝트의 빌드 방식(54장)이 자연스러워지고, “선언과 정의”(24장)라는 언어 규칙이 왜 존재하는지 — 컴파일러는 예고만 보고 일하고 링커가 실물을 잇기 때문 — 가 이해된다.

문. 네 단계를 매번 직접 시켜야 하는가?

답. 아니다 — 보통은 cc hello.c -o hello 한 줄이면 컴파일러가 네 주자를 알아서 차례로 뛰게 한다. 단계 선택지(-E, -S, -c)는 학습과 진단용 도구다. 다만 큰 프로젝트에서는 3주자까지만 파일별로 뛰게 해 두고(각각 .o로) 마지막 링크만 다시 하는 방식이 표준 관행이 된다 — 파일 하나 고쳤다고 전부를 다시 번역할 이유가 없기 때문이다. 그 세계는 54장에서 열린다.

이제 소스에서 실행까지의 밑그림이 생겼다. 남은 것은 이 릴레이를 내 컴퓨터에서 돌릴 수 있게 도구를 갖추는 일이다 — 다음 장에서 컴파일러를 설치하고, 버그를 그물처럼 걸러 주는 현대적 보조 도구(새니타이저)까지 장만한다.