Найти в Дзене
Loosegrasp

Процессор для Tang Nano 9K

Разработанная компанией Sipeed отладочная плата Tang Nano 9K была представлена в предыдущей статье. Здесь же я постараюсь по возможности вкратце изложить описанный в материалах компании Lushay Labs процесс создания ядра ЦП общего назначения. В оригинальном материале читателю предлагается выстроить архитектуру набора инструкций, ядро ​​ЦП и ассемблер. Чтобы сократить материал, мы не будем рассматривать модуль верхнего уровня, ассемблер и ту часть кода ядра, которая отвечает за вывод текста на OLED-дисплей. Мы вернемся к примеру со светодиодным счетчиком, с которого начали, но на этот раз реализуем его не аппаратно, а программно. Под процессором обычно подразумевают устройство, способное читать и выполнять записанные в виде программы команды. Иначе говоря, процессор реализует заданную архитектуру набора команд (instruction set architecture, ISA ). Для каждой команды определяется своя последовательность нулей и единиц, именуемая кодом команды (opcode, сокр. от operation code - код опера
Оглавление

Разработанная компанией Sipeed отладочная плата Tang Nano 9K была представлена в предыдущей статье. Здесь же я постараюсь по возможности вкратце изложить описанный в материалах компании Lushay Labs процесс создания ядра ЦП общего назначения. В оригинальном материале читателю предлагается выстроить архитектуру набора инструкций, ядро ​​ЦП и ассемблер. Чтобы сократить материал, мы не будем рассматривать модуль верхнего уровня, ассемблер и ту часть кода ядра, которая отвечает за вывод текста на OLED-дисплей. Мы вернемся к примеру со светодиодным счетчиком, с которого начали, но на этот раз реализуем его не аппаратно, а программно.

Под процессором обычно подразумевают устройство, способное читать и выполнять записанные в виде программы команды. Иначе говоря, процессор реализует заданную архитектуру набора команд (instruction set architecture, ISA ). Для каждой команды определяется своя последовательность нулей и единиц, именуемая кодом команды (opcode, сокр. от operation code - код операции, также известен как машинный код инструкции, instruction machine code, строка операции, opstring и т.д.), и процессор должен уметь декодировать эту последовательность, чтобы распознавать команды. Но человеку писать код в двоичном формате неудобно, поэтому так называемый ассемблер преобразует для процессора команды написанной человеком программы из текстового в двоичное представление.

Предположим, инструкция "очистить регистр A" (в регистрах временно хранятся данные) представляется процессору как 01100001, -- это и есть байт opcode. Но вместо того чтобы записать команду именно так, программист пишет ее на языке ассемблера, например CLR A, т.е. в текстовом представлении, которое ассемблер преобразует в вышеприведенный код операции. Обычно люди не пишут программы даже для ассемблера, а используют языки еще более высокого уровня абстракции, такие как C или Java для написания более краткого кода, который затем компилируется в код ассемблера и собирается им в двоичный код .

ISA

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

  • Способ работы с данными (загрузка данных и базовая арифметика)
  • Способ сохранения результатов вычислений (обычно -- в регистрах общего назначения)
  • Способ получения вводимых пользователем данных
  • Способ вывода данных пользователю
  • Способ проверки условия для реализации выбора той или иной ветви вычислений по типу if-else
  • Способ перехода из одного места программы в другое для организации циклических вычислений

Мы создадим простой 8-битный процессор, это означает, что его регистры и операции будут 8-битными, а команды будут использовать 8-битные параметры.

В нашем процессоре будет 4 регистра общего назначения, при этом условимся, что главный регистр называется AC, или аккумулятор (ACcumulator), и в нем будет по умолчанию сохраняться результат выполнения любой операции. Другие регистры просто получат букву в качестве своего имени, соответственно A, B и C.

Далее реализуем следующие операции:

  • Очистить регистр
  • Инвертировать регистр (т.е. заменить нули единицами и наоборот)
  • Добавить число в регистр
  • Сложить 2 регистра вместе

Это позволит нам выполнять сложение и умножение (поскольку умножение может быть реализовано как повторное сложение), а с помощью команды инвертирования мы можем реализовать вычитание с использованием дополнительного кода, а с помощью вычитания теоретически можно реализовать деление как повторное вычитание.

Таким образом, реализовав эти четыре, мы получаем базовые арифметические операции. Помимо этих четырех команд реализуем также некоторые другие, а именно:

  • Сохранение содержимого главного регистра AC в одном из других регистров
  • Выведение символа на экран пользователя
  • Установка значения для светодиодов
  • Способ проверки нажатия кнопки пользователем
  • Способ условного перехода между строками кода
  • Команда ожидать Х миллисекунд
  • Способ остановки выполнения кода

Все это взятое вместе дает нам хороший базовый набор команд для создания некоторых программ. Мы реализуем эти команды в виде восьми инструкций, где каждая инструкция может иметь 1 из 4 типов параметров.

; CLR A/B/BTN/AC
CLR A ; очистить регистр А
CLR B ; очистить регистр B
CLR BTN ; при нажатии кнопки очистить регистр аккумулятора АС
CLR AC ; очистить регистр аккумулятора АС
; STA A/B/C/LED
STA A ; поместить содержимое аккумулятора в регистр A
STA B ; поместить содержимое аккумулятора в регистр B
STA C ; поместить содержимое аккумулятора в регистр C
STA LED ; установить значения элементов LED (светодиоды) по младшим шести битам аккумулятора
; INV A/B/C/AC
INV A ; побитово инвертировать содержимое регистра a
INV B ; побитово инвертировать содержимое регистра b
INV C ; iпобитово инвертировать содержимое регистра c
INV AC ; побитово инвертировать содержимое аккумулятора
; HLT
HLT ; halt execution (stop program)
; ADD A/B/C/Constant
ADD A ; ac = ac + a
ADD B ; ac = ac + b
ADD C ; ac = ac + c
ADD 20 ; ac = ac + 20
; PRNT A/B/C/Constant (ac should have the screen char index)
PRNT A ; screen[ac] = a (a should be ascii value)
PRNT B ; screen[ac] = b (b should be ascii value)
PRNT C ; screen[ac] = c (c should be ascii value)
PRNT 110 ; screen[ac] = 110
; JMPZ A/B/C/Constant
JMPZ A ; go to line a in code if ac == 0
JMPZ B ; go to line b in code if ac == 0
JMPZ C ; go to line c in code if ac == 0
JMPZ 20 ; go to line 20 in code if ac == 0
; WAIT A/B/C/Constant
WAIT A ; wait a milliseconds
WAIT B ; wait b milliseconds
WAIT C ; wait c milliseconds
WAIT 100 ; wait 100 milliseconds

Итак, у нас есть 8 инструкций, большинство из которых имеют 4 вариации.

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

| 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 |

Здесь биты 1-4 обозначают вариацию, биты 5-7 обозначают команду, восьмой бит содержит признак наличия параметра-константы. Так, например, если первая команда — CLR, то ее четыре варианта будут такими:

CLR A ; 00001000 -> 0 000 1000
CLR B ; 00000100 -> 0 000 0100
CLR BTN ; 00000010 -> 0 000 0010
CLR AC ; 00000001 -> 0 000 0001

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

ADD A ; 00011000 -> 0 001 1000
ADD B ; 00010100 -> 0 001 0100
ADD C ; 00010010 -> 0 001 0010
ADD 20 ; 10010001 -> 1 001 0001

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

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

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

Спланировав набор инструкций, давайте рассмотрим некоторую теорию его реализации.

Ядро процессора

Реализация ядра, которое может обрабатывать набор инструкций, обычно выполняется с помощью конвейерной конструкции. (Не путать с конвейеризацией, которая распараллеливает конвейер выполнения!) Здесь мы подразумеваем сами шаги, необходимые для выполнения одной команды.

В нашем процессоре у нас будут следующие этапы конвейера (Pipeline):

  • Fetch — загрузка очередной команды из памяти
  • Decode — обработка/подготовка к вводу параметров команды
  • Retrieve — необязательное извлечение другого байта, если используется постоянный параметр
  • Execute — запуск команды после загрузки всего

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

Давайте рассмотрим, как мы реализуем эти этапы:

  • Этап выборки - Fetch
    Регистр, хранящий адрес нашего текущего местоположения в программе, обычно называют PC (program counter, перевод на русский «счетчик команд» не слишком удачен, поскольку в нем всего лишь хранится номер строки; есть еще вариант: Instruction Pointer, IP -- указатель команды, но и эта аббревиатура слишком часто интерпретируется по другому).
    На этапе выборки мы запросим байт по адресу, на который указывает регистр PC, этот байт, который мы получим, и является байтом opcode.
  • Этап декодирования - Decode
    Здесь мы немного обработаем считанный нами на этапе Fetch байт opcode. Проверим, нужно ли нам переходить к этапу извлечения (Retrieve), --это зависит от того, содержит ли единицу бит «Имеется постоянный параметр».
    В случае, если в следующем (считанном?) байте параметр отсутствует, он подготавливается уже на основе вариации.
  • Этап извлечения - Retrieve
    Если требуется считывание еще одного байта, то на этом этапе мы запросим этот следующий байт из памяти и сохраним его значение как параметр для текущей инструкции.
  • Этап выполнения - Execute
    Теперь у нас есть все, что требуется, и мы фактически выполняем нужную операцию. Для каждой инструкции это может немного отличаться, например, если в данный момент выполняется инструкция ADD B, то делается это так: ac <= ac + b.

    Реализовав эти четыре этапа, мы получим процессор, способный выполнять наш набор инструкций. Закончив с теорией, давайте, наконец, непосредственно перейдем к реализации.

Некоторые предварительные условия


Прежде чем мы начнем реализовывать наш модуль cpu на языке verilog, нам понадобятся модули screen.v и text.v, которые можно найти на странице
github здесь вместе со ссылками на статьи, где были реализованы эти модули).

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

