Курс лабораторных работ

Полезное

Порядок выполнения лабораторных работ для групп

ИБ, ИКТ, КТ, РТ

  1. Сумматор (01. Adder)
  2. АЛУ (02. Arithmetic-logic unit)
  3. Регистровый файл и внешняя память (03. Register file and memory)
  4. Простейшее программируемое устройство (04. Primitive programmable device)

ПИН, ПМ

  1. Сумматор (01. Adder)
  2. АЛУ (02. Arithmetic-logic unit)
  3. Регистровый файл и внешняя память (03. Register file and memory)
  4. Простейшее программируемое устройство (04. Primitive programmable device)
  5. Основной дешифратор (05. Main decoder)
    1. Тракт данных (06. Datapath)
    2. Интеграция блока загрузки и сохранения (09. LSU Integration)
    3. Интеграция подсистемы прерываний (11. Interrupt Integration)
  6. Периферийные устройства (13. Peripheral units)
  7. Программирование (14. Programming)

ИВТ

  1. АЛУ (02. Arithmetic-logic unit)
    1. Память (03. Register file and memory),
    2. Простейшее программируемое устройство (04. Primitive programmable device)
  2. Основной дешифратор (05. Main decoder)
  3. Тракт данных (06. Datapath)
    1. Модуль загрузки и сохранения (08. Load-store unit)
    2. Интеграция блока загрузки и сохранения (09. LSU Integration)
    1. Контроллер прерываний (10. Interrupt subsystem)
    2. Интеграция подсистемы прерываний (11. Interrupt Integration)
  4. Периферийные устройства (13. Peripheral units)
  5. Программирование (14. Programming)

Предисловие

Данная книга проведет вас от азов разработки цифровых схем до проектирования однотактного микроконтроллера с архитектурой RISC-V, а также до написания и компиляции программного обеспечения для него. Представленный здесь материал является сборником лабораторных работ, выполняемых студентами в НИУ МИЭТ в рамках дисциплины "Архитектуры процессорных систем" (АПС) и, в первую очередь, рассчитан именно на них, поэтому в тексте будут встречаться фразы вроде: "Допуск к лабораторной работе", "Проконсультироваться с преподавателем", которые имеют смысл, только для обучающихся в ВУЗе. Если вы читаете эту книгу для самостоятельного обучения, большую часть подобных фраз можно игнорировать.

Мотивация

Целью курса "Архитектуры процессорных систем" является изучение устройства и способов организации процессоров, а также систем под их управлением. Практическая часть курса ориентирована на разработку процессора с архитектурой RISC-V.

Под словом Архитектура понимается некоторый способ организации. Процессор – это программно-управляемое устройство для обработки информации. Проще говоря, это устройство, управлять поведением которого можно с помощью программ (последовательности команд/действий). Система – это комбинация взаимодействующих элементов, организованных для достижения поставленных целей. Таким образом, дисциплина "Архитектур процессорных систем" посвящена способам организации и построения систем под управлением устройств, управляемых программами.

Дисциплина реализуется Институтом МПСУ на базе НИУ МИЭТ сразу для 7 различных направлений подготовки, которые имеют разные названия и количество теоретического и практического материалов. Несмотря на это масштаб покрытия у них одинаковый, а суть предмета изучения общая - организация компьютеров. Отличаются лишь глубина погружения и акценты.

Для успешного погружения в дисциплину важно понимать зачем эта дисциплина нужна именно тебе, будучи студентом:

Информационной безопасности

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

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

Информатики и вычислительной техники

30–40 лет назад, когда персональные компьютеры были ещё в новинку, а интернета как такового не было, пионеры вычислительной техники предсказывали, что в будущем электронные чипы станут настолько дешёвыми, что они будут повсюду — в домах, в транспорте, даже в человеческом теле. Для того времени эта идея казалась фантастической, даже абсурдной. Персональные компьютеры тогда были очень дороги и в большинстве своём даже не подключались к интернету. Мысль о том, что миллиарды крохотных чипов когда-нибудь будут во всем и станут дешевле семечек, казалась нелепой. Сегодня эти мысли уже не кажутся фантастическими. В последнее десятилетие почти всегда, какой-нибудь компьютер или компьютеры находятся на расстоянии вытянутой руки от человека. Билетик в метро – тоже компьютер, который спроектировал, возможно, выпускник ИВТ.

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

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

Инфокоммуникационных технологий и систем связи

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

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

Знания в области разработки компьютеров являются важным инструментом в создании информационно-коммуникационных систем связи.

Конструирования и технологии электронных средств

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

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

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

Программной инженерии

Не понимать как устроен и работает компьютер современному программисту, все равно что гонщику Формулы-1 не знать, как работает и устроена его машина. Это просто немыслимо! Такое возможно, но скорее исключение из правил. Конечно же кузнец знает, как устроен его инструмент, ведь тогда он может его более эффективно использовать. Понимает его слабые стороны и знает как хитро применить его на практике. Только в этом случае кузнец ценен.

Современные языки программирования дают возможность значительно оторваться от реального железа. Не редко в этом есть практический смысл, но далеко не всегда. Большинство современных компьютеров автономны (на батарейном питании), а значит, что эффективность их работы есть продолжительность их работы. Понимание нюансов может значительно сэкономить энергию. А порой надо выбрать железо для сервера, а порой понять почему очевидно быстрый код работает медленно. Часто приходится разбираться в новых технологиях, фреймворках, языках, сервисах, библиотеках, но все это дается легко только в том случае, если есть устойчивая база, отвечающая на вопрос - "как это работает и почему именно так?". Во всем перечисленном поможет знание АПС.

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

Прикладной математики

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

Математические приложения, какими бы они ни были (моделирование, автоматизация, расчеты или что-то другое), требуют инструмента их решения – компьютера. Понимание устройства и работы основного инструмента дает явные преимущества перед тем, у кого этого понимания нет. Порой надо выбрать железо для системы, решающей некоторую задачу, порой – понять почему очевидно быстрый код работает медленно. Часто приходится разбираться в новых технологиях, фреймворках, языках, сервисах, библиотеках, но все это дается легко только в том случае, если есть устойчивая база, отвечающая на вопрос - "как это работает и почему именно так?". Во всем перечисленном поможет знание АПС.

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

Радиотехники

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

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

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

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

Радиотехника связана с радиосигналами, а радиосигналы всегда связаны с процессорами в современной аппаратуре. И если вы хотите быть в центре этой захватывающей области, изучение АПС — важный шаг на этом пути.

Как читать эту книгу

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

Вне зависимости от вашего уровня подготовки, работу с этим курсом рекомендуется начать с прочтения документов из части: "Введение".

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

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

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

Лабораторные занятия будут проходить с использованием САПР Vivado (и отладочными стендами Nexys A7). Это очень сложный профессиональный инструмент, на полноценное изучение которого могут уйти годы. Во время данного курса лабораторных работ нет времени на эти годы, поэтому для вас собрана основная информация по взаимодействию с САПР в разделе "Основы Vivado". Этой информации хватит, чтобы с помощью Vivado реализовать весь цикл лабораторных работ.

Если вы читаете данную книгу не в рамках курса АПС, вы вольны в выборе как программных средств, так и способов отладки. Репозиторий, сопровождающий эту книгу будет содержать некоторые файлы, специализированные для плат Nexys A7 (так называемые ограничения/констрейны), однако при должном уровне навыков вы с легкостью сможете портировать его под свою плату. В этому случае, авторы будут признательны, если вы предоставите получившиеся файлы и название платы, чтобы их можно было добавить в отдельную папку по другим платам для будущих читателей. По всем вопросам/замечаниям/предложениям вы можете связаться с авторами курса через разделы Issues и Discussions данного репозитория.

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

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

Материал этой книги будет пестрить множеством ссылок, которые в электронной версии этой книги, разумеется, будут кликабельными. Однако, если вы имеете удовольствие читать эту книгу в "аналоговом" формате, для вашего удобства все ссылки будут представлены в виде сносок под соответствующей страницей в текстовом формате. Текстовый формат вместо QR-кодов выбран чтобы иметь возможность вбить ссылку вручную на компьютере (все ссылки будут представлены в формате Unicode, так что не беспокойтесь, что вам придется вводить что-то наподобие "https://ru.wikipedia.org/wiki/%D0%A2%D1%80%D0%B8%D0%B3%D0%B3%D0%B5%D1%80"). Кроме того, "умные" камеры современных смартфонов отлично справляются с распознаванием текстовых ссылок, поэтому авторы надеются, что и с этой стороны отсутствие QR-кодов не произведет неудобств.

Большая часть информации, касающаяся архитектуры RISC-V взята напрямую из спецификации. Поскольку работа над спецификацией все ещё идёт (хотя базовый набор инструкций rv32i уже заморожен и не изменится), чтобы ссылки на конкретные страницы спецификации имели смысл, они будут даваться на следующие версии двух документов:

История курса и разработчики

Дисциплины связанные с организацией вычислительной техники читаются в МИЭТ с самого его основания. Текущий курс эволюционировал из "Микропроцессорных средств и систем" (МПСиС) читаемый факультету МПиТК (Микроприборов и технической кибернетики) сначала Савченко Юрием Васильевичем, а после – Переверзевым Алексеем Леонидовичем. С 2014 по 2022 годы дисциплина проводилась и значительно модернизировалась Поповым Михаилом Геннадиевичем совместно с коллективом сотрудников и студентов Института МПСУ. С 2022 года группам ИБ, ИКТ, КТ и РТ курс читает Силантьев Александр Михайлович, а группам ИВТ, ПИН, ПМ – Орлов Александр Николаевич, разработка методических материалов перешла в руки Солодовникова Андрея Павловича.

В 2019-2023 годах была значительно переработана, осовременена и дополнена теоретическая часть курса. Тогда же разработаны и полностью обновлены лабораторные работы с переходом на использование архитектуры RISC-V, введены новые способы оценки полученных знаний. Все материалы курса включая видеозаписи лекций были выложены в свободный доступ.

Основное влияние на структуру и содержание курса в современном виде оказали: оригинальные лекции МПСиС для МПиТК, курс Вычислительных структур 6.004 читаемый в MIT, Харрис и Харрис "Цифровая схемотехника и архитектура компьютера" и Орлов и Цилькер "Организация ЭВМ и систем".

С подготовкой курса и репозитория помогали студенты и сотрудники института МПСУ (бывшие и нынешние):

Фамилия, Имя, ОтчествоВклад в курс
Барков Евгений СергеевичПрофессиональные консультации по деталям языка SystemVerilog, спецификации RISC-V и RTL-разработки, тематике синтеза и констрейнов.
Булавин Никита СергеевичОтработка материалов, подготовка тестбенчей и модулей верхнего уровня для плат Nexys A7 для лабораторных работ.
Козин Алексей АлександровичОтработка материалов, подготовка обфусцированных модулей для лабораторных работ.
Кулешов Владислав КонстантиновичВычитка и исправление ошибок в методических материалах, сбор обратной связи от студентов.
Орлов Александр НиколаевичПрофессиональные консультации по деталям языка SystemVerilog, спецификации RISC-V и RTL-разработки, примерам программ иллюстрирующим особенности архитектуры.
Примаков Евгений ВладимировичПрофессиональные консультации по деталям языка SystemVerilog, спецификации RISC-V и RTL-разработки и вопросам микроархитектуры.
Протасова Екатерина АндреевнаПодготовка индивидуальных заданий и допусков к лабораторным работам, отработка материалов и сбор обратной связи от студентов.
Русановский Богдан ВитальевичПеренос лабораторной работы по прерываниям из PDF в Markdown, подготовка иллюстраций.
Рыжкова Дарья ВасильевнаПодготовка тестбенчей для лабораторных работ.
Силантьев Александр МихайловичПрофессиональные консультации по деталям языка SystemVerilog, спецификации RISC-V и RTL-разработки, вопросам микроархитектуры, тематике синтеза и констрейнов, особенностям компиляции и профилирования.
Стрелков Даниил ВладимировичОтработка материалов, подготовка тестбенчей для лабораторных работ и иллюстраций структуры курса.
Терновой Николай ЭдуардовичПрофессиональные консультации по деталям языка SystemVerilog, спецификации RISC-V и RTL-разработки, вычитка материалов, сбор обратной связи от студентов.
Харламов Александр АлександровичОтработка материалов, проектирование вспомогательных модулей для лабораторных работ.
Хисамов Василь ТагировичВычитка материалов, сбор обратной связи от студентов.
Чусов Сергей АндреевичВычитка материалов, сбор обратной связи от студентов.

Кроме того, часть иллюстраций была нарисована Краснюк Екатериной Александровной.

На этом вводное слово окончено, желаю вам успехов в этом увлекательнейшем путешествии!

Введение в HDL и работу с ПЛИС

Неподготовленному человеку может показаться что на этих лабах мы будем заниматься изучением Ещё Одного Бесполезного Языка Программирования, и с таким отношением по окончанию курса ваше мнение скорее всего не изменится. Данный раздел содержит документы, призванные подготовить вас к выполнению лабораторных работ, немного изменить ваш взгляд на некоторые вещи.

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

Начать необходимо с документа "What is HDL", рассказывающем о том, что такое Языки Описания Аппаратуры (Hadrware Description Languages, HDL).

Затем, для того чтобы закрепить понимание происходящего, вам предлагается прочитать документ "How FPGA Works", рассказывающий о том, как работает ПЛИС изнутри.

И в довершение, вам предлагается прочитать документ "Implementation Steps". Этот документ дополнит ваше понимание о принципе работы ПЛИС и позволит посмотреть на некоторые её реальные элементы изнутри.

Обратите внимание, что во втором абзаце не было использовано слово "поймете". Часто это слово несет не тот смысл. Можно прочесть документ и понять каждое его слово, но не постигнуть смысла, который в этих слова лежал (слышать, но не слушать). В романе Роберта Хайнлайна "Чужак в чужой стране" вводится особое марсианское слово, непереводимое на земной язык: "грокать", которое имеет множество значений. В первом приближении можно подумать, что это слово переводится как "понять", однако это не так. Например, на Марсе очень мало воды и процесс её питья марсианами (по сюжету романа, разумеется) является целым ритуалом, и обозначается этим же словом "грокать". Грокать что-то — означает что это что-то стало частью твоего естества. В отношении информации это означает, это информация стала частью тебя, изменила то, как ты думаешь. Грокать — это постичь что-то на самом глубинном уровне, это видеть девушку в красном сквозь завесу падающих зеленых символов. Даже этот абзац расписан для того, чтобы вы не просто поняли, что эти документы важно понять — а грокнули то, что эти документы важно грокнуть.

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

Что такое язык описания аппаратуры (HDL)

На заре появления цифровой электроники, цифровые схемы в виде диаграммы на бумаге были маленькими, а их реализация в виде физической аппаратуры — большой. В процессе развития электроники (и её преобразования в микроэлектронику) цифровые схемы на бумаге становились всё больше, а относительный размер их реализации в виде физических микросхем — всё меньше. На рисунке ниже, вы можете увидеть диаграмму цифровой схемы устройства intel 4004, выпущенного в 1971 году.

../.pic/Introduction/What%20is%20HDL/i4004.gif

Рисунок 1. Цифровая схема процессора intel 4004[1].

Данная микросхема состоит из 2300 транзисторов[2].
За прошедшие полсотни лет сложность цифровых схем выросла колоссально. Современные процессоры для настольных компьютеров состоят из десятков миллиардов транзисторов. Диаграмма выше при печати в оригинальном размере займет прямоугольник размером 115х140см с площадью около 1.6м2. Предполагая, что площадь печати имеет прямо пропорциональную зависимость от количества транзисторов, получим что печать диаграммы современных процессоров потребует площадь в 16млн. м2, что эквивалентно квадрату со стороной в 4км.

Старый город

Рисунок 2. Масштаб размеров, которых могли бы достигать цифровые схемы современных процессоров, если бы они печатались на бумаге.

Как вы можете догадаться в какой-то момент между 1971-ым и 2022-ым годами инженеры перестали разрабатывать цифровые схемы, рисуя их на бумаге.
Разумеется, разрабатывая устройство, не обязательно вырисовывать на схеме каждый транзистор — можно управлять сложностью, переходя с одного уровня абстракции на другой. Например, начинать разработку схемы с соединения функциональных блоков, а затем рисовать схему для каждого отдельного блока.
К примеру, схему intel 4004 можно представить в следующем виде:

../.pic/Introduction/What%20is%20HDL/4004_arch.png

Рисунок 3. Структурная схема intel 4004[2].

Однако несмотря на это, даже отдельные блоки порой бывают довольно сложны. Возьмем блок аппаратного шифрования по алгоритму AES на рисунке 4:

../.pic/Introduction/What%20is%20HDL/aes_enc_sml.png

Рисунок 4. Структурная схема блока аппаратного шифрования по алгоритму AES[3].

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

Схема полусумматора

Рисунок 5. Цифровая схема полусумматора.

Это устройство (полусумматор) имеет два входа: a и b, а также два выхода: S и P. Выход S является результатом логической операции Исключающее ИЛИ от операндов a и b. Выход P является результатом логической операции И от операндов a и b.

Текст выше и является тем описанием, по которому можно воссоздать эту цифровую схему. Если стандартизировать описание схемы, то в нем можно будет оставить только слова, выделенные жирным и курсивом. Пример того, как можно было бы описать эту схему по стандарту IEEE 1364-2005 (язык описания аппаратуры (Hardware Description Language — HDL) Verilog):

module half_sum(    // устройство полусумматор cо
  input   a,        // входом a,
  input   b,        // входом b,
  output  S,        // выходом S и
  output  P         // выходом P.
);

assign S = a ^ b;   // Где выход S является результатом Исключающего ИЛИ от a и b,
assign P = a & b;   // а выход P является результатом логического И от a и b.

endmodule

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

module half_sum(input a, b, output S, P);
  assign S = a ^ b;
  assign P = a & b;
endmodule

Обратите внимание, что код на языке Verilog описывает устройство целиком, одномоментно. Это описание схемы выше, а не построчное выполнение программы.
Может показаться, что описывать устройство текстом сложнее, чем рисовать схему (тем более что сперва мы уже нарисовали схему, а затем её описали). Однако, с практикой описание схемы в текстовом виде становится намного проще и не требует диаграммы. Для описания достаточно только спецификации: формальной записи того, что должно делать устройство, по которой разрабатывается алгоритм, который затем претворяется в описание на HDL.

Занятный факт: ранее было высказано предположение о том, что инженеры перестали разрабатывать устройства, рисуя цифровые схемы в промежуток времени между 1971-ым и 2022-ым годами. Так вот, первая конференция, посвященная языкам описания аппаратуры состоялась в 1973-ем году[4, стр. 8]. Таким образом, Intel 4004 можно считать одним из последних цифровых устройств, разработанных без использования языков описания аппаратуры.

Источники

  1. Intel 4004 — 50th Anniversary Project;
  2. Страница википедии по Intel 4004;
  3. F.Ka˘gan. Gürkaynak / Side Channel Attack Secure Cryptographic Accelerators;
  4. P. Flake, P. Moorby, S. Golson, A. Salz, S. Davidmann / Verilog HDL and Its Ancestors and Descendants.

Что такое ПЛИС и как она работает

Разделы "Цифровые схемы и логические вентили" и "Таблицы подстановки" являются переводом статьи "How Does an FPGA Work?[1]" за авторством Alchitry, Ell C, распространяемой по лицензии CC BY-SA 4.0.

История появления ПЛИС

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

Стоит оговориться, что обычно под термином ПЛИС подразумевается конкретный тип программируемых схем: FPGA (field-programmable gate array, программируемая пользователем вентильная матрица) здесь и далее термин ПЛИС будет использоваться как синоним FPGA.

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

Цифровые схемы и логические вентили

Цифровые схемы

В электронике, словом "цифровая" описывают схемы, которые абстрагируются от непрерывных (аналоговых) значений напряжений, вместо этого используется только два дискретных значения: 0 и 1. На текущем уровне абстракции нас не интересуют конкретные значения напряжений и пороги этих значений. К примеру, в ПЛИС часто используются значения 1.2 В в качестве 1 и 0 В в качестве 0. Если реальным значением сигнала будет напряжение 0.8 В, что достаточно близко к 1.2 В, оно всё ещё будет считаться 1.
Цифровые схемы разрабатываются таким образом, чтобы устанавливать крайние значения напряжений (сигнал, имеющий значение напряжения 0.8 В, пройдя через очередной логический вентиль будет иметь значение напряжения около 1.2 В), что делает их крайне устойчивыми к шумам и влиянию внешнего мира.
Таким образом, концепция "цифровой схемы" позволяет нам уйти от всего этого сложного поведения на уровне напряжений, давая нам возможность разрабатывать схему в идеальном мире, где у напряжения может быть всего два значения: 0 и 1. А обеспечением этих условий будут заниматься базовые блоки, из которых мы будем строить цифровые схемы.

Эти базовые блоки называются логическими вентилями.

Логические вентили

Существует множество логических вентилей, но чаще всего используется четыре из них: И, ИЛИ, Исключающее ИЛИ, НЕ. Каждый из этих элементов принимает на вход цифровое значение (см. цифровая схема), выполняет определенную логическую функцию над входами и подает на выход результат этой функции в виде цифрового значения.

Логический вентиль И принимает два входа и выдает на выход значение 1 только в том случае, если оба входа равны 1. Если хотя бы один из входов 0, то на выходе будет 0. На схемах логический вентиль И отображается следующим образом:

../.pic/Introduction/How%20FPGA%20works/fig_01.drawio.svg

Рисунок 1. Обозначение логического вентиля И.

Логический вентиль ИЛИ принимает два входа и выдает на выход значение 1 в случае, если хотя бы один из входов равен 1. Если оба входа равны 0, то на выходе будет 0. На схемах логический вентиль ИЛИ отображается следующим образом:

../.pic/Introduction/How%20FPGA%20works/fig_02.drawio.svg

Рисунок 2. Обозначение логического вентиля ИЛИ.

Логический вентиль Исключающее ИЛИ принимает два входа и выдает на выход значение 1 в случае, если значения входов не равны между собой (один из них равен 1, а другой 0). Если значения входов равны между собой (оба равны 0 или оба равны 1), то на выходе будет 0. На схемах логический вентиль Исключающее ИЛИ отображается следующим образом:

../.pic/Introduction/How%20FPGA%20works/fig_03.drawio.svg

Рисунок 3. Обозначение логического вентиля Исключающее ИЛИ.

Логический вентиль НЕ — самый простой. Он принимает один вход и подает на выход его инверсию. Если на вход пришло значение 0, то на выходе будет 1, если на вход пришло значение 1, то на выходе будет 0. Он обозначается на схемах следующим образом:

../.pic/Introduction/How%20FPGA%20works/fig_04.drawio.svg

Рисунок 4. Обозначение логического вентиля НЕ.

Так же существуют вариации базовых вентилей, такие как И-НЕ, ИЛИ-НЕ, Исключающее ИЛИ-НЕ, отличающиеся от исходных тем, что их выходы инвертируются.

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

На приведенном ниже рисунке показан способ построения логического вентиля И на базе двух транзисторов. Подача значения 1 на вход А или B "открывает" соответствующий транзистор. Если оба транзистора открыты, на выход идет напряжение питания (1 в контексте цифровых значений). В случае, если хотя бы на одном входе А или B будет значение 0, соответствующий транзистор будет закрыт (можно считать, что он превратится в разрыв цепи). В этом случае выход будет подключен к земле (0 в контексте цифровых значений). Как вы видите, напряжение на выход подается от источников постоянного питания или земли, а не от входов вентиля, именно этим и обеспечивается постоянное обновление напряжения и устойчивость цифровых схем к помехам.

../.pic/Introduction/How%20FPGA%20works/fig_05.drawio.svg

Рисунок 5. Обозначение логического вентиля Схема логического вентиля И, построенного на транзисторах.

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

Однако, при описании цифровых схем, некоторые цифровые блоки используются настолько часто, что для них ввели отдельные символы (сумматоры, умножители, мультиплексоры), используемые при описании более сложных схем. Мы рассмотрим один из фундаментальных строительных блоков в ПЛИС — мультиплексор.

Мультиплексоры

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

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

../.pic/Introduction/How%20FPGA%20works/fig_06.drawio.svg

Рисунок 6. Обозначение Мультиплексора.

Символ / на линии sel используется, чтобы показать, что этот сигнал шириной 6 бит.

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

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

Посмотрим, как можно реализовать мультиплексор с управляющим сигналом, использующим one-hot-кодирование, используя только логические вентили И, ИЛИ:

../.pic/Introduction/How%20FPGA%20works/fig_07.drawio.svg

Рисунок 7. Реализация мультиплексора, использующего one-hot кодирование.

Если мы выставим значение управляющего сигнала, равное 000010, означающее что только первый бит этого сигнала (счет ведется с нуля) будет равен единице (sel[1] = 1), то увидим, что на один из входов каждого логического вентиля И будет подано значение 0. Исключением будет логический вентиль И для входа b, на вход которого будет подано значение 1. Это означает, что все логические вентили И (кроме первого, на который подается вход b) будут выдавать на выход 0 (см. Логические вентили) вне зависимости от того, что было подано на входы a,c,d,e и f. Единственным входом, который будет на что-то влиять окажется вход b. Когда он равен 1, на выходе соответствующего логического вентиля И окажется значение 1. Когда он равен 0 на выходе И окажется значение 0. Иными словами, выход И будет повторять значение b.

../.pic/Introduction/How%20FPGA%20works/fig_08.drawio.svg

Рисунок 8. Реализация мультиплексора, использующего one-hot кодирование.

Логический вентиль ИЛИ на данной схеме имеет больше двух входов. Подобный вентиль может быть создан в виде каскада логических вентилей ИЛИ:

../.pic/Introduction/How%20FPGA%20works/fig_09.drawio.svg

Рисунок 9. Реализация многоходового логического ИЛИ.

Многовходовой вентиль ИЛИ ведет себя ровно так же, как двухвходовой: он выдает на выход значение 1 когда хотя бы один из входов равен 1. В случае, если все входы равны 0, на выход ИЛИ пойдет 0.

Но для нашей схемы мультиплексора гарантируется, что каждый вход ИЛИ кроме одного будет равняться 0 (поскольку выход каждого И кроме одного будет равен 0). Это означает, что выход многовходового ИЛИ будет зависеть только от одного входа (в случае, когда sel = 000010 — от входа b).

../.pic/Introduction/How%20FPGA%20works/fig_10.drawio.svg

Рисунок 10. Реализация мультиплексора, использующего one-hot кодирование.

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

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

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

Таблицы подстановки (Look-Up Tables, LUTs)

Итак, у нас есть способ динамически менять маршрут сигналов и приводить их туда, куда нам нужно. Теперь необходимо понять, как генерировать произвольную логику. И для этого мы снова воспользуемся мультиплексорами, в частности их производными, которые называются Таблицы подстановки или Look-Up Tables (LUTs).

Представьте мультиплексор с четырьмя входными сигналами, и двухбитным управляющим сигналом (обратите внимание, что в теперь это сигнал не использует one-hot-кодирование). Но теперь, вместо того чтобы выставлять входные сигналы во внешний мир, давайте подключим их к программируемой памяти. Это означает, что мы можем "запрограммировать" каждый из входов на какое-то константное значение. Поместим то, что у нас получилось в отдельный блок и вот, мы получили двухвходовую Таблицу подстановки (далее LUT).

../.pic/Introduction/How%20FPGA%20works/fig_11.drawio.svg

Рисунок 11. Реализация таблицы подстановки (Look-Up Table, LUT).

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

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

Адрес (In[1:0])Значение
000
010
100
111

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

D-триггеры

Как вы уже поняли, используя неограниченное количество LUT-ов, вы можете построить цифровую схему, реализующую логическую функцию любой сложности. Однако цифровые схемы не ограничиваются реализацией одних только логических функций (цифровые схемы, реализующие логическую функцию, называются комбинационными, поскольку выход зависит только от комбинации входов). Например, так не построить цифровую схему, реализующую процессор. Для таких схем, нужны элементы памяти. Заметим, что речь идет не о программируемой памяти, задавая значения которой мы управляем тем, куда будут направлены сигналы, и какие логические функции будут реализовывать LUT-ы. Речь идет о ячейках памяти, которые будут использоваться логикой самой схемы.
Такой базовой ячейкой памяти является D-триггер, из которых можно собрать другие ячейки памяти, например регистры (а из регистров можно собрать память с произвольным доступом (random access memory, RAM)), сдвиговые регистры и т.п.

D-триггер — это цифровой элемент, способный хранить один бит информации. В базовом варианте у этого элемента есть два входа и один выход. Один из входов подает значение, которое будет записано в D-триггер, второй вход управляет записью (обычно он называется clk или clock и подключается к тактирующему синхроимпульсу схемы). Когда управляющий сигнал меняет свое значение с 0 на 1 (либо с 1 на 0, зависит от схемы), в D-триггер записывается значение сигнала данных. Обычно, описывая D-триггер, говорится, что он строится из двух защелок, которые в свою очередь строятся из RS-триггеров, однако в конечном итоге, все эти элементы строятся на базе логических вентилей И/ИЛИ, НЕ:

../.pic/Introduction/How%20FPGA%20works/fig_12.drawio.svg

Рисунок 12. Реализация D-триггера.

Арифметика

Помимо описанных выше блоков (мультиплексоров и построенных на их основе LUT-ов и регистров) выделяется еще один тип блоков, настолько часто используемый в цифровых схемах, что его заранее размещают в ПЛИС в больших количествах: это арифметические блоки. Эти блоки используются при сложении, вычитании, сравнении чисел, реализации счётчиков. В разных ПЛИС могут быть предустановлены разные блоки: где-то это может быть однобитный сумматор, а где-то блок вычисления ускоренного переноса (carry-chain).

Все эти блоки могут быть реализованы через логические вентили, например так можно реализовать сумматор:

../.pic/Labs/lab_01_adder/fig_02.drawio.svg

Рисунок 13. Реализация полного однобитного сумматора.

Логическая ячейка

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

Логический блок содержит одну или несколько LUT, арифметический блок, и один или несколько D-триггеров, которые соединены между собой некоторым количеством мультиплексоров. Ниже представлена схема того, как может выглядеть логический блок:

../.pic/Labs/lab_03_memory/fig_02.png

Рисунок 14. Схема логической ячейки[2].

Может показаться запутанным, но все достаточно просто. Логический блок представляет собой цепочку операций: логическая функция, реализованная через LUT -> арифметическая операция -> Запись в D-триггер. Каждый из мультиплексоров определяет то, будет ли пропущен какой-либо из этих этапов. Таким образом, конфигурируя каждый логический блок, можно получить следующие вариации кусочка цифровой схемы:

  1. Комбинационная схема (логическая функция, реализованная в LUT)
  2. Арифметическая операция
  3. Запись данных в D-триггер
  4. Комбинационная схема, с записью результата в D-триггер
  5. Арифметическая операция с записью результата в D-триггер
  6. Комбинационная схема с последующей арифметической операцией
  7. Комбинационная схема с последующей арифметической операцией и записью в D-триггер

А вот реальный пример использования логического блока в ПЛИС xc7a100tcsg324-1 при реализации Арифметико-логического устройства (АЛУ), подключенного к периферии отладочной платы Nexys-7:

../.pic/Introduction/How%20FPGA%20works/fig_15.png

Рисунок 15. Пример использования логической ячейки.

Здесь вы можете увидеть использование LUT-ов, блока расчета ускоренного переноса, и одного из D-триггеров. D-триггеры, обозначенные серым цветом, не используются.

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

Помимо логических блоков, в ПЛИС есть и другие примитивы: Блочная память, блоки умножителей и т.п.

Выводы

Обобщим сказанное:

  1. Используя такие полупроводниковые элементы, как транзисторы, можно собирать логические вентили: элементы И, ИЛИ, НЕ и т.п.
  2. Используя логические вентили, можно создавать схемы, реализующие как логические функции (комбинационные схемы), так и сложную логику с памятью (синхронные схемы).
  3. Из логических вентилей среди прочего строится и такая важная комбинационная схема, как мультиплексор: цифровой блок, в зависимости от управляющего сигнала подающий на выход один из входных сигналов.
  4. Подключив управляющий сигнал мультиплексора к программируемой памяти, можно управлять тем, какие сигналы пойдут на выход и направлять их в нужную часть схемы (маршрутизировать сигналы).
  5. Подключив входные сигналы мультиплексора к программируемой памяти, можно получить Таблицу подстановок (Look-Up Table, LUT), которая может реализовывать простейшие логические функции. LUT-ы позволяют заменить логические вентили И/ИЛИ/НЕ, и удобны тем, что их можно динамически изменять, логические вентили в свою очередь исполняются на заводе и уже не могут быть изменены после создания.
  6. Из логических вентилей так же можно собрать базовую ячейку памяти: D-триггер, и такую часто используемую комбинационную схему как полный однобитный сумматор (или любой другой часто используемый арифметический блок).
  7. Объединив LUT, арифметический блок и D-триггер получается структура в ПЛИС, которая называется логический блок.
  8. Логический блок (а также другие примитивы, такие как блочная память или умножители) — это множество блоков, которые заранее физически размещаются в кристалле ПЛИС, их количество строго определено конкретной ПЛИС и не может быть изменено.
  9. Конфигурируя примитивы и маршрутизируя сигнал между ними (см. п.4), можно получить практически любую цифровую схему (с учетом ограничения ёмкости ПЛИС).

Источники

  1. Alchitry, Ell C / How Does an FPGA Work?
  2. Field-programmable gate array

Этапы реализации проекта в ПЛИС

Для того, чтобы описанное на языке описания аппаратуры устройство было реализовано в ПЛИС, необходимо выполнить несколько этапов:

  1. Предобработку (elaboration)
  2. Синтез (synthesis)
  3. Внедрение (implementation)
  4. Создание двоичного файла конфигурации (generate bitstream)

Остановимся на каждом шаге подробнее:

Предобработка

На этапе предобработки, САПР считывает HDL-описание вашего устройства, производит подстановку значений параметров и блоков generate, устанавливает разрядности сигналов и строит иерархию модулей устройства. Затем, ставит в соответствие отдельным участкам кода соответствующие абстрактные элементы: логические вентили, мультиплексоры, элементы памяти и т.п. Кроме того, производится анализ и оптимизация схемы, например, если какая-то часть логики в конечном итоге не связана ни с одним из выходных сигналов, эта часть логики будет удалена[1]. Итогом предобработки является так называемая топология межсоединений (в быту называемая словом нетлист). Нетлист — это представление цифровой схемы в виде графа, где каждый элемент схемы является узлом, а цепи, соединяющие эти элементы являются ребрами. Нетлист может храниться как в виде каких-то внутренних файлов САПР-а (так хранится нетлист этапа предобработки), так и в виде HDL-файла, описывающего экземпляры примитивов и связи между ними. Рассмотрим этап предобработки и термин нетлиста на примере.

Допустим, мы хотим реализовать следующую цифровую схему:

../.pic/Introduction/Implementation%20steps/fig_01.drawio.svg

Её можно описать следующим SystemVerilog-кодом:

module sample(
    input logic a, b, c, d, sel,
    output logic res
    );

    logic ab = a & b;
    logic xabc = ab ^ c;

    assign res = sel? d : xabc;

endmodule

Выполним этап элаборации. Для этого в Vivado на вкладке RTL Analysis выберем Schematic.

Откроются следующие окна:

../.pic/Introduction/Implementation%20steps/fig_02.png

В левом окне мы видим наш нетлист. В нижней части обозначены узлы графа (элементы ab_i, res_i, xabc_i, которые представляют собой И, мультиплексор и Исключающее ИЛИ соответственно. Имена этих элементов схожи с именами проводов, присваиванием которым мы создавали данные элементы)

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

Справа вы видите схематикграфическую схему, построенную на основе данного графа (нетлиста).

Synthesis

На шаге синтеза, САПР берет сгенерированную ранее цифровую схему и реализует элементы этой схемы через примитивы конкретной ПЛИС — в основном через логические ячейки, содержащие таблицы подстановки, полный однобитный сумматор и D-триггер (см. как работает ПЛИС).

В рамках нашего примера, САПР смотрит на построенный на этапе элаборации нетлист и решает, какими средствами (примитивами) ПЛИС можно его реализовать. Поскольку схема чисто комбинационная, результат её работы можно рассчитать и выразить в виде таблицы истинности, а значит для её реализации лучше всего подойдут Таблицы Подстановки (LUT-ы). Более того, в ПЛИС xc7a100tcsg324-1 есть пятивходовые LUT-ы, а у нашей схемы именно столько входов. Это означает, работу всей этой схемы можно заменить всего одной таблицей подстановки внутри ПЛИС.

Итак, продолжим рассматривать наш пример и выполним этап синтеза. Для этого нажмем на кнопку Run Synthesis.

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

../.pic/Introduction/Implementation%20steps/fig_03.png

Мы видим, что между входами/выходами схемы и её внутренней логикой появились новые примитивы — буферы. Они нужны, преобразовать уровень напряжения между ножками ПЛИС и внутренней логикой (условно говоря, на вход плис могут приходить сигналы с уровнем 3.3 В, а внутри ПЛИС примитивы работают с сигналами уровня 1.2 В).

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

строка EQN=32'hAAAA3CCC означает, что таблица подстановки будет инициализирована следующим 32-битным значением: 0xAAAA3CCC. Поскольку у схемы 5 входов, у нас может быть 25=32 возможных комбинаций входных сигналов и для каждого нужно свое значение результата.

Можно ли как-то проверить данное 32-битное значение без просчитывания всех 32 комбинаций сигналов "на бумажке"?

Да, можно. Сперва надо понять в каком именно порядке будут идти эти комбинации. Мы видим, что сигналы подключены к таблице подстановки в следующем порядке: `d, c, b, a, sel`. Это означает, что сама таблица примет вид:
|sel| a | b | c | d |  |res|
|---|---|---|---|---|  |---|
| 0 | 0 | 0 | 0 | 0 |  | 0 |
| 0 | 0 | 0 | 0 | 1 |  | 0 |
| 0 | 0 | 0 | 1 | 0 |  | 1 |
| 0 | 0 | 0 | 1 | 1 |  | 1 |
| 0 | 0 | 1 | 0 | 0 |  | 0 |
....
| 1 | 1 | 1 | 1 | 1 |  | 1 |

Давайте посмотрим на логику исходной схемы и данную таблицу истинности: когда sel==1, на выход идет d, это означает, что мы знаем все значения для нижней половины таблицы истинности, в нижней половине таблице истинности самый левый входной сигнал (sel) равен только единице, значит результат будет совпадать с сигналом d, который непрерывно меняется с 0 на 1 и оканчивается значением 1. Это означает, что если читать значения результатов снизу-вверх (от старших значений к младшим), то сначала у нас будет 16 цифр, представляющих 8 пар 10:101010101010, что эквивалентно записи AAAA в шестнадцатеричной записи.

Рассчитывать оставшиеся 16 вариантов тоже не обязательно, если посмотреть на схему, то можно заметить, что когда sel!=1, ни sel, ни d больше не участвуют в управлении выходом. Выход будет зависеть от операции xor, которая дает 1 только когда её входы не равны между собой. Верхний вход xor (выход И) , будет равен единице только когда входы a и b равны единице, причем в данный момент, нас интересуют только ситуации, когда sel!=1. Принимая во внимание, что в таблице истинности значения входов записываются чередующимися степенями двойки (единицами, парами, четверками, восьмерками) единиц и нулей, мы понимаем, что интересующая нас часть таблицы истинности будет выглядеть следующим образом:

       ...

  | a | b | c |
. |---|---|---| .
. | 1 | 1 | 0 | .
. | 1 | 1 | 0 | .
  | 1 | 1 | 1 |
  | 1 | 1 | 1 |

       ...

Только в этой части таблицы истинности мы получим 1 на выходе И, при этом в старшей части, вход c так же равен 1. Это значит, что входы Исключающего ИЛИ будут равны и на выходе будет 0. Значит результат этой таблицы истинности будет равен 0011 или 3 в шестнадцатеричной записи.

Ниже данной части таблицы истинности располагается та часть, где sel==1, выше та часть, где выход И будет равен 0. Это означает, что оставшаяся младшая часть будет повторять значения c, которое сменяется парами нулей и единиц: 00-11-00-11... Эта оставшаяся последовательность будет записана в шестнадцатеричном виде как 0xCCC.

Таким образом, мы и получаем искомое выражение EQN=32'hAAAA3CCC. Если с этой частью возникли сложности, попробуйте составить данную таблицу истинности (без вычисления самих результатов, а затем просмотрите логику быстрого вычисления результата).

Implementation

После построения новой схемы, где в качестве элементов используются ресурсы конкретной ПЛИС, происходит расчёт размещения этой схемы внутри ПЛИС: выбираются конкретные логические ячейки, происходит маршрутизация сигнала между этими ячейками. Например, реализация 32-битного сумматора с ускоренным переносом может потребовать 32 LUT-а и 8 примитивов вычисления быстрого переноса (CARRY4). Будет неразумно использовать для этого примитивы, разбросанные по всему кристаллу ПЛИС, ведь тогда придется выполнять сложную маршрутизацию сигнала, да и временные характеристики устройства так же пострадают (сигналу идущему от предыдущего разряда к следующему придется проходить больший путь). Вместо этого, САПР будет пытаться разместить схему таким образом, чтобы использовались близлежащие примитивы ПЛИС, для получения оптимальных характеристик.

Что именно считается "оптимальным" зависит от двух вещей: настроек САПР и ограничений (constraints), наложенных на имплементацию. Ограничения сужают область возможных решений по размещению примитивов внутри ПЛИС под определенные характеристики (временны́е и физические). Например, можно сказать, внутри ПЛИС схема должна быть размещена таким образом, чтобы время прохождения по критическому пути не превышало 20ns. Это временно́е ограничение. Также нужно сообщить САПР, к какой ножке ПЛИС необходимо подключить входы и выходы нашей схемы — это физическое ограничение.

Ограничения описываются не на языке описания аппаратуры, вместо этого используются текстовые файлы специального формата, зависящего от конкретной САПР.

Пример используемых ограничений для лабораторной по АЛУ.

Строки, начинающиеся с # являются комментариями.

Строки, начинающиеся с set_property являются физическими ограничениями, связывающими входы и выходы нашей схемы с конкретными входами и выходами ПЛИС.

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

## This file is a general .xdc for the Nexys A7-100T

# Clock signal

set_property -dict { PACKAGE_PIN E3    IOSTANDARD LVCMOS33 } [get_ports { CLK100 }]; #IO_L12P_T1_MRCC_35 Sch=clk100mhz
create_clock -add -name sys_clk_pin -period 10.00 -waveform {0 5} [get_ports {CLK100}];

# Switches
set_property -dict { PACKAGE_PIN J15   IOSTANDARD LVCMOS33 } [get_ports { SW[0] }]; #IO_L24N_T3_RS0_15 Sch=sw[0]
set_property -dict { PACKAGE_PIN L16   IOSTANDARD LVCMOS33 } [get_ports { SW[1] }]; #IO_L3N_T0_DQS_EMCCLK_14 Sch=sw[1]
set_property -dict { PACKAGE_PIN M13   IOSTANDARD LVCMOS33 } [get_ports { SW[2] }]; #IO_L6N_T0_D08_VREF_14 Sch=sw[2]
set_property -dict { PACKAGE_PIN R15   IOSTANDARD LVCMOS33 } [get_ports { SW[3] }]; #IO_L13N_T2_MRCC_14 Sch=sw[3]
set_property -dict { PACKAGE_PIN R17   IOSTANDARD LVCMOS33 } [get_ports { SW[4] }]; #IO_L12N_T1_MRCC_14 Sch=sw[4]
set_property -dict { PACKAGE_PIN T18   IOSTANDARD LVCMOS33 } [get_ports { SW[5] }]; #IO_L7N_T1_D10_14 Sch=sw[5]
set_property -dict { PACKAGE_PIN U18   IOSTANDARD LVCMOS33 } [get_ports { SW[6] }]; #IO_L17N_T2_A13_D29_14 Sch=sw[6]
set_property -dict { PACKAGE_PIN R13   IOSTANDARD LVCMOS33 } [get_ports { SW[7] }]; #IO_L5N_T0_D07_14 Sch=sw[7]
set_property -dict { PACKAGE_PIN T8    IOSTANDARD LVCMOS18 } [get_ports { SW[8] }]; #IO_L24N_T3_34 Sch=sw[8]
set_property -dict { PACKAGE_PIN U8    IOSTANDARD LVCMOS18 } [get_ports { SW[9] }]; #IO_25_34 Sch=sw[9]
set_property -dict { PACKAGE_PIN R16   IOSTANDARD LVCMOS33 } [get_ports { SW[10] }]; #IO_L15P_T2_DQS_RDWR_B_14 Sch=sw[10]
set_property -dict { PACKAGE_PIN T13   IOSTANDARD LVCMOS33 } [get_ports { SW[11] }]; #IO_L23P_T3_A03_D19_14 Sch=sw[11]
set_property -dict { PACKAGE_PIN H6    IOSTANDARD LVCMOS33 } [get_ports { SW[12] }]; #IO_L24P_T3_35 Sch=sw[12]
set_property -dict { PACKAGE_PIN U12   IOSTANDARD LVCMOS33 } [get_ports { SW[13] }]; #IO_L20P_T3_A08_D24_14 Sch=sw[13]
set_property -dict { PACKAGE_PIN U11   IOSTANDARD LVCMOS33 } [get_ports { SW[14] }]; #IO_L19N_T3_A09_D25_VREF_14 Sch=sw[14]
set_property -dict { PACKAGE_PIN V10   IOSTANDARD LVCMOS33 } [get_ports { SW[15] }]; #IO_L21P_T3_DQS_14 Sch=sw[15]

### LEDs

set_property -dict { PACKAGE_PIN H17   IOSTANDARD LVCMOS33 } [get_ports { LED[0] }]; #IO_L18P_T2_A24_15 Sch=led[0]
set_property -dict { PACKAGE_PIN K15   IOSTANDARD LVCMOS33 } [get_ports { LED[1] }]; #IO_L24P_T3_RS1_15 Sch=led[1]
set_property -dict { PACKAGE_PIN J13   IOSTANDARD LVCMOS33 } [get_ports { LED[2] }]; #IO_L17N_T2_A25_15 Sch=led[2]
set_property -dict { PACKAGE_PIN N14   IOSTANDARD LVCMOS33 } [get_ports { LED[3] }]; #IO_L8P_T1_D11_14 Sch=led[3]
set_property -dict { PACKAGE_PIN R18   IOSTANDARD LVCMOS33 } [get_ports { LED[4] }]; #IO_L7P_T1_D09_14 Sch=led[4]
set_property -dict { PACKAGE_PIN V17   IOSTANDARD LVCMOS33 } [get_ports { LED[5] }]; #IO_L18N_T2_A11_D27_14 Sch=led[5]
set_property -dict { PACKAGE_PIN U17   IOSTANDARD LVCMOS33 } [get_ports { LED[6] }]; #IO_L17P_T2_A14_D30_14 Sch=led[6]
set_property -dict { PACKAGE_PIN U16   IOSTANDARD LVCMOS33 } [get_ports { LED[7] }]; #IO_L18P_T2_A12_D28_14 Sch=led[7]
set_property -dict { PACKAGE_PIN V16   IOSTANDARD LVCMOS33 } [get_ports { LED[8] }]; #IO_L16N_T2_A15_D31_14 Sch=led[8]
set_property -dict { PACKAGE_PIN T15   IOSTANDARD LVCMOS33 } [get_ports { LED[9] }]; #IO_L14N_T2_SRCC_14 Sch=led[9]
set_property -dict { PACKAGE_PIN U14   IOSTANDARD LVCMOS33 } [get_ports { LED[10] }]; #IO_L22P_T3_A05_D21_14 Sch=led[10]
set_property -dict { PACKAGE_PIN T16   IOSTANDARD LVCMOS33 } [get_ports { LED[11] }]; #IO_L15N_T2_DQS_DOUT_CSO_B_14 Sch=led[11]
set_property -dict { PACKAGE_PIN V15   IOSTANDARD LVCMOS33 } [get_ports { LED[12] }]; #IO_L16P_T2_CSI_B_14 Sch=led[12]
set_property -dict { PACKAGE_PIN V14   IOSTANDARD LVCMOS33 } [get_ports { LED[13] }]; #IO_L22N_T3_A04_D20_14 Sch=led[13]
set_property -dict { PACKAGE_PIN V12   IOSTANDARD LVCMOS33 } [get_ports { LED[14] }]; #IO_L20N_T3_A07_D23_14 Sch=led[14]
set_property -dict { PACKAGE_PIN V11   IOSTANDARD LVCMOS33 } [get_ports { LED[15] }]; #IO_L21N_T3_DQS_A06_D22_14 Sch=led[15]

## 7 segment display
set_property -dict { PACKAGE_PIN T10   IOSTANDARD LVCMOS33 } [get_ports { CA }]; #IO_L24N_T3_A00_D16_14 Sch=ca
set_property -dict { PACKAGE_PIN R10   IOSTANDARD LVCMOS33 } [get_ports { CB }]; #IO_25_14 Sch=cb
set_property -dict { PACKAGE_PIN K16   IOSTANDARD LVCMOS33 } [get_ports { CC }]; #IO_25_15 Sch=cc
set_property -dict { PACKAGE_PIN K13   IOSTANDARD LVCMOS33 } [get_ports { CD }]; #IO_L17P_T2_A26_15 Sch=cd
set_property -dict { PACKAGE_PIN P15   IOSTANDARD LVCMOS33 } [get_ports { CE }]; #IO_L13P_T2_MRCC_14 Sch=ce
set_property -dict { PACKAGE_PIN T11   IOSTANDARD LVCMOS33 } [get_ports { CF }]; #IO_L19P_T3_A10_D26_14 Sch=cf
set_property -dict { PACKAGE_PIN L18   IOSTANDARD LVCMOS33 } [get_ports { CG }]; #IO_L4P_T0_D04_14 Sch=cg
# set_property -dict { PACKAGE_PIN H15   IOSTANDARD LVCMOS33 } [get_ports { DP }]; #IO_L19N_T3_A21_VREF_15 Sch=dp
set_property -dict { PACKAGE_PIN J17   IOSTANDARD LVCMOS33 } [get_ports { AN[0] }]; #IO_L23P_T3_FOE_B_15 Sch=an[0]
set_property -dict { PACKAGE_PIN J18   IOSTANDARD LVCMOS33 } [get_ports { AN[1] }]; #IO_L23N_T3_FWE_B_15 Sch=an[1]
set_property -dict { PACKAGE_PIN T9    IOSTANDARD LVCMOS33 } [get_ports { AN[2] }]; #IO_L24P_T3_A01_D17_14 Sch=an[2]
set_property -dict { PACKAGE_PIN J14   IOSTANDARD LVCMOS33 } [get_ports { AN[3] }]; #IO_L19P_T3_A22_15 Sch=an[3]
set_property -dict { PACKAGE_PIN P14   IOSTANDARD LVCMOS33 } [get_ports { AN[4] }]; #IO_L8N_T1_D12_14 Sch=an[4]
set_property -dict { PACKAGE_PIN T14   IOSTANDARD LVCMOS33 } [get_ports { AN[5] }]; #IO_L14P_T2_SRCC_14 Sch=an[5]
set_property -dict { PACKAGE_PIN K2    IOSTANDARD LVCMOS33 } [get_ports { AN[6] }]; #IO_L23P_T3_35 Sch=an[6]
set_property -dict { PACKAGE_PIN U13   IOSTANDARD LVCMOS33 } [get_ports { AN[7] }]; #IO_L23N_T3_A02_D18_14 Sch=an[7]

## Buttons
set_property -dict { PACKAGE_PIN C12   IOSTANDARD LVCMOS33 } [get_ports { resetn }]; #IO_L3P_T0_DQS_AD1P_15 Sch=cpu_resetn

После выполнения имплементации, нетлист и схема остаются неизменными, однако использованные для реализации схемы примитивы получают свой "адрес" внутри ПЛИС:

cell_add../.pic/Introduction/Implementation%20steps/fig_04.png

Теперь, мы можем посмотреть на "внутренности" нашей ПЛИС xc7a100tcsg324-1 и то, как через её примитивы будет реализована наша схема. Для этого необходимо отрыть имплементированное устройство: Implementation -> Open implemented design. Откроется следующее окно:

../.pic/Introduction/Implementation%20steps/fig_05.png

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

Нас интересует "бледно-голубая точка", расположенная в нижнем левом углу прямоугольника X0Y1 (выделено красным). Если отмасштабировать эту зону, мы найдем используемый нами LUT:

../.pic/Introduction/Implementation%20steps/fig_06.png

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

Generate Bitstream

После того, как САПР определил конкретные примитивы, их режим работы, и пути сигнала между ними, необходимо создать двоичный файл (bitstream), который позволит сконфигурировать ПЛИС необходимым нам образом. Получив этот файл, остается запрограммировать ПЛИС, после чего она воплотит разработанное устройство.

Выводы

Таким образом, маршрут перехода от HDL-описания устройства до его реализации в ПЛИС выглядит следующим образом:

  1. Сперва происходит анализ HDL-описания. В ходе этого анализа выявляются простейшие структуры: регистры, мультиплексоры, вычислительные блоки (сложения/умножения/сдвига и т.п.). Строится граф схемы, построенной с помощью этих структур (нетлист). Данный нетлист платформонезависим, т.е. не привязан к конкретной ПЛИС.
  2. После происходит этап синтеза нетлиста, полученного на предыдущем этапе в нетлист, использующий имеющиеся ресурсы конкретной ПЛИС. Все, использовавшиеся на предыдущем этапе структуры (регистры, мультиплексоры и прочие блоки) реализуются через примитивы ПЛИС (LUT-ы, D-триггеры, блоки сложения и т.п.).
  3. Затем происходит этап размещения схемы внутри ПЛИС: если на предыдущем этапе часть схемы была реализована через LUT, то на этом этапе решается какой именно LUT будет использован. Область допустимых решений по этому вопросу сужается путем наложения ограничений (constraints).
  4. После размещения остается только сгенерировать двоичный файл (bitstream), который во время прошивки сконфигурирует ПЛИС на реализацию нашей схемы.

Список использованной литературы

  1. Форум Xilinx: what exactly is 'elaborating' a design?

Лабораторная работа 1 "Сумматор"

Цель

Познакомиться с САПР Vivado и научиться реализовывать в нём простейшие схемотехнические модули с помощью конструкций языка SystemVerilog.

Допуск к лабораторной работе

Изучить описание модулей на языке SystemVerilog.

Ход работы

  1. Тренинг по созданию проекта в Vivado;
  2. Изучение, реализация и проверка полного однобитного сумматора;
  3. Изучение реализации полного четырехбитного сумматора;
  4. Реализация полного четырехбитного сумматора;
  5. Реализация 32-битного сумматора.

Теория

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

Давайте начнем с примера и сложим в столбик какую-нибудь пару чисел, например 42 и 79:

../../.pic/Labs/lab_01_adder/column_add_dec.drawio.svg

2 + 9             = 11 ➨ 1 пишем, 1 "в уме"
4 + 7 + "1 в уме" = 12 ➨ 2 пишем, 1 "в уме"
0 + 0 + "1 в уме" = 1

Итого, 121.

Назовём то, что мы звали "1 в уме", переносом разряда.

Теперь попробуем сделать то же самое, только в двоичной системе исчисления. К примеру, над числами 3 и 5. Три в двоичной системе записывается как 011. Пять записывается как 101.

../../.pic/Labs/lab_01_adder/column_add_bin.drawio.svg

Поскольку в двоичной системе всего две цифры: 0 и 1, один разряд не может превысить 1. Складывая числа 1 и 1, вы получаете 2, что не умещается в один разряд, поэтому мы пишем 0 и держим 1 "в уме". Это снова перенос разряда. Поскольку в двоичной арифметике разряд называют битом, перенос разряда называют переносом бита, а сам разряд, который перенесли — битом переноса.

Полный однобитный сумматор

Полный однобитный сумматор — это цифровое устройство с тремя входными сигналами: операндами a, b и входным битом переноса, которое складывает их между собой, возвращая два выходных сигнала: однобитный результат суммы и выходной бит переноса. Что такое входной бит переноса? Давайте вспомним второй этап сложения чисел 42 и 79:

4 + 7 + "1 в уме" = 12 ➨ 2 пишем, 1 "в уме"

+ "1 в уме" — это прибавление разряда, перенесённого с предыдущего этапа сложения.

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

Реализация одноразрядного сложения

Можно ли как-то описать сложение двух одноразрядных двоичных чисел с помощью логических операций? Давайте посмотрим на таблицу истинности подобной операции

../../.pic/Labs/lab_01_adder/tt1.png

Таблица истинности одноразрядного сложения.

S — это цифра, записываемая в столбце сложения под числами a и b. C (carry, перенос) — это цифра, записываемая левее, если произошел перенос разряда. Как мы видим, перенос разряда происходит только в случае, когда оба числа одновременно равны единице. При этом в этот момент значение S обращается в 0, и результат записывается как 10, что в двоичной системе означает 2. Кроме того, S = 0 и в случае, когда оба операнда одновременно равны нулю. Вы можете заметить, что S равно нулю в тех случаях, когда а и b равны, и не равно нулю в противоположном случае. Подобным свойством обладает логическая операция Исключающее ИЛИ (eXclusive OR, XOR):

../../.pic/Labs/lab_01_adder/tt2.png

Таблица истинности операции Исключающее ИЛИ (XOR).

Для бита переноса всё ещё проще — он описывается операцией логическое И:

../../.pic/Labs/lab_01_adder/tt3.png

Таблица истинности операции И.

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

../../.pic/Labs/lab_01_adder/fig_01.drawio.svg

Рисунок 1. Цифровая схема устройства, складывающего два операнда с сохранением переноса (полусумматора).

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

Таким образом, таблицы истинности немного усложняются:

../../.pic/Labs/lab_01_adder/tt4.png

Таблица истинности сигналов полного однобитного сумматора.

Поскольку теперь у нас есть и входной и выходной биты переноса, для их различия добавлены индексы “in” и “out”.

Как в таком случае описать S? Например, как а ^ b ^ Cіn, где ^ — операция исключающего ИЛИ. Давайте сравним такую операцию с таблицей истинности. Сперва вспомним, что Исключающее ИЛИ — ассоциативная операция [(a^b)^c = a^(b^с)], т.е. нам не важен порядок вычисления. Предположим, что Cin равен нулю. Исключающее ИЛИ с нулем дает второй операнд (a^0=a), значит (a^b)^0 = a^b. Это соответствует верхней половине таблицы истинности для сигнала S, когда Cin равен нулю.

Предположим, что Cin равен единице. Исключающее ИЛИ с единицей дает нам отрицание второго операнда (a^1=!a), значит (a^b)^1=!(a^b). Это соответствует нижней половине таблицы истинности, когда Cin равен единице.

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

Cоut = (a&b) | (а&Cіn) | (b&Cіn), где & — логическое И, | — логическое ИЛИ.

Цифровая схема устройства с описанным поведением выглядит следующим образом:

../../.pic/Labs/lab_01_adder/fig_02.drawio.svg

Рисунок 2. Цифровая схема полного однобитного сумматора.

Практика

Реализуем схему полусумматора (рис.1) в виде модуля, описанного на языке SystemVerilog.

Модуль half_adder имеет два входных сигнала и два выходных. Входы a_i и b_i идут на два логических элемента: Исключающее ИЛИ и И, выходы которых подключены к выходам модуля sum_o и carry_o соответственно.

Прочти меня перед использованием кода из примера.

Во все примеры кода намеренно вставлены неподдерживаемые символы. Не копируй, одумайся!

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

— Но мне очень надо.

../../.pic/Labs/lab_01_adder/im_watching_you.jpg

— Я переписал пример точь-в-точь, а он все равно не работает!

Позови преподавателя, он тебе поможет.

module half_adder(
  inрut  logic    a_i,     // Входные сигналы
  inрut  logic    b_i,

  outрut logic    sum_o,   // Выходной сигнал
  outрut logic    carry_o

  );

  assign sum_o = a_i ^ b_i;
  assign carry_o = a_i & b_i;

  endmodule

Листинг 1. SystemVerilog-код модуля half_adder.

По данному коду, САПР может реализовать следующую схему:

../../.pic/Labs/lab_01_adder/fig_03.png

Рисунок 3. Цифровая схема модуля half_adder, сгенерированная САПР Vivado.

Схема похожа на рис. 1, но как проверить, что эта схема не содержит ошибок и делает именно то, что от нее ожидается?

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

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

Внутри тестбенча можно использовать конструкции из несинтезируемого подмножества языка SystemVerilog, в частности программный блок initial, в котором команды выполняются последовательно, что делает этот блок чем-то отдаленно похожим на проверяющую программу. Поскольку изменение внутренних цепей происходит с некоторой задержкой относительно изменений входных сигналов, при моделировании есть возможность делать паузы между командами. Это делается с помощью специального символа #, за которым указывается количество отсчётов времени симуляции, которое нужно пропустить перед следующей командой.

Перед тем как писать верификационное окружение, необходимо составить план того, как будет проводиться проверка устройства (составить верификационный план).

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

module testbench();                // <- Не имеет ни входов, ни выходов!
  logic a, b, carry, sum;

  half_adder DUT(                  // <- Подключаем проверяемый модуль
    .a_i    (a),
    .b_i    (b),
    .carry_o(p),
    .sum_o  (s)
);

  initial begin
    a = 1'b0; b = 1'b0;            // <- Подаём на входы модуля тестовые
    #10;                           //    воздействия
    a = 1'b0; b = 1'b1;
    #10;                           // <- Делаем паузу в десять отсчётов
    a = 1'b1; b = 1'b0;            //    времени симуляции перед очередным
    #10;                           //    изменением входных сигналов
    a = 1'b1; b = 1'b1;
  end
endmodule

Листинг 2. SystemVerilog-код тестбенча для модуля example.

../../.pic/Labs/lab_01_adder/fig_04.png

Рисунок 4. Временная диаграмма, моделирующая работу схемы с рис. 3.

В данной лабораторной работе вам предстоит реализовать схему полного однобитного сумматора (рис. 2).

Полный четырехбитный сумматор

Складывать несколько однобитных чисел не сильно впечатляет, поэтому сейчас мы займемся по-настоящему крутыми вещами — будем складывать пары четырехбитных чисел! Четырехбитные числа — это сила, они позволяют выбрать любое число от 0 до 15, а если сложить два числа с сохранением переноса, то вы получите диапазон результатов вплоть до 31! И вся эта вычислительная мощь будет у вас прямо под рукой — бери и пользуйся!

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

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

../../.pic/Labs/lab_01_adder/fig_05.drawio.svg

Рисунок 5. Схема четырехбитного сумматора.

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

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

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

../../.pic/Labs/lab_01_adder/fig_06.png

Рисунок 6. Схема четырехбитного сумматора, сгенерированная САПР Vivado.

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

Задание

Опишите полный однобитный сумматор, схема которого представлена на Рис. 2. Прототип модуля следующий:

module fulladder(
  input  logic a_i,
  input  logic b_i,
  input  logic carry_i,
  output logic sum_o,
  output logic carry_o
);

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

module fulladder32(
    іnput  logic [31:0] a_i,
    іnput  logic [31:0] b_i,
    іnput  logic        carry_i,
    оutput logic [31:0] sum_o,
    оutput logic        carry_o
);

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

Если вы решите делать 4-разрядный сумматор, то модуль должен быть описан в соответствии со следующим прототипом:

module fulladder4(
  input  logic [3:0] a_i,
  input  logic [3:0] b_i,
  input  logic       carry_i,
  output logic [3:0] sum_o,
  output logic       carry_o
);

либо же можно создать массив однобитных сумматоров.

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

  • если разрядность подключаемого сигнала совпадает с разрядностью порта модуля из массива, этот сигнал подключается к каждому из модулей в массиве;
  • если разрядность подключаемого сигнала превосходит разрядность порта модуля из массива в N раз (где N — количество модулей в массиве), к модулю подключается соответствующий диапазон бит подключаемого сигнала (диапазон младших бит будет подключен к модулю с меньшим индексом в массиве).
  • если разрядность подключаемого сигнала не подходит ни под один из описанных выше пунктов, происходит ошибка синтеза схемы, поскольку в этом случае САПР не способен понять каким образом подключать данный сигнал к каждому модулю из массива.

Далее идет пример того, как можно создать массив модулей:

module example1(
  input  logic [3:0] a,
  input  logic       b,
  output logic       c,
  output logic       d
);

  assign c = |a ^ b;
  assign d = &a;

endmodule

module example2(
  input  logic [31:0] A,
  input  logic        B,
  output logic [ 8:0] C
);

example1 instance_array[7:0]( // Создается массив из 8 модулей example1
  .a(A),                      // Поскольку разрядность сигнала A в 8 раз больше
                              // разрядности входа a, к каждому модулю в массиве
                              // будет подключен свой диапазон бит сигнала A
                              // (к instance_array[0] будет подключен диапазон
                              // A[3:0], к instance_array[7] будет подключен
                              // диапазон A[31:28]).

  .b(B)                       // Поскольку разрядность сигнала B совпадает с
                              // разрядностью входа b, сигнал B будет подключен
                              // как есть ко всем модулям в массиве.

  .c(C[7:0])                  // Поскольку разрядность сигнала C не равна
                              // ни разрядности входа c, ни его увосьмиренной
                              // разрядности, мы должны выбрать такой диапазон
                              // бит, который будет удовлетворять одному из
                              // этих требований.

  .d(C[8])                    // Аналогично предыдущему.
);
endmodule

Листинг 3. Пример создания массива модулей.

Порядок выполнения задания

  1. Создайте проект, согласно руководству по созданию проекта в Vivado
  2. В Design Sources проекта создайте SystemVerilog-файл fulladder.
  3. Опишите в файле модуль fulladder, схема которого представлена на Рис. 2.
  4. Проверьте 1-битный сумматор. Для этого:
    1. В Simulation Sources проекта создайте SystemVerilog-файл tb_fulladder.
    2. Вставьте содержимое файла tb_fulladder.sv, расположенного рядом с данным документом.
    3. Запустите моделирование. Для запуска симуляции воспользуйтесь этой инструкцией.
    4. Убедитесь по сигналам временной диаграммы, что модуль работает корректно.
  5. В Design Sources проекта создайте SystemVerilog-файл fulladder4.
  6. Опишите модуль fulladder4, схема которого представлена на Рис. 5 и 6, используя иерархию модулей, чтобы в нем выполнялось поразрядное сложение двух 4-разрядных чисел и входного бита переноса. Некоторые входы и выходы модуля будет необходимо описать в виде векторов.
    1. Обратите внимание, что входной бит переноса должен подаваться на сумматор, выполняющий сложение нулевого разряда, выходной бит переноса соединяется с выходным битом переноса сумматора, выполняющего сложение 4-го разряда.
  7. Проверьте 4-битный сумматор. Для этого:
    1. В Simulation Sources проекта создайте SystemVerilog-файл tb_fulladder4.
    2. Вставьте содержимое файла tb_fulladder4.sv. Нажмите по нему в окне Sources ПКМ и выберите Set as Top.
    3. Запустите моделирование. Для запуска симуляции воспользуйтесь этой инструкцией.
    4. Проверьте содержимое TCL-консоли. Убедитесь в появлении сообщения о завершении теста. В случае, если в tcl-консоли написано CLICK THE BUTTON 'Run All', вам необходимо нажать соответствующую кнопку на панели моделирования.
    5. Убедитесь по сигналам временной диаграммы, что модуль работает корректно.
  8. В Design Sources проекта создайте SystemVerilog-файл fulladder32.
  9. Опишите модуль fulladder32 так, чтобы в нем выполнялось поразрядное сложение двух 32-разрядных чисел и входного бита переноса. Его можно реализовать через последовательное соединение восьми 4-битных сумматоров, либо же можно соединить 32 однобитных сумматора (как вручную, так и с создания массива модулей).
    1. Обратите внимание, что входной бит переноса должен подаваться на сумматор, выполняющий сложение нулевого разряда, выходной бит переноса соединяется с выходным битом переноса сумматора, выполняющего сложение 31-го разряда.
  10. Проверьте 32-битный сумматор. Для этого:
    1. В Simulation Sources проекта создайте SystemVerilog-файл tb_fulladder32.
    2. Вставьте содержимое файла tb_fulladder32.sv. Нажмите по нему в окне Sources ПКМ и выберите Set as Top.
    3. Запустите моделирование.
    4. Проверьте содержимое TCL-консоли. Убедитесь в появлении сообщения о завершении теста. В случае, если в tcl-консоли написано CLICK THE BUTTON 'Run All', вам необходимо нажать соответствующую кнопку на панели моделирования.
    5. Если в tcl-консоли были сообщения об ошибках, разберитесь в причине ошибок по временной диаграмме и исправьте их.
  11. Проверьте работоспособность вашей цифровой схемы в ПЛИС. Для этого:
    1. Добавьте файлы из папки board files в проект.
      1. Файл nexys_adder.sv необходимо добавить в Design Sources проекта.
      2. Файл nexys_a7_100t.xdc необходимо добавить в Constraints проекта.
    2. Выберите nexys_adder в качестве модуля верхнего уровня (top-level).
    3. Выполните генерацию битстрима и сконфигурируйте ПЛИС. Для этого воспользуйтесь следующей инструкцией.
    4. Описание логики работы модуля верхнего уровня и связи периферии ПЛИС с реализованным модулем находится в папке board files.

Лабораторная работа 2. Арифметико-логическое устройство

Так как основной задачей процессора является обработка цифровых данных, одним из его основных блоков является арифметико-логическое устройство (АЛУ). Задача АЛУ производить над входными данным арифметические и поразрядно логические операции.

Цель

Используя навыки по описанию мультиплексоров, писать блок арифметико-логического устройства (АЛУ) на языке SystemVerilog.

Допуск к лабораторной работе

Освоить описание мультиплексора на языке SystemVerilog.

Общий ход выполнения работы

  1. Изучить устройство и принцип работы АЛУ (раздел #теория)
  2. Изучить языковые конструкции SystemVerilog для реализации АЛУ (раздел #инструменты)
  3. Внимательно ознакомиться с заданием (раздел #задание)
  4. Описать модуль АЛУ, проверить его предоставленным верификационным окружением.
  5. Проверить работу АЛУ в ПЛИС.

Теория

Арифметико-логическое устройство (АЛУ, Arithmetic Logic Unit – ALU) – это блок процессора, выполняющий арифметические и поразрядно логические операции. Разница между арифметическими и логическими операциями в отсутствии у последних бита переноса, так как логические операции происходят между однобитными числами и дают однобитный результат, а в случае АЛУ (в рамках данной лабораторной работы) одновременно между 32-мя однобитными парами чисел. В логических операциях результаты значений отдельных битов друг с другом никак не связаны.

Также, кроме результата операций, АЛУ формирует флаги, которые показывают выполняется ли заданное условие. Например, выведет 1, если один операнд меньше другого.

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

../../.pic/Labs/lab_02_alu/fig_01.drawio.svg

Рисунок 1. Структурное обозначение элемента АЛУ[1, стр. 305].

На рис. 1 изображен пример АЛУ, используемый в книге "Цифровая схемотехника и архитектура компьютера" Харрис и Харрис. На входы A и B поступают операнды с разрядностью N. На трехбитный вход F подается код операции. Например, если туда подать 000, то на выходе Y появится результат операции логическое И между битами операндов A и B. Если на F подать 010, то на выходе появится результат сложения. Это лишь пример, разрядность и коды могут отличаться в зависимости от количества выполняемых операций и архитектуры.

Существует несколько подходов к реализации АЛУ, отличающиеся внутренней организацией. В лабораторных работах применяется повсеместно используемый подход мультиплексирования операций, то есть подключения нескольких операционных устройств (которые выполняют какие-то операции, например сложения, логическое И и т.п.) к мультиплексору, который будет передавать результат нужного операционного устройства на выходы АЛУ.

Рассмотрим на примере все того же АЛУ MIPS из книги Харрисов. На рис. 2, в левой его части, изображена внутренняя организация этого АЛУ, справа – таблица соответствия кодов операциям. На выходе схемы (внизу) стоит четырехвходовый мультиплексор, управляемый двумя из трех битов F. К его входам подключены N логических И (побитовое И N-разрядных операндов), N логических ИЛИ, N-разрядный сумматор и Zero Extend – устройство делающее из однобитного числа N-битное число, дополняя нулями слева.

К одному из входов этих операционных устройств подключен A без изменений, а ко второму подключен выход двухвходового мультиплексора, управляемого оставшимся битом F. То есть F[2] определяет, что будет вторым операндом: B или ~B. Вдобавок F[2] подается на входной перенос сумматора, то есть, когда F[2] == 1 на выходе сумматора появляется результат операции A + ~B + 1, что (с учетом дополнительного кода) эквивалентно A – B.

../../.pic/Labs/lab_02_alu/fig_02.drawio.svg

Рисунок 2. Структурная схема АЛУ MIPS[1, стр. 305].

Посмотрим, что произойдет, если на вход F такого АЛУ подать 111. Будет выполняться операция SLT(сокращение от Set Less Then) – выдать 1, если A меньше B, в противном случае — выдать 0. Биты F[1:0] переключат мультиплексор на выход блока Zero Extend. На вход Zero Extend поступает старший бит выхода сумматора, этот бит отвечает за знак результата. Так как F[2] == 1, сумматор вычисляет A + ~B + 1, то есть A – B, значит, если A < B, то результат вычитания будет отрицательный, а старший бит Y[N-1] == 1. Если A не меньше B, то разность будет неотрицательна, а Y[N-1] == 0, как и требуется от этой операции.

../../.pic/Labs/lab_02_alu/fig_03.drawio.svg

Рисунок 3. Пример исполнения операции АЛУ.

Преимущество такой организации АЛУ в его простой модификации, настройке под нужные коды операций, читаемости кода и масштабируемости. Можно легко добавить или убрать требуемые операции. Подумайте, как бы вы обновили данную схему, если бы от вас потребовалось расширить её функционал операциями XOR (Исключающее ИЛИ) и (SGE операция "больше либо равно")?

Инструменты

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

При описании очередной комбинации управляющего сигнала, выходу мультиплексора можно сразу присваивать необходимое логическое выражение (например результат побитового ИЛИ можно подать на выход сразу в виде выражения: a | b, однако в некоторых случаях выражения будут сложнее из-за различных особенностей реализации, о которых будет рассказано в задании).

Параметры

Очень удобным на практике оказывается использование параметров. Параметры добавляют модулю гибкости, позволяя убрать "магические" константы из описания модулей, подставляя вместо них выразительное символьное имя. Параметры отдаленно схожи с макросами #define в языке Си, однако стоит понимать, параметры используются на этапе синтеза схемы, в отличие от макросов в языке Си, которые заменяются препроцессором еще до этапа компиляции.

Допустим, ваше устройство должно включить тостер, если на вход ему придет сигнал 32'haf3c5bd0. Человек, не знакомый с устройством, при прочтении этого кода будет недоумевать, что это за число и почему используется именно оно. Однако, скрыв его за параметром TOASTER_EN, читающий поймет, что это код включения тостера. Кроме того, если некоторая константа должна использоваться в нескольких местах кода, то определив её через в виде параметра, можно будет менять её в одном месте, и она тут же поменяется везде.

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

Параметр может быть объявлен в модуле двумя способами:

  • в прототипе модуля;
  • в теле описания модуля.

В первом случае для указания параметра после имени модуля ставится символ #, за которым в круглых скобках пишется ключевое слово parameter затем указывается тип параметра (по умолчанию знаковое 32-битное число), после чего указывается имя и (опционально) значение по умолчанию.

Пример:

module overflow #(parameter WIDTH = 32)(
  input  logic [WIDTH-1 : 0]  a, b,
  output logic                 overflow
);

logic [WIDТН : 0] sum;

ass𝚒gn sum = a + b;
ass𝚒gn overflow = sum[WIDTH];

endmodule

В случае, если параметр не влияет на разрядность портов, его можно объявить в теле модуля:

module toaster(
  input  logic [31:0] command,
  output logic        power
)

parameter TOASTER_EN = 32'haf3c5bd0;

assign power = command == TOASTER_EN;

endmodule

В случае АЛУ будет удобно использовать параметры для обозначения кодов команд. Во-первых, для того чтобы в case не допустить ошибок, а во-вторых – чтобы можно было легко менять управляющие коды для повторного использования АЛУ в других проектах.

Сравните сами:

//parameter SLT = 5'b00011;
//parameter BEQ = 5'b11000;

//...

always_comb
  case(ALUOp)
  //...
  5'b00011: //...   // вообще же ничего не понятно
  5'b11000: //...   // никуда не годится

и

parameter SLT = 5'b00011;
parameter BEQ = 5'b11000;

//...

аlwауs_comb
  case(ALUOp)
  //...
  SLT: //...   // очень понятно
  BEQ: //...   // так лаконично и красиво

С параметрами гораздо взрослее, серьезнее и понятнее смотрится. Кстати, сразу на заметку: в SystemVerilog можно объединять группу параметров в пакет (package), а затем импортировать его внутрь модуля, позволяя переиспользовать параметры без повторного их прописывания для других модулей.

Делается это следующим образом.

Сперва создается SystemVerilog-файл, который будет содержать пакет (к примеру, содержимое файла может быть таким):

package riscv_params_pkg;
  parameter ISA_WIDTH   = 32;
  parameter ANOTHER_EX  = 15;
endpackage

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

module riscv_processor(
  //...Порты
);

import riscv_params_pkg::ISA_WIDTH;   // Если необходимо импортировать
import riscv_params_pkg::ANOTHER_EX;  // все параметры в пакете,эти две строчки
                                      // могут быть заменены закомментированной
                                      // ниже строкой:
//import riscv_params_pkg::*;

endmodule

При реализации АЛУ, вам также потребуется использовать операции сдвига, к которым относятся:

  • << — логический сдвиг влево
  • >> — логический сдвиг вправо
  • >>> — арифметический сдвиг вправо







Особенности реализации сдвига

Для ВСЕХ операций сдвига вы должны брать только 5 младших бит операнда B.

Сами посмотрите: пятью битами можно описать 32 комбинации [0-31], а у операнда А будет использоваться ровно 32 бита. Это обязательное требование, поскольку старшие биты в дальнейшем будут использоваться по другому назначению и, если вы упустите это, ваш будущий процессор станет работать неправильно.







Задание

Необходимо на языке SystemVerilog реализовать АЛУ в соответствии со следующим прототипом:


module аlu_r𝚒sсv (
  𝚒nput  logic [31:0]  a_i,
  𝚒nput  logic [31:0]  b_i,
  𝚒nput  logic [4:0]   alu_op_i,
  оutput logic         flag_o,
  оutput logic [31:0]  result_o
);

import alu_opcodes_pkg::*;      // импорт параметров, содержащих
                                // коды операций для АЛУ

endmodule

Для стандартного набора целочисленных операций архитектуры RISC-V требуется выполнять 16 различных операций. Для кодирования 16 операций было бы достаточно 4 бит, но в лабораторной работе предлагается использовать 5-битный код, что связано с особенностями кодирования инструкций. Видно, что старший бит кода операции указывает на то, является ли операция вычислительной или это операция сравнения.

Для удобства чтения, список инструкций разбит на две таблицы.

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

alu_op_i={cmp, add/sub, alu_op_i}result_oОперация
ADD0 0 000result_o = a_i + b_iСложение
SUB0 1 000result_o = a_i – b_iВычитание
SLL0 0 001result_o = a_i << b_iСдвиг влево
SLTS0 0 010result_o = a_i < b_i (знаковое сравнение)Знаковое сравнение
SLTU0 0 011result_o = a_i < b_iБеззнаковое сравнение
XOR0 0 100result_o = a_i ^ b_iПобитовое исключающее ИЛИ
SRL0 0 101result_o = a_i >> b_iСдвиг вправо
SRA0 1 101result_o = a_i >>> b_iАрифметический сдвиг вправо (операнд a_i — знаковый)
OR0 0 110result_o = a_i | b_iПобитовое логическое ИЛИ
AND0 0 111result_o = a_i & b_iПобитовое логическое И

Таблица 1. Список вычислительных операций.

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

alu_op_i={cmp, add/sub, alu_op_i}flag_oОперация
EQ1 1 000flag_o = (a_i == b_i)Выставить флаг, если равны
NE1 1 001flag_o = (a_i != b_i)Выставить флаг, если не равны
LTS1 1 100flag_o = a_i < b_i (знаковое сравнение)Знаковое сравнение <
GES1 1 101flag_o = a_i ≥ b_i (знаковое сравнение)Знаковое сравнение
LTU1 1 110flag_o = a_i < b_iБеззнаковое сравнение <
GEU1 1 111flag_o = a_i ≥ b_iБеззнаковое сравнение

Таблица 2. Список операций сравнения.

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

Несмотря на разделение на вычислительные операции, и операции сравнения, в Таблице 1 (вычислительных операция) оказалось две операции SLTS и SLTU, которые выполняют сравнения. В итоге у нас есть две похожие пары инструкций:

  • LTS
  • LTU
  • SLTS
  • SLTU

Первая пара инструкций вычисляет "ветвительный" результат. Результат операции будет подан на выходной сигнал flag_o и использован непосредственно при ветвлении.

Вторые две инструкции используются для получения "вычислительного" результата. Т.е. результат сравнения будет подан на выходной сигнал result_o так же, как подается результат операции ADD, и будет использован в неких вычислениях, избегая при этом условного перехода.

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

  1. В каждой итерации цикла сделать ветвление: в одном случае инкрементировать переменную, в другом случае — нет (для ветвления использовать "ветвительную" операцию LTS).
  2. В каждой итерации цикла складывать текущее значение переменной с результатом "вычислительной" операции SLTS.

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

Различие между SLTS и SLTU (или LTS и LTU) заключается в том, как мы интерпретируем операнды: как знаковые числа (операции STLS и LTS) или как беззнаковые (операции SLTU и LTU).

Предположим, мы сравниваем два двоичных числа: 1011 и 0100. Если интерпретировать эти числа как беззнаковые, то это 11 и 4, результат: 11 > 4. Однако если интерпретировать эти числа как знаковые, то теперь это числа -5 и 4 и в этом случае -5 < 4.

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

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

Конструкция $signed говорит САПР интерпретировать число, переданное в качестве операнда, как знаковое.

  аss𝚒gn Rеsult = $s𝚒gnеd(А) >>> В[4:0];

В этом примере некоторому сигналу Result присваивают результат сдвига знакового числа A на значение количества бит получаемых из младших 5 бит сигнала B.

Так как используются не все возможные комбинации управляющего сигнала АЛУ, то при описании через case не забывайте использовать default. Если описать АЛУ как задумано, то получится что-то похожее на картинку ниже. Но не обязательно, зависит от вашего описания.

../../.pic/Labs/lab_02_alu/fig_04.png

Порядок выполнения задания

  1. Добавьте в проект файл alu_opcodes_pkg.sv. Этот файл содержит объявление пакета alu_opcodes_pkg, в котором прописаны все опкоды АЛУ.
  2. В Design Sources проекта создайте SystemVerilog-файл аlu_r𝚒sсv.sv.
  3. Опишите в нем модуль АЛУ с таким же именем и портами, как указано в задании.
    1. Поскольку у вас два выходных сигнала, зависящих от сигнала alu_op_i, вам потребуется описать два разных мультиплексора (их лучше всего описывать через два отдельных блока case). При описании, используйте default на оставшиеся комбинации сигнала alu_op_i.
    2. Следите за разрядностью ваших сигналов.
    3. Для реализации АЛУ, руководствуйтесь таблицей с операциями, а не схемой в конце задания, которая приведена в качестве референса. Обратите внимание, в одной половине операций flag_o должен быть равен нулю, в другой result_o (т.е. всегда либо один, либо другой сигнал должен быть равен нулю). Именно поэтому удобней всего будет описывать АЛУ в двух разных блоках case.
    4. Вам не нужно переписывать опкоды из таблицы в качестве вариантов для блока case. Вместо этого используйте символьные имена с помощью параметров, импортированных из пакета alu_opcodes_pkg.
    5. При операции сложения вы должны использовать ваш 32-битный сумматор из первой лабораторной (описывая вычитание сумматор использовать не надо, можно использовать -).
      1. При подключении сумматора, на входной бит переноса необходимо подать значение 1'b0. Если не подать значение на входной бит переноса, результат суммы будет не определен (т.к. не определено одно из слагаемых).
      2. Выходной бит переноса при подключении сумматора можно не указывать, т.к. он использоваться не будет.
    6. При реализации операций сдвига, руководствуйтесь особенностями реализации сдвигов.
  4. После реализации модуля АЛУ его нужно будет проверить с помощью тестового окружения.
    1. Добавьте файл tb_alu.sv в Simulation sources.
    2. Для запуска симуляции воспользуйтесь этой инструкцией.
    3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран модуль tb_alu.
    4. Убедитесь, что симуляция завершена (об этом будет соответствующее сообщение в консоли). По завершению симуляции, в случае отсутствия ошибок, будет выведено сообщение "SUCCESS", в противном случае будут выведены сообщения об этих ошибках.
    5. В случае, если были найдены ошибки, вы должны найти и исправить их. Для этого руководствуйтесь документом.
  5. Проверьте работоспособность вашей цифровой схемы в ПЛИС. Для этого:
    1. Добавьте файлы из папки board files в проект.
      1. Файл nexys_alu.sv необходимо добавить в Design Sources проекта.
      2. Файл nexys_a7_100t.xdc необходимо добавить в Constraints проекта. В случае, если вы уже добавляли одноименный файл в рамках предыдущих лабораторных работ, его содержимое необходимо заменить содержимым нового файла.
    2. Выберите nexys_alu в качестве модуля верхнего уровня (top-level).
    3. Выполните генерацию битстрима и сконфигурируйте ПЛИС. Для этого воспользуйтесь следующей инструкцией.
    4. Описание логики работы модуля верхнего уровня и связи периферии ПЛИС с реализованным модулем находится в папке board files.

Список использованной литературы

  1. Д.М. Харрис, С.Л. Харрис / Цифровая схемотехника и архитектура компьютера / пер. с англ. Imagination Technologies / М.: ДМК Пресс, 2018.

Лабораторная работа 3 "Регистровый файл и внешняя память"

Процессор — это программно-управляемое устройство, выполняющее обработку информации и управление этим процессом. Очевидно, программа, которая управляет процессором, должна где-то храниться. Данные, с которыми процессор работает, тоже должны быть в доступном месте. Нужна память!

Цель

Описать на языке SystemVerilog элементы памяти для будущего процессора:

  • память команд (Instruction Memory);
  • память данных (Data Memory);
  • регистровый файл (Register File).

Допуск к лабораторной работе

Для успешного выполнения лабораторной работы, вам необходимо освоить:

Ход работы

  1. Изучить способы организации памяти (раздел #теория про память).
  2. Изучить конструкции SystemVerilog для реализации запоминающих элементов (раздел #инструменты).
  3. В проекте с прошлой лабораторной реализовать модули: Instruction Memory, Data Memory и Register File (#задание).
  4. Проверить с помощью тестового окружения корректность их работы.
  5. Проверить работу регистрового файла в ПЛИС.

Теория про память

Память — это устройство для упорядоченного хранения и выдачи информации. Различные запоминающие устройства отличаются способом и организацией хранения данных. Базовыми характеристиками памяти являются:

  • V — объем (количество бит данных, которые единовременно может хранить память);
  • a — разрядность адреса (ширина шины адреса, определяет адресное пространство — количество адресов отдельных ячеек памяти);
  • d — разрядность хранимых данных (разрядность ячейки памяти, как правило совпадает с разрядностью входных/выходных данных).

В общем случае V = 2^a * d.

Для объема памяти в 1 KiB (кибибайт, 1024 байта или 8192 бита) разрядность адреса может быть, например, 10 бит (что покрывает 2^10 = 1024 адреса), тогда разрядность хранимых данных должна быть 8 бит. 1024 * 8 = 8192, то есть 1 кибибайт. Если разрядность адреса, например, 8 бит (что покрывает 2^8 = 256 адресов), то разрядность данных d = V / 2^a это 8192 / 256 = 32 бита.

Однако, может быть такое, что не все ячейки памяти реально реализованы на кристалле микросхемы, то есть некоторые адреса существуют, но по ним не имеет смысла обращаться, а объем памяти, соответственно, не равен V ≠ 2^a * d — он меньше. Подобные случаи будут рассмотрены отдельно.

Память можно разделить на категории: ПЗУ (постоянное запоминающее устройство) и ОЗУ (оперативное запоминающее устройство). Из ПЗУ можно только считывать информацию, которая попадает в ПЗУ до начала использования памяти и не может изменяться в процессе работы. Из ОЗУ можно считывать и записывать информацию. В самом простом случае ПЗУ имеет один вход адреса addr и один выход считываемых данных read_data. На вход addr подается адрес требуемой ячейки памяти, на выходе read_data появляются данные, которые хранятся по этому адресу.

Для ОЗУ требуется больше сигналов. Кроме входного addr и выходного read_data добавляются: входные данные для записи write_data, сигнал синхронизации clk, который определяет момент записи данных и сигнал разрешения на запись write_enable, который контролирует нужно ли записывать данные или только считывать. Для того, чтобы записать информацию в такую память необходимо:

  • выставить адрес addr в который планируется запись данных,
  • выставить сами данные для записи на вход write_data,
  • установить сигнал write_enable в состояние разрешения записи (как правило это 1) и
  • дождаться нужного фронта clk — в этот момент данные будут записаны по указанному адресу. При этом, на выходе read_data будут старые данные, хранящиеся по адресу addr. На одном такте происходит одновременное считывание информации и запись новой.

Так же возможна реализация, в которой вход write_data и выход read_data объединены в единый вход/выход data. В этом случае операции чтения и записи разделены во времени и используют для этого один единый порт ввода-вывода (inout, двунаправленный порт) data.

../../.pic/Labs/lab_03_memory/fig_01.drawio.svg

Рисунок 1. Примеры блоков ПЗУ и ОЗУ.

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

Еще одной характеристикой памяти является количество доступных портов. Количество портов определяет к скольким ячейкам памяти можно обратиться одновременно. Проще говоря, сколько входов адреса существует. Все примеры памяти рассмотренные выше являются однопортовыми, то есть у них один порт. Например, если у памяти 2 входа адреса addr1 и addr2 — это двухпортовая память. При этом не важно, можно ли по этим адресам только читать/писать или выполнять обе операции.

Регистровый файл, который будет реализован в рамках данной работы, является трехпортовым, и имеет 2 порта на чтение и 1 порт на запись.

С точки зрения аппаратной реализации память в ПЛИС может быть блочной, распределенной или регистровой. Блочная память — это аппаратный блок памяти, который можно сконфигурировать под свои нужды. Распределенная и регистровая память (в отличие от блочной) реализуется на конфигурируемых логических блоках (см. как работает ПЛИС). Такая память привязана к расположению конфигурируемых логических блоков ПЛИС и как бы равномерно распределена по всему кристаллу. Вместо реализации логики конфигурируемые логические блоки используются для нужд памяти. Чтобы понять почему это возможно, рассмотрим структуру логического блока:

../../.pic/Labs/lab_03_memory/fig_02.png

Рисунок 2. Структурная схема логического блока в ПЛИС[1].

В логическом блоке есть таблицы подстановки (Look Up Table, LUT), которые представляют собой не что иное как память, которая переконфигурируется под нужды хранения, а не реализацию логики. Таким образом, трехвходовой LUT может выступать в роли восьмиразрядной памяти.

Однако LUT будет сложно приспособить под многопортовую память: посмотрим на схему еще раз: три входа LUT формируют адрес одной из восьми ячеек. Это означает, что среди этих восьми ячеек нельзя обратиться к двум из них одновременно.

Для реализации многопортовой памяти небольшого размера лучше воспользоваться расположенным в логическом блоке D-триггером (DFF на рис. 2). Несмотря на то, что D-триггер позволяет воспроизвести только 1 разряд элемента памяти, он не ограничивает реализацию по портам.

Таким образом, плюс распределенной памяти относительно регистровой заключается в лучшей утилизации ресурсов: одним трёхвходовым LUT можно описать до 8 бит распределенной памяти, в то время как одним D-триггером можно описать только один бит регистровой памяти. Предположим, что в ПЛИС размещены логические блоки, структура которых изображена на рис. 2 и нам необходимо реализовать 1KiB памяти. Мы можем реализовать распределенную память, используя 64 логических блока (в каждом блоке два трёхвходовых LUT), либо регистровую память, используя 1024 логических блока.

Минусом является ограниченность в реализации многопортовой памяти.

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

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

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

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

Инструменты для реализации памяти

Описание памяти на языке SystemVerilog

Память на языке SystemVerilog объявляется подобно регистрам, используя ключевое слово logic. Но, кроме разрядности (разрядности ячеек памяти, в данном случае) после имени регистра (памяти, в данном случае) указывается количество создаваемых ячеек либо в виде натурального числа, либо в виде диапазона адресов этих ячеек.:

logic [19:0] memory1 [16];    // memory1 и memory2 являются полностью
logic [19:0] memory2 [0:15];  // идентичными памятями.

logic [19:0] memory3 [15:0];  // memory3 будет такой же памятью, что и
                              // предыдущие, но на временной диаграмме
                              // Vivado при ее отображении сперва будут
                              // идти ячейки, начинающиеся со старших
                              // адресов (что в рамках данного курса
                              // лабораторных работ будет скорее минусом).


logic [19:0] memory3 [1:16];  // А вот memory3 хоть и совпадает по
                              // размеру с предыдущими реализациями,
                              // но отличается по адресному пространству
                              // обращение по нулевому адресу выдаст
                              // недетерминированный результат. Это не
                              // значит, что память будет плохой или
                              // дефектной, просто надо учитывать эту её
                              // особенность.

В приведенном листинге logic [19:0] memory1 [16]; создается память с шестнадцатью (от 0-го до 15-го адреса) 20-битными ячейками памяти. В таком случае говорят, что ширина памяти 20 бит, а глубина 16. Для адресации такой памяти потребуется адрес с разрядностью ceil(log2(16)) = 4 бита (ceil — операция округления вверх). Это однопортовая память.

Для обращения к конкретной ячейке памяти используются квадратные скобки с указанием нужного адреса memory[addr]. Грубо говоря, то, что указывается в квадратных скобках будет подключено ко входу адреса памяти memory.

Реализация асинхронного подключения к выходу памяти осуществляется оператором assign. А, если требуется создать память с синхронным чтением, то присваивание выходу требуется описать внутри блокаalways_ff.

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

module mem16_20 (                     // создать блок с именем mem16_20
  input  logic         clk,           // вход синхронизации
  input  logic [3:0]   addr,          // адресный вход
  input  logic [19:0]  write_data,    // вход данных для записи
  input  logic         write_enable,  // сигнал разрешения на запись
  output logic [19:0]  async_read_data// асинхронный выход считанных данных
  output logic [19:0]  sync_read_data // синхронный выход считанных данных
);

  logic [19:0] memory [0:15];         // создать память с 16-ю
                                      // 20-битными ячейками

  // асинхронное чтение
  assign async_read_data = memory[addr];  // подключить к выходу async_read_data
                                          // ячейку памяти по адресу addr
                                          // (асинхронное чтение)

  // синхронное чтение
  always_ff @(posedge clk) begin     // поставить перед выходом sync_read_data
    sync_read_data <= memory[addr];  // регистр, в который каждый такт будут
  end                                // записываться считываемые данные

  // запись
  always_ff @(posedge clk) begin    // каждый раз по фронту clk
    if(write_enable) begin          // если сигнал write_enable == 1, то
      memory[addr] <= write_data;   // в ячейку по адресу addr будут записаны
                                    // данные сигнала write_data
    end
  end
endmodule

В случае реализации ПЗУ нет необходимости в описании входов для записи, поэтому описание памяти занимает всего пару строк. Чтобы проинициализировать такую память (то есть поместить в нее начальные значения, которые можно было бы из нее читать), требуемое содержимое нужно добавить к прошивке, вместе с которой данные попадут в ПЛИС. Для этого в проект добавляется текстовый файл формата .mem с содержимым памяти. Для того, чтобы отметить данный файл в качестве инициализирующего память, можно использовать системную функцию $readmemh.

У данной функции есть два обязательных аргумента:

  • имя инициализирующего файла
  • имя инициализируемой памяти

и два опциональных:

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

Пример полного вызова выглядит так:

$readmemh("<data file name>",<memory name>,<start address>,<end address>);

Однако на деле обычно используются только обязательные аргументы:

$readmemh("<data file name>",<memory name>);

Пример описанной выше памяти:

module rom16_8 (
  input  logic [3:0]   addr1,       // первый 4-битный адресный вход
  input  logic [3:0]   addr2,       // второй 4-битный адресный вход
  output logic [7:0]   read_data1,  // первый 8-битный выход считанных данных
  output logic [7:0]   read_data2   // второй 8-битный выход считанных данных
);

  logic [7:0] ROM [0:15];           // создать память с 16-ю 8-битными ячейками

  initial begin
    $readmemh("rom_data.mem", ROM); // поместить в память RAM содержимое
  end                               // файла rom_data.mem


  assign read_data1 = R0M[addr1];   // реализация первого порта на чтение
  assign read_data2 = R0M[addr2]    // реализация второго порта на чтение

endmodule

Содержимое файла rom_data.mem, к примеру может быть таким (каждая строка соответствует значению отдельной ячейки памяти, начиная со стартового адреса):

  FA
  E6
  0D
  15
  A7

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

Задание по реализации памяти

Необходимо описать на языке SystemVerilog три вида памяти:

  1. память инструкций;
  2. память данных;
  3. регистровый файл.

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

У данного модуля будет два входных/выходных сигнала:

  • 32-битный вход адреса
  • 32-битный выход данных (асинхронное чтение)
mоdulе instr_mеm(
  inрut  logic [31:0] addr_i,
  оutрut logic [31:0] rеаd_dаtа_o
);

Не смотря на разрядность адреса, на практике, внутри данного модуля вы должны будете реализовать память с 1024-мя 32-битными ячейками (в ПЛИС попросту не хватит ресурсов на реализации памяти с 232 ячеек). Таким образом, реально будет использоваться только 10 бит адреса.

При этом по спецификации процессор RISC-V использует память с побайтовой адресацией. Байтовая адресация означает, что процессор способен обращаться к отдельным байтам в памяти (за каждым байтом памяти закреплен свой индивидуальный адрес).

Однако, если у памяти будут 32-рязрядные ячейки, доступ к конкретному байту будет осложнен, ведь каждая ячейка — это 4 байта. Как получить данные третьего байта памяти? Если обратиться к третьей ячейке в массиве — придут данные 12-15-ых байт (поскольку каждая ячейка содержит по 4 байта). Чтобы получить данные третьего байта, необходимо разделить значение пришедшего адреса на 4 (отбросив остаток от деления). 3 / 4 = 0 — и действительно, если обратиться к нулевой ячейке памяти — будут получены данные 3-го, 2-го, 1-го и 0-го байт. То, что помимо значения третьего байта есть еще данные других байт нас в данный момент не интересует, важна только сама возможность указать адрес конкретного байта.

Деление на 2n можно осуществить, отбросив n младших бит числа. Учитывая то, что для адресации 1024 ячеек памяти мы будем использовать 10 бит адреса, память инструкций должна выдавать на выход данные, расположенные по адресу addr_i[11:2].

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

У данного модуля будет шесть входных/выходных сигналов:

  • вход тактового синхроимпульса
  • вход запроса на работу с памятью
  • вход сигнала разрешения записи
  • 32-битный вход адреса
  • 32-битный вход данных записи
  • 32-битный выход данных синхронного чтения
mоdulе data_mеm(
  inрut  logic        clk_i,
  input  logic        mem_req_i,
  inрut  logic        write_enable_i,
  inрut  logic [31:0] addr_i,
  inрut  logic [31:0] write_data_i,
  оutрut logic [31:0] rеаd_dаtа_o
);

Как и память инструкций, память данных будет состоять из 32-разрядных ячеек. Только теперь их будет 4096, а значит при обращении к ячейкам памяти нужно использовать не 10 бит адреса, а 12. При этом по-прежнему необходимо разделить пришедший адрес на 4, т.е. нужно отбросить два младших бита. Таким образом, обращение к ячейкам памяти (для записи и чтения) должно осуществляться по адресу addr_i[13:2].

Однако в отличие от памяти инструкций, в память данных добавлено два управляющих сигнала (mem_req_iи write_enable_i). Сигнал mem_req_i является сигналом запроса на работу с памятью. Без этого сигнала память не должна выполнять операции чтения/записи (вне зависимости от сигнала write_enable, определяющего происходит сейчас запись или чтение). Как сделать так, чтобы не происходило чтение без запроса? Например, не обновлять значение, считанное во время предыдущей операции чтения.

Если mem_req_i == 1 и write_enable_i == 1, то происходит запрос на запись в память. В этом случае, необходимо записать значение write_data_i в ячейку по адресу addr_i[13:2]. Во всех других случаях (любой из сигналов mem_req_i, write_enable_i равен нулю), запись в память не производится.

Если mem_req_i == 1 и write_enable_i == 0, то происходит запрос на чтение из памяти. В этом случае, необходимо записать в выходной регистр read_data_o значение из ячейки по адресу addr_i[13:2]. Во всех других случаях чтение из памяти не производится (read_data_o сохраняет предыдущее значение).

3. Регистровый файл

У данного модуля будет восемь входных/выходных сигналов:

  • вход тактового синхроимпульса
  • вход сигнала разрешения записи
  • 5-битный вход первого адреса чтения
  • 5-битный вход второго адреса чтения
  • 5-битный вход адреса записи
  • 32-битный вход данных записи
  • 32-битный выход данных асинхронного чтения по первому адресу
  • 32-битный выход данных асинхронного чтения по второму адресу
mоdulе rf_r𝚒sсv(
  inрut  logic        сlk_i,
  inрut  logic        write_enable_i,

  inрut  logic [ 4:0] write_addr_i,
  inрut  logic [ 4:0] read_addr1_i,
  inрut  logic [ 4:0] read_addr2_i,

  inрut  logic [31:0] write_data_i,
  оutрut logic [31:0] read_data1_o,
  оutрut logic [31:0] read_data2_o
);

На языке SystemVerilog необходимо реализовать модуль регистрового файла (rf_r𝚒sсv) для процессора с архитектурой RISC-V, представляющего собой трехпортовую ОЗУ с двумя портами на чтение и одним портом на запись и состоящей из 32-х 32-битных регистров с именем rf_mem.

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

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

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

Порядок выполнения работы

  1. Внимательно ознакомьтесь с заданием. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  2. Реализуйте память инструкций. Для этого:
    1. В Design Sources проекта с предыдущих лаб, создайте SystemVerilog-файл instr_mem.sv.
    2. Опишите в нем модуль памяти инструкций с таким же именем и портами, как указано в задании.
      1. Сперва необходимо создать память (массив регистров). Как это сделать, сказано в разделе описание памяти на языке SystemVerilog. Разрядность ячеек памяти должна быть 32 бита, количество ячеек — 1024.
      2. Добавить в Design Sources проекта файл с содержимым памяти инструкций. Данный файл будет использоваться при вызове системной функции $readmemh в описании памяти инструкций.
      3. К созданной памяти необходимо подключить выход модуля read_data_o. При подключении должен быть использован вход модуля addr_i, значение которого должно быть уменьшено в 4 раза (побайтовая адресация).
      4. При реализации выхода read_data_o помните, что обращаясь к ячейке памяти, вам необходимо использовать [11:2] биты адреса.
      5. Реализуемый порт на чтение памяти инструкций должен быть асинхронным.
    3. После описания памяти инструкций, её необходимо проверить с помощью тестового окружения.
      1. Тестовое окружение находится здесь.
      2. Для запуска симуляции воспользуйтесь этой инструкцией.
      3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_instr_mem).
      4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
  3. Реализуйте память данных. Для этого:
    1. В Design Sources проекта создайте SystemVerilog-файл data_mem.sv.
    2. Опишите в нем модуль памяти данных с таким же именем и портами, как указано в задании.
      1. Описание модуля будет схожим с описанием модуля памяти инструкций, однако порт чтения в этот раз будет синхронным (запись в него будет происходить в блоке always_ff). Количество ячеек в памяти данных — 4096. Кроме того, необходимо будет описать логику записи данных в память.
      2. Запись в ячейки памяти описывается подобно записи данных в регистры, только при этом, происходит доступ к конкретной ячейке памяти с помощью входа addr_i (как осуществляется доступ к ячейкам памяти сказано в разделе описание памяти на языке SystemVerilog).
      3. Доступ к ячейкам (на запись и чтение) осуществляется по адресу addr_i[13:2].
      4. Обратите внимание что работа с памятью должна осуществляться только когда сигнал mem_req_i == 1, в противном случае запись не должна производиться, а на шине read_data_o должен оставаться результат предыдущего чтения.
    3. После описания памяти данных, её необходимо проверить с помощью тестового окружения.
      1. Тестовое окружение находится здесь.
      2. Для запуска симуляции воспользуйтесь этой инструкцией.
      3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_data_mem).
      4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
  4. Реализуйте регистровый файл. Для этого:
    1. В Design Sources проекта создайте SystemVerilog-файл rf_riscv.sv.
    2. Опишите в нем модуль регистрового файла с таким же именем и портами, как указано в задании.
      1. Обратите внимание, что имя памяти (не название модуля, а имя объекта памяти внутри модуля) должно быть rf_mem. Такое имя необходимо для корректной работы верификационного окружения.
      2. Как и у памяти инструкций, порты чтения регистрового файла должны быть асинхронными.
      3. Не забывайте, что у вас 2 порта на чтение и 1 порт на запись, при этом каждый порт не зависит от остальных (в модуле 3 независимых входа адреса).
      4. Чтение из нулевого регистра (чтение по адресу 0) всегда должно возвращать нулевое значение. Этого можно добиться двумя путями:
        1. Путем добавления мультиплексора перед выходным сигналом чтения (мультиплексор будет определять, пойдут ли на выход данные из ячейки регистрового файла, либо, в случае если адрес равен нулю, на выход пойдет константа ноль).
        2. Путем инициализации нулевого регистра нулевым значением и запретом записи в этот регистр (при записи и проверки write_enable добавить дополнительную проверку на адрес).
        3. Каким образом будет реализована эта особенность регистрового файла не важно, выберите сами.
    3. После описания регистрового файла, его необходимо проверить с помощью тестового окружения.
      1. Тестовое окружение находится здесь.
      2. Для запуска симуляции воспользуйтесь этой инструкцией.
      3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_rf_riscv).
      4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
  5. Проверьте работоспособность вашей цифровой схемы в ПЛИС. Для этого:
    1. Добавьте файлы из папки board files в проект.
      1. Файл nexys_rf_riscv.sv необходимо добавить в Design Sources проекта.
      2. Файл nexys_a7_100t.xdc необходимо добавить в Constraints проекта. В случае, если вы уже добавляли одноименный файл в рамках предыдущих лабораторных работ, его содержимое необходимо заменить содержимым нового файла.
    2. Выберите nexys_rf_riscv в качестве модуля верхнего уровня (top-level).
    3. Выполните генерацию битстрима и сконфигурируйте ПЛИС. Для этого воспользуйтесь следующей инструкцией.
    4. Описание логики работы модуля верхнего уровня и связи периферии ПЛИС с реализованным модулем находится в папке board files.

Источники

  1. Field-programmable gate array

Лабораторная работа 4 "Примитивное программируемое устройство"

В этой лабораторной работе на основе ранее разработанных блоков памяти и АЛУ ты соберешь простой учебный процессор с архитектурой CYBERcobra 3000 Pro 2.1. Это нужно для более глубокого понимания принципов работы программируемых устройств, чтобы проще было понять архитектуру RISC-V в будущем.

Допуск к лабораторной работе

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

  1. Описание модулей, их создание внутри других модулей и оператор непрерывного присваивания assign (Modules.md).
  2. Описание мультиплексоров: с помощью тернарного оператора, блоков case и if/else. Знать особенности использования этих блоков и особенности синтеза комбинационной логики внутри блока always (Multiplexors.md).
  3. Описание регистров (Registers.md).
  4. Оператор конкатенации (Concatenation.md).
  5. Отладку проекта по временной диаграмме (Debug manual.md).

Цель

Реализовать простейшее программируемое устройство с архитектурой CYBERcobra 3000 Pro 2.1

Ход работы

  1. Изучить принцип работы процессоров (соответствующий раздел #теории)
  2. Познакомиться с архитектурой и микроархитектурой CYBERcobra 3000 Pro 2.1 (раздел про эту #архитектуру)
  3. Изучить необходимые для описания процессора конструкции SystemVerilog (раздел #инструменты)
  4. Реализовать процессор с архитектурой CYBERcobra 3000 Pro 2.1 (#задание по разработке аппаратуры)
  5. Проверить работу процессора в ПЛИС.

Доп. задание, выполняемое дома:

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

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

Теория про программируемое устройство

В обобщенном виде, процессор включает в себя память, АЛУ, устройство управления и интерфейсную логику для организации ввода/вывода. Также, в процессоре есть специальный регистр PC (Program Counter – счетчик команд), который хранит в себе число – адрес ячейки памяти, в которой лежит инструкция, которую нужно выполнить. Инструкция тоже представляет собой число, в котором закодировано что нужно сделать и с чем это нужно сделать.

Алгоритм работы процессора следующий:

  1. Из памяти считывается инструкция по адресу PC
  2. Устройство управления дешифрует полученную инструкцию (то есть определяет какую операцию нужно сделать, где взять операнды и куда разместить результат)
  3. Декодировав инструкцию, устройство управления выдает всем блокам процессора (АЛУ, регистровый файл, мультиплексоры) соответствующие управляющие сигналы, тем самым выполняя эту инструкцию.
  4. Изменяется значение PC.
  5. Цикл повторяется с п.1.

Любая инструкция приводит к изменению состояния памяти. В случае процессора с архитектурой CYBERcobra 3000 Pro 2.1 есть два класса инструкций: одни изменяют содержимое регистрового файла — это инструкции записи. Другие изменяют значение PC — это инструкции перехода. В первом случае используются вычислительные инструкции и инструкции загрузки данных из других источников. Во-втором случае используются инструкции перехода.

Если процессор обрабатывает вычислительную инструкцию, то PC перейдет к следующей по порядку инструкции. На лабораторной работе, посвященной памяти, мы сделали память инструкций с побайтовой адресацией. Это означает, что каждый байт памяти имеет свой собственный адрес. Поскольку длина инструкции составляет 4 байта, для перехода к следующей инструкции PC должен быть увеличен на 4 (PC = PC + 4). При этом, регистровый файл сохранит результат некоторой операции на АЛУ или данные со входного порта.

Если же обрабатывается инструкция перехода, то возможно два варианта. В случае безусловного или успешного условного перехода, значение PC увеличится на значение константы, закодированной внутри инструкции PC = PC + const*4 (иными словами, const говорит о том, через сколько инструкций перепрыгнет PC, const может быть и отрицательной). В случае же неуспешного условного перехода PC, как и после вычислительных команд, просто перейдет к следующей инструкции, то есть PC = PC + 4.

Строго говоря PC меняется при выполнении любой инструкции (кроме случая const = 0, то есть перехода инструкции на саму себя PC = PC + 0*4). Разница в том, на какое значение PC изменится. В вычислительных инструкциях это всегда адрес следующей инструкции, программа не управляет PC, он "сам знает", что ему делать. В инструкциях перехода программа и контекст определяют, что произойдет с PC.

Архитектура CYBERcobra 3000 Pro 2.1 и ее микроархитектура

В качестве первого разрабатываемого программируемого устройства предлагается использовать архитектуру специального назначения CYBERcobra 3000 Pro 2.1, которая была разработана в МИЭТ. Главным достоинством данной архитектуры является простота ее понимания и реализации. Главным ее минусом является неоптимальность ввиду неэффективной реализации кодирования инструкций, что приводит к наличию неиспользуемых битов в программах. Но это неважно, так как основная цель разработки процессора с архитектурой CYBERcobra 3000 Pro 2.1 — это более глубокое понимание принципов работы программируемых устройств, которое поможет при разработке более сложного процессора с архитектурой RISC-V.

../../.pic/Labs/lab_04_cybercobra/logoCC3000.svg

Простота архитектуры CYBERcobra 3000 Pro 2.1 проявляется, в том числе, за счет отсутствия памяти данных. Это значит, что данные c которыми работает программа могут храниться только в регистровом файле. Также в таком процессоре почти полностью отсутствует устройство управления (формально оно существует, но состоит только из проводов и пары логических вентилей).

Архитектурой предусмотрена поддержка 19 инструкций (5 типов команд):

Первые два типа содержат 16 инструкций, которые выполняются на АЛУ:

  • 10 вычислительных
  • 6 операций сравнения для условного перехода

Кроме того, есть инструкции:

  • безусловного перехода
  • загрузки константы
  • загрузки данных с внешнего устройства.

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

Последовательное считывание инструкций

Будем рассматривать архитектуру (функции процессора) и микроархитектуру (реализация процессора) одновременно, прослеживая рассуждения их разработчика.

Для начала реализуем базовый функционал, подключив счетчик команд PC к памяти инструкций instr_mem и сумматору, прибавляющему 4 к PC. Выход сумматора подключим ко входу PC.

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

Так как операции будут выполняться только над данными в регистровом файле, то его можно сразу подключить к АЛУ, соединив порты чтения read_data1_o и read_data2_o со входами операндов АЛУ, а результат операции АЛУ подключив к порту на запись write_data_i. Полученный результат изображен на рис. 0.

../../.pic/Labs/lab_04_cybercobra/ppd_0.drawio.svg

Рисунок 0. Размещение на схеме основных блоков.

Для компактности схемы, названия портов регистрового файла сокращены (RA1 обозначает read_addr1_i и т.п.).

Кодирование вычислительных инструкций

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

  1. по каким адресам регистрового файла лежат операнды?
  2. по какому адресу будет сохранен результат?
  3. какая операция должна быть выполнена?

Для этого в инструкции были выбраны следующие поля: 5 бит ([27:23]) для кодирования операции на АЛУ, два раза по 5 бит для кодирования адресов операндов в регистровом файле ([22:18] и [17:13]) и 5 бит для кодирования адреса результата ([4:0]). Таблица 1 демонстрирует деление 32-битной инструкции на поля alu_op, RA1, RA2 и WA.

../../.pic/Labs/lab_04_cybercobra/ppd_code_1.png

Таблица 1. Кодирование вычислительных инструкций в архитектуре CYBERcobra 3000 Pro v2.1.

  reg_file[WA] ← reg_file[RA1] {alu_op} reg_file[RA2]

Запись выше является некоторой формализацией выполняемой функции, которая как бы отвечает на вопрос "а что, собственно, будет сделано?". В регистр по адресу WA (reg_file[WA]) будет записан () результат операции alu_op ({alu_op}) между регистрами по адресам RA1 (reg_file[RA1]) и RA2 (reg_file[RA1]).

Реализация вычислительных инструкций

Чтобы процессор правильно реагировал на эти инструкции, требуется подключить ко входам адреса регистрового файла и управляющему входу АЛУ соответствующие биты выхода read_data памяти инструкции (Instruction Memory). В таком случае, когда PC будет указывать на ячейку памяти, в которой лежит, например, следующая 32-битная инструкция:

0000 00111  00100 01000 00000000 11100
    |alu_op| RA1 | RA2 |        | WA

будет выполнена операция reg_file[28] = reg_file[4] | reg_file[8], потому что alu_op = 00111, что соответствует операции логического ИЛИ, WA = 11100, то есть 28-ой регистр, RA1 = 00100 (4-ый регистр) и RA2 = 01000 (8-ой регистр). Рис. 1 иллюстрирует фрагмент микроархитектуры, поддерживающий вычислительные операции на АЛУ. Так как пока что другие инструкции не поддерживаются, то вход WE регистрового файла всегда равен 1 (это временно).

../../.pic/Labs/lab_04_cybercobra/ppd_1.drawio.svg

Рисунок 1. Подключение АЛУ и регистрового файла для реализации вычислительных инструкций.

Реализация загрузки константы в регистровый файл

Информация как-то должна попадать в регистровый файл, для этого добавим инструкцию загрузки константы по адресу WA. Чтобы аппаратура могла различать, когда ей нужно выполнять операцию на АЛУ, а когда загружать константу, назначим один бит инструкции определяющим "что именно будет записано в регистровый файл": результат с АЛУ или константа из инструкции. За это будет отвечать 28-ой бит инструкции WS (Write Source). Если WS == 1, значит выполняется вычислительная инструкция, а если WS == 0, значит нужно загрузить константу в регистровый файл.

Сама константа имеет разрядность 23 бита ([27:5] биты инструкции) и должна быть знакорасширена до 32-х бит, то есть к 23-битной константе нужно приклеить слева 9 раз 23-ий знаковый бит константы (см. оператор конкатенации).

Пример: если [27:5] биты инструкции равны:

10100000111100101110111

то после знакорасширения константа примет вид:

11111111110100000111100101110111

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

Нет ничего страшного в том, что биты константы попадают на те же поля, что и alu_op, RA1 и RA2 — когда выполняется инструкция загрузки константы не важно что будет выдавать АЛУ в этот момент (ведь благодаря мультиплексору на вход регистрового файла приходит константа). А значит не важно и что приходит в этот момент на АЛУ в качестве операндов и кода операции. Таблица 2 демонстрирует деление 32-битной инструкции на поля alu_op, RA1, RA2, WA, WS и const, с перекрытием полей.

../../.pic/Labs/lab_04_cybercobra/ppd_code_2.png

Таблица 2. Добавление кодирования источника записи и 23-битной константы.

  reg_file[WA] ← const

Так как вход записи уже занят результатом операции АЛУ, его потребуется мультиплексировать со значением константы из инструкции, которая предварительно знакорасширяется в блоке SE. На входе WD регистрового файла появляется мультиплексор, управляемый 28-м битом инструкции, который и определяет, что будет записано: константа или результат вычисления на АЛУ.

Например, в такой реализации следующая 32-битная инструкция поместит константу -1 в регистр по адресу 5:

000  0 11111111111111111111111 00101
   |WS|        RF_const       | WA  |

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

../../.pic/Labs/lab_04_cybercobra/ppd_2.drawio.svg

Рисунок 2. Добавление константы из инструкции в качестве источников записи в регистровый файл.

Реализация загрузки в регистровый файл данных с внешних устройств

Чтобы процессор мог взаимодействовать с внешним миром добавим возможность загрузки данных с внешних устройств в регистр по адресу WA. Появляется третий тип инструкции, который определяет третий источник ввода для регистрового файла. Одного бита WS для выбора одного из трех источников будет недостаточно, поэтому расширим это поле до 2 бит. Теперь, когда WS == 0 будет загружаться константа, когда WS == 1 – будет загружаться результат вычисления АЛУ, а при WS == 2 будут загружаться данные с внешних устройств. Остальные поля в данной инструкции не используются.

../../.pic/Labs/lab_04_cybercobra/ppd_code_3.png

Таблица 3. Кодирование в инструкции большего числа источников записи.

  reg_file[WA] ← sw_i

По аналогии с загрузкой констант увеличиваем входной мультиплексор до 4 входов и подключаем к нему управляющие сигналы – [29:28] биты инструкции. Последний вход используется, чтобы разрешить неопределенность на выходе при WS == 3(default-вход, см. мультиплексор).

Выход OUT подключается к первому порту на чтение регистрового файла. Значение на выходе OUT будет определяться содержимым ячейки памяти по адресу RA1. На рис. 3 приводится фрагмент микроархитектуры, поддерживающий вычислительные операции на АЛУ, загрузку констант из инструкции в регистровый файл и загрузку данных с внешних устройств.

../../.pic/Labs/lab_04_cybercobra/ppd_3.drawio.svg

Рисунок 3. Подключение к схеме источников ввода и вывода.

Реализация условного перехода

С реализованным набором инструкций полученное устройство нельзя назвать процессором – пока что это продвинутый калькулятор. Добавим поддержку инструкции условного перехода, при выполнении которой программа будет перепрыгивать через заданное количество команд. Чтобы аппаратура отличала эту инструкцию от других будем использовать 30-ый бит B (branch). Если B == 1, значит это инструкция условного перехода и, если условие перехода выполняется, к PC надо прибавить константу. Если B == 0, значит это какая-то другая инструкция и к PC надо прибавить четыре.

../../.pic/Labs/lab_04_cybercobra/ppd_code_4.png

Таблица 4.Кодирование условного перехода.

Для вычисления результата условного перехода, нам необходимо выполнить операцию на АЛУ и посмотреть на сигнал flag. Если он равен 1, переход выполняется, в противном случае — не выполняется. А значит, нам нужны операнды A, B, и alu_op. Кроме того, нам необходимо указать насколько мы сместимся относительно текущего значения PC (константу смещения, offset). Для передачи этой константы лучше всего подойдут незадействованные биты инструкции [12:5].

Обратим внимание на то, что PC 32-битный и должен быть всегда кратен четырем (PC не может указывать кроме как в начало инструкции, а каждая инструкция длиной в 32 бита). Кратные четырем двоичные числа всегда будут иметь в конце два нуля (так же, как и кратные ста десятичные числа). Поэтому для более эффективного использования бит константы смещения, эти два нуля будут неявно подразумеваться при её описании. При этом, перед увеличением программного счетчика на значение константы смещения, эти два нуля нужно будет к ней приклеить справа. Кроме того, чтобы разрядность константы совпадала с разрядностью PC, необходимо знакорасширить её до 32 бит.

Предположим, мы хотим переместиться на две инструкции вперед. Это означает, что программный счетчик должен будет увеличиться на 8 ([2 инструкции] * [4 байта — размер одной инструкции в памяти]). Умножение на 4 константы смещения произойдет путем добавления к ней двух нулей справа, поэтому в поле offset мы просто записываем число инструкций, на которые мы переместим программный счетчик (на две): 0b00000010.

Приведенный ниже Си-подобный псевдокод (далее мы назовем его псевдоассемблером) демонстрирует кодирование инструкций с новым полем B:

  if (reg_file[RA1] {alu_op} reg_file[RA2])
    PC ← PC + const * 4
  else
    PC ← PC + 4

Так как второй вход сумматора счетчика команд занят числом 4, то для реализации условного перехода этот вход надо мультиплексировать с константой. Мультиплексор при этом управляется 30-ым битом B, который и определяет, что будет прибавляться к PC.

Сигнальные линии, которые управляют АЛУ и подают на его входы операнды уже существуют. Поэтому на схему необходимо добавить только логику управления мультиплексором на входе сумматора счетчика команд так. Эта логика работает следующим образом:

  1. если сейчас инструкция условного перехода
  2. и если условие перехода выполнилось,

то к PC прибавляется знакорасширенная константа, умноженная на 4. В противном случае, к PC прибавляется 4.

Так как теперь не любая инструкция приводит к записи в регистровый файл, появляется необходимость управлять входом WE так, чтобы при операциях условного перехода запись в регистровый файл не производилась. Это можно сделать, подав на WE значение !B (запись происходит, если сейчас не операция условного перехода).

../../.pic/Labs/lab_04_cybercobra/ppd_4.drawio.svg

Рисунок 4. Реализация условного перехода.

Реализация безусловного перехода

Осталось добавить поддержку инструкции безусловного перехода, для идентификации которой используется оставшийся 31-ый бит J(jump). Если бит J == 1, то это безусловный переход, и мы прибавляем к PC знакорасширенную константу смещения, умноженную на 4 (как это делали и в условном переходе).

../../.pic/Labs/lab_04_cybercobra/ppd_code_5.png

Таблица 5. Кодирование безусловного перехода.

  PC ← PC + const*4

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

  1. Если сейчас инструкция безусловного перехода
  2. ИЛИ если сейчас инструкция условного перехода
  3. И если условие перехода выполнилось

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

На рис. 5 приводится итоговый вариант микроархитектуры процессора CYBERcobra 3000 Pro 2.1.

../../.pic/Labs/lab_04_cybercobra/ppd_5.drawio.svg

Рисунок 5. Реализация безусловного перехода.

Финальный обзор

Итого, архитектура CYBERcobra 3000 Pro 2.1 поддерживает 5 типов инструкций, которые кодируются следующим образом (иксами помечены биты, которые не задействованы в данной инструкции):

  1. 10 вычислительных инструкций 0 0 01 alu_op RA1 RA2 xxxx xxxx WA
  2. Инструкция загрузки константы 0 0 00 const WA
  3. Инструкция загрузки из внешних устройств 0 0 10 xxx xxxx xxxx xxxx xxxx xxxx WA
  4. Безусловный переход 1 x xx xxx xxxx xxxx xxxx const xxxxx
  5. 6 инструкций условного перехода 0 1 xx alu_op RA1 RA2 const x xxxx

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

  • J – однобитный сигнал, указывающий на выполнение безусловного перехода;
  • B – однобитный сигнал, указывающий на выполнение условного перехода;
  • WS – двухбитный сигнал, указывающий источник данных для записи в регистровый файл:
    • 0 – константа из инструкции;
    • 1 – результат с АЛУ;
    • 2 – внешние данные;
    • 3 – не используется;
  • alu_op – 5-битный сигнал кода операции АЛУ (в соответствии с таблицей из лабораторной по АЛУ);
  • RA1 и RA2 – 5-битные адреса операндов из регистрового файла;
  • offset – 8-битная константа для условного / безусловного перехода;
  • const — 23-битная константа для загрузки в регистровый файл;
  • WA – 5-битный адрес регистра, в который будет записан результат.

Напишем простую программу для этого процессора, которая в бесконечном цикле увеличивает значение первого регистра на 1. Сначала напишем программу на псевдоассемблере (используя предложенную мнемонику):

  reg_file[1] ← -1                        // загрузить константу `-1` регистр 1
  reg_file[2] ← sw_i                      // загрузить значение с входа sw_i в регистр 2
  reg_file[3] ←  1                        // загрузить константу `1` регистр 3

  reg_file[1] ← reg_file[1] + reg_file[3] // сложить регистр 1 с регистром 3 и
                                          // поместить результат в регистр 1

  if (reg_file[1] < reg_file[2])          // если значение в регистре 1 меньше
                                          // значения в регистре 2,
    PC ← PC – 1                           // возврат на 1 инструкцию назад

  out_o = reg_file[1], PC ← PC + 0        // бесконечное повторение этой инструкции
                                          // с выводом на out_o значения в регистре 1

Теперь в соответствии с кодировкой инструкций переведем программу в машинные коды:

  0 0 00   11111111111111111111111  00001
  0 0 10   00000000000000000000000  00010
  0 0 00   00000000000000000000001  00011
  0 0 01 00000 00001 00011 00000000 00001
  0 1 00 11110 00001 00010 11111111 00000
  1 0 00 00000 00001 00000 00000000 00000

Полученную программу можно помещать в память программ и выполнять на процессоре.

Инструменты для реализации процессора

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

Для реализации блоков знакорасширения с умножением на 4 подходит использование оператора конкатенации (Concatenation.md).

Задание по реализации процессора

Разработать процессор CYBERcobra (см. рис. 5), объединив ранее разработанные модули:

  • Память инструкций (проинициализированную в двоичном формате файлом program.mem)
  • Регистровый файл
  • Арифметико-логическое устройство
  • 32-битный сумматор

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

module CYВЕRcоbrа (
  inрut  logic         clk_i,
  inрut  logic         rst_i,
  inрut  logic [15:0]  sw_i,
  оutрut logic [31:0]  out_o
);

 // тут твой код, о котором говорится чуть выше

endmodule

Порядок выполнения задания

  1. Внимательно ознакомьтесь с заданием. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  2. Реализуйте модуль CYBERcobra. Для этого:
    1. В Design Sources проекта с предыдущих лаб, создайте SystemVerilog-файл cybercobra.sv.
    2. Опишите в нем модуль процессора с таким же именем и портами, как указано в задании (обратите внимание на регистр имени модуля).
      1. В первую очередь, необходимо создать счетчик команд и все вспомогательные провода. При создании, следите за разрядностью.
      2. Затем, необходимо создать экземпляры модулей: памяти инструкции, АЛУ, регистрового файла и сумматора. При подключении сигналов сумматора, надо обязательно надо подать нулевое значение на входной бит переноса. Выходной бит переноса подключать не обязательно. Объекту памяти инструкций нужно дать имя imem.
      3. После этого, необходимо описать оставшуюся логику:
        1. Программного счетчика
        2. Сигнала управления мультиплексором, выбирающим слагаемое для программного счетчика
        3. Сигнала разрешения записи в регистровый файл
        4. Мультиплексор, выбирающий слагаемое для программного счетчика
        5. Мультиплексор, выбирающий источник записи в регистровый файл.
  3. После описания модуля, его необходимо проверить с помощью тестового окружения.
    1. Тестовое окружение находится здесь.
    2. Программа, которой необходимо проинициализировать память инструкций находится в файле program.mem. Алгоритм работы программы приведен в разделе Финальный обзор.
    3. Для запуска симуляции воспользуйтесь этой инструкцией.
    4. Перед запуском симуляции убедитесь, что выбран правильный модуль верхнего уровня.
    5. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
    6. В этот раз, в конце не будет сообщения о том, работает ли ваше устройство или в нем есть ошибки. Вы должны самостоятельно проверить работу модуля, перенеся его внутренние сигналы на временную диаграмму, и проверив логику их работы.
  4. Проверьте работоспособность вашей цифровой схемы в ПЛИС. Для этого:
    1. Добавьте файлы из папки board files в проект.
      1. Файл nexys_cybercobra.sv необходимо добавить в Design Sources проекта.
      2. Файл nexys_a7_100t.xdc необходимо добавить в Constraints проекта. В случае, если вы уже добавляли одноименный файл в рамках предыдущих лабораторных работ, его содержимое необходимо заменить содержимым нового файла.
    2. Выберите nexys_cybercobra в качестве модуля верхнего уровня (top-level).
    3. Выполните генерацию битстрима и сконфигурируйте ПЛИС. Для этого воспользуйтесь следующей инструкцией.
    4. Описание логики работы модуля верхнего уровня и связи периферии ПЛИС с реализованным модулем находится в папке board files.

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


Дерзайте!

Лабораторная работа 5 "Основной дешифратор команд"

Устройство управления – один из базовых блоков процессора, функцией которого является декодирование инструкций и выдача управляющих сигналов для всех блоков процессора.

Цель

Описать на языке SystemVerilog блок основного дешифратора команд (модуль Main Decoder) для однотактного процессора с архитектурой RISC-V.

Допуск к лабораторной работе

  • Изучить форматы кодирования инструкций базового набора команд RV32I

Ход работы

  1. Изучить особенности архитектуры RISC-V (#теория)
  2. Изучить конструкции SystemVerilog, с помощью которых будет описан дешифратор (#инструменты)
  3. Реализовать на языке SystemVerilog модуль основного дешифратора команд – Main Decoder (#задание)
  4. Верифицировать разработанное устройство с помощью предлагаемого testbench (в том же #задании)

Архитектура RISC-V и предлагаемая микроархитектура

Набор инструкций RISC-V и способы их кодирования

Все инструкции архитектуры RISC-V можно условно разделить на три категории:

  • Вычислительные инструкции (операции выполняются на АЛУ)
    • Использующие в качестве операндов два регистра
    • Использующие в качестве операндов регистр и непосредственный операнд из инструкции (константу)
  • Инструкции для доступа к памяти
    • Загрузки из основной памяти в регистровый файл
    • Сохранения данных из регистрового файла в основную память
  • Инструкции управления программой (управляют тем, как изменится счетчик команд PC)
    • Условные переходы
    • Безусловные переходы

В Таблице 1 приводится фрагмент из спецификации RISC-V. В верхней её части приводится 6 форматов кодирования инструкций: R, I, S, B, U и J, затем идут конкретные значения полей внутри инструкции. Под rd подразумевается 5-битный адрес регистра назначения (register destination), rs1 и rs2 —5-битные адреса регистров источников (register source), imm — константа (immediate), расположение и порядок битов которой указывается в квадратных скобках. Обратите внимание, что в разных форматах кодирования константы имеют различную разрядность, а их биты упакованы по-разному. Для знаковых операций константу предварительно знаково расширяют до 32 бит. Для беззнаковых расширяют нулями до 32 бит.

../../.pic/Labs/lab_05_decoder/rv32i_BIS.png

Таблица 1. Базовый набор инструкций из спецификации RISC-V[1, стр. 130], Стандартное расширение Zicsr[1, стр. 131], а также привилегированная инструкция mret[2, стр. 138].

КодированиеОписание
R-типАрифметические и логические операции над двумя регистрами с записью результата в третий (регистр назначения может совпадать с одним из регистров-источников)
I-типИнструкции с 12-битным непосредственным операндом
S-типИнструкции записи в память (инструкции store)
B-типИнструкции ветвления
U-типИнструкции с 20-битным «длинным» непосредственным операндом, сдвинутым влево на 12
J-типЕдинственная инструкция jal, осуществляющая безусловный переход по адресу относительно текущего счетчика команд

Таблица 2. Описание типов форматов кодирования инструкций ISA RISC-V.

SYSTEM-инструкции

SYSTEM-инструкции используются для доступа к системным функциям и могут требовать привилегированный доступ. Данные инструкции могут быть разделены на два класса:

  • Обращение к регистрам статуса и контроля (CSR)
  • Все остальные инструкции (возможно из набора привилегированных инструкций)

Для будущей поддержки прерываний, нам потребуется декодировать инструкции обоих классов.

Обращение к регистрам статуса и контроля осуществляется шестью инструкциями стандартного расширения Zicsr. Каждая из этих инструкций (если у нее легальные поля) осуществляет запись в CS-регистры и регистровый файл (блоки Control Status Registers и Register File на рис. 1 соответственно).

Кроме того, для возврата управления основному потоку инструкций, нужна дополнительная SYSTEM-инструкция привилегированного набора команд MRET.

Единственное что нужно сделать при появлении этой инструкции — это сформировать единицу на одноименном выходе.

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

Инструкции ECALL и EBREAK должны вызывать исключение. Единственное исключение, которое будет поддерживаться нашей системой, это исключение через сигнал illegal_instr_o, поэтому в рамках данного цикла лабораторных работ вам предлагается выставлять illegal_instr_o == 1, когда приходят эти инструкции (что довольно легко описать, ведь получается что если SYSTEM-инструкция не является MRET либо инструкцией из набора Zicsr, то это либо нелегальная инструкция, либо ECALL / EBREAK, которые ведут себя точно так же как и нелегальная инструкция).

MISC-MEM инструкция

В базовом наборе инструкций RISC-V к MISC-MEM-операции относится инструкция FENCE. В реализуемом процессорном ядре эта инструкция не должны приводить ни к каким изменениям. Инструкция FENCE в RISC-V необходима при работе с несколькими аппаратными потоками, или "хартами" (hart – «hardware thread»). В RISC-V используется расслабленная модель памяти (relaxed memory model): потоки «видят» все инструкции чтения и записи, которые исполняются другими потоками, однако видимый порядок этих инструкций может отличаться от реального. Инструкция FENCE, использованная между двумя инструкциями чтения и/или записи гарантирует, что остальные потоки увидят первую инструкцию перед второй. Реализация FENCE является опциональной в RISC-V и в данном случае в ней нет необходимости, так как в системе не предполагается наличия нескольких аппаратных потоков. Данная инструкция должна быть реализована как NOP (no operation).

../../.pic/Labs/lab_05_decoder/rv32i_summary.png

Таблица 3. Инструкции набора RV32I с приведением их типов, функционального описания и примеров использования.

Обратите внимание на операции slli, srli и srai (операции сдвига на константную величину). У этих инструкций немного измененный формат кодирования I*. Формат кодирования I предоставляет 12-битную константу. Сдвиг 32-битного числа более, чем на 31 не имеет смысла. Для кодирования числа 31 требуется всего 5 бит. Выходит, что из 12 бит константы используется только 5 бит для операции сдвига (в виде поля shamt, сокращение от shift amount — "сколько раз сдвигать"), а оставшиеся 7 бит – не используются. А, главное (какое совпадение!), эти 7 бит находятся ровно в том же месте, где у других инструкций находится поле func7. Поэтому, чтобы у инструкций slli, srli и srai использующих формат I не пропадала эта часть поля, к ней относятся как к полю func7.

Также обратите внимание на инструкции ecall, ebreak и mret. Все эти инструкции I-типа имеют поле func3, равное нулю. С точки зрения декодирования инструкции I-типа, это одна и та же инструкция с разными значениями поля imm. Однако конкретно в данном случае (SYSTEM_OPCODE и func3 == 0) эти инструкции должны рассматриваться как совокупность всех 32-бит сразу (см. Таблицу 1).

Предлагаемая микроархитектура процессора RISC-V

На рис. 1 приводится микроархитектура ядра процессора RISC-V. Регистр PC (Program Counter – счетчик команд) подключен к адресному входу памяти инструкций. Считываемая инструкция декодируется основным дешифратором, после чего он выставляет управляющие сигналы для всех блоков процессора (мультиплексоры, АЛУ, интерфейс взаимодействия с памятью).

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

../../.pic/Labs/lab_11_irq_integration/fig_01.drawio.svg

Рисунок 1. Микроархитектура будущего процессорного ядра.

Предложенная микроархитектура процессора CYBERcobra 3000 Pro 2.0 из прошлой лабораторной имеет схожую структуру, с некоторыми изменениями.

В первую очередь изменились входы и выходы процессора (помимо появления двух непонятных модулей):

  • память инструкций вынесена наружу процессора, таким образом, у процессора появляются входы и выходы: instr_addr_o и instr_i;
  • помимо прочего, у модуля появились сигналы интерфейса памяти данных:
    • mem_addr_o — адрес внешней памяти;
    • mem_req_o — запрос на обращение во внешнюю память;
    • mem_size_o — размер данных при обращении в память;
    • mem_we_o — сигнал разрешения записи во внешнюю память;
    • mem_wd_o — данные для записи во внешнюю память;
    • mem_rd_i — считанные из внешней памяти данные;
  • еще у процессора появился вход stall_i, отключающий программный счетчик.

Так же добавились источники операндов АЛУ: программный счетчик, множество констант из инструкций и микроархитектурных констант. Выбор нужных операндов для АЛУ осуществляется с помощью двух мультиплексоров, управляемых сигналами декодера a_sel_o и b_sel_o.

Изменилось и число источников записи в регистровый файл: их стало 3: результат операции на АЛУ, данные, считанные с внешней памяти и данные из модуля регистров статуса и контроля. Выборка осуществляется сигналом декодера wb_sel_o.

Интерфейс памяти

Интерфейс памяти использует несколько сигналов для взаимодействия с памятью: mem_req_o (этот выход должен быть выставлен в 1 каждый раз, когда необходимо обратиться к памяти – считать или записать), mem_we_o (выставляется в 1, если необходимо записать данные в память, и 0 – если считать из памяти) и mem_size_o (указывающий размер порции данных необходимых для передачи; возможные значения указаны в Таблице 4). Перечисленных сигналов достаточно для того, чтобы основная память понимала: обращаются ли к ней в данный момент, нужно ли записывать или считывать данные, и о какой порции данных идет речь.

НазваниеЗначение mem_size_oПояснение
LDST_B3'd0Знаковое 8-битное значение
LDST_H3'd1Знаковое 16-битное значение
LDST_W3'd232-битное значение
LDST_BU3'd4Беззнаковое 8-битное значение
LDST_HU3'd5Беззнаковое 16-битное значение

Таблица 4. Значения сигнала mem_size_o при передаче различных порций данных.

Main Decoder — Основной дешифратор команд RISC-V

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

Пример: для выполнения инструкции записи 32-бит данных из регистрового файла во внешнюю память sw, дешифратор должен:

  • направить в АЛУ два операнда (базовый адрес и смещение) вместе с кодом операции АЛУ (сложения) для вычисления адреса:
    • a_sel_o = 2'd0;
    • b_sel_o = 3'd1;
    • alu_op_o= ALU_ADD;
  • сформировать управляющие сигналы интерфейса памяти:
    • mem_req_o = 1'b1;
    • mem_size_o= 3'd2;
    • mem_we_o = 1'b1.

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

Поскольку операция sw не является операцией перехода, сигналы jal_o, jalr_o и branch_o должны быть равны нулю (иначе процессор совершит переход, а инструкция lw этого не подразумевает). Точно так же, поскольку во время записи во внешнюю память, в регистровый файл не должно быть ничего записано, сигналы gpr_we_o и csr_we_o также должны быть равны нулю.

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


Управляющие сигналы на выходе декодера зависят от трех полей инструкции: opcode, func3 и func7. Обратите внимание, что расположение этих полей одинаково для всех типов инструкций. Это сделано для удобства декодирования. При этом для некоторых инструкций поля func3 и func7 могут отсутствовать.

Название сигналаПояснениеНа каких опкодах может принять ненулевое значение (см. таблицу 6)
fetched_instr_iИнструкция для декодирования, считанная из памяти инструкций
a_sel_oУправляющий сигнал мультиплексора для выбора первого операнда АЛУНа всех кроме MISC_MEM и SYSTEM
b_sel_oУправляющий сигнал мультиплексора для выбора второго операнда АЛУНа всех кроме MISC_MEM и SYSTEM
alu_op_oОперация АЛУНа всех кроме MISC_MEMиSYSTEM
csr_op_oОперация модуля CSRТолько на SYSTEM
csr_we_oРазрешение на запись в CSRТолько на SYSTEM
mem_req_oЗапрос на доступ к памяти (часть интерфейса памяти)На LOAD и STORE
mem_we_oСигнал разрешения записи в память, «write enable» (при равенстве нулю происходит чтение)Только на STORE
mem_size_oУправляющий сигнал для выбора размера слова при чтении-записи в память (часть интерфейса памяти)На LOAD и STORE
gpr_we_oСигнал разрешения записи в регистровый файлНа всех кроме STORE, BRANCH, MISC_MEM
wb_sel_oУправляющий сигнал мультиплексора для выбора данных, записываемых в регистровый файлНа всех кроме STORE, BRANCH, MISC_MEM
illegal_instr_oСигнал о некорректной инструкции (на схеме не отмечен)На всех кроме JAL, LUI, AUIPC
branch_oСигнал об инструкции условного переходаТолько на BRANCH
jal_oСигнал об инструкции безусловного перехода jalТолько на JAL
jalr_oСигнал об инструкции безусловного перехода jalrТолько на JALR
mret_oСигнал об инструкции возврата из прерывания/исключения mretТолько на SYSTEM

Таблица 5. Описание портов основного дешифратора.

Единственным входным сигналом этого модуля является fetched_instr_i.

В системе команд RV32I два младших бита поля opcode всегда равны 11, таким образом декодер понимает, что будут исполняться именно 32-битные инструкции, а не 16-битные, например. Main decoder должен выдать единицу на выходе illegal_instr_o в случае:

  • неравенства двух младших битов opcode значению 11;
  • некорректного значения func3 или func7 для данной операции;
  • если значение opcode не совпадает ни с одним из известных и следовательно операция не определена.
  • если это инструкция ECALL / EBREAK.

При реализации декодера его удобнее описывать разбив все инструкции на однотипные группы, как это сделано ниже. Представленные в Таблице 6 коды операций 5-битные потому, что 2 младших бита полноценного 7-битного кода операции должны всегда быть равны 11. Если это не так, то вся инструкция уже запрещенная и не нуждается в дальнейшем декодировании.

ОперацияOpcodeОписание операцииКраткая запись
OP01100Записать в rd результат вычисления АЛУ над rs1 и rs2rd = alu_op(rs1, rs2)
OP_IMM00100Записать в rd результат вычисления АЛУ над rs1 и immrd = alu_op(rs1, imm)
LUI01101Записать в rd значение непосредственного операнда U-типа imm_urd = imm << 12
LOAD00000Записать в rd данные из памяти по адресу rs1+immrd = Mem[rs1 + imm]
STORE01000Записать в память по адресу rs1+imm данные из rs2Mem[rs1 + imm] = rs2
BRANCH11000Увеличить счетчик команд на значение imm, если верен результат сравнения rs1 и rs2if cmp_op(rs1, rs2) then PC += imm
JAL11011Записать в rd следующий адрес счетчика команд, увеличить счетчик команд на значение immrd = PC + 4; PC += imm
JALR11001Записать в rd следующий адрес счетчика команд, в счетчик команд записать rs1+immrd = PC + 4; PC = rs1+imm
AUIPC00101Записать в rd результат сложения непосредственного операнда U-типа imm_u и счетчика командrd = PC + (imm << 12)
MISC-MEM00011Не производить операцию-
SYSTEM11100Записать в rd значение csr. Обновить значение csr с помощью rs1. (либо mret/ecall/ebreak)csr = csr_op(rs1); rd = csr

Таблица 6. Описание кодов операций

Инструменты

В первую очередь язык описания аппаратуры SystemVerilog – это язык. С помощью этого языка человек объясняет либо синтезатору какое он хочет получить устройство, либо симулятору – как он хочет это устройство проверить. Синтезатор – это программа, которая создает из логических элементов цифровое устройство по описанию, предоставляемому человеком. Синтезатору внутри Vivado нужно объяснить, что ты от него хочешь. Например, чтобы спросить дорогу у испанца, придется делать это на испанском языке, иначе он ничем не сможет помочь. Если ты знаешь испанский, то это можно сделать еще и разными способами. В SystemVerilog точно также – одно и то же устройство можно описать разным кодом, но результат синтеза будет одним и тем же. Однако, часто два разных кода одинаковые по смыслу могут синтезироваться в разную аппаратуру, хотя функционально они будут идентичны, но могут отличаться, например, скоростью работы. Или одни и те же специальные языковые конструкции могут применяться для синтезирования разных цифровых элементов.

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

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

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

Рассмотрим пример ниже. Внутри конструкции always_comb, перед конструкцией case указываются значения по-умолчанию. Благодаря этому пропадает необходимость указывать все сигналы внутри каждого обработчика case, достаточно указать только те, что имеют значение отличное от значения по-умолчанию. Представленный пример реализует комбинационную схему, которая при cucumber == 4'b1100 будет выставлять сигнал c == 1'b0, то есть отличное, от значения по-умолчанию. Сигнал a никак не меняется, поэтому он не указан в соответствующем обработчике. Если сигнал size == 1'b0, то b будет равен 1, а d равен 0. Если сигнал size == 1'b1, то наоборот – b будет равен 0, а d равен 1.



// ... какие-то еще дефайны

module tequila (
  input  logic [3:0] cucumber;
  input  logic       size;
  output logic       a, b, c, d;
);
  parameter logic [3:0] PICKLE  =  4'b1100;
  always_comb begin
    a = 1'b0;             // значения по-умолчанию
    b = 1'b0;             // обратите внимание, что в блоке
    c = 1'b1;             // always_comb используется оператор
    d = 1'b0;             // блокирующего присваивания
    case(cucumber)
      // ...                 какие-то еще комбинации
      PICKLE: begin       // если на cucumber значение PICKLE
        c = 1'b0;
        case (size)
          1'b0: b = 1'b1; // если на size значение 1'b0
          1'b1: d = 1'b1; // если на size значение 1'b1
        endcase
      end
      // ...                  какие-то еще обработчики
      default: begin      // так как описаны не все значения
        a = 1'b0;         // cucumber, то чтобы case не было
        b = 1'b0;         // защелки (latch) на выходе
        c = 1'b1;         // нужно обязательно добавлять
        d = 1'b0;         // default
      end
    endcase
  end

endmodule

Задание

Необходимо реализовать на языке SystemVerilog модуль основного дешифратора команд однотактного процессора RISC-V в соответствии с предложенной микроархитектурой. Далее приводится прототип разрабатываемого модуля.

module decoder_riscv (
  input  logic [31:0]  fetched_instr_i,
  output logic [1:0]   a_sel_o,
  output logic [2:0]   b_sel_o,
  output logic [4:0]   alu_op_o,
  output logic [2:0]   csr_op_o,
  output logic         csr_we_o,
  output logic         mem_req_o,
  output logic         mem_we_o,
  output logic [2:0]   mem_size_o,
  output logic         gpr_we_o,
  output logic [1:0]   wb_sel_o,
  output logic         illegal_instr_o,
  output logic         branch_o,
  output logic         jal_o,
  output logic         jalr_o,
  output logic         mret_o
);
  import riscv_pkg::*;

endmodule

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

Порядок выполнения задания

  1. Внимательно ознакомьтесь с выходными сигналами декодера и тем, за что они отвечают, а также типами команд. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  2. Реализуйте модуль decoder_riscv. Для этого:
    1. В Design Sources проекта с предыдущих лаб, создайте SystemVerilog-файл decoder_riscv.sv.
    2. Опишите в нем модуль основного дешифратора с таким же именем и портами, как указано в задании.
      1. Для удобства дальнейшего описания модуля, рекомендуется сперва создать сигналы opcode, func3, func7 и присвоить им соответствующие биты входного сигнала инструкции.
      2. При описании модуля вы можете воспользоваться параметрами, объявленными пакетах riscv_pkg, csr_pkg и alu_opcodes_pkg, описанных в файлах riscv_pkg.sv, csr_pkg.sv и alu_opcodes_pkg.sv соответственно.
      3. Модуль может быть описан множеством способов: каждый выходной сигнал может быть описан через собственную комбинационную логику в отдельном блоке case, однако проще всего будет описать все сигналы через вложенные case внутри одного блока always_comb.
      4. Внутри блока always_comb до начала блока case можно указать базовые значения для всех выходных сигналов. Это не то же самое, что вариант default в блоке case. Здесь вы можете описать состояния, которые будут использованы чаще всего, и в этом случае, присваивание сигналу будет выполняться только в том месте, где появится инструкция, требующая значение этого сигнала, отличное от базового.
      5. Далее вы можете описать базовый блок case, где будет определен тип операции по ее коду.
      6. Определив тип операции, вы сможете определить какая конкретно операция по полям func3 и func7 (если данный тип имеет такие поля).
      7. Не забывайте, что в случае, если на каком-то из этапов (определения типа, или определения конкретной операции) вам приходит какое-то неправильное поле, необходимо выставить сигнал illegal_instr_o.
      8. В случае некорректной инструкции, вы должны гарантировать, что не произойдет условный/безусловный переход, а во внешнюю память и регистровый файл ничего не запишется. Не важно, что будет выполняться на АЛУ, не важно какие данные будут выбраны на мультиплексоре источника записи. Важно чтобы не произошел сам факт записи в любое из устройств (подумайте какие значения для каких сигналов необходимо для этого выставить).
  3. После описания модуля, его необходимо проверить с помощью тестового окружения.
    1. Тестовое окружение находится здесь.
    2. Для запуска симуляции воспользуйтесь этой инструкцией.
    3. Перед запуском симуляции убедитесь, что выбран правильный модуль верхнего уровня.
    4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
    5. Вполне возможно, что после первого запуска вы столкнетесь с сообщениями о множестве ошибок. Вам необходимо исследовать эти ошибки на временной диаграмме и исправить их в вашем модуле.
  4. Данная лабораторная работа не предполагает проверки в ПЛИС

Источники

  1. The RISC-V Instruction Set Manual Volume I: Unprivileged ISA
  2. The RISC-V Instruction Set Manual Volume II: Privileged Architecture

Лабораторная работа 6 "Тракт данных"

Микроархитектуру можно разделить на две части: тракт данных и устройство управления. По тракту данных перемещаются данные (из памяти инструкций, регистрового файла, АЛУ, памяти данных, мультиплексоров), а устройство управления (основной дешифратор команд) получает текущую инструкцию из тракта и в ответ говорит ему как именно выполнить эту инструкцию, то есть управляет тем, как эти данные будут через тракт данных проходить.

Цель

Описать на языке SystemVerilog процессор с архитектурой RISC-V, реализовав его тракт данных, используя разработанные ранее блоки, и подключив к нему устройство управления. Итогом текущей лабораторной работы станет процессор RISC-V, который пока что сможет обрабатывать лишь слова (то есть БЕЗ инструкций, связанных с байтами и полусловами: lh, lhu, lb, lbu, sh, sb).

Ход работы

  1. Изучить микроархитектурную реализацию однотактного процессора RISC-V (без поддержки команд загрузки/сохранения байт/полуслов)
  2. Реализовать тракт данных с подключенным к нему устройством управления(#задание)
  3. Подготовить программу по индивидуальному заданию и загрузить ее в память инструкций
  4. Сравнить результат работы процессора на модели в Vivado и в симуляторе программы ассемблера

Микроархитектура RISC-V

riscv_core

Рассмотрим микроархитектуру процессорного ядра riscv_core. Данный модуль обладает следующим прототипом и микроархитектурой:

module riscv_core (

  input  logic        clk_i,
  input  logic        rst_i,

  input  logic        stall_i,
  input  logic [31:0] instr_i,
  input  logic [31:0] mem_rd_i,

  output logic [31:0] instr_addr_o,
  output logic [31:0] mem_addr_o,
  output logic [ 2:0] mem_size_o,
  output logic        mem_req_o,
  output logic        mem_we_o,
  output logic [31:0] mem_wd_o
);

endmodule

../../.pic/Labs/lab_06_dp/fig_01.drawio.svg

Рисунок 1. Микроархитектура ядра процессора RISC-V.

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

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

Константы I,U,S используются для вычисления адресов и значений. Поэтому все эти константы должны быть подключены к АЛУ. А значит теперь, для выбора значения для операндов требуются мультиплексоры, определяющие что именно будет подаваться на АЛУ.

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

Программный счетчик (PC) теперь также изменяется более сложным образом. Поскольку появился еще один вид безусловного перехода (jalr), программный счетчик может не просто увеличиться на значение константы из инструкции, но и получить совершенно новое значение в виде суммы константы и значения из регистрового файла (см. на самый левый мультиплексор схемы). Обратите внимание, что младший бит этой суммы должен быть обнулен — таково требование спецификации.

Поскольку обращение во внешнюю память требует времени, необходимо останавливать программный счетчик, чтобы до конца обращения в память не начались исполняться последующие инструкции. Для этого у программного счетчика появился управляющий сигнал stall_i. Программный счетчик может меняться только когда этот сигнал равен нулю (иными словами, инверсия этого сигнала является сигналом enable для регистра PC).

riscv_unit

После реализации процессорного ядра, к нему необходимо подключить память. Это происходит в модуле riscv_unit.

module riscv_unit(
  input  logic        clk_i,
  input  logic        rst_i
);

endmodule

../../.pic/Labs/lab_06_dp/fig_02.drawio.svg

Рисунок 2. Микроархитектура процессора.

Обратите внимание на регистр stall. Этот регистр и будет управлять разрешением на запись в программный счетчик PC. Поскольку мы используем блочную память, расположенную прямо в ПЛИС, доступ к ней осуществляется за 1 такт, а значит, что при обращении в память, нам необходимо "отключить" программный счетчик ровно на 1 такт. Если бы использовалась действительно "внешняя" память (например чип DDR3), то вместо этого регистра появилась бы другая логика, выставляющая на вход ядра stall_i единицу пока идет обращение в память.

Задание

Реализовать ядро процессора riscv_core архитектуры RISC-V по предложенной микроархитектуре. Подключить к нему память инструкций и память данных в модуле riscv_unit. Проверить работу процессора с помощью программы, написанной на ассемблере RISC-V по индивидуальному заданию, которое использовалось для написания программы для процессора архитектуры CYBERcobra.

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

00:  addi  x1,  x0, 0x75С
04:  addi  x2,  x0, 0x8A7
08:  add   x3,  x1, x2
0C:  and   x4,  x1, x2
10:  sub   x5,  x4, x3
14:  mul   x6,  x3, x4    // неподдерживаемая инструкция
18:  jal   x15, 0x00050   // прыжок на адрес 0x68
1C:  jalr  x15, 0x0(x6)
20:  slli  x7,  x5, 31
24:  srai  x8,  x7, 1
28:  srli  x9,  x8, 29
2C:  lui   x10, 0xfadec
30:  addi  x10, x10,-1346
34:  sw    x10, 0x0(x4)
38:  sh    x10, 0x6(x4)
3C:  sb    x10, 0xb(x4)
40:  lw    x11, 0x0(x4)
44:  lh    x12, 0x0(x4)
48:  lb    x13, 0x0(x4)
4С:  lhu   x14, 0x0(x4)
50:  lbu   x15, 0x0(x4)
54:  auipc x16, 0x00004
58:  bne   x3,  x4, 0x08  // перескок через
5С:                       // нелегальную нулевую инструкцию
60:  jal   x17, 0x00004
64:  jalr  x14, 0x0(x17)
68:  jalr  x18, 0x4(x15)

Теперь в соответствии с кодировкой инструкций переведем программу в машинные коды:

00:  011101011100  00000 000 00001 0010011 (0x75C00093)
04:  100010100111  00000 000 00010 0010011 (0x8A700113)
08:  0000000 00010 00001 000 00011 0110011 (0x002081B3)
0C:  0000000 00010 00001 111 00100 0110011 (0x0020F233)
10:  0100000 00011 00100 000 00101 0110011 (0x403202B3)
14:  0000001 00100 00011 000 00110 0110011 (0x02418333)
18:  00000101000000000000    01111 1101111 (0x050007EF)
1C:  000000000000  00110 000 01111 1100111 (0x000307E7)
20:  0000000 11111 00101 001 00111 0010011 (0x01F29393)
24:  0100000 00001 00111 101 01000 0010011 (0x4013D413)
28:  0000000 11101 01000 101 01001 0010011 (0x01D45493)
2C:  11111010110111101100    01010 0110111 (0xFADEC537)
30:  101010111110  01010 000 01010 0010011 (0xABE50513)
34:  0000000 01010 00100 010 00000 0100011 (0x00A22023)
38:  0000000 01010 00100 001 00110 0100011 (0x00A21323)
3C:  0000000 01010 00100 000 01011 0100011 (0x00A205A3)
40:  000000000000  00100 010 01011 0000011 (0x00022583)
44:  000000000000  00100 001 01100 0000011 (0x00021603)
48:  000000000000  00100 000 01101 0000011 (0x00020683)
4C:  000000000000  00100 101 01110 0000011 (0x00025703)
50:  000000000000  00100 100 01111 0000011 (0x00024783)
54:  00000000000000000100    10000 0010111 (0x00004817)
58:  0000000 00011 00100 001 01000 1100011 (0x00321463)
5C:  00000000  00000000 00000000  00000000 (0x00000000)
60:  00000000010000000000    10001 1101111 (0x004008EF)
64:  000000000000  10001 000 01110 1100111 (0x00088767)
68:  000000000100  01111 000 10010 1100111 (0x00478967)

Данная программа, представленная в шестнадцатеричном формате находится в файле program.mem.

Порядок выполнения задания

  1. Внимательно ознакомьтесь микроархитектурной реализацией. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  2. Реализуйте модуль riscv_core. Для этого:
    1. В Design Sources проекта с предыдущих лаб, создайте SystemVerilog-файл riscv_core.sv.
    2. Опишите в нем модуль процессор riscv_core с таким же именем и портами, как указано в задании.
      1. Процесс реализации модуля очень похож на процесс описания модуля cybercobra, однако теперь появляется:
        1. декодер
        2. дополнительные мультиплексоры и знакорасширители.
    3. Создайте в проекте новый SystemVerilog-файл riscv_unit.sv и опишите в нем модуль riscv_unit, объединяющий ядро процессора (riscv_core) с памятями инструкция и данных.
      1. При создании объекта модуля riscv_core в модуле riscv_unit вы должны использовать имя сущности core (т.е. создать объект в виде: riscv_core core(...)
  3. После описания модуля, его необходимо проверить с помощью тестового окружения.
    1. Тестовое окружение находится здесь.
    2. Программа, которой необходимо проинициализировать память инструкций находится в файле program.mem.
    3. Для запуска симуляции воспользуйтесь этой инструкцией.
    4. Перед запуском симуляции убедитесь, что выбран правильный модуль верхнего уровня.
    5. Во время симуляции убедитесь, что в логе есть сообщение о завершении теста!
    6. Вполне возможно, что после первого запуска вы столкнетесь с сообщениями о множестве ошибок. Вам необходимо исследовать эти ошибки на временной диаграмме и исправить их в вашем модуле.
  4. Проверьте работоспособность вашей цифровой схемы в ПЛИС. Для этого:
    1. Добавьте файлы из папки board files в проект.
      1. Файл nexys_riscv_unit.sv необходимо добавить в Design Sources проекта.
      2. Файл nexys_a7_100t.xdc необходимо добавить в Constraints проекта. В случае, если вы уже добавляли одноименный файл в рамках предыдущих лабораторных работ, его содержимое необходимо заменить содержимым нового файла.
    2. Выберите nexys_riscv_unit в качестве модуля верхнего уровня (top-level).
    3. Выполните генерацию битстрима и сконфигурируйте ПЛИС. Для этого воспользуйтесь следующей инструкцией.
    4. Описание логики работы модуля верхнего уровня и связи периферии ПЛИС с реализованным модулем находится в папке board files.

Прочти меня, когда выполнишь. Поздравляю, ты сделал(а) свой первый взрослый процессор! Теперь ты можешь говорить:

Я способен(на) на всё! Я сам(а) полностью, с нуля, сделал(а) процессор с архитектурой RISC-V! Что? Не знаешь, что такое архитектура? Пф, щегол! Подрастешь – узнаешь

Лабораторная работа 7 "Внешняя память"

Ранее вы реализовали процессор архитектуры RISC-V с оговоркой что он не поддерживает инструкции sh, sb, lh, lhu, lb, lbu. Данное ограничение имеет две причины:

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

Реализации такой памяти и будет посвящена эта лабораторная работа.

Цель

Описать внешнюю память данных, полностью поддерживающую побайтовую адресацию.

Допуск к лабораторной работе

Для успешного выполнения лабораторной работы, вам необходимо:

Теория

В задании по реализации памяти инструкций лабораторной работы №3 байтовая адресация была описана следующим образом:

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

Данное описание было дано не совсем корректным образом, чтобы в третьей лабораторной работе было более четкое понимание задания. В чем заключается некорректность? Процессор должен быть способен не только обращаться к отдельным байтам в памяти, но и обновлять в памяти любой отдельный байт, а также считывать отдельные байты.

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

Нас интересует возможность памяти обновлять любой из байт в слове. Для этого используется специальный сигнал, который называется byte_enable_i. Разрядность этого сигнала равна числу байт в ячейке памяти (в данном случае разрядность byte_enable_i составляет 4). Вы можете представить byte_enable_i, как 4 провода, каждый из которых является сигналом разрешения записи для 8-ми D-триггеров, формирующих соответствующий этому проводу байт.

При этом не стоит забывать о том, что записью управляет еще и сигнал write_enable_i, определяющий режим работы памяти: запись или чтение.

Таким образом, для каждого байта addr_i / 4-ой ячейки памяти:

если write_enable_i равен единице и соответствующий этому байту бит сигнала byte_enable_i, то в данный байт памяти записывается соответствующий байт сигнала write_data_i.

При этом значение byte_enable_i может быть любым: если byte_enable_i == 4'b1001 (при write_enable == 1'b1), то данные должны быть записаны в старший и младший байты addr_i / 4-ой ячейки памяти.

Задание

Реализовать память данных с поддержкой обновления отдельных байт в выбранной ячейке памяти. Прототип модуля следующий (обратите внимание что название модуля отличается от названия памяти данных из третьей лабораторной работы — так мы сможем различать их):

module ext_mem(
  input  logic        clk_i,
  input  logic        mem_req_i,
  input  logic        write_enable_i,
  input  logic [ 3:0] byte_enable_i,
  input  logic [31:0] addr_i,
  input  logic [31:0] write_data_i,
  output logic [31:0] read_data_o,
  output logic        ready_o
);

Как и память данных из лабораторной работы №3, память данных в данной лабораторной состоит из 4096-и 32-разрядных ячеек и обладает портом синхронного чтения, обновляющим данные только по запросу на чтение (mem_req_i & !write_enable_i).

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

Если mem_req_i == 1 и write_enable_i == 1 (т.е. если происходит запрос на запись в память), то необходимо обновить данные в тех байтах addr_i / 4-ой ячейки памяти, которые соответствуют единичным битам сигнала byte_enable_i.

Сигнал ready_o тождественно равен единице.

Порядок выполнения работы

  1. Внимательно ознакомьтесь с заданием. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  2. Реализуйте память данных. Для этого:
    1. В Design Sources проекта создайте SystemVerilog-файл ext_mem.sv.
    2. Опишите в нем модуль памяти данных с таким же именем и портами, как указано в задании.
      1. Данный модуль будет очень похож на память данных из лабораторной работы №3 (в частности, логика порта на чтение будет в точности повторять логику той памяти данных).
      2. Отличие заключается в двух новых сигналах ready_o и byte_enable_i.
        1. Сигнал ready_o тождественно равен единице.
        2. Сигнал byte_enable_i используется в качестве сигнала, разрешающего запись (в случае операции записи) в конкретный байт ячейки памяти.
    3. После описания памяти данных, её необходимо проверить с помощью тестового окружения.
      1. Тестовое окружение находится здесь.
      2. Для запуска симуляции воспользуйтесь этой инструкцией.
      3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_ext_mem).
      4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!

Лабораторная работа 8 "Блок загрузки и сохранения"

Итогом шестой лабораторной работы стал практически завершенный процессор архитектуры RISC-V. Особенностью той реализации процессора было отсутствие поддержки инструкций LB, LBU, SB, LH, LHU, SH. Тому было две причины:

  • в третьей лабораторной работе была реализована память данных, не поддерживавшая возможность обновления отдельных байт;
  • необходимо подготовить считанные из памяти полуслова / байты для записи в регистровый файл.

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

Для этих целей используется специальный модуль — Блок загрузки и сохранения (Load and Store Unit, LSU).

Цель

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


Ход работы

Изучить:

  • Функции и задачи блока загрузки/сохранения
  • Интерфейс процессора и блока загрузки/сохранения
  • Интерфейс блока загрузки/сохранения и памяти

Реализовать и проверить модуль riscv_lsu.


Теория

Модуль загрузки и сохранения (Load/Store UnitLSU) служит для исполнения инструкций типа LOAD и STORE: является прослойкой между внешним устройством – памятью, и ядром процессора. LSU считывает содержимое из памяти данных или записывает в нее требуемые значения, преобразуя 8- и 16-битные данные в знаковые или беззнаковые 32-битные числа для регистров процессора. В процессорах с RISC архитектурой с помощью LSU осуществляется обмен данными между регистрами общего назначения и памятью данных.

../../.pic/Labs/lab_08_lsu/fig_01.drawio.svg

Рисунок 1. Место LSU в микроархитектуре RISC-процессора.

Интерфейс процессора и блока загрузки/сохранения

Параграф посвящен описанию сигналов и правил взаимодействия между процессором и блоком загрузки/сохранения LSU (core protocol).

На входной порт core_addr_i от процессора поступает адрес ячейки памяти, к которой будет произведено обращение. Намеренье процессора обратиться к памяти (и для чтения, и для записи) отражается выставлением сигнала core_req_i в единицу. Если процессор собирается записывать в память, то сигнал core_we_i выставляется в единицу, а сами данные, которые следует записать, поступают от него на вход core_wd_i. Если процессор собирается читать из памяти, то сигнал core_we_i находится в нуле, а считанные данные подаются для процессора на выход core_rd_o.

Инструкции LOAD и STORE в RV32I поддерживают обмен 8-битными, 16-битными или 32-битными значениями, однако в самом процессоре происходит работа только с 32-битными числами, поэтому загружая байты или полуслова из памяти их необходимо предварительно расширить до 32-битного значения. Для выбора разрядности на вход LSU подается сигнал core_size_i, принимающий следующие значения:

НазваниеЗначениеПояснение
LDST_B3'd0Знаковое 8-битное значение
LDST_H3'd1Знаковое 16-битное значение
LDST_W3'd232-битное значение
LDST_BU3'd4Беззнаковое 8-битное значение
LDST_HU3'd5Беззнаковое 16-битное значение

Формат представления числа (является оно знаковым или беззнаковым) имеет значение только для операций типа LOAD: если число знаковое, то производится расширение знака до 32 бит, а если беззнаковое – расширение нулями.

Для операций типа STORE формат представления чисел не важен, для них core_size_i сможет принимать значение только от 0 до 2.

Выходной сигнал core_stall_o нужен для остановки программного счетчика. Ранее логика этого сигнала временно находилась в модуле riscv_unit — теперь она займет свое законное место в модуле LSU.

Интерфейс блока загрузки/сохранения и памяти

В параграфе описывается организация внешней памяти, и то, как к ней подключается LSU.

Память данных имеет 32-битную разрядность ячейки памяти и поддерживает побайтовую адресацию. Это значит, что существует возможность записи значения по одному байту в пределах одного слова (4-байтовой ячейки памяти). Для указания на необходимые байты интерфейс к памяти предусматривает использование 4-битного сигнала mem_be_o, подаваемого вместе с адресом слова mem_addr_o. Позиции битов 4-битного сигнала соответствуют позициям байтов в слове. Если конкретный бит mem_be_o равен 1, то соответствующий ему байт будет записан в память. Данные для записи подаются на выход mem_wd_o. На результат чтения из памяти состояние mem_be_o не влияет, так как чтение производится всегда по 32-бита.

После получения запроса на чтение/запись из ядра, LSU перенаправляет запрос в память данных, взаимодействие осуществляется следующими сигналами:

  • сигнал mem_req_o сообщает памяти о наличии запроса в память (напрямую подключен к core_req_i);
  • сигнал mem_we_o сообщает памяти о типе этого запроса (напрямую подключен к core_we_i):
    • mem_we_o равен 1, если отправлен запрос на запись,
    • mem_we_o равен 0, если отправлен запрос на чтение;
  • сигнал mem_wd_o содержит данные на запись в память. В зависимости от размера записи, данные этого сигнала будут отличаться от пришедшего сигнала core_wd_i и будут является результатом определенных преобразований.
  • сигнал mem_rd_i содержит считанные из памяти данные. Перед тем, как вернуть считанные данные ядру через выходной сигнал core_rd_o, эти данные будет необходимо подготовить.
  • сигнал mem_ready_i сообщает о готовности памяти завершить транзакцию на текущем такте. Этот сигнал используется для управления выходным сигналом core_stall_o.

Практика

Познай как описать выходные сигналы модуля — и ты познаешь как описать сам модуль. ©Джейсон Стейтем

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

mem_req_o, mem_we_o, mem_addr_o

Все эти сигналы подключаются напрямую к соответствующим core-сигналам:

  • mem_req_o к core_req_i;
  • mem_we_o к core_we_i;
  • mem_addr_o к core_addr_i.

mem_be_o

По запросу на запись (core_req_i == 1, core_we_i == 1), если core_size_i соответствует инструкции записи байта (SB), то в сигнале mem_be_o бит с индексом равным значению двух младших бит адреса core_addr_i должен быть равен единице.

Допустим, пришел запрос на запись байта по адресу 18:

  • core_req_i == 1,
  • core_we_i == 1,
  • core_size_i == LDST_B
  • core_addr_i == 32'b10010

В данном случае, необходимо выставить единицу во втором (считая с нуля) бите сигнала mem_be_o (поскольку значение двух младших бит core_addr_i равно двум): mem_be_o == 4'b0100.

Если пришел запрос на запись полуслова (SH, core_size_i == LDST_H), то в сигнале mem_be_o необходимо выставить в единицу либо два старших, либо два младших бита (в зависимости от core_addr[1])

Если пришел запрос на запись слова (SW, core_size_i == LDST_W), то в сигнале mem_be_o необходимо выставить в единицу все биты.

mem_wd_o

Сигнал mem_wd_o функционально связан с сигналом mem_be_o, т.к. они оба выполняют функцию записи конкретных байт в памяти. Допустим процессор хочет записать байт 0xA5 по адресу 18. Для этого он формирует сигналы:

  • core_req_i == 1,
  • core_we_i == 1,
  • core_size_i == LDST_B
  • core_addr_i == 32'b10010
  • core_wd_i == 32h0000_00A5

Мы уже знаем, что mem_be_o должен быть при этом равен 4'b0100. Однако если в память придут сигналы:

  • mem_be_o == 4'b0100,
  • mem_wd_o == 32'h0000_00A5

то по адресу 18 будет записано значение 0x00 (поскольку второй байт на шине mem_wd_o равен нулю).

Для того, чтобы по 18-ому адресу записалось значение A5, это значение должно оказаться во втором байте mem_wd_o. А в случае 17-го адреса, значение должно оказаться в первом байте и т.п.

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

В случае записи полуслова (core_size_i == LDST_H) ситуация схожа, только теперь дублировать надо не 1 байт 4 раза, а полслова (16 младших бит шины core_wd_i) два раза.

В случае записи слова (core_size_i == LDST_W), сигнал mem_wd_o будет повторять сигнал core_wd_i.

core_rd_o

Сигнал core_rd_o — это сигнал, который будет содержать данные для записи в регистровый файл процессора во время инструкций загрузки из памяти (LW, LH, LHU, LB, LBU). Чтобы понять, как управлять этим сигналом, нужно понять, что происходит во время этих инструкций.

Предположим, по адресам 16-19 лежит слово 32'hA55A_1881. Чтение по любому из адресов 16, 17, 18, 19 вернет это слово на входном сигнале mem_rd_i. В случае инструкции LB (core_size_i == LDST_B) по адресу 19 (чтение байта, который интерпретируется как знаковое число), в регистровый файл должно быть записано значение 32'hFFFF_FFA5, поскольку по 19-ому адресу лежит байт A5, который затем будет знакорасширен. В случае той же самой инструкции, но по адресу 18, в регистровый файл будет записано значение 32'h0000_005A (знакорасширенный байт 5A, расположенный по 18ому адресу).

Получить нужный байт можно из входного сигнала mem_rd_i, но чтобы понять какие биты этого сигнала нас интересуют, необходимо посмотреть на входные сигналы core_size_i и core_addr_i[1:0]. core_size_i сообщит конкретный тип инструкции (сколько нужно взять байт из считанного слова), а core_addr_i[1:0] укажет номер начального байта, который нужно взять из mem_rd_i.

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

А для инструкций LBU и LHU будет все тоже самое, только результат будет не знакорасширен, а дополнен нулями.

Для инструкций LW на выход core_rd_o пойдут данные mem_rd_i без изменений.

core_stall_o

Сигнал core_stall_o запрещает менять значение программного счетчика на время обращения в память. Этот сигнал должен:

  • стать равным единице в тот же такт, когда пришел сигнал core_req_i
  • удерживать это значение до тех пор, пока не придет сигнал mem_ready_i, но не менее 1 такта (т.е. даже если сигнал mem_ready_i будет равен единице, core_req_i должен подняться хотя бы на 1 такт).

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

../../.pic/Labs/lab_08_lsu/fig_02.png

Рисунок 2. Таблица истинности выхода core_stall_o.


Задание

Реализовать блок загрузки и сохранения со следующим прототипом:

module riscv_lsu(
  input logic clk_i,
  input logic rst_i,

  // Интерфейс с ядром
  input  logic        core_req_i,
  input  logic        core_we_i,
  input  logic [ 2:0] core_size_i,
  input  logic [31:0] core_addr_i,
  input  logic [31:0] core_wd_i,
  output logic [31:0] core_rd_o,
  output logic        core_stall_o,

  // Интерфейс с памятью
  output logic        mem_req_o,
  output logic        mem_we_o,
  output logic [ 3:0] mem_be_o,
  output logic [31:0] mem_addr_o,
  output logic [31:0] mem_wd_o,
  input  logic [31:0] mem_rd_i,
  input  logic        mem_ready_i
);

../../.pic/Labs/lab_08_lsu/fig_03.drawio.svg

Рисунок 3. Структурная схема модуля riscv_lsu.


Порядок выполнения задания

  1. Внимательно ознакомьтесь с описанием функционального поведения выходов LSU. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  2. Реализуйте модуль riscv_lsu. Для этого:
    1. В Design Sources проекта с предыдущих лаб, создайте SystemVerilog-файл riscv_lsu.sv.
    2. Опишите в нем модуль riscv_lsu с таким же именем и портами, как указано в задании.
      1. При описании обратите внимание на то, что большая часть модуля является чисто комбинационной. В этом плане реализация модуля будет частично похожа на реализацию декодера.
      2. Однако помимо комбинационной части, в модуле будет присутствовать и один регистр.
  3. После описания модуля, его необходимо проверить с помощью тестового окружения.
    1. Тестовое окружение находится здесь.
    2. Для запуска симуляции воспользуйтесь этой инструкцией.
    3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_lsu).
    4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!

Лабораторная работа 9 "Интеграция блока загрузки и сохранения"

После реализации блока загрузки и сохранения, его необходимо интегрировать в процессорную систему. На рис. 1 представлена схема, иллюстрирующая интеграцию компонентов:

../../.pic/Labs/lab_08_lsu/fig_01.drawio.svg

Рисунок 1. Подключение LSU в процессорную систему.

Задание

Интегрировать модуль riscv_lsu в модуль riscv_unit c использованием внешней памяти (ext_mem) из лабораторной работы №7.

Порядок выполнения работы

  1. Интегрируйте модули riscv_lsu и ext_mem в модуль riscv_unit.
    1. Обратите внимание, что из модуля riscv_unit необходимо убрать логику сигнала stall, т.к. она была перемещена внутрь модуля riscv_lsu.
    2. Модуль data_mem из ЛР3 заменяется модулем ext_mem из ЛР7.
  2. После интеграции модулей, проверьте процессорную систему с помощью программы из ЛР6.
    1. Обратите внимание на то, как теперь исполняются инструкции sw, sh, sb, lw, lh, lb, lhu, lbu.

Лабораторная работа 10 "Подсистема прерывания"

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

Цель

  1. Разработать модуль контроллера прерываний.
  2. Разработать модуль контроллера регистров статуса и контроля (CSR-контроллер).

Ход выполнения

  1. Изучение теории по прерываниям и исключениям в архитектуре RISC-V, включая работу с регистрами статуса и контроля (CSR) и механизмы реализации прерываний.
  2. Реализация схемы обработки прерывания для устройства на основе RISC-V
  3. Реализация схемы управления регистрами статуса и контроля.

Теоретическая часть

Прерывания/Исключения

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

С.А. Орлов, Б.Я. Цилькер в учебнике "Организация ЭВМ и систем" дают следующее определение системе прерывания:

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

Прерывания делятся на маски́руемые — которые при желании можно игнорировать (на которые можно наложить битовую маску, отсюда ударение на второй слог), и немаски́руемые — которые игнорировать нельзя (например сбой генератора тактового синхроимпульса в микроконтроллерах семейства PIC24FJ512GU410[2, стр. 130]). Прерывание похоже на незапланированный вызов функции, вследствие события в аппаратном обеспечении. Программа (функция), запускаемая в ответ на прерывание, называется обработчиком прерывания.

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

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

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

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


На протяжении многих лет, концепция понятия "прерывание" постоянно расширялась. Семейство процессоров 80x86 внесло ещё большую путаницу введя инструкцию int (программное прерывание). Многие производители используют такие термины как: исключение (exception), ошибка (fault), отказ (abort), ловушка (trap) и прерывание (interrupt), чтобы описать явление, которому посвящена данная лабораторная работа. К несчастью, не существует какого-то чёткого соглашения насчёт этих названий. Разные авторы по-разному приспосабливают эти термины для своего повествования[3, стр. 995]. Для того, чтобы постараться избежать путаницы, в данной лабораторной работе мы будем использовать три термина, которые введены в спецификации архитектуры RISC-V[4, стр. 10], однако имейте в виду, что за пределами этой методички и спецификации RISC-V в эти термины могут вкладывать другие смыслы.

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

  1. Под исключением будут подразумеваться нетипичные условия, произошедшие во время исполнения программы, связанные с инструкцией в текущем харте (hart, сокращение от hardware thread — аппаратном потоке).
  2. Под прерыванием будут подразумеваться внешние асинхронные события, которые могут стать причиной непредвиденной передачи управления внутри текущего харта.
  3. Под перехватом (вариант глагольного использования слова trap, которое обычно переводят как "ловушка") будет подразумеваться передача управления обработчику перехватов (trap handler), вызванная либо прерыванием, либо исключением.

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

Соответственно перехватом будет называться обобщение этих двух терминов.

Прерывания и исключения — это события (причины). Перехват — это действие (следствие).


Современные процессоры, предусматривающие запуск операционной системы, обладают несколькими уровнями привилегий выполнения инструкций. Это значит, что существует специальный регистр, определяющий режим, в котором в данный момент находится вычислительная машина. Наличие определенного значения в этом регистре устанавливает определенные ограничения для выполняемой в данный момент программы. В архитектуре RISC-V выделяется 4 режима работы, в порядке убывания возможностей и увеличения ограничений:

  1. машинный (machine mode), в котором можно всё;
  2. гипервизора (hypervisor mode), который поддерживает виртуализацию машин, то есть эмуляцию нескольких машин (потенциально с несколькими операционными системами), работающих на одной физической машине;
  3. привилегированный (supervisor mode), для операционных систем, с возможностью управления ресурсами;
  4. пользовательский (user mode), для прикладных программ, использующих только те ресурсы, которые определила операционная система.

../../.pic/Labs/lab_10_irq/fig_01.png

Рисунок 1. Распределение привилегий по уровням абстракций программного обеспечения.

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

Системы прерываний имеет ряд характеристик, которые варьируются в зависимости от их реализации. Все системы можно условно разбить на две категории: обзорные (прямые) и векторные.

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

В векторных системах прерывания разные события приводят к запуску на исполнение разных программ обработчиков. Адрес начала обработчика перехвата называется вектором прерывания. В векторных системах прерывания выделяется фрагмент памяти, в котором хранятся адреса переходов на начало каждого из обработчиков. Такой участок памяти называется таблицей векторов прерываний (Interrupt Vector Table, IVT).

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

Регистры Статуса и Управления (Control and Status Registers)

Для поддержания работы операционной системы, виртуализации, системы прерывания и тому подобное, в архитектуре RISC-V предусмотрено использование группы регистров, под общим названием Control and Status Registers (CSR), обеспечивающих управление элементами процессора и доступ к статусной информации о системе. С помощью этих регистров реализуются привилегированные режимы работы процессора, хранение указателей на различные программные стеки, статус различных подсистем, регистры для обеспечения работы прерываний и многое другое.

Все регистры имеют уникальные 12-битные адреса, а их роли определены в спецификации на архитектуру RISC-V. В Таблице 1 приводится фрагмент спецификации привилегированной архитектуры[5, стр. 10], иллюстрирующая некоторые из регистров. В левом столбце указан 12-битный адрес. Далее указывается в каком режиме, что можно делать с этим регистром. После идет название, а в правом столбике описание.

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

../../.pic/Labs/lab_10_irq/tab_01.png

Таблица 1. Регистры контроля и состояния машинного (наивысшего) уровня привилегий.

Для работы с CS-регистрами используются специальные инструкции SYSTEM (1110011) I-типа, хранящие в 12-битном поле imm адрес регистра, к которому будет осуществлен доступ и адреса в регистровом файле откуда будет считан или куда будет записан один из CS-регистров. Вы уже добавляли поддержку этих инструкций во время выполнения лабораторной работы №5 "Основной дешифратор".

opcodefunc3ТипИнструкцияОписаниеОперация
1110011000ImretВозврат из прерыванияPC = mepc
1110011001Icsrrw rd, csr, rs1Чтение и Запись CSRrd = csr, csr = rs1
1110011010Icsrrs rd, csr, rs1Чтение и Установка бит CSRrd = csr, csr = csr | rs1
1110011011Icsrrc rd, csr, rs1Чтение и Очистка бит CSRrd = csr, csr = csr & ~rs1
1110011101Icsrrwi rd, csr, rs1Чтение и Запись CSRrd = csr, csr = imm
1110011110Icsrrsi rd, csr, rs1Чтение и Установка бит CSRrd = csr, csr = csr | imm
1110011111Icsrrci rd, csr, rs1Чтение и Очистка бит CSRrd = csr, csr = csr & ~imm

Таблица 2. Список инструкций для работы с регистрами контроля и статуса.

Для удобства программирования на языке ассемблера RISC-V существуют псевдоинструкции для работы с CS-регистрами.

ПсевдоинструкцияИнструкция RISC-VОписаниеОперация
csrr rd, csrcsrrs rd, csr, x0Чтение CSRrd = csr
csrw csr, rs1csrrw x0, csr, rs1Запись CSRcsr = rs1

Таблица 3. Псевдоинструкции для работы с регистрами контроля и статуса.

Операция логического ИЛИ нулевого регистра с содержимым CS-регистра не меняет его содержимого, поэтому при использовании инструкции csrr происходит только операция чтения. Подобным образом реализована псевдоинструкция csrw.

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

АдресУровень привилегийНазваниеОписание
Machine Trap Setup
0x304MRWmieРегистр маски прерываний.
0x305MRWmtvecБазовый адрес обработчика перехвата.
0x340MRWmscratchАдрес верхушки стека обработчика перехвата.
0x341MRWmepcРегистр, хранящий адрес перехваченной инструкции.
0x342MRWmcauseПричина перехвата

Таблица 4. Список регистров, подлежащих реализации в рамках лабораторной работы.

По адресу 0x304 должен располагаться регистр, позволяющий маскировать прерывания. Например, если на 5-ом входе системы прерывания генерируется прерывание, то процессор отреагирует на него только в том случае, если 5-ый бит регистра mie будет равен 1.

Регистр mtvec состоит из двух полей: BASE[31:2] и MODE. Поле BASE хранит старшие 30 бит базового адреса обработчика перехвата (поскольку этот адрес должен быть всегда равен четырем, младшие два бита считаются равными нулю). Поле MODE кодирует тип системы прерывания:

  • MODE == 2'd0 — система прерывания обзорная;
  • MODE == 2'd1 — система прерывания векторная.

../../.pic/Labs/lab_10_irq/fig_02.png

Рисунок 2. Разделение регистра mtvec на поля BASE и MODE

В случае обзорной системы прерывания, любой перехват приводит к загрузке в PC значения базового адреса обработчика перехвата (PC=BASE). В векторной системе прерывания исключения обрабатываются таким же способом, как и в обзорной системе, а вот прерывания обрабатываются путем загрузки в PC суммы базового адреса и учетверенного значения причины прерывания (PC=BASE+4*CAUSE).

В рамках данной лабораторной работы мы будем реализовывать обзорную систему прерываний. Кроме того, поскольку у обзорной системы прерываний MODE==0, что совпадет с тем, что два младших бита базового адреса обработчика перехвата должны быть равны нулю, при перехвате мы можем присваивать программному счетчику значение mtvec без каких-либо преобразований.

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

То как кодируется причина перехвата в регистре mcause описано в спецификации привилегированной архитектуры[5, стр. 38]:

../../.pic/Labs/lab_10_irq/tab_05.png

Таблица 5. Кодирование причины перехвата в регистре mcause.

Нас интересуют части, выделенные красным. В первую очередь то, как кодируется старший бит регистра mcause. Он зависит от типа причины перехвата (1 в случае прерывания, 0 в случае исключения). Оставшиеся 31 бит регистра отводятся под коды различных причин. Поскольку мы создаем учебный процессор, который не будет использован в реальной жизни, он не будет поддерживать большую часть прерываний/исключений (таких как невыровненный доступ к памяти, таймеры и т.п.). В рамках данного курса мы должны поддерживать исключение по нелегальной инструкции (код 0x02) и должны уметь поддерживать прерывания периферийных устройств (под которые зарезервированы коды начиная с 16-го). В рамках данной лабораторной работы процессор будет поддерживать только один источник прерывания, поэтому для кодирования причины прерывания нам потребуется только первый код из диапазона "Designated for platform use". В случае, если вы захотите расширить количество источников прерываний, вы можете выполнить вспомогательную лабораторную работу №12.

Таким образом: в случае если произошло исключение (в связи с нелегальной инструкцией), значение mcause должно быть 0x00000002. Если произошло прерывание, значение mcause должно быть 0x80000010.

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

Когда процессор включается, программа первым делом должна инициализировать все требуемые CS-регистры, в частности:

  • задать маску прерывания mie,
  • задать адрес вектора прерывания mtvec,
  • задать адрес вершины стека прерываний mscratch.

После чего уже можно переходить к исполнению основного потока инструкций.

Реализация прерываний в архитектуре RISC-V

Процессор RISC-V может работать в одном из нескольких режимов выполнения с различными уровнями привилегий. Машинный режим – это самый высокий уровень привилегий; программа, работающая в этом режиме, может получить доступ ко всем регистрам и ячейкам памяти. M-режим является единственным необходимым режимом привилегий и единственным режимом, используемым в процессорах без операционной системы, включая многие встраиваемые системы.

Обработчики прерываний/исключений используют для перехвата четыре специальных регистра управления и состояния (CSR): mtvec, mcause, mepc и mscratch. Регистр базового адреса вектора прерывания mtvec, содержит адрес кода обработчика прерывания. При перехвате процессор:

  • записывает причину перехвата в mcause,
  • сохраняет адрес перехваченной инструкции, в mepc,
  • переходит к обработчику перехвата, загружая в PC адрес, предварительно настроенный в mtvec.

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

После выполнения программы-обработчика перехвата, возвращение в программу выполняется командой возврата mret, которая помещает в PC значение регистра mepc. Сохранение PC инструкции при прерывании в mepc аналогично использованию регистра ra для хранения обратного адреса во время инструкции jal. Обработчики прерываний должны использовать программные регистры (x1−x31) для своей работы, поэтому они используют память, на которую указывает mscratch, для хранения и восстановления этих регистров.

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

Периферийное устройство, которое может генерировать прерывание, подключается к контроллеру прерывания паре проводов: запрос на прерывание (irq_req_i) и прерывание обслужено (irq_ret_o). Предположим, к контроллеру прерываний подключили клавиатуру. Когда на ней нажимают клавишу, код этой клавиши попадает в буферный регистр с дополнительным управляющим битом, выставленным в единицу, который подключен к входу запроса на прерывание. Если прерывание не замаскировано (в нашем процессоре это означает, что нулевой бит регистра mie выставлен в 1), то контроллер прерывания сгенерирует код причины прерывания (в нашем случае — это константа 0x80000010). Кроме этого, контроллер прерывания подаст сигнал irq_o, чтобы устройство управления процессора узнало, что произошло прерывание и разрешило обновить содержимое регистра причины mcause, сохранило адрес прерванной инструкции в mepc и загрузило в PC вектор прерывания mtvec.

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

Структура разрабатываемых устройств

В рамках лабораторной работы необходимо реализовать поддержку обработки аппаратных прерываний. Для этого необходимо реализовать два модуля: блок управления регистрами контроля и статуса (CSR-контроллер) и контроллер прерываний (Interrupt Controller).

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

Контроллер прерываний позволит обрабатывать входящие запросы на прерывания: маски́ровать их, выбирать один запрос из нескольких, а также игнорировать запросы во время обработки текущего прерывания.

../../.pic/Labs/lab_10_irq/fig_03.drawio.svg

Рисунок 3. Место разрабатываемых блоков в структуре процессора.

Пока что вам нужно реализовать только блоки irq controller и control status registers, а не саму схему, приведенную выше.

CSR-контроллер

Рассмотрим один из возможных вариантов организации блока Control and Status Registers. Основная работа по описанию схемы блока состоит в описании мультиплексора и демультиплексора. Мультиплексор подает на выход read_data_o значение регистра, который соответствует пришедшему адресу. В свою же очередь, демультиплексор маршрутизирует сигнал разрешения на запись write_enable_i (en) на тот же регистр.

../../.pic/Labs/lab_10_irq/fig_04.drawio.svg

Рисунок 4. Структурная схема контроллера CS-регистров.

3-битный вход opcode_i определяет операцию, которая будет производиться над содержимым CSR по адресу addr_i.

Для реализации мультиплексора на языке описания аппаратуры SystemVerilog можно воспользоваться конструкцией case внутри блока always_comb. Для реализации демультиплексора также можно использовать case, только если при описании мультиплексора в зависимости от управляющего сигнала на один и тот же выход идут разные входы, то при описании демультиплексора все будет наоборот: в зависимости от управляющего сигнала, один и тот же вход будет идти на разные выходы (например, на разные биты многоразрядной шины enable).

Мультиплексоры, располагаемые на входах регистров mepc и mcause нужны, чтобы при возникновении сигнала прерывания сразу же разрешить обновить значение этих регистров значением pc_i, на котором произошел перехват и кодом причины происходящего сейчас перехвата.

Контроллер прерываний

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

../../.pic/Labs/lab_10_irq/fig_05.drawio.svg

Рисунок 5. Структурная схема контроллера прерываний.

Контроллер состоит из логики:

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

Регистры отслеживания обработки прерывания и исключения нужны для того, чтобы мы могли понимать, что в данный момент процессор уже выполняет обработку прерывания / исключения. В такие моменты (если любой из регистров exc_h/irq_h содержит значение 1) все последующие запросы на прерывание игнорируются. За это отвечают вентили И и ИЛИ-НЕ в правом верхнем углу схемы.

Однако возможна ситуация возникновения исключения во время обработки прерывания — в этом случае, оба регистра будут хранить значение 1. В момент возврата из обработчика, придет сигнал mret_i, который в первую очередь сбросит регистр exc_h и только если тот равен нулю, сбросит регистр irq_h.

Исключение во время обработки исключения не поддерживается данной микроархитектурой и приведет к неопределенному поведению. Поэтому код обработчика исключений должен быть написан с особым вниманием.

Логика установки и сброса работает следующим образом:

  • если сигнал, обозначенный в прямоугольнике как reset равен единице, в регистр будет записано значение 0;
  • если сигнал, обозначенный в прямоугольнике как set равен единице, в регистр будет записано значение 1;
  • в остальных случах, регистр сохраняет свое значение.

Обратите внимание, что логика установки и сброса регистров дает приоритет сбросу, хотя сигнал сброса никогда не придет одновременно с сигналом установки (поскольку инструкция mret не генерирует исключение, сигнал mret_i никогда не придет одновременно с сигналом exception_i, а логика приоритета исключений над прерываниями не даст сигналу mret распространиться до регистра irq_h одновременно с формированием сигнала irq_o).

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

Логика маскирования запросов на прерывания заключается в простейшем И между запросом на прерывания (irq_req_i) и сигналом разрешения прерывания (mie_i).

Пример обработки перехвата

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

Алгоритм работы обработчика перехвата (trap handler) выглядит следующим образом:

  1. сохраняется содержимое регистрового файла на стек;
  2. проверяется регистр причины чтобы запустить необходимую подпрограмму;
  3. происходит вызов необходимой подпрограммы;
  4. после возврата происходит восстановление содержимого регистрового файла;
  5. затем происходит возврат управления прерванной программе.

Если бы система прерывания была векторной, то рутина со считыванием кода причины отсутствовала.

_start:
# Инициализируем начальные значения регистров
00: li x2, 0x00003FFC       # устанавливаем указатель на верхушку стека
04:                         # данная псевдоинструкция будет разбита на две
                            # инструкции: lui и addi

08: li x3, 0x00000000       # устанавливаем указатель на глобальные данные

0C: li x5, 0x00000001       # подготавливаем маску прерывания единственного
                            # (нулевого) входа
10: csrw mie, x5            # загружаем маску в регистр маски

14: la x5, trap_handler     # псевдоинструкция la аналогично li загружает число,
18:                         # только в случае la — это число является адресом
                            # указанного места (адреса обработчика перехвата)
                            # данная псевдоинструкция будет разбита на две
                            # инструкции: lui и addi

1С: csrw mtvec, x5          # устанавливаем вектор прерывания

20: li x5, 0x00001FFC       # готовим адрес верхушки стека прерывания
24:                         # данная псевдоинструкция будет разбита на две
                            # инструкции: lui и addi

28: csrw mscratch, x5       # загружаем указатель на верхушку стека прерывания

2С: li x5, 1                # начальное значение глобальной переменной
30: sw x5, 0(x3)            # загружаем переменную в память

34: li x6, 0                # начальное значение, чтобы в симуляции не было xxx
38: li x7, 0                # начальное значение, чтобы в симуляции не было xxx

# Вызов ecall исключительно из хулиганских соображений, поскольку в данной
# микроархитектурной реализации это приведет к появлению illegal_instr и
# последующей обработке исключения
3С: ecall

# Вызов функции main
main:
40: beq x0, x0, main        # бесконечный цикл, аналогичный while (1);

# ОБРАБОТЧИК ПЕРЕХВАТА
# Без стороннего вмешательства процессор никогда не перейдет к инструкциям ниже,
# однако в случае прерывания в программный счетчик будет загружен адрес первой
# нижележащей инструкции.

# Сохраняем используемые регистры на стек
trap_handler:
44: csrrw x5, mscratch, x5  # меняем местами mscratch и x5
48: sw x6, 0(x5)            # сохраняем x6 на стек mscratch
4С: sw x7, 4(x5)            # сохраняем x7 на стек mscratch

# Проверяем произошло ли прерывание
50: csrr x6, mcause         # x6 = mcause
54: li x7, 0x80000010       # загружаем в x7 код того, что произошло прерывание
58:                         # данная псевдоинструкция будет разбита на две
                            # инструкции: lui и addi
5C: bne x6, x7, exc_handler # если коды не совпадают, переходим к проверке
                            # на исключение
# Обработчик прерывания
60: lw x7, 0(x3)            # загружаем переменную из памяти
64: addi x7, x7, 3          # прибавляем к значению 3
68: sw x7, 0(x3)            # возвращаем переменную в память
6C: j done                  # идем возвращать регистры и на выход

exc_handler:                # Проверяем произошло ли исключение
70: li x7, 0x0000002        # загружаем в x7 код того, что произошло исключение
74: bne x6, x7, done        # если это не оно, то выходим

# Обработчик исключения
78: csrr x6, mepc           # Узнаем значение PC (адреса инструкции,
                            # вызвавшей исключение)
7C: lw x7, 0x0(x6)          # Загружаем эту инструкцию в регистр x7.
                            # В текущей микроархитектурной реализации это
                            # невозможно, т.к. память инструкций отделена от
                            # памяти данных и не участвует в выполнении
                            # операций load / store.
                            # Другой способ узнать об инструкции, приведшей
                            # к исключению — добавить поддержку статусного
                            # регистра mtval, в который при исключении
                            # может быть записана текущая инструкция.
                            # Теоретически мы могли бы после этого
                            # сделать что-то, в зависимости от этой инструкции.
                            # Например, если это операция умножения — вызвать
                            # подпрограмму умножения.

80: addi x6, x6, 4          # Увеличиваем значение PC на 4, чтобы после
                            # возврата не попасть на инструкцию, вызвавшую
                            # исключение.
84: csrw mepc, x6           # Записываем обновленное значение PC в регистр mepc
88: j done                  # идем восстанавливать регистры со стека и на выход

# Возвращаем регистры на места и выходим
done:
8C: lw x6, 0(x5)            # возвращаем x6 со стека
90: lw x7, 4(x5)            # возвращаем x7 со стека
94: csrrw x5, mscratch, x5  # меняем обратно местами x5 и mscratch
98: mret                    # возвращаем управление программе (pc = mepc)
                            # что означает возврат в бесконечный цикл

Задание

  1. Описать на языке SystemVerilog модуль контроллера регистров статуса и контроля (CSR-контроллер) со следующим прототипом:
module csr_controller(

  input  logic        clk_i,
  input  logic        rst_i,
  input  logic        trap_i,

  input  logic [ 2:0] opcode_i,

  input  logic [11:0] addr_i,
  input  logic [31:0] pc_i,
  input  logic [31:0] mcause_i,
  input  logic [31:0] rs1_data_i,
  input  logic [31:0] imm_data_i,
  input  logic        write_enable_i,

  output logic [31:0] read_data_o,
  output logic [31:0] mie_o,
  output logic [31:0] mepc_o,
  output logic [31:0] mtvec_o
);

import csr_pkg::*;

endmodule
  1. Описать на языке SystemVerilog модуль контроллера прерываний со следующим прототипом:
module interrupt_controller(
  input  logic        clk_i,
  input  logic        rst_i,
  input  logic        exception_i,
  input  logic        irq_req_i,
  input  logic        mie_i,
  input  logic        mret_i,

  output logic        irq_ret_o,
  output logic [31:0] irq_cause_o,
  output logic        irq_o
);

endmodule

Порядок выполнения задания

  1. Внимательно ознакомьтесь с описанием модуля csr_controller и его структурной схемой. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  2. Реализуйте модуль csr_controller. Для этого:
    1. В Design Sources проекта с предыдущих лаб, создайте SystemVerilog-файл csr_controller.sv.
    2. Опишите в нем модуль csr_controller с таким же именем и портами, как указано в задании.
    3. Обратите внимание на наличие импорта пакета csr_pkg, данный пакет содержит адреса используемых регистров контроля и статуса, которыми будет удобно пользоваться при реализации модуля.
  3. После описания модуля, его необходимо проверить с помощью тестового окружения.
    1. Тестовое окружение находится здесь.
    2. Для запуска симуляции воспользуйтесь этой инструкцией.
    3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_csr).
    4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
  4. Внимательно ознакомьтесь с описанием функционального поведения сигналов interrupt_controller, а также его структурной схемой. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  5. Реализуйте модуль interrupt_controller. Для этого:
    1. В Design Sources проекта с предыдущих лаб, создайте SystemVerilog-файл interrupt_controller.sv.
    2. Опишите в нем модуль interrupt_controller с таким же именем и портами, как указано в задании.
  6. После описания модуля, его необходимо проверить с помощью тестового окружения.
    1. Тестовое окружение находится здесь.
    2. Для запуска симуляции воспользуйтесь этой инструкцией.
    3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_irq).
    4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!

Список использованной литературы

  1. С.А. Орлов, Б.Я. Цилькер / Организация ЭВМ и систем: Учебник для вузов. 2-е изд. / СПб.: Питер, 2011.
  2. PIC24FJ512GU410 Family Data Sheet
  3. The Art of Assembly Language
  4. The RISC-V Instruction Set Manual Volume I: Unprivileged ISA
  5. The RISC-V Instruction Set Manual Volume II: Privileged Architecture

Лабораторная работа 11 "Интеграция подсистемы прерываний"

После реализации подсистемы прерываний, её необходимо интегрировать в процессорную систему. Для этого необходимо обновить модуль riscv_core по схеме, приведенной на рис. 1:

../../.pic/Labs/lab_10_irq/fig_03.drawio.svg

Рисунок 1. Интеграция подсистемы прерываний в ядро процессора.

Схема без выделения новых частей относительно старой версии модуля

../../.pic/Labs/lab_11_irq_integration/fig_01.drawio.svg Рисунок 1. Схема без выделения новых частей относительно старой версии модуля.

Задание

Интегрировать модули csr_controller и irq_controller в модуль riscv_core.

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

Порядок выполнения работы

  1. Интегрируйте модули csr_controller и irq_controller в модуль riscv_core.
    1. Обратите внимание, что что в модуле riscv_core появились новые входные и выходные сигналы: irq_req_i и irq_ret_o. Эти сигналы должны быть использованы при подключении riscv_core в модуле riscv_unit.
      1. Ко входу irq_req_i должен быть подключен провод irq_req, другой конец которого пока не будет ни к чему подключен (в следующей лабораторной это будет изменено).
      2. К выходу irq_ret_o необходимо подключить провод irq_ret, который также пока не будет использован.
  2. После интеграции модулей, проверьте процессорную систему с помощью программы, текст которой был представлен в ЛР10 с помощью предоставленного тестбенча.

Лабораторная работа 12 "Увеличение количества источников прерываний с помощью схемы daisy chain"

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

Цель

  1. Разработать блок приоритетных прерываний (БПП), построенный по схеме daisy chain.
  2. Интегрировать БПП в контроллер прерываний.

Теория

Если процессорная система предполагает наличие более одного источника прерываний, то необходимо разобраться с тем, что делать в случае возникновения коллизий — наложения одновременных запросов прерываний от нескольких источников. Одним из способов решения такой проблемы является реализация приоритетов прерываний. Со схемотехнической точки зрения, проще всего реализовать схему со статическим, не изменяемым, приоритетом. Одной из таких схем является daisy chain (по-русски — гирлядна, или дейзи-чейн, или дейзи-цепочка). Пример такой схемы можно увидеть на рис. 1.

../../.pic/Labs/lab_12_daisy_chain/fig_01.png

Рисунок 1. Структурная схема daisy chain.

Данная схема состоит из двух массивов элементов И. Первый массив (верхний ряд элементов) формирует многоразрядный сигнал (назовем его для определенности ready, на рис. 1 он обозначен как "Приоритет"), который перемножается с запросами с помощью массива элементов И нижнего ряда, формируя многоразрядный сигнал y. Обратите внимание на то, что результат операции И на очередном элементе нижнего массива влияет на результат И следующего за ним элемента верхнего массива и наоборот (readyₙ₊₁ зависит от yₙ, в то время как yₙ зависит от readyₙ). Как только на одном из разрядов y появится значение 1, оно сразу же распространится в виде 0 по всем оставшимся последующим разрядам ready, обнуляя их. А обнулившись, разряды ready обнулят соответствующие разряды y (нулевые разряды ready запрещают генерацию прерывания для соответствующих разрядов y).

Нижний массив элементов И можно описать через непрерывное присваивание побитового И между ready и сигналом запросов на прерывание.

Для описания верхнего ряда элементов И вам будет необходимо сделать непрерывное присваивание readyₙ & !yₙ для n+1-ого бита ready. Для этого будет удобно воспользоваться конструкцией generate for, которая позволяет автоматизировать создание множества однотипных структур.

Рассмотрим принцип работы этой конструкции. Предположим, мы хотим создать побитовое присваивание 5-битного сигнала a 5-битному сигналу b.

Индексы, используемые конструкцией, должны быть объявлены с помощью ключевого слова genvar. Далее, в области, ограниченной ключевыми словами generate/endgenerate описывается цикл присваиваний / созданий модулей:

logic [4:0] a;
logic [4:0] b;

// ...

genvar i;
generate
  for(i = 0; i < 5; i++) begin
    assign a[i] = b[i];
  end
endgenerate

Разумеется в этом примере можно было бы просто сделать одно непрерывное присваивание assign a = b;, однако в случае реализации верхнего ряда элементов И, подобное многобитное непрерывное присваивание не приведет к синтезу требуемой схемы.

Практика

Рассмотрим реализацию нашего контроллера прерываний:

../../.pic/Labs/lab_12_daisy_chain/fig_02.drawio.svg

Рисунок 2. Структурная схема блока приоритетных прерываний.

Помимо портов clk_i и rst_i, модуль daisy_chain будет иметь 3 входа и три выхода:

  • masked_irq_i — 16-разрядный вход маскированного запроса на прерывания (т.е. источник прерывание уже прошел маскирование сигналом CS-регистра mie).
  • irq_ret_i — сигнал о возврате управления основному потоку инструкций (выход из обработчика прерываний).
  • ready_i — сигнал о готовности процессора к перехвату (т.е. прямо сейчас процессор не находится в обработчике перехвата). Это нулевой бит сигнала ready в дейзи-цепочке. Пока ready_i равен нулю, дейзи-цепочка не будет генерировать сигналы прерываний.
  • irq_o — сигнал о начале обработки прерываний.
  • irq_cause_o — причина прерывания.
  • irq_ret_o — сигнал о завершении обработки запроса на прерывания. Будет соответствовать cause_o в момент появления сигнала mret_i.

Внутренний сигнал cause является сигналом y с рис. 1. Как пояснялось выше, этот сигнал может содержать только одну единицу, она будет соответствовать прошедшему запросу на прерывание. А значит этот результат можно использовать в качестве сигнала для идентификации причины прерывания. При этом, свертка по ИЛИ этого сигнала даст итоговый запрос на прерывание.

Однако, как упоминалось в ЛР10, спецификация RISC-V накладывает определенные требования на кодирование кода mcause для причины прерывания. В частности, необходимо выставить старший бит в единицу, а значение на оставшихся битах должно быть больше 16. Схемотехнически это проще реализовать выполнив склейку {12'h800, cause, 4'b0000} — в этом случае старший разряд будет равен единице, и если хоть один разряд cause будет равен единице (а именно это и является критерием появления прерывания), младшие 31 бит mcause будут больше 16.

Регистр на рис. 2 хранит значение внутреннего сигнала cause, чтобы по завершению прерывания выставить единицу на соответствующем разряде сигнала irq_ret_o, который сообщит устройству, чье прерывание обрабатывалось ранее, что его обработка завершена.

Задание

  • Реализовать модуль daisy_chain.
  • Интегрировать daisy_chain в модуль irq_controller по схеме, представленной на рис. 3.
  • Отразить изменения в прототипе сигнала irq_controller в модулях riscv_core и riscv_unit.

../../.pic/Labs/lab_12_daisy_chain/fig_03.drawio.svg

Рисунок 3. Структурная схема блока приоритетных прерываний.

Обратите внимание, что разрядность сигналов irq_req_i, mie_i, irq_ret_o изменилась. Теперь это 16-разрядные сигналы. Сигнал, который ранее шел на выход к irq_ret_o теперь идет на вход irq_ret_i модуля daisy_chain. Формирование кода причины прерывания irq_cause_o перенесено в модуль daisy_chain.

Порядок выполнения работы

  1. Опишите модуль daisy_chain.
    1. При формировании верхнего массива элементов И с рис. 2, вам необходимо воспользоваться сформировать 16 непрерывных присваиваний через блок generate for.
    2. Формирование нижнего массива элементов И можно сделать с помощью одного непрерывного присваивания посредством операции побитовое И.
    3. Проверьте модуль daisy_chain с помощью модуля tb_daisy_chain.
  2. Интегрируйте модуль daisy_chain в модуль irq_controller по схеме, представленной на рис. 3.
    1. Не забудьте обновить разрядность сигналов irq_req_i, mie_i, irq_ret_o.
    2. Также не забудьте обновить разрядность сигналов irq_req_i, irq_ret_o в riscv_core и riscv_unit, также использовать младшие 16 бит сигнала mie вместо одного при подключении модуля irq_controller.

Лабораторная работа 13 "Периферийные устройства"

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

Цель

Интегрировать периферийные устройства в процессорную систему.


Допуск к лабораторной работе

Для успешного выполнения лабораторной работы, вам необходимо:

Ход работы

  1. Изучить теорию об адресном пространстве
  2. Получить индивидуальный вариант со своим набором периферийных устройств
  3. Интегрировать контроллеры периферийных устройств в адресное пространство вашей системы
  4. Собрать финальную схему вашей системы
  5. Проверить работу системы в ПЛИС с помощью демонстрационного ПО, загружаемого в память инструкций

Теория

Помимо процессора и памяти, третьим ключевым элементом вычислительной системы является система ввода/вывода, обеспечивающая обмен информации между ядром вычислительной машины и периферийными устройствами[1, стр. 364].

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

Система ввода/вывода может быть организована одним из двух способов: с выделенным адресным пространством устройств ввода/вывода, или с совместным адресным пространством. В первом случае система ввода/вывода имеет отдельную шину для подключения к процессору (и отдельные инструкции для обращения к периферии), во втором – шина для памяти и системы ввода/вывода общая (а обращение к периферии осуществляется теми же инструкциями, что и обращение к памяти).

Адресное пространство

Архитектура RISC-V подразумевает использование совместного адресного пространства — это значит, что в лабораторной работе будет использована единая шина для подключения памяти и регистров управления периферийными устройствами. При обращении по одному диапазону адресов процессор будет попадать в память, при обращении по другим – взаимодействовать с регистрами управления/статуса периферийного устройства. Например, можно разделить 32-битное адресное пространство на 256 частей, отдав старшие 8 бит адреса под указание конкретного периферийного устройства. Тогда каждое из периферийных устройств получит 24-битное адресное пространство (16 MiB). Допустим, мы распределили эти части адресного пространства в следующем порядке (от младшего диапазона адресов к старшему):

  1. Память данных
  2. Переключатели
  3. Светодиоды
  4. Клавиатура PS/2
  5. Семисегментные индикаторы
  6. UART-приемник
  7. UART-передатчик
  8. Видеоадаптер

В таком случае, если мы захотим обратиться к первому байту семисегментных индикаторов, мы должны будем использовать адрес 0x04000001. Старшие 8 бит (0x04) определяют выбранное периферийное устройство, оставшиеся 24 бита определяют конкретный адрес в адресном пространстве этого устройства.

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

../../.pic/Labs/lab_13_periph/fig_01.drawio.svg

Рисунок 1. Итоговая структура процессорной системы.

Активация выбранного устройства

В зависимости от интерфейса используемой шины, периферийные устройства либо знают какой диапазон адресов им выделен (например, в интерфейсе I²C), либо нет (интерфейс APB). В первом случае, устройство понимает что к нему обратились непосредственно по адресу в данном обращении, во втором случае — по специальному сигналу.

На рис. 1 используется второй вариант — устройство понимает, что к нему обратились по специальному сигналу req_i. Данный сигнал формируется из двух частей: сигнала req исходящего из процессорного ядра (сигнал о том, обращение в память вообще происходит) и специального сигнала-селектора исходящего из 256-разрядной шины. Формирование значения на этой шине происходит с помощью унитарного (one-hot) кодирования. Процесс кодирования достаточно прост. В любой момент времени на выходной шине должен быть ровно один бит, равный единице. Индекс этого бита совпадает со значением числа, формируемого из старших восьми бит адреса. Поскольку для восьмибитного значения существует 256 комбинаций значений, именно такая разрядность будет на выходе кодировщика. Это означает, что в данной системе можно связать процессор с 256 устройствами (одним из которых будет память данных).

Реализация унитарного кодирования предельно проста:

  • Нулевой сигнал этой шины будет равен единице только если data_addr_o[31:24] = 8'd0.
  • Первый бит этой шины будет равен единице только если data_addr_o[31:24] = 8'd1.
  • ...
  • Двести пятьдесят пятый бит шины будет равен единице только если data_addr_o[31:24] = 8'd255.

Для реализации такого кодирования достаточно выполнить сдвиг влево константы 255'd1 на значение data_addr_o[31:24].

Дополнительные правки модуля riscv_unit

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

Для этого необходимо добавить в модуль riscv_unit дополнительные входы и выходы, которые подключены посредством файла ограничений (nexys_a7_100t.xdc) к входам и выходам ПЛИС.

module riscv_unit(
  input  logic        clk_i,
  input  logic        resetn_i,

  // Входы и выходы периферии
  input  logic [15:0] sw_i,       // Переключатели

  output logic [15:0] led_o,      // Светодиоды

  input  logic        kclk_i,     // Тактирующий сигнал клавиатуры
  input  logic        kdata_i,    // Сигнал данных клавиатуры

  output logic [ 6:0] hex_led_o,  // Вывод семисегментных индикаторов
  output logic [ 7:0] hex_sel_o,  // Селектор семисегментных индикаторов

  input  logic        rx_i,       // Линия приема по UART
  output logic        tx_o,       // Линия передачи по UART

  output logic [3:0]  vga_r_o,    // Красный канал vga
  output logic [3:0]  vga_g_o,    // Зеленый канал vga
  output logic [3:0]  vga_b_o,    // Синий канал vga
  output logic        vga_hs_o,   // Линия горизонтальной синхронизации vga
  output logic        vga_vs_o    // Линия вертикальной синхронизации vga

);
//...
endmodule

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

Обратите внимание на то, что изменился сигнал сброса (resetn_i). Буква n на конце означает, что сброс работает по уровню 0 (когда сигнал равен нулю — это сброс, когда единице — не сброс).

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

Для этого необходимо:

  1. Подключить файл sys_clk_rst_gen.sv в ваш проект.
  2. Создать экземпляр этого модуля в начале описания модуля riscv_unit следующим образом:
logic sysclk, rst;
sys_clk_rst_gen divider(.ex_clk_i(clk_i),.ex_areset_n_i(resetn_i),.div_i(5),.sys_clk_o(sysclk), .sys_reset_o(rst));
  1. После вставки данных строк в начало описания модуля riscv_unit вы получите тактовый сигнал sysclk с частотой в 10 МГц и сигнал сброса rst с активным уровнем 1 (как и в предыдущих лабораторных). Все ваши внутренние модули (riscv_core, data_mem и контроллеры периферии) должны работать от тактового сигнала sysclk. На модули, имеющие входной сигнал сброса (rst_i) необходимо подать ваш сигнал rst.

Задание

В рамках данной лабораторной работы необходимо реализовать модули-контроллеры двух периферийных устройств, реализующих управление в соответствии с приведенной ниже картой памяти и встроить их в процессорную систему, используя рис. 1. На карте приведено шесть периферийных устройств, вам необходимо взять только два из них. Какие именно — сообщит преподаватель.

Карта памяти

Рисунок 2. Карта памяти периферийных устройств.

Работа с картой осуществляется следующим образом. Под названием каждого периферийного устройства указана старшая часть адреса (чему должны быть равны старшие 8 бит адреса, чтобы было сформировано обращение к данному периферийному устройству). Например, для переключателей это значение равно 0x01, для светодиодов 0x02 и т.п. В самом левом столбце указаны используемые/неиспользуемые адреса в адресном пространстве данного периферийного устройства. Например для переключателей есть только один используемый адрес: 0x000000. Его функциональное назначение и разрешения на доступ указаны в столбце соответствующего периферийного устройства. Возвращаясь к адресу 0x000000, для переключателей мы видим следующее:

  • (R) означает что разрешен доступ только на чтение (операция записи по этому адресу должна игнорироваться вашим контроллером).
  • "Выставленное на переключателях значение" означает ровно то, что и означает. Если процессор выполняет операцию чтения по адресу 0x01000000 (0x01 [старшая часть адреса переключателей] + 0x000000 [младшая часть адреса для получения выставленного на переключателях значения]), то контроллер должен выставить на выходной сигнал RD значение на переключателях (о том как получить это значение будет рассказано чуть позже).

Рассмотрим еще один пример. При обращении по адресу 0x02000024 (0x02 (старшая часть адреса контроллера светодиодов) + 0x000024 (младшая часть адреса для доступа на запись к регистру сброса) ) должна произойти запись в регистр сброса, который должен сбросить значения в регистре управления зажигаемых светодиодов и регистре управления режимом "моргания" светодиодов (подробнее о том как должны работать эти регистры будет ниже).

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

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

Подробное описание периферийных устройств их управления и назначение управляющих регистров описано после порядка выполнения задания.


Порядок выполнения задания

  1. Внимательно ознакомьтесь с примером описания модуля контроллера.
  2. Внимательно ознакомьтесь со спецификацией контроллеров периферии своего варианта. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  3. Реализуйте модули контроллеров периферии. Имена модулей и их порты будут указаны в описании контроллеров. Пример разработки контроллера приведен здесь.
    1. Готовые модули периферии, управление которыми должны осуществлять модули-контроллеры хранятся в папке peripheral modules.
  4. Обновите модуль riscv_unit в соответствии с разделом "Дополнительные правки модуля riscv_unit".
    1. Подключите в проект файл sys_clk_rst_gen.sv.
    2. Добавьте в модуль riscv_unit входы и выходы периферии. Необходимо добавить порты даже тех периферийных устройств, которые вы не будете реализовывать.
    3. Создайте в начале описания модуля riscv_unit экземпляр модуля sys_clk_rst_gen, скопировав приведенный фрагмент кода.
    4. Замените подключение тактового сигнала исходных подмодулей riscv_unit на появившийся сигнал sysclk. Убедитесь, что на модули, имеющие сигнал сброса, приходит сигнал rst.
  5. Интегрируйте модули контроллеров периферии в процессорную систему по приведенной схеме руководствуясь старшими адресами контроллеров, представленными на карте памяти (рис. 2). Это означает, что если вы реализуете контроллер светодиодов, на его вход req_i должна подаваться единица в случае, если mem_req_o == 1 и старшие 8 бит адреса равны 0x02.
    1. При интеграции вы должны подключить только модули-контроллеры вашего варианта. Контроллеры периферии других вариантов подключать не надо.
    2. При этом во время интеграции, вы должны использовать старшую часть адреса, представленную в карте памяти для формирования сигнала req_i для ваших модулей-контроллеров.
  6. Проверьте работу процессорной системы с помощью моделирования.
    1. Для каждой пары контроллеров в папке firmware/mem_files представлены файлы, инициализирующие память инструкций. Обратите внимание, что для пары "PS2-VGA" также необходим файл, инициализирующий память данных (в модуле ext_mem необходимо прописать блок $readmemh).
    2. Исходный код программ с адресами и результирующими инструкциями находится в папке firmware/software.
    3. При моделировании светодиодов лучше уменьшить значение, до которого считает счетчик в режиме "моргания" в 10 раз, чтобы уменьшить время моделирования. Перед генерацией битстрима это значение будет необходимо восстановить.
    4. Для проверки тестбенч имитирует генерацию данных периферийных устройств ввода. При реализации контроллера клавиатуры или uart_rx рекомендуется ознакомиться с тем, какие именно данные тестбенч подает на вход.
    5. Для того, чтобы понять, что устройство работает должным образом, в первую очередь необходимо убедиться, что контроллер устройства ввода успешно осуществил прием данных (сгенерированные тестбенчем данные оказались в соответствующем регистре контроллера периферийного устройства) и сгенерировал запрос на прерывание.
    6. После чего, необходимо убедиться, что процессор среагировал на данное прерывание, и в процессе его обработки в контроллер устройства вывода были поданы выходные данные.
    7. Для того, чтобы лучше понимать как именно процессор будет обрабатывать прерывание, рекомендуется ознакомиться с исходным кодом исполняемой программы, расположенным в папке firmware/software.
  7. Переходить к следующему пункту можно только после того, как вы полностью убедились в работоспособности модуля на этапе моделирования (увидели корректные значения на выходных сигналах периферии, либо (если по сигналам периферии сложно судить о работоспособности), значениям в контрольных/статусных регистрах модуля-контроллера этой периферии). Генерация битстрима будет занимать у вас долгое время, а итогом вы получите результат: заработало / не заработало, без какой-либо дополнительной информации, поэтому без прочного фундамента на моделировании далеко уехать у вас не выйдет.
  8. Подключите к проекту файл ограничений (nexys_a7_100t.xdc), если тот еще не был подключен, либо замените его содержимое данными из файла к этой лабораторной работе.
  9. Проверьте работу вашей процессорной системы на отладочном стенде с ПЛИС.
    1. Обратите внимание, что в данной лабораторной уже не будет модуля верхнего уровня nexys_..., так как ваш модуль процессорной системы уже полностью самостоятелен и взаимодействует непосредственно с ножками ПЛИС через модули, управляемые контроллерами периферии.

Описание контроллеров периферийных устройств

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

  1. Под "запросом на запись по адресу 0xАДРЕС" будет пониматься совокупность следующих условий:
    1. Происходит восходящий фронт clk_i.
    2. На входе req_i выставлено значение 1.
    3. На входе write_enable_i выставлено значение 1.
    4. На входе addr_i выставлено значение 0xАДРЕС
  2. Под "запросом на чтение по адресу 0xАДРЕС" будет пониматься совокупность следующих условий:
    1. Происходит восходящий фронт clk_i.
    2. На входе req_i выставлено значение 1.
    3. На входе write_enable_i выставлено значение 0.
    4. На входе addr_i выставлено значение 0xАДРЕС

Обратите внимание на то, что запрос на чтение должен обрабатываться синхронно (выходные данные должны выдаваться по положительному фронту clk_i).

При описании поддерживаемых режимов доступа по данному адресу используется следующее обозначение:

  • R — доступ только на чтение;
  • W — доступ только на запись;
  • RW — доступ на чтение и запись.

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

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

В случае осуществления записи по принятому запросу, необходимо записать данные с сигнала write_data_i в регистр, ассоциированный с адресом addr_i (если разрядность регистра меньше разрядности сигнала write_data_i, старшие биты записываемых данных отбрасываются).

В случае осуществления чтения по принятому запросу, необходимо по положительному фронту clk_i выставить данные с сигнала, ассоциированного с адресом addr_i на выходной сигнал read_data_o (если разрядность сигнала меньше разрядности выходного сигнала read_data_o, возвращаемые данные должны дополниться нулями в старших битах).

Переключатели

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

module sw_sb_ctrl(
/*
    Часть интерфейса модуля, отвечающая за подключение к системной шине
*/
  input  logic        clk_i,
  input  logic        rst_i,
  input  logic        req_i,
  input  logic        write_enable_i,
  input  logic [31:0] addr_i,
  input  logic [31:0] write_data_i,  // не используется, добавлен для
                                     // совместимости с системной шиной
  output logic [31:0] read_data_o,

/*
    Часть интерфейса модуля, отвечающая за отправку запросов на прерывание
    процессорного ядра
*/

  output logic        interrupt_request_o,
  input  logic        interrupt_return_i,

/*
    Часть интерфейса модуля, отвечающая за подключение к периферии
*/
  input logic [15:0]  sw_i
);

endmodule

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

Адресное пространство контроллера:

АдресРежим доступаФункциональное назначение
0x00RЧтение значения, выставленного на переключателях

При этом, будучи устройством ввода, данный модуль может генерировать прерывание, чтобы сообщить процессору о том, что данные на переключателях были изменены. Если на очередном такте clk_i данные на входе sw_i изменились (т.е. отличаются от тех, что были на предыдущем такте), модуль должен выставить значение 1 на выходе interrupt_request_o и удерживать его до получения сигнала о завершении обработки прерывания interrupt_return_i.

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

Светодиоды

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

module led_sb_ctrl(
/*
    Часть интерфейса модуля, отвечающая за подключение к системной шине
*/
  input  logic        clk_i,
  input  logic        rst_i,
  input  logic        req_i,
  input  logic        write_enable_i,
  input  logic [31:0] addr_i,
  input  logic [31:0] write_data_i,
  output logic [31:0] read_data_o,

/*
    Часть интерфейса модуля, отвечающая за подключение к периферии
*/
  output logic [15:0] led_o
);

logic [15:0]  led_val;
logic         led_mode;

endmodule

Данный модуль должен выводить на выходной сигнал led_o данные с регистра led_val. Запись и чтение регистра led_val осуществляется по адресу 0x00. Запись любого значения, превышающего 2¹⁶-1 должна игнорироваться.

Регистр led_mode отвечает за режим вывода данных на светодиоды. Когда этот регистр равен единице, светодиоды должны "моргать" выводимым значением. Под морганием подразумевается вывод значения из регистра led_val на выход led_o на одну секунду (загорится часть светодиодов, соответствующие которым биты шины led_o равны единице), после чего на одну секунду выход led_o необходимо подать нули. Запись и чтение регистра led_mode осуществляется по адресу 0x04. Запись любого значения, отличного от 0 и 1 в регистр led_mode должна игнорироваться.

Отсчет времени можно реализовать простейшим счетчиком, каждый такт увеличивающимся на 1 и сбрасывающимся по достижении определенного значения, чтобы продолжить считать с нуля. Зная тактовую частоту, нетрудно определить до скольки должен считать счетчик. При тактовой частоте в 10 МГц происходит 10 миллионов тактов в секунду. Это означает, что при такой тактовой частоте через секунду счетчик будет равен 10⁷-1 (счет идет с нуля). Тем не менее удобней будет считать не до 10⁷-1 (что было бы достаточно очевидным и тоже правильным решением), а до 2*10⁷-1. В этом случае старший бит счетчика каждую секунду будет инвертировать свое значение, что может быть использовано при реализации логики "моргания".

Важно отметить, что счетчик должен работать только при led_mode == 1, в противном случае счетчик должен быть равен нулю.

Обратите внимание на то, что адрес 0x24 является адресом сброса. В случае запроса на запись по этому адресу значения 1. вы должны сбросить регистры led_val, led_mode и все вспомогательные регистры, которые вы создали. Для реализации сброса вы можете как создать отдельный регистр led_rst, в который будет происходить запись, а сам сброс будет происходить по появлению единицы в этом регистре (в этом случае необходимо не забыть сбрасывать и этот регистр), так и создать обычный провод, формирующий единицу в случае выполнения всех указанных условий (условий запроса на запись, адреса сброса и значения записываемых данных равному единице).

Адресное пространство контроллера:

АдресРежим доступаДопустимые значенияФункциональное назначение
0x00RW[0:65535]Чтение и запись в регистр led_val отвечающий за вывод данных на светодиоды
0x04RW[0:1]Чтение и запись в регистр led_mode, отвечающий за режим "моргания" светодиодами
0x24W1Запись сигнала сброса

Клавиатура PS/2

Клавиатура PS/2 осуществляет передачу скан-кодов, нажатых на этой клавиатуре клавиш.

В рамках данной лабораторной работы вам будет дан готовый модуль, осуществляющий прием данных с клавиатуры. От вас требуется написать лишь модуль, осуществляющий контроль предоставленным модулем. У готового модуля будет следующий прототип:

module PS2Receiver(
    input        clk_i,          // Сигнал тактирования процессора и вашего модуля-контроллера
    input        kclk_i,         // Тактовый сигнал, приходящий с клавиатуры
    input        kdata_i,        // Сигнал данных, приходящий с клавиатуры
    output [7:0] keycode_o,      // Сигнал полученного с клавиатуры скан-кода клавиши
    output       keycode_valid_o // Сигнал готовности данных на выходе keycodeout
    );
endmodule

Вам необходимо реализовать модуль-контроллер со следующим прототипом:

module ps2_sb_ctrl(
/*
    Часть интерфейса модуля, отвечающая за подключение к системной шине
*/
  input  logic         clk_i,
  input  logic         rst_i,
  input  logic [31:0]  addr_i,
  input  logic         req_i,
  input  logic [31:0]  write_data_i,
  input  logic         write_enable_i,
  output logic [31:0]  read_data_o,

/*
    Часть интерфейса модуля, отвечающая за отправку запросов на прерывание
    процессорного ядра
*/

  output logic        interrupt_request_o,
  input  logic        interrupt_return_i,

/*
    Часть интерфейса модуля, отвечающая за подключение к модулю,
    осуществляющему прием данных с клавиатуры
*/
  input  logic kclk_i,
  input  logic kdata_i
);

logic [7:0] scan_code;
logic       scan_code_is_unread;

endmodule

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

По каждому восходящему фронту сигнала clk_i вы должны проверять выход keycode_valid_o и, если тот равен единице, записать значение с выхода keycode_o в регистр scan_code. При этом значение регистра scan_code_is_unread необходимо выставить в единицу.

В случае, если произошел запрос на чтение по адресу 0x00, необходимо выставить на выход read_data_o значение регистра scan_code (дополнив старшие биты нулями), при этом значение регистра scan_code_is_unread необходимо обнулить. В случае, если одновременно с запросом на чтение пришел сигнал keycode_valid_o, регистр scan_code_is_unread обнулять не нужно (в этот момент в регистр scan_code уже записывается новое, еще непрочитанное значение).

Обнуление регистра scan_code_is_unread должно происходить и в случае получения сигнала interrupt_return_i (однако опять же, если в этот момент приходит сигнал keycode_valid_o, обнулять регистр не нужно).

В случае запроса на чтение по адресу 0x04 необходимо вернуть значение регистра scan_code_is_unread.

В случае запроса на запись значения 1 по адресу 0x24, необходимо осуществить сброс регистров scan_code и scan_code_is_unread в 0.

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

Адресное пространство контроллера:

АдресРежим доступаДопустимые значенияФункциональное назначение
0x00R[0:255]Чтение из регистра scan_code, хранящего скан-код нажатой клавиши
0x04R[0:1]Чтение из регистра scan_code_is_unread, сообщающего о том, что есть непрочитанные данные в регистре scan_code
0x24W1Запись сигнала сброса

Семисегментные индикаторы

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

module hex_digits(
  input  logic       clk_i,
  input  logic       rst_i,
  input  logic [3:0] hex0_i,    // Цифра, выводимой на нулевой (самый правый) индикатор
  input  logic [3:0] hex1_i,    // Цифра, выводимая на первый индикатор
  input  logic [3:0] hex2_i,    // Цифра, выводимая на второй индикатор
  input  logic [3:0] hex3_i,    // Цифра, выводимая на третий индикатор
  input  logic [3:0] hex4_i,    // Цифра, выводимая на четвертый индикатор
  input  logic [3:0] hex5_i,    // Цифра, выводимая на пятый индикатор
  input  logic [3:0] hex6_i,    // Цифра, выводимая на шестой индикатор
  input  logic [3:0] hex7_i,    // Цифра, выводимая на седьмой индикатор
  input  logic [7:0] bitmask_i, // Битовая маска для включения/отключения
                                // отдельных индикаторов

  output logic [6:0] hex_led_o, // Сигнал, контролирующий каждый отдельный
                                // светодиод индикатора
  output logic [7:0] hex_sel_o  // Сигнал, указывающий на какой индикатор
                                // выставляется hex_led
);
endmodule

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

За включение/отключение индикаторов отвечает входной сигнал bitmask_i, состоящий из 8 бит, каждый из которых включает/отключает соответствующий индикатор. Например, при bitmask_i == 8'b0000_0101, включены будут нулевой и второй индикаторы, остальные будут погашены.

Выходные сигналы hex_led и hex_sel необходимо просто подключить к соответствующим выходным сигналам модуля-контроллера. Они пойдут на выходы ПЛИС, соединенные с семисегментными индикаторами.

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

module hex_sb_ctrl(
/*
    Часть интерфейса модуля, отвечающая за подключение к системной шине
*/
  input  logic        clk_i,
  input  logic [31:0] addr_i,
  input  logic        req_i,
  input  logic [31:0] write_data_i,
  input  logic        write_enable_i,
  output logic [31:0] read_data_o,

/*
    Часть интерфейса модуля, отвечающая за подключение к модулю,
    осуществляющему вывод цифр на семисегментные индикаторы
*/
  output logic [6:0] hex_led,
  output logic [7:0] hex_sel
);

  logic [3:0] hex0, hex1, hex2, hex3, hex4, hex5, hex6, hex7;
  logic [7:0] bitmask;
endmodule

Регистры hex0-hex7 отвечают за вывод цифры на соответствующий семисегментный индикатор. Регистр bitmask отвечает за включение/отключение семисегментных индикаторов. Когда в регистре bitmask бит, индекс которого совпадает с номером индикатора равен единице — тот включен и выводит число, совпадающее со значением в соответствующем регистре hex0-hex7. Когда бит равен нулю — этот индикатор гаснет.

Доступ на чтение/запись регистров hex0-hex7 осуществляется по адресам 0x00-0x1c (см. таблицу адресного пространства).

Доступ на чтение/запись регистра bitmask осуществляется по адресу 0x20.

При запросе на запись единицы по адресу 0x24 необходимо выполнить сброс всех регистров. При этом регистр bitmask должен сброситься в значение 0xFF.

Адресное пространство контроллера:

АдресРежим доступаДопустимые значенияФункциональное назначение
0x00RW[0:15]Регистр, хранящий значение, выводимое на hex0
0x04RW[0:15]Регистр, хранящий значение, выводимое на hex1
0x08RW[0:15]Регистр, хранящий значение, выводимое на hex2
0x0CRW[0:15]Регистр, хранящий значение, выводимое на hex3
0x10RW[0:15]Регистр, хранящий значение, выводимое на hex4
0x14RW[0:15]Регистр, хранящий значение, выводимое на hex5
0x18RW[0:15]Регистр, хранящий значение, выводимое на hex6
0x1CRW[0:15]Регистр, хранящий значение, выводимое на hex7
0x20RW[0:255]Регистр, управляющий включением/отключением индикаторов
0x24W1Запись сигнала сброса

UART

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

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

  • её скорости (бодрейт);
  • контроля целостности данных (использование бита четности/нечетности/отсутствие контроля);
  • длины стопового бита.

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

module uart_rx (
  input  logic            clk_i,      // Тактирующий сигнал
  input  logic            rst_i,      // Сигнал сброса
  input  logic            rx_i,       // Сигнал линии, подключенной к выводу ПЛИС,
                                      // по которой будут приниматься данные
  output logic            busy_o,     // Сигнал о том, что модуль занят приемом данных
  input  logic [16:0]     baudrate_i, // Настройка скорости передачи данных
  input  logic            parity_en_i,// Настройка контроля целостности через бит четности
  input  logic            stopbit_i,  // Настройка длины стопового бита
  output logic [7:0]      rx_data_o,  // Принятые данные
  output logic            rx_valid_o  // Сигнал о том, что прием данных завершен

);
endmodule
module uart_tx (
  input  logic            clk_i,      // Тактирующий сигнал
  input  logic            rst_i,      // Сигнал сброса
  output logic            tx_o,       // Сигнал линии, подключенной к выводу ПЛИС,
                                      // по которой будут отправляться данные
  output logic            busy_o,     // Сигнал о том, что модуль занят передачей данных
  input  logic [16:0]     baudrate_i, // Настройка скорости передачи данных
  input  logic            parity_en_i,// Настройка контроля целостности через бит четности
  input  logic            stopbit_i,  // Настройка длины стопового бита
  input  logic [7:0]      tx_data_i,  // Отправляемые данные
  input  logic            tx_valid_i  // Сигнал о старте передачи данных
);
endmodule

Для управления этими модулями вам необходимо написать два модуля-контроллера со следующими прототипами

module uart_rx_sb_ctrl(
/*
    Часть интерфейса модуля, отвечающая за подключение к системной шине
*/
  input  logic          clk_i,
  input  logic          rst_i,
  input  logic [31:0]   addr_i,
  input  logic          req_i,
  input  logic [31:0]   write_data_i,
  input  logic          write_enable_i,
  output logic [31:0]   read_data_o,

/*
    Часть интерфейса модуля, отвечающая за отправку запросов на прерывание
    процессорного ядра
*/

  output logic        interrupt_request_o,
  input  logic        interrupt_return_i,

/*
    Часть интерфейса модуля, отвечающая за подключение передающему,
    входные данные по UART
*/
  input  logic          rx_i
);

  logic busy;
  logic [16:0] baudrate;
  logic parity_en;
  logic stopbit;
  logic [7:0]  data;
  logic valid;

endmodule
module uart_tx_sb_ctrl(
/*
    Часть интерфейса модуля, отвечающая за подключение к системной шине
*/
  input  logic          clk_i,
  input  logic          rst_i,
  input  logic [31:0]   addr_i,
  input  logic          req_i,
  input  logic [31:0]   write_data_i,
  input  logic          write_enable_i,
  output logic [31:0]   read_data_o,

/*
    Часть интерфейса модуля, отвечающая за подключение передающему,
    выходные данные по UART
*/
  output logic          tx_o
);

  logic busy;
  logic [16:0] baudrate;
  logic parity_en;
  logic stopbit;
  logic [7:0]  data;

endmodule

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

Взаимодействие модулей uart_rx и uart_tx с соответствующими модулями-контроллерами осуществляется следующим образом.

Сигналы clk_i и rx_i/tx_o подключаются напрямую к соответствующим сигналам модулей-контроллеров.

Сигнал rst_i модулей uart_rx / uart_tx должен быть равен единице при запросе на запись единицы по адресу 0x24, а также в случае, когда сигнал rst_i модуля-контроллера равен единице.

Выходной сигнал busy_o на каждом такте clk_i должен записываться в регистр busy, доступ на чтение к которому осуществляется по адресу 0x08.

Значения входных сигналов baudrate_i, parity_en_i, stopbit_i берутся из соответствующих регистров, доступ на запись к которым осуществляется по адресам 0x0C, 0x10, 0x14 соответственно, но только в моменты, когда выходной сигнал busy_o равен нулю. Иными словами, изменение настроек передачи возможно только в моменты, когда передача не происходит. Доступ на чтение этих регистров может осуществляться в любой момент времени.

В регистр data модуля uart_rx_sb_ctrl записывается значение одноименного выхода модуля uart_rx в моменты положительного фронта clk_i, когда сигнал rx_valid_o равен единице. Доступ на чтение этого регистра осуществляется по адресу 0x00.

В регистр valid модуля uart_rx_sb_ctrl записывается единица по положительному фронту clk_i, когда выход rx_valid_o равен единице. Данный регистр сбрасывается в ноль при выполнении запроса на чтение по адресу 0x00, а также при получении сигнала interrupt_return_i. Сам регистр доступен для чтения по адресу 0x04. Регистр valid подключается к выходу interrupt_request_o. Что позволяет узнать о пришедших данных и посредством прерывания.

На вход tx_data_i модуля uart_tx подаются данные из регистра data модуля uart_tx_sb_ctrl. Доступ на запись в этот регистр происходит по адресу 0x00 в моменты положительного фронта clk_i, когда сигнал busy_o равен нулю. Доступ на чтение этого регистра может осуществляться в любой момент времени.

На вход tx_valid_i модуля uart_tx подается единица в момент выполнения запроса на запись по адресу 0x00 (при сигнале busy равном нулю). В остальное время на вход этого сигнала подается 0.

В случае запроса на запись значения 1 по адресу 0x24 (адресу сброса), все регистры модуля-контроллера должны сброситься. При этом регистр baudrate должен принять значение 9600, регистр parity должен принять значение 1, регистр, stopbit должен принять значение 1. Остальные регистры должны принять значение 0.

Адресное пространство контроллера uart_rx_sb_ctrl:

АдресРежим доступаДопустимые значенияФункциональное назначение
0x00R[0:255]Чтение из регистра data, хранящего значение принятых данных
0x04R[0:1]Чтение из регистра valid, сообщающего о том, что есть непрочитанные данные в регистре data
0x08R[0:1]Чтение из регистра busy, сообщающего о том, что модуль находится в процессе приема данных
0x0CRW[0:131072]Чтение/запись регистра baudrate, отвечающего за скорость передачи данных
0x10RW[0:1]Чтение/запись регистра parity, отвечающего за включение отключение проверки данных через бит четности
0x14RW[0:1]Чтение/запись регистра stopbit, отвечающего за длину стопового бита
0x24W1Запись сигнала сброса

Адресное пространство контроллера uart_tx_sb_ctrl:

АдресРежим доступаДопустимые значенияФункциональное назначение
0x00RW[0:255]Чтение и запись регистра data, хранящего значение отправляемых данных
0x08R[0:1]Чтение из регистра busy, сообщающего о том, что модуль находится в процессе передачи данных
0x0CRW[0:131072]Чтение/запись регистра baudrate, отвечающего за скорость передачи данных
0x10RW[0:1]Чтение/запись регистра parity, отвечающего за включение отключение проверки данных через бит четности
0x14RW[0:1]Чтение/запись регистра stopbit, отвечающего за длину стопового бита
0x24W1Запись сигнала сброса

Видеоадаптер

Видеоадаптер позволяет выводить информацию на экран через интерфейс VGA. Предоставляемый в данной лабораторной работе vga-модуль способен выводить 80х30 символов (разрешение символа 8x16). Таким образом, итоговое разрешение экрана, поддерживаемого vga-модулем будет 80*8 x 30*16 = 640x480. VGA-модуль поддерживает управление цветовой схемой для каждого поля символа в сетке 80х30. Это значит, что каждый символ (и фон символа) может быть отрисован отдельным цветом из диапазона 16-ти цветов.

https://upload.wikimedia.org/wikipedia/commons/c/cf/RebelstarII.png

Рисунок 3. Пример игры с использованием символьной графики[2].

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

../../.pic/Labs/lab_13_periph/fig_04.png

Рисунок 4. Карта памяти vga-модуля.

Для того, чтобы вывести символ на экран, необходимо использовать адрес этого символа на сетке 80x30 (диапазон адресов char_map). К примеру, мы хотим вывести символ в верхнем левом углу. Это нулевой символ в диапазоне адресов char_map. Поскольку данный диапазон начинается с адреса 0x0000_0000, запись по этому адресу приведет к отображению символа, соответствующего ASCII-коду, пришедшему на write_data_i.

Если мы хотим вывести нулевой (левый) символ в первой строке (счет ведется с нуля), то необходимо произвести запись по адресу 1*80 + 0 = 80 = 0x0000_0050.

Вывод символа в правом нижнем углу осуществляется записью по адресу 0x0000_095F (80*30-1)

Установка цветовой схемы осуществляется по тем же самым адресам, к которым прибавлено значение 0x0000_1000:

  • верхний левый символ — 0x0000_1000
  • нулевой символ первой строки — 0x0000_1050
  • нижний правый символ — 0x0000_195F

Цветовая схема каждой позиции состоит из двух цветов: цвета фона и цвета символа. Оба эти цвета выбираются из палитры 8 цветов, каждый из которых содержит два оттенка: цвет на полной яркости и цвет на уменьшенной яркости (см. рис. 5). Один из цветов — черный, оба его оттенка представляют собой один и тот же цвет. Ниже приведены коды цветов их rgb-значения:

../../.pic/Labs/lab_13_periph/fig_05.png

Рисунок 5. Цветовая палитра vga-модуля.

Код цвета формируется следующим образом: старший бит определяет яркость оттенка цвета. Оставшиеся 3 бита кодируют используемый канал:

  • 0 бит кодирует использование синего канала;
  • 1 бит кодирует использование зеленого канала;
  • 2 бит кодирует использование красного канала.

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

К примеру, мы хотим установить черный фоновый цвет и белый цвет в качестве цвета символа для верхней левой позиции. В этом случае, мы должны записать значение f0 (f(15) — код белого цвета, 0 — код черного цвета) по адресу 0x0000_1000 (нулевой адрес в диапазоне color_map).

Для отрисовки символов, мы условно поделили экран на сетку 80х30, и для каждой позиции в этой сетке определили фоновый и активный цвет. Чтобы модуль мог отрисовать символ на очередной позиции (которая занимает 16х8 пикселей), ему необходимо знать в какой цвет необходимо окрасить каждый пиксель для каждого ascii-кода. Для этого используется память шрифтов.

Допустим, нам необходимо отрисовать символ F (ascii-код 0x46).

../../.pic/Labs/lab_13_periph/fig_06.png

Рисунок 6. Отрисовка символа F в разрешении 16х8 пикселей.

Данный символ состоит из 16 строчек по 8 пикселей. Каждый пиксель кодируется одним битом (горит/не горит, цвет символа/фоновый цвет). Каждая строчка кодируется одним байтом (8 бит на 8 пикселей). Таким образом, каждый сканкод требует 16 байт памяти.

Данный модуль поддерживает 256 сканкодов. Следовательно, для хранения шрифта под каждый из 256 сканкодов требуется 16 * 256 = 4KiB памяти.

Для хранения шрифтов в модуле отведен диапазон адресов 0x00002000-0x00002FFF. В отличие от предыдущих диапазонов адресов, где каждый адрес был закреплен за соответствующей позицией символа в сетке 80x30, адреса данного диапазона распределены следующим образом:

  • 0-ой байт — нулевая (верхняя) строчка символа с кодом 0;
  • 1-ый байт — первая строчка символа с кодом 0;
  • ...
  • 15-ый байт — пятнадцатая (нижняя) строчка символа с кодом 0;
  • 16-ый байт — нулевая (верхняя) строчка символа с кодом 1;
  • ...
  • 4095-ый байт — пятнадцатая (нижняя) строчка символа с кодом 255.

Прототип vga-модуля следующий:

module vgachargen (
  input  logic          clk_i,            // системный синхроимпульс
  input  logic          clk100m_i,        // клок с частотой 100МГц
  input  logic          rst_i,            // сигнал сброса

  /*
      Интерфейс записи выводимого символа
  */
  input  logic [ 9:0]   char_map_addr_i,  // адрес позиции выводимого символа
  input  logic          char_map_we_i,    // сигнал разрешения записи кода
  input  logic [ 3:0]   char_map_be_i,    // сигнал выбора байтов для записи
  input  logic [31:0]   char_map_wdata_i, // ascii-код выводимого символа
  output logic [31:0]   char_map_rdata_o, // сигнал чтения кода символа

  /*
      Интерфейс установки цветовой схемы
  */
  input  logic [ 9:0]   col_map_addr_i,   // адрес позиции устанавливаемой схемы
  input  logic          col_map_we_i,     // сигнал разрешения записи схемы
  input  logic [ 3:0]   col_map_be_i,     // сигнал выбора байтов для записи
  input  logic [31:0]   col_map_wdata_i,  // код устанавливаемой цветовой схемы
  output logic [31:0]   col_map_rdata_o,  // сигнал чтения кода схемы

  /*
      Интерфейс установки шрифта
  */
  input  logic [ 9:0]   char_tiff_addr_i, // адрес позиции устанавливаемого шрифта
  input  logic          char_tiff_we_i,   // сигнал разрешения записи шрифта
  input  logic [ 3:0]   char_tiff_be_i,   // сигнал выбора байтов для записи
  input  logic [31:0]   char_tiff_wdata_i,// отображаемые пиксели в текущей позиции шрифта
  output logic [31:0]   char_tiff_rdata_o,// сигнал чтения пикселей шрифта


  output logic [3:0]    vga_r_o,          // красный канал vga
  output logic [3:0]    vga_g_o,          // зеленый канал vga
  output logic [3:0]    vga_b_o,          // синий канал vga
  output logic          vga_hs_o,         // линия горизонтальной синхронизации vga
  output logic          vga_vs_o          // линия вертикальной синхронизации vga
);

Файлы модуля:

  • peripheral modules/vhachargen.sv
  • peripheral modules/vhachargen_pkg.sv
  • firmware/mem_files/lab_13_ps2_vga_instr.mem — этим файлом необходимо проинициализировать память инструкций
  • firmware/mem_files/lab_13_ps2ascii_data.mem — этим файлом необходимо проинициализировать память данных
  • firmware/mem_files/lab_13_vga_ch_map.mem
  • firmware/mem_files/lab_13_vga_ch_t_ro.mem
  • firmware/mem_files/lab_13_vga_ch_t_rw.mem
  • firmware/mem_files/lab_13_vga_col_map.mem

Вам необходимо добавить в проект все эти файлы.

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

module vga_sb_ctrl (
  input  logic        clk_i,
  input  logic        rst_i,
  input  logic        clk100m_i,
  input  logic        req_i,
  input  logic        write_enable_i,
  input  logic [3:0]  mem_be_i,
  input  logic [31:0] addr_i,
  input  logic [31:0] write_data_i,
  output logic [31:0] read_data_o,

  output logic [3:0]  vga_r_o,
  output logic [3:0]  vga_g_o,
  output logic [3:0]  vga_b_o,
  output logic        vga_hs_o,
  output logic        vga_vs_o
);

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

  • clk_i,
  • rst_i,
  • clk100m_i,
  • vga_r_o,
  • vga_g_o,
  • vga_b_o,
  • vga_hs_o,
  • vga_vs_o

Кроме того, необходимо:

  1. подключить напрямую сигнал write_data_i ко входам:
    1. char_map_wdata_i,
    2. col_map_wdata_i,
    3. char_tiff_wdata_i,
  2. подключить биты addr_i[11:2] ко входам:
    1. char_map_addr_i,
    2. col_map_addr_i,
    3. char_tiff_addr_i,
  3. сигнал mem_be_i подключить ко входам:
    1. char_map_be_i,
    2. col_map_be_i,
    3. char_tiff_be_i.

Остается только разобраться с сигналами write_enable_i и read_data_o.

Оба эти сигнала мультиплексируются / демультиплексируются с помощью одного и того же управляющего сигнала: addr_i[13:12] в соответствии с диапазонами адресов (рис. 4):

  • addr_i[13:12] == 2'b00 — сигнал write_enable_i поступает на вход char_map_we_i, выход char_map_rdata_o записывается в выходной регистр read_data_o;
  • addr_i[13:12] == 2'b01 — сигнал write_enable_i поступает на вход col_map_we_i, выход col_map_rdata_o записывается в выходной регистр read_data_o;
  • addr_i[13:12] == 2'b10 — сигнал write_enable_i поступает на вход char_tiff_we_i, выход char_tiff_rdata_o записывается в выходной регистр read_data_o.

Список использованной литературы

  1. С.А. Орлов, Б.Я. Цилькер / Организация ЭВМ и систем: Учебник для вузов. 2-е изд. / СПб.: Питер, 2011.
  2. Rebelstar

Лабораторная работа 14 "Высокоуровневое программирование"

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

Цель

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

Ход работы

  1. Изучить теорию:
    1. Соглашение о вызовах
    2. Скрипт для компоновки
    3. Файл первичных команд
  2. Подготовить набор инструментов для кросс-компиляции
  3. Изучить порядок компиляции и команды, её осуществляющую:
    1. Компиляция объектных файлов
    2. Компоновка объектных файлов в исполняемый
    3. Экспорт секций для инициализации памяти
    4. Дизассемблирование
  4. Написать и скомпилировать собственную программу
  5. Проверить исполнение программы вашим процессором в ПЛИС

Теория

В рамках данной лабораторной работы вы напишите полноценную программу, которая будет запущена на вашем процессоре. В процессе компиляции, вам потребуются файлы linker_script.ld и startup.S, лежащие в этой папке.

— Но зачем мне эти файлы? Мы ведь уже делали задания по программированию на предыдущих лабораторных работах и нам не были нужны никакие дополнительные файлы.

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

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

Штука в том, что ассемблерный код, который писали ранее вы отличается от ассемблерного кода, генерируемого компилятором. Код, написанный вами, обладал, скажем так... более тонким микро-контролем хода программы. Когда вы писали программу, вы знали какой у вас размер памяти, где в памяти расположены инструкции, а где данные (ну, при написании программ вы почти не пользовались памятью данных, а когда пользовались — просто лупили по случайным адресам и все получалось). Вы пользовались всеми регистрами регистрового файла по своему усмотрению, без ограничений. Однако, представьте на секунду, что вы пишете проект на ассемблере вместе с коллегой: вы пишите одни функции, а он другие. Как в таком случае вы будете пользоваться регистрами регистрового файла? Поделите его напополам и будете пользоваться каждый своей половиной? Но что будет, если к проекту присоединится еще один коллега — придется делить регистровый файл уже на три части? Так от него уже ничего не останется. Для разрешения таких ситуаций было разработано соглашение о вызовах (calling convention).

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

Соглашение о вызовах

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

Кроме того, соглашение делит регистры регистрового файла на две группы: оберегаемые и необерегаемые регистры.

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

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

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

В таблице ниже приведено разделение регистров на оберегаемые (в правом столбце записано Callee, т.е. за их сохранение отвечает вызванная функция) и необерегаемые (Caller — за сохранение отвечает вызывающая функция). Кроме того, есть три регистра, для которых правый столбец не имеет значения: нулевой регистр (поскольку его невозможно изменить) и указатели на поток и глобальную область памяти. По соглашению о вызовах, эти регистры нельзя использовать для вычислений функций, они изменяются только по заранее оговоренным ситуациям.

В столбце ABI name записывается синоним имени регистра, связанный с его функциональным назначением (см. описание регистра). Часто ассемблеры одинаково воспринимают обе формы написания имени регистров.

RegisterABI NameDescriptionSaver
x0zeroHard-wired zero
x1raReturn addressCaller
x2spStack pointerCallee
x3gpGlobal pointer
x4tpThread pointer
x5t0Temporary/alternate link registerCaller
x6–7t1–2TemporariesCaller
x8s0/fpSaved register/frame pointerCallee
x9s1Saved registerCallee
x10–11a0–1Function arguments/return valuesCaller
x12–17a2–7Function argumentsCaller
x18–27s2–11Saved registersCallee
x28–31t3–6TemporariesCaller

Таблица 1. Ассемблерные мнемоники для целочисленных регистров RISC-V и их назначение в соглашении о вызовах.

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

Запись Callee означает, что к моменту возврата из вызываемой функции, значение Callee-saved регистров должно быть ровно таким же, каким было в момент вызова функций. Для s0-s11 регистров это осуществляется путем сохранения их значений на стек. При этом, перед каждым сохранением на стек, изменяется значение указателя на стек таким образом, чтобы он указывал на сохраняемое значение (обычно он декрементируется). Затем, перед возвратом из функций все сохраненные на стек значения восстанавливаются, попутно изменяя значение указателя на стек противоположным образом (инкрементируют его). Таким образом, несмотря на то, что значение указателя на стек менялось в процессе работы вызываемой функции, к моменту выхода из нее, его значение в итоге останется тем же.

Скрипт для компоновки (linker_script.ld)

Скрипт для компоновки описывает то, как в вашей памяти будут храниться данные. Вы уже могли слышать о том, что исполняемый файл содержит секции .text и .data — инструкций и данных соответственно. Компоновщик (linker) ничего не знает о том, какая у вас структура памяти: принстонская у вас архитектура или гарвардская, по каким адресам у вас должны храниться инструкции, а по каким данные, какой в памяти используется порядок следования байт (endianess). У вас может быть несколько типов памятей под особые секции — и обо всем этом компоновщику можно сообщить в скрипте для компоновки.

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

Для удобства этого описания существует вспомогательная переменная: счетчик адресов. В начале скрипта этот счетчик равен нулю. Размещая очередную секцию, этот счетчик увеличивается на размер этой секции. Допустим, у нас есть два файла fourier.o и main.o, в каждом из которых есть секции .text и .data. Мы хотим разместить их в памяти следующим образом: сперва разместить секции .text обоих файлов, а затем секции .data.

В итоге начиная с нулевого адреса будет размещена секция .text файла fourier.o. Она будет размещена именно там, поскольку счетчик адресов в начале скрипта равен нулю, а очередная секция размещается по адресу, куда указывает счетчик адресов. После этого, счетчик адресов будет увеличен на размер этой секции и секция .text файла main.o будет размещена сразу же за секцией .text файла fourier.o. После этого счетчик адресов будет увеличен на размер этой секции. То же самое произойдет и при размещении оставшихся секций.

Кроме того, вы в любой момент можете изменить значение счетчика адресов. Например, у вас две раздельные памяти: память инструкций объемом 512 байт и память данных объемом 1024 байта. Эти памяти находятся в одном адресном пространстве. Диапазон адресов памяти инструкций: [0:511], диапазон памяти данных: [512:1535]. При этом общий объем секций .text составляет 416 байт. В этом случае, вы можете сперва разместить секции .text так же, как было описано в предыдущем примере, а затем, выставив значение на счетчике адресов равное 512, описываете размещение секций данных. Тогда, между секциями появится разрыв в 96 байт, а данные окажутся в диапазоне адресов, выделенном для памяти данных.

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

Все это с подробными комментариями описано в файле linker_script.ld.

OUTPUT_FORMAT("elf32-littleriscv")      /* Указываем порядок следования байт */

ENTRY(_start)                           /* мы сообщаем компоновщику, что первая
                                           исполняемая процессором инструкция
                                           находится у метки "start"
                                        */

_text_size = 0x4000;                    /* Размер памяти инстр.: 16KiB */
_data_base_addr  = _text_size;          /* Стартовый адрес секции данных */
_data_size = 0x4000;                    /* Размер памяти данных: 16KiB */

_data_end = _data_base_addr + _data_size;

_trap_stack_size = 2560;                /* Размер стека обработчика перехватов.
                                           Данный размер позволяет выполнить
                                           до 32 вложенных вызовов при обработке
                                           перехватов.
                                        */

_stack_size = 1280;                     /* Размер программного стека.
                                           Данный размер позволяет выполнить
                                           до 16 вложенных вызовов.
                                        */

/*
  В данном разделе указывается структура памяти:
    Сперва идет регион "rom", являющийся памятью с исполняемым кодом
    (об этом говорит аттрибут 'x'). Этот регион начинается
    с адреса 0x00000000 и занимает _text_size байт.
    Далее идет регион "ram", начинающийся с адреса _data_base_addr и занимающий
    _data_size байт. Этот регион является памятью, противоположной региону "rom"
    (в том смысле, что это не память с исполняемым кодом).
*/
MEMORY
{
  rom  (x) : ORIGIN = 0x00000000,      LENGTH = _text_size
  ram (!x) : ORIGIN = _data_base_addr, LENGTH = _data_size
}


/*
  В данном разделе описывается размещение программы в памяти.
  Программа разделяется на различные секции:
  - секции исполняемого кода программа;
  - секции статических переменных и массивов, значение которых должно быть
    "вшито" в программу;
  и т.п.
*/

SECTIONS
{
  PROVIDE( _start = 0x00000000 );       /* Позиция start в памяти*/
  /*
    В скриптах компоновщика есть внутренняя переменная, записываемая как '.'
    Эта переменная называется "счетчиком адресов". Она хранит текущий адрес в
    памяти.
    В начале файла она инициализируется нулем. Добавляя новые секции, эта
    переменная будет увеличиваться на размер каждой новой секции.
    Если при размещении секций не указывается никакой адрес, они будут размещены
    по текущему значению счетчика адресов.
    Этой переменной можно присваивать значения, после этого, она будет
    увеличиваться с этого значения.
    Подробнее:
      https://home.cs.colorado.edu/~main/cs1300/doc/gnu/ld_3.html#IDX338
  */

  /*
    Следующая команда сообщает, что начиная с адреса, которому в данных момент
    равен счетчик адресов (в данный момент, начиная с нуля) будет находиться
    секция .text итогового файла, которая состоит из секций .boot, а также всех
    секций, начинающихся на .text во всех переданных компоновщику двоичных
    файлах.
    Дополнительно мы указываем, что данная секция должна быть размещена в
    регионе "rom".
  */
  .text : {*(.boot) *(.text*)} >rom


  /*
    Поскольку мы не знаем суммарного размера получившейся секции, мы проверяем
    что не вышли за границы памяти инструкций и переносим счетчик адресов за
    пределы памяти инструкций в область памяти данных.
    Дополнительно мы указываем, что данная секция должна быть размещена в
    регионе "ram".
  */
  ASSERT(. < _text_size, ".text section exceeds instruction memory size")
  . = _data_base_addr;

  /*
    Следующая команда сообщает, что начиная с адреса, которому в данных момент
    равен счетчик адресов (_data_base_addr) будет находиться секция .data
    итогового файла, которая состоит из секций всех секций, начинающихся
    на .data во всех переданных компоновщику двоичных файлах.
    Дополнительно мы указываем, что данная секция должна быть размещена в
    регионе "ram".
  */
  .data : {*(.*data*)} >ram

  /*
    Общепринято присваивать GP значение равное началу секции данных, смещенное
    на 2048 байт вперед.
    Благодаря относительной адресации со смещением в 12 бит, можно адресоваться
    на начало секции данных, а также по всему адресному пространству вплоть до
    4096 байт от начала секции данных, что сокращает объем требуемых для
    адресации инструкций (практически не используются операции LUI, поскольку GP
    уже хранит базовый адрес и нужно только смещение).
    Подробнее:
      https://groups.google.com/a/groups.riscv.org/g/sw-dev/c/60IdaZj27dY/m/s1eJMlrUAQAJ
  */
  _gbl_ptr = _data_base_addr + 0x800;


  /*
    Поскольку мы не знаем суммарный размер всех используемых секций данных,
    перед размещением других секций, необходимо выровнять счетчик адресов по
    4х-байтной границе.
  */
  . = ALIGN(4);


  /*
    BSS (block started by symbol, неофициально его расшифровывают как
    better save space) — это сегмент, в котором размещаются неинициализированные
    статические переменные. В стандарте Си сказано, что такие переменные
    инициализируются нулем (или NULL для указателей). Когда вы создаете
    статический массив — он должен быть размещен в исполняемом файле.
    Без bss-секции, этот массив должен был бы занимать такой же объем
    исполняемого файла, какого объема он сам. Массив на 1000 байт занял бы
    1000 байт в секции .data.
    Благодаря секции bss, начальные значения массива не задаются, вместо этого
    здесь только записываются названия переменных и их адреса.
    Однако на этапе загрузки исполняемого файла теперь необходимо принудительно
    занулить участок памяти, занимаемый bss-секцией, поскольку статические
    переменные должны быть проинициализированы нулем.
    Таким образом, bss-секция значительным образом сокращает объем исполняемого
    файла (в случае использования неинициализированных статических массивов)
    ценой увеличения времени загрузки этого файла.
    Для того, чтобы занулить bss-секцию, в скрипте заводятся две переменные,
    указывающие на начало и конец bss-секции посредством счетчика адресов.
    Подробнее:
      https://en.wikipedia.org/wiki/.bss

    Дополнительно мы указываем, что данная секция должна быть размещена в
    регионе "ram".
  */
  _bss_start = .;
  .bss : {*(.bss*)} >ram
  _bss_end = .;


  /*=================================
      Секция аллоцированных данных завершена, остаток свободной памяти отводится
      под программный стек, стек прерываний и (возможно) кучу. В соглашении о
      вызовах архитектуры RISC-V сказано, что стек растет снизу вверх, поэтому
      наша цель разместить его в самых последних адресах памяти.
      Поскольку стеков у нас два, в самом низу мы разместим стек прерываний, а
      над ним программный стек. При этом надо обеспечить защиту программного
      стека от наложения на него стека прерываний.
      Однако перед этим, мы должны убедиться, что под программный стек останется
      хотя бы 1280 байт (ничем не обоснованное число, взятое с потолка).
      Такое значение обеспечивает до 16 вложенных вызовов (если сохранять только
      необерегаемые регистры).
    =================================
  */

  /* Мы хотим гарантировать, что под стек останется как минимум 1280 байт */
  ASSERT(. < (_data_end - _trap_stack_size - _stack_size),
            "Program size is too big")

  /*  Перемещаем счетчик адресов над стеком прерываний (чтобы после мы могли
      использовать его в вызове ALIGN) */
  . = _data_end - _trap_stack_size;

  /*
      Размещаем указатель программного стека так близко к границе стека
      прерываний, насколько можно с учетом требования о выравнивании адреса
      стека до 16 байт.
      Подробнее:
        https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf
  */
  _stack_ptr = ALIGN(16) <= _data_end - _trap_stack_size?
                ALIGN(16) : ALIGN(16) - 16;
  ASSERT(_stack_ptr <= _data_end - _trap_stack_size, "SP exceed memory size")

  /*  Перемещаем счетчик адресов в конец памяти (чтобы после мы могли
      использовать его в вызове ALIGN) */
  . = _data_end;

  /*
      Обычно память имеет размер, кратный 16, но на случай, если это не так, мы
      делаем проверку, после которой мы либо остаемся в самом конце памяти (если
      конец кратен 16), либо поднимаемся на 16 байт вверх от края памяти,
      округленного до 16 в сторону большего значения
  */
  _trap_stack_ptr = ALIGN(16) <= _data_end ? ALIGN(16) : ALIGN(16) - 16;
  ASSERT(_trap_stack_ptr <= _data_end, "ISP exceed memory size")
}

Листинг 1. Пример скрипта компоновщика с комментариями.

Файл первичных команд при загрузке (startup.S)

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

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

  .section    .boot

 .global _start
_start:
  la    gp, _gbl_ptr     # Инициализация глобального указателя
  la    sp, _stack_ptr   # Инициализация указателя на стек

# Инициализация (зануление) сегмента bss
  la    t0, _bss_start
  la    t1, _bss_end
_bss_init_loop:
  blt   t1, t0, _irq_config
  sw    zero, 0(t0)
  addi  t0, t0, 4
  j     _bss_init_loop

# Настройка вектора (mtvec) и маски (mie) прерываний, а также указателя на стек
# прерываний (mscratch).
_irq_config:
  la    t0, _int_handler
  li    t1, -1 # -1 (все биты равны 1) означает, что разрешены все прерывания
  la    t2, _trap_stack_ptr
  csrw  mtvec, t0
  csrw  mie, t1
  csrw  mscratch, t2

# Вызов функции main
_main_call:
  li    a0, 0 # Передача аргументов argc и argv в main. Формально, argc должен
  li    a1, 0 # быть больше нуля, а argv должен указывать на массив строк,
              # нулевой элемент которого является именем исполняемого файла,
              # Но для простоты реализации оба аргумента всего лишь обнулены.
              # Это сделано для детерминированного поведения программы в случае,
              # если программист будет пытаться использовать эти аргументы.
  call  main
# Зацикливание после выхода из функции main
_endless_loop:
  j     _endless_loop

# Низкоуровневый обработчик прерывания отвечает за:
#   * Сохранение и восстановление контекста;
#   * Вызов высокоуровневого обработчика с передачей id источника прерывания в
#     качестве аргумента.
# В основе кода лежит обработчик из репозитория urv-core:
# https://github.com/twlostow/urv-core/blob/master/sw/common/irq.S
# Из реализации убраны сохранения нереализованных CS-регистров. Кроме того,
# судя по документу приведенному ниже, обычное ABI подразумевает такое же
# сохранение контекста, что и при программном вызове (EABI подразумевает еще
# меньшее сохранение контекста), поэтому нет нужды сохранять весь регистровый
# файл.
# Документ:
#  https://github.com/riscv-non-isa/riscv-eabi-spec/blob/master/EABI.adoc
_int_handler:
  # Данная операция меняет местами регистры sp и mscratch.
  # В итоге указатель на стек прерываний оказывается в регистре sp, а вершина
  # программного стека оказывается в регистре mscratch.
  csrrw sp, mscratch, sp

  # Далее мы поднимаемся по стеку прерываний и сохраняем все регистры.
  addi  sp, sp, -80 # Указатель на стек должен быть выровнен до 16 байт, поэтому
                    # поднимаемся вверх не на 76, а на 80.
  sw    ra, 4(sp)
  # Мы хотим убедиться, что очередное прерывание не наложит стек прерываний на
  # программный стек, поэтому записываем в освободившийся регистр низ
  # программного стека, и проверяем что приподнятый указатель на верхушку
  # стека прерываний не залез в программный стек.
  # В случае, если это произошло (произошло переполнение стека прерываний),
  # мы хотим остановить работу процессора, чтобы не потерять данные, которые
  # могут помочь нам в отладке этой ситуации.
  la    ra, _stack_ptr
  blt   sp, ra, _endless_loop

  sw    t0, 12(sp)  # Мы перепрыгнули через смещение 8, поскольку там должен
                    # лежать регистр sp, который ранее сохранили в mscratch.
                    # Мы запишем его на стек чуть позже.
  sw    t1, 16(sp)
  sw    t2, 20(sp)
  sw    a0, 24(sp)
  sw    a1, 28(sp)
  sw    a2, 32(sp)
  sw    a3, 36(sp)
  sw    a4, 40(sp)
  sw    a5, 44(sp)
  sw    a6, 48(sp)
  sw    a7, 52(sp)
  sw    t3, 56(sp)
  sw    t4, 60(sp)
  sw    t5, 64(sp)
  sw    t6, 68(sp)

  # Кроме того, мы сохраняем состояние регистров прерываний на случай, если
  # произойдет еще одно прерывание.
  csrr  t0, mscratch
  csrr  t1, mepc
  csrr  a0, mcause
  sw    t0, 8(sp)
  sw    t1, 72(sp)
  sw    a0, 76(sp)

  # Вызов высокоуровневого обработчика прерываний
  call  int_handler

  # Восстановление контекста. В первую очередь мы хотим восстановить CS-регистры,
  # на случай, если происходило вложенное прерывание. Для этого, мы должны
  # вернуть исходное значение указателя стека прерываний. Однако его нынешнее
  # значение нам еще необходимо для восстановления контекста, поэтому мы
  # сохраним его в регистр a0, и будем восстанавливаться из него.
  mv    a0, sp

  lw    t1, 72(a0)
  addi  sp, sp, 80
  csrw  mscratch, sp
  csrw  mepc, t1
  lw    ra, 4(a0)
  lw    sp, 8(a0)
  lw    t0, 12(a0)
  lw    t1, 16(a0)
  lw    t2, 20(a0)
  lw    a1, 28(a0)  # Мы пропустили a0, потому что сейчас он используется в
                    # качестве указателя на верхушку стека и не может быть
                    # восстановлен.
  lw    a2, 32(a0)
  lw    a3, 36(a0)
  lw    a4, 40(a0)
  lw    a5, 44(a0)
  lw    a6, 48(a0)
  lw    a7, 52(a0)
  lw    t3, 56(a0)
  lw    t4, 60(a0)
  lw    t5, 64(a0)
  lw    t6, 68(a0)
  lw    a0, 40(a0)

  # Выход из обработчика прерывания
  mret

Листинг 2. Пример содержимого файла первичных команд с поясняющими комментариями.

Практика

Для того, чтобы запустить моделирование исполнения программы на вашем процессоре, сперва эту программу необходимо скомпилировать и преобразовать в текстовый файл, которым САПР сможет проинициализировать память процессора. Для компиляции программы, вам потребуется особый компилятор, который называется "кросскомпилятор". Он позволяет компилировать исходный код под архитектуру компьютера, отличную от компьютера, на котором ведется компиляция. В нашем случае, вы будете собирать код под архитектуру RISC-V на компьютере с архитектурой x86_64.

Компилятор, который подойдет для данной задачи (для запуска в операционной системе Windows) уже установлен в аудиториях. Но если что, вы можете скачать его отсюда (обратите внимание, что размер архива составляет ~550 МБ, попытка скачивания этого архива из учебной аудитории может потратить вашу месячную квоту интернет-трафика).

Компиляция объектных файлов

В первую очередь необходимо скомпилировать файлы с исходным кодом в объектные. Это можно сделать следующей командой:

<исполняемый файл компилятора> -с <флаги компиляции> <входной файл с исходным кодом> -o <выходной объектный файл>

Вам потребуются следующие флаги компиляции:

  • -march=rv32i_zicsr — указание разрядности и набора расширений в архитектуре, под которую идет компиляция (у нас процессор rv32i с расширением инструкциями для взаимодействия с регистрами контроля и статуса Zicsr)
  • -mabi=ilp32 — указание двоичного интерфейса приложений. Здесь сказано, что типы int, long и pointer являются 32-разрядными.

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

С учетом названия исполняемого файла скачанного вами компилятора (при условии, что папку из архива вы переименовали в riscv_cc и скопировали в корень диска C:, а команду запускаете из терминала git bash), командой для компиляции файла startup.S может быть:

/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 startup.S -o startup.o

Компоновка объектных файлов в исполняемый

Далее необходимо выполнить компоновку объектных файлов. Это можно выполнить командной следующего формата:

<исполняемый файл компилятора> <флаги компоновки> <входные объектные файлы> -o <выходной объектный файл>

Исполняемый файл компилятора тот же самый, флаги компоновки будут следующие:

  • -march=rv32i_zicsr -mabi=ilp32 — те же самые флаги, что были при компиляции (нам все еще нужно указывать архитектуру, иначе компоновщик может скомпоновать объектные файлы со стандартными библиотеками от другой архитектуры)
  • -Wl,--gc-sections — указать компоновщику удалять неиспользуемые секции (сокращает объем итогового файла)
  • -nostartfiles — указать компоновщику не использовать стартап-файлы стандартных библиотек (сокращает объем файла и устраняет ошибки компиляции из-за конфликтов с используемым стартап-файлом).
  • -T linker_script.ld — передать компоновщику скрипт компоновки

Пример команды компоновки:

/c/riscv_cc/bin/riscv-none-elf-gcc -march=rv32i_zicsr -mabi=ilp32 -Wl,--gc-sections -nostartfiles -T linker_script.ld startup.o main.o -o result.elf

Экспорт секций для инициализации памяти

В результате компоновки вы получите исполняемый файл формата elf (Executable and Linkable Format). Это двоичный файл, однако это не просто набор двоичных инструкций и данных, которые будут загружены в память процессора. Данный файл содержит заголовки и специальную информацию, которая поможет загрузчику разместить этот файл в памяти компьютера. Поскольку роль загрузчика будете выполнять вы и САПР, на котором будет вестись моделирование, эта информация вам не понадобятся, поэтому вам потребуется экспортировать из данного файла только двоичные инструкции и данные, отбросив всю остальную информацию. Полученный файл уже можно будет использовать в функции $readmemh.

Для экспорта используйте команду:

/c/riscv_cc/bin/riscv-none-elf-objcopy -O verilog result.elf init.mem

ключ -O verilog говорит о том, что файл надо сохранить в формате, который сможет воспринять команда $readmemh.

Поскольку память инструкций и данных у вас разделены, можно экспортировать отдельные секции в разные файлы:

/c/riscv_cc/bin/riscv-none-elf-objcopy -O verilog -j .text result.elf init_instr.mem
/c/riscv_cc/bin/riscv-none-elf-objcopy -O verilog -j .data -j .bss -j .sdata result.elf init_data.mem

Обратите внимание на содержимое полученного файла:

@00000000
97 11 00 00 93 81 01 AD 13 01 00 76 93 02 00 2D
13 03 00 2D 63 88 62 00 23 A0 02 00 93 82 42 00
6F F0 5F FF 93 02 40 04 13 03 F0 FF 73 90 52 30
73 10 43 30 13 05 00 00 93 05 00 00 EF 00 C0 1F
6F 00 00 00 73 11 01 34 13 01 01 FB 23 22 11 00
...

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

Для того, чтобы итоговый файл подходил для памяти с 32-разрядными ячейками, команду экспорта необходимо дополнить опцией --verilog-data-width=4, которая указывает размер ячейки инициализируемой памяти в байтах. Файл примет следующий вид:

@00000000
00001197 AD018193 76000113 2D000293
2D000313 00628863 0002A023 00428293
FF5FF06F 04400293 FFF00313 30529073
30431073 00000513 00000593 1FC000EF
0000006F 34011173 FB010113 00112223
...

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

Вернемся к первой строке: @00000000. Как уже говорилось, число, начинающееся с символа @ говорит САПР, что с этого момента инициализация идет начиная с ячейки памяти, номер которой совпадает с этим числом. Когда вы будете экспортировать секции данных, первой строкой будет: @00001000. Так произойдет, поскольку в скрипте компоновщика сказано, что секция данных идет с 0x00004000 адреса. Это было сделано, чтобы не произошло наложения адресов памяти инструкций и памяти данных. Чтобы система работала корректно, эту строчку необходимо удалить.

Дизассемблирование

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

Пример дизасемблированного файла:

Disassembly of section .text:

00000000 <_start>:
   0: 00001197           auipc gp,0x1
   4: adc18193           addi gp,gp,-1316 # adc <_gbl_ptr>
   8: 76000113           li sp,1888
   c: 2dc00293           li t0,732
  10: 2dc00313           li t1,732

00000014 <_bss_init_loop>:
  14: 00628863           beq t0,t1,24 <_irq_config>
  18: 0002a023           sw zero,0(t0)
  1c: 00428293           addi t0,t0,4
...

00000164 <bubble_sort>:
 164: fd010113           addi sp,sp,-48
 168: 02112623           sw ra,44(sp)
 16c: 02812423           sw s0,40(sp)
 170: 03010413           addi s0,sp,48
 174: fca42e23           sw a0,-36(s0)
 178: fcb42c23           sw a1,-40(s0)
 17c: fe042623           sw zero,-20(s0)
 180: 09c0006f           j 21c <bubble_sort+0xb8>
...

00000244 <main>:
 244: ff010113           addi sp,sp,-16
 248: 00112623           sw ra,12(sp)
 24c: 00812423           sw s0,8(sp)
 250: 01010413           addi s0,sp,16
 254: 00a00593           li a1,10
 258: 2b400513           li a0,692
 25c: f09ff0ef           jal ra,164 <bubble_sort>
 260: 2b400793           li a5,692
...

Disassembly of section .data:

000002b4 <array_to_sort>:
 2b4: 00000003           lb zero,0(zero) # 0 <_start>
 2b8: 0005                 c.nop 1
 2ba: 0000                 unimp
 2bc: 0010                 0x10
 2be: 0000                 unimp
...

Числа в самом левом столбце, увеличивающиеся на 4 — это адреса в памяти. Отлаживая программу на временной диаграмме, вы можете ориентироваться на эти числа, как на значения PC.

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

В правом столбце находится ассемблерный (человекочитаемый) аналог инструкции из предыдущего столбца. Например, инструкция 00001197 — это операция auipc gp,0x1, где gp — это синоним (ABI name) регистра x3 (см. раздел Соглашение о вызовах).

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

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

Это не значит, что секция данных в дизасме бесполезна — в приведенном выше листинге вы можете понять, что первыми элементами массива array_to_sort являются числа 3, 5, 10, а также то, по каким адресам они лежат (0x2b4, 0x2b8, 0x2bc, если непонятно почему первое число записано в одну 4-байтовую строку, а два других разделены на две двубайтовые — попробуйте перечитать предыдущий абзац). Просто разбирая дизасемблерный файл, обращайте внимание на то, какую именно секцию вы сейчас читаете.

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

<исполняемый файл дизасемблера> -D (либо -d) <входной исполняемый файл> > <выходной файл на языке ассемблер>

Для нашего примера, командной будет

/c/riscv_cc/bin/riscv-none-elf-objdump -D result.elf > disasmed_result.S

Опция -D говорит, что дизасемблировать необходимо вообще все секции. Опция -d позволяет дизасемблировать только исполняемые секции (секции с инструкциями). Таким образом, выполнив дизасемблирование с опцией -d мы избавимся от проблем с непонятными инструкциями, в которые декодировались данные из секции .data, однако в этом случае, мы не сможем проверить адреса и значения, которые хранятся в этих секциях.


Задание

Вам необходимо написать программу для вашего индивидуального задания к 4-ой лабораторной работе на языке C или C++ (в зависимости от выбранного языка необходимо использовать соответствующий компилятор: gcc для C, g++ для C++).

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

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

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

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

Пример взаимодействия с периферийным устройством через вымышленную структуру:

#include "platform.h"

/*
  Создаем заголовочном файле "platform.h" объявлен collider_ptr — указатель на структуру SUPER_COLLIDER_HANDLE.
  Доступ к полям этой структуры можно осуществлять через оператор "->".
  Также в этом файле объявлен указатель collider_mem, который указывает на
  некоторую память этого периферийного устройства. Данный указатель можно
  использовать в качестве имени массива.
*/

int main(int argc, char** argv)
{
  while(1){                             // В бесконечном цикле
    while (!(collider_ptr->ready));     // Постоянно опрашиваем регистр ready,
                                        // пока тот не станет равен 1.

                                        // После чего запускаем коллайдер,
    collider_ptr->start = 1;            // записав 1 в контрольный регистр start
    collider_mem[0] += collider_mem[1]; // Пример взаимодействия с памятью,
                                        // Используя объявленный в platform.h
                                        // указатель в качестве имени массива.
  }
}

#define DEADLY_SERIOUS_EVENT 0xDEADDAD1

// extern "C" нужно использовать только в С++. Благодаря этому, в объектном
// файле функция будет называться именно int_handler, как и ожидает компоновщик
// при объединении кода с startup.S
// Без extern "C", при компиляции C++ кода имя функции в объектном файле будет
// немного другим (что-то типа _Z11int_handlerv), из-за чего возникнут проблемы
// в компоновке.
extern "C" void int_handler()
{
  // Если от коллайдера приходит прерывание, сразу же проверяем регистр статуса
  // и если его код равен DEADLY_SERIOUS_EVENT, экстренно останавливаем коллайдер
  if(DEADLY_SERIOUS_EVENT == collider_ptr->status)
  {
    collider_ptr->emergency_switch = 1;
  }
}

Если одним из ваших периферийных устройств был VGA-контроллер, то вы можете использовать не указатель на структуру, а объявленные в том же файле указатели на байты: char_map, color_map, tiff_map. Как вы знаете, указатель может использоваться в качестве имени массива, а значит вы можете обращаться к нужному вам байту в соответствующей области памяти VGA-контроллера как к элементу массива. Например, для того, чтобы записать символ в шестое знакоместо второй строки, вам необходимо будет обратиться к char_map[2*80+6] (2*80 — индекс начала второй строки).


Порядок выполнения задания

  1. Написать программу для своего индивидуального задания на языке C или C++.
  2. Скомпилировать программу и стартап-файл в объектные файлы.
  3. Скомпоновать объектные файлы исполняемый файл, передав компоновщику соответствующий скрипт.
  4. Экспортировать из объектного файла секции .text и .data в текстовые файлы init_instr.mem, init_data.mem. Если вы не создавали инициализированных статических массивов или глобальных переменных, то файл init_data.mem может быть оказаться пустым.
    1. Если файл init_data.mem не пустой, необходимо проинициализировать память в модуле ext_mem c помощью системной функции $readmemh как это было сделано для памяти инструкций.
    2. Перед этим из файла init_data.mem необходимо удалить первую строку (вида @00001000), указывающую начальный адрес инициализации.
  5. Добавить получившиеся текстовые файлы в проект Vivado.
  6. Запустить моделирование исполнения программы вашим процессором. Для отладки во время моделирования будет удобно использовать дизасемблерный файл, ориентируясь на сигналы адреса и данных шины инструкций.
  7. Проверить корректное исполнение программы процессором в ПЛИС.

Лабораторная работа 15 "Программатор"

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

Цель

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

Ход работы

  1. Познакомиться с информацией о программаторах и загрузчиках (#теория)
  2. Изучить информацию о конечных автоматах и способах их реализации (#практика)
  3. Описать перезаписываемую память инструкций (#память инструкций)
  4. Описать и проверить модуль программатора (#программатор)
  5. Интегрировать программатор в процессорную систему и проверить её (#интеграция)
  6. Проверить работу системы в ПЛИС с помощью предоставленного скрипта по прошивкe системы (#проверка)

Теория

До этого момента исполняемая процессором программа попадала в память инструкций через магический вызов $readmemh. Однако, реальные микроконтроллеры не обладают такими возможностями. Программа из внешнего мира попадает в них посредством так называемого программатора — устройства, обеспечивающего запись программы в память микроконтроллера. Программатор записывает данные в постоянное запоминающее устройство (ПЗУ). Для того, чтобы программа попала из ПЗУ в память инструкций (в ОЗУ), после запуска контроллера сперва начинает исполняться загрузчик (bootloader) — небольшая программа, вшитая в память микроконтроллера на этапе изготовления. Загрузчик отвечает за первичную инициализацию различных регистров и подготовку микроконтроллера к выполнению основной программы, включая её перенос из ПЗУ в память инструкций.

Со временем появилось несколько уровней загрузчиков: сперва запускается первичный загрузчик (first stage bootloader, fsbl), после которого запускается вторичный загрузчик (часто в роли вторичного загрузчика исполняется программа под названием u-boot). Такая иерархия загрузчиков может потребоваться, например, в случае загрузки операционной системы (которая хранится в файловой системе). Код для работы с файловой системой может попросту не уместиться в первичный загрузчик. В этом случае, целью первичного загрузчика является лишь загрузить вторичный загрузчик, который в свою очередь уже будет способен взаимодействовать с файловой системой и загрузить операционную систему[1].

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

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

Практика

Конечные автоматы (Finite-State Machines, FSM)

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

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

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

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

Иными словами, у турникета есть:

  • два состояния
    • заблокирован (locked)
    • разблокирован(unlocked)
  • два входа (события)
    • жетон принят (coin)
    • попытка поворота треноги (push)
  • один выход
    • блокировка треноги

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

Опишем данный автомат в виде графа переходов:

https://upload.wikimedia.org/wikipedia/commons/9/9e/Turnstile_state_machine_colored.svg

Рисунок 1. Граф переходов конечного автомата для турникета[2].

Черной точкой со стрелкой в вершину Locked обозначен сигнал сброса. Иными словами, при сбросе турникет всегда переходит в заблокированное состояние.

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

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

Реализация конечных автоматов в SystemVerilog

Глядя на описание составляющих конечного автомата, вы могли задаться вопросом: чем автомат отличается от последовательностной логики, ведь она состоит из тех же компонент. Ответом будет: ничем. Конечные автоматы являются математической абстракцией над функцией последовательностной логики[3]. Иными словами — конечный автомат, это просто другой способ представления последовательностной логики, а значит вы уже умеете их реализовывать.

Для реализации регистра состояния конечного автомата будет удобно воспользоваться специальным типом языка SystemVerilog, который называется enum (перечисление).

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

module turnstile_fsm(
  input  logic clk,
  input  logic rst,
  input  logic push,
  input  logic coin,
  output logic is_locked
)

    enum logic {LOCKED=1, UNLOCKED=0} state;

    assign is_locked = state;

    always_ff @(posedge clk) begin
      if(rst) begin
        state <= LOCKED;
      end
      else begin
        if(push) begin
          state <= LOCKED;
        end
        else if (coin) begin
          state <= UNLOCKED;
        end
        else begin
          state <= state;
        end
      end
    end

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

../../.pic/Labs/lab_15_programming_device/fig_02.png

Рисунок 2. Вывод значений объекта enum на временную диаграмму.

Для описания регистра состояния часто используют отдельный комбинационный сигнал, который подается непосредственно на его вход (часто именуемый как next_state). Приведенный выше автомат турникета слишком простой, чтобы показать преимущества такого подхода. Предположим, что в момент перехода из состояния locked в состояние unlocked мы хотим, чтобы загоралась и сразу гасла зеленая лампочка. Без сигнала next_state подобный модуль можно было бы описать как:

module turnstile_fsm(
  input  logic clk,
  input  logic rst,
  input  logic push,
  input  logic coin,
  output logic is_locked,
  output logic green_light
)

    enum logic {LOCKED=1, UNLOCKED=0} state;

    assign is_locked = state;

    // (!push) && coin — условие перехода в состояние UNLOCKED
    assign green_light = (state == LOCKED) && (!push) && coin;

    always_ff @(posedge clk) begin
      if(rst) begin
        state <= LOCKED;
      end
      else begin
        if(push) begin
          state <= LOCKED;
        end
        else if (coin) begin
          state <= UNLOCKED;
        end
        else begin
          state <= state;
        end
      end
    end

Используя сигнал next_state, автомат мог бы быть переписан следующим образом:

module turnstile_fsm(
  input  logic clk,
  input  logic rst,
  input  logic push,
  input  logic coin,
  output logic is_locked,
  output logic green_light
)

    enum logic {LOCKED=1, UNLOCKED=0} state, next_state;

    assign is_locked = state;

    assign green_light = (state == LOCKED) && (next_state == UNLOCKED);

    always_ff @(posedge clk) begin
      if(rst) begin
        state <= LOCKED;
      end
      else begin
        state <= next_state
      end
    end

    always_comb begin
      if(push) begin
        next_state = LOCKED;
      end
      else if (coin) begin
        next_state = UNLOCKED;
      end
      else begin
        next_state = state;
      end
    end

На первый взгляд может показаться, что так даже сложнее. Во-первых, появился дополнительный сигнал. Во-вторых, появился еще один always-блок. Однако представьте на секунду, что условиями перехода будут не однобитные входные сигналы, а какие-нибудь более сложные условия. И что от них будет зависеть не один выходной сигнал, а множество как выходных сигналов, так и внутренних элементов памяти помимо регистра состояний. В этом случае, сигнал next_state позволит избежать дублирования множества условий.

Важно отметить, что объектам типа enum можно присваивать только перечисленные константы и объекты того же типа. Иными словами, state можно присваивать значения LOCKED/UNLOCKED и next_state, но нельзя, к примеру, присвоить 1'b0.

Задание

Для выполнения данной лабораторной работы необходимо:

  • описать перезаписываемую память инструкций;
  • описать модуль-программатор;
  • заменить в riscv_unit память инструкций на новую, и интегрировать в riscv_unit программатор.

Перезаписываемая память инструкций

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

module rw_instr_mem(
  input  logic        clk_i,
  input  logic [31:0] read_addr_i,
  output logic [31:0] read_data_o,

  input  logic [31:0] write_addr_i,
  input  logic [31:0] write_data_i,
  input  logic        write_enable_i
);

Как вы помните, модуль instr_mem отличался от data_mem только портом на запись, так что данный модуль будет практически в точности повторять модуль data_mem за исключением того, что у этого модуля нет сигнала req_i (поскольку память инструкций подключена к процессору в обход системной шины).

Программатор

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

Описание модуля

В основе работы модуля лежит конечный автомат со следующим графом перехода между состояниями:

../../.pic/Labs/lab_15_programming_device/fig_03.drawio.svg

Рисунок 3. Граф перехода между состояниями программатора.

Условия перехода следующие:

  • send_fin = ( msg_counter == 0) && !tx_busy — условие завершения передачи модулем сообщения по uart_tx;
  • size_fin = ( size_counter == 0) && !rx_busy — условие завершения приема модулем размера будущей посылки;
  • flash_fin = (flash_counter == 0) && !rx_busy — условие завершения приема модулем блока записываемых данных;
  • next_round = (flash_addr != 0) && !rx_busy — условие записи блока данных через системную шину;

Ниже представлен прототип модуля с частично реализованной логикой:

module bluster
(
  input   logic clk_i,
  input   logic rst_i,

  input   logic rx_i,
  output  logic tx_o,

  output logic [ 31:0] instr_addr_o,
  output logic [ 31:0] instr_wdata_o,
  output logic         instr_write_enable_o,

  output logic [ 31:0] data_addr_o,
  output logic [ 31:0] data_wdata_o,
  output logic         data_write_enable_o,

  output logic core_reset_o
);

enum logic [2:0] {
  RCV_NEXT_COMMAND,
  INIT_MSG,
  RCV_SIZE,
  SIZE_ACK,
  FLASH,
  FLASH_ACK,
  WAIT_TX_DONE,
  FINISH}
state, next_state;

logic rx_busy, rx_valid, tx_busy, tx_valid;
logic [7:0] rx_data, tx_data;

logic [5:0] msg_counter;
logic [31:0] size_counter, flash_counter;
logic [3:0] [7:0] flash_size, flash_addr;

logic send_fin, size_fin, flash_fin, next_round;

assign send_fin   = (msg_counter    == 0)  && !tx_busy;
assign size_fin   = (size_counter   == 0)  && !rx_busy;
assign flash_fin  = (flash_counter  == 0)  && !rx_busy;
assign next_round = (flash_addr     != '1) && !rx_busy;

localparam INIT_MSG_SIZE  = 40;
localparam FLASH_MSG_SIZE = 57;
localparam ACK_MSG_SIZE   = 4;

logic [7:0] [7:0] flash_size_ascii, flash_addr_ascii;
// Блок generate позволяет создавать структуры модуля цикличным или условным
// образом. В данном случае, при описании непрерывных присваиваний была
// обнаружена закономерность, позволяющая описать четверки присваиваний в более
// общем виде, который был описан в виде цикла.
// Важно понимать, данный цикл лишь автоматизирует описание присваиваний и во
// время синтеза схемы развернется в четыре четверки непрерывных присваиваний.
genvar i;
generate
  for(i=0; i < 4; i=i+1) begin
    // Разделяем каждый байт flash_size и flash_addr на два ниббла.
    // Ниббл — это 4 бита. Каждый ниббл можно описать 16-ричной цифрой.
    // Если ниббл меньше 10 (4'ha), он описывается цифрами 0-9. Чтобы представить
    // его ascii-кодом, необходимо прибавить к нему число 8'h30
    // (ascii-код символа '0').
    // Если ниббл больше либо равен 10, он описывается буквами a-f. Для его
    // представления в виде ascii-кода, необходимо прибавить число 8'h57
    // (ascii-код символа 'a' - 8'h61).
    assign flash_size_ascii[i*2]    = flash_size[i][3:0] < 4'ha ? flash_size[i][3:0] + 8'h30 :
                                                                  flash_size[i][3:0] + 8'h57;
    assign flash_size_ascii[i*2+1]  = flash_size[i][7:4] < 4'ha ? flash_size[i][7:4] + 8'h30 :
                                                                  flash_size[i][7:4] + 8'h57;

    assign flash_addr_ascii[i*2]    = flash_addr[i][3:0] < 4'ha ? flash_addr[i][3:0] + 8'h30 :
                                                                  flash_addr[i][3:0] + 8'h57;
    assign flash_addr_ascii[i*2+1]  = flash_addr[i][7:4] < 4'ha ? flash_addr[i][7:4] + 8'h30 :
                                                                  flash_addr[i][7:4] + 8'h57;
  end
endgenerate

logic [INIT_MSG_SIZE-1:0][7:0] init_msg;
// ascii-код строки "ready for flash staring from 0xflash_addr\n"
assign init_msg = { 8'h72, 8'h65, 8'h61, 8'h64, 8'h79, 8'h20, 8'h66, 8'h6F,
                    8'h72, 8'h20, 8'h66, 8'h6C, 8'h61, 8'h73, 8'h68, 8'h20,
                    8'h73, 8'h74, 8'h61, 8'h72, 8'h69, 8'h6E, 8'h67, 8'h20,
                    8'h66, 8'h72, 8'h6F, 8'h6D, 8'h20, 8'h30, 8'h78,
                    flash_addr_ascii, 8'h0a};

logic [FLASH_MSG_SIZE-1:0][7:0] flash_msg;
//ascii-код строки: "finished write 0xflash_size bytes starting from 0xflash_addr\n"
assign flash_msg = {8'h66, 8'h69, 8'h6E, 8'h69, 8'h73, 8'h68, 8'h65, 8'h64,
                    8'h20, 8'h77, 8'h72, 8'h69, 8'h74, 8'h65, 8'h20, 8'h30,
                    8'h78,      flash_size_ascii,      8'h20, 8'h62, 8'h79,
                    8'h74, 8'h65, 8'h73, 8'h20, 8'h73, 8'h74, 8'h61, 8'h72,
                    8'h74, 8'h69, 8'h6E, 8'h67, 8'h20, 8'h66, 8'h72, 8'h6F,
                    8'h6D, 8'h20, 8'h30, 8'h78,     flash_addr_ascii,
                    8'h0a};

uart_rx rx(
  .clk_i      (clk_i      ),
  .rst_i      (rst_i      ),
  .rx_i       (rx_i       ),
  .busy_o     (rx_busy    ),
  .baudrate_i (17'd115200 ),
  .parity_en_i(1'b1       ),
  .stopbit_i  (1'b1       ),
  .rx_data_o  (rx_data    ),
  .rx_valid_o (rx_valid   )
);

uart_tx tx(
  .clk_i      (clk_i      ),
  .rst_i      (rst_i      ),
  .tx_o       (tx_o       ),
  .busy_o     (tx_busy    ),
  .baudrate_i (17'd115200 ),
  .parity_en_i(1'b1       ),
  .stopbit_i  (1'b1       ),
  .tx_data_i  (tx_data    ),
  .tx_valid_i (tx_valid   )
);

endmodule

Здесь уже объявлены:

  • enum-сигналы state и next_state;
  • сигналы, send_fin, size_fin, flash_fin, next_round, используемые в качестве условий переходов между состояниями;
  • счетчики msg_counter, size_counter, flash_counter, необходимые для реализации условий переходов;
  • сигналы, необходимые для подключения модулей uart_rx и uart_tx:
    • rx_busy,
    • rx_valid,
    • tx_busy,
    • tx_valid,
    • rx_data,
    • tx_data;
  • модули uart_rx, uart_tx;
  • сигналы init_msg, flash_msg, хранящие ascii-код ответов программатора, а также логику и сигналы, необходимые для реализации этих ответов:
    • flash_size,
    • flash_addr,
    • flash_size_ascii,
    • flash_addr_ascii.

Реализация модуля программатора

Для реализации данного модуля, необходимо реализовать все объявленные выше сигналы, кроме сигналов:

  • rx_busy, rx_valid, rx_data, tx_busy (т.к. те уже подключены к выходам модулей uart_rx и uart_tx),
  • flash_size_ascii, flash_addr_ascii, init_msg, flash_msg (т.к. они уже реализованы в представленной выше логике).

Так же необходимо реализовать выходы модуля программатора:

  • instr_addr_o;
  • instr_wdata_o;
  • instr_write_enable_o;
  • data_addr_o;
  • data_wdata_o;
  • data_write_enable_o;
  • core_reset_o.
Реализация конечного автомата

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

Логика условий переходов уже была представлена сразу после графа переходов.

Для работы логики переходов, необходимо реализовать счетчики size_counter, flash_counter, msg_counter.

size_counter должен сбрасываться в значение 4, а также принимать это значение во всех состояниях кроме: RCV_SIZE, RCV_NEXT_COMMAND. В данных двух состояниях счетчик должен декрементироваться в случае, если rx_valid равен единице.

flash_counter должен сбрасываться в значение flash_size, а также принимать это значение во всех состояниях кроме FLASH. В этом состоянии счетчик должен декрементироваться в случае, если rx_valid равен единице.

msg_counter должен сбрасываться в значение INIT_MSG_SIZE-1.

Счетчик должен инициализироваться следующим образом:

  • в состоянии FLASH счетчик должен принимать значение FLASH_MSG_SIZE-1,
  • в состоянии RCV_SIZE счетчик должен принимать значение ACK_MSG_SIZE-1,
  • в состоянии RCV_NEXT_COMMAND счетчик должен принимать значение INIT_MSG_SIZE-1.

В состояниях: INIT_MSG, SIZE_ACK, FLASH_ACK счетчик должен декрементироваться в случае, если tx_valid равен единице.

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

Реализация сигналов, подключаемых к uart_tx

Сигнал tx_valid должен быть равен единице только когда tx_busy равен нулю, а конечный автомат находится в одном из следующих состояний:

  • INIT_MSG,
  • SIZE_ACK,
  • FLASH_ACK

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

Сигнал tx_data должен нести очередной байт одного из передаваемых сообщений:

  • в состоянии INIT_MSG передается очередной байт сообщения init_msg
  • в состоянии SIZE_ACK передается очередной байт сообщения flash_size
  • в состоянии FLASH_ACK передается очередной байт сообщения flash_msg.

В остальных состояниях он равен нулю. Для отсчета байт используется счетчик msg_counter.

Реализация оставшейся части логики

Регистр flash_size работает следующим образом:

  • сбрасывается в 0;
  • в состоянии RCV_SIZE при rx_valid равном единице становится равен {flash_size[2:0], rx_data} (сдвигается на 1 байт влево и на освободившееся место ставится очередной пришедший байт);
  • в остальных ситуациях сохраняет свое значение.

Регистр flash_addr почти полностью повторяет поведение flash_size:

  • сбрасывается в 0;
  • в состоянии RCV_NEXT_COMMAND при rx_valid равном единице становится равен {flash_size[2:0], rx_data} (сдвигается на 1 байт влево и на освободившееся место ставится очередной пришедший байт);
  • в остальных ситуациях сохраняет свое значение.

Сигнал core_reset_o равен единице в случае, если состояние конечного автомата не FINISH.

Оставшиеся сигналы (сигналы интерфейса памяти инструкций и памяти данных) работают по схожей логике.

Сигналы памяти инструкций (instr_addr_o, instr_wdata_o, instr_write_enable_o):

  • сбрасываются в ноль
  • в случае состояния FLASH и пришедшего сигнала rx_valid, если значение flash_addr меньше размера памяти инструкций в байтах:
    • instr_wdata_o принимает значение {instr_wdata_o[23:0], rx_data} (справа вдвигается очередной пришедший байт)
    • instr_write_enable_o становится равен (flash_counter[1:0] == 2'b01)
    • instr_addr_o становится равен flash_addr + flash_counter - 1
  • во всех остальных ситуациях instr_wdata_o и instr_addr_o сохраняют свое значение, а instr_write_enable_o сбрасывается в ноль.

Сигналы памяти данных (data_addr_o, data_wdata_o, data_write_enable_o):

  • сбрасываются в ноль
  • в случае состояния FLASH и пришедшего сигнала rx_valid, если значение flash_addr больше либо равно размеру памяти инструкций в байтах:
    • data_wdata_o принимает значение {data_wdata_o[23:0], rx_data} (справа вдвигается очередной пришедший байт)
    • data_write_enable_o становится равен (flash_counter[1:0] == 2'b01)
    • data_addr_o становится равен flash_addr + flash_counter - 1
  • во всех остальных ситуациях data_wdata_o и data_addr_o сохраняют свое значение, а data_write_enable_o сбрасывается в ноль.

Так как вышесказанное по сути является полным описанием работы программатора на русском языке, то фактически задача сводится к переводу текста описания программатора выше с руского на verilog

Интеграция программатора в riscv_unit

../../.pic/Labs/lab_15_programming_device/fig_04.drawio.svg

Рисунок 3. Интеграция программатора в riscv_unit.

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

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

Пример загрузки программы

Чтобы проверить работу программатора на практике необходимо подготовить скомпилированную программу подобно тому, как это делалось в лабораторной работе 13. Подключить интерфейс последовательного порта к компьютеру также, как это делалось в лабораторной работе 12, после чего необходимо запустить скрипт:

# Пример использования скрипта. Указывается программа для записи и последовательный порт, к которому подключается программатор
python3 flash.py ./path/to/program COM3

Порядок выполнения задания

  1. Напишите модуль перезаписываемой памяти инструкций. Данный модуль будет аналогичен памяти данных, только в нем не будет сигнала mem_req_i.
  2. Создайте модуль bluster, используя предоставленный код.
    1. Опишите конечный автомат используя сигналы state, next_state, send_fin, size_fin, flash_fin, next_round.
    2. Реализуйте логику сигналов send_fin, size_fin, flash_fin, next_round.
    3. Реализуйте логику счетчиков size_counter, flash_counter, msg_counter.
    4. Реализуйте логику сигналов tx_valid, tx_data.
    5. Реализуйте логику оставшихся сигналов.
  3. После описания модуля, его необходимо проверить с помощью тестового окружения.
    1. Тестовое окружение находится здесь.
    2. Для запуска симуляции воспользуйтесь этой инструкцией.
    3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_bluster).
    4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
  4. Интегрируйте программатор в модуль riscv_unit.
    1. Обновите память инструкций.
    2. Добавьте модуль программатор.
    3. Подключите программатор к процессорной системе.
      1. Интерфейс памяти инструкций подключается к порту записи обновленной памяти инструкций.
      2. Интерфейс памяти данных мультиплексируется с интерфейсом памяти данных модуля LSU.
      3. Замените сигнал сброса модуля riscv_core сигналом core_reset_o.
      4. В случае если у вас есть периферийное устройство uart_tx его выход tx_o необходимо мультиплексировать с выходом tx_o программатора аналогично тому, как был мультиплексирован интерфейс памяти данных.
  5. После интеграции модуля, его необходимо проверить с помощью тестового окружения.
    1. Тестовое окружение находится здесь.
    2. Для запуска симуляции воспользуйтесь этой инструкцией.
    3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_top_asic).
    4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
  6. Переходить к следующему пункту можно только после того, как вы полностью убедились в работоспособности модуля на этапе моделирования (увидели, что в память инструкций и данных были записаны корректные данные). Генерация битстрима будет занимать у вас долгое время, а итогом вы получите результат: заработало / не заработало, без какой-либо дополнительной информации, поэтому без прочного фундамента на моделировании далеко уехать у вас не выйдет.
  7. Подключите к проекту файл ограничений (nexys_a7_100t.xdc), если тот еще не был подключен, либо замените его содержимое данными из файла к этой лабораторной работе.
  8. Проверьте работу вашей процессорной системы на отладочном стенде с ПЛИС.
    1. Для прошивки процессорной системы используется скрипт flash.py.

Лабораторная работа 16 "Оценка производительности"

Допуск к лабораторной работе

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

  1. Периферийные устройства
  2. Программирование
  3. Программатор

Цель

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

Для оценки производительности необходимо модифицировать существующую процессорную систему, а после собрать и запустить специализированное ПО, отвечающее за измерение производительности (будет использована программа Coremark).

Теория

Coremark (далее кормарк) — это набор синтетических тестов (специальных программ) для измерения производительности процессорной системы. В данный набор входят такие тесты, как работа со связными списками, матричные вычисления, обработка конечных автоматов и подсчет контрольной суммы. Результат выражается в одном числе, которое можно использовать для сравнения с результатами других процессорных систем.

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

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

Кроме того, скомпилированная без оптимизаций программа будет занимать чуть более 32KiB, поэтому нам потребуется изменить размер памяти инструкций.

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

Задание

  1. Реализовать модуль-контроллер "таймер".
  2. Подключить этот модуль к системной шине. 2.1. В случае, если до этого в ЛР13 вашим устройством вывода было не UART TX, вам необходимо подключить к системной шине готовый модуль uart_tx_sb_ctrl.
  3. Добавить реализацию платформозависимых функций программы coremark.
  4. Скомпилировать программу.
  5. Изменить размер памяти инструкций.
  6. Запустить моделирование.
  7. Сравнить результаты измерения производительности с результатами существующих процессорных системам.

Таймер

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

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

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

Таким образом, мы сформировали следующее адресное пространство данного контроллера:

АдресРежим доступаДопустимые значенияФункциональное назначение
0x00R[0:2⁶⁴-1]Значение системного счетчика, доступное только для чтения
0x04RW[0:2⁶⁴-1]Указание задержки, спустя которую таймер будет генерировать прерывание
0x08RW[0:2]Указание режима генерации прерываний (выключен, заданное число раз, бесконечно)
0x0cRW[0:2³²-1]Указание количества повторений генерации прерываний
0x24W1Программный сброс

Прототип модуля следующий:

module timer_sb_ctrl(
/*
    Часть интерфейса модуля, отвечающая за подключение к системной шине
*/
  input  logic        clk_i,
  input  logic        rst_i,
  input  logic        req_i,
  input  logic        write_enable_i,
  input  logic [31:0] addr_i,
  input  logic [31:0] write_data_i,  // не используется, добавлен для
                                     // совместимости с системной шиной
  output logic [31:0] read_data_o,
  output logic        ready_o,
/*
    Часть интерфейса модуля, отвечающая за отправку запросов на прерывание
    процессорного ядра
*/
  output logic        interrupt_request_o
);

Для работы данного контроллера потребуются следующие сигналы:

logic [63:0] system_counter;
logic [63:0] delay;
enum logic [1:0] {OFF, NTIMES, FOREVER} mode, next_mode;
logic [31:0] repeat_counter;
logic [63:0] system_counter_at_start;
  • system_counter — регистр, ассоциированный с адресом 0x00, системный счетчик. Задача регистра заключается в ежетактном увеличении на единицу.
  • delay — регистр, ассоциированный с адресом 0x04. Число тактов, спустя которое таймер (когда тот будет включен) сгенерирует прерывание. Данный регистр изменяется только сбросом, либо запросом на запись.
  • mode — регистр, ассоциированный с адресом 0x08. Режим работы таймера:
    • OFF — отключен (не генерирует прерывания)
    • NTIMES — включен до тех пор, пока не сгенерирует N прерываний (Значение N хранится в регистре repeat_counter и обновляется после каждого сгенерированного прерывания). После генерации N прерываний, переходит в режим OFF.
    • FOREVER — бесконечная генерация прерываний. Не отключится, пока режим работы прерываний не будет изменен.
  • next_mode — комбинационный сигнал, который подается на вход записи в регистр mode (аналог next_state из предыдущей лабораторной работы).
  • repeat_counter — регистр, ассоциированный с адресом 0x0c. Количество повторений для режима NTIMES. Уменьшается в момент генерации прерывания в этом режиме.
  • system_counter_at_start — неархитектурный регистр, хранящий значение системного счетчика на момент начала отсчета таймера. Обновляется при генерации прерывания (если это не последнее прерывание в режиме NTIMES) и при запросе на запись в регистр mode значения не OFF.

Для подключения данного таймера к системной шине, мы воспользуемся первым свободным базовым адресом, оставшимся после ЛР13: 0x08. Таким образом, для обращения к системному счетчику, процессор будет использовать адрес 0x08000000 для обращения к регистру delay 0x08000004 и т.п.

Настройка Coremark

В первую очередь, необходимо скачать исходный код данной программы, размещенный по адресу: https://github.com/eembc/coremark. На случай возможных несовместимых изменений в будущем, все дальнейшие ссылки будут даваться на слепок репозитория, который был на момент коммита d5fad6b.

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

  1. Реализовать функцию, измеряющую время
  2. Реализовать функцию, выводящую очередной символ сообщения с результатами
  3. Реализовать функцию, выполняющую первичную настройку периферии перед тестом
  4. Выполнить мелкую подстройку, такую как количество итераций в тесте и указание аргументов, с которыми будет скомпилирована программа.

Все файлы, содержимое которых мы будем менять расположены в папке barebones.

1. Реализация функции, измеряющей время

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

Данной функцией является barebones_clock, расположенная в файле core_portme.c. В данный момент, в реализации функции описан вызов ошибки (поскольку реализации как таковой нет). Мы должны заменить реализацию функции следующим кодом:

barebones_clock()
{
    volatile ee_u32 *ptr = (ee_u32*)0x08000000;
    ee_u32 tim = *ptr;
    return tim;
}

После ЛР14 вы уже должны представлять, что здесь происходит. Мы создали указатель с абсолютным адресом 0x08000000 — адресом системного счетчика. Разыменование данного указателя вернет текущее значение системного счетчика, что и должно быть результатом вызова этой функции.

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

#define CLOCKS_PER_SEC             10000000

На этом наша задача по измерению времени завершена. Остальные правки будут не сложнее этих.

2. Реализация вывода очередного символа сообщения

Для вывода очередного символа во встраиваемых системах используется (какое совпадение!) функция uart_send_char, расположенная в файле ee_printf.c.

В реализации данной функции вам уже предлагают алгоритм, по которому та должна работать. Необходимо:

  1. дождаться готовности UART к отправке;
  2. передать отправляемый символ;
  3. дождаться готовности UART к отправке (завершения передачи).

Давайте так и реализуем эту функцию:

uart_send_char(char c)
{
    volatile ee_u8 *uart_ptr = (ee_u8 *)0x06000000;
    while(*(uart_ptr+0x08));
    *uart_ptr = c;
    while(*(uart_ptr+0x08));
}

0x06000000 — базовый адрес контроллера UART TX из ЛР13 (и адрес передаваемых этим контроллером данных). 0x08 — смещение до адреса регистра busy в адресном пространстве этого контроллера.

3. Реализация функции первичной настройки

Это функция portable_init, расположена в уже известном ранее файле [core_portme.c]. Данная функция выполняет необходимые нам настройки перед началом теста. Для нас главное — настроить нужным образом контроллер UART. Допустим, мы хотим чтобы данные передавались на скорости 115200, c одним стоповым битом и контролем бита четности. В этом случае, мы должны добавить в начало функции следующий код:

portable_init(core_portable *p, int *argc, char *argv[])
{
    volatile ee_u32 *uart_tx_ptr = (ee_u32 *)0x06000000;
    *(uart_tx_ptr + 3) = 115200;
    *(uart_tx_ptr + 4) = 1;
    *(uart_tx_ptr + 5) = 1;

    //...
}

4. Дополнительные настройки

Для тонких настроек используется заголовочный файл core_portme.h, куда также требуется внести несколько изменений. Нам необходимо:

  1. Объявить в начале файла макрос ITERATIONS, влияющий на количество прогонов теста. Нам достаточно выставить значение 1.
  2. Обновить значение макроса COMPILER_FLAGS, заменив его значение FLAGS_STR на"-march=rv32i_zicsr -mabi=ilp32", именно с этими аргументами мы будем собирать программу. Это опциональная настройка, которая позволит вывести флаги компиляции в итоговом сообщении.
  3. Добавить подключение заголовочного файла #include <stddef.h>.

Компиляция

Для компиляции программы, вам потребуются предоставленные файлы Makefile и linker_script.ld, а также файл startup.S из ЛР14. Эти файлы необходимо скопировать с заменой в корень папки с программой.

Makefile написан из расчёта, что кросс-компилятор расположен по пути C:/riscv_cc/. В случае, если это не так, измените первую строчку данного файла в соответствии с расположением кросс-компилятора.

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

make

В случае, если на вашем рабочем компьютере не установлена утилита make, то вы можете скомпилировать программу вручную, выполнив следующую последовательность команд:

cp barebones/*.c barebones/*.h ./
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 -I"./" core_main.c -o core_main.o
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 -I"./" startup.S -o startup.o
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 -I"./" core_list_join.c -o core_list_join.o
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 -I"./" core_matrix.c -o core_matrix.o
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 -I"./" core_portme.c -o core_portme.o
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 -I"./" core_state.c -o core_state.o
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 -I"./" core_util.c -o core_util.o
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 -I"./" cvt.c -o cvt.o
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i_zicsr -mabi=ilp32 -I"./" ee_printf.c -o ee_printf.o
/c/riscv_cc/bin/riscv-none-elf-gcc core_main.o startup.o core_list_join.o core_matrix.o core_portme.o core_state.o core_util.o cvt.o ee_printf.o -Wl,--gc-sections -nostartfiles -T linker_script.ld -march=rv32i_zicsr -mabi=ilp32 -I"./" -o coremark.elf
/c/riscv_cc/bin/riscv-none-elf-objdump -D coremark.elf > coremark_disasm.S
/c/riscv_cc/bin/riscv-none-elf-objcopy -O verilog --verilog-data-width=4 -j .data -j .sdata -j .bss coremark.elf coremark_data.mem
/c/riscv_cc/bin/riscv-none-elf-objcopy -O verilog --verilog-data-width=4 -j .text coremark.elf coremark_instr.mem
/c/riscv_cc/bin/riscv-none-elf-size coremark.elf
sed -i '1d' coremark_data.mem

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

   text    data     bss     dec     hex filename
  34324    2268     100   36692    8f54 coremark.elf

Изменение размера памяти инструкций

Как видите, размер секции инструкций превышает 32KiB на 1556 байт (32768—34324). Поэтому на время оценки моделирования, нам придется увеличить размер памяти инструкций до 64KiB, изменив число слов памяти инструкций до 16384. При этом необходимо изменить диапазон бит адреса, используемых для чтения инструкции из памяти с [11:2] на [15:2].

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

Запуск моделирования

Программирование 32KiB по UART займет ощутимое время, поэтому вам предлагается проинициализировать память инструкций и данных "по старинке" через системные функции $readmemh.

Если все было сделано без ошибок, то примерно на 276ms времени моделирования вам начнется выводиться сообщение вида:

CoreMark Size    : 666
Total ticks      : 2574834
Total time (secs): <скрыто то получения результатов моделирования>
Iterations/Sec   : <скрыто то получения результатов моделирования>
ERROR! Must execute for at least 10 secs for a valid result!
Iterations       : 1
Compiler version : GCC13.2.0
Compiler flags   : -march=rv32i_zicsr -mabi=ilp32
Memory location  : STACK
seedcrc          : 0x29f4
[0]crclist       : 0x7704
[0]crcmatrix     : 0x1fd7
[0]crcstate      : 0x8e3a
[0]crcfinal      : 0x7704
Correct operation validated. See README.md for run and reporting rules.

(вывод сообщения будет завершен приблизительно на 335ms времени моделирования).

Порядок выполнения задания

  1. Опишите таймер в виде модуля timer_sb_ctrl.
  2. Проверьте описанный модуль с помощью тестового окружения tb_timer.
  3. Подключите timer_sb_ctrl к системной шине. Сигнал прерывания этого модуля подключать не нужно. 2.1 В случае, если до этого в ЛР13 вашим устройством вывода было не UART TX, вам необходимо подключить к системной шине и готовый модуль uart_tx_sb_ctrl.
  4. Получите исходники программы Coremark. Для этого можно либо склонировать репозиторий, либо скачать его в виде архива со страницы: https://github.com/eembc/coremark.
  5. Добавьте реализацию платформозависимых функций программы coremark. Для этого в папке barebones необходимо:
    1. в файле core_portme.c:
      1. реализовать функцию barebones_clock, возвращающую текущее значение системного счетчика;
      2. объявить макрос CLOCKS_PER_SEC, характеризующий тактовую частоту процессора;
      3. реализовать функцию portable_init, выполняющую первичную инициализацию периферийных устройств до начала теста;
    2. в файле ee_printf.c реализовать функцию uart_send_char, отвечающую за отправку очередного символа сообщения о результате.
  6. Добавьте с заменой в корень программы файлы Makefile, linker_script.ld и startup.S.
  7. Скомпилируйте программу вызовом make.
    1. Если кросскомпилятор расположен не в директории C:/riscv_cc, перед вызовом make вам необходимо соответствующим образом отредактировать первую строчку в Makefile.
    2. В случае отсутствия на компьютере утилиты make, вы можете самостоятельно скомпилировать программу вызовом команд, представленных в разделе "Компиляция".
  8. Временно измените размер памяти инструкций до 64KiB.
    1. Для этого необходимо изменить размер памяти инструкций с 1024 слов до 16384 слов.
    2. Кроме того, необходимо изменить используемые индексы адреса в памяти с [11:2] на [15:2].
  9. Проинициализируйте память инструкций и память данных файлами "coremark_instr.mem" и "coremark_data.mem", полученными в ходе компиляции программы.
  10. Выполните моделирование системы с помощью модуля tb_coremark.
    1. Результаты теста будут выведены приблизительно на 335ms времени моделирования.
11. Прочти меня после успешного завершения моделирования

Итак, вы получили сообщение вида:

CoreMark Size    : 666
Total ticks      : 2574834
Total time (secs): 0.257483
Iterations/Sec   : 3.883746
ERROR! Must execute for at least 10 secs for a valid result!
Iterations       : 1
Compiler version : GCC13.2.0
Compiler flags   : -march=rv32i_zicsr -mabi=ilp32
Memory location  : STACK
seedcrc          : 0x29f4
[0]crclist       : 0x7704
[0]crcmatrix     : 0x1fd7
[0]crcstate      : 0x8e3a
[0]crcfinal      : 0x7704
Correct operation validated. See README.md for run and reporting rules.

Не обращайте внимание на строчку "ERROR! Must execute for at least 10 secs for a valid result!". Программа считает, что для корректных результатов, необходимо крутить ее по кругу в течении минимум 10 секунд, однако по большей части это требование необходимо для более достоверного результата у систем с кэшем/предсказателями переходов и прочими блоками, которые могут изменить количество тактов на прохождение между итерациями. Наш однотактный процессор будет вести себя одинаково на каждом круге, поэтому нет смысла в дополнительном времени моделирования.

Нас интересует строка:

Iterations/Sec   : 3.883746

Это и есть так называемый "кормарк" — метрика данной программы. Результат нашего процессора: 3.88 кормарка.

Обычно, для сравнения между собой нескольких реализаций микроархитектур, более достоверной считается величина "кормарк / МГц", т.е. значение кормарка, поделённое на тактовую частоту процессора. Дело в том, что можно реализовать какую-нибудь очень сложную архитектуру, которая будет выдавать очень хороший кормарк, но при этом будет иметь очень низкую частоту. Более того, при сравнении с другими результатами, необходимо учитывать флаги оптимизации, которые использовались при компиляции программы, поскольку они также влияют на результат.

Мы не будем уходить в дебри темных паттернов маркетинга и вместо этого будет оценивать производительность в лоб: сколько кормарков в секунду смог прогнать наш процессор в сравнении с представленными результатами других систем вне зависимости от их оптимизаций.

Таблица опубликованных результатов находится по адресу: https://www.eembc.org/coremark/scores.php. Нам необходимо отсортировать эту таблицу по столбцу CoreMark, кликнув по нему.

Мы получим следующий расклад:

../../.pic/Labs/lab_16_coremark/fig_01.png

На что мы можем обратить внимание? Ну, во-первых, мы видим, что ближайший к нам микроконтроллер по кормарку — это ATmega2560 с результатом 4.25 кормарка. Т.е. наш процессор по производительности схож с микроконтроллерами Arduino.

Есть ли здесь еще что-нибудь интересное? Посмотрим в верх таблицы, мы можем увидеть производителя Intel с их микропроцессором Intel 80286. Как написано на вики, данный микропроцессор был в 3-6 раз производительней Intel 8086, который соперничал по производительности с процессором Zilog Z80, который устанавливался в домашний компьютер TRS-80.

А знаете, с чем был сопоставим по производительности компьютер TRS-80? С бортовым компьютером Apollo Guidance Computer, который проводил вычисления и контролировал движение, навигацию, управлял командным и лунным модулями в ходе полётов по программе Аполлон.

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

Можно ли как-то улучшить наш результат? Безусловно. Мы можем улучшить его примерно на 5% изменив буквально одну строчку. Дело в том, что для простоты реализации, мы генерировали сигнал stall для каждой операции обращения в память. Однако приостанавливать работу процессора было необходимо только для операций чтения из памяти. Если не генерировать сигнал stall для операций типа store, мы уменьшим время, необходимое на исполнение бенчмарка. Попробуйте сделать это сами.

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

Но это, как говорится, уже другая история.

Базовые конструкции языка Verilog

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

  1. Для первой лабораторной работы необходимо разобраться как описывается базовый модуль и комбинационная логика, построенная на непрерывном присваивании. Этому посвящен документ Modules.md.
  2. Для выполнения второй лабораторной работы необходимо уметь писать базовый модуль (см. пункт 1) и описывать такой комбинационный блок, как мультиплексор. Этому посвящен документ Multiplexors.md.
  3. Для выполнения третьей лабораторной работы в дополнение к предыдущим добавляется знание по описанию базовой ячейки памяти — регистру, и способу группировки сигналов (конкатенации). Этому посвящены документы Registers.md и Concatenation.md соответственно.

Для выполнения всех последующих лаб необходимы знания по всем этим документам.

Желаю успехов при подготовке к лабораторным работам!

Описание модулей в SystemVerilog

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

../.pic/Basic%20Verilog%20structures/modules/fig_00.svg

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

Для создания модуля в языке SystemVerilog используются ключевые слова module и endmodule, которые определяют начало и конец модуля, обрамляя его. Можно сказать, что эти ключевые слова являются корпусом нашего устройства, отделяют его содержимое от внешнего мира.

Определим наш модуль:

../.pic/Basic%20Verilog%20structures/modules/fig_01.drawio.svg

module


endmodule

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

../.pic/Basic%20Verilog%20structures/modules/fig_02.drawio.svg

module box();


endmodule

Модуль без входов и выходов (портов) — это просто коробка, которая никак не взаимодействует с внешним миром. Подключим к нему два входных сигнала a, b и один выходной q. Для объявления портов, необходимо указать направление порта (вход это или выход), и тип используемого сигнала. В рамках данного курса лабораторных работ в качестве типа и входов и выходов будет использоваться тип logic, о котором будет рассказано чуть позже.

../.pic/Basic%20Verilog%20structures/modules/fig_03.drawio.svg

module box(
  input  logic a,
  input  logic b,
  output logic q
);


endmodule

Внутри модуля могут быть объявления сигналов, параметров, констант и т.п., о которых другой модуль не узнает. Объявим внутри модуля box провод c.

../.pic/Basic%20Verilog%20structures/modules/fig_04.drawio.svg

module box(
  input  logic a,
  input  logic b,

  output logic q
);

  logic c;

endmodule

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

Подключим провод c ко входу a. Для этого используется конструкция assign c = a;. Такая конструкция называется непрерывным присваиванием. Если очень сильно упростить, то непрерывное присваивание схоже со спайкой двух проводов. После подобного присваивания, провод c всегда будет иметь то же значение, что и a — как только входной сигнал a изменит свое значение, внутренний провод c также изменит свое значение (проводу c будет непрерывно присваиваться значение входа a).

../.pic/Basic%20Verilog%20structures/modules/fig_05.drawio.svg

module box(
  input  logic a,
  input  logic b,

  output logic q
);

  logic c;

  assign c = a;

endmodule

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

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

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

К примеру, мы можем присвоить проводу с значение выхода логического вентиля. Пусть нам нужно, чтобы к сигналу c был подключен результат операции a ИЛИ b.

../.pic/Basic%20Verilog%20structures/modules/fig_06.drawio.svg

Такую схему можно реализовать следующим описанием:

module box(
  input  logic a,
  input  logic b,

  output logic q
);

  logic c;

  assign c = a | b;

endmodule

Пусть в схеме имеется ещё один логический вентиль - Исключающее ИЛИ. На него подаётся результат операции a ИЛИ b, то есть c, а также входной сигнал b. Результат операции c ИСКЛЮЧАЮЩЕЕ ИЛИ b подаётся на выход q нашего модуля.

../.pic/Basic%20Verilog%20structures/modules/fig_07.drawio.svg

module box(
  input  logic a,
  input  logic b,

  output logic q
);

  logic c;

  assign c = a | b;
  assign q = c ^ b;

endmodule

Отлично! Мы научились создавать простейшее описание модуля.

Для завершения базового представления о модулях осталось разобраться с таким понятием как вектор.

Векторы

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

Синтаксис объявления вектора представлен ниже:

<тип> [<старший индекс>:<младший индекс>] имя_вектора

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

Пример:

logic [7:0] sum; // Объявляется 8-битный вектор с именем sum типа logic.
                 // Старший индекс равен 7, младший — 0.

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

фрагмент кодаописание
sum[0];Обращение к младшему биту вектора sum, объявленного выше
sum[7:4];Обращение к старшим четырем битам 8-битного вектора sum, объявленного выше

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

module vector_ex(
  input  logic [3:0] a, // У данного модуля четырехразрядный вход 'a'
  output logic [7:0] b  // и восьмиразрядный выход 'b'.
);

assign b[7:4] = a;      // К старшим четырем битам выхода b подключен вход a
assign b[3:1] = a[2:0]; // К битам с третьего по первый выхода b подключены
                        // биты со второго по нулевой входа a
assign b[0]   = a[3];   // к младшему биту b подключен старший бит a;

endmodule

Иерархия модулей

Модули могут содержать другие модули. Реализуя модуль "Пульт ДУ" можно использовать такие цифровые схемы как "Передатчик ИК-сигнала" и "Контроллер нажатия клавиш". Обе эти цифровые схемы могут быть независимыми модулями, которые объединяются в модуле верхнего уровня.

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

../.pic/Basic%20Verilog%20structures/modules/fig_08.drawio.svg

Опишем inv:

module inv(
  input  logic a,
  output logic d
);

  assign d = ~a;
endmodule

Опишем top:

module top(
  input  logic a,
  input  logic b,
  output logic q
);
  // подключение модуля
  inv invertor_1( // подключаем модуль inv и
                  // даём экземпляру этого модуля
                  // имя invertor_1

    .a(a),        // вход а модуля inv подключаем ко
                  //входу a модуля top

    .d(c)         // выход d модуля inv подключаем к
                  // проводу с модуля top
  );

endmodule

Обратите внимание на то, как подключаются сигналы к вложенному модулю: при подключении после . пишется имя сигнала подключаемого модуля, затем в скобках пишется имя сигнала подключающего модуля. Для лучшего понимания, посмотрите внимательно на схеме на провод c и выход d модуля inv, а также на SystemVerilog-описание этой схемы.

Мы можем подключить сколько угодно экземпляров одного модуля, поэтому у каждого из экземпляра должно быть свое уникальное имя. Пусть c подаётся на логический вентиль И вместе со входом b. Результат операции И тоже пойдет на инвертор, а затем на выход q модуля top.

../.pic/Basic%20Verilog%20structures/modules/fig_09.drawio.svg

Тогда в нашем описании добавится подключение второго модуля inv и провод c.

module inv(
  input  logic a,
  output logic d
);

  assign d = ~a;
endmodule
module top(
  input  logic a,
  input  logic b,
  output logic q
);

  logic c;

  // подключение модуля 1
  inv invertor_1( // подключаем модуль inv и даём ему
                  // имя invertor_1

    .a(a),        // подключаем вход 'а' модуля inv ко
                  // входу 'a' модуля top

    .d(c)         // подключаем выход 'd' модуля inv к
                  // проводу 'с' модуля top
  );

  // подключение модуля 2
  inv invertor_2( // подключаем модуль inv и даём ему
                  // имя invertor_2

    .a(c & b),    // на вход 'а' модуля inv подаём
                  // результат логической операции
                  // "с И b"

    .d(q)         // подключаем выход 'd' модуля inv
                  // к выходу q модуля top
  );

endmodule

Итоги

  1. Ключевым блоком в иерархии цифровой схемы, описанной на языке SystemVerilog является модуль. Модули позволяют выносить части сложной цифровой схемы в отдельные блоки, из которых потом и будет составлена итоговая схема, что сильно упрощает разработку.
  2. Условно, модуль можно разделить на следующие части:
    1. Объявление модуля:
      1. Ключевые слова module / endmodule определяющие границы описания модуля.
      2. Название модуля, следующее за ключевым словом module. Описанный модуль представляет собой отдельный тип, имя которого совпадает с названием модуля.
      3. Указание входов и выходов (портов) модуля, идущих в круглых скобках после названия модуля. Для указания направления порта модуля используются ключевые слова input и output. После указание направления порта следует указать тип порта (в рамках данного курса типом портов всегда будет logic), его разрядность, а затем имя.
    2. Функциональное описание модуля:
      1. Объявление внутренних сигналов модуля (будь то проводов или регистров) с помощью ключевого слова logic.
      2. Создание при необходимости объектов других модулей.
      3. Описание функциональной связи между различными сигналами и объектами внутри описываемого модуля.

Проверь себя

Как, по-вашему, описать нижеприведенную схему на языке описания аппаратуры SystemVerilog?

Обратите внимание, что вход a модуля top является двухразрядным: нулевой его бит идет на вход a модуля or, первый бит идет на вход b модуля or.

../.pic/Basic%20Verilog%20structures/modules/fig_10.drawio.svg

Описание мультиплексора на языке SystemVerilog

Мультипле́ксор — устройство, имеющее несколько сигнальных входов, один или более управляющих входов и один выход. Мультиплексор позволяет передавать сигнал с одного из входов на выход; при этом выбор желаемого входа осуществляется подачей соответствующей комбинации управляющих сигналов[1].

Иными словами, мультиплексор — это переключатель (коммутатор), соединяющий выход с одним из множества входов.

../.pic/Basic%20Verilog%20structures/multiplexor/fig_01.drawio.svg

Для начала создадим простой двухвходовой мультиплексор. Предположим, на Y нам необходимо передать один из сигналов — D0 или D1 в зависимости от значения управляющего сигнала S: когда S==0, на Y подается сигнал D0, в противном случае — D1.

../.pic/Basic%20Verilog%20structures/multiplexors/fig_02.drawio.svg

На языке SystemVerilog это можно описать несколькими способами. Первый — с помощью тернарного условного оператора:

Тернарный условный оператор

О тернарном условном операторе

Операторы бывают различной арности(количества аргументов оператора[операндов]):

  • унарный (с одним операндом), пример: -a;
  • бинарный (с двумя операндами), пример: a+b;
  • тернарный (с тремя операндами), пример: cond ? if_true : false;
  • и др.

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

<условие> ? <значение_если_условие_истинно> : <значение_если_условие_ложно>

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

Пример для языка C++:

a = b+c >= 5 ? b+c : b+d;

Сперва вычисляется первый операнд (выражение b+c >= 5). Если это выражение оказалось истинным (равно единице), то переменной a будет присвоено значение второго операнда (выражения b+c), в противном случае переменной a будет присвоено значение третьего операнда (выражения b+d).

logic Y;
assign Y = S==1 ? D1 : D0;

Данное выражение говорит нам, что если S==1, то Y присваивается значение D1, в противном случае — значение D0.

../.pic/Basic%20Verilog%20structures/multiplexors/fig_03.drawio.svg

Также мультиплексор можно описать через конструкцию if-else в блоке always.

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




Блок always

Блок always — это специальный блок, который позволяет описывать комбинационные и последовательностные схемы, используя более сложные конструкции, такие как if-else, case. На самом деле, в языке SystemVerilog помимо общего блока always, которым можно описать любой вид логики, существует множество специализированных блоков, предназначенных для описания отдельно комбинационной, синхронной и последовательностной асинхронной логики соответственно:

  • always_comb
  • always_ff
  • always_latch

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

В зависимости от вида always-блока используется один из двух видов присваиваний: блокирующее присваивание (=) и неблокирующего присваивания (<=). Подробно о различиях между присваиваниями рассказано в этом документе. До его прочтения запомните:

  • внутри блока always_ff и always_latch необходимо использовать оператор неблокирующего присваивания (<=);
  • внутри блока always_comb необходимо использовать оператор блокирующего присваивания (=).

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



Блок if-else

Реализация мультиплексора через блок if-else похожа на реализацию мультиплексора через тернарный оператор. Если в тернарном операторе управляющий сигнал указывался в качестве первого операнда, отделяемого оператором ?, то в данном блоке управляющий сигнал указывается в скобках после ключевого слова if.

Далее описывается присваивание сигнала, который должен идти на выход при управляющем сигнале равном единице (значение до оператора : в тернарном операторе).

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

logic Y;
always_comb begin // 1) Используется always_comb, т.к. мы хотим подключить
                  // выход мультиплексора к проводу
  if(S) begin     // 2) if-else может находиться только внутри блока always.
    Y = D1;       // 3) Используется оператор блокирующего присваивания.
  end else begin
    Y = D0;
  end
end

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

Неправильно:

logic Y;
always_comb begin
  if(S==1) begin
    Y = D1;
  end
end

always_comb begin
  if(S==0) begin // Нельзя выполнять операцию присваивания
    Y = D0;      // для одного сигнала (Y) в нескольких
  end            // блоках always!
end

Если нарушить это правило, то в будущем (возможно не сразу, но в любом случае — обязательно), возникнет ошибка, которая так или иначе будет связана с multiple drivers.

Будьте очень внимательны при использовании данного блока. Он обманчиво похож на условный блок в языках программирования, из-за чего возникает желание пользоваться им так же, как можно пользоваться условными блоками в языках программирования. Это не так. Обратите внимание на то, что данный блок выше упоминается исключительно как блок if-else. При реализации мультиплексора, у любого блока if должен быть соответствующий блок else, как у тернарного оператора должно быть два выходных операнда. Если не указать блок else при описании мультиплексора, у него будет только один вход, и в итоге на выходе мультиплексора будет сгенерирована защелка. Подробнее о защелках описано здесь.

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

case-блок

Мультиплексор также можно описать с использованием конструкции case. Блок case лучше подходит для описания мультиплексора, когда у того более двух входов (ведь в случае конструкции if-else пришлось бы делать вложенное ветвление).

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

Конструкция case, наряду с if-else, может быть описана только в блоке always.

Реализация двухвходового мультиплексора с помощью case может выглядеть так:

logic Y;
always_comb begin
  case(S)           // Описываем блок case, где значение сигнала S
                    // будет сравниваться с различными возможными его значениями
    1'b0: Y = D0;   // Если S==0, то Y = D0
    1'b1: Y = D1;
  endcase           // Каждый case должен заканчиваться endcase
end                 // (так же как каждый begin должен оканчиваться end)

Рассмотрим вариант посложнее и опишем следующую схему:

../.pic/Basic%20Verilog%20structures/multiplexors/fig_04.drawio.svg

Здесь уже используется мультиплексор 4в1. Управляющий сигнал S в данном случае двухбитный. В блоке case мы перечисляем всевозможные варианты значений S и описываем выход мультиплексора.

module case_mux_ex(
  input  logic        A,
  input  logic        B,
  input  logic        C,
  input  logic        D,
  input  logic [2:0]  S,

  output logic        Y

);
  always_comb begin
    case(S)
      3'b000:  Y = A;
      3'b001:  Y = C | B;      // в блоке case можно мультиплексировать
                               // не только провода, но и логические выражения
      3'b010:  Y = (C|B) & D;
      /*
        Обратите внимание, что разрядность сигнала S — 3 бита.
        Это означает, что есть 8 комбинаций его разрядов.
        Выше было описано только 3 комбинации из 8.
        Если для всех остальных комбинаций на выходе мультиплексора должно
        быть какое-то одно значение "по умолчанию", используется специальная
        комбинация "default":
      */
      default: Y = D;
    endcase
  end
endmodule

Оператор адресации

Представим, что нам необходимо мультиплексировать реально много сигналов. Например, отдельные биты 1024-разрядной шины. Описывать case на 1024 варианта будет сущим безумием. В этом случае, можно будет воспользоваться оператором '[]', который наверняка известен вам как "оператор адресации по массиву" в Си-подобных языках. Работает он интуитивно понятно:

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

В контексте примера по мультиплексированию 1024 бит использование оператора может быть выполнено следующим образом:

logic [1023:0] bus1024;
logic [   9:0] select;

logic          one_bit_result;

assign one_bit_result = bus1024[select];

Реализация мультиплексоров через оператор '[]' будет активно применяться вами при реализации различных памятей.

Итоги

  1. Мультиплексор — это комбинационный блок, подающий на выход один из нескольких входных сигналов.
  2. Мультиплексор можно описать множеством способов, среди них:
    1. использование тернарного условного оператора;
    2. использование конструкции if-else внутри блока always;
    3. использование конструкции case внутри блока always;
    4. использование оператора '[]'.
  3. Во избежание появления защелок при описании мультиплексора, необходимо убедиться что у блоков if есть соответствующие им блоки else, а у мультиплексоров описаны все комбинации управляющего сигнала (при необходимости, множество оставшихся комбинаций можно покрыть с помощью комбинации default). Появление непреднамеренной защелки в дизайне ведет к ухудшению временных характеристик, избыточному использованию ресурсов, а также непредсказуемому поведению схемы из-за возможного удержания сигнала.
  4. Важно отметить, что блоки if-else и case могут использоваться не только для описания мультиплексоров.
  5. Конструкции if-else и case в рамках данных лабораторных работ можно описывать только внутри блока always. При работе с этим блоком необходимо помнить следующие особенности:
    1. Существует несколько типов блока always: always_comb, always_ff, always_latch, определяющих то, к чему будет подключена описанная в этом блоке логика: проводу, регистру или защелке соответственно. В данных лабораторных работах вам нужно будет пользоваться блоками always_ff и always_comb, причем:
      1. внутри блока always_ff необходимо использовать оператор неблокирующего присваивания (<=);
      2. внутри блока always_comb необходимо использовать оператор блокирующего присваивания (=).
    2. Присваивание для любого сигнала возможно только внутри одного блока always. Два разных сигнала могут присваиваться как в одном блоке always, так и каждый в отдельном, но операция присваивания одному и тому же сигналу в двух разных блоках always — нет.

Проверь себя

Как, по-вашему, описать на языке SystemVerilog схему, приведённую ниже?

../.pic/Basic%20Verilog%20structures/multiplexors/fig_05.drawio.svg

Описание регистра на языке SystemVerilog

Перед тем, как описывать память, необходимо научиться описывать отдельные регистры. Регистр — это базовая ячейка памяти, позволяющая хранить состояние, пока на схему подается питание. В современной электронике, регистр чаще всего строится на D-триггерах. В лабораторной работе по АЛУ уже вскользь упоминалось, что как для описания проводов, так и для описания регистров, используется тип logic.

logic reg_name;

../.pic/Basic%20Verilog%20structures/registers/fig_01.drawio.svg

У регистра может быть несколько входов и один выход. Основных входов, без которых не может существовать регистр два: вход данных и вход тактирующего синхроимпульса. На рисунке они обозначены как D и clk. Опциональный вход сигнала сброса (rst) позволяет обнулять содержимое регистра вне зависимости от входных данных и может работать как с тактовым синхроимпульсом (синхронный сброс), так и без него (асинхронный сброс).

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

Выход у регистра один. На рисунке выше он обозначен как Q.

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

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

../.pic/Basic%20Verilog%20structures/registers/fig_02.drawio.svg

Итак, мы добавили регистр на холст схемы, но как соединить его с какой-то логикой? Предположим, у нас есть сигнал тактового синхроимпульса и данные, которые мы хотим записать:

../.pic/Basic%20Verilog%20structures/registers/fig_03.drawio.svg

Данной схеме соответствует код:

modulе rеg_ехаmрlе(
  inрut  logic clk,
  inрut  logic dаtа,
  оutрut logic rеg_dаtа
);

  logic rеg_nаmе;

еndmоdulе

Очевидно, мы хотим подключить сигнал clk ко входу тактирующего сигнала регистра, вход data ко входу данных, а выход регистра к выходу reg_data:

../.pic/Basic%20Verilog%20structures/registers/fig_04.drawio.svg

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

Описание регистра, а также указание фронта и тактирующего сигнала происходит в конструкции always_ff:

аlwауs_ff @(pоsеdgе clk)

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

аlwауs_ff @(pоsеdgе clk)
  rеg_nаmе <= dаtа;
еnd

Обратите внимание на оператор <=. В данном случае, это не знак "меньше либо равно", а оператор неблокирующего присваивания. Существует оператор блокирующего присваивания (=), который меняет способ построения схемы для такого же выражения справа от оператора, однако в данный момент этот оператор останется за рамками курса. Хоть это и плохая практика в обучении, но пока вам надо просто запомнить, что при описании записи в регистр всегда используйте оператор неблокирующего присваивания <=.

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

Таким образом, итоговый код описания данной схемы примет вид:

modulе rеg_ехаmрlе(
  inрut  logic сlk,
  inрut  logic dаtа,
  оutрut logic rеg_dаtа
);

  logic rеg_nаmе;

  аlwауs_ff @(pоsеdgе clk) bеgin
    rеg_nаmе <= dаtа;
  еnd

  аssign reg_data = reg_name;

еndmоdulе

Предположим, мы хотим добавить управление записью в регистр через сигналы enable и reset. Это, например, можно сделать следующим образом:

modulе rеg_ехаmрlе(
  inрut  logic сlk,
  inрut  logic dаtа,
  inрut  logic reset,
  inрut  logic enable,
  оutрut logic rеg_dаtа
);

  logic rеg_nаmе;

  аlwауs_ff @(pоsеdgе clk) bеgin
    if(rеsеt) bеgin
      rеg_nаmе <= 1'b0;
    еnd
    еlse if(enable) bеgin
      rеg_nаmе <= dаtа;
    еnd
  еnd

  аssign rеg_dаtа = rеg_nаmе;

еndmоdulе

Обратите внимание на очередность условий. В первую очередь, мы проверяем условие сброса, и только после этого условие разрешения на запись. Если сперва проверить разрешение на запись, а затем в блоке else описать логику сброса, то регистр не будет сбрасываться в случае, если enable будет равен 1 (запись в регистр будет приоритетней его сброса). Если сброс описать не в блоке else, а в отдельном блоке if, то может возникнуть неопределенное состояние: нельзя однозначно сказать в какой момент придет сигнал reset относительно сигнала enable и что в итоге запишется в регистр. Поэтому при наличии сигнала сброса, остальная логика по записи в регистр должна размещаться в блоке else.

Кроме того, САПР-ы смотрят на паттерн описания элемента схемы, и когда распознают его, реализуют элемент так как задумывал разработчик. Поэтому при описании регистра всегда сперва описывается сигнал сброса (если он используется) и только затем в блоке else описывается вся остальная часть логики записи.

Итоговая схема регистра со сбросом и сигналом разрешения записи:

../.pic/Basic%20Verilog%20structures/registers/fig_05.drawio.svg

Помимо прочего есть еще одно важное правило, которое необходимо знать при описании регистра:

Присваивание регистру может выполняться только в одном блоке always

Даже если вдруг, САПР не выдаст сразу сообщение об ошибке, в конечном итоге, на этапе синтеза схемы она рано или поздно появится в виде сообщения связанного с "multiple drivers".

В блоке присваивания регистру можно описывать и комбинационную логику, стоящую перед ним, например схему:

../.pic/Basic%20Verilog%20structures/registers/fig_06.drawio.svg

можно описать как

modulе rеg_ехаmрlе(
  inрut  logic сlk,
  inрut  logic dаtа,
  input  logic A,
  input  logic B,
  оutрut logic rеg_dаtа
);

  logic rеg_nаmе;

  аlwауs_ff @(pоsеdgе clk) bеgin
    rеg_nаmе <= А & В;
  еnd

  аssign reg_data = reg_name;

еndmоdulе

Однако это всего лишь упрощение. Если вы умеете описывать регистр с подключением к нему всего одного провода на входе данных, вы все равно сможете описать эту схему:

modulе rеg_ехаmрlе(
  inрut  logic сlk,
  inрut  logic А,
  inрut  logic В,
  оutрut logic rеg_dаtа
);

  logic rеg_nаmе;     // Обратите внимание, что несмотря на то, что
  logic аb;           // и reg_name и ab объявлены типом logic,
                      // ab станет проводом, а reg_name — регистром
                      // (из-за непрерывного присваивания на ab, и блока
                      // always_ff для reg_name)
  аssign аb = А & В;

  аlwауs_ff @(pоsеdgе clk) bеgin
    rеg_nаmе <= аb;
  еnd

  аssign reg_data = reg_name;

еndmоdulе

Поэтому так важно разобраться в базовом способе описания регистра.

Более того, с точки зрения синтезатора данное описание проще для синтеза, т.к. ему не разделять из одного always блока комбинационную и синхронные части.

Вообще говоря, регистр в общем смысле этого слова представляет собой многоразрядную конструкцию (в рассмотренном ранее примере, однобитный регистр мог представлять из себя простой D-триггер). Создание многоразрядного регистра мало отличается от создания многоразрядного провода, а описание логики записи в многоразрядный регистр ничем не отличается от логики записи в одноразрядный регистр:

modulе rеg_ехаmрlе(
  inрut  logic        сlk,
  inрut  logic [7:0]  dаtа,
  оutрut logic [7:0]  rеg_dаtа
);

  logic [7:0] rеg_nаmе;

  аlwауs_ff @(pоsеdgе clk) bеgin
    rеg_nаmе <= dаtа;
  еnd

  аssign reg_data = reg_name;

еndmоdulе

Итоги

  1. Регистр — это базовая ячейка памяти, позволяющая хранить состояние, пока на схему подается питание.
  2. Для объявления регистра используется тип logic, при необходимости после типа указывается разрядность будущего регистра.
  3. Для описания логики записи в регистр используется блок always_ff, в круглых скобках которого указывается тактирующий сигнал и фронт, по которому будет вестись запись, а также (в случае асинхронного сброса), сигнал сброса.
  4. Регистр может иметь различные управляющие сигналы: установки/сброса/разрешения на запись. Логика этих управляющих сигналов является частью логики записи в этот регистр и так же описывается в блоке always_ff.
  5. При описании логики записи в регистр, необходимо пользоваться оператором неблокирующего присваивания <=.
  6. Нельзя описывать логику записи в регистр более чем в одном блоке always (иными словами, операция присваивания для каждого регистра может находиться только в одном блоке always).

Проверь себя

Как, по-вашему, описать на языке SystemVerilog схему, приведённую ниже?

../.pic/Basic%20Verilog%20structures/registers/fig_07.drawio.svg

Конкатенация (объединение сигналов)

Конкатенация позволяет присвоить какому-то многоразрядному сигналу "склейку" из нескольких сигналов меньшей разрядности, либо наоборот: присвоить сигнал большей разрядности группе сигналов меньшей разрядности.

Оператор конкатенации выглядит следующим образом: {sig1, sig2, ..., sign}.

Предположим, у нас есть следующий набор сигналов:

../.pic/Basic%20Verilog%20structures/concatenation/fig_01.drawio.svg


logic a;
logic b;
logic [7:0] c;
logic [1:0] d;

logic [5:0] e;

И мы хотим, чтобы на провод e подавались следующие сигналы:

  • на старший бит сигнала e подавался сигнал a
  • на его следующий бит подавался сигнал b
  • на его следующие 2 бита подавались биты [4:3] сигнала c
  • на младшие 2 бита подавался сигнал d

../.pic/Basic%20Verilog%20structures/concatenation/fig_02.drawio.svg

Это можно сделать путем 4 непрерывных присваиваний:

logic a;
logic b;
logic [7:0] c;
logic [1:0] d;

logic [5:0] e;

assign e[5]   = a;
assign e[4]   = b;
assign e[3:2] = c[4:3];
assign e[1:0] = d;

либо через одно присваивание, использующее конкатенацию:

logic a;
logic b;
logic [7:0] c;
logic [1:0] d;

logic [5:0] e;

assign e = {a, b, c[4:3], d};

Кроме того, возможна и обратная ситуация. Предположим, мы хотим подать отдельные биты сигнала e на различные провода:

../.pic/Basic%20Verilog%20structures/concatenation/fig_02.drawio.svg

logic a;
logic b;
logic [7:0] c;
logic [1:0] d;

logic [5:0] e;

assign a      = e[5];
assign b      = e[4];
assign c[4:3] = e[3:2];
assign d      = e[1:0];

Подобную операцию можно так же выполнить в одно выражение через конкатенацию:

logic a;
logic b;
logic [7:0] c;
logic [1:0] d;

logic [5:0] e;

assign {a, b, c[4:3], d} = e;

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

{a, {число_повторений{повторяемый_сигнал}} ,b}

Допустим, мы хотим присвоить какому-то сигналу три копии [4:3] битов сигнала c, после которых идут сигналы a и b. Это можно сделать выражением:

logic a;
logic b;
logic [7:0] c;

logic [7:0] e;

assign e = { {3{c[4:3]}}, a, b};

Защелка

Очень важно при описании мультиплексора с помощью блока case описывать оставшиеся комбинации управляющего сигнала с помощью default (а при использовании блока if — описывать блок else) — в противном случае в вашей схеме может появиться защелка (даже несмотря на то, что для описания защелок в SytemVerilog есть отдельный блок always: always_latch).

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

Защелка — это всего лишь элемент цифровой схемы и будет неправильно говорить о нем в терминах "плохой" или "хороший". Защелка имеет свои плюсы для ASIC-проектирования. Однако защелка совершенно не подходит при проектировании устройств под ПЛИС.

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

always_comb begin
  if(password_is_correct) begin
    open_the_safe <= 1'b1;
  end
end

Вроде бы вы все описали правильно: "если ввели правильный пароль — сейф откроется". Однако проблема в том, что это состояние сохранится, и сейф уже не закроется. Именно поэтому у каждого блока if должен быть свой блок else:

always_comb begin
  if(password_is_correct) begin
    open_the_safe <= 1'b1;
  end
  else begin
    open_the_safe <= 1'b0;
  end
end

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

Ещё один пример:

module unexpected_d_latch_ex(
  input  logic [1:0]  S,
  input  logic        D0,
  input  logic        D1,
  output logic        R
);

always_comb begin
  case(S)
    2'b00: R <= D0;
    2'b01: R <= D1;
    // Поскольку сигнал S двухразрядный, осталось еще две комбинации:
    // S == 2'b10
    // S == 2'b11
  endcase
end


endmodule

../.pic/Basic%20Verilog%20structures/latches/fig_01.png

Рисунок 1. Пример генерации защелки у неполного мультиплексора.

На рис. 1 различные её части обозначены следующим образом:

  1. Мультиплексор, который мы хотели описать
  2. Защелка
  3. Мультиплексор, который был добавлен чтобы генерировать сигнал, "открывающий" защелку
  4. Константная единица (питание)
  5. Константный ноль (земля).

В случае, если S == 0 или S == 1, на выход мультиплексора 3 будет подана единица, которая переведет защелку в "прозрачный" режим (данные с выхода мультиплексора 1 будут проходить сквозь защелку).

В случае, если S > 1, на выход мультиплексора 3 будет подан ноль, который переведет защелку в "непрозрачный" режим (данные с выхода мультиплексора 1 не будут идти сквозь защелку, вместо этого на выходе защелки останутся последние данные, которые шли через нее, пока она была "открыта").

../.pic/Basic%20Verilog%20structures/latches/fig_02.png

Рисунок 2. Пример удержания предыдущих значений защелкой.

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

Таким образом, во избежание появления защелки, необходимо описывать все возможные комбинации в блоке case (при необходимости покрывая множество оставшихся комбинаций с помощью default) и для каждого блока if описывать блоки else. В случае, если подобная комбинация не планируется к использованию, можно присвоить сигналу значение ноль. Конечно, в этом случае будет создана избыточная логика для присваивания ненужного значения, которое никогда не должно произойти (и существуют способы описания аппаратуры, позволяющие этого избежать), но в данном случае это самый простой способ.

О различиях между блокирующими и неблокирующими присваиваниями

Вскоре после начала курса студенты сталкиваются с понятиями "блокирующего" и "неблокирующего" присваивания. Часто объяснения преподавателей по этой теме сопровождаются словами "последовательный" и "параллельный", а также предлагается просто запомнить [1, стр. 2]:

  • при описании последовательностной логики (регистров) используйте неблокирующее присваивание;
  • при описании комбинационной логики используйте блокирующее присваивание.

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

Начать придется издалека. Несмотря на то, что SystemVerilog является языком описания аппаратуры, он так же является и языком для верификации описанной аппаратуры (слово Verilog является объединением двух слов: verification и logic). Для целей верификации в языке выделено целое подмножество конструкций, которые не могут быть использованы для описания аппаратуры — так называемое "несинтезируемое подмножество языка SystemVerilog". Разумеется, часть языка, которая может быть использована для описания аппаратуры ("синтезируемое подмножество языка SystemVerilog") тоже может использоваться в верификации.

Давайте для начала разберемся в том, как будут использоваться операторы присваивания при программном моделировании (так называемой симуляции) — одним из инструментов верификации. Разобравшись в поведении операторов во время симуляции будет куда проще объяснить результат использования операторов при синтезе цифровой схемы.

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

  • под LHS (left hand side) мы будем подразумевать "выражение, которому присваивают";
  • под RHS (right hand side) мы будем подразумевать "выражение которое присваивают".

В выражении a = b+c, a является LHS, b+c является RHS.

два вида присваиваний: непрерывное и процедурное.

module assignment_example(
  input  logic a, b
  output logic c, d
);

// непрерывное присваивание
assign c = a + b;

// процедурное присваивание
always_comb begin
  d = a + b;
end

endmodule

Листинг 1. Пример непрерывного и процедурного присваивания.

С непрерывным присваиванием вы знакомитесь в самом начале — это оператор assign. Непрерывное присваивание постоянно следит за RHS этого оператора, и каждый раз, когда любая часть этого выражения меняет своё значение, производит пересчёт значения RHS, а затем сразу же передает это значение LHS. Если мы произведем assign a = b+c, то каждый раз, когда будет меняться значение b или c, будет пересчитываться результат их суммы, который сразу же будет присвоен выражению a.

Непрерывное присваивание может быть использовано только вне программных блоков.

Под "программными блоками" подразумеваются блоки always (всех типов) и initial. Есть и другие программные блоки, но в рамках данного курса лабораторных работ вы с ними не столкнетесь. Вообще говоря, синтаксис языка SystemVerilog допускает использование оператора assign внутри программного блока (так называемое "процедурное непрерывное присваивание")[2, стр. 232], однако в рамках данного курса не существует ни одной ситуации, когда это может потребоваться и со 100% вероятностью будет ошибочно.

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

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

Блоки initial (их может быть много) исполняются в момент начала моделирования. Блоки always исполняются по событиям указанным в списке чувствительности:

  • always @(posedge clk) будет исполняться каждый раз когда произойдет положительный фронт clk;
  • always @(a,b,c) будет исполняться каждый раз, когда изменится значение любого из сигналов a,b,c;
  • always @(*) будет исполняться каждый раз, когда изменится состояние любой составляющей любого RHS в этом блоке (когда изменится хоть что-то, от чего зависит любое выражение слева от оператора присваивания в этом блоке).

Похожие правила применимы и к остальным блокам always: always_comb, always_ff, always_latch (с некоторыми оговорками, не имеющими значения в рамках данного повествования).

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

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

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

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

Рассмотрим пример, представленный на рис. 1.

../.pic/Basic%20Verilog%20structures/assignments/fig_01.drawio.svg

Рисунок 1. Пример цепочки блокирующих присваиваний.

  1. Сперва вычисляется RHS первого присваивания программного блока — константа 5.
  2. Затем, вычисленное значение записывается в LHS первого присваивания — сигнал a становится равным 5.
  3. Далее вычисляется RHS следующего присваивания — a, которое к этому моменту уже равно 5.
  4. Поскольку вычисленное RHS равняется 5 то LHS второго присваивания (b) тоже становится равным 5.
  5. Аналогичным образом c тоже становится равным 5.

Обратите внимание, что все это произошло в нулевой момент времени. На временной диаграмме Vivado просто отобразится, что все сигналы одновременно стали равны 5, однако с точки зрения симулятора это было не так. Другие симуляторы (например QuestaSim) позволяют настроить временную диаграмму таким образом, чтобы отображались все переходы между присваиваниями.

Посмотрим, как работает аналогичная цепочка неблокирующих присваиваний. Чтобы иллюстрация была более наглядной, предположим, что перед присваиваниями был исполнен какой-то код, который привел a в состояние 3, b в 2, c в 7.

../.pic/Basic%20Verilog%20structures/assignments/fig_02.drawio.svg

Рисунок 2. Пример цепочки неблокирующих присваиваний.

  1. Сперва вычисляется значение RHS первого присваивания (5). Присваивание этого значения откладывается на потом.
  2. Затем вычисляется значение RHS второго присваивания. Поскольку a еще не присвоили значение 5, результатом RHS становится текущее значение a — 3. Присваивание этого значения сигналу b откладывается на потом.
  3. Аналогичным образом вычисляется RHS третьего присваивания (2). Присваивание этого значения также откладывается на потом.

Так называемое "потом" наступает когда завершается вычисление RHS всех неблокирующих присваиваний и завершение присвоений всех блокирующих присваиваний (однако "потом" все равно происходит в тот же момент времени, обратите внимание на значение времени на рис. 2). В стандарте SystemVerilog этот момент называется NBA-region (сокр. от "Non-Blocking Assignment region") [2, стр. 61]. Выполнение отложенных присваиваний происходит в том же порядке, в котором они шли в программном блоке. Подробнее о том как работает событийная симуляция (event based simulation) в SystemVerilog вы можете прочесть в стандарте IEEE 1800-2017 (раздел 4). Стандарт доступен бесплатно всем желающим по программе "IEEE GET Program".

Таким образом, если LHS блокирующего присваивания используется в качестве операнда RHS любого другого последующего присваивания, это выражение будет иметь уже обновленное значение, что очень похоже на "последовательное вычисление".

С другой стороны значение, присвоенное LHS значение с помощью неблокирующего присваивания не может использоваться в качестве операнда RHS последующих присваиваний, что создает иллюзию "параллельного вычисления" (см. рис. 3).

../.pic/Basic%20Verilog%20structures/assignments/fig_03.drawio.svg

Рисунок 3. Иллюстрация блокирующих и неблокирующих присваиваний.

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

Начнем с непрерывного присваивания. Оно превращается в провод, передающий данные от RHS к LHS. При этом вы должны контролировать что к чему вы присваиваете (не путайте местами RHS и LHS).

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

Начнем с исходного примера c цепочкой блокирующих присваиваний, только теперь перепишем его в синтезируемом виде, сохранив изначальную идею.

module example_1(
  input  logic        clk,
  input  logic [31:0] in,
  output logic [31:0] out
);

logic [31:0] a,b,c;

always_ff @(posedge clk) begin
  a = in;
  b = a;
  c = b;
end

assign out = c;

endmodule

Листинг 2. Пример описания модуля, использующего цепочку блокирующих присваиваний.

Если вы уже знакомы с содержимым документа о том, как описывать регистры, подумайте: какой будет результат синтеза у этой схемы?


Давайте "прочитаем" эту схему. Мы видим модуль, с входом in, выходом out и тактирующим синхроимпульсом clk. Также мы видим три сигнала a,b,c, которые описываются в блоке always_ff, предназначенном для описания регистров. Значение in по цепочке этих регистров передается до регистра c, выход которого подключен к выходу out.

Похоже, что здесь был описан сдвиговый регистр, представленный на рис. 4.

../.pic/Basic%20Verilog%20structures/assignments/fig_04.drawio.svg

Рисунок 4. Трехразрядный сдвиговый регистр.

Давайте откроем цифровую схему, сгенерированную Vivado и убедимся в наших выводах.

../.pic/Basic%20Verilog%20structures/assignments/fig_05.png

Рисунок 5. Схема, сгенерированная Vivado по описанию из Листинга 2.

Произошло что-то странное. Вместо трех регистров Vivado создал только один и судя по названию — это последний регистр c. Почему это произошло?

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

Каждое последующее присваивание ожидало, пока не выполнится предыдущее, таким образом, RHS первого присваивания (5) сразу же распространился по всем регистрам. Моделируя Листинг 2 мы получим поведение, когда на вход каждого регистра будет подаваться сигнал in.

Таким образом на самом деле, мы должны были изобразить нашу схему как на рис. 6.

../.pic/Basic%20Verilog%20structures/assignments/fig_06.drawio.svg

Рисунок 6. Схема, описанная Листингом 2.

Но почему тогда на схеме Vivado не осталось регистров a и b? Посмотрим на них внимательней. Их выходы ни на что не влияют, они висят неподключенные. А значит эти регистры не имеют никакого смысла и если их убрать, ничего не изменится.

При генерации схемы, Vivado вывел в Tcl Console следующие предупреждения:

WARNING: [Synth 8-6014] Unused sequential element a_reg was removed.  [example_1.sv:10]
WARNING: [Synth 8-6014] Unused sequential element b_reg was removed.  [example_1.sv:11]

Vivado обнаружил, что регистры a и b ни на что не влияют и удалил их со схемы.

Если вы используете Vivado 2023.1 и новее, вы можете обнаруживать подобные предупреждения более удобным способом — посредством линтера, который можно вызвать из вкладки RTL ANALYSIS окна Flow Navigator.

../.pic/Basic%20Verilog%20structures/assignments/fig_07.png

Рисунок 7. Пример вызова линтера.

Давайте заменим в Листинге 2 блокирующие присваивания на неблокирующие. Напоминаем, что оператор неблокирующего присваивания записывается как <=.

module example_2(
  input  logic        clk,
  input  logic [31:0] in,
  output logic [31:0] out
);

logic [31:0] a,b,c;

always_ff @(posedge clk) begin
  a <= in;
  b <= a;
  c <= b;
end

assign out = c;

endmodule

Листинг 3. Пример описания модуля, использующего цепочку неблокирующих присваиваний.

Посмотрим, какую схему сгенерирует Vivado в этот раз.

../.pic/Basic%20Verilog%20structures/assignments/fig_08.png

Рисунок 8. Схема, сгенерированная Vivado по описанию из Листинга 3.

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

Для этого обратимся к примеру, представленному на рис. 2. В данном примере неблокирующего присваивания сперва вычислялись значения всех RHS (запоминались значения выходов всех регистров) и только потом происходило присваивание новых значений. Подобное поведение аналогично поведению сдвиговых регистров.

Слово "поведение" было выделено дважды неспроста. Описание схем, которое мы сделали называется "поведенческим описанием схемы".

Можно ли реализовать сдвиговый регистр, используя блокирующие присваивания? Конечно. Например, можно поменять порядок присваиваний как в Листинге 4.

module example_3(
  input  logic        clk,
  input  logic [31:0] in,
  output logic [31:0] out
);

logic [31:0] a,b,c;

always_ff @(posedge clk) begin
  c = b;
  b = a;
  a = in;
end

assign out = c;

endmodule

Листинг 4. Цепочка блокирующих присваиваний в порядке обратном приведенному в Листинге 2.

В этом случае, линтер не сообщит ни о каких ошибках, а Vivado сгенерирует схему, аналогичную рис. 8

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

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

Давайте разнесем логику работы каждого регистра по отдельным блокам always.

module example_4(
  input  logic        clk,
  input  logic [31:0] in,
  output logic [31:0] out
);

logic [31:0] a,b,c;

always_ff @(posedge clk) begin
  a = in;
end

always_ff @(posedge clk) begin
  b = a;
end

always_ff @(posedge clk) begin
  c = b;
end

assign out = c;

endmodule

Листинг 5. Сдвиговый регистр, описанный через блокирующие присваивания в отдельных блоках always.

Сгенерированная в Vivado схема будет аналогична рис. 8. Но давайте попробуем промоделировать работу этой схемы, подавая случайные воздействия на вход in.

../.pic/Basic%20Verilog%20structures/assignments/fig_09.png

Рисунок 9. Симуляция модуля, описанного Листингом 5.

Выглядит как-то не по "сдвигово-регистерски". В чем же дело?

Как уже упоминалось ранее, программные блоки (коими являются блоки always) исполняются во время моделирования независимо друг от друга в недетерминированном стандартом порядке. На практике это означает то, что сперва может исполниться второй блок, потом третий, а потом первый — либо в любом другом порядке. Разработчик не может (и не должен) рассчитывать на порядок блоков always при описании схемы.

Конкретно в данной ситуации, симулятор воспроизвел блоки ровно в том порядке, в котором они были описаны. Сперва a получил значение in, потом b получил обновленное значение a, затем c получил обновленное значение b.

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

Если заменить порядок always подобно тому, как мы изменили порядок в Листинге 4, результат на временной диаграмме совпадет с поведением сдвигового регистра.

../.pic/Basic%20Verilog%20structures/assignments/fig_10.png

Рисунок 10. Моделирование поведения сдвигового регистра.

Однако, как уже объяснялось ранее, вы не можете рассчитывать на такой результат. Сегодня симулятор смоделировал поведение одним образом — завтра он смоделирует этот же код (в котором не изменилась ни одна строка) по-другому, и будет по прежнему работать в соответствии со стандартом.

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

Рассмотрим еще один пример того, как различие в присваиваниях приведет к описанию двух различных схем:

module example_5(
  input  logic clk,
  input  logic a, b, c,
  output logic d
);

logic temp;

always_ff @(posedge clk) begin
  temp  = a | b;
  d     = c & temp;
end

endmodule

Листинг 6. Пример цепочки блокирующих присваиваний с комбинационной логикой.

Остановитесь на минуту и подумайте, схему с каким поведением описывает Листинг 6?


Как вы могли догадаться, данный листинг описывает схему с одним регистром d, на вход которого подается результат комбинационной логики c & (a | b), поскольку сперва в temp попадает результат a | b и только после этого вычисляется значение c & temp.

../.pic/Basic%20Verilog%20structures/assignments/fig_11.png

Рисунок 11. Схема, сгенерированная Vivado по описанию из Листинга 6.

Попробуйте догадаться о том, что произойдет, если снова заменить блокирующие присваивания на неблокирующие?


Результат изменится следующим образом.

../.pic/Basic%20Verilog%20structures/assignments/fig_12.png

Рисунок 12. Схема, сгенерированная Vivado по описанию из Листинга 6 после замены блокирующих присваиваний на неблокирующие.

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

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

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

Однако нельзя не заметить, что при использовании блокирующего присваивания, мы "теряли" регистры. Более того, моделирование неблокирующих присваиваний ближе всего по поведению приближено к моделированию регистровой логики [1, стр. 14].

Пока что мы рассматривали только синхронные схемы (схемы, работающие по тактовому синхроимпульсу).

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

module example_6(
  input  logic a, b, c,
  output logic d
);

logic temp;

always_comb begin
  temp  = a | b;
  d     = c & temp;
end

endmodule

Листинг 7. Пример цепочки блокирующих присваиваний в комбинационной схеме.

Обратите внимание на то, что always_ff поменялся на always_comb.

Как вы думаете, какая схема будет сгенерирована по описанию, представленному Листинга 7, и что произойдет с этой схемой, если заменить в нем все блокирующие присваивания на неблокирующие?


Вас может это удивить, но в обоих случаях будет сгенерирована схема, представленная на рис. 13.

../.pic/Basic%20Verilog%20structures/assignments/fig_13.png

Рисунок 13. Схема, сгенерированная Vivado по описанию из Листинга 7.

Девочка остолбенела. У неё возникло отчётливое чувство какой-то ужасной несправедливости по отношению к ней. Гарри Поттер был грязным, отвратительным обманщиком и лжецом. Но во время игры все его ответы были верными. [Элиезер Юдковский / Гарри Поттер и методы рационального мышления]

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

Давайте разберемся по порядку, что же произошло.

Все дело в изменении блока always. Когда мы использовали always_ff @(posedge clk), этот программный блок исполнялся только один раз за такт.

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

Начнем со схемы, построенной по описанию, использующему блокирующее присваивание. В общем-то, тут у вас не должно было возникнуть вопросов, логика ровно та же, что была и при построении схемы по Листингу 6 (рис. 11), только без выходного регистра. Что логично, ведь мы убрали из описания тактирующий сигнал.

Вопрос в том, почему это вдруг схема, построенная после замены блокирующих присваиваний на неблокирующие ведет себя точно так же?

Рассмотрим рис. 14.

../.pic/Basic%20Verilog%20structures/assignments/fig_14.drawio.svg

Рисунок 14. Моделирование цепочки присваиваний в комбинационном блоке always.

Комбинационный блок always начинает исполняться каждый раз, когда операнд любого RHS этого блока меняет своё значение.

Изначально, блок always начал исполняться, когда операнды a, b и c приняли значение 1, 0 и 1 соответственно. В этот момент (поскольку присваивание неблокирующее), вычисляются значения RHS для присваивания сигналам temp и d. Новое значение temp будет 1, но пока что этого не произошло, и temp все ещё в состоянии x, поэтому новым значением d все ещё будет x.

После происходит присваивание вычисленных значений сигналам temp и d.

Однако temp является операндом RHS в выражении d = c & temp, поэтому блок always запускается еще один раз в этот же момент времени (в примере это 5ns). Поскольку значения a и b не менялись, значение RHS первого выражения останется прежним. А вот значение temp уже иное, поэтому RHS второго выражения станет 1.

После повторного присваивания сигналы temp и d примут установившееся значение. Поскольку ни один из операндов RHS—выражений больше не изменил своего значения, блок always больше не вызывается.

Подобное поведение можно сравнить с переходным процессом в комбинационной схеме.

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

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

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

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

Подведем итоги прочитанному:

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

В связи с особенностями поведения блокирующего и неблокирующего присваивания, выведены следующие две максимы:

  • при описании последовательностной логики (регистров) используйте неблокирующее присваивание;
  • при описании комбинационной логики используйте блокирующее присваивание.

Кроме того, существуют следующие рекомендации и требования[1, стр. 5]:

  • При описании как последовательностной логики, так и комбинационной в одном блоке always используйте неблокирующее присваивание.
  • Не смешивайте в одном блоке блокирующие и неблокирующие присваивания — стандарт допускает подобное описание, но оно затрудняет его чтение. Представьте, что читая описание схемы, вам бы постоянно приходилось держать в голове какие присваивания уже произошли, а какие только произойдут, чтобы понять как эта схема работает.
  • Не смешивайте блокирующие и неблокирующие присваивания для одного и того же сигнала — стандарт это запрещает (для блоков always_ff, always_comb, always_latch).

Использованная литература:

  1. Clifford E. Cummings / Nonblocking Assignments in Verilog Synthesis, Coding Styles That Kill
  2. 1800-2017 - IEEE Standard for SystemVerilog--Unified Hardware Design, Specification, and Verification Language

Пример разработки модуля-контроллера периферийного устройства

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

В первую очередь, здесь будет продублирована выдержка из спецификации на этот контроллер (общая часть раздела "Описание контроллеров периферийных устройств", а также подраздел "Светодиоды"):

Спецификация контроллера

Общие термины

  1. Под "запросом на запись по адресу 0xАДРЕС" будет пониматься совокупность следующих условий:
    1. Происходит восходящий фронт clk_i.
    2. На входе req_i выставлено значение 1.
    3. На входе write_enable_i выставлено значение 1.
    4. На входе addr_i выставлено значение 0xАДРЕС
  2. Под "запросом на чтение по адресу 0xАДРЕС" будет пониматься совокупность следующих условий:
    1. На входе req_i выставлено значение 1.
    2. На входе write_enable_i выставлено значение 0.
    3. На входе addr_i выставлено значение 0xАДРЕС

Обратите внимание на то, что запрос на чтение должен обрабатываться синхронно (выходные данные должны выдаваться по положительному фронту clk_i).

При описании поддерживаемых режимов доступа по данному адресу используется интуитивно понятное обозначение:

  • R — доступ только на чтение;
  • W — доступ только на запись;
  • RW — доступ на чтение и запись.

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

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

К примеру, в случае запроса на чтение по адресу 0x0100004 (четвертый байт в адресном пространстве периферийного устройства "переключатели"), на выходе read_data_o должно оказаться значение 32'hdead_beef. В случае отсутствия запроса на чтение (req_i == 0 или write_enable_i == 1), на выходе read_data_o контроллера переключателей должно оказаться значение 32'hfa11_1eaf.

В случае осуществления записи по принятому запросу, необходимо записать данные с сигнала write_data_i в регистр, ассоциированный с адресом addr_i (если разрядность регистра меньше разрядности сигнала write_data_i, старшие биты записываемых данных отбрасываются).

В случае осуществления чтения по принятому запросу, необходимо по положительному фронту clk_i выставить данные с сигнала, ассоциированного с адресом addr_i на выходной сигнал read_data_o (если разрядность сигнала меньше разрядности выходного сигнала read_data_o, возвращаемые данные должны дополниться нулями в старших битах).

Светодиоды

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

module led_sb_ctrl(
/*
    Часть интерфейса модуля, отвечающая за подключение к системной шине
*/
  input  logic        clk_i,
  input  logic        rst_i
  input  logic        req_i,
  input  logic        write_enable_i,
  input  logic [31:0] addr_i,
  input  logic [31:0] write_data_i,
  output logic [31:0] read_data_o,

/*
    Часть интерфейса модуля, отвечающая за подключение к периферии
*/
  output logic [15:0]  led_o
);

logic [15:0]  led_val;
logic         led_mode;

endmodule

Данный модуль должен выводить на выходной сигнал led_o данные с регистра led_val. Запись и чтение регистра led_val осуществляется по адресу 0x00. Запись любого значения, превышающего 2¹⁶-1 должна игнорироваться.

Регистр led_mode отвечает за режим вывода данных на светодиоды. Когда этот регистр равен единице, светодиоды должны "моргать" выводимым значением. Под морганием подразумевается вывод значения из регистра led_val на выход led_o на одну секунду (загорится часть светодиодов, соответствующие которым биты шины led_o равны единице), после чего на одну секунду выход led_o необходимо подать нули. Запись и чтение регистра led_mode осуществляется по адресу 0x04. Запись любого значения, отличного от 0 и 1 должна игнорироваться.

Отсчет времени можно реализовать простейшим счетчиком, каждый такт увеличивающимся на 1 и сбрасывающимся по достижении определенного значения, чтобы продолжить считать с нуля. Зная тактовую частоту, нетрудно определить до скольки должен считать счетчик. При тактовой частоте в 10 МГц происходит 10 миллионов тактов в секунду. Это означает, что при такой тактовой частоте через секунду счетчик будет равен 10⁷-1 (счет идет с нуля).

Обратите внимание на то, что адрес 0x24 является адресом сброса. В случае записи по этому адресу единицы вы должны сбросить регистры led_val, led_mode и все вспомогательные регистры, которые вы создали. Для реализации сброса вы можете как создать отдельный регистр led_rst, в который будет происходить запись, а сам сброс будет происходить по появлению единицы в этом регистре (в этом случае необходимо не забыть сбрасывать и этот регистр), так и создать обычный провод, формирующий единицу в случае выполнения всех указанных условий (условий запроса на запись, адреса сброса и значения записываемых данных равному единице).

Адресное пространство контроллера:

АдресРежим доступаДопустимые значенияФункциональное назначение
0x00RW[0:65535]Чтение и запись в регистр led_val отвечающий за вывод данных на светодиоды
0x04RW[0:1]Чтение и запись в регистр led_mode, отвечающий за режим "моргания" светодиодами
0x24W1Запись сигнала сброса

Реализация схемы контроллера

Для начала, добавим на структурную схему входы и выходы модуля:

../.pic/Basic%20Verilog%20structures/controllers/fig_01.drawio.svg

В первую очередь, спецификация вводит понятия запрос на чтение и запрос на запись. Создадим вспомогательные провода, которые будут сигнализировать о том, что произошел запрос на чтение или запрос на запись:

../.pic/Basic%20Verilog%20structures/controllers/fig_02.drawio.svg

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

../.pic/Basic%20Verilog%20structures/controllers/fig_03.drawio.svg

Теперь, когда подготовительные работы выполнены, начнем с реализации сброса этого контроллера. Сброс может произойти в двух случаях: когда rst_i == 1 либо же в случае запроса на запись единицы по адресу 0x24. Создадим вспомогательный провод rst, который будет равен единице в случае, если произойдет любое из этих событий. Этот сигнал будет сбрасывать все созданные в данном модуле регистры.

../.pic/Basic%20Verilog%20structures/controllers/fig_04.drawio.svg

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

  • произошел запрос на запись;
  • addr_i == 0x00;
  • write_data_i находится в диапазоне [0:65535].

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

../.pic/Basic%20Verilog%20structures/controllers/fig_05.drawio.svg

Теперь реализация регистра lev_val становится совершенно тривиальной задачей, ведь у нас есть:

  • сигнал сброса регистра rst;
  • сигнал разрешения записи в регистр val_en;
  • сигнал данных для записи в регистр write_data_i(из которого мы будем брать только младшие 16 бит данных).

../.pic/Basic%20Verilog%20structures/controllers/fig_06.drawio.svg

Аналогичным образом реализуем еще один архитектурный регистр led_mode:

../.pic/Basic%20Verilog%20structures/controllers/fig_07.drawio.svg

Два этих регистра должны управлять поведением выходного сигнала led_o следующим образом:

  1. В случае led_mode == 0 на выходе led_o должно оказаться значение led_val;
  2. В случае led_mode == 1 на выходе led_o должно циклически меняться значение c led_val на 16'd0 и обратно с периодом в одну секунду.

Для реализации счета времени нам потребуется вспомогательный неархитектурный регистр cntr, который станет простейшим счетчиком со сбросом. Мы знаем, что тактовый сигнал нашей схемы будет работать с периодом в 10 МГц. Если каждый такт инкрементировать счетчик на единицу, то за одну секунду счетчик досчитает до 10 миллионов. Первой мыслью может показаться, что нам нужно, чтобы счетчик считал до 10 миллионов, дойдя до которых он бы сбрасывался в ноль, однако в этом случае у нас будут сложности при дальнейшей реализации. Будет куда удобней, если вместо этого счетчик будет считать до 20 миллионов (полного периода смены значения с led_val на 16'd0 и обратно). В этом случае, нам останется всего лишь добавить условие вывода значения на мультиплексор:

  • пока значение счетчика меньше 10 миллионов, на выходе led_o будет значение led_val
  • в противном случае, на выходе led_o будет значение 16'd0.

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

  • счетчик сбрасывается, в следующих случаях:
    • произошел сброс (rst == 1);
    • произошло отключение "моргания" светодиодов (led_mode == 0);
    • счетчик досчитал до 20 миллионов (cntr >= 32'd20_000_000);
  • в остальных ситуациях, счетчик инкрементирует свое значение.

../.pic/Basic%20Verilog%20structures/controllers/fig_08.drawio.svg

Последним этапом описания контроллера будет добавление логики управления выходным сигналом read_data_o.

На управление этим сигналом наложены следующие требования:

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

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

Таким образом, итоговая схема примет вид:

../.pic/Basic%20Verilog%20structures/controllers/fig_09.drawio.svg

Создание базового проекта с прошивкой ПЛИС в Vivado

Создание проекта в Системе Автоматизированного Проектирования (САПР)

  1. Запустить Vivado 2019.2
  2. Нажать Create Project
  3. В открывшемся окне нажать Next
  4. Ввести название проекта (никаких пробелов и кириллических символов) → Выбрать папку для проектов (создать каталок на D:/) → Поставить галку Create project subdirectory → Нажать Next
  5. Выбрать RTL Project → Поставить галку Do not specify sources at this time → Нажать Next
  6. Выставить фильтры, для сужения списка ПЛИС:
    • Family: Artix 7
    • Package: CSG324,
    • Speed: -1.
Скриншот окна с выставленными фильтрами

../.pic/Vivado%20Basics/Vivado%20trainer/fig_01.png

  1. В списке выбрать ПЛИС xc7a100tcsg324-1 → Нажать Next
  2. Нажать Finish
  3. Закрыть Vivado
  4. Удалить папку
  5. Повторить все действия самостоятельно

Создание модуля на SystemVerilog

  1. Создать новый SystemVerilog файл, для этого в окне Sources нажать на кнопку +
  2. В открывшемся окне выбрать Add or create design source → Нажать Next
  3. Нажать Create File → В открывшемся окне ввести имя модуля top и выбрать тип файла SystemVerilog → Нажать OK → В оставшемся окне нажать Finish
  4. В открывшемся окне НЕ вводить названия портов и сразу нажать OK → После чего подтвердить выбор Yes
  5. Двойным кликов в окне Source открыть файл top.sv
  6. Написать следующий код:
module top (
  input  logic clk,
  input  logic a,
  input  logic b,
  output logic q
);

logic c;

assign c = a ^ b;

always_ff @(posedge clk) begin
 q <= c;
end

endmodule
  1. Сохранить изменения
  2. Нажать Open Elaborated Design
  3. Нажать Schematic в открывшемся списке
  4. Проанализировать полученный результат (сопоставить с SystemVerilog-описанием)
  5. Закрыть проект

Реализация простого проекта на отладочном стенде

  1. Создать новый проект
  2. Создать новый SystemVerilog файл с названием basic
  3. Написать следующий код:
module basic (
  input  logic [15:0] SW,
  output logic [15:0] LED
);

assign LED[0] = SW[0] & SW[1];
assign LED[2] = SW[2] | SW[3];
assign LED[4] = SW[4] ^ SW[5];
assign LED[10:6] = ~SW[10:6];
assign LED[13:11] = {SW[11], SW[12], SW[13]};
assign LED[15:14] = { 2{SW[14]} };

endmodule

  1. Сохранить изменения
  2. В окне Sources нажать на кнопку +
  3. В открывшемся окне выбрать Add or create constraints → Нажать Next
  4. Нажать Create File → В открывшемся окне ввести название → Нажать OKFinish
  5. В окне Source в открывающемся списке Constraints найти только что созданный файл и открыть его для редактирования двойным щелчком
  6. Скопировать содержимое файла констрейнов с официального сайта и вставить в только что созданный → Найти строки посвященные SW и LED и раскомментировать их → Сохранить изменения
  7. Run Synthesis
  8. Run Implementation
  9. После успешной имплементации нажимаем Generate Bitstream для генерации файла прошивки
  10. Аккуратно достаем и подключаем стенд к компьютеру → Включаем питание на плате
  11. Нажимаем Open Hardware Manager (под Generate Bitstream)
  12. Вместо окна Source будет отображаться окно Hardware, в нем необходимо нажать кнопку Auto Connect (единственная активная кнопка) → В окне появится подключенное устройство
  13. Нажать правой кнопкой на устройстве xc7a100t_0 → Выбрать пункт меню Program Device
  14. В открывшемся окне нажать Program
  15. Сопоставить поведение отладочной платы с SystemVerilog-описанием

Окно исходников проекта Vivado

Данный документ расскажет вам об одном из основных окон программы Vivado: Sources. Данное оно расположено в левом верхнем углу. Если вы его не видите, данное окно можно активировать через меню: Window –> Sources. Окно состоит из следующих вкладок:

  1. Hierarchy;
  2. Libraries;
  3. Compile Order.

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

Рассмотрим первые три вкладки.

Иерархия модулей проекта

Рассмотрим рис. 1.

../.pic/Vivado%20Basics/How%20to%20use%20Source%20Window/fig_01.png

Рисунок 1. Окно Sources, открытое на вкладке Hierarchy.

Данная вкладка состоит из четырех "папок":

  1. Design Sources;
  2. Constraints;
  3. Simulation Sources;
  4. Utility Sources.

В рамках текущего курса лабораторных работ мы будем взаимодействовать только с тремя из них.

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

В папке Design Sources строится иерархия проектируемых модулей (реальных схем, которые в будущем могут быть воспроизведены в ПЛИС или заказной микросхеме).

Папка Constraints содержит файлы ограничений, помогающих реализовать проект на конкретной ПЛИС (см. "Этапы реализации проекта в ПЛИС").

Simulation Sources хранит в себе иерархию верификационного окружения, включая модули из папки Design Sources — т.е. все модули (как синтезируемые, так и не синтезируемые), которые будут использованы пр моделировании.

Обратите внимание на то, вкладка Hierarchy не содержит файлов. Здесь отображается иерархия модулей проекта. Один модуль может быть использован несколько раз — и в этом случае он будет столько же раз отображён в иерархии, хотя файл, хранящий описание этого модуля останется один (см. рис. 5).

Допустим, мы создали модуль полного однобитного сумматора fulladder, а также создали модуль полного четырехбитного сумматора fulladder4, содержимое которого мы только планируем описать, подключив внутри него четыре однобитных сумматора.

Раскрыв папку Design Sources мы увидим два модуля – fulladder и fulladder4, которые пока что никак друг с другом не связаны. Двойное нажатие на название модуля приведёт к открытию файла, содержащего описание этого модуля.

../.pic/Vivado%20Basics/How%20to%20use%20Source%20Window/fig_02.png

Рисунок 2. Содержимое папки Design Sources.

Модуль fulladder4 является модулем верхнего уровня (top-level module). Это значит, что при попытке запуска моделирования или синтеза, Vivado будет работать именно с этим модулем. Чтобы сменить модуль верхнего уровня, необходимо нажать правой кнопкой мыши на интересующий модуль и выбрать Set a top.

../.pic/Vivado%20Basics/How%20to%20use%20Source%20Window/fig_03.png

Рисунок 3. Пример смены модуля верхнего уровня.

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

../.pic/Vivado%20Basics/How%20to%20use%20Source%20Window/fig_04.png

Рисунок 4. Обновленное содержимое папки Design Sources.

После раскрытия ветки fulladder4 будет отображено 4 подключенных модуля fulladder.

../.pic/Vivado%20Basics/How%20to%20use%20Source%20Window/fig_05.png

Рисунок 5. Иерархия проекта с четырьмя копиями модуля fulladder.

В Simulation Sources мы видим один файл тестбенча, к которому что-то подключено, и модуль fulladder4 с подключенными к нему другими модулями:

../.pic/Vivado%20Basics/How%20to%20use%20Source%20Window/fig_06.png

Рисунок 6. Иерархия модулей Simulation Sources.

Модули из Design Sources автоматически попадают в Simulation Sources, так как эти модули используются при моделировании.

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

Каждый раз, когда вы меняете что-то в модулях разрабатываемого устройства, это отражается как во вкладке Design Sources, так и в Simulation Sources. Раскроем вкладку с модулем tb:

../.pic/Vivado%20Basics/How%20to%20use%20Source%20Window/fig_07.png

Рисунок 7. Пример иерархии с отсутствующим модулем.

Такая картина говорит нам о попытке подключить модуль, которого нет в проекте. Часто это связано с неправильным указанием подключаемого модуля. В данном случае мы хотим подключить модуль half_adder и Vivado не может его найти.

module tb();

//...

half_adder DUT(
  .A    (a),
  .B    (b),
  .P    (p),
  .S    (s)
);

// ...

endmodule

Переименуем название подключаемого модуля на fulladder4 и сохраним.

module tb();

//...

fulladder4 DUT(
  .A    (a),
  .B    (b),
  .P    (p),
  .S    (s)
);

// ...

endmodule

После обновления в окне Sources модуль fulladder4 "спрячется" под tb. Если раскрыть вкладку, будет видно, что fulladder4 подключен к tb, а четыре модуля fulladder – к fulladder4. Также отметим, что tb является модулем верхнего уровня, значит, если мы захотим запустить симуляцию, то Vivado выполнит симуляцию именно для модуля tb. Изменить модуль верхнего уровня можно так же, как было описано ранее.

../.pic/Vivado%20Basics/How%20to%20use%20Source%20Window/fig_08.png

Рисунок 8. Пример исправленной иерархии верификационного окружения.

После каждого сохранения файла проекта, иерархия проекта будет какое-то время обновляться. Это можно заметить по надписи Updating вверху окна (см. рис 9). Во время обновления иерархии не стоит выполнять операции синтеза или моделирования — это приведет к ошибке.

../.pic/Vivado%20Basics/How%20to%20use%20Source%20Window/fig_09.png

Рисунок 9. Окно Sources во время обновления иерархии проекта.

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

В случае, если произошла такая ошибка, она может быть легко исправлена нажатием правой кнопкой мыши по неправильно расположеному модулю и выбору кнопки: "Move to Design[или Simulation] sources".

Библиотеки проекта

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

Порядок компиляции сущностей проекта

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

Дополнительные материалы

Более подробную информацию по окну Sources вы можете найти в руководстве пользователя Vivado: "Vivado Design Suite User Guide: Using the Vivado IDE (UG893)" (раздел "Using the Sources Window").

Как открыть цифровую схему проекта

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

Сохраняем модуль → Слева на панели управления раскрываем вкладку RTL ANALYSIS → Раскрываем вкладку Open Elaborated Design → Нажимаем на Schematic.

../.pic/Vivado%20Basics/How%20to%20open%20a%20schematic/fig_1.png

Рисунок 1. Расположение кнопки Schematic.

Нажатие на Schematic приведет к появлению окна Elaborate Design, в котором необходимо будет нажать на кнопку OK.

../.pic/Vivado%20Basics/How%20to%20open%20a%20schematic/fig_2.png

Рисунок 2. Окно Elaborate Design.

После нажатия на OK, появится окно Open Elaborated Design, которое автоматически пропадет по завершению процесса. В случае если вы компилируете крупный проект и хотите продолжить работу во время компиляции, вы можете нажать на кнопку Background.

../.pic/Vivado%20Basics/How%20to%20open%20a%20schematic/fig_3.png

Рисунок 3. Окно Open Elaborated Design.

После этого в окне Project Manager появится вкладка Schematic, где вы должны увидеть свою схему:

../.pic/Vivado%20Basics/How%20to%20open%20a%20schematic/fig_4.png

Рисунок 4. Открывшаяся схема модуля.

Обратите внимание, что во вкладках SYNTHESIS и IMPLEMENTATION также есть возможность открыть Schematic. Запуск в них строит схему на основе примитивов ПЛИС (см. "Этапы реализации проекта в ПЛИС"). В рамках лабораторных работ нам будет интересна именно цифровая схема, собранная из логических элементов, которая открывается при нажатии на Schematic во вкладке RTL ANALYSIS.`

Как обновить схему после правок модуля

После правок в модуле, необходимо отобразить обновленную схему. Повторное нажатие на Schematic приведет лишь к открытию ещё одной вкладки со старой версией схемы.

Однако, после изменения модуля вы можете обратить внимание на появление светло-жёлтого уведомления вверху окна Project Manager, где будет сказано о том, что построенный проект устарел, т.к. исходники были изменены, а рядом с ним — кнопку "Reload" (см. рис. 5). Нажатие по этой кнопке приведет к рекомпиляции проекта и открытию обновленной схемы.

../.pic/Vivado%20Basics/How%20to%20open%20a%20schematic/fig_5.png

Рисунок 5. Кнопка повторной загрузки схемы.

Дополнительные материалы

Подробнее о взаимодействии с окном схемы можно прочитать в руководстве пользователя Vivado: "Vivado Design Suite User Guide: Using the Vivado IDE (UG893)" (раздел "Using the Schematic Window").

Инструкция по работе с ошибками элаборации

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

Однако, в результате какого-то из этих действий появляется окно с сообщением о непонятной ошибке:

../.pic/Vivado%20Basics/Elaboration%20failed/simFail.png

Рисунок 1. Пример окна ошибки при попытке запустить моделирование.

Ничего страшного — ошибки, это часть учебного и рабочего процесса.

Смело нажимаем OK, не читая сообщения (и поступаем так же, с еще одним открывшимся окном).

Мы собираемся получить информацию об ошибки из более подробного источника: вкладки Tcl Console.

../.pic/Vivado%20Basics/Elaboration%20failed/err_log.png

Рисунок 2. Элементы Tcl Console, содержащей лог сообщений нашего взаимодействия с Vivado`.

На рис. 2 представлено:

  1. Место последнего запуска (конец синего текста) — лог хранит информацию по всем попыткам запуска моделирования / открытия схемы. Каждая такая попытка будет начинаться с текста синего цвета, а значит по нему можно отслеживать начало нашего лога.
  2. Если попыток запуска было много, навигация по логу может быть осложнена. Для упрощения навигации, можно сворачивать неинтересующие нас попытки через кнопку, обозначенную на рис. 2 меткой 2.
  3. Кроме того, вместо сворачивания ненужных логов, можно очистить от сообщений всю Tcl Console, чтобы начать работу "с чистого листа". Это можно сделать нажав на кнопку с иконкой корзины, обозначенной меткой 3.
  4. Чтение ошибок в логе должно начинаться с самой первой, т.к. одна ошибка может причиной последующих.
  5. Найдя первую ошибку, необходимо внимательно ознакомиться с сообщением этой ошибки (обозначено меткой 5).
  6. Сообщение об ошибке обычно сопровождается номером строки в конкретном файле, вызвавшей эту ошибку.

Чаще всего этого сообщения будет достаточно, чтобы понять в чем дело.

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

Если не помог и он, обратитесь к преподавателю.

Как запустить симуляцию в Vivado

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

Генерация тестовых воздействий, подача их на верифицируемое устройство и модель, сверка результатов и логирование ошибок — все это выполняется средствами верификационного окружения, которое в рамках данных лабораторных работ будет именоваться как "тестбенч". Тестбенчи — это несинтезируемые модули, поэтому они не должны находиться в папке Design Sources, вместо этого для них есть папка Simulation Sources (см. "Окно исходников проекта Vivado").

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

Есть несколько способов запустить симуляцию, рассмотрим два из них:

  1. На панели слева, в разделе SIMULATION, нажать Run SimulationRun Behavioral Simulation.

../.pic/Vivado%20Basics/Run%20Simulation/fig_1.png

Рисунок 1. Запуск симуляции через вкладку SIMULATION окна Flow Navigator.

  1. В иерархии проекта нажать по папке sim_1 правой кнопкой мыши, далее выбрать Run SimulationRun Behavioral Simulation.

../.pic/Vivado%20Basics/Run%20Simulation/fig_2.png

Рисунок 2. Запуск симуляции через контекстное меню папки sim_1 в Simulation Sources.

После запуска симуляции будет отмоделировано определенное количество времени, задаваемое через настройки проекта, после чего моделирование приостанавливается. Если этого времени не хватает для прохождения всех этапов симуляции (если в Tcl Console вы не видите сообщение о завершении моделирования), необходимо нажать кнопку (Run all) на панели инструментов появившегося окна SIMULATION (либо нажать горячую клавишу F3).

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

В случае, не меняли исходный код, но хотите промоделировать модуль заново, вы можете воспользоваться кнопкой Restart (|◀). В этом случае, вы избежите повторной компиляции модулей.

Для закрытия симуляции вы можете кликнуть на крестик окна SIMULATION (бирюзовое), либо нажать правой кнопкой мыши по SIMULATION и выбрать Close Simulation.

../.pic/Vivado%20Basics/Run%20Simulation/fig_3.png

Рисунок 3. Закрытие симуляции через окно Flow Navigator.

Если вы изменили модуль верхнего уровня в Simulation Sources, вам необходимо закрыть текущую симуляцию. Без этого новая не сможет запуститься и будет выдавать ошибку "boost filesystem remove: Процесс не может получить доступ к файлу".

Руководство по поиску и исправлению ошибок в проекте

Цель

При выполнении лабораторных работ вы непременно будете сталкиваться с множеством ошибок. И это нормально: "Не ошибается тот, кто ничего не делает" — © Джейсон Стейтем.
Важно воспитать в себе положительное восприятие обнаружения ошибок (ведь это приводит к улучшению вашего творения). Если относиться к обнаружению ошибок отрицательно, то вы подсознательно будете пытаться найти ошибки спустя рукава, но, если вы "в домике", и ошибок не видите — это не значит, что их нет.

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

Этот документ посвящен практикуму по поискам подобных ошибок в SystemVerilog-коде.

Обратите внимание на то, как ставится ударение в словосочетании "временна́я диаграмма" (не "вре́менная"). В обиходе это словосочетание заменяется словом "времянка".

Алгоритм поиска ошибок

  1. Обычно всё начинается с сообщения в логе тестов (никто не проверяет глазами временную диаграмму сложных проектов, состоящую из тысяч сигналов, меняющихся миллионы раз за микросекунду), но на наших простых лабах, этот шаг иногда может быть и пропущен.
    Сообщение в логе обычно содержит следующую ключевую информацию: имя сигнала, на котором установилось неверное значение, и время когда это произошло. Чем лучше написаны тесты, тем больше ключевой информации будет отражено в сообщении, поэтому написание тестов является своего рода искусством.
  2. Получив имя сигнала и время, мы отправляемся на временную диаграмму и проверяем нашу ошибку. Как это сделать? Необходимо определить по коду, какие сигналы и каким образом управляют нашим сигналом. Вариантов может быть несколько:
    1. Управляющие сигналы имеют корректное значение, но логика, по которой они управляют сигналом неверна, из-за этого на нем возникает неверное значение. Это идеальный случай, при возникновении которого мы сразу же находим причину проблемы и исправляем ее.
    2. Логика управления верна, а какая-то часть управляющих сигналов имеет неверное значение (пусть для примера, неверное значение будет на управляющем сигнале X). Это означает, что обнаруженное несоответствие сигналов является уже следствием какой-то ошибки, и мы должны вернуться к шагу 2, проверяя источники сигналов для сигнала X. Так происходит до тех пор, пока мы не попадаем в тип 1.
    3. Логика управления и значения управляющих сигналов верны. Это самый сложный тип ошибок, который заключается либо в ошибке в спецификации разрабатываемого устройства, либо в САПРе или компонентах, влияющих на его работу. В рамках данного курса вас не должны заботить данные ошибки, и при их возникновении вам стоит обратиться к преподавателю (предварительно убедившись, что ошибка совершенно точно не подходит под первые два варианта).
    4. Любая возможная комбинация всех предыдущих типов.
  3. Обнаружив первопричину ошибки, мы исправляем ее (возможно дополняя набор тестов, или внеся правки в спецификацию), и повторно запускаем все тесты, чтобы убедиться в двух вещах:
    1. ошибка действительно исправлена
    2. исправление ошибки не породило новых ошибок

Давайте отработаем эти шаги на примере отладки ошибок в проекте по вычислению приблизительной длины вектора.

Работа с логом при появлении ошибок

После запуска симуляции мы видим в логе множество ошибок:

../.pic/Vivado%20Basics/Debug%20manual/fig_01.png

Рисунок 1. Пример сообщения об ошибках в тесте.

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

../.pic/Vivado%20Basics/Debug%20manual/fig_02.png

Рисунок 2. Пример конкретной ошибки в тесте.

В логе сказано, что в момент времени 5ns, на дизайн подавались координаты вектора, равные 0 и 0, модель посчитала, что длина вектора равна нулю, в то время как дизайн вернул значение x.

Поиск ошибки на временной диаграмме

Давайте найдем это место на временной диаграмме. Обычно, сразу после запуска симуляции на временной диаграмме отображено место, где симуляция остановилась (возможно с очень неподходящим масштабом). Для начала подгоним масштаб таким образом, чтобы вся временная диаграмма умещалась в окне. Это делается либо нажатием правой кнопкой мыши по в области отображения сигналов, с выбором "Full View" во всплывающем меню, либо нажатием соответствующей кнопки на панели временной диаграммы (см. рис. 4), либо нажатием комбинации клавиш Ctrl+0. Затем найдем приблизительное место рядом с тем временем, что нас интересует, установим там курсор, и приблизим масштаб (покрутив колесиком мыши при зажатой клавише Ctrl), периодически уточняя местоположения курсора, пока не найдем интересующее нас место.

../.pic/Vivado%20Basics/Debug%20manual/fig_03.png

Рисунок 3. Пример временной диаграммы сразу поле остановки моделирования.

../.pic/Vivado%20Basics/Debug%20manual/fig_04.png

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

../.pic/Vivado%20Basics/Debug%20manual/fig_05.png

Рисунок 5. Пример временной диаграммы после подгонки масштаба.

../.pic/Vivado%20Basics/Debug%20manual/fig_06.png

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

../.pic/Vivado%20Basics/Debug%20manual/fig_07.png

Рисунок 7. Временная диаграмма, отмасштабированная к времени ошибки с рис. 2.

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

Открытие файла исходного кода проблемного сигнала

В любом случае, первым делом необходимо определить, источник формирования значения сигнала res. Для этого, откроем файл с исходным кодом, где определен данный сигнал. Для этого, нажмем правой кнопкой мыши по имени сигнала на временной диаграмме, и выберем Go To Source Code:

../.pic/Vivado%20Basics/Debug%20manual/fig_08.png

Рисунок 8. Переход к месту объявления "проблемного" сигнала.

Открывается следующий код (с курсором на строчке wire [31:0] res;):

module tb();

reg [31:0] a;
reg [31:0] b;
wire [31:0] res;

vector_abs dut(
  .x(a),
  .y(b),
  .abs(res)
);
//...

Выделив res мы видим, что у нас подсветился res в строке abs(res). Это означает, что мы завели наш провод внутрь объекта dut модуля vector_abs, и у нас проблема второго типа (X-состояние передалось от выхода abs модуля vector_abs проводу res модуля tb).

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

Добавление сигналов объектов на временную диаграмму

Обратите внимание, что в иерархии окна Scope находятся не имена модулей, а имена сущностей модуля. В приведенном выше листинге кода мы создали сущность модуля vector_abs с именем dut, поэтому в иерархии Scope мы видим внутри модуля верхнего уровня объект dut (не vector_abs), так будет и со всеми вложенными объектами.

Выделим объект dut. В окне Objects справа отобразятся все внутренние сигналы (входы/выходы, внутренние провода и регистры) объекта dut:

../.pic/Vivado%20Basics/Debug%20manual/fig_09.png

Рисунок 9. Отображение внутренних сигналов проверяемого модуля.

Вообще говоря, мы уже видим, что выход abs (к которому подключен наш провод res) находится в X-состоянии, но для отработки навыков, разберемся с добавлением на временную диаграмму. Можно поступить двумя способами:

  1. Добавить все сигналы (то, что видно в окне Objects на временную диаграмму) из окна Scope для этого, либо перетаскиваем нужный нам объект, зажав левую кнопку мыши на временную диаграмму, либо жмем правой кнопкой мыши по нужному объекту, и выбираем Add to Wave Window
  2. Добавить отдельные сигналы из окна Objects. Для этого выделяем их (возможно множественное выделение через модификаторы shift или ctrl), и как и в прошлом случае, либо перетаскиваем сигналы левой кнопкой мыши, либо добавляем их через правую кнопку мыши.

../.pic/Vivado%20Basics/Debug%20manual/fig_10.png

Рисунок 10. Добавление сигналов модуля на временную диаграмму.

../.pic/Vivado%20Basics/Debug%20manual/fig_11.png

Рисунок 11. Результат добавления сигналов модуля на временную диаграмму.

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

Для того чтобы объединить сигналы в группу, необходимо их выделить. Это можно сделать двумя способами:

  1. "прокликав" интересующие сигналы при зажатой клавише Ctrl;
  2. если речь идет о диапазоне сигналов, можно выбрать сигнал с одного края, после чего, при зажатой клавише Shift, выбрать сигнал с другого края этого диапазона.

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

../.pic/Vivado%20Basics/Debug%20manual/fig_12.png

Рисунок 12. Пример создания группы сигналов (контекстное меню было обрезано для удобства отображения).

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

../.pic/Vivado%20Basics/Debug%20manual/fig_13.png

Рисунок 13. Пример созданной группы сигналов.

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

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

Сброс симуляции и ее повтор, установка времени моделирования

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

Для этого, необходимо на панели симуляции нажать кнопку Restart (|◀), а затем кнопку Run all () или Run for (▶t). Положение кнопок в окне Vivado иллюстрирует рис. 14.

../.pic/Vivado%20Basics/Debug%20manual/fig_14.png

Рисунок 14. Расположение кнопок, управляющих моделированием в окне Vivado.

Панель управления симуляции с кнопками:

  1. Restart, горячие клавиши: Ctrl+Shift+F5;
  2. Run all, горячая клавиша: F3;
  3. Run for, горячие клавиши: Shift+F2;
  4. Relaunch Simulation.

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

Run all отличается от Run for тем, что в качестве количества моделируемого времени указывается "бесконечность", и моделирование будет остановлено только вручную, либо вызовом соответствующей инструкции.

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

Кроме того, чтобы курсор и лог снова не ушли далеко от места первой ошибки, можно сразу указать, необходимое нам время моделирования перед выполнением команды Run for: 5ns.

../.pic/Vivado%20Basics/Debug%20manual/fig_15.png

Рисунок 15. Пример моделирования 5ns.

На рис. 16 представлен результат моделирования с новыми сигналами.

../.pic/Vivado%20Basics/Debug%20manual/fig_16.png

Рисунок 16. Результат повторного моделирования после добавления на временную диаграмму новых сигналов.

Видим два сигнала в Z-состоянии и один сигнал в X-состоянии. Обычно, сигналы с Z-состоянием проще всего исправить, т.к. зачастую это забытое или некорректное подключение провода. Кроме того, сигнал, зависящий от сигнала с Z-состоянием, может оказаться в X-состоянии, так что это может быть решением нашей проблемы, поэтому займемся проводами min и min_half. Сперва займемся сигналом min и перейдем к шагу 2 нашего алгоритма (нажимаем правой кнопкой мыши и выбираем Go To Source Code):

 module vector_abs(
   input [31:0] x,
   input [31:0] y,
   output[31:0] abs
 );


 wire [31:0] min;
 wire [31:0] min_half;

 max_min max_min_unit(
   .a(x),
   .b(y),
   .max(max),
   .min(min)
 );

Исправление сигналов с Z-состоянием

Мы видим, что сигнал min подключен к выходу min объекта max_min_unit модуля max_min. Добавим сигналы этого модуля на временную диаграмму. Для этого, необходимо раскрыть список объектов, содержащихся в объекте dut иерархии объектов Scope и выбрать там объект max_min_unit.

../.pic/Vivado%20Basics/Debug%20manual/fig_17.png

Рисунок 17. Добавление сигналов вложенных модулей на временную диаграмму.

Добавляем внутренние сигналы на временную диаграмму, группируем их под именем max_min, и повторяем моделирование.

../.pic/Vivado%20Basics/Debug%20manual/fig_18.png

Рисунок 18. Результат добавления и группировки сигналов подмодуля max_min.

Произошло что-то странное: все внутренние сигналы объекта max_min_unit "зеленые" (не имеющие X или Z состояния), однако подключенный к выходу этого модуля сигнал min находится в Z-состоянии. Как такое могло произойти?

Если присмотреться к сигналу min, находящемуся в Z-состоянии, можно заметить, что младшая цифра находится не в Z-состоянии, а в состоянии 0, такое же значение стоит и на сигнале min объекта max_min_unit. Это интересно.

Если присмотреться к этим двум сигналам еще пристальней, то можно увидеть, что у сигнала min объекта dut разрядность 32 бита, в то время как разрядность сигнала min объекта max_min_unit составляет 4 бита.

Это и является проблемой: мы подключили 4 бита сигнала 4-разрядного сигнала min к младшим 4 битам 32-разрядного сигнала min, а остальные разряды остались не подключенными.

По всей видимости, при написании модуля max_min, была указана неверная разрядность сигнала min, вместо 31 было написано 3. Исправим это и повторим моделирование.

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

../.pic/Vivado%20Basics/Debug%20manual/fig_19.png

Рисунок 19. Результат моделирования после исправления разрядности сигнала min.

В логе сообщается о 102 найденных ошибках. Ровно на одну ошибку меньше, чем было ранее. Это не означает, что в проекте осталось 102 ошибки, только то, что, исправив данную ошибку — мы действительно что-то исправили, и один из тестовых сценариев, который ранее завершался ошибкой, теперь завершился без нее.

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

Посмотрим на нашу временную диаграмму снова, и выберем дальнейшие действия:

../.pic/Vivado%20Basics/Debug%20manual/fig_20.png

Рисунок 20. Временная диаграмма после исправления разрядности сигнала min.

Мы видим, что на временной диаграмме не осталось сигналов в X или Z-состоянии, а значит мы собрали все "низковисящие" улики нашего с вами расследования. Вернемся к месту преступления и попробуем поискать новые улики:

../.pic/Vivado%20Basics/Debug%20manual/fig_21.png

Рисунок 21. Первая ошибка в новом логе моделирования.

Поиск ошибки в сигналах, формирующих проблемный сигнал

Мы видим, что первой ошибкой в логе стала не та ошибка, что была прежде. Раньше первый неверный результат мы видели в момент времени 5ns, когда на дизайн подавались значения 0 и 0, теперь же первой ошибкой стал момент времени 10ns, когда на дизайн подаются значения 1 и 1. Наше устройство считает, что результат должен равняться 3, в то время как модель считает, что результат должен равняться 1. Проверим, нет ли ошибки в модели и посчитаем результат самостоятельно:

Для определения приблизительной длины вектора в евклидовом пространстве(вычисления квадратного корня из суммы квадратов / длины гипотенузы прямоугольного треугольника) можно воспользоваться формулой:

sqrt(a^2 + b^2) ≈ max + min/2, где max и min — большее и меньшее из пары чисел соответственно [Ричард Лайонс: Цифровая обработка сигналов, Глава 13.2, стр. 475].

Подставим наши числа в формулу (поскольку оба числа равны, не важно какое из них будет максимумом, а какое минимумом):

1 + 1/2 = 1.5

Ни модель, ни дизайн не правы?

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

1 + 1/2 = 1 + 0 = 1

Модель правильно отразила особенность нашего устройства и дала корректный результат.

Значит надо смотреть как формируется результат в нашем устройстве, посмотрим на выход abs в модуле vector_abs:

assign abs = max + min_half;

Выход abs зависит от двух внутренних сигналов: max и min_half. В соответствии с нашим алгоритмом, либо проблема в логике, связывающей эти два сигнала (операции сложения), либо в значении какого-то из этих сигналов, либо комбинации этих вариантов.

Изучив модуль, мы понимаем, что в логике этого присваивания проблем нет, т.к. оно повторяет логику формулы max + min/2, складывая максимум с половиной минимума. Значит проблема в значении какого-то из этих сигналов (или обоих из них). Посчитаем значения этих сигналов самостоятельно (для сложного проекта эти значения бы посчитала модель):
1 и 0.

Смотрим, какие значения установлены на сигналах max и min_half в момент времени 10ns.

../.pic/Vivado%20Basics/Debug%20manual/fig_22.png

_Рисунок 22. Значения сигналов max и min_half в момент времени 10 ns

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

Мы видим, что в момент времени 10 ns значения max и min_half изменились ак 1 -> 4 и 2 -> 8 соответственно. Нас интересуют значения 1 и 2, т.к. в момент времени 10ns на выходе дизайна в этот момент был установившийся результат для предыдущих значений (еще не успел посчитаться результат для новых значений).

Значение max=1 совпадает с ожидаемым, в то время как min_half=2 явно нет.

Мы нашли причину неправильного вычисления результата: и правда, 1+2=3, теперь необходимо найти ошибку в вычислении сигнала min_half.

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

module half_divider(
  input [31:0] numerator,
  output[31:0] quotient
);

  assign quotient = numerator << 1'b1;

endmodule

Что делает данный модуль? Он принимает на вход значение и делит его на два. На вход данного модуля будет приходить значение минимума из нашей формулы.

Выход данного модуля зависит от входа numerator и логики сдвига влево на 1. Это значит, что проблема либо в логике, либо в значении, подаваемом на вход. Выведем сигнал numerator на временную диаграмму и посмотрим на его значение в момент времени `10ns.

../.pic/Vivado%20Basics/Debug%20manual/fig_23.png

Рисунок 23. Значение сигнала numerator в момент времени 10 ns.

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

Исправление логики проблемного сигнала

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

Именно поэтому, когда мы в первый раз пытались посчитать результат "на бумаге", у нас было расхождение с моделью: когда мы делим 1 на 2, мы получаем 0.5, однако деление путем отбрасывания цифры округляет результат вниз (1/2=0, 15/10=1).

Как "отбросить" цифру средствами цифровой логики? Для этого используется операция сдвига вправо.

Операция сдвига вправо в SystemVerilog записывается оператором >>. Справа от оператора указывается число "отбрасываемых цифр", в нашем случае одна. Но постойте, в логике присваивания стоит оператор <<. Это ошибка, исправим ее!

Повторяем моделирование.

../.pic/Vivado%20Basics/Debug%20manual/fig_24.png

Рисунок 24. Результат моделирования после исправления оператора сдвига.

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

../.pic/Vivado%20Basics/Debug%20manual/fig_25.png

Рисунок 25. Первая ошибка в повторном моделировании.

Мы продвинулись во времени безошибочного моделирования до 15 ns, начинаем наше расследование с начала:

На вход дизайна подаются значения 3 и 4, дизайн считает, что результатом вычисления max + min/2 будет 2, модель считает, что 5. Посчитаем сами:

max=4
min=3
max + min/2 = 4 + 3/2 = 4 + 1 = 5

И снова модель выдала правильный результат. Разберемся в значениях сигналов, формирующих сигнал abs.

Проблема необъявленных сигналов

К этому моменту на вашей временной диаграмме скорей всего стало уже очень много сигналов. Уберем лишние, оставив только внутренние сигналы модуля vector_abs (для этого выделяем не нужные сигналы, и удаляем их с помощью клавиши Delete).

../.pic/Vivado%20Basics/Debug%20manual/fig_26.png

Рисунок 26. Поведение внутренних сигналов модуля vector_abs на временной диаграмме.

В глаза сразу же бросается, что сигнал max внешне отличается от всех остальных — он ведет себя как однобитный сигнал. Если все остальные сигналы 32-разрядные, то и сигнал max должен быть таким же. Перейдем к объявлению этого сигнала, чтобы это исправить (нажав правой кнопкой мыши, и выбрав Go To Source Code):

 module vector_abs(
   input [31:0] x,
   input [31:0] y,
   output[31:0] abs
 );


 wire [31:0] min;
 wire [31:0] min_half;

 max_min max_min_unit(
   .a(x),
   .b(y),
   .max(max),
   .min(min)
 );
//...

Это странно, курсор был установлен на строку .max(max), хотя раньше в этом случае курсор устанавливался на строку, где объявлялся выбранный сигнал. Но вот в чем дело, если мы просмотрим файл внимательно, то не обнаружим объявления сигнала вовсе. Как так вышло, что мы использовали необъявленный сигнал, а САПР не выдал нам ошибку? Дело в том, что стандарт IEEE 1364-2005 для языка SystemVerilog допускает подобное использование необъявленного сигнала. В этом случае, синтезатор неявно создаст одноименный одноразрядный сигнал, что и произошло.

Для исправления этой ошибки, объявим сигнал max с корректной разрядностью и повторим моделирование.

../.pic/Vivado%20Basics/Debug%20manual/fig_27.png

Рисунок 27. Результат моделирования после объявления пропущенного сигнала.

Самостоятельная работа

Число ошибок сократилось до 40! Мы явно на верном пути. Повторяем предыдущие шаги, вернувшись к первой ошибке:

../.pic/Vivado%20Basics/Debug%20manual/fig_28.png

Рисунок 28. Первая ошибка в повторном моделировании.

В этот раз первая ошибка осталась прежней, только теперь дизайн считает, что результат должен равняться шести (в прошлый раз дизайн выдавал 2). Мы уже убедились, что в этом случае модель дает правильный результат, поэтому сразу перейдем к формирующим результат сигналам:

../.pic/Vivado%20Basics/Debug%20manual/fig_29.png

Рисунок 29. Поведение внутренних сигналов модуля vector_abs на временной диаграмме.

Видим, что значение сигнала min_half, формирующего значение выхода abs неверно (минимумом из 3 и 4 является 3, 3/2 = 1).

Не отходя далеко от кассы, мы замечаем, что значение min, формирующее сигнал min_half неверно: его значение 4, а должно быть 3.

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

Как прошить ПЛИС

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

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

  1. Добавить модуль верхнего уровня (содержащийся в файле с расширением .sv) в Design Sources вашего проекта.
  2. Выберете добавленный модуль в качестве модуля верхнего уровня вашего проекта.
    1. Для этого нажмите по нему правой кнопкой мыши.
    2. В контекстном меню выберете Set as Top.
  3. Добавьте файл ограничений (с расширением .xdc) в Constraints вашего проекта. Если такой файл уже есть в вашем проекте (а он будет в нем уже после первой лабораторной), вам необходимо заменить старого файла содержимым нового. Ограничения меняются от лабы к лабе.

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

Для генерации битстрима вам необходимо нажать на Generate Bitstream во вкладке PROGRAM AND DEBUG окна Flow Navigator (левый нижний угол окна программы).

../.pic/Vivado%20Basics/How%20to%20program%20an%20fpga%20board/fig_1.png

Рисунок 1. Расположение кнопки Generate Bitstream.

После нажатия на эту кнопку, нажимайте утвердительно во всех всплывающих окнах (варианты YES/OK, в зависимости от состояния вашего проекта, число появляющихся окон будет различным). После успешной генерации битстрима откроется окно Bitstream Generation Completed.

Остается прошить ПЛИС. Для этого подключите отладочный стенд к USB-порту компьютера и включите на стенде питание. Затем откройте окно HARDWARE MANAGER для этого:

  1. Убедитесь, что выбран пункт Open Hardware Manager в окне Bitstream и нажмите на OK.
  2. Кликните Open targetAuto ConnectProgram deviceProgram.

../.pic/Vivado%20Basics/How%20to%20program%20an%20fpga%20board/fig_2.png

Рисунок 2. Последовательность действий для прошивки ПЛИС.

После этого появится окно с индикатором реконфигурации ПЛИС. Когда окно закроется, в ПЛИС окажется прототип вашего модуля.

RV32I - Стандартный набор целочисленных инструкций RISC-V

Разделы статьи:

Большая часть данного документа в той или иной степени является переводом спецификации RISC-V[1], распространяемой по лицензии CC-BY-4.0 .

Краткая справка по RISC-V и RV32I

RISC-V — открытая и свободная система набора команд (ISA) на основе концепции RISC. Чтобы понять архитектуру любого компьютера, нужно в первую очередь выучить его язык, понять, что он умеет делать. Слова в языке компьютера называются «инструкциями», или «командами», а словарный запас компьютера — «системой команд»[2, стр. 355].

В архитектуре RISC-V имеется обязательный для реализации минимальный список команд — набор инструкций I (Integer). В этот набор входят различные логические и арифметические операции с целыми числами, работа с памятью, и команды управления. Этого достаточно для обеспечения поддержки компиляторов, ассемблеров, компоновщиков и операционных систем (с дополнительными привилегированными инструкциями). Плюс, таким образом обеспечивается удобный "скелет" ISA и программного инструментария, вокруг которого могут быть построены более специализированные ISA процессоров путем добавления дополнительных инструкций.

Строго говоря RISC-V — это семейство родственных ISA, из которых в настоящее время существует четыре базовые ISA. Каждый базовый целочисленный набор инструкций характеризуется шириной целочисленных регистров и соответствующим размером адресного пространства, а также количеством целочисленных регистров. Существует два основных базовых целочисленных варианта, RV32I и RV64I, которые, соответственно, обеспечивают 32- или 64-битное адресное пространство и соответствующие размеры регистров регистрового файла. На основе базового набора инструкций RV32I существует вариант подмножества RV32E, который был добавлен для поддержки небольших микроконтроллеров и имеет вдвое меньшее количество целочисленных регистров — 16, вместо 32. Разрабатывается вариант RV128I базового целочисленного набора инструкций, поддерживающий плоское 128-битное адресное пространство. Также, стоит подчеркнуть, что размеры регистров и адресного пространства, во всех перечисленных стандартных наборах инструкций, не влияют на размер инструкций — во всех случаях они кодируются 32-битными числами. То есть, и для RV32I, и для RV64I одна инструкция будет кодироваться 32 битами. Базовые целочисленные наборы команд используют представление знаковых целых чисел в дополнительном коде.

В рамках дисциплины АПС затрагивается только RV32I, то есть стандартный набор целочисленных инструкций, предусматривающий в процессоре регистровый файл из 32-х 32-битных регистров, и использующий 32-битное адресное пространство памяти.

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

  • M — Целочисленное умножение и деление (Integer Multiplication and Division)
  • A — Атомарные операции (Atomic Instructions), инструкции для атомарного чтения-изменения-записи в память для межпроцессорной синхронизации
  • F — Стандартное расширение для арифметических операций с плавающей точкой одинарной точности (Single-Precision Floating-Point) добавляет регистры с плавающей точкой, инструкции вычислений с одинарной точностью, а также инструкции загрузки и сохранения в регистровый файл для чисел с плавающей точкой
  • D — Стандартное расширение с плавающей точкой двойной точности (Double-Precision Floating-Point) расширяет регистры с плавающей точкой до 64 бит и добавляет инструкции вычислений с двойной точностью, загрузку и сохранение
  • C — Набор сжатых инструкций (Compressed Instructions), позволяющий кодировать инструкции 16-битными словами, что позволяет уплотнить программный код (если одну и ту же программу можно писать 16-битными словами вместо 32-битных, значит её размер сократится в 2 раза). Разумеется, у такого уплотнения есть своя цена, иначе инструкции просто кодировали бы 16-ю битами вместо 32. У сжатых инструкций меньший диапазон адресов и констант.
  • Zicsr — Инструкции для работы с контрольными и статусными регистрами (Control and Status Register (CSR) Instructions). Используется, например, при работе с прерываниями/исключениями и виртуальной памятью
  • Zifencei — Инструкции синхронизации потоков команд и данных (Instruction-Fetch Fence)

Поддерживаемые процессором команды отражаются в названии набора инструкций. Например, RV64IMC это архитектура RISC-V с 64-битными регистрами и 64-битным адресным пространством, поддерживающая кроме стандартных целочисленных операций умножение и деление M, и может выполнять сжатые инструкции C.

Одной из целей проекта RISC-V является его использование в качестве стабильного объекта для разработки программного обеспечения. Для этого ее разработчики определили комбинацию базового ISA (RV32I или RV64I) и некоторых стандартных расширений (IMAFD + Zicsr + Zifencei) как "general-purpose" ISA (набор инструкций общего назначения), а для комбинации расширений набора команд IMAFDZicsrZifencei стали использовать аббревиатуру G. То есть RV32G это тоже самое, что и RV32IMAFDZicsrZifencei.

Чтобы устройство управления понимало, когда оно имеет дело с набором сжатых команд C, то есть с 16-битными инструкциями, а когда с другими наборами команд, то есть с инструкциями длиной 32 бита, каждая 32-битная инструкция в младших битах имеет 11. Если в двух младших битах что-то отличное от 11, значит это 16-битная инструкция!

На рисунке ниже показана видимая пользователю структура для основного подмножества команд для целочисленных вычислений RV32I. Она содержит регистровый файл, состоящий из 31 регистра общего назначения x1x31, каждый из которых может содержать целочисленное значение, и регистра x0, жестко привязанного к константе 0. В случае RV32, регистры xN, и вообще все регистры, имеют длину в 32 бита. Также есть АЛУ, выполняющее операции над данными в регистровом файле (концепция RISC - load&store), и память с побайтовой адресацией и шириной адреса 32 бита.

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

../.pic/Labs/rv_model.png

RISC-V является load&store архитектурой (все операции с числами выполняются над данными только в регистровом файле), поэтому глядя на рисунок выше можно легко заключить, что функционально все инструкции сводятся к трём типам:

  • Операции на АЛУ над числами в регистровом файле
  • Операции обмена данными между регистровым файлом и памятью
  • Манипуляции с pc (другими словами — управление программой)

Как было сказано ранее, память имеет 32-битную шину адреса и имеет побайтовую адресацию. Это значит, что каждый из 232 байт памяти имеет свой уникальный адрес, по которому к нему можно обратиться, чтобы считать из него или записать в него новую информацию. Однако, инструкции кодируются 32-битными числами, а один байт это всего 8 бит, значит одна инструкция занимает сразу 4 адреса в памяти. Подразумевается, что из такой памяти можно читать одновременно из нескольких последовательных адресов, то есть устройство управления процессора сообщает памяти начальный адрес требуемой ячейки, и количество ячеек (одну, две или четыре), которые нужно прочитать или записать.

Одна ячейка называется байт - 8 бит. Две последовательные 8-битные ячейки называются полуслово - 16 бит. Четыре последовательные 8-битные ячейки называются словом - 32 бита. Например, если процессор собирается выполнить инструкцию, которая занимает четыре байта по адресам 0x00000007 — 0x00000004, то он обращается к памяти, сообщая, что "нужны 4 байта начиная с адреса 0x00000004", взамен процессор получает 32-битное число — инструкцию, которая была слеплена из байт, хранящихся в памяти по адресам: 4, 5, 6 и 7, для данного примера. К памяти также можно обратиться за полусловом или за байтом. Предполагается реализация выровненного доступа к памяти, то есть адреса слов и полуслов должны быть кратны 4 и 2, соответственно.

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

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

В архитектуре RISC-V каждая несжатая инструкция представлена 32-разрядным словом. Микропроцессоры — это цифровые системы, которые читают и выполняют команды машинного языка. Для людей чтение и разработка компьютерных программ на машинном языке представляются нудным и утомительным делом, поэтому мы предпочитаем представлять инструкции в символическом формате, который называется языком ассемблера[2, стр. 356]. Ассемблер позволяет выполнить взаимно однозначный переход от машинного кода к тестовому и обратно.

RV32I

В таблице ниже приводятся 40 команд стандартного набора целочисленных инструкций RV32I: мнемоники языка ассемблера, функции, описания, форматы кодирования и значения соответствующих полей при кодировании. В RISC-V предусмотрено несколько форматов кодирования инструкций (следующий рисунок, еще ниже), то есть договоренность какая информация в каком месте 32-битной инструкции хранится и как она представлена. У всех операций есть поле opcode (operation code - код операции), в котором закодировано "что нужно сделать". По полю opcode устройство управления понимает, что требуется сделать процессору и каким именно способом закодирована инструкция (R, I, S, B, U или J). В 32-битных инструкциях два младших бита всегда равны 11.

Почти все инструкции имеют поле Func3, и некоторые — поле Func7. Их названия определены их разрядностью: 3 и 7 бит, соответственно. В этих полях, если они есть у инструкции, закодировано уточнение операции. Например, код операции 0010011 указывает на то, что будет выполняться некоторая операция на АЛУ между значением из регистрового файла и константой. Поле Func3 уточняет операцию, для данного примера, если оно будет равно 0x0, то АЛУ выполнит операцию сложения между значением из регистра и константой из инструкции. Если Func3 равно 0x6, то будет выполнена операция "логическое ИЛИ".

../.pic/Labs/lab_05_decoder/rv32i_summary.png

Обратите внимание на операции slli, srli и srai (операции сдвига на константную величину). У этих инструкций немного измененный формат кодирования I*. Формат кодирования I предоставляет 12-битную константу. Сдвиг 32-битного числа более, чем на 31 не имеет смысла. Для кодирования числа 31 требуется всего 5 бит. Выходит, что из 12 бит константы используется только 5 бит для операции сдвига, а оставшиеся 7 бит – не используются. А, главное (какое совпадение!), эти 7 бит находятся ровно в том же месте, где у других инструкций находится поле Func7. Поэтому, чтобы у инструкций slli, srli и srai использующих формат I не пропадала эта часть поля, к ней относятся как к полю Func7.

На рисунке ниже приводится фрагмент из оригинальной спецификации RISC-V. Сверху приводятся 6 форматов кодирования инструкций: R, I, S, B, U и J, а ниже приводятся конкретные значения полей внутри инструкции. Под rd подразумевается 5-битный адрес регистра назначения, rs1 и rs2 - 5-битные адреса регистров источников, imm — константа, расположение и порядок битов которой указывается в квадратных скобках. Обратите внимание, что в разных форматах кодирования константы имеют различную разрядность, а их биты упакованы по-разному. Для знаковых операций константу предварительно знаково расширяют до 32 бит. Для беззнаковых расширяют нулями до 32 бит.

../.pic/Labs/lab_05_decoder/rv32i_BIS.png

Ниже, для наглядности, приводится пример кодирования пары инструкций из книги Харриса и Харриса "Цифровая схемотехника и архитектура компьютера" в машинный код[2, стр. 368].

../.pic/Other/rv32i/example_instr_code.png

Примечание: s2, s3, s4, t0, t1, t2 — это синонимы регистров x18,x19,x20,x5,x6,x7 соответственно. Введены соглашением о вызовах (calling convention) для того, чтобы стандартизировать функциональное назначение регистров. Подробнее об этом будет в лабораторной работе по программированию.

Псевдоинструкции

В архитектуре RISC-V размер команд и сложность аппаратного обеспечения минимизированы путем использования лишь небольшого количества команд. Тем не менее RISC-V определяет псевдокоманды, которые на самом деле не являются частью набора команд, но часто используются программистами и компиляторами. При преобразовании в машинный код псевдокоманды транслируются в одну или несколько команд RISC-V[2, стр. 410]. Например, псевдокоманда безусловного перехода j, преобразуется в инструкцию безусловного перехода с возвратом jal с регистром x0 в качестве регистра-назначения, то есть адрес возврата не сохраняется.

../.pic/Other/rv32i/pseudo.png

Основные типы команд

В основе ISA лежит четыре основных типа команд (R/I/S/U), которые изображены на рисунке ниже. Все они имеют фиксированную длину в 32 бита и должны быть выровнены в памяти по четырехбайтовой границе. Если адрес перехода (в случае безусловного перехода, либо успешного условного перехода) не выровнен, генерируется исключение о невыровненном адресе инструкции. Исключение не генерируется в случае невыполненного условного перехода.

../.pic/Other/rv32i/RISU.png

Для упрощения декодирования, архитектура команд RISC-V сохраняет положение адресов регистров-источников (rs1 и rs2) и регистра назначения (rd) между всеми типами инструкций.

За исключением 5-битных непосредственных операндов, используемых в командах CSR, все непосредственные операнды (imm) проходят знаковое расширение. Для уменьшения сложности оборудования, константа размещается в свободные (от полей func3/func7/rs1/rd) биты инструкции, начиная от левого края. В частности, благодаря этому ускоряется схема знакового расширения, поскольку знаковый бит всех непосредственных операндов всегда находится в 31-ом бите инструкции.

Способы кодирования непосредственных операндов

Существует еще два формата кодирования констант в инструкции (B/J-типа), представленные на рисунке ниже.

Единственное различие между форматами S и B заключается в том, что в формате B, 12-битная константа используется для кодирования кратных двум смещений адреса при ветвлении (примечание: кратность двум обеспечивается сдвигом числа на 1 влево). Вместо того, чтобы сдвигать непосредственный операнд относительно всех бит инструкции на 1 влево, средние биты (imm[10:1]) и знаковый бит остаются в прежних местах, а оставшийся младший бит константы формата S (inst[7]) кодирует imm[11] бит константы в формате B.

Аналогично, единственное различие между форматами U и J состоит в том, что в формате U 20-разрядная константа сдвигается влево на 12 бит, в то время как в формате J — на 1. Расположение бит в непосредственных значениях формата U и J выбирались таким образом, чтобы максимально увеличить перекрытие с другими форматами и между собой.

../.pic/Other/rv32i/BJ.png

На рисунке ниже показаны непосредственные значения (константы), создаваемые каждым из основных форматов команд, также они помечены, чтобы показать, какой бит команды (inst[y]) какому биту непосредственного значения соответствует.

../.pic/Other/rv32i/ISBUJ.drawio.svg

Знаковое расширение — одна из самых важных операций над непосредственными значениями (особенно в RV64I). Поэтому в RISC-V знаковый бит всех непосредственных значений всегда содержится в 31-м бите инструкции. Это позволяет выполнять знаковое расширение параллельно с декодированием команды.

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

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

Команды для целочисленных вычислений

Большинство инструкций целочисленных вычислений работают с 32-битными значениями, хранящимся в регистровом файле. Такие команды либо кодируются как операции константа-регистр, используя формат I-типа, либо как операции регистр-регистр, используя формат R-типа. В обоих случаях результат сохраняется в регистр rd . Ни одна инструкция целочисленных вычислений не вызывает арифметических исключений.

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

add t0, t1, t2
bltu t0, t1, overflow

Для знакового сложения, если известен знак одного операнда, проверка на переполнение требует только одного ветвления после сложения:

addi t0, t1, +imm;
blt t0, t1, overflow.

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

add t0, t1, t2
slti t3, t2, 0
slt t4, t0, t1
bne  t3,  t4, overflow

В RV64 проверки 32-разрядных знаковых сложений могут быть дополнительно оптимизированы путем сравнения результатов выполнения команд ADD и ADDW для каждого из операндов.

Команда типа константа-регистр

../.pic/Other/rv32i/addi_andi.png

ADDI суммирует знакорасширенную 12-битную константу с регистром rs1. Арифметическое переполнение игнорируется, и результатом являются младшие 32 бита результата. Команда ADDI rd, rs1, 0 используется для реализации ассемблерной псевдоинструкции MV rd, rs1.

SLTI (установить, если меньше чем константа) помещает значение 1 в регистр rd, если регистр rs1 меньше, чем расширенное непосредственное значение, когда оба значения обрабатываются как знаковые числа, иначе в rd записывается 0. SLTIU аналогична, но сравнивает значения как беззнаковые числа (то есть непосредственное значение сначала расширяется до 32 бит, а затем обрабатывается как число без знака). Обратите внимание, что команда SLTIU rd, rs1, 1 устанавливает rd в 1, если rs1 равен нулю, в противном случае rd устанавливается в 0 (псевдоинструкция ассемблера SEQZ rd, rs).

Примечание: у студентов часто возникает вопрос: зачем вообще нужны инструкции вида SLT, если есть инструкции вида BLT? Например, они могут использоваться для вычисления сложных условий переходов. Один из примеров таких условий вы видели выше, в примере обработке результата сложения на переполнение. Кроме того, не смотря на ограниченность этих инструкций (все они проверяют только на строго меньше), мы можем добиться операции строго больше поменяв операнды местами, а если результат обоих операций даст 0 — значит операнды равны. Поскольку идея RISC архитектуры в том, чтобы переложить организацию всех этих ухищрений на компилятор, этих инструкций оказывается достаточно.

ANDI, ORI, XORI — это логические операции, которые выполняют побитовое И, ИЛИ и исключающее ИЛИ над регистром rs1 и непосредственным 12-битным значением с знаковым расширением и помещают результат в rd. Обратите внимание, что команда XORI rd, rs, -1 выполняет побитовую логическую инверсию значения регистра rs1 (псевдоинструкция NOT rd, rs).

../.pic/Other/rv32i/slli_srli_srai.png

Сдвиги на константу кодируются как разновидность формата команд I-типа. Операнд, который должен быть сдвинут, находится в rs1, а величина сдвига кодируется в младших 5 битах поля непосредственного значения. Тип сдвига вправо определяется 30-ым битом. SLLI - логический сдвиг влево (нули задвигаются в младшие биты); SRLI - логический сдвиг вправо (нули задвигаются в старшие биты); SRAI - арифметический сдвиг вправо (исходный знаковый бит задвигается в старшие биты).

../.pic/Other/rv32i/lui_auipc.png

LUI (загрузка старшей части непосредственного значения) используется для получения 32-битных констант и использует формат U-типа. LUI помещает непосредственное значение U-типа в старшие 20 бит регистра назначения rd, заполняя младшие 12 бит нулями. AUIPC (прибавить старшую часть непосредственного значения к pc) используется для построения адресов относительно pc, и использует формат U-типа. AUIPC формирует 32-битное смещение из 20-битного непосредственного значения U-типа, заполняя младшие 12 битов нулями, прибавляет это смещение к значению pc, а затем размещает результат в регистре rd.

Команда AUIPC поддерживает последовательности из двух команд для получения произвольных смещений pc как для передачи потока управления, так и для доступа к данным. Комбинация AUIPC и 12-битного непосредственного значения в JALR может передавать управление на любой 32-битный адрес pc, в то время как AUIPC сложенное с 12-битным непосредственным значением смещения в обычных командах загрузки или сохранения позволяет получить доступ к любому 32-битному адресу данных относительно pc. Текущее значение pc можно получить, установив непосредственное значение U-типа в 0. Несмотря на то, что команда JAL+4 также позволяет получить значение pc, она может вызывать остановки конвейера в более простых микроархитектурах или засорять структуры буфера предсказания переходов (BTB) в более сложных микроархитектурах.

Команды типа регистр-регистр

В RV32I определено несколько арифметических операций R-типа. Все операции берут исходные операнды из регистров rs1 и rs2 и записывают результат в регистр rd. Полями funct7 и funct3 задается тип операции.

../.pic/Other/rv32i/add_and_sll_sub.png

ADD и SUB выполняют сложение и вычитание соответственно. Переполнения игнорируются, и младшие 32 бита результатов записываются в место назначения. SLT и SLTU выполняют знаковое и беззнаковое сравнения соответственно, записывая 1 в rd, если rs1 < rs2, или 0 в противном случае. Обратите внимание, что команда SLTU rd, x0, rs2 устанавливает rd в 1, если rs2 не равно нулю, иначе устанавливает rd в ноль (псевдоинструкция ассемблера SNEZ rd, rs). AND, OR и XOR выполняют побитовые логические операции.

SLL, SRL и SRA выполняют соответственно логический сдвиг влево, логический сдвиг вправо и арифметический сдвиг вправо значения в регистре rs1 на величину сдвига, содержащуюся в младших 5 битах регистра rs2.

Команда NOP

../.pic/Other/rv32i/nop.png

Инструкция NOP не изменяет архитектурное состояние процессора, за исключением увеличения pc и опциональных счетчиков производительности. NOP кодируется как ADDI x0, x0, 0.

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

Список использованной литературы

  1. RISC-V Instruction Set Manual
  2. Д.М. Харрис, С.Л. Харрис / Цифровая схемотехника и архитектура компьютера / пер. с англ. Imagination Technologies / М.: ДМК Пресс, 2018.

Список типичных ошибок при работе с Vivado и SystemVerilog

Содержание

Ошибки связанные с САПР Vivado

Не запускается симуляция FATAL_ERROR PrivateChannel Error creating client socket

Причина: ошибка связана с проблемами Win Sockets, из-за которых симуляция не может быть запущена на сетевых дисках.
Способ воспроизведения ошибки: создать проект на сетевом диске.
Решение: скорее всего, вы создали проект на диске H:/. Создайте проект на локальном диске (например, на рабочем столе диске C:/)


Не запускается симуляция boost filesystem remove Процесс не может получить доступ к файлу

Скриншот ошибки:

../.pic/Other/FAQ/boot_filesystem_remove.png

Причина: вы запустили симуляцию с другим top level-модулем, не закрыв предыдущую симуляцию.
Скорее всего, после создания тестбенча, вы слишком быстро запустили первую симуляцию. Из-за этого, Vivado не успел обновить иерархию модулей и сделать тестбенч top-level-модулем. На запущенной симуляции все сигналы находились в Z и X состояниях, после чего вы попробовали запустить ее снова. К моменту повторного запуска иерархия модулей обновилась, сменился top-level, что и привело к ошибке.
Способ воспроизведения ошибки: запустить симуляцию, создать новый файл симуляции, сделать его top-level-модулем, запустить симуляцию.
Решение: закройте предыдущую симуляцию (правой кнопкой мыши по кнопки SIMULATION -> Close Simulation) затем запустите новую.

Иллюстрация закрытия симуляции:

../.pic/Other/FAQ/close_sim.png


Вылетает Vivado при попытке открыть схему

Причина: кириллические символы (русские буквы) в пути рабочей папки Vivado. Скорее всего, причина в кириллице в имени пользователя (НЕ В ПУТИ УСТАНОВКИ VIVADO).
Способ воспроизведения ошибки: (см. решение, только для воспроизведение необходимо сделать обратно, дать папке имя с кириллицей)
Решение: чтобы не создавать нового пользователя без кириллицы в имени, проще назначить Vivado новую рабочую папку.
Для этого:

  1. Создайте в корне диска C:/ какую-нибудь папку (например Vivado_temp).
  2. Откройте свойства ярлыка Vivado (правой кнопкой мыши по ярлыку -> свойства) 2.1 Если у вас нет ярлыка Vivado на рабочем столе, вместо этого вы запускаете его из меню пуск, кликните в меню пуск правой кнопкой мыши по значку Vivado -> открыть расположение файла. Если там будет ярлык выполните пункт 2, если там будет исполняемый файл — создайте ярлык для этого файла (правой кнопкой мыши по файлу -> создать ярлык) и выполните пункт 2.
  3. В поле "Рабочая папка", укажите путь до созданной вами директории (в примере пункта 1 этот путь будет: C:/Vivado_temp). Нажмите "ОК".

Не устанавливается Vivado Unable to open archive

Иллюстрация:

../.pic/Other/FAQ/unable_to_open_archive.jpg

Причина: скорее всего, проблема в том, что файлы установки (НЕ ПУТЬ УСТАНОВКИ VIVADO) расположены по пути с кириллическими символами (например, в какой-то личной папке "Загрузки").
Решение: переместите файлы установки в директорию, не содержащую кириллицу в пути.


Ошибки синтаксиса языка SystemVerilog

имя сигнала is not a type

Скорее всего, компилятор не распознал присваивание, поскольку оно было записано с ошибками. Вне блоков always и initial можно выполнять только непрерывное присваивание (через assign).

module half_adder(input logic a, input logic b, output logic c);
c = a ^ b;  // ошибка, для непрерывного присваивания
            // необходимо ключевое слово assign
endmodule

cannot find port on this module

Имя порта, указанного при подключении модуля (после точки) не соответствует ни одному имени сигналов подключаемого модуля

Пример

module half_adder(input logic a, input logic b, output logic c);
  assign c = a ^ b;
endmodule

module testbench();
logic A, B, C;

adder DUT(
  .A(A),  // <- здесь будет ошибка,
          // т.к. в модуле half_adder нет порта 'A'
  .b(B),
  .c(C)
);
endmodule