- pslang은 대형 게임의 모딩 가능성과 C++ 컴파일러가 만드는 어셈블리에 대한 관심에서 시작됐고, 현재 약 1,000 LOC 규모의 Monte-Carlo path tracer를 작성할 수 있을 정도로 동작함
- 모딩 언어에는 C 상호운용성, 저수준 배열·포인터 처리, 쉬운 샌드박싱, 작은 컴파일러 크기, 빠른 컴파일이 필요하며 Lua와 C++ 네이티브 모드는 각각 성능 연결·샌드박싱·배포 측면에서 한계를 보임
- pslang은 명령형·즉시 평가·값 호출 기반의 저수준 언어로, 정적·엄격·명목적 타입 시스템, 들여쓰기 기반 스코프, 내장 배열, 함수 타입, 포인터, 보장된 메모리 배치를 제공함
- 컴파일러는 Bison 기반 파서, AST 타입 검사, IR, 인터프리터, JIT로 나뉘며, 현재 지원 대상은 Aarch64 Mac뿐이고 IR 도입 뒤에는 레지스터 할당기 부재 때문에 생성 코드 품질이 아직 낮음
- 현재 구현은 약 10,000줄의 C++ 코드이며, 앞으로 레지스터 할당기, IR 최적화, IR 인터프리터, 실행 파일 생성, 디버깅 정보, 다형성, 모듈, 표준 라이브러리 같은 기능을 검토 중임
pslang을 만들게 된 배경
- 약 17년 동안 프로그래밍을 해온 뒤, 장난감이 아니라 어느 정도 실사용을 염두에 둔 언어를 직접 만들고 싶다는 욕구가 커짐
- 과거에는 FALSE 같은 난해한 언어 인터프리터와 여러 람다 계산 인터프리터를 만들었지만, “진짜” 언어를 만든다는 욕구를 채우지는 못함
- 개발 중인 대형 게임이 모딩에 적합한 구조라서, 모딩 방식을 고민하던 중 커스텀 프로그래밍 언어가 단순한 해법 중 하나로 떠오름
- 2025년 12월 Matt Godbolt의 Advent of Compiler Optimisations를 보며 C++ 컴파일러가 생성하는 어셈블리를 따라가게 됐고, 다시 어셈블리를 다뤄보고 싶어짐
- 현재 언어는 프로덕션 품질과 거리가 멀지만, 약 1,000 LOC 규모의 동작하는 Monte-Carlo path tracer를 작성할 수 있을 정도까지 구현됨
모딩 요구사항과 기존 선택지의 한계
- 게임은 커스텀 ECS 엔진으로 수십만 개 엔티티를 시뮬레이션하므로, 모딩 언어가 컴포넌트 포인터 묶음을 받아 C의 for 루프처럼 순회할 수 있기를 원함
- 모드는 제어하기 어려우므로 플레이어 보호를 위해 샌드박싱이 쉬워야 하며, 이상적으로는 단일 스위치로 모든 IO와 유사 기능을 비활성화할 수 있어야 함
- 모딩은 특정 폴더에 스크립트를 넣으면 바로 모드로 쓸 수 있을 정도로 쉬워야 함
-
Lua와 JIT 스크립팅 언어
- Lua는 표준적인 선택이지만, 신뢰할 수 없는 코드 앞에 표준 라이브러리의 IO 관련 함수를 삭제하는 전처리 코드를 붙이는 식의 샌드박싱이 필요해 보이며 안정적인 해법으로 느껴지지 않음
- Lua는 고수준 동적 타입 언어라 C 포인터를 직접 이해하지 못하므로, ECS 엔티티 순회를 연결하려면 엔티티마다 native ↔ Lua ↔ native 전환이 발생하거나 네이티브 엔티티를 Lua 배열로 만들었다가 다시 해체해야 함
- 표준 Lua와 LuaJIT가 몇 버전 전부터 갈라져 있어 모더와 구현자 모두에게 혼란을 줄 수 있음
-
C++와 네이티브 모드
- C++로 모드를 만들면 엔티티 순회 문제는 사라지지만, 바이너리 배포는 모든 플랫폼용 개발 환경과 바이너리 아티팩트 저장소가 필요해짐
- 소스 코드로 배포하려면 게임에 C++ 컴파일러를 포함해야 하며, 기본 LLVM 설치도 현재 게임 크기보다 10~20배 많은 디스크 공간을 차지함
- 네이티브 DLL이 int open();을 선언하고 사용하면 파일시스템이나 네트워크 접근을 막기 사실상 불가능해 샌드박싱이 불가능함
- Rust 같은 다른 네이티브 언어에도 같은 문제가 적용됨
- 모딩은 목표 중 하나지만 실제로 이 언어를 게임 모딩에 쓸지는 아직 불확실하며, 특정 용례에 과도하게 특화하고 싶지는 않음
언어 설계 목표
- C 상호운용성을 끊김 없이 제공해 네이티브 게임 코드와 모딩 코드 사이의 연결을 함수 호출처럼 단순하게 만들고자 함
- 원시 엔티티 배열을 다뤄야 하므로 저수준 기능이 필요함
- 모더가 합리적인 편의성으로 코드를 작성할 수 있도록 실용적이고 사용하기 좋아야 함
- 샌드박싱이 쉬워야 하며, 컴파일러 크기도 작아야 함
- 50MB 게임에 1GB 컴파일러를 넣고 싶지 않으므로 컴파일러 풋프린트를 줄이려 함
- 플레이어가 모드 컴파일을 오래 기다리지 않도록 빠른 컴파일이 필요하며, 일부는 광범위한 캐싱으로 완화할 수 있음
- 실제 크로스플랫폼을 원하지만, 널리 쓰이는 데스크톱 플랫폼 몇 개와 64비트, IEEE754 지원 같은 가정은 받아들임
- 대부분의 동적 언어와 비교했을 때 합리적으로 빠른 수준이면 충분함
- C++가 오랫동안 주 언어였기 때문에 언어관에 큰 영향을 줬지만, 가능하면 C++를 그대로 다시 만들지 않으려 함
pslang의 현재 언어 모델
- 작업명은 게임 엔진 psemek에서 따온 pslang이며, 명령형, 즉시 평가, 값 호출, 저수준 언어임
- 타입 시스템은 정적, 엄격, 명목적 타입 시스템으로 구성됨
- 기본 예시는 함수, 구조체, 함수 타입, 배열 반환을 함께 사용함
func min(x: i32, y: i32) -> i32:
return if x < y then x else y
struct vec3i:
x: i32
y: i32
z: i32
func apply(f: i32 -> i32, v: vec3i) -> vec3i:
return vec3i(f(v.x), f(v.y), f(v.z))
func as_array(v: vec3i) -> i32[3]:
return [v.x, v.y, v.z]
스코프와 기본 타입
- 들여쓰기 기반 스코프를 사용해 스크립팅 언어처럼 보이고 초보자에게 더 친근하게 느껴지도록 함
- 현재 들여쓰기는 탭 문자를 사용하지만, 나중에 스페이스로 바뀔 수도 있음
- 함수, 루프 본문, if 본문 등은 새 스코프를 만들며, 함수와 구조체는 어떤 스코프 안에서도 정의할 수 있고 해당 스코프 안에서만 보임
- 로컬 함수는 자신이 정의된 스코프의 변수에 접근하지 못하므로 클로저가 아니며, 스코프는 이름 해석에만 영향을 줌
- 최상위 스코프는 다른 스코프처럼 취급되며, 파일이 로드되거나 초기화될 때 실행되는 엔트리 포인트를 포함함
- 기본 타입은 bool, 부호 있는 정수 4종, 부호 없는 정수 4종, 부동소수점 3종, unit으로 총 13개임
i8 i16 i32 i64
u8 u16 u32 u64
f16 f32 f64
- f8은 대부분의 데스크톱 CPU에서 지원되지 않고 8비트 부동소수점의 의미에도 합의가 없어 포함하지 않음
- f16은 일반 사용자에게는 덜 유용하지만 HDR 색상, 정점 속성 등 그래픽스에서 자주 쓰이며, 최신 데스크톱 CPU 대부분이 IEEE754 f16을 구현하므로 기본 지원함
- 모든 정수 산술은 오버플로를 동반한 2의 보수 방식이며, 정의되지 않은 동작은 없음
- unit은 단일 값 unit()만 가지며, 반환값이 없는 함수의 공식 반환 타입임
- 반환 타입을 생략한 함수는 자동으로 unit을 반환하고, 그런 함수 끝의 return을 생략하면 자동 삽입됨
- unit 함수가 아닌데 값을 반환하지 않으면 오류임
리터럴, 배열, 함수 타입, 포인터
- 숫자 10은 기본적으로 i32이며, 10b, 10s, 10l 같은 접미사로 크기를 지정함
- 부호 없는 리터럴은 u 접미사를 붙이며, 10ub, 10us, 10u, 10ul처럼 씀
- 소수점이 있는 부동소수점 리터럴은 기본적으로 f32이며, 10.0h는 16비트, 10.0d는 64비트임
- 10.이나 .5처럼 정수부나 소수부를 생략할 수 없고, 10.0, 0.5처럼 완전하게 써야 함
- 모든 숫자 리터럴은 모호하지 않은 타입을 가짐
- 배열은 내장 일급 타입이며, C/C++와 달리 배열 전체를 함수에 전달하거나 반환하거나 서로 대입할 수 있음
- 배열 크기는 항상 컴파일 타임에 알려져 있으며, 같은 타입 필드를 여러 개 가진 구조체처럼 동작함
- 배열 타입은 i32[5], 배열 리터럴은 [1, 2, 3, 4, 5]처럼 작성함
- 함수 타입은 C의 함수 포인터에 가까우며, (a, b, c) -> d 형식으로 쓰고 인자가 하나면 a -> b처럼 괄호를 생략할 수 있음
- 내부적으로 함수 타입은 데이터가 함께 전달되지 않는 일반 함수 포인터이며 클로저가 아님
- 포인터 타입은 i32*처럼 쓰며, 기본적으로 불변 포인터이고 가변 포인터는 i32 mut*로 선언함
- 변수 주소는 &x, 가변 포인터는 &mut x, 역참조는 *p, 포인터 산술은 *(p + 10)처럼 사용함
구조체, 메모리 배치, 빈 타입
- 구조체는 struct 키워드와 필드 목록으로 선언함
struct string_view:
size: u64
data: u8*
- 구조체는 string_view(10, data)처럼 내장 함수형 생성자로 만들고, 필드는 v.x처럼 점으로 접근함
- 구조체 포인터에서도 같은 점 문법으로 필드에 접근할 수 있음
- 구조체 필드에는 별도 가변성 지정자가 없으며, 가변 객체의 필드는 가변이고 불변 객체의 필드는 불변임
- 접근 지정자는 없고 필드는 항상 public임
- 모든 객체는 보장된 메모리 배치를 가지며, 기본 타입은 크기와 같은 정렬을 갖고 bool은 1바이트임
- 포인터와 함수 타입은 항상 64비트이고 같은 정렬을 가짐
- 배열은 원소와 같은 정렬을 갖고, 구조체는 정렬 요구사항을 만족하도록 패딩을 가짐
- 이 보장은 주로 C 상호운용성과 GPU 프로그래밍 사용을 단순화하기 위한 것임
- unit과 필드 없는 구조체는 단일 유효값만 가지는 빈 타입으로 취급되며, 실제 크기는 0바이트임
- 빈 타입을 함수에 전달하거나 변수로 선언하거나 필드로 넣어도 메모리 사용이나 구조체 크기에 영향을 주지 않음
- 빈 타입은 타입 수준 컴파일 타임 태그 같은 용도로 쓸 수 있음
- 빈 타입 포인터를 통한 읽기/쓰기는 아직 결정되지 않았고, 현재는 그런 타입의 포인터 산술이 불법임
- C++처럼 각 객체가 고유한 메모리 주소를 가진다는 규칙은 따르지 않음
변수, 함수, 제어 흐름, 외부 함수
- 불변 변수는 let x = 10, 가변 변수는 mut x = 20처럼 선언함
- 불변 변수에 대한 가변 포인터는 만들 수 없음
- let x: i32 = 10처럼 타입을 명시할 수 있지만, 모든 표현식 타입을 모호하지 않게 추론할 수 있도록 설계되어 있어 필수는 아님
- 모든 변수는 반드시 초기화해야 함
- 함수는 func foo(x: A, y: B) -> C: 뒤에 본문을 쓰는 방식이며, 반환 타입을 생략하면 unit임
- 모든 함수는 실행 플랫폼의 네이티브 C ABI를 따르며, C 상호운용성과 콜백, ECS 시스템 등에 함수 포인터로 넘기기 위한 결정임
- 같은 스코프 안에서는 함수와 구조체 선언 순서가 자유로워, 뒤에 선언된 함수나 구조체를 먼저 사용할 수 있음
- 모든 함수 인자와 반환 타입은 완전히 명시해야 하므로, 선언 순서 자유화가 타입 추론을 복잡하게 만들지 않음
- if/else if/else 문과 while 루프가 있으며, for 루프는 아직 없음
- 표현식 형태의 if는 if A then B else C처럼 사용함
- 외부 함수는 foreign func sin(x: f64) -> f64처럼 선언하며, 구현은 다른 곳에 링크되어야 함
- 현재 인터프리터는 그런 함수를 인터프리터 실행 파일 자체에서 dlsym으로 찾음
- 외부 함수는 C 라이브러리와 서드파티 라이브러리 상호운용의 주요 메커니즘이며, raytracer 예제는 제곱근 계산, 파일 쓰기, 시간 측정, 스레드 생성에 이 기능을 사용함
타입 캐스팅과 연산자
- 암묵적 타입 캐스팅은 전혀 없으며, 수동 캐스팅은 (x as f32)처럼 as 연산자를 사용함
- 모든 숫자 타입은 서로 캐스팅할 수 있고, 모든 포인터 타입도 서로 캐스팅할 수 있지만 불변 포인터를 가변 포인터로 바꾸는 것은 제외됨
- 포인터 타입은 u64로, u64는 포인터 타입으로 캐스팅할 수 있음
- bool은 어떤 타입과도 캐스팅할 수 없음
- T mut*에서 T*로의 암묵적 캐스팅 하나를 추가할지 고민 중임
- 산술, 논리, 비교 등 표준 연산자는 대체로 제공됨
- &, |, &&, ||는 불리언과 정수 모두에서 동작하며, &와 |는 양쪽 피연산자를 항상 평가하고 &&와 ||는 단락 평가함
- 산술과 비교는 같은 숫자 타입 쌍에만 동작하며, 숫자 타입 승격은 없음
- 현재 언어 기능은 많아 보이지 않지만, 이미 실제 프로그램을 어느 정도 편하게 작성할 수 있음
컴파일러 구조
- 프로젝트는 여러 라이브러리로 나뉨
- types: 타입 시스템 정의
- ast: 추상 구문 트리 정의와 유틸리티
- parser: 파서
- ir: 중간 표현
- interpreter: 인터프리터
- jit: JIT 컴파일러
- 인터프리터와 컴파일러는 이 라이브러리들을 사용하는 단순 CLI 앱으로 두는 구상이며, 현재는 JIT 모드의 인터프리터만 있음
- 언어를 임베드하려면 parser와 jit 라이브러리를 사용하면 됨
파서와 들여쓰기 처리
- 파서 생성기로 Bison을 사용함
- 토큰은 lexer grammar, 언어 문법은 parser grammar에 정의됨
- 파일은 문장 목록이고, 문장은 함수 선언, 제어 흐름 연산자, 변수 선언, 표현식 등이 될 수 있으며, 표현식은 리터럴, 변수, 연산자, 함수 호출 등이 될 수 있음
- 문법에서 shift/reduce 충돌을 몇 번 고쳐야 했고, Bison의 -Wcounterexamples 플래그로 충돌을 일으키는 정확한 상황을 확인함
- lalr1.cc Bison 스켈레톤을 사용해 C++ 파서 클래스를 생성함
- 기본 Bison은 파서 상태를 전역 변수로 갖는 C 파서를 만들지만, 인터프리터나 게임 모드처럼 여러 파일을 병렬로 파싱할 수 있어야 하는 경우에는 맞지 않음
- Bison 실행은 CMake scripts의 빌드 단계에 넣음
- 파서 출력은 파싱된 파일의 AST를 나타내는 C++ 객체임
- 들여쓰기 때문에 문법은 실제로 문맥 자유가 아니며, 어떤 문장이 while 본문에 속하는지는 앞의 들여쓰기 토큰 수에 의존함
- 해결책으로 각 줄을 독립 문장과 들여쓰기 수준으로 파싱한 뒤, 단순 선형 패스에서 들여쓰기 수준을 보고 스코프를 확정함
- 이 방식은 해키하지만 동작하고 매우 빠르므로 받아들임
- 같은 패스에서 break와 continue는 루프 안에만, return은 함수 안에만, 필드 정의는 구조체 안에만 오도록 검사함
타입 검사와 인터프리터
- 파싱 뒤 첫 번째 패스는 모든 식별자를 해석해, 식별자 노드를 해당 변수, 함수, 구조체 정의 노드에 직접 연결함
- 다음 핵심 패스는 모든 타입을 검사하고 추론함
- 타입 추론은 대체로 단순하며, 특정 AST 노드 타입에 따른 조건 검사로 구성됨
- 예를 들어 if나 while 안의 표현식 타입은 bool이어야 하고, 덧셈의 두 피연산자는 같은 숫자 타입이거나 한쪽이 정수이고 한쪽이 포인터여야 함
- 초기 인터프리터는 AST 노드를 직접 방문해 C++ 구문을 실행하는 트리 워킹 인터프리터임
- 주요 함수는 exec()와 eval()이며, exec()는 단일 문장을 실행하고 eval()은 단일 표현식 값을 계산해 반환함
- C++가 정적 타입이므로 eval()은 언어의 모든 가능 값 타입에 대한 variant를 반환함
- 구조체는 필드마다 하나씩 이름-값 쌍 배열로 표현되며, 변수 값 저장에도 같은 variant를 사용함
- 인터프리터 목적은 언어 코드를 크로스플랫폼으로 실행하고, 구현과 프로그램 디버깅을 돕는 것이며 빠르게 만들 목적은 아님
- 현재 인터프리터는 매우 망가진 상태라 IR 기반으로 완전히 다시 작성할 계획임
- 기존 인터프리터는 foreign 함수를 실행하지 못함
- foreign 함수는 C 호출 규약으로 호출해야 하고 인자 수와 타입을 미리 알 수 없으므로, vararg 기법이나 libffi가 필요할 가능성이 있음
- 인터프리터는 내부 상태, 즉 변수의 이름, 타입, 값을 stdout으로 덤프할 수 있고, 이는 제대로 된 컴파일러를 만들기 전 파서와 인터프리터 디버깅의 주된 방식이었음
첫 번째 Aarch64 JIT 컴파일러
- 2026년 1월 초 휴가 중 M1 Mac만 가지고 있었기 때문에, 첫 컴파일러 대상 아키텍처는 Aarch64 Mac이 됨
- 현재 지원 아키텍처도 이것뿐임
- 컴파일러는 JIT 방식이며, 결과는 실행 가능 비트로 매핑된 메모리 블롭과 각 함수 시작 지점 포인터임
- 고수준 구조는 거의 전통적인 스택 기반 컴파일러에 가깝지만, 표현식 결과를 Aarch64 Mac의 표준 C 호출 규약인 AAPCS64에서 같은 반환 타입 함수가 값을 두는 방식으로 배치함
- 정수와 포인터는 x0 범용 레지스터, 부동소수점은 v0 부동소수점 레지스터에 반환되며, 구조체는 크기에 따라 레지스터나 스택에 반환됨
- 이 방식은 메모리 접근 수를 줄여 생성 코드가 더 빨라지고 함수 호출도 단순해짐
- 스택은 주로 이항 연산 같은 중간 결과에 사용됨
(eval A) # the value of A is in x0
push x0 # the value of A is on stack top
(eval B) # the value of B is in x0
pop x1 # the value of A is in x1
add x0, x0, x1 # the value of A+B is in x0
- 제어 흐름 구조는 조건부 점프로 바뀌지만, 단일 패스 컴파일에서는 if나 while 본문을 아직 컴파일하지 않았으므로 점프 대상을 알 수 없음
- 이를 해결하기 위해 오프셋 0인 점프 명령을 먼저 출력하고, 대상 오프셋을 알게 된 뒤 실제 점프 오프셋을 주입함
- 함수 호출에도 같은 방식이 적용됨
- 대상 CPU 명령 생성에는 서드파티 라이브러리를 쓰지 않고, 컴파일러를 작게 유지하기 위해 직접 구현함
- 구현은 instruction manual을 뒤져 필요한 비트를 적어 넣는 방식이었음
Aarch64에서 까다로웠던 부분
- Aarch64의 모든 명령은 32비트라 다루기 쉬워 보이지만, 32비트 상수를 레지스터에 넣으려면 레지스터 선택 비트와 명령 비트, 상수 비트가 모두 필요해 단일 32비트 명령에 담을 수 없음
- 64비트 상수는 더 큰 문제가 됨
- 상수는 16비트 조각을 오프셋 0, 16, 32, 48비트 위치에 로드하는 명령들로 조립하거나, 상수 메모리에 넣고 거기서 로드해야 함
- 부동소수점 상수는 상수 메모리에서 로드하는 방식을 사용함
- x86과 달리 push/pop 명령이 없으며, 레지스터와 메모리 주소 사이 읽기/쓰기를 수행하고 주소 레지스터를 조정하는 식의 명령을 조합해야 함
- 모든 명령이 정확히 32비트라서 오프셋이 signed인지 unsigned인지, 특정 상수로 미리 곱해지는지, 주소 레지스터를 수정하는지 등을 계속 신경 써야 함
- SP 레지스터 기준으로 스택을 읽고 쓸 때 스택 포인터는 항상 16바이트 정렬되어야 함
- 가능한 오프셋은 12비트에 묶여 있어 스택 프레임이 대략 16KB보다 클 때는 특수 코드가 필요하지만 아직 구현되지 않음
- 호출 규약에는 구조체가 최대 2개 범용 레지스터, 부동소수점 레지스터, 또는 메모리 포인터를 통해 전달·반환되는 특수 사례가 있어 컴파일러 코드가 이를 다뤄야 함
IR 도입과 두 번째 컴파일러
- 기본 인터프리터와 컴파일러를 만든 뒤, 코드 재사용, 다른 아키텍처용 컴파일러 작성 단순화, 최적화를 위해 중간 표현(IR)을 도입함
- IR은 SSA와 비슷하게 시작했지만, 같은 노드에 값을 재할당할 수 있고 phi 노드도 쓰지 않으므로 실제로는 SSA가 아님
- IR은 nodes의 시퀀스이며, 각 노드는 리터럴, 입력 노드를 갖는 연산, 조건부·무조건 점프, 함수 호출 등을 나타냄
- 값을 나타내는 노드는 해당 값의 타입도 저장함
- 재할당을 허용하기 때문에 기존 노드 값을 다시 할당하는 assign IR 명령이 있음
- 조건부 점프는 jump_if_zero와 jump_if_nonzero로 나뉘며, 이는 보통 서로 다른 CPU 명령에 대응하고 값을 부정한 뒤 반대 명령을 쓰는 것보다 빠름
- 함수 포인터를 지원하므로, 알려진 IR 노드를 호출하는 명령과 알 수 없는 포인터 값을 호출하는 명령이 따로 있음
- 최적화에서 임의 위치에 노드를 제거하거나 삽입하기 쉽도록 노드는 std::list에 저장하고 참조는 리스트 이터레이터로 함
- 구조체 값 리터럴은 만들 수 없어서, 구조체 값을 나타내는 alloc 노드를 두고 보통 스택에 초기화되지 않은 구조체 공간을 할당하는 식으로 컴파일함
- 구조체는 개별 필드에 대입해 구성됨
- 중첩 구조체 필드 a.x.y를 단순하게 표현하면 a.x를 새 노드로 읽고 그 노드의 y를 읽게 되어 낭비가 큼
- a.x.y = b도 t = a.x, t.y = b, a.x = t처럼 표현되면 비효율적이라, IR에서 중첩 필드를 특별 처리함
- copy 노드는 구조체에서 임의의 중첩 필드를 추출할 수 있고, assign 노드는 구조체의 임의 중첩 필드에 대입할 수 있음
- 중첩 필드는 “0번 필드를 취하고, 그 안의 2번 필드를 취하고, 그 안의 5번 필드를 취함” 같은 인덱스 배열로 표현됨
- 이후 Aarch64 컴파일러를 AST → IR 컴파일러와 IR → Aarch64 컴파일러로 나눠 다시 작성함
- AST → IR은 비교적 단순하지만, IR → Aarch64 컴파일러는 현재 이전 스택 기반 컴파일러보다 훨씬 나쁜 상태임
- 함수 시작 시 해당 함수의 모든 IR 노드에 필요한 만큼 스택 공간을 할당하므로, 대부분 짧게 사는 중간 값까지 모두 스택 프레임을 차지함
- raytracer의 한 함수는 앞서 나온 12비트 제한 안에 스택 프레임을 맞추기 위해 둘로 나눠야 했음
- 이 컴파일러는 레지스터 할당기를 쓰는 것을 전제로 하므로, 이후 생성 코드는 몇 자릿수 수준으로 개선될 것으로 기대함
컴파일러와 인터프리터 계획
- 현재 구현은 약 10,000줄의 C++ 코드로 구성되어 있으며, 현대 기준으로 컴파일러가 작고 실제로 동작한다는 점에 만족함
-
레지스터 할당기
- 현재 IR → Aarch64 컴파일러는 레지스터 할당기가 꼭 필요함
- 컴파일 속도와 코드 품질의 절충으로 표준적인 선형 스캔 할당기를 사용할 계획임
-
IR 최적화
- IR을 기반으로 상수 전파, 산술 단순화, 죽은 코드 제거, 인라이닝, 루프 펼치기를 추가하고자 함
- GCC나 LLVM을 이기는 것이 목표는 아니지만, 3D 벡터 덧셈 같은 단순 함수가 가능한 한 적은 CPU 명령으로 컴파일되기를 원함
-
IR 인터프리터
- 인터프리터를 IR 직접 평가 방식으로 다시 작성할 계획이며, 이렇게 하면 인터프리터가 상당히 단순해질 것으로 봄
-
실행 파일 생성
- 현재 컴파일러는 즉시 실행할 JIT 메모리 블롭만 생성함
- 플랫폼별 포맷으로 실행 가능한 바이너리도 만들고 싶어 하며, ELF, Mach-O, PE 같은 바이너리 포맷 스펙을 파야 함
- 가능한 한 작은 실행 파일을 만들어보는 것도 목표 중 하나임
-
디버깅
- JIT가 만든 어셈블리를 lldb에서 많이 따라가 봤고, 언어 자체를 제대로 디버깅할 수 있기를 원함
- 이를 위해 DWARF 디버그 정보 포맷 지원이 필요할 가능성이 높으며, 현재는 그에 대해 거의 모름
추가하고 싶은 언어 기능
-
구조체 생성자
- 현재 구조체는 vec3i(1, 2, 3)처럼 모든 필드를 설정하거나 vec3i()처럼 0으로 초기화하는 방식만 가능함
- 구조체 이름과 같은 이름의 함수를 선언하면 임의 생성자로 동작하게 하는 방식을 고려함
func vec3i(x: i32, y: i32) -> vec3i:
return vec3i(x, y, 0)
- 다만 이런 함수에는 고유한 이름을 주는 편이 나을 수도 있어 확정하지 않음
-
전역 변수
- 현재 전역 변수는 지원하지 않음
- global 키워드로 전역 변수를 만들 계획이며, 접근은 여전히 스코프 규칙의 제한을 받으므로 C의 static 변수처럼 함수 로컬 전역 변수를 만들 수 있음
- 최상위 변수는 global을 쓰지 않는 한 실제 전역이 아니라 파일 엔트리 포인트 함수의 로컬 변수임
- 이 구조는 사용자에게 혼란스러울 수 있어 다른 선택지도 고민 중임
- Mac은 쓰기 가능하고 실행 가능한 메모리 매핑을 동시에 허용하지 않으므로, 전역 변수는 코드와 별도로 할당하고 다른 플래그로 매핑해야 할 수 있음
- 전역 접근은 컴파일 타임에 알려진 오프셋 대신 런타임에 해석된 주소로 해야 할 수 있음
- 다만 mprotect()로 매핑 일부의 플래그를 바꿀 수 있어 보이므로 먼저 그것을 시도할 계획임
-
메서드 호출 문법
- 가독성을 위해 x.f(y)가 가능한 경우 f(&x, y) 또는 f(&mut x, y)를 의미하도록 만들고 싶어 함
-
다형성
- 가장 중요한 잠재 기능으로 봄
- 유력한 선택지는 C++ 스타일 함수 오버로딩과 제한 없는 함수 템플릿·구조체 템플릿, 또는 Haskell/Rust 스타일 명시적 trait와 trait 제약 제네릭 함수·구조체임
- C++ 스타일은 더 강력하고 단순한 경우 읽기 쉬우며 컴파일러 구현도 쉽지만 오류 메시지가 매우 난해해질 수 있음
- 명시적 trait는 경우에 따라 읽기 쉽고 오류 메시지 문제를 해결하지만, trait와 trait bound라는 새 시스템이 필요해 컴파일러 구현이 더 어려움
- 아직 결정하지 않았지만, C++를 다시 만들지 않으려 했음에도 첫 번째 선택지 쪽으로 강하게 기울고 있음
struct vec2<t: type>:
x: t
y: t
func min<t: type>(x: t, y: t) -> t:
return if x < y then x else y
- 가능한 경우 함수 인자 추론도 원함
-
연산자 오버로딩
- 어떤 형태든 다형성이 필요함
- a + b가 add(a, b) 같은 오버로드 함수나 Add::add 같은 trait 메서드를 호출하는 방식이 될 수 있음
-
for 루프
- while로 흉내낼 수 있으므로, for는 C++의 range-based loop나 Python 루프처럼 컬렉션 기반 루프로 사용할 계획임
- 이를 위해 range/iterator 인터페이스가 필요하고, 다시 다형성이 필요함
-
자동 자원 관리
- 실용적이고 쓰기 좋은 언어는 메모리, 파일, 소켓, 뮤텍스 같은 자원 해제를 돕는 방법이 필요하다고 봄
- 후보는 C++ 스타일 RAII와 move, Zig 스타일 defer, 선형 타입임
- RAII는 암묵적이어서 숨은 명령과 제어 흐름을 추가하는 단점이 있음
- defer는 명시적이지만 매번 직접 넣어야 하고 빠뜨리는 것을 막지 못하며, 파일 배열처럼 중첩 컬렉션을 해제할 때 불편함
defer free(array)
defer for file in array:
close(file)
- 선형 타입은 free나 close를 수동 호출하는 명시성을 유지하면서 자원 해제 함수로 객체를 소비하도록 강제할 수 있어 유망함
- 하지만 동적 파일 배열 같은 중첩 컬렉션과 섞기 어렵기 때문에 아직 결정하지 않음
-
다형적 리터럴
- 빈 배열 []은 크기 0은 알 수 있지만 원소 타입을 추론할 수 없음
- null은 어떤 포인터 타입도 될 수 있고, 추가하고 싶은 inf 리터럴은 어떤 부동소수점 타입도 될 수 있음
- 해결책으로 Haskell식 다형적 리터럴, C++의 nullptr_t 같은 특수 내장·라이브러리 타입과 암묵 변환, AST의 특수 리터럴과 ad-hoc 컴파일러 처리 세 가지를 고려함
- 현재는 null을 명시 타입 변수 초기화나 함수 인자 전달처럼 기대 포인터 타입을 아는 위치에서만 허용하는 마지막 방식에 기울고 있음
- 이 방식은 가장 단순하지만 확장 가능하지 않아 커스텀 타입을 null에서 만들 수 없음
-
컴파일 타임 평가
- const 키워드로 컴파일 타임 변수를 선언하고, 배열 크기 같은 컴파일 타임 표현식에서 사용할 수 있게 하고자 함
- const 값은 재할당할 수 없고 주소를 취할 수 없음
- 적절한 함수는 전역 변수 접근이나 부작용이 없을 때 컴파일 타임 표현식에서 호출될 수 있음
- 함수 본문은 일반 함수처럼 동작하지만 컴파일 중 실행되고 결과가 컴파일 타임 표현식이 됨
- 수학 함수나 메모리 할당처럼 컴파일 타임에 호출해도 안전한 foreign 함수를 표시하는 장치가 필요함
-
타입 계산
- 메타프로그래밍을 위해 타입에 대한 계산을 지원하고 싶어 함
- 정적 타입 언어에서 런타임 타입 인코딩을 만들고 싶지 않고 런타임 타입의 효용도 제한적이므로 컴파일 타임 전용으로 계획함
- C++ concepts와 비슷한 기능도 별도 문법 없이 컴파일 타임 호출로 구현할 수 있을 것으로 봄
func comparable(t: type) -> bool:
// Implemented somehow...
func min<t: comparable type>(x: t, y: t) -> t:
return if x < y then x else y
-
코루틴
- Python이나 JS 스타일 async/await 추가는 계획보다는 희망에 가까움
라이브러리와 모듈 계획
-
모듈
- 모든 코드를 한 파일에 쓰는 것은 무리라서 모듈이 필요함
- import lib.sublib 같은 단순한 문장을 계획하며, 코드 어디에나 둘 수 있고 스코프 규칙도 따름
- 스코프는 가시성에만 영향을 주며, 실제 로딩은 컴파일 타임에 일어나고 가져온 모듈의 엔트리 포인트는 현재 모듈보다 먼저 실행됨
- 라이브러리 이름은 컴파일러나 인터프리터에 지정한 루트 경로 기준 파일시스템 경로와 직접 대응함
- 단일 소스 파일이면 그 파일만 가져오고, 디렉터리면 해당 디렉터리의 모든 파일을 어떤 순서로 가져옴
- 같은 디렉터리 파일을 가리키는 문법이 필요하며, import .another 같은 형태를 고려함
- 가져온 함수와 전역 변수는 접두사 없이 사용할 수 있고, 모호할 때는 io.print(x)처럼 라이브러리 이름 접두사를 붙일 수 있음
- 모듈 엔트리 포인트는 import 순서와 재귀 import의 위상 정렬에 따라 결정적 순서로 실행될 예정이며, C나 C++의 초기화 순서 문제를 해결할 수 있음
- 여러 모듈 프로그램의 메모리 배치는 아직 결정하지 않음
- 모듈마다 별도 메모리 패치를 두고 함수 호출과 전역 변수 접근을 런타임에 해석할 수도 있고, 하나의 큰 메모리 매핑으로 만들고 상대 오프셋을 사용할 수도 있음
- 하나의 큰 매핑은 런타임에는 더 빠를 수 있지만 여러 모듈 병렬 컴파일을 어렵게 함
-
Prelude
- 모듈이 생기면 기본 유틸리티를 모든 프로그램에 암묵적으로 포함되는 prelude 모듈에 넣을 수 있음
- 내장 배열용 length() 함수와 iterator 인터페이스, string view 타입, Python의 range(n) 같은 숫자 range 등이 후보임
-
문자열 리터럴
- 문자열 리터럴은 아직 없으며, 어떤 의미를 가져야 할지 정하지 못함
- 계획은 prelude에 불변 string_view 타입을 두고, 문자열 내용은 실행 가능 메모리 어딘가에 배치하며, 리터럴 자체는 그 메모리를 가리키는 string_view로 바꾸는 것임
-
표준 라이브러리
- 모듈이 생기면 표준 라이브러리도 필요함
- 포함하고 싶은 범위는 벡터와 행렬을 포함한 수학 라이브러리, libc에서 연결한 alloc/free 형태의 메모리 관리, 동적 배열, 동적 문자열과 포매팅, 해시 테이블, 콘솔과 파일 IO, 파일시스템 헬퍼, 시간·시계 헬퍼, 네트워킹임
현재 우선순위
- 계획한 기능을 언제 구현할지, 이 언어를 실제 게임 모딩이나 다른 용도로 쓸지는 정해지지 않음
- 야심 찬 프로젝트를 동시에 여러 개 진지하게 진행하는 것은 좋지 않다고 보고, 현재 우선순위는 여전히 게임 개발임
- 게임이 만들어지기 전에는 게임을 모딩할 수 없다는 점 때문에, 언어 작업은 하고 싶을 때 진행하는 상태임