Теперь, когда с этой настройкой покончено, мы можем приступить к сборке нашего процессорного модуля.

Реализация


Для начала давайте создадим файл cpu.v со следующим определением модуля:

module cpu(
input clk,

output reg [10:0] flashReadAddr = 0,
input [7:0] flashByteRead,
output reg enableFlash = 0,
input flashDataReady,

output reg [5:0] leds = 6'b111111,

output reg [7:0] cpuChar = 0,
output reg [5:0] cpuCharIndex = 0,
output reg writeScreen = 0,

input reset,
input btn
);

endmodule

Первый вход — это наш тактовый сигнал, далее следуют 4 порта, необходимые для управления флэш-модулем. Для взаимодействия с флэш-модулем нам нужно в flashReadAddr установить адрес, по которому мы хотим прочитать, затем установить enableFlash на высокий уровень ("1"), чтобы начать процесс чтения. Затем нам нужно дождаться, пока flashDataReady не станет высоким, что означает, что байт был прочитан, тогда мы можем взять его значение из flashByteRead.

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

Далее имеются 3 порта для вывода символов на экран. Работать это будет следующим образом: мы поместим символ ascii в cpuChar, а индекс символа на экране — в cpuCharIndex. Затем мы установим writeScreen на высокий уровень, чтобы запустить сохранение значения в памяти экрана для отображения.
Еще у нас будет 64-байтовый регистр, в котором мы будем хранить 64 символьных значения, и эти значения будут сопоставлены каждому из индексов экранных символов. Так, например, если мы установим символ 0 на «A», это будет означать, что первый символ (вверху слева) должен быть «A» и так далее.

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

Добавим некоторые определения (localparam):

localparam CMD_CLR = 0;
localparam CMD_ADD = 1;
localparam CMD_STA = 2;
localparam CMD_INV = 3;
localparam CMD_PRNT = 4;
localparam CMD_JMPZ = 5;
localparam CMD_WAIT = 6;
localparam CMD_HLT = 7;

Они определяют номера для каждой из наших 8 команд. Как мы говорили в разделе ISA, 3 бита будут определять, какая из 8 инструкций должна быть выбрана. Эти 3 бита и будут представлять одно из этих 8 определений localparam.

Далее нам понадобятся регистры:

reg [5:0] state = 0;
reg [10:0] pc = 0;
reg [7:0] a = 0, b = 0, c = 0, ac = 0;
reg [7:0] param = 0, command = 0;

reg [15:0] waitCounter = 0;


Во-первых, у нас есть регистр конечного автомата state, который реализует конвейер выполнения, о котором мы говорили выше. Во-вторых, у нас есть регистр для счетчика команд pc, который хранит, на какой строке мы сейчас находимся в коде/памяти, мы начинаем с адреса 0 флэш-памяти.

Далее у нас есть четыре основных регистра, используемых в нашем ISA: A, B, C и AC, каждый из которых имеет длину 8 бит.

Вдобавок имеются еще два регистра, один из которых хранит текущую команду, а другой — текущий параметр. Так, например, если наша команда — ADD C, то она будет сохранена в регистре command, а значение регистра c будет сохранено в param, а если текущая инструкция имеет постоянный параметр, то вместо с в param будет сохранен постоянный параметр.

Наконец, у нас есть регистр для команды WAIT. Эта команда должна ждать x миллисекунд, при 27 МГц, каждая миллисекунда составляет 27 000 тактов, поэтому у нас предусмотрен 16-битный регистр для подсчета 27 000 тактов, чтобы знать, что мы ждали 1 миллисекунду.

Теперь определим состояния конечного автомата нашего процессора:

localparam STATE_FETCH = 0;
localparam STATE_FETCH_WAIT_START = 1;
localparam STATE_FETCH_WAIT_DONE = 2;
localparam STATE_DECODE = 3;
localparam STATE_RETRIEVE = 4;
localparam STATE_RETRIEVE_WAIT_START = 5;
localparam STATE_RETRIEVE_WAIT_DONE = 6;
localparam STATE_EXECUTE = 7;
localparam STATE_HALT = 8;
localparam STATE_WAIT = 9;
localparam STATE_PRINT = 10;



У нас есть состояния для наших 4 этапов конвейера: выборка, декодирование, извлечение и выполнение. Команды, которые взаимодействуют с флэш-памятью, такие как выборка и извлечение, имеют 3 состояния: одно для инициализации операции чтения, одно для ожидания начала операции чтения флэш-памяти и одно для сохранения результата после завершения операции.

Причина, по которой это делается в 3 шага вместо 1 или 2, например, заключается в том, чтобы устранить дребезг флагов. Если мы немедленно проверим, высок ли флаг dataReady, мы можем случайно прочитать флаг dataReady предыдущей операции чтения и решить, что наши данные готовы. Вместо этого сначала дождавшись, пока флаг готовности данных станет низким, и только затем проверив, высок ли он, мы гарантируем, что он высок из нашей текущей операции.

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

Конечный автомат


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

always @(posedge clk) begin
if (reset) begin
pc <= 0;
a <= 0;
b <= 0;
c <= 0;
ac <= 0;
command <= 0;
param <= 0;
state <= STATE_FETCH;
enableFlash <= 0;
leds <= 6'b111111;
end
else begin
case(state)
// states here
endcase
end
end


Мы делаем сброс приоритетным, помещая весь конечный автомат в ветвь else.

Первое состояние — «Выборка», в котором нам нужно загрузить байт по адресу, указанному в регистре счетчика команд:

STATE_FETCH: begin
if (~enableFlash) begin
flashReadAddr <= pc;
enableFlash <= 1;
state <= STATE_FETCH_WAIT_START;
end
end
STATE_FETCH_WAIT_START: begin
if (~flashDataReady) begin
state <= STATE_FETCH_WAIT_DONE;
end
end
STATE_FETCH_WAIT_DONE: begin
if (flashDataReady) begin
command <= flashByteRead;
enableFlash <= 0;
state <= STATE_DECODE;
end
end


Эти три состояния взаимодействуют с модулем флэш-памяти для чтения байта. Первое состояние STATE_FETCH устанавливает нужный адрес по счетчику команд, высокий уровень на выводе enableFlash, затем мы переходим в состояние STATE_FETCH_WAIT_START, в котором ждем начала операции чтения.

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

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

STATE_DECODE: begin
pc <= pc + 1;
// command has constant param
if (command[7]) begin
state <= STATE_RETRIEVE;
end else begin
param <= command[3] ? a : command[2] ? b : command[1] ? c : ac;
state <= STATE_EXECUTE;
end
end

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

Если вы помните из нашей ISA, мы устанавливаем 8-й бит (индекс бита равен 7, при нумерации индекса от нуля до семи) высоким, если текущая инструкция требует загрузки постоянного параметра, поэтому это легко проверить, в этом случае мы перейдем к этапу извлечения. В случае же, если эта команда не имеет постоянного параметра, мы сохраняем один из других регистров в param на основе того, какой из 4 битов в инструкции установлен.

Таким образом, ADD A будет иметь установленный бит с индексом 3, тогда как ADD B будет иметь установленный бит с индексом 2. Не все команды имеют параметры, как HLT, или некоторые команды имеют другие параметры, как STA LED, где параметром является регистр светодиода. Но не помешает сохранить один из 4 регистров в param, поэтому проще просто делать это всегда, а не только при необходимости.

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

STATE_RETRIEVE: begin
if (~enableFlash) begin
flashReadAddr <= pc;
enableFlash <= 1;
state <= STATE_RETRIEVE_WAIT_START;
end
end
STATE_RETRIEVE_WAIT_START: begin
if (~flashDataReady) begin
state <= STATE_RETRIEVE_WAIT_DONE;
end
end
STATE_RETRIEVE_WAIT_DONE: begin
if (flashDataReady) begin
param <= flashByteRead;
enableFlash <= 0;
state <= STATE_EXECUTE;
pc <= pc + 1;
end
end

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

В состоянии STATE_RETRIEVE_WAIT_DONE мы сохраняем считанный байт в параметре и переходим на этап выполнения. Мы также снова увеличиваем счетчик команд, так как мы считываем еще один байт и должны перейти к следующему адресу байта, чтобы получить следующую инструкцию.

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

STATE_EXECUTE: begin
state <= STATE_FETCH;
case (command[6:4])
...
endcase
end

В этом состоянии у нас есть другой оператор case, где мы проверяем 3 бита, которые определяют, какую инструкцию мы в данный момент загрузили. В этом операторе case мы используем наши 8 определений localparam команд, которые мы задали выше.

Первая команда, которую мы реализуем, — это CLR:

CMD_CLR: begin
if (command[0])
ac <= 0;
else if (command[1])
ac <= btn ? 0 : (ac ? 1 : 0);
else if (command[2])
b <= 0;
else if (command[3])
a <= 0;
end

Здесь мы очищаем регистры, поэтому параметр нам не особо помогает, мы проходим по 4 битам, которые выбирают, над какой вариацией мы работаем, и выполняем соответствующее действие. Большинство вариаций просто устанавливают регистр в ноль, за исключением CLR BTN, который очищает ac только если в данный момент нажата кнопка пользователя, в противном случае он сохраняет текущее значение ac.

Следующая команда, которую мы реализуем, — ADD. Эта команда намного проще, поскольку значение всегда сохраняется в ac, а параметр уже сохраняется в param, даже в случае постоянного параметра благодаря этапу извлечения.

CMD_ADD: begin
ac <= ac + param;
end

Далее у нас есть команда STA, которая сохраняет регистр ac в регистр назначения на основе вариации:

CMD_STA: begin
if (command[0])
leds <= ~ac[5:0];
else if (command[1])
c <= ac;
else if (command[2])
b <= ac;
else if (command[3])
a <= ac;
end

Большинство из 4 вариаций просто сохраняют регистр ac в другой регистр, опять же, поскольку значение в каждой из операций равно ac, это не поможет нам сохранить его в param, поэтому нам нужно обработать каждую из 4 вариаций здесь. Первая вариация инвертирует значение и берет только нижние 6 бит, поскольку светодиоды активны на низком уровне, а у нас их всего 6.

Следующая инструкция — это инструкция INV, которая просто инвертирует биты одного из регистров:

CMD_INV: begin
if (command[0])
ac <= ~ac;
else if (command[1])
c <= ~c;
else if (command[2])
b <= ~b;
else if (command[3])
a <= ~a;
end

Здесь нечего особенно объяснять, каждая вариация обрабатывается так же, как и раньше, и каждая просто переворачивает биты регистра.

Следующая команда — это инструкция PRNT, которая обновляет память символов, отображаемую на экране.

CMD_PRNT: begin
cpuCharIndex <= ac[5:0];
cpuChar <= param;
writeScreen <= 1;
state <= STATE_PRINT;
end

Мы устанавливаем индекс символа экрана на нижние 6 бит ac (только нижние 6, так как есть только 64 позиции), а фактическое значение символа ascii сохраняется в param. Затем мы устанавливаем writeScreen на 1, чтобы запустить обновление памяти экрана, и переходим в состояние STATE_PRINT, чтобы получить дополнительный такт для этой инструкции, чтобы дать памяти экрана время записать изменения.

Следующая команда — JMPZ, которая переходит на другую строку кода, если регистр ac в данный момент равен 0.

CMD_JMPZ: begin
pc <= (ac == 8'd0) ? {3'b0,param} : pc;
end

Адрес, по которому мы хотим перейти, уже сохранен в param, поэтому здесь мы просто проверяем, равен ли ac нулю, в этом случае мы устанавливаем текущее значение pc на адрес, по которому мы хотим перейти. В противном случае мы сохраняем текущее значение pc, фактически ничего не делая.

Стоит отметить, что наш счетчик команд имеет длину 11 бит, а наши параметры имеют длину всего 8 бит, что означает, что хотя теоретически программы могут быть длиной 2048 строк (поскольку наш счетчик программ увеличится до этого значения, прежде чем вернуться к нулю), наша инструкция перехода может перейти только на адрес до строки 256. Это ограничение нашей ISA, которое нам придется обойти при проектировании наших программ.

Следующая инструкция, которую нам нужно реализовать, — это инструкция WAIT, в которой мы ждем x миллисекунд, где x — это значение, сохраненное в param

CMD_WAIT: begin
waitCounter <= 0;
state <= STATE_WAIT;
end

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

Последняя инструкция — это инструкция HLT, которая просто останавливает выполнение:

CMD_HLT: begin
state <= STATE_HALT;
end

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

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

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

STATE_PRINT: begin
writeScreen <= 0;
state <= STATE_FETCH;
end

Далее у нас есть состояние ожидания x миллисекунд:

STATE_WAIT: begin
if (waitCounter == 27000) begin
param <= param - 1;
waitCounter <= 0;
if (param == 0)
state <= STATE_FETCH;
end else
waitCounter <= waitCounter + 1;
end

Мы отсчитываем 27 000 тактов, что равно 1 миллисекунде, а затем уменьшаем param. Так что если param был 20, мы сделаем это 20 раз, по сути, ожидая 20 миллисекунд, а затем, когда param станет 0, мы вернемся к нашему обычному конвейеру.

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

STATE_HALT: begin
end

-2

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

Тестбенч


В
оригинальной статье далее предлагается создать модуль верхнего уровня, файл ограничений, написать программу для ассемблера на основе разработанного выше набора команд, написать сам ассемблер (!), сгенерировать бинарный код и битстрим, все это вместе загрузить во флеш-память платы Tang Nano 9K и наблюдать за светодиодами, как в предыдущей статье. Я же просто приведу здесь результаты тестирования самого модуля cpu.

Вот начало тестбенча:

`timescale 1ns/1ns
module cpu_tb;
// Test inputs
reg reset;
reg clk;
reg [7:0] flashByteRead;
reg flashDataReady;
reg btn;
// Test outputs
wire [10:0] flashReadAddr;
wire enableFlash;
wire [5:0] leds;
cpu dut(clk, flashReadAddr, flashByteRead, enableFlash, flashDataReady, leds, reset, btn);
initial begin
clk = 0; #10;
flashDataReady = 0 ; flashByteRead = 8'b00000000 ; reset = 0 ; btn = 0; #10
clk = 1; #10;
flashDataReady = 0 ; flashByteRead = 8'b00000000 ; #10
clk = 0; #10;
flashDataReady = 0 ; flashByteRead = 8'b00000001 ; #10
clk = 1; #10;
flashDataReady = 0 ; flashByteRead = 8'b00000001 ; #10
clk = 0; #10;
flashDataReady = 1 ; flashByteRead = 8'b00000001 ; #10
clk = 1; #10;
...

Далее в том же духе -- чередование установок и сбросов flashDataReady с помещением во flashByteRead строк бинарного кода программы счетчика counter.bin

Счетчик заработал!
Счетчик заработал!

Последовательно вводя коды команд во flashByteRead, на 40-41 тактах достигаем момента первого срабатывания счетчика. На приведенной ниже временной диаграмме этот момент обозначен линией визира, стрелка же мыши указывает на выход конечного автомата из состояния выполнения (EXECUTE).

Нулевой) светодиод сработал!
Нулевой) светодиод сработал!

Уверен, что дочитавшие статью до этого места способны проделать остальные шаги самостоятельно. Удачи!