Swift на Apple II. Часть 1: язык, компилятор и байткод

от автора

Swift — современный язык, на котором написаны многие приложения для платформ Apple. Мне показалось интересным вернуть кусочек этого языка к самым ранним дням Apple — к Apple II. Это была первая массовая серия машин Apple, вышедшая в 1977 году с процессором MOS 6502 на 1 МГц.

Я собрал SwiftII — мини-среду разработки в духе Swift для машин от оригинального Apple II до IIe и новее.

Скажу честно сразу: проект делался в свободное время и с активной помощью ИИ. Ниже я подробно расскажу, чем именно ИИ помог, — для меня этот процесс оказался не менее интересным, чем сама ретрокомпьютерная часть.

Важная оговорка: это не современный Swift и никогда им не станет. Полная стандартная библиотека Swift на эту машину просто не влезет. SwiftII — намеренно урезанное подмножество, по духу гораздо ближе к Embedded Swift, чем к SDK для iOS или macOS.

Цель была такая: уместить в Apple II столько Swift, сколько получится, но так, чтобы код по-прежнему читался как Swift. Если вы знаете Swift, программу на SwiftII вы поймёте с первого взгляда.

Кроме самого интерпретатора хотелось дать пользователю более-менее приличный опыт разработки. Поэтому пришлось написать ещё и загрузочное меню, файловый браузер и текстовый редактор — из-за ограничений, с которыми я столкнулся на Apple II.

Что это такое

Загружаетесь с нужной дискеты и попадаете в меню. Из него можно выбрать интерактивный REPL, файловый4 браузер, который запускает .swift-программы прямо с диска, или полноэкранный редактор — всё это на одной загрузочной дискете.

Загрузочное меню SwiftII

Загрузочное меню SwiftII
Полноэкранный редактор (IIe, 80 колонок)

Полноэкранный редактор (IIe, 80 колонок)

У клавиатуры оригинального II Plus нет ни строчных букв, ни клавиши \, поэтому программа набирается через диграфы. Например, ??/ превращается в \, который SwiftII трактует как канонический Swift. Об этой особенности — подробнее во второй части.

Если не терпится залезть в код и образы дисков — вот репозиторий.

Зачем это всё

Мне недавно подарили Apple II Plus, который я восстановил, и захотелось понять, на какие современные штуки его можно подбить. Раньше я писал ChatGPT-клиент под DOS и Slack-клиент под Windows 3.1 — там речь шла о том, чтобы впихнуть современный сетевой сервис в винтажную машину. В этот раз хотелось проделать то же самое с современным языком программирования — да ещё и от самой Apple.

Источник вдохновения — Apple Pascal. В 1979 году он принёс на Apple II систему UCSD p-System. Вместо компиляции Pascal в машинный код 6502 он компилировал в байткод, исполнявшийся виртуальной машиной, — примерно как это делает Java.

То же самое я хотел сделать для Swift: компилятор выдаёт байткод, а виртуальная машина (ВМ) его исполняет. Спустя почти полвека оба языка добираются до 6502 одним и тем же приёмом — обходя генерацию нативного кода через ВМ.

Ещё мне хотелось интерактивный REPL, чтобы код можно было пробовать на лету. Современный Swift SDK от Apple тоже поддерживает REPL, так что добавить его показалось логичным.

<anchor>zhelezo</anchor>

Целевое железо

Базовая цель — оригинальный Apple II 1977 года с необходимыми апгрейдами. Если SwiftII работает здесь, он заработает практически на любом более позднем Apple II.

Мой II Plus имеет такую конфигурацию:

●        процессор MOS 6502, 1 МГц;

●        48 КБ основной оперативной памяти;

●        клон карты Saturn 128K (умеет работать как 16K Language Card ради обратной совместимости);

●        клон 80-колоночной карты Videx Videoterm;

●        универсальный дисковый контроллер Yellowstone;

●        Floppy Emu вместо дисководов Disk II.

Оригинальный Apple II 1977 года поддерживается, если он дорос до 48 КБ ОЗУ плюс 16-килобайтная языковая карта — те же 64 КБ суммарно. Я использовал ProDOS 2.4.3: это современная поддерживаемая реинкарнация ProDOS 8, которая хорошо задокументирована и всё ещё работает на оригинальном Apple II. Единый образ ProDOS сильно упростил сборку и тестирование дисков. Языковая карта минимум на 16 КБ для ProDOS обязательна.

Пару слов про сам процессор. 6502 — 8-битный. Его регистры A, X и Y все восьмибитные, полноценного 16-битного регистра общего назначения нет. Нет ни блока с плавающей точкой, ни даже аппаратного умножения или деления. Аппаратный стек — всего 256 байт, что ограничивает глубину вложенности вызовов. А 64 КБ памяти по нынешним меркам — уровень микроконтроллера.

Язык

Вот как выглядит SwiftII. Если вы знаете Swift, всё будет знакомо:

let answer = 42var count = 0 let maybe: Int? = 5if let x = maybe { print(x) }let n = maybe ?? 0 while count < 10 { count += 1 }for i in 0..<5 { print(i) } func greet(name: String) -> String {    return "Hello, \(name)!"} var xs = [1, 2, 3]xs.append(4)print("\(xs.count) items")

Что поддерживается. Ядро (lite-дискеты) читается ровно как Swift: let/var с выводом типов; if/else if/else, while и for-in по диапазонам; &&/|| с коротким замыканием и префиксный !; функции верхнего уровня с return; опционалы с if let, ?? и force-unwrap !; массивы с append, count, isEmpty и индексированием; строки с конкатенацией через +, интерполяцией \(…) и String(n).

Что доступно сверх этого — зависит от загруженной дискеты. Дискеты с расширениями (sat/aux) добавляют разбор чисел (Int(s)), помощники для байтов и строк (asc, chr), больше методов массива (removeLast, removeAll, contains), peek/poke, управление курсором и текстом, а также графику низкого разрешения (lo-res): gr, color, plot, hlin, vlin. Дискеты компилятора семейства B идут ещё дальше — switch, for-in прямо по массивам, random(in:), звук через tone и файловый ввод-вывод. Так что один и тот же исходник может не собраться на одной дискете и прекрасно работать на другой.

Чем типы отличаются от обычного Swift. Int — знаковый 16-битный (ограничение кросс-компилятора), диапазон от −32768 до 32767, и это единственный числовой тип: ни Double, ни Float. String — это просто байты, а не Unicode: последовательность ASCII-байтов, ближе к C-строкам, чем к коллекции символов. Элементы массива обязаны быть одного типа. Идентификаторы ограничены 11 символами ради экономии памяти, причём слишком длинное имя — это жёсткая ошибка компиляции, а не тихое усечение, так что два длинных имени не могут случайно совпасть по общему префиксу.

Нет замыканий, словарей, обработки ошибок и throws, конкурентности (async/await), макросов и меток аргументов на месте вызова. Каждая из этих вещей стоила бы памяти или усложнила бы однопроходный компилятор так, как машина себе позволить не может.

Главное ограничение: 40 704 байта

Чтобы понять бюджет памяти, надо сначала разобраться с картой памяти Apple II. 6502 адресует 65536 байт своей 16-битной шиной. Бинарники SwiftII запускаются под ProDOS как SYS-файл, который загружается по адресу $2000.

Поскольку места для BASIC.SYSTEM над ним не остаётся, практический потолок образа — это непрерывный диапазон от $2000 до глобальной страницы ProDOS на $BF00, то есть $2000–$BEFF.

Схема банков: окно $D000–$FFFF, банки Saturn и копирование из aux-RAM

Схема банков: окно $D000–$FFFF, банки Saturn и копирование из aux-RAM

Этот регион — ровно 40 704 байта. Это вся основная память, которую может занять мой бинарник.

А что с памятью сверх 64 КБ? Её недостаточно для более крупных программ, но и напрямую она не адресуется. Расширения того времени — карта Saturn 128K или 64 КБ вспомогательной памяти у IIe — не расширяют адресное пространство как современная линейная память. Они сидят за общим окном, а нужный банк выбирается программными переключателями (soft switches), и программа пишет в выбранный в данный момент банк. Тем, кто знаком с миром DOS на PC, это концептуально напомнит EMS.

На Apple II всё осложняется тем, что окно языковой карты — это ещё и место, где живут ROM и код ProDOS MLI. Одновременно там может быть видно что-то одно. К этой теме я вернусь во второй части.

<anchor>konveyer</anchor>

Как устроен конвейер компиляции

Программа на SwiftII проходит вполне классический конвейер:

исходник Swift —> лексер   —> компилятор —> байткод —> ВМ —> вывод

                   (токены)     (парсер Пратта)  (.swb)

Большинство реализаций языков разбирают исходник в абстрактное синтаксическое дерево (AST), а потом обходят его, генерируя код. Но SwiftII дерево себе позволить не может — памяти под него нет. Вместо этого используется однопроходный парсер Пратта, который выдаёт байткод сразу по мере чтения исходника. За основу взята книга Роберта Нюстрома Crafting Interpreters.

От текста к байткоду. Ключевая мысль для тех, кто компиляторов не писал: SwiftII не «понимает» всю программу целиком. Он читает токен, решает, какой маленький кусочек языка перед ним, и дописывает инструкции ВМ в байтовый буфер.

Возьмём строку:

let x = 1 + 2

Лексер сначала превращает символы в поток токенов:

LET  IDENT(x)  EQUAL  INT(1)  PLUS  INT(2)  EOF

Парсер операторов видит LET — значит, компилируется объявление константы. Он записывает x в таблицу глобальных переменных и просит парсер выражений скомпилировать правую часть. Тут и помогает парсинг по Пратту: у каждого оператора есть приоритет. Увидев 1 + 2, он выдаёт «положить 1», встречает +, понимает, что + связывает со следующим значением, выдаёт «положить 2», затем «сложить». Никакого объекта BinaryExpr(left: 1, op: +, right: 2) в памяти нет — байты формируются прямо по ходу разбора:

