Введение

Я люблю вызовы - например, написать код в условиях ограниченных ресурсов: медленный процессор, странный набор инструкций, крохи памяти. У меня уже было несколько проектов такого рода - я запускал тяжелую вычислительную задачу на процессорах, которые уже разменяли пол-века: Intel 4004, Intel 4040 и Intel 8008. Очевидно, что на очереди Intel 8080!

В этой статье я опишу детали проекта по созданию системной платы с чипсетом на основе FPGA, на которой будет запущен Intel 8080A-1 на частоте выше 3Мгц. А также расскажу о том, как писать программы для этого процессора на C, и в финале покажу результаты бенчмарков - Dhrystone и CoreMark.

ожидание (реальность куда хуже)
ожидание (реальность куда хуже)

Телеграм-канал не веду, но более развернутое описание проекта (данная статья содержит примерно 30% материала) и косяков, которые я допустил, есть на YouTube:

Аппаратный дизайн системы

Упрощенная схема платы
Упрощенная схема платы

Очевидно, что схему проектируем, опираясь на спецификацию процессора:

  • В качестве питающего напряжения используются три уровня: +5V, -5V и +12V. Соответственно и управляющие сигналы тоже бывают +5V и +12V.

  • Целевая частота, на которой планируем запускать процессор - 3Мгц

  • Хотим предоставить максимальный объем памяти, который может быть адресован напрямую - 64КиБ

  • Для максимальной производительности мы не должны блокировать шину данных, когда читаем/пишем в ОЗУ. То есть, такие операции должны выполняться за 1 такт или быстрее.

С питанием всё понятно - рандомный буст-контроллер для +12V и ICL7660 для инвертирования +5V в -5V.

В качестве сердца системы я рассматривал вариант использования мощного микроконтроллера, а-ля stm32h7. Его производительности должно было бы хватить, но у меня есть в планах проекты плат для еще более высоких частот, и поэтому я выбрал FPGA как платформу для контроллера памяти. Хотя я не писал какой-либо осмысленный код под FPGA уже лет 20, но настало время освежить навыки.

Существует куча вариантов FPGA, но так как мне нужно было что-то попроще, чтоб не сильно накосячить, то требования к чипу были соответствующими:

  • Достаточно LUT'ов. Основной челлендж это код для 8080, а не эффективный синтез.

  • Flash/RAM должны быть интегрированы. Меньше микросхем на плате - меньше шанс ошибиться в разводке относительно высокоскоростных сигналов. Очевидно, что нужно RAM в достаточном объеме для 8080 (64КиБ).

  • Никаких BGA. Я до сих пор паяю вручную (и с помощью фена), поэтому BGA увеличивает сложность, особенно с учётом того, что и так весьма много мест, где система позволяет мне сделать что-то неправильное.

  • Можно залить bitstream с помощью подручных средств, без необходимости покупать программаторы от вендора за сотни денег, особенно с учётом того, что не факт, что я и дальше буду использовать то же семейство ПЛИС.

После изучения предложений на рынке, я остановился на Microchip IGLOO2. Не самый оптимальный выбор, но второй пункт про интегрированные Flash/RAM сильно ограничивал перечень возможных вариантов. Особенно настораживала странная среда разработки (Libero SoC). Но в итоге всё срослось как надо.

GPIO со стороны FPGA поддерживают напряжение в +3.3V. Значит нам нужно конвертировать их в +5V и +12V. Если с +5V проблем нет - существует много как однонаправленных, так и двунаправленных конверторов, то с +12V всё немного хитрее. Особенно если учесть, что на этом уровне 8080 ожидает увидеть тактовый сигнал (а его частота 3Мгц), то есть нам нужно весьма быстро переключать +3.3V в +12V. Я решил что с этой задачей неплохо справится высокоскоростной драйвер затвора (без доп транзистора) - токи небольшие. Однако изначально выбранный драйвер (2EDN752) перегревался через 10 секунд работы - видимо, 3Мгц было слишком круто для него. Пришлось поменять на пару UCC2751.

