Компьютеры обладают замечательным свойством: они способны моделировать широкий спектр других систем, включая самих себя! Но то, что они могут симулировать себя, не означает, что они должны это делать. Ни один компьютер не эффективен на 100%, поэтому моделируемый компьютер должен быть медленнее, чем компьютер, моделирующий его. Так зачем вообще симулировать компьютер? На самом деле довольно распространенной практикой является развертывание приложений на чем-то, что называется виртуальная машина. Виртуальная машина — это просто компьютер, моделируемый внутри другого компьютера. Причина этого в том, что некоторые программы совместимы только с определенными компьютерами. Некоторые системы не могут запускать определенное программное обеспечение в готовом виде, но путем имитации компьютера, на котором может работать это программное обеспечение, любой компьютер может запускать это программное обеспечение. Есть смысл? Если нет, то ничего страшного, потому что сегодня мы не собираемся собирать компьютер, чтобы запускать на нем устаревшее программное обеспечение или развертывать на нем наши приложения. Скорее, мы будем строить чрезвычайно простой компьютер, чтобы мы могли лучше понять, как и почему они работают на фундаментальном уровне.

В этой статье я расскажу о том, как собрать 8-битный компьютер с помощью кода Julia. Но еще раз, почему? Во-первых, думаю, будет весело и интересно. Но что более важно, это значительно упростит сборку моего 8-битного компьютера в Minecraft. Мы сможем спроектировать и запрограммировать наш 8-битный компьютер в гораздо менее ограниченной среде, чем Minecraft. Даже если вы не планируете следить за серией 8-битных компьютеров Minecraft, которую я делаю, сборка компьютера с помощью кода по-прежнему дает возможность узнать, как компьютеры на самом деле выполняют программы.

Дизайн

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

Автобус

В компьютере шина — это то, через что проходит вся информация. Он обеспечивает механизм передачи данных из одной части в другую и соединяет все вместе. Компьютеры хранят информацию в двоичном формате, который состоит из двух разных состояний, обычно обозначаемых либо 0, либо 1. Это связано с тем, что стандартный провод может одновременно занимать 2 разных состояния: положительный или отрицательный заряд. Очевидно, нам не нужно беспокоиться о токах и электрическом заряде в нашем компьютере, но нам все равно придется представлять информацию на нашей шине. В нашем моделировании шина сможет передавать 8-битную информацию, следовательно, наш компьютер является 8-битным.

Регистры

Регистр хранит число. Это число является размером шины, поэтому в нашем случае каждый регистр будет хранить 8-битное число. Каждый регистр подключен к шине таким образом, что он может считывать значение с шины и сохранять его или записывать сохраненное значение на шину, чтобы другой компонент мог получить доступ к его значению. В нашем компьютере у нас будет 4 разных регистра. Первые два — это регистры А и В. У них есть особая функция — они служат входами для арифметико-логического блока. Два других будут представлены позже.

Арифметико-логическое устройство

Эта часть компьютера занимается математикой. В нашем компьютере он сможет выполнять как сложение, так и вычитание, так как остальные операции можно реализовать программно, многократно используя эти две операции. В задаче на сложение или вычитание есть два входа и один выход. Два входа будут поданы в АЛУ из регистра А и регистра В. Выход АЛУ также будет подключен к шине, чтобы значение можно было переместить в другие части компьютера. Например, мы могли бы передать результат операции сложения обратно в регистр A и сохранить 1 в регистре B, и у нас была бы схема, которая считает, многократно добавляя 1 к числу.

Оперативная память

Оперативная память (чаще называемая ОЗУ) похожа на регистры в том, что она может хранить числа, но может хранить гораздо больше, чем 1. В нашем компьютере она сможет хранить только 16 различных чисел по причинам, которые я буду обсуждать. позже. Это достигается путем предоставления каждому из номеров, которые он хранит, другого адреса, аналогичного почтовому адресу, присвоенному зданию. Третий регистр в нашем компьютере — регистр адреса памяти (сокращенно MAR). Этот регистр сообщает ОЗУ, по какому адресу считывать или записывать число на шину или с нее. Оперативная память — это то, что будет хранить наши программы. Каждая последовательная строка нашей программы будет помещена в последовательные адреса памяти внутри нашей оперативной памяти.

Счетчик команд

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

Регистр инструкций

