Как написать процессор на verilog

Обновлено: 29.09.2022

На прошлых лабах мы уже полностью сделали процессор с выбранным подмножеством архитектуры MIPS на Verilog - cсылки на предыдущие части лабы:

1. Определение архитектуры процессора, язык ассемблер: Лабораторная работа 5: делаем процессор MIPS (1)

Реализация на Verilog:
2. Ключевые модули - файл регистров, память данных, память инструкций, счетчик программы: Лабораторная работа 5: делаем процессор MIPS (2),
3. Шина данных и контроллер: Лабораторная работа 5: делаем процессор MIPS (3) и
4. Реализация команд ассемблера, ядро MIPS и модуль верхнего уровня: Лабораторная работа 5: делаем процессор MIPS (4)
5. Подключение простых устройств ввода-вывода: Лабораторная работа 5: делаем процессор MIPS (5)

Весь Verilog код проекта:
mips.v - модули основной логики.
datamem.v - память данных.
instrmem.v - память инструкций.
mips_top.v - модуль верхнего уровня - для генерации файла прошивки ПЛИС и подключения устройств ввода-вывода.

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

7. Тестовые программы

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

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

/**
* Instruction memory - contains program instructions.
*/
module instrmem (
/* read address */
input [ 31 : 0 ] addr ,
/* instruction value */
output [ 31 : 0 ] instr ) ;

// раскомментировать нужный модуль для теста

//instrmem_test_7segment_draw_8 instrmem_program(addr, instr);
//instrmem_test_7segment_draw_5 instrmem_program(addr, instr);
//instrmem_test_beq_input instrmem_program(addr, instr);
//instrmem_test_sw_lw instrmem_program(addr, instr);
//instrmem_test_input_4bits instrmem_program(addr, instr);
instrmem_test_io_calc instrmem_program ( addr , instr ) ;
endmodule

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

Проверка "видео-памяти" - выводим цифру 8

Проверим, что все сегменты дисплея подключены - нарисуем цифру 8 с точкой. Отправляем b'11111111 в регистр $s0 при помощи операции addi (прибавляем константу в выставленными битами к нулю $0). Из $s0 отправляем в видео-память - sw в память данных по адресу 0x0000f000.

// put 11111111 value (8 with dot 'on') to s0 (switch on all display segments)
addi $s0 , $ 0 , b0000000011111111
// send value to video memory (7-segment display) device at 0xf000
sw $s0 , 0x0000f000 ( $ 0 )

module instrmem_test_7segment_draw_8 (
/* read address */
input [ 31 : 0 ] addr ,
/* instruction value */
output reg [ 31 : 0 ] instr ) ;

// hardcode program data - as soon as instruction memory is read-only,
// implement it in ROM-way
always @ ( addr )
case ( addr )
3 2'h00000000 : instr 3 2'h00000004 : instr

default : instr endcase
endmodule

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

Проверка "видео-памяти" - выводим цифру 5

Аналогичным образом рисуем цифру 5 без точки, чтобы убедитьсяв том, что у нас не просто горяд все сегменты из-за всех единиц на всех портах вывода, а мы этим процессом действительно управляем. Отправляем последовательность b'11100110 в видео-память аналогичным образом.

// put 11111111 (5 with dot off) value to s0 (switch on all display segments)
addi $s0 , $ 0 , b0000000011100110
// send value to video memory (7-segment display) device at 0xf000
sw $s0 , 0x0000f000 ( $ 0 )

module instrmem_test_7segment_draw_5 (
/* read address */
input [ 31 : 0 ] addr ,
/* instruction value */
output reg [ 31 : 0 ] instr ) ;

// hardcode program data - as soon as instruction memory is read-only,
// implement it in ROM-way
always @ ( addr )
case ( addr )
3 2'h00000000 : instr 3 2'h00000004 : instr

default : instr endcase
endmodule

Цифра 5, нарисована процессором:

Проверка ввода (1 бит - порт bsf) и условного перехода beq

Добавим немного динамики - проверим команду условного перехода beq и заодно ввода 1го бита информации рычажковым способом с порта bsf.

Смысл программы - в бесконечном цикле загружаем текущее значение рычага bsf (всего возможно два варианта - 0 или 1) из памяти данных по адресу 0x0000f0008 в регистр $s0. Далее при помощи команды beq сравниваем его с нулем (регистр $0 - ноль, который всегда под рукой). Если $s0 равен нулю (рычаг выключен), переходим к рисованию на дисплее '0', иначе рисуем на дисплее '1'. Т.к. цикл бесконечный (в конце программы прыгаем на начало при помощи команды j), можно переключать рычажок bsf и значение на дисплее будет меняться 1/0/1/0/.

loop :
// load value from 1-bit input (button switch) device at 0xf008
lw $s0 , 0xf008 ( $ 0 )
// go to 'display_0' if input bit is 0
beq $s0 , $ 0 , display_0 // 0x0014
display_1 :
// display "1" on 7-segment display (if input is 1)
addi $s1 , $ 0 , b00011100
sw $s1 , 0xf000 ( $ 0 )
// jump to beginning
j loop // 0x0000
display_0 :
// display "0" on 7-segment display (if input is 0)
addi $s1 , $ 0 , b01111111
sw $s1 , 0xf000 ( $ 0 )
// jump to beginning
j loop // 0x0000

