Zig AstGen: AST => ZIR

Mitchell Hashimoto

Zig AstGen: AST => ZIR

이 글은 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에서 주목해야 할 핵심은 프로그램의 실제 로직이 더 세분화된 명령어 단위로 쪼개지기 시작한다는 점입니다. ZIR 명령어는 인덱스로 참조할 수 있으며, 위 출력에서는 % 기호 뒤에 표시됩니다. 예를 들어 명령어 9는 result 참조를 hello 함수의 반환 타입으로 변환하고 있습니다.

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

ZIR은 왜 비타입일까?

ZIR은 비타입 중간 포맷입니다. 이는 ZIR이 타입을 전혀 모른다는 뜻이 아니라, 타입이 완전히 평가되지 않았다는 의미입니다. Zig의 언어를 정의하는 특징 중 하나는 타입을 일급 값으로 사용하고 컴파일 타임 평가를 제네릭 타이핑 메커니즘으로 활용한다는 점입니다. 따라서 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은 comptime 평가가 끝나기 전까지는 알 수 없기 때문에, result에 대해 미리 정의된 타입을 가리킬 수 없습니다.

이것이 ZIR이 비타입인 이유입니다. ZIR은 comptime 평가와 이후의 의미 분석을 위해 준비된 중간 형태입니다. comptime 패스가 끝난 뒤에는 모든 타입이 알려지고, 완전히 타입이 정해진 중간 표현을 만들 수 있습니다(이것이 AIR이며, AstGen 다음 단계의 결과물입니다).

AstGen의 구조

“AstGen”은 AST를 ZIR로 변환하는 단계입니다. AstGen의 소스는 src/AstGen.zig에 있습니다. AstGen은 외부에 공개된 struct가 아니며, 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 할당자”는 개별 항목을 하나씩 추적해 해제하는 대신, 메모리 영역 전체를 한 번에 할당하고 해제하는 메모리 할당자 유형입니다. 공통된 생명주기를 공유하는 할당에 자주 사용되는데, 개별 항목을 추적하는 것보다 메모리 덩어리 전체를 해제하는 것이 훨씬 쉽고 성능도 좋기 때문입니다. 할당자와 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 중 하나로 변환됩니다.

각 명령어는 연관된 데이터를 가지고 있습니다. 데이터 유니온에서 활성화되는 필드는 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}, {}, {})

여기서 관련된 명령어는 %2: int(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 enum의 태그 중 하나입니다. 이 밖에도 많은 태그가 있습니다. 예를 들어 모든 빌트인 타입에 대해 하나씩 존재합니다. 다음 예제는 말이 안 되지만, 유효한 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}, {}, {})

참조를 살펴볼 핵심 명령어는 %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 데이터

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

extra 데이터의 예를 살펴보겠습니다.

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의 데이터를 예쁘게 출력하기 때문에(명령어 %3), ZIR 텍스트 출력에서는 바로 드러나지 않습니다. 내부적으로는 다음과 같이 생겼습니다.

// 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 소스에는 구조화된 extra 데이터를 타입 안전하게 인코딩하는 헬퍼 함수 addExtra가 있습니다. 이 패턴은 Zig Parser 페이지의 AST 노드 섹션에서 더 자세히 설명됩니다.

extra 데이터 자체에는 정적 값, 다른 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 - 참조될 수 있는 선언들의 순서 없는 집합을 포함하는 스코프입니다. 여기서 핵심은 “순서 없음”이며, 이 스코프는 보통 struct, union 등을 위한 블록 내의 자식 스코프입니다. 예: struct 변수는 파일 뒤쪽에 정의된 변수를 참조할 수 있지만, 함수 본문은 그럴 수 없습니다. 하나는 Namespace이고 다른 하나는 아닙니다.
  • Scope.LocalValScope.LocalPtr - 정의된 단일 식별자(변수나 상수 등)입니다. 식별자가 정의되면 이 타입의 새로운 자식 스코프가 생성되어 이제 “스코프 안에” 있게 됩니다. 이는 정확히 하나의 선언을 나타내고 순서 없는 목록이 아니라는 점에서 Namespace와 다릅니다.
  • Scope.Defer - defer 또는 errdefer를 둘러싼 스코프입니다. defer가 존재함을 추적하여 모든 탈출 지점에서 명령어를 생성할 수 있도록 하는 데 사용됩니다.

스코프에는 스코프들을 위로 순회하는 데 사용할 수 있는 parent 필드가 있습니다. 식별자 해석이 이렇게 동작합니다.

문자열 인터닝

문자열 값(바이트 배열)은 ZIR 명령어 안에 직접 저장되지 않습니다. 문자열은 인터닝되어 하나의 연속된 string_bytes 배열에 저장됩니다. ZIR 명령어 안에는 문자열 값의 시작 부분에 대한 인덱스가 저장됩니다. 이는 공유되는 문자열이 정확히 한 번만 저장됨을 의미합니다(식별자 등).

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

결과 위치

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를 즉시(eagerly) 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비트 정수로 파싱을 시도합니다. 숫자가 너무 크면 “other paths” 주석으로 표시된 부분으로 넘어가는데, 이는 “큰 정수(big ints)”를 저장하는 복잡성과 관련된 부분으로 여기서는 다루지 않겠습니다. 이 예제에서는 모든 정수 상수가 18,446,744,073,709,551,615(부호 없는 64비트 정수의 최댓값) 이하라고 가정하겠습니다.

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

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

마지막으로 rvalue를 호출하는데, 이는 앞서 다룬 ResultLoc 의미를 값에 적용하여 그대로 반환해야 하는지, 알려진 타입으로 변환해야 하는지, 메모리 위치에 저장해야 하는지 등을 판단합니다. 많은 경우 이는 아무 작업도 하지 않고(no-op) 단순히 .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);}

이것이 우리의 첫 번째 재귀 사례입니다. 왼쪽과 오른쪽 표현식에 대해 각각 재귀적으로 ZIR을 구축하여 lhsrhs 데이터를 채운 .add ZIR 명령어를 만듭니다. 왼쪽 표현식은 42이고 오른쪽 표현식은 1입니다. 정수 리터럴을 살펴본 바에 따르면 이들은 .int 명령어가 될 것임을 알 수 있습니다.

결과 ZIR은 대략 다음과 같이 생겼습니다.

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

할당

다음으로 덧셈 결과를 비타입 상수에 할당해 보겠습니다.

const x = 42 + 1;

expr에서는 이에 대한 케이스를 찾을 수 없습니다. 이는 표현식이 아니라 문장이기 때문입니다. statement 함수도 찾을 수 없는데, Zig에서 변수 할당은 컨테이너(struct)나 블록(즉, 함수 본문) 안에서만 가능하기 때문입니다. 이는 containerMembersblockExprStmts에서 찾을 수 있습니다. 전자는 struct 본문에, 후자는 함수 본문에 사용됩니다. containerMembers보다는 blockExprStmts를 먼저 보는 것이 더 쉽다고 생각하지만, 학습 목적상 둘 다 유효한 다음 단계입니다.

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 ast-check 명령을 자주 사용해 Zig 컴파일러가 실제로 무엇을 생성하는지 확인하고, 이를 어떤 함수를 공부할지에 대한 가이드로 활용하세요.

다음은 Sema 과정입니다.

원문은 Mitchell Hashimoto님이 에 게재했습니다.

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