Это наш четвертый и последний регистр. В этом регистре будет храниться текущая строка кода, которую мы выполняем. Мы не можем просто выполнить наш код непосредственно из ОЗУ, потому что наш код может изменить содержимое нашей ОЗУ. Таким образом, мы должны хранить текущую строку кода внутри регистра инструкций. Однако, в отличие от других регистров, этот регистр может выводить только 4 из 8 бит, которые он хранит.

Декодер инструкций

Декодер инструкций подключен непосредственно к регистру инструкций. Каждая инструкция состоит из двух 4-битных частей. Первая 4-битная часть — это операция. Мне нравится думать об инструкции как о простом утверждении, говорящем компьютеру, что делать. Например, позже мы определим инструкцию LDA, которая берет значение из ОЗУ и сохраняет его в регистре А. Поскольку инструкционная часть строки кода занимает 4 бита, у нас может быть до 16 различных инструкций, которые может выполнять наш компьютер. Вторая половина 8-битной инструкции называется операндом. Это сообщает нашему компьютеру более подробную информацию о том, над чем на самом деле выполнять операцию. Например, операнд операции LDA сообщает компьютеру, какой адрес в ОЗУ хранить в регистре А. Вот почему наша оперативная память сможет хранить только 16 различных чисел или строк кода: потому что наши адреса могут иметь длину всего 4 бита. Есть способы обойти это ограничение, но для простоты я не буду рассматривать это ограничение. Если вы считаете небольшой размер ОЗУ потенциальной проблемой, не стесняйтесь расширять архитектуру моего компьютера до большего размера шины. 16-битный компьютер аналогичной конструкции может иметь 512 байт ОЗУ (2 байта на число, 256 чисел).

Схема конструкции

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

На этой диаграмме происходит довольно много всего, но самый важный вывод заключается в том, что все эти дискретные компоненты, о которых мы говорили в предыдущем разделе, подключены к шине в основном одинаковыми способами, и что декодер команд — это то, что на самом деле управляет управляющими сигналами. для каждого из этих компонентов. Сигнал управления — это просто сигнал, сообщающий компоненту, какое действие необходимо выполнить. Помните, как регистр может либо вводить, либо выводить значение из шины? Например, если мы хотим ввести данные в регистр A, мы должны включить управляющий сигнал AI (обозначает ввод регистра A). Если бы мы хотели вывести регистр A, мы бы включили управляющий сигнал AO. Каждый управляющий сигнал является своего рода вариантом входа или выхода определенного компонента, кроме одного, управляющего сигнала CE. Это можно найти в верхнем левом углу, подключенном к счетчику программ. Мы можем вводить или выводить на шину с помощью CI или CO, но управляющий сигнал CE означает «включение счетчика». Все это означает, что при наличии сигнала CE программный счетчик увеличивает содержащееся в нем значение на 1.

На данный момент мы знаем, что должен делать каждый из компонентов, так что теперь все, что осталось, — реализовать это в каком-нибудь коде Julia!

Выполнение

Прежде чем мы начнем, знайте, что весь код для этого проекта можно найти на https://github.com/ctrekker/8-bit-vm.

Компоненты

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

Различные компоненты, которые нам нужно реализовать, это шина, регистр, программный счетчик, АЛУ и ОЗУ. Каждый из наших компонентов может быть определен как экземпляр этих компонентов, за исключением декодера инструкций, который мы будем реализовывать для каждой архитектуры из-за его зависимости от состава компонентов.

Давайте взглянем на самый простой компонент: автобус

mutable struct Bus
    contents::UInt8
end
value(c::Bus) = c.contents
set_value(c::Bus, v::UInt8) = c.contents = v

Обратите внимание, что структура содержит только один тип UInt8, длина которого составляет 8 бит. Методы value и set_value будут определены для каждого компонента. Они значительно упростят написание абстрактного кода, в котором при подключении компонента не имеет значения, что это за компонент, если он может иметь значение.

Шина немного отличается тем, что она не связана с шиной. Для каждого другого компонента мы будем подтипировать наши структуры под абстрактным типом Component, который мы определяем ниже:

abstract type Component end

Далее у нас есть регистры для реализации. Они должны хранить значение и иметь возможность выводить это значение на шину.

mutable struct Register <: Component
    value::UInt8
    bus::Bus
end
value(c::Register) = c.value
set_value(c::Register, v::UInt8) = c.value = v

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

