Щупаем первый 8-битный процессор от Intel — 8008

Введение

После того как я собрал систему на самом первом процессоре от Intel (4004), логичным, в каком-то смысле, шагом было перейти к Intel 8008. Концепция проекта та же — компилируем ассемблерный код на обычном ПК, отправляем скомпилированный бинарник на системную плату через USB, а современный микроконтроллер (stm32) эмулирует ПЗУ и ОЗУ для реального 50-летнего процессора, вставленного в DIP-сокет.

Вполне возможно собрать систему на аутентичных микросхемах, но такое решение проигрывает в удобстве использования — вместо запуска одной команды на ПК нужно будет постоянно перепрограммировать ПЗУ. Да и для меня основной интерес представляет сам процессор, а не его обвязка.

Так же как и в случае с 4004, моя плата эмулирует максимально возможный объем памяти, который нативно адресуется процессором. В данном случае, это 16Кб с некоторыми нюансами (об этом отдельно расскажу ниже).

И, конечно же, было занятно сравнить 4004 и 8008 в небольшой нишевой задачке. Да, сравнение весьма условное и какие-либо выводы по нему сделать сложно, но всё равно результаты вышли интересными.

Записал коротенькое (10 минут) видео на несколько корявом английском, в котором рассказывается то же самое что и в этой статье, но голосом, и с дополнительными видеофрагментами, которые сложно включить в текст.

История процессора

Наверное, лучше всего историю возникновения Intel 8008 описал Ken Shirriff в статье про первые процессоры. Я лишь вскользь упомяну ключевые моменты. Изначально 8008 разрабатывался в качестве сердца для программируемого терминала Datapoint 2200. До начала 70х годов прошлого века в качестве процессора выступала целая плата (а то и не одна) с россыпью логики. Однако CTC (производитель вышеупомянутого терминала), планировали упаковать всю эту вычислительную логику в один чип. Для этого они связались с несколькими компаниями (в том числе и с Intel) и предоставили свой дизайн архитектуры, который укладывался в нужды их системы.

Datapoint 2200

Datapoint 2200

В итоге CTC отказались от услуг Intel, но 8008 всё равно увидел свет как микропроцессор общего назначения и выступает дедушкой всех современных x86 процессоров. Было разработано несколько других компьютеров на его базе — MCM70, Mark-8 или SCELBI, к примеру.

MCM70

MCM70

Я не зря не упоминал 4004 в этой исторической отсылке — несмотря на говорящее название, 8008 не имеет никакого отношения к первому процессору от Intel. Даже больше: они разрабатывались параллельно разными командами и была вероятность, что 8008 могли выпустить раньше чем 4004. Звучит странно, но это так.

Архитектуры процессоров настолько разная, что они даже относятся к разным семействам — модель фон Нейманна против чего-то похожего на Гарвардскую архитектуру.

Программная архитектура 8008

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

Архитектура фон Нейманна подразумевает то, что код и данные находятся в одном пространстве памяти. Поэтому 8008 не разделял ПЗУ и ОЗУ физически. Логика маршрутизации сигналов для памяти была вынесена на уровень обвязки. Обычно старшие биты адреса использовались для определения того, запрашивается ли ПЗУ или ОЗУ.

8008 это 8-битный процессор, но адресная шина у него 14-битная и таким образом суммарный объем ПЗУ и ОЗУ мог достигать 16 кибибайт. Увеличенная разрядность адреса еще и сказалась на том, что он перестал помещаться в один регистр (а адресация памяти у нас регистровая). Таким образом, из 6 регистров общего назначения, два (H и L) отводятся для косвенной адресации ОЗУ. Да, у нас есть еще дополнительный регистр — аккумулятор (A), но всё равно регистровый файл весьма мал и это накладывает ограничение на стиль программирования — мы не можем персистентно хранить переменные в регистрах и вынуждены постоянно дергать память.

Набор команд вполне типичен — загрузка данных в/из регистров и памяти, арифметические и логические операторы над аккумулятором, управление потоком выполнения программы, операции ввода-вывода.

Вот к примеру, код для тестирования ОЗУ:

write_incH: LHI 0x01
            LLI 0x00
write_incL: LMH
            INL
            LML
            INL
            JFZ write_incL
            INH
            LAH
            CPI 0x40
            JFZ write_incH
            LHI 0x01
read_incH:  LLI 0x00
read_incL:  LAM
            CPH
            JFZ error
            INL
            LAM
            CPL
            JFZ error
            INL
            JFZ read_incL
            INH
            LAH
            CPI 0x40
            JFZ read_incH
            LHI 0x00
error:      LAH
            OUT 8
            HLT

В первом цикле мы записываем известные значения в память по адресам от 0×100 до 0×4000, а во втором проверяем то, что записалось.

Синтаксис весьма прост:

  • LrI — записать число в регистр r

  • Lr1r2 — записать содержимое регистра r2 в r1

  • LMr — записать значение регистра r в память по адресу H * 0x100 + L

  • LrM — прочитать байт из ячейки памяти по адресу H * 0x100 + L в регистр r

  • INr — инкремент регистра

  • CPx — оператор сравнения аккумулятора с другим регистром или числом

  • JFZ — прыжок на адрес, если установлен флаг нуля после арифметической операции

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

Железо

Схема нашей платы тривиальна. Фактически есть 2 компонента — процессор и «чипсет», который тактирует 8008 и предоставляет доступ к ОЗУ/ПЗУ.

4f365883f7b1e7947cc929661640653f.png

Схемотехника отражает простоту системы — основная обвязка это согласование уровней сигналов. С ПК мы взимодействуем через USB интерфейс. С него же снимаем необходимые питания — 3.3 вольта для микроконтроллера и 14 вольт для 8008. В даташите прописано что мы должны подать два напряжения на процессор — положительные 5 вольт и отрицательные -9. Но так как нет рефернсной точки отсчета этих уровней (отдельный земляной контакт отсутствует), то мы можем использовать 0в и 14в.

352c659aea8bc50462025f2b66be9340.png

Форма сигналов

2acb7a1d1c75740c7da6195f2a950615.png

На этой схеме есть два интересных момента — двухфазный тактовый сигнал и упоминание каких-то сигналов, связанных со состоянием процессора. Что же это за состояние? Intel 8008 не имеет конвейера, и инструкции исполняются одна за другой, но при этом обработка каждой инструкции разбивается на несколько стадий.

Разные инструкции требуют разного числа обращений к памяти (ОЗУ и ПЗУ). Каждое такое обращение упаковано в так называемый машинный цикл — компактный конечный автомат из десятка состояний. Если упрощать, то сначала процессор на первых двух тактах отправляет адрес и тип операции на адресную шину, затем читает/пишет данные, а в конце опциональные такты отводятся на выполнение инструкции. Конечно же, есть множество нюансов, но их можно опустить для поверхностного обзора.

Если мы используем инструкцию, включающую в себя обращение к ОЗУ, то машинных циклов будет как минимум два — чтение инструкции из «ПЗУ» и затем запись или чтение в «ОЗУ». Собственно 8008 и выводит наружу информацию о внутреннем состоянии конечного автомата для того, чтобы внешняя логика знала как интерпретировать данные на шине. Если контроллер памяти видит сотояние T1, то он знает что на шине сейчас лежат первые 8 бит адреса. T2 — старшие 6 бит адреса и 2 бита под код операции: чтение первого байта инструкции из ПЗУ, чтение данных из ПЗУ/ОЗУ, запись данных в ОЗУ, операция ввода/вывода. Затем на T3 состояние шины зависит уже от кода операции, либо наш контроллер памяти выставляет на неё прочитанный байт, либо, наоборот, 8008 управляет шиной и отправляет данные на запись. Машинный цикл обязательно будет содержать такты с этими тремя состояниями, всё остальное опционально и зависит от инструкции.

25fdb452a5a9598da40cc471bb78dfce.png

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

Для максимальной производительности я задаю наибольшую разрешенную в спецификации частоту — 800 кГц. Но предельная частота имеет свои последствия — тайминги становятся весьма требовательными к скорости реакции stm32 микроконтроллера. На картинке ниже я отразил задержку между тем, когда процессор переходит в состояние X, и тем, когда новое состояние проявляется на сигнальных линиях. Зазор между зелёной и красной линиями это и есть тот максимальный временной промежуток, за который нам нужно успеть среагировать.

64fd811e51d448583d5dbb2f09b40e0c.png

Прошивка

Тактирование я организовал на двух встроенных таймерах, с отдачей ШИМ-сигнала. Но всё равно, для того, чтобы всё успеть на stm32, потребовалось немного схитрить. Во-первых, я отключил прерывания (в том числе и связанные с USB) и использую простой опрос GPIO, чтоб определить момент, когда мы можем прочитать корректное состояние процессора. И во-вторых, мы точно можем предсказать последовательность состояний в нашей системе. Всегда будет T1, T2, T3. На других платах это может быть не так, потому что после выставления адреса на такте T2, 8008 ждёт когда контроллер памяти будет готов, что в случае с медленной памятью может быть через пару машинных тактов. Но у нас-то задел на производительность! Поэтому RDY сигнал всегда выставляем в 1 и не задерживаем проц.

Основная логика прошивки умещается буквально в 50 строк:

static inline uint8_t waitForState() {
  // i8008 steps into new state after phi22 fall, but i8008 state lines
  // reflects that after some delay, so we need to wait a proper moment
  while (isPinLow(STM_OUT_PH2));

  // check phase of state (based on SYNC): if it is 1st or 2nd tick
  // we need 1st ph2 rise
  if (isPinLow(STM_IN_SYNC)) {
    while (isPinLow(STM_IN_SYNC));
    while (isPinLow(STM_OUT_PH2));
  }

  while (isPinLow(STM_OUT_PH1));

  return i8008_getState();
}

// run in the loop
void handleProcessorSignals() {
  uint8_t i8008State = waitForState();
  if (i8008State == I8008_STATE_STOPPED) {
    processorState = ProcessorShutdown;
    return;
  }

  statesCount++;
  while (isPinHigh(STM_OUT_PH1)); // wait for i8008 to put data onto bus
  switch (i8008State) {
    case I8008_STATE_T1:
      currentAddr = i8008_readDataBus();
      break;

    case I8008_STATE_T2: {
      uint8_t highAddr = i8008_readDataBus();
      currentAddr = ((highAddr & 0x3FU) << 8U) | currentAddr;
      currentCycle = highAddr >> 6U;
      if (currentCycle == I8008_CYCLE_PCI || currentCycle == I8008_CYCLE_PCR) {
        uint8_t currentData = memory[currentAddr];
        // send data to the bus at the very beginning of T3
        while (isPinLow(STM_OUT_PH1));
        i8008_writeData(currentData);
      }
      break;
    }

    case I8008_STATE_T3:
      if (currentCycle == I8008_CYCLE_PCW) {
        memory[currentAddr] = i8008_readDataBus();
      } else if (currentCycle == I8008_CYCLE_PCC) {
        processorIOBuffer[processorIOBufferLen] = currentAddr & 0xFF;
        processorIOBufferLen++;
      } else {
        i8008_freeDataBus();
      }
      break;

    default:
      break;
  }
}

Результаты

После отладки платы на простых тестовых программах, настало время для настоящей задачи. Конечно же, это было вычисление цифр числа π. В этот раз ударяться в глубокую оптимизацию кода не хотелось, поэтому реализовал простой краниковый алгоритм, который я уже описывал в одной из предыдущих статьях. 8008 не содержит инструкций умножения или деления, так что опять пришлось написать простые функции. Вот, например, умножение 16-битного числа на 8-битное:

%include "mul8x8.i8008"

# INPUT:
#   [B, A] - first term
#   C - second term
# OUTPUT:
#   [E, H, L] - product
mul16x8:
  LEB              # tmp = a1

  LDA
  CAL mul8x8       # [B, A] = a0 * b

  LLA              # r0 = low(a0 * b)
  LHB              # r1 = high(a0 * b)

  LDE
  CAL mul8x8       # [B, A] = a1 * b

  ADH
  LHA              # r1 = r1 + low(a1 * b)

  LEB              # r2 = high(a1 * b)
  RFC              # if (!carry)

  INE              # r2 = r2 + 1
  RET

Меня интересовало два результата — 255 цифр и 2048. Аналогичный краниковый алгоритм для 255 цифр я запускал на 4004. Но из-за ограничений по памяти вычислить больше цифр на Intel 4004 без смены алгоритма невозможно. Однако в прошлом году я весьма сильно оптимизировал код для Intel 4040, который вычисляет π с использованием идей Фабриса Белларда и можем использовать данные оттуда. В плане производительности 4040 практически не отличается от 4004 — есть небольшие дополнения системы команд, но они не настолько грандиозны. Само собой, не очень верно сравнивать 2 разных алгоритма на 2х разных процессорах с разными архитектурами и разной степенью оптимизации кода (программу для 4040 я весьма сильно проработал). Тем не менее, было любопытно глянуть на замеры.

Intel 4004, краниковый алгоритм, 255 цифр

03:31:05

Intel 8008, краниковый алгоритм, 255 цифр

01:03:10

Intel 4040, алгоритм Белларда, 2048 цифр

69:28:31

Intel 8008, краниковый алгоритм, 2048 цифр

79:20:53

Что можно сказать глядя на эту табличку? Только банальности — 8-битная архитектура более производительная по сравнению с 4-битной в операциях длинной арифметики; чем больше памяти, тем лучше; старые процессоры очень медленные.

Проект для KiCad, герберы отдельно, прошивку и управляющий софт можно найти здесь. А набор утилит для разработки под i8008 — тут.

PS: Проект несёт минимальную практическую пользу и делался только для фана.

© Habrahabr.ru