인터프리터
인터프리터는 MIR을 기계어 코드로 컴파일하지 않고 실행하기 위한 가상 머신입니다. 인터프리터는 보통 tcx.const_eval_* 함수를 통해 호출됩니다. 인터프리터는 컴파일러(컴파일 타임 함수 평가, 즉 CTFE용)와 도구 Miri가 공유합니다. Miri는 같은 가상 머신을 사용하여 (unsafe) Rust 코드의 정의되지 않은 동작(Undefined Behavior)을 탐지합니다.
다음과 같은 상수로 시작한다고 가정해 봅시다.
#![allow(unused)]
fn main() {
const FOO: usize = 1 << 12;
}
rustc는 상수가 사용되거나 메타데이터에 배치되기 전까지는 실제로 아무것도 호출하지 않습니다.
다음과 같은 사용 지점이 있다고 합시다.
type Foo = [u8; FOO - 42];
컴파일러는 해당 타입을 사용하는 항목(지역 변수, 상수, 함수 인자 등)을 생성하기 전에 배열의 길이를 알아내야 합니다.
(이 경우에는 비어 있는) 매개변수 환경을 얻으려면 let param_env = tcx.param_env(length_def_id);를 호출하면 됩니다. 필요한 GlobalId는 다음과 같습니다.
let gid = GlobalId {
promoted: None,
instance: Instance::mono(length_def_id),
};
이제 tcx.const_eval(param_env.and(gid))를 호출하면 배열 길이 표현식의 MIR 생성이 시작됩니다. MIR은 대략 다음과 같은 모습입니다.
Foo::{{constant}}#0: usize = {
let mut _0: usize;
let mut _1: (usize, bool);
bb0: {
_1 = CheckedSub(const FOO, const 42usize);
assert(!move (_1.1: bool), "attempt to subtract with overflow") -> bb1;
}
bb1: {
_0 = move (_1.0: usize);
return;
}
}
평가에 앞서, 평가 결과를 저장하기 위한 가상 메모리 위치(이 경우 사실상 vec![u8; 4] 또는 vec![u8; 8])가 생성됩니다.
평가가 시작될 때 _0과 _1은 Operand::Immediate(Immediate::Scalar(ScalarMaybeUndef::Undef))입니다. 꽤 길고 복잡한 표현입니다. Operand는 인터프리터 메모리 어딘가에 저장된 데이터(Operand::Indirect)를 나타낼 수도 있고, (최적화를 위해) 인라인으로 저장된 즉시 데이터를 나타낼 수도 있습니다. 그리고 Immediate는 (초기화되지 않았을 수도 있는) 단일 스칼라 값(정수 또는 thin 포인터)이거나, 그러한 값 두 개의 쌍일 수 있습니다. 이 경우에는 단일 스칼라 값이 (아직) 초기화되지 않았습니다.
_1의 초기화가 호출되면 FOO 상수의 값이 필요해지고, 이로 인해 tcx.const_eval_*가 다시 호출되지만 여기서는 다루지 않습니다. FOO의 평가가 성공하면 그 값인 4096에서 42를 빼고, 그 결과를 _1에 Operand::Immediate(Immediate::ScalarPair(Scalar::Raw { data: 4054, .. }, Scalar::Raw { data: 0, .. })로 저장합니다. 쌍의 첫 번째 부분은 계산된 값이고, 두 번째 부분은 오버플로가 발생했으면 true가 되는 bool입니다. Scalar::Raw는 이 스칼라 값의 크기(바이트 단위)도 저장하지만, 여기서는 생략합니다.
다음 문장은 앞서 말한 불리언이 0임을 단언합니다. 단언이 실패하면 그 오류 메시지가 컴파일 타임 오류를 보고하는 데 사용됩니다.
단언이 실패하지 않으므로, Operand::Immediate(Immediate::Scalar(Scalar::Raw { data: 4054, .. }))가 평가 전에 할당된 가상 메모리에 저장됩니다. _0은 항상 그 위치를 직접 가리킵니다.
평가가 끝나면 반환 값은 op_to_const에 의해 Operand에서 ConstValue로 변환됩니다. 전자의 표현은 const 평가 도중에 필요한 것에 맞춰져 있는 반면, ConstValue는 const 평가 결과를 소비하는 컴파일러의 나머지 부분에 필요한 형태로 구성되어 있습니다. 이 변환 과정의 일부로, 스칼라 값을 갖는 타입의 경우 결과 Operand가 Indirect이더라도 (일반적인 ConstValue::Indirect 대신) 즉시 값인 ConstValue::Scalar(computed_value)를 반환합니다. 이렇게 하면 usize처럼 단순한 값을 얻기 위해 추가 쿼리를 실행할 필요가 없으므로, 결과를 훨씬 효율적이고 편리하게 사용할 수 있습니다.
이후 같은 상수를 평가할 때는 실제로 인터프리터를 호출하지 않고 캐시된 결과를 그대로 사용합니다.
자료 구조
인터프리터가 외부에 노출하는 자료 구조는 rustc_middle/src/mir/interpret에서 찾을 수 있습니다. 주로 오류 열거형과 ConstValue 및 Scalar 타입입니다. ConstValue는 Scalar(단일 Scalar, 즉 정수 또는 얇은 포인터), Slice(패턴 매칭에 필요한 바이트 슬라이스와 문자열을 표현하기 위한 것), Indirect 중 하나일 수 있으며, Indirect는 그 밖의 모든 경우에 사용되고 가상 할당을 참조합니다. 이러한 할당은 tcx.interpret_interner의 메서드를 통해 접근할 수 있습니다. Scalar는 어떤 Raw 정수이거나 포인터입니다. 이에 대한 자세한 내용은 다음 절을 참고하십시오.
숫자 결과를 기대하는 경우 eval_usize(u64로 표현할 수 없는 값에 대해서는 패닉을 일으킴)를 사용하거나, 가능한 경우 Scalar를 산출하는 Option<u64>를 결과로 내는 try_eval_usize를 사용할 수 있습니다.
메모리
모든 종류의 포인터를 지원하려면 인터프리터에는 포인터가 가리킬 수 있는 “가상 메모리“가 있어야 합니다. 이는 Memory 타입으로 구현되어 있습니다. 가장 단순한 모델에서는 모든 전역 변수, 스택 변수, 그리고 모든 동적 할당이 해당 메모리의 Allocation 하나에 대응합니다. (실제로 모든 MIR 스택 변수마다 할당을 사용하면 매우 비효율적입니다. 그래서 크기가 작으면서 주소를 한 번도 취하지 않는 스택 변수를 위해 Operand::Immediate가 있습니다. 하지만 이는 순전히 최적화일 뿐입니다.)
이러한 Allocation은 기본적으로 이 할당의 각 바이트 값을 저장하는 u8의 시퀀스일 뿐입니다. (여기에 몇 가지 추가 데이터가 더해지며, 아래를 참고하십시오.) 모든 Allocation에는 Memory에서 부여한 전역적으로 고유한 AllocId가 있습니다. 이를 바탕으로 Pointer는 AllocId(할당을 나타냄)와 할당 내 오프셋(포인터가 할당의 어느 바이트를 가리키는지 나타냄)의 쌍으로 구성됩니다. Pointer가 단순한 정수 주소가 아니라는 점이 이상해 보일 수 있지만, 상수 평가 중에는 할당이 실제로 어느 정수 주소에 놓이게 될지 알 수 없다는 점을 기억하십시오. 그래서 AllocId를 기호적 기준 주소로 사용하며, 이 때문에 별도의 오프셋이 필요합니다. (덧붙여 말하자면, 런타임의 포인터 역시 단순한 정수 이상의 것임이 밝혀졌습니다.)
이러한 할당은 참조와 원시 포인터가 가리킬 대상을 갖도록 하기 위해 존재합니다. 모든 것이 할당되는 전역 선형 힙은 존재하지 않으며, 대신 각 할당(지역 변수든, 정적 변수든, (향후의) 힙 할당이든)은 정확히 필요한 크기만큼의 작은 자체 메모리를 받습니다. 따라서 지역 변수 a에 대한 할당을 가리키는 포인터가 있다면, (아무리 unsafe하더라도) 그 포인터를 다른 지역 변수 b를 가리키는 포인터로 바꿀 수 있는 연산은 존재하지 않습니다. a에 대한 포인터 산술은 오직 오프셋만 바꿀 뿐이며, AllocId는 그대로 유지됩니다.
그러나 이는 Pointer를 Allocation에 저장하려 할 때 문제를 일으킵니다. 포인터를 올바른 길이의 u8 시퀀스로 바꿀 수 없기 때문입니다! AllocId와 오프셋을 합치면 포인터가 “보이는” 크기의 두 배가 됩니다. 바로 이것이 Allocation의 relocation 필드가 존재하는 이유입니다. Pointer의 바이트 오프셋은 여러 개의 u8로 저장되고, AllocId는 대역 외(out-of-band)에 따로 저장됩니다. 이 둘은 메모리에서 Pointer를 읽을 때 다시 조립됩니다. Allocation에 필요한 나머지 추가 데이터는 각 바이트가 초기화되었는지 추적하기 위한 undef_mask입니다.
전역 메모리와 특수한 할당
Memory는 평가 중에만 존재하며, 상수의 최종 값이 계산되면 파괴됩니다. 해당 상수에 포인터가 포함되어 있으면, 그 포인터들은 “인턴“되어 TyCtxt의 일부인 전역 “상수 평가 메모리“로 옮겨집니다. 이러한 할당은 나머지 계산 동안 유지되며, (의존하는 크레이트가 사용할 수 있도록) 최종 출력에 직렬화됩니다.
또한 함수 포인터도 지원하기 위해 TyCtxt의 전역 메모리에는 “가상 할당“도 포함될 수 있습니다. 이들은 Allocation 대신 Instance를 담고 있습니다. 이를 통해 Pointer는 일반 데이터나 함수 중 하나를 가리킬 수 있으며, 이는 함수 포인터에서 원시 포인터로의 캐스트를 평가하는 데 필요합니다.
마지막으로, 전역 메모리에서 사용되는 GlobalAlloc 타입에는 특정 const 또는 static 항목을 가리키는 Static 변형도 있습니다. 이는 순환 정적 변수를 지원하는 데 필요합니다. 이 경우 값의 바이트를 아직 알지 못해 Allocation을 가질 수 없는 static에 대한 Pointer가 필요합니다.
포인터 값 대 포인터 타입
인터프리터에서 흔히 혼란을 일으키는 원인 중 하나는 포인터 값이라는 것과 포인터 타입을 가진다는 것이 완전히 독립적인 속성이라는 점입니다. “포인터 값“이란 Pointer를 담고 있어 인터프리터의 가상 메모리 어딘가를 가리키는 Scalar::Ptr를 말합니다. 이는 단지 어떤 구체적인 정수일 뿐인 Scalar::Raw와 대조됩니다.
그러나 *const T나 &T와 같은 포인터 또는 참조 타입의 변수가 반드시 포인터 값을 가질 필요는 없습니다. 정수를 포인터로 캐스팅하거나 트랜스뮤트하여 얻을 수도 있기 때문입니다. 마찬가지로, 실제 할당을 가리키는 참조를 정수로 캐스팅하거나 트랜스뮤트하면 정수 타입(usize)의 포인터 값(Scalar::Ptr)을 얻게 됩니다. 포인터 값에 대해서는 나눗셈과 같은 정수 연산을 의미 있게 수행할 수 없으므로 이는 문제가 됩니다.
해석
상수 평가의 주요 진입점은 tcx.const_eval_* 함수들이지만, rustc_const_eval/src/const_eval에는 ConstValue(Indirect이든 아니든)의 필드에 접근할 수 있게 해 주는 추가 함수들이 있습니다. 컴파일 대상(현재는 LLVM뿐)으로 변환하는 경우를 제외하면 Allocation에 직접 접근해야 할 일은 결코 없어야 합니다.
인터프리터는 현재 평가 중인 상수를 위한 가상 스택 프레임을 생성하는 것으로 시작합니다. 이 가이드를 작성하는 시점에서 상수는 지역(이름 있는) 변수를 허용하지 않는다는 점을 제외하면, 상수와 인자가 없는 함수 사이에는 본질적으로 차이가 없습니다.
스택 프레임은 rustc_const_eval/src/interpret/eval_context.rs의 Frame 타입으로 정의되며, 모든 지역 변수의 메모리(평가 시작 시에는 None)를 담고 있습니다. 각 프레임은 루트 상수의 평가 또는 이후의 const fn 호출에 대한 평가 중 하나를 가리킵니다. 다른 상수를 평가할 때는 단순히 tcx.const_eval_*를 호출하며, 이는 완전히 새롭고 독립적인 스택 프레임을 생성합니다.
프레임은 단지 Vec<Frame>일 뿐이므로, unsafe 코드로 아무리 끔찍한 편법을 쓰더라도 Frame의 메모리를 실제로 참조할 방법은 없습니다. 참조할 수 있는 유일한 메모리는 Allocation들입니다.
이제 인터프리터는 오류를 반환하거나 더 이상 실행할 문장이 없을 때까지 step 메서드(rustc_const_eval/src/interpret/step.rs에 있음)를 호출합니다. 이제 각 문장은 지역 변수 또는 지역 변수가 참조하는 가상 메모리를 초기화하거나 수정합니다. 이 과정에서 다른 상수나 정적 변수를 평가해야 할 수도 있으며, 이는 단순히 tcx.const_eval_*를 재귀적으로 호출합니다.