Счетчик программ снова похож на регистр, но имеет добавленный метод enable, который будет увеличивать его текущее значение на 1.

mutable struct ProgramCounter <: Component
    value::UInt8
    bus::Bus
end
value(c::ProgramCounter) = c.value
set_value(c::ProgramCounter, v::UInt8) = c.value = v
enable(c::ProgramCounter) = c.value += 1

Далее идет АЛУ. Этот компонент должен принимать два входа от регистров и одну шину для вывода.

mutable struct ALU <: Component
    in1::Register
    in2::Register
    bus::Bus
end
value(c::ALU) = value(c.in1) + value(c.in2)

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

Наконец, последний и один из самых важных компонентов — это оперативная память.

mutable struct RAM <: Component
    contents::Vector{UInt8}
    mar::Register
    bus::Bus

    RAM(mar::Register, bus::Bus) = new(zeros(UInt8, 16), mar, bus)
end
value(c::RAM) = c.contents[value(c.mar) << 4 >> 4 + 1]
set_value(c::RAM, v::UInt8) = c.contents[value(c.mar) << 4 >> 4 + 1] = v

Он принимает регистр, который будет использоваться для адресации его памяти, шину для вывода или ввода значений и вектор, содержащий список UInt8 значений, представляющих содержимое ОЗУ. Мы также определяем конструктор для структуры, потому что нам не нужно вручную инициализировать содержимое ОЗУ. Метод value берет младшие 4 бита регистра адреса памяти и индексирует с их помощью содержимое ОЗУ. Мы добавляем 1 к этому значению, потому что массивы Julia по умолчанию начинаются с индекса 1, а не с 0. Метод set_value выполняет аналогичную функцию, но устанавливает индексированное значение, а не возвращает его.

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

output(c::Component) = c.bus.contents = value(c)
input(c::Component) = set_value(c, c.bus.contents)

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

Компьютер

Теперь нам действительно нужно создать экземпляры этих структур и соединить их так, как это представлено на диаграмме. Это довольно интуитивно понятно, поскольку мы определили наши компоненты как отдельные структуры. Я начал с создания отдельного файла с именем computer.jl и включения components.jl в начале.

include("components.jl")
bus = Bus(0x0)
register_a = Register(0x0, bus)
register_b = Register(0x0, bus)
register_mar = Register(0x0, bus)
register_ir = Register(0x0, bus)
register_out = Register(0x0, bus)
alu = ALU(register_a, register_b, bus)
pc = ProgramCounter(0x00, bus)
ram = RAM(register_mar, bus)

Каждый компонент инициализируется 0, но на самом деле не имеет значения, какие это значения, за исключением счетчика программ. Мы начинаем с определения нашей шины, затем подключаем каждый из наших регистров к этой шине, а затем используем наши регистры для создания компонентов более высокого уровня АЛУ, счетчика программ и ОЗУ.

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

AO = () -> output(register_a)
AI = () -> input(register_a)
BO = () -> output(register_b)
BI = () -> input(register_b)
ΣO = () -> output(alu)
MI = () -> input(register_mar)
RO = () -> output(ram)
RI = () -> input(ram)
IO = () -> output(register_ir)
II = () -> input(register_ir)
CO = () -> output(pc)
CI = () -> input(pc)
CE = () -> enable(pc)
OI = () -> input(register_out)

Каждый из этих управляющих сигналов можно найти на схеме, и каждый из них представляет собой простую операцию input, output или, в случае программного счетчика, enable.

Далее мы можем начать создавать наш машинный язык, на котором мы можем писать программы. Машинный язык использует эти управляющие сигналы для создания инструкций более высокого уровня. Например, первая команда, которую мы определим, называется LDA. Это означает «Загрузить A» и загрузит значение из ОЗУ в регистр A. Первые 4 бита, представленные одной шестнадцатеричной цифрой, представляют операцию (например, LDA), которую мы выполняем. Вторые 4 бита — это операнд, который можно использовать для указания, где и над чем выполнять операцию. С технической точки зрения в нашей архитектуре операция занимает 4 самых значащих бита, а операнд — 4 младших значащих бита.

