Zig Sema: ZIR => AIR
이 글은 Zig 컴파일러 내부 구조 시리즈의 일부입니다.
“AstGen” 다음 컴파일러 단계는 “Sema”입니다. Sema는 AstGen 단계에서 나온 ZIR 출력을 받아 AIR를 생성하는 역할을 합니다. “Analyzed Intermediate Representation(분석된 중간 표현)”의 약자인 AIR는 완전히 타입이 지정된 중간 표현이며, ZIR은 타입이 없는 중간 표현입니다. AIR는 이후 머신 코드로 직접 낮출 수 있습니다.
AstGen 페이지에서 언급했듯이, ZIR이 타입이 없는 이유 중 하나는 Zig 프로그램을 완전히 타이핑하려면 제네릭 타입 등을 완전히 구체화하기 위해 comptime 평가가 필요하기 때문입니다. 따라서 Sema는 Zig 프로그램의 모든 comptime 평가도 수행합니다. 바로 이곳에서 마법이 일어납니다!
AIR는 ZIR이나 AST처럼 파일 단위가 아니라 함수 단위로 생성됩니다. 이 페이지에서는 함수 본문을 ZIR에서 AIR로 변환하는 과정에 집중합니다. Sema가 더 큰 컴파일 과정 속에서 어떻게 호출되는지는 이후 글에서 다룰 예정입니다.
참고: 파일 스코프에서 생성되는 AIR도 일부 있으므로, AIR가 함수 단위로 생성된다고 하는 것이 100% 정확하지는 않습니다. 하지만 파일 스코프 AIR 과정을 이해하려면 컴파일 과정 전반에 대해 더 깊이 다루어야 하며, 이는 다음 글로 미룹니다. 이 페이지는 그 이해를 위한 중요한 토대가 될 것입니다.
AIR는 어떻게 생겼을까?
AIR가 내부적으로 어떻게 구조화되고 만들어지는지 본격적으로 살펴보기 전에, 예제 AIR를 먼저 살펴보겠습니다. 간단한 Zig 프로그램과 그 프로그램이 생성하는 AIR는 다음과 같습니다.
export fn add(a: u32, b: u32) u32 {
return a + b;
}# Begin Function AIR: add:
%0 = arg("a", u32)
%1 = arg("b", u32)
%2!= dbg_stmt(2:5)
%3 = add(%0!, %1!)
%4!= ret(%3!)
# End Function AIR: addzig build-obj --verbose-air <file.zig>를 실행하면 어떤 프로그램의 AIR든 확인할 수 있습니다. 이를 위해서는 Zig 컴파일러의 디버그 빌드가 필요합니다. Zig 컴파일러를 소스에서 빌드하는 방법은 Zig 위키의 가이드를 참고하시기 바랍니다.
AIR는 함수 단위로 생성되고 렌더링됩니다. 위 출력에서 add 함수에 대한 AIR 출력을 나타내는 주석 형태의 줄을 볼 수 있습니다. 또한 AIR는 exported되거나 참조된 함수에 대해서만 생성된다는 점에 유의하십시오(지연 생성됩니다). 따라서 디버깅 목적으로는 AIR를 확인하고 싶은 함수에 보통 export를 붙입니다.
컴파일러의 이전 단계들에 대해 읽어오셨다면, AIR의 렌더링된 형태가 ZIR과 매우 유사하다는 것을 눈치채셨을 겁니다. 두 표현은 유사하고 많은 경우 명령어 태그 이름까지 공유하지만, AIR는 완전히 별개의 중간 표현입니다.
%1은 명령어의 인덱스를 나타냅니다. 뒤에 !가 붙어 있으면 해당 명령어가 Zig 프로그램의 다른 어떤 부분에서도 사용되거나 참조되지 않았음을 의미합니다. 위 예시에서는 %0과 %1이 모두 덧셈 명령어에서 %3을 만드는 데 사용되고, %3은 return 명령어에서 사용됩니다. 반면 디버그 문인 %2와 반환 결과인 %4는 사용되지 않습니다. AIR를 최종 형식으로 변환하는 백엔드는 이 정보가 도움이 된다면 활용할 수 있습니다.
참고: 서두에서 언급했듯이, 함수 스코프가 아닌 파일 스코프에서 생성되는 AIR도 일부 있습니다. 예를 들어 파일 스코프 변수 초기화, comptime 블록 등이 그렇습니다. 현재는 이 AIR를 렌더링할 방법이 없습니다. 다만 파일 스코프 AIR는 항상 comptime에서 평가되므로 보통 constant 명령어들의 연속일 뿐입니다.
AIR의 구조
ZIR이 어떻게 AIR로 변환되는지 살펴보기 전에, 먼저 AIR의 형식과 개별 AIR 명령어에 대해 설명하겠습니다. AIR의 구조를 이해하면 AIR가 만들어지는 과정을 이해하기가 훨씬 수월해집니다.
AIR의 구조는 ZIR과 매우 유사하지만 미묘한 차이가 있습니다.
pub const AIR = struct {
instructions: std.MultiArrayList(Inst).Slice,
extra: []const u32,
values: []const Value,
};ZIR과 마찬가지로 AIR도 근본적으로 instructions 필드에 저장된 일련의 명령어들입니다. 그리고 ZIR 및 AST와 마찬가지로, 명령어는 extra 슬라이스에 명령어별 추가 데이터를 저장할 수 있습니다. 이 두 필드는 ZIR과 AST에서와 동일하게 동작합니다. 이 시점에서 직관적으로 이해가 되지 않는다면, 해당 구조들(특히 extra 데이터가 어떻게 채워지는지 자세히 다루는 AST 구조)을 다시 복습하시기를 권합니다.
참고: MultiArrayList에 대한 설명은 여기서 생략합니다. ZIR과 AST에서 사용된 것과 동일한 패턴이며, 파서 탐색 과정에서 이미 자세히 설명했기 때문입니다.
AIR에 새로 추가된 필드는 values 슬라이스입니다. 여기에는 ZIR의 comptime 실행으로 얻은 알려진 값들이 들어 있습니다. 명령어는 comptime에 알려진 값을 참조할 수 있습니다. 예를 들어 파일 최상위에 const a = 42;가 있다면, 42는 comptime에 알려진 값이므로 values 목록에 저장됩니다. comptime 값의 더 많은 예시는 뒤에서 살펴보겠습니다.
문자열 상수를 위한 문자열 테이블 같은 필드가 없다는 점에 주목하십시오. AIR를 만드는 과정과 이후 AIR를 사용하는 Codegen 과정에서도 계속 ZIR에 접근할 수 있으므로, ZIR의 문자열 테이블에서 데이터를 조회할 수 있습니다.
단일 AIR 명령어의 구조
단일 AIR 명령어의 구조는 Inst 구조체입니다.
pub const Inst = struct {
tag: Tag,
data: Data,
};이 구조체는 구조적으로 ZIR의 Inst 구조체와 동일합니다. enum인 tag가 있고, 그 뒤에 태그별 data가 있는데 이는 가능한 데이터 타입들의 union입니다. AIR의 Tag와 Data 타입은 ZIR의 타입들과는 별개이지만 기능적으로는 동일합니다.
기본적인 예시를 살펴보겠습니다. 시맨틱 분석 과정에서 어떤 값이 상수라고 판단되면 .constant 명령어를 생성합니다. constant 태그를 가진 명령어의 데이터는 ty_pl 필드이며, 여기에는 상수의 타입이 들어가고 페이로드는 comptime에 알려진 값이 담긴 values 배열에 대한 인덱스입니다.
const c = 42;%0 = constant(comptime_int, 42)Inst{
.tag = .constant,
.data = .{
.ty_pl = . {
.ty = Type.initTag(.comptime_int),
.pl = 7, // index into values
},
},
};위 예시는 Zig 코드, 렌더링된 AIR, 그리고 내부 명령어 표현을 보여줍니다. c가 어디에도 나타나지 않는다는 점에 유의하십시오. 이 AIR를 만들기 위해 분석된 ZIR은 대입문의 우변(rhs)인 상수 42만을 대상으로 하기 때문입니다.
값, 타입, TypedValue
Sema 전반에서 매우 자주 사용되는 세 가지 타입이 있습니다. Value, Type, 그리고 TypedValue입니다. Value는 정수, 구조체 등 comptime에 알려진 값을 나타냅니다. Type은 u8 같은 comptime에 알려진 타입을 나타냅니다(참고로 모든 타입은 comptime에 알려져 있습니다). 그리고 TypedValue는 정확한 타입이 알려진 값으로, 예를 들어 값 42와 타입 u16을 짝지은 것입니다.
다소 헷갈릴 수 있는 점 중 하나는 Zig에서 타입도 유효한 값이라는 것입니다. 타입은 타입이 “type”인 값이 될 수 있습니다. 예를 들어 Zig에서는 const c = u8처럼 할당할 수 있습니다. c는 타입이 “type”이고 값이 “u8”인 값입니다. 아래에서 이러한 시나리오의 예시를 다수 보여드려 더 직관적으로 이해할 수 있도록 하겠습니다.
값
Value 구조체는 아래와 같습니다.
pub const Value = extern union {
tag_if_small_enough: Tag,
ptr_otherwise: *Payload,
}타입은 자신이 어떤 종류의 값인지를 설명하는 tag를 가집니다. 여기서 의도적으로 “type” 대신 “kind”라는 표현을 쓴 이유는, 값 자체는 타입이 없지만 특정 경우에는 타입을 자명하게 알 수 있기 때문입니다. 일부 tag 값은 페이로드가 없고, 나머지는 페이로드가 필요합니다. ptr_otherwise 필드는 값을 얻는 데 필요한 추가 정보를 담고 있는 페이로드에 대한 포인터입니다.
Payload 타입은 더 구체적인 페이로드 타입의 Payload 타입 필드에 대한 포인터입니다. 이는 Zig에서 다형성 타입이 활용되는 방식 중 하나입니다. 이후 @fieldParentPtr 내장 함수를 사용해 전체 타입을 결정합니다. 이는 Zig에서 흔한 패턴이며, 더 자세히 설명하는 것은 이 페이지의 범위를 벗어납니다. 동작 방식을 이해하려면 @fieldParentPtr 가이드와 Zig 관련 자료를 검색해 보시기 바랍니다.
정수
정수 값을 살펴보겠습니다. 상수 42는 아래 Value로 표현됩니다.
Value{
.ptr_otherwise = &Payload.U64{
.tag = .int_u64,
.data = 42,
},
};참고: 이 값 표현이 엄밀히 정확하지는 않습니다. ptr_otherwise 필드는 전체 Payload.U64 구조체가 아니라 Payload를 가리킵니다. 하지만 의도를 더 명확하게 보여주기 위해 이 글에서는 위 형식을 계속 사용하겠습니다.
볼 수 있듯이 값 42는 int_u64 플래그로 표현됩니다. int_u64는 u64에 들어갈 수 있는 모든 값을 나타내는 데 사용되기 때문입니다. 이것이 42가 u64 타입이라는 의미는 아닙니다. 여전히 u8, u16 등일 수 있습니다. Type이 없는 Value는 타입이 없는 상태입니다. 혹은 타입이 있는 것 같아 신경 쓰인다면, 값을 ‘정확히 타입이 지정되지 않은’ 상태로 생각해도 좋습니다.
타입 값 (TypedValue가 아님)
Zig에서 타입도 값이 될 수 있습니다. 예를 들어 const c = u8 문은 완전히 유효한 Zig 코드입니다. 타입 u8을 상수 c에 할당하는 것이며, c 자체의 타입은 type입니다(타입을 담고 있는 값인 셈입니다). 이는 매우 헷갈릴 수 있고, 실제 시맨틱 분석에서 타입 값이 사용되면서 더욱 헷갈리게 되므로 여기서 미리 설명합니다.
값으로서의 상수 u8은 아래 Value로 표현됩니다.
Value{
.tag_if_small_enough = .u8_type,
};이 값은 페이로드가 없습니다. 값은 u8_type 태그 하나로 정확히 표현됩니다. 값 자체는 u8 타입입니다. 하지만 값 자체는 여전히 타입이 없는 상태입니다. 다만 이 경우 u8_type에 대한 유효한 타입은 type뿐이므로 타입을 자명하게 알 수 있습니다. 그럼에도 Value 구조체 자체는 기술적으로 여전히 타입이 없습니다(예를 들어 언젠가 Zig가 모든 정수 타입을 나타내는 inttype이라는 키워드를 도입한다면, 이 값의 타입은 inttype일 수도 있고 type일 수도 있을 것입니다).
더 복잡한 타입 값인 const c = [4]bool을 살펴보겠습니다.
Value{
.ptr_otherwise = &Payload.Ty{
.tag = .ty,
.data = Type{
// .tag = .array
// type data including element type (bool), length (4), etc.
},
},
};이 값은 .ty (“type”의 약자) 태그를 가집니다. 어떤 타입 자체가 값으로 존재하는 경우입니다. 페이로드는 4개의 boolean 요소를 가진 배열을 설명하는 Type 구조체입니다. Type 구조체에 대해서는 다음 섹션에서 더 자세히 설명하겠습니다. 여기서 중요한 점은 Value가 배열 ‘값’이 아니라 배열 ‘타입 값’을 나타낸다는 것입니다.
아직 헷갈린다면 Type에 정의된 toValue 함수를 살펴보십시오. 타입은 값이 될 수 있으므로 Type은 언제나 값으로 변환될 수 있지만, 그 역은 항상 성립하지 않습니다.
타입
Type 구조체는 Value와 동일하지만, 타입에 특화된 타입들을 사용합니다.
pub const Type = extern union {
tag_if_small_enough: Tag,
ptr_otherwise: *Payload,
}Value와 거의 동일하게 동작하므로 덧붙일 내용은 많지 않습니다. 최소한 하나의 구체적인 예시를 위해 [4]bool이 어떻게 표현되는지 살펴보겠습니다.
Type{
.ptr_otherwise = &Payload.Array{
.tag = .array,
.data = .{
.len = 4,
.elem_type = Type{.tag_if_small_enough = .bool},
},
}
}TypedValue
TypedValue는 Type과 Value를 함께 묶어 값에 정확한 타입 정보를 짝지어 놓은 것에 불과합니다. 두 가지가 함께 있어야만 값의 정확한 타입을 알 수 있습니다.
pub const TypedValue = struct {
ty: Type,
val: Value,
};Sema의 구조
Zig 컴파일러에서 “AstGen” 다음 단계는 관용적으로 “Sema”라고 불립니다. Sema는 이 단계를 주로 담당하는 구조체의 이름이기도 합니다. Sema의 소스는 src/Sema.zig에 있으며, 이 글을 작성하는 시점에 18,000줄이 넘는 매우 큰 파일로, 스스로를 “Zig 컴파일러의 심장”이라고 설명합니다.
Sema에는 많은 공개 API가 있지만, 그중 가장 중요한 것은 analyzeBody입니다. Sema 구조체는 내부 상태를 위한 필드를 많이 가지고 있습니다. 모두 다루지는 않고 주요 필드 일부를 아래에示합니다. 유사한 필드끼리 설명하기 쉽도록 원본 소스와는 다른 순서로 나열했습니다.
pub const Sema = struct {
mod: *Module,
gpa: Allocator,
arena: Allocator,
perm_arena: Allocator,
code: Zir,
owner_decl: *Decl,
func: ?*Module.Fn,
fn_ret_ty: Type,
air_instructions: std.MultiArrayList(Air.Inst) = .{},
air_extra: std.ArrayListUnmanaged(u32) = .{},
air_values: std.ArrayListUnmanaged(Value) = .{},
inst_map: InstMap = .{},
// other fields...
};첫 번째 그룹은 Sema 과정을 위한 주요 필수 입력입니다. gpa는 Sema 과정과 선언의 수명을 넘어 유지되는 데이터를 할당하는 데 사용됩니다. arena는 Sema 이후 해제되는 임시 데이터를 할당하는 데 사용됩니다. 그리고 perm_arena는 시맨틱 분석 중인 선언의 수명에 묶인 데이터를 할당하는 데 사용됩니다.
mod는 분석 중인 모듈입니다. 모듈은 이 페이지에서 다루지 않지만, 단일 프로그램의 모든 Zig 코드를 캡슐화합니다. 이 페이지에서는 모든 모듈 API를 다루지 않겠습니다.
두 번째 그룹은 Sema가 시맨틱 분석 중인 대상을 설명하는 입력입니다. code는 분석 중인 선언을 포함하는 파일의 ZIR입니다. owner_decl은 현재 분석 중인 선언으로, 보통 함수, comptime 블록, 테스트 등입니다. func와 fn_ret_ty는 함수가 분석될 때의 추가 정보입니다.
세 번째 그룹은 Sema 과정의 출력입니다. 이 필드들이 대부분 Air 구조체를 구성하는 필드들이라는 것을 눈치채셨을 겁니다. 이 필드들은 Sema 과정 전반에 걸쳐 채워지며 최종 Air 결과를 만드는 데 사용됩니다.
inst_map 필드는 특히 중요하며 Sema 전반에서 사용됩니다. ZIR에서 AIR로의 맵입니다. 모든 ZIR 명령어가 AIR 명령어로 이어지는 것은 아니지만, AIR 명령어가 나중에 해석되는 특정 ZIR 명령어(예: 덧셈 피연산자, 함수 매개변수 등)를 참조할 수 있도록 자주 사용됩니다.
함수 본문 분석하기
Sema의 핵심 함수는 analyzeBody입니다. 이는 “본문(body)” — 함수 본문, 루프 본문, 블록 본문 등 — 에 대한 ZIR을 분석하고 해당 본문에 대한 AIR를 생성하는 데 사용됩니다. 가장 단순한 본문은 함수 본문입니다. 먼저 아주 간단한 함수와 그 함수가 생성하는 AIR를 살펴보겠습니다.
export fn add() u32 {
return 40 + 2;
}# Begin Function AIR: add:
%1 = constant(comptime_int, 40)
%2 = constant(comptime_int, 2)
%3 = constant(comptime_int, 42)
%4 = constant(u32, 42)
%0!= dbg_stmt(2:5)
%5!= ret(%4!)
# End Function AIR: add참고: 함수를 export한 뒤 zig build-obj --verbose-air example.zig를 실행하면 해당 함수의 AIR를 덤프할 수 있습니다.
AIR 명령어의 세부 사항을 모르더라도 무슨 일이 일어나고 있는지 파악할 수 있을 것입니다. 대략적인 진행 과정은 아래와 같습니다.
- 타입 없는 상수 정수
40을 봅니다(명령어%1) - 타입 없는 상수 정수
2를 봅니다(명령어%2) - 덧셈을 comptime에 수행하여 결과가 타입 없는 상수 정수
42임을 알게 됩니다(명령어%3). - 상수
42를 타입u32와 짝짓습니다(명령어%4). 값42가u32에 자동으로 들어가므로 별도의 변환이 필요하지 않습니다. 이 타이핑은 반환 타입u32에 맞추기 위해 필요합니다. %4에서 생성된 값을 반환합니다(명령어%5).
깔끔합니다! 다소 장황하긴 하지만, 이 과정이 어떻게 add 함수를 구현하는지 명확하게 볼 수 있습니다. 또한 comptime 평가가 수행되어 덧셈이 이 단계에서 이미 처리되었고, 그 결과가 미리 알려져 있다는 것도 알 수 있습니다. 이것이 나중에 머신 코드로 번역될 때, 실제 덧셈 결과는 이미 미리 계산되어 있는 상태가 됩니다.
이제 이 AIR가 어떻게 단계별로 생성되었는지 살펴보겠습니다.
함수를 단계별로 따라가기
AIR 생성을 위한 주 루프는 analyzeBodyInner(analyzeBody에 의해 호출됨)에 있습니다. 이 루프는 ZIR 명령어들을 순서대로 순회하면서 각 ZIR 명령어마다 0개 이상의 AIR 명령어를 생성합니다.
const result = while (true) {
const inst = body[i];
const air_inst: Air.Inst.Ref = switch (tags[inst]) {
.alloc => try sema.zirAlloc(block, inst),
.alloc_inferred => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_const)),
.alloc_inferred_mut => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_mut)),
.alloc_inferred_comptime => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_const)),
.alloc_inferred_comptime_mut => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_mut)),
.alloc_mut => try sema.zirAllocMut(block, inst),
// hundreds more...
};
try sema.inst_map.put(sema.gpa, inst, air_inst);
i += 1;
}덕분에 주어진 ZIR 집합에 대해 AIR가 어떻게 생성되는지 이해하기가 매우 쉽습니다. ZIR을 덤프한 뒤 한 명령어씩 따라가며 AIR가 어떻게 만들어지는지 확인할 수 있습니다. 위 예제 add 함수의 ZIR은 다음과 같습니다.
%0 = extended(struct_decl(parent, Auto, {
[25] export add line(6) hash(4ca8d4e33898374bdeee80480f698dad): %1 = block_inline({
%10 = func(ret_ty={
%2 = break_inline(%10, @Ref.u32_type)
}, body={
%3 = dbg_stmt(2, 5)
%4 = extended(ret_type()) node_offset:8:5
%5 = int(40)
%6 = int(2)
%7 = add(%5, %6) node_offset:8:15
%8 = as_node(%4, %7) node_offset:8:15
%9 = ret_node(%8) node_offset:8:5
}) (lbrace=1:21,rbrace=3:1) node_offset:7:8
%11 = break_inline(%1, %10)
}) node_offset:7:8
}, {}, {})함수 본문은 ZIR 명령어 %3에서 시작해 %9에서 끝납니다. %3은 add 함수 본문을 분석할 때 analyzeBodyInner가 보는 첫 번째 명령어입니다. %2와 %10 같은 그 이전(및 이후) ZIR 명령어들은 더 이른 시점에 분석되며, 그 과정은 조금 뒤에 다루겠습니다.
%3: dbg_stmt
첫 번째 명령어는 .dbg_stmt입니다. 메인 루프에서 이것이 zirDbgStmt로 이어지는 것을 볼 수 있습니다. 해당 함수는 약간 단순화된 형태로 아래에 재현했습니다.
fn zirDbgStmt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!void {
const inst_data = sema.code.instructions.items(.data)[inst].dbg_stmt;
_ = try block.addInst(.{
.tag = .dbg_stmt,
.data = .{ .dbg_stmt = .{
.line = inst_data.line,
.column = inst_data.column,
} },
});
}이는 ZIR에서 AIR 명령어로 변환되는 좋은 단순 예시입니다. 이보다 더 단순할 수는 없습니다. 이 경우 dbg_stmt ZIR은 거의 그대로 dbg_stmt AIR 명령어로 번역됩니다. 그 결과 앞서 본 %0 AIR 명령어가 생성됩니다.
%0!= dbg_stmt(2:5)%4: extended(ret_type())
.extended ZIR 명령어는 zirExtended를 호출하며, 이는 하위 opcode를 순회해 .ret_type을 zirRetType에 매핑합니다. 이 함수는 아래에 재현했습니다.
fn zirRetType(
sema: *Sema,
block: *Block,
extended: Zir.Inst.Extended.InstData,
) CompileError!Air.Inst.Ref {
const src: LazySrcLoc = .{ .node_offset = @bitCast(i32, extended.operand) };
try sema.requireFunctionBlock(block, src);
return sema.addType(sema.fn_ret_ty);
}분석 중인 함수의 반환 타입은 Sema의 fn_ret_ty 필드에서 확인할 수 있습니다. addType 함수는 타입 정의를 위한 명령어를 추가합니다. 우리 함수의 경우 결과 타입은 잘 알려진 타입인 u32이므로 추가 명령어가 생성되지 않습니다.
실험 삼아 반환 값을 잘 알려지지 않은 타입인 u9로 바꾸면, AIR는 다음과 같은 명령어를 생성합니다.
%5 = const_ty(u9)%5: int(40)
다음은 상수 40에 대한 .int 명령어입니다. 이는 메인 본문 분석 루프에서 zirInt 함수 호출로 이어집니다. zirInt 함수는 정수 값을 읽어 addConstant를 호출합니다. 두 함수는 아래와 같습니다.
fn zirInt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!Air.Inst.Ref {
const int = sema.code.instructions.items(.data)[inst].int;
return sema.addConstant(ty, try Value.Tag.int_u64.create(sema.arena, int));
}pub fn addConstant(sema: *Sema, ty: Type, val: Value) SemaError!Air.Inst.Ref {
const gpa = sema.gpa;
const ty_inst = try sema.addType(ty);
try sema.air_values.append(gpa, val);
try sema.air_instructions.append(gpa, .{
.tag = .constant,
.data = .{ .ty_pl = .{
.ty = ty_inst,
.payload = @intCast(u32, sema.air_values.items.len - 1),
} },
});
return Air.indexToRef(@intCast(u32, sema.air_instructions.len - 1));
}addConstant 함수는 comptime에 알려진 값을 추가하기 위해 Sema의 여러 곳에서 호출됩니다. 먼저 addType을 사용해 타입을 추가하는데, 이는 이전 명령어에서 본 바 있습니다. 이 경우 타입은 잘 알려진 타입인 comptime_int이므로 새로운 명령어가 생성되지 않습니다. 다음으로 값이 air_values에 추가되고, 마지막으로 constant AIR 명령어가 air_instructions에 추가됩니다. 그 결과 다음과 같은 AIR 명령어가 생성됩니다.
%1 = constant(comptime_int, 40)한 가지 유의할 점은 .constant 명령어의 페이로드가 air_values 슬라이스에 대한 인덱스를 참조한다는 것입니다. 모든 comptime에 알려진 값은 air_values에 저장되며, 명령어 내의 모든 참조는 air_values 슬라이스에 대한 인덱스로 저장됩니다.
%6은 상수 2에 대한 것만 제외하면 %5와 동일하므로 생략하겠습니다.
%7: add(%5, %6)
다음은 우리의 첫 번째 실질적인 논리 연산인 덧셈입니다. .add 명령어는 zirArithmetic 함수로 이어집니다. 이 함수는 여러 이항 수학 연산에 사용됩니다. 함수는 아래와 같습니다.
fn zirArithmetic(
sema: *Sema,
block: *Block,
inst: Zir.Inst.Index,
zir_tag: Zir.Inst.Tag,
) CompileError!Air.Inst.Ref {
const inst_data = sema.code.instructions.items(.data)[inst].pl_node;
sema.src = .{ .node_offset_bin_op = inst_data.src_node };
const lhs_src: LazySrcLoc = .{ .node_offset_bin_lhs = inst_data.src_node };
const rhs_src: LazySrcLoc = .{ .node_offset_bin_rhs = inst_data.src_node };
const extra = sema.code.extraData(Zir.Inst.Bin, inst_data.payload_index).data;
const lhs = sema.resolveInst(extra.lhs);
const rhs = sema.resolveInst(extra.rhs);
return sema.analyzeArithmetic(block, zir_tag, lhs, rhs, sema.src, lhs_src, rhs_src);
}상단의 모든 상수 변수 할당은 ZIR 명령어를 디코딩하는 과정입니다. 특히 중요한 할당은 lhs와 rhs입니다. resolveInst 함수는 주어진 ZIR 명령어에 대한 AIR 명령어 인덱스를 찾는 데 사용됩니다. 따라서 %5와 %6 ZIR 명령어가 주어지면 lhs와 rhs는 각각 그 결과로 생성된 AIR 인덱스로 설정됩니다. 이 AIR 명령어들은 인수에 대한 .constant 명령어를 생성했던 이전 루프 반복 과정에서 설정된 것들입니다.
ZIR ⇒ AIR 매핑은 어디에서 유지될까? ZIR에서 AIR로의 매핑은 analyzeBodyInner 루프에 의해 inst_map 필드에서 유지됩니다. 다른 몇 군데에서도 업데이트될 수 있지만, 본문 루프가 주된 위치입니다.
이어서 analyzeArithmetic으로 이어집니다. 이 함수는 꽤 길며, 거의 모든 코드가 이 산술 연산을 comptime에 분석할 수 있는지 판단하는 데 쓰입니다. 핵심 부분은 아래에 재현했습니다.
const maybe_lhs_val = try sema.resolveMaybeUndefVal(block, lhs_src, casted_lhs);
const maybe_rhs_val = try sema.resolveMaybeUndefVal(block, rhs_src, casted_rhs);
if (maybe_lhs_val) |lhs_val| {
if (maybe_rhs_val) |rhs_val| {
if (is_int) {
return sema.addConstant(
scalar_type,
try lhs_val.intAdd(rhs_val, sema.arena),
);
}
}
}
return block.addBinOp(.add, casted_lhs, casted_rhs);먼저 resolveMaybeUndefVal을 호출합니다. 이 함수는 AIR 명령어 인덱스를 받아 그에 대한 comptime에 알려진 값을 로드하려고 시도합니다. 값이 comptime에 알려질 수 없는 경우 반환값이 null이 되므로 optional을 반환합니다.
다음으로 optional을 언래핑하려고 시도합니다. lhs와 rhs 모두에 대해 comptime에 알려진 값을 찾을 수 있었고 둘 다 정수 타입이라면, comptime 덧셈을 수행해 최종 상수를 만들 수 있습니다. 우리 프로그램에서 결과 42를 담은 .constant AIR 명령어가 생성되는 과정이 바로 이에 해당합니다.
%3 = constant(comptime_int, 42)값이 comptime에 알려지지 않은 경우를 위한 fallthrough도 포함했습니다. block.addBinOp입니다. 이는 (comptime 대신) 런타임 계산을 위한 .add AIR 명령어를 추가합니다. 실험해 보려면 상수 2를 var b: u32 = 2 같은 변수로 바꾸고 그 변수를 덧셈에 사용해 보십시오. 변수는 comptime에 연산될 수 없으므로 .add 연산이 생성될 것입니다.
%8: as_node(%4, %7)
다음 명령어는 안전한 타입 강제 변환을 구현합니다. ZIR을 보면 덧셈 결과를 반환 타입으로 변환하는 부분입니다. 우리가 이미 알고 있는 것에 더 구체적으로 말하면, comptime 정수 42를 u32로 변환해야 합니다.
.as_node 명령어는 zirAsNode를 호출하고, 이는 결국 coerce의 핵심 로직으로 이어집니다. coerce 함수는 Sema 전반에서 한 타입에서 다른 타입으로의 안전한 타입 강제 변환을 수행하는 데 사용됩니다.
이 함수에 대한 학습은 독자에게 맡기겠습니다. 로직 자체는 상당히 직관적이지만, 다양한 타입 강제 변환 케이스를 처리해야 하므로 장황합니다. comptime_int에서 u32로의 변환에서는 값이 u32에 들어간다고 판단되어 값을 그대로 반환합니다. 타입 변환을 위해 추가 처리는 필요하지 않습니다.
%9: ret_node(%8)
마지막으로 return 명령어는 ZIR에서 ret_node로 인코딩됩니다. 이는 결과와 함께 .ret AIR 명령어를 생성하는 zirRetNode로 이어집니다. 이 명령어의 생성 과정에는 새롭게 다루거나 아직 설명하지 않은 내용이 없습니다.
zirRetNode 함수는 always_noreturn을 반환합니다. 이 값은 analyzeBodyInner 루프를 종료시켜 함수 본문의 AIR 생성을 완료시킵니다.
return은 항상 함수 본문의 AIR 생성을 완료시킬까? 여러 개의 return 문이 있으면 어떨까? return은 항상 ‘현재 블록’에 대한 AIR 생성을 완료시킵니다. 도달할 수 없는 코드는 허용되지 않으며 AstGen 단계에서 잡히므로, 여러 개의 return 문이 존재할 수 있는 유일한 방법은 그것들이 서로 다른 블록에 있는 경우입니다. 따라서 함수의 맥락에서는 여러 analyzeBodyInner 호출로 재귀하면서 처리되므로 여러 개의 return 문이 있어도 문제가 없습니다.
Comptime을 사용할 수 없는 경우
처음 살펴본 예시는 모든 로직이 comptime에서 처리 가능했기 때문에 다소 지루했습니다. Comptime은 const 값에만 사용할 수 있고 var 값에는 사용할 수 없으므로, var를 사용해 런타임 덧셈에 대한 AIR를 살펴보겠습니다.
export fn add() u32 {
var b: u32 = 2;
return 40 + b;
}# Begin Function AIR: add:
%2 = constant(comptime_int, 2)
%3 = constant(u32, 2)
%6 = constant(comptime_int, 40)
%8 = constant(u32, 40)
%0!= dbg_stmt(2:5)
%1 = alloc(*u32)
%4!= store(%1, %3!)
%5!= dbg_stmt(3:5)
%7 = load(u32, %1!)
%9 = add(%8!, %7!)
%10!= ret(%9!)
# End Function AIR: add상수 2를 변수에 할당된 값 2로 바꾸자 훨씬 더 많은 AIR가 생성되었습니다! 이제 .alloc을 통한 할당, .store를 통한 값 저장, 그리고 런타임 .add 연산도 볼 수 있습니다.
AIR가 어떻게 생성되는지 학습하고 추적해 보기 좋은 프로그램입니다. 이전 예시와 많은 코드 경로를 공유하므로, 독자의 연습 문제로 남겨두겠습니다.
Comptime 타깃 에뮬레이션
comptime에 평가되는 코드에 대해, Zig 컴파일러는 필요할 때 타깃 플랫폼의 특성을 에뮬레이트합니다. 이는 Zig에서 comptime 평가를 안전하고 가능하게 만드는 핵심 기능입니다.
그 예시는 @floatCast 구현에서 볼 수 있습니다.
const target = sema.mod.getTarget();
const src_bits = operand_ty.floatBits(target);
const dst_bits = dest_ty.floatBits(target);
if (dst_bits >= src_bits) {
return sema.coerce(block, dest_ty, operand, operand_src);
}
return block.addTyOp(.fptrunc, dest_ty, operand);이 함수는 getTarget을 통해 타깃에 대한 정보를 가져온 뒤, 타깃 플랫폼에서 float이 지원할 수 있는 비트 수를 결정합니다. 목적지의 비트 수가 원본보다 많다면 타입을 안전하게 강제 변환할 수 있습니다. 그렇지 않다면 float을 잘라냅니다.
Sema 과정 마무리하기
Sema는 함수당 한 번씩 호출됩니다. 파일 단위가 아니라 함수 단위로 실행되는 첫 번째 과정입니다. 모든 선언을 순회하는 대신, Sema는 참조된 선언 각각에 대해서만 한 번씩 호출됩니다. 참조된 모든 선언을 결정하기 위해 Sema는 프로세스의 진입점 함수에서 시작합니다.
이는 Zig의 “지연 분석(lazy analysis)”을 구현한 것입니다. 즉, 선언이 참조되지 않으면 특정 오류가 컴파일러 오류로 나타나지 않을 수 있습니다. 참조된 코드만 컴파일되므로 컴파일 과정이 매우 빨라지고 결과 코드젠의 크기도 작아지지만, 때로는 혼란스러운 동작을 초래하기도 합니다.
이 페이지에서 설명한 기본 지식을 갖추면, 어떤 Zig 프로그램이든 따라가며 그것이 어떻게 AIR로 번역되는지 파악할 수 있을 것입니다. Zig 컴파일러가 무엇을 생성하는지 확인하기 위해 zig ast-check와 zig build-obj --verbose-air 명령어를 자주 활용하는 것을 잊지 마십시오.
이후 AIR는 최종 형식으로 낮추기 위해 “codegen” 과정으로 넘겨집니다. Codegen은 공통 컴파일러 “프론트엔드”와 여러 “백엔드” 사이의 경계입니다. 백엔드는 LLVM일 수도 있고 WASM 같은 네이티브 백엔드일 수도 있습니다.
글을 무작위로 읽기