Курс лабораторных работ
Полезное
- Создание базового проекта с прошивкой ПЛИС в Vivado
- Базовые конструкции Verilog
- Список типичных ошибок в Vivado и SystemVerilog
- Тестовое окружение
Порядок выполнения лабораторных работ для групп
ИБ, ИКТ, КТ, РТ
- Сумматор (01. Adder)
- АЛУ (02. Arithmetic-logic unit)
- Регистровый файл и внешняя память (03. Register file and memory)
- Простейшее программируемое устройство (04. Primitive programmable device)
ПИН, ПМ
- Сумматор (01. Adder)
- АЛУ (02. Arithmetic-logic unit)
- Регистровый файл и внешняя память (03. Register file and memory)
- Простейшее программируемое устройство (04. Primitive programmable device)
- Основной дешифратор (05. Main decoder)
-
- Тракт данных (07. Datapath)
- Интеграция блока загрузки и сохранения (09. LSU Integration)
- Интеграция подсистемы прерываний (11. Interrupt Integration)
- Периферийные устройства (13. Peripheral units)
- Программирование (14. Programming)
ИВТ
- АЛУ (02. Arithmetic-logic unit)
-
- Память (03. Register file and memory),
- Простейшее программируемое устройство (04. Primitive programmable device)
- Основной дешифратор (05. Main decoder)
- Тракт данных (07. Datapath)
-
- Модуль загрузки и сохранения (08. Load-store unit)
- Интеграция блока загрузки и сохранения (09. LSU Integration)
-
- Контроллер прерываний (10. Interrupt subsystem)
- Интеграция подсистемы прерываний (11. Interrupt Integration)
- Периферийные устройства (13. Peripheral units)
- Программирование (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 уже заморожен и не изменится), чтобы ссылки на конкретные страницы спецификации имели смысл, они будут даваться на следующие версии двух документов:
- "The RISC-V Instruction Set Manual Volume I: Unprivileged ISA" — версия документа
20240411
; - "The RISC-V Instruction Set Manual Volume II: Privileged Architecture" — версия документа
20240411
.
История курса и разработчики
Дисциплины связанные с организацией вычислительной техники читаются в МИЭТ с самого его основания. Текущий курс эволюционировал из "Микропроцессорных средств и систем" (МПСиС) читаемый факультету МПиТК (Микроприборов и технической кибернетики) сначала Савченко Юрием Васильевичем, а после – Переверзевым Алексеем Леонидовичем. С 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" — в документе описано, как работает ПЛИС изнутри.
- "Sequential logic" — в документе описана классификация цифровой логики (комбинационная / последовательностная), эволюция бистабильных ячеек от петли инверторов до D-триггера, явление метастабильности и критический путь.
- "Implementation Steps" — в документе описано процесс реализации проекта от HDL-описания цифровой схемы до конфигурации этой схемой ПЛИС. Его прочтение даст большее понимание о принципе работы ПЛИС и позволит посмотреть на некоторые её реальные элементы изнутри.
Обратите внимание, что во втором абзаце не было использовано слово "поймёте". Часто это слово несет не тот смысл. Можно прочесть документ и понять каждое его слово, но не постигнуть смысла, который в этих слова лежал (слышать, но не слушать). В романе Роберта Хайнлайна "Чужак в чужой стране" вводится особое марсианское слово, непереводимое на земной язык: "грокать", которое имеет множество значений. В первом приближении можно подумать, что это слово переводится как "понять", однако это не так. Например, на Марсе очень мало воды и процесс её питья марсианами (по сюжету романа, разумеется) является целым ритуалом, и обозначается этим же словом "грокать". Грокать что-то — означает что это что-то стало частью твоего естества. В отношении информации это означает, это информация стала частью тебя, изменила то, как ты думаешь. Грокать — это постичь что-то на самом глубинном уровне, это видеть девушку в красном сквозь завесу падающих зеленых символов. Даже этот абзац расписан для того, чтобы вы не просто поняли, что эти документы важно понять — а грокнули то, что эти документы важно грокнуть.
На самом деле не важно, каким словом будет обозначен результат вашего прочтения. Важно то, что если после того как вы прочтете эти документы, на лабах вы будете употреблять словосочетания наподобие: "объявляем переменную", значит что-то пошло не так, и образ вашего мышления все еще заперт в парадигме "программирования". Это не то чтобы плохо, просто усложнит вам процесс изучения и выполнения лабораторных работ.
Что такое язык описания аппаратуры (HDL)
На заре появления цифровой электроники, цифровые схемы в виде диаграммы на бумаге были маленькими, а их реализация в виде физической аппаратуры — большой. В процессе развития электроники (и её преобразования в микроэлектронику) цифровые схемы на бумаге становились всё больше, а относительный размер их реализации в виде физических микросхем — всё меньше. На рис. 1, вы можете увидеть цифровую схему устройства Intel 4004, выпущенного в 1971 году.
Рисунок 1. Цифровая схема процессора Intel 4004 на уровне транзисторов[1].
Данная микросхема состоит из 2300 транзисторов[2].
За прошедшие полсотни лет сложность цифровых схем выросла колоссально. Современные процессоры для настольных компьютеров состоят из десятков миллиардов транзисторов. Диаграмма выше при печати в оригинальном размере займет прямоугольник размером 115х140см с площадью около 1.6м2. Предполагая, что площадь печати имеет прямо пропорциональную зависимость от количества транзисторов, получим что распечатка схемы современного процессора из 23 млрд транзисторов заняла бы площадь в 16млн. м2, что эквивалентно квадрату со стороной в 4км.
Рисунок 2. Масштаб размеров, которых могли бы достигать цифровые схемы современных процессоров, если бы они печатались на бумаге.
Как вы можете догадаться в какой-то момент между 1971-ым и 2022-ым годами инженеры перестали разрабатывать цифровые схемы, рисуя их на бумаге.
Разумеется, разрабатывая устройство, не обязательно вырисовывать на схеме каждый транзистор — можно управлять сложностью, переходя с одного уровня абстракции на другой. Например, начинать разработку схемы с уровня функциональных блоков, а затем рисовать схему для каждого отдельного блока.
К примеру, схему Intel 4004 можно представить в следующем виде:
Рисунок 3. Цифровая схема процессора Intel 4004 на уровне функциональных блоков[2].
Однако несмотря на это, даже отдельные блоки порой бывают довольно сложны. Возьмем блок аппаратного шифрования по алгоритму AES[3] на рисунке 4:
Рисунок 4. Цифровая схема блока аппаратного шифрования по алгоритму AES[4].
Заметьте, что даже этот блок не представлен на уровне отдельных транзисторов. Каждая операция Исключающего ИЛИ, умножения, мультиплексирования сигнала и таблицы подстановки — это отдельные блоки, функционал которых еще надо реализовать. В какой-то момент инженеры поняли, что проще описать цифровую схему в текстовом представлении, нежели в графическом.
Как можно описать цифровую схему текстом? Рассмотрим цифровую схему полусумматора:
Рисунок 5. Цифровая схема полусумматора на уровне логических вентилей.
Это устройство (полусумматор) имеет два входа: a и b, а также два выхода: sum и carry. Выход sum является результатом логической операции Исключающее ИЛИ от операндов a и b. Выход carry является результатом логической операции И от операндов a и b.
Текст выше и является тем описанием, по которому можно воссоздать эту цифровую схему. Если стандартизировать описание схемы, то в нем можно будет оставить только слова, выделенные жирным и курсивом. Пример того, как можно было бы описать эту схему по стандарту IEEE 1364-2005 (язык описания аппаратуры (Hardware Description Language — HDL) Verilog):
module half_sum( // устройство полусумматор cо
input a, // входом a,
input b, // входом b,
output sum, // выходом sum и
output carry // выходом carry.
);
assign sum = a ^ b; // Где выход sum является результатом Исключающего ИЛИ от a и b,
assign carry = a & b; // а выход carry является результатом логического И от a и b.
endmodule
На первый взгляд такое описание выглядит даже больше, чем записанное естественным языком, однако видимый объем получен только за счёт переноса строк и некоторой избыточности в описании входов и выходов, которая была добавлена для повышения читаемости. То же самое описание можно было записать и в виде:
module half_sum(input a, b, output sum, carry);
assign sum = a ^ b;
assign carry = a & b;
endmodule
Важно отметить, что код на языке Verilog описывает устройство целиком, одномоментно. Это описание схемы выше, а не построчное выполнение программы.
С практикой описание схемы в текстовом виде становится намного проще и не требует графического представления. Для описания достаточно только спецификации: формальной записи того, что должно делать устройство, по которой разрабатывается алгоритм, который затем претворяется в описание на HDL.
Занятный факт: ранее было высказано предположение о том, что инженеры перестали разрабатывать устройства, рисуя цифровые схемы в промежуток времени между 1971-ым и 2022-ым годами. Так вот, первая конференция, посвященная языкам описания аппаратуры состоялась в 1973-ем году[5, стр. 8]. Таким образом, Intel 4004 можно считать одним из последних цифровых устройств, разработанных без использования языков описания аппаратуры.
Список источников
- Intel 4004 — 50th Anniversary Project;
- Страница википедии по Intel 4004;
- F.Ka˘gan. Gürkaynak / Side Channel Attack Secure Cryptographic Accelerators;
- FIPS 197, Advanced Encryption Standart (AES);
- 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.
В дальнейшем произошла миниатюризация базовых элементов — транзисторов, из которых состоят электронные схемы, и появилась возможность реализовать соединения между ними непосредственно на кристалле, что привело к появлению интегральных схем — электронных схем, выполненных на полупроводниковой подложке и заключенных в неразборный корпус.
В большинстве случаев, исправить ошибку, допущенную при разработке и изготовлении интегральной схемы, невозможно. С учетом того, что изготовление прототипа интегральной схемы является долгим и затратным мероприятием (от десятков тысяч до миллионов долларов в зависимости от технологии (или технологического процесса) , по которой изготавливается схема и занимаемой площади), возникла необходимость в гибком, быстром и дешевом в способе проверки схемы до изготовления её прототипа. Так появились программируемые логические интегральные схемы (ПЛИС). В связи с повсеместным использованием англоязычной литературы, имеет смысл дать и англоязычное название этого класса устройств: programmable logic devices (PLD).
ПЛИС содержит некоторое конечное множество базовых блоков (примитивов), блоки межсоединений примитивов и блоки ввода-вывода. Подав определенный набор воздействий на ПЛИС (запрограммировав её), можно настроить примитивы, их межсоединения между собой и блоками ввода-вывода, чтобы получить определенную цифровую схему. Удобство ПЛИС заключается в том, что в случае обнаружения ошибки на прототипе, исполненном в ПЛИС, вы можете исправить свою цифровую схему, и повторно запрограммировать ПЛИС.
Кроме того, эффективно использовать ПЛИС не как средство дешевого прототипирования, но и как средство реализации конечного продукта в случае малого тиража (дешевле купить и запрограммировать готовую партию ПЛИС, чем изготовить партию собственных микросхем).
Стоит оговориться, что в данной книге под термином ПЛИС будет подразумеваться конкретный тип программируемых схем: FPGA (field-programmable gate array, программируемая пользователем вентильная матрица, ППВМ).
Давайте разберемся что же это за устройство и как оно работает изнутри, но перед этим необходимо провести ликбез по цифровым схемам и логическим вентилям.
Цифровые схемы и логические вентили
Цифровые схемы
Цифровая схема — это абстрактная модель вычислений, которая оперирует двумя дискретными состояниями, обычно обозначаемыми как 0
и 1
. Важно понимать, что эти состояния не привязаны к конкретным физическим величинам, таким как напряжение в электрической цепи. Вместо этого они представляют собой обобщенные логические значения, которые могут быть реализованы на любой технологии, способной различать два четких состояния.
Благодаря этой абстракции, цифровые схемы могут быть реализованы не только с помощью традиционных электронных компонентов, но и на совершенно иных платформах, например, на пневматических системах, из картона и шариков, красной пыли в игре Майнкрафт или даже с использованием человеческого взаимодействия, подобно тому как это описано в романе Лю Цысиня "Задача трёх тел" (эффективность подобных схем — это уже другой вопрос). Основная идея заключается в том, что цифровая схема отвязывается от физической реализации, фокусируясь лишь на логике взаимодействия состояний 0
и 1
, что делает ее универсальной и независимой от конкретной технологии.
Разумеется, при проектировании эффективных цифровых схем, необходимо оглядываться на технологию, по которой эти схемы будут работать.
В электронике, словом "цифровая" описывают схемы, которые абстрагируются от непрерывных (аналоговых) значений напряжений, вместо этого используется только два дискретных значения: 0
и 1
. На данном уровне абстракции нас не интересуют конкретные значения напряжений и пороги этих значений, что позволяет нам разрабатывать схему в идеальном мире, где у напряжения может быть всего два значения: 0
и 1
. А обеспечением этих условий будут заниматься базовые блоки, из которых мы будем строить цифровые схемы.
Эти базовые блоки называются логическими вентилями.
Логические вентили
Существует множество логических вентилей, но чаще всего используется четыре из них: И, ИЛИ, Исключающее ИЛИ, НЕ. Каждый из этих элементов принимает на вход цифровое значение (см. цифровая схема), выполняет определенную логическую функцию над входами и подает на выход результат этой функции в виде цифрового значения.
Логические вентили на рис. 1-4 иллюстрируются условными графическими обозначениями (УГО), взятыми из двух стандартов: ANSI и ГОСТ. Ввиду повсеместного использования в литературе первого варианта, в дальнейшем в книге будет использован он.
Логический вентиль И принимает два входа и выдает на выход значение 1
только в том случае, если оба входа равны 1
. Если хотя бы один из входов 0
, то на выходе будет 0
. На схемах логический вентиль И отображается следующим образом:
Рисунок 1. УГО логического вентиля И.
Логический вентиль ИЛИ принимает два входа и выдает на выход значение 1
в случае, если хотя бы один из входов равен 1
. Если оба входа равны 0
, то на выходе будет 0
. На схемах логический вентиль ИЛИ отображается следующим образом:
Рисунок 2. УГО логического вентиля ИЛИ.
Логический вентиль Исключающее ИЛИ принимает два входа и выдает на выход значение 1
в случае, если значения входов не равны между собой (один из них равен 1
, а другой 0
). Если значения входов равны между собой (оба равны 0
или оба равны 1
), то на выходе будет 0
. На схемах логический вентиль Исключающее ИЛИ отображается следующим образом:
Рисунок 3. УГО логического вентиля Исключающее ИЛИ.
Логический вентиль НЕ — самый простой. Он принимает один вход и подает на выход его инверсию. Если на вход пришло значение 0
, то на выходе будет 1
, если на вход пришло значение 1
, то на выходе будет 0
. Он обозначается на схемах следующим образом:
Рисунок 4. УГО логического вентиля НЕ.
Так же существуют вариации базовых вентилей, такие как И-НЕ, ИЛИ-НЕ, Исключающее ИЛИ-НЕ, отличающиеся от исходных тем, что результат операции инвертирован относительно результата аналогичной операции без -НЕ.
Логические вентили строятся из транзисторов. Транзистор — это элемент, который может пропускать/блокировать ток в зависимости от поданного напряжения на его управляющий вход.
Особенностью современных интегральных схем является то, что они строятся на основе комплементарной (взаимодополняющей) пары транзисторов P и N-типа (Комплементарная Металл-Оксид-Полупроводниковая, КМОП логика). Для данного типа транзисторов оказалось эффективнее реализовать операции И-НЕ и ИЛИ-НЕ.
С точки зрения построения цифровых схем МОП-транзистор (P и N-типа) можно воспринимать как выключатель, который замыкает или размыкает связь между двумя выводами. Разница между P и N типом заключается в состоянии, в котором транзистор "открыт" (вход и выход замкнуты) или "закрыт" (связь разорвана). Рис. 5 иллюстрирует данное различие.
Вход и выход, между которыми образуется связь называются "сток" (drain, d) и "исток" (source, s), а управляющий вход — "затвор" (gate, g). Обратите внимание, что логический вентиль (logic gate) и затвор транзистора (просто gate) — это разные сущности!
Рисунок 5. МОП-транзисторы P и N типа.
На рис. 6 показан способ построения логических вентилей И-НЕ, ИЛИ-НЕ по КМОП технологии. Рассмотрим принцип работы вентиля И-НЕ.
Подача значения 1
на вход А или B открывает соответствующий этому входу n-канальный транзистор (обозначен на рис. 6 красным цветом), и закрывает дополняющий его (комплементарный ему) p-канальный транзистор (обозначен синим цветом). Подача на оба входа 1
закрывает оба p-канальных транзистора (верхняя часть схемы разомкнута, что для значения на выходе означает что её будто бы и нет) и открывает оба n-канальных транзистора. В результате чего выход замыкается на "землю" (чёрный треугольник внизу схемы) что эквивалентно 0
в контексте цифровых значений.
В случае, если хотя бы на одном из входов А или B будет значение 0
, откроется один из параллельно соединенных p-канальных транзисторов (в то время как соединение с "землей" будет разорвано) и выход будет подключен к питанию (две перпендикулярные линии вверху схемы), что эквивалентно 1
в контексте цифровых значений.
Как вы видите, напряжение на выход подается от источников постоянного питания или земли, а не от входов вентиля, именно этим и обеспечивается постоянное обновление напряжения и устойчивость цифровых схем к помехам.
Рисунок 6. Схема логических вентилей И-НЕ, ИЛИ-НЕ, построенных на КМОП транзисторах.
Как правило, при необходимости инвертировать вход или выход логического элемента на схеме, на нем рисуют кружок вместо добавления логического вентиля НЕ в том виде, котором он изображён на рис. 4. К примеру, логический элемент И-НЕ обозначают в виде, представленном на рис. 6.
При желании, из логического элемента И-НЕ можно легко получить логический элемент И (как и элемент ИЛИ из ИЛИ-НЕ). Для этого необходимо поставить на выходе И-НЕ инвертор, собираемый из двух МОП-транзисторов по схеме, представленной на рис. 7.
Рисунок 7. Схема логического вентиля НЕ, построенного на КМОП транзисторах.
КМОП логика далеко не единственный способ построения цифровых элементов, ранее достаточно широко применялись другие варианты построения схем, например только на одном типе транзисторов. Однако наиболее эффективным оказалось использование именно комплементарных пар, и на сегодня такой подход для цифровых схем является доминирующим.
Используя одни лишь описанные выше логические вентили можно построить любую(!) цифровую схему.
Однако, при описании цифровых схем, некоторые цифровые блоки используются настолько часто, что для них ввели отдельные обозначения (сумматоры, умножители, мультиплексоры т.п.), используемые при описании более сложных схем. Мы рассмотрим один из фундаментальных строительных блоков в ПЛИС — мультиплексор.
Мультиплексоры
Мультиплексор — это устройство, которое в зависимости от значения управляющего сигнала подает на выход значение одного из входных сигналов.
Схематически, мультиплексор обозначается следующим образом:
Рисунок 8. Обозначение Мультиплексора.
Символ /
на линии sel
используется, чтобы показать, что этот сигнал шириной 6 бит.
Число входов мультиплексора может быть различным, но выход у него всегда один.
Способ, которым кодируется значение управляющего сигнала может также различаться. Простейшая цифровая схема мультиплексора получится, если использовать унитарное (one-hot) кодирование. При таком кодировании, значение многоразрядного сигнала всегда содержит ровно одну 1
. Информация, которую несет закодированный таким образом сигнал содержится в положении этой 1
внутри многоразрядного сигнала.
Посмотрим, как можно реализовать мультиплексор с управляющим сигналом, использующим one-hot-кодирование, используя только логические вентили И, ИЛИ:
Рисунок 9. Реализация мультиплексора, использующего one-hot кодирование.
Если мы выставим значение управляющего сигнала, равное 000010
, означающее что только первый бит этого сигнала (счет ведется с нуля) будет равен единице (sel[1] = 1
), то увидим, что на один из входов каждого логического вентиля И будет подано значение 0
. Исключением будет логический вентиль И для входа b
, на вход которого будет подано значение 1
. Это означает, что все логические вентили И (кроме первого, на который подается вход b
) будут выдавать на выход 0
(см. Логические вентили) вне зависимости от того, что было подано на входы a,c,d,e и f. Единственным входом, который будет на что-то влиять окажется вход b
. Когда он равен 1
, на выходе соответствующего логического вентиля И окажется значение 1
. Когда он равен 0
на выходе И окажется значение 0
. Иными словами, выход И будет повторять значение b
.
Рисунок 10. Реализация мультиплексора, использующего one-hot кодирование.
Логический вентиль ИЛИ на данной схеме имеет больше двух входов. Подобный вентиль может быть создан в виде каскада логических вентилей ИЛИ:
Рисунок 11. Реализация многоходового логического ИЛИ.
Многовходовой вентиль ИЛИ ведет себя ровно так же, как двухвходовой: он выдает на выход значение 1
когда хотя бы один из входов равен 1
. В случае, если все входы равны 0
, на выход ИЛИ пойдет 0
.
Но для нашей схемы мультиплексора гарантируется, что каждый вход ИЛИ кроме одного будет равняться 0
(поскольку выход каждого И кроме одного будет равен 0
). Это означает, что выход многовходового ИЛИ будет зависеть только от одного входа (в случае, когда sel = 000010
— от входа b
).
Рисунок 12. Реализация мультиплексора, использующего one-hot кодирование.
Меняя значение sel
, мы можем управлять тем, какой из входов мультиплексора будет идти на его выход.
Программируемая память
Из транзисторов можно построить не только логические элементы, но и элементы памяти. На рис. 13 представлена схема простейшей ячейки статической памяти, состоящей из транзистора и двух инверторов (т.е. суммарно состоящей из 5 транзисторов, поэтому она называется 5T SRAM). Данная ячейка реализует 1 бит конфигурируемой памяти, являвшейся одним из основных компонентов самой первой ПЛИС.
Рисунок 13. Конфигурируемая ячейка памяти ПЛИС Xilinx XC2064[2, стр. 2-63].
Данная память представляет собой бистабильную ячейку — петлю из двух инверторов, в которых "заперто" хранимое значение. Дважды инвертированный сигнал совпадает по значению с исходным, при этом проходя через каждый из инверторов, сигнал обновляет свое значение напряжения, что не позволяет ему угаснуть из-за сопротивления цепи.
Для того чтобы поместить в бистабильную ячейку новое значение, к ее входу подключается еще один транзистор, замыкающий или размыкающий ее с напряжением питания/земли.
Таблицы подстановки (Look-Up Tables, LUTs)
Представьте мультиплексор с четырьмя входными сигналами, и двухбитным управляющим сигналом (обратите внимание, что в теперь это сигнал не использует one-hot-кодирование). Но теперь, вместо того чтобы выставлять входные сигналы во внешний мир, давайте подключим их к программируемой памяти. Это означает, что мы можем "запрограммировать" каждый из входов на какое-то константное значение. Поместим то, что у нас получилось в отдельный блок и вот, мы получили двухвходовую Таблицу подстановки (Look-Up Tables, далее LUT).
Рисунок 14. Реализация таблицы подстановки (Look-Up Table, LUT).
Эти два входа LUT являются битами управляющего сигнала мультиплексора, спрятанного внутри LUT. Программируя входы мультиплексора (точнее, программируя память, к которой подключены входы мультиплексора), мы можем реализовать на базе LUT любую(!) логическую функцию, принимающую два входа и возвращающую один выход.
Допустим мы хотим получить логическое И. Для этого, нам потребуется записать в память следующее содержимое:
Адрес (In[1:0]) | Значение |
---|---|
00 | 0 |
01 | 0 |
10 | 0 |
11 | 1 |
Это простейший пример — обычно LUT-ы имеют больше входов, что позволяет им реализовывать более сложную логику.
D-триггеры
Как вы уже поняли, используя неограниченное количество LUT-ов, вы можете построить цифровую схему, реализующую логическую функцию любой сложности. Однако цифровые схемы не ограничиваются реализацией одних только логических функций (цифровые схемы, реализующие логическую функцию, называются комбинационными, поскольку выход зависит только от комбинации входов). Например, так не построить цифровую схему, реализующую процессор. Для таких схем, нужны элементы памяти. Заметим, что речь идет не о программируемой памяти, задавая значения которой мы управляем тем, куда будут направлены сигналы, и какие логические функции будут реализовывать LUT-ы. Речь идет о ячейках памяти, которые будут использоваться логикой самой схемы.
Такой базовой ячейкой памяти является D-триггер (D flip-flop), из которых можно собрать другие ячейки памяти, например регистры (а из регистров можно собрать память с произвольным доступом (random access memory, RAM)), сдвиговые регистры и т.п.
D-триггер — это цифровой элемент, способный хранить один бит информации. В базовом варианте у этого элемента есть два входа и один выход. Один из входов подает значение, которое будет записано в D-триггер, второй вход управляет записью (обычно он называется clk
или clock
и подключается к тактирующему синхроимпульсу схемы). Когда управляющий сигнал меняет свое значение с 0
на 1
(либо с 1
на 0
, зависит от схемы), в D-триггер записывается значение сигнала данных. Обычно, описывая D-триггер, говорится, что он строится из двух триггеров-защелок (D latch), которые в свою очередь строятся из RS-триггеров, однако в конечном итоге, все эти элементы могут быть построены на базе логических вентилей И/ИЛИ, НЕ:
Рисунок 15. Реализация D-триггера.
Арифметика
Помимо описанных выше блоков (мультиплексоров и построенных на их основе LUT-ов и регистров) выделяется еще один тип блоков, настолько часто используемый в цифровых схемах, что его заранее размещают в ПЛИС в больших количествах: это арифметические блоки. Эти блоки используются при сложении, вычитании, сравнении чисел, реализации счётчиков. В разных ПЛИС могут быть предустановлены разные блоки: где-то это может быть однобитный сумматор, а где-то блок вычисления ускоренного переноса (carry-chain
).
Все эти блоки могут быть реализованы через логические вентили, например так можно реализовать сумматор:
Рисунок 16. Реализация полного однобитного сумматора.
Логическая ячейка
И вот, мы подходим к внутреннему устройству ПЛИС. Мы уже узнали, что в ПЛИС есть матрица программируемых мультиплексоров, направляющих сигналы туда, куда нам нужно.
Вторым важным элементом является логический блок (обычно состоящих из логических ячеек или логических элементов, но для простоты мы отождествим все эти термины).
Логический блок содержит одну или несколько LUT, арифметический блок, и один или несколько D-триггеров, которые соединены между собой некоторым количеством мультиплексоров. На рис. 17 представлена схема того, как может выглядеть логический блок:
Рисунок 17. Схема логической ячейки[2].
Всё достаточно просто. Логический блок представляет собой цепочку операций: логическая функция, реализованная через LUT -> арифметическая операция -> Запись в D-триггер
. Каждый из мультиплексоров определяет то, будет ли пропущен какой-либо из этих этапов.
Таким образом, конфигурируя каждый логический блок, можно получить следующие вариации кусочка цифровой схемы:
- Комбинационная схема (логическая функция, реализованная в LUT)
- Арифметическая операция
- Запись данных в D-триггер
- Комбинационная схема, с записью результата в D-триггер
- Арифметическая операция с записью результата в D-триггер
- Комбинационная схема с последующей арифметической операцией
- Комбинационная схема с последующей арифметической операцией и записью в D-триггер
А вот реальный пример использования логического блока в ПЛИС xc7a100tcsg324-1
при реализации Арифметико-логического устройства (АЛУ), подключенного к периферии отладочной платы Nexys-7
:
Рисунок 18. Пример использования логической ячейки.
Здесь вы можете увидеть использование LUT-ов, арифметического блока (ускоренного расчета переноса), и одного из D-триггеров. D-триггеры, обозначенные серым цветом, не используются.
Располагая большим наборов таких логических блоков, и имея возможность межсоединять их нужным вам образом, вы получаете широчайшие возможности по реализации практически любой цифровой схемы (ограничением является только ёмкость ПЛИС, т.е. количество подобных логических блоков, входов выходов и т.п.).
Помимо логических блоков, в ПЛИС есть и другие примитивы: Блочная память, блоки умножителей и т.п.
Сеть межсоединений
Для того, чтобы разобраться как управлять межсоединением логических блоков, рассмотрим рис. 19, входящий в патент на ПЛИС[4].
Рисунок 19. Содержимое ПЛИС в виде межсоединения логических блоков и блоков ввода-вывода.
Синим показано 9 логических блоков, желтым — 12 блоков ввода-вывода. Все эти блоки окружены сетью межсоединений (interconnect net), представляющей собой матрицу из горизонтальных и вертикальных соединительных линий — межсоединений общего назначения (general purpose interconnect) [2, 2-66].
Косыми чертами в местах пересечения линий обозначены программируемые точки межсоединений (programmable interconnect points, PIPs), представляющие собой транзисторы, затвор которых подключен к программируемой памяти.
Управляя значением в подключенной к затвору транзистора памяти, можно управлять тем, что из себя будет представлять транзистор в данной точке: разрыв, или цепь. А значит, можно удалять "лишние" участки сети, оставляя только используемые логические блоки, соединенные между собой.
Итоги главы
Обобщим сказанное:
- Используя такие элементы, как транзисторы, можно собирать логические вентили: элементы И, ИЛИ, НЕ и т.п.
- Используя логические вентили, можно создавать схемы, реализующие как логические функции (комбинационные схемы), так и сложную логику с памятью (последовательностные схемы).
- Из логических вентилей среди прочего строится и такая важная комбинационная схема, как мультиплексор: цифровой блок, в зависимости от управляющего сигнала подающий на выход один из входных сигналов.
- Кроме того, подключив вход бистабильной ячейки (представляющую собой петлю из двух инверторов) к транзистору, можно получить 1 бит конфигурируемой памяти.
- Подключив входные сигналы мультиплексора к программируемой памяти, можно получить Таблицу подстановок (Look-Up Table, LUT), которая может реализовывать простейшие логические функции. LUT-ы позволяют заменить логические вентили И/ИЛИ/НЕ, и удобны тем, что их можно динамически изменять, логические вентили в свою очередь исполняются на заводе и уже не могут быть изменены после создания.
- Из логических вентилей так же можно собрать базовую ячейку памяти: D-триггер, и такую часто используемую комбинационную схему как полный однобитный сумматор (или любой другой часто используемый арифметический блок).
- Объединив LUT, арифметический блок и D-триггер получается структура в ПЛИС, которая называется логический блок.
- Логический блок (а также другие примитивы, такие как блочная память или умножители) — это множество блоков, которые заранее физически размещаются в кристалле ПЛИС, их количество строго определено конкретной ПЛИС и не может быть изменено.
- Подключая такой бит конфигурируемой памяти к транзисторам, расположенных в узлах сети межсоединений, можно управлять тем, где в этой сети будут разрывы, а значит можно оставить только маршрут, по которому сигнал пойдет туда, куда нам нужно (трассировать сигнал).
- Конфигурируя примитивы и трассируя сигнал между ними (см. п.4), можно получить практически любую цифровую схему (с учетом ограничения ёмкости ПЛИС).
Список источников
- Alchitry, Ell C / How Does an FPGA Work?
- Xilinx / The Programmable Gate Array Data Book
- Wikipedia / Field-programmable gate array
- Ken Shirriff / Reverse-engineering the first FPGA chip, the XC2064
Этапы реализации проекта в ПЛИС
Для того, чтобы описанное на языке описания аппаратуры устройство было реализовано в ПЛИС, необходимо выполнить несколько этапов:
- Elaboration
- Synthesis
- Implementation
- Bitstream generation
В русскоязычной литературе не сложилось устоявшихся терминов для этапов 1 и 3, но elaboration можно назвать как "предобработку" или "развертывание", а implementation как "реализацию" или "построение". Этапы 2 и 4 переводятся дословно: синтез и "генерация двоичного файла конфигурации (битстрима)".
Более того, граница между этапами весьма условна и в зависимости от используемой системы автоматизированного проектирования (САПР), задачи, выполняемые на различных этапах, могут перетекать из одного в другой. Описание этапов будет даваться для маршрута проектирования под ПЛИС, однако, с некоторыми оговорками, эти же этапы используются и при проектировании сверхбольших интегральных схем (СБИС).
Остановимся на каждом шаге подробнее.
Elaboration
На этапе предобработки, САПР разбирает и анализирует HDL-описание вашего устройства, проверяет его на наличие синтаксических ошибок, производит подстановку значений параметров и блоков generate
, устанавливает разрядности сигналов и строит иерархию модулей устройства.
Затем, ставит в соответствие отдельным участкам кода соответствующие абстрактные элементы: логические вентили, мультиплексоры, элементы памяти и т.п. Кроме того, производится анализ и оптимизация схемы, например, если какая-то часть логики в конечном итоге не связана ни с одним из выходных сигналов, эта часть логики будет удалена[1].
Итогом предобработки является так называемая топология межсоединений (в быту называемая словом нетлист). Нетлист — это представление цифровой схемы в виде графа, где каждый элемент схемы является вершиной графа, а цепи, соединяющие эти элементы являются его ребрами. Нетлист может храниться как в виде каких-то внутренних файлов САПР-а (так хранится нетлист этапа предобработки), так и в виде HDL-файла, описывающего экземпляры примитивов и связи между ними. Рассмотрим этап предобработки и термин нетлиста на примере.
Допустим, мы хотим реализовать следующую цифровую схему:
Рисунок 1. Пример простой цифровой схемы.
Её можно описать следующим 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
.
Откроются следующие окна:
Рисунок 2. Результат этапа предобработки.
В левом окне мы видим наш нетлист. В нижней части обозначены узлы графа (элементы ab_i, res_i, xabc_i, которые представляют собой И, мультиплексор и Исключающее ИЛИ соответственно. Имена этих элементов схожи с именами проводов, присваиванием которым мы создавали данные элементы)
В верхней части обозначены ребра графа, соединяющие элементы схемы. Это входы и выходы нашего модуля, а также созданные нами промежуточные цепи.
Справа вы видите схематик — графическую схему, построенную на основе данного графа (нетлиста).
Synthesis
На шаге синтеза, САПР берет сгенерированную ранее цифровую схему и реализует элементы этой схемы через примитивы конкретной ПЛИС — в основном через логические ячейки, содержащие таблицы подстановки, полный однобитный сумматор и D-триггер
(см. как работает ПЛИС).
Поскольку в примере схема чисто комбинационная, результат её работы можно рассчитать и выразить в виде таблицы истинности, а значит для её реализации лучше всего подойдут Таблицы Подстановки (LUT-ы). Более того, в ПЛИС xc7a100tcsg324-1
есть пятивходовые LUT-ы, а у нашей схемы именно столько входов. Это означает, работу всей этой схемы можно заменить всего одной таблицей подстановки внутри ПЛИС.
Итак, продолжим рассматривать наш пример и выполним этап синтеза. Для этого нажмем на кнопку Run Synthesis
.
После выполнения синтеза у нас появится возможность открыть новый схематик, сделаем это.
Рисунок 3. Результат этапа синтеза.
Мы видим, что между входами/выходами схемы и её внутренней логикой появились новые примитивы — буферы. Они нужны, чтобы преобразовать уровень напряжения между входами ПЛИС и внутренней логикой (условно говоря, на вход плис могут приходить сигналы с уровнем 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
После получения нетлиста, где в качестве элементов используются ресурсы конкретной ПЛИС, происходит размещение этой схемы на элементы заданной ПЛИС: выбираются конкретные логические ячейки. Затем происходит трассировка (маршрутизация) связей между ними. Для этих процедур часто используется термин place & route (размещение и трассировка). Например, реализация 32-битного сумматора с ускоренным переносом может потребовать 32 LUT-а и 8 примитивов вычисления быстрого переноса (CARRY4
). Будет неразумно использовать для этого примитивы, разбросанные по всему кристаллу ПЛИС, ведь тогда придётся выполнять сложную трассировку сигнала, да и временные характеристики устройства так же пострадают (сигналу, идущему от предыдущего разряда к следующему, придётся проходить больший путь). Вместо этого, САПР будет пытаться разместить схему таким образом, чтобы использовались близлежащие примитивы ПЛИС, для получения оптимальных характеристик.
Что именно считается "оптимальным" зависит от двух вещей: настроек САПР и ограничений (constraints), учитываемых при построении итоговой схемы в ПЛИС. Ограничения сужают область возможных решений по размещению примитивов внутри ПЛИС под определенные характеристики (временны́е и физические). Например, можно сказать, внутри ПЛИС схема должна быть размещена таким образом, чтобы время прохождения по критическому пути не превышало 20ns
. Это временно́е ограничение. Также нужно сообщить САПР, к какой ножке ПЛИС необходимо подключить входы и выходы нашей схемы — это физическое ограничение.
Ограничения описываются не на языке описания аппаратуры, вместо этого используются текстовые файлы специального формата, зависящего от конкретной САПР.
Пример используемых ограничений для лабораторной по АЛУ под отладочную плату `Nexys A7-100T` (для упрощения восприятия, убраны старшие разряды переключателей и светодиодов, т.к. ограничения для каждого из разряда однотипны).
Строки, начинающиеся с #
являются комментариями.
Строки, начинающиеся с 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]
# ...
### 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]
# ...
## 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 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]
# ...
## Buttons
set_property -dict { PACKAGE_PIN C12 IOSTANDARD LVCMOS33 } [get_ports { resetn }]; #IO_L3P_T0_DQS_AD1P_15 Sch=cpu_resetn
После выполнения построения, нетлист и сама цифровая схема остаются неизменными, однако использованные для реализации схемы примитивы получают свой "адрес" внутри ПЛИС:
Рисунок 4. "Адрес" конкретного LUT-а в ПЛИС.
Теперь, мы можем посмотреть на "внутренности" нашей ПЛИС xc7a100tcsg324-1
и то, как через её примитивы будет реализована наша схема. Для этого необходимо открыть построенную схему: Implementation -> Open implemented design
. Откроется следующее окно:
Рисунок 5. Окно просмотра реализованного устройства.
Это содержимое ПЛИС. Просто из-за огромного количества содержащихся в ней примитивов, оно показана в таком масштабе, что все сливается в один цветной ковер. Большая часть этого окна неактивна (показана в темно-синих тонах) и это нормально, ведь мы реализовали крошечную цифровую схему, она и не должна занимать значительное количество ресурсов ПЛИС.
Нас интересует "бледно-голубая точка", расположенная в нижнем левом углу прямоугольника X0Y1
(выделено красным). Если отмасштабировать эту зону, мы найдем используемый нами LUT:
Рисунок 6. Расположение выбранного LUT-а внутри ПЛИС.
Кроме того, если поиграться со свойствами этого примитива, мы сможем найти нашу таблицу истинности, инициализирующую этот примитив.
Bitstream generation
После того, как САПР определил конкретные примитивы, их режим работы, и пути сигнала между ними, необходимо создать двоичный файл (bitstream), который позволит сконфигурировать ПЛИС необходимым нам образом. Получив этот файл, остается запрограммировать ПЛИС, после чего она воплотит разработанное устройство.
Итоги главы
Таким образом, маршрут перехода от HDL-описания устройства до его реализации в ПЛИС выглядит следующим образом:
- Сперва происходит этап предобработки (elaboration). В основные задачи этого этапа входит:
- развертывание иерархии модулей: преобразование иерархической структуры проекта в плоскую, что облегчает дальнейшие этапы обработки;
- проверка синтаксиса и семантики HDL-кода;
- разрешение параметров и констант;
- генерация промежуточного представления проекта, которое затем используется на следующих этапах. Полученное промежуточное представление проекта использует абстрактные элементы (логические вентили, мультиплексоры, регистры), которые не привязаны к конкретной ПЛИС.
- Затем выполняется этап синтеза (synthesis) нетлиста, который использует ресурсы конкретной ПЛИС. Все, использовавшиеся на предыдущем этапе структуры (регистры, мультиплексоры и прочие блоки) реализуются через примитивы ПЛИС (LUT-ы, D-триггеры, арифметические блоки и т.п.). Выполняется этап оптимизации логических сетей для минимизации занимаемой площади, уменьшения задержек и энергопотребления.
- После выполняется этап построения (implementation) конечной цифровой схемы, выполняющий несколько подэтапов:
- Размещение (Placement): определение конкретных местоположений для всех логических элементов в ПЛИС. Если на предыдущем этапе часть схемы была реализована через LUT, то на этом этапе решается какой именно LUT будет использован (имеется в виду не его тип, а какой из множества однотипных элементов будет выбран).
- Трассировка (Routing): создание соединений между элементами в соответствии с нетлистом.
- Временной анализ (Timing Analysis): Проверка временны́х характеристик для подтверждения, что все сигналы распространяются по цепи в допустимые временны́е рамки. Область допустимых решений для этапов "place & route" сужается путем наложения физических и временны́х ограничений (constraints).
- Последним этапом выполняется генерация двоичного файла конфигурации (bitstream generation), который во время прошивки сконфигурирует ПЛИС на реализацию построенной схемы.
Список источников
Лабораторная работа №1 "Сумматор"
Цель
Познакомиться с САПР Vivado и научиться реализовывать в нём простейшие схемотехнические модули с помощью конструкций языка SystemVerilog.
Материал для подготовки к лабораторной работе
Описание модулей на языке SystemVerilog.
Ход работы
- Изучение 1-битного сумматора;
- Воспроизведение примера по реализации и проверке полусумматора.
- Реализация и проверка полного 1-битного сумматора
- Изучение 4-битного сумматора;
- Реализация и проверка полного 4-битного сумматора;
- Реализация и проверка полного 32-битного сумматора.
Теория
Итогом лабораторной работы будет создание устройства, способного складывать два числа. Но перед тем, как учиться создавать подобное устройство, необходимо немного освоиться в самом процессе складывания чисел.
Давайте начнём с примера и сложим в столбик произвольную пару чисел, например 42 и 79:
2 + 9 = 11 ➨ 1 пишем, 1 "в уме"
4 + 7 + "1 в уме" = 12 ➨ 2 пишем, 1 "в уме"
0 + 0 + "1 в уме" = 1
Итого, 121.
Назовём то, что мы звали "1 в уме", переносом разряда.
Теперь попробуем сделать то же самое, только в двоичной системе исчисления. К примеру, над числами 3 и 5. Три в двоичной системе записывается как 011. Пять записывается как 101.
Поскольку в двоичной системе всего две цифры: 0 и 1, один разряд не может превысить 1. Складывая числа 1 и 1, вы получаете 2, что не умещается в один разряд, поэтому мы пишем 0 и держим 1 "в уме". Это снова перенос разряда. Поскольку в двоичной арифметике разряд называют битом, перенос разряда можно назвать переносом бита, а сам разряд, который перенесли — битом переноса.
Полный 1-битный сумматор
Полный 1-битный сумматор — это цифровое устройство, которое выполняет сложение двух 1-битных чисел и учитывает входной бит переноса. Это устройство имеет три входа: два слагаемых и входной бит переноса, а также два выхода: 1-битный результат суммы и выходной бит переноса.
Что такое входной бит переноса? Давайте вспомним второй этап сложения чисел 42 и 79:
4 + 7 + "1 в уме" = 12 ➨ 2 пишем, 1 "в уме"
+ "1 в уме" — это прибавление разряда, перенесённого с предыдущего этапа сложения.
Входной бит переноса — это бит, перенесённый с предыдущего этапа сложения двоичных чисел. Имея этот сигнал, мы можем складывать многоразрядные двоичные числа путём последовательного соединения нескольких 1-битных сумматоров: выходной бит переноса сумматора младшего разряда передастся на входной бит переноса сумматора старшего разряда.
Реализация одноразрядного сложения
Можно ли как-то описать сложение двух одноразрядных двоичных чисел с помощью логических операций? Давайте посмотрим на таблицу истинности подобной операции:
Таблица истинности одноразрядного сложения.
S
— это младший разряд 2-битного результата суммы, записываемый в столбце сложения под слагаемыми a
и b
. C
(carry, перенос) — это старший разряд суммы, записываемый левее, если произошёл перенос разряда. Как мы видим, перенос разряда происходит только в случае, когда оба числа одновременно равны единице. При этом значение S
обращается в 0
, и результат записывается как 10
, что в двоичной системе означает 2
. Кроме того, значение S
равно 0
и в случае, когда оба операнда одновременно равны нулю. Вы можете заметить, что S
равно нулю в тех случаях, когда а
и b
равны, и не равно нулю в противоположном случае. Подобным свойством обладает логическая операция Исключающее ИЛИ (eXclusive OR, XOR):
Таблица истинности операции Исключающее ИЛИ (XOR).
Для бита переноса всё ещё проще — он описывается операцией логическое И:
Таблица истинности операции И.
Давайте нарисуем цифровую схему, связывающую входные и выходные сигналы с помощью логических элементов, соответствующих ожидаемому поведению:
Рисунок 1. Цифровая схема устройства, складывающего два операнда с сохранением переноса (полусумматора).
Однако, в описании полного 1-битного сумматора сказано, что у него есть три входа, а в наших таблицах истинности и на схеме выше их только два (схема, представленная на рис. 1, реализует так называемый "полусумматор"). На самом деле, на каждом этапе сложения в столбик мы всегда складывали три числа: цифру верхнего числа, цифру нижнего числа, и единицу в случае переноса разряда из предыдущего столбца (если с предыдущего разряда не было переноса, прибавление нуля неявно опускалось).
Таким образом, таблица истинности немного усложняется:
Таблица истинности сигналов полного 1-битного сумматора.
Поскольку теперь у нас есть и входной и выходной биты переноса, для их различия добавлены суффиксы "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)
, где &
— логическое И, |
— логическое ИЛИ.
Цифровая схема устройства с описанным поведением выглядит следующим образом:
Рисунок 2. Цифровая схема полного 1-битного сумматора.
Практика
Реализуем схему полусумматора (рис. 1) в виде модуля, описанного на языке SystemVerilog.
Модуль half_adder
имеет два входных сигнала и два выходных. Входы a_i
и b_i
идут на два логических элемента: Исключающее ИЛИ и И, выходы которых подключены к выходам модуля sum_o
и carry_o
соответственно.
module half_adder(
input logic a_i, // Входные сигналы
input logic b_i,
output logic sum_o, // Выходные сигналы
output logic carry_o
);
assign sum_o = a_i ^ b_i;
assign carry_o = a_i & b_i;
endmodule
Листинг 1. SystemVerilog-код модуля half_adder.
По данному коду, САПР может реализовать следующую схему:
Рисунок 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(carry),
.sum_o (sum )
);
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-код тестбенча для модуля half_adder.
Рисунок 4. Временная диаграмма, моделирующая работу схемы с рис. 3.
В данной лабораторной работе вам предстоит реализовать схему полного 1-битного сумматора (рис. 2). Модуль полусумматора, код которого представлен в листинге 1 не используется в лабораторной работе (он был дан только в качестве примера).
Полный 4-битный сумматор
До этого мы реализовали только сложение одного столбца в столбик, теперь мы хотим реализовать всю операцию сложения в столбик. Как это сделать? Сделать ровно то, что делается при сложении в столбик: сначала сложить младший столбец, получить бит переноса для следующего столбца, сложить следующий и т.д.
Давайте посмотрим, как это будет выглядеть на схеме (для простоты, внутренняя логика 1-битного сумматора скрыта, но вы должны помнить, что каждый прямоугольник — это та же самая схема с рис. 2).
Рисунок 5. Схема 4-битного сумматора.
Фиолетовой линией на схеме показаны провода, соединяющие выходной бит переноса сумматора предыдущего разряда с входным битом переноса сумматора следующего разряда.
Как же реализовать модуль, состоящий из цепочки других модулей? Половину этой задачи мы уже сделали, когда писали тестбенч к 1-битному полусумматору в Листинге 2 — мы создавали модуль внутри другого модуля и подключали к нему провода. Теперь надо сделать то же самое, только с чуть большим числом модулей.
Для того, чтобы описать 4-битный сумматор, необходимо подключить четыре 1-битных подобно тому, как было описано в документе
, который вы изучали перед лабораторной работой.
Рисунок 6. Схема 4-битного сумматора, сгенерированная САПР Vivado.
Несмотря на запутанность схемы, если присмотреться, вы увидите, как от шин A, B и S отходят линии к каждому из сумматоров, а бит переноса передаётся от предыдущего сумматора к следующему. Для передачи битов переноса от одного сумматора к другому, потребуется создать вспомогательные провода, которые можно сгруппировать в один вектор (см. сигналы c[0]-c[2] на рис. 5).
Задание
Опишите полный 1-битный сумматор, схема которого представлена на Рис. 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(
input logic [31:0] a_i,
input logic [31:0] b_i,
input logic carry_i,
output logic [31:0] sum_o,
output 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
);
либо же можно создать массив 1-битных сумматоров.
Создание массива модулей схоже с созданием одного модуля за исключением того, что после имени сущности модуля указывается диапазон, определяющий количество модулей в массиве. При этом подключение сигналов к массиву модулей осуществляется следующим образом:
- если разрядность подключаемого сигнала совпадает с разрядностью порта модуля из массива, этот сигнал подключается к каждому из модулей в массиве;
- если разрядность подключаемого сигнала превосходит разрядность порта модуля из массива в
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. Пример создания массива модулей.
Порядок выполнения задания
- Создайте проект, согласно руководству по созданию проекта в Vivado
- Опишите модуль
fulladder
, схема которого представлена на Рис. 2.- Модуль необходимо описать с таким же именем и портами, как указано в задании.
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_01.tb_fulladder.sv
. Убедитесь по сигналам временной диаграммы, что модуль работает корректно. В случае обнаружения некорректного поведения сигналов суммы и выходного бита переноса, вам необходимо найти причину этого поведения, и устранить её. - Опишите модуль
fulladder4
, схема которого представлена на Рис. 5 и 6, используяиерархию модулей
, чтобы в нем выполнялось поразрядное сложение двух 4-битных чисел и входного бита переноса. Некоторые входы и выходы модуля будет необходимо описать в видевекторов
.- Модуль необходимо описать с таким же именем и портами, как указано в задании.
- Обратите внимание, что входной бит переноса должен подаваться на сумматор, выполняющий сложение нулевого разряда, выходной бит переноса соединяется с выходным битом переноса сумматора, выполняющего сложение 4-го разряда. Промежуточные биты переноса передаются с помощью вспомогательных проводов, которые необходимо создать самостоятельно.
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_01.tb_fulladder4.sv
. Убедитесь по сигналам временной диаграммы, что модуль работает корректно. В случае обнаружения некорректного поведения сигналов суммы и выходного бита переноса, вам необходимо найти причину этого поведения, и устранить её.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Опишите модуль
fulladder32
так, чтобы в нем выполнялось поразрядное сложение двух 32-битных чисел и входного бита переноса. Его можно реализовать через последовательное соединение восьми 4-битных сумматоров, либо же можно соединить 32 1-битных сумматора (как вручную, так и с помощью создания массива модулей).- Модуль необходимо описать с таким же именем и портами, как указано в задании.
- Обратите внимание, что входной бит переноса должен подаваться на сумматор, выполняющий сложение нулевого разряда, выходной бит переноса соединяется с выходным битом переноса сумматора, выполняющего сложение 31-го разряда.
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_01.tb_fulladder32.sv
. В случае, если в TCL-консоли появились сообщения об ошибках, вам необходимо найти и исправить их.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Проверьте работоспособность вашей цифровой схемы в ПЛИС.
Лабораторная работа №2. Арифметико-логическое устройство
Так как основной задачей процессора является обработка цифровых данных, одним из его основных блоков является арифметико-логическое устройство (АЛУ). Задача АЛУ производить над входными данным арифметические и поразрядно логические операции.
Цель
Используя навыки по описанию мультиплексоров, описать блок арифметико-логического устройства (АЛУ) на языке SystemVerilog.
Материалы для подготовки к лабораторной работе
В дополнение к материалам, изученным в ходе предыдущей лабораторной работы, вам рекомендуется ознакомиться с:
- способами описания мультиплексора на языке SystemVerilog.
Общий ход выполнения работы
- Изучить устройство и принцип работы АЛУ (раздел #теория)
- Изучить языковые конструкции SystemVerilog для реализации АЛУ (раздел #инструменты)
- Внимательно ознакомиться с заданием (раздел #задание)
- Описать модуль АЛУ, проверить его предоставленным верификационным окружением.
- Проверить работу АЛУ в ПЛИС.
Теория
Арифметико-логическое устройство (АЛУ, Arithmetic Logic Unit – ALU) – это блок процессора, выполняющий арифметические и поразрядно логические операции. Разница между арифметическими и логическими операциями в отсутствии у последних бита переноса, так как логические операции происходят между 1-битными числами и дают 1-битный результат, а в случае АЛУ (в рамках данной лабораторной работы) одновременно между 32-мя 1-битными парами чисел. В логических операциях результаты значений отдельных битов друг с другом никак не связаны.
Также, кроме результата операций, АЛУ формирует флаги, которые показывают выполняется ли заданное условие. Например, выведет 1
, если один операнд меньше другого.
Обычно АЛУ представляет собой комбинационную схему (то есть не имеет элементов памяти), на входы которой поступают информационные (операнды) и управляющие (код операции) сигналы, в ответ на что на выходе появляется результат заданной операции. АЛУ бывает не комбинационной схемой, но это скорее исключение.
Рисунок 1. Структурное обозначение элемента АЛУ[1, стр. 305].
На рис. 1 изображен пример АЛУ, используемый в книге "Цифровая схемотехника и архитектура компьютера" Харрис и Харрис. На входы A
и B
поступают операнды с разрядностью N. На 3-битный вход F
подается код операции. Например, если туда подать 000
, то на выходе Y
появится результат операции логическое И между битами операндов A
и B
. Если на F
подать 010
, то на выходе появится результат сложения. Это лишь пример, разрядность и коды могут отличаться в зависимости от количества выполняемых операций и архитектуры.
Существует несколько подходов к реализации АЛУ, отличающиеся внутренней организацией. В лабораторных работах применяется повсеместно используемый подход мультиплексирования операций, то есть подключения нескольких операционных устройств (которые выполняют какие-то операции, например сложения, логического ИЛИ и т.п.) к мультиплексору, который будет передавать результат нужного операционного устройства на выходы АЛУ.
Рассмотрим данный подход на примере все того же АЛУ MIPS из книги Харрисов. На рис. 2, в левой его части, изображена внутренняя организация этого АЛУ, справа – таблица соответствия кодов операциям. На выходе схемы (внизу) стоит 4-входовой мультиплексор, управляемый двумя из трех битов F
. К его входам подключены N логических И (побитовое И N-битных операндов), N логических ИЛИ, N-битный сумматор и Zero Extend – устройство, дополняющее слева нулями 1-битное число до N-битного.
Рисунок 2. Структурная схема АЛУ MIPS[1, стр. 305].
К одному из входов этих операционных устройств подключен без изменений вход A
, а ко второму подключен выход двухвходового мультиплексора, управляемого оставшимся битом F. То есть F[2]
определяет, что будет вторым операндом: B
или ~B
. Вдобавок F[2]
подается на входной перенос сумматора, то есть, когда F[2] == 1
на выходе сумматора появляется результат операции A + ~B + 1
, что (с учетом дополнительного кода) эквивалентно A – B
.
Посмотрим, что произойдет, если на вход 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
, как и требуется от этой операции.
Сравнение двух чисел несколько сложнее чем просто проверка старшего бита разности и зависит от того, сравниваем ли мы знаковые числа или беззнаковые. Если знаковые — то произошло ли переполнение. Для простоты схемы, принято, что схема реализует операцию SLT для знаковых пар чисел, разность которых не вызывает переполнения [2, 307].
Рисунок 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 [WIDTH : 0] sum;
assign sum = a + b;
assign overflow = sum[WIDTH];
endmodule
Листинг 1. Пример описания параметра в прототипе модуля.
В случае, если параметр не влияет на разрядность портов, его можно объявить в теле модуля:
module toaster(
input logic [31:0] command,
output logic power
)
parameter TOASTER_EN = 32'haf3c5bd0;
assign power = command == TOASTER_EN;
endmodule
Листинг 2. Пример описания параметра в теле модуля.
В случае АЛУ будет удобно использовать параметры для обозначения кодов команд. Во-первых, для того чтобы в case
не допустить ошибок, а во-вторых – чтобы можно было легко менять управляющие коды для повторного использования АЛУ в других проектах.
Сравните сами листинги 3 и 4:
//parameter SLT = 5'b00011;
//parameter BEQ = 5'b11000;
//...
always_comb
case(ALUOp)
//...
5'b00011: //... // вообще же ничего не понятно
5'b11000: //... // никуда не годится
Листинг 3. Пример описания модуля, использующего "магические" числа.
parameter SLT = 5'b00011;
parameter BEQ = 5'b11000;
//...
always_comb
case(ALUOp)
//...
SLT: //... // очень понятно
BEQ: //... // так лаконично и красиво
Листинг 4. Пример описания модуля, использующего параметры.
С параметрами смотрится гораздо взрослее, серьёзнее и понятнее. Кстати, сразу на заметку: в SystemVerilog можно объединять группу параметров в пакет (package), а затем импортировать его внутрь модуля, позволяя переиспользовать параметры без повторного их прописывания для других модулей.
Делается это следующим образом.
Сперва создается SystemVerilog-файл, который будет содержать пакет (к примеру, содержимое файла может быть таким):
package riscv_params_pkg;
parameter ISA_WIDTH = 32;
parameter ANOTHER_EX = 15;
endpackage
Далее, внутри модуля, которому нужны параметры из этого пакета, необходимо сделать соответствующий импорт этих параметров. Это можно сделать либо для каждого параметра отдельно, либо импортировать все параметры сразу:
module riscv_processor
//import riscv_params_pkg::*;
import riscv_params_pkg::ISA_WIDTH; // Если необходимо импортировать
(
//...Порты
);
import riscv_params_pkg::ANOTHER_EX; // все параметры в пакете, эти две строчки
// могут быть заменены закомментированной
// выше строкой:
endmodule
При реализации АЛУ, вам также потребуется использовать операции сдвига, к которым относятся:
<<
— логический сдвиг влево>>
— логический сдвиг вправо>>>
— арифметический сдвиг вправо
Особенности реализации сдвига
Для ВСЕХ операций сдвига вы должны брать только 5 младших бит операнда B.
Сами посмотрите: пятью битами можно описать 32 комбинации [0-31], а у операнда А будет использоваться ровно 32 бита. Это обязательное требование, поскольку старшие биты в дальнейшем будут использоваться по другому назначению и, если вы упустите это, ваш будущий процессор станет работать неправильно.
Задание
Необходимо на языке SystemVerilog реализовать АЛУ в соответствии со следующим прототипом:
module alu (
input logic [31:0] a_i,
input logic [31:0] b_i,
input logic [4:0] alu_op_i,
output logic flag_o,
output 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 | Операция |
---|---|---|---|
ADD | 0 0 000 | result_o = a_i + b_i | Сложение |
SUB | 0 1 000 | result_o = a_i – b_i | Вычитание |
SLL | 0 0 001 | result_o = a_i << b_i | Сдвиг влево |
SLTS | 0 0 010 | result_o = a_i < b_i (знаковое сравнение) | Знаковое сравнение |
SLTU | 0 0 011 | result_o = a_i < b_i | Беззнаковое сравнение |
XOR | 0 0 100 | result_o = a_i ^ b_i | Побитовое исключающее ИЛИ |
SRL | 0 0 101 | result_o = a_i >> b_i | Сдвиг вправо |
SRA | 0 1 101 | result_o = a_i >>> b_i | Арифметический сдвиг вправо (операнд a_i — знаковый) |
OR | 0 0 110 | result_o = a_i | b_i | Побитовое логическое ИЛИ |
AND | 0 0 111 | result_o = a_i & b_i | Побитовое логическое И |
Таблица 1. Список вычислительных операций.
Во второй таблице перечислены операции, вычисляющие значение сигнала flag_o
. При любом коде операции alu_op_i
не входящим в эту таблицу, сигнал flag_o
должен быть равен нулю.
alu_op_i | ={cmp, add/sub, alu_op_i} | flag_o | Операция |
---|---|---|---|
EQ | 1 1 000 | flag_o = (a_i == b_i) | Выставить флаг, если равны |
NE | 1 1 001 | flag_o = (a_i != b_i) | Выставить флаг, если не равны |
LTS | 1 1 100 | flag_o = a_i < b_i (знаковое сравнение) | Знаковое сравнение < |
GES | 1 1 101 | flag_o = a_i ≥ b_i (знаковое сравнение) | Знаковое сравнение ≥ |
LTU | 1 1 110 | flag_o = a_i < b_i | Беззнаковое сравнение < |
GEU | 1 1 111 | flag_o = a_i ≥ b_i | Беззнаковое сравнение ≥ |
Таблица 2. Список операций сравнения.
Выражения в этих двух таблицах приведены для примера. Не все из них можно просто переписать — часть этих выражений надо дополнить. Чтобы вы не копировали выражения, в них вставлены неподдерживаемые символы.
Несмотря на разделение на вычислительные операции, и операции сравнения, в Таблице 1 (вычислительных операция) оказалось две операции SLTS
и SLTU
, которые выполняют сравнения. В итоге у нас есть две похожие пары инструкций:
LTS
LTU
SLTS
SLTU
Первая пара инструкций вычисляет "ветвительный" результат. Результат операции будет подан на выходной сигнал flag_o
и использован непосредственно при ветвлении.
Вторые две инструкции используются для получения "вычислительного" результата. Т.е. результат сравнения будет подан на выходной сигнал result_o
так же, как подается результат операции ADD
, и будет использован в неких вычислениях, избегая при этом условного перехода.
К примеру, нам необходимо пройтись по массиву из миллиона элементов и убедиться, что все они были неотрицательны. Об этом будет сигнализировать переменная num_of_err
, значение которой должно быть равно числу элементов массива, меньших нуля. Вычислить значение этой переменной можно двумя способами:
- В каждой итерации цикла сделать ветвление: в одном случае инкрементировать переменную, в другом случае — нет (для ветвления использовать "ветвительную" операцию
LTS
). - В каждой итерации цикла складывать текущее значение переменной с результатом "вычислительной" операции
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
говорит САПР интерпретировать число, переданное в качестве операнда, как знаковое.
assign Result = $signed(A) >>> B[4:0];
В этом примере некоторому сигналу Result
присваивают результат сдвига знакового числа A
на значение количества бит получаемых из младших 5 бит сигнала B
.
Так как используются не все возможные комбинации управляющего сигнала АЛУ, то при описании через case
не забывайте использовать default
. Если описать АЛУ как задумано, то получится что-то похожее на рис. 4. Но не обязательно, зависит от вашего описания.
Рисунок 4. Пример схемы, реализующей АЛУ.
Порядок выполнения задания
- Добавьте в
Design Sources
проекта файлalu_opcodes_pkg.sv
. Этот файл содержит объявление пакетаalu_opcodes_pkg
, в котором прописаны все опкоды АЛУ.- Поскольку данный файл не содержит описания модулей, он не отобразится во вкладке
Hierarchy
окнаSources
Vivado (исключением может быть ситуация, когда в проекте вообще нет ни одного модуля). Добавленный файл можно будет найти во вкладкахLibraries
иCompile Order
. - Обратите внимание, что имена параметров кодов операций АЛУ, объявленных в добавляемом пакете, имеют префикс
ALU_
, которого не было в таблицах 1 и 2. - В случае, если вы добавили пакет в проект и импортировали его в модуле АЛУ, однако Vivado выдает ошибку о том, что используемые параметры не объявлены, попробуйте сперва исправить все остальные синтаксические ошибки и сохранить файл. Если и это не помогло, можно перейти на вкладку
Compile Order
, нажать правой кнопкой мыши по файлуalu_opcodes_pkg.sv
и выбратьMove to Top
. Таким образом, мы сообщаем Vivado, что при компиляции проекта, этот файл всегда необходимо собирать в первую очередь. Это вариант "последней надежды" и должен использоваться только в самом крайнем случае. Когда в проекте нет никаких проблем, Vivado всегда может самостоятельно определить правильный порядок компиляции файлов. Тот факт, что вам приходится менять этот порядок означает, что в проекте есть какие-то проблемы, не позволяющие Vivado определить правильный порядок самостоятельно.
- Поскольку данный файл не содержит описания модулей, он не отобразится во вкладке
- Опишите модуль
alu
с таким же именем и портами, как указано в задании.- Поскольку у вас два выходных сигнала, зависящих от сигнала
alu_op_i
, вам потребуется описать два разных мультиплексора (их лучше всего описывать через два отдельных блокаcase
). При описании, используйтеdefault
на оставшиеся комбинации сигналаalu_op_i
. - Следите за разрядностью ваших сигналов.
- Для реализации АЛУ, руководствуйтесь таблицей с операциями, а не схемой в конце задания, которая приведена в качестве референса. Обратите внимание, в одной половине операций
flag_o
должен быть равен нулю, в другойresult_o
(т.е. всегда либо один, либо другой сигнал должен быть равен нулю). Именно поэтому удобней всего будет описывать АЛУ в двух разных блокахcase
. - Вам не нужно переписывать опкоды из таблицы в качестве вариантов для блока
case
. Вместо этого используйте символьные имена с помощью параметров, импортированных из пакетаalu_opcodes_pkg
. - При операции сложения вы должны использовать ваш 32-битный сумматор из первой лабораторной (описывая вычитание сумматор использовать не надо, можно использовать
-
).- При подключении сумматора, на входной бит переноса необходимо подать значение
1'b0
. Если не подать значение на входной бит переноса, результат суммы будет не определен (т.к. не определено одно из слагаемых). - Выходной бит переноса при подключении сумматора можно не указывать, т.к. он использоваться не будет.
- При подключении сумматора, на входной бит переноса необходимо подать значение
- При реализации операций сдвига, руководствуйтесь особенностями реализации сдвигов.
- Поскольку у вас два выходных сигнала, зависящих от сигнала
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_02.tb_alu.sv
. В случае, если в TCL-консоли появились сообщения об ошибках, вам необходимо найти и исправить их.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Проверьте работоспособность вашей цифровой схемы в ПЛИС.
Список использованной литературы
- Д.М. Харрис, С.Л. Харрис / Цифровая схемотехника и архитектура компьютера / пер. с англ. Imagination Technologies / М.: ДМК Пресс, 2018.
- Д.М. Харрис, С.Л. Харрис / Цифровая схемотехника и архитектура компьютера: RISC-V / пер. с англ. В. С. Яценков, А. Ю. Романов; под. ред. А. Ю. Романова / М.: ДМК Пресс, 2021.
Лабораторная работа №3 "Регистровый файл и память инструкций"
Процессор — это программно-управляемое устройство, выполняющее обработку информации и управление этим процессом. Очевидно, программа, которая управляет процессором, должна где-то храниться. Данные, с которыми процессор работает, тоже должны быть в доступном месте. Нужна память!
Цель
Описать на языке SystemVerilog элементы памяти для будущего процессора:
- память инструкций;
- регистровый файл.
Материалы для подготовки к лабораторной работе
В дополнение к материалам, изученным в ходе предыдущих работ, вам рекомендуется ознакомиться с:
- способами описания регистров на языке SystemVerilog.
Ход работы
- Изучить способы организации памяти (раздел #теория про память).
- Изучить конструкции SystemVerilog для реализации запоминающих элементов (раздел #инструменты).
- Реализовать модули памяти инструкции и регистрового файла.
- Проверить с помощью верификационного окружения корректность их работы.
- Проверить работу регистрового файла в ПЛИС.
Теория про память
Память — это устройство для упорядоченного хранения и выдачи информации. Различные запоминающие устройства отличаются способом и организацией хранения данных. Базовыми характеристиками памяти являются:
- 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
— в этот момент данные будут записаны по указанному адресу.
Так же возможна реализация, в которой вход write_data
и выход read_data
объединены в единый вход/выход data
. В этом случае операции чтения и записи разделены во времени и используют для этого один единый порт ввода-вывода (inout
, двунаправленный порт) data
.
Рисунок 1. Примеры блоков ПЗУ и ОЗУ.
Кроме того, различают память с синхронным и асинхронным чтением. В первом случае, перед выходным сигналом шины данных ставится дополнительный регистр, в который по тактовому синхроимпульсу записываются запрашиваемые данные. Такой способ может значительно сократить критический путь цифровой схемы, но требует дополнительный такт на доступ в память. В свою очередь, асинхронное чтение позволяет получить данные, не дожидаясь очередного синхроимпульса, но такой способ увеличивает критический путь.
Еще одной характеристикой памяти является количество доступных портов чтения или записи (не путайте с портами модуля, которые являются любыми его входными/выходными сигналами). Количество портов определяет к скольким ячейкам памяти можно обратиться одновременно. Проще говоря, сколько входов адреса существует. Все примеры памяти рассмотренные выше являются однопортовыми, то есть у них один порт. Например, если у памяти 2 входа адреса addr1
и addr2
— это двухпортовая память. При этом не важно, можно ли по этим адресам только читать/писать или выполнять обе операции.
Регистровый файл, который будет реализован в рамках данной работы, является трехпортовым, и имеет 2 порта на чтение и 1 порт на запись.
С точки зрения аппаратной реализации память в ПЛИС может быть блочной, распределенной или регистровой. Блочная память — это аппаратный блок памяти, который можно сконфигурировать под свои нужды. Распределенная и регистровая память (в отличие от блочной) реализуется на конфигурируемых логических блоках (см. как работает ПЛИС). Такая память привязана к расположению конфигурируемых логических блоков ПЛИС и как бы равномерно распределена по всему кристаллу. Вместо реализации логики конфигурируемые логические блоки используются для нужд памяти. Чтобы понять почему это возможно, рассмотрим структуру логического блока:
Рисунок 2. Структурная схема логического блока в ПЛИС[1].
В логическом блоке есть таблицы подстановки (Look Up Table, LUT), которые представляют собой не что иное как память, которая конфигурируется под нужды хранения, а не реализацию логики. Таким образом, трехвходовой LUT может выступать в роли 8-битной памяти.
Однако 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 хоть и совпадает по
// размеру с предыдущими реализациями,
// но отличается по адресному пространству
// обращение по нулевому адресу выдаст
// недетерминированный результат. Это не
// значит, что память будет плохой или
// дефектной, просто надо учитывать эту её
// особенность.
Листинг 1. Пример создания массива ячеек.
В приведенном листинге 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
Листинг 2. Пример описания портов памяти.
В случае реализации ПЗУ нет необходимости в описании входов для записи, поэтому описание памяти занимает всего пару строк. Чтобы проинициализировать такую память (то есть поместить в неё начальные значения, которые можно было бы считать), требуемое содержимое нужно добавить к прошивке, вместе с которой данные попадут в ПЛИС. Для этого в проект добавляется текстовый файл формата .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); // поместить в память ROM содержимое
end // файла rom_data.mem
assign read_data1 = ROM[addr1]; // реализация первого порта на чтение
assign read_data2 = ROM[addr2] // реализация второго порта на чтение
endmodule
Листинг 3. Пример использования инициализирующей функции $readmemh.
Содержимое файла rom_data.mem
, к примеру, может быть таким (каждая строка соответствует значению отдельной ячейки памяти, начиная со стартового адреса):
FA
E6
0D
15
A7
Для того, чтобы при сборке модуля не было проблем с путями, по которым будет искаться данный файл, обычно его необходимо добавить в проект. В случае Vivado, чтобы тот распознал этот файл как инициализирующий память, необходимо чтобы у этого файла было расширение .mem
.
Задание по реализации памяти
Необходимо описать на языке SystemVerilog два вида памяти:
- память инструкций;
- регистровый файл.
1. Память инструкций
У данного модуля будет два входных/выходных сигнала:
- 32-битный вход адреса
- 32-битный выход данных (асинхронное чтение)
module instr_mem(
input logic [31:0] read_addr_i,
output logic [31:0] read_data_o
);
Несмотря на разрядность адреса, на практике, внутри данного модуля вы должны будете реализовать память с 512-ю 32-битными ячейками (в ПЛИС попросту не хватит ресурсов на реализации памяти с 232 ячеек). Таким образом, реально будет использоваться только 9 бит адреса.
При этом по спецификации процессор RISC-V использует память с побайтовой адресацией [2, стр. 15]. Байтовая адресация означает, что процессор способен обращаться к отдельным байтам в памяти (за каждым байтом памяти закреплен свой индивидуальный адрес).
Однако, если у памяти будут 32-битные ячейки, доступ к конкретному байту будет осложнен, ведь каждая ячейка — это 4 байта. Как получить данные третьего байта памяти? Если обратиться к третьей ячейке в массиве — придут данные 12-15-ых байт (поскольку каждая ячейка содержит по 4 байта). Чтобы получить данные третьего байта, необходимо разделить значение пришедшего адреса на 4 (отбросив остаток от деления). 3 / 4 = 0
— и действительно, если обратиться к нулевой ячейке памяти — будут получены данные 3-го, 2-го, 1-го и 0-го байт. То, что помимо значения третьего байта есть еще данные других байт нас в данный момент не интересует, важна только сама возможность указать адрес конкретного байта.
Рисунок 3. Связь адреса байта и индекса слова в массиве ячеек памяти.
Деление на 2n можно осуществить, отбросив n
младших бит числа. Учитывая то, что для адресации 512 ячеек памяти мы будем использовать 9 бит адреса, память инструкций должна выдавать на выход данные, расположенные по адресу addr_i[10:2]
.
Несмотря на зафиксированный заданием размер памяти инструкций в 512 32-битных ячейки, на практике удобно параметризовать это значение, чтобы в ситуациях, когда требуется меньше или больше памяти можно было получить обновленный модуль, не переписывая код во множестве мест. Подобное новшество вы сможете оценить на практике, получив возможность существенно сокращать время синтеза процессора, уменьшая размер памяти до необходимого минимума путем изменения значения одного лишь параметра.
Для этого можно, например, создать параметр: INSTR_MEM_SIZE_BYTES
, показывающий размер памяти инструкций в байтах. Однако, поскольку у данной памяти 32-битные ячейки, нам было бы удобно иметь и параметр INSTR_MEM_SIZE_WORDS
, который говорит сколько в памяти 32-битных ячеек.
При этом INSTR_MEM_SIZE_WORDS = INSTR_MEM_SIZE_BYTES / 4
(т.е. в 32-битном слове 4 байта).
В случае подобной параметризации, необходимо иметь возможность подстраивать количество используемых бит адреса. Для 512 ячеек памяти мы использовали 9 бит адреса, для 1024 ячеек нам потребуется уже 10 бит. Нетрудно заметить, что нам нужно такое число бит данных, возведя в степень которого 2
, мы получим размер нашей памяти (либо число, превышающее этот размер в случае, если размер памяти не является степенью двойки). Иными словами, нам нужен логарифм по основанию 2 от размера памяти, с округлением до целого вверх. И неудивительно, что в SystemVerilog есть специальная конструкция, которая позволяет считать подобные числа. Эта конструкция называется $clog2
(с
означает "ceil" — операцию округления вверх).
Поскольку реализация памяти состоит буквально из нескольких строчек, но при этом использование параметров может вызвать некоторые затруднения, код памяти инструкций предоставляется в готовом виде:
module instr_mem
import memory_pkg::INSTR_MEM_SIZE_BYTES;
import memory_pkg::INSTR_MEM_SIZE_WORDS;
(
input logic [31:0] read_addr_i,
output logic [31:0] read_data_o
);
logic [31:0] ROM [INSTR_MEM_SIZE_WORDS]; // создать память с
// <INSTR_MEM_SIZE_WORDS>
// 32-битных ячеек
initial begin
$readmemh("program.mem", ROM); // поместить в память ROM содержимое
end // файла program.mem
// Реализация асинхронного порта на чтение, где на выход идёт ячейка памяти
// инструкций, расположенная по адресу read_addr_i, в котором обнулены два
// младших бита, а также биты, двоичный вес которых превышает размер памяти
// данных в байтах.
// Два младших бита обнулены, чтобы обеспечить выровненный доступ к памяти,
// в то время как старшие биты обнулены, чтобы не дать обращаться в память
// по адресам несуществующих ячеек (вместо этого будут выданы данные ячеек,
// расположенных по младшим адресам).
assign read_data_o = ROM[read_addr_i[$clog2(INSTR_MEM_SIZE_BYTES)-1:2]];
endmodule
Листинг 4. SystemVerilog-описание памяти инструкций.
3. Регистровый файл
На языке SystemVerilog необходимо реализовать модуль регистрового файла для процессора с архитектурой RISC-V, представляющего собой трехпортовое ОЗУ с двумя портами на чтение и одним портом на запись и состоящей из 32-х 32-битных регистров, объединенных в массив с именем rf_mem
.
У данного модуля будет восемь входных/выходных сигналов:
- вход тактового синхроимпульса
- вход сигнала разрешения записи
- 5-битный вход первого адреса чтения
- 5-битный вход второго адреса чтения
- 5-битный вход адреса записи
- 32-битный вход данных записи
- 32-битный выход данных асинхронного чтения по первому адресу
- 32-битный выход данных асинхронного чтения по второму адресу
module register_file(
input logic clk_i,
input logic write_enable_i,
input logic [ 4:0] write_addr_i,
input logic [ 4:0] read_addr1_i,
input logic [ 4:0] read_addr2_i,
input logic [31:0] write_data_i,
output logic [31:0] read_data1_o,
output logic [31:0] read_data2_o
);
По адресу 0
должно всегда считываться значение 0
вне зависимости от того, какое значение в этой ячейке памяти, и есть ли она вообще. Такая особенность обусловлена тем, что при выполнении операций очень часто используется ноль (сравнение с нулем, инициализация переменных нулевым значением, копирование значения одного регистра в другой посредством сложения с нулем и записи результата и т.п.). Эту особенность регистрового файла можно реализовать несколькими способами:
- можно решить эту задачу с помощью мультиплексора, управляющим сигналом которого является сигнал сравнения адреса на чтение с нулем;
- либо же можно проинициализировать нулевую ячейку памяти нулем с запретом записи в неё каких-либо значений. В этом случае в ячейке всегда будет ноль, а значит и считываться с нулевого адреса будет только он.
Инициализация ячейки памяти может быть осуществлена (только при проектировании под ПЛИС) с помощью присваивания в блоке initial
.
Порядок выполнения работы
- Добавьте в проект файл
memory_pkg.sv
. Этот файл содержит объявление пакетаmemory_pkg
, в котором прописаны размеры памяти инструкций и памяти данных (реализуется позднее). - Реализуйте память инструкций посредством описания, представленного в листинге 4.
- Опишите регистровый файл с таким же именем и портами, как указано в задании.
- Обратите внимание, что имя памяти (не название модуля, а имя массива регистров внутри модуля) должно быть
rf_mem
. Такое имя необходимо для корректной работы верификационного окружения. - Как и у памяти инструкций, порты чтения регистрового файла должны быть асинхронными.
- Не забывайте, что у вас 2 порта на чтение и 1 порт на запись, при этом каждый порт не зависит от остальных (в модуле 3 независимых входа адреса).
- Чтение из нулевого регистра (чтение по адресу 0) всегда должно возвращать нулевое значение. Этого можно добиться двумя путями:
- Путем добавления мультиплексора перед выходным сигналом чтения (мультиплексор будет определять, пойдут ли на выход данные из ячейки регистрового файла, либо, в случае если адрес равен нулю, на выход пойдет константа ноль).
- Путем инициализации нулевого регистра нулевым значением и запретом записи в этот регистр (при записи и проверки write_enable добавить дополнительную проверку на адрес).
- Каким образом будет реализована эта особенность регистрового файла не важно, выберите сами.
- Обратите внимание, что имя памяти (не название модуля, а имя массива регистров внутри модуля) должно быть
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_03.tb_register_file.sv
. В случае, если в TCL-консоли появились сообщения об ошибках, вам необходимо найти и исправить их.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Проверьте работоспособность вашей цифровой схемы в ПЛИС.
Источники
- Field-programmable gate array
- The RISC-V Instruction Set Manual Volume I: Unprivileged ISA, Document Version 20240411, Editors Andrew Waterman and Krste Asanović, RISC-V Foundation, April 2024
Лабораторная работа №4 "Простейшее программируемое устройство"
В этой лабораторной работе, на основе ранее разработанных блоков памяти и АЛУ, вы соберете простой учебный процессор с архитектурой CYBERcobra 3000 Pro 2.1
. Это нужно для более глубокого понимания принципов работы программно-управляемых устройств, чтобы проще было понять архитектуру RISC-V в будущем.
Материалы для подготовки к лабораторной работе
В дополнение к материалам, изученным в ходе предыдущих работ, вам рекомендуется ознакомиться с:
- Оператором конкатенации (Concatenation.md).
Цель
Реализовать простейшее программируемое устройство с архитектурой CYBERcobra 3000 Pro 2.1
Ход работы
- Изучить принцип работы процессоров (соответствующий раздел #теории)
- Познакомиться с архитектурой и микроархитектурой
CYBERcobra 3000 Pro 2.1
(раздел про эту #архитектуру) - Изучить необходимые для описания процессора конструкции SystemVerilog (раздел #инструменты)
- Реализовать процессор с архитектурой
CYBERcobra 3000 Pro 2.1
(#задание по разработке аппаратуры) - Проверить работу процессора в ПЛИС.
Доп. задание, выполняемое дома:
- Написать программу для процессора и на модели убедиться в корректности её выполнения (Индивидуальное задание).
Теория про программируемое устройство
В обобщенном виде, процессор включает в себя память, АЛУ, устройство управления и интерфейсную логику для организации ввода/вывода. Также, в процессоре есть специальный регистр PC
(Program Counter – счётчик команд), который хранит в себе число – адрес ячейки памяти, где хранится инструкция, которую нужно выполнить. Инструкция тоже представляет собой число, в котором закодировано что нужно сделать
и с чем это нужно сделать
.
Алгоритм работы процессора следующий:
- из памяти считывается инструкция по адресу
PC
; - устройство управления декодирует полученную инструкцию (то есть определяет какую операцию нужно сделать, где взять операнды и куда разместить результат);
- декодировав инструкцию, устройство управления выдает всем блокам процессора (АЛУ, регистровый файл, мультиплексоры) соответствующие управляющие сигналы, тем самым выполняя эту инструкцию;
- изменяется значение
PC
; - цикл повторяется с
п.1
.
Любая инструкция приводит к изменению состояния памяти. В случае процессора с архитектурой CYBERcobra 3000 Pro 2.1
есть два класса инструкций: одни изменяют содержимое регистрового файла — это инструкции записи. Другие изменяют значение PC
— это инструкции перехода. В первом случае используются вычислительные инструкции и инструкции загрузки данных из других источников. Во-втором случае используются инструкции перехода.
Если процессор обрабатывает вычислительную инструкцию, то PC
перейдет к следующей по порядку инструкции. В ЛР№3 мы реализовали память инструкций с побайтовой адресацией. Это означает, что каждый байт памяти имеет свой собственный адрес. Поскольку длина инструкции составляет 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.
Простота архитектуры 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.
Для того, чтобы номера таблиц и рисунков лучше соотносились друг с другом и сопутствующим текстом, первая схема разрабатываемой микроархитектуры будет обозначена как Рисунок 0. Все последующие схемы будут совпадать по нумерации с таблицами, обозначающими способ кодирования инструкций.
Рисунок 0. Размещение на схеме основных блоков.
Для компактности схемы, названия портов регистрового файла сокращены (RA1
обозначает read_addr1_i
и т.п.).
Кодирование вычислительных инструкций
Чтобы добавить поддержку каких-либо инструкций, необходимо договориться как они будут кодироваться (эта часть относится к вопросам архитектуры). Вычислительные инструкции требуют следующую информацию:
- по каким адресам регистрового файла лежат операнды?
- по какому адресу будет сохранен результат?
- какая операция должна быть выполнена?
Для этого в инструкции были выбраны следующие поля: 5 бит ([27:23]
) для кодирования операции на АЛУ, два раза по 5 бит для кодирования адресов операндов в регистровом файле ([22:18]
и [17:13]
) и 5 бит для кодирования адреса результата ([4:0]
). Таблица 1 демонстрирует деление 32-битной инструкции на поля alu_op
, RA1
, RA2
и WA
.
Таблица 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_o
памяти инструкции (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
, что соответствует операции логического ИЛИ (см ЛР№2), WA = 11100
, то есть запись произойдёт в 28-ой регистр, RA1 = 00100
и RA2 = 01000
— это значит что данные для АЛУ будут браться из 4-го и 8-го регистров соответственно.
Рис. 1 иллюстрирует фрагмент микроархитектуры, поддерживающий вычислительные операции на АЛУ. Поскольку другие инструкции пока что не поддерживаются, то вход WE
регистрового файла просто равен 1
(это временно).
Рисунок 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
и rf_const
, с перекрытием полей.
Таблица 2. Добавление кодирования источника записи и 23-битной константы.
reg_file[WA] ← rf_const
На рис. 2 приводится фрагмент микроархитектуры, поддерживающий вычислительные операции на АЛУ и загрузку констант из инструкции в регистровый файл.
Так как вход записи уже занят результатом операции АЛУ, его потребуется мультиплексировать со значением константы из инструкции, которая предварительно знакорасширяется в блоке SE
. На входе WD
регистрового файла появляется мультиплексор, управляемый 28-м битом инструкции, который и определяет, что будет записано: константа или результат вычисления на АЛУ.
Например, в такой реализации следующая 32-битная инструкция поместит константу -1
в регистр по адресу 5
:
000 0 11111111111111111111111 00101
|WS| RF_const | WA |
Рисунок 2. Добавление константы из инструкции в качестве источников записи в регистровый файл.
Реализация загрузки в регистровый файл данных с внешних устройств
Чтобы процессор мог взаимодействовать с внешним миром добавим возможность загрузки данных с внешних устройств в регистр по адресу WA
. Появляется третий тип инструкции, который определяет третий источник ввода для регистрового файла. Одного бита WS
для выбора одного из трех источников будет недостаточно, поэтому расширим это поле до 2 бит. Теперь, когда WS == 0
будет загружаться константа, когда WS == 1
– будет загружаться результат вычисления АЛУ, а при WS == 2
будут загружаться данные с внешних устройств. Остальные поля в данной инструкции не используются.
Таблица 3. Кодирование в инструкции большего числа источников записи.
reg_file[WA] ← sw_i
На рис. 3 приводится фрагмент микроархитектуры, поддерживающий вычислительные операции на АЛУ, загрузку констант из инструкции в регистровый файл и загрузку данных с внешних устройств.
По аналогии с загрузкой констант увеличиваем входной мультиплексор до 4 входов и подключаем к нему управляющие сигналы – [29:28]
биты инструкции. Последний вход используется, чтобы разрешить неопределённость на выходе при WS == 3
(default
-вход, см. мультиплексор).
Выход OUT подключается к первому порту на чтение регистрового файла. Значение на выходе OUT будет определяться содержимым ячейки памяти по адресу RA1
.
Рисунок 3. Подключение к схеме источников ввода и вывода.
Реализация условного перехода
С реализованным набором инструкций полученное устройство нельзя назвать процессором – пока что это продвинутый калькулятор. Добавим поддержку инструкции условного перехода, при выполнении которой программа будет перепрыгивать через заданное количество команд. Чтобы аппаратура отличала эту инструкцию от других будем использовать 30-ый бит B
(branch
). Если B == 1
, значит это инструкция условного перехода и, если условие перехода выполняется, к PC
надо прибавить константу. Если B == 0
, значит это какая-то другая инструкция и к PC
надо прибавить 4.
Таблица 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
.
Сигнальные линии, которые управляют АЛУ и подают на его входы операнды уже существуют. Поэтому на схему необходимо добавить только логику управления мультиплексором на входе сумматора счётчика команд так. Эта логика работает следующим образом:
- если сейчас инструкция условного перехода
- и если условие перехода выполнилось,
то к PC
прибавляется знакорасширенная константа, умноженная на 4. В противном случае, к PC
прибавляется 4.
Так как теперь не любая инструкция приводит к записи в регистровый файл, появляется необходимость управлять входом WE
так, чтобы при операциях условного перехода запись в регистровый файл не производилась. Это можно сделать, подав на WE значение !B
(запись происходит, если сейчас не операция условного перехода).
Рисунок 4. Реализация условного перехода.
Реализация безусловного перехода
Осталось добавить поддержку инструкции безусловного перехода, для идентификации которой используется оставшийся 31-ый бит J
(jump). Если бит J == 1
, то это безусловный переход, и мы прибавляем к PC
знакорасширенную константу смещения, умноженную на 4 (как это делали и в условном переходе).
Таблица 5. Кодирование безусловного перехода.
PC ← PC + const*4
Для реализации безусловного перехода, нам необходимо добавить дополнительную логику управления мультиплексором перед сумматором. Итоговая логика его работы звучит так:
- Если сейчас инструкция безусловного перехода
- ИЛИ если сейчас инструкция условного перехода
- И если условие перехода выполнилось
Кроме того, при безусловном переходе в регистровый файл так же ничего не пишется. А значит, необходимо обновить логику работы сигнала разрешения записи WE
, который будет равен 0 если сейчас инструкция условного или безусловного перехода.
На рис. 5 приводится итоговый вариант микроархитектуры процессора CYBERcobra 3000 Pro 2.1
.
Рисунок 5. Реализация безусловного перехода.
Финальный обзор
Итого, архитектура CYBERcobra 3000 Pro 2.1
поддерживает 5 типов инструкций, которые кодируются следующим образом (иксами помечены биты, которые не задействованы в данной инструкции):
- 10 вычислительных инструкций
0 0 01 alu_op RA1 RA2 xxxx xxxx WA
- Инструкция загрузки константы
0 0 00 const WA
- Инструкция загрузки из внешних устройств
0 0 10 xxx xxxx xxxx xxxx xxxx xxxx WA
- Безусловный переход
1 x xx xxx xxxx xxxx xxxx const xxxxx
- 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 CYBERcobra (
input logic clk_i,
input logic rst_i,
input logic [15:0] sw_i,
output logic [31:0] out_o
);
endmodule
Порядок выполнения задания
- Добавьте в
Design Sources
проекта файл program.mem. - Опишите модуль
CYBERcobra
с таким же именем и портами, как указано в задании (обратите внимание на регистр имени модуля).- В первую очередь, необходимо создать счётчик команд и все вспомогательные провода. При создании, следите за разрядностью.
- Затем, необходимо создать экземпляры модулей: памяти инструкции, АЛУ, регистрового файла и сумматора. При подключении сигналов сумматора, надо обязательно надо подать нулевое значение на входной бит переноса. Выходной бит переноса подключать не обязательно. Объекту памяти инструкций нужно дать имя
imem
. - После этого, необходимо описать оставшуюся логику:
- Программного счётчика
- Сигнала управления мультиплексором, выбирающим слагаемое для программного счётчика
- Сигнала разрешения записи в регистровый файл
- Мультиплексор, выбирающий слагаемое для программного счётчика
- Мультиплексор, выбирающий источник записи в регистровый файл.
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_04.tb_cybercobra.sv
.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
. - В этот раз, в конце не будет сообщения о том, работает ли ваше устройство или в нем есть ошибки. Вам необходимо самостоятельно проверить работу модуля, перенеся его внутренние сигналы на временную диаграмму, и изучив их поведение.
- По сути, проверка сводится к потактовому изучению временной диаграммы, во время которого вам нужно циклично ответить на следующие вопросы (после чего необходимо сравнить предсказанный ответ со значением сигналов на временной диаграмме):
- Какое сейчас значение программного счётчика?
- Какое должно быть значение у ячейки памяти инструкций с адресом, соответствующим значению программного счётчика. Какой инструкции соответствует значение этой ячейки памяти?
- Как должно обновиться содержимое регистрового файла в результате выполнения этой инструкции: должно ли записаться какое-либо значение? Если да, то какое и по какому адресу?
- Как должен измениться программный счётчик после выполнения этой инструкции?
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Проверьте работоспособность вашей цифровой схемы в ПЛИС.
После выполнения задания по реализации процессора, необходимо также выполнить индивидуальное задание по написанию двоичной программы под созданный вами процессор.
Дерзайте!
Лабораторная работа №5 "Декодер инструкций"
Устройство управления (УУ) – один из базовых блоков процессора, функцией которого является декодирование инструкций и выдача управляющих сигналов для всех блоков процессора. Роль УУ в данном курсе (с некоторыми оговорками) будет играть декодер инструкций.
Цель
Описать на языке SystemVerilog блок декодера инструкций для однотактного процессора с архитектурой RISC-V.
Материалы для подготовки к лабораторной работе
- Форматы кодирования инструкций базового набора команд
RV32I
. - Теорию по регистрам контроля и статуса.
- Различия между блокирующими и неблокирующими присваиваниями.
Ход работы
- Изучить микроархитектуру реализуемого процессорного ядра.
- Разобраться с логикой формирования управляющих сигналов для всех типов инструкций.
- Изучить описание сигналов декодера инструкций.
- Изучить набор поддерживаемых инструкций RISC-V и способы их кодирования
- Изучить конструкции SystemVerilog, с помощью которых будет описан декодер (#инструменты)
- Реализовать на языке SystemVerilog декодер инструкций (#задание)
- Проверить с помощью верификационного окружения корректность его работы.
Предлагаемая микроархитектура процессора RISC-V
На рис. 1 приводится микроархитектура реализуемого ядра процессора RISC-V.
Приведенная архитектура не является заданием для текущей лабораторной работы, лишь отражает то, как в дальнейшем будет подключаться и использоваться реализуемый в данной лабораторной работе декодер.
Рисунок 1. Микроархитектура будущего процессорного ядра.
Предложенная микроархитектура похожа на микроархитектуру процессора CYBERcobra 3000 Pro 2.0 из ЛР№4, но с некоторыми изменениями.
В первую очередь изменились входы и выходы процессора:
- память инструкций вынесена наружу, таким образом, у процессора появляются входы и выходы:
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
, приостанавливающий обновление программного счётчика.
Кроме того, появилось два новых модуля: Interrupt Controller и Control Status Registers. Эти модули будут обеспечивать поддержку прерываний в процессорной системе.
Так же добавились источники операндов АЛУ: программный счетчик, множество констант из инструкций и микроархитектурных констант — а значит необходимо мультиплексировать эти сигналы.
Изменились и источники записи в регистровый файл, теперь это:
- результат операции на АЛУ;
- данные, считанные с внешней памяти;
- данные из модуля регистров контроля и статуса.
Для того, чтобы управлять усложнившимся набором мультиплексоров, интерфейсом памяти данных и появившимися модулями нужно специальное устройство — Устройство управления (УУ). В данной микроархитектуре логика устройства управления не вынесена в отдельный модуль, лишь выделена на схеме синим цветом. По большей части, в предложенной микроархитектуре роль устройства управления выполняет декодер инструкций.
Описание сигналов декодера инструкций
Список портов декодера инструкций и их назначение представлен в таблице 1.
Название сигнала | Пояснение |
---|---|
fetched_instr_i | Инструкция, подлежащая декодированию |
a_sel_o | Управляющий сигнал мультиплексора для выбора первого операнда АЛУ |
b_sel_o | Управляющий сигнал мультиплексора для выбора второго операнда АЛУ |
alu_op_o | Операция АЛУ |
csr_op_o | Операция модуля CSR |
csr_we_o | Разрешение на запись в CSR |
mem_req_o | Запрос на доступ к памяти (часть интерфейса памяти) |
mem_we_o | Сигнал разрешения записи в память, «write enable» (при равенстве нулю происходит чтение) |
mem_size_o | Управляющий сигнал для выбора размера слова при чтении-записи в память (часть интерфейса памяти) |
wb_sel_o | Управляющий сигнал мультиплексора для выбора данных, записываемых в регистровый файл |
gpr_we_o | Сигнал разрешения записи в регистровый файл |
branch_o | Сигнал об инструкции условного перехода |
jal_o | Сигнал об инструкции безусловного перехода jal |
jalr_o | Сигнал об инструкции безусловного перехода jalr |
mret_o | Сигнал об инструкции возврата из прерывания/исключения mret |
illegal_instr_o | Сигнал о некорректной инструкции |
Таблица 1. Описание портов декодера инструкций.
У данного модуля будет лишь один вход: fetched_instr_i
— декодируемая в данный момент инструкция. Все остальные сигналы — это выходы модуля, которые можно классифицировать следующим образом:
Сигналы кода операции
В данный класс будут входить сигналы, которые сообщают отдельному функциональному блоку о том, какую из операций он должен выполнить. Таких блока два: АЛУ и модуль регистров контроля и статуса. АЛУ может выполнять одну из 16 операций, представленных в ЛР№2, для выбора которой и нужен подобный сигнал. Вы ещё не знакомы с появившимся в микроархитектуре модулем регистров контроля и статуса, однако на текущий момент нужно лишь понимать, что он тоже может выполнять одну из нескольких операций и что для этого ему нужен специальный сигнал.
Таким образом, в класс сигналов кода операции входят:
alu_op_o
,csr_op_o
.
Для удобства использования, возможные значения этих сигналов определены в виде параметров в пакетах alu_opcodes_pkg
и csr_pkg
соответственно.
Управляющие сигналы мультиплексоров стадии выполнения и записи результата
В данный класс входят сигналы, управляющие мультиплексорами, размещенными в правой части схемы:
a_sel_o
,b_sel_o
,wb_sel_o
.
Сигналы a_sel_o
и b_sel_o
определяют откуда пойдут данные на операнды АЛУ a_i
, b_i
соответственно. К примеру, если мы хотим, чтобы оба операнда брались из регистрового файла, нам необходимо подать значение 0
на оба соответствующих мультиплексора.
Сигнал wb_sel_o
определяет источник данных для записи в регистровый файл: это либо результат операции на АЛУ, считанные данные из памяти данных, либо же данные, полученные из модуля регистров контроля и статуса.
Интерфейс памяти
Память данных используется для хранения и доступа к информации, необходимой для выполнения программы. Несмотря на то что такая память, как и регистровый файл используются для хранения данных, назначение этих модулей различно: регистровый файл используется для хранения данных, работа над которыми осуществляется здесь и сейчас (в пределах нескольких инструкций процессора), в то время как память данных хранит всю остальную информацию, которая не может уместиться в регистровый файл в виду ограниченности его размера.
Для взаимодействия с подсистемой памяти данных декодер инструкций будет использовать следующие сигналы:
mem_req_o
— этот сигнал должен быть выставлен в 1 каждый раз, когда необходимо обратиться к памяти (считать или записать данные);mem_we_o
— этот сигнал должен быть выставлен в 1, если необходимо записать данные в память, (0 при чтении);mem_size_o
— этот сигнал указывает размер порции данных для передачи (возможные значения этого сигнала указаны в Таблице 2). Для удобства использования, данные значения определены в виде параметров в пакетеdecoder_pkg
.
Параметр | Значение mem_size_o | Пояснение |
---|---|---|
LDST_B | 3'd0 | Знаковое 8-битное значение |
LDST_H | 3'd1 | Знаковое 16-битное значение |
LDST_W | 3'd2 | 32-битное значение |
LDST_BU | 3'd4 | Беззнаковое 8-битное значение |
LDST_HU | 3'd5 | Беззнаковое 16-битное значение |
Таблица 2. Значения сигнала mem_size_o
при передаче различных порций данных.
Перечисленных сигналов достаточно для того, чтобы основная память понимала: обращаются ли к ней в данный момент, нужно ли записывать или считывать данные, и о какой порции данных идет речь.
Сигналы разрешения записи
В данную категорию входят два однобитных сигнала:
gpr_we_o
— сигнал разрешения записи в регистровый файл (General Purpose Registers, GPR);csr_we_o
— сигнал разрешения записи в модуле регистров контроля и статуса.
Сигналы управления программным счетчиком
В данную категорию входят однобитные сигналы, которые оповещают о том, что выполняется инструкция, связанная с изменением значения программного счетчика:
branch_o
— сигнал об инструкции условного перехода;jal_o
— сигнал об инструкции безусловного переходаjal
;jalr_o
— сигнал об инструкции безусловного переходаjalr
;mret_o
— сигнал об инструкции возврата из прерывания/исключенияmret
.
Сигнал нелегальной инструкции
Это сигнал, который должен принять значение 1
, в случае если пришла инструкция, которая не входит в список поддерживаемых процессором.
Это не единственное, что должен сделать декодер в подобной ситуации. Давайте разберем подробней, что должно происходить по приходу нелегальной инструкции.
Обработка нелегальной инструкции
Существует множество причин, почему процессору может прийти на исполнение неподдерживаемая инструкция, в том числе:
- ошибка компиляции: либо баг в самом компиляторе, либо компиляция с неверными параметрами;
- ошибка в аппаратуре (например сбой в работе памяти);
- намеренная вставка неподдерживаемой инструкции (например для эксплуатации какой-нибудь уязвимости);
- инструкция, которая на самом деле поддерживается процессором, но требует большего уровня привилегий и потому не может быть выполнена.
В случае появления инструкции, которая не поддерживается процессором, устройство управления должно обеспечить стабильность системы. В самом простом случае, такую инструкцию необходимо пропустить, сохранив так называемое архитектурное состояние процессора — т.е. сохранив значение всех элементов системы, характеризующих состояние системы в текущий момент. К таким элементам относятся: содержимое регистрового файла, основой памяти, содержимое регистров контроля и статуса и т.п. Значение программного счетчика также входит в архитектурное состояние процессора, однако в контексте пропуска инструкции с сохранением архитектурного состояния, его значение нужно изменить, иначе система оказалась бы в бесконечном цикле (неизменный счетчик бы указывал на ту же самую инструкцию, которая не должна менять архитектурного состояния).
Иными словами, в случае появления нелегальной инструкции, устройство управления (роль которого в нашей системе по большей части играет декодер) должно проследить за тем, чтобы в системе не изменилось ничего кроме программного счетчика. К сигналам, влияющим на изменение архитектурного состояния, относятся:
mem_req_o
,mem_we_o
,gpr_we_o
,csr_we_o
,branch_o
,jal_o
,jalr_o
,mret_o
,
то есть, должны быть запрещены все запросы на запись, обращения в память и любые "прыжки" программного счетчика.
Давайте теперь разберемся с тем, какие именно инструкции должен будет поддерживать наш процессор.
Набор поддерживаемых инструкций RISC-V и способы их кодирования
Все инструкции архитектуры RISC-V можно условно разделить на три категории.
- Вычислительные инструкции (операции выполняются на АЛУ, с записью результата в регистровый файл). В основном это инструкции:
- использующие в качестве операндов два регистра;
- использующие в качестве операндов регистр и непосредственный операнд из инструкции (константу).
- Инструкции для доступа к памяти:
- загрузки из основной памяти в регистровый файл;
- сохранения данных из регистрового файла в основную память;
- Инструкции управления:
- Условные переходы
- Безусловные переходы
- Системные инструкции
- обращение к регистрам контроля и статуса;
- системные вызовы и возврат из обработчика прерываний
В Таблице 3 приводится фрагмент из спецификации RISC-V
. В верхней её части приводится 6 форматов кодирования инструкций: R, I, S, B, U и J (описание типов представлено в таблице 4). Затем список всех инструкций с конкретными значениями полей, соответствующих формату кодирования инструкции данного типа.
Под rd
подразумевается 5-битный адрес регистра назначения (register destination), rs1
и rs2
— 5-битные адреса регистров источников (register source), imm
— непосредственный (immediate, задающийся прямиком в инструкции) операнд (константа), расположение и порядок битов которого указывается в квадратных скобках. Обратите внимание, что в разных форматах кодирования константы имеют различную разрядность, а их биты расположены по-разному. Для знаковых операций константу предварительно знаково расширяют до 32 бит. Для беззнаковых расширяют нулями до 32 бит.
Таблица 3. Базовый набор инструкций из спецификации RISC-V[1, стр. 130], Стандартное расширение Zicsr[1, стр. 131], а также привилегированная инструкция mret[2, стр. 138].
Кодирование | Описание |
---|---|
R-тип | Арифметические и логические операции над двумя регистрами с записью результата в третий (регистр назначения может совпадать с одним из регистров-источников) |
I-тип | Инструкции с 12-битным непосредственным операндом |
S-тип | Инструкции записи в память (инструкции store) |
B-тип | Инструкции ветвления |
U-тип | Инструкции с 20-битным «длинным» непосредственным операндом, сдвинутым влево на 12 |
J-тип | Единственная инструкция jal, осуществляющая безусловный переход по адресу относительно текущего счетчика команд |
Таблица 4. Описание типов форматов кодирования инструкций ISA RISC-V.
Декодирование инструкций RISC-V
Как уже описывалось в дополнительных материалах, декодирование инструкции начинается с поля opcode
(operation code, опкод). По этому полю определяется группа инструкций одного типа. Далее (для большинства типов кодирования) инструкция доопределяется через поля func3
и func7
(при наличии). Обратите внимание, что расположение этих полей одинаково для всех типов инструкций (см. верхнюю часть таблицы 3).
Поля rs1
/rs2
/imm
и rd
декодеру не нужны и используются напрямую для адресации в регистровом файле / использования непосредственного операнда в АЛУ.
Существуют особые инструкции, не имеющие никаких переменных полей (к примеру инструкция ECALL в таблице 3). Такие инструкции необходимо проверять целиком (нужно убедиться, что инструкция совпадает вплоть бита).
В Таблице 5 представлены все опкоды реализуемых нами инструкций. Представленные в ней коды операций 5-битные потому, что 2 младших бита полноценного 7-битного кода операции в реализуемых нами инструкциях должны всегда быть равны 11
. Если это не так, то вся инструкция уже запрещенная и не нуждается в дальнейшем декодировании.
Для удобства значения кодов операций определены в виде параметров в пакете decoder_pkg
.
Параметр | Opcode | Описание группы операций | Краткая запись |
---|---|---|---|
OP | 01100 | Записать в rd результат вычисления АЛУ над rs1 и rs2 | rd = alu_op(rs1, rs2) |
OP_IMM | 00100 | Записать в rd результат вычисления АЛУ над rs1 и imm | rd = alu_op(rs1, imm) |
LUI | 01101 | Записать в rd значение непосредственного операнда U-типа imm_u | rd = imm << 12 |
LOAD | 00000 | Записать в rd данные из памяти по адресу rs1+imm | rd = Mem[rs1 + imm] |
STORE | 01000 | Записать в память по адресу rs1+imm данные из rs2 | Mem[rs1 + imm] = rs2 |
BRANCH | 11000 | Увеличить счетчик команд на значение imm , если верен результат сравнения rs1 и rs2 | if cmp_op(rs1, rs2) then PC += imm |
JAL | 11011 | Записать в rd следующий адрес счетчика команд, увеличить счетчик команд на значение imm | rd = PC + 4; PC += imm |
JALR | 11001 | Записать в rd следующий адрес счетчика команд, в счетчик команд записать rs1+imm | rd = PC + 4; PC = rs1+imm |
AUIPC | 00101 | Записать в rd результат сложения непосредственного операнда U-типа imm_u и счетчика команд | rd = PC + (imm << 12) |
MISC-MEM | 00011 | Не производить операцию | - |
SYSTEM | 11100 | Записать в rd значение csr . Обновить значение csr с помощью rs1 . (либо mret /ecall /ebreak ) | csr = csr_op(rs1); rd = csr |
Таблица 5. Описание кодов операций.
SYSTEM-инструкции
SYSTEM-инструкции используются для доступа к системным функциям и могут требовать привилегированный доступ. Данные инструкции могут быть разделены на два класса:
- Обращение к регистрам статуса и контроля (Control and Status Registers, CSR)
- Все остальные инструкции (возможно из набора привилегированных инструкций)
Для того, чтобы в будущем процессор поддерживал прерывания, нам требуется декодировать инструкции обоих классов.
Обращение к регистрам контроля и статуса осуществляется шестью инструкциями стандартного расширения Zicsr
. Каждая из этих инструкций (если у нее легальные поля) осуществляет запись в CSR и регистровый файл (блоки Control Status Registers
и Register File
на рис. 1 соответственно).
Кроме того, для возврата управления основному потоку инструкций, нужна дополнительная SYSTEM
-инструкция привилегированного набора команд MRET
.
Перечисленные выше инструкции являются "дополнительными" — их намеренно их добавили сверх стандартного набора инструкций, чтобы обеспечить требуемый нашей системе функционал. Однако осталось ещё две SYSTEM-инструкции, которые мы должны уметь декодировать, поскольку они есть в стандартном наборе инструкций.
Инструкции ECALL
и EBREAK
вызывают исключение. Подробнее исключения и прерывания будут разобраны в ЛР№10.
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).
В таблице 6 представлены инструкции из таблицы 3 с приведением их типов, значениями полей opcode
, func3
, func7
, функциональным описанием и примерами использования.
Таблица 6. Расширенное описание инструкций RV32IZicsr.
Обратите внимание на операции 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-бит сразу (см. таблицу 3).
Формирование управляющих сигналов
Как говорилось ранее, декодер инструкций в процессоре служит для преобразования инструкции в набор управляющих сигналов, необходимых для ее исполнения. Таким образом, для каждой инструкции из таблицы 3 декодер должен поставить в соответствие конкретное значение для каждого из выходов, перечисленных в таблице 1.
Пример: для выполнения инструкции записи 32-бит данных из регистрового файла во внешнюю память sw
, дешифратор должен направить в АЛУ два операнда (базовый адрес и смещение) вместе с кодом операции АЛУ (сложения) для вычисления адреса. Базовый адрес берется из регистрового файла, а смещение является непосредственным операндом инструкции S-типа. Таким образом для вычисления адреса записи декодер должен выставить следующие значения на выходах:
a_sel_o = 2'd0
,b_sel_o = 3'd1
,alu_op_o= ALU_ADD
.
Кроме того, для самой операции записи в основную память, декодер должен сформировать управляющие сигналы интерфейса памяти (запрос на обращение в память, размер передаваемых данных и сигнал разрешения записи):
mem_req_o = 1'b1
,mem_size_o = LDST_W
(см. таблицу 2),mem_we_o = 1'b1
.
Несмотря на то, что для записи во внешнюю память ключевыми сигналами будут описанные выше, это не означает, что остальные выходные сигналы дешифратора команд могут быть абы какими.
Поскольку операция sw
не является операцией перехода, сигналы jal_o
, jalr_o
и branch_o
и mret
должны быть равны нулю (иначе процессор совершит переход, а инструкция sw
этого не подразумевает). Точно так же, поскольку во время записи во внешнюю память, в регистровый файл и регистры контроля и статуса ничего не должно быть записано, сигналы gpr_we_o
и csr_we_o
также должны быть равны нулю.
Иными словами, крайне важно следить выходными сигналами, влияющими на изменение архитектурного состояния процессора, не затрагиваемые инструкцией в явном виде.
А вот сигнал wb_sel
может принять любое значение (поскольку сигнал разрешения записи в регистровый файл равен нулю, не важно, каким будет источник данных для записи в регистровый файл, т.к. в него все равно ничего не будет записано).
Разумеется, описывая модуль декодера инструкций, было бы нерационально прописывать для каждой из 47 инструкций значение 14 выходов модуля, особенно учитывая, что многие выходные сигналы будут иметь одно и то же значение для всех инструкций одного опкода, поэтому удобнее всего будет описывать их, сгруппировав по кодам операций.
В таблице 7 определен список выходных сигналов декодера инструкций и групп инструкций, при которых эти выходы могут принимать ненулевое значение.
Название сигнала | Пояснение | На каких опкодах может принять ненулевое значение (см. таблицу 6) |
---|---|---|
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 |
Таблица 7. Описание портов дешифратора команд.
Дешифратор должен выдать единицу на выходе illegal_instr_o
в случае:
- неравенства двух младших битов opcode значению
11
; - если значение поля
opcode
не совпадает ни с одним из известных и следовательно операция не определена. - некорректного значения полей
func3
илиfunc7
для данного опкода.
Кроме того, поскольку представленная на рис. 1 микроархитектура поддерживает только одно исключение (исключение через сигнал illegal_instr_o
), этот сигнал должен быть равен единице и в случае:
- если это инструкция
ECALL
/EBREAK
.
Инструменты
В первую очередь язык описания аппаратуры SystemVerilog – это язык. С помощью этого языка человек объясняет либо синтезатору какое он хочет получить устройство, либо симулятору – как он хочет это устройство проверить. Синтезатор – это программа, которая создает из логических элементов цифровое устройство по описанию, предоставляемому человеком. Синтезатору внутри Vivado нужно объяснить, что от него нужно. Например, чтобы спросить дорогу у испанца, придется делать это на испанском языке, иначе он ничем не сможет помочь. А если вы знаете испанский, то скорее всего сможете это сделать еще и разными способами. В SystemVerilog точно также – одно и то же устройство можно описать разным кодом, но результат синтеза будет одним и тем же. Однако, часто два разных кода одинаковые по смыслу могут синтезироваться в разную аппаратуру, хотя функционально они будут идентичны, но могут отличаться, например, скоростью работы. Или одни и те же специальные языковые конструкции могут применяться для синтезирования разных цифровых элементов.
Декодер – комбинационная схема. Это значит, что каждый раз подавая на вход одни и те же значения, вы будете получать на выходе один и тот же результат, потому что комбинационные схемы не содержат элементов памяти.
Можно по-разному описывать комбинационные схемы. Например — через конструкцию assign
. Для описания декодера отлично подойдет конструкция case
, которая превратится не в мультиплексор, а в комбинационную схему с оптимальными параметрами критического пути. В доверилоговую эпоху разработчикам пришлось бы строить гигантские таблицы истинности и карты Карно, искать оптимальные схемы реализации. Сегодня эту задачу решает синтезатор, по описанию устройства сам находит наиболее эффективное решение.
Разница с реализацией мультиплексора в том, что в этом случае справа от знака равно всегда стоит константа. Получается это такой способ описать таблицу истинности. В такой код легко вносить правки и искать интересующие фрагменты.
Рассмотрим пример ниже. Внутри конструкции always_comb
, перед конструкцией case
указываются значения по умолчанию. Благодаря этому пропадает необходимость указывать все сигналы внутри каждого обработчика case
, достаточно указать только те, что имеют значение отличное от значения по умолчанию. Представленный пример реализует комбинационную схему, которая при control_signal== 4'b1100
будет выставлять сигнал c == 1'b0
, то есть отличное, от значения по умолчанию. Сигнал a
никак не меняется, поэтому он не указан в соответствующем обработчике. Если сигнал size == 1'b0
, то b
будет равен 1, а d
равен 0. Если сигнал size == 1'b1
, то наоборот – b
будет равен 0, а d
равен 1.
module example (
input logic [3:0] control_signal,
input logic sub_signal,
output logic a, b, c, d
);
parameter logic [3:0] SOME_PARAM = 4'b1100;
always_comb begin
a = 1'b0; // значения по умолчанию
b = 1'b0; // обратите внимание, что в блоке
c = 1'b1; // always_comb используется оператор
d = 1'b0; // блокирующего присваивания
case(control_signal)
// ... какие-то еще комбинации
SOME_PARAM: begin // если на control_signal значение SOME_PARAM
c = 1'b0;
case (sub_signal)
1'b0: b = 1'b1; // если на sub_signal значение 1'b0
1'b1: d = 1'b1; // если на sub_signal значение 1'b1
endcase
end
// ... какие-то еще обработчики
default: begin // так как описаны не все значения
a = 1'b0; // control_signal, то чтобы результатом
b = 1'b0; // case не было защелки (latch),
c = 1'b1; // на выходе нужно обязательно добавлять
d = 1'b0; // default
end
endcase
end
endmodule
Имейте в виду, что значения по умолчанию, описанные в начале блока always_comb
можно использовать таким образом при помощи блокирующих присваиваний (которые следует использовать только в комбинационных блоках).
Кроме того, использование вложенных блоков case
обосновано только в ситуации создания блока декодера (т.е. в случаях, когда справа от всех присваиваний будут использованы константы, а не другие сигналы). В случае описания мультиплексора, вложенные блоки case
могут быть синтезированы в каскад мультиплексоров, что негативно скажется на временных характеристиках схемы.
Задание
Необходимо реализовать на языке SystemVerilog модуль декодера инструкций однотактного процессора RISC-V в соответствии с предложенной микроархитектурой. Далее приводится прототип разрабатываемого модуля.
module decoder (
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 decoder_pkg::*;
endmodule
В зависимости от стиля оформления, модуль может занимать больше сотни строк кода, но это не делает его реализацию сложной. По сути, дешифратор — это просто большой case
с описанием того, в каком случае, какие сигналы и чему должны быть равны. Работа требует внимательности, немного усидчивости и понимания выполняемых действий. С огромной вероятностью в коде будут ошибки и их нужно будет исправлять. Ошибки — это нормально (не ошибается тот, кто ничего не делает), а исправление ошибок дает бесценный опыт разработки. Возможно, реализация этого модуля в какой-то момент покажется рутинной, но по окончании следующей лабораторной работы удовольствие от результата покажет, что оно того стоило.
Порядок выполнения задания
- Внимательно ознакомьтесь с выходными сигналами декодера инструкций и тем, как они управляют функциональными блоками процессорного ядра, представленного на рис. 1, а также типами команд. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
- Добавьте в
Design Sources
проекта файлalu_opcodes_pkg.sv
(если тот ещё не был добавлен в ходе выполнения ЛР№2), а также файлыcsr_pkg.sv
иdecoder_pkg.sv
. Эти файлы содержат параметры, которые будет удобно использовать при описании декодера. - Опишите модуль декодера инструкций с таким же именем и портами, как указано в задании.
- Для удобства дальнейшего описания модуля, рекомендуется сперва создать сигналы
opcode
,func3
,func7
и присвоить им соответствующие биты входного сигнала инструкции. - Модуль может быть описан множеством способов: каждый выходной сигнал может быть описан через собственную комбинационную логику в отдельном блоке
case
, однако проще всего будет описать все сигналы через вложенныеcase
внутри одного блокаalways_comb
. - Внутри блока
always_comb
до начала блокаcase
можно указать базовые значения для всех выходных сигналов. Это не то же самое, что вариантdefault
в блокеcase
. Здесь вы можете описать состояния, которые будут использованы чаще всего, и в этом случае, присваивание сигналу будет выполняться только в том месте, где появится инструкция, требующая значение этого сигнала, отличное от базового. - Далее вы можете описать базовый блок
case
, где будет определен тип операции по ее коду. - Определив тип операции, вы сможете определить какая конкретно операция по полям
func3
иfunc7
(если данный тип имеет такие поля). - Не забывайте, что в случае, если на каком-то из этапов (определения типа, или определения конкретной операции) вам приходит непредусмотренное ISA значение какого-либо поля, необходимо выставить сигнал
illegal_instr_o
. - В случае некорректной инструкции, вы должны гарантировать, что не произойдет условный/безусловный переход, а во внешнюю память, регистровый файл, а также регистры контроля и статуса ничего не запишется. Не важно, что будет выполняться на АЛУ, не важно какие данные будут выбраны на мультиплексоре источника записи. Важно чтобы не произошел сам факт записи в любое из устройств (подумайте какие значения для каких сигналов необходимо для этого выставить).
- Для удобства дальнейшего описания модуля, рекомендуется сперва создать сигналы
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_05.tb_decoder.sv
. Вполне возможно, что после первого запуска вы столкнётесь с сообщениями о множестве ошибок. Вам необходимо исследовать эти ошибки на временной диаграмме и исправить их в вашем модуле.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Данная лабораторная работа не предполагает проверки в ПЛИС
Список источников
- The RISC-V Instruction Set Manual Volume I: Unprivileged ISA
- The RISC-V Instruction Set Manual Volume II: Privileged Architecture
Лабораторная работа 6 "Основная память"
Процессор CYBERcobra 2000 использовал в качестве основного хранилища данных регистровый файл, однако на практике 31-го регистра недостаточно для выполнения сложных программ. Для этих целей используется основная память, роль которой в нашей системе будет выполнять память данных.
Цель
Описать память данных, с побайтовой адресацией.
Материалы для подготовки к лабораторной работе
Для успешного выполнения лабораторной работы, вам необходимо использовать навыки, полученные при написании ЛР№3 "Регистровый файл и память инструкций";
Теория
В задании по реализации памяти инструкций ЛР№3 байтовая адресация была описана следующим образом:
Байтовая адресация означает, что процессор способен обращаться к отдельным байтам в памяти (за каждым байтом памяти закреплён свой индивидуальный адрес).
Данное описание было дано не совсем корректным образом, чтобы в третьей лабораторной работе было более чёткое понимание задания. В чём заключается некорректность? Процессор должен быть способен не только обращаться к отдельным байтам в памяти, но и обновлять в памяти любой отдельный байт, а также считывать отдельные байты.
Вопрос считывания отдельного байта будет решаться специальным модулем загрузки и сохранения. Памяти данных при этом будет достаточно возвращать всё слово, содержащее запрашиваемый байт как это уже было сделано памяти инструкций.
Нас интересует возможность памяти обновлять любой из байт в слове. Подобный функционал часто используется при реализации памяти и в системных интерфейсах, например AXI4 или APB. Для этого используется специальный сигнал, который называется byte enable
. Разрядность этого сигнала равна числу байт в шине данных (в нашем случае разрядность byte enable
составляет 4). Вы можете представить этот сигнал, как 4 провода, каждый из которых является сигналом разрешения записи для отдельной памяти с шириной данных в 1 байт.
Давайте разберёмся как это будет работать. Допустим, мы хотим записать значение 0xA5
по адресу 0x6
. Поскольку мы работаем с байтовой адресацией, а ячейки памяти 32-битные — как и при реализации памяти инструкций, пришедший адрес необходимо будет разделить на 4 (см. рис. 1). В итоге мы получим указатель на первую 32-битную ячейку памяти (6 / 4 = 1
). Однако, чтобы пришедшие данные были в итоге записаны не в нулевой байт первого слова (четвёртый байт памяти), а во второй, мы будем использовать сигнал byte enable
, второй бит которого будет равен 1
. Это значит, что лучше разделить запись в отдельные байты памяти и для каждого байта проверять отдельно соответствующий бит byte enable
, независимо от остальных.
Рисунок 1. Связь адреса байта с индексом слова в массиве ячеек памяти и сигналом byte enable.
Чтобы данные остальных байт не были испорчены, при описании памяти на SystemVerilog нужно разделить запись в отдельные байты. Для того, чтобы получить доступ к отдельным диапазонам бит ячейки памяти, после указания индекса ячейки необходимо указать диапазон бит, к которым вы хотите получить доступ. К примеру, чтобы получить доступ к битам с 5-го по 3-ий 18-ой ячейки памяти, необходимо использовать следующую запись:
mem[18][5:3];
Учитывайте и то, что комбинации значений бит в сигнале byte enable
могут быть любыми: 0000
, 0100
, 0110
, 1111
и т.п.
Задание
Реализовать память данных с поддержкой обновления отдельных байт в выбранной ячейке памяти.
У данного модуля будет шесть входных/выходных сигналов:
- вход тактового синхроимпульса
- вход запроса на работу с памятью
- вход сигнала разрешения записи
- 32-битный вход адреса
- 32-битный вход данных записи
- 32-битный выход данных синхронного чтения
Прототип модуля следующий:
module data_mem
import memory_pkg::DATA_MEM_SIZE_BYTES;
import memory_pkg::DATA_MEM_SIZE_WORDS;
(
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
);
Как и память инструкций, память данных будет состоять из 32-битных ячеек, количество которых определяется параметром. Как и в памяти инструкций, необходимо использовать только младшие биты адреса в количестве, равном логарифму по основанию 2 от количества ячеек памяти, начиная со второго бита (см. код памяти инструкций из ЛР№3).
Отличие от памяти инструкций будет заключаться в:
- синхронном порте на чтение;
- наличии порта на запись;
- посредством этого порта на запись можно менять отдельные байты ячейки памяти.
Синхронный порт на чтение означает, что выдача данных по предоставленному адресу осуществляется не сразу же, а на следующий такт (см. рис. 2). Для этого, перед выходным сигналом ставится отдельный регистр. Таким образом, выдача данных с порта на чтение будет осуществляться не с помощью непрерывного присваивания, а посредством блока always_ff
(см. практическую часть ЛР№3).
Также в памяти появилось три управляющих сигнала:
mem_req_i
,write_enable_i
,byte_enable_i
и один статусный:
ready_o
.
Сигнал mem_req_i
является сигналом запроса на работу с памятью. Без этого сигнала память не должна выполнять операции чтения/записи. Как сделать так, чтобы не происходило чтение без запроса? Например, не обновлять значение, считанное во время предыдущей операции чтения.
Сигнал write_enable_i
является сигналом разрешения записи. Этот сигнал определяет, является ли пришедший запрос в память запросом на запись, либо же запросом на чтение.
Если mem_req_i == 1
и write_enable_i == 0
, то происходит запрос на чтение из памяти. В этом случае, необходимо записать в выходной регистр read_data_o
значение из ячейки, на которую указывает addr_i
. Во всех других случаях чтение из памяти не производится (read_data_o
сохраняет предыдущее значение).
Рисунок 2. Операции запросов на чтение.
Если mem_req_i == 1
и write_enable_i == 1
, то происходит запрос на запись в память. В этом случае, необходимо записать значение write_data_i
в ячейку по, на которую указывает addr_i
. Во всех других случаях (любой из сигналов mem_req_i
, write_enable_i
равен нулю), запись в память не производится. Запись необходимо производить только в те байты указанной ячейки, которым соответствуют биты сигнала byte_enable_i
, равные 1.
На рис. 3 показан пример записей по различным адресам. Т.к. деление на 4 любого из приведенных на рис. 3 адресов даёт результат 2, на рисунке показано только содержимое второй 32-битной ячейки памяти и то, как оно менялось в зависимости от комбинации сигналов write_data_i
и byte_enable_i
.
Рисунок 3. Операции запросов на запись.
Выход ready_o
в данном модуле должен всегда быть равен 1, поскольку данные всегда будут выдаваться на следующий такт. В реальности, обращение в память может занимать сотни тактов процессора, причём их число бывает недетерминированным (нельзя заранее предсказать сколько тактов займёт очередной запрос в память). Именно поэтому стандартные интерфейсы обычно используют такие сигналы как ready
или valid
, позволяющие синхронизировать разные блоки системы. Сигнал ready_o
в нашем интерфейсе используется сигнала о задержке в выдаче данных. В случае, если устройству нужно больше одного такта, чтобы выдать данные, он устанавливает на данный сигнал значение 0
до тех пор, пока данные не будут готовы.
Порядок выполнения работы
- Опишите память данных с таким же именем и портами, как указано в задании.
- Обратите внимание, что имя памяти (не название модуля, а имя массива регистров внутри модуля) должно быть ram. Такое имя необходимо для корректной работы верификационного окружения
- Описание модуля будет схожим с описанием модуля памяти инструкций, однако порт чтения в этот раз будет синхронным (запись в него будет происходить в блоке
always_ff
). Количество ячеек памяти данных определяется параметромDATA_MEM_SIZE_WORDS
, определенным вmemory_pkg
. Кроме того, необходимо будет описать логику записи данных в память. - Запись в ячейки памяти описывается подобно записи данных в регистры, только при этом, происходит доступ к конкретной ячейке памяти с помощью входа
addr_i
. - Перед тем как обратиться к ячейке памяти, значение с
addr_i
необходимо преобразовать по аналогии с памятью инструкций. - Обратите внимание что работа с памятью должна осуществляться только когда сигнал
mem_req_i == 1
. В противном случае запись не должна производиться, а на шинеread_data_o
должен оставаться результат предыдущего чтения. - При этом запись должна вестись только в те байты выбранной ячейки памяти, которым соответствуют биты сигнала
byte_enable_i
, выставленные в1
. - У памяти есть дополнительный выход
ready_o
, который всегда равен единице.
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_06.tb_data_mem.sv
. В случае, если в TCL-консоли появились сообщения об ошибках, вам необходимо найти и исправить их.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Данная лабораторная работа не предполагает проверки в ПЛИС.
Лабораторная работа №7 "Тракт данных"
Микроархитектуру можно разделить на две части: тракт данных и устройство управления. По тракту данных перемещаются данные (из памяти инструкций, регистрового файла, АЛУ, памяти данных, мультиплексоров), а устройство управления (в нашем случае — декодер инструкций) получает текущую инструкцию из тракта и в ответ говорит ему как именно её выполнить, то есть управляет тем, как эти данные будут через проходить тракт данных.
Цель
Описать на языке SystemVerilog процессор с архитектурой RISC-V, реализовав его тракт данных, используя разработанные ранее блоки, и подключив к нему устройство управления. Итогом текущей лабораторной работы станет процессор RISC-V, который пока что сможет работать с памятью данных лишь посредством 32-битных слов (то есть БЕЗ инструкций, связанных с байтами и полусловами: lh
, lhu
, lb
, lbu
, sh
, sb
).
Ход работы
- Изучить микроархитектурную реализацию однотактного процессора RISC-V (без поддержки команд загрузки/сохранения байт/полуслов)
- Реализовать тракт данных с подключенным к нему устройством управления(#задание)
- Подготовить программу по индивидуальному заданию и загрузить ее в память инструкций
- Сравнить результат работы процессора на модели в Vivado и в симуляторе программы ассемблера
Микроархитектура RISC-V
processor_core
Рассмотрим микроархитектуру процессорного ядра processor_core
. Данный модуль обладает следующим прототипом и микроархитектурой:
module processor_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
Рисунок 1. Микроархитектура ядра процессора RISC-V.
Предложенная микроархитектура имеет схожую структуру c процессором CYBERcobra 3000 Pro 2.0
из ЛР№4, с некоторыми изменениями.
В первую очередь изменились входы и выходы процессора:
- память инструкций вынесена наружу процессора, таким образом, у процессора появляются входы и выходы:
instr_addr_o
иinstr_i
; - помимо прочего, у модуля появились сигналы интерфейса памяти данных, реализованной в ЛР№6:
mem_addr_o
— адрес внешней памяти;mem_req_o
— запрос на обращение во внешнюю память;mem_size_o
— размер данных при обращении в память;mem_we_o
— сигнал разрешения записи во внешнюю память;mem_wd_o
— данные для записи во внешнюю память;mem_rd_i
— считанные из внешней памяти данные; Эти сигналы используются при выполнении инструкций загрузки (сохранения) информации из (в) памяти данных.
- еще у процессора появился вход
stall_i
, приостанавливающий обновление программного счётчика.
Кроме того, в данной микроархитектуре используется пять различных видов констант (соответствующих определенным типам инструкций).
Константы I
, U
, S
используются для вычисления адресов и значений. Поэтому все эти константы должны быть подключены к АЛУ. А значит теперь, для выбора значения для операндов требуются мультиплексоры, определяющие, что именно будет подаваться на АЛУ.
Обратите внимание на константу imm_U
. В отличие от всех остальных констант, она не знакорасширяется, вместо этого к ней "приклеивается" справа 12 нулевых бит.
Константы B
и J
используются для условного и безусловного перехода (в киберкобре для этого использовалась одна константа offset
).
Программный счётчик (PC
) теперь также изменяется более сложным образом. Поскольку появился еще один вид безусловного перехода (jalr
), программный счётчик может не просто увеличиться на значение константы из инструкции, но и получить совершенно новое значение в виде суммы константы и значения из регистрового файла (см. на самый левый мультиплексор рис. 1). Обратите внимание, что младший бит этой суммы должен быть обнулен — таково требование спецификации [1, стр. 28].
Поскольку обращение во внешнюю память требует времени, необходимо приостанавливать программный счётчик, чтобы до конца обращения в память не начались исполняться последующие инструкции. Для этого у программного счётчика появился управляющий сигнал stall_i
. Программный счётчик может меняться только когда этот сигнал равен нулю (иными словами, инверсия этого сигнала является сигналом enable
для регистра PC
).
processor_system
После реализации процессорного ядра, к нему необходимо подключить память. Это происходит в модуле processor_system
.
module processor_system(
input logic clk_i,
input logic rst_i
);
endmodule
Рисунок 2. Микроархитектура процессорной системы.
Обратите внимание на регистр stall
. Этот регистр и будет управлять разрешением на запись в программный счётчик PC
. Поскольку мы используем блочную память, расположенную прямо в ПЛИС, доступ к ней осуществляется за 1 такт, а значит, что при обращении в память, нам необходимо "отключить" программный счётчик ровно на 1 такт. Если бы использовалась действительно "внешняя" память (например чип DDR3), то вместо этого регистра появилась бы другая логика, выставляющая на вход ядра stall_i
единицу пока идет обращение в память.
Задание
Реализовать ядро процессора processor_core
архитектуры RISC-V по предложенной микроархитектуре. Подключить к нему память инструкций и память данных в модуле processor_system
. Проверить работу процессора с помощью программы, написанной на ассемблере 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)
Листинг 1. Пример программы на ассемблере.
Теперь в соответствии с кодировкой инструкций переведем программу в машинные коды:
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)
Листинг 2. Программа из Листинга 1, представленная в машинных кодах.
Данная программа, представленная в шестнадцатеричном формате находится в файле program.mem.
Порядок выполнения задания
- Внимательно ознакомьтесь микроархитектурной реализацией процессорного ядра. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
- Замените файл
program.mem
вDesign Sources
проекта новым файлом program.mem, приложенном в данной лабораторной работе. Данный файл содержит программу из листинга 1. - Опишите модуль процессорного ядра с таким же именем и портами, как указано в задании.
- Процесс реализации модуля очень похож на процесс описания модуля cybercobra, однако теперь появляется:
- декодер
- дополнительные мультиплексоры и знакорасширители.
- Сперва рекомендуется создать все провода, которые будут подключены к входам и выходам каждого модуля на схеме.
- Затем необходимо создать экземпляры модулей.
- Также необходимо создать 32-разрядные константы I, U, S, B и J-типа и программный счётчик.
- После необходимо описать логику, управляющую созданными в п. 3.2 проводами.
- В конце останется описать логику работы программного счётчика.
- Процесс реализации модуля очень похож на процесс описания модуля cybercobra, однако теперь появляется:
- Опишите модуль процессорной системы, объединяющий ядро процессора (
processor_core
) с памятями инструкция и данных.- Опишите модуль с таким же именем и портами, как указано в задании.
- При создании объекта модуля
processor_core
в модулеprocessor_system
вы должны использовать имя сущностиcore
(т.е. создать объект в виде:processor_core core(...
).
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_07.tb_processor_system.sv
.- Перед запуском симуляции убедитесь, что выбран правильный модуль верхнего уровня в
Simulation Sources
. - Как и в случае с проверкой процессора архитектуры CYBERcobra, вам не будет сказано пройден тест или нет. Вам необходимо самостоятельно, такт за тактом проверить что процессор правильно выполняет описанные в Листинге 1 инструкции (см. порядок выполнения задания ЛР№4). Для этого, необходимо сперва самостоятельно рассчитать что именно должна сделать данная инструкция, а потом проверить что процессор сделал именно это.
- Перед запуском симуляции убедитесь, что выбран правильный модуль верхнего уровня в
- Проверьте работоспособность вашей цифровой схемы в ПЛИС.
Прочти меня, когда выполнишь.
Поздравляю, ты сделал(а) свой первый взрослый процессор! Теперь ты можешь говорить:Я способен(на) на всё! Я сам(а) полностью, с нуля, сделал(а) процессор с архитектурой RISC-V! Что? Не знаешь, что такое архитектура? Пф, щегол! Подрастешь – узнаешь
Список источников
Лабораторная работа №8 "Блок загрузки и сохранения"
Итогом шестой лабораторной работы стал практически завершенный процессор архитектуры RISC-V. Особенностью реализации процессора было отсутствие поддержки инструкций LB
, LBU
, SB
, LH
, LHU
, SH
. Тому было две причины:
- подключенный к памяти данных сигнал
byte_enable_i
был аппаратно зафиксирован на значении4'b1111
, но на самом деле этим сигналом должен кто-то управлять; - необходимо подготовить считанные из памяти полуслова / байты для записи в регистровый файл.
Для этих целей используется специальный модуль — Блок загрузки и сохранения (Load and Store Unit, LSU).
Цель
Разработка блока загрузки и сохранения для подключения к внешней памяти данных, поддерживающей запись в отдельные байты памяти.
Ход работы
Изучить:
- Функции и задачи блока загрузки/сохранения
- Интерфейс процессора и блока загрузки/сохранения
- Интерфейс блока загрузки/сохранения и памяти
Реализовать и проверить модуль lsu
.
Теория
Модуль загрузки и сохранения (Load/Store Unit – LSU) служит для исполнения инструкций типа LOAD
и STORE
: является прослойкой между внешним устройством – памятью, и ядром процессора. LSU считывает содержимое из памяти данных или записывает в нее требуемые значения, преобразуя 8- и 16-битные данные в знаковые или беззнаковые 32-битные числа для регистров процессора. В процессорах с RISC архитектурой с помощью LSU осуществляется обмен данными между регистрами общего назначения и памятью данных.
Рисунок 1. Место LSU в микроархитектуре RISC-процессора.
Интерфейс процессора и блока загрузки/сохранения
На входной порт 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
, принимающий следующие значения (для удобства использования, данные значения определены в виде параметров в пакете decoder_pkg
):
Параметр | Значение | Пояснение |
---|---|---|
LDST_B | 3'd0 | Знаковое 8-битное значение |
LDST_H | 3'd1 | Знаковое 16-битное значение |
LDST_W | 3'd2 | 32-битное значение |
LDST_BU | 3'd4 | Беззнаковое 8-битное значение |
LDST_HU | 3'd5 | Беззнаковое 16-битное значение |
Для операций типа STORE
формат представления чисел не важен, для них core_size_i
сможет принимать значение только от 0 до 2.
Выходной сигнал core_stall_o
нужен для приостановки программного счётчика. Ранее логика этого сигнала временно находилась в модуле processor_system
— теперь она займёт своё законное место в модуле 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
. Если core_size_i
соответствует инструкции записи байта (LDST_B
, 3'd0), то в сигнале 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
.
Если пришёл запрос на запись полуслова (core_size_i == LDST_H
), то в сигнале mem_be_o
необходимо выставить в единицу либо два старших, либо два младших бита (в зависимости от core_addr_i[1]
: если core_addr_i[1] == 1
, то в двух старших битах, если core_addr_i[1] == 0
, то в двух младших).
Если пришёл запрос на запись слова (core_size_i == LDST_W
), то в сигнале mem_be_o
необходимо выставить в единицу все биты.
Рисунок 2. Временна́я диаграмма запросов на запись со стороны ядра и сигнала 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
.
Рисунок 3. Временна́я диаграмма запросов на запись со стороны ядра и сигнала mem_wd_o.
core_rd_o
Сигнал core_rd_o
— это сигнал, который будет содержать данные для записи в регистровый файл процессора во время инструкций загрузки из памяти (LW
, LH
, LHU
, LB
, LBU
).
Предположим, по адресам 16-19
лежит слово 32'hA55A_1881
(см. рис. 4). Чтение по любому из адресов 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
без изменений.
Рисунок 4. Временна́я диаграмма запросов на чтение со стороны ядра и сигнала core_rd_o.
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
и таблица истинности для этого выхода, представленная на рис. 5.
Рисунок 5. Таблица истинности выхода core_stall_o
.
Задание
Реализовать блок загрузки и сохранения со следующим прототипом:
module 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
);
Рисунок 6. Функциональная схема модуля lsu
.
Порядок выполнения задания
- Внимательно ознакомьтесь с описанием функционального поведения выходов LSU. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
- Опишите модуль загрузки и сохранения с таким же именем и портами, как указано в задании
- При описании обратите внимание на то, что большая часть модуля является чисто комбинационной. В этом плане реализация модуля будет частично похожа на реализацию декодера.
- При описании мультиплексоров, управляемых сигналом core_size_i посредством конструкции
case
, не забывайте описать блокdefault
, иначе вы получите защелку!
- При описании мультиплексоров, управляемых сигналом core_size_i посредством конструкции
- Однако помимо комбинационной части, в модуле будет присутствовать и один регистр.
- При описании обратите внимание на то, что большая часть модуля является чисто комбинационной. В этом плане реализация модуля будет частично похожа на реализацию декодера.
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_08.tb_lsu.sv
. В случае, если в TCL-консоли появились сообщения об ошибках, вам необходимо найти и исправить их.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Данная лабораторная работа не предполагает проверки в ПЛИС.
Лабораторная работа №9 "Интеграция блока загрузки и сохранения"
После реализации блока загрузки и сохранения, его необходимо интегрировать в процессорную систему, реализованную в рамках ЛР№7. На рис. 1 представлена схема, иллюстрирующая интеграцию компонентов:
Рисунок 1. Подключение LSU в процессорную систему.
Материалы для подготовки к лабораторной работе
Перед выполнение данной лабораторной работы, рекомендуется изучить теоретическую часть ЛР№8.
Задание
Интегрировать модуль lsu
в модуль processor_system
.
Порядок выполнения работы
- Интегрируйте модули
lsu
иdata_mem
в модульprocessor_system
.- Обратите внимание, что из модуля
processor_system
необходимо убрать логику сигналаstall
, т.к. она была перемещена внутрь модуляlsu
.
- Обратите внимание, что из модуля
- После интеграции модулей, проверьте процессорную систему с помощью программы и верификационного окружения из ЛР№7.
- Обратите внимание на то, как теперь исполняются инструкции
sw
,sh
,sb
,lw
,lh
,lb
,lhu
,lbu
.
- Обратите внимание на то, как теперь исполняются инструкции
- Данная лабораторная работа не предполагает проверки в ПЛИС.
Лабораторная работа №10 "Подсистема прерывания"
Данная лабораторная работа посвящена изучению систем прерывания в компьютерах и их использованию для обработки программных и аппаратных событий. В процессе работы вы познакомитесь с основными понятиями и принципами работы систем прерывания, а также со средствами программной обработки прерываний.
Материалы для подготовки к лабораторной работе
Цель
- Описать модуль контроллера прерываний.
- Описать модуль контроллера регистров статуса и контроля (CSR-контроллер).
Ход выполнения
- Изучение теории по прерываниям и исключениям в архитектуре RISC-V, включая работу с регистрами статуса и контроля (CSR) и механизмы реализации прерываний.
- Реализация схемы обработки прерывания для устройства на основе RISC-V
- Реализация схемы управления регистрами статуса и контроля.
Теоретическая часть
Прерывания/Исключения
С компьютером постоянно происходят события, на которые он должен реагировать, запуская соответствующие подпрограммы. Например, при движении мышки нужно перерисовать её курсор на новом месте или нужно среагировать на подключение флешки и т.п. Возможность запускать нужные подпрограммы в ответ на различные события, возникающие внутри или снаружи компьютера, существенно расширяют его возможности. События, требующие внимания процессора называются прерываниями (interrupt). Происходящие события формируют запрос на прерывание процессору.
С.А. Орлов, Б.Я. Цилькер в учебнике "Организация ЭВМ и систем" дают следующее определение системе прерывания:
Система прерывания – это совокупность программно-аппаратных средств, позволяющая процессору (при получении соответствующего запроса) на время прервать выполнение текущей программы, передать управление программе обслуживания поступившего запроса, по завершению которой и продолжить прерванную программу с того места, где она была остановлена[1, стр. 155].
Прерывания делятся на маски́руемые — которые при желании можно игнорировать (на которые можно наложить битовую маску, отсюда ударение на второй слог), и немаски́руемые — которые игнорировать нельзя (например сбой генератора тактового синхроимпульса в микроконтроллерах семейства PIC24FJ512GU410[2, стр. 130]). Прерывание похоже на незапланированный вызов функции, вследствие события в аппаратном обеспечении. Программа (функция), запускаемая в ответ на прерывание, называется обработчиком прерывания.
События могут быть не только аппаратными, но и программными – синхронными. Такие события называются исключениями (exception). Программа может столкнуться с состоянием ошибки, вызванным программным обеспечением, таким как неопределённая инструкция, неподдерживаемая данным процессором, в таком случаях говорят, что возникло исключение. К исключениям также относятся сброс, деление на ноль, переполнение и попытки считывания из несуществующей памяти.
Важно понимать, что ни прерывание, ни исключение не являются обязательно чем-то плохим. И то и другое — это всего лишь события. Например, с помощью исключений может осуществляться системные вызовы и передача управления отладчику программы.
Как и любой другой вызов функции, при возникновении прерывания или исключения необходимо сохранить адрес возврата, перейти к программе обработчика, выполнить свою работу, восстановить контекст (не оставить никаких следов работы обработчика прерывания) и вернуться к программе, которую прервали.
Благодаря исключениям можно реализовать имитацию наличия каких-то аппаратных блоков программными средствами. Например, при отсутствии аппаратного умножителя, можно написать программу обработчика исключения неподдерживаемой инструкции умножения, реализующую алгоритм умножения через сложение и сдвиг. Тогда, каждый раз, когда в программе будет попадаться инструкция умножения, будет возникать исключение, приводящее к запуску обработчика, перемножающего числа и размещающего результат в нужные ячейки памяти. После выполнения обработчика управление возвращается программе, которая даже не поймёт, что что-то произошло и умножитель «ненастоящий».
На протяжении многих лет, концепция понятия "прерывание" постоянно расширялась. Семейство процессоров 80x86 внесло ещё большую путаницу введя инструкцию int
(программное прерывание). Многие производители используют такие термины как: исключение (exception), ошибка (fault), отказ (abort), ловушка (trap) и прерывание (interrupt), чтобы описать явление, которому посвящена данная лабораторная работа. К несчастью, не существует какого-то чёткого соглашения насчёт этих названий. Разные авторы по-разному приспосабливают эти термины для своего повествования[3, стр. 995].
Для того, чтобы постараться избежать путаницы, в данной лабораторной работе мы будем использовать три термина, которые введены в спецификации архитектуры RISC-V[4, стр. 18], однако имейте в виду, что за пределами данного практикума и спецификации RISC-V в эти термины могут вкладывать другие смыслы.
Сперва озвучим выдержку из спецификации, а потом дадим этим терминам обывательские определения.
- Под исключением будут подразумеваться нетипичные условия, произошедшие во время исполнения программы, связанные с инструкцией в текущем харте (hart, сокращение от hardware thread — аппаратном потоке).
- Под прерыванием будут подразумеваться внешние асинхронные события, которые могут стать причиной непредвиденной передачи управления внутри текущего харта.
- Под перехватом (вариант глагольного использования слова trap, которое обычно переводят как "ловушка", что по мнению автора совершенно не раскрывает сути этого понятия) будет подразумеваться передача управления обработчику перехватов (trap handler), вызванная либо прерыванием, либо исключением.
Иными словами, прерываниями мы будем называть исключительно аппаратные (внешние, асинхронные) события, которые могут привести к перехвату (передаче управления обработчику). Под исключениями мы будем подразумевать исключительно программные (являющиеся следствием какой-то инструкции, синхронные) события, которые могут привести к перехвату.
Соответственно перехватом будет называться обобщение этих двух терминов.
Прерывания и исключения — это события (причины). Перехват — это действие (следствие).
Та часть разрабатываемой процессорной системы, которая будет отвечать за обработку прерываний и исключений, будет называться традиционным именем "Система прерывания".
Современные процессоры, предусматривающие запуск операционной системы, обладают несколькими уровнями привилегий выполнения инструкций. Это значит, что существует специальный регистр, определяющий режим, в котором в данный момент находится вычислительная машина. Наличие определенного значения в этом регистре устанавливает соответствующие ограничения для выполняемой в данный момент программы. В архитектуре RISC-V выделяется 4 режима работы, в порядке убывания возможностей и увеличения ограничений:
- машинный (machine mode), в котором можно всё;
- гипервизора (hypervisor mode), который поддерживает виртуализацию машин, то есть эмуляцию нескольких машин (потенциально с несколькими операционными системами), работающих на одной физической машине;
- привилегированный (supervisor mode), для операционных систем, с возможностью управления ресурсами;
- пользовательский (user mode), для прикладных программ, использующих только те ресурсы, которые определила операционная система.
Рисунок 1. Распределение привилегий по уровням абстракций программного обеспечения [5, стр.448], [6, стр. 8].
Переключение между этими режимами происходит с помощью исключения, называемого системный вызов, и который происходит при выполнении специальной инструкции. Для RISC-V такой инструкцией является ecall. Это похоже на вызов подпрограммы, но при системном вызове изменяется режим работы и управление передаётся операционной системе, которая, по коду в инструкции вызова определяет, что от неё хотят. Например, операционная система может предоставить данные с диска, так как запускаемая программа не имеет никакого представления о том, на какой машине её запустили, или что используется какая-то конкретная файловая система.
Системы прерываний имеет ряд характеристик, которые варьируются в зависимости от их реализации. Все системы можно условно разбить на две категории: обзорные (прямые) и векторные.
В обзорных системах прерывания любой перехват приводит к запуску одного и того же обработчика. Внутри такого обработчика перехвата определяется причина его возникновения (как правило — это число в специальном регистре), и уже в зависимости от причины запускается нужная подпрограмма. Обзорные системы аппаратно проще векторных, но требуют больше рутины и времени на обработку. Именно такая система прерываний будет реализована в данной лабораторной работе.
В векторных системах прерывания разные события приводят к запуску на исполнение разных программ обработчиков. Адрес начала обработчика перехвата называется вектором прерывания. В векторных системах прерывания выделяется фрагмент памяти, в котором хранятся адреса переходов на начало каждого из обработчиков. Такой участок памяти называется таблицей векторов прерываний (Interrupt Vector Table, IVT).
В самом простом случае система прерывания позволяет обрабатывать только одно прерывание за раз (именно такую систему мы и будет делать в рамках данной лабораторной работы). Существуют реализации, позволяющие во время обработки прерывания «отвлекаться» на другие события. В таких системах используется система приоритетов, чтобы прерывание с более низким приоритетом не прерывало более приоритетное.
Регистры Контроля и Статуса
В процессе создания декодера инструкций в ЛР№5 вы уже реализовывали инструкции для работы с регистрами контроля и статуса. Теперь необходимо спроектировать блок управления этими регистрами.
Для реализации простейшей системы прерывания на процессоре с архитектурой RISC-V достаточно реализовать 5 регистров контроля и статуса, работающих в машинном (самом привилегированном) режиме.
Адрес | Уровень привилегий | Название | Описание |
---|---|---|---|
Machine Trap Setup | |||
0x304 | MRW | mie | Регистр маски перехватов. |
0x305 | MRW | mtvec | Базовый адрес обработчика перехвата. |
0x340 | MRW | mscratch | Адрес верхушки стека обработчика перехвата. |
0x341 | MRW | mepc | Регистр, хранящий адрес перехваченной инструкции. |
0x342 | MRW | mcause | Причина перехвата |
Таблица 1. Список регистров, подлежащих реализации в рамках лабораторной работы [6, стр. 17].
По адресу 0x304
должен располагаться регистр, позволяющий маскировать перехваты. Например, если на 5-ом входе системы прерывания генерируется прерывание, то процессор отреагирует на него только в том случае, если 5-ый бит регистра mie
будет равен 1. Младшие 16 бит этого регистра спецификация RISC-V отводит под маскирование специальных системных прерываний [6, стр. 36], которые не будут поддерживаться нашим процессором (подробней об этом будет в описании регистра mcause). Поэтому в нашей процессорной системе мы будем использовать только старшие 16 бит регистра mie
, которые отведены для нужд конкретной платформы.
По адресу 0x305
должен располагаться регистр mtvec
, который состоит из двух полей: BASE[31:2] и MODE. Поле BASE хранит старшие 30 бит базового адреса обработчика перехвата (поскольку этот адрес должен быть всегда равен четырём, младшие два бита считаются равными нулю). Поле MODE кодирует тип системы прерывания:
MODE == 2'd0
— система прерывания обзорная;MODE == 2'd1
— система прерывания векторная.
Рисунок 2. Разделение регистра mtvec
на поля BASE
и MODE
[6, стр. 34]
В случае обзорной системы прерывания, любой перехват приводит к загрузке в PC значения базового адреса обработчика перехвата (PC=BASE
). В векторной системе прерывания исключения обрабатываются таким же способом, как и в обзорной системе, а вот прерывания обрабатываются путём загрузки в PC суммы базового адреса и учетверённого значения причины прерывания (PC=BASE+4*CAUSE
).
В рамках данной лабораторной работы мы будем реализовывать обзорную систему прерываний. Кроме того, поскольку у обзорной системы прерываний MODE==0
, что совпадёт с тем, что два младших бита базового адреса обработчика перехвата должны быть равны нулю, при перехвате мы можем присваивать программному счётчику значение mtvec
без каких-либо преобразований.
Так как обработчик перехвата будет использовать те же регистры, что и прерванная программа, перед использованием регистрового файла, данные из него необходимо сохранить, разместив их на специальном стеке — стеке прерываний. Адрес начала этого стека хранится в регистре mscratch
, расположенного по адресу 0x340
и по сути является указателем на верхушку стека прерываний.
Регистр mepc
, расположенный по адресу 0x341
сохраняет адрес инструкции, во время исполнения которой произошёл перехват [6, стр. 42]. Это очень важно понимать, при реализации обработчика исключения — если в нем не перезаписать этот регистр, по возврату из обработчика процессор снова окажется на инструкции, которая вызвала исключение.
То как кодируется причина перехвата в регистре mcause
, расположенного по адресу 0x342
описано в спецификации привилегированной архитектуры[6, стр. 43]:
Таблица 2. Кодирование причины перехвата в регистре mcause
.
Нас интересуют части , выделенные цветом. В первую очередь то, как кодируется старший бит регистра mcause
(выделено синим). Он зависит от типа причины перехвата (1
в случае прерывания, 0
в случае исключения). Оставшиеся 31 бит регистра отводятся под коды различных причин. Поскольку мы создаём учебный процессор, который не будет использован в реальной жизни, он не будет поддерживать большую часть прерываний/исключений (таких как невыровненный доступ к памяти, таймеры и т.п.). В рамках данного курса мы должны поддерживать исключение по нелегальной инструкции (код 0x02, выделено красным) и должны уметь поддерживать прерывания периферийных устройств (под которые зарезервированы коды начиная с 16-го, именно поэтому мы будем использовать только старшие 16 бит регистра mie
). В рамках данной лабораторной работы процессор будет поддерживать только один источник прерывания, поэтому для кодирования причины прерывания нам потребуется только первый код из диапазона "Designated for platform use" (выделено зелёным). В случае, если вы захотите расширить количество источников прерываний, вы можете выполнить вспомогательную лабораторную работу №12.
Таким образом: в случае если произошло исключение (в связи с нелегальной инструкцией), значение mcause
должно быть 0x00000002
. Если произошло прерывание, значение mcause
должно быть 0x80000010
.
При желании, процессор можно будет улучшить, добавив поддержку большего числа периферийных устройств. В этом случае потребуется только расширить контроллер прерываний.
Когда процессор включается, программа первым делом должна инициализировать регистры контроля и статуса, в частности:
- задать адрес вектора прерывания
mtvec
, - задать адрес вершины стека прерываний
mscratch
, - задать маску прерывания
mie
.
После чего уже можно переходить к исполнению основного потока инструкций. Обратите внимание, что маску прерываний следует задавать в последнюю очередь, т.к. в противном случае система может начать реагировать на прерывания, не имея в регистре mtvec
корректного адреса вектора прерываний.
Реализация прерываний в архитектуре RISC-V
Процессор RISC-V может работать в одном из нескольких режимов выполнения с различными уровнями привилегий. Машинный режим – это самый высокий уровень привилегий; программа, работающая в этом режиме, может получить доступ ко всем регистрам и ячейкам памяти. M-режим является единственным необходимым режимом привилегий и единственным режимом, используемым в процессорах без операционной системы, включая многие встраиваемые системы.
Обработчики прерываний/исключений используют для перехвата четыре специальных регистра управления и состояния (CSR): mtvec
, mcause
, mepc
и mscratch
. Регистр базового адреса вектора прерывания mtvec
, содержит адрес кода обработчика прерывания. При перехвате процессор:
- записывает причину перехвата в
mcause
, - сохраняет адрес перехваченной инструкции, в
mepc
, - переходит к обработчику перехвата, загружая в
PC
адрес, предварительно настроенный вmtvec
.
После перехода по адресу в mtvec
обработчик считывает регистр mcause
, чтобы проверить, что вызвало прерывание или исключение, и реагирует соответствующим образом (например, считывая пришедший с клавиатуры символ при аппаратном прерывании).
После выполнения программы-обработчика перехвата, возвращение в программу выполняется командой возврата mret
, которая помещает в PC
значение регистра mepc
. Сохранение PC
инструкции при прерывании в mepc
аналогично использованию регистра ra
для хранения обратного адреса во время инструкции jal
. Поскольку обработчики перехватов могут использовать для своей работы регистровый файл, для хранения и восстановления значений его регистров им нужен отдельный стек, на который указывает mscratch
.
Контроллер прерываний – это блок процессора, обеспечивающий взаимодействие с устройствами, запрашивающими прерывания, формирование кода причины прерывания для процессора, маскирование прерываний. В некоторых реализация, контроллер прерываний может реагировать на прерывания в соответствии с приоритетом.
Периферийное устройство, которое может генерировать прерывание, подключается к контроллеру прерывания парой проводов: "запрос на прерывание" (irq_req_i
) и "прерывание обслужено" (irq_ret_o
). Предположим, к контроллеру прерываний подключили клавиатуру. Когда на ней нажимают клавишу, код этой клавиши попадает в буферный регистр с дополнительным управляющим битом, выставленным в единицу, который подключён к входу запроса на прерывание. Если прерывание не замаскировано (в нашем процессоре это означает, что нулевой бит регистра mie
выставлен в 1), то контроллер прерывания сгенерирует код причины прерывания (в нашем случае — это константа 0x80000010
). Кроме этого, контроллер прерывания подаст сигнал irq_o
, чтобы устройство управления процессора узнало, что произошло прерывание и разрешило обновить содержимое регистра причины mcause
, сохранило адрес прерванной инструкции в mepc
и загрузило в PC
вектор прерывания mtvec
.
Когда будет выполняться инструкция mret
, устройство управления подаст сигнал контроллеру прерывания, чтобы тот, в свою очередь, направил его в виде сигнала «прерывание обслужено» для соответствующего устройства. После этого периферийное устройство обязано снять сигнал запроса прерывания хотя бы на один такт. В нашем примере сигнал «прерывание обслужено» может быть подключён непосредственно к сбросу буферного регистра клавиатуры.
Структура разрабатываемых устройств
В рамках лабораторной работы необходимо реализовать поддержку обработки аппаратных прерываний. Для этого необходимо реализовать два модуля: блок управления регистрами контроля и статуса (CSR-контроллер) и контроллер прерываний (Interrupt Controller).
Блок управления регистрами контроля и статуса позволяет добавить особые архитектурные регистры, которые будут использоваться нами при обработке прерываний и исключений.
Контроллер прерываний позволит обрабатывать входящие запросы на прерывания: маски́ровать их, выбирать один запрос из нескольких, а также игнорировать запросы во время обработки текущего прерывания.
Рисунок 3. Место разрабатываемых блоков в структуре процессора.
Пока что вам нужно реализовать только блоки irq controller и control status registers, а не саму схему, приведённую выше.
CSR-контроллер
Рассмотрим один из возможных вариантов организации блока Control and Status Registers. Основная работа по описанию схемы блока состоит в описании мультиплексора и демультиплексора. Мультиплексор подаёт на выход read_data_o значение регистра, который соответствует пришедшему адресу. В свою же очередь, демультиплексор маршрутизирует сигнал разрешения на запись write_enable_i (en) на тот же регистр.
Рисунок 4. Структурная схема контроллера CS-регистров.
3-битный вход opcode_i определяет операцию, которая будет производиться над содержимым CSR по адресу addr_i.
Для реализации мультиплексора на языке описания аппаратуры SystemVerilog можно воспользоваться конструкцией case
внутри блока always_comb. Для реализации демультиплексора также можно использовать case
, только если при описании мультиплексора в зависимости от управляющего сигнала на один и тот же выход идут разные входы, то при описании демультиплексора все будет наоборот: в зависимости от управляющего сигнала, один и тот же вход будет идти на разные выходы (например, на разные биты многоразрядной шины enable
).
Мультиплексоры, располагаемые на входах регистров mepc
и mcause
нужны, чтобы при возникновении сигнала прерывания сразу же разрешить обновить значение этих регистров значением pc_i
, на котором произошёл перехват и кодом причины происходящего сейчас перехвата.
Контроллер прерываний
Рассмотрим один из возможных способов реализации простейшего контроллера прерываний, представленного на рис. 5.
Рисунок 5. Структурная схема контроллера прерываний.
Контроллер состоит из логики:
- обработки вложенных прерываний, частью которой являются регистры отслеживания обработки прерывания и исключения (
irq_h
иexc_h
соответственно), - установки и сброса этих регистров (которая вместе с этими регистрами заключена в штрихованные прямоугольники),
- приоритета исключений над прерываниями,
- маскирования запросов на прерывание.
Регистры отслеживания обработки прерывания и исключения нужны для того, чтобы мы могли понимать, что в данный момент процессор уже выполняет обработку прерывания / исключения. В такие моменты (если любой из регистров exc_h
/irq_h
содержит значение 1
) все последующие запросы на прерывание игнорируются. За это отвечают вентили И и ИЛИ-НЕ в правом верхнем углу схемы.
Однако возможна ситуация возникновения исключения во время обработки прерывания — в этом случае, оба регистра будут хранить значение 1
. В момент возврата из обработчика, придёт сигнал mret_i
, который в первую очередь сбросит регистр exc_h
и только если тот равен нулю, сбросит регистр irq_h
.
Исключение во время обработки исключения не поддерживается данной микроархитектурой и скорее всего приведёт к циклическому вызову обработчика исключения. Поэтому код обработчика исключений должен быть написан с особым вниманием.
Логика установки и сброса регистров irq_h
и exc_h
работает следующим образом:
- если сигнал, обозначенный в прямоугольнике как
reset
равен единице, в регистр будет записано значение0
; - если сигнал, обозначенный в прямоугольнике как
set
равен единице, в регистр будет записано значение1
; - в остальных случаях, регистр сохраняет своё значение.
Обратите внимание, что логика установки и сброса регистров даёт приоритет сбросу, хотя сигнал сброса никогда не придёт одновременно с сигналом установки (поскольку инструкция mret
не генерирует исключение, сигнал mret_i
никогда не придёт одновременно с сигналом exception_i
, а логика приоритета исключений над прерываниями не даст сигналу mret
распространиться до регистра irq_h
одновременно с формированием сигнала irq_o
).
Логика приоритета исключений над прерываниями заключается в том, что сигнал exception_i
является частью логики обработки вложенных прерываний. Пройдя через два логических ИЛИ и последующий инвертор, этот сигнал обнулит запрос на прерывание на логическом И в правом верхнем углу.
Логика маскирования запросов на прерывания заключается в простейшем И между запросом на прерывания (irq_req_i
) и сигналом разрешения прерывания (mie_i
).
Пример обработки перехвата
В листинге 1 представлен пример программы с обработчиком перехватов. Программа начинается с инициализации начальных значений регистров управления, указателя на верхушку стека и глобальную область данных, после чего уходит в бесконечный цикл ничего не делая, до тех пор, пока не произойдёт перехват.
Алгоритм работы обработчика перехвата (trap handler
) выглядит следующим образом:
- сохраняется содержимое регистрового файла на стек;
- проверяется регистр причины чтобы запустить необходимую подпрограмму;
- происходит вызов необходимой подпрограммы;
- после возврата происходит восстановление содержимого регистрового файла;
- затем происходит возврат управления прерванной программе.
Если бы система прерывания была векторной, то рутина со считыванием кода причины отсутствовала.
_start:
# Инициализируем начальные значения регистров
00: li x2, 0x00003FF0 # устанавливаем указатель на верхушку стека
04: # данная псевдоинструкция будет разбита на две
# инструкции: lui и addi
08: li x3, 0x00000000 # устанавливаем указатель на глобальные данные
0С: la x5, trap_handler # псевдоинструкция la аналогично li загружает число,
10: # только в случае la — это число является адресом
# указанного места (адреса обработчика перехвата)
# данная псевдоинструкция будет разбита на две
# инструкции: lui и addi
14: csrw mtvec, x5 # устанавливаем вектор прерывания
18: li x5, 0x00001FFC # готовим адрес верхушки стека прерывания
1С: # данная псевдоинструкция будет разбита на две
# инструкции: lui и addi
20: csrw mscratch, x5 # загружаем указатель на верхушку стека прерывания
24: li x5, 0x00010000 # подготавливаем маску прерывания единственного
# входа прерываний
28: csrw mie, 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: addi x5, x5, -16 # поднимаем верхушку стека на 16 байт вверх
# (указатель на стек всегда должен быть выровнен
# границе в 16 байт)
4С: sw x6, 0(x5) # сохраняем x6 на стек mscratch
50: sw x7, 4(x5) # сохраняем x7 на стек mscratch
# Проверяем произошло ли прерывание
54: csrr x6, mcause # x6 = mcause
58: li x7, 0x80000010 # загружаем в x7 код того, что произошло прерывание
5С: # данная псевдоинструкция будет разбита на две
# инструкции: lui и addi
60: bne x6, x7, exc_handler # если коды не совпадают, переходим к проверке
# на исключение
# Обработчик прерывания
64: lw x7, 0(x3) # загружаем переменную из памяти
68: addi x7, x7, 3 # прибавляем к значению 3
6С: sw x7, 0(x3) # возвращаем переменную в память
70: j done # идем возвращать регистры и на выход
exc_handler: # Проверяем произошло ли исключение
74: li x7, 0x0000002 # загружаем в x7 код того, что произошло исключение
78: bne x6, x7, done # если это не оно, то выходим
# Обработчик исключения
7С: csrr x6, mepc # Узнаем значение PC (адреса инструкции,
# вызвавшей исключение)
80: lw x7, 0x0(x6) # Загружаем эту инструкцию в регистр x7.
# В текущей микроархитектурной реализации это
# невозможно, т.к. память инструкций отделена от
# памяти данных и не участвует в выполнении
# операций load / store.
# Другой способ узнать об инструкции, приведшей
# к исключению — добавить поддержку статусного
# регистра mtval, в который при исключении
# может быть записана текущая инструкция.
# Теоретически мы могли бы после этого
# сделать что-то, в зависимости от этой инструкции.
# Например, если это операция умножения — вызвать
# подпрограмму умножения.
84: addi x6, x6, 4 # Увеличиваем значение PC на 4, чтобы после
# возврата не попасть на инструкцию, вызвавшую
# исключение.
88: csrw mepc, x6 # Записываем обновленное значение PC в регистр mepc
8С: j done # идем восстанавливать регистры со стека и на выход
# Возвращаем регистры на места и выходим
done:
90: lw x6, 0(x5) # возвращаем x6 со стека
94: lw x7, 4(x5) # возвращаем x7 со стека
98: addi x5, x5, 16 # опускаем верхушку стека обратно на 16 байт вниз
9С: csrrw x5, mscratch, x5 # меняем обратно местами x5 и mscratch
A0: mret # возвращаем управление программе (pc = mepc)
# что означает возврат в бесконечный цикл
Задание
- Описать на языке 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
- Описать на языке 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
Порядок выполнения задания
- Внимательно ознакомьтесь с описанием модуля
csr_controller
и его структурной схемой. В случае возникновения вопросов, проконсультируйтесь с преподавателем. - Добавьте в
Design Sources
проекта файл сsr_pkg.sv. Данный файл содержит пакет с адресами регистров контроля и статуса, а также кодами команд для взаимодействия с ними. - Опишите модуль
csr_controller
с таким же именем и портами, как указано в задании. - Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_10.tb_csr.sv
. В случае, если в TCL-консоли появились сообщения об ошибках, вам необходимо найти и исправить их.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Внимательно ознакомьтесь с описанием функционального поведения сигналов
interrupt_controller
, а также его структурной схемой. В случае возникновения вопросов, проконсультируйтесь с преподавателем. - Опишите модуль
interrupt_controller
с таким же именем и портами, как указано в задании. - Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_10.tb_irq.sv
. В случае, если в TCL-консоли появились сообщения об ошибках, вам необходимо найти и исправить их.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Данная лабораторная работа не предполагает проверки в ПЛИС.
Список использованной литературы
- С.А. Орлов, Б.Я. Цилькер / Организация ЭВМ и систем: Учебник для вузов. 2-е изд. / СПб.: Питер, 2011.
- PIC24FJ512GU410 Family Data Sheet
- The Art of Assembly Language
- The RISC-V Instruction Set Manual Volume I: Unprivileged ISA
- Pillai, V.P., Megalingam, R.K. (2022). System Partitioning with Virtualization for Federated and Distributed Machine Learning on Critical IoT Edge Systems. In: Saraswat, M., Sharma, H., Balachandran, K., Kim, J.H., Bansal, J.C. (eds) Congress on Intelligent Systems. Lecture Notes on Data Engineering and Communications Technologies, vol 111. Springer, Singapore.
- The RISC-V Instruction Set Manual Volume II: Privileged Architecture
Лабораторная работа №11 "Интеграция подсистемы прерывания"
После реализации подсистемы прерывания, её необходимо интегрировать в процессорную систему. Для этого необходимо обновить модуль processor_core
по схеме, приведённой на рис. 1:
Рисунок 1. Интеграция подсистемы прерываний в ядро процессора.
Схема без выделения новых частей относительно старой версии модуля
Рисунок 2. Схема без выделения новых частей относительно старой версии модуля.
Задание
Интегрировать модули csr_controller
и irq_controller
в модуль processor_core
. При этом у модуля processor_core
будет обновлённый прототип (поскольку добавился вход irq_req_i
и irq_ret_o
):
module processor_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,
input logic irq_req_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,
output logic irq_ret_o
);
Обновите описание создания модуля processor_core
в модуле processor_system
с учётом появившихся портов. Для этого создайте провода irq_req
и irq_ret
и подключите их к соответствующим входам processor_core
. Другим концом эти провода не будут пока что ни к чему подключены — это изменится в ЛР№13.
В случае, если вы захотите расширить количество источников прерывания, вы можете выполнить вспомогательную ЛР№12.
Порядок выполнения работы
- Замените файл
program.mem
вDesign Sources
проекта новым файлом program.mem, приложенном в данной лабораторной работе. Данный файл содержит программу из листинга 1 ЛР№10. - Интегрируйте модули
csr_controller
иirq_controller
в модульprocessor_core
.- Обратите внимание, что что в модуле
processor_core
появились новые входные и выходные сигналы:irq_req_i
иirq_ret_o
. Эти порты должны быть использованы при подключенииprocessor_core
в модулеprocessor_system
.- Ко входу
irq_req_i
должен быть подключён проводirq_req
, другой конец которого пока не будет ни к чему подключён. - К выходу
irq_ret_o
необходимо подключить проводirq_ret
, который также пока не будет использован. - Имена проводов
irq_req
иirq_ret
должны быть именно такими, т.к. используются верификационным окружением при проверке данной лабораторной работы.
- Ко входу
- Обратите внимание на то, что появилась константа
imm_Z
— это единственная константа ядра, которая расширяется нулями, а не знаковым битом.
- Обратите внимание, что что в модуле
- Проверьте модуль с помощью верификационного окружения, представленного в файле lab_11.tb_processor_system.sv.
- Перед запуском симуляции убедитесь, что выбран правильный модуль верхнего уровня в
Simulation Sources
. - Как и в случае с проверкой процессора архитектуры CYBERcobra, вам не будет сказано пройден тест или нет. Вам необходимо самостоятельно, такт за тактом проверить что процессор правильно выполняет описанные в Листинге 1 ЛР№10 инструкции (см. порядок выполнения задания ЛР№4). Для этого, необходимо сперва самостоятельно рассчитать что именно должна сделать данная инструкция, а потом проверить что процессор сделал именно это.
- Перед запуском симуляции убедитесь, что выбран правильный модуль верхнего уровня в
- Данная лабораторная работа не предполагает проверки в ПЛИС.
Лабораторная работа №12 "Блок приоритетных прерываний"
В базовом варианте лабораторных работ предлагается реализовать процессорную систему с одним источником прерываний, чего достаточно для выполнения лабораторных работ. Однако, если появится желание усовершенствовать систему и увеличить количество периферийных устройств, то поддержка только одного источника прерываний создаст множество сложностей. В рамках данной лабораторной работы необходимо реализовать блок приоритетных прерываний и интегрировать его в контроллер прерываний, увеличив число потенциальных источников прерываний до 16.
Цель
- Разработать блок приоритетных прерываний (БПП), построенный по схеме daisy chain.
- Интегрировать БПП в контроллер прерываний.
Теория
Если процессорная система предполагает наличие более одного источника прерываний, то необходимо разобраться с тем, что делать в случае возникновения коллизий — наложения одновременных запросов прерываний от нескольких источников. Одним из способов решения такой проблемы является реализация приоритетов прерываний. Со схемотехнической точки зрения, проще всего реализовать схему со статическим, не изменяемым, приоритетом. Одной из таких схем является daisy chain (по-русски — гирлянда, или дейзи-чейн, или дейзи-цепочка). Пример такой схемы можно увидеть на рис. 1.
Рисунок 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
Листинг 1. Пример использования конструкции generate.
Разумеется в этом примере можно было бы просто сделать одно непрерывное присваивание assign a = b;
, однако в случае реализации верхнего ряда элементов И, подобное многобитное непрерывное присваивание не приведёт к синтезу требуемой схемы.
Практика
Рассмотрим реализацию контроллера прерываний, представленную на рис. 2.
Рисунок 2. Структурная схема блока приоритетных прерываний.
Помимо портов clk_i
и rst_i
, модуль daisy_chain
будет иметь 3 входа и три выхода:
masked_irq_i
— 16-разрядный вход маскированного запроса на прерывания (т.е. источник прерывания уже прошел маскирование сигналом регистра контроля и статуса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
.
Рисунок 3. Структурная схема блока приоритетных прерываний.
Обратите внимание, что разрядность сигналов irq_req_i
, mie_i
, irq_ret_o
изменилась. Теперь это 16-разрядные сигналы. Сигнал, который ранее шёл на выход к irq_ret_o
теперь идёт на вход irq_ret_i
модуля daisy_chain
. Формирование кода причины прерывания irq_cause_o
перенесено в модуль daisy_chain
.
Порядок выполнения работы
- Опишите модуль
daisy_chain
.- При формировании верхнего массива элементов И с рис. 2, вам необходимо воспользоваться сформировать 16 непрерывных присваиваний через блок
generate for
. - Формирование нижнего массива элементов И можно сделать с помощью одного непрерывного присваивания посредством операции побитовое И.
- При формировании верхнего массива элементов И с рис. 2, вам необходимо воспользоваться сформировать 16 непрерывных присваиваний через блок
- Проверьте модуль
daisy_chain
с помощью верификационного окружения, представленного в файлеlab_12.tb_daisy_chain
. В случае, если в TCL-консоли появились сообщения об ошибках, вам необходимо найти и исправить их.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Интегрируйте модуль
daisy_chain
в модульirq_controller
по схеме, представленной на рис. 3.- Не забудьте обновить разрядность сигналов
irq_req_i
,mie_i
,irq_ret_o
в модулеirq_controller
. - Также не забудьте обновить разрядность сигналов
irq_req_i
,irq_ret_o
в модуляхprocessor_core
иprocessor_system
. - Кроме того, теперь вам нужно использовать старшие 16 бит сигнала
mie
вместо одного при подключении модуляirq_controller
в модулеprocessor_core
.
- Не забудьте обновить разрядность сигналов
- Проверьте с помощью верификационного окружения из ЛР№11, что в процессе интеграции ничего не сломалось.
Лабораторная работа №13 "Периферийные устройства"
В ЛР№11 вы закончили реализовывать свой собственный RISC-V процессор. Однако пока что он находится "в вакууме" и никак не связан с внешним миром. Для исправления этого недостатка вами будет реализована системная шина, через которую к процессору смогут подключаться различные периферийные устройства.
Цель
Интегрировать периферийные устройства в процессорную систему.
Материалы для подготовки к лабораторной работе
Ход работы
- Изучить теорию об адресном пространстве.
- Получить индивидуальный вариант со своим набором периферийных устройств.
- Интегрировать контроллеры периферийных устройств в адресное пространство вашей системы.
- Собрать финальную схему вашей системы.
- Проверить работу системы в ПЛИС с помощью демонстрационного ПО, загружаемого в память инструкций.
Теория
Помимо процессора и памяти, третьим ключевым элементом вычислительной системы является система ввода/вывода, обеспечивающая обмен информации между ядром вычислительной машины и периферийными устройствами [1, стр. 364].
Любое периферийное устройство со стороны вычислительной машины видится как набор ячеек памяти (регистров). С помощью чтения и записи этих регистров происходит обмен информации с периферийным устройством, и управление им. Например, датчик температуры может быть реализован самыми разными способами, но для процессора он в любом случае ячейка памяти, из которой он считывает число – температуру.
Система ввода/вывода может быть организована одним из двух способов: с выделенным адресным пространством устройств ввода/вывода, или с совместным адресным пространством. В первом случае система ввода/вывода имеет отдельную шину для подключения к процессору (и отдельные инструкции для обращения к периферии), во втором – шина для памяти и системы ввода/вывода общая (а обращение к периферии осуществляется теми же инструкциями, что и обращение к памяти).
Адресное пространство
Архитектура RISC-V подразумевает использование совместного адресного пространства — это значит, что в лабораторной работе будет использована единая шина для подключения памяти и регистров управления периферийными устройствами. При обращении по одному диапазону адресов процессор будет попадать в память, при обращении по другим – взаимодействовать с регистрами управления/статуса периферийного устройства. Например, можно разделить 32-битное адресное пространство на 256 частей, отдав старшие 8 бит адреса под указание конкретного периферийного устройства. Тогда каждое из периферийных устройств получит 24-битное адресное пространство (16 MiB). Допустим, мы распределили эти части адресного пространства в следующем порядке (от младшего диапазона адресов к старшему):
- Память данных
- Переключатели
- Светодиоды
- Клавиатура PS/2
- Семисегментные индикаторы
- UART-приёмник
- UART-передатчик
- Видеоадаптер
В таком случае, если мы захотим обратиться к первому байту семисегментных индикаторов, мы должны будем использовать адрес 0x04000001
. Старшие 8 бит (0x04
) определяют выбранное периферийное устройство, оставшиеся 24 бита определяют конкретный адрес в адресном пространстве этого устройства.
На рис. 1 представлен способ подключения процессора к памяти инструкций и данных, а также 255 периферийным устройствам.
Рисунок 1. Итоговая структура процессорной системы.
Обратите внимание на то, что на вход mem_ready_i
модуля lsu
подаётся единица. Вообще говоря, каждый модуль-контроллер периферийного устройства должен содержать выходной сигнал ready_o
, который должен мультиплексироваться с остальными подобно тому, как мультиплексируются сигналы read_data_o. На вход lsu
должен подаваться выход мультиплексора. Однако, поскольку все модули достаточно просты, чтобы, как и у памяти данных, выходной сигнал ready_o
был всегда равен единице (а также для упрощения рис. 1), эти сигналы были убраны из микроархитектуры. В случае, если вы решите добавить в процессорную систему периферийное устройство, сигнал ready_o
которого не будет равен константной единице, логику управления входом mem_ready_i
модуля lsu
будет необходимо обновить описанным выше способом.
Активация выбранного устройства
В зависимости от интерфейса используемой шины, периферийные устройства либо знают какой диапазон адресов им выделен (например, в интерфейсе 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]
.
Дополнительные правки модуля processor_system
Ранее, для того чтобы ваши модули могли работать в ПЛИС, вам предоставлялся специальный модуль верхнего уровня, который выполнял всю работу по связи с периферией через входы и выходы ПЛИС. Поскольку в текущей лабораторной вы завершаете свою процессорную систему, она сама должна оказаться модулем верхнего уровня, а значит здесь вам необходимо и выполнить всё подключение к периферии.
Для этого необходимо добавить в модуль processor_system
дополнительные входы и выходы, которые подключены посредством файла ограничений к входам и выходам ПЛИС (см. документ "Как работает ПЛИС").
module processor_system(
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
(в таком случае говорят, что активный уровень данного сигнала 0
: когда сигнал равен нулю — это сброс, когда единице — не сброс).
Помимо прочего, необходимо подключить к вашему модулю блок делителя частоты
. Поскольку в данном курсе лабораторных работ вы выполняли реализацию однотактного процессора, инструкция должна пройти через все ваши блоки за один такт. Из-за этого критический путь схемы не позволит использовать тактовый сигнал частотой в 100 МГц
, от которого работает отладочный стенд. Поэтому, необходимо создать отдельный сигнал с пониженной тактовой частотой, от которого будет работать ваша схема.
Для этого необходимо:
- Подключить файл
sys_clk_rst_gen.sv
в ваш проект. - Создать экземпляр этого модуля в начале описания модуля
processor_system
следующим образом:
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. Пример создания экземпляра блока делителя частоты.
- После вставки данных строк в начало описания модуля
processor_system
вы получите тактовый сигналsysclk
с частотой в 10 МГц и сигнал сбросаrst
с активным уровнем1
(как и в предыдущих лабораторных). Все ваши внутренние модули (processor_core
,data_mem
и контроллеры периферии) должны работать от тактового сигналаsysclk
. На модули, имеющие входной сигнал сброса (rst_i
) необходимо подать ваш сигналrst
.
Задание
В рамках данной лабораторной работы необходимо реализовать модули-контроллеры двух периферийных устройств, реализующих управление в соответствии с приведенной в таблице 1 картой памяти и встроить их в процессорную систему, используя рис. 1. На карте приведено семь периферийных устройств, вам необходимо взять только два из них. Какие именно — сообщит преподаватель.
Таблица 1. Карта памяти периферийных устройств.
Работа с картой осуществляется следующим образом. Под названием каждого периферийного устройства указана старшая часть адреса (чему должны быть равны старшие 8 бит адреса, чтобы было сформировано обращение к данному периферийному устройству). Например, для переключателей это значение равно 0x01
, для светодиодов 0x02
и т.п.
В самом левом столбце указаны используемые/неиспользуемые адреса в адресном пространстве данного периферийного устройства. Например для переключателей есть только один используемый адрес: 0x000000
. Его функциональное назначение и разрешения на доступ указаны в столбце соответствующего периферийного устройства. Возвращаясь к адресу 0x000000
, для переключателей мы видим следующее:
- (R) означает что разрешён доступ только на чтение (операция записи по этому адресу должна игнорироваться вашим контроллером).
- "Выставленное на переключателях значение" означает ровно то, что и означает. Если процессор выполняет операцию чтения по адресу
0x01000000
(0x01
[старшая часть адреса переключателей] +0x000000
[младшая часть адреса для получения выставленного на переключателях значения]), то контроллер должен выставить на выходной сигналRD
значение на переключателях (о том, как получить это значение будет рассказано чуть позже).
Рассмотрим ещё один пример. При обращении по адресу 0x02000024
(0x02
[старшая часть адреса контроллера светодиодов] + 0x000024
[младшая часть адреса для доступа на запись к регистру сброса] ) должна произойти запись в регистр сброса, который должен сбросить значения в регистре управления зажигаемых светодиодов и регистре управления режимом "моргания" светодиодов (подробнее о том как должны работать эти регистры будет ниже).
Таким образом, каждый контроллер периферийного устройства должен выполнять две вещи:
- При получении сигнала
req_i
, записать в регистр или вернуть значение из регистра, ассоциированного с переданным адресом (адрес передаётся с обнуленной старшей частью). Если регистра, ассоциированного с таким адресом нет (например, для переключателей не ассоциировано ни одного адреса кроме0x000000
), игнорировать эту операцию. - Выполнять управление периферийным устройством с помощью управляющих регистров.
Подробное описание периферийных устройств их управления и назначение управляющих регистров описано после порядка выполнения задания.
Порядок выполнения задания
-
Ознакомьтесь с примером описания модуля контроллера.
-
Ознакомьтесь со спецификацией контроллеров периферии своего варианта. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
-
Добавьте в проект пакет
peripheral_pkg
. Данный пакет содержит старшие части адресов периферии в виде параметров, а также вспомогательные вызовы, используемые верификационным окружением. -
Реализуйте модули контроллеров периферии. Имена модулей и их порты будут указаны в описании контроллеров. Пример разработки контроллера приведен в примере описания модуля контроллера.
- Готовые модули периферии, управление которыми должны осуществлять модули-контроллеры хранятся в папке
peripheral modules
.
- Готовые модули периферии, управление которыми должны осуществлять модули-контроллеры хранятся в папке
-
Обновите модуль
processor_system
в соответствии с разделом "Дополнительные правки модуля processor_system".- Подключите в проект файл
sys_clk_rst_gen.sv
. - Добавьте в модуль
processor_system
входы и выходы периферии, а также замените входrst_i
входомresetn_i
. Необходимо добавить порты даже тех периферийных устройств, которые вы не будете реализовывать. - Создайте в начале описания модуля
processor_system
экземпляр модуляsys_clk_rst_gen
, скопировав фрагмент кода, приведённый в листинге 1. - Замените подключение тактового сигнала исходных подмодулей
processor_system
на появившийся сигналsysclk
. Убедитесь, что на модули, имеющие сигнал сброса, приходит сигналrst
.
- Подключите в проект файл
-
Интегрируйте модули контроллеров периферии в процессорную систему по схеме представленной на рис. 1, руководствуясь старшими адресами контроллеров, представленными на карте памяти (таблицы 1). Это означает, что если вы реализуете контроллер светодиодов, на его вход
req_i
должна подаваться единица в случае, еслиmem_req_o == 1
и старшие 8 бит адреса равны0x02
.- При интеграции вам необходимо подключить только модули-контроллеры вашего варианта. Контроллеры периферии других вариантов подключать не надо.
- Во время интеграции, вам необходимо использовать старшую часть адреса, представленную в карте памяти для формирования сигнала
req_i
для ваших модулей-контроллеров.
-
Проверьте работу процессорной системы с помощью моделирования.
- Для моделирования используйте тестбенч
lab_13_tb_system
. - Для каждой пары контроллеров в папке
firmware/mem_files
представлены файлы, инициализирующие память инструкций. Содержимым одного из файлов, соответствующих паре периферийных устройств вашего варианта необходимо заменить содержимое файлаprogram.mem
вDesign Sources
проекта. Обратите внимание, что для пары "PS2-VGA" также необходим файл, инициализирующий память данных (в модулеdata_mem
необходимо добавить вызов инициализирующей функции$readmemh
в блокеinitial
). - Для проверки тестбенч имитирует генерацию данных периферийных устройств ввода. Перед проверкой желательно найти в тестбенче
initial
-блок своего устройства ввода (sw_block
,ps2_block
,uart_block
) — по этому блоку будет понятно, какие данные будет передавать устройство ввода. Именно эти данные в итоге должны оказаться на шинеmem_rd_i
. - Для того, чтобы понять, что устройство работает должным образом, в первую очередь необходимо убедиться, что контроллер устройства ввода успешно осуществил прием данных (сгенерированные тестбенчем данные оказались в соответствующем регистре контроллера периферийного устройства) и выполнил запрос на прерывание.
- После чего, необходимо убедиться, что процессор среагировал на данное прерывание, и в процессе его обработки в контроллер устройства вывода были поданы выходные данные.
- Для того, чтобы лучше понимать как именно процессор будет обрабатывать прерывание, рекомендуется ознакомиться с исходным кодом исполняемой программы, расположенным в папке
firmware/software
.- Общая логика программ для всех периферий сводится к ожиданию в бесконечном цикле прерывания от устройства ввода, после чего в процессе обработки прерывания процессор загружает данные от устройства ввода и (возможно преобразовав их) выдаёт их на устройство вывода.
- В случае правильной работы программы на временной диаграмме это будет отображено следующим образом: сразу после поступления прерывания от устройства ввода, на системной шине начинается операция чтения из устройства ввода (это легко определить по старшей части адреса, к которому обращается процессор), после чего выполняются операции записи в устройство вывода (аналогично, обращение к устройству вывода можно определить по адресу, к которому обращается процессор).
- При моделировании светодиодов лучше уменьшить значение, до которого считает счётчик в режиме "моргания" в 1000 раз, чтобы сократить время моделирования до очередного переключения светодиодов. Перед генерацией битстрима это значение будет необходимо восстановить, иначе моргание станет слишком быстрым и его нельзя будет воспринять невооружённым взглядом.
- Для моделирования используйте тестбенч
-
Переходить к следующему пункту можно только после того, как вы полностью убедились в работоспособности модуля на этапе моделирования (увидели корректные значения на выходных сигналах периферии, либо (если по сигналам периферии сложно судить о работоспособности), значениям в контрольных/статусных регистрах модуля-контроллера этой периферии). Генерация битстрима будет занимать у вас долгое время, а итогом вы получите результат: заработало / не заработало, без какой-либо дополнительной информации, поэтому без прочного фундамента на моделировании далеко уехать у вас не выйдет.
-
Подключите к проекту файл ограничений (nexys_a7_100t.xdc), если тот ещё не был подключён, либо замените его содержимое данными из файла к этой лабораторной работе.
-
Проверьте работу вашей процессорной системы на отладочном стенде с ПЛИС.
- Обратите внимание, что в данной лабораторной уже не будет модуля верхнего уровня
nexys_...
, так как ваш модуль процессорной системы уже полностью самостоятелен и взаимодействует непосредственно с ножками ПЛИС через модули, управляемые контроллерами периферии. - Для проверки периферии переключателей и светодиодов будет достаточно одного лишь отладочного стенда. Для проверки всей остальной периферии может могут потребоваться: компьютер (для uart_rx / uart_tx), клавиатура (для контроллера клавиатуры) и VGA-монитор для VGA-контроллера.
- Чтобы проверить работоспособность контроллеров UART, необходимо запустить на компьютере программу Putty, в настройках программы указать настройки, которыми будет сконфигурирован программой ваш контроллер (либо указать значения, которыми сбрасываются регистры, если программа ничего не настраивает) и COM-порт, через который компьютер будет общаться с контроллером. Определить нужный COM-порт на операционной системе Windows можно через "Диспетчер устройств", который можно открыть через меню пуск.
В данном окне необходимо найти вкладку "Порты (COM и LPT)", раскрыть её, а затем подключить отладочный стенд через USB-порт (если тот ещё не был подключён). В списке появится новое устройство, а в скобках будет указан нужный COM-порт. - Несмотря на то, что описанный контроллер клавиатуры позволяет управлять клавиатурой с интерфейсом PS/2, некоторые платы (например, Nexys A7) позволяют подключать вместо них клавиатуры с USB-интерфейсом. Дело в том, что PS/2 уже давно устарел и найти клавиатуры с таким интерфейсом — задача непростая. Однако протокол передачи по этому интерфейсу очень удобен для образовательных целей, поэтому некоторые производители просто ставят на платы переходник с USB на PS/2, позволяя объединить простоту разработки с удобством использования.
- Чтобы проверить работоспособность контроллеров UART, необходимо запустить на компьютере программу Putty, в настройках программы указать настройки, которыми будет сконфигурирован программой ваш контроллер (либо указать значения, которыми сбрасываются регистры, если программа ничего не настраивает) и COM-порт, через который компьютер будет общаться с контроллером. Определить нужный COM-порт на операционной системе Windows можно через "Диспетчер устройств", который можно открыть через меню пуск.
- Обратите внимание, что в данной лабораторной уже не будет модуля верхнего уровня
Описание контроллеров периферийных устройств
Для того, чтобы избежать избыточности в контексте описания контроллеров периферийных устройств будет использоваться два термина:
- Под "запросом на запись по адресу
0xАДРЕС
" будет пониматься совокупность следующих условий:- Происходит восходящий фронт
clk_i
. - На входе
req_i
выставлено значение1
. - На входе
write_enable_i
выставлено значение1
. - На входе
addr_i
выставлено значение0xАДРЕС
- Происходит восходящий фронт
- Под "запросом на чтение по адресу
0xАДРЕС
" будет пониматься совокупность следующих условий:- Происходит восходящий фронт
clk_i
. - На входе
req_i
выставлено значение1
. - На входе
write_enable_i
выставлено значение0
. - На входе
addr_i
выставлено значение0xАДРЕС
- Происходит восходящий фронт
Обратите внимание на то, что запрос на чтение должен обрабатываться синхронно (выходные данные должны выдаваться по положительному фронту clk_i
) так же, как был реализован порт на чтение памяти данных в ЛР№6.
При описании поддерживаемых режимов доступа по данному адресу используются следующее обозначения:
- 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
его старшие биты необходимо дополнить нулями.
Адресное пространство контроллера представлено в таблице 2.
Адрес | Режим доступа | Функциональное назначение |
---|---|---|
0x00 | R | Чтение значения, выставленного на переключателях |
Таблица 2. Адресное пространство контроллера переключателей.
При этом, будучи устройством ввода, данный модуль может генерировать прерывание, чтобы сообщить процессору о том, что данные на переключателях были изменены. Если на очередном такте 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
.
Регистр led_mode
отвечает за режим вывода данных на светодиоды. Когда этот регистр равен единице, светодиоды должны "моргать" выводимым значением. Под морганием подразумевается вывод значения из регистра led_val
на выход led_o
на одну секунду (загорится часть светодиодов, соответствующие которым биты шины led_o
равны единице), после чего на одну секунду выход led_o
необходимо подать нули. Запись и чтение регистра led_mode
осуществляется по адресу 0x04
.
Отсчёт времени можно реализовать простейшим счётчиком, каждый такт увеличивающимся на 1 и сбрасывающимся по достижении определенного значения, чтобы продолжить считать с нуля. Зная тактовую частоту, нетрудно определить до скольки должен считать счётчик. При тактовой частоте в 10 МГц происходит 10 миллионов тактов в секунду. Это означает, что при такой тактовой частоте через секунду счётчик будет равен 10⁷-1
(счёт идёт с нуля). Тем не менее удобней будет считать не до 10⁷-1
(что было бы достаточно очевидным и тоже правильным решением), а до 2*10⁷-1
. В этом случае старший бит счётчика каждую секунду будет инвертировать своё значение, что может быть использовано при реализации логики "моргания".
Важно отметить, что счётчик должен работать только при led_mode == 1
, в противном случае счётчик должен быть равен нулю.
Обратите внимание на то, что адрес 0x24
является адресом сброса. В случае запроса на запись по этому адресу значения 1
. вам необходимо сбросить регистры led_val
, led_mode
и все вспомогательные регистры, которые вы создали. Для реализации сброса вы можете как создать отдельный регистр led_rst
, в который будет происходить запись, а сам сброс будет происходить по появлению единицы в этом регистре (в этом случае необходимо не забыть сбрасывать и этот регистр тоже), так и создать обычный провод, формирующий единицу в случае выполнения всех указанных условий (условий запроса на запись, адреса сброса и значения записываемых данных равному единице).
Адресное пространство контроллера представлено в таблице 3.
Адрес | Режим доступа | Допустимые значения | Функциональное назначение |
---|---|---|---|
0x00 | RW | [0:65535] | Чтение и запись в регистр led_val отвечающий за вывод данных на светодиоды |
0x04 | RW | [0:1] | Чтение и запись в регистр led_mode , отвечающий за режим "моргания" светодиодами |
0x24 | W | 1 | Запись сигнала сброса |
Таблица 3. Адресное пространство контроллера светодиодов.
Клавиатура PS/2
Клавиатура PS/2 осуществляет передачу скан-кодов, нажатых на этой клавиатуре клавиш.
В рамках данной лабораторной работы вам будет дан готовый модуль, осуществляющий приём данных с клавиатуры. От вас требуется написать лишь модуль, осуществляющий контроль предоставленным модулем. У готового модуля будет следующий прототип:
module PS2Receiver(
input clk_i, // Сигнал тактирования
input rst_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
.
Адресное пространство контроллера представлено в таблице 4.
Адрес | Режим доступа | Допустимые значения | Функциональное назначение |
---|---|---|---|
0x00 | R | [0:255] | Чтение из регистра scan_code , хранящего скан-код нажатой клавиши |
0x04 | R | [0:1] | Чтение из регистра scan_code_is_unread , сообщающего о том, что есть непрочитанные данные в регистре scan_code |
0x24 | W | 1 | Запись сигнала сброса |
Таблица 4. Адресное пространство контроллера клавиатуры.
Семисегментные индикаторы
Семисегментные индикаторы позволяют выводить арабские цифры и первые шесть букв латинского алфавита, тем самым позволяя отображать шестнадцатеричные цифры. На отладочном стенде 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
(т.е. после сброса все семисегментные индикаторы должны загореться с цифрой 0
).
Адресное пространство контроллера представлено в таблице 5.
Адрес | Режим доступа | Допустимые значения | Функциональное назначение |
---|---|---|---|
0x00 | RW | [0:15] | Регистр, хранящий значение, выводимое на hex0 |
0x04 | RW | [0:15] | Регистр, хранящий значение, выводимое на hex1 |
0x08 | RW | [0:15] | Регистр, хранящий значение, выводимое на hex2 |
0x0C | RW | [0:15] | Регистр, хранящий значение, выводимое на hex3 |
0x10 | RW | [0:15] | Регистр, хранящий значение, выводимое на hex4 |
0x14 | RW | [0:15] | Регистр, хранящий значение, выводимое на hex5 |
0x18 | RW | [0:15] | Регистр, хранящий значение, выводимое на hex6 |
0x1C | RW | [0:15] | Регистр, хранящий значение, выводимое на hex7 |
0x20 | RW | [0:255] | Регистр, управляющий включением/отключением индикаторов |
0x24 | W | 1 | Запись сигнала сброса |
Таблица 5. Адресное пространство контроллера семисегментных индикаторов.
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 [1:0] 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 [1:0] 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 [1:0] 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 [1:0] 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
. Что позволяет узнать о пришедших данных и посредством прерывания.
Доступ на запись в регистр data
модуля uart_tx_sb_ctrl
происходит по адресу 0x00
в моменты положительного фронта clk_i
, когда сигнал busy_o
равен нулю. Доступ на чтение этого регистра может осуществляться в любой момент времени.
На вход tx_data_i
модуля uart_tx
непрерывно подаётся младший байт входа write_data_i
.
На вход tx_valid_i
модуля uart_tx
подаётся единица в момент выполнения запроса на запись по адресу 0x00
(при сигнале busy
равном нулю). В остальное время на вход этого сигнала подаётся 0
.
В случае запроса на запись значения 1
по адресу 0x24
(адресу сброса), все регистры модуля-контроллера должны сброситься. При этом регистр baudrate
должен принять значение 9600
, регистр, stopbit
должен принять значение 1
. Остальные регистры должны принять значение 0
.
Адресное пространство контроллера uart_rx_sb_ctrl
представлено в таблице 6.
Адрес | Режим доступа | Допустимые значения | Функциональное назначение |
---|---|---|---|
0x00 | R | [0:255] | Чтение из регистра data , хранящего значение принятых данных |
0x04 | R | [0:1] | Чтение из регистра valid , сообщающего о том, что есть непрочитанные данные в регистре data |
0x08 | R | [0:1] | Чтение из регистра busy , сообщающего о том, что модуль находится в процессе приема данных |
0x0C | RW | [0:131072] | Чтение/запись регистра baudrate , отвечающего за скорость передачи данных |
0x10 | RW | [0:1] | Чтение/запись регистра parity_en , отвечающего за включение отключение проверки данных через бит чётности |
0x14 | RW | [1:2] | Чтение/запись регистра stopbit , хранящего длину стопового бита |
0x24 | W | 1 | Запись сигнала сброса |
Таблица 6. Адресное пространство приёмника UART.
Адресное пространство контроллера uart_tx_sb_ctrl
представлено в таблице 7.
Адрес | Режим доступа | Допустимые значения | Функциональное назначение |
---|---|---|---|
0x00 | RW | [0:255] | Чтение и запись регистра data , хранящего значение отправляемых данных |
0x08 | R | [0:1] | Чтение из регистра busy , сообщающего о том, что модуль находится в процессе передачи данных |
0x0C | RW | [0:131072] | Чтение/запись регистра baudrate , отвечающего за скорость передачи данных |
0x10 | RW | [0:1] | Чтение/запись регистра parity_en , отвечающего за включение отключение проверки данных через бит чётности |
0x14 | RW | [1:2] | Чтение/запись регистра stopbit , хранящего длину стопового бита |
0x24 | W | 1 | Запись сигнала сброса |
Таблица 7. Адресное пространство передатчика UART.
В случае установки регистра parity_en
в значение 1
, модуль uart_tx будет дополнять посылку битом чётности (который вычисляется как исключающее ИЛИ по всем битам передаваемого байта). Модуль uart_rx
же будет выполнять проверку этого бита с тем, что он рассчитает самостоятельно. Однако в случае появления ошибки, внешне его поведение никак не изменится (поскольку выход err_o
данного модуля закомментирован ради простоты системы).
Видеоадаптер
Видеоадаптер позволяет выводить информацию на экран через интерфейс VGA. Предоставляемый в данной лабораторной работе vga-модуль способен выводить 80х30
символов (разрешение символа 8x16
). Таким образом, итоговое разрешение экрана, поддерживаемого vga-модулем будет 80*8 x 30*16 = 640x480
. VGA-модуль поддерживает управление цветовой схемой для каждого поля символа в сетке 80х30
. Это значит, что каждый символ (и фон символа) может быть отрисован отдельным цветом из диапазона 16-ти цветов.
Рисунок 2. Пример игры с использованием символьной графики[2].
Для управления выводимым на экран содержимым, адресное пространство модуля разделено на диапазоны, представленные в таблице 8.
Таблица 8. Адресное пространство контроллера 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). Один из цветов — черный, оба его оттенка представляют собой один и тот же цвет. На рис. 5 приведены коды цветов их rgb-значения:
Рисунок 3. Цветовая палитра vga-модуля.
Код цвета формируется следующим образом: старший бит определяет яркость оттенка цвета. Оставшиеся 3 бита кодируют используемый канал:
- 0 бит кодирует использование синего канала;
- 1 бит кодирует использование зелёного канала;
- 2 бит кодирует использование красного канала.
Таким образом, для установки цветовой схемы, необходимо выбрать два цвета из палитры, склеить их (в старших разрядах идёт цвет символа, в младших — цвет фона) и записать получившееся 8-битное значение по адресу выбранной позиции в диапазоне адресов цветовой схемы (color_map).
К примеру, мы хотим установить черный фоновый цвет и белый цвет в качестве цвета символа для верхней левой позиции. В этом случае, мы должны записать значение f0
(f(15) — код белого цвета, 0 — код черного цвета) по адресу 0x0000_1000
(нулевой адрес в диапазоне color_map
).
Для отрисовки символов, мы условно поделили экран на сетку 80х30
, и для каждой позиции в этой сетке определили фоновый и активный цвет. Чтобы модуль мог отрисовать символ на очередной позиции (которая занимает 16х8
пикселей), ему необходимо знать в какой цвет необходимо окрасить каждый пиксель для каждого ascii-кода. Для этого используется память шрифтов.
Допустим, нам необходимо отрисовать символ F
(ascii-код 0x46
).
Рисунок 4. Отрисовка символа 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 char_map_req_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 col_map_req_i, // запрос к памяти цветов символов
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 char_tiff_req_i, // запрос к памяти шрифтов символов
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_t.mem
- firmware/mem_files/lab_13_vga_ch_map.mem
- firmware/mem_files/lab_13_vga_col_map.mem
Вам необходимо добавить в проект все эти файлы. Последние три файла отвечают за инициализацию памятей шрифтов[3], символов и цветов. Инициализация будет выполнена автоматически. Главное, чтобы файлы были добавлены в проект.
Для управления данным модулем, необходимо написать модуль-контроллер со следующим прототипом:
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
Кроме того, необходимо:
- подключить напрямую сигнал
write_data_i
ко входам:char_map_wdata_i
,col_map_wdata_i
,char_tiff_wdata_i
,
- подключить биты
addr_i[11:2]
ко входам:char_map_addr_i
,col_map_addr_i
,char_tiff_addr_i
,
- сигнал
mem_be_i
подключить ко входам:char_map_be_i
,col_map_be_i
,char_tiff_be_i
.
Остается только разобраться с сигналами req_i
, write_enable_i
и read_data_o
.
Все эти сигналы мультиплексируются / демультиплексируются с помощью одного и того же управляющего сигнала: addr_i[13:12]
в соответствии с диапазонами адресов (рис. 4):
addr_i[13:12] == 2'b00
req_i
подаётся на входchar_map_req_i
,write_enable_i
поступает на входchar_map_we_i
,char_map_rdata_o
подаётся на выходread_data_o
;
addr_i[13:12] == 2'b01
req_i
поступает на входcol_map_req_i
,write_enable_i
поступает на входcol_map_we_i
,col_map_rdata_o
подаётся на выходread_data_o
;
addr_i[13:12] == 2'b10
req_i
поступает на входchar_tiff_req_i
,write_enable_i
поступает на входchar_tiff_we_i
,char_tiff_rdata_o
подаётся на выходread_data_o
.
[!Important] Обратите внимание на то, что контроллер vga является единственным контроллером, для которого не нужно реализовывать регистр перед выходом read_data_o для реализации синхронного чтения. Данная особенность обусловлена тем, что внутри модуля
vgachargen
уже находится блочная память с синхронным портом на чтение. Добавление ещё одного регистра приведёт к тому, данные будут "опаздывать" на один такт. Таким образом, данные на выходread_data_o
необходимо подавать с помощью чисто комбинационной логики.
Список использованной литературы
- С.А. Орлов, Б.Я. Цилькер / Организация ЭВМ и систем: Учебник для вузов. 2-е изд. / СПб.: Питер, 2011.
- Rebelstar
- Easycode
Лабораторная работа №14 "Высокоуровневое программирование"
Благодаря абстрагированию можно создавать действительно сложные системы — из вентилей можно собрать модули, из модулей микроархитектуру и так далее. В этом контексте архитектура выступает как фундамент, на котором строится программный стек абстракций. На основе архитектур строятся ассемблеры, на основе которых "строятся" языки высокого уровня, на основе которых создаются фреймворки и метафреймворки, что обеспечивает более высокий уровень и удобство при разработке новых программ. Давайте немного глубже погрузимся в этот стек.
Цель
В соответствии с индивидуальным заданием, написать программу на языке программирования высокого уровня C, скомпилировать в машинные коды и запустить на ранее разработанном процессоре RISC-V.
Ход работы
- Изучить теорию:
- Подготовить набор инструментов для кросс-компиляции
- Изучить порядок компиляции и команды, её осуществляющую:
- Написать и скомпилировать собственную программу
- Проверить исполнение программы вашим процессором в ПЛИС
Теория
В рамках данной лабораторной работы вы напишите полноценную программу, которая будет запущена на вашем процессоре. В процессе компиляции, вам потребуются файлы linker_script.ld и startup.S, лежащие в этой папке.
— Но зачем мне эти файлы? Мы ведь уже делали задания по программированию на предыдущих лабораторных работах и нам не были нужны никакие дополнительные файлы.
Дело в том, что ранее вы писали небольшие программки на ассемблере. Однако, язык ассемблера архитектуры RISC-V, так же, как и любой другой RISC архитектуры, недружелюбен к программисту, поскольку изначально создавался с прицелом на то, что будут созданы компиляторы и программы будут писаться на более удобных для человека языках высокого уровня. Ранее вы писали простенькие программы, которые можно было реализовать на ассемблере, теперь же вам будет предложено написать полноценную программу на языке Си.
— Но разве в процессе компиляции исходного кода на языке Си мы не получаем программу, написанную на языке ассемблера? Получится ведь тот же код, что мы могли написать и сами.
Штука в том, что ассемблерный код, который писали ранее вы отличается от ассемблерного кода, генерируемого компилятором. Код, написанный вами, обладал, скажем так... более тонким микро-контролем хода программы. Когда вы писали программу, вы знали какой у вас размер памяти, где в памяти расположены инструкции, а где данные (ну, при написании программ вы почти не пользовались памятью данных, а когда пользовались — просто лупили по случайным адресам и все получалось). Вы пользовались всеми регистрами регистрового файла по своему усмотрению, без ограничений. Однако, представьте на секунду, что вы пишете проект на ассемблере вместе с коллегой: вы пишите одни функции, а он другие. Как в таком случае вы будете пользоваться регистрами регистрового файла? Ведь если вы будете пользоваться одними и теми же регистрами, вызов одной функции может испортить данные в другой. Поделите его напополам и будете пользоваться каждый своей половиной? Но что будет, если к проекту присоединится ещё один коллега — придётся делить регистровый файл уже на три части? Так от него уже ничего не останется. Для разрешения таких ситуаций было разработано соглашение о вызовах (calling convention).
Таким образом, генерируя ассемблерный код, компилятор не может так же, как это делали вы, использовать все ресурсы без каких-либо ограничений — он должен следовать ограничениям, накладываемым на него соглашением о вызовах, а также ограничениям, связанным с тем, что он ничего не знает о памяти устройства, в котором будет исполняться программа — а потому он не может работать с памятью абы как. Работая с памятью, компилятор следует некоторым правилам, благодаря которым после компиляции компоновщик сможет собрать программу под ваше устройство с помощью специального скрипта.
Соглашение о вызовах
Соглашение о вызовах устанавливает порядок вызова функций: где размещаются аргументы при вызове функций, где находятся указатель на стек и адрес возврата и т.п.
Кроме того, соглашение делит регистры регистрового файла на две группы: оберегаемые и необерегаемые регистры.
При работе с оберегаемыми регистрами, функция должна гарантировать, что перед возвратом в этих регистрах останется тоже самое значение, что было при вызове функции. То есть, если функция собирается записать что-то в оберегаемый регистр, она должна сохранить перед этим его значение на стек, а затем, перед возвратом, вернуть это значение со стека обратно в этот же регистр.
Простая аналогия — в маленькой квартире двое делят один рабочий стол по времени. Каждый использует стол по полной, но после себя он должен оставить половину стола соседа (оберегаемые регистры) в том же виде, в котором её получил, а со своей (необерегаемые регистры) делает что хочет. Кстати, вещи соседа, чтоб не потерять, убирают на стопку (stack) рядом (в основную память).
С необерегаемыми регистрами функция может работать как ей угодно — не существует никаких гарантий, которые вызванная функция должна исполнить. При этом, если функция вызывает другую функцию, она точно так же не получает никаких гарантий, что вызванная функция оставит значения необерегаемых регистров без изменений, поэтому если там хранятся значения, которые потребуются по окончанию выполнения вызываемой функции, эти значения необходимо сохранить на стек.
В таблице 1 приведено разделение регистров на оберегаемые (в правом столбце записано Callee
, т.е. за их сохранение отвечает вызванная функция) и необерегаемые (Caller
— за сохранение отвечает вызывающая функция). Кроме того, есть три регистра, для которых правый столбец не имеет значения: нулевой регистр (поскольку его невозможно изменить) и указатели на поток и глобальную область памяти. По соглашению о вызовах, эти регистры нельзя использовать для вычислений функций, они изменяются только по заранее оговорённым ситуациям.
В столбце ABI name
записывается синоним имени регистра, связанный с его функциональным назначением (см. описание регистра). Часто ассемблеры одинаково воспринимают обе формы написания имени регистров.
Register | ABI Name | Description | Saver |
---|---|---|---|
x0 | zero | Hard-wired zero | — |
x1 | ra | Return address | Caller |
x2 | sp | Stack pointer | Callee |
x3 | gp | Global pointer | — |
x4 | tp | Thread pointer | — |
x5 | t0 | Temporary/alternate link register | Caller |
x6–7 | t1–2 | Temporaries | Caller |
x8 | s0/fp | Saved register/frame pointer | Callee |
x9 | s1 | Saved register | Callee |
x10–11 | a0–1 | Function arguments/return values | Caller |
x12–17 | a2–7 | Function arguments | Caller |
x18–27 | s2–11 | Saved registers | Callee |
x28–31 | t3–6 | Temporaries | Caller |
Таблица 1. Ассемблерные мнемоники для целочисленных регистров RISC-V и их назначение в соглашении о вызовах[1, стр. 6].
Несмотря на то, что указатель на стек помечен как Callee-saved регистр, это не означает, что вызываемая функция может записать в него что заблагорассудится, предварительно сохранив его значение на стек. Ведь как вы вернёте значение указателя на стек со стека, если в регистре указателя на стек лежит что-то не то?
Запись Callee
означает, что к моменту возврата из вызываемой функции, значение Callee-saved регистров должно быть ровно таким же, каким было в момент вызова функций. Для s0-s11 регистров это осуществляется путём сохранения их значений на стек. При этом, перед каждым сохранением на стек, изменяется значение указателя на стек таким образом, чтобы он указывал на сохраняемое значение (обычно он декрементируется). Затем, перед возвратом из функции все сохранённые на стек значения восстанавливаются, попутно изменяя значение указателя на стек противоположным образом (инкрементируют его). Таким образом, несмотря на то что значение указателя на стек менялось в процессе работы вызываемой функции, к моменту выхода из неё, его значение в итоге останется тем же.
Скрипт для компоновки (linker_script.ld)
Скрипт для компоновки описывает то, как в вашей памяти будут храниться данные. Вы уже могли слышать о том, что исполняемый файл содержит секции .text
и .data
— инструкций и данных соответственно. Компоновщик (linker) ничего не знает о том, какая у вас структура памяти: принстонская у вас архитектура или гарвардская, по каким адресам у вас должны храниться инструкции, а по каким данные, какой в памяти используется порядок следования байт (endianess). У вас может быть несколько типов памятей под особые секции — и обо всем этом компоновщику можно сообщить в скрипте для компоновки.
В самом простом виде скрипт компоновки состоит из одного раздела: раздела секций, в котором вы и описываете какие части программы куда и в каком порядке необходимо разместить.
Для удобства этого описания существует вспомогательная переменная: счётчик адресов. Этот счётчик показывает в какое место в памяти будет размещена очередная секция (если при размещении секции в явном виде не будет указано иного). На момент начала исполнения скрипта этот счётчик равен нулю. Размещая очередную секцию, счётчик увеличивается на размер размещаемой секции. Допустим, у нас есть два файла startup.o
и main.o
, в каждом из которых есть секции .text
и .data
. Мы хотим разместить их в памяти следующим образом: сперва разместить секции .text
обоих файлов, а затем секции .data
.
В итоге начиная с нулевого адреса будет размещена секция .text
файла startup.o
. Она будет размещена именно там, поскольку счётчик адресов в начале скрипта равен нулю, а очередная секция размещается по адресу, куда указывает счётчик адресов. После этого, счётчик будет увеличен на размер этой секции и секция .text
файла main.o
будет размещена сразу же за секцией .text
файла startup.o
. После этого счётчик адресов будет увеличен на размер этой секции. То же самое произойдёт и при размещении оставшихся секций.
Кроме того, вы в любой момент можете изменить значение счетчика адресов. Например, если адресное пространство памяти поделено на две части: под инструкции отводится 512 байт, а под данные 1024 байта. Таким образом, выделенный диапазон адресов для инструкций: [0:511]
, а для данных: [512:1535]
. Предположим при этом, что общий объем секций .text
составляет 416 байт. В этом случае, вы можете сперва разместить секции .text
так же, как было описано в предыдущем примере, а затем, выставив значение на счетчике адресов равное 512
, описать размещение секций данных. Тогда, между секциями появится разрыв в 96 байт, а данные окажутся в выделенном для них диапазоне адресов.
В нашей процессорной системе гарвардская архитектура. Это значит, что память инструкций и данных у нас независимы друг от друга. Это физически разные устройства, с разными шинами и разным адресным пространством. Однако обе эти памяти имеют общие значения младших адресов: самый младший имеет адрес ноль, следующий адрес 1 и т.д. Таким образом, происходит наложение адресных пространств памяти инструкций и памяти данных. Компоновщику трудно работать в таких условиях: "как я записать что по этому адресу будет размещаться секция данных, когда здесь уже размещена секция инструкций".
Есть два механизма для решения этого вопроса. Первый: компоновать секции инструкций и данных по отдельности. В этом случае будет два отдельных скрипта компоновщика. Однако, компоновка секций инструкций зависит от компоновки секций данных (в частности, от того по каким адресам будут размещены стек и .bss-секция, а также указатель на глобальную область данных), поскольку в часть инструкций необходимо прописать конкретные адреса. В этом случае, придётся делать промежуточные операции в виде экспорта глобальных символов в отдельный объектный файл, который будет использован при компоновке секции инструкций, что кажется некоторым переусложнением.
Вместо этого, будет использован другой подход, механизм виртуальных адресов (Virtual Memory Address, VMA) и адресов загрузки (Load Memory Address, LMA).
- VMA — это адрес, по которому секция будет доступна во время выполнения программы. По этому адресу процессор будет обращаться к секции.
- LMA — это адрес, по которому секция будет загружена в память при старте программы.
Обычно LMA совпадает с VMA. Однако в некоторых случаях они могут быть и различны (например, изначально секция данных записывается в ROM, а перед выполнением программы, копируется из ROM в RAM). В этом случае, LMA — это адрес секции в ROM, а VMA — адрес секции в RAM.
Таким образом, мы можем сделать общие VMA (процессор, обращаясь к секциям инструкций и данных будет использовать пересекающееся адресное пространство), а конфликт размещения секций компоновщиком разрешить, задав какой-нибудь заведомо большой VMA для секции данных. В последствии, мы просто проигнорируем этот адрес, проинициализировав память данных начиная с нуля.
Помимо прочего, в скрипте компоновщика необходимо прописать, каков порядок следования байт, где будет находиться стек, и какое будет значение у указателя на глобальную область памяти.
Все это с подробными комментариями описано в файле linker_script.ld
.
OUTPUT_FORMAT("elf32-littleriscv") /* Указываем порядок следования байт */
ENTRY(_start) /* мы сообщаем компоновщику, что первая
исполняемая процессором инструкция
находится у метки "_start"
*/
/*
В данном разделе указывается структура памяти:
Сперва идёт регион "instr_mem", являющийся памятью с исполняемым кодом
(об этом говорит аттрибут 'x'). Этот регион начинается
с адреса 0x00000000 и занимает 1024 байта.
Далее идет регион "data_mem", начинающийся с адреса 0x00000000 и занимающий
2048 байт. Этот регион является памятью, противоположной региону "instr_mem"
(в том смысле, что это не память с исполняемым кодом).
*/
MEMORY
{
instr_mem (x) : ORIGIN = 0x00000000, LENGTH = 1K
data_mem (!x) : ORIGIN = 0x00000000, LENGTH = 2K
}
_trap_stack_size = 640; /* Размер стека обработчика перехватов.
Данный размер позволяет выполнить
до 8 вложенных вызовов при обработке
перехватов.
*/
_stack_size = 640; /* Размер программного стека.
Данный размер позволяет выполнить
от 8 вложенных вызовов.
*/
/*
В данном разделе описывается размещение программы в памяти.
Программа разделяется на различные секции:
- секции исполняемого кода программа;
- секции статических переменных и массивов, значение которых должно быть
"вшито" в программу;
и т.п.
*/
SECTIONS
{
/*
В скриптах компоновщика есть внутренняя переменная, записываемая как '.'
Эта переменная называется "счётчиком адресов". Она хранит текущий адрес в
памяти.
В начале файла она инициализируется нулём. Добавляя новые секции, эта
переменная будет увеличиваться на размер каждой новой секции.
Если при размещении секций не указывается никакой адрес, они будут размещены
по текущему значению счётчика адресов.
Этой переменной можно присваивать значения, после этого, она будет
увеличиваться с этого значения.
Подробнее:
https://home.cs.colorado.edu/~main/cs1300/doc/gnu/ld_3.html#IDX338
*/
/*
Следующая команда сообщает, что начиная с адреса, которому в данных момент
равен счётчик адресов (в данный момент, начиная с нуля) будет находиться
секция .text итогового файла, которая состоит из секций .boot, а также всех
секций, начинающихся на .text во всех переданных компоновщику двоичных
файлах.
Дополнительно мы указываем, что данная секция должна быть размещена в
регионе "instr_mem".
*/
.text : {
PROVIDE(_start = .);
*(.boot)
*(.text*)
} > instr_mem
/*
Секция данных размещается аналогично секции инструкций за исключением
адреса загрузки в памяти (Load Memory Address, LMA). Поскольку память
инструкций и данных физически разделены, у них есть пересекающееся адресное
пространство, которое мы бы хотели использовать (поэтому в разделе MEMORY мы
указали что стартовые адреса обоих памятей равны нулю). Однако компоновщику
это не нравится, ведь как он будет размещать две разные секции в одно и то же
место. Поэтому мы ему сообщаем, с помощью оператора "AT", что загружать секцию
данных нужно на самом деле не по нулевому адресу, а по какому-то другому,
заведомо большему чем размер памяти инструкций, но процессор будет
использовать адреса, начинающиеся с нуля. Такой вариант компоновщика
устраивает и он собирает исполняемый файл без ошибок. Наша же задача,
загрузить итоговую секцию данных по нулевым адресам памяти данных.
*/
.data : AT (0x00800000) {
/*
Общепринято присваивать GP значение равное началу секции данных, смещённое
на 2048 байт вперёд.
Благодаря относительной адресации со смещением в 12 бит, можно адресоваться
на начало секции данных, а также по всему адресному пространству вплоть до
4096 байт от начала секции данных, что сокращает объем требуемых для
адресации инструкций (практически не используются операции LUI, поскольку GP
уже хранит базовый адрес и нужно только смещение).
Подробнее:
https://groups.google.com/a/groups.riscv.org/g/sw-dev/c/60IdaZj27dY/m/s1eJMlrUAQAJ
*/
_gbl_ptr = . + 2048;
*(.*data*)
*(.sdata*)
} > data_mem
/*
Поскольку мы не знаем суммарный размер всех используемых секций данных,
перед размещением других секций, необходимо выровнять счётчик адресов по
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
Дополнительно мы указываем, что данная секция должна быть размещена в
регионе "data_mem".
*/
_bss_start = .;
.bss : {
*(.bss*)
*(.sbss*)
} > data_mem
_bss_end = .;
/*=================================
Секция аллоцированных данных завершена, остаток свободной памяти отводится
под программный стек, стек прерываний и (возможно) кучу. В соглашении о
вызовах архитектуры RISC-V сказано, что стек растёт снизу вверх, поэтому
наша цель разместить его в самых последних адресах памяти.
Поскольку стеков у нас два, в самом низу мы разместим стек прерываний, а
над ним программный стек. При этом надо обеспечить защиту программного
стека от наложения на него стека прерываний.
Однако перед этим, мы должны убедиться, что под оба стека хватит места.
=================================
*/
/* Мы хотим гарантировать, что под стек останется место */
ASSERT(. < (LENGTH(data_mem) - _trap_stack_size - _stack_size),
"Program size is too big")
/* Перемещаем счётчик адресов над стеком прерываний (чтобы после мы могли
использовать его в вызове ALIGN) */
. = LENGTH(data_mem) - _trap_stack_size;
/*
Размещаем указатель программного стека так близко к границе стека
прерываний, насколько можно с учетом требования о выравнивании адреса
стека до 16 байт.
Подробнее:
https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf
*/
_stack_ptr = ALIGN(16) <= LENGTH(data_mem) - _trap_stack_size?
ALIGN(16) : ALIGN(16) - 16;
ASSERT(_stack_ptr <= LENGTH(data_mem) - _trap_stack_size,
"SP exceed memory size")
/* Перемещаем счётчик адресов в конец памяти (чтобы после мы могли
использовать его в вызове ALIGN) */
. = LENGTH(data_mem);
/*
Обычно память имеет размер, кратный 16, но на случай, если это не так, мы
делаем проверку, после которой мы либо остаёмся в самом конце памяти (если
конец кратен 16), либо поднимаемся на 16 байт вверх от края памяти,
округлённого до 16 в сторону большего значения
*/
_trap_stack_ptr = ALIGN(16) <= LENGTH(data_mem) ? ALIGN(16) : ALIGN(16) - 16;
ASSERT(_trap_stack_ptr <= LENGTH(data_mem), "ISP exceed memory size")
}
Листинг 1. Пример скрипта компоновщика с комментариями.
Обратите внимание на указанные размеры памяти инструкций и данных. Они отличаются от размеров, которые использовались ранее в пакете memory_pkg
. Дело в том, что пока система и исполняемые ей программы были простыми, в большом объеме памяти не было нужды и меньший размер значительно сокращал время синтеза системы. Однако в данный момент, чтобы обеспечить программе достаточно места под инструкции, а также программный стек и стек прерываний, необходимо увеличить объемы памяти инструкций и памяти данных. Для этого необходимо обновить значения параметров INSTR_MEM_SIZE_BYTES
и DATA_MEM_SIZE_BYTES
на 32'd1024 и 32'd2048 соответственно. В зависимости от сложности вашего проекта, в будущем вам может снова потребоваться изменять размер памяти в вашей системе. Помните, все изменения в memory_pkg должны отражаться и в скрипте компоновщика для вашей системы.
Файл первичных команд при загрузке (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 mscratch, t2
csrw mie, t1
# Вызов функции main
_main_call:
li a0, 0 # Передача аргументов argc и argv в main. Формально, argc должен
li a1, 0 # быть больше нуля, а argv должен указывать на массив строк,
# нулевой элемент которого является именем исполняемого файла,
# Но для простоты реализации оба аргумента всего лишь обнулены.
# Это сделано для детерминированного поведения программы в случае,
# если программист будет пытаться использовать эти аргументы.
# Вызов main.
# Для того чтобы программа скомпоновалась, где-то должна быть описана
# функция именно с таким именем.
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. Пример содержимого файла первичных команд с поясняющими комментариями.
Обратите внимание на строки call main
и call int_handler
. Компоновка объектного файла, полученного после компиляции startup.S
будет успешной только в том случае, если в других компонуемых файлах будут функции именно с такими именами.
Практика
Для того, чтобы запустить моделирование исполнения программы на вашем процессоре, сперва эту программу необходимо скомпилировать и преобразовать в текстовый файл, которым САПР сможет проинициализировать память процессора. Для компиляции программы, вам потребуется особый компилятор, который называется "кросскомпилятор". Он позволяет компилировать исходный код под архитектуру компьютера, отличную от компьютера, на котором ведется компиляция. В нашем случае, вы будете собирать код под архитектуру RISC-V
на компьютере с архитектурой x86_64
.
Компилятор, который подойдет для данной задачи должен быть установлен в учебной аудитории. Но если что, вы можете скачать его отсюда (обратите внимание, что размер архива составляет ~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
. Как уже говорилось, число, начинающееся с символа @
говорит САПР, что с этого момента инициализация идет начиная с ячейки памяти, номер которой совпадает с этим числом. Когда вы будете экспортировать секции данных, первой строкой будет: @20000000
. Так произойдет, поскольку в скрипте компоновщика сказано, указано инициализировать память данных с 0x80000000
адреса (значение которого было поделено на 4, чтобы получить номер 32-битной ячейки памяти). Это было сделано, чтобы не произошло наложения адресов памяти инструкций и памяти данных (см раздел скрипт для компоновки). Чтобы система работала корректно, эту строчку необходимо удалить.
Дизассемблирование
В процессе отладки лабораторной работы потребуется много раз смотреть на программный счётчик и текущую инструкцию. Довольно тяжело декодировать инструкцию самостоятельно, чтобы понять, что сейчас выполняется. Для облегчения задачи можно дизасемблировать скомпилированный файл. Полученный файл на языке ассемблера будет хранить адреса инструкций, а также их двоичное и ассемблерное представление.
Пример дизасемблированного файла:
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
...
Листинг 3. Пример дизасемблированного файла.
Числа в самом левом столбце, увеличивающиеся на 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++).
Для того чтобы ваша программа собралась, необходимо описать две функции: main
и int_handler
. Аргументы и возвращаемые значения могут быть любыми, но использоваться они не смогут. Функция main
будет вызвана в начале работы программы (после исполнения .boot-секции startup-файла), функция int_handler
будет вызываться автоматически каждый раз, когда ваш контроллер устройства ввода будет генерировать запрос прерывания (если процессор закончил обрабатывать предыдущий запрос).
Таким образом, минимальный алгоритм работы заключается в том, чтобы считать по прерыванию данные от устройства ввода (в индивидуальном задании обозначалось как sw_i), выполнить обработку из вашего варианта, и записать результат в устройство вывода. При этом необходимо помнить о следующем:
- При вводе данных с клавиатуры, отправляется скан-код клавиши, а не значение нажатой цифры (и не ascii-код нажатой буквы). Более того, при отпускании клавиши, генерируется скан-код
FO
, за которым следует повторная отправка скан-кода этой клавиши. - Работая с uart через программу Putty, вы отправляете ascii-код вводимого символа.
Таким образом, для этих двух устройств ввода, вам необходимо продумать протокол, по которому вы будете вводить числа в вашу программу. В простейшем случае можно обрабатывать данные "как есть". Т.е. в случае клавиатуры, нажатие на клавишу 1
в верхнем горизонтальном ряду на клавиатуры со скан-кодом 0x16 интерпретировать как число 0x16
. А в случае отправки по uart символа 1
с ascii-кодом 0x31
интерпретировать его как 0x31
. Однако вывод в Putty осуществляется в виде символов принятого ascii-кода, поэтому высок риск получить непечатный символ.
Функция main может быть как пустой, содержать один лишь оператор return или бесконечный цикл — ход работы в любом случае не сломается, т.к. в стартап-файле прописан бесконечный цикл после выполнения main. Тем не менее, вы можете разместить здесь и какую-то логику, получающую данные от обработчика прерываний через глобальные переменные.
Доступ к регистрам контроллеров периферии осуществляется через обращение в память. В простейшем случае такой доступ осуществляется через разыменование указателей, проинициализированных адресами регистров из карты памяти 13-ой лабораторной работы.
При написании программы помните, что в C++ сильно ограничена арифметика указателей, поэтому при присваивании указателю целочисленного значения адреса, необходимо использовать оператор reinterpret_cast
.
Для того, чтобы уменьшить ваше взаимодействие с черной магией указателей, вам представлен файл platform.h, в котором объявлены указатели на структуры, отвечающие за отображение полей на физические адреса периферийных устройств. Вам нужно лишь воспользоваться указателем на ваше периферийное устройство.
Если вашим устройством вывода является VGA-контроллер, то вам необходимо использовать экземпляр структуры, а не указатель на нее. Внутри данной структуры представлены указатели на байты: char_map
, color_map
, tiff_map
. Как вы знаете, указатель может использоваться в качестве имени массива, а значит вы можете обращаться к нужному вам байту в соответствующей области памяти VGA-контроллера как к элементу массива. Например, для того, чтобы записать символ в шестое знакоместо второй строки, вам необходимо будет обратиться к char_map[2*80+6]
(2*80 — индекс начала второй строки).
Пример взаимодействия с периферийным устройством через структуру ВЫМЫШЛЕННОГО периферийного устройства. Данная программа является лишь примером, иллюстрирующим взаимодействие с периферией через представленные указатели на структуры. Вам необходимо разобраться в том, как осуществляется работа с вымышленным устройством, а затем написать собственную программу, работающую по логике вашего индивидуального задания, которая взаимодействует с вашим реальным устройством.
/*
Не надо копировать и использовать в качестве основы вашей программы этот код.
Он для этого не подходит. В вашей процессорной системе нет никаких коллайдеров
DEADLY_SERIOUS-событий и аварийных выключателей.
Просто разберитесь в операторах `->`, ".", использовании указателей в качестве
имени массива и напишите собственную программу.
*/
#include "platform.h"
/*
Создаем заголовочном файле "platform.h" объявлены collider_ptr — указатель на
структуру SUPER_COLLIDER_HANDLE и collider_obj — экземпляр аналогичной
структуры.
Доступ к полям этой структуры через указатель можно осуществлять посредством
оператора "->". Доступ к полям через экземпляр осуществляется с помощью
оператора ".".
Среди прочих полей, структура содержит указатель collider_mem, который
указывает на некоторую память этого периферийного устройства. Данный указатель
можно использовать в качестве имени массива.
*/
int main(int argc, char** argv)
{
while(1){ // В бесконечном цикле
while (!(collider_ptr->ready)); // Постоянно опрашиваем регистр ready,
// пока тот не станет равен 1.
// После чего запускаем коллайдер,
collider_ptr->start = 1; // записав 1 в контрольный регистр start
collider_obj.mem[0] = 300; // Пример взаимодействия с памятью,
// Используя объявленный в структуре
// указатель в качестве имени массива.
}
}
#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;
}
}
Листинг 4. Пример кода на C++, взаимодействующего с выдуманным периферийным устройством через указатели на структуру и массив, объявленные в platform.h.
Порядок выполнения задания
- Внимательно изучите разделы теории и практики.
- Разберите принцип взаимодействия с контрольными и статусными регистрами периферийного устройства на примере Листинга 4.
- Обновите значения параметров
INSTR_MEM_SIZE_BYTES
иDATA_MEM_SIZE_BYTES
в пакетеmemory_pkg
на 32'd1024 и 32'd2048 соответственно. Поскольку пакеты не являются модулями, вы не увидите их во вкладкеHierarchy
окна исходников, вместо этого вы сможете найти их во вкладкахLibraries
иCompile order
. - Напишите программу для своего индивидуального задания и набора периферийных устройств на языке C или C++. В случае написания кода на C++ помните о необходимости добавления
extern "C"
перед определением функцииint_handler
.- В описываемой программе обязательно должны присутствовать функции
main
иint_handler
, т.к. в стартап-файле описаны вызовы этих функций. При необходимости, вы можете описать необходимые вам вспомогательные функции — ограничений на то, что должно быть ровне две этих функции нет. - Функция
main
может быть пустой — по её завершению в стартап-файле предусмотрен бесконечный цикл, из которого процессор сможет выходить только по прерыванию. - В функции
int_handler
вам необходимо считать поступившие от устройства ввода входные данные. - Вам необходимо самостоятельно решить, как вы хотите построить ход работы вашей программы: будет ли ваше индивидуальное задание вычисляться всего лишь 1 раз в функции
main
, данные в которую поступят от функцииint_handler
через глобальные переменные, или же оно будет постоянно пересчитываться при каждом вызовеint_handler
. - Доступ к регистрам контроля и статуса необходимо осуществлять посредством указателей на структуры, объявленные в файле platform.h. В случае VGA-контроллера, доступ к областям памяти осуществляется через экземпляр структуры (а не указатель на нее), содержащий имена массивов
char_map
,color_map
иtiff_map
.
- В описываемой программе обязательно должны присутствовать функции
- Скомпилируйте программу и стартап-файл в объектные файлы.
- Скомпонуйте объектные файлы исполняемый файл, передав компоновщику соответствующий скрипт.
- Экспортируйте из объектного файла секции
.text
и.data
в текстовые файлыinit_instr.mem
,init_data.mem
. Если вы не создавали инициализированных статических массивов или глобальных переменных, то файлinit_data.mem
может быть оказаться пустым.- Если файл
init_data.mem
не пустой, необходимо проинициализировать память в модулеext_mem
c помощью системной функции$readmemh
как это было сделано для памяти инструкций. - Перед этим из файла
init_data.mem
необходимо удалить первую строку (вида@20000000
), указывающую начальный адрес инициализации.
- Если файл
- Добавьте получившиеся текстовые файлы в проект Vivado.
- Запустите моделирование исполнения программы вашим процессором с помощью тестбенча из ЛР№13.
- В
peripheral_pkg
находятся вспомогательные вызовы, позволяющие сымитировать ввод с клавиатуры или uart (для переключателей никаких вспомогательных вызовов не требуется). Пример имитации ввода вы можете посмотреть в тестбенче. Обновите код тестбенча таким образом, чтобы в вашу систему были поданы необходимые для работы вашей программы данные. - Для отладки во время моделирования будет удобно использовать дизасемблерный файл, ориентируясь на сигналы адреса и данных шины инструкций.
- В
- Проверьте корректное исполнение программы процессором в ПЛИС.
Список источников:
- ISC-V ABIs Specification, Document Version 1.0', Editors Kito Cheng and Jessica Clarke, RISC-V International, November 2022;
- Using LD, the GNU linker — Linker Scripts;
- Google Gropus — "gcc gp (global pointer) register";
- Wikipedia — .bss.
Лабораторная работа №15 "Программатор"
Чтобы выпустить микроконтроллер в "дикую природу", то есть, чтобы его можно было использовать не в лабораторных условиях, а независимо от всего этого дополнительного оборудования, необходимо предусмотреть механизм замены исполняемой программы.
Цель
Реализация программатора — части микроконтроллера, обеспечивающего получение исполняемой программы из внешних, по отношению к системе, устройств.
Ход работы
- Познакомиться с информацией о программаторах и загрузчиках (#теория)
- Изучить информацию о конечных автоматах и способах их реализации (#практика)
- Описать перезаписываемую память инструкций (#память инструкций)
- Описать и проверить модуль программатора (#программатор)
- Интегрировать программатор в процессорную систему и проверить её (#интеграция)
- Проверить работу системы в ПЛИС с помощью предоставленного скрипта, инициализирующего память системы (#проверка)
Теория
До этого момента исполняемая процессором программа попадала в память инструкций через магический вызов $readmemh
. Однако, реальные микроконтроллеры не обладают такими возможностями. Программа из внешнего мира попадает в них посредством так называемого программатора — устройства, обеспечивающего запись программы в память микроконтроллера. Программатор записывает данные в постоянное запоминающее устройство (ПЗУ). Для того, чтобы программа попала из ПЗУ в память инструкций (в ОЗУ), после запуска контроллера сперва начинает исполняться загрузчик (bootloader) — небольшая программа, вшитая в память микроконтроллера на этапе изготовления. Загрузчик отвечает за первичную инициализацию различных регистров и подготовку микроконтроллера к выполнению основной программы, включая её перенос из ПЗУ в память инструкций.
Со временем появилось несколько уровней загрузчиков: сперва запускается первичный загрузчик (first stage bootloader, fsbl), после которого запускается вторичный загрузчик (часто в роли вторичного загрузчика исполняется программа под названием u-boot). Такая иерархия загрузчиков может потребоваться, например, в случае загрузки операционной системы (которая хранится в файловой системе). Код для работы с файловой системой может попросту не уместиться в первичный загрузчик. В этом случае, целью первичного загрузчика является лишь загрузить вторичный загрузчик, который в свою очередь уже будет способен взаимодействовать с файловой системой и загрузить операционную систему.
Кроме того, код вторичного загрузчика может быть изменен, поскольку программируется вместе с основной программой. Первичный же загрузчик не всегда может быть изменен.
В рамках данной лабораторной работы мы немного упростим процесс передачи программы: вместо записи в ПЗУ, программатор будет записывать её сразу в память инструкций, минуя загрузчик.
Практика
Конечные автоматы (Finite-State Machines, FSM)
Программатор будет представлен в виде модуля с конечным автоматом. Конечный автомат представляет собой устройство, состоящее из:
- элемента памяти (так называемого регистра состояния);
- логики, обеспечивающей изменение значения регистра состояния (логики перехода между состояниями) в зависимости от его текущего состояния и входных сигналов;
- логики, отвечающей за выходы конечного автомата.
Обычно, конечные автоматы описываются в виде направленного графа переходов между состояниями, где вершины графа — это состояния конечного автомата, а рёбра (дуги) — условия перехода из одного состояния в другое.
Простейшим примером конечного автомата может быть турникет. Когда в приёмник турникета опускается подходящий жетон, тот разблокирует вращающуюся треногу. После попытки поворота треноги, та блокируется до следующего жетона.
Иными словами, у турникета есть:
- два состояния
- заблокирован (
locked
) - разблокирован(
unlocked
)
- заблокирован (
- два входа (события)
- жетон принят (
coin
) - попытка поворота треноги (
push
)
- жетон принят (
- один выход
- блокировка треноги
Для описания двух состояний нам будет достаточно однобитного регистра. Для взаимодействия с регистром, нам потребуются так же сигнал синхронизации и сброса.
Опишем данный автомат в виде графа переходов:
Рисунок 1. Граф переходов конечного автомата для турникета[1].
Черной точкой со стрелкой в вершину Locked
обозначен сигнал сброса. Иными словами, при сбросе турникет всегда переходит в заблокированное состояние.
Как мы видим, повторное опускание жетона в разблокированном состоянии приводит к сохранению этого состояния (но турникет не запоминает, что было опущено 2 жетона, и после первого же прохода станет заблокирован). В случае попытки поворота треноги в заблокированном состоянии, автомат так и останется в заблокированном состоянии.
Так же необходимо оговорить приоритет переходов: в первую очередь проверяется попытка поворота треноги, в случае если такой попытки не было, проверяется опускание монетки. Такой приоритет можно было бы указать и на графе, показав на рёбрах что переход в состояние unlocked возможен только при отсутствии сигнала push
.
Реализация конечных автоматов в SystemVerilog
Глядя на описание составляющих конечного автомата, вы могли задаться вопросом: чем автомат отличается от последовательностной логики, ведь она состоит из тех же компонент. Ответом будет: ничем. Конечные автоматы являются математической абстракцией над функцией последовательностной логики[2]. Иными словами — конечный автомат, это просто другой способ представления последовательностной логики, а значит вы уже умеете их реализовывать.
Для реализации регистра состояния конечного автомата будет удобно воспользоваться специальным типом языка 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 == LOCKED;
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
endmodule
Листинг 1. Пример реализации конечного автомата для турникета.
Кроме того, при должной поддержке со стороны инструментов моделирования, значения объектов перечислений могут выводиться на временную диаграмму в виде перечисленных имен:
Рисунок 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 = ststate == LOCKED;
// (!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
endmodule
Листинг 2. Пример реализации конечного автомата для усложнённого турникета.
Используя сигнал 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
endmodule
Листинг 3. Пример реализации конечного автомата для усложнённого турникета с использованием сигнала next_state.
На первый взгляд может показаться, что так даже сложнее. Во-первых, появился дополнительный сигнал. Во-вторых, появился ещё один always
-блок. Однако представьте на секунду, что условиями перехода будут не однобитные входные сигналы, а какие-нибудь более сложные условия. И что от них будет зависеть не один выходной сигнал, а множество как выходных сигналов, так и внутренних элементов памяти помимо регистра состояний. В этом случае, сигнал next_state
позволит избежать дублирования множества условий.
Важно отметить, что объектам типа enum
можно присваивать только перечисленные константы и объекты того же типа. Иными словами, state
можно присваивать значения LOCKED
/UNLOCKED
и next_state
, но нельзя, к примеру, присвоить 1'b0
.
Задание
Для выполнения данной лабораторной работы необходимо:
- описать перезаписываемую память инструкций;
- описать модуль-программатор;
- заменить в
riscv_unit
память инструкций на новую, и интегрировать вriscv_unit
программатор.
Перезаписываемая память инструкций
Поскольку ранее из памяти инструкций можно было только считывать данные, но не записывать их в неё, программатор не сможет записать принятую из внешнего мира программу. Поэтому необходимо добавить в память инструкций порт на запись. Для того, чтобы различать реализации памяти инструкций, данный модуль будет называться rw_instr_mem
:
module rw_instr_mem
import memory_pkg::INSTR_MEM_SIZE_BYTES;
import memory_pkg::INSTR_MEM_SIZE_WORDS;
(
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
);
logic [31:0] ROM [INSTR_MEM_SIZE_WORDS];
assign read_data_o = ROM[read_addr_i[$clog2(INSTR_MEM_SIZE_BYTES)-1:2]];
always_ff @(posedge clk_i) begin
if(write_enable_i) begin
ROM[write_addr_i[$clog2(INSTR_MEM_SIZE_BYTES)-1:2]] <= write_data_i;
end
end
endmodule
Листинг 4. Модуль rw_instr_mem.
Программатор
Необходимо реализовать модуль программатора, использующий с одной "стороны" uart
в качестве интерфейса для обмена данными с внешним миром, а с другой — интерфейсы для записи полученных данных в память инструкций и память данных.
Описание модуля
В основе работы модуля лежит конечный автомат со следующим графом перехода между состояниями:
Рисунок 3. Граф перехода между состояниями программатора.
Данный автомат реализует следующий алгоритм:
- Получение команды ("запись очередного блока" / "программирование завершено"). Данная команда представляет собой адрес записи очередного блока, и в случае, если адрес равен 0xFFFFFFFF, это означает команду "программирование завершено".
- В случае получения команды "программирование завершено", модуль завершает свою работу, снимая сигнал сброса с процессора.
- В случае получения команды "запись очередного блока" происходит переход к п. 2.
- Модуль отправляет сообщение о готовности принимать размер очередного блока.
- Выполняется передача размера очередного блока.
- Модуль подтверждает получение размера очередного блока и повторяет его значение.
- Выполняется передача очередного блока, который записывается, начиная с адреса, принятого в п.1.
- Получив заданное в п.3 количество байт очередного блока, модуль сообщает о завершении записи и переходит к ожиданию очередной команды в п.1.
На графе перехода автомата обозначены следующие условия:
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 !='1) && !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_we_o,
output logic [ 31:0] data_addr_o,
output logic [ 31:0] data_wdata_o,
output logic data_we_o,
output logic core_reset_o
);
import memory_pkg::INSTR_MEM_SIZE_BYTES;
import bluster_pkg::INIT_MSG_SIZE;
import bluster_pkg::FLASH_MSG_SIZE;
import bluster_pkg::ACK_MSG_SIZE;
enum logic [2:0] {
RCV_NEXT_COMMAND,
INIT_MSG,
RCV_SIZE,
SIZE_ACK,
FLASH,
FLASH_ACK,
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;
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,
// которые представляют собой "сырые" двоичные числа в ASCII-символы[1]
// Разделяем каждый байт flash_size и flash_addr на два ниббла.
// Ниббл — это 4 бита. Каждый ниббл можно описать 16-битной цифрой.
// Если ниббл меньше 10 (4'ha), он описывается цифрами 0-9. Чтобы представить
// его ascii-кодом, необходимо прибавить к нему число 8'h30
// (ascii-код символа '0').
// Если ниббл больше либо равен 10, он описывается буквами a-f. Для его
// представления в виде ascii-кода, необходимо прибавить число 8'h57
// (это уменьшенный на 10 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 starting 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'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};
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 (2'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 (2'b1 ),
.tx_data_i (tx_data ),
.tx_valid_i (tx_valid )
);
endmodule
Листинг 5. Готовая часть программатора.
Здесь уже объявлены:
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
),send_fin
,size_fin
,flash_fin
,next_round
,flash_size_ascii
,flash_addr_ascii
,init_msg
,flash_msg
(т.к. они уже реализованы в представленной выше логике).
Так же необходимо реализовать выходы модуля программатора:
instr_addr_o
;instr_wdata_o
;instr_we_o
;data_addr_o
;data_wdata_o
;data_we_o
;core_reset_o
.
Реализация конечного автомата
Для реализации сигналов state
, next_state
используйте граф переходов между состояниями, представленный на рис. 3. В случае, если не выполняется ни одно из условий перехода, автомат должен остаться в текущем состоянии.
Для работы логики переходов, необходимо реализовать счетчики 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
(в Листинге 5 объявлены параметры INIT_MSG_SIZE
, FLASH_MSG_SIZE
и ACK_MSG_SIZE
).
счётчик должен инициализироваться следующим образом:
- в состоянии
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
.
Реализация интерфейсов памяти инструкций и данных
Почему программатору необходимо два интерфейса? Дело в том, что в процессорной системе используется две шины: шина инструкций и шина данных. Чтобы не переусложнять логику системы дополнительным мультиплексированием, программатор также будет реализовывать два отдельных интерфейса. При этом необходимо различать, когда выполняется программирование памяти инструкций, а когда — памяти данных. Поскольку обе эти памяти имеют независимые адресные пространства, адреса по которым может вестись программирование могут быть неотличимы. Однако с этой же проблемой мы сталкивались и в ЛР14 во время описания скрипта компоновщика. Тогда было решено дать секции данных специальный заведомо большой адрес загрузки. Это же решение отлично ложится и в логику программатора: если мы будет использовать при программировании системы те адреса загрузки, по их значению мы сможем понимать назначение текущего блока данных: если адрес записи этого блока больше либо равен размеру памяти инструкций в байтах — этот блок не предназначен для памяти инструкций и будет отправлен на запись по интерфейсу памяти данных, в противном случае — наоборот.
Сигналы памяти инструкций (регистры instr_addr_o
, instr_wdata_o
, instr_we_o
):
- сбрасываются в ноль
- в случае состояния
FLASH
и пришедшего сигналаrx_valid
, если значениеflash_addr
меньше размера памяти инструкций в байтах:instr_wdata_o
принимает значение{instr_wdata_o[23:0], rx_data}
(справа вдвигается очередной пришедший байт)instr_we_o
становится равен(flash_counter[1:0] == 2'b01)
instr_addr_o
становится равенflash_addr + flash_counter - 1
- во всех остальных ситуациях
instr_wdata_o
иinstr_addr_o
сохраняют свое значение, аinstr_we_o
сбрасывается в ноль.
Сигналы памяти данных (data_addr_o
, data_wdata_o
, data_we_o
):
- сбрасываются в ноль
- в случае состояния
FLASH
и пришедшего сигналаrx_valid
, если значениеflash_addr
больше либо равно размеру памяти инструкций в байтах:data_wdata_o
принимает значение{data_wdata_o[23:0], rx_data}
(справа вдвигается очередной пришедший байт)data_we_o
становится равен(flash_counter[1:0] == 2'b01)
data_addr_o
становится равенflash_addr + flash_counter - 1
- во всех остальных ситуациях
data_wdata_o
иdata_addr_o
сохраняют свое значение, аdata_we_o
сбрасывается в ноль.
Реализация оставшейся части логики
Регистр 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_addr[2:0], rx_data}
(сдвигается на 1 байт влево и на освободившееся место ставится очередной пришедший байт); - в остальных ситуациях сохраняет свое значение.
Сигнал core_reset_o
равен единице в случае, если состояние конечного автомата не FINISH
.
Так как вышесказанное по сути является полным описанием работы программатора на русском языке, то фактически задача сводится к переводу текста описания программатора с русского на SystemVerilog.
Интеграция программатора в riscv_unit
Рисунок 4. Интеграция программатора в riscv_unit
.
В первую очередь, необходимо заменить память инструкций и добавить новый модуль. После чего подключить программатор к памяти инструкций и мультиплексировать выход интерфейса памяти данных программатора с интерфейсом памяти данных LSU. Сигнал сброса процессора необходимо заменить на выход core_reset_o
.
В случае, если использовалось периферийное устройство uart_tx
, необходимо мультиплексировать его выход tx_o
с одноименным выходом программатора аналогично тому, как это было сделано с сигналами интерфейса памяти данных.
Пример загрузки программы
Чтобы проверить работу программатора на практике необходимо подготовить скомпилированную программу подобно тому, как это делалось в ЛР№14 (или взять готовые .mem-файлы вашего варианта из ЛР№13). Однако, в отличие от ЛР№14, удалять первую строчку из файла, инициализирующего память данных не надо — теперь адрес загрузки будет использоваться в процессе загрузки.
Необходимо подключить отладочный стенд к последовательному порту компьютера (в случае платы Nexys A7 — достаточно просто подключить плату usb-кабелем, как это делалось на протяжении всех лабораторных для прошивки). Необходимо будет узнать COM-порт, по которому отладочный стенд подключен к компьютеру. Определить нужный COM-порт на операционной системе Windows можно через "Диспетчер устройств", который можно открыть через меню пуск. В данном окне необходимо найти вкладку "Порты (COM и LPT)", раскрыть ее, а затем подключить отладочный стенд через USB-порт (если тот еще не был подключен). В списке появится новое устройство, а в скобках будет указан нужный COM-порт.
Подключив отладочный стенд к последовательному порту компьютера и сконфигурировав ПЛИС вашим проектом, остается проинициализировать память. Сделать это можно с помощью предоставленного скрипта, пример запуска которого приведен в листинге 6.
# Пример использования скрипта. Сперва указываются опциональные аргументы
# (инициализация памяти данных и различных областей памяти vga-контроллера),
# Затем идут обязательные аргументы: файл для прошивки памяти инструкций и
# COM-порт.
$ python flash.py --help
usage: flash.py [-h] [-d DATA] [-c COLOR] [-s SYMBOLS] [-t TIFF] instr comport
positional arguments:
instr File for instr mem initialization
comport COM-port name
optional arguments:
-h, --help show this help message and exit
-d DATA, --data DATA File for data mem initialization
-c COLOR, --color COLOR
File for color mem initialization
-s SYMBOLS, --symbols SYMBOLS
File for symbols mem initialization
-t TIFF, --tiff TIFF File for tiff mem initialization
python3 flash.py -d /path/to/data.mem -c /path/to/col_map.mem \
-s /path/to/char_map.mem -t /path/to/tiff_map.mem /path/to/program COM3
Листинг 6. Пример использования скрипта для инициализации памяти.
Порядок выполнения задания
- Опишите модуль
rw_instr_mem
, используя код, представленный в листинге 4. - Добавьте пакет
bluster_pkg
, содержащий объявления параметров и вспомогательных вызовов, используемых модулем и тестбенчем. - Опишите модуль
bluster
, используя код, представленный в листинге 5. Завершите описание этого модуля.- Опишите конечный автомат используя сигналы
state
,next_state
,send_fin
,size_fin
,flash_fin
,next_round
. - Реализуйте логику счетчиков
size_counter
,flash_counter
,msg_counter
. - Реализуйте логику сигналов
tx_valid
,tx_data
. - Реализуйте интерфейсы памяти инструкций и данных.
- Реализуйте логику оставшихся сигналов.
- Опишите конечный автомат используя сигналы
- Проверьте модуль с помощью верификационного окружения, представленного в файле
lab_15.tb_bluster.sv
. В случае, если в TCL-консоли появились сообщения об ошибках, вам необходимо найти и исправить их.- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
. - Для работы тестбенча потребуется пакет
peripheral_pkg
из ЛР№13, а также файлыlab_15_char.mem
,lab_15_data.mem
,lab_15_instr.mem
из папки mem_files.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
- Интегрируйте программатор в модуль
processor_system
.- В модуле
processor_system
замените память инструкцией модулемrw_instr_mem
. - Добавьте в модуль
processor_system
экземпляр модуля-программатора.- Интерфейс памяти инструкций подключается к порту записи модуля
rw_instr_mem
. - Интерфейс памяти данных мультиплексируется с интерфейсом памяти данных модуля
LSU
. - Замените сигнал сброса модуля
riscv_core
сигналомcore_reset_o
. - В случае если у вас есть периферийное устройство
uart_tx
его выходtx_o
необходимо мультиплексировать с выходомtx_o
программатора аналогично тому, как был мультиплексирован интерфейс памяти данных.
- Интерфейс памяти инструкций подключается к порту записи модуля
- В модуле
- Проверьте процессорную систему после интеграции программатора с помощью верификационного окружения, представленного в файле
lab_15.tb_processor_system.sv
.- Данный тестбенч необходимо обновить под свой вариант. Найдите строки со вспомогательным вызовом
program_region
, первыми аргументами которого являются "YOUR_INSTR_MEM_FILE" и "YOUR_DATA_MEM_FILE". Обновите эти строки под имена файлов, которыми вы инициализировали свои память инструкций и данных в ЛР№13. Если память данных вы не инициализировали, можете удалить/закомментировать соответствующий вызов. При необходимости вы можете добавить столько вызовов, сколько вам потребуется. - В .mem-файлах, которыми вы будете инициализировать вашу память необходимо сделать доработку. Вам необходимо указать адрес ячейки памяти, с которой необходимо начать инициализировать память. Это делается путем добавления в начало файла строки вида:
@hex_address
. Пример@FA000000
. Строка обязательно должна начинаться с символа@
, а адрес обязательно должен быть в шестнадцатеричном виде. Для памяти инструкций нужен нулевой адрес, а значит можно использовать строку@00000000
. Для памяти данных необходимо адрес, превышающий размер памяти инструкций, но не попадающий в адресное пространство других периферийных устройств (старший байт адреса должен быть равен нулю). Поскольку система использует байтовую адресацию, адрес ячеек будет в 4 раза меньше адреса по которому обратился бы процессор. Это значит, что если бы вы хотели проинициализировать память VGA-контроллера, вам нужно было бы использовать не адрес@07000000
, а@01C00000
(01C00000 * 4 = 07000000
). Таким образом, для памяти данных оптимальным адресом инициализации будет@00200000
, поскольку эта ячейка с адресом00200000
соответствует адресу00800000
— этот адрес не накладывается на адресное пространство других периферийных устройств, но при этом заведомо больше возможного размера памяти инструкций. Примеры использования начальных адресов вы можете посмотреть в файлахlab_15_char.mem
,lab_15_data.mem
,lab_15_instr.mem
из папки mem_files. - Тестбенч будет ожидать завершения инициализации памяти, после чего сформирует те же тестовые воздействия, что и в тестбенче к ЛР№13. А значит, если вы использовали для инициализации те же самые файлы, поведение вашей системы после инициализации не должно отличаться от поведения на симуляции в ЛР№13.
- Перед запуском моделирования, убедитесь, что у вас выбран корректный модуль верхнего уровня в
Simulation Sources
.
- Данный тестбенч необходимо обновить под свой вариант. Найдите строки со вспомогательным вызовом
- Переходить к следующему пункту можно только после того, как вы полностью убедились в работоспособности системы на этапе моделирования (увидели, что в память инструкций и данных были записаны корректные данные, после чего процессор стал обрабатывать прерывания от устройства ввода). Генерация битстрима будет занимать у вас долгое время, а итогом вы получите результат: заработало / не заработало, без какой-либо дополнительной информации, поэтому без прочного фундамента на моделировании далеко уехать у вас не выйдет.
- Подключите к проекту файл ограничений (nexys_a7_100t.xdc), если тот еще не был подключен, либо замените его содержимое данными из файла к этой лабораторной работе.
- Проверьте работу вашей процессорной системы на отладочном стенде с ПЛИС.
- Для инициализации памяти процессорной системы используется скрипт flash.py.
- Перед инициализацией необходимо подключить отладочный стенд к последовательному порту компьютера и узнать номер этого порта (см. пример загрузки программы).
- Формат файлов для инициализации памяти с помощью скрипта аналогичен формату, использовавшемуся в тестбенче. Это значит что первой строчкой всех файлов должна быть строка, содержащая адрес ячейки памяти, с которой должна начаться инициализация (см. п. 5.1.2).
- В текущем исполнении, инициализировать память системы можно только 1 раз с момента сброса, что может оказаться не очень удобным при отладке программ. Подумайте, как можно модифицировать конечный автомат программатора таким образом, чтобы получить возможность в неограниченном количестве инициализаций памяти без повторного сброса всей системы.
Список источников
Лабораторная работа №16 "Оценка производительности"
Материал для подготовки к лабораторной работе
Данная лабораторная работа будет полностью опираться на навыки, полученные в ходе выполнения лабораторных работ:
Цель
Дать количественную оценку, характеризующую производительность реализованной вычислительной системы. На текущий момент мы создали процессорную систему, которая способна взаимодействовать с внешним миром посредством периферийных устройств ввода-вывода и программатора, по сути являющуюся компьютером. Однако встает вопрос, какое место данная система занимает в ряду уже существующих вычислительных систем.
Для оценки производительности необходимо модифицировать существующую процессорную систему, а после собрать и запустить специализированное ПО, отвечающее за измерение производительности (будет использована программа Coremark).
Теория
Coremark — это набор синтетических тестов (специальных программ) для измерения производительности процессорной системы. В данный набор входят такие тесты, как работа со связными списками, матричные вычисления, обработка конечных автоматов и подсчет контрольной суммы. Результат выражается в одном числе, которое можно использовать для сравнения с результатами других процессорных систем.
Для подсчета производительности, coremark опирается на функцию, возвращающую текущее время, поэтому для оценки производительности нам потребуется вспомогательное периферийное устройство: таймер.
Для вывода результатов тестирования, необходимо описать способ, которым coremark сможет выводить очередной символ сообщения — для этого мы будем использовать контроллер UART из ЛР№13.
Кроме того, скомпилированная без оптимизаций программа будет занимать чуть более 32KiB, поэтому нам потребуется изменить размер памяти инструкций.
Таким образом, для того чтобы запустить данную программу, нам необходимо выполнить как аппаратные изменения процессорной системы (добавить таймер и (если отсутствует) контроллер UART), так и программные изменения самого coremark (для этого в нем предусмотрены специальные платформозависимые файлы, в которых объявлены функции, реализацию которых нам необходимо выполнить).
Говорят, что лучшей проверкой процессора на наличие ошибок является попытка запустить на нем ядро Linux. Наша процессорная система на это в принципе не рассчитана (поскольку для запуска Linux нужна поддержка нескольких дополнительных расширений), поэтому coremark можно по праву считать "бюджетным" аналогом проверки процессора на прочность.
Задание
- Реализовать модуль-контроллер "таймер".
- Подключить этот модуль к системной шине. 2.1. В случае, если до этого в ЛР13 вашим устройством вывода было не UART TX, вам необходимо подключить к системной шине готовый модуль uart_tx_sb_ctrl.
- Добавить реализацию платформозависимых функций программы coremark.
- Скомпилировать программу.
- Изменить размер памяти инструкций.
- Запустить моделирование.
- Сравнить результаты измерения производительности с результатами существующих процессорных системам.
Таймер
Разберемся с тем, как будет работать наш таймер. По сути, это просто системный счётчик (не путайте с программным счётчиком), непрерывно считающий такты с момента последнего сброса. Системным он называется потому, что работает на системной тактовой частоте. Значения частот, на которых работают процессорные системы сопоставимы с 32-битными значениями, поэтому системный счётчик должен быть 64-битным. Для измерения времени мы будем засекать значение счётчика на момент начала отсчета и значение счётчика в конце отсчёта. Зная тактовую частоту и разность между значениями счётчика мы с легкостью сможем вычислить прошедшее время. При этом нужно обеспечить счётчик такой разрядностью, чтобы он точно не смог переполниться.
Поскольку мы уже назвали данный модуль "таймером", чтобы тот не был слишком простым, давайте добавим ему функциональности: пускай это будет устройство, способное генерировать прерывание через заданное число тактов. Таким образом, процессорная система сможет засекать время без постоянного опроса счётчика. Для работы coremark эта функциональность не нужна — если ее реализация окажется слишком сложной для вас, просто создайте системный счётчик, инкрементирующийся каждый такт, с доступом на чтение по адресу 32'h0
.
Было бы удобно, чтобы мы могли управлять тем, каким образом данный модуль будет генерировать такое прерывание: однократно, заданное число раз, или же бесконечно, пока тот не остановят.
Таким образом, мы сформировали адресное пространство контроллера, представленное в таблице 1.
Адрес | Режим доступа | Допустимые значения | Функциональное назначение |
---|---|---|---|
0x00 | R | [0:2³²-1] | Значение младших 32 бит системного счётчика, доступное только для чтения |
0x04 | R | [0:2³²-1] | Значение старших 32 бит системного счётчика, доступное только для чтения |
0x08 | RW | [0:2³²-1] | Указание младших 32 бит задержки, спустя которую таймер будет генерировать прерывание |
0x0c | RW | [0:2³²-1] | Указание старших 32 бит задержки, спустя которую таймер будет генерировать прерывание |
0x10 | RW | [0:2] | Указание режима генерации прерываний (выключен, заданное число раз, бесконечно) |
0x14 | RW | [0:2³²-1] | Указание количества повторений генерации прерываний |
0x24 | W | 1 | Программный сброс |
_Таблица 1. Адресное пространство
Прототип модуля представлен в листинге 1.
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
);
Листинг 1. Прототип таймера.
Обратите внимание, что у модуля нет сигнала interrupt_return_i
. Модуль будет генерировать прерывания ровно на 1 такт. Если процессор в этот момент не будет готов обработать прерывания (обрабатывая в этот момент какой-либо другой перехват) — запрос будет сразу же пропущен и таймер начнет отсчитывать следующий.
Для работы данного контроллера потребуются следующие сигналы:
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
(младшие 32 бита) и0x04
(старшие 32 бита), системный счётчик. Задача регистра заключается в ежетактном увеличении на единицу.delay
— регистр, ассоциированный с адресами0x08
(младшие 32 бита) и0x0c
(старшие 32 бита). Число тактов, спустя которое таймер (когда тот будет включен) сгенерирует прерывание. Данный регистр изменяется только сбросом, либо запросом на запись.mode
— регистр, ассоциированный с адресом0x10
. Режим работы таймера:OFF
— отключен (не генерирует прерывания)NTIMES
— включен до тех пор, пока не сгенерирует N прерываний (Значение N хранится в регистреrepeat_counter
и обновляется после каждого сгенерированного прерывания). После генерации N прерываний, переходит в режимOFF
.FOREVER
— бесконечная генерация прерываний. Не отключится, пока режим работы прерываний не будет изменен.
next_mode
— комбинационный сигнал, который подается на вход записи в регистрmode
(аналогnext_state
из ЛР№15). Данный сигнал меняется только запросами на запись по адресу0x10
или в случае, еслиrepeat_counter == 0
в режимеNTIMES
. Поскольку этому сигналу можно присваивать только значения сигналов такого же типа (timer_mods
), либо константы из перечисления, запросы на запись можно реализовать через блокcase
(где перебираются 3 возможных значенияwrite_data_i
).repeat_counter
— регистр, ассоциированный с адресом0x14
. Количество повторений для режимаNTIMES
. Уменьшается в момент генерации прерывания в этом режиме в случае, если еще не равен нулю.system_counter_at_start
— неархитектурный регистр, хранящий значение системного счётчика на момент начала отсчета таймера. Обновляется при генерации прерывания (если это не последнее прерывание в режимеNTIMES
) и при запросе на запись в регистрmode
значения неOFF
.
Выходной сигнал interrupt_request_o должен быть равен единице, если текущий режим работы не OFF
, а сумма system_counter_at_start
и delay
равна system_counter
.
Для подключения данного таймера к системной шине, мы воспользуемся первым свободным базовым адресом, оставшимся после ЛР13: 0x08
. Таким образом, для обращения к системному счётчику, процессор будет использовать адрес 0x08000000
для обращения к регистру delay
0x08000008
и т.п.
Настройка Coremark
В первую очередь, необходимо скачать исходный код данной программы, размещенный по адресу: https://github.com/eembc/coremark. На случай возможных несовместимых изменений в будущем, все дальнейшие ссылки будут даваться на слепок репозитория, который был на момент коммита d5fad6b
.
После этого, чтобы добавить поддержку нашей процессорной системы потребуется:
- Реализовать функцию, измеряющую время
- Реализовать функцию, выводящую очередной символ сообщения с результатами
- Реализовать функцию, выполняющую первичную настройку периферии перед тестом
- Выполнить мелкую подстройку, такую как количество итераций в тесте и указание аргументов, с которыми будет скомпилирована программа.
Все файлы, содержимое которых мы будем менять расположены в папке barebones.
1. Реализация функции, измеряющей время
Не мы первые придумали измерять время путем отсчета системных тактов, поэтому вся логика по измерению времени уже реализована в coremark. От нас требуется только реализовать функцию, которая возвращает текущее значение системного счётчика.
Данной функцией является barebones_clock
, расположенная в файле core_portme.c
. В данный момент, в реализации функции описан вызов ошибки (поскольку реализации как таковой нет). Мы должны заменить реализацию функции кодом, приведённым в листинге 2.
barebones_clock()
{
volatile ee_u32 *ptr = (ee_u32*)0x08000000;
ee_u32 tim = *ptr;
return tim;
}
Листинг 2. Код функции barebones_clock
.
После ЛР14 вы уже должны представлять, что здесь происходит. Мы создали указатель с абсолютным адресом 0x08000000
— адресом системного счётчика. Разыменование данного указателя вернет текущее значение системного счётчика, что и должно быть результатом вызова этой функции. Поскольку тест закончится менее чем за секунду, не обязательно загружать значение старших 32 бит (они будут не равны нулю только спустя 2³²тактов / 10⁶тактов/с ≈ 429c).
Для того, чтобы корректно преобразовать тики системного счётчика во время, используется функция time_in_secs
, которая уже реализована, но для работы которой нужно определить макрос CLOCKS_PER_SEC
, характеризующий тактовую частоту, на которой работает процессор. Давайте определим данный макрос сразу над макросом EE_TICKS_PER_SEC
:
#define CLOCKS_PER_SEC 10000000
На этом наша задача по измерению времени завершена. Остальные правки будут не сложнее этих.
2. Реализация вывода очередного символа сообщения
Для вывода очередного символа во встраиваемых системах используется (какое совпадение!) функция uart_send_char
, расположенная в файле ee_printf.c
.
В реализации данной функции вам уже предлагают алгоритм, по которому та должна работать. Необходимо:
- дождаться готовности UART к отправке;
- передать отправляемый символ;
- дождаться готовности 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));
}
Листинг 3. Код функции uart_send_char_
.
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. Код функции uart_send_char
.
4. Дополнительные настройки
Для тонких настроек используется заголовочный файл core_portme.h
, куда также требуется внести несколько изменений. Нам необходимо:
- Объявить в начале файла макрос
ITERATIONS
, влияющий на количество прогонов теста. Нам достаточно выставить значение 1. - Обновить значение макроса
COMPILER_FLAGS
, заменив его значениеFLAGS_STR
на"-march=rv32i_zicsr -mabi=ilp32"
, именно с этими аргументами мы будем собирать программу. Это опциональная настройка, которая позволит вывести флаги компиляции в итоговом сообщении. - Добавить подключение заголовочного файла
#include <stddef.h>
.
Компиляция
Для компиляции программы, вам потребуются предоставленные файлы Makefile и linker_script.ld, а также файл startup.S из ЛР№14. Эти файлы необходимо скопировать с заменой в корень папки с программой.
Makefile
написан из расчёта, что кросс-компилятор расположен по пути C:/riscv_cc/
. В случае, если это не так, измените первую строчку данного файла в соответствии с расположением кросс-компилятора.
Для запуска компиляции, необходимо выполнить следующую команду, находясь в корне программы coremark:
make
В случае, если на вашем рабочем компьютере не установлена утилита make
, то вы можете скомпилировать программу вручную, выполнив последовательность команд, приведённую в листинге 5.
export CC_BASE=/c/riscv_cc/bin/riscv-none-elf
export CC="$CC_BASE"-gcc
export OBJ_DUMP="$CC_BASE"-objdump
export OBJ_COPY="$CC_BASE"-objcopy
export SIZE="$CC_BASE"-size
export CC_FLAGS="-march=rv32i_zicsr -mabi=ilp32 -I./ -I./barebones"
export LD_FLAGS="-Wl,--gc-sections -nostartfiles -T linker_script.ld"
export OC_FLAGS="-O verilog --verilog-data-width=4"
$CC -c $CC_FLAGS -o core_main.o core_main.c
$CC -c $CC_FLAGS -o startup.o startup.S
$CC -c $CC_FLAGS -o core_list_join.o core_list_join.c
$CC -c $CC_FLAGS -o core_matrix.o core_matrix.c
$CC -c $CC_FLAGS -o core_state.o core_state.c
$CC -c $CC_FLAGS -o core_util.o core_util.c
$CC -c $CC_FLAGS -o core_portme.o barebones/core_portme.c
$CC -c $CC_FLAGS -o cvt.o barebones/cvt.c
$CC -c $CC_FLAGS -o ee_printf.o barebones/ee_printf.c
$CC $CC_FLAGS $LD_FLAGS *.o -o coremark.elf
$OBJ_DUMP -D coremark.elf > coremark_disasm.S
$OBJ_COPY $OC_FLAGS -j .data -j .bss coremark.elf coremark_data.mem
$OBJ_COPY $OC_FLAGS -j .text coremark.elf coremark_instr.mem
$SIZE coremark.elf
Листинг 5. Последовательность команд для компиляции coremark.
В случае успешной компиляции, вам будет выведено сообщение об итоговом размере секций инструкций и данных:
text data bss dec hex filename
34324 2268 100 36692 8f54 coremark.elf
Изменение размера памяти инструкций
Как видите, размер секции инструкций превышает 32KiB на 1556 байт (32768—34000). Поэтому на время оценки моделирования, нам придется увеличить размер памяти инструкций до 64KiB, изменив значение параметра INSTR_MEM_SIZE_BYTES
в пакете memory_pkg
до значения 32'h10000
. Размер памяти данных также необходимо увеличить, изменив значение параметра DATA_MEM_SIZE_BYTES
до 32'h4000
.
Обратите внимание, что увеличение размера памяти в 16 раз приведет к значительному увеличению времени синтеза устройства, поэтому данное изменение мы производим исключительно на время поведенческого моделирования.
Запуск моделирования
Программирование 32KiB по UART займет ощутимое время, поэтому вам предлагается проинициализировать память инструкций и данных "по-старинке" через системные функции $readmemh
.
Если все было сделано без ошибок, то примерно через 300ms
после снятия сигнала сброса с ядра процессора выход tx_o
начнет быстро менять свое значение, сигнализируя о выводе результатов программы, которые отобразятся в tcl console
примерно еще через 55ms
в виде листинга 6 (вывод сообщения будет завершен приблизительно на 355ms
времени моделирования).
2K performance run parameters for coremark.
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
Errors detected
Листинг 6. Лог вывода результатов coremark. Значения "Total time (secs)" и "Iterations/Sec" скрыты до получения результатов моделирования.
Порядок выполнения задания
- Опишите таймер в виде модуля
timer_sb_ctrl
. - Проверьте модуль с помощью верификационного окружения, описанного в файле lab_16.tb_timer.sv.
- Интегрируйте модуль
timer_sb_ctrl
в процессорную систему.- Ко входу
rst_i
модуля подключите сигналcore_reset_o
программатора. Таким образом, системный счётчик начнет работать только когда память системы будет проинициализирована. - Сигнал прерывания этого модуля подключать не обязательно, т.к. coremark будет осуществлять чтение путем опроса системного счётчика, а не по прерыванию.
- Ко входу
- В случае, если до этого в Л№Р13 вашим устройством вывода было не UART TX, вам необходимо подключить к системной шине готовый модуль uart_tx_sb_ctrl.
- Получите исходники программы coremark. Для этого можно либо склонировать репозиторий, либо скачать его в виде архива.
- Добавьте реализацию платформозависимых функций программы coremark. Для этого в папке
barebones
необходимо:- в файле
core_portme.c
:- реализовать функцию
barebones_clock
, возвращающую текущее значение системного счётчика; - объявить макрос
CLOCKS_PER_SEC
, характеризующий тактовую частоту процессора; - реализовать функцию
portable_init
, выполняющую первичную инициализацию периферийных устройств до начала теста;
- реализовать функцию
- в файле
ee_printf.c
реализовать функциюuart_send_char
, отвечающую за отправку очередного символа сообщения о результате.
- в файле
- Добавьте с заменой в корень программы файлы Makefile, linker_script.ld и startup.S.
- Скомпилируйте программу вызовом
make
.- Если кросскомпилятор расположен не в директории
C:/riscv_cc
, перед вызовомmake
вам необходимо соответствующим образом отредактировать первую строчку вMakefile
. - В случае отсутствия на компьютере утилиты
make
, вы можете самостоятельно скомпилировать программу вызовом команд, представленных в разделе "Компиляция".
- Если кросскомпилятор расположен не в директории
- Временно измените размер памяти инструкций до 64KiB, а памяти данных до 16KiB, изменив значение параметров
INSTR_MEM_SIZE_BYTES
иDATA_MEM_SIZE_BYTES
в пакетеmemory_pkg
на32'h10_000
и32'h4_000
соответственно. - Проинициализируйте память инструкций и память данных файлами
coremark_instr.mem
иcoremark_data.mem
, полученными в ходе компиляции программы.- Память можно проинициализировать двумя путями: с помощью вызова системной функции
$readmemh
, либо же с помощью программатора. Однако имейте в виду, что инициализация памятей с помощью программатора будет достаточно долго моделироваться в виду большого объема программы. - В случае, если инициализация будет осуществляться посредством
$readmemh
, не забудьте удалить первую строчку со стартовым адресом из файла, инициализирующего память данных. - В случае, если инициализация будет осуществляться с помощью программатора, используйте вспомогательные вызовы
program_region
из пакетаbluster_pkg
, как это было сделано вlab_15_tb_system
. - В исходном виде тестбенч описан под инициализацию памяти посредством
$readmemh
.
- Память можно проинициализировать двумя путями: с помощью вызова системной функции
- Выполните моделирование системы с помощью модуля lab_16.tb_coremark.
- Результаты теста будут выведены приблизительно на
355ms
времени моделирования.
- Результаты теста будут выведены приблизительно на
Оценка производительности
Прочти меня после успешного завершения моделирования
Итак, вы получили сообщение, представленное в листинге 7.
2K performance run parameters for coremark.
CoreMark Size : 666
Total ticks : 2901822
Total time (secs): 0.290182
Iterations/Sec : 3.446111
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 : 0xe9f5
[0]crclist : 0xe714
[0]crcmatrix : 0x1fd7
[0]crcstate : 0x8e3a
[0]crcfinal : 0xe714
Errors detected
Листинг 7. Лог вывода результатов coremark.
Не обращайте внимание на строки "ERROR! Must execute for at least 10 secs for a valid result!" и "Errors detected". Программа считает, что для корректных результатов, необходимо крутить ее по кругу в течении минимум 10 секунд, однако по большей части это требование необходимо для более достоверного результата у систем с кэшем/предсказателями переходов и прочими блоками, которые могут изменить количество тактов на прохождение между итерациями. Наш однотактный процессор будет вести себя одинаково на каждом круге, поэтому нет смысла в дополнительном времени моделирования. Тем не менее, если вы захотите получить результаты, не содержащих сообщения об ошибках, измените число итераций в файле core_portme.h
до 45.
Нас интересует строка:
Iterations/Sec : 3.446111
Это и есть так называемый "кормарк" — метрика данной программы. Результат нашего процессора: ~3.45 кормарка.
Обычно, для сравнения между собой нескольких реализаций микроархитектур, более достоверной считается величина "кормарк / МГц", т.е. число кормарков, поделённое на тактовую частоту процессора, поскольку время прохождения теста напрямую зависит от тактовой частоты. Это значит, что чип с менее удачной микроархитектурной реализацией может выиграть по кормарку просто потому, что он был выпущен по лучшей технологии, позволяющей запустить его на больших частотах. Кормарк/МГц нормализует результаты, позволяя сравнивать микроархитектурные решения, не заботясь о том, на какой частоте был получен результат.
Более того, при сравнении с другими результатами, необходимо учитывать флаги оптимизации, которые использовались при компиляции программы, поскольку они также влияют на результат. Например, если собрать coremark с уровнем оптимизаций -O1
, результат нашей системы скакнёт до 11.23 кормарков, что всего лишь является следствием того, что программа стала меньше обращаться к памяти вследствие оптимизаций. Именно поэтому результаты coremark указываются вместе с опциями, с которыми тот был собран.
Мы не будем уходить в дебри темных паттернов маркетинга и вместо этого будет оценивать производительность в лоб: сколько кормарков в секунду смог прогнать наш процессор без каких-либо оптимизаций в сравнении с представленными результатами других систем вне зависимости от их оптимизаций.
Таблица опубликованных результатов находится по адресу: https://www.eembc.org/coremark/scores.php. Нам необходимо отсортировать эту таблицу по столбцу CoreMark
, кликнув по нему.
Мы получим следующий расклад:
На что мы можем обратить внимание? Ну, во-первых, мы видим, что ближайший к нам микроконтроллер по кормарку — это 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
Данные файлы содержат информацию, овладев которой вы сможете без труда выполнить первые лабораторные работы. Порядок изучения следующий:
- Для первой лабораторной работы необходимо разобраться как описывается базовый модуль и комбинационная логика, построенная на непрерывном присваивании. Этому посвящен документ Modules.md.
- Для выполнения второй лабораторной работы необходимо уметь писать базовый модуль (см. пункт 1) и описывать такой комбинационный блок, как мультиплексор. Этому посвящен документ Multiplexors.md.
- Для выполнения третьей лабораторной работы в дополнение к предыдущим добавляется знание по описанию базовой ячейки памяти — регистру, и способу группировки сигналов (конкатенации). Этому посвящены документы Registers.md и Concatenation.md соответственно.
Для выполнения всех последующих лаб необходимы знания по всем этим документам.
Желаю успехов при подготовке к лабораторным работам!
Описание модулей в SystemVerilog
Основой цифровых схем в SystemVerilog является модуль. Модуль — это блок SystemVerilog-кода, описывающий цифровую схему какого-то устройства, например пульта телевизора:
У пульта есть входные сигналы: кнопки, нажатие на которые сообщает о нашем намерении изменить громкость или переключить канал. Кроме того, есть выходной сигнал ИК-светодиода, по которому пульт отправляет информацию телевизору.
Для создания модуля в языке SystemVerilog используются ключевые слова module
и endmodule
, которые определяют начало и конец модуля, обрамляя его. Можно сказать, что эти ключевые слова являются корпусом нашего устройства, отделяют его содержимое от внешнего мира.
Определим наш модуль:
module
endmodule
У всякого модуля должно быть название. Назовём его box
. В круглых скобках пишутся имена портов, их направление и типы. Если модуль не имеет ни входов, ни выходов, внутри скобок ничего не пишется. После них всегда ставится точка с запятой.
module box();
endmodule
Модуль без входов и выходов (портов) — это просто коробка, которая никак не взаимодействует с внешним миром. Подключим к нему два входных сигнала a, b
и один выходной q
. Для объявления портов, необходимо указать направление порта (вход это или выход), и тип используемого сигнала. В рамках данного курса лабораторных работ в качестве типа и входов и выходов будет использоваться тип logic
, о котором будет рассказано чуть позже.
module box(
input logic a,
input logic b,
output logic q
);
endmodule
Внутри модуля могут быть объявления сигналов, параметров, констант и т.п., о которых другой модуль не узнает. Объявим внутри модуля box
провод c
.
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
).
module box(
input logic a,
input logic b,
output logic q
);
logic c;
assign c = a;
endmodule
Стоит, однако, заметить, что аналогия со спайкой проводов имеет свои недостатки: после неё некоторые студенты начинают думать, что расположение "спаиваемых" сигналов относительно знака равно не имеет значения, однако это не так.
В непрерывном присваивании участвует две компоненты: выражение-приемник сигнала и выражение-источник сигнала. Обычно, выражением-приемником является провод (либо группа проводов). Выражение-источник сигнала может быть совершенно различным. В примере, приведенном выше, выражением-источником так же был провод, но вместо него мог использоваться и регистр, и выражение, построенное из цепочки арифметических или логических вентилей.
Важно понять, что при непрерывном присваивании слева от знака равно указывается то, чему мы будем присваивать, а справа от знака равно указывается то, что мы будем присваивать.
К примеру, мы можем присвоить проводу с
значение выхода логического вентиля. Пусть нам нужно, чтобы к сигналу c
был подключен результат операции a ИЛИ b
.
Такую схему можно реализовать следующим описанием:
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
нашего модуля.
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:5]; | Обращение к старшим трём битам 8-битного вектора sum, объявленного выше |
sum[5+:3]; | Обращение к трём битам, начиная со пятого (т.е. это аналог предыдущего выражения, удобно использовать, когда известен начальный бит и их количество, а конечный нужно считать через них) |
sum[7-:3]; | Обращение к трём битам, заканчивая седьмым (т.е. это аналог предыдущего выражения, удобно использовать, когда известен конечный бит и их количество, а начальный нужно считать через них) |
Таблица 1. Способы обращения как к отдельным битам вектора, так и к диапазонам его бит.
Векторы могут быть использованы и при описании портов модуля:
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
следующим образом:
Опишем 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
);
// создаём вспомогательный провод c
logic c;
// подключение модуля
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.
Тогда в нашем описании добавится подключение второго модуля inv
и провод c
.
module top(
input logic a,
input logic b,
output logic q
);
// создаём вспомогательный провод c
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
Итоги главы
- Ключевым блоком в иерархии цифровой схемы, описанной на языке SystemVerilog является модуль. Модули позволяют выносить части сложной цифровой схемы в отдельные блоки, из которых потом и будет составлена итоговая схема, что сильно упрощает разработку.
- Условно, модуль можно разделить на следующие части:
- Объявление модуля:
- Ключевые слова
module
/endmodule
определяющие границы описания модуля. - Название модуля, следующее за ключевым словом
module
. Описанный модуль представляет собой отдельный тип, имя которого совпадает с названием модуля. - Указание входов и выходов (портов) модуля, идущих в круглых скобках после названия модуля. Для указания направления порта модуля используются ключевые слова
input
иoutput
. После указание направления порта следует указать тип порта (в рамках данного курса типом портов всегда будетlogic
), его разрядность, а затем имя.
- Ключевые слова
- Функциональное описание модуля:
- Объявление внутренних сигналов модуля (будь то проводов или регистров) с помощью ключевого слова
logic
. - Создание при необходимости объектов других модулей.
- Описание функциональной связи между различными сигналами и объектами внутри описываемого модуля.
- Объявление внутренних сигналов модуля (будь то проводов или регистров) с помощью ключевого слова
- Объявление модуля:
Проверьте себя
Как, по-вашему, описать нижеприведенную схему на языке описания аппаратуры SystemVerilog?
Обратите внимание, что вход a
модуля top
является двухразрядным: нулевой его бит идет на вход a
модуля or
, первый бит идет на вход b
модуля or
.
Описание мультиплексоров в SystemVerilog
Мультипле́ксор — устройство, имеющее несколько сигнальных входов, один или более управляющих входов и один выход. Мультиплексор позволяет передавать сигнал с одного из входов на выход; при этом выбор желаемого входа осуществляется подачей соответствующей комбинации управляющих сигналов[1].
Иными словами, мультиплексор — это переключатель (коммутатор), соединяющий выход с одним из множества входов.
Для начала создадим простой двухвходовой мультиплексор. Предположим, на Y
нам необходимо передать один из сигналов — D0
или D1
в зависимости от значения управляющего сигнала S
: когда S==0
, на Y
подается сигнал D0
, в противном случае — D1
.
На языке 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
.
Также мультиплексор можно описать через конструкцию 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)
Рассмотрим вариант посложнее и опишем следующую схему:
Здесь уже используется мультиплексор 8в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];
Реализация мультиплексоров через оператор '[]' будет активно применяться вами при реализации различных памятей.
Итоги главы
- Мультиплексор — это комбинационный блок, подающий на выход один из нескольких входных сигналов.
- Мультиплексор можно описать множеством способов, среди них:
- использование тернарного условного оператора;
- использование конструкции
if-else
внутри блокаalways
; - использование конструкции
case
внутри блокаalways
; - использование оператора '[]'.
- Во избежание появления защелок при описании мультиплексора, необходимо убедиться что у блоков
if
есть соответствующие им блокиelse
, а у мультиплексоров описаны все комбинации управляющего сигнала (при необходимости, множество оставшихся комбинаций можно покрыть с помощью комбинацииdefault
). Появление непреднамеренной защелки в дизайне ведет к ухудшению временных характеристик, избыточному использованию ресурсов, а также непредсказуемому поведению схемы из-за возможного удержания сигнала. - Важно отметить, что блоки
if-else
иcase
могут использоваться не только для описания мультиплексоров. - Конструкции
if-else
иcase
в рамках данных лабораторных работ можно описывать только внутри блокаalways
. При работе с этим блоком необходимо помнить следующие особенности:- Существует несколько типов блока
always
:always_comb
,always_ff
,always_latch
, определяющих то, к чему будет подключена описанная в этом блоке логика: проводу, регистру или защелке соответственно. В данных лабораторных работах вам нужно будет пользоваться блокамиalways_ff
иalways_comb
, причем:- внутри блока
always_ff
необходимо использовать оператор неблокирующего присваивания (<=
); - внутри блока
always_comb
необходимо использовать оператор блокирующего присваивания (=
).
- внутри блока
- Присваивание для любого сигнала возможно только внутри одного блока always. Два разных сигнала могут присваиваться как в одном блоке always, так и каждый в отдельном, но операция присваивания одному и тому же сигналу в двух разных блоках always — нет.
- Существует несколько типов блока
Проверь себя
Как описать на языке SystemVerilog следующую схему?
Описание регистров в SystemVerilog
Перед тем, как описывать память, необходимо научиться описывать отдельные регистры. Регистр — это базовая ячейка памяти, позволяющая хранить состояние, пока на схему подается питание. В современной электронике, регистр чаще всего строится на D-триггерах. В лабораторной работе по АЛУ уже вскользь упоминалось, что как для описания проводов, так и для описания регистров, используется тип logic
.
logic reg_name;
У регистра может быть несколько входов и один выход. Основных входов, без которых не может существовать регистр два: вход данных и вход тактирующего синхроимпульса. На рисунке они обозначены как D
и clk
. Опциональный вход сигнала сброса (rst
) позволяет обнулять содержимое регистра вне зависимости от входных данных и может работать как с тактовым синхроимпульсом (синхронный сброс), так и без него (асинхронный сброс).
Помимо прочего у регистра также может быть входной сигнал разрешения записи (enable
), который определяет будут ли записаны данные с входного сигнала данных в регистр или нет, опциональный вход установки (set
), позволяющий принудительно выставить значение регистра в единицу.
Выход у регистра один. На рисунке выше он обозначен как Q
.
Важно понимать, что названия приведенных портов не являются чем-то высеченным на камне, они просто описывают функциональное назначение. В процессе описания работы регистра вы будете оперировать только над именем регистра, и сигналами, которые подводите к нему.
Поскольку все сигналы в цифровой схеме передаются по цепям, удобно представлять, что к выходу регистра всегда неявно подключен провод, с именем, совпадающим с именем регистра, поэтому вы можете использовать имя регистра в дальнейшей цифровой логике:
Итак, мы добавили регистр на холст схемы, но как соединить его с какой-то логикой? Предположим, у нас есть сигнал тактового синхроимпульса и данные, которые мы хотим записать:
Данной схеме соответствует код:
module reg_example(
input logic clk,
input logic data,
output logic reg_data
);
logic reg_name;
endmodule
Очевидно, мы хотим подключить сигнал clk
ко входу тактирующего сигнала регистра, вход data
ко входу данных, а выход регистра к выходу reg_data
:
Запись в регистр возможна только по фронту тактирующего синхроимпульса. Фронт — это переход сигнала из нуля в единицу (положительный фронт), либо из единицы в ноль (отрицательный фронт).
Описание регистра, а также указание фронта и тактирующего сигнала происходит в конструкции always_ff
:
always @(posedge clk)
Далее, внутри данной конструкции необходимо указать, что происходит с содержимым регистра. В нашем случае, происходит запись с входного сигнала data
always @(posedge clk) begin
reg_name <= data;
end
Обратите внимание на оператор <=
. В данном случае, это не знак "меньше либо равно", а оператор неблокирующего присваивания. Существует оператор блокирующего присваивания (=
), который меняет способ построения схемы для такого же выражения справа от оператора, однако в данный момент этот оператор останется за рамками курса. Хоть это и плохая практика в обучении, но пока вам надо просто запомнить, что при описании записи в регистр всегда используйте оператор неблокирующего присваивания <=
.
Помимо прочего, нам необходимо связать выход схемы с выходом регистра. Это можно сделать уже известным вам оператором непрерывного присваивания assign
.
Таким образом, итоговый код описания данной схемы примет вид:
module reg_example(
input logic clk,
input logic data,
output logic reg_data
);
logic reg_name;
always @(posedge clk) begin
reg_name <= data;
end
assign reg_data = reg_name;
endmodule
Предположим, мы хотим добавить управление записью в регистр через сигналы enable
и reset
. Это, например, можно сделать следующим образом:
module reg_example(
input logic clk,
input logic data,
input logic reset,
input logic enable,
output logic reg_data
);
logic reg_name;
always_ff @(posedge clk) begin
if(reset) begin
reg_name <= 1'b0;
end
else if(enable) begin
reg_name <= data;
end
end
assign reg_data = reg_name;
endmodule
Обратите внимание на очередность условий. В первую очередь, мы проверяем условие сброса, и только после этого условие разрешения на запись.
Если сперва проверить разрешение на запись, а затем в блоке else
описать логику сброса, то регистр не будет сбрасываться в случае, если enable
будет равен 1
(запись в регистр будет приоритетней его сброса). Если сброс описать не в блоке else
, а в отдельном блоке if
, то может возникнуть неопределенное состояние: нельзя однозначно сказать в какой момент придет сигнал reset
относительно сигнала enable
и что в итоге запишется в регистр. Поэтому при наличии сигнала сброса, остальная логика по записи в регистр должна размещаться в блоке else
.
Кроме того, САПР-ы смотрят на паттерн описания элемента схемы, и когда распознают его, реализуют элемент так как задумывал разработчик. Поэтому при описании регистра всегда сперва описывается сигнал сброса (если он используется) и только затем в блоке else
описывается вся остальная часть логики записи.
Итоговая схема регистра со сбросом и сигналом разрешения записи:
Помимо прочего есть еще одно важное правило, которое необходимо знать при описании регистра:
Присваивание регистру может выполняться только в одном блоке always
Даже если вдруг САПР не выдаст сразу сообщение об ошибке, в конечном итоге, на этапе синтеза схемы она рано или поздно появится в виде сообщения связанного с "multiple drivers".
В блоке присваивания регистру можно описывать и комбинационную логику, стоящую перед ним, например схему:
можно описать как
module reg_example(
input logic clk,
input logic A,
input logic B,
input logic reset,
input logic enable,
output logic reg_data
);
logic reg_name;
always_ff @(posedge clk) begin
if(reset) begin
reg_name <= 1'b0;
end
else if(enable) begin
reg_name <= A & B;
end
end
assign reg_data = reg_name;
endmodule
Однако это всего лишь упрощение. Если вы умеете описывать регистр с подключением к нему всего одного провода на входе данных, вы все равно сможете описать эту схему:
module reg_example(
input logic clk,
input logic A,
input logic B,
input logic reset,
input logic enable,
output logic reg_data
);
logic reg_name; // Обратите внимание, что несмотря на то, что
logic ab; // и reg_name и ab объявлены типом logic,
// ab станет проводом, а reg_name - регистром
// (из-за непрерывного присваивания на ab, и блока
// always_ff для reg_name)
assign ab = A & B;
always_ff @(posedge clk) begin
if(reset) begin
reg_name <= 1'b0;
end
else if(enable) begin
reg_name <= ab;
end
end
assign reg_data = reg_name;
endmodule
Поэтому так важно разобраться в базовом способе описания регистра.
Более того, с точки зрения синтезатора данное описание проще для синтеза, т.к. ему не разделять из одного always
блока комбинационную и синхронные части.
Вообще говоря, регистр в общем смысле этого слова представляет собой многоразрядную конструкцию (в рассмотренном ранее примере, однобитный регистр мог представлять из себя простой D-триггер). Создание многоразрядного регистра мало отличается от создания многоразрядного провода, а описание логики записи в многоразрядный регистр ничем не отличается от логики записи в одноразрядный регистр:
module reg_example(
input logic clk,
input logic [7:0] data,
output logic [7:0] reg_data
);
logic [7:0] reg_name;
always_ff @(posedge clk) begin
reg_name <= data;
end
assign reg_data = reg_name;
endmodule
Итоги главы
- Регистр — это базовая ячейка памяти, позволяющая хранить состояние, пока на схему подается питание.
- Для объявления регистра используется тип
logic
, при необходимости после типа указывается разрядность будущего регистра. - Для описания логики записи в регистр используется блок
always_ff
, в круглых скобках которого указывается тактирующий сигнал и фронт, по которому будет вестись запись, а также (в случае асинхронного сброса), сигнал сброса. - Регистр может иметь различные управляющие сигналы: установки/сброса/разрешения на запись. Логика этих управляющих сигналов является частью логики записи в этот регистр и так же описывается в блоке
always_ff
. - При описании логики записи в регистр, необходимо пользоваться оператором неблокирующего присваивания
<=
. - Нельзя описывать логику записи в регистр более чем в одном блоке
always
(иными словами, операция присваивания для каждого регистра может находиться только в одном блоке always).
Проверь себя
Как, по-вашему, описать на языке SystemVerilog схему, приведённую ниже?
Конкатенация (объединение сигналов)
Конкатенация позволяет присвоить какому-то многоразрядному сигналу "склейку" из нескольких сигналов меньшей разрядности, либо наоборот: присвоить сигнал большей разрядности группе сигналов меньшей разрядности.
Оператор конкатенации выглядит следующим образом: {sig1, sig2, ..., sign}
.
Предположим, у нас есть следующий набор сигналов:
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
Это можно сделать путем 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
на различные провода:
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
Рисунок 1. Пример генерации защелки у неполного мультиплексора.
На рис. 1 различные её части обозначены следующим образом:
- Мультиплексор, который мы хотели описать
- Защелка
- Мультиплексор, который был добавлен чтобы генерировать сигнал, "открывающий" защелку
- Константная единица (питание)
- Константный ноль (земля).
В случае, если S == 0
или S == 1
, на выход мультиплексора 3 будет подана единица, которая переведет защелку в "прозрачный" режим (данные с выхода мультиплексора 1 будут проходить сквозь защелку).
В случае, если S > 1
, на выход мультиплексора 3 будет подан ноль, который переведет защелку в "непрозрачный" режим (данные с выхода мультиплексора 1 не будут идти сквозь защелку, вместо этого на выходе защелки останутся последние данные, которые шли через нее, пока она была "открыта").
Рисунок 2. Пример удержания предыдущих значений защелкой.
Кроме того, защелка усложняет временной анализ и ухудшает временные характеристики, из-за чего схема может работать на меньших частотах, чем могла бы.
Таким образом, во избежание появления защелки, необходимо описывать все возможные комбинации в блоке case
(при необходимости покрывая множество оставшихся комбинаций с помощью default
) и для каждого блока if
описывать блоки else
. В случае, если подобная комбинация не планируется к использованию, можно присвоить сигналу значение ноль. Конечно, в этом случае будет создана избыточная логика для присваивания ненужного значения, которое никогда не должно произойти (и существуют способы описания аппаратуры, позволяющие этого избежать), но в данном случае это самый простой способ.
О различиях между блокирующими и неблокирующими присваиваниями
Вскоре после начала курса студенты сталкиваются с понятиями "блокирующего" и "неблокирующего" присваивания. Часто объяснения преподавателей по этой теме сопровождаются словами "последовательный" и "параллельный", а также предлагается просто запомнить [1, стр. 2]:
- при описании последовательностной логики (регистров) используйте неблокирующее присваивание;
- при описании комбинационной логики используйте блокирующее присваивание.
Давайте разберемся что это за присваивания и почему необходимо руководствоваться этими правилами.
Начать придется издалека. Несмотря на то, что SystemVerilog является языком описания аппаратуры, он так же является и языком для верификации описанной аппаратуры (слово Verilog
является объединением двух слов: verification
и logic
[2, стр. 24]). Для целей верификации в языке выделено целое подмножество конструкций, которые не могут быть использованы для описания аппаратуры — так называемое "несинтезируемое подмножество языка SystemVerilog". Разумеется, часть языка, которая может быть использована для описания аппаратуры ("синтезируемое подмножество языка SystemVerilog") тоже может использоваться в верификации.
Давайте для начала разберемся в том, как будут использоваться операторы присваивания при программном моделировании (так называемой симуляции) — одним из инструментов верификации. Разобравшись в поведении операторов во время симуляции, будет куда проще объяснить результат использования операторов при синтезе цифровой схемы.
Введем пару сокращений для удобства дальнейшего повествования:
- под
LHS
(left hand side) мы будем подразумевать "выражение, которому присваивают"; - под
RHS
(right hand side) мы будем подразумевать "выражение, которое присваивают".
В выражении a = b+c
, a
является LHS
, b+c
является RHS
.
Существует два вида присваиваний: непрерывное и процедурное.
module example_1(
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.
Рисунок 1. Пример цепочки блокирующих присваиваний.
- Сперва вычисляется
RHS
первого присваивания программного блока — константа5
. - Затем, вычисленное значение записывается в LHS первого присваивания — сигнал
a
становится равным5
. - Далее вычисляется
RHS
следующего присваивания —a
, которое к этому моменту уже равно5
. - Поскольку вычисленное
RHS
равняется5
тоLHS
второго присваивания (b
) тоже становится равным5
. - Аналогичным образом
c
тоже становится равным5
.
Обратите внимание, что все это произошло в нулевой момент времени. На временной диаграмме Vivado просто отобразится, что все сигналы одновременно стали равны 5
, однако с точки зрения симулятора это было не так. Другие симуляторы (например QuestaSim
) позволяют настроить временную диаграмму таким образом, чтобы отображались все переходы между присваиваниями.
Посмотрим, как работает аналогичная цепочка неблокирующих присваиваний. Чтобы иллюстрация была более наглядной, предположим, что перед присваиваниями был исполнен какой-то код, который привел a
в состояние 3
, b
в 2
, c
в 7
.
Рисунок 2. Пример цепочки неблокирующих присваиваний.
- Сперва вычисляется значение
RHS
первого присваивания (5
). Присваивание этого значения откладывается на потом. - Затем вычисляется значение
RHS
второго присваивания. Посколькуa
еще не присвоили значение5
, результатомRHS
становится текущее значениеa
— 3. Присваивание этого значения сигналуb
откладывается на потом. - Аналогичным образом вычисляется
RHS
третьего присваивания (2
). Присваивание этого значения также откладывается на потом.
Так называемое "потом" наступает, когда завершается вычисление RHS
всех неблокирующих присваиваний и завершение присвоений всех блокирующих присваиваний (однако "потом" все равно происходит в тот же момент времени, обратите внимание на значение времени на рис. 2). В стандарте SystemVerilog этот момент называется NBA-region
(сокр. от "Non-Blocking Assignment region") [2, стр. 61]. Выполнение отложенных присваиваний происходит в том же порядке, в котором они шли в программном блоке. Подробнее о том как, работает событийная симуляция (event based simulation) в SystemVerilog, вы можете прочесть в стандарте IEEE 1800-2023 (раздел 4). Стандарт доступен бесплатно всем желающим по программе "IEEE GET Program".
Таким образом, если LHS
блокирующего присваивания используется в качестве операнда RHS
любого другого последующего присваивания, это выражение будет иметь уже обновленное значение, что очень похоже на "последовательное вычисление".
С другой стороны значение, присвоенное LHS
значение с помощью неблокирующего присваивания, не может использоваться в качестве операнда RHS
последующих присваиваний, что создает иллюзию "параллельного вычисления" (см. рис. 3).
Рисунок 3. Иллюстрация блокирующих и неблокирующих присваиваний.
Теперь, понимая как работают присваивания с точки зрения моделирования, посмотрим на то, во что могут синтезироваться подобные операторы.
Начнем с непрерывного присваивания. Оно превращается в провод, передающий данные от RHS
к LHS
. При этом вы должны следить за тем, что и чему вы присваиваете (не путайте местами RHS
и LHS
).
То, во что синтезируются блокирующие и неблокирующие присваивания зависит от описываемой логики, поэтому давайте разберём несколько примеров.
Начнем с исходного примера c цепочкой блокирующих присваиваний, только теперь перепишем его в синтезируемом виде, сохранив изначальную идею.
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
Листинг 2. Пример описания модуля, использующего цепочку блокирующих присваиваний.
Если вы уже знакомы с содержимым документа о том, как описывать регистры, подумайте: какой будет результат синтеза у этой схемы?
Давайте "прочитаем" эту схему. Мы видим модуль, с входом in
, выходом out
и тактирующим синхроимпульсом clk
. Также мы видим три сигнала a
, b
, c
, которые описываются в блоке always_ff
, предназначенном для описания регистров. Значение in
по цепочке этих регистров передается до регистра c
, выход которого подключен к выходу out
.
Похоже, что здесь был описан сдвиговый регистр, представленный на рис. 4.
Рисунок 4. Трехразрядный сдвиговый регистр.
Давайте откроем цифровую схему, сгенерированную Vivado и убедимся в наших выводах.
Рисунок 5. Схема, сгенерированная Vivado по описанию из Листинга 2.
Произошло что-то странное. Вместо трех регистров Vivado создал только один и судя по названию — это последний регистр c
. Почему это произошло?
Изучим внимательней поведение цепочки блокирующих присваиваний, представленное на рис. 1.
Каждое последующее присваивание ожидало, пока не выполнится предыдущее, таким образом, RHS
первого присваивания (5
) сразу же распространился по всем регистрам. Моделируя Листинг 2, мы получим поведение, когда на вход каждого регистра будет подаваться сигнал in
.
Таким образом на самом деле, мы должны были изобразить нашу схему как на рис. 6.
Рисунок 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
.
Рисунок 7. Пример вызова линтера.
Давайте заменим в Листинге 2 блокирующие присваивания на неблокирующие. Напоминаем, что оператор неблокирующего присваивания записывается как <=
.
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
a <= in;
b <= a;
c <= b;
end
assign out = c;
endmodule
Листинг 3. Пример описания модуля, использующего цепочку неблокирующих присваиваний.
Посмотрим, какую схему сгенерирует Vivado в этот раз.
Рисунок 8. Схема, сгенерированная Vivado по описанию из Листинга 3.
Вряд ли полученный результат стал для вас неожиданным сюжетным поворотом, но давайте разберемся, почему в этот раз сгенерировалась схема, аналогичная представленной на рис. 4.
Для этого обратимся к примеру, представленному на рис. 2. В данном примере неблокирующего присваивания сперва вычислялись значения всех RHS
(запоминались значения выходов всех регистров) и только потом происходило присваивание новых значений. Подобное поведение аналогично поведению сдвиговых регистров.
Слово "поведение" было выделено дважды неспроста. Описание схем, которое мы сделали называется "поведенческим описанием схемы".
Можно ли реализовать сдвиговый регистр, используя блокирующие присваивания? Конечно. Например, можно поменять порядок присваиваний как в Листинге 4.
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
c = b;
b = a;
a = in;
end
assign out = c;
endmodule
Листинг 4. Цепочка блокирующих присваиваний в порядке обратном приведенному в Листинге 2.
В этом случае, линтер не сообщит ни о каких ошибках, а Vivado сгенерирует схему, аналогичную рис. 8
Так произошло, поскольку мы разорвали зависимость значений RHS
последующих присваиваний от значений LHS
предыдущих (поведение, которое демонстрировала цепочка неблокирующих присваиваний с самого начала). Однако данное решение является скорее хаком, чем примером хорошего проектирования. По сути, мы просто подстроили код, описанный с помощью блокирующих присваиваний таким образом, чтоб он вел себя как код, использующий неблокирующие присваивания.
Важно отметить, что при использовании неблокирующих присваиваний, их порядок вообще не имеет значения с точки зрения синтеза.
Давайте разнесем логику работы каждого регистра по отдельным блокам always
.
module example_5(
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
.
Рисунок 9. Симуляция модуля, описанного Листингом 5.
Выглядит как-то не по "сдвигово-регистерски". В чем же дело?
Как уже упоминалось ранее, программные блоки (коими являются блоки always
) исполняются во время моделирования независимо друг от друга в недетерминированном стандартом порядке. На практике это означает то, что сперва может исполниться второй блок, потом третий, а потом первый — либо в любом другом порядке. Разработчик не может (и не должен) рассчитывать на порядок блоков always
при описании схемы.
Конкретно в данной ситуации, симулятор воспроизвел блоки ровно в том порядке, в котором они были описаны. Сперва a
получил значение in
, потом b
получил обновленное значение a
, затем c
получил обновленное значение b
.
Поскольку поведение недетерминировано, нельзя однозначно сказать, какую схему должен был воспроизвести синтезатор. В данной ситуации, он воспроизвел схему которую мы хотели получить, но моделирование того же самого модуля демонстрирует поведение вовсе не этой схемы.
Если заменить порядок always
подобно тому, как мы изменили порядок в Листинге 4, результат на временной диаграмме совпадет с поведением сдвигового регистра.
Рисунок 10. Моделирование поведения сдвигового регистра.
Однако, как уже объяснялось ранее, вы не можете рассчитывать на такой результат. Сегодня симулятор смоделировал поведение одним образом — завтра он смоделирует этот же код (в котором не изменилась ни одна строка) по-другому, и будет по-прежнему работать в соответствии со стандартом.
Для того, чтобы получить детерминированный результат, вам необходимо снова воспользоваться неблокирующим присваиванием, поскольку и в этом случае порядок исполнения блоков always
не влияет на результат присваиваний — сначала вычисляются значения RHS
всех неблокирующих присваиваний всех программных блоков, и только потом происходит присваивание этих значений LHS
.
Рассмотрим еще один пример того, как различие в присваиваниях приведет к описанию двух различных схем:
module example_6(
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
.
Рисунок 11. Схема, сгенерированная Vivado по описанию из Листинга 6.
Попробуйте догадаться о том, что произойдет, если снова заменить блокирующие присваивания на неблокирующие?
Результат изменится следующим образом.
Рисунок 12. Схема, сгенерированная Vivado по описанию из Листинга 6 после замены блокирующих присваиваний на неблокирующие.
Из прочтенного может сложиться впечатление, будто бы автор хочет показать, что блокирующее присваивание — это плохо, а неблокирующее — хорошо, однако это не так. Это просто два похожих инструмента, работающих разными способами, о которых должен знать профессионал, использующий эти инструменты.
Одно и тоже описание, использующее разные типы присваиваний может привести к синтезу разных схем.
Рассмотрим предыдущий пример еще раз. Нельзя сказать, что одна схема лучше другой — это просто две разные схемы и то, какая из них вам нужна зависит только от вашей задачи.
Однако нельзя не заметить, что при использовании блокирующего присваивания, мы "теряли" регистры. Более того, моделирование неблокирующих присваиваний ближе всего по поведению приближено к моделированию регистровой логики [1, стр. 14].
Пока что мы рассматривали только синхронные схемы (схемы, работающие по тактовому синхроимпульсу).
Рассмотрим зависимость от типа присваивания в комбинационных схемах. Для этого возьмем предыдущий пример, и уберем тактирующий синхроимпульс.
module example_7(
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.
Рисунок 13. Схема, сгенерированная Vivado по описанию из Листинга 7.
Девочка остолбенела. У неё возникло отчётливое чувство какой-то ужасной несправедливости по отношению к ней. Гарри Поттер был грязным, отвратительным обманщиком и лжецом. Но во время игры все его ответы были верными. [Элиезер Юдковский / Гарри Поттер и методы рационального мышления]
На протяжении всего документа вам рассказывали, что использование блокирующих присваиваний приведет к изменению поведения и синтезу другой схемы, а теперь сами же приводят пример, где схема остается точно такой же!
Давайте разберемся по порядку, что же произошло.
Все дело в изменении блока always
. Когда мы использовали always_ff @(posedge clk)
, этот программный блок исполнялся только один раз за такт.
Теперь, когда мы стали использовать блок always_comb
, правила игры изменились. Нет, принцип работы блокирующих и неблокирующих присваиваний остался тем же самым. Изменилось только то, сколько раз будет вызван данный блок.
Начнем со схемы, построенной по описанию, использующему блокирующее присваивание. В общем-то, тут у вас не должно было возникнуть вопросов, логика ровно та же, что была и при построении схемы по Листингу 6 (рис. 11), только без выходного регистра. Что логично, ведь мы убрали из описания тактирующий сигнал.
Вопрос в том, почему это вдруг схема, построенная после замены блокирующих присваиваний на неблокирующие ведет себя точно так же?
Рассмотрим рис. 14.
Рисунок 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
).
Список источников
- Clifford E. Cummings / Nonblocking Assignments in Verilog Synthesis, Coding Styles That Kill
- 1800-2017 - IEEE Standard for SystemVerilog--Unified Hardware Design, Specification, and Verification Language
Пример разработки модуля-контроллера периферийного устройства
Для того, чтобы лучше понять, что от вас требуется в рамках лабораторной работы по периферийным устройствам, рассмотрим процесс разработки структурной схемы (не SystemVerilog-описания) для контроллера светодиодов.
В первую очередь, здесь будет продублирована выдержка из спецификации на этот контроллер (общая часть раздела "Описание контроллеров периферийных устройств", а также подраздел "Светодиоды"):
Спецификация контроллера
Общие термины
- Под "запросом на запись по адресу
0xАДРЕС
" будет пониматься совокупность следующих условий:- Происходит восходящий фронт
clk_i
. - На входе
req_i
выставлено значение1
. - На входе
write_enable_i
выставлено значение1
. - На входе
addr_i
выставлено значение0xАДРЕС
- Происходит восходящий фронт
- Под "запросом на чтение по адресу
0xАДРЕС
" будет пониматься совокупность следующих условий:- Происходит восходящий фронт
clk_i
. - На входе
req_i
выставлено значение1
. - На входе
write_enable_i
выставлено значение0
. - На входе
addr_i
выставлено значение0xАДРЕС
- Происходит восходящий фронт
Обратите внимание на то, что запрос на чтение должен обрабатываться синхронно (выходные данные должны выдаваться по положительному фронту clk_i
) так же как был реализован порт на чтение памяти данных в ЛР№6.
При описании поддерживаемых режимов доступа по данному адресу используются следующее обозначения:
- 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
, возвращаемые данные должны дополниться нулями в старших битах).
Светодиоды
Светодиоды являются простейшим устройством вывода. Поэтому, чтобы задание было интересней, для их управления был добавлен регистр, управляющий режимом вывода данных на светодиоды. Рассмотрим прототип модуля, который вам необходимо реализовать:
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
.
Регистр led_mode
отвечает за режим вывода данных на светодиоды. Когда этот регистр равен единице, светодиоды должны "моргать" выводимым значением. Под морганием подразумевается вывод значения из регистра led_val
на выход led_o
на одну секунду (загорится часть светодиодов, соответствующие которым биты шины led_o
равны единице), после чего на одну секунду выход led_o
необходимо подать нули. Запись и чтение регистра led_mode
осуществляется по адресу 0x04
.
Отсчет времени можно реализовать простейшим счетчиком, каждый такт увеличивающимся на 1 и сбрасывающимся по достижении определенного значения, чтобы продолжить считать с нуля. Зная тактовую частоту, нетрудно определить до скольки должен считать счетчик. При тактовой частоте в 10 МГц происходит 10 миллионов тактов в секунду. Это означает, что при такой тактовой частоте через секунду счетчик будет равен 10⁷-1
(счет идет с нуля). Тем не менее удобней будет считать не до 10⁷-1
(что было бы достаточно очевидным и тоже правильным решением), а до 2*10⁷-1
. В этом случае старший бит счетчика каждую секунду будет инвертировать свое значение, что может быть использовано при реализации логики "моргания".
Важно отметить, что счетчик должен работать только при led_mode == 1
, в противном случае счетчик должен быть равен нулю.
Обратите внимание на то, что адрес 0x24
является адресом сброса. В случае запроса на запись по этому адресу значения 1
, вы должны сбросить регистры led_val
, led_mode
и все вспомогательные регистры, которые вы создали. Для реализации сброса вы можете как создать отдельный регистр led_rst
, в который будет происходить запись, а сам сброс будет происходить по появлению единицы в этом регистре (в этом случае необходимо не забыть сбрасывать и этот регистр тоже), так и создать обычный провод, формирующий единицу в случае выполнения всех указанных условий (условий запроса на запись, адреса сброса и значения записываемых данных равному единице).
Адресное пространство контроллера:
Адрес | Режим доступа | Допустимые значения | Функциональное назначение |
---|---|---|---|
0x00 | RW | [0:65535] | Чтение и запись в регистр led_val отвечающий за вывод данных на светодиоды |
0x04 | RW | [0:1] | Чтение и запись в регистр led_mode , отвечающий за режим "моргания" светодиодами |
0x24 | W | 1 | Запись сигнала сброса |
Реализация схемы контроллера
Для начала, добавим на структурную схему входы и выходы модуля:
В первую очередь, спецификация вводит понятия запрос на чтение и запрос на запись. Создадим вспомогательные провода, которые будут сигнализировать о том, что произошел запрос на чтение или запрос на запись:
Помимо прочего, спецификация описывает адресное пространство контроллера. Поэтому создадим вспомогательные сигналы, сигнализирующие о том, что текущий адрес соответствует одному из регистров контроллера:
Теперь, когда подготовительные работы выполнены, начнем с реализации сброса этого контроллера. Сброс может произойти в двух случаях: когда rst_i == 1
либо же в случае запроса на запись единицы по адресу 0x24
. Создадим вспомогательный провод rst
, который будет равен единице в случае, если произойдет любое из этих событий. Этот сигнал будет сбрасывать все созданные в данном модуле регистры.
Продолжим описание контроллера, создав первый из архитектурных регистров — led_val
. Запись в этот регистр возможна только при запросе на запись по адресу 0x00
. Создадим вспомогательный сигнал val_en
, который будет равен единице только в случае выполнения этих условий:
Теперь реализация регистра lev_val
становится совершенно тривиальной задачей, ведь у нас есть:
- сигнал сброса регистра
rst
; - сигнал разрешения записи в регистр
val_en
; - сигнал данных для записи в регистр
write_data_i
(из которого мы будем брать только младшие 16 бит данных).
Аналогичным образом реализуем еще один архитектурный регистр led_mode
:
Два этих регистра должны управлять поведением выходного сигнала led_o
следующим образом:
- В случае
led_mode == 0
на выходеled_o
должно оказаться значениеled_val
; - В случае
led_mode == 1
на выходеled_o
должно циклически меняться значение cled_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
);
- произошел сброс (
- в остальных ситуациях, счетчик инкрементирует свое значение.
Последним этапом описания контроллера будет добавление логики управления выходным сигналом read_data_o
.
На управление этим сигналом наложены следующие требования:
- изменения этого сигнала должны быть синхронными (значит перед выходным сигналом должен стоять регистр);
- в случае запроса на чтение по поддерживаемому адресу, данный сигнал должен принять значение ассоциированного с этим адресом регистра (дополнив это значение нулями в старших разрядах).
- в случае отсутствия запроса на чтение, или запроса на чтение по неподдерживаемому адресу, регистр должен сохранить значение
Чтобы регистр сохранял значение между запросами на чтение по поддерживаемому адресу, добавим ему сигнал enable, а на вход данных подадим выход с мультиплексора, выбирающего между доступными источниками данных для чтения.
Таким образом, итоговая схема примет вид:
Создание нового проекта в Vivado
Для того, чтобы создать новый проект в Vivado для отладочного стенда Nexys A7, следуйте следующему порядку выполнения действий.
-
Запустите Vivado.
-
Нажмите
Create Project
. -
В открывшемся окне нажмите
Next
. -
Введите название проекта (никаких пробелов и кириллических символов) → Выберите папку для проектов → Установите селектор
Create project subdirectory
→ НажмитеNext
. -
Выберите RTL Project → Установите селектор
Do not specify sources at this time
→ НажмитеNext
. -
Выставьте следующие фильтры, чтобы сузить список ПЛИС:
- Family:
Artix 7
- Package:
csg324
, - Speed:
-1
.
На рис. 1 показано окно с примененными фильтрами.
- Family:
-
В списке выберите ПЛИС
xc7a100tcsg324-1
(расположена в самом низу) → НажмитеNext
. -
Нажмите
Finish
Рисунок 1. Пример заполнения фильтров для выбора ПЛИС, используемой в Nexys A7.
После нажатия на Finish
, откроется окно созданного проекта. Выполним его настройку. Для этого, в окне Flow Navigator
, расположенном в левой части Vivado необходимо нажать на кнопку Settings
.
В первую очередь, нам необходимо указать какое количество времени работы схемы будет моделироваться при запуске симуляции. Для этого, в группе Project Settings
необходимо выбрать Simulation
. В открывшейся странице выбрать вкладку Simulation
, и в поле xsim.simulate.runtime
указать значение 1s
, что означает, что по умолчанию будет запускаться симуляция одной секунды времени работы схемы. На рис. 2. показан пример данной настройки. Пока что не закрывайте окно настроек.
Рисунок 2. Пример настройки времени симуляции.
Одна секунда — это очень большое значение, на многие порядки превышающее время симуляции в большинстве лабораторных работ. Однако верификационное окружение во всех лабораторных будет досрочно останавливать моделирование. Установив подобное большое значение, мы избавимся от необходимости указывать нужное нам время симуляции при каждой симуляции: она просто будет идти, пока не остановится, но в случае, если верификационное окружение почему-то не остановит моделирование, мы будем знать, что оно остановится само по достижении времени в 1с.
Выполним также настройку этапа предобработки (пункт Elaboration
в группе Project Settings
). Здесь необходимо установить переключатель в положение Blackbox model (Stub file)
. На самом деле, в курсе лабораторных работ мы не будем пользоваться ничем, на что влияет эта настройка, однако установив переключатель в данное положение, мы отключим появление ненужного информационного окна каждый раз, когда мы будем выполнять предобработку проекта. После выполнения данной настройки можно нажать на OK
.
Рисунок 3. Пример настройки предобработки проекта.
Все эти настройки можно сделать в автоматическом режиме, введя следующие команды в поле для ввода Tcl Console
, помеченном текстом Type a Tcl command here
:
set_property -name {xsim.simulate.runtime} -value {1s} -objects [get_filesets sim_1]
set_property elab_link_dcps false [current_fileset]
Навигатор по маршруту проектирования (Flow Navigatior)
После создания нового проекта, откроется основное окно проекта Vivado, представленного на рис. 1.
Рисунок 1. Окно пустого проекта Vivado.
Визуально, основное окно Vivado разделено на две части: тонкую полоску окна Flow Navigator
, расположенную слева, и основное окно Project Manager
, расположенную справа. Дело в том, что Vivado представляет собой мощную многофункциональную интегрированную среду разработки (Integrated Design Environment, IDE), состоящую из нескольких подпрограмм, которые для удобства упакованы в одну общую графическую оболочку. Flow Navigator
позволяет переключаться между этими подпрограммами в рамках одного окна.
Маршрут проектирования подразумевает цикличное повторение шагов:
- Написание кода.
- Анализ получившейся схемы на предмет легко обнаруживаемых ошибок.
- Симуляция схемы
- Синтез
- Имплементация
- Генерация двоичного кода для прошивки ПЛИС
Подробнее о некоторых из этапов рассказано в главе "Этапы реализации проекта в ПЛИС".
Если на каком-то из этапов обнаруживается ошибка, происходит возврат на шаг 1 и повторение всего процесса заново. Благодаря Flow Navigator
вам не нужно постоянно запускать для каждого этапа новую программу, вы можете быстро переходить от этапа к этапу в рамках одного графического окна.
При этом, в зависимости от того, какую именно подпрограмму вы активировали во Flow Navigator
, соответствующим образом изменится основная часть окна Vivado. После создания проекта автоматически активируется программа Project Manager
(выделено бирюзовым в окне Flow Navigator
), именно поэтому в основном окне открылся менеджер проекта.
Менеджер проекта (Project Manager)
Окно Project Manager позволяет управлять проектом: добавлять и редактировать исходные коды проекта, изучить краткое ревью по утилизации ресурсов ПЛИС, используемых для реализации проекта, просматривать логи и сообщения о результатах сборки проекта и многое другое.
В первую очередь нас интересует окно исходных кодов проекта, которое называется Design Sources
и представлено на рис. 1.
Окно Design Sources
Данное окно находится по умолчанию в верхнем левом углу окна Project Manager (в случае, если вы случайно закрыли это окно, вы можете вернуть его обратно через меню Windows->Sources
).
Рисунок 1. Окно исходных кодов проекта.
Данное окно разделено на три вкладки:
- Иерархия (Hierarchy)
- Библиотеки (Libraries)
- Порядок сборки (Compile Order)
В определенных ситуациях в данном окне может появиться и вкладка IP Cores, но в рамках данного курса она нас не интересует.
Рассмотрим по порядку данные вкладки.
Вкладка Hierarchy
Данная вкладка состоит из четырех "папок":
- Design Sources;
- Constraints;
- Simulation Sources;
- Utility Sources.
В рамках текущего курса лабораторных работ мы будем взаимодействовать только с первыми тремя из них.
Помните, что несмотря на использование слова "папка", речь идет не о директориях операционной системы. Папки проекта — это всего лишь удобная абстракция для управления иерархией проекта.
В папке Design Sources
строится иерархия проектируемых модулей (исходников цифровых схем, которые в будущем могут быть воспроизведены в ПЛИС или заказной микросхеме).
Папка Constraints
содержит файлы ограничений, помогающих реализовать проект на конкретной ПЛИС (см. "Этапы реализации проекта в ПЛИС").
Simulation Sources
хранит в себе иерархию верификационного окружения, включая модули из папки Design Sources
— т.е. все модули (как синтезируемые, так и не синтезируемые), которые будут использованы при моделировании.
Обратите внимание на то, вкладка
Hierarchy
не содержит файлов. Здесь отображается иерархия модулей проекта. Один модуль может быть использован несколько раз — и в этом случае он будет столько же раз отображён в иерархии, хотя файл, хранящий описание этого модуля останется один (см. рис. 6).
Добавление файла в проект
Для того, чтобы добавить в проект новый файл, необходимо нажать на значок +
, расположенный в верхней части окна Sources
(либо использовать комбинацию горячих клавиш Alt+A
).
Появится окно добавления исходников. На первой странице этого окна будет необходимо выбрать тип добавляемого файла (см. рис. 2).
- файлы ограничений для синтеза схемы под конкретную ПЛИС (
Constraints
); - файлы проектируемой схемы (
Design Sources
); - файлы верификационного окружения для верификации схемы (
Simulation Sources
).
Рисунок 2. Первая страница окна добавления исходников.
В первую очередь мы хотим описать какую-нибудь простую схему, поэтому необходимо убедиться, что активным выбран пункт Design Sources
. Выбрав, нажимаем Next
.
Появится страница, представленная на рис. 3, которая предлагает три варианта добавления исходников.
- добавить существующий файл;
- добавить все имеющиеся файлы в заданной директории;
- создать новый файл.
Рисунок 3. Вторая страница окна добавления исходников.
Создадим новый файл, нажав на соответствующую кнопку окна. Появится всплывающее окно, предлагающее выбрать тип файла и его имя (см. рис. 4). В поле File Type
выберите SystemVerilog
(этот тип будет использоваться в качестве основного на протяжении всего курса кроме случаев, когда будет сказано иное). В поле File Name
задайте имя новому файлу (в рамках примера, имя файла будет max_min
). Указывать расширение файла не нужно — САПР автоматически его добавит в зависимости от выбранного типа файла. Когда всё будет готово, нажмите на OK
. После того, как были добавлены (или созданы) все необходимые источники, можно нажать кнопку Finish
в окне Add Sources
.
Рисунок 4. Окно создания нового файла.
В случае, если создавался новый файл, после нажатия на кнопку Finish
появится окно, предлагающее автоматически создать прототип модуля, указав в графическом интерфейсе направление и разрядность его портов (см. рис. 5). В рамках данного примера, откажемся от данного предложения, нажав кнопки Cancel->Yes
.
Рисунок 5. Окно описания входов и выходов модуля.
После добавления файлов с исходными кодами, Vivado автоматически начнет обновлять иерархию проекта. Вы можете заметить это по появившейся надписи Updating
с анимацией крутящейся стрелки, показанной на рис. 6.
Рисунок 6. Уведомление об обновлении иерархии проекта.
Пока в окне есть данное уведомление, не рекомендуется запускать подпрограммы во Flow Navigator
(к примеру, пытаться открыть схематик, запустить симуляцию/синтез и т.п.), поскольку иерархия проекта еще не построена и в конечном итоге может либо произойти ошибка, либо будет выполнено действие не для нужного вам модуля.
В зависимости от того, какие подпрограммы запущены через
Flow Navigator
, в момент вызова окнаAdd Sources
, Vivado автоматически будет стараться выбрать наиболее подходящий пункт (что не всегда будет совпадать с вашим намереньем). К примеру, вы описали модуль, запустили симуляцию, чтобы его проверить, а затем решили описать следующий модуль. Из-за того, что в момент вызова окнаAdd Sources
в фоне запущена симуляция, в этом окне по умолчанию будет выбран пунктSimulation Sources
.
После того, как Vivado закончит обновлять иерархию (и, если при создании файла вы отказались указывать порты модуля, нажав на кнопку Cancel
), рядом с папкой Design Sources
появится стрелка, позволяющая развернуть эту папку, внутри которой обнаружится подпапка Non-module Files
с созданным нами файлом. Новый файл пометили таким образом, поскольку он не содержит модуля. Как только в нем окажется описание какого-нибудь модуля, эта подпапка пропадёт.
Откроем редактор двойным кликом по файлу max_min.sv
и опишем в нём код, приведённый в листинге 1. В коде листингов 1-3 могут содержаться логические ошибки — они запланированы и будут найдены и исправлены в главе "Руководство по поиску и исправлению ошибок".
module max_min(
input logic [31:0] a,
input logic [31:0] b,
output logic [31:0] max,
output logic [ 3:0] min
);
always_comb begin
if(a > b) begin
max = a;
min = b;
end
else begin
max = b;
min = b;
end
end
endmodule
Листинг 1. Описание модуля max_min.
Не забудьте сохранить файл после описания в нем модуля нажав в редакторе на значок дискеты, или комбинацию клавиш Ctrl+S
.
Аналогичным образом, добавьте в Design Sources
проекта файлы half_divider
и vector_abs
и опишите в них модули, представленные в листингах 2-3 соответственно (все файлы листингов находятся в репозитории в папке Vivado Basics/vector_abs). На второй странице окна добавления исходников, представленном на рис. 3, вы можете создавать сразу несколько новых файлов. При создании убедитесь, что вы выбрали корректный тип файла.
module half_divider(
input logic [31:0] numerator,
output logic [31:0] quotient
);
assign quotient = numerator << 1'b1;
endmodule
Листинг 2. Описание модуля half_divider.
module vector_abs(
input logic [31:0] x,
input logic [31:0] y,
output logic [31:0] abs
);
logic [31:0] min;
logic [31:0] min_half;
max_min max_min_unit(
.a(x),
.b(y),
.max(max),
.min(min)
);
half_divider div_unit(
.numerator(min),
.quotient(min_half)
);
assign abs = max + min_half;
endmodule
Листинг 3. Описание модуля vector_abs.
В Simulation Sources
добавьте файл tb_vector_abs, описываемый листингом 4.
module tb_vector_abs();
logic [31:0] a;
logic [31:0] b;
logic [31:0] res;
vector_abs dut(
.x(a),
.y(b),
.abs(res)
);
integer err_count = 0;
task check_result(input logic [31:0]a, b, res);
begin : check_result
reg [31:0] ref_res;
ref_res = a < b? a/2 + b : a + b/2;
if (res !== ref_res) begin
$display("Incorrect res at time %0t:", $time);
$display("a = %0d, b = %0d", a, b);
$display("design res = %0d", res);
$display("reference res = %0d", ref_res);
$display("------------------");
err_count = err_count + 1'b1;
end
end
endtask
initial begin : test
integer i;
$timeformat(-9,0,"ns");
a = 0; b = 0;
#5;
check_result(a,b,res);
a = 1; b = 1;
#5;
check_result(a,b,res);
a = 3; b = 4;
#5;
check_result(a,b,res);
for(i = 0; i < 100; i=i+1) begin
a = $random()&32'hff; b = $random()&32'hff;
#5;
check_result(a,b,res);
end
$display("Test has been finished with %d errors", err_count);
if(err_count == 0) begin
$display("SUCCESS!");
end
$finish();
end
endmodule
Листинг 4. Описание модуля tb_vector_abs.
Построение иерархии модулей
После создания указанных файлов и описания в них модулей из листингов 2-4, иерархия модулей примет следующий вид, представленный на рис. 7.
Рисунок 7. Иерархия проекта, представленная в свёрнутом виде.
Нажав на стрелку слева от модуля vector_abs
, иерархия развернётся (рис. 8).
Рисунок 8. Иерархия проекта, представленная в развёрнутом виде.
Обратите внимание на то, что модуль vector_abs
выделен жирным относительно других модулей. Такое выделение означает, что данный модуль выбран в качестве модуля верхнего уровня (top-level module). Это означает, это данный модуль и представляет итоговую схему, которую мы проектируем, и что другие подпрограммы во Flow Navigator
, такие как RTL ANALYSIS
, SYNTHESIS
, IMPLEMENTATION
и PROGRAM AND DEBUG
будут обрабатывать именно этот модуль. Если вдруг вы захотите работать с другим модулем (например, с модулем, half_divider
) — его необходимо пометить вручную в качестве модуля верхнего уровня. Для этого необходимо нажать по нему правой кнопкой мыши, и в выпадающем меню выбрать Set as Top
(см. рис. 9).
Рисунок 9. Выбор модуля верхнего уровня (показана середина выпадающего списка).
Обратите внимание, как строится иерархия проекта. Модули, являющиеся объектами других модулей "вложены" в эти модули. Причем в иерархии проекта сперва указывается имя объекта модуля, затем через двоеточие имя самого модуля. В скобках указывается имя файла, где модуль описан. Модуль, который не содержится в других модулях не имеет имени объекта модуля (т.к. нет сущности, которая бы этот объект создавала). Если модуль будет содержать несколько объектов одного и того же модуля, в иерархии будут отображены все эти объекты — именно поэтому нужно понимать, чем иерархия модулей отличается от дерева файлов. Несмотря на то, что модуль описан всего в одном файле, в иерархии проекта может встречаться несколько экземпляров одного и того же модуля.
Добавьте в Simulation Sources
файл tb_vector_abs
, содержимое которого представлено в листинге 4.
module tb_vector_abs();
logic [31:0] a;
logic [31:0] b;
logic [31:0] res;
vector_abs dut(
.x(a),
.y(b),
.abs(res)
);
integer err_count = 0;
task check_result(input logic [31:0]a, b, res);
begin : check_result
reg [31:0] ref_res;
ref_res = a < b? a/2 + b : a + b/2;
if (res !== ref_res) begin
$display("Incorrect res at time %0t:", $time);
$display("a = %0d, b = %0d", a, b);
$display("design res = %0d", res);
$display("reference res = %0d", ref_res);
$display("------------------");
err_count = err_count + 1'b1;
end
end
endtask
initial begin : test
integer i;
$timeformat(-9,0,"ns");
a = 0; b = 0;
#5;
check_result(a,b,res);
a = 1; b = 1;
#5;
check_result(a,b,res);
a = 3; b = 4;
#5;
check_result(a,b,res);
for(i = 0; i < 100; i=i+1) begin
a = $random()&32'hff; b = $random()&32'hff;
#5;
check_result(a,b,res);
end
$display("Test has been finished with %d errors", err_count);
if(err_count == 0) begin
$display("SUCCESS!");
end
$finish();
end
endmodule
Листинг 4. Описание модуля tb_vector_abs.
Ошибки иерархии
В случае, если при создании какого-либо из файлов вы ошиблись с папкой назначения (добавили файл, предназначенный для Design Sources
в Simulation Sources
или наоборот), вы можете перенести этот файл в нужную папку без необходимости его удаления и повторного добавления. Для этого кликните по нужному файлу правой кнопкой мыши и выберите Move to Design/Simulation sources
(см. рис. 10).
Рисунок 10. Перенос модуля в нужную папку.
После добавления модуля tb_vector_abs
, обратите внимание на иерархию Simulation Sources
. Обратите внимание на то, что все модули Design Sources
продублированы в Simulation Sources
. Это ещё одно отличие от дерева файлов. Физически каждый модуль находится всего в одном файле, здесь представлена иерархия модулей.
Можно также заметить, что модуль верхнего уровня в Simulation Sources
другой. Модуль верхнего уровня в Simulation Sources
определяет то, какой модуль будет использоваться при симуляции (обычно это тестбенч, внутри которого создан объект проверяемого модуля). Модули верхнего уровня в Design Sources
и Simulation Sources
не связаны друг с другом (вам не нужно выбирать модулем верхнего уровня в Design Sources
тот модуль, что вы будете проверять с помощью тестбенча в Simulation Sources
).
Давайте изменим в модуле tb_vector_abs
название модуля vector_abs
, использовавшееся при создании объекта DUT
(например на vector
). Получившаяся иерархия модулей представлена на рис. 11.
Рисунок 11. Иерархия проекта с отсутствующим модулем.
Иерархия обновилась, но поскольку в проекте не существует модуля с названием vector
, это отобразилось соответствующим образом. Поскольку модуль vector_abs
не является частью модуля tb_vector_abs
, он перестал быть вложенным модулем и разместился рядом в Simulation Sources
(в Design Sources
иерархия осталась прежде, т.к. изменения коснулись только модуля tb_vector_abs
, расположенного в Simulation Sources
).
Вкладка Libraries
В данной вкладке находятся файлы проекта, сгруппированные по библиотекам. В рамках данного курса, эта вкладка использоваться не будет.
Вкладка Compile Order
Обычно Vivado сам определяет порядок компиляции по иерархии проекта. Однако, в некоторых ситуациях он может определить что-то неправильно. На данной вкладке вы можете исправить порядок компиляции (скорее всего, вам может потребоваться эта вкладка, для указания порядка компиляции пакетов SystemVerilog).
Дополнительные материалы
Более подробную информацию по окну Sources
вы можете найти в руководстве пользователя Vivado: "Vivado Design Suite User Guide: Using the Vivado IDE (UG893)" (раздел "Using the Sources Window").
Как запустить симуляцию в Vivado
Симуляция — это один из видов моделирования. Моделирование используется для проверки поведения разработанного устройства. Для этого, на входы модуля подаются тестовые воздействия, а с его выходов считывается результат. Параллельно этому процессу, те же самые тестовые воздействия отправляются и в эталонную модель устройства. Результат модели сверяют с результатом проектируемого устройства и, в случае расхождения, сигнализируют об ошибке.
Генерация тестовых воздействий, подача их на верифицируемое устройство и модель, сверка результатов и логирование ошибок — все это выполняется средствами верификационного окружения, которое в рамках данных лабораторных работ будет именоваться как "тестбенч". Тестбенчи — это несинтезируемые модули, поэтому они не должны находиться в папке Design Sources
, вместо этого для них есть папка Simulation Sources
(см. "Менеджер проекта").
Для каждого верифицируемого модуля в репозитории есть отдельный тестбенч. Перед запуском моделирования, необходимо убедиться, что в качестве модуля верхнего уровня в папке Simulation Sources
выбран тестбенч того модуля, который вы собираетесь верифицировать.
Есть несколько способов запустить симуляцию, рассмотрим два из них:
- На панели слева, в разделе
SIMULATION
, нажатьRun Simulation
→Run Behavioral Simulation
.
Рисунок 1. Запуск симуляции через вкладку SIMULATION
окна Flow Navigator
.
- В иерархии проекта нажать по папке
sim_1
правой кнопкой мыши, далее выбратьRun Simulation
→Run Behavioral Simulation
.
Рисунок 2. Запуск симуляции через контекстное меню папки sim_1
в Simulation Sources
.
После запуска симуляции будет промоделировано определенное количество времени, задаваемое через настройки проекта (после создания проекта мы сделали это количество равное одной секунде), после чего моделирование приостанавливается. Моделирование может быть остановлено досрочно самим тестбенчем.
Окна для работы с симуляцией
После запуска симуляции в основной части окна Vivado откроется окно симуляции, представленное на рис. 3.
Рисунок 3. Окно симуляции.
Данное окно состоит из 4-х под-окон:
- окно с вкладками
Scope
иSources
; - окно с вкладками
Objects
иProtocol Instances
; - окно редактора с открытыми файлами и появившимся там окном временной диаграммы (которая также представляет собой файл);
- Окно c вкладками
Tcl Console
,Messages
иLog
.
Окно с вкладками Scope и Sources
Вкладка Sources является той же самой вкладкой, что использовалась вами при добавлении и описании исходников и подробно разобрана в главе "Менеджер проекта".
Вкладка Scope отображает область видимости симуляции, верхним уровнем в которой является модуль верхнего уровня Simulation Sources
и библиотека glbl
, которую в рамках данного курса можно будет игнорировать. Раскрыв модуль верхнего уровня, можно увидеть иерархию модулей подобную иерархии в Simulation Sources
. Выбрав конкретный модуль во вкладке Scope
, можно "отправить" его на временную диаграмму: либо перетащив его в область сигналов, либо нажав по нему правой кнопкой мыши, и выбрав Add to Wave Window
. В этом случае, на временную диаграмму добавятся входы и выходы этого модуля, а также его внутренние сигналы. Кроме того, выбор модуля во вкладке Scope
влияет на отображение содержимого окна с вкладкой Objects
.
На вкладке Objects
находятся все объекты, связанные с модулем, выбранным во вкладке Scope
: его входы и выходы, внутренние провода и регистры, параметры этого модуля и т.п. С помощью данной вкладки можно добавлять отдельные объекты выбранного модуля.
Панель инструментов симуляции
После запуска симуляции, вверху окна Vivado меняется панель инструментов. На рис. 3 обозначены следующие кнопки:
- сбросить симуляцию (горячая клавиша
Ctrl+Shift+F5
); - запустить симуляцию до тех пор, пока она не будет остановлена тестбенчем или вручную (горячая клавиша
F3
); - запустить симуляцию указанного справа от кнопки промежутка времени (горячая клавиша
Shift+F2
); - перезапустить симуляцию (по умолчанию горячей клавиши нет, но может быть добавлена в настройках);
- закрыть симуляцию.
Отличие сброса симуляции от её перезапуска отличается в следующем. При сбросе симуляции очищаются промоделированные значения добавленных на временную диаграмму сигналов (сами сигналы остаются на месте), при этом время симуляции перемещается на нулевую отметку (т.е симуляция начнется заново). Подобное действие может быть необходимо в случае отладки, или же посреди моделирования вы добавили на временную диаграмму новые сигналы, и хотите увидеть их поведение с самого начала симуляции. При сбросе симуляции не выполняется компиляция исходников (даже если их содержимое было изменено).
Перезапуск симуляции аналогичен закрытию симуляции и повторному её открытию. При этом, если в исходниках происходили изменения — файлы будут перекомпилированы. Обратите внимание, что Vivado в первую очередь обнаруживает только изменения, сделанные из собственного редактора. В случае, если файлы были изменены извне (в особенности это касается mem
-файлов, которые начинают использоваться начиная с четвертой лабораторной работы) — Vivado может не обнаружить новых изменений. В случае, если симуляция ранее уже запускалась и с тех пор Vivado не обнаружил изменений в файлах — повторная компиляция, не производится и симуляция запускается средствами уже скомпилированных объектов. В случае, если изменения были сделаны извне, но Vivado их не обнаружил, можно очистить предыдущую сборку нажав правой кнопкой мыши по кнопки Simulation
в окне Flow Navigator
и выбрав Reset Behavioral Simulation
(см. рис. 4).
Рисунок 4. Сброс файлов симуляции.
Таким образом, в случае если вы добавили сигналы на временную диаграмму, и хотите увидеть их поведение с нулевого момента времени, или же вы хотите очистить лог сообщений и увидеть сообщения только до определенного момента (т.е. все действия, которые не связаны с повторной компиляцией исходных кодов), имеет смысл сбросить симуляцию и выполнить моделирование повторно.
В случае, если вы изменили исходный код какого-то из модулей, и хотите выполнить моделирование обновленного кода, симуляцию можно закрыть и запустить повторно теми же способами, которыми вы запустили её в прошлый раз, либо перезапустить симуляцию в с помощью кнопки 4
, представленной на рис. 3.
Если вы изменили модуль верхнего уровня в
Simulation Sources
, вам необходимо закрыть текущую симуляцию. Без этого новая не сможет запуститься и будет выдавать ошибку "boost filesystem remove: Процесс не может получить доступ к файлу". Подробнее об этой ошибке рассказано в главе "Список типичных ошибок в Vivado".
Подробнее о поиске ошибок и работе с временной диаграммой рассказано в главе "Руководство по поиску ошибок".
Руководство по поиску функциональных ошибок
Цель
При выполнении лабораторных работ вы непременно будете сталкиваться с множеством ошибок. И это нормально: "Не ошибается тот, кто ничего не делает" — © Джейсон Стейтем.
Важно воспитать в себе положительное восприятие обнаружения ошибок (ведь это приводит к улучшению вашего творения). Если относиться к обнаружению ошибок отрицательно, то вы подсознательно будете пытаться найти ошибки спустя рукава, но, если вы "в домике", и ошибок не видите — это не значит, что их нет.
При должном отношении, поиск ошибок может превратиться в увлекательное детективное расследование, где у вас есть "место преступления" (обнаруженное несоответствие в поведении, обычно это не сама ошибка, а ее следствие, круги на воде) и какой-то "набор улик" (фрагменты лога, исходный код). И вы, по чуть-чуть, будете разматывать "нераспутываемую паутину лжи", получая всё новые улики, ведущие к истинной ошибке.
Этот документ представляет собой практикум по поиску подобных ошибок в SystemVerilog-коде.
Обратите внимание на то, как ставится ударение в словосочетании "временна́я диаграмма" (не "вре́менная"). В обиходе это словосочетание заменяется словом "времянка".
- Руководство по поиску функциональных ошибок
- Цель
- Алгоритм поиска ошибок
- Работа с логом при появлении ошибок
- Поиск ошибки на временной диаграмме
- Открытие файла исходного кода проблемного сигнала
- Добавление сигналов объектов на временную диаграмму
- Сброс симуляции и ее повтор, установка времени моделирования
- Исправление сигналов с Z-состоянием
- Поиск ошибки в сигналах, формирующих проблемный сигнал
- Исправление логики проблемного сигнала
- Проблема необъявленных сигналов
- Самостоятельная работа
Алгоритм поиска ошибок
- Обычно всё начинается с сообщения в логе тестов (никто не проверяет глазами временную диаграмму сложных проектов, состоящую из тысяч сигналов, меняющихся миллионы раз за микросекунду), но на наших лабораторных работах с относительно простыми модулями, этот шаг иногда может быть и пропущен.
Сообщение в логе обычно содержит следующую ключевую информацию: имя сигнала, на котором установилось неверное значение, и время, когда это произошло. Чем лучше написано верификационное окружение, тем больше ключевой информации будет отражено в сообщении, поэтому его написание является своего рода искусством. - Получив имя сигнала и время, мы отправляемся на временную диаграмму и проверяем нашу ошибку. Как это сделать? Необходимо определить по коду, какие сигналы и каким образом управляют нашим сигналом. Вариантов может быть несколько:
- Управляющие сигналы имеют корректное значение, но логика, по которой они управляют сигналом неверна, из-за этого на нем возникает неверное значение. Это идеальный случай, при возникновении которого мы сразу же находим причину проблемы и исправляем ее.
- Логика управления верна, а какая-то часть управляющих сигналов имеет неверное значение (пусть для примера, неверное значение будет на управляющем сигнале
X
). Это означает, что обнаруженное несоответствие сигналов является уже следствием какой-то ошибки, и мы должны вернуться к шагу 2, проверяя источники для сигнала со значениемX
. Так происходит до тех пор, пока мы не попадаем в тип 1. - Логика управления и значения управляющих сигналов верны. Это самый сложный тип ошибок, который заключается либо в ошибке в спецификации разрабатываемого устройства, либо в САПРе или компонентах, влияющих на его работу. В рамках данного курса вас не должны заботить данные ошибки, и при их возникновении вам стоит обратиться к преподавателю (предварительно убедившись, что ошибка совершенно точно не подходит под первые два варианта).
- Любая возможная комбинация всех предыдущих типов.
- Обнаружив первопричину ошибки, мы исправляем ее (возможно дополняя набор тестов, или внеся правки в спецификацию), и повторно запускаем все тесты, чтобы убедиться в двух вещах:
- ошибка действительно исправлена
- исправление ошибки не породило новых ошибок
Давайте отработаем эти шаги на примере отладки ошибок в проекте по вычислению приблизительной длины вектора, создание которого было описано в документе "Менеджер проекта".
Работа с логом при появлении ошибок
После запуска симуляции мы видим в логе множество ошибок:
Рисунок 1. Пример сообщения об ошибках в тесте.
В любой ситуации с множеством ошибок, сначала надо разбираться с самой первой из них, поскольку она может быть причиной появления всех остальных. Поэтому листаем лог до момента первой ошибки:
Рисунок 2. Пример конкретной ошибки в тесте.
В логе сказано, что в момент времени 5ns
, на вход схемы подавались координаты вектора, равные 0
и 0
, модель посчитала, что длина вектора равна нулю, в то время как схема вернула значение x
.
Поиск ошибки на временной диаграмме
Давайте найдем это место на временной диаграмме. Обычно, сразу после запуска симуляции на временной диаграмме отображено место, где симуляция остановилась (возможно с очень неподходящим масштабом). Для начала подгоним масштаб таким образом, чтобы вся временная диаграмма умещалась в окне. Это делается либо нажатием правой кнопкой мыши по в области отображения сигналов, с выбором "Full View" во всплывающем меню, либо нажатием соответствующей кнопки на панели временной диаграммы (см. рис. 4), либо нажатием комбинации клавиш Ctrl+0
. Затем найдем приблизительное место рядом с тем временем, что нас интересует, установим там курсор, и приблизим масштаб (покрутив колесиком мыши при зажатой клавише Ctrl
), периодически уточняя местоположения курсора, пока не найдем интересующее нас место.
Рисунок 3. Пример временной диаграммы сразу поле остановки моделирования.
Рисунок 4. Пример установки масштаба временной диаграммы таким образом, чтобы та помещалась в текущем окне.
Рисунок 5. Пример временной диаграммы после подгонки масштаба.
Рисунок 6. Установка курсора в начало моделирования, чтобы, при увеличении масштаба, временная диаграмма сходилась к началу.
Рисунок 7. Временная диаграмма, отмасштабированная к времени ошибки с рис. 2.
Мы видим ровно ту информацию, которую нам предоставил тестбенч. Теперь надо разобраться в причинах возникновения X-состояния. Такое может произойти по множеству причин, вот три из них:
- какой-то из сигналов, формирующих этот находится в
X
илиZ
состоянии; - два каких-то сигнала одновременно пытаются выставить разные значения на целевой сигнал;
- этот сигнал является выходом модуля, но был описан с ключевым словом
input
.
Открытие файла исходного кода проблемного сигнала
В любом случае, первым делом необходимо определить, источник формирования значения сигнала res
. Откроем файл с исходным кодом, где определен данный сигнал. Для этого, нажмем правой кнопкой мыши по имени сигнала на временной диаграмме, и выберем Go To Source Code
:
Рисунок 8. Переход к месту объявления "проблемного" сигнала.
Откроется код, представленный в листинге 1 (с курсором на строчке logic [31:0] res;
):
module tb_vector_abs();
logic [31:0] a;
logic [31:0] b;
logic [31:0] res;
vector_abs dut(
.x(a),
.y(b),
.abs(res)
);
//...
Листинг 1. Начало кода симулируемого тестбенча.
Выделив res
мы видим, что у нас подсветился res
в строке abs(res)
. Это означает, что мы завели наш провод внутрь объекта dut
модуля vector_abs
, и у нас проблема второго типа (в логике работы провода res
нет ошибок, он принял некорректное значение, поскольку ему таковое передали).
В этом можно убедиться, если вытащить сигналы модуля vector_abs
на временную диаграмму. Чтобы это сделать, надо переключиться на окно Scope
, где размещена иерархия моделируемых объектов.
Добавление сигналов объектов на временную диаграмму
Обратите внимание, что в иерархии окна
Scope
находятся не имена модулей, а имена сущностей модуля. В приведенном выше листинге кода мы создали сущность модуляvector_abs
с именемdut
, поэтому в иерархииScope
мы видим внутри модуля верхнего уровня объектdut
(неvector_abs
), так будет и со всеми вложенными объектами.
Выделим объект dut
. В окне Objects
справа отобразятся все внутренние сигналы (входы/выходы, внутренние провода и регистры) объекта dut
:
Рисунок 9. Отображение внутренних сигналов проверяемого модуля.
Вообще говоря, мы уже видим, что выход abs
(к которому подключен наш провод res
) находится в X-состоянии, но для отработки навыков, разберемся с добавлением новых сигналов на временную диаграмму. Можно поступить двумя способами:
- Добавить все сигналы (то, что видно в окне
Objects
на временную диаграмму) из окнаScope
для этого, либо перетаскиваем нужный нам объект, зажав левую кнопку мыши на временную диаграмму, либо жмем правой кнопкой мыши по нужному объекту, и выбираемAdd to Wave Window
- Добавить отдельные сигналы из окна
Objects
. Для этого выделяем их (возможно множественное выделение через модификаторыshift
илиctrl
), и как и в прошлом случае, либо перетаскиваем сигналы левой кнопкой мыши, либо добавляем их через правую кнопку мыши.
Рисунок 10. Добавление сигналов модуля на временную диаграмму.
Рисунок 11. Результат добавления сигналов модуля на временную диаграмму.
По мере роста сложности проекта, число сигналов на временной диаграмме будет постоянно расти, в связи с чем встает вопрос группировки сигналов.
Для того чтобы объединить сигналы в группу, необходимо их выделить. Это можно сделать двумя способами:
- кликнув левой кнопкой мыши по каждому из интересующих сигналов при зажатой клавише
Ctrl
; - если речь идет о диапазоне сигналов, можно выбрать сигнал с одного края, после чего, при зажатой клавише
Shift
, выбрать сигнал с другого края этого диапазона.
После выбора, необходимо нажать правой кнопкой мыши по выделенным сигналам, и в низу выпадающего списка выбрать New Group
.
Рисунок 12. Пример создания группы сигналов (контекстное меню было обрезано для удобства отображения).
После создания группы, ей нужно будет дать имя. В случае, если все сигналы принадлежат одному модулю, удобно называть группу сигналов именем этого модуля.
Рисунок 13. Пример созданной группы сигналов.
Данную группу можно сворачивать и разворачивать, нажимая на соответствующую стрелку слева от имени группы.
Обратите внимание, что часть сигналов отображают какое-то значение (сигнал
abs
отображает X-состояние), а часть не отображают ничего. Так произошло, потому что проводabs
непрерывно связан с проводомres
, с точки зрения симулятора это одна сущность, и записывая во время моделирования значения для сигналаres
, симулятор неявно записывал значения для сигналаabs
, чего не скажешь про остальные сигналы, которых не было во время моделирования на временной диаграмме.
Сброс симуляции и ее повтор, установка времени моделирования
Для того, чтобы получить отсутствующие значения, необходимо повторить моделирование. Для этого, необходимо сбросить время моделирования в 0 и запустить его снова.
Для этого, необходимо на панели симуляции нажать кнопку Restart
(|◀
), а затем кнопку Run all
(▶
) или Run for
(▶t
). Положение кнопок в окне Vivado иллюстрирует рис. 14.
Рисунок 14. Расположение кнопок, управляющих моделированием в окне Vivado.
Панель управления симуляции с кнопками:
Restart
, горячие клавиши:Ctrl+Shift+F5
;Run all
, горячая клавиша:F3
;Run for
, горячие клавиши:Shift+F2
;Relaunch Simulation
.
Run for
выполняет моделирование указанного количества времени, после чего моделирование приостанавливается. Моделирование может быть остановлено так же и вручную, либо вызовом соответствующей инструкции из кода теста.
Run all
отличается от Run for
тем, что в качестве количества моделируемого времени указывается "бесконечность", и моделирование будет остановлено только вручную, либо вызовом соответствующей инструкции.
Обратите внимание, что для добавления недостающих значений добавленных сигналов лучше всего выполнять описанную выше инструкцию. Аналогичного результата можно добиться и нажатием на кнопку
Relaunch Simulation
, однако эта команда работает дольше и, если вы не меняли исходный код модулей, не нужна.
Кроме того, чтобы курсор и лог снова не ушли далеко от места первой ошибки, можно сразу указать, необходимое нам время моделирования перед выполнением команды Run for
: 5ns
.
Рисунок 15. Пример моделирования 5ns.
На рис. 16 представлен результат моделирования с новыми сигналами.
Рисунок 16. Результат повторного моделирования после добавления на временную диаграмму новых сигналов.
Видим два сигнала в Z-состоянии и один сигнал в X-состоянии. Обычно, сигналы с Z-состоянием проще всего исправить, т.к. зачастую это забытое или некорректное подключение провода. Кроме того, сигнал, зависящий от сигнала с Z-состоянием, может оказаться в X-состоянии, так что это может быть решением нашей проблемы, поэтому проверим провода min
и min_half
. Сперва займемся сигналом min
и перейдем к шагу 2 нашего алгоритма (нажимаем правой кнопкой мыши и выбираем Go To Source Code
):
module vector_abs(
input logic [31:0] x,
input logic [31:0] y,
output logic [31:0] abs
);
logic [31:0] min;
logic [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
.
Рисунок 17. Добавление сигналов вложенных модулей на временную диаграмму.
Добавляем внутренние сигналы на временную диаграмму, группируем их под именем max_min
, и повторяем моделирование.
Рисунок 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
, поскольку нужна повторная компиляция проекта.
Рисунок 19. Результат моделирования после исправления разрядности сигнала min
.
В логе сообщается о 102 найденных ошибках. Ровно на одну ошибку меньше, чем было ранее. Это не означает, что в проекте осталось 102 ошибки, только то, что, исправив данную ошибку — мы действительно что-то исправили, и один из тестовых сценариев, который ранее завершался ошибкой, теперь завершился без неё.
Помните, что если в проекте много ошибок, то часть ошибок может выправлять поведение других ошибок (хоть и не всегда, но иногда минус на минус может выдать плюс контексте ошибок проекта), поэтому надо осторожно полагаться на число найденных ошибок, если это число больше нуля.
Посмотрим на нашу временную диаграмму снова, и выберем дальнейшие действия:
Рисунок 20. Временная диаграмма после исправления разрядности сигнала min
.
Мы видим, что на временной диаграмме не осталось сигналов в X или Z-состоянии, а значит мы собрали все "низковисящие" улики нашего с вами расследования. Вернемся к месту преступления и попробуем поискать новые улики:
Рисунок 21. Первая ошибка в новом логе моделирования.
Поиск ошибки в сигналах, формирующих проблемный сигнал
Мы видим, что первой ошибкой в логе стала не та ошибка, что была прежде. Раньше первый неверный результат мы видели в момент времени 5ns
, когда на схему подавались значения 0
и 0
, теперь же первой ошибкой стал момент времени 10ns
, когда на схему подаются значения 1
и 1
. Наше устройство считает, что результат должен равняться 3
, в то время как модель считает, что результат должен равняться 1
. Проверим, нет ли ошибки в модели и посчитаем результат самостоятельно:
Для определения приблизительной длины вектора в евклидовом пространстве (т.е. длины гипотенузы прямоугольного треугольника, которая равна квадратному корню из суммы квадратов катетов) можно воспользоваться формулой:
sqrt(a^2 + b^2) ≈ max + min/2
, где max
и min
— большее и меньшее из пары чисел соответственно [Ричард Лайонс: Цифровая обработка сигналов, стр. 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
.
Рисунок 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.
Рисунок 23. Значение сигнала numerator
в момент времени 10 ns
.
Мы помним, что в момент, когда схема начала выдавать неправильный результат, на его входы подавались числа 1
и 1
, это значит, что на вход numerator
пришло корректное значение: минимум из этих двух чисел и правда равен 1
. Проверим логику данного модуля.
Исправление логики проблемного сигнала
Операция деления в цифровой схемотехнике является очень "дорогой" в плане ресурсов логических блоков и критического пути, поэтому этой операции часто стараются избегать. В нашем случае, нам не нужно обычное деление — нам нужно деление только напополам. В двоичной арифметике, для того чтобы разделить число на два, достаточно отбросить его младшую цифру. Вы часто пользуетесь подобной операцией в повседневной жизни при выполнении операции деления на 10: отбрасываете младшую цифру в десятичной арифметике.
Именно поэтому, когда мы в первый раз пытались посчитать результат "на бумаге", у нас было расхождение с моделью: когда мы делим 1 на 2, мы получаем 0.5, однако деление путем отбрасывания цифры округляет результат вниз (1/2=0, 15/10=1).
Как "отбросить" цифру средствами цифровой логики? Для этого используется операция сдвига вправо.
Операция сдвига вправо в SystemVerilog записывается оператором >>
. Справа от оператора указывается число "отбрасываемых цифр", в нашем случае одна. Но постойте, в логике присваивания стоит оператор <<
. Это ошибка, исправим ее!
Повторяем моделирование.
Рисунок 24. Результат моделирования после исправления оператора сдвига.
Снова на одну ошибку меньше. Не унываем, вряд ли в проекте число ошибок больше, чем число непустых строк самого проекта. Возвращаемся к начальной ошибке:
Рисунок 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
).
Рисунок 26. Поведение внутренних сигналов модуля vector_abs
на временной диаграмме.
В глаза сразу же бросается, что сигнал max
внешне отличается от всех остальных — он ведет себя как однобитный сигнал. Если все остальные сигналы 32-разрядные, то и сигнал max
должен быть таким же. Перейдем к объявлению этого сигнала, чтобы это исправить (нажав правой кнопкой мыши, и выбрав Go To Source Code
):
module vector_abs(
input logic [31:0] x,
input logic [31:0] y,
output logic [31:0] abs
);
logic [31:0] min;
logic [31:0] min_half;
max_min max_min_unit(
.a(x),
.b(y),
.max(max),
.min(min)
);
//...
Это странно, курсор был установлен на строку .max(max)
, хотя раньше в этом случае курсор устанавливался на строку, где объявлялся выбранный сигнал. Но вот в чем дело, если мы просмотрим файл внимательно, то не обнаружим объявления сигнала вовсе. Как так вышло, что мы использовали необъявленный сигнал, а САПР не выдал нам ошибку? Дело в том, что стандарт IEEE 1364-2005 для языка SystemVerilog допускает подобное использование необъявленного сигнала. В этом случае, синтезатор неявно создаст одноименный одноразрядный сигнал, что и произошло.
Для исправления этой ошибки, объявим сигнал max
с корректной разрядностью и повторим моделирование.
Рисунок 27. Результат моделирования после объявления пропущенного сигнала.
Самостоятельная работа
Число ошибок сократилось до 40! Мы явно на верном пути. Повторяем предыдущие шаги, вернувшись к первой ошибке:
Рисунок 28. Первая ошибка в повторном моделировании.
В этот раз первая ошибка осталась прежней, только теперь схема считает, что результат должен равняться шести (в прошлый раз схема выдавала 2
). Мы уже убедились, что в этом случае модель дает правильный результат, поэтому сразу перейдем к формирующим результат сигналам:
Рисунок 29. Поведение внутренних сигналов модуля vector_abs
на временной диаграмме.
Видим, что значение сигнала min_half
, формирующего значение выхода abs
неверно (минимумом из 3
и 4
является 3
, 3/2 = 1
).
Не отходя далеко от кассы, мы замечаем, что значение min
, формирующее сигнал min_half
неверно: его значение 4
, а должно быть 3
.
Используя файлы исходного кода проекта, попробуйте разобраться в последней обнаруженной нами ошибке.
Анализ RTL
RTL (register transfer level — уровень межрегистровых передач) — это один из уровней абстракции при проектировании цифровой схемы, когда та описывается в виде регистров и логики передачи данных между этими регистрами.
Vivado предоставляет средства по анализу RTL-кода, позволяя обнаруживать и исправлять ошибки на раннем этапе, до выполнения моделирования и попытки синтезировать проект. Для того чтобы провести анализ, необходимо выполнить предобработку проекта (Open Elaborated Design
, см. рис. 1).
Рисунок 1. Инструменты анализа RTL в окне Flow Navigator
.
Итогом предобработки станет отображение графической схемы (подробнее рассказано в документе "Этапы реализации проекта в ПЛИС"). Если схема не отобразилось, можно нажать на кнопку Schematic
.
Рисунок 2. Пример построения схемы для схемы, описанной в документе "Менеджер проекта".
Допустим нашли ошибку, изменили код модуля и хотите увидеть обновленную схему. Вы нажимаете на кнопку Schematic
у вас появляется новая вкладка, но схема на ней осталась без изменений. Дело в том, что открытие новой схемы требует повторной предобработки проекта. Для этого необходимо либо закрыть окно Elaborated Design
, и открыть его заново, либо нажать на кнопку Reload Design
вверху окна Vivado, которая появляется в информационном сообщении при обновлении кода модуля (см. рис. 3).
Рисунок 3. Информационное сообщение о том, что предобработанный проект устарел в виду изменения исходников. Кнопка Reload позволяет выполнить повторную предобработку для обновленного кода.
Помимо построения схемы, Vivado выполнит её анализ, а обнаруженные проблемы будут отображены во вкладке Messages
, которая расположена внизу окна Vivado (рис. 4).
Рисунок 4. Окно с сообщениями о результатах выполненных операциях. Для удобства отображения, информационные сообщения скрыты, оставлены только предупреждения.
Проблема окна сообщений заключается в том, что их число быстро накапливается и превращается в огромный поток, с которым тяжело работать даже с включенными фильтрами. Более того, сообщения сохраняются между запусками анализа, т.е. даже если вы исправите какую-то проблему — сообщение о ней так и останется до тех пор, пока вы не очистите окно сообщений.
Начиная с версии 2023.1 в Vivado появился специальный инструмент — линтер, который анализирует код и сообщает о проблемах в отдельном окне. Проблемы группируются по типам и список проблем очищается и генерируется повторно каждый раз, когда запускается линтер.
Если вы уже прочли документ "Руководство по поиску функциональных ошибок", вы можете заметить, что предупреждения, которые Vivado вывел в окно сообщений напрямую связаны с ошибками, которые мы обнаружили в процессе симуляции. Разница заключается в том, что Vivado вывел сообщения об этих ошибках практически мгновенно, в то время как нам для этого потребовалось проводить целое расследование. Именно в этом и заключается мощь данного инструмента — он позволяет найти большинство простых ошибок, давая возможность сосредоточиться на более сложных.
Дополнительные материалы
Подробнее о взаимодействии с окном схемы можно прочитать в руководстве пользователя Vivado: "Vivado Design Suite User Guide: Using the Vivado IDE (UG893)" (раздел "Using the Schematic Window").
Как прошить ПЛИС
После того как вы описали и верифицировали модуль, остается запрототипировать его в ПЛИС. Для этого в большинстве папок лабораторных работ есть подпапка board_files
в которой хранятся необходимые файлы. Обычно там будет находиться модуль верхнего уровня и файл ограничений, которые позволяют связать вашу логику с периферией, расположенной на плате Nexys-A7
.
Для сборки итогового проекта вам необходимо:
- Добавить модуль верхнего уровня (содержащийся в файле с расширением
.sv
) вDesign Sources
вашего проекта. - Выбрать добавленный модуль в качестве модуля верхнего уровня вашего проекта.
- Для этого нажмите по нему правой кнопкой мыши.
- В контекстном меню выберете
Set as Top
.
- Добавить файл ограничений (с расширением
.xdc
) вConstraints
вашего проекта. Если такой файл уже есть в вашем проекте (а он будет в нём уже после первой лабораторной), вам необходимо заменить содержимое старого файла содержимым нового. Ограничения меняются от лабораторной к лабораторной.
После выполнения указанных шагов, ваш проект готов к генерации битстрима — двоичного файла, с помощью которого реконфигурируется ПЛИС.
По сути, весь процесс генерации битстрима и конфигурациии оным ПЛИС сводится к последовательному нажатию следующих четырех кнопок в группе PROGRAM AND DEUBG
окна Flow Navigator
, которые представлены на рис. 1.
Рисунок 1. Порядок выполнения действий для компиляции проекта и прошивки ПЛИС.
Нажатие на кнопку Generate Bitstream позволяет сгенерировать двоичный код для конфигурации ПЛИС. В случае, если перед этим не были выполнены этапы синтеза и имплементации, появятся всплывающие окна, предлагающие выполнить эти этапы. Вам достаточно утвердительно отвечать во всех всплывающих окнах (варианты YES
/OK
, в зависимости от состояния вашего проекта, число появляющихся окон будет различным). Последним окном, информирующим о том, что двоичный файл готов будет Bitstream Generation Completed
(в случае, если все этапы были выполнены без ошибок).
Остаётся прошить ПЛИС. Для этого подключите отладочный стенд к USB-порту компьютера и включите на стенде питание.
Затем запустите менеджер аппаратуры Vivado. Для этого нажмите на кнопку Open Hardware Manager
(кнопка 2 на рис. 1).
После, необходимо подключиться к ПЛИС. Для этого необходимо нажать на кнопку Open Target
(кнопка 3 на рис. 1) и в контекстном меню выбрать вариант Auto Connect
.
И последним шагом остается прошить ПЛИС нажатием на кнопку Program Device
(кнопка 4 на рис. 1). Появится всплывающее окно, предлагающее выбрать двоичный файл конфигурации, поле которого будет автоматически заполнено путем к последнему сгенерированному файлу. Вам не нужно ничего менять, только нажать на кнопку Program
.
После этого появится окно с индикатором реконфигурации ПЛИС. Когда окно закроется, ПЛИС будет сконфигурирована под прототип вашего модуля.
Руководство по работе с ошибками обработки кода
Некоторые ошибки (например ошибки синтаксиса или иерархии) могут привести к тому, что САПР не сможет построить схему или запустить симуляцию.
Без должного опыта, при подобных ошибках можно растеряться, т.к. всплывающие окна, сообщающие об этих ошибках малоинформативны (см. рис. 1-2).
Предположим, мы забыли поставить точку с запятой в конце одного из присваиваний, и попробовали запустить моделирование.
В результате, всплывающие окна, представленные на рис. 1-2.
Рисунок 1. Первое всплывающее окно при попытке запустить моделирование проекта с синтаксической ошибкой.
Рисунок 2. Второе всплывающее окно при попытке запустить моделирование проекта с синтаксической ошибкой.
Во втором окне есть кнопка Open Messages View
. Нажмём её. Будет активировано окно сообщений, представленное на рис. 3.
Рисунок 3. Окно сообщений после неудачной попытки запуска симуляции.
Сообщения из раздела Vivado commands
на рис. 2 дают мало информации. Однако здесь же есть критические предупреждения о синтаксической ошибке с возможностью перейти к строчке в файле, вызвавшей это предупреждение. Разумеется, не всегда САПР может сообщить доступным языком в чем именно ошибка, в данном случае, он просто обнаружил что ключевое слово end
встретилось не там, где оно должно было бы быть (оно встретилось до завершения оператора присваивания, который должен был быть завершен символом ;
). В этом случае, вам необходимо самим разобраться в чем именно заключается ошибка (для этого вы можете кликнуть по гиперссылке в критическом предупреждении — откроется редактор с местом ошибки).
Помните, что большая часть сообщений в данном окне сохраняется даже если ошибка будет исправлена, поэтому рекомендуется очищать окно сообщений, в случае если появились ошибки и уже сложно понять какие из них старые, а какие из них новые. Сделать это можно, нажав на иконку корзины в окне сообщений. При этом удалятся не все ошибки, а только те, которые были вызваны процессами, запущенными пользователем. К примеру, если очистить окно сообщений, не исправив указанную ошибку, пропадут только ошибки из раздела Vivado commands
. Дело в том, что критические предупреждения появились не после того, как мы попытались запустить моделирования, а после того, как Vivado автоматически запустил инструменты анализа кода. Делает он это автоматически каждый раз, когда сохраняется файл. Эти ошибки пропадут только когда повторный анализ покажет, что они были исправлены.
RV32I - Стандартный набор целочисленных инструкций RISC-V
Разделы статьи:
Большая часть данного документа в той или иной степени является переводом спецификации RISC-V[1], распространяемой по лицензии CC-BY-4.0 .
Краткая справка по RISC-V и RV32I
RISC-V — открытая и свободная система набора команд (ISA) на основе концепции RISC. Чтобы понять архитектуру любого компьютера, нужно в первую очередь выучить его язык, понять, что он умеет делать. Слова в языке компьютера называются «инструкциями», или «командами», а словарный запас компьютера — «системой команд»[2, стр. 360].
В архитектуре RISC-V имеется обязательный для реализации минимальный список команд — набор инструкций I (Integer). В этот набор входят различные логические и арифметические операции с целыми числами, работа с памятью, и команды управления. Этого достаточно для обеспечения поддержки компиляторов, ассемблеров, компоновщиков и операционных систем (с дополнительными привилегированными инструкциями). Плюс, таким образом обеспечивается удобный "скелет" ISA и программного инструментария, вокруг которого могут быть построены более специализированные ISA процессоров путем добавления дополнительных инструкций.
Строго говоря RISC-V — это семейство родственных ISA, из которых в настоящее время существует четыре базовые ISA. Каждый базовый целочисленный набор инструкций характеризуется шириной целочисленных регистров
и соответствующим размером адресного пространства
, а также количеством целочисленных регистров
. Существует два основных базовых целочисленных варианта, RV32I
и RV64I
, которые, соответственно, обеспечивают 32- или 64-битное адресное пространство и соответствующие размеры регистров регистрового файла. На основе базового набора инструкций RV32I
существует вариант подмножества RV32E
, который был добавлен для поддержки небольших микроконтроллеров и имеет вдвое меньшее количество целочисленных регистров — 16, вместо 32. Разрабатывается вариант RV128I
базового целочисленного набора инструкций, поддерживающий плоское 128-битное адресное пространство. Также, стоит подчеркнуть, что размеры регистров и адресного пространства, во всех перечисленных стандартных наборах инструкций, не влияют на размер инструкций — во всех случаях они кодируются 32-битными числами. То есть, и для RV32I
, и для RV64I
одна инструкция будет кодироваться 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-битная инструкция!
В рамках дисциплины АПС изучается базовая ISA RV32I
и расширение для работы с регистрами контроля и статуса Zicsr
, обеспечивающими поддержку подсистемы прерываний.
На рис. 1 показана видимая пользователю структура для основного подмножества команд для целочисленных вычислений RV32I
, а также расширения Zicsr
. Эта структура содержит регистровый файл
, состоящий из 31 регистра общего назначения x1 — x31, каждый из которых может содержать целочисленное значение, и регистра x0, жестко привязанного к константе 0. В случае RV32
, регистры xN, и вообще все регистры, имеют длину в 32 бита. Кроме того, в структуре присутствует АЛУ
, выполняющее операции над данными в регистровом файле, память
с побайтовой адресацией и шириной адреса 32 бита, а также блок 32-битных регистров контроля и статуса с шириной адреса в 12 бит.
Также существует еще один дополнительный видимый пользователю регистр: счетчик команд — pc
(program counter), который содержит адрес текущей инструкции. pc
изменяется либо автоматически, указывая на следующую инструкцию, либо в результате использования инструкций управления (операции условного и безусловного переходов).
Рисунок 1. Основные компоненты архитектуры RISC-V.
Поскольку RISC-V является Load & Store
архитектурой, все операции с числами выполняются только над данными в регистровом файле (если необходимо обработать данные из основной памяти, их необходимо сперва загрузить в регистровый файл (Load), а после обработки выгрузить обратно в основную память (Store)).
Из рисунка 1 можно легко заключить, что функционально все инструкции сводятся к трём типам:
- операции на АЛУ над числами в регистровом файле;
- операции обмена данными между регистровым файлом и памятью;
- манипуляции с
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, стр. 361]. Ассемблер позволяет выполнить взаимно однозначный переход от машинного кода к тестовому и обратно.
RV32I
В таблице 1 приводятся 47 команд стандартного набора целочисленных инструкций RV32I
: мнемоники языка ассемблера, функции, описания, форматы кодирования и значения соответствующих полей при кодировании. В RISC-V предусмотрено несколько форматов кодирования инструкций (рис. 3). Формат кодирования — это договоренность о том, какая информация в каком месте 32-битной инструкции хранится и как она представлена. У всех операций есть поле opcode
(operation code - код операции), в котором закодировано "что нужно сделать". По полю opcode
устройство управления понимает, что требуется сделать процессору и каким именно способом закодирована инструкция (R, I, S, B, U или J). В 32-битных инструкциях два младших бита всегда равны 11
(бывают 16-битные инструкции из набора сжатых инструкций).
Почти все инструкции имеют поле func3
, и некоторые — поле func7
(в зависимости от формата кодирования и некоторых исключений). Их названия определены разрядностью: 3 и 7 бит, соответственно. В этих полях, если они есть у инструкции, закодировано уточнение операции. Например, код операции 0010011
указывает на то, что будет выполняться некоторая операция на АЛУ между значением из регистрового файла и константой. Поле func3
уточняет операцию, для данного примера, если оно будет равно 0x0, то АЛУ выполнит операцию сложения между значением из регистра и константой из инструкции. Если func3
равно 0x6, то будет выполнена операция "логическое ИЛИ".
Таблица 1. Инструкции набора RV32I с приведением их типов, функционального описания и примеров использования.
Обратите внимание на операции slli
, srli
и srai
(операции сдвига на константную величину). У этих инструкций немного измененный формат кодирования I*. Формат кодирования I предоставляет 12-битную константу. Сдвиг 32-битного числа более, чем на 31 не имеет смысла. Для кодирования числа 31 требуется всего 5 бит. Выходит, что из 12 бит константы используется только 5 бит для операции сдвига, а оставшиеся 7 бит – не используются. А, главное (какое совпадение!), эти 7 бит находятся ровно в том же месте, где у других инструкций находится поле Func7
. Поэтому, чтобы у инструкций slli
, srli
и srai
использующих формат I не пропадала эта часть поля, к ней относятся как к полю Func7
.
Таблица 2 является фрагментом оригинальной спецификации RISC-V
. Сверху приводятся 6 форматов кодирования инструкций: R, I, S, B, U и J, а ниже приводятся конкретные значения полей внутри инструкции. Под rd
подразумевается 5-битный адрес регистра назначения, rs1
и rs2
- 5-битные адреса регистров источников, imm
— константа, расположение и порядок битов которой указывается в квадратных скобках. Обратите внимание, что в разных форматах кодирования константы имеют различную разрядность, а их биты упакованы по-разному. Для знаковых операций константу предварительно знаково расширяют до 32 бит. Для беззнаковых расширяют нулями до 32 бит.
Таблица 2. Базовый набор инструкций RV32I.
На рис. 2, для наглядности, приводится пример кодирования пары инструкций из книги Харриса и Харриса "Цифровая схемотехника и архитектура компьютера" в машинный код[2, стр. 401].
Рисунок 2. Пример двоичного кодирования инструкций RISC-V.
Примечание: s2
, s3
, s4
, t0
, t1
, t2
— это синонимы регистров x18
,x19
,x20
,x5
,x6
,x7
соответственно. Введены соглашением о вызовах (calling convention) для того, чтобы стандартизировать функциональное назначение регистров. Подробнее об этом будет в лабораторной работе по программированию.
Псевдоинструкции
В архитектуре RISC-V размер команд и сложность аппаратного обеспечения минимизированы путем использования лишь небольшого количества команд. Тем не менее RISC-V определяет псевдокоманды, которые на самом деле не являются частью набора команд, но часто используются программистами и компиляторами. При преобразовании в машинный код псевдокоманды транслируются в одну или несколько команд RISC-V[2, стр. 399]. Например, псевдокоманда безусловного перехода j
, преобразуется в инструкцию безусловного перехода с возвратом jal
с регистром x0
в качестве регистра-назначения, то есть адрес возврата не сохраняется.
Таблица 3. Список псевдоинструкций RISC-V.
Основные типы команд
В основе ISA лежит четыре основных типа команд (R/I/S/U), которые изображены на рис. 3. Все они имеют фиксированную длину в 32 бита и должны быть выровнены в памяти по четырехбайтовой границе. Если адрес перехода (в случае безусловного перехода, либо успешного условного перехода) не выровнен, генерируется исключение о невыровненном адресе инструкции. Исключение не генерируется в случае невыполненного условного перехода.
Рисунок 3. Типы кодирования инструкций RISC-V.
Для упрощения декодирования, архитектура команд RISC-V сохраняет положение адресов регистров-источников (rs1
и rs2
) и регистра назначения (rd
) между всеми типами инструкций.
За исключением 5-битных непосредственных операндов, используемых в командах CSR, все непосредственные операнды (imm
) проходят знаковое расширение. Для уменьшения сложности аппаратуры, константа размещается в свободные (от полей func3
/func7
/rs1
/rd
) биты инструкции, начиная от левого края. В частности, благодаря этому ускоряется схема знакового расширения, поскольку знаковый бит всех непосредственных операндов всегда находится в 31-ом бите инструкции.
Способы кодирования непосредственных операндов
Существует еще два формата кодирования констант в инструкции (B/J-типа), представленные на рис. 4.
Единственное различие между форматами S и B заключается в том, что в формате B, 12-битная константа используется для кодирования кратных двум смещений адреса при ветвлении (примечание: кратность двум обеспечивается сдвигом числа на 1 влево). Вместо того, чтобы сдвигать непосредственный операнд относительно всех бит инструкции на 1 влево, средние биты (imm[10:1]
) и знаковый бит остаются в прежних местах, а оставшийся младший бит константы формата S (inst[7]
) кодирует imm[11]
бит константы в формате B.
Аналогично, единственное различие между форматами U и J состоит в том, что в формате U 20-разрядная константа сдвигается влево на 12 бит, в то время как в формате J — на 1. Расположение бит в непосредственных значениях формата U и J выбирались таким образом, чтобы максимально увеличить перекрытие с другими форматами и между собой.
Рисунок 4. Кодирование констант в инструкциях B и J типа.
На рис. 5 показаны непосредственные значения (константы), создаваемые каждым из основных форматов команд, также они помечены, чтобы показать, какой бит команды (inst[y]
) какому биту непосредственного значения соответствует.
Рисунок 5. Иллюстрация общих частей при кодировании констант различных типов инструкций.
Знаковое расширение — одна из самых важных операций над непосредственными значениями (особенно в
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 для каждого из операндов.
Команда типа константа-регистр
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
).
Сдвиги на константу кодируются как разновидность формата команд I-типа. Операнд, который должен быть сдвинут, находится в rs1
, а величина сдвига кодируется в младших 5 битах поля непосредственного значения. Тип сдвига вправо определяется 30-ым битом. SLLI
- логический сдвиг влево (нули задвигаются в младшие биты); SRLI
- логический сдвиг вправо (нули задвигаются в старшие биты); SRAI
- арифметический сдвиг вправо (исходный знаковый бит задвигается в старшие биты).
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
задается тип операции.
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
Инструкция NOP
не изменяет архитектурное состояние процессора, за исключением увеличения pc
и опциональных счетчиков производительности. NOP
кодируется как ADDI x0, x0, 0
.
Команды
NOP
могут быть использованы для выравнивания сегментов кода по микроархитектурно значимым границам адресов или для резервирования места для модификаций встраиваемого кода. Хотя существует множество возможных способов кодированияNOP
, мы использовали каноническое кодированиеNOP
, чтобы обеспечить возможность микроархитектурной оптимизации, а также для более читаемого вывода при дизассемблировании.
Список использованной литературы
- RISC-V Instruction Set Manual
- Д.М. Харрис, С.Л. Харрис / Цифровая схемотехника и архитектура компьютера: RISC-V / пер. с англ. В. С. Яценков, А. Ю. Романов; под. ред. А. Ю. Романова / М.: ДМК Пресс, 2021.
Список типичных ошибок при работе с Vivado и SystemVerilog
Содержание
- Список типичных ошибок при работе с Vivado и SystemVerilog
Ошибки связанные с САПР Vivado
Не запускается симуляция FATAL_ERROR PrivateChannel Error creating client socket
Причина: ошибка связана с проблемами Win Sockets, из-за которых симуляция не может быть запущена на сетевых дисках.
Способ воспроизведения ошибки: создать проект на сетевом диске.
Решение: скорее всего, вы создали проект на диске H:/
. Создайте проект на локальном диске (например, на рабочем столе диске C:/
)
Не запускается симуляция boost filesystem remove Процесс не может получить доступ к файлу
Скриншот ошибки:
Причина: вы запустили симуляцию с другим top level
-модулем, не закрыв предыдущую симуляцию.
Скорее всего, после создания тестбенча, вы слишком быстро запустили первую симуляцию. Из-за этого, Vivado не успел обновить иерархию модулей и сделать тестбенч top-level
-модулем. На запущенной симуляции все сигналы находились в Z и X состояниях, после чего вы попробовали запустить ее снова. К моменту повторного запуска иерархия модулей обновилась, сменился top-level
, что и привело к ошибке.
Способ воспроизведения ошибки: запустить симуляцию, создать новый файл симуляции, сделать его top-level
-модулем, запустить симуляцию.
Решение: закройте предыдущую симуляцию (правой кнопкой мыши по кнопки SIMULATION -> Close Simulation) затем запустите новую.
Иллюстрация закрытия симуляции:
Вылетает Vivado при попытке открыть схему
Причина: кириллические символы (русские буквы) в пути рабочей папки Vivado. Скорее всего, причина в кириллице в имени пользователя (НЕ В ПУТИ УСТАНОВКИ VIVADO).
Способ воспроизведения ошибки: (см. решение, только для воспроизведение необходимо сделать обратно, дать папке имя с кириллицей)
Решение: чтобы не создавать нового пользователя без кириллицы в имени, проще назначить Vivado новую рабочую папку.
Для этого:
- Создайте в корне диска
C:/
какую-нибудь папку (например Vivado_temp). - Откройте свойства ярлыка Vivado (правой кнопкой мыши по ярлыку -> свойства) 2.1 Если у вас нет ярлыка Vivado на рабочем столе, вместо этого вы запускаете его из меню пуск, кликните в меню пуск правой кнопкой мыши по значку Vivado -> открыть расположение файла. Если там будет ярлык выполните пункт 2, если там будет исполняемый файл — создайте ярлык для этого файла (правой кнопкой мыши по файлу -> создать ярлык) и выполните пункт 2.
- В поле "Рабочая папка", укажите путь до созданной вами директории (в примере пункта 1 этот путь будет:
C:/Vivado_temp
). Нажмите "ОК".
Не устанавливается Vivado Unable to open archive
Иллюстрация:
Причина: скорее всего, проблема в том, что файлы установки (НЕ ПУТЬ УСТАНОВКИ 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