15

가상 머신

마술사들은 비밀이 거대하고 중요해서가 아니라, 너무나 작고 사소해서 비밀을 지킨다. 무대 위에서 만들어지는 놀라운 효과들은 종종 마술사가 그렇게 했다고 인정하기 창피할 정도로 터무니없는 비밀의 결과이다.

크리스토퍼 프리스트, 프레스티지

우리는 프로그램을 바이트코드 명령어 시퀀스로 표현하는 방법에 대해 많은 시간을 할애했지만, 마치 박제된 죽은 동물로만 생물학을 배우는 것과 같은 느낌이 듭니다. 명령어들이 이론적으로 무엇인지 알고는 있지만, 실제로 동작하는 것을 본 적이 없기 때문에 그것들이 무엇을 하는지 제대로 이해하기 어렵습니다. 바이트코드가 어떻게 동작하는지 잘 이해하지 못하면, 바이트코드를 출력하는 컴파일러를 작성하기 어려울 것입니다.

그래서 새로운 인터프리터의 프런트엔드를 구축하기 전에, 백엔드, 즉 명령어들을 실행하는 가상 머신부터 시작하겠습니다. 가상 머신은 바이트코드에 생명을 불어넣습니다. 명령어들이 움직이는 것을 지켜보면 컴파일러가 사용자의 소스 코드를 일련의 명령어로 어떻게 변환할지에 대한 더 명확한 그림을 얻을 수 있습니다.

15 . 1명령어 실행 머신

가상 머신은 우리 인터프리터의 내부 아키텍처 중 한 부분입니다. 코드 덩어리, 말 그대로 `Chunk`를 전달하면 가상 머신이 이를 실행합니다. VM의 코드와 데이터 구조는 새 모듈에 포함됩니다.

vm.h
새 파일 생성
#ifndef clox_vm_h
#define clox_vm_h

#include "chunk.h"

typedef struct {
  Chunk* chunk;
} VM;

void initVM();
void freeVM();

#endif
vm.h, 새 파일 생성

늘 그렇듯 간단하게 시작합니다. VM은 점차 추적해야 할 많은 상태를 가지게 될 것이므로, 지금은 이를 담을 구조체를 정의합니다. 현재는 실행할 `chunk`만 저장합니다.

우리가 만드는 대부분의 데이터 구조와 마찬가지로, VM을 생성하고 해체하는 함수도 정의합니다. 다음은 그 구현입니다.

vm.c
새 파일 생성
#include "common.h"
#include "vm.h"

VM vm; 

void initVM() {
}

void freeVM() {
}
vm.c, 새 파일 생성

이 함수들을 "구현"이라고 부르기에는 무리가 있습니다. 아직 초기화하거나 해제할 흥미로운 상태가 없어서 함수는 비어 있습니다. 하지만 곧 채워질 것입니다.

여기서 조금 더 흥미로운 부분은 `vm` 선언입니다. 이 모듈은 결국 많은 함수를 갖게 될 것이고, 모든 함수에 VM 포인터를 전달하는 것은 번거로운 일이 될 것입니다. 대신, 단일 전역 VM 객체를 선언합니다. 어차피 하나만 필요하며, 이렇게 하면 책의 코드가 조금 더 간결해집니다.

VM에 재미있는 코드를 넣기 전에, 인터프리터의 메인 진입점에 VM을 연결해 봅시다.

