# 자신만의 프로그래밍 언어를 만드는 것은 생각보다 쉽다(하지만 더 어렵기도 하다)

> Clean Markdown view of GeekNews topic #29295. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=29295](https://news.hada.io/topic?id=29295)
- GeekNews Markdown: [https://news.hada.io/topic/29295.md](https://news.hada.io/topic/29295.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-05-08T15:04:22+09:00
- Updated: 2026-05-08T15:04:22+09:00
- Original source: [lisyarus.github.io](https://lisyarus.github.io/blog/posts/making-your-own-programming-language.html)
- Points: 1
- Comments: 1

## Topic Body

- **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](https://esolangs.org/wiki/FALSE) 같은 난해한 언어 인터프리터와 여러 람다 계산 인터프리터를 만들었지만, “진짜” 언어를 만든다는 욕구를 채우지는 못함
- 개발 중인 [대형 게임](https://lisyarus.github.io/blog/projects/village-builder.html)이 모딩에 적합한 구조라서, 모딩 방식을 고민하던 중 커스텀 프로그래밍 언어가 단순한 해법 중 하나로 떠오름
- 2025년 12월 Matt Godbolt의 [Advent of Compiler Optimisations](https://xania.org/202511/advent-of-compiler-optimisation)를 보며 C++ 컴파일러가 생성하는 어셈블리를 따라가게 됐고, 다시 어셈블리를 다뤄보고 싶어짐
- 현재 언어는 프로덕션 품질과 거리가 멀지만, 약 **1,000 LOC** 규모의 동작하는 Monte-Carlo [path tracer](https://bitbucket.org/lisyarus/pslang/src/79838a1bb316a2aa75bd7718c24fef384e976f52/examples/raytracer.psl)를 작성할 수 있을 정도까지 구현됨

### 모딩 요구사항과 기존 선택지의 한계
- 게임은 [커스텀 ECS 엔진](https://bitbucket.org/lisyarus/psemek/src/master/libs/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**이며, 명령형, 즉시 평가, 값 호출, 저수준 언어임
- 타입 시스템은 정적, 엄격, 명목적 타입 시스템으로 구성됨
- 기본 예시는 함수, 구조체, 함수 타입, 배열 반환을 함께 사용함
```text
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개**임
```text
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` 키워드와 필드 목록으로 선언함
```text
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](https://www.gnu.org/software/bison/)을 사용함
- 토큰은 [lexer grammar](https://bitbucket.org/lisyarus/pslang/src/trunk/libs/parser/rules/pslang.l), 언어 문법은 [parser grammar](https://bitbucket.org/lisyarus/pslang/src/trunk/libs/parser/rules/pslang.y)에 정의됨
- 파일은 문장 목록이고, 문장은 함수 선언, 제어 흐름 연산자, 변수 선언, 표현식 등이 될 수 있으며, 표현식은 리터럴, 변수, 연산자, 함수 호출 등이 될 수 있음
- 문법에서 shift/reduce 충돌을 몇 번 고쳐야 했고, Bison의 `-Wcounterexamples` 플래그로 충돌을 일으키는 정확한 상황을 확인함
- `lalr1.cc` Bison 스켈레톤을 사용해 C++ 파서 클래스를 생성함
- 기본 Bison은 파서 상태를 전역 변수로 갖는 C 파서를 만들지만, 인터프리터나 게임 모드처럼 여러 파일을 병렬로 파싱할 수 있어야 하는 경우에는 맞지 않음
- Bison 실행은 [CMake scripts](https://bitbucket.org/lisyarus/pslang/src/trunk/libs/parser/CMakeLists.txt)의 빌드 단계에 넣음
- 파서 출력은 파싱된 파일의 AST를 나타내는 C++ 객체임
- 들여쓰기 때문에 문법은 실제로 문맥 자유가 아니며, 어떤 문장이 `while` 본문에 속하는지는 앞의 들여쓰기 토큰 수에 의존함
- 해결책으로 각 줄을 독립 문장과 들여쓰기 수준으로 파싱한 뒤, [단순 선형 패스](https://bitbucket.org/lisyarus/pslang/src/trunk/libs/parser/source/finalize.cpp)에서 들여쓰기 수준을 보고 스코프를 확정함
- 이 방식은 해키하지만 동작하고 매우 빠르므로 받아들임
- 같은 패스에서 `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` 부동소수점 레지스터에 반환되며, 구조체는 크기에 따라 레지스터나 스택에 반환됨
- 이 방식은 메모리 접근 수를 줄여 생성 코드가 더 빨라지고 함수 호출도 단순해짐
- 스택은 주로 이항 연산 같은 중간 결과에 사용됨
```text
(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 명령 생성에는 서드파티 라이브러리를 쓰지 않고, 컴파일러를 작게 유지하기 위해 [직접 구현](https://bitbucket.org/lisyarus/pslang/src/trunk/libs/jit/source/arch/aarch64/instruction_builder.cpp)함
- 구현은 [instruction manual](https://cs140e.sergio.bz/docs/ARMv8-Reference-Manual.pdf)을 뒤져 필요한 비트를 적어 넣는 방식이었음

### 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](https://en.wikipedia.org/wiki/Static_single-assignment_form)와 비슷하게 시작했지만, 같은 노드에 값을 재할당할 수 있고 phi 노드도 쓰지 않으므로 실제로는 SSA가 아님
- IR은 [nodes](https://bitbucket.org/lisyarus/pslang/src/trunk/libs/ir/include/pslang/ir/node.hpp)의 시퀀스이며, 각 노드는 리터럴, 입력 노드를 갖는 연산, 조건부·무조건 점프, 함수 호출 등을 나타냄
- 값을 나타내는 노드는 해당 값의 타입도 저장함
- 재할당을 허용하기 때문에 기존 노드 값을 다시 할당하는 `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으로 초기화하는 방식만 가능함
  - 구조체 이름과 같은 이름의 함수를 선언하면 임의 생성자로 동작하게 하는 방식을 고려함
```text
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++를 다시 만들지 않으려 했음에도 첫 번째 선택지 쪽으로 강하게 기울고 있음
```text
struct vec2&lt;t: type&gt;:
    x: t
    y: t

func min&lt;t: type&gt;(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`는 명시적이지만 매번 직접 넣어야 하고 빠뜨리는 것을 막지 못하며, 파일 배열처럼 중첩 컬렉션을 해제할 때 불편함
```text
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와 비슷한 기능도 별도 문법 없이 컴파일 타임 호출로 구현할 수 있을 것으로 봄
```text
func comparable(t: type) -> bool:
    // Implemented somehow...

func min&lt;t: comparable type&gt;(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, 파일시스템 헬퍼, 시간·시계 헬퍼, 네트워킹임

### 현재 우선순위
- 계획한 기능을 언제 구현할지, 이 언어를 실제 게임 모딩이나 다른 용도로 쓸지는 정해지지 않음
- 야심 찬 프로젝트를 동시에 여러 개 진지하게 진행하는 것은 좋지 않다고 보고, 현재 우선순위는 여전히 게임 개발임
- 게임이 만들어지기 전에는 게임을 모딩할 수 없다는 점 때문에, 언어 작업은 하고 싶을 때 진행하는 상태임

## Comments



### Comment 57064

- Author: neo
- Created: 2026-05-08T15:04:22+09:00
- Points: 1

###### [Lobste.rs 의견들](https://lobste.rs/s/vqjc0e/making_your_own_programming_language_is) 
- 여기 댓글들이 이 커뮤니티에서 기대한 것보다 훨씬 **가혹하게** 느껴짐  
  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을 오염시킬 수 있었고](https://github.com/dwisiswant0/CVE-2025-46818), 그러면 Lua 기능을 쓰는 다른 데이터베이스 사용자 권한으로 코드를 실행할 수 있었음. Lua는 JavaScript 같은 것보다 프로토타입 오염 표면이 천문학적으로 작지만, 전역 프로토타입이 대략 2개뿐인데도 그중 하나로 똑같은 일을 할 수 있다는 게 웃김  
    그렇긴 해도 [Luau는 이 문제에 꽤 유능한 해법을 갖고 있고](https://luau.org/sandbox/), 작성자가 새 샌드박스를 만들면 왜 같은 문제들을 암묵적으로 모두 피할 수 있다고 보는지는 잘 모르겠음

- “내 게임은 시뮬레이션 비중이 매우 높다. 커스텀 ECS 엔진으로 수십만 개 엔티티를 시뮬레이션한다. 이상적으로는 모딩 언어가 여러 컴포넌트 포인터를 받아 C의 for 루프처럼 순회할 수 있으면 좋겠다”는 부분은 더 나은 이상을 가질 수 있음  
  특히 Unity, Unreal, Blender, Godot 같은 렌더링 엔진들이 이 문제를 어떻게 다루는지 비교해볼 만함. 외부 반복은 초당 메가픽셀 단위를 논하기엔 충분히 빠르지 않고, 초당 수십만 엔티티에도 맞지 않을 수 있음. 여기서는 **병렬성**을 생각해야 함  
  대형 엔진들은 모두 GPU 친화적이고 대개 [당황스러울 정도로 병렬화 가능한](https://en.wikipedia.org/wiki/Embarrassingly_parallel) 분기 없는 알고리즘의 [데이터플로](https://en.wikipedia.org/wiki/Dataflow_programming) 서술을 사용함. 작성자가 시각 편집기를 싫어할 수는 있고, 그런 생각도 흔하지만, 그렇다고 for 루프가 답이라는 뜻은 아님  
  만약 작성자가 [ECS](https://en.wikipedia.org/wiki/Entity_component_system)가 본질적으로 관계형 패러다임이고, 비교 대상으로 삼아야 할 역사적 짐이 많은 언어가 [SQL](https://en.wikipedia.org/wiki/SQL)이라고 언급했다면 좀 더 너그럽게 봤을지도 모르겠음
  - [Graydon Hoare의 벡터화 인터프리터 강연](http://venge.net/graydon/talks/VectorizedInterpretersTalk-2023-05-12.pdf)을 [다시](https://lobste.rs/c/fikmkn) [(댓글 9개)](https://lobste.rs/s/ilpqe5/vectorized_interpreters_mrt_for_pl) 언급할 만함