фрагмент исходника   выданный байткод------------------   ----------------1                    OP_INT_U8 12                    OP_INT_U8 2+                    OP_ADDlet x = ...          OP_DEFINE_GLOBAL #0

ВМ — стековая машина: этот байткод кладёт операнды, выполняет опкод, который их снимает и кладёт результат, а затем сохраняет его в глобальный слот 0.

Функции верхнего уровня — это тоже просто диапазоны байткода. Увидев func greet(…), компилятор записывает смещение начала функции, число параметров и флаг возврата в небольшую таблицу функций, а тело выдаёт в общую байткодовую арену. Последующий вызов несёт не указатель на функцию, а индекс в таблице.

Со строками чуть иначе: компилятор копирует каждый литерал в константную кучу и выдаёт OP_STR <смещение>. Поэтому .swb-файл несёт и байткод, и пул констант, а раннер обязан воссоздать ту же раскладку кучи перед запуском.

Как выглядит сам байткод. Каждая инструкция — 1 байт опкода и от 0 до 2 байт встроенного операнда. Никаких многобайтовых опкодов или префиксов. Вотlet x = 1 + 2, за которым следует print(x) (при условии, что x — глобальная №0, а print — встроенная функция №0), — двенадцать байтов:

03 01       OP_INT_U8 1          ; положить малое целое 103 02       OP_INT_U8 2          ; положить малое целое 230          OP_ADD               ; снять два, положить их сумму22 00       OP_DEFINE_GLOBAL #0  ; связать с x20 00       OP_GET_GLOBAL #0     ; снова положить x63 00 01    OP_CALL_BUILTIN print, argc=1

Поскольку однопроходный компилятор не отслеживает типы операндов глубоко, некоторые опкоды полиморфны во время выполнения. OP_ADD складывает целые, если оба операнда целые, но переключается на конкатенацию строк в куче, если оба — строки. Так язык получает «a» + «b» без отдельной инструкции конкатенации. Интерполяция строк работает так же: один опкод смотрит на тег значения и рендерит Int, Bool или nil в текст.

Никакой плавающей точки. В SwiftII нет ни Double, ни Float, ни десятичных дробей вообще. Int — 16-битное знаковое целое, и это единственный числовой тип. Причина простая: 6502 не умеет в плавающую точку, поэтому и компилятор cc65 её не поддерживает, а тащить библиотеку эмуляции — это сильно раздуть бинарник. Показательно, что даже Embedded Swift обходится с плавающей точкой очень осторожно: долгое время нельзя было даже напечатать Double, потому что нужной библиотечной процедуры просто не было, и её дописали на чистом Swift лишь в версии 6.3. Если уж урезанный Swift относится к плавающей точке аккуратно, то Apple II из 1970-х тем более. Applesoft BASIC на II Plus плавающую точку поддерживает, но за счёт того, что она зашита прямо в ROM, — а мне так сделать нельзя.

Две семьи бинарников

Одно и то же дерево исходников выходит двумя семействами бинарников. Причина — устройство 16-килобайтной языковой карты Apple II по адресам $D000–$FFFF. Там же ProDOS держит свой MLI.

●        Семейство A содержит REPL. Чтобы уместиться, интерпретатор сбрасывает «холодный» код в языковую карту. Это затирает MLI ProDOS, поэтому семейство A не может делать полноценный файловый ввод-вывод после старта интерпретатора: запускать программы, подготовленные лаунчером, — может, а свободно открывать и читать произвольные файлы — нет.

●        Семейство B содержит компилятор и раннер. Это инструменты, работающие только в основной памяти, поэтому языковая карта у них свободна, а MLI ProDOS остаётся жив.

Обе семьи дискет содержат лаунчер, в который встроены файловый браузер и редактор.

Компилятор семейства B читает .swift-исходник и пишет на диск .swb-байткод для раннера. У .swb-файла 12-байтовый заголовок (трёхсимвольная сигнатура SWB, однобайтовая версия формата, входной счётчик команд и длины секций), за которым идут байткод, пул констант и по 4 байта на функцию.

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

Уровень

Машина

Макс. размер байткода

1 (flat)

II Plus с LC или IIe

1834 байта

2 (Saturn)

II Plus с LC + Saturn 128K

~36 КБ

3 (aux)

IIe + 64 КБ aux

~36 КБ

 

На стоковой машине вам доступно 1834 байта байткода. Чтобы взять больше, карта Saturn 128K или 64 КБ вспомогательной памяти IIe используются для выгрузки готовых тел функций из основной памяти в запасную. Дополнительная ёмкость помогает в основном программам с большим числом функций: каждая отдельная функция и main верхнего уровня всё равно должны помещаться в резидентное окно.

Это конец первой части. Во второй — почему на клавиатуре 1977 года Swift набирается через диграфы, как устроены редактор и файловый браузер, за что отвечает переключение банков памяти, почему проект — это девять дискет, и большой рассказ о том, как всё это собиралось с помощью Claude Code и Codex.

ссылка на оригинал статьи https://habr.com/ru/articles/1067592/