LabHub
시작하기
배우기 러닝패스 코스

컴파일러 — 작은 언어를 처음부터 끝까지 만든다

gcc 를 멈춰 보고, 계산기를 두 번 만든다

LabHub 에서 이어서 보기

목표

gcc 를 전처리·컴파일·어셈블·링크 단계마다 멈춰 산출물을 확인하고, 같은 RPN 계산을 바로 계산하는 인터프리터C 로 옮겨 굽는 컴파일러 두 가지로 만들어 둘이 같은 답을 내는지 대조합니다.

왜 중요한가

컴파일러와 인터프리터는 같은 앞단을 쓰고 마지막 한 걸음이 다를 뿐입니다. 그 한 걸음 때문에 오류를 잡는 시점(실행 전인가 중인가), 속도(번역 비용을 몇 번에 나눠 내는가), 그리고 뜻이 어긋날 위험이 생깁니다. 파이썬으로 짠 인터프리터가 파이썬의 나눗셈 규칙을 그대로 쓰면, C 로 구운 쪽과 음수에서 답이 갈립니다. 이 실습은 그 차이를 작은 계산기에서 손으로 겪게 합니다.

RPN 계산기의 말

낱말      정수(부호가 붙을 수 있음, 예: 7, -3) · x(실행할 때 받는 값) · + - * / %
뜻        낱말을 왼쪽부터 읽는다. 수는 스택에 올리고, 연산자는 두 값을 내려(먼저 내린 것이 오른쪽)
          계산한 결과를 올린다. 끝에 값이 정확히 하나 남아야 한다.
나눗셈    C 처럼 0 쪽으로 자른다: -7 / 2 = -3, 나머지의 부호는 나눠지는 수를 따른다: -7 % 2 = -1
예        "x 1 + 2 *" 는 (x + 1) * 2
오류 글자 bad token 'foo' at 3      (1부터 센 낱말 자리)
          stack underflow at 2      (그 연산자의 자리)
          expected one value, got 2 (끝에 남은 값의 수)
          division by zero

단계

  1. /opt/fixtures/mini/c/hello.c 를 단계마다 멈춰 /root/mini/stages/hello.i(전처리), hello.s(어셈블리), hello.o(목적 파일), hello(실행 파일)를 만듭니다. nm hello.o 로 아직 풀리지 않은 이름(U)을 확인해 보세요.
  2. /root/mini/rpn.pytokenize(text) 를 채웁니다 — 공백으로 자른 낱말 목록, 모르는 낱말은 RpnError("bad token 'foo' at 3").
  3. evaluate(tokens, x) 를 채웁니다 — 스택으로 바로 계산하는 인터프리터. 오류는 위 글자 그대로 RpnError.
  4. check(tokens) 를 채웁니다 — 실행하지 않고 스택 깊이만 따라가, 문제가 없으면 가장 깊을 때의 칸 수를, 있으면 evaluate 와 같은 글자의 RpnError 를 냅니다. 0 나누기는 x 에 달려 있으니 여기서 판단하지 않습니다.
  5. to_c(tokens) 를 채웁니다 — 같은 계산을 하는 C 프로그램 글자. 먼저 check 로 검사해 틀린 프로그램이면 C 를 쓰지 않고 RpnError 를 냅니다. x 는 argv[1], 결과는 한 줄로 찍고, 0 으로 나누면 runtime error: division by zero 를 찍고 1 로 끝납니다.
  6. build(tokens, out) 를 채웁니다 — out.cout.sout.oout 을 gcc 로 차례로 만들고 중간 산출물을 지우지 않습니다.
  7. 여러분의 인터프리터와 여러분의 컴파일러를 서로 대조합니다. 채점기가 무작위 프로그램 15편을 x 네 값으로 두 길에 모두 돌립니다 — 음수 나눗셈·나머지에서 갈리지 않는지 확인하세요.
  8. bench(tokens, xs, out) 를 채우고, /opt/fixtures/mini/rpn/big.rpn 을 x = 0부터 199까지로 잰 결과를 /root/mini/rpn_report.json 에 그대로 적습니다.

참고

gcc 를 네 번 멈춰 세운다

gcc 는 -E(전처리)·-S(컴파일)·-c(어셈블)에서 멈출 수 있고, 옵션이 없으면 링크까지 갑니다. 앞 단계의 산출물을 다음 단계의 입력으로 주면 한 단계씩 나눠 볼 수 있습니다. .o 에는 printf 의 주소가 아직 없어서 nm 이 U 로 표시합니다.

낱말로 자른다

text.split() 으로 자른 뒤 낱말마다 x·연산자·정수인지 봅니다. 정수는 부호가 하나 붙을 수 있는 숫자열입니다('--3' 은 정수가 아닙니다). 자리는 1부터 셉니다.

스택으로 바로 계산한다 — 인터프리터

연산자를 만나면 두 값을 내립니다. 먼저 내린 것이 오른쪽 피연산자입니다. 나눗셈은 abs 로 몫을 구한 뒤 부호를 붙여 0 쪽으로 자르고, 나머지는 a - 몫*b 로 구하면 C 와 같아집니다.

실행하지 않고 검사한다

값 대신 스택의 깊이만 셉니다. 수는 +1, 연산자는 두 개를 내려 하나를 올리니 -1 이고, 그 전에 깊이가 2 이상인지 봅니다. 가장 깊었던 값이 C 배열의 크기가 됩니다.

같은 계산을 C 로 옮긴다 — 컴파일러

C 에도 스택 배열 하나와 칸 번호(sp)를 두면 낱말마다 한 줄씩 그대로 옮겨집니다. 배열 크기는 check 가 잰 깊이입니다. C 의 / 와 % 는 이미 0 쪽으로 자르므로 따로 할 일이 없지만, 0 으로 나누면 프로세스가 죽으니 그 앞에서 검사합니다.

C → 어셈블리 → 목적 파일 → 실행 파일

1단계에서 손으로 한 네 단계를 subprocess 로 차례로 부릅니다(gcc -S, gcc -c, gcc). 한 단계라도 실패하면 gcc 오류의 마지막 줄을 RpnError 에 담아 냅니다.

두 길이 같은 답을 내는지 대조한다

이 단계는 새 코드를 쓰지 않습니다. 채점기가 무작위 프로그램을 여러분의 evaluate 와 build 결과 양쪽에 돌립니다. 떨어지면 음수 나눗셈·나머지의 부호부터 확인하세요 — 인터프리터가 파이썬 규칙을 따르면 C 와 갈립니다.

언제 컴파일이 이득인가 잰다

time.perf_counter 로 세 구간을 잽니다 — 값마다 evaluate, build 한 번, 값마다 실행 파일 띄우기. 프로세스를 띄우는 비용이 계산보다 클 수 있다는 것도 숫자로 보입니다. same 은 두 결과 목록이 같은지입니다.