gw-batsic: интерпретатор GW-BASIC на голом batch — восемь лет в свободное время

Полноценный интерпретатор 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
2
3
4
5
6
7
$ cmd gw-batsic.bat /R examples/99BOB.BAS
99 BOTTLES OF BEER ON THE WALL
99 BOTTLES OF BEER
TAKE ONE DOWN
PASS IT AROUND
98 BOTTLES OF BEER ON THE WALL
...

Он стартует до приглашения 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. H48, i69, !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
2
3
4
# собрать порт и запустить программу на BASIC
git clone https://github.com/esix/cmd && cd cmd && go build -o cmd .
cd /path/to/gw-batsic
/path/to/cmd/cmd gw-batsic.bat /R examples/HELLO.BAS

Так что хотите попробовать — берите cmd (или прямо готовый релиз), он же гоняет весь тестовый набор gw-batsic без расхождений с настоящим Windows.