Итак, как мы можем сделать это, используя только наши управляющие сигналы? Ну, сначала нам нужно фактически загрузить инструкцию из ОЗУ в регистр инструкций, чтобы мы могли видеть, что текущая инструкция — это LDA. Для этого нам нужно получить текущую строку (адрес памяти) из счетчика программ и поместить ее в регистр адреса памяти. Это представлено выполнением управляющих сигналов CO и MI по порядку. Затем нам нужно взять текущее значение из ОЗУ по этому адресу и поместить его в регистр инструкций. Мы можем сделать это с управляющими сигналами RO и II. На этом этапе мы также должны увеличить счетчик программы для следующей инструкции, когда мы выполняем ее с помощью CE. После всего этого мы можем фактически выполнить нашу инструкцию. Мы видим, что первые 4 бита в регистре инструкций — это 0x0. Это означает, что наша инструкция является LDA. Это означает, что вторые 4 бита в регистре инструкций следует рассматривать как адрес памяти в ОЗУ, из которого нужно сохранить значение. Итак, сначала выполняются управляющие сигналы IO и MI. Затем мы берем значение из ОЗУ, к которому обращаемся, и помещаем его в регистр A с помощью RO и AI.

Довольно длительный процесс! Хорошей новостью является то, что первые 3 шага одинаковы для каждой отдельной инструкции. На самом деле мы можем сократить первые 3 шага до 2, просто увеличив счетчик каждого на втором шаге, а не на своем собственном шаге. Мы можем поместить это в код следующим образом:

# Standard operation
so = [
    [CO, MI],
    [RO, II, CE]    
]

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

instructions = [
    # 0x0 - LDA - Load A
    [
        [IO, MI],
        [RO, AI]
    ],
    # 0x1 - LDB - Load B
    [
        [IO, MI],
        [RO, BI]
    ],
    # 0x2 - STA - Store A
    [
        [IO, MI],
        [AO, RI]
    ],
    # 0x3 - STB - Store B
    [
        [IO, MI],
        [BO, RI]
    ],
    # 0x4 - ADD - Add to A register
    [
        [ΣO, AI]
    ],
    # 0x5 - ADDR - Add to RAM address
    [
        [IO, MI],
        [ΣO, RI]
    ],
    # 0x6 - JMP - Jump to address
    [
        [IO, CI]
    ],
    # 0x7 - OUT - Output A register
    [
        [AO, OI]
    ],
    # 0x8 - OUTR - Output from RAM address
    [
        [IO, MI],
        [RO, OI]
    ],
    [],
    [],
    [],
    [],
    [],
    [],
    # 0xf - HLT - Halt program execution
    [
    ]
]

Это много, но выбор, который я сделал здесь, в основном произвольный. Вы можете определить эти инструкции как хотите, но я просто выбрал эти инструкции, потому что считаю, что они делают возможным написание множества программ (но не всех, как мы узнаем позже). Как мы уже говорили, LDA загружает значение из ОЗУ в регистр A, а LDB делает то же самое, но для регистра B. STA и STB означают «сохранение A» или «сохранение b» и будут делать обратное, беря текущее значение в соответствующем регистре и сохраняя его по предоставленному адресу памяти. Сейчас самое время увидеть пример программы, которая будет вычислять последовательность Фибоначчи, поскольку может быть трудно увидеть, как все это сочетается друг с другом.

LDA 0x7
LDB 0x8
ADDR 0x7
STA 0x8
OUT
JMP 0x0

0x01
0x00

Сначала мы загружаем значение адреса 0x7 в регистр A. Наша программа определяет это (первая строка 0x0) как 0x01 в строке 8 (это адрес памяти 0x7). Затем мы загружаем значение по адресу 0x8 в регистр B. Затем мы складываем эти два числа и сохраняем их обратно по адресу 0x7. Затем мы берем значение регистра A и сохраняем это значение по адресу 0x8. По сути, это «сдвигает» значение регистра A к регистру B и устанавливает значение регистра A равным сумме адресов 0x7 и 0x8. Вы можете видеть, как этот набор операций приведет к тому, что регистр A будет содержать последовательность Фибоначчи после каждого выполнения этого набора инструкций, поскольку мы просто складываем результат двух предыдущих чисел и сохраняем его как следующий старший операнд. . Последние две вещи, которые мы делаем, это вывод регистра A, перемещая его содержимое в выходной регистр, и возвращаемся к адресу 0x0, чтобы начать процесс заново. И вот у нас есть программа, которая должна вычислить последовательность Фибоначчи на нашем компьютере.

К сожалению, наш компьютер не может прочитать красивый текст, который мы написали, поэтому сначала нам нужно скомпилировать наш ассемблерный код, написанный выше, в машинный код. Чтобы сделать это вручную, мы можем посмотреть, какое шестнадцатеричное значение представляет выполняемая нами операция, и объединить его с операндом. Скомпилированный вывод первой строки — 0x07, а второй строки — 0x18. Крайняя левая цифра представляет операцию, а крайняя правая цифра представляет операнд. Теперь я сделаю это для остальных:

0x07 0x18 0x57 0x28 0x70 0x60 0x00 0x01 0x00

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

function load(program::Vector{UInt8})
    if length(program) > 16
        @error "Cannot load program more than 16 bytes in length"
        return
    end
    reset()

    ram.contents = UInt8[program..., zeros(UInt8, 16 - length(program))...]
end

В нашем случае наша программа имеет длину всего 9 байт, поэтому она автоматически дополнит нашу программу 7 дополнительными нулями.

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

function hex(n)
    return "0x" * string(n; base=16)
end
function state()
    return """
        Register A: $(hex(value(register_a)))
        Register B: $(hex(value(register_b)))
        Register MAR: $(hex(value(register_mar)))
        Register IR: $(hex(value(register_ir)))
        Register OUT: $(hex(value(register_out)))
        Program Counter: $(hex(value(pc)))
    """
end

Это распечатает для нас регистр нашего компьютера и состояние счетчика программ, что может быть полезно при отладке. Теперь, после всего этого, мы можем написать код, который будет выполнять инструкции. Благодаря всей абстракции, которую мы сделали ранее, этот шаг очень прост. Все, что нам нужно сделать, это перебрать so и выполнить каждую функцию управляющего сигнала, затем найти соответствующую инструкцию в нашем векторе instructions и выполнить каждую функцию управляющего сигнала в этом векторе. В целом это выглядит так:

function step()
    for s ∈ so
        for f ∈ s
            f()
        end
    end
    op = value(register_ir) >> 4
    for s ∈ instructions[op+1]
        for f ∈ s
            f()
        end
    end
    op
end

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

function run_with_output(; steps=100, display_hex=true)
    c = (x) -> display_hex ? hex(x) : x
    
    println(c(value(register_out)))

    for i ∈ 1:steps
        op = step()
        if op == instruction_map["OUT"] || op == instruction_map["OUTR"]
            println(c(value(register_out)))
        end
    end
end

Откройте Julia REPL и включите computer.jl. Затем вызовите функцию load с вектором, содержащим наш скомпилированный код в качестве параметра. После этого просто вызовите run_with_output с display_hex установленным в false, и мы получим список первых чисел Фибоначчи!

include("computer.jl")
load([0x07, 0x18, 0x57, 0x28, 0x70, 0x60, 0x00, 0x01, 0x00])
run_with_output(; display_hex=false)

Но ждать? Некоторое время результат хороший, но затем он портится и начинает выдавать неправильные ответы!

0
1
1
2
3
5
8
13
21
34
55
89
144
233
121
98
219

Все сбивается с пути, когда он проходит 233. Это потому, что 144 + 233 равно 377, что выше нашего 8-битного максимума 255. Действительно, 377–256 равно 121, что означает, что первый бит нашего ответа был обрезан из-за переполнения. . Такова проблема 8-битного компьютера, и в результате программы, которые мы можем выполнять на компьютере такого размера, довольно малы. Но мы выполнили программу!

Последние мысли

Я большой сторонник экспериментов с вещами самостоятельно. Я думаю, что это самый верный способ узнать что-то новое, даже если вы точно не знаете, что вы узнаете. Я призываю вас скачать репозиторий этой статьи и попробовать написать программы для себя! Если вы обнаружите, что длина программы в 16 байт является помехой (в чем я бы не стал вас винить, если бы вы это сделали), попробуйте расширить компьютер до 16 или 32 бит. Кроме того, если сборка собственного 8-битного компьютера в реальной жизни кажется вам интересной, ознакомьтесь с серией статей Бена Итера 8-битный компьютер с нуля или следуйте моей серии Создание 8-битного компьютера с идентичной архитектурой. к той, которую мы построили сегодня внутри Minecraft, поскольку она намного дешевле и заставляет вас больше думать об эффективности, поскольку наносекунды в реальных схемах переводятся в секунды в схемах Minecraft.

Первоначально опубликовано на https://cotangent.dev 11 января 2021 г.