负责翻译的程序与负责执行的程序
한국어 원문으로 표시합니다.
한 줄 요약
컴파일러는 프로그램을 실행하지 않고 다른 언어의 프로그램으로 옮기는 프로그램이고, 인터프리터는 프로그램을 읽으면서 바로 실행하는 프로그램입니다. 둘은 같은 앞단(글자를 토큰으로, 토큰을 트리로, 트리의 뜻을 검사)을 공유하고, 마지막에 '지금 계산할 것인가, 나중에 계산할 코드를 남길 것인가' 에서 갈립니다.
왜 이게 필요했나
CPU 는 x = (a + 1) * 2 를 읽지 못합니다. 읽을 수 있는 것은 add, imul 같은 명령과 레지스터 번호뿐입니다. 사람이 쓰기 좋은 글과 기계가 돌릴 수 있는 명령 사이의 거리를 누군가 메워야 하는데, 메우는 시점이 두 가지입니다.
- 실행하기 전에 한 번(컴파일러). 번역에 시간이 들지만, 번역한 결과는 몇 번이고 빠르게 돕니다. 오류 가운데 상당수 — 없는 변수, 맞지 않는 타입, 괄호 짝 — 를 사용자가 프로그램을 돌리기도 전에 잡을 수 있습니다.
- 실행하면서 매번(인터프리터). 번역을 기다리지 않고 바로 돌지만, 같은 줄을 백만 번 돌면 백만 번 해석합니다. 대신 구현이 단순하고, 실행 중인 상태를 들여다보기 쉽습니다.
실무에서 둘은 섞여 있습니다. 파이썬은 소스를 바이트코드로 컴파일한 뒤(__pycache__ 의 .pyc) 그 바이트코드를 인터프리트합니다. 자바는 javac 가 바이트코드를 만들고, JVM 이 처음엔 인터프리트하다가 자주 도는 메서드만 기계어로 컴파일합니다(JIT). 그래서 "이 언어는 컴파일 언어인가" 보다 "어느 단계를 언제 하는가" 가 더 정확한 질문입니다.
어떻게 동작하나
컴파일러는 한 덩어리가 아니라 단계의 줄입니다. 앞 단계의 출력이 다음 단계의 입력이 되고, 이 코스의 모듈도 그 줄을 그대로 따라갑니다.
소스 글자 ──렉서──▶ 토큰 ──파서──▶ 트리(AST) ──의미 분석──▶ 검사된 트리
(2모듈) (3모듈) (4모듈)
│
┌─────────────────────────────────┼──────────────────────────┐
▼ ▼ ▼
트리를 걸으며 실행 바이트코드 + 스택 VM 중간 표현 → 최적화 → x86-64
(5모듈) (6모듈) (7·8·9모듈)
gcc 도 같은 줄을 몇 개의 프로그램으로 나눠 돌립니다. 평소에는 gcc hello.c 한 줄에 가려 보이지 않지만, 옵션으로 단계마다 멈출 수 있습니다.
| 멈추는 옵션 | 한 일 | 산출물 |
|---|---|---|
-E |
전처리 — #include 를 펼치고 #define 을 바꿔 넣는다 |
.i(아직 C) |
-S |
컴파일 — C 를 어셈블리로 | .s(글자) |
-c |
어셈블 — 어셈블리를 기계어로 | .o(재배치 가능 목적 파일) |
| (없음) | 링크 — 목적 파일과 라이브러리를 이어 붙인다 | 실행 파일 |
목적 파일에는 아직 주소가 정해지지 않은 이름이 남습니다. hello.o 가 부르는 printf 는 libc 에 있으므로, nm hello.o 는 그 이름 앞에 U(undefined)를 찍습니다. 링커가 그 빈칸을 채웁니다. 링크 단계에서만 나는 오류("undefined reference to …")가 따로 있는 이유가 이것입니다 — 컴파일은 파일 하나만 보고, 링크는 전부를 봅니다.
인터프리터와 컴파일러가 같은 뜻을 지키는지도 따로 확인해야 합니다. 이 실습의 계산기는 나눗셈을 C 처럼 0 쪽으로 자릅니다(-7 / 2 는 -3). 파이썬의 // 는 아래로 내리므로(-4) 인터프리터를 파이썬으로 짜면서 // 를 쓰면, 같은 프로그램이 인터프리터와 컴파일한 실행 파일에서 다른 답을 냅니다. 두 구현의 뜻이 같은지는 저절로 지켜지지 않고, 돌려서 대조해야만 압니다. 이 코스의 채점기가 거의 모든 단계에서 기준 구현과 무작위 입력으로 대조하는 이유도 같습니다.
현장에서 만나는 모습
- 빌드는 되는데 링크가 안 된다. 헤더(
#include)는 선언만 주므로 컴파일은 통과하고, 정의가 든 라이브러리를 링크 줄에 빠뜨리면undefined reference가 납니다. 위 표의 어느 단계에서 났는지를 알면 고칠 곳(소스인가, 빌드 설정인가)이 바로 갈립니다. - "파이썬은 인터프리터 언어라 느리다". 느린 까닭은 컴파일을 안 해서가 아니라, 바이트코드 명령 하나하나가 타입을 실행 중에 확인하기 때문입니다. PyPy 나 JVM 처럼 실행 중에 자주 도는 곳을 다시 컴파일(JIT)하면 같은 소스가 수십 배 빨라지기도 합니다.
- CI 의 빌드 캐시. 컴파일이 파일 단위로 끝나고 링크가 따로 있기 때문에, 바뀐 파일만 다시 컴파일하고 링크만 새로 하는 증분 빌드가 가능합니다.
이 코스 내내 만들 언어는 '미니' 입니다. 64비트 정수와 참거짓, let·print·if·while·fn·return, 그리고 우선순위가 있는 연산자 스무 개 남짓이 전부입니다. 작지만 렉서부터 x86-64 코드까지 한 줄로 이어 보기에 모자라지 않고, 모든 단계를 채점기가 실제로 돌려 볼 수 있습니다.
다음 실습에서 할 것
/opt/fixtures/mini/c/hello.c 를 gcc 로 단계마다 멈춰 네 산출물을 만들고, nm 으로 목적 파일의 빈칸을 봅니다. 그다음 RPN 계산기를 rpn.py 하나에 두 번 만듭니다 — 토큰으로 자르고, 스택으로 바로 계산하는 인터프리터, 실행하지 않고 스택 깊이만 따라가는 검사기, 같은 계산을 하는 C 를 써서 gcc 로 굽는 컴파일러. 끝으로 두 길이 같은 답을 내는지 무작위 프로그램으로 대조하고, 어느 쪽이 언제 빠른지 잽니다.