Zig AstGen: AST => ZIR

Mitchell Hashimoto

Zig AstGen: AST => ZIR

원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기

이 글은 Zig 컴파일러 내부 구조 시리즈의 일부입니다.

추상 구문 트리(AST)를 구축한 뒤, 많은 컴파일러의 다음 단계는 중간 표현(IR)을 생성하는 것이다. AST가 트리 형태인 반면, IR은 보통 프로그램의 다양한 블록(파일, 함수 등)에 대한 명령어 시퀀스를 만들기 시작한다. 이러한 명령어 시퀀스 형태는 최적화와 실행 가능한 머신 코드로의 변환을 위해 더 쉽게 분석할 수 있다.

Zig 컴파일러는 여러 중간 표현을 가지고 있다. 가장 먼저 생성되는 IR 형태는 Zig 중간 표현(ZIR)이라고 불린다. AST는 내부적으로 “AstGen”이라고 불리는 단계를 통해 직접 ZIR로 변환된다. ZIR은 각 Zig 파일마다 생성되는 타입이 없는(untyped) IR 형태이다.

ZIR은 어떻게 생겼을까?

ZIR의 내부 구조와 생성 과정을 본격적으로 살펴보기 전에, ZIR이 어떻게 생겼는지 간단한 예제를 먼저 보자. 지금 당장 이 내용을 모두 이해할 필요는 없다.

다음은 아주 작은 Zig 파일이다:

const result = 42;export fn hello() u8 {    return result;}

그리고 이 파일에 대한 결과 ZIR이다:

%0 = extended(struct_decl(parent, Auto, {  [24] result line(0) hash(193c9ec04fd3f33e5e849a85f0c3b7fd): %1 = block_inline({    %2 = int(42)    %3 = break_inline(%1, %2)  }) node_offset:1:1  [32] export hello line(2) hash(322ad1340dd6faf97c80f152e26dfdd4): %4 = block_inline({    %11 = func(ret_ty={      %5 = break_inline(%11, @Ref.u8_type)    }, body={      %6 = dbg_stmt(2, 5)      %7 = extended(ret_type()) node_offset:4:5      %8 = decl_val("result") token_offset:4:12      %9 = as_node(%7, %8) node_offset:4:12      %10 = ret_node(%9) node_offset:4:5    }) (lbrace=1:22,rbrace=3:1) node_offset:3:8    %12 = break_inline(%4, %11)  }) node_offset:3:8}, {}, {})

참고: ZIR은 반드시 안정적인 포맷은 아니므로, 위 출력이 오늘날 Zig 컴파일러가 출력하는 결과와 정확히 일치하지 않을 수도 있다. 그럼에도 ZIR을 처음 접하는 목적에는 충분히 가깝다.

ZIR에서 주목해야 할 가장 중요한 점은 프로그램의 실제 로직이 더 세분화된 명령어(instructions) 단위로 분해되기 시작한다는 것이다. ZIR 명령어는 인덱스로 참조할 수 있으며, 위 출력에서 % 기호 뒤에 표시된다. 예를 들어, 명령어 9는 hello 함수의 반환 타입으로 result 참조를 변환하는 것이다.

더 많은 ZIR을 보고 싶나요? Zig를 소스에서 직접 컴파일한다면, zig ast-check -t <file> 명령으로 모든 Zig 파일의 ZIR을 덤프할 수 있다. 간단한 Zig 구문을 작성하고 ZIR을 덤프하는 것은 AstGen 단계를 학습하는 훌륭한 방법이다. Zig를 소스에서 컴파일하는 단계는 이 페이지에서 설명하지 않는다.

왜 ZIR은 타입이 없는가?

ZIR은 타입이 없는(untyped) 중간 형식이다. 이는 ZIR이 타입을 인식하지 못한다는 의미가 아니라, 타입이 완전히 평가되지 않았다는 의미이다. Zig의 언어를 정의하는 특징 중 하나는 타입을 일급 값으로 사용하고 컴파일타임 평가(comptime evaluation)를 제네릭 타이핑 메커니즘으로 활용한다는 점이다. 따라서 AST와 ZIR 형식에서는 타입이 아직 알려지지 않았을 수도 있다.

이는 예제 ZIR 출력에서 가장 쉽게 확인할 수 있다. 먼저, 대체로 타입이 지정된 형태를 살펴보자:

const result: u32 = 42;
%0 = extended(struct_decl(parent, Auto, {  [10] a line(0) hash(63c8310741c228c128abf6692d07292d): %1 = block_inline({    %2 = int(42)    %3 = as_node(@Ref.u32_type, %2) node_offset:1:16    %4 = break_inline(%1, %3)  }) node_offset:1:1}, {}, {})

명령어 %3은 타입이 없는 값 42u32로 변환한다. 이 작은 예제에서는 ZIR이 완전히 타입이 지정된 것처럼 보이지만, 여전히 타입이 없는 명령어가 존재한다. 명령어 %2는 타입이 없는 상수 42이다. 상수 42가 여전히 u8, u16, u32 등으로 강제 변환될 수 있기 때문에 타입이 없는 것이다. 코드가 타입이 없는 상수를 타입이 있는 상수에 할당할 때까지 ZIR은 타입 강제 변환 명령어 as_node를 생성하지 않는다.

다음으로, 확실히 타입이 없는 형태를 살펴보자:

const t = bool;const result: t = 42;
%0 = extended(struct_decl(parent, Auto, {  [16] t line(0) hash(243086356f9a6b0669ba4a7bb4a990e4): %1 = block_inline({    %2 = break_inline(%1, @Ref.bool_type)  }) node_offset:1:1  [24] result line(1) hash(8c4cc7d2e9f1310630b3af7c4e9162bd): %3 = block_inline({    %4 = decl_val("t") token_offset:2:10    %5 = as_node(@Ref.type_type, %4) node_offset:2:10    %6 = int(42)    %7 = as_node(%5, %6) node_offset:2:14    %8 = break_inline(%3, %7)  }) node_offset:2:1}, {}, {})

이 경우 result의 타입은 상수 t이다. 이 경우 상수 t가 자명하게 알려져 있다고 주장할 수도 있지만, Zig에서는 t에 대한 할당이 모든 comptime 표현식이 될 수 있다(모든 값이 컴파일타임에 알려져 있기만 하다면 사실상 언어 전체가 해당된다). 이는 Zig의 매우 강력한 기능이다.

이러한 동적 가능성을 고려하면, result에 대한 할당의 ZIR이 훨씬 더 복잡하다는 것을 알 수 있다. 명령어 %4%5t로 식별되는 값을 로드하고 이를 type 타입(값이 아닌 타입을 나타내는 타입)으로 강제 변환한다. 그리고 명령어 %7에는 익숙한 as_node 강제 변환이 있지만, 이번에는 타입 피연산자가 정적 값이 아니라 ZIR 명령어 %5의 결과를 참조한다.

마지막으로, 더 극단적인 예제를 보자:

const std = @import("std");const result: std.ArrayListUnmanaged(u8) = .{};

ZIR이 방대하기 때문에 여기서는 보여주지 않겠다. result의 타입은 함수 ArrayListUnmanaged의 comptime 평가를 통해 생성된 제네릭 타입이다. ZIR은 컴파일타임 평가가 완료될 때까지는 단순히 알 수 없기 때문에 result에 대해 미리 정의된 타입을 가리킬 수 없다.

이것이 ZIR이 타입이 없는 이유이다. ZIR은 컴파일타임 평가와 이후 의미 분석을 위해 준비된 중간 형태이다. 컴파일타임 패스가 끝나면 모든 타입이 알려지고, 완전히 타입이 지정된 중간 표현이 형성될 수 있다(이것이 AIR로 알려져 있으며 AstGen 다음 단계의 결과물이다).

AstGen의 구조

“AstGen”은 AST를 ZIR로 변환하는 단계이다. AstGen의 소스는 src/AstGen.zig에 있다. AstGen은 외부에 공개된 구조체가 아니며, 이 구조체는 ZIR 생성 과정의 내부 상태를 관리하는 데만 사용된다. 외부 호출자는 AST 트리를 받아 전체 트리에 대한 ZIR을 반환하는 generate 함수를 호출한다.

AstGen 구조체는 내부 상태를 위한 많은 필드를 가지고 있다. 모든 필드를 살펴보지는 않겠지만, 아래에 중요한 몇 가지를 소개한다. 아래 구조체의 필드는 소스에서의 순서와 동일하게 표시되지 않았다:

const Astgen = struct {    gpa: Allocator,    arena: Allocator,    tree: *const Ast,    instructions: std.MultiArrayList(Zir.Inst) = .{},    extra: ArrayListUnmanaged(u32) = .{},    string_bytes: ArrayListUnmanaged(u8) = .{},    // other fields, not covered...};

첫 번째 그룹인 gpa, arena, tree는 AstGen 과정에 대한 입력이다. gpa는 생성 과정을 넘어서 유지되는 데이터를 할당하는 데 사용된다. arena는 ZIR 생성 중에만 사용되고 ZIR을 사용자에게 반환하기 전에 해제되는 임시 데이터를 할당하는 데 사용된다. 그리고 tree는 ZIR로 변환되는 AST이다.

왜 “arena”를 “arena”라고 부를까? “arena allocator”는 개별 항목을 하나씩 추적하고 해제하는 대신 한 번에 전체 메모리 영역을 할당하고 해제하는 메모리 할당자 범주이다. 공통된 생명 주기를 공유하는 할당에 자주 사용되는데, 개별 항목을 추적하는 대신 전체 메모리 덩어리를 해제하는 것이 훨씬 쉽고 성능도 좋기 때문이다. 할당자와 Zig에 대한 더 기초적인 내용은 강연 “What’s a Memory Allocator Anyways?”를 참고하라.

두 번째 그룹인 instructions, extra, string_bytes는 AstGen 과정의 출력이다. 이 그룹은 결과 ZIR의 핵심 구조이기 때문에 매우 중요하다:

  • instructions는 명령어 목록이다. 이 목록의 각 항목은 하나의 명령어에 해당한다. 예를 들어, 이 페이지 앞부분의 ZIR 출력을 보면, 이 목록의 9번째 항목이 as_node(%7, %8) 명령어가 될 것이다.
  • extra는 ZIR 명령어가 저장해야 할 수 있는 추가 데이터이다. 이는 AST 노드와 그 extra_data 필드가 따르는 패턴과 동일하다. AST extra_data를 이해하지 못한다면, AstGen 전반에서 해당 패턴이 사용되므로 지금이 복습하기 좋은 때이다!
  • string_bytes는 식별자, 문자열 리터럴, 문서 주석 등을 위한 문자열 인터닝 풀이다. 모든 정적 문자열은 AstGen의 일부로 인터닝되며, ZIR 명령어는 문자열 자체의 복사본을 저장하는 대신 이 문자열 바이트 목록에서의 오프셋을 참조한다.

ZIR 명령어의 구조

AST를 ZIR로 변환하는 동작을 본격적으로 살펴보기 전에, 단일 ZIR 명령어의 형식에 대해 상당한 분량을 할애해 설명하려 한다. ZIR 구성을 이해하는 데 근본적인 구성 요소이므로 이 섹션을 주의 깊게 읽고 이해할 것을 권장한다.

ZIR은 사실상 명령어들의 목록이다. 명령어의 형식은 AST 노드에 사용된 패턴과 매우 유사하므로 이 페이지에서는 구조적 세부 사항 중 일부를 간략하게 넘어갈 수 있다. 그런 경우 유사점을 명확히 언급하겠다.

pub const Inst = struct {    tag: Tag,    data: Data,    pub const Tag = enum(u8) {        add,        addwrap,        // many more...    };    pub const Data = union {        // various fields that can be set depending on tag    };};

이는 AST 노드와 매우 유사하다. “tag” 패턴은 AST 노드와 동일하지만, 태그의 집합은 완전히 다르다. 예를 들어, 정수 리터럴에 대한 AST는 정수의 크기에 관계없이 .integer_literal이다. ZIR에서는 정수가 u64에 들어가는지 여부에 따라 .int 또는 .int_big 중 하나로 변환된다.

각 명령어는 그와 관련된 데이터를 가지고 있다. 데이터 합집합(union)에서 활성화되는 필드는 tag 값에 따라 달라진다. 데이터는 데이터 필드 내에 직접 저장될 수도 있고(정수 리터럴 값과 같이), 데이터 필드가 다른 곳에 위치한 정보를 참조하는 것일 수도 있다.

AST 노드와 마찬가지로, ZIR은 extra 필드를 가지고 있다. 이는 AST 노드의 extra_data 필드와 거의 동일하게 동작한다: 일부 데이터 항목은 extra 필드의 항목을 가리킬 수 있으며, 그 지점부터 시작하는 일정 개수의 필드가 해당 ZIR 명령어와 관련된 특정 값들을 담게 된다. 더 자세한 내용은 Zig Parser 페이지와 아래 예제를 참고하라.

정적 값

가장 단순한 ZIR 명령어는 정적 값을 포함하는 것들이다. 간단한 정적 정수 값과 그에 대해 생성된 ZIR을 살펴보자.

const x = 42;
%0 = extended(struct_decl(parent, Auto, {  [7] x line(0) hash(5b108956afe84d38689dcd3f2d652f04): %1 = block_inline({    %2 = int(42)    %3 = break_inline(%1, %2)  }) node_offset:1:1}, {}, {})

관련 명령어는 %2int(42)이다. 볼 수 있듯이, 정적 값 42는 명령어 자체에 직접 인코딩되어 있다. 내부적으로는 아래와 같이 표현된다:

Zir.Inst{    .tag = .int,    .data = .{        .int = 42,    },}

이는 ZIR 명령어가 가질 수 있는 가장 단순한 형태이다: 데이터가 명령어 내부에 직접 임베드되어 있다. 이 경우 데이터의 활성 태그는 정적 정수 값을 임베드하는 .int이다.

데이터가 참조되거나 태그에 Inst 구조체에 직접 들어가지 않는 더 복잡한 정보가 포함되는 경우가 훨씬 더 일반적이다. 이에 대한 예시는 다음에 소개한다.

Refs

많은 ZIR 명령어는 값에 대한 참조를 포함한다. 예를 들어, 단항 ! 연산자(boolean NOT)는 정적 값 !true, 변수 !myVar, 함수 호출 !myFunc() 등과 같은 모든 표현식 앞에 붙을 수 있다. ZIR이 참조를 어떻게 인코딩하는지 이해하는 것은 매우 흔하게 사용되므로 매우 중요하다.

ZIR 참조는 전용 타입인 Zir.Inst.Ref를 가진다. 이는 비완전 열거형(non-exhaustive enum)이다. 정의된 태그는 원시 값이거나 매우 일반적인 값에 대한 것이다. 그 외의 경우 값은 다른 ZIR 명령어 인덱스에 대한 참조이다.

태그가 있는 Refs

작동 방식을 보기 위해 예제를 살펴보자:

const x = !true;

이는 다음과 같은 ZIR을 만든다:

%0 = extended(struct_decl(parent, Auto, {  [7] result line(0) hash(0be0bb45d9cb29941abcc19d4176dde6): %1 = block_inline({    %2 = bool_not(@Ref.bool_true) node_offset:1:16    %3 = break_inline(%1, %2)  }) node_offset:1:1}, {}, {})

명령어 %2를 보라. 우리의 bool_not 명령어(! 연산자)가 들어 있으며 매개변수는 @Ref.bool_true이다. bool_not 명령어는 단일 피연산자를 받는다. 내부적으로 이는 대략 다음과 같은 구조를 가진 Inst이다(예제와 관련 없는 일부 필드는 의도적으로 생략했다):

Zir.Inst{    .tag = .bool_not,    .data = .{        .un_node = .{ .operand = Ref.bool_true },    },}

Ref.bool_true는 정적 값 true를 나타내는 Ref 열거형의 태그 중 하나이다. 이 외에도 더 많은 태그가 있다. 예를 들어, 모든 내장 타입에 대해 하나씩 존재한다. 다음 예제는 비논리적이지만 유효한 ZIR을 생성한다(컴파일러는 이후에 오류를 발생시킬 것이다):

const x = !u8; // ZIR: bool_not(@Ref.u8_type)

잘 알려진 원시 값과 값들에 대해서는 Ref의 태그 값이 사용된다.

명령어에 대한 Refs

잘 알려지지 않은 값의 경우, Ref는 명령어 인덱스를 나타낸다. 다음은 또 다른 예제와 생성되는 관련 ZIR이다.

const input = true;const x = !input;
%0 = extended(struct_decl(parent, Auto, {  [13] input line(0) hash(1af5eb6ed7d8836d8d54ff390bb38c7d): %1 = block_inline({    %2 = break_inline(%1, @Ref.bool_true)  }) node_offset:1:1  [21] result line(1) hash(4ba2a2df9e05a2d7963b6c1eb80fdcea): %3 = block_inline({    %4 = decl_val("input") token_offset:2:17    %5 = as_node(@Ref.bool_type, %4) node_offset:2:17    %6 = bool_not(%5) node_offset:2:16    %7 = break_inline(%3, %6)  }) node_offset:2:1}, {}, {})

ref를 살펴보기 위한 핵심 명령어는 %6이다. 다시 익숙한 bool_not 명령어를 볼 수 있지만, 이번에는 피연산자가 다른 명령어인 %5에 대한 참조이다. 명령어 체인을 계속 따라가 보면 input의 값을 불리언으로 읽어오는 것임을 직관적으로 이해할 수 있을 것이다.

내부적으로 명령어는 대략 다음과 같이 보인다:

Zir.Inst{    .tag = .bool_not,    .data = .{        .un_node = .{ .operand = Ref.typed_value_map.len + 5 },    },}

또는 더 간단히 말하면: 태그 값의 총 개수에 5를 더한 값이다. 마찬가지로 Ref 값이 태그인지 인덱스인지 판단하려면, 값이 태그 개수보다 큰지 확인하면 된다. 그렇다면 태그 길이만큼 뺀 값이 명령어가 된다.

이는 매우 흔해서 이를 수행하는 두 개의 공개 함수가 있다: indexToRefrefToIndex. 위 예제에서는 피연산자를 indexToRef(5)로 설정하고 있으며, refToIndex(operand)를 호출하면 5가 반환된다.

const ref_start_index: u32 = Inst.Ref.typed_value_map.len;pub fn indexToRef(inst: Inst.Index) Inst.Ref {    return @intToEnum(Inst.Ref, ref_start_index + inst);}pub fn refToIndex(inst: Inst.Ref) ?Inst.Index {    const ref_int = @enumToInt(inst);    if (ref_int >= ref_start_index) {        return ref_int - ref_start_index;    } else {        return null;    }}

추가 데이터(Extra Data)

일부 ZIR 명령어는 extra 필드에 대한 참조를 포함하는데, 이는 “trailing” 데이터라고도 불린다. 이는 AST 노드의 extra_data 필드와 매우 유사하므로 인코딩/디코딩을 자세히 다루지는 않겠다. AST 노드의 extra_data 필드를 잘 모르거나 이해하지 못한다면 해당 섹션을 복습할 것을 권장한다.

추가 데이터의 예제를 살펴보자:

const x = 1 + 2;
%0 = extended(struct_decl(parent, Auto, {  [10] x line(0) hash(48fa081b63af0a1c7f2d11a7bf9fbbc3): %1 = block_inline({    %2 = int(2)    %3 = add(@Ref.one, %2) node_offset:1:13    %4 = break_inline(%1, %3)  }) node_offset:1:1}, {}, {})

이 출력 ZIR은 우리가 이미 배운 모든 내용을 기반으로 한다! 정적 값(명령어 %2)이 있고, 태그가 있는 ref(%3 명령어의 @Ref.one)가 있으며, 다른 명령어를 참조하는 ref(%3 명령어의 %2 피연산자)가 있다.

이진 덧셈 연산은 extra를 사용한다. ZIR 렌더러가 add 명령어를 이해하고 extra의 데이터를 보기 좋게 출력하기 때문에 ZIR 텍스트 출력에서는 즉시 명확하게 드러나지 않는다(명령어 %3). 내부적으로는 다음과 같이 보인다:

// InstructionZir.Inst{    .tag = .add,    .data = .{        .pl_node = .{ .payload_index = 7 },    },}// Exra data[ ..., Ref.one, %2, ... ]

명령어는 pl_node(“payload node”의 약자) 데이터 태그를 사용한다. 이는 extra 배열에서 추가 데이터를 찾을 수 있는 시작 필드를 가리키는 payload_index 필드를 포함한다. .add 태그에 대한 주석을 보면, extra 필드에 저장된 구조는 lhsrhs를 가진 Zir.Inst.Bin이다. 이 경우 lhs = Ref.one이고 rhs = %2이다.

AST 노드와 마찬가지로, AstGen 소스에는 구조화된 추가 데이터를 타입 안전한 방식으로 인코딩하는 헬퍼 함수 addExtra가 포함되어 있다. 이 패턴은 Zig Parser 페이지의 AST 노드 섹션에서 더 자세히 설명된다.

추가 데이터 자체에는 정적 값, 다른 Zig.Inst.Ref 값 등이 포함될 수 있다. 각 태그가 어떤 값을 인코딩하는지 이해하려면 각 태그에 대한 소스 코드를 살펴봐야 한다.

기타 데이터 타입

더 많은 데이터 타입이 있지만, 모두를 빠짐없이 다루지는 않겠다. 위에서 소개한 데이터 타입들이 ZIR 명령어에 대한 정보를 인코딩하는 데 사용되는 더 넓은 패턴을 이해하는 데 특히 중요하다고 생각했기 때문이다.

AstGen의 구성 요소

AstGen 과정에는 ZIR을 구축하는 동안 사용되는 다수의 공통되고 공유되는 구성 요소가 있다. 이러한 구성 요소들은 공통되거나 공유되는 로직을 나타내며 AstGen의 전체 동작을 이해하는 데 매우 중요하다. 이러한 구성 요소 대부분은 AstGen 내 모든 함수의 인수이다.

이것은 AstGen의 모든 기능을 빠짐없이 나열한 것은 아니지만, 몇 가지 핵심 항목을 강조한 것이다.

스코프

AstGen은 스코프 인식을 도입하여, “부모” 스코프에 있는 식별자를 참조하거나, 정의되지 않은 식별자가 사용될 때 오류를 기록하거나, 식별자 섀도잉을 감지할 수 있게 한다.

스코프는 AstGen.zig에 있는 Scope 구조체를 사용해 정의된다. 스코프는 다형성을 가지며 여러 하위 타입 중 하나가 될 수 있다. 이는 Zig에서 @fieldParentPtr을 사용한 일반적인 패턴으로 구현되어 있다. 이 패턴을 설명하는 것은 Zig 내에서 흔한 패턴이므로 이 페이지의 범위를 벗어난다.

일곱 가지 스코프 “타입”이 있다(이 글을 작성하는 시점 기준):

  • Scope.Top - 파일을 나타내는 가장 바깥쪽 스코프이다. 이는 항상 가장 부모인 스코프이며 부모 스코프가 없다. 이 스코프는 추가 데이터를 추적하지 않는다.
  • GenZir - 이 구조체는 일반적으로 Zig의 “블록”을 나타내지만, AST 노드 전반에 걸쳐 많은 추가 상태 추적에 사용된다. 현재 블록 레이블(있는 경우), 명령어 목록, comptime 위치에 있는지 여부 등을 추적한다.
  • Scope.Namespace - 참조될 수 있는 선언들의 순서 없는 집합을 포함하는 스코프이다. 여기서 핵심 단어는 “순서 없음”이며, 이 스코프는 보통 구조체, 공용체 등을 위한 블록 내의 자식 스코프이다. 예: 구조체 변수는 파일에서 나중에 정의된 변수를 참조할 수 있지만, 함수 본문은 그렇지 않다. 하나는 Namespace이고 다른 하나는 그렇지 않다.
  • Scope.LocalValScope.LocalPtr - 정의된 단일 식별자(변수나 상수 등)이다. 식별자가 정의되면 해당 타입의 새로운 자식 스코프가 생성되어 이제 “스코프 안에” 있게 된다. 이는 정확히 하나의 선언을 나타내고 순서 없는 목록이 아니라는 점에서 Namespace와 다르다.
  • Scope.Defer - defer 또는 errdefer 주변의 스코프이다. 이는 모든 종료 지점에서 명령어가 생성될 수 있도록 defer가 존재함을 추적하는 데 사용된다.

스코프는 스코프들을 위로 순회하는 데 사용할 수 있는 parent 필드를 가지고 있다. 식별자 해석이 동작하는 방식이 바로 이것이다.

문자열 인터닝

문자열 값(바이트 배열)은 ZIR 명령어 내에 직접 저장되지 않는다. 문자열은 인터닝되어 단일 연속 string_bytes 배열에 저장된다. 문자열 값의 시작 부분에 대한 인덱스가 ZIR 명령어 내에 저장된다. 이는 공유되는 문자열이 정확히 한 번만 저장된다는 것을 의미한다(식별자와 같은 경우).

ZIR은 지금까지 파이프라인에서 문자열을 전혀 저장하는 첫 번째 구조체이다. AST 노드는 토큰 인덱스를 저장하고 토큰은 소스 코드에서 시작 및 끝 오프셋을 저장한다. 이는 AST와 소스 모두를 계속 사용할 수 있어야 함을 요구한다. AstGen 이후에는 AST와 소스를 해제할 수 있다. 이를 통해 매우 큰 Zig 프로그램을 파싱하고 메모리에 필요한 것만 저장할 수 있다.

결과 위치(Result Locations)

ResultLoc 구조체는 표현식 트리의 최종 결과가 어디에 쓰여야 하는지를 추적한다. AST를 순회하며 ZIR이 생성되는 동안, 일부 쓰기 연산의 값이 깊게 중첩될 수 있으며, AstGen은 최종 값을 어디에 써야 하는지 알아야 한다.

다음 예제를 고려해보자:

const x = blk1: {  switch (someUnion) {    .someTag => break :blk1 42,    else => break :blk1 0,  }}

가장 바깥쪽 스코프에서는 상수 x에 쓰고 있다. AST 트리를 시각화하면, 블록 안으로 중첩되고, 그 다음 switch, 그 다음 switch 케이스, 마지막으로 레이블이 있는 break 문으로 들어가야 한다. 최소 네 개의 함수를 재귀한 뒤, ResultLoc는 AstGen이 레이블이 있는 break의 값을 어디에 써야 하는지를 알려주는 방법이다.

ResultLoc은 가능한 많은 결과 타입을 가진 태그된 합집합이다. 잘 주석이 달려 있으므로 소스를 읽어볼 것을 권장한다. 몇 가지 예제 태그를 여기서 설명하겠다:

  • .discard - 할당이 버려진 식별자 _의 우변에 있기 때문에 버려진다는 것을 의미한다. 이 경우 AstGen은 저장 명령어를 전혀 생성하지 않아도 된다는 것을 알며, 값을 그냥 버릴 수 있다.
  • .ty - 할당이 어떤 타입이 지정된 값에 대한 것이다. 이는 결과 값을 반환하기 전에 강제 변환하기 위해 as_node를 생성한다.
  • .ptr - 할당이 메모리 위치에 쓰이고 있다. 이는 결과가 해당 메모리 위치에 쓰이도록 store 명령어를 생성한다.

ZIR 생성하기

이제 AstGen이 AST를 ZIR로 변환하는 방법에 대해 알아보자. 추상적으로, AST는 트리를 순회하고 각 노드에 대해 명령어를 내보냄으로써 ZIR로 변환된다. AstGen은 전체 AST를 ZIR로 즉시 변환한다(지연 평가하지 않는다 — 이는 이후 컴파일러 단계에서 등장할 패턴이다).

중요하게도, AstGen은 우리의 첫 번째 중요한 의미적 검증 계층을 도입한다. 예를 들어, AstGen은 식별자가 정의되었는지, 바깥 스코프를 섀도잉하지 않는지 검증하고 부모 스코프를 순회하는 방법을 안다. 이전에 AST 구성이 구조적 검증을 도입했음을 기억하라: x pub == 7과 같은 토큰 시퀀스가 구문 오류를 발생시키도록 보장했지만, 식별자 x가 정의되었는지는 검증하지 않았다. 그리고 토크나이저 자체는 토큰 단위 검증만을 강제했다: 72는 유효한 토큰이지만 72a는 유효하지 않음(식별자는 숫자로 시작할 수 없음)을 검증했다.

ZIR이 어떻게 생성되는지 배우기 시작하기 가장 쉬운 곳은 expr 함수를 살펴보는 것이다. 이는 Zig 언어의 모든 유효한 표현식에 대한 ZIR을 생성한다. 나는 언어의 가장 단순한 구성 요소부터 시작한 뒤 거기서부터 쌓아 올리는 것이 가장 쉽다고 생각한다. IR 생성은 깊게 재귀적인 과정이기 때문이다.

정수 리터럴

간단한 정적 정수 값부터 시작해보자:

42

이는 AST 노드 태그 .integer_literal로 파싱되며, 거대한 switch를 통해 expr에서 integerLiteral 함수로 이어진다. integerLiteral 함수는 아래에 재현되어 있다:

fn integerLiteral(gz: *GenZir, rl: ResultLoc, node: Ast.Node.Index) InnerError!Zir.Inst.Ref {    const tree = astgen.tree;    const main_tokens = tree.nodes.items(.main_token);    const int_token = main_tokens[node];    const prefixed_bytes = tree.tokenSlice(int_token);    if (std.fmt.parseInt(u64, prefixed_bytes, 0)) |small_int| {        const result: Zir.Inst.Ref = switch (small_int) {            0 => .zero,            1 => .one,            else => try gz.addInt(small_int),        };        return rvalue(gz, rl, result, node);    } else |err| switch (err) {        error.InvalidCharacter => unreachable, // Caught by the parser.        error.Overflow => {},    }    // ... other paths}

여기에 많은 내용이 있다! 그래서 가장 단순한 것부터 시작하는 것이다. 함수 선언과 같은 것으로 시작했다면, 너무 많은 세부 사항에 파묻혀 학습하기 벅찼을 것이다. 정수 리터럴을 보면서도 여전히 조금 압도될 수 있지만, 가장 단순한 형태이므로 자세히 살펴보자.

먼저, AST를 사용해 정수 리터럴에 대한 토큰을 조회한다. 그런 다음 토큰 시작 위치를 사용해 tree.tokenSlice로 토큰과 연관된 바이트를 가져올 수 있다. 결과는 문자열 "42" 또는 더 구체적으로는 두 바이트 42이다.

다음으로, 해당 문자 배열을 부호 없는 64비트 정수로 파싱을 시도한다. 숫자가 너무 크면, “다른 경로들” 주석으로 넘어가는데, 이는 “큰 정수(big ints)”를 저장하는 복잡한 부분으로 여기서는 다루지 않겠다. 이 예제에서는 모든 정수 상수가 18,446,744,073,709,551,615(부호 없는 64비트 정수의 최댓값) 이하라고 가정하겠다.

부호 없음? 음수는 어떻게 하나요? 음수 앞의 접두사 -는 별도의 AST 노드로 저장되며 ZIR에서 별도로 단항 연산을 생성한다. 정수 리터럴은 항상 양수이다.

우리 예제의 경우 숫자 42가 유효한 u64로 파싱되므로 파싱이 성공할 것이다. 다음으로, 값이 0 또는 1인 특수한 경우를 처리하는데, 이는 특별한 태그가 있는 ref를 가지고 있기 때문이다. 그렇지 않으면 .int ZIR 명령어를 생성하고 명령어 인덱스를 result에 저장한다.

마지막으로 rvalue를 호출하는데, 이는 ResultLoc(앞서 다룸) 의미론을 값에 적용하여 그대로 반환해야 하는지, 알려진 타입으로 변환해야 하는지, 메모리 위치에 저장해야 하는지 등을 결정한다. 많은 경우 이는 아무 작업도 하지 않고 단순히 .int ZIR 명령어를 반환할 것이다.

덧셈

정적 정수 값에서 한 단계 더 나아가 덧셈을 해보자:

42 + 1

이는 AST 노드 태그 .add로 파싱되며, expr에서 simpleBinOp로 이어진다.

fn simpleBinOp(    gz: *GenZir,    scope: *Scope,    rl: ResultLoc,    node: Ast.Node.Index,    op_inst_tag: Zir.Inst.Tag,) InnerError!Zir.Inst.Ref {    const astgen = gz.astgen;    const tree = astgen.tree;    const node_datas = tree.nodes.items(.data);    const result = try gz.addPlNode(op_inst_tag, node, Zir.Inst.Bin{        .lhs = try reachableExpr(gz, scope, .none, node_datas[node].lhs, node),        .rhs = try reachableExpr(gz, scope, .none, node_datas[node].rhs, node),    });    return rvalue(gz, rl, result, node);}

이는 재귀의 첫 번째 사례이다. 이는 lhsrhs 데이터가 각각 왼쪽 및 오른쪽 표현식에 대한 ZIR을 재귀적으로 구축함으로써 채워진 .add ZIR 명령어를 만든다. 왼쪽 표현식은 42이고 오른쪽 표현식은 1이다. 정수 리터럴 탐색에서 이것이 .int 명령어로 변환된다는 것을 알고 있다.

결과 ZIR은 대략 다음과 같이 보인다:

%1 = int(42)%2 = add(%1, @Ref.one)

할당

다음으로, 우리의 덧셈을 타입이 없는 상수에 할당해보자:

const x = 42 + 1;

expr에서 이에 대한 케이스를 찾을 수 없을 것이다. 이는 표현식이 아니라 문장이기 때문이다. statement 함수도 찾을 수 없을 것이다. Zig에서는 변수 할당이 컨테이너(구조체)나 블록(즉, 함수 본문) 내에서만 가능하기 때문이다. 이는 containerMembersblockExprStmts에서 찾을 수 있다. 전자는 구조체 본문에 사용되고 후자는 함수 본문에 사용된다. blockExprStmts를 살펴보자. 여기에는 변수 선언(상수 포함)에 대해 varDecl로 이어지는 switch가 있다. varDecl은 크다! 전체 함수를 아래에 붙이지는 않겠다. 가장 일반적인 코드 경로만 붙이겠다:

const type_node = var_decl.ast.type_node;const result_loc: ResultLoc = if (type_node != 0) .{    .ty = try typeExpr(gz, scope, type_node),} else .none;const init_inst = try reachableExpr(gz, scope, result_loc, var_decl.ast.init_node, node);const sub_scope = try block_arena.create(Scope.LocalVal);sub_scope.* = .{    .parent = scope,    .gen_zir = gz,    .name = ident_name,    .inst = init_inst,    .token_src = name_token,    .id_cat = .@"local constant",};return &sub_scope.base;

가장 위 섹션부터 시작해보자. 이 섹션은 단순히 재귀하여 타입 표현식(있는 경우)과 초기화 표현식을 평가한다. 상수 const x: t = init에서 t는 타입 표현식이고 init은 초기화 표현식이다. 우리 예제에서는 타입 표현식이 없고, 초기화 표현식은 우리가 이미 구성 방법을 알고 있는 .add 명령어이다.

다음으로, 이 값을 나타내기 위해 새로운 LocalVal 스코프를 생성한다. 스코프는 식별자 이름 x, 값 명령어 init_inst를 가지며 varDecl 함수에 주어진 부모 스코프를 가리킨다. 스코프는 이 함수의 반환 값이며 호출자인 blockExprStmts는 현재 스코프를 그 문장 이후부터 이 새로운 스코프로 교체하여 이후 문장과 표현식이 이 할당을 참조할 수 있도록 한다.

이전 예제들은 ZIR 명령어 인덱스를 반환했고 이번 것은 스코프를 반환했다. x에 대한 명명된 할당이 ZIR 명령어를 생성하지 않는다는 점에 주목하라. 생성된 ZIR을 보면 x가 어디에도 보이지 않는다:

%2 = int(42)%3 = add(%2, @Ref.one)

x스코프에 있으며 스코프는 값 명령어 inst를 추적하고 있다. x가 참조될 때마다 그 값이 해당 명령어에서 온다는 것을 알고 그에 따라 참조할 수 있다. 예를 들어, 함수 본문에서 다음에 대한 ZIR을 살펴보자:

const x = 42 + 1;const y = x;
%2 = int(42)%3 = add(%2, @Ref.one) // x assignment%4 = ensure_result_non_error(%3) // y assignment

y 할당(명령어 %4)이 명령어 %3에서 직접 왔다는 점에 주목하라. ZIR에서는 명령어 결과를 정확히 참조할 수 있으므로 명시적인 메모리 로드/저장을 할 필요가 없다.

결국 머신 코드가 생성될 때 백엔드는 (컴퓨터 아키텍처에 따라) 로드/저장이 필요하다고 판단할 수도 있지만, 중간 표현은 이러한 결정을 내릴 필요가 없다.

독자를 위한 연습으로: 다음 단계로 문장 const y = x를 살펴보고 식별자 참조가 어떻게 동작하는지 배워보라. 이는 위 ZIR이 어떻게 생성되는지 설명하며 향후 복잡성을 위한 좋은 발판이 된다.

AstGen 과정 마무리

AstGen 과정은 파일당 한 번 실행되며, 전체 파일에 대한 ZIR을 재귀적으로 구축한다. 함수가 끝나면 호출자는 Zir 값을 받는다.

이 페이지의 세부 내용으로, 모든 Zig 언어 구성을 따라가며 ZIR이 어떻게 생성되는지 배울 수 있을 것이다. Zig 컴파일러가 실제로 무엇을 생성하는지 검토하기 위해 zig ast-check 명령을 자주 사용하고, 이를 어떤 함수를 공부해야 하는지에 대한 가이드로 활용하라.

다음은 Sema 과정이다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글