module instrmem_test_beq_input (
/* read address */
input [ 31 : 0 ] addr ,
/* instruction value */
output reg [ 31 : 0 ] instr ) ;

default : instr endcase
endmodule

Наблюдаем результат в динамике - рычажок bsf переключает значения на дисплее с 0 на 1 и обратно:

Проверка сохранения слова (sw) и загрузки слова (lw)

Проверим, что память данных реально может хранить произвольные значения и мы можем использовать ее например в качестве буфера, чтобы сохранять результаты вычислений, производимых при помощи регистров (внутренних регистров у процессора мало, а память большая). Запишем в регистр $s0 код для отображения на дисплее все той же цифры '5': b'11100110, далее сохраним это значение в память данных по адресу 0x0 (sw - store word). Далее загрузим значение из памяти данных по этому же адресу 0x0 в регистр $s1 (lw - load word), а из $s1 отправим его в видео-память привычным способом - сохранить слово в память данных на адрес 0x0000f000. Если все сработало правильно, те. нужное значение попало из регистра $s0 в регистр $s1 через промежуточную ячейку внутри памяти данных, дисплей отобразит цифру '5'.

module instrmem_test_sw_lw (
/* read address */
input [ 31 : 0 ] addr ,
/* instruction value */
output reg [ 31 : 0 ] instr ) ;

default : instr endcase
endmodule

Все та же цифра '5', только теперь ее рисует куда более сложная программа:

Проверка рычажкового способа ввода данных

Теперь проверим ввод с 4х рычажков bs. Загружаем текущее 4хбитное значение с устройства bs (прочитать память данных по адресу 0x0000f004) и сразу отправляем их как есть в видео-память (записать слово в память данных по адресу 0x0000f000). Младшие значащие 4 бита рычажков должны переключать 3 нижних сегмента дисплея и точку, т.к. они подключены к младшим 4м битам видео-памяти. Верхние сегменты дисплея должны быть погашены, т.к. все старшие биты слова bs выше 4го разряда равны нулю.

module instrmem_test_input_4bits (
/* read address */
input [ 31 : 0 ] addr ,
/* instruction value */
output reg [ 31 : 0 ] instr ) ;

default : instr endcase
endmodule

Результат в динамике - 4 рычажка включают и выключают 3 нижних сегмента 7мисегментного дисплея и точку.

Калькулятор

Ввести два числа, выставленных рычагами на плате, в 2 ячейки памяти, сложить их значения и показать результат на 7мисегментром дисплее. Если результат больше 9ти (те. число не умещается в 1 разряд дисплея), дисплей показывает код ошибки "E." (Error).

- Первое значение устанавливается на 4хбитных рычагах при значении 1битного рычага 1, затем вводится в память при переключении 1битного рычага из 1 в 0.
- После этого второе значение устанавливается на этих же 4хбитных рычагах при значении 1битного рычага 0, затем вводится в память при переключении 1битного рычага из 0 в 1.
- После этого выполняется сложение двух введенных значений.
- Результат сложения отображается на 7мисегментном дисплее.

Самая длинная из всех тестовых программ, хотя значительную её часть занимает драйвер дисплея, который умеет отображать на нем произвольное число от 0я до 9ти или E при выходе за эти границы, - его код однообразен и создан большей частью методом копи-пасты. В деталях реализации ассемблерной программы предлагается разобраться самостоятельно в качестве самостоятельного упражнения.

. // (опустим код вывода остальных цифр - он полностью аналогичен - полную версию см в исходном файле)

Хотим сложить числа 1 и 2 и увидеть на диплее результат. Стартовое положение рычаг bsf=1 (вкл) - ожидание ввода 1го слагаемого. Вводим 1е слагаемое - число 1 в двоичной форме - перемещаем рычажки bs в положение 0001. Переводим рычаг bsf=0 (выкл) - происходит ввод 1го слагаемого в память и одновременно начинается ожидание ввода 2го слагаемого. Вводим 2е слагаемое - число 2 в двоичной форме - рычажки bs=0010. Переводим рычаг bsf=1(вкл) - происходит ввод 2го числа, вычисляется сумма 1+2=3 и результат '3' отображается на дисплее. Т.к. мы опять оказались в исходном положении (bsf=1), можно повторить ввод 1го слагаемого с другим значением и далее по кругу.

Результат работы в динамике:

8. Лаба окончена

Ну вот, на этот раз действительно всё - процессор спроектирован, реализован на Verilog, запрограммирован асемблером и проверен на работоспособность на ПЛИС. Подведем промежуточные итоги по курсу лабораторных работ. Думаю, что можно считать, что все математики и программисты, ознакомившиеся с курсом - студенты группы Прикладная Математики, на которых этот курс был испытан в аудитории, те, кто прочитал отчеты по лабам в онлайне (если такие вообще есть) и я лично, - пропасть от физики к программированию пересекли. Т.к. начав с мигающих лампочек с батарейками и проводами на 1й лабе (она отвечала за физику) к пятой лабе добрались до собственной реализации подмножества процессора MIPS и даже сумели запрограммировать его программой на языке программирования ассемблер (вот собственно и программирование). В рамках курса для полноты картины планируется опубликовать отчет по финальной 6й лабораторной работе - знакомство с промышленной реализацией процессора MIPS на примере контроллера с PIC32.

