Тулпар: компилируемый язык с казахским синтаксисом, написанный с нуля (без LLVM, без libc, без C-компилятора)

в 12:58, , рубрики: c++, Rust, Си, язык программирования
Tulpar

Tulpar

Привет! Я написал Тулпар (Тұлпар — крылатый конь из казахской мифологии) — язык программирования, в котором ключевые слова, сообщения об ошибках и встроенные функции на казахском, синтаксис похож на Python, а программы компилируются в нативный машинный код x86-64.

Это не транспилятор в C и не обёртка над LLVM. Весь путь от исходника до ELF-файла — мой: лексер, парсер, вывод типов, оптимизатор, аллокатор регистров, кодогенератор, а также рантайм со сборщиком мусора. Рантайм не использует libc и ходит в ядро системными вызовами напрямую.

Ниже — как это устроено, что получилось по скорости, и что дальше.

Как это выглядит

анықта фиб(n: бүтін) -> бүтін:
егер n < 2:
қайтар n
қайтар фиб(n — 1) + фиб(n — 2)

әр і ішінде аралық(10):
жаз(f"фиб({і}) = {фиб(і)}")

Для знающих Python соответствие почти буквальное:

Tulpar

Python

Tulpar

Python

анықта

def

және / немесе / емес

and / or / not

қайтар

return

рас / жалған / жоқ

True / False / None

егер / әйтпесе егер / әйтпесе

if / elif / else

класс

class

әзірше

while

тоқта / жалғастыр / өткіз

break / continue / pass

әр x ішінде ...

for x in ...

тексер

assert

Идентификаторы — полноценный Unicode, так что переменные и функции можно называть как угодно, включая казахские буквы ә ғ қ ң ө ұ ү һ і.

Несколько свойств языка:

— Статическая типизация с выводом типов. x = 5 - это бүтін (int64), x = 2.5 - нақты (float64). Типы параметров функций пишутся явно, тип возврата выводится (для рекурсивных функций его нужно указать). — Классы с одиночным наследованием и виртуальными методами, кортежи с возвратом нескольких значений, генераторы списков, f-строки, лямбды (без замыканий), функции как значения. — Коллекции: тізім[T], сөздік[K, V], жиын[T]. Словарь сохраняет порядок вставки. — Строки — UTF-8, но индексация и срезы идут по символам, а не по байтам: ұзындық("Қазақстан«) == 9. Для казахского текста это принципиально: «Қ», «ұ», «ы» в UTF-8 занимают по два байта, и len() в стиле C дал бы бессмыслицу. — Две модели памяти. По умолчанию — сборщик мусора. Но внутри блока қолмен: («вручную») доступны указатели, бөл(T, n)/босат(p) и арифметика указателей. Вне этого блока указателей в языке нет.

қолмен:
p = бөл(бүтін, 1000) # *бүтін, обнулён, GC им не управляет
әр і ішінде аралық(1000):
p[і] = і * і
жаз(p[10], *p)
босат(p) # освободить нужно самому

Сообщения об ошибках — тоже на казахском, и при падении программы печатается стек вызовов с файлом, строкой и функцией:

Шақырулар тізбегі (соңғысы төменде):
«prog.tlp», 16-жол, <main>
«prog.tlp», 13-жол, сыртқы
«prog.tlp», 7-жол, тереңірек
Қате: нөлдік көрсеткішке (жоқ) қатынау

Конвейер компиляции

Компилятор — около 10 тысяч строк на Rust (без интерпретатора), интерпретатор — ещё около 1,7 тысячи, рантайм — около 3,6 тысячи. Всего в репозитории ~15 тыс. строк Rust. Конвейер классический:

исходник .tlp
│ lexer.rs токены с учётом отступов, f-строки, Unicode-идентификаторы
│ parser.rs рекурсивный спуск → AST
│ check.rs проверка и вывод типов, AST → HIR (типизированное дерево)
│ lower.rs HIR → трёхадресный IR: виртуальные регистры, базовые блоки
│ opt.rs упрощение CFG, свёртка констант, copy propagation,
│ слияние mov, удаление мёртвого кода, if-конверсия (cmov)
│ inline.rs инлайнинг маленьких функций
│ regalloc.rs linear scan, веса по глубине циклов, callee/caller-saved
│ codegen.rs x86-64, синтаксис Intel, System V ABI
▼
`as` + `ld` (+ libtulpar_rt.a) → статический ELF

Рантайм без libc

Рантайм (runtime/) - это #![no_std] крейт на Rust, собираемый в статическую библиотеку. Точка входа _start лежит в нём же, системные вызовы (write, mmap, socket, …) делаются инструкцией syscall через inline-ассемблер. В итоге готовая программа — статический ELF без динамических зависимостей; hello world занимает порядка 30 КБ.

Рантайм при старте:

— выделяет под стек 1 ГБ виртуальной памяти (физическая выдаётся ядром по мере использования) с защитной страницей снизу, поэтому бесконечная рекурсия превращается в понятное «переполнение стека», а не в segfault; — ставит обработчики сигналов: обращение к защитной странице стека превращается в сообщение о переполнении стека, а разыменование нулевой ссылки (жоқ) - в сообщение со стеком вызовов; — инициализирует GC.

Сборщик мусора

GC — консервативный mark-sweep:

— корни — стек и глобальные переменные; — куча разбита на spans по размерным классам (в духе tcmalloc/Go), поэтому аллокация мелких объектов — это снятие элемента со списка свободных; — понимает внутренние указатели (указатель в середину объекта удерживает объект); — объекты без указателей (строки, тізім[бүтін] и т. п.) вообще не сканируются; — 8-байтные слова объектов сканируются консервативно, то есть точной карты указателей нет. Цена — теоретическая возможность, что целое число, похожее на адрес, удержит мусор. На практике это редко, но это честное ограничение.

Объекты устроены просто: строка — [длина в байтах][число символов][байты], список — [длина][ёмкость][данные][флаги], поля класса — по 8 байт. В иерархии классов нулевое слово — id класса, виртуальные вызовы идут через vtable[id].

Отдельная фишка — ссылки на GC-объекты можно хранить в «ручной» памяти: бөл(мәтін, 4) или бөл(Класс, 4). GC считает такие блоки корнями. Благодаря этому ручное управление не ломает автоматическое.

жина() запускает сборку принудительно, жад() возвращает размер кучи. В репозитории есть тест hello_trees.tlp: ветвистые деревья — долгоживущее на 2¹⁸ узлов и сорок временных по 2¹⁶ - и проверка, что долгоживущее после всего цела.

Производительность

Замеры на Linux x86-64, 8 ядер. Rust — rustc -O, C — gcc -O2, Python — CPython 3.14. Время одного запуска:

тест

Tulpar

Rust

C -O2

Python 3

Коллатц, 3 млн чисел

0,59 с

0,34 с

0,47 с

33 с

решето Эратосфена, 2·10⁷

0,13 с

0,11 с

0,10 с

2,2 с

цикл с float, 10⁸

0,13 с

0,12 с

0,13 с

18 с

фиб(42)

1,55 с

0,65 с

0,39 с

36 с

Выводы:

— на простых циклах Тулпар вплотную к C и Rust; — от CPython он быстрее в 17-140 раз — ожидаемо для компилируемого языка со статическими типами против интерпретатора; — на рекурсии я проигрываю C и Rust в ~2,4 раза, на ветвистом коде — в ~1,7 раза. Причины понятны: нет раскрутки рекурсии, нет векторизации, cmov выбирается консервативно; — это не «обогнал C». Это «простой компилятор с нормальным аллокатором регистров даёт скорость того же порядка, что и промышленные».

Что даёт основной выигрыш: linear scan с учётом глубины циклов (горячие переменные получают регистры первыми), инлайнинг мелких функций, свёртка констант и if-конверсия в cmov.

Как проверять корректность оптимизатора

  1. Тесты (tests/run_tests.sh, сейчас 75 проверок): .tlp-программы с эталонными .out, каждая выполняется и с оптимизацией, и в режиме -O0, а затем ещё и в интерпретаторе. Отдельный каталог tests/errors/ содержит программы, которые обязаны упасть с определённым сообщением.

  2. Дифференциальный фаззер (tools/fuzz.py): генерирует случайные арифметические программы с функциями, делением, сдвигами, тернарными выражениями и считает эталон на Python (с ручной эмуляцией 64-битного переполнения). Результаты сравниваются с -O0 и с оптимизированной сборкой. Расхождение — баг.

  3. Предупреждения компилятора считаются багами: cargo build —release не должен печатать ничего.

Сеть и «настоящие» программы

Чтобы язык не оставался игрушкой для чисел, встроены TCP-сокеты, файлы и аргументы командной строки. Вот минимальный HTTP-сервер целиком на Тулпаре:

сервер = тыңда_локал(8080)
әзірше рас:
клиент = қабылда(сервер)
сұраныс = желі_оқы(клиент)
дене = «Сәлем!»
желі_жаз(клиент, f"HTTP/1.1 200 OKrnContent-Length: {дене.байт_саны()}rnrn{дене}«)
желі_жап(клиент)

Ограничения тут жёсткие: только TCP/IPv4, нет TLS и DNS, один поток (запросы обрабатываются по очереди). Для демонстрации и внутренних утилит достаточно, для интернета — нет.

Чего в языке нет

— Linux x86-64 only. Нужны as и ld; — нет исключений (try), нет пользовательских дженериков, нет пространств имён (импорт работает как include); — лямбды не захватывают переменные — замыканий нет; — целые числа 64-битные, при переполнении «заворачиваются»; длинной арифметики нет; — GC однопоточный и консервативный; — сеть: только TCP/IPv4, без TLS/DNS, один поток.

Что дальше

Из того, что хочется: собственный ассемблер и ELF-линкер (чтобы убрать зависимость от binutils), замыкания, исключения, раскрутка рекурсии и более умный выбор cmov, больше платформ. Если у вас есть идеи или желание помочь — репозиторий открыт.

Попробовать

Нужны Linux x86-64, Rust и GNU binutils:

git clone https://github.com/kuanysh228/tulpar
cd tulpar
cargo build —release
./install.sh # (по желанию) положит tulpar в ~/.local/bin

tulpar run файл.tlp # скомпилировать и запустить
tulpar build файл.tlp -o prog # автономный статический ELF
tulpar asm файл.tlp # посмотреть ассемблер
tulpar interp файл.tlp # запустить в интерпретаторе

Для VS Code в editors/vscode есть подсветка синтаксиса .tlp. README есть на казахском, русском и английском. Лицензия — MIT.

Репозиторий: https://github.com/kuanysh228/tulpar

Буду рад вопросам и критике в комментариях — особенно от тех, кто писал бэкенды компиляторов и увидит в моём regalloc.rs или opt.rs то, что я сделал наивно.

Автор: undercoverfts

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js