int main(int argc, const char* argv[]) {
main.c
main() 함수 안
  initVM();

  Chunk chunk;
main.c, main() 함수 안

인터프리터가 처음 시작될 때 VM을 가동합니다. 그리고 종료할 때는 VM을 해제합니다.

  disassembleChunk(&chunk, "test chunk");
main.c
main() 함수 안
  freeVM();
  freeChunk(&chunk);
main.c, main() 함수 안

마지막 의식입니다.

#include "debug.h"
main.c
#include "vm.h"

int main(int argc, const char* argv[]) {
main.c

이제 clox를 실행하면 지난 장에서 수동으로 작성한 청크를 생성하기 전에 VM이 시작됩니다. VM이 준비되었으니, 이제 VM에 무언가를 시켜봅시다.

15 . 1 . 1명령어 실행

바이트코드 청크를 해석하라는 명령이 내려지면 VM이 작동합니다.

  disassembleChunk(&chunk, "test chunk");
main.c
main() 함수 안
  interpret(&chunk);
  freeVM();
main.c, main() 함수 안

이 함수는 VM의 주요 진입점입니다. 다음과 같이 선언됩니다.

void freeVM();
vm.h
freeVM() 함수 뒤에 추가
InterpretResult interpret(Chunk* chunk);

#endif
vm.h, freeVM() 함수 뒤에 추가

VM은 청크를 실행한 다음 이 enum의 값으로 응답합니다.

} VM;

vm.h
struct VM 뒤에 추가
typedef enum {
  INTERPRET_OK,
  INTERPRET_COMPILE_ERROR,
  INTERPRET_RUNTIME_ERROR
} InterpretResult;

void initVM();
void freeVM();
vm.h, struct VM 뒤에 추가

아직 이 결과를 사용하지는 않지만, 정적 오류를 보고하는 컴파일러와 런타임 오류를 감지하는 VM이 갖춰지면 인터프리터는 이를 사용하여 프로세스의 종료 코드를 설정하는 방법을 알게 될 것입니다.

실제 구현에 점점 가까워지고 있습니다.

vm.c
freeVM() 함수 뒤에 추가
InterpretResult interpret(Chunk* chunk) {
  vm.chunk = chunk;
  vm.ip = vm.chunk->code;
  return run();
}
vm.c, freeVM() 함수 뒤에 추가

먼저, 실행할 청크를 VM에 저장합니다. 그런 다음 바이트코드 명령어를 실제로 실행하는 내부 헬퍼 함수인 `run()`을 호출합니다. 이 두 부분 사이에 흥미로운 줄이 있습니다. 이 `ip`는 무엇일까요?

VM이 바이트코드를 처리하면서 현재 실행 중인 명령어의 위치를 추적합니다. `run()` 내부의 지역 변수를 사용하지 않는 이유는 궁극적으로 다른 함수들도 이 값에 접근해야 하기 때문입니다. 대신, 이를 VM의 필드로 저장합니다.

typedef struct {
  Chunk* chunk;
vm.h
struct VM
  uint8_t* ip;
} VM;
vm.h, struct VM

`ip`의 타입은 바이트 포인터입니다. 정수형 인덱스 대신 바이트코드 배열의 중간을 직접 가리키는 실제 C 포인터를 사용하는 이유는 인덱스로 배열의 요소를 찾는 것보다 포인터를 역참조하는 것이 더 빠르기 때문입니다.

"IP"라는 이름은 전통적인 이름이며, 대부분의 전통적인 컴퓨터 과학 용어들과 달리 실제로 의미가 통합니다. 명령어 포인터(instruction pointer)의 약자입니다. 실제 컴퓨터든 가상 컴퓨터든 거의 모든 명령어 세트에는 이와 같은 레지스터나 변수가 있습니다.

`ip`는 청크 내 코드의 첫 번째 바이트를 가리키도록 초기화됩니다. 아직 해당 명령어를 실행하지 않았으므로, `ip`는 실행될 예정인 명령어를 가리킵니다. VM이 실행되는 내내 이 규칙이 적용됩니다. IP는 항상 현재 처리 중인 명령어가 아니라 다음 명령어를 가리킵니다.

진정한 재미는 `run()`에서 시작됩니다.

vm.c
freeVM() 함수 뒤에 추가
static InterpretResult run() {
#define READ_BYTE() (*vm.ip++)

  for (;;) {
    uint8_t instruction;
    switch (instruction = READ_BYTE()) {
      case OP_RETURN: {
        return INTERPRET_OK;
      }
    }
  }

#undef READ_BYTE
}
vm.c, freeVM() 함수 뒤에 추가

이것은 clox의 모든 함수 중에서 단연코 가장 중요한 함수입니다. 인터프리터가 사용자의 프로그램을 실행할 때, 시간의 약 90%를 `run()` 내부에서 보낼 것입니다. 이 함수는 VM의 심장부입니다.

극적인 소개에도 불구하고 개념적으로는 매우 간단합니다. 계속해서 반복되는 외부 루프가 있습니다. 이 루프의 각 턴마다 단일 바이트코드 명령어를 읽고 실행합니다.

명령어를 처리하려면 먼저 어떤 종류의 명령어인지 파악해야 합니다. `READ_BYTE` 매크로는 `ip`가 현재 가리키는 바이트를 읽고, 그 다음 명령어 포인터를 진행시킵니다. 모든 명령어의 첫 번째 바이트는 opcode입니다. 숫자 opcode가 주어지면, 해당 명령어의 의미론을 구현하는 올바른 C 코드에 도달해야 합니다. 이 과정을 디코딩(decoding) 또는 디스패칭(dispatching)이라고 합니다.

우리는 모든 단일 명령어에 대해, 매번 실행될 때마다 이 과정을 수행하므로, 이 부분은 가상 머신 전체에서 성능에 가장 중요한 부분입니다. 프로그래밍 언어의 역사에는 컴퓨터 초창기부터 바이트코드 디스패치를 효율적으로 수행하는 영리한 기술들이 가득합니다.

아쉽게도 가장 빠른 솔루션은 비표준 C 확장 기능이나 직접 작성한 어셈블리 코드를 필요로 합니다. clox에서는 간단하게 유지할 것입니다. 우리 디스어셈블러처럼, 각 opcode에 대한 `case`를 가진 하나의 거대한 `switch` 문이 있습니다. 각 `case`의 본문은 해당 opcode의 동작을 구현합니다.

지금까지는 `OP_RETURN`이라는 단일 명령어만 처리하며, 이 명령어는 루프를 완전히 종료하는 역할만 합니다. 결국 이 명령어는 현재 Lox 함수에서 반환하는 데 사용될 것이지만, 아직 함수가 없으므로 임시로 실행을 종료하는 용도로 사용하겠습니다.

이제 다른 명령어 하나도 지원하도록 합시다.

    switch (instruction = READ_BYTE()) {
vm.c
run() 함수 안
      case OP_CONSTANT: {
        Value constant = READ_CONSTANT();
        printValue(constant);
        printf("\n");
        break;
      }
      case OP_RETURN: {
vm.c, run() 함수 안

상수를 가지고 할 만한 충분한 메커니즘이 아직 없습니다. 지금은 단순히 상수를 출력하여 인터프리터 해커들이 VM 내부에서 무슨 일이 벌어지는지 볼 수 있도록 할 것입니다. `printf()` 호출을 위해 포함 파일이 필요합니다.

vm.c
파일 맨 위에 추가
#include <stdio.h>

#include "common.h"
vm.c, 파일 맨 위에 추가

새로운 매크로도 정의해야 합니다.

#define READ_BYTE() (*vm.ip++)
vm.c
run() 함수 안
#define READ_CONSTANT() (vm.chunk->constants.values[READ_BYTE()])

  for (;;) {
vm.c, run() 함수 안

`READ_CONSTANT()`는 바이트코드에서 다음 바이트를 읽어 그 결과를 인덱스로 취급하고, 청크의 상수 테이블에서 해당 `Value`를 찾아옵니다. 이후 장에서는 피연산자가 상수를 참조하는 몇 가지 명령어를 더 추가할 것이므로, 지금 이 헬퍼 매크로를 설정해 둡니다.

이전 `READ_BYTE` 매크로와 마찬가지로, `READ_CONSTANT`는 `run()` 내부에서만 사용됩니다. 이러한 스코프를 더 명확히 하기 위해 매크로 정의 자체를 해당 함수로 제한했습니다. 시작 부분에 정의하고, 우리가 신경 쓰기 때문에 끝 부분에 정의를 해제합니다.

#undef READ_BYTE
vm.c
run() 함수 안
#undef READ_CONSTANT
}
vm.c, run() 함수 안

15 . 1 . 2실행 추적

지금 clox를 실행하면 지난 장에서 수동으로 작성한 청크를 실행하고 터미널에 `1.2`를 출력합니다. 작동하는 것을 볼 수 있지만, 이는 `OP_CONSTANT` 구현에 값을 기록하는 임시 코드가 있기 때문입니다. 이 명령어가 제 역할을 하고 상수를 사용하려는 다른 연산으로 전달하기 시작하면 VM은 블랙박스가 될 것입니다. 이는 VM 구현자로서 우리의 삶을 더 어렵게 만듭니다.

스스로를 돕기 위해, 청크 자체에서 그랬듯이 VM에 일부 진단 로깅을 추가하는 것이 좋은 시기입니다. 사실, 같은 코드를 재사용할 것입니다. 이 로깅은 항상 활성화되기를 원치 않습니다VM 해커를 위한 것이지 Lox 사용자를 위한 것이 아니기 때문입니다. 그래서 먼저 이를 숨길 플래그를 만듭니다.

#include <stdint.h>
common.h

#define DEBUG_TRACE_EXECUTION

#endif
common.h

이 플래그가 정의되면, VM은 각 명령어를 실행하기 직전에 디스어셈블하고 출력합니다. 이전 디스어셈블러가 전체 청크를 한 번에 정적으로 순회했던 것과 달리, 이 방식은 명령어를 동적으로 실시간으로 디스어셈블합니다.

  for (;;) {
vm.c
run() 함수 안
#ifdef DEBUG_TRACE_EXECUTION
    disassembleInstruction(vm.chunk,
                           (int)(vm.ip - vm.chunk->code));
#endif

    uint8_t instruction;
vm.c, run() 함수 안

`disassembleInstruction()`는 정수 바이트 오프셋을 인자로 받고, 우리는 현재 명령어 참조를 직접 포인터로 저장하므로, 먼저 작은 포인터 연산을 통해 `ip`를 바이트코드 시작점으로부터의 상대 오프셋으로 변환합니다. 그런 다음 해당 바이트에서 시작하는 명령어를 디스어셈블합니다.

늘 그렇듯이, 함수를 호출하기 전에 선언을 포함해야 합니다.

#include "common.h"
vm.c
#include "debug.h"
#include "vm.h"
vm.c

이 코드가 아직 그렇게 인상적이지 않다는 것을 압니다글자 그대로 `for` 루프로 감싸진 `switch` 문일 뿐입니다. 하지만 믿거나 말거나, 이것이 우리 VM의 두 가지 주요 구성 요소 중 하나입니다. 이것으로 명령어를 명령형으로 실행할 수 있습니다. 그 단순함은 장점입니다작업을 적게 할수록 더 빠르게 수행할 수 있습니다. AST 순회를 위한 Visitor 패턴을 사용했던 jlox의 복잡성과 오버헤드와는 대조적입니다.

15 . 2값 스택 조작기

명령형 부작용 외에도, Lox에는 값을 생성, 수정 및 소비하는 표현식이 있습니다. 따라서 컴파일된 바이트코드는 값이 필요한 다양한 명령어들 사이에서 값을 주고받는 방법을 필요로 합니다. 예를 들어:

print 3 - 2;

상수 3과 2, `print` 문, 그리고 뺄셈을 위한 명령어가 분명히 필요합니다. 하지만 뺄셈 명령어는 3이 피감수이고 2가 감수라는 것을 어떻게 알까요? `print` 명령어는 그 결과를 출력해야 한다는 것을 어떻게 알까요?

좀 더 명확히 하자면, 다음 코드를 보세요.

fun echo(n) {
  print n;
  return n;
}

print echo(echo(1) + echo(2)) + echo(echo(4) + echo(5));

각 하위 표현식을 인수를 출력하고 반환하는 `echo()` 호출로 래핑했습니다. 이 부작용은 연산의 정확한 순서를 볼 수 있게 해줍니다.

잠시 VM에 대해 걱정하지 마세요. Lox 자체의 의미론에 대해서만 생각해 봅시다. 산술 연산자의 피연산자는 연산 자체를 수행하기 전에 분명히 평가되어야 합니다. (`a`와 `b`가 무엇인지 모른다면 `a + b`를 더하기는 꽤 어렵습니다.) 또한 jlox에서 표현식을 구현할 때, 왼쪽 피연산자가 오른쪽 피연산자보다 먼저 평가되어야 한다고 결정했습니다.

다음은 `print` 문의 구문 트리입니다.

예제 문장의 AST, 숫자는 노드가 평가되는 순서를 나타냅니다.

왼쪽에서 오른쪽으로의 평가와 표현식 중첩 방식을 고려할 때, 올바른 Lox 구현은 이 숫자들을 다음 순서로 출력해야 합니다.

1  // echo(1)에서
2  // echo(2)에서
3  // echo(1 + 2)에서
4  // echo(4)에서
5  // echo(5)에서
9  // echo(4 + 5)에서
12 // print 3 + 9에서

이전 jlox 인터프리터는 AST를 재귀적으로 순회하여 이를 달성했습니다. 후위 순회를 수행합니다. 먼저 왼쪽 피연산자 브랜치를 재귀적으로 순회하고, 그 다음 오른쪽 피연산자를 순회한 다음, 마지막으로 노드 자체를 평가합니다.

왼쪽 피연산자를 평가한 후, jlox는 오른쪽 피연산자 트리를 순회하는 동안 해당 결과를 어딘가에 임시로 저장해야 합니다. 우리는 이를 위해 Java의 지역 변수를 사용했습니다. 우리 재귀 트리-워크 인터프리터는 평가되는 각 노드에 대해 고유한 Java 호출 프레임을 생성했으므로, 필요한 만큼 이러한 지역 변수를 가질 수 있었습니다.

clox에서 `run()` 함수는 재귀적이지 않습니다중첩된 표현식 트리는 선형적인 명령어 시퀀스로 평탄화됩니다. C 지역 변수를 사용할 여유가 없으므로, 이러한 임시 값들을 어디에 어떻게 저장해야 할까요? 아마 이미 짐작하셨겠지만, 저는 이 점에 대해 깊이 파고들고 싶습니다. 왜냐하면 이는 우리가 당연하게 여기지만, 컴퓨터가 이러한 방식으로 설계된 이유를 거의 배우지 않는 프로그래밍의 한 측면이기 때문입니다.

이상한 연습을 해봅시다. 위 프로그램을 단계별로 실행해 보겠습니다.

명령어 시퀀스와 어떤 숫자를 어떤 명령어에 걸쳐 보존해야 하는지 보여주는 막대 그래프.

왼쪽은 코드 단계입니다. 오른쪽은 추적하는 값들입니다. 각 막대는 숫자를 나타냅니다. 값은 처음 생성될 때 시작됩니다상수이거나 덧셈의 결과로. 막대의 길이는 이전에 생성된 값을 얼마나 오래 유지해야 하는지를 나타내며, 해당 값이 연산에 의해 최종적으로 사용될 때 끝납니다.

단계별로 진행하면서 값이 나타났다가 나중에 사라지는 것을 볼 수 있습니다. 가장 오래 살아남는 값들은 덧셈의 왼쪽 피연산자에서 생성된 값들입니다. 이 값들은 오른쪽 피연산자 표현식을 처리하는 동안 계속 존재합니다.

위 다이어그램에서 각 고유한 숫자에는 고유한 시각적 열을 주었습니다. 좀 더 절약해 봅시다. 숫자가 사용되고 나면, 해당 열은 나중에 다른 값을 위해 재사용될 수 있습니다. 즉, 위의 모든 빈 공간을 채우고, 오른쪽에서 숫자들을 밀어 넣습니다.

이전 다이어그램과 유사하지만, 숫자 막대가 왼쪽으로 밀려 스택을 형성합니다.

여기서 흥미로운 일들이 벌어지고 있습니다. 모든 것을 왼쪽으로 옮겼을 때, 각 숫자는 전체 수명 동안 여전히 단일 열에 머무릅니다. 또한, 빈 공간도 남지 않습니다. 다시 말해, 어떤 숫자가 다른 숫자보다 먼저 나타나면, 적어도 두 번째 숫자만큼 오래 살아남습니다. 가장 먼저 나타나는 숫자는 가장 마지막에 사용됩니다. 음 . . . 후입선출 . . . 이것은 스택입니다!

두 번째 다이어그램에서, 숫자를 도입할 때마다 스택의 오른쪽에서 푸시합니다. 숫자가 사용될 때는 항상 가장 오른쪽에서 왼쪽으로 팝됩니다.

추적해야 할 임시 값들이 스택과 같은 동작을 하기 때문에, 우리 VM은 스택을 사용하여 이 값들을 관리할 것입니다. 명령어가 값을 "생성"하면, 스택에 푸시합니다. 하나 이상의 값이 필요할 때, 스택에서 팝하여 가져옵니다.

15 . 2 . 1VM의 스택

이것이 대단한 발견처럼 보이지 않을 수도 있지만, 저는 스택 기반 VM을 정말 좋아합니다. 처음 마술을 볼 때는 실제로 마법 같은 느낌이 듭니다. 하지만 작동 방식을 배우고 나면대개 기계적 속임수나 시선 분산경이로움은 사라집니다. 컴퓨터 과학에는 제가 그것들을 분해하고 모든 것을 배운 후에도 초기 반짝임이 일부 남아있는 몇몇 아이디어들이 있습니다. 스택 기반 VM이 바로 그중 하나입니다.

이번 장에서 보겠지만, 스택 기반 VM에서 명령어를 실행하는 것은 아주 간단합니다. 이후 장에서는 소스 언어를 스택 기반 명령어 세트로 컴파일하는 것도 식은 죽 먹기라는 것을 알게 될 것입니다. 그럼에도 불구하고 이 아키텍처는 프로덕션 언어 구현에서 사용될 만큼 충분히 빠릅니다. 마치 프로그래밍 언어 게임에서 속임수를 쓰는 것 같은 느낌이 듭니다.

좋습니다, 이제 코딩할 시간입니다! 다음은 스택입니다.

typedef struct {
  Chunk* chunk;
  uint8_t* ip;
vm.h
struct VM
  Value stack[STACK_MAX];
  Value* stackTop;
} VM;
vm.h, struct VM

우리는 순수한 C 배열 위에 스택 의미론을 직접 구현합니다. 스택의 바닥가장 먼저 푸시되고 가장 마지막에 팝되는 값은 배열의 0번 인덱스에 있으며, 나중에 푸시된 값들은 그 뒤를 따릅니다. 제가 가장 좋아하는 쌓을 수 있는 아침 식사 메뉴인 "crepe"의 글자를 순서대로 스택에 푸시하면, 결과 C 배열은 다음과 같습니다.

'crepe'의 글자가 0번 인덱스부터 순서대로 담긴 배열.

값이 푸시되고 팝됨에 따라 스택이 커지고 줄어들기 때문에, 배열에서 스택의 맨 위가 어디인지 추적해야 합니다. `ip`와 마찬가지로, 정수 인덱스 대신 직접 포인터를 사용하는데, 이는 필요할 때마다 인덱스에서 오프셋을 계산하는 것보다 포인터를 역참조하는 것이 더 빠르기 때문입니다.

포인터는 스택의 가장 위에 있는 값을 포함하는 요소 바로 다음 요소를 가리킵니다. 이것이 약간 이상하게 들릴 수 있지만, 거의 모든 구현이 이 방식을 따릅니다. 이는 배열의 0번 인덱스를 가리킴으로써 스택이 비어 있음을 나타낼 수 있음을 의미합니다.

stackTop이 첫 번째 요소를 가리키는 빈 배열.

만약 맨 위 요소를 가리켰다면, 빈 스택의 경우 -1번 인덱스를 가리켜야 했을 것입니다. 이는 C에서 정의되지 않은 동작입니다. 값을 스택에 푸시하면 . . . 

0번 인덱스에 'c'가 있는 배열.

 . . . `stackTop`은 항상 마지막 항목 바로 다음을 가리킵니다.

처음 다섯 요소에 'c', 'r', 'e', 'p', 'e'가 들어 있는 배열.

저는 이렇게 기억합니다: `stackTop`은 다음에 푸시될 값이 들어갈 위치를 가리킨다. (지금은 적어도) 스택에 저장할 수 있는 최대 값의 수는 다음과 같습니다.

#include "chunk.h"
vm.h

#define STACK_MAX 256

typedef struct {
vm.h

VM에 고정된 스택 크기를 부여한다는 것은 일련의 명령어가 너무 많은 값을 푸시하여 스택 공간이 부족해질 수 있다는 것을 의미합니다고전적인 "스택 오버플로우"입니다. 필요에 따라 스택을 동적으로 늘릴 수도 있지만, 지금은 간단하게 유지하겠습니다. VM이 `Value`를 사용하므로, `Value`의 선언을 포함해야 합니다.

#include "chunk.h"
vm.h
#include "value.h"

#define STACK_MAX 256
vm.h

이제 VM이 흥미로운 상태를 갖게 되었으니, 이를 초기화할 차례입니다.

void initVM() {
vm.c
initVM() 함수 안
  resetStack();
}
vm.c, initVM() 함수 안

이것은 다음 헬퍼 함수를 사용합니다.

vm.c
변수 vm 뒤에 추가
static void resetStack() {
  vm.stackTop = vm.stack;
}
vm.c, 변수 vm 뒤에 추가

스택 배열은 VM 구조체에 직접 선언되어 있으므로, 따로 할당할 필요가 없습니다. 배열의 사용되지 않은 셀을 명시적으로 지울 필요도 없습니다값이 저장된 후에만 접근할 것이기 때문입니다. 필요한 유일한 초기화는 스택이 비어 있음을 나타내기 위해 `stackTop`이 배열의 시작을 가리키도록 설정하는 것입니다.

스택 프로토콜은 두 가지 연산을 지원합니다.

InterpretResult interpret(Chunk* chunk);
vm.h
interpret() 함수 뒤에 추가
void push(Value value);
Value pop();

#endif
vm.h, interpret() 함수 뒤에 추가

스택 맨 위에 새 값을 푸시할 수 있고, 가장 최근에 푸시된 값을 다시 팝할 수 있습니다. 다음은 첫 번째 함수입니다.

vm.c
freeVM() 함수 뒤에 추가
void push(Value value) {
  *vm.stackTop = value;
  vm.stackTop++;
}
vm.c, freeVM() 함수 뒤에 추가

C 포인터 구문과 연산에 익숙하지 않다면, 이것은 좋은 워밍업입니다. 첫 번째 줄은 `value`를 스택의 맨 위 배열 요소에 저장합니다. `stackTop`은 마지막으로 사용된 요소 바로 다음, 즉 다음에 사용 가능한 요소를 가리킨다는 것을 기억하세요. 이 값은 해당 슬롯에 저장됩니다. 그런 다음, 이전 슬롯이 채워졌으므로 포인터 자체를 증가시켜 배열의 다음 사용되지 않는 슬롯을 가리키게 합니다.

팝은 그 반대입니다.

vm.c
push() 함수 뒤에 추가
Value pop() {
  vm.stackTop--;
  return *vm.stackTop;
}
vm.c, push() 함수 뒤에 추가

먼저, 스택 포인터를 뒤로 이동시켜 배열의 가장 최근에 사용된 슬롯에 도달합니다. 그런 다음 해당 인덱스의 값을 찾아 반환합니다. 배열에서 값을 명시적으로 "제거"할 필요는 없습니다`stackTop`을 아래로 이동하는 것만으로 해당 슬롯이 더 이상 사용되지 않는다고 표시하기에 충분합니다.

15 . 2 . 2스택 추적

작동하는 스택이 있지만, 작동하는 것을 보기는 어렵습니다. 더 복잡한 명령어를 구현하고 더 큰 코드 조각을 컴파일 및 실행하기 시작하면, 많은 값이 그 배열에 채워지게 될 것입니다. 스택에 대한 가시성이 있다면 VM 해커로서 우리의 삶이 더 쉬워질 것입니다.

이를 위해, 실행을 추적할 때마다 각 명령어를 해석하기 전에 스택의 현재 내용도 함께 표시할 것입니다.

#ifdef DEBUG_TRACE_EXECUTION
vm.c
run() 함수 안
    printf("          ");
    for (Value* slot = vm.stack; slot < vm.stackTop; slot++) {
      printf("[ ");
      printValue(*slot);
      printf(" ]");
    }
    printf("\n");
    disassembleInstruction(vm.chunk,
vm.c, run() 함수 안

우리는 배열의 각 값을 반복적으로 출력합니다. 스택의 바닥(첫 번째)부터 시작하여 맨 위(stackTop)에 도달할 때까지 진행합니다. 이렇게 하면 각 명령어가 스택에 미치는 영향을 관찰할 수 있습니다. 출력은 상당히 장황하지만, 인터프리터의 깊은 곳에서 골치 아픈 버그를 외과적으로 추출할 때 유용합니다.

스택이 준비되었으니, 우리의 두 가지 명령어를 다시 살펴보겠습니다. 첫 번째는 다음과 같습니다.

      case OP_CONSTANT: {
        Value constant = READ_CONSTANT();
vm.c
run() 함수 안
두 줄 교체
        push(constant);
        break;
vm.c, run() 함수 안, 두 줄 교체

지난 장에서는 `OP_CONSTANT` 명령어가 상수를 "로드"하는 방법에 대해 대략적으로 설명했습니다. 이제 스택이 생겼으므로, 실제로 값을 생성한다는 것이 무엇을 의미하는지 알게 되었습니다. 즉, 스택에 푸시됩니다.

      case OP_RETURN: {
vm.c
run() 함수 안
        printValue(pop());
        printf("\n");
        return INTERPRET_OK;
vm.c, run() 함수 안

그런 다음 `OP_RETURN`이 스택을 팝하고 맨 위 값을 출력한 다음 종료하도록 만듭니다. clox에 실제 함수 지원을 추가할 때 이 코드를 변경할 것입니다. 하지만 지금은 VM이 간단한 명령어 시퀀스를 실행하고 결과를 표시하는 방법을 제공합니다.

15 . 3산술 계산기

우리 VM의 핵심이 이제 제자리를 잡았습니다. 바이트코드 루프는 명령어를 디스패치하고 실행합니다. 스택은 값이 흐름에 따라 커지고 줄어듭니다. 두 부분이 작동하지만, 지금까지 가지고 있는 두 가지 기본적인 명령만으로는 이들이 얼마나 영리하게 상호작용하는지 체감하기 어렵습니다. 그래서 인터프리터에게 산술 연산을 가르쳐봅시다.

가장 간단한 산술 연산인 단항 부정부터 시작하겠습니다.

var a = 1.2;
print -a; // -1.2.

접두사 `-` 연산자는 하나의 피연산자, 즉 부정을 할 값을 받습니다. 그리고 하나의 결과를 생성합니다. 아직 파서에 대해 고민하고 있지는 않지만, 위 구문이 컴파일될 바이트코드 명령어를 추가할 수 있습니다.

  OP_CONSTANT,
chunk.h
enum OpCode
  OP_NEGATE,
  OP_RETURN,
chunk.h, enum OpCode

다음과 같이 실행합니다.

      }
vm.c
run() 함수 안
      case OP_NEGATE:   push(-pop()); break;
      case OP_RETURN: {
vm.c, run() 함수 안

이 명령어는 연산할 값을 필요로 하며, 스택에서 팝하여 가져옵니다. 그 값을 부정하고, 나중에 사용할 수 있도록 결과를 다시 푸시합니다. 이보다 더 쉬울 수는 없습니다. 디스어셈블도 할 수 있습니다.

    case OP_CONSTANT:
      return constantInstruction("OP_CONSTANT", chunk, offset);
debug.c
disassembleInstruction() 함수 안
    case OP_NEGATE:
      return simpleInstruction("OP_NEGATE", offset);
    case OP_RETURN:
debug.c, disassembleInstruction() 함수 안

그리고 테스트 청크에서 시도해 볼 수 있습니다.

  writeChunk(&chunk, constant, 123);
main.c
main() 함수 안
  writeChunk(&chunk, OP_NEGATE, 123);

  writeChunk(&chunk, OP_RETURN, 123);
main.c, main() 함수 안

상수를 로드한 후, 반환하기 전에 부정 명령어를 실행합니다. 그러면 스택의 상수가 그 부정된 값으로 대체됩니다. 그리고 반환 명령어가 그 값을 출력합니다.

-1.2

마법 같죠!

15 . 3 . 1이항 연산자

단항 연산자가 그렇게 인상적이지는 않습니다. 스택에는 여전히 단일 값만 있습니다. 진정한 깊이를 보려면 이항 연산자가 필요합니다. Lox에는 덧셈, 뺄셈, 곱셈, 나눗셈의 네 가지 이항 산술 연산자가 있습니다. 이들을 한꺼번에 구현해 보겠습니다.

  OP_CONSTANT,
chunk.h
enum OpCode
  OP_ADD,
  OP_SUBTRACT,
  OP_MULTIPLY,
  OP_DIVIDE,
  OP_NEGATE,
chunk.h, enum OpCode

바이트코드 루프로 돌아가서, 이들은 다음과 같이 실행됩니다.

      }
vm.c
run() 함수 안
      case OP_ADD:      BINARY_OP(+); break;
      case OP_SUBTRACT: BINARY_OP(-); break;
      case OP_MULTIPLY: BINARY_OP(*); break;
      case OP_DIVIDE:   BINARY_OP(/); break;
      case OP_NEGATE:   push(-pop()); break;
vm.c, run() 함수 안

이 네 가지 명령어의 유일한 차이점은 두 피연산자를 결합하기 위해 궁극적으로 어떤 기본 C 연산자를 사용하는지입니다. 핵심 산술 표현식을 둘러싸고 있는 것은 스택에서 값을 가져오고 결과를 푸시하는 상용구 코드입니다. 나중에 동적 타이핑을 추가하면 이 상용구는 늘어날 것입니다. 이 코드를 네 번 반복하는 것을 피하기 위해 매크로로 묶었습니다.

#define READ_CONSTANT() (vm.chunk->constants.values[READ_BYTE()])
vm.c
run() 함수 안
#define BINARY_OP(op) \
    do { \
      double b = pop(); \
      double a = pop(); \
      push(a op b); \
    } while (false)

  for (;;) {
vm.c, run() 함수 안

이것이 C 전처리기 사용에 있어 꽤 과감한 시도임을 인정합니다. 주저했지만, 각 피연산자에 대한 타입 검사를 추가해야 하는 이후 장에서는 이 매크로가 유용할 것입니다. 같은 코드를 네 번 반복해서 설명하는 것은 번거로운 일일 것입니다.

이 기법에 익숙하지 않다면, 바깥쪽 `do while` 루프가 정말 이상하게 보일 것입니다. 이 매크로는 일련의 문장으로 확장되어야 합니다. 매크로 작성자로서 신중하게, 우리는 매크로가 확장될 때 그 문장들이 모두 동일한 스코프에 있게 하고 싶습니다. 예를 들어 다음과 같이 정의했다고 상상해 봅시다.

#define WAKE_UP() makeCoffee(); drinkCoffee();

그리고 다음과 같이 사용했습니다.

if (morning) WAKE_UP();

의도는 `morning`이 true일 경우에만 매크로 본문의 두 문장을 모두 실행하는 것입니다. 하지만 이것은 다음과 같이 확장됩니다.

if (morning) makeCoffee(); drinkCoffee();;

이런. `if`는 첫 번째 문장에만 연결됩니다. 블록을 사용하면 이 문제를 해결할 수 있다고 생각할 수도 있습니다.

#define WAKE_UP() { makeCoffee(); drinkCoffee(); }

이것은 더 좋지만, 여전히 다음과 같은 위험이 있습니다.

if (morning)
  WAKE_UP();
else
  sleepIn();

이제 매크로 블록 뒤에 붙은 `;` 때문에 `else`에서 컴파일 오류가 발생합니다. 매크로에서 `do while` 루프를 사용하는 것은 우스꽝스러워 보이지만, 여러 문장을 블록 안에 포함하면서 또한 끝에 세미콜론을 허용하는 방법을 제공합니다.

어디까지 얘기했죠? 맞다, 그래서 그 매크로 본문이 하는 일은 간단합니다. 이항 연산자는 두 개의 피연산자를 받으므로 두 번 팝합니다. 그 두 값에 대해 연산을 수행하고 결과를 푸시합니다.

두 번 팝하는 순서에 주의하세요. 첫 번째로 팝된 피연산자를 `a`가 아니라 `b`에 할당합니다. 거꾸로 보일 수도 있습니다. 피연산자 자체가 계산될 때, 왼쪽이 먼저 평가된 다음 오른쪽이 평가됩니다. 이는 왼쪽 피연산자가 오른쪽 피연산자보다 먼저 푸시된다는 것을 의미합니다. 따라서 오른쪽 피연산자가 스택의 맨 위에 있을 것입니다. 그러므로 우리가 첫 번째로 팝하는 값은 `b`입니다.

예를 들어, `3 - 1`을 컴파일하면 명령어 간의 데이터 흐름은 다음과 같습니다.

명령어 시퀀스와 각 스택이 푸시 및 팝 작업으로 값의 순서를 어떻게 뒤집는지 보여줍니다.

`run()` 내부의 다른 매크로들과 마찬가지로, 함수 끝에서 사용했던 매크로들을 정리합니다.

#undef READ_CONSTANT
vm.c
run() 함수 안
#undef BINARY_OP
}
vm.c, run() 함수 안

마지막은 디스어셈블러 지원입니다.

    case OP_CONSTANT:
      return constantInstruction("OP_CONSTANT", chunk, offset);
debug.c
disassembleInstruction() 함수 안
    case OP_ADD:
      return simpleInstruction("OP_ADD", offset);
    case OP_SUBTRACT:
      return simpleInstruction("OP_SUBTRACT", offset);
    case OP_MULTIPLY:
      return simpleInstruction("OP_MULTIPLY", offset);
    case OP_DIVIDE:
      return simpleInstruction("OP_DIVIDE", offset);
    case OP_NEGATE:
debug.c, disassembleInstruction() 함수 안

산술 명령어 형식은 `OP_RETURN`과 같이 간단합니다. 산술 연산자는 스택에서 피연산자를 가져오지만, 산술 바이트코드 명령어는 피연산자를 가지지 않습니다.

더 큰 표현식을 평가하여 새로운 명령어들을 시험해 봅시다.

평가될 표현식: -((1.2 + 3.4) / 5.6)

기존 예제 청크를 바탕으로, 해당 AST를 바이트코드로 수동 컴파일하는 데 필요한 추가 명령어는 다음과 같습니다.

  int constant = addConstant(&chunk, 1.2);
  writeChunk(&chunk, OP_CONSTANT, 123);
  writeChunk(&chunk, constant, 123);
main.c
main() 함수 안

  constant = addConstant(&chunk, 3.4);
  writeChunk(&chunk, OP_CONSTANT, 123);
  writeChunk(&chunk, constant, 123);

  writeChunk(&chunk, OP_ADD, 123);

  constant = addConstant(&chunk, 5.6);
  writeChunk(&chunk, OP_CONSTANT, 123);
  writeChunk(&chunk, constant, 123);

  writeChunk(&chunk, OP_DIVIDE, 123);
  writeChunk(&chunk, OP_NEGATE, 123);

  writeChunk(&chunk, OP_RETURN, 123);
main.c, main() 함수 안

덧셈이 먼저 수행됩니다. 왼쪽 상수 1.2에 대한 명령어는 이미 있으므로, 3.4에 대한 명령어를 추가합니다. 그런 다음 `OP_ADD`를 사용하여 두 상수를 더하고, 그 결과를 스택에 남깁니다. 이는 나눗셈의 왼쪽 부분을 처리합니다. 다음으로 5.6을 푸시하고, 덧셈의 결과를 5.6으로 나눕니다. 마지막으로 그 결과에 부정을 가합니다.

`OP_ADD`의 출력이 `OP_DIVIDE`의 피연산자로 어떻게 암묵적으로 흐르는지 주목하세요. 두 명령어는 서로 직접적으로 연결되어 있지 않습니다. 이것이 스택의 마법입니다. 스택은 명령어들이 데이터 흐름에 대한 복잡성이나 인지 없이 자유롭게 구성될 수 있도록 합니다. 스택은 모든 명령어가 읽고 쓰는 공유 작업 공간처럼 작동합니다.

이 작은 예제 청크에서는 스택이 여전히 두 값 높이까지만 커지지만, Lox 소스를 바이트코드로 컴파일하기 시작하면 훨씬 더 많은 스택을 사용하는 청크를 보게 될 것입니다. 그동안, 이 수동으로 작성된 청크를 가지고 다양한 중첩 산술 표현식을 계산하고 값이 명령어와 스택을 통해 어떻게 흐르는지 실험해 보세요.

지금쯤이면 충분히 해보셨을 것입니다. 이것이 우리가 직접 만들 마지막 청크입니다. 다음에 바이트코드를 다시 다룰 때는, 이를 생성할 컴파일러를 작성하게 될 것입니다.

도전 과제

  1. 다음 표현식에 대해 어떤 바이트코드 명령어 시퀀스를 생성하시겠습니까?

    1 * 2 + 3
    1 + 2 * 3
    3 - 2 - 1
    1 + 2 * 3 - 4 / -5
    

    (Lox는 음수 리터럴에 대한 구문이 없으므로, `-5`는 숫자 5를 부정하는 것입니다.)

  2. 정말로 최소한의 명령어 세트를 원한다면, `OP_NEGATE` 또는 `OP_SUBTRACT` 중 하나를 제거할 수 있습니다. 다음 표현식에 대해 생성할 바이트코드 명령어 시퀀스를 보여주세요.

    4 - 3 * -2
    

    먼저 `OP_NEGATE`를 사용하지 않고. 그 다음 `OP_SUBTRACT`를 사용하지 않고.

    위 내용을 바탕으로 두 명령어를 모두 포함하는 것이 합리적이라고 생각하십니까? 그 이유는 무엇입니까? 포함을 고려할 만한 다른 중복 명령어는 있습니까?

  3. 우리 VM의 스택은 고정된 크기이며, 값을 푸시할 때 오버플로우를 확인하지 않습니다. 이는 잘못된 명령어 시퀀스가 인터프리터를 충돌시키거나 정의되지 않은 동작으로 이어질 수 있음을 의미합니다. 필요에 따라 스택을 동적으로 늘려 이를 피하세요.

    이렇게 할 때의 비용과 이점은 무엇입니까?

  4. `OP_NEGATE`를 해석하기 위해, 피연산자를 팝하고, 값을 부정한 다음, 결과를 푸시합니다. 이는 간단한 구현이지만, 스택이 결국 동일한 높이로 끝나기 때문에 `stackTop`을 불필요하게 증가시키고 감소시킵니다. 단순히 스택에서 값을 그 자리에서 부정하고 `stackTop`을 그대로 두는 것이 더 빠를 수 있습니다. 이를 시도하고 성능 차이를 측정할 수 있는지 확인하세요.

    유사한 최적화를 수행할 수 있는 다른 명령어는 있습니까?

설계 노트: 레지스터 기반 바이트코드

이 책의 나머지 부분에서는 스택 기반 바이트코드 명령어 세트를 중심으로 인터프리터를 세심하게 구현할 것입니다. 다른 계열의 바이트코드 아키텍처도 있습니다바로 레지스터 기반입니다. 이름과는 달리, 이 바이트코드 명령어들은 x64와 같은 실제 칩의 레지스터만큼 다루기 어렵지는 않습니다. 실제 하드웨어 레지스터는 일반적으로 전체 프로그램에서 소수만 사용할 수 있으므로, 레지스터를 효율적으로 사용하고 값들을 레지스터에 넣고 빼는 데 많은 노력을 기울여야 합니다.

레지스터 기반 VM에서도 여전히 스택이 있습니다. 임시 값들은 여전히 스택에 푸시되고 더 이상 필요하지 않을 때 팝됩니다. 주요 차이점은 명령어들이 스택의 어느 위치에서든 입력을 읽을 수 있고, 특정 스택 슬롯에 출력을 저장할 수 있다는 것입니다.

이 작은 Lox 스크립트를 살펴보세요.

var a = 1;
var b = 2;
var c = a + b;

우리 스택 기반 VM에서는 마지막 문장이 다음과 같이 컴파일될 것입니다.

load <a>  // 지역 변수 a를 읽어 스택에 푸시.
load <b>  // 지역 변수 b를 읽어 스택에 푸시.
add       // 두 값을 팝하고, 더해서 결과를 푸시.
store <c> // 값을 팝하고 지역 변수 c에 저장.

(`load` 및 `store` 명령어를 아직 완전히 이해하지 못해도 걱정하지 마세요. 변수를 구현할 때 훨씬 더 자세히 설명할 것입니다.) 우리는 네 개의 별개 명령어를 가지고 있습니다. 이는 바이트코드 해석 루프를 네 번 거치고, 네 개의 명령어를 디코딩하고 디스패치해야 한다는 의미입니다. 최소 7바이트의 코드입니다opcode 4바이트와 어떤 지역 변수를 로드하고 저장할지 식별하는 피연산자 3바이트. 세 번의 푸시와 세 번의 팝. 많은 작업입니다!

레지스터 기반 명령어 세트에서는 명령어가 지역 변수에서 직접 읽고 저장할 수 있습니다. 위 마지막 문장의 바이트코드는 다음과 같습니다.

add <a> <b> <c> // a와 b에서 값을 읽어 더한 다음 c에 저장.

덧셈 명령어는 더 큽니다스택의 어디에서 입력을 읽고 결과를 쓸지를 정의하는 세 개의 명령어 피연산자를 가집니다. 하지만 지역 변수는 스택에 존재하므로, `a`와 `b`에서 직접 읽어와 그 결과를 `c`에 바로 저장할 수 있습니다.

디코딩하고 디스패치할 명령어는 단 하나이며, 전체 코드가 4바이트 안에 들어갑니다. 추가 피연산자 때문에 디코딩이 더 복잡하지만, 여전히 전체적으로 이득입니다. 푸시나 팝, 기타 스택 조작이 없습니다.

Lua의 주요 구현은 원래 스택 기반이었습니다. Lua 5.0에서는 구현자들이 레지스터 명령어 세트로 전환했고 속도 향상을 기록했습니다. 향상 정도는 언어 의미론, 특정 명령어 세트 및 컴파일러의 정교함에 크게 좌우되지만, 이는 주목할 만한 사실입니다.

이것은 왜 제가 이 책의 나머지 부분을 스택 기반 바이트코드로만 설명할지에 대한 명확한 질문을 제기합니다. 레지스터 VM은 멋지지만, 컴파일러를 작성하기가 훨씬 더 어렵습니다. 아마도 여러분의 첫 번째 컴파일러가 될 것이므로, 생성하기 쉽고 실행하기 쉬운 명령어 세트를 고수하고 싶었습니다. 스택 기반 바이트코드는 놀랍도록 간단합니다.

또한 문헌과 커뮤니티에서 훨씬 더 잘 알려져 있습니다. 나중에 더 고급스러운 것으로 전환할 수도 있겠지만, 다른 언어 해커들과 공유할 수 있는 좋은 공통 기반입니다.