Нам нужно как-то инциализировать память для 8080. Хранить образ памяти в самой прошивке FPGA не хотелось, поэтому решил использовать USB интерфейс (тем более нам нужно питание) и передавать образ с ПК. Мало кто реализует USB устройство на FPGA, обычно ставят какой-то мост, который упаковывает данные с USB в более простой протокол - UART/SPI/I2C. В качестве такого моста, я использовал (сюрприз!) stm32. Почему? Хотел иметь запас прочности - если вдруг возникнет непредвиденная проблема, то возможно получится решить её программно с помощью микроконтроллера. Почти не пригодилось.

Оттрассировал в меру своих способностей (2 слоя, конечно, не айс, но вроде сильных шумов на осцилоскопе не видел).

Трассировка
Трассировка

Конечно, не обошлось без промашек (здесь их показывать я не буду), но фатальных недостатков не было (за исключением замены драйвера). Так что перезаказывать плату и перепаивать компоненты на новую ревизию я не хочу.

Программирование FPGA и микроконтроллера происходит через JTAG - отдельные коннекторы, ибо это домашняя плата и возня с цепочками JTAG смысла не имела. Я и так в какой-то момент погрузился слишком глубоко в SWD протокол, когда отлаживал причину отсутствия ответа от blue pill, которую я использовал для прошивки ПЛИС.

Кстати, через OpenOCD прошить Microchip IGLOO2 у меня не вышло, но зато получилось найти код от Microchip, который позволяет общаться с FPGA через JTAG. На его основе я написал простой софт для ПК, который через ft232h-адаптер заливал bitstream в flash-память. Единственно, это занимало 45 минут, и, когда уже мне надоело ждать по часу между итерациями, я перенёс код на blue pill, которая валялась рядом. И время заливки уменьшилось до 20 секунд, что вообще ни о чём, особенно учитывая скорость синтеза Verilog кода.

Gateware / Firmware / Software

После устранения всех очевидных проблем на уровне железа, настало время писать код. Причём разнообразный - управляющий код на ПК, который будет высылать образ RAM; код для stm32, соединяющий ПК и FPGA; основной Verilog-код для взимодействия с 8080; и сами программки для 8080, конечно же.

Устройство 8080

Прежде чем показывать куски кода, имеет смысл рассказать о нашем процессоре по-подробнее. В первую очередь, стоит отметить, что 16-битное пространство памяти не разделено на память команд и память данных. В системах на 8080, это разделение между RAM и ROM было исполнено на аппаратном уровне - какие-то адреса маршрутизировались в чипы ПЗУ, какие-то в ОЗУ. Поэтому нам достаточно просто предоставить 64 кибибайтный блоб, а софт под 8080 будет решать какую часть использовать под данные. Я добавил только небольшой штрих - аппаратный регистр, размещенный в памяти, который содержит текущий счетчик тактов. Да, 8080 имеет инструкции для взаимодействия с подсистемой ввода/вывода, но мне показалось удобнее сделать так, как сделал я.

Помимо очевидных шин адреса и данных, нам нужно работать еще с парочкой сигналов со стороны 8080.

тайминги сигналов
тайминги сигналов

Нас не особо интересует внутреннее устройство 8080, поэтому мы ориентируемся только на внешние сигналы. И сигнал SYNC означает начало цикла работы с шиной данных - чтение/запись/ввод/вывод. Спустя какое-то время после поднятия SYNC (я ориентируюсь на задний фронт второго тактового сигнала), мы можем прочитать адрес с шины адреса и тип операции с шины данных.

Сами данные на шине появляются позже - если это операция чтения или ввода с внешнего устройства, то процессор устанавливает DBIN в высокий уровень и ожидает данные на шине. Противоположная операция записи/вывода работает аналогично, но уже сигнал WR ставится в низкий уровень и на шине данных выставлятся байт для отправки в память или внешнему устройству.

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

Управляющая программа на ПК

Многого в плане управления нам не нужно - только отправить образ памяти и ребутнуть процессор. Дополнительно, я добавил парочку служебных команд дабы проверить работу встроенной eSRAM в ПЛИС.

const InCommandType = Object.freeze({ CmdAck: 0x01, CmdResult: 0x02, CmdPrintTime: 0x05 });
const OutCommandType = Object.freeze({ CmdWriteDump: 0x01, CmdWriteByte: 0x02, CmdReadByte: 0x03, CmdReset: 0x04 });

const sendCommand = (port, opcode, data) => new Promise((resolve, reject) => {
  const result = [];
  const writeResult = ({ resultByte }) => result.push(resultByte);
  eventBus.on('result', writeResult);

  eventBus.once('ack', () => {
    eventBus.off('result', writeResult);
    resolve(result);
  });

  port.write(Buffer.from([opcode, ...(data ?? [])]), (err) => err && reject(err));
});

const processInputCommand = (buf, offset, len) => {
  switch (buf[offset]) {
    case InCommandType.CmdAck:
      eventBus.emit('ack');
      return 1;

    case InCommandType.CmdResult:
      if (len - offset < 2) {
        return 0;
      }
      eventBus.emit('result', { resultByte: buf[offset + 1] });
      return 2;

    case InCommandType.CmdPrintTime:
      console.log(`\nCurrent time: ${Date.now()}ms\n`);
      return 1;

    default:
      process.stdout.write(String.fromCharCode(buf[offset]));
      return 1;
  }
};

FPGA так же пробрасывает данные, которые выплёвывает 8080 в порт вывода. Здесь пришлось использовать небольшой хак: вместо того, чтобы завести отдельный код команды вывода байта на терминал, я просто вывожу всё, что не подпадает под известные опкоды.

Зачем? Потому что у меня была программа, которая хотела вывести на консоль много данных, и делала это весьма шустро (каждые 450 тиков = 150мкс). А передача 2х байт по UART между FPGA и stm32 занимала 175мкс на скорости интерфейса в 115200. Буферы, конечно, имелись, но их размер был не бесконечный. Скорость увеличивать было страшно - UART заработал не сразу (показывает мой уровень, хех), и поэтому трогать его не хотелось. Тем более выводил я только печатные ascii-символы, которые не пересекаются с кодами команд, так что хак рабочий.

Возможно возникнет еще вопрос о предназначении CmdPrintTime. Ответ заключается в том, что целевая частота не ровна максимальным 3.125Мгц, а несколько меньше, и немного плавает. Поэтому я использовал время на хост-машине и количество выполненных тактов для вычисления реальной тактовой частоты.

Firmware для stm32

Микроконтроллер просто обеспечивал конвертацию данных USB интерфейса в UART до ПЛИС.

typedef struct {
  uint8_t buffer[TRANSFER_SIZE];
  volatile uint16_t writePtr;
  volatile uint16_t readPtr;
} RingBuffer;

static RingBuffer transferQueue = { .writePtr = 0, .readPtr = 0 };

void addDataToQueue(uint8_t data) {
  __disable_irq();
  transferQueue.buffer[transferQueue.writePtr] = data;
  transferQueue.writePtr = (transferQueue.writePtr + 1) % TRANSFER_SIZE;
  __enable_irq();
}

void flushData() {
  uint8_t transferChunk[TRANSFER_CHUNK_SIZE];

  // we don't want to disable IRQs for a long time, so at first we just copying chunk of queue into small buffer
  __disable_irq();
  uint16_t writePtr = transferQueue.writePtr, readPtr = transferQueue.readPtr;
  uint16_t transferSize = (writePtr >= readPtr) ? (writePtr - readPtr) : (TRANSFER_SIZE - readPtr);
  uint8_t chunkSize = (transferSize > TRANSFER_CHUNK_SIZE) ? TRANSFER_CHUNK_SIZE : transferSize;
  for (uint8_t i = 0; i < chunkSize; ++i) {
    transferChunk[i] = transferQueue.buffer[readPtr];
    readPtr = (readPtr + 1) % TRANSFER_SIZE;
  }
  transferQueue.readPtr = readPtr;
  __enable_irq();

  if (!chunkSize) {
    return;
  }

  while (CDC_Transmit_FS(transferChunk, chunkSize) != USBD_OK);
}

Обычный кольцевой буфер. Единственный нюанс в том, что передача данных на хост через USB выполнялась в главном цикле и не хотелось дергать CDC_Transmit_FS с отключенными прерываниями, поэтому копируем кусок кольцевого буфера во временную переменную, включаем прерывания и отправляем этот кусок на ПК.

Gateware для FPGA

Самая интересная часть это, конечно же, код для FPGA. Именно здесь происходит вся жара. Выбор ПЛИС диктовал среду разработки, поэтому пришлось адаптироваться. Итеративно я составил такую схему компонента верхнего уровня.

Чудесная диаграма из Libero SoC
Чудесная диаграма из Libero SoC

Голубенький прямоугольник это как раз подсистема встроенной RAM-памяти. Работает через AHB-шину, и лучше на сниженной тактовой частоте. Поэтому перед ней есть AHB-мастер и мой мультиплексер/контроллер, который согласовывает разные тактовые домены - 180Мгц основной частоты и 50Мгц для eSRAM.

Из служебных модулей можно еще упомянуть приснопамятный UART. Кроме самих модулей TX/RX я добавил мультиплексер (чтоб отправлять байтики с разных мест) и простенькую FIFO-очередь.

Обработкой команд с UART'a занимается нехитрая FSM. Ради примера покажу как исполняется команда на загрузку дампа памяти:

                // WRITE_DUMP 0x01 size1 size0 data[N] data[N-1] .... data[1] data[0]
                if (in_cmd_opcode == CMD_IN_WRITE_DUMP && in_byte_count == 2) begin
                    case (in_cmd_state)
                        CMD_WRITE_DUMP_STATE_READ_DATA: begin
                            if (uart_rx_valid) begin
                                in_cmd_value <= uart_rx_data;
                                in_cmd_addr <= in_cmd_addr - 1;
                                in_cmd_state <= CMD_WRITE_DUMP_STATE_WRITE_MEM;
                            end
                        end
                            
                        CMD_WRITE_DUMP_STATE_WRITE_MEM: begin
                            sram_data_reg <= in_cmd_value;
                            sram_write <= 1;
                            sram_read <= 0;
                            sram_addr_reg <= in_cmd_addr;
                            if (sram_busy == 0) begin
                                in_cmd_state <= CMD_WRITE_DUMP_STATE_WRITTEN;
                            end    
                        end
                            
                        CMD_WRITE_DUMP_STATE_WRITTEN: begin
                            sram_write <= 0;
                              
                            if (in_cmd_addr == 0)
                                in_state <= CMD_STATE_FINISHED;
                            else 
                                in_cmd_state <= CMD_WRITE_DUMP_STATE_READ_DATA;
                        end
                    endcase
                end

Взимодействие с 8080 начинается с генерации тактовых сигналов (их, кстати, два):

module i8080_clock( 
    input wire clk, // expects 184.333 Mhz, one tick ~ 5.43ns
    input wire rst,
    
    // to i8080
    output reg CLK1,
    output reg CLK2,
    output reg READY
);

    reg [5:0]  counter;
    
    // 0 .. 59 = 320ns period = 3.125Mhz
    always @(posedge clk) begin
        if (rst)
            counter <= 0;
        else if (counter == 6'd59)
            counter <= 0;
        else
            counter <= counter + 1;
    end
    
    // phi1 high at [0, 50ns] clock interval
    always @(posedge clk) begin
        if (rst)
            CLK1 <= 1;
        else
            CLK1 <= (counter < 6'd9); // ~49ns
    end
  
    // phi2 high at [60ns, 210ns] clock interval
    always @(posedge clk) begin
        if (rst)
            CLK2 <= 1;
        else
            CLK2 <= ((counter >= 6'd11) && (counter < 6'd39)); // ~59ns ... ~206ns
    end

    assign READY = 1;
    
endmodule

Не получилось абсолютно точно передать форму тактового сигнала для частоты в 3.125Мгц, но вышло достаточно близко. Всё равное выше 3Мгц. Приемлемо.

Наконец-то код самого контроллера памяти. Так как сигналы - внешние, то сначала запихиваем их в наш тактовый домен, через две триггера:

    // synchronization of external signals from i8080 via two flip-flops
    reg i8080_sync_1tick_before, i8080_sync_2tick_before;
    reg i8080_dbin_sync0, i8080_dbin_sync1;
    reg i8080_wr_sync0, i8080_wr_sync1;
    always @(posedge clk or posedge rst) begin
        if (rst) begin
            i8080_sync_1tick_before <= 0;
            i8080_sync_2tick_before <= 0;
            i8080_dbin_sync0 <= 0;
            i8080_dbin_sync1 <= 0;
            i8080_wr_sync0 <= 0;
            i8080_wr_sync1 <= 0;
        end else begin
            i8080_sync_1tick_before <= i8080_sync; 
            i8080_sync_2tick_before <= i8080_sync_1tick_before;
            i8080_dbin_sync0 <= i8080_dbin; 
            i8080_dbin_sync1 <= i8080_dbin_sync0;
            i8080_wr_sync0 <= i8080_wr; 
            i8080_wr_sync1 <= i8080_wr_sync0;
        end
    end

    wire i8080_sync_rise = i8080_sync_1tick_before && !i8080_sync_2tick_before;
    wire i8080_clk2_rise = i8080_clk2 && !clk2_sync;

Ловим поднятие SYNC сигнала, ждём когда сможем защёлкнуть статусный байт и адрес, и решаем что делать дальше - читать из памяти, писать в память или отправлять байт на ПК, чтоб вывести его на терминал:

                STATE_IDLE: begin
                    if (i8080_sync_rise)
                        state <= STATE_WAIT_STATUS;
                end
                
                STATE_WAIT_STATUS: begin
                    if (!clk2_sync) begin
                        i8080_status_latched <= i8080_data;
                        i8080_addr_latched <= i8080_addr;
                        state <= STATE_CHECK_STATUS;
                    end
                end
                
                STATE_CHECK_STATUS: begin
                    if (i8080_status_latched[3] == 1) // HLTA
                        state <= STATE_IDLE;
                    else if (i8080_status_latched[7] == 1) // memory read
                        state <= STATE_READ_SRAM;
                    else if (i8080_status_latched[1] == 0) // memory write or output
                        state <= STATE_LATCH_DATA_TO_WRITE;
                    else
                        state <= STATE_IDLE;
                end

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

                STATE_READ_SRAM: begin
                    if (i8080_addr_latched == 16'hF880 || i8080_addr_latched == 16'hF881 || i8080_addr_latched == 16'hF882 || i8080_addr_latched == 16'hF883 || i8080_addr_latched == 16'hF884) begin
                        state <= STATE_LATCH_SRAM_DATA;
                    end else if (sram_busy == 0) begin
                        sram_read <= 1;
                        sram_req <= 1;
                        sram_write <= 0;
                        state <= STATE_LATCH_SRAM_DATA;
                    end   
                end
                
                STATE_LATCH_SRAM_DATA: begin
                    case (i8080_addr_latched)
                        16'hF880: begin
                            sram_data_latched <= clocks[7:0];
                            state <= STATE_SEND_DATA_TO_CPU;
                        end
                        
                        16'hF881: begin
                            sram_data_latched <= clocks[15:8];
                            state <= STATE_SEND_DATA_TO_CPU;
                        end
                        
                        16'hF882: begin
                            sram_data_latched <= clocks[23:16];
                            state <= STATE_SEND_DATA_TO_CPU;
                        end
                        
                        16'hF883: begin
                            sram_data_latched <= clocks[31:24];
                            state <= STATE_SEND_DATA_TO_CPU;
                        end
                        
                        16'hF884: begin
                            sram_data_latched <= clocks[39:32];
                            state <= STATE_SEND_DATA_TO_CPU;
                        end

                        default:
                            if (sram_busy == 0 && sram_valid == 1) begin
                                sram_read <= 0;
                                sram_req <= 0;
                                sram_data_latched <= sram_datain;
                                state <= STATE_SEND_DATA_TO_CPU;
                            end  
                    endcase   
                end
                
                STATE_SEND_DATA_TO_CPU: begin
                    if (i8080_dbin_sync1) begin
                        data_output_enable <= 1;
                        state <= STATE_FREE_DATA_BUS;
                    end
                end
                
                STATE_FREE_DATA_BUS: begin
                    if (!i8080_dbin_sync1) begin
                        data_output_enable <= 0;
                        state <= STATE_IDLE;
                    end
                end

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

Разработка для 8080

Компилятор

Прошлые разы я писал всё на ассемблере, но в этот раз я воспользовался C и тулкитом z88dk, который поддерживает 8080 в качестве целевой архитектуры.

Нужно просто добавить пару конфигов и можно компилировать сишный код!

#
# Target configuration file for z88dk, should be placed at z88dk\lib\config\
#
CRT0      DESTDIR\lib\target\8080\classic\8080_crt.asm
OPTIONS   -m -O2 -SO2 -M --list -subtype=default -clib=8080 -D__8080__
CLIB      8080 -Cc-standard-escape-chars -m8080 -l8080_opt -lndos -l8080_clib -startuplib=8080_crt0 -LDESTDIR\lib\clibs\8080
SUBTYPE   default -Cz+hex
; CRT for i8080-sbc, should be placed at z88dk\lib\target\8080\classic\8080_crt.asm

    module i8080_crt0

    defc    crt0 = 1
    INCLUDE "zcc_opt.def"

    EXTERN    _main           ;main() is always external to crt0 code
    EXTERN    asm_im1_handler

    PUBLIC    cleanup         ;jp'd to by exit()

IFNDEF CLIB_FGETC_CONS_DELAY
    defc CLIB_FGETC_CONS_DELAY = 150
ENDIF

    defc    TAR__clib_exit_stack_size = 4
    defc    TAR__register_sp = 0x0000
    defc    CRT_KEY_DEL = 12
    defc    __CPU_CLOCK = 3125000
    defc    CONSOLE_COLUMNS = 64
    defc    CONSOLE_ROWS = 32
    INCLUDE "crt/classic/crt_rules.inc"

    defc    CRT_ORG_CODE = 0x0000

    org	    CRT_ORG_CODE

if (ASMPC <> $0000)
    defs    CODE_ALIGNMENT_ERROR
endif

    jp      program
    INCLUDE	"crt/classic/crt_z80_rsts.asm"

program:
    INCLUDE "crt/classic/crt_init_sp.asm"
    INCLUDE "crt/classic/crt_init_atexit.asm"
    call    crt0_init_bss
    ld      hl,0
    add     hl,sp
    ld      (exitsp), hl
; Optional definition for auto MALLOC init
; it assumes we have free space between the end of
; the compiled program and the stack pointer
IF DEFINED_USING_amalloc
    INCLUDE "crt/classic/crt_init_amalloc.asm"
ENDIF
    push    bc	;argv
    push    bc	;argc
    call    _main
    pop     bc
    pop     bc
cleanup:
    call    crt0_exit

    INCLUDE "crt/classic/crt_terminate.inc"
    INCLUDE "crt/classic/crt_runtime_selection.asm"
    INCLUDE	"crt/classic/crt_section.asm"

Файл для инциализации CRT содержит пару важных магических констант: CRT_ORG_CODE задаёт адрес начала кода (у нас программа стартует с 0x0000) и TAR__register_sp которая содержит начальное значение указателя на стек. Не смущайтесь значению 0x0000 - процессор сначала декрементирует регистр, а только потом пишет в память. Поэтому первый push будет выполнен по адресу 0xFFFF, а затем стек будет расти вниз. Всё как надо.

Кроме того, нам нужен простейший модуль для ввода/вывода на экран (хотя бы чтоб printf работал):

fputc_cons_native:
_fputc_cons_native:
    pop     bc  ;return address
    pop     hl  ;character to print in l
    push    hl
    push    bc
    ld      a,l
    out     (1),a
    ret

fgetc_cons:
_fgetc_cons:
    ret
               

Просто отсылаем байт на устройство 1 с помощью инструкции out. FPGA определит что 8080 хочет отправить байт в подсистему ввода/вывода и перенаправит его через UART на ПК. А мы его уже покажем в нашей консоли.

Несмотря на то, что компуктер у нас 8-битный (если говорим об ALU), z88dk предоставляет математическую библиотеку для работы с 16/32/64-битными числами (числа с плавающей запятой не пробовал, но вроде как тоже есть поддержка):

    uint32_t carry = 0;

    uint16_t denominator = len - 1, numerator = (2 * len - 1), idx = 0;
    while (denominator > 0) {
      uint32_t x = ((uint32_t)A[idx]) * 10L + carry;
      carry = denominator * (x / numerator);
      A[idx] = x % numerator;
      denominator--;
      numerator -= 2;
      idx++;
    }

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

А вот пример того, как можно вывести текущее количество тактов:

void storeTime(uint8_t * dst) {
  *dst = *(uint8_t *)0xF880;
  *(dst + 1) = *(uint8_t *)0xF881;
  *(dst + 2) = *(uint8_t *)0xF882;
  *(dst + 3) = *(uint8_t *)0xF883;
  *(dst + 4) = *(uint8_t *)0xF884;
}

static char hex2char[16] = {
  '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
};

static printHex(uint8_t val) {
  fputc_cons(hex2char[val >> 4]);
  fputc_cons(hex2char[val & 0xF]);
}

void printTime(char * prefix, uint8_t * tm) {
  fputs(prefix, stdout);
  fputs(": ", stdout);
  printHex(*(tm + 4));
  printHex(*(tm + 3));
  printHex(*(tm + 2));
  printHex(*(tm + 1));
  printHex(*tm);
  fputs(" ticks\n", stdout);
}

Эмулятор

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

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

Dhrystone бенчмарк

После отладки тестовых программ, а-ля различных вариантов hello world, я наконец-то запустил реальный код на реальном 8080. Это весьма знаменитый в прошом бенчмарк - Dhrystone.

так себе результаты
так себе результаты

Если перевести такты в секунды (и учесть известную среднюю тактовую частоту процессора), то получится ~0.064 DMIPS, что не очень много. Для сравнения Raspberry Pi 3 выдаёт около 3500 DMIPS. Но из винтажных систем удалось опередить Apple IIe и крайне популярный в США Commondore 64. Что ж, хоть что-то.

CoreMark

Следующий на очереди более современный бенчмарк, который и сейчас используется для различных тестов процессоров/микроконтроллеров/ядер.

тоже не самые блестящие
тоже не самые блестящие

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

Заключение и ссылки

А как же моё любимое число π? Конечно же, я опять написал код для его вычисления! В этот раз с использованием алгоритма Чудновского и "быстрым" вычислением квадратного корня для длинной арифметики (не, это не метод Ньютона). Но это тема для другой статьи - более "математической".

Весь исходный код разбит на три репозитория:

  1. https://github.com/quasiengineer/i8080-sbc - схема платы, gerber-файлы, прошивки для stm32/fpga и управляющая программа для ПК.

  2. https://github.com/quasiengineer/i8080-emulator - эмулятор для 8080

  3. https://github.com/quasiengineer/i8080-benchmarks - набор программ, написанных для 8080

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


  1. Flammmable
    10.06.2025 10:15

    После изучения предложений на рынке, я остановился на Microchip IGLOO2. Не самый оптимальный выбор, но второй пункт про интегрированные Flash/RAM сильно ограничивал перечень возможных вариантов.

    Почему не MAX-10 от Altera или не MachXO2 от Lattice?


    1. mark_ablov Автор
      10.06.2025 10:15

      У MachXO2 слишком мало памяти (нужно 64Кб, а не 64 кбит). MAX-10 тоже смотрел - вполне укладывался в критерии, но не заметил особой разницы с Microsemi/Microchip, поэтому взял подошевле (подходящий вариант MAX10 что-то порядка 50 баксов, вместо 15 за Igloo2).


      1. Flammmable
        10.06.2025 10:15

        Взвешенный подход и хорошая статья :)
        Спасибо!


  1. Zara6502
    10.06.2025 10:15

    Для меня странно что i8080 обошёл 6502/6510, спросил у ИИ (можно перепроверить конечно), но по моим ощущением он ближе к правде:

    | Система     | Процессор       | Такт.частота | Dhrystones/s |
    |-------------|-----------------|--------------|--------------|
    | Apple II    | MOS Tech 6502   | 1 МГц        | 1500         |
    | Commodore 64| MOS Tech 6510   | 1 МГц        | 1700         |
    | Atari 130XE | MOS Tech 6502C  | 1,79 МГц     | 2000         |
    | Intel 8080  | Intel 8080      | 2 МГц        | 400          |


    1. mark_ablov Автор
      10.06.2025 10:15

      Не знаю, откуда данные у ChatGPT, но вот данные, или можно даже глянуть в сами исходники Dhrystone: https://github.com/ARM-software/workload-automation/blob/master/wa/workloads/dhrystone/src/dhrystone.c

       * MACHINE	MICROPROCESSOR	OPERATING	COMPILER	DHRYSTONES/SEC.
       * TYPE				SYSTEM				NO REG	REGS
       * --------------------------	------------	-----------	---------------
       * Apple IIe	65C02-1.02Mhz	DOS 3.3		Aztec CII v1.05i  37	  37
       * -		Z80-2.5Mhz	CPM-80 v2.2	Aztec CII v1.05g  91	  91
       * -		8086-8Mhz	RMX86 V6	Intel C-86 V2.0	 197	 203LM??

      37 dhrystones/sec для 65C02 ;)

      Нюанс может быть в том, что информация по 6502/6510 представлен для Dhrystone v1.1, а я запускал v2.1, но обычно очки в v2.1 ниже, то есть разрыв с 8080 может быть еще больше.

      Скорее всего дело в быстрой памяти. Я отдаю данные в тот же такт, а обычно в системах проц ждёт память.


      1. checkpoint
        10.06.2025 10:15

        37 dhrystones/sec для 65C02 ;)

        Не верно ты, дядь Фёдор, бутерброд ешь! :)

        В приведенной Вами таблице указаны результаты тестов на системе, а не "голого" процессора. Известно, что у Apple II видеоподсистема (как и у C64, или у Atari 800) останавливает центральный процессор при обновлении кадра, что занимает почти половину времени. Еще один момент состоит в том, что в Apple II использовалась динамическая память, которая требует циклов регенерации в процессе выполнения которых она недоступна и процессор простаивает в ожидании. Вы же проводите замеры чистого процессора без внешних раздражителей на статической памяти.

        Известно, что i8080 в среднем выполняет 0.125 млн операций в секунду на частоте 1 МГц. Для MOS 6502 этот показатель равен 0.45 млн, т.е. в три с половиной раза больше. Однако у i8080 больше регистров, но у 6502 первая страница памяти (256 байт) может использоваться как таблица указателей, что существенно ускоряет вычисления. В итоге выиграет тот бенчмарк, который более тщательно адаптирован под специфику процессора и конкретной вычислительной системы. :-)


        1. mark_ablov Автор
          10.06.2025 10:15

          В итоге выиграет тот бенчмарк, который более тщательно адаптирован под специфику процессора и конкретной вычислительной системы. :-)

          Скорее дело в компиляторе. Не зря CoreMark требует указывать компилятор и его флаги, если захочешь запостить результаты.

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

          Ага, указал на этот момент в родительском моменте. На i8080 системах проц тоже обычно ждёт память, что съедает такты.

          Не верно ты, дядь Фёдор, бутерброд ешь! :)

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


  1. spiteman
    10.06.2025 10:15

    Можно было и не тестировать, так как 8080 был близок (немного отставал) от Z80, а его мы знаем по многим проектам, но только ZX Spectrum.