Гляньте-ка! Это я с сервером Minecraft, запущенным на компьютере UNIVAC 1219B:

Nathan standing next to the UNIVAC 1219B with a laptop running Minecraft.

А вот эмулятор NES с первым отрендеренным кадром Pinball:

An ASCII rendering of the NES Pinball title frame on teletype paper.

… и селфи, напечатанное при помощи техники многократной печати «overstrike»:

An ASCII portrait of Nathan printed on the teletype.

Мы сделали ещё кучу безумных штук, и в том числе:

  • Программы OCaml (!)

  • Веб-сервер

  • Шифрование Curve25519 + AES

  • Интерпретатор BASIC

  • ELIZA

  • Игры наподобие Oregon Trail, Wordle и Battleship

… а также многое другое! И всё это на компьютере из 1960-х с частотой 250 кГц и всего с 90 КБ ОЗУ. Ради такого я и живу! Я одержим запуском кода в странных местах и преодолением технических ограничений. Этот проект стал для меня самым амбициозным на данный момент, он отнял у меня и других примерно восемь месяцев.

Исходники проекта я выложил на Github. Также можете посмотреть видео TheScienceElf об этом проекте!


UNIVAC — странная машина

UNIVAC 1219B — очень странная машина, почти во всём враждебная к современному программированию:

  • 18-битные слова. Адреса памяти и значения 18-битные! Это даже не степень двойки.

  • Арифметика обратного кода (вроде как). В современных компьютерах для обозначения целых чисел со знаком используется дополнительный код (two’s complement). Этот компьютер использует обратный код (ones’ complement), но с раздражающими отличиями в области нуля со знаком, которые нам пришлось подвергать реверс-инжинирингу.

  • Всего несколько регистров. Один 36-битный регистр A можно отдельно адресовать, как AU:AL. У нас есть он и ещё один 18-битный регистр B.

  • Всего 40960 слов памяти. Всего 90 КБ общей памяти, которую нужно разделить между кодом и памятью, которая необходима во время исполнения.

  • Память в банках. Эти 40960 слов памяти разделены на десять банков. Нужно заранее конфигурировать, к какому банку относятся команды.

Изначально компьютер предназначался в ВМФ США для чтения сигналов радаров и управления артиллерией. И это на самом деле удивительное чудо инженерии. На фотографии ниже компьютер находится слева. Справа расположен блок магнитной ленты (пока частично поломанный).

The UNIVAC 1219B and its tape drive at the Vintage Computer Federation museum.

Рядом находится телетайп, при помощи которого происходит общение с компьютером. Можно печатать ввод для UNIVAC и он будет отвечать; всё печатается на одном и том же листе бумаги. Это stdin и stdout.

A Model 35 Teletype with its dust cover, paper spool, and keyboard.
Model 35 Teletype с крышкой от пыли, рулоном бумаги и клавиатурой

Сегодня сохранилось всего два UNIVAC 1219; оба спасены из Университета Джонса Хопкинса ребятами из Vintage Computer Federation. Этот остался единственным рабочим.

До того, как мы приступили к этому проекту, все существовавшие программы писались вручную на ассемблере UNIVAC. Мы изменим ситуацию, реализовав компиляцию C!


Первая встреча на VCF East 2025

Впервые я увидел этот компьютер, поехав на VCF East в апреле 2025 года. Билл и Стивен запускали на машине демо-программы. За последние десять лет Дуэйн, Билл и Стивен проделали огромный труд по спасению и восстановлению компьютера.

Это было поистине вдохновляющее зрелище: мигающие лампочки, стук телетайпа, запах масла… Уже тогда я понял, что обязан запустить на этой штуке какой-нибудь безумный код. Что-нибудь посложнее, чем fizzbuzz. Мне хотелось создать эмулятор NES. Я хотел OCaml. Насколько мы сможем расширить возможности этого железа?


Нам нужен эмулятор и ассемблер

Первым делом нам нужен ассемблер для ассемблерного языка UNIVAC и эмулятор для запуска ассемблированной программы. К счастью для нас, много лет назад Дуэйн написал ассемблер для ассемблерного языка UNIVAC на BASIC (!) и эмулятор на VB.NET.

Вскоре после завершения ярмарки VCF TheScienceElf решил попробовать написать на Rust новые ассемблер и эмулятор, сверяясь со сканами потрясающих руководств и взяв за основу реализации Дуэйна.

Эмулятор на Rust был быстрым. Он оказался в 400 раз быстрее, чем реальное железо UNIVAC, и в 40 тысяч раз быстрее, чем эмулятор на VB.NET. Оказалось, что эта скорость нужна нам полностью для обеспечения фаззинг-тестирования, о котором я расскажу ниже.

На этом этапе оба эмулятора воссоздавали оборудование не совсем точно, но этого было вполне достаточно для начала!


Wee как первая попытка реализации компилятора C

Итак, у нас есть эмулятор; как запустить в нём код на C?

Быстрее всего проверить компилятор C можно было при помощи моего старого проекта wee. Это крошечный набор команд, которым я раньше пользовался для компиляции C в странных местах.

Всё сработало, но боже, как же плохо. Тривиальная программа fizzbuzz занимала около 27 тысяч слов, или примерно 67% от общей памяти компьютера. Вычисление первых ста строк fizzbuzz занимало целую минуту. Так как моя цель заключалась в запуске реальных и сложных программ, это решение явно не подходило.


Наш вариант — эмулятор RISC-V

Нужно что-то похитрее, чем wee. Вариантов много, поэтому проясню две свои основные задачи:

  1. Я хочу запускать реальные, большие, интересные программы. Хочу компилировать их прямо из Github и выпускать их резвиться на машине. Менее важно, чтобы эти реальные программы работали максимально быстро.

  2. При этом я не хочу сойти с ума.

Нам нужен реальный компилятор наподобие LLVM и GCC

Для достижения цели запуска реальных программ нам нужно всё нижеперечисленное:

  • Полная стандартная библиотека C. В этом случае я использовал picolibc.

  • Программные float и прочие легализации. Нам нужно, чтобы работали все типы и операции. Float, double, int32, int64, всё сразу. Даже несмотря на то, что у UNIVAC нет оборудования для реализации всего этого нативно.

  • Устранение мёртвого кода + оптимизация размера. Нам нужно всё плотно упаковывать в 90 КБ пространства.

  • Другие языки. Я хочу поддерживать не только C, но и языки наподобие Rust, C++, Zig и так далее.

Прямая компиляция на UNIVAC не решит задачу

Написание бэкенда LLVM или GCC для UNIVAC было бы совершенно кошмарным предприятием, и я нарушил бы свою вторую цель — не сойти с ума. Арифметику обратного кода, 18-битные слова и память в банках было бы весьма мучительно запихивать в современные компиляторы.

И даже если бы это нам удалось, то для получения выгоды от прямой компиляции значения int языка C должны были бы стать 18-битными int обратного кода. Строго говоря, это допускается спецификацией C (по крайней мере, до обязательного требования дополнительного кода, введённого в C23), но на практике в реальном коде часто подразумевается >=32-битный дополнительный код, поэтому готовые программы поломаются.

Итак, будем эмулировать платформу, которую GCC уже поддерживает, например, RISC-V

Идея заключается в использовании GCC для компиляции C на RISC-V с последующей эмуляцией RISC-V на UNIVAC при помощи эмулятора RISC-V, написанного на ассемблерном языке UNIVAC.

Задумайтесь, насколько это удобно:

  • Достаточно одного раза. Можно написать эмулятор один раз и больше никогда не касаться ассемблера UNIVAC.

  • Можно выполнять фаззинг. С большой долей уверенности можно будет убедиться в корректности эмулятора, генерируя произвольные программы RISC-V, запуская их через эмулятор и эталонный эмулятор, а затем сравнивая финальное состояние регистров.

  • Постоянный приток дофамина. Много лет назад я прочитал пост, который врезался мне в память: о том, что нужно структурировать проекты таким образом. чтобы на протяжении всей реализации он обеспечивал стабильный выброс дофамина. Если вы попытаетесь написать проект целиком, а тестировать его будете только в конце, то можете выгореть, прежде чем получите положительное подкрепление в виде работоспособной части программы. В базовом наборе команд RISC-V всего 38 важных нам команд, то есть это чёткая конечная цель. Мы можем ставить напротив них галочки в списке в процессе реализации и прохождения ими фаззинг-тестов.

  • Плотные двоичные файлы. Можно эффективно закодировать команду RISC-V в два 18-битных слова UNIVAC, чтобы эффективно упаковывать их в нашу ограниченную память. Также это оставляет нам возможность в будущем реализовать сжатое расширение или добавить дополнительные специальные способы сжатия.

Эмуляция медленнее, но это нас устраивает

Существенный недостаток такого подхода заключается в снижении скорости декодирования и эмуляции каждой команды в среде исполнения. После всех оптимизаций для эмуляции одной команды RISC-V требуется примерно 40 команд UNIVAC. Это значит, что наш компьютер UNIVAC с частотой 250 кГц будет работать примерно как RISC-V с частотой 6 кГц.

… и это вполне неплохо! Реальное препятствие для запуска реальных, сложных программ — это память на 40 тысяч слов. Эта эмуляция обеспечивает нам наилучшую эффективность использования пространства, а также другие преимущества.


Создаём тулчейн

Вот как выглядит тулчейн в общих чертах:

  1. Пишем код на C.

  2. Компилируем его на RISC-V при помощи GCC.

  3. Перекодируем каждую команду в эффективный для UNIVAC формат, по два слова на одну команду RISC-V.

  4. Присоединяем эти перекодированные команды к исходнику эмулятора.

  5. Выполняем сборку программы в ленточный файл .76 для загрузки в машину.

Написание примерно тысячи строк ассемблерного кода UNIVAC для эмулятора RISC-V будет непростой задачей; перед её выполнением нужно иметь хороший инструментарий. До того, как начать писать эту программу, я потратил пару недель на подготовку:

  1. major mode в emacs.

  2. Инструментария OCaml для парсинга, эмуляции и перекодирования RISC-V с фаззингом.

  3. Дифференциального фаззера, сверяющего мой эмулятор RISC-V для UNIVAC с эталоном (mini-rv32ima).

  4. Эффективного инструмента редуцирования тестовых случаев (при помощи порта Lithium).

И все эти вложения принесли огромные дивиденды.

Claude Code пока не может писать ассемблерный код UNIVAC

Claude Code очень хорош, он целиком написал за меня major mode для emacs по документации с командами. Я часто использую его для редактирования кода, когда пишу на OCaml. К моему разочарованию, даже при наличии документации, эмулятора и дифференциального фаззера Claude Code провалил задачу написания ассемблерного кода UNIVAC. Но я не могу его винить, язык UNIVAC на самом деле очень странный.

Что бы я ни делал, на этом этапе проекта Claude Code не мог уяснить для себя особенности UNIVAC: арифметику обратного кода, то, что сдвиг влево круговой, а сдвиг вправо арифметический, а также странные особые случаи команд, например, что CPAL с 0 ведёт себя иначе.

Зато ассемблерный код UNIVAC могу писать я

В жизни каждого программиста бывают моменты, когда нужно просто сесть и продолжать пахать. Я закатал рукава и за несколько дней написал примерно тысячу строк ассемблерного кода UNIVAC, реализующих 38 команд RISC-V, которые нужны были нам из базового набора. Честно говоря, это был увлекательный процесс!

A UNIVAC assembly file open in emacs with syntax highlighting.
major mode emacs обеспечивает подсветку синтаксиса и показывает подсказки с таймингом команды.

Фаззинг-тестирование отлавливало баги и мгновенно редуцировало их до минимального образца для воспроизведения. После того, как фаззер проходил все тесты для команды, я спокойно двигался дальше; на этом этапе меня волновала не эффективность, а только корректность.

Первая программа на C работает!

После того, как все фаззинг-тесты начали проходить, я запустил свою первую программу на C. И она... почти заработала! Обнаружился небольшой баг транслирования адресов памяти RISC-V в адреса памяти UNIVAC. Я дополнил свой фаззер, чтобы он мог отлавливать этот баг, устранил его, и с этого момента все программы на C начали работать! Я многократно поблагодарил себя из прошлого за написание фаззера.

Это был потрясающий момент. Fizzbuzz работал. Интерпретатор BASIC работал. Работал даже эмулятор NES smolnes!

…единственная проблема заключалась в том, что на реальном компьютере для рендеринга первого кадра игры Pinball потребовалось бы двадцать часов (а в эмуляторе — три минуты). К сожалению, мы не можем ждать двадцать часов в музее. Так что же, идея эмулятора NES обречена на провал?

Ни в коем случае; нам просто нужно чертовски всё оптимизировать.


Теперь сделаем всё в тридцать раз быстрее

Наш эмулятор UNIVAC определяет общее время, которое понадобится для выполнения программ на реальной машине. Это даёт нам число, к которому нужно стремиться при оптимизации.

При оптимизации я сосредоточился на двух численных показателях:

  • Время исполнения всех подвергнутых фаззингу программ, что даст нам хорошую среднюю метрику для всех команд.

  • Демо NES — эталонный бенчмарк, максимальная скорость которого мне по-настоящему была важна.

Переносим работу из среды исполнения на этап кодирования

Самая важная оптимизация будет заключатся в перекодировании команд RISC-V в формат, максимально эффективный для UNIVAC. Команда RISC-V имеет длину 32 бита. При перекодировании мы берём эту 32-битную команду, выполняем некие преобразования и записываем результат в два 18-битных слова, которые будет использовать эмулятор UNIVAC.

Я был потрясён, когда прочитал спецификацию RISC-V и узнал, как в ней кодируются команды с непосредственным адресом: биты скремблируются внутри команды!

The RISC-V JAL instruction encoding diagram from the official spec.
Диаграмма кодирования команды JAL RISC-V из официальной спецификации

Для воссоздания команды с непосредственным адресом в программном эмуляторе нам нужно потратить кучу тактов на битовые сдвиги и маскирование. Вероятно, это удобно и эффективно для аппаратных реализаций? Однако мы не можем сэкономить эти такты, поэтому логично будет заранее выполнить преобразование скремблированных битов и записать их в нужном порядке в перекодировании, передаваемом эмулятору UNIVAC.

Это та же история, что и с опкодами. При выборе того, как эмулировать команду RISC-V, иногда нужно проверять множество битов команды, находящихся в разных местах. Наше кодирование просто назначает каждой команде удобный номер опкода.

Наряду с преобразованием скремблированных команд с непосредственными адресами, если обработчик команды хоть что-то делает непосредственно, мы будем запекать это напрямую в команду. Например, некоторым обработчикам нужно непосредственно вычислять immediate * 2. Можно просто сохранять immediate * 2 вместо immediate.

Крайние случаи этого — команды SRLI и SRAI. На UNIVAC сдвиг на переменное значение невозможен. Решить эту проблему можно, динамически создавая команду сдвига в среде исполнения наподобие самомодифицирующегося кода, а затем исполнять её. Но на самом деле, работу по созданию этой команды UNIVAC можно выполнить заранее! В случае SRLI/SRAI мы просто упаковываем команду UNIVAC напрямую в полезную нагрузку, которая позже будет извлечена, записана в ОЗУ и исполнена.

Из-за этих преобразований мы технически теряем возможность поддерживать программы RISC-V, использующие самомодифицирующийся код, но это приемлемая жертва в обмен на огромный прирост скорости.

Ускоряем исполнение на горячем пути

К UNIVAC применимы и классические принципы оптимизации:

Удаление мёртвого кода. Здесь я поступил хитро: переделал мой минимизатор тестовых случаев так, чтобы он удалял из эмулятора максимально возможное количество команд UNIVAC, а фаззинг-тесты при этом продолжали проходить. Так я обнаруживал код, который просто можно удалить!

Таблицы переходов. Оказалось, что самый эффективный способ диспетчеризации команд — это таблицы переходов на основании опкода.

Изменение порядка команд и живучесть регистров. Чем реже нам приходится сохранять и загружать регистры в/из памяти, тем лучше.

Встраивание кода. Вызовы подпроцедур имеют оверхед переходов и возвратов; для его устранения можно встраивать небольшие функции.

Добавляем систему макросов OCaml для управления встраиванием

Встраивание кода повышает скорость, но сильно усложняет поддержку, если у нас нет системы макросов, избавляющей нас от необходимости копипастить код по всей программе. Я написал простую систему макросов OCaml: код OCaml, записанный между тройными обратными штрихами, может инъецировать содержимое непосредственно в файл. Очень удобно.

Вот пример снижения степени дублирования кода:

An OCaml helper function defined at the top of an asm file and called from two instruction handlers below.

А вот пример, в котором я использую OCaml для генерации таблицы поиска с 32 элементами:

A short OCaml expression in an asm file that generates a 32-entry data table.

Добавляем быстрые системные вызовы, используем подходящие флаги компилятора

Большинство этих программ на C имеет глобальные переменные, которые нам нужно обнулять при запуске в разделе .bss. Это занимает время, поэтому я добавил системный вызов memclear, который быстро делает это на ассемблерном языке UNIVAC для оптимизации времени запуска.

Также я добавил аннотацию компоновщика .noinit, чтобы убрать из инициализации .bss некоторые большие глобальные буферы, которые ей не особо нужны.

Что касается флагов компилятора, то -O3 ускоряет работу, но не так сильно по сравнению с -Os. У UNIVAC нет сложного оборудования наподобие кэшей, блоков предсказания ветвления и так далее, которыми бы могли воспользоваться компиляторы.

Claude Code при параллельной работе вносит множество микрооптимизаций

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

В моей первой оптимизации было множество простых возможностей для оптимизации в сфере изменения порядка команд, устранения мёртвого кода и так далее. Процесс Claude Code породил десять параллельных субагентов, каждый в собственном worktree для независимого исследования и проверки различных идей по оптимизации.

A terminal showing 10 Claude Code agents running in parallel in separate worktrees.
Десять субагентов Claude Code параллельно пытаются оптимизировать эмулятор

Главный агент выполняет мерджинг их результатов, исходя из описанных мной критериев удобства поддержки/качества. (Я вежливо попросил, чтобы он не просто встраивал всё). Перед мерджингом я просматривал получившийся результат и оценивал баланс между сложностью и удобством поддержки.

И всё получилось! После множества итераций благодаря только одному этому способу я получил ускорение примерно на 20%.

Пару раз мне пришлось доработать фаззер, когда LLM что-нибудь ломала, а фаззер этого не отлавливал. Из-за этого я придумал закон Мёрфи для вайб-оптимизации:

Когда LLM оптимизируют программу, то в пределе, если какая-то из частей системы не кодифицирована тестами, в ней обязательно появится баг.

Claude Code пишет на Python обработчик умножения

Сильного ускорения в некоторых программах на C можно добиться, реализовав в эмуляторе команду умножения. В базовом наборе команд RISC-V нет умножения; компилятор может обойти эту проблему, генерируя сложения и сдвиги.

Я передал эту задачу Claude Code, но она была объёмной. Нам нужно эмулировать 32-битное умножение в дополнительном коде странными 18-битными операциями в обратном коде. Даже при наличии фаззинг-тестирования, возможности трассировки исполнения программы, документации, высококачественного примера asm и множества параллельных попыток Claude Code всё равно терпел неудачу.

Тогда у меня появилась следующая идея: реализовать каждую и арифметических команд UNIVAC в виде функций Python. Затем попросить Claude Code написать программу на Python, эмулирующую 32-битное умножение с помощью этих функций, и дать ему фаззинг-тесты.

Логика была такой:

  • Claude Code больше знаком с Python.

  • Он может писать вложенные выражения вместо простых операторов asm.

  • Он может присваивать результаты переменным и писать вспомогательные функции.

  • Он может использовать стандартные методики отладки Python.

Разумеется, когда ему дали достаточно параллелизма и времени, он смог написать этот скрипт на Python!

Затем я дал ему задачу попроще: транслировать программу на Python в ассемблерный код UNIVAC. И это сработало!

Обработчик умножения — это 676 строк непостижимого ассемблерного кода UNIVAC, составляющего примерно 43% от всего эмулятора. Это огромный монстр, но он обеспечивает шестикратное ускорение программ, активно использующих умножение, например, для проверки простоты чисел и криптографии на эллиптических кривых, так что пока всё останется так.

Ускорение в тридцать раз

Благодаря всему этому время рендеринга кадра NES упало с примерно двадцати часов до примерно сорока минут (ускорение в тридцать раз!). Наконец-то скорость стала достаточной для запуска кода в музее во время обеденного перерыва.


Делимся хорошими новостями!

Примерно в это время мы с TheScienceElf написали Дуэйну, Биллу и Стивену о том, что нам удалось сделать. После нашего визита мы с ними особо не общались, а поскольку я уже потратил так много времени на этот проект, меня внезапно обеспокоило, что компьютер мог поломаться со времени нашей последней беседы.

Я отправил письмо, в котором рассказал о нашем эмуляторе UNIVAC 1219 на Rust, тулчейне C и о том, что мы можем запускать реальные программы. В конце я спросил, можно ли приехать в музей и проверить их.

Эта идея всем понравилась! Мы запланировали, что съездим в музей в январе.

За недели, предшествующие нашей поездке, Дуэйн сильно помог нам с техническими вопросами, ведь он больше двадцати пяти лет работал с UNIVAC. Он отвечал на вопросы о пограничных случаях обратного кода компьютера, настройке канала ввода-вывода, кодировке символов TTY, процессе загрузки и многом другом. Большое ему спасибо!


Первый визит в музей: отладка оборудования и код загрузки

Этот день наконец настал. Я, TheScienceElf, Стивен и Билл январским утром приехали в музей. Дуэйн был на созвоне с нами. Мы запустили UNIVAC, но возникла проблема: горел сигнал WAIT.

The UNIVAC indicator panels with one lamp lit on the channel 4 row.
Сигнал WAIT, включившийся из-за ложной активности в строке канала 4.

Когда горит сигнал WAIT, компьютер отказывается выполнять какие-либо команды. Очевидно, эта проблема известна давно; раньше её устраняли, дожидаясь, пока машина разогреется и он отключится. Прождав полчаса, мы не дождались отключения индикатора и потеряли надежду. Мы позвонили Дуэйну. Билл, Дуэйн и Стивен проверили по руководству цепи и решили полностью отключить канал ввода-вывода 4. И это сработало! Сигнал прерывания больше не горел! Мы думаем, что на канале 4 есть какое-то сбойное оборудование, вызывающее ложную активность, а потому и прерывания.

А теперь самое интересное: нам нужно было разобраться, как загружать наши программы. Обычно это работает так:

  1. Нужно вручную нажимать кнопки и рычаги на передней панели, чтобы запрограммировать около тридцати команд в память компьютера. Это загрузочная программа на бумажной ленте, способная загружать программу со считывателя бумажной ленты.

  2. Затем загрузить рулон ленты LECPAC в считыватель ленты. LECPAC — это вспомогательная программа, имеющая полезные функции отладки и загрузки программ.

  3. Понажимать кнопки и переключить рычаги, чтобы сконфигурировать LECPAC на чтение из канала 7 (канала последовательного ввода-вывода). Дуэйн проделал потрясающую работу по разработке проекта Teensy, преобразующего параллельный интерфейс ввода-вывода UNIVAC в последовательный, чтобы можно было подключать наши компьютеры и получать/передавать данные.

  4. Запустить загрузочную процедуру LECPAC для считывания программы из последовательного канала!

A Teensy-based RS-232 to parallel adapter.
Адаптер «ввод-вывод UNIVAC <-> последовательный порт» Дуэйна. Teensy даёт нам обычный последовательный порт для общения с UNIVAC через ноутбук.

Но на этапе 4 у нас возникла проблема: загружались только мусорные данные. Мы попробовали всевозможные замены USB-кабеля, последовательного кабеля и ноутбуков. Но данные не проходили.

Дуэйн отправил нам небольшую программу всего из восьми команд для отладки последовательного ввода. Мы ввели её вручную на передней панели. Программа ждала символа в последовательном канале и отображала результат в накопителе, значение которого затем отображалось индикаторами на передней панели.

С помощью этой программы поэкспериментировали с разными конфигурациями последовательного ввода, пока нам не удалось отправить букву «A» и получить в AL нужное значение. Так мы нашли нужную нам конфигурацию последовательного порта!

Для проверки процесса загрузки через последовательный порт с ноутбукам мы загрузили «Hunt the Wumpus» — заведомо рабочую программу, написанную Дуэйном. Она заработала! Но когда мы попытались загружать собственные программы из нашего тулчейна, они отказывались грузиться. Почему?

Мы выполнили diff наших ленточных файлов с файлом Wumpus и поняли, что начало нашего ленточного файла нужно заполнить нулями... по какой-то причине. После этого исправления наши программы начали успешно загружаться в память!

Наступил момент истины. Мы записали в регистр PC начальный адрес нашей программы «hello world» на C, нажали переключатель запуска и... ничего не произошло. По какой-то непонятной нам причине программа зависла. Мы загрузили другую нашу программу, она должна была вычислять пи, и запустили её. Вместо вывода пи она печатала случайную мусорную последовательность:

Teletype paper showing a line of random ASCII characters instead of the digits of pi.
Это явно непохоже на пи

При помощи рычагов на передней панели мы пошагово выполнили примерно двадцать команд и сравнили их с нашей трассировкой эмулятора. Всё выглядело нормально, но мы потратили весь день на оборудование и загрузчик, поэтому времени на поиск расхождений уже не было. При помощи LECPAC мы создали несколько дампов ядра для офлайн-анализа и на этом закончили.

Но и это было огромным успехом! Мы устранили проблему в железе и разобрались, как загружать наши программы. Когда мы вернёмся в следующий раз, останется лишь отладка ПО, а с этим я справляюсь лучше всего.


Фаззинг и трассировки, чтобы эмулятор соответствовал оборудованию

Мы запланировали следующую поездку спустя месяц. Как нам готовиться к следующему визиту? Что делать, если компьютер просто выдаёт мусор?

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

Фаззинг-программа, которая генерирует «отпечатки пальцев» команд

Я написал на языке ассемблера UNIVAC диагностическую программу, которая берёт каждую арифметическую команду (ADDALADDASUBALSUBA и так далее), прогоняет её сотни раз с псевдослучайными входными данными, накапливает результаты в хэш и выводит хэш на телетайп. Хэш будет отпечатками пальцев поведения команды. При запуске одной и той же программы в двух разных реализациях совпадающие хэши будут означать, что реализации согласованы. Если разница в хэшах есть, то где-то таится расхождение.

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

ADDAL: 614424 223254
ADDA: 020656 635560
ADDAB: 401323 107167
SUBAL: 633336 720540
SUBA: 235365 124723
...

Всё это замечательно, но что делать, когда отпечатки различаются? Почему они различаются? И при каких входных данных? Мы не знаем. И здесь нам на помощь приходит программный трассировщик:

Трассировщик для выполнения команд UNIVAC по отдельности

Это самая безумная программа из написанных мной: программный трассировщик на языке ассемблера UNIVAC, который выполняет команду за командой другой программы UNIVAC, между каждым этапом выводя полное состояние машины (PC, команда, AUALBSRICR) в последовательный порт. Идея заключается в том, чтобы сделать diff этой распечатки с эмулированной трассировкой, чтобы точно понять, когда и почему трассировки начинают различаться.

Поиск способов написания такого программного трассировщика — это крайне сложная задача. Если вкратце, трассировщик хранит собственный PC, указывающий на целевую программу. На каждом шаге он копирует текущую команду из целевой программы в собственную память. Он сохраняет полное состояние машины, исполняет скопированную команду, а затем снова сохраняет состояние и выводит результат. Некоторые команды, например, переходы, нужно изменить так, чтобы они указывали на обработчики трассировщика, но условие перехода всё равно проверяет сам CPU, поэтому трассировщик не реализует условную логику повторно.

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

PC     INSN   AU     AL     B      SR     ICR
050000 340007 000000 000000 000000 000000 000000
050007 507300 000000 000000 000000 000000 000000
050010 507200 000000 000000 000000 000000 000000
050011 701234 000000 000000 000000 000000 000000
050012 100001 000000 001234 000000 000000 000000
050013 440003 123456 001234 000000 000000 000000
050014 460003 123456 001234 000000 000000 000000
050015 120001 123456 001234 000000 000000 000000
050016 140006 123456 123456 000000 000000 000000
050017 507200 123456 123555 000000 000000 000000
050020 360144 123456 123555 000000 000000 000000
050021 420003 123456 123555 000144 000000 000000
050022 507203 123456 123555 000144 000000 000000
050023 360310 123456 123555 000141 000000 000003

На самом деле, ничто не мешает нам превратить это в интерактивную программу и таким образом подключить GDB к реальному оборудованию. При этом мы вполне сможем устанавливать контрольные точки, изучать память, изменять регистры, выполнять шаги и так далее.


Второй визит в музей: первые работающие программы

Спустя месяц после первой поездки мы вернулись в музей, захватив с собой гениальные отладочные программы. Нужно начать с доказательства работоспособности самых базовых примитивов. Сможем ли мы хотя бы правильно выводить в телетайп текст?

Мы начали с рукописной программы «HI» на языке ассемблера UNIVAC. Она заработала с первой попытки! Теперь настало время запуска программы для создания отпечатков пальцев команд. Телетайп начал выводить отпечатки, и мы, разумеется, обнаружили разницу с эмулятором! Четыре 36-битных команды сложения/вычитания выводили отличающиеся отпечатки.

Teletype output listing one opcode per line, each followed by an octal hash.

Я передал Claude Code отпечатки с реального оборудования и попросил его перебрать различные реализации руководства, пока мы не нашли что-то соответствующее.

После устранения этой разницы в эмуляторе мы запустили в нём ассемблерную программу вычисления пи. И она вывела тот же самый мусор, который мы видели на машине! Это значило, что наш эмулятор, вероятно, теперь точен. Я никогда не был столь счастлив от того, что вижу мусор!

Teletype paper above a laptop terminal, both showing the same garbage output from the pi program.

Далее мы исправили программу вычисления пи и эмулятор RISC-V, чтобы они работали с новой интерпретацией 36-битных операций.

…и после всего этого все наши программы заработали. Hello world, fizzbuzz, Oregon Trail, BASIC, Figlet, ELIZA. Солвер судоку, скомпилированный из OCaml при помощи C_of_ocaml. Шифрование AES. Бейсбол. Блекджек. Шифрователь и взломщик «Энигмы». Wordle. Всё работало! Нам даже больше не нужен был программный трассировщик!

Teletype paper showing HELLO WORLD followed by fizzbuzz output.
Hello world и fizzbuzz стали первыми программами на C, которые заработали на UNIVAC.
Teletype paper showing an ELIZA conversation about being sad that the snow hasn't melted.
Сеанс общения с ELIZA. «Пожалуйста, поясните свои мысли».
A BASIC prime sieve listing and its output on the teletype.
Интерактивный интерпретатор BASIC, выполняющий программу поиска простых чисел методом решета.

В обед мы запустили эмулятор NES. После возвращения мы с восторгом увидели первый графический кадр!

A teletype printout of an NES Pinball frame held next to a laptop showing the same frame.

Также мы воспользовались возможностью и сдампили в телетайп всю таблицу ASCII, чтобы изучить его набор символов:

An ASCII table printed on the teletype with decimal, octal, and glyph columns.

Эта поездка оказалась настолько успешной, что у нас даже осталось время попробовать достичь самой амбициозной цели всего проекта: сможет ли он хостить сервер Minecraft? Я привёз с собой proof of concept, который точно работал в эмуляторе.

A workbench with the UNIVAC teletype on the left, a Raspberry Pi in the middle, and a Mac laptop on the right.
Сетевая конфигурация: Raspberry Pi с запущенным PPPD-мостом между Mac и последовательным портом UNIVAC.

Мы добрались до PPP и TCP handshake, но нам не удалось передать данные из конца в конец.


Работа с сетью на UNIVAC

Изначально я мечтал о том, чтобы запустить на UNIVAC эмулятор NES. Но хоть мы и добились этого, я повысил ставки и решил попробовать захостить на компьютере сервер Minecraft. Пока это самая моя безумная идея, технически очень сложная; для её реализации понадобятся все знания и инструменты, которые мы приобрели за последние несколько месяцев.

Для меня важно, чтобы не было никакого жульничества, поэтому изложим наши цели:

  1. В нашем PoC мне достаточно, чтобы клиент Minecraft просто залогинился, то есть нужно реализовать только протокол логина Minecraft.

  2. Вся интересная логика должна происходить в UNIVAC, без хитростей.

Мы будем передавать IP-пакеты на UNIVAC через PPP, а сам UNIVAC будет реализовывать все протоколы PPP/IP/TCP/Minecraft. В моей конфигурации ноутбук Mac подключается к порту на Pi, который просто перенаправляет IP-пакеты через pppd по последовательному порту на UNIVAC. Я уверен, что Mac может сам напрямую выполнять pppd, но мне привычнее Linux, поэтому выбрал Pi в качестве промежуточного звена.

Но как UNIVAC может выполнять все эти протоколы? Я даже не уверен, что 90 КБ памяти достаточно для хранения кода полной реализации TCP, не говоря уже о её запуске.

Ключевая идея будет такой: выбросить из TCP всю обработку ошибок. Будем считать, что одновременно происходит только одно соединение, все пакеты приходят полностью и по порядку; тогда TCP внезапно оказывается крайне простым! Если превратить TCP в UDP, то его можно запустить на UNIVAC.

Реализация логина Minecraft позаимствована из bareiron. Соединив все части вместе, мы , вероятно, сможем залогиниться в мир без кубов, упадём и умрём, а потом отключимся.

Почему же это не сработало в наш предыдущий визит? Я предположил, что проблема заключалась в отбрасывании входящих пакетов компьютером UNIVAC из-за того, что он сам записывал исходящие пакеты. Это бы не было проблемой, если бы мы корректно реализовали всю обработку ошибок TCP, но мы этого не сделали, поэтому крайне важно, чтобы такого не происходило.

Для решения нам нужно закатать рукава и разобраться в функциях конкурентного ввода-вывода UNIVAC. Интерфейс ввода-вывода UNIVAC приблизительно напоминает DMA: оборудование записывает входящие байты в буфер памяти, который мы ему укажем. У интерфейса ввода-вывода есть режим «Continuous Data Mode» (CDM). Мы можем сконфигурировать CDM так, чтобы DMA перезагружался в начале буфера после его заполнения.

Это даёт нам примитив кольцевого буфера. Мы сможем отдельно отслеживать последний байт, который считали из нашей программы, и пока не будем выходить за размер буфера (4 КБ), то байты не будут теряться, даже если мы активно обрабатываем или отправляем данные на другом канале.


Селфи в технике overstrike на TTY

В промежутке между посещениями музея TheScienceElf работал над повышением точности эмулируемого TTY. Он отправил мне скриншот с корректной повторной печатью TTY над тем же символом в конце строки; точно такой же, которую мы видели в музее, когда забыли добавить в свой вывод символы новой строки:

The emulated TTY printing pi digits and overwriting itself when the line wraps.

У нас появилась идея, что если можно будет печатать по одному и тому же символу много раз, то мы получим ASCII-арт высокого разрешения. Чем больше контролируемых переменных, тем выше разрешение. (К сожалению, с этой идеей мы опоздали на пятьдесят лет).

Когда нам нужно перейти на модели TTY 35 на новую строку, нужно отправить символ возврата каретки (\r), за которым следует символ новой строки (\n). \r перемещает курсор до упора влево, а \n смещает курсор вниз на одну строку. Если отправить только \r, то можно будет снова печатать на той же строке, перекрывая то, что было напечатано ранее.

Я написал на Python скрипт, преобразующий изображение в строку символов для отправки на телетайп. Алгоритм был таким:

  1. Рендерим каждый печатный символ модели 35 в битовую карту покрытия чернилами.

  2. Разделяем целевое изображение на сетку ячеек, по одной на каждую позицию символа.

  3. Для каждой ячейки жадным образом выбираем символ, сильнее всего уменьшающий воспринимаемую погрешность. Повторяем это до получения нескольких максимальных значений на ячейку. Если чернила накладываются, то задаём темноту пикселя согласно закону Бугера — Ламберта — Бера (0,5 -> 0,75 -> 0,875). Распознанные на изображении края при вычислении погрешности весят больше.

  4. Распределяем оставшуюся погрешность по соседним ячейка при помощи диффузии Флойда — Стейнберга.

Присмотритесь к этому изображению. На нём видно, что несколько символов напечатано один поверх другого, повышая темноту и усиливая текстуру:

A close-up of the overstrike portrait showing individual character cells.

Третий визит в музей: Minecraft, веб-сервер и селфи

Во время нашего последнего визита в музей UNIVAC включился сразу, без аппаратных проблем. У меня сразу появились хорошие предчувствия о том, как пройдёт этот день.

Начали мы с пары экспериментов, которые привезли с собой:

  • Протестировали все случаи сложения/вычитания с -0 и +0. Так мы убедились, что UNIVAC отклоняется от типичной схемы обратного кода, нормализуя -0 в +0 на пути без переноса.

  • Выполнили проверку памяти, чтобы убедиться, то нам доступно ровно 40960 слов.

Затем мы запустили программы, которые привезли с собой: программы TheScienceElf вычисления пи и метода Эйлера, мою программу вычисления SHA-256 и программу Стивена Battleship; все они заработали.

Teletype paper showing the SHA-256 utility hashing three inputs.
Утилита SHA-256, хэширующая три блока входящих данных (HELLO WORLD, YAY, UNIVAC).

А потом настал момент истины: заработают ли наши изменения, внесённые в конкурентный ввод-вывод? Получится ли запустить Minecraft?

Как обычно, нужно было начинать с простого и создать наши примитивы. Я заранее подготовил простую тестовую программу, выводившую статистику кольцевого буфера в процессе своего выполнения, чтобы убедиться, что правильно понял руководство. И она действительно работала через последовательный порт именно так, как я ожидал. Указатель записи циклически перемещался по буферу.

Следующей мы протестировали программу веб-сервера, потому что она всё ещё проще, чем Minecraft. Я нервничал. Мы загрузили её и подключили PPP… Неудача! Мы не смогли подсоединиться. Я пал духом. Настало время обеда, поэтому мы вышли поесть и накидать идей. Но прежде, чем уйти я запустил программу ASCII-арта, которая по нашим прикидкам должна была печатать десятки минут. Результат выглядел великолепно!

A strip of teletype paper held up above the Model 35, showing an ASCII portrait of Nathan.

Когда мы вернулись, я перезагрузил веб-сервер, чтобы просто попробовать ещё раз... но всё заработало! Может, мы неправильно его сконфигурировали при первой попытке? Я без проблем мог общаться с ним через curl. Запустил браузер и…

Nathan giving a thumbs up next to the UNIVAC while holding a laptop with a webpage open.

Невероятно! Это доказывает, что PPP/IP/TCP работают через последовательный порт на UNIVAC, выдавая веб-страницу, которую я запросил со своего современного компьютера! Я не мог поверить своим глазам. (Не знаю, откуда появляется эта лишняя буква «H». Наверно, это как-то связано с тем, что Chrome выполняет дополнительный запрос favicon.ico. Понятия не имею).

И снова ещё один момент истины. Что насчёт Minecraft?

Мы загрузили программу, я запустил свой клиент Minecraft, указал IP-адрес UNIVAC и нажал на Connect. И, разумеется, мы залогинились с первого раза!

Я был вне себя от восторга. Все эти месяцы отладки и хитрых хаков вылились в то, что считалось невозможным (по крайней мере, так думал TheScienceElf).

Оставшуюся часть дня мы потратили на запуск программ, чтобы снять видео, и на празднование наших достижений за последние восемь месяцев.


Заключение

Ну и безумное приключение. Мои любимые проекты — те, в реализуемости которых не уверен, когда берёшься за них.

Мне нравится мысль о том, что всё проделанное нами технически было возможно шестьдесят лет назад. Представьте, что вы вернулись в прошлое и подкинули операторам бумажную ленту с этими программами. Они бы с ума сошли!

Огромное спасибо людям, благодаря которым это стало возможным: Дуэйну, Биллу, Стивену и TheScienceElf. Благодарю персонал VCF за то, что позволил нам приехать и замечательно поработать с компьютером! Это был потрясающий опыт.

Спасибо за чтение! Исходники выложены на Github.

Wile E. Coyote operating a UNIVAC in a Looney Tunes cartoon.
Я нажимаю кнопки, чтобы запустить сервер Minecraft на UNIVAC, 2026 год

Комментарии (4)


  1. Lev3250
    22.04.2026 21:43

    И всё это на компьютере из 1960-х с частотой 250 кГц и всего с 90 КБ ОЗУ. Ради такого я и живу!

    я искренне, без стёба, надеюсь, что чувак использовал такой оборот лишь ради красивого словца... Потому что жить ради запуска майнкрафта (да чего угодно!!!!!!) на оборудовании из прошлого века, это так себе цель. Хоть меня убейте и забаньте опять, но нафиг ради этого жить! Это прям стрём!

    Переводчику, как всегда, спасибо!


    1. yesus1707
      22.04.2026 21:43

      я искренне, без стёба, надеюсь, что ты написал комментарий лишь ради красивого словца... Потому что жить ради написания таких комментариев (да чего угодно!!!!!!) на подобных платформах, это так себе цель. Хоть меня убейте и забаньте опять, но нафиг ради этого жить! Это прям стрём!


  1. MasterMentor
    22.04.2026 21:43

    Да чувак - морж. Самое ценное в статье ссылка на доки для UNIVAC http://www.bitsavers.org/pdf/univac/military/1219/


  1. here-we-go-again
    22.04.2026 21:43

    Что-то не могу понять, что именно он запустил, что этот сервер “майнкрафт” делает с учетом ресурсов машины? Явно же не настоящий сервер.

    upd, посмотрел видео на его канале, этого не хватает в статье, сервер все что делает это отдает имя и пустой мир в который можно зайти и умирает от перегрузки через несколько секунд