По случаю окончания верилоговской части курса хочу провести небольшой опрос.

Помог ли вам курс лабораторных работ по ПЛИС и Verilog пересечь пропасть от физики к программированию?


В статье описан очередной примитивный процессор и ассемблер для него.
Вместо обычных RISC/СISC, процессор не обладает набором инструкций как таковым, есть только единственная инструкция копирования.

Подобные процессоры есть у Maxim серия MAXQ.

Для начала опишем ROM, память программ

двухпортовую RAM для памяти данных

и сам процессор

Ему, как минимум, нужен регистр счётчика команд, а также один вспомогательный регистр, ну и регистр IO порта, чтобы было что показать наружу из нашего процессора.

Счётчик команд и будет адресом для памяти программ.

Память программ удвоенной ширины содержит два адреса: куда и откуда скопировать данные в двухпортовой памяти данных.

Обозначим специальные адреса: счётчик команд, генератор констант, проверка на 0 (для условных переходов), операций сложения/вычитания и порт ввода-вывода, в данном случае пока только вывода.

Шины данных двух портов памяти не просто соединены между собой, а через мультиплексоры, которые и будут заодно выполнять роль АЛУ.

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

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

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

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

Генератор констант записывает в себя адрес, а не значение памяти по этому адресу.

Для безусловных переходов надо просто скопировать нужный адрес в reg_pc, а для условных переходов зарезервируем ещё один адрес TST, который превращает любое ненулевое значение в 1, и заодно увеличивает счётчик команд на 2 вместо 1 для пропуска следующей за ним команды, если результат не 0.

Вот собственно и весь процессор.

Assembler

Теперь напишем для него простую программу, которая просто выдаёт последовательно значения в порт, и останавливается на 5.

Писать ассемблер самому, даже такой простой (весь синтаксис A = B), было лень, поэтому вместо этого за основу был взят готовый язык Lua, который очень хорошо подходит для построения различных Domain Specific Language на его основе, заодно на халяву получим готовый Lua препроцессор.

Cначала объявление специальных адресов, запись в которые изменяет данные и переменная счётчика по адресу 7

Вместо макросов можно использовать обычные функции Lua, правда из-за того что метатаблица окружения _G была изменена для отлавливания присваиваний (см. ниже), заодно отвалились и глобальные переменные: объявление нелокальной переменной some_variable = 0xAA наш ассемблер посчитает "своим" и попробует разобрать, вместо этого для объявлений глобальной переменной препроцессора придётся использовать rawset(_G, some_variable, 0xAA), который не трогает метаметоды.

Метки будем обозначать словом label и строковыми константами, в Lua в случае единственного строкового аргумента у функции скобки можно опустить.

Обнулим счётчик и регистр порта:

В цикле загружаем константу 1, добавляем её к переменной счётчика и показываем в порт:

Добавляем недостающее до переполнения в 0 и, если там не ноль, переходим в начало, пропуская CG="exit", иначе заканчиваем в бесконечном цикле "exit".

А теперь и собственно сам ассемблер asm.lua, как положено в 20 строк:

В функцию mem (для объявления специальных адресов) надо бы ещё добавить автоматическое присвоение очередного свободного адреса, если таковой не указан в качестве аргумента.
А для меток надо бы сделать проверку на повторное объявление существующей метки

В Lua нет метаметода для присвоения, но есть метаметоды для индексации существующих значений и для добавления новых, в том числе и для таблицы глобального окружения _G.
Так как __newindex срабатывает только для несуществующих в таблице значений, то вместо добавления новых элементов в _G, надо их куда-то перепрятать, не добавляя в _G, и, соответственно, достать оттуда, когда к ним обратились через __index.

Если имя уже существует, то добавляем данную инструкцию к остальным.

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

Запустив lua53 test.lua > rom.txt (или онлайн) получим программу для процессора в машинных кодах.

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


Просимулировав с помощью iverilog -o test.vvp test.v откроем получившийся test.vcd в GTKWave:

порт считает до пяти, а потом процессор зацикливается.

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



Часть I
Часть II
Часть III
Часть IV
Часть V

Спроектируем Little Man Computer на языке Verilog.

Статья про LMC была на Хабре.

Online симулятор этого компьютера здесь.

Напишем модуль оперативной памяти RAM/ОЗУ, состоящий из четырех (N=2) четырёхбитных (M=4) слов. Данные загружаются в ОЗУ из data_in по адресу adr при нажатии на кнопку:

В качестве внешнего генератора подключим КМОП таймер 555 (работающий от 3.3V).
Подключим таймер 555 к счётчику, подключим счётчик к адресному входу ОЗУ:

Добавим в счетчик функцию загрузки.
Загрузка осуществляется командой Counter_load:


В отдельном модуле создаем 4bit'ный регистр (аккумулятор):

Добавим в общую схему аккумулятор Acc, мультиплексор MUX2 и сумматор sum.
Сумматор прибавляет к числу в аккумуляторе Acc числа из памяти.
На сигнальные входы мультиплексора подаются числа data_in и sum.
Далее число из мультиплексора MUX2 загружается в аккумулятор Acc:

Always @* — значит «всегда». Некоторые синтезаторы не понимают эту конструкцию. Мультиплексор можно написать и без Always @* (тут используется просто для примера).

Вычитание

Для того, чтобы произвести вычитание, надо представить вычитаемое число в дополнительном коде. Про сложение и вычитание двоичных чисел можно прочитать в учебнике «Цифорвая схемотехника и архитектура компьютера» (Дэвид М. Харрис и Сара Л. Харрис) в главе 1.4.6 Знак двоичных чисел

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

Заменим 2-входовой мультиплексор 4-входовым:

Подключим к аккумулятору устройство вывода (4bit'ный регистр), также подключим к аккумулятору 2 флага:

1. Флаг «Ноль» — это лог. элемент 4ИЛИ-НЕ. Флаг поднимается, если содержимое Асс равно нулю.

2. Флаг «Ноль или Положительное число» — это лог. элемент НЕ на старшем разряде 4-разрядного аккумулятора. Флаг поднимается, если содержимое Асс больше или равно нулю.

Здесь мы описали многовходовой вентиль ИЛИ-НЕ как ~(|Acc)
Также в языке Verilog поддерживается набор типов логических вентилей (Gate Types).

Для логических вентилей определены ключевые слова: and (И), nand (И-НЕ), or (ИЛИ), nor (ИЛИ-НЕ), xor (Исключающее ИЛИ), xnor (Исключающее ИЛИ-НЕ), buf (Буферный элемент), not (Отрицание, НЕ).

В Verilog при использовании вентилей необходимо задать входы и выходы элемента, а также (не обязательно) имя вентиля. Например, вентили and и or должны иметь один выход и два и более входов. Так, для вентиля nor имеем
nor name list_of_ arguments
nor mynor(out, in0, in1, in2, in3);

Добавим три команды

1. загрузка содержимого аккумулятора в устройство вывода data_out
2. загрузка адреса в счётчик, если поднят флаг «ноль» (JMP if Acc=0)
3. загрузка адреса в счётчик, если поднят флаг «ноль или положительное число» (JMP if Acc>=0)


Поместим команды и адреса в одно RAM/ОЗУ, а данные — в другое.

Схему можно скачать отсюда.

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

Вообще, загрузка числа в аккумулятор Асс должна производиться после переключения мультиплексора MUX (для команд ADD, SUB, LDA), по спаду тактового сигнала.

Т.о. в нашем компьютере следующая система команд

48х — ADD добавить число из ОЗУ к Асс
50х — SUB вычесть число, хранящееся в ОЗУ из Асс
80x — STA сохранить число из аккумулятора Асс в ОЗУ по адресу х
58х — LDA загрузить число из адреса х в Асс
04х — BRA безусловный переход в ячейку с адресом x
02х — BRZ переход в ячейку с адресом x, если Асс=0 (условный переход)
01x — BRP переход в ячейку с адресом x, если Асс>=0 (условный переход)
40х — INP загрузить число из data_input в Асс
20х — OUT загрузить число из Асс в data_out

Команды HLT у нас не будет.

Алгоритм работает так: сохраняем в память данных два числа из data_in. Вычитаем из второго числа первое:

  • если результат отрицательный, записываем первое число в Асс, записываем в data_out число из Асс;
  • если результат положительный, записываем второе число в Асс, записываем в data_out число из Асс.

В нашей системе команд этот алгоритм будет выглядеть так


Элемент НЕ на управляющем входе счётчика, необходимый для загрузки данных в счётчик — это такая особенность программы Logisim, в реальных схемах элемент НЕ на управляющем входе не требуется (по крайней мере я таких счётчиков не знаю).

Quartus II можно скачать с официального сайта.

При регистрации в разделе My Primary Job Function is* необходимо выбрать пункт Student.
Далее необходимо скачать драйвер для программатора (драйвер для usb-blaster'a можно установить из C:\altera\. \quartus\drivers\usb-blaster).

Продолжаем делать процессор MIPS на Verilog. В предыдущей части определились с понятием процессора, языка ассемблера и подмножеством архитектуры процессора, которое будет реализовано в рамках лабы. Теперь можно приступить непосредственно к созданию дизайна на HDL (Verilog). В конце лабы наш процессор, загруженный в память ПЛИС, будет аппаратно выполнять простую программу на ассемблере МИПС.

3. Основные модули дизайна HDL

Вспоминаем основные элементы, которые определяют текущее состояние системы - счетчик программы (program counter), файл регистров (register file), память инструкций (instruction memory) и память данных (data memory) - они и станут основными блоками дизайна - определим их в виде соответствующих модулей.

Далее эти модули "склеиваются" между собой центральной логикой работы процессора, в рамках которой реализованы механизмы разбора потока ассемблерных команд из памяти инструкций и логика их исполнения. Каждая команда в момент выполнения будет влиять на один или несколько перечисленных модулей одновременно - собственно в том, каким образом команда модифицирует счетчик программы, файл регистров и память данных, и заключается ее основной смысл. С точки зрения описания на языке HDL это могут быть один или несколько модулей, которые в простом случае будут представлять собой простой однотактовый процессор, а в сложном могут являться реализацией многоуровневого конвейера с нетривиальными оптимизациями типа предсказаний, многопоточности и т.п.

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

Чтобы оценить объем дизайна, можно сразу заглянуть в исходные файлы проекта:
mips.v - модули основной логики.
datamem.v - память данных.
instrmem.v - память инструкций (пусть объем файла не пугает - 99% строк в нем занимает двоичное представление тестовой программы).
mips_top.v - модуль верхнего уровня - для генерации файла прошивки ПЛИС и подключения устройств ввода-вывода.

Далее подробнее в описании каждого модуля.

Счетчик программы

Модуль счетчик программы (program counter) позволяет установить следующее значение счетчика программы на каждый такт синхросигнала.

На входе - 1бит на тактовый сигнал clk (Clock) и 32хбитное значение для следующего значения счетчика программы pc' (program counter next - pc_next). На выходе - текущее значение счетчика программы pc (program counter). Значение на выходе pc меняется на значение входа pc' на каждый такт сигнала clk.

Код на Verilog очевидно реализует именно эту простую логику.

/**
* Счетчик программы - переход на следующее значение на каждый такт.
*
* @param clk - тактовый сигнал clock
*
* @param pc_next - следующее значение для счетчика программы (program counter)
* @param pc - счетчик программы (program counter)
*/
module pc ( input clk ,
input [ 31 : 0 ] pc_next , output reg [ 31 : 0 ] pc ) ;

always @ ( posedge clk )
pc endmodule


Файл регистров

Модуль файл регистров (register file) хранит значения внутренних регистров процессора, позволяет получать 32хбитное значение регистра по 5тибитному адресу и делать запись 32хбитного значения в регистр по 5тибитному адресу.

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

Весь код модуля.

module regfile ( input clk ,
/* Чтение 2х регистров */
input [ 4 : 0 ] ra1 , input [ 4 : 0 ] ra2 ,
output [ 31 : 0 ] rd1 , output [ 31 : 0 ] rd2 ,

/* Запись в регистр */
input we , input [ 4 : 0 ] wa , input [ 31 : 0 ] wd ) ;

reg [ 31 : 0 ] rf [ 31 : 0 ] ;

always @ ( posedge clk )
if ( we ) rf [ wa ]
assign rd1 = ra1 ? rf [ ra1 ] : 0 ; // reg[0] is zero
assign rd2 = ra2 ? rf [ ra2 ] : 0 ; // reg[0] is zero
endmodule

Для операций чтения

Выходы:
rd1 - считываемые данные (read data) регистра-источника 1 - 32 бит
rd2 - считываемые данные (read data) регистра-источника 2 - 32 бит

Для операций записи - все входы:
clk - тактовый сигнал clock - запись в регистр-назначение осуществляется на каждый тактовый сигнал при включенном флаге we
we - флаг разрешения записи (write enabled)
wa - адрес записи (write address) регистра-назначения - 5 бит
wd - записываемые данные для (write data) регистра-назначения - 32 бит

module regfile ( input clk ,
/* Чтение 2х регистров */
input [ 4 : 0 ] ra1 , input [ 4 : 0 ] ra2 ,
output [ 31 : 0 ] rd1 , output [ 31 : 0 ] rd2 ,

/* Запись в регистр */
input we , input [ 4 : 0 ] wa , input [ 31 : 0 ] wd ) ;


Производим операцию записи данных wd (write data) в регистр по адресу wa (write address) на каждый такт сигнала clk (clock) если флаг we (write enabled) равен 1.


Производим операцию чтения из двух регистров - по адресам ra1 и ra2. Значения считываемых из регистров данных появляются на выходах модуля rd1 и rd2 в момент присвоения значений адресов входам ra1 и ra2 без участия тактового сигнала clock. Для регистра по адресу 0 всегда возвращается значение 0 (см список регистров с ролями - регистр $0 - константа ноль).

assign rd1 = ra1 ? rf [ ra1 ] : 0 ; // reg[0] всегда ноль
assign rd2 = ra2 ? rf [ ra2 ] : 0 ; // reg[0] всегда ноль


Память данных

Модуль память данных (data memory) - RAM (random access memory) - запоминающее устройство с произвольным доступом на чтение и запись. Адресуем память 32хбитным указателем 2^32 байт=4096 Мегабайт. Память организована словами (блоками) по 4 байта, загрузка и сохранение осуществляется также словами по 4 байта. С 32хбитных адресом получаем 2^32 / 4 = 1'073'741'824 4хбайтовых слов виртуальной памяти в доступном адресном пространстве.

Код в большой степени аналогичен коду модуля файла регистров только с парой нюансов.

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

Замечание 2: Еще далее модуль будет дополнен расширениями для адресации устройств ввода-вывода.

/**
* Память данных.
*
* @param clk - clock
*
* @param we - флаг разрешения записи (write enabled)
* @param addr - адрес доступа на чтение/запись (address)
* @param wd - записываемые данные (write data)
* @param rd - считанные данные (read data)
*/
module datamem_plain ( input clk ,
input we , input [ 31 : 0 ] addr ,
input [ 31 : 0 ] wd ,
output [ 31 : 0 ] rd ) ;

// массив памяти данных
reg [ 31 : 0 ] RAM [ 63 : 0 ] ;

// запись данных в RAM если флаг we (write enabled) равен '1'
always @ ( posedge clk )
if ( we ) RAM [ addr [ 31 : 2 ] ]

// чтение данных из RAM
// выравнивание по словам (word aligned) - поделить адрес addr на 4
// (просто отбросить 2 последних бита)
assign rd = RAM [ addr [ 31 : 2 ] ] ;
endmodule

Массив слов 32xN - доступная физическая память. N - количество слов, выделенных под память; максимальное количество, которое можно адресовать в рамках 32хбитной архитектуры - 2^32 / 4 = 1'073'741'824, но т.к. на такой объем памяти нам никаких вентилей на ПЛИС не хватит, берем столько, сколько потребуется программе - в данном случае N=63 взято "с потолка" с очень большим запасом.

Чтение и запись данных в RAM. Запись на каждый такт clock, если флаг we (write enabled) равен 1. Чтение по факту присвоения нового значения входу с адресом addr. Важный нюанс при работе с адресом - выравнивание памяти по словам (word aligned): для обращения к 4хбайтовому слову по адресу байта адрес addr требуется поделить на 4 (просто отбросить 2 последних бита).

// запись данных в RAM если флаг we (write enabled) равен '1'
always @ ( posedge clk )
if ( we ) RAM [ addr [ 31 : 2 ] ]

// чтение данных из RAM
assign rd = RAM [ addr [ 31 : 2 ] ] ;


Память инструкций

Модуль память инструкций (instruction memory) - ROM (read-only memory) - память, доступная только для чтения. По логике адресации полная аналогия с памятью данных - 32хбитный адрес, выравнивание по 4 слова. Главное отличие от модуля памяти данных - отсутствие интерфейса записи.

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

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

Например для такой ассемблерной программы из 2х строк

module instrmem_test_7segment_draw_8 (
/* адрес чтения инструкции */
input [ 31 : 0 ] addr ,

/* значение инструкции */
output reg [ 31 : 0 ] instr ) ;

// hardcode program data - as soon as instruction memory is read-only,
// implement it in ROM-way
always @ ( addr )
case ( addr )
3 2'h00000000 : instr 3 2'h00000004 : instr default : instr endcase
endmodule

На входе:
addr - 32хбитный адрес инструкции - должен подключиться к текущему значению счетчика программы (program counter).

На выходе:
instr - 32хбитное значение инструкции - двоичное представление ассемблерной команды, на которую указывает адрес addr (или счетчик программы).

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

Продолжение следует. Осталось совсем немного - разобрать ключевой модуль с дизайном логики процессора datapath_and_controller, подключить простые устройства ввода-вывода и запустить на ПЛИС тестовые программы.

Итак, на прошлых лабах мы уже полностью сделали процессор с выбранным подмножеством архитектуры MIPS на Verilog - cсылки на предыдущие части лабы:

1. Определение архитектуры процессора, язык ассемблер: Лабораторная работа 5: делаем процессор MIPS (1)

Реализация на Verilog:
2. Ключевые модули - файл регистров, память данных, память инструкций, счетчик программы: Лабораторная работа 5: делаем процессор MIPS (2),
3. Шина данных и контроллер: Лабораторная работа 5: делаем процессор MIPS (3) и
4. Реализация команд ассемблера, ядро MIPS и модуль верхнего уровня: Лабораторная работа 5: делаем процессор MIPS (4)

Осталось провести его демонстрацию и тестовые испытания - для этого подключим базовые устройства ввода-вывода и запустим демонстрационные программы на ПЛИС.

6. Подключение устройств ввода-вывода и демо-запуск на ПЛИС

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

Как уже было заранее решено, в качестве устройства вывода используем уже знакомый любимый семисегментный диодный дисплей. Подключим к плате внешний дисплей к портам ввода-вывода общего назначения PIO. Всего для дисплея задействовано 8 портов (7 сегментов + 1 точка), т.е. 8мибитное двоичное число, направленное на эти порты, будет определять текущее значение, которое отображено на дисплее (каждый бит числа - один сегмент на дисплее) - далее будем называть его v (video memory - типа видео-память).

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

Из 8ми доступных рычажков сделаем 2 устройства ввода:
- 4 рычага bs (button switch) - для ввода чисел в двоичной форме - каждый рычаг определяет один бит 1/0 в двоичном числе (4 рычага - 4 бита - максимальное число, которое можно ввести таким образом - 2^4=16 - для наших тестов будет вполне достаточно, но при желании можно подключить и дополнительные рычажки) - каждый рычаг является значени.
- один рычаг bsf (button switch flag): однобитный флаг 1/0 или вкл/выкл - его можно использовать, когда например потребуется подтвеждение некоторого действия (типа кнопки Enter на клавиатуре).

Технические подробности подключения рычажков и дисплея к плате ПЛИС можно вспомнить из материалов предыдущих лабораторных работ Лабораторная работа2: знакомство с платой ПЛИС (FPGA) - основы комбинаторной логики (1) (работа с рычажками) и Лабораторная работа2: знакомство с платой ПЛИС (FPGA) - основы комбинаторной логики (2) (подключение дисплея).

Также как уже было определено в первой части лабораторной работы, наш процессор будет иметь возможность общаться с устройствами ввода-вывода через память данных через обычные операции чтения/записи lw/sw, т.е. наша задача сейчас правильно спроецировать порты ввода-вывода перечисленных устройств на адресное пространство памяти данных.

Там же было решено, что в нашем случае адреса устройств ввода-вывода внутри памяти данных будут начинаться с адреса 0x0000f000.


Видео-память 7мисегментный дисплей v

Видео-память 7мисегментный дисплей v подключим к слову в памяти данных по адресу 0x0000f000. Из всего 32хбитного слова к сегментам дисплея подключены только младшие 8 бит, остальные старшие 24 бит - просто всегда нули. Другими словами, единичка, записанная (при помощи операции sw) в один из 8ми младших битов слова в памяти данных по адресу 0x0000f000 будет зажигать соответствующий сегмент на дисплее; нолик, записанный в этот же бит - гасить.

Например, чтобы отобразить на дисплее цифру 5, сначала определим для нее последовательность включенных и выключенных сегментов дисплея (слева направо от 7го к 0му): v[7]=1, v[6]=1, v[5]=1, v[4]=0, v[3]=0 (точка выключена), v[2]=1, v[1]=1, v[=0] или в виде двоичного числа - 11100110. Теперь, если выполнить операцию sw (сохранить слово из регистра в память данных) с исходным значением в регистре b'00000000_00000000_00000000_11100110 в память данных по адресу 0x0000f000, на дисплее загорятся нужные сегменты и мы увидим цифру 5 (без точки).

Массив из 4х рычажков bs

Аналогичным образом массив из 4х рычажков bs подключен к слову в памяти данных по адресу 0x0000f004. Текущее положение внешних рычажков отражают 4 младшие бита, остальные старшие 28 - всегда нули. В данном случае устройство предназначено только для чтения - если в момент чтения слова по адресу 0x0000f004 из памяти данных (при помощи операции lw) определенный рычажок будет находиться в позиции "вкл", то соответствующий бит в считанном слове будет равен 1, если рычок в позиции "выкл", то 0.

Например, чтобы ввести число 5, сначала переводим его в двоичное представление: 5=b'101. Далее рычаги нужно перевести в следующие положения: bs[3]=выкл=0, bs[2]=вкл=1, bs[1]=выкл=0, bs[0]=вкл=1, те. выставить на них значение 0101. Теперь, если выполнить операцию lw (загрузить слово из памяти в регистр) из памяти по адресу 0x0000f0004, то в регистр будет записано двоичное значение: b'00000000_00000000_00000000_0000_0101 (в десятичном представлении это 5).

Однобитный флаг bsf

Однобитный флаг bsf подключен к слову по адресу 0x0000f0008. Все в точности так же, как для bs, только значащим является один младший бит, подключенный к единственному рычажку.

Дополняем модуль Память данных

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

/**
* Память данных. Устройства ввода-вывода спроецированы на
* адреса начиная с 0x0000f000.
*
* v (7мисегментынй дисплей "видео-память") подключен к слову по адресу 0x0000f000,
* bs (массив рычажковых переключателей - button switches) подключен к слову по адресу 0x0000f004,
* bsf (однобитный флаг - рычажковый переключатель) подключен к слову по адресу 0x0000f008.
*
* @param clk - clock
*
* @param we - флаг разрешения записи (write enabled)
* @param addr - адрес доступа на чтение/запись (address)
* @param wd - записываемые данные (write data)
* @param rd - считанные данные (read data)
*
* @param bs - устройство ввода рычажковый переключатель (button switch)
* @param bsf - устройство ввода однобитный флаг (buttons switch flag)
* @param v - устройство вывода 7мегментный дисплей ("video")
*/
module datamem ( input clk ,
input we , input [ 31 : 0 ] addr ,
input [ 31 : 0 ] wd ,
output [ 31 : 0 ] rd ,

/* Простой ввод/вывод */
input [ 31 : 0 ] bs , input bsf , output [ 31 : 0 ] v ) ;

// массив памяти данных
reg [ 31 : 0 ] RAM [ 63 : 0 ] ;

// работа с простым вводом/выводом - спроецировать некоторые адреса на внешние устройства:
// "видео-память" - значение для 7мисегментного дисплея
reg [ 31 : 0 ] video ;

// запись данных в RAM, если флаг we (write enabled) равен '1'
always @ ( posedge clk )
if ( we )
begin
if ( addr == 3 2'h0000f000 )
// запись в видео-память по адресу 0x0000f000
video else
// запись в память данных
RAM [ addr [ 31 : 2 ] ] end

// bs (массив рычажков - button switches), bsf (флаг button switch flag)
assign rd = addr == 3 2'h0000f004 ? bs : ( addr == 3 2'h0000f008 ? bsf : RAM [ addr [ 31 : 2 ] ] ) ;
// и v (7мисегментный дисплей "видео-память")
assign v = ~ video ; // инвертировать значение для дисплея с общим анодом
endmodule

Видим, что по сравнению со старой версией у нас появилось 3 новых параметра - по одному на каждое подключаемое устройство:

bs - устройство ввода рычажковый переключатель (button switch)
bsf - устройство ввода однобитный флаг (buttons switch flag)
v - устройство вывода 7мегментный дисплей ("video")


Заведем для видео-памяти специальный регистр, хотя в принципе можно было бы обойтись и основным массивом RAM.


Если в качестве адреса записи указан адрес видео-памяти 0x0000f000, на такт clock осуществляем запись в регистр видеопамяти, во всех остальных случаях записываем как обычно в RAM.

always @ ( posedge clk )
if ( we )
begin
if ( addr == 3 2'h0000f000 )
video else
RAM [ addr [ 31 : 2 ] ] end


И наконец подключаем значение внутреннего регистра видео-памяти к внешнему выходу, ведущему к 7мисегментному дисплею. Также инвертируем биты для дисплея с общим анодом (для дисплеев с общим катодом инверсию '~' делать не нужно).

Теперь любое значение, которое будет записано в память данных по адресу 0x0000f000, отправится в выход модуля v и далее на 7мисегментный дисплей. Видео подключено.

Подключаем массив рычажков bs и однобитный флаг bsf - все в одной строке вместе с чтением значения из обычной памяти данных RAM. Если адрес чтения равен 0x0000f0004, возвращаем результат чтения значение входа bs. Если адрес чтения равен 0x0000f0008, возвращаем результат чтения значение входа bsf. Во всех остальных случаях возвращаем значение из внутреннего массива RAM памяти данных.

Рычажки подключены тоже.

Дополняем модуль верхнего уровня

Теперь назначим на дисплей и рычажки уже конкретные порты ввода-вывода в модуле верхнего уровня и перейдем к тестированию дизайна на ПЛИС.

/**
* Модуль верхнего уровня для процессора MIPS - подключить clock
* и устройства ввода/вывода:
* 4+1 рычажка для ввода 4хбитных и однобитных значений
* 4+1 светодиодов для подстветки значений рычажков,
* 8 портов вывода для 7мисегментного дисплея.
*/
module mips_top ( input clk ,
output [ 7 : 0 ] v ,

input [ 3 : 0 ] bs ,
input bsf ,

output [ 3 : 0 ] ld,
output ldf ) ;

// Память инструкций
wire [ 31 : 0 ] pc ;
wire [ 31 : 0 ] instr ;
instrmem instrmem ( pc , instr ) ;

// Память данных
wire dmem_we ;
wire [ 31 : 0 ] dmem_addr ;
wire [ 31 : 0 ] dmem_wd ;
wire [ 31 : 0 ] dmem_rd ;

datamem dmem ( clk , dmem_we , dmem_addr , dmem_wd , dmem_rd ,
bs , bsf , v ) ;

// Ядро процессора MIPS
mips mips ( clk ,
pc , instr ,
dmem_we , dmem_addr , dmem_wd , dmem_rd ) ;

// используем светодиоды для подсветки значений рычажковых переключателей
assign ldf = bsf ;
assign ld = bs ;
endmodule

По сравнению с предыдущей версией появились новые параметры:

Все те же устройства ввода-вывода:
bs - устройство ввода рычажковый переключатель (button switch)
bsf - устройство ввода однобитный флаг (buttons switch flag)
v - устройство вывода 7мегментный дисплей ("video")

Плюс несколько необязательных диодных лампочек, которые мы просто подключим к рычажкам для большей наглядности (рычажок включен - лампочка горит, выключен - не горит):
ld - индикация для рычагов bs
ldf - индикация для рычага bsf

Просто передаем их (ввод-вывод без лампочек) в обновленный модуль память данных.

Замечание: При вводе данных с внешних устройств, особенно с кнопок и переключателей, может происходить такое явление как дребезг контактов (bounce). Это когда рычаг переключен один раз из положения 1 в положение 0, а на подключенном к нему порте ввода-вывода после этого за долю секунды значение сменилось с 1 на 0 и обратно несколько раз, хотя в конечном итоге все-таки остановилось на финальном положении 0. Во многих случаях этим эффектом можно пренебречь, т.к. он в конечном итоге может не повлиять на результат работы устройства. Но в некоторых случаях, особенно когда мы ловим и обрабатываем входящее значение несколько миллионов раз в секунду, он может вызвать слабопредсказуемые проблемы, поэтому для его подавления используются специальные механизмы - модули-фильтры-дебоунсеры (debounce). В данной лабе мы его рассматривать не будем - без дебоунсера будет работать большинство примеров, хотя некоторые непредсказуемые глюки могут возникнуть в последнем калькуляторе именно из-за дребезга контактов на рычаге-флаге bsf. Описание и реализация дебоунсера будут приведены в отдельном разделе за пределами процессорной лабы.

Подключаем лампочки к рычажкам для большей наглядности:

Назначаем параметры на адреса портов на плате - для продуктов Xilinx через специальны ucf-файл.

NET "ldf" LOC = "g1" ;

NET "bsf" LOC = "n3" ;

NET "v" LOC = "C6" ;
NET "v" LOC = "B6" ;
NET "v" LOC = "C5" ;
NET "v" LOC = "B7" ;

Устройства ввода-вывода подключены везде, где нужно. Выбираем нужный тест, генерируем прошивку и запускаем процессор с программой на ПЛИС. Подробные инструкции по геренации файла прошивки и запуску его на ПЛИС можно посмотреть в старой работе "Лабораторная работа2: знакомство с платой ПЛИС (FPGA) - основы комбинаторной логики (1)".

Читайте также: