자신만의 프로그래밍 언어를 만드는 것은 생각보다 쉽다(하지만 더 어렵기도 하다)
(lisyarus.github.io)- 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 대부분이 IEEE754f16을 구현하므로 기본 지원함- 모든 정수 산술은 오버플로를 동반한 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.ccBison 스켈레톤을 사용해 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의 시퀀스이며, 각 노드는 리터럴, 입력 노드를 갖는 연산, 조건부·무조건 점프, 함수 호출 등을 나타냄
- 값을 나타내는 노드는 해당 값의 타입도 저장함
- 재할당을 허용하기 때문에 기존 노드 값을 다시 할당하는
assignIR 명령이 있음 - 조건부 점프는
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추가는 계획보다는 희망에 가까움
- Python이나 JS 스타일
라이브러리와 모듈 계획
-
모듈
- 모든 코드를 한 파일에 쓰는 것은 무리라서 모듈이 필요함
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, 파일시스템 헬퍼, 시간·시계 헬퍼, 네트워킹임
현재 우선순위
- 계획한 기능을 언제 구현할지, 이 언어를 실제 게임 모딩이나 다른 용도로 쓸지는 정해지지 않음
- 야심 찬 프로젝트를 동시에 여러 개 진지하게 진행하는 것은 좋지 않다고 보고, 현재 우선순위는 여전히 게임 개발임
- 게임이 만들어지기 전에는 게임을 모딩할 수 없다는 점 때문에, 언어 작업은 하고 싶을 때 진행하는 상태임
Lobste.rs 의견들
-
여기 댓글들이 이 커뮤니티에서 기대한 것보다 훨씬 가혹하게 느껴짐
Lua 같은 다른 언어로도 충분했을 가능성은 있음. 작성자가 거대한 yak shaving에 빠졌을 가능성도 있음
그래도 실력이 뛰어나고 아주 즐기고 있다는 건 분명하고, 글 안에 흥미로운 기술 내용도 있음
게임 엔진용 스크립트 언어를 또 하나 설계하는 동료 너드의 글이라면 기꺼이 즐겁게 읽겠음. vibecoding으로 만든 SaaS 쓰레기가 세상을 구하고 작성자를 부자로 만들어 준다는 AI 생성 잡글 하나를 피할 수 있다면, 이런 글은 하루에 천 개라도 읽을 수 있음 -
“Lua 또는 다른 JIT 컴파일 스크립트 언어는 표준 선택지지만 샌드박싱이 정말 어렵다”는 건 정말 이해하기 어려운 주장임
Lua의 샌드박싱이 쉽다는 점은 가장 큰 장점 중 하나고, 모드나 플러그인 말고도 큰 이점을 줌. 내가 본 어떤 언어도 여기에 근접하지 못했음- 그 문단 전체가 “이 언어에 대해 읽어보긴 했지만, 지난 20년간 표준 선택지였는데도 몇 시간 조사할 생각은 없다”처럼 읽힘
Lua 버전 문제는 어느 정도 일리가 있지만, 실제로 사람들이 크게 분통 터뜨리는 건 별로 못 봤음. “현대적” Lua를 어떤 용도로 쓰다가 다른 작업 때문에 5.1/5.2로 내려가야 하는 경우가 아니라면, 대부분은 둘 중 하나만 쓰는 듯함 - “흔한 가능성”이 애초에 Lua와 C++ 뿐이라는 게 꽤 이상함. 존재하는 언어 부류가 딱 두 가지뿐이라는 건가 싶음
“내 언어를 만들고 싶다”를 합리화하기 위해 조사한 느낌이 강함. 그 자체는 괜찮지만, 기존 선택지에 대해 완전히 틀린 주장을 하기보다는 솔직한 편이 낫다 - 이 글에서 또 걸리는 점은 언어 설계를 배우고 싶다면, 맨바닥까지 내려가기보다 기존 가상 머신이나 런타임을 대상으로 하는 호스트 언어용 컴파일러를 작성하는 쪽이 훨씬 좋다는 것임
가상 머신 설계나 더 낮은 수준의 부분에 관심이 있다면 글에서 설명한 방식도 물론 가능함. 하지만 언어 설계를 배우는 최고의 방법과는 거리가 멂 - 실력 있는 프로그래머들이 만든 게임들도 Lua 샌드박스 탈출을 겪은 적이 꽤 있음. Factorio, Binding of Isaac, 그리고 클라우드 프로그래밍을 모두가 지는 괴상한 게임으로 본다면 ~~Redis~~도 그렇고, 그래서 API가 제시되는 방식에 뭔가 문제가 있는지 의심됨
가장 쉬운 예는 바이트코드 탈출임. 존재를 알면 비활성화하면 되지만, 이런 일이 반복된다는 사실은 더 넓은 문제를 드러냄. Lua 명세의 서로 떨어진 부분들이 상호작용하는 방식을 이해해서 샌드박싱 규칙을 조립해야 하지, 어떤 추가 상호작용을 허용하는지 명확한 기본 요소들로 프로그램을 안전하게 합성할 수 있는 구조가 아님
더 억지스러운 예로는 같은 Lua VM 안의 서로 다른 환경 사이에서 일어나는 프로토타입 오염이 있음. Redis에서는 string의 metatable을 오염시킬 수 있었고, 그러면 Lua 기능을 쓰는 다른 데이터베이스 사용자 권한으로 코드를 실행할 수 있었음. Lua는 JavaScript 같은 것보다 프로토타입 오염 표면이 천문학적으로 작지만, 전역 프로토타입이 대략 2개뿐인데도 그중 하나로 똑같은 일을 할 수 있다는 게 웃김
그렇긴 해도 Luau는 이 문제에 꽤 유능한 해법을 갖고 있고, 작성자가 새 샌드박스를 만들면 왜 같은 문제들을 암묵적으로 모두 피할 수 있다고 보는지는 잘 모르겠음
- 그 문단 전체가 “이 언어에 대해 읽어보긴 했지만, 지난 20년간 표준 선택지였는데도 몇 시간 조사할 생각은 없다”처럼 읽힘
-
“내 게임은 시뮬레이션 비중이 매우 높다. 커스텀 ECS 엔진으로 수십만 개 엔티티를 시뮬레이션한다. 이상적으로는 모딩 언어가 여러 컴포넌트 포인터를 받아 C의 for 루프처럼 순회할 수 있으면 좋겠다”는 부분은 더 나은 이상을 가질 수 있음
특히 Unity, Unreal, Blender, Godot 같은 렌더링 엔진들이 이 문제를 어떻게 다루는지 비교해볼 만함. 외부 반복은 초당 메가픽셀 단위를 논하기엔 충분히 빠르지 않고, 초당 수십만 엔티티에도 맞지 않을 수 있음. 여기서는 병렬성을 생각해야 함
대형 엔진들은 모두 GPU 친화적이고 대개 당황스러울 정도로 병렬화 가능한 분기 없는 알고리즘의 데이터플로 서술을 사용함. 작성자가 시각 편집기를 싫어할 수는 있고, 그런 생각도 흔하지만, 그렇다고 for 루프가 답이라는 뜻은 아님
만약 작성자가 ECS가 본질적으로 관계형 패러다임이고, 비교 대상으로 삼아야 할 역사적 짐이 많은 언어가 SQL이라고 언급했다면 좀 더 너그럽게 봤을지도 모르겠음- Graydon Hoare의 벡터화 인터프리터 강연을 다시 (댓글 9개) 언급할 만함