Полноценный интерпретатор GW-BASIC,
написанный целиком на .bat-файлах Windows. Без C, без Python, без единого скомпилированного бинарника — только SET, CALL, GOTO и FOR /F.
Что это
gw-batsic запускает программы на GW-BASIC — том самом диалекте, что шёл в комплекте с IBM PC 1980-х. Лексер, LL(1)-парсер, арифметика с плавающей точкой в формате Microsoft Binary Format, файловая подсистема, обработка ошибок — всё это написано на командных файлах cmd.exe. Да, на batch.
1 | $ cmd gw-batsic.bat /R examples/99BOB.BAS |
Он стартует до приглашения Ok, умеет LOAD "PROG.BAS", LIST, правку строки, RUN и SAVE обратно в токенизированный бинарник — ровно то же, что делали на PC в 1985-м, на языке, у которого единственная структура данных — строка.
Восемь лет по чуть-чуть
Честно про сроки: я начал этот проект восемь лет назад. Это не значит, что я пилил его восемь лет подряд — я возвращался к нему время от времени, по выходным и свободным вечерам, когда было настроение поковыряться в чём-то заведомо бесполезном и приятном.
Сам, своими руками, я довёл до рабочего состояния фундамент:
- целочисленную арифметику;
- сложение и вычитание чисел в формате MBF (это уже немало — об этом ниже);
- слой представления строк в шестнадцатеричном виде;
- лексер;
- LL(1)-парсер с генератором таблиц;
- и запуск нескольких первых команд —
PRINT, простые выражения, присваивания.
А потом упёрся в масштаб. Дальше предстояла вся математическая библиотека, все строковые функции, целая файловая подсистема (последовательный и random-access доступ с FIELD-записями), обработка ошибок, PRINT USING, DEF FN, CHAIN… Один, в свободное время, я бы делал это годами. Поэтому я позвал на помощь агента (Claude Code) — и вместе мы дотащили проект от «работает пара команд» до «84% реального винтажного корпуса доходит до своей настоящей логики» и 1080+ проходящих тестов.
Ниже — про самые интересные части.
Почему всё в шестнадцатеричном виде
Первая же стена, в которую упирается любой интерпретатор на batch, — это то, что cmd.exe калечит текст ещё до того, как ты успел его разобрать. Наберите в скрипте PRINT "A > B" — и > уедет в перенаправление вывода в файл с именем B". И это только начало списка: <, &, |, ^, =, кавычки, ведущие пробелы, а отложенное раскрытие !VAR! ещё и съедает восклицательные знаки прямо из переменных.
Решение, к которому проект пришёл, радикальное: на границе превращаем весь текст в шестнадцатеричную строку и держим его таким, пока потребитель явно не декодирует обратно. Пара hex-цифр на байт — и дальше мы передаём через set, call и for только символы [0-9A-F], которые batch при всём желании не способен истолковать как-то иначе.
Hi! превращается в 486921. H → 48, i → 69, ! → 21. Никаких метасимволов — никаких сюрпризов.
Перекодировка туда-обратно идёт через certutil — единственную программу, которая гарантированно есть в любой Windows и умеет -encodehex / -decodehex побайтово:
1 | certutil -encodehex "input.txt" "output.hex" 12 >nul |
Числа, кстати, живут так же — в виде хекс-строк с тегом типа: i0005 — целое 5, s8148F5C3 — single 3.14, d8148F5C28F5C28F6 — double 3.14. Один и тот же приём — текст и числа суть строки из [0-9A-F] — пронизывает весь интерпретатор.
Лексер
Лексер — это конечный автомат, который едет по hex-строке по одной паре символов (по байту) за шаг. Состояния обычные: Normal, Id (идентификатор), Number, Quote, Rem и так далее. На выходе — поток токенов.
Возьмём простой пример:
1 | PRINT 2+3*4 |
Сначала исходник кодируется в hex:
1 | 5052494E5420322B332A34 |
Лексер пробегает по нему и выдаёт токены:
1 | PRINT NUM_i0002 PLUS NUM_i0003 MUL NUM_i0004 EOL |
Числа уже здесь превратились в тегированные значения (NUM_i0002 — целое 2), ключевые слова распознаны по таблице, а операторы получили свои имена (PLUS, MUL). Заодно лексер разгребает винтажные причуды: склеенные конструкции вроде FNA (вызов DEF FN) или PRINT#1, плотную форму FIELD 1,5ASO1$, двусловное GO TO и табуляцию как пробел.
Парсер
Дальше — LL(1)-парсер, управляемый таблицей. Грамматика лежит в bnf.txt, отдельный генератор прогоняет по ней неподвижную точку FIRST/FOLLOW и собирает таблицу разбора (она закоммичена в репозиторий, чтобы не пересобирать каждый раз). Семантические действия в грамматике помечены маркерами @action, и парсер выдаёт их в постфиксном порядке.
И вот тут — самое красивое. Постфикс — это же обратная польская запись, ровно как в Forth. Наш PRINT 2+3*4 превращается в:
1 | NUM_i0002 NUM_i0003 NUM_i0004 MUL ADD PEND |
То есть буквально 2 3 4 * + . — «положи 2, положи 3, положи 4, умножь, сложи, напечатай». Приоритет операций уже зашит в порядок: 3*4 идёт раньше, чем +2, потому что грамматика так разложила. Никаких деревьев, никакого обхода AST на этапе исполнения — плоская лента команд для стек-машины.
Как это исполняется
Исполнитель — это крошечная стек-машина. Она едет по постфиксной ленте слева направо. Увидела значение (NUM_/VAR_/STR_) — кладёт на стек. Увидела что-то ещё — значит, это действие, и она зовёт обработчик src/rtl/<ИМЯ>.bat, который снимает аргументы со стека и кладёт результат обратно.
Для 2 3 4 * + . это выглядит так:
| Токен | Действие | Стек после |
|---|---|---|
NUM_i0002 |
положить 2 | 2 |
NUM_i0003 |
положить 3 | 2 3 |
NUM_i0004 |
положить 4 | 2 3 4 |
MUL |
снять 3 и 4, умножить | 2 12 |
ADD |
снять 2 и 12, сложить | 14 |
PEND |
снять 14, напечатать | — |
На экране — 14 (с ведущим пробелом на месте знака — это родная привычка GW-BASIC печатать числа). Сам стек — это обычная строковая переменная окружения, а каждый оператор и каждая встроенная функция — это один .bat-файл, который её толкает и пихает. Примерно 196 таких обработчиков и составляют всю семантику языка.
Арифметика — большая и сложная часть
Если лексер и парсер — это аккуратная механика, то арифметика — это та часть, на которую ушло больше всего сил.
GW-BASIC считает не в IEEE 754, а в MBF — Microsoft Binary Format: single — 4 байта, double — 8 байт, со своим расположением знака, экспоненты и мантиссы. А batch не умеет в плавающую точку вообще никак — у него есть только целочисленный set /a на 32 бита. Значит, всё нужно делать руками, по полубайтам (по 4 бита), в шестнадцатеричном виде. В src/num/ лежит целый слой такой хекс-арифметики: _xbyte, _xword, _xhalf, _xdword, _xqword — сложение и сдвиги на 8, 16, 32, 64 бита нибл за ниблом.
Своими руками я написал целочисленную часть и сложение с вычитанием MBF — а это уже изрядный кусок работы: выровнять экспоненты, сдвинуть мантиссы, сложить/вычесть, перенормировать результат, округлить. Когда оно впервые сошлось и PRINT 0.1+0.2 выдало правильный MBF-результат, это было маленькое счастье.
А вот дальше начиналось то, ради чего я и позвал агента. Умножение и деление. И — самое весёлое — трансцендентные функции: SIN, COS, TAN, LOG, EXP, ATN. Все они считаются разложением в ряд Тейлора с предварительным приведением аргумента к рабочему диапазону. Написать ряд Тейлора на batch, по hex-ниблам, с приведением диапазона и округлением — это ровно тот объём, который в одиночку и по вечерам растянулся бы ещё на годы. Вдвоём с агентом это заняло несколько подходов.
Полностью честно: пара шероховатостей в конверсии больших чисел и округлении на присваивании осталась — всё задокументировано. Но базовая математика работает, и винтажные программы на ней считают то, что должны.
Итог
Полезно ли это? Конечно, нет. Никто не запускает GW-BASIC через интерпретатор на batch-файлах. Но в этом и был весь смысл — собрать заведомо невозможную штуку из самого неподходящего материала и довести до того, что она реально работает.
Самое ценное для меня — два момента. Первый: когда фундамент, который я выложил сам — числа, hex, лексер, парсер — впервые сложился в работающий конвейер. И второй: когда стало ясно, что дальше в одиночку это годы, и помощь агента превратила «годы свободных вечеров» в несколько недель плотной работы — и проект из «пары команд» дорос до интерпретатора, который гоняет реальные программы 1980-х.
Восемь лет, по чуть-чуть. Зато теперь оно печатает бутылки пива на стене — на чистом batch.
Как это запустить
Нативно gw-batsic работает на Windows — это же обычные .bat-файлы, их выполняет встроенный cmd.exe. Но есть нюанс: интерпретатор batch в Windows очень медленный, а здесь его гоняют тысячами вызовов на каждую строчку BASIC — так что на родном cmd.exe всё ползёт.
Поэтому я заодно написал порт cmd.exe на Go — про него отдельный пост. Он решает сразу две задачи:
- запускает всё это на не-Windows — Linux, macOS — те же самые
.bat-файлы без единой правки; - и даже на самой Windows работает заметно быстрее оригинального batch-интерпретатора.
Проще всего взять готовый бинарник из релиза v1.0.0 — без Go-тулчейна и сборки. Ну или собрать из исходников:
1 | # собрать порт и запустить программу на BASIC |
Так что хотите попробовать — берите cmd (или прямо готовый релиз), он же гоняет весь тестовый набор gw-batsic без расхождений с настоящим Windows.