Лабораторная работа 13 "Высокоуровневое программирование"
Цель
В соответствии с индивидуальным заданием, написать программу на языке программирования высокого уровня C, скомпилировать в машинные коды и запустить на ранее разработанном процессоре RISC-V.
Ход работы
- Изучить теорию:
- Подготовить набор инструментов для кросс-компиляции.
- Изучить порядок компиляции и команды, её осуществляющую.
- Написать и скомпилировать собственную программу.
- Проверить исполнение программы вашим процессором в ПЛИС.
Теория
В рамках данной лабораторной работы вы напишите полноценную программу, которая будет запущена на вашем процессоре. В процессе компиляции, вам потребуются файлы, лежащие в этой папке.
— Но зачем мне эти файлы? Мы ведь уже делали задания по программированию на предыдущих лабораторных работах и нам не были нужны никакие дополнительные файлы.
Дело в том, что ранее вы писали небольшие программки на ассемблере. Однако, язык ассемблера архитектуры RISC-V, так же как и любой другой RISC архитектуры, недружелюбен к программисту, поскольку изначально создавался с прицелом на то, что будут созданы компиляторы и программы будут писаться на более удобных для человека языках высокого уровня. Ранее вы писали простенькие программы, которые можно было реализовать на ассемблере, теперь же вам будет предложено написать полноценную программу на языке Си.
— Но разве в процессе компиляции исходного кода на языке Си мы не получаем программу, написанную на языке ассемблера? Получится ведь тот же код, что мы могли написать и сами.
Штука в том, что ассемблерный код который писали ранее вы отличается от ассемблерного кода, генерируемого компилятором. Код, написанный вами обладал, скажем так... более тонким микро-контролем хода программы. Когда вы писали программу, вы знали какой у вас размер памяти, где в памяти расположены инструкции, а где данные (ну, при написании программ вы почти не пользовались памятью данных, а когда пользовались — просто лупили по случайным адресам и все получалось). Вы пользовались всеми регистрами регистрового файла по своему усмотрению, без ограничений. Однако, представьте на секунду, что вы пишете проект на ассемблере вместе с коллегой: вы пишите одни функции, а он другие. Как в таком случае вы будете пользоваться регистрами регистрового файла? Поделите его напополам и будете пользоваться каждый своей половиной? Но что будет, если к проекту присоединится еще один коллега — придется делить регистровый файл уже на три части? Так от него уже ничего не останется. Для разрешения таких ситуаций было разработано соглашение о вызовах (calling convention).
Таким образом, генерируя ассемблерный код, компилятор не может так же, как это делали вы, использовать все ресурсы без каких-либо ограничений — он должен следовать ограничениям, накладываемым на него соглашением о вызовах, а так же ограничениям, связанным с тем, что он ничего не знает о памяти устройства, в котором будет исполняться программа — а потому он не может работать с памятью абы как. Работая с памятью, компилятор следует некоторым правилам, благодаря которым после компиляции компоновщик сможет собрать программу под ваше устройство с помощью специального скрипта.
Соглашение о вызовах
Соглашение о вызовах устанавливает порядок вызова функций: где размещаются аргументы при вызове функций, где находятся указатель на стек и адрес возврата и т.п.
Кроме того, соглашение делит регистры регистрового файла на две группы: оберегаемые и необерегаемые регистры. При работе с оберегаемыми регистрами, функция должна гарантировать, что перед возвратом в этих регистрах останется тоже самое значение, что было при вызове функции. То есть, если функция собирается записать что-то в оберегаемый регистр, она должна сохранить перед этим его значение на стек, а затем, перед возвратом, вернуть это значение со стека обратно в этот же регистр. Простая аналогия — в маленькой квартире двое делят один рабочий стол по времени. Каждый использует стол по полной, но после себя он должен оставить половину стола соседа (оберегаемые регистры) в том же виде, в котором ее получил, а со своей (необерегаемые регистры) делает что хочет. Кстати, вещи соседа, чтоб не потерять, убирают на стопку рядом (в основную память). С необерегаемыми регистрами функция может работать как ей угодно — не существует никаких гарантий, которые вызванная функция должна исполнить. При этом, если функция вызывает другую функцию, она точно так же не получает никаких гарантий, что вызванная функция оставит значения необерегаемых регистров без изменений, поэтому если там хранятся значения, которые потребуются по окончанию выполнения вызываемой функции, эти значения необходимо сохранить на стек.
В таблице ниже приведено разделение регистров на оберегаемые (в правом столбце записано 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–7 | t0–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 |
Не смотря на то, что указатель на стек помечен как Callee-saved регистр, это не означает, вызываемая функция может записать в него что заблагорассудится, предварительно сохранив его значение на стек. Ведь как вы вернете значение указателя на стек со стека, если в регистре указателя на стек лежит что-то не то? Запись Callee
означает, что к моменту возврата из вызываемой функции, значение Callee-saved регистров должно быть ровно таким же, каким было в момент вызова функций. Для s0-s11 регистров это осуществляется путем сохранения их значений на стек. При этом, перед каждым сохранением на стек, изменяется значение указателя на стек таким образом, чтобы он указывал на сохраняемое значение (обычно он декрементируется). Затем, перед возвратом из функций все сохраненные на стек значения восстанавливаются, попутно изменяя значение указателя на стек противоположным образом (инкрементируют его). Таким образом, не смотря на то, что значение указателя на стек менялось в процессе работы вызываемой функции, к моменту выхода из нее, его значение в итоге останется тем же.
Скрипт для компоновки (linker_script.ld)
Скрипт для компоновки описывает то, как в вашей памяти будут храниться данные. Вы уже могли слышать о том, что исполняемый файл содержит секции .text
и .data
— инструкций и данных соответственно. Линковщик ничего не знает о том, какая у вас структура памяти: принстонская у вас архитектура или гарвардская, по каким адресам у вас должны храниться инструкции, а по каким данные. У вас может быть несколько типов памятей, под особые секции — и обо всем этом компоновщику можно сообщить в скрипте для компоновки.
В самом простом виде скрипт компоновки состоит из одного раздела: раздела секций, в котором вы и описываете какие части программы куда и в каком порядке необходимо разместить.
Для удобства этого описания существует вспомогательная переменная: счетчик адресов. В начале скрипта этот счетчик равен нулю. Размещая очередную секцию, этот счетчик увеличивается на размер этой секции. Допустим, у нас есть два файла fourier.o
и main.o
, в каждом из которых есть секции .text
и .data
. Мы хотим разместить их в памяти следующим образом: сперва разместить секции .text
обоих файлов, а затем секции .data
.
В итоге по нулевом адресу будет размещена секция .text
файла fourier.o
. Она будет размещена именно там, поскольку счетчик адресов в начале скрипта равен нулю, а очередная секция размещается по адресу, куда указывает счетчик адресов. После этого, счетчик адресов будет увеличен на размер этой секции и секция .text
файла main.o
будет размещена сразу же за секцией .text
файла fourier.o
. После этого счетчик адресов будет увеличен на размер этой секции. То же самое произойдет и при размещении оставшихся секций.
Кроме того, вы в любой момент можете изменить значение счетчика адресов. Например, у вас две раздельные памяти: память инструкций объемом 512 байт и память данных объемом 1024 байта. Эти памяти находятся в одном адресном пространстве. Диапазон адресов памяти инструкций: [0:511]
, диапазон памяти данных: [512:1535]
. При этом общий объем секций .text
составляет 416 байт. В этом случае, вы можете сперва разместить секции .text
так же, как было описано в предыдущем примере, а затем, выставив значение на счетчике адресов равное 512
, описываете размещение секций данных. Тогда, между секциями будет появится разрыв в 96 байт. А данные окажутся в диапазоне адресов, выделенном для памяти данных.
Помимо прочего, в скрипте компоновщика необходимо прописать где будет находиться стек, и какое будет значение у указателя на глобальную область памяти.
Все это с подробными комментариями описано в файле linker_script.ld
.
OUTPUT_FORMAT("elf32-littleriscv")
ENTRY(_start)
SECTIONS
{
PROVIDE( _start = 0x00000000 );
PROVIDE( _memory_size = 1024); /* 1024 байта */
.text : {*(.boot) *(.text*)}
/*
В скриптах линковщика есть внутренняя переменная, записываемая как '.'
Эта переменная называется счетчиком адресов. Она хранит текущий адрес в
памяти.
В начале файла она инициализируется нулем. Добавляя новые секции, эта
переменная будет увеличиваться на размер каждой новой секции.
Если при размещении секций не указывается никакой адрес, они будут размещены
по текущему значению счетчика адресов.
Этой переменной можно присваивать значения, после этого, она будет
увеличиваться с этого значения.
Подробнее:
https://home.cs.colorado.edu/~main/cs1300/doc/gnu/ld_3.html#IDX338
*/
. = ALIGN(4);
.data : {*(.data*)}
/*
Значение, присвоенное глобальному указателю (GP) выходит за границы RAM,
однако (для архитектуры RISC-V) общепринято присваивать GP значение равное
началу секции данных, смещенное на 2048 байт вперед.
Благодаря относительной адресации со смещением по 12-битному значению, можно
адресоваться на начало секции данных, а так же по всему адресному
пространству вплоть до 4096 байт от начала секции данных, что сокращает
объем требуемых для адресации инструкций (практически не используются
операции LUI, поскольку GP уже хранит базовый адрес и нужно только смещение).
Подробнее:
https://groups.google.com/a/groups.riscv.org/g/sw-dev/c/60IdaZj27dY/m/s1eJMlrUAQAJ
*/
_gbl_ptr = . + 0x800 ;
. = 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
*/
_bss_start = .;
.bss : {*(.bss*)}
_bss_end = .;
/*=================================
Секция аллоцированных данных завершена, остаток свободной памяти отводится
под программный стек и (возможно) кучу. В соглашении о
вызовах архитектуры RISC-V сказано, что стек растет снизу вверх, поэтому
наша цель разместить его в самых последних адресах памяти.
Однако перед этим, мы должны убедиться, что под программный стек останется
хотя бы 256 байт (ничем не обоснованное число, взятое с потолка).
Поскольку указатель стека (SP) должен быть выровнен до 16 байт, мы
обеспечиваем себе максимум 16 вложенных вызовов.
Подробнее:
https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf
=================================
*/
/* Мы хотим гарантировать, что под стек останется как минимум 256 байт */
ASSERT(. < (_memory_size - 256),
"Program size is too big")
/* Перемещаем счетчик адресов в конец памяти (чтобы после мы могли
использовать его в вызове ALIGN) */
. = _memory_size;
/*
Размещаем указатель программного стека так близко к концу памяти,
насколько это можно с учетом требования о выравнивании адреса
стека до 16 байт.
*/
_stack_ptr = ALIGN(16) <= _memory_size ?
ALIGN(16) : ALIGN(16) - 16;
ASSERT(_stack_ptr <= _memory_size, "SP exceed memory size")
}
Файл первичных команд при загрузке (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:
beq t0, t1, _main_call
sw zero, 0(t0)
addi t0, t0, 4
j _bss_init_loop
# Вызов функции main
_main_call:
li a0, 0 # Передача аргументов argc и argv в main. Формально, argc должен
li a1, 0 # быть больше нуля, а argv должен указывать на массив строк,
# нулевой элемент которого является именем исполняемого файла,
# Но для простоты реализации оба аргумента всего лишь обнулены.
# Это сделано для детерминированного поведения программы в случае,
# если будет пытаться использовать эти аргументы.
call main
# Зацикливание после выхода из функции main
_endless_loop:
j _endless_loop
Практика
Для того, чтобы запустить симуляцию исполнения программы на вашем процессоре, сперва эту программу необходимо скомпилировать и преобразовать в текстовый файл, которым САПР сможет проинициализировать память процессора. Для компиляции программы, вам потребуется особый компилятор, называемый "кросскомпилятор". Он позволяет компилировать исходный код под архитектуру компьютера, отличную от компьютера, на котором ведется компиляция. В нашем случае, вы будете собирать код под архитектуру RISC-V на компьютере с архитектурой x86_64
.
Компилятор, который подойдет для данной задачи (для запуска в операционной системе Windows) уже установлен в аудиториях. Но если что, вы можете скачать его отсюда.
Вам потребуется скомпилировать файлы с исходным кодом в объектные. Это можно сделать следующей командой:
<исполняемый файл компилятора> -с <флаги компиляции> <входной файл с исходным кодом> -o <выходной объектный файл>
Вам потребуются следующие флаги компиляции:
-march=rv32i
— указание разрядности и набора расширений в архитектуре, под которую идет компиляция-mabi=ilp32
— указание двоичного интерфейса приложений
С учетом названия исполняемого файла скачанного вами компилятора (при условии, что папку из архива вы переименовали в riscv_cc
и скопировали в корень диска C:
, а команду запускаете из терминала git bash
), командой для компиляции файла startup.S
может быть:
/c/riscv_cc/bin/riscv-none-elf-gcc -c -march=rv32i -mabi=ilp32 startup.S -o startup.o
Далее необходимо выполнить компоновку объектных файлов. Это можно выполнить командной следующего формата:
<исполняемый файл компилятора> <флаги компоновки> <входные объектные файлы> -o <выходной объектный файл>
Исполняемый файл компилятора тот же самый, флаги компоновки будут следующие:
-march=rv32i -mabi=ilp32
— те же самые флаги, что были при компиляции (нам все еще нужно указывать архитектуру, иначе компоновщик может скомпоновать объектные файлы со стандартными библиотеками от другой архитектуры)-Wl,--gc-sections
— указать компоновщику удалять неиспользуемые секции (сокращает объем итогового файла)-nostartfiles
— указать компоновщику не использовать стартап-файлы стандартных библиотек (сокращает объем файла)-T $(LINK_SCRIPT)
— передать компоновщику скрипт компоновки
Пример команды компоновки:
/c/riscv_cc/bin/riscv-none-elf-gcc -march=rv32i -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 result.elf init_data.mem
В процессе отладки лабораторной работы потребуется много раз смотреть на программный счетчик и текущую инструкцию. Довольно тяжело декодировать инструкцию самостоятельно, чтобы понять что сейчас выполняется. Для облегчения задачи можно дизасемблировать скомпилированный файл. Полученный файл на языке ассемблера будет хранить адреса инструкций, а так же их двоичное и ассемблерное представление.
Пример дизасемблированного файла:
Disassembly of section .text:
00000000 <_start>:
0: 00001197 auipc gp,0x1
4: adc18193 addi gp,gp,-1316 # adc <_gbl_ptr>
8: 76000113 li sp,1888
c: 2dc00293 li t0,732
10: 2dc00313 li t1,732
00000014 <_bss_init_loop>:
14: 00628863 beq t0,t1,24 <_irq_config>
18: 0002a023 sw zero,0(t0)
1c: 00428293 addi t0,t0,4
...
00000164 <bubble_sort>:
164: fd010113 addi sp,sp,-48
168: 02112623 sw ra,44(sp)
16c: 02812423 sw s0,40(sp)
170: 03010413 addi s0,sp,48
174: fca42e23 sw a0,-36(s0)
178: fcb42c23 sw a1,-40(s0)
17c: fe042623 sw zero,-20(s0)
180: 09c0006f j 21c <bubble_sort+0xb8>
...
00000244 <main>:
244: ff010113 addi sp,sp,-16
248: 00112623 sw ra,12(sp)
24c: 00812423 sw s0,8(sp)
250: 01010413 addi s0,sp,16
254: 00a00593 li a1,10
258: 2b400513 li a0,692
25c: f09ff0ef jal ra,164 <bubble_sort>
260: 2b400793 li a5,692
...
Disassembly of section .data:
000002b4 <array_to_sort>:
2b4: 00000003 lb zero,0(zero) # 0 <_start>
2b8: 0005 c.nop 1
2ba: 0000 unimp
2bc: 0010 0x10
2be: 0000 unimp
...
Числа в самом левом столбце, увеличивающиеся на 4 — это адреса в памяти. Отлаживая программу на временной диаграмме вы можете ориентироваться на эти числа, как на значения PC.
Следующая за адресом строка, записанная в шестнадцатеричном виде — это та инструкция (или данные), которая размещена по этому адресу. С помощью этого столбца вы можете проверить, что стянутая инструкция на временной диаграмме (сигнал instr
) корректна.
В правом столбце находится ассемблерный (человекочитаемый) аналог инструкции из предыдущего столбца. Например, инструкция 00001197
— это операция auipc gp,0x1
, где gp
— это синоним (ABI name) регистра x3
(см. раздел Соглашение о вызовах).
Обратите внимание на последнюю часть листинга: дизасм секции .data
. В этой секции адреса могут увеличиваться на любое число, шестнадцатеричные данные могут быть любого размера, а на ассемблерные инструкции в правом столбце и вовсе не надо обращать внимание.
Дело в том, что дизасемблер пытается декодировать вообще все двоичные данные, которые видит: не делая различий инструкции это или нет. В итоге, если у него получается как-то декодировать байты из секции данных (которые могут быть абсолютно любыми) — он это сделает. Причем получившиеся инструкции могут быть из совершенно не поддерживаемых текущим файлом расширений: сжатыми (по два байта вместо четырех), инструкциями операций над числами с плавающей точкой, атомарными и т.п.
Это не значит, что секция данных в дизасме бесполезна — в приведенном выше листинге вы можете понять, что первыми элементами массива array_to_sort
являются числа 3
, 5
, 10
, а так же то, по каким адресам они лежат (0x2b4
, 0x2b8
, 0x2bc
, если непонятно почему первое число записано в одну 4-байтовую строку, а два других разделены на две двубайтовые — попробуйте перечитать предыдущий абзац). Просто разбирая дизасемблерный файл, обращайте внимание на то, какую именно секцию вы сейчас читаете.
Для того, чтобы произвести дизасемблирование, необходимо выполнить следующую команду:
<исполняемый файл дизасемблера> -D (либо -d) <входной исполняемый файл> > <выходной файл на языке ассемблер>
Для нашего примера, командной будет
/c/riscv_cc/bin/riscv-none-elf-objdump -D result.elf > disasmed_result.S
Опция -D
говорит что дизасемблировать необходимо вообще все секции. Опция -d
говорит дизасемблировать только исполняемые секции (секции с инструкциями). Таким образом, выполнив дизасемблирование с опцией -d
мы избавимся от проблем с непонятными инструкциями, в которые декодировались данные из секции .data
, однако в этом случае, мы не сможем проверить адреса и значения, которые хранятся в этих секциях.
Задание
Вам необходимо написать программу для вашего индивидуального задания на языке C или C++ (в зависимости от выбранного языка необходимо использовать соответствующий компилятор: gcc для C, g++ для C++).
Доступ к контрольным и статусным регистрам осуществляется через разыменование указателей. Скорее всего, вам будет удобно объявить дефайны с базовыми адресами периферийного устройства и смещения до конкретного регистра в контроллере этого устройства.
При написании программы, помните что в C++ сильно ограничена арифметика указателей, поэтому при присваивании указателю целочисленного значения адреса, необходимо использовать оператор reinterpret_cast
.
Пример доступа к регистрам устройства:
#define SUPER_COLLIDER_BASE_ADDR 0x05000000
#define SUPER_COLLIDER_IS_READY_OFFSET 0x00000000
#define SUPER_COLLIDER_START_OFFSET 0x00000004
void main(int argc, char** argv)
{
uint32_t* is_ready_ptr = reinterpret_cast<uint32_t*>(SUPER_COLLIDER_BASE_ADDR + SUPER_COLLIDER_IS_READY_OFFSET);
uint32_t* start_ptr = reinterpret_cast<uint32_t*>(SUPER_COLLIDER_BASE_ADDR + SUPER_COLLIDER_START_OFFSET);
while(1){ // В бесконечном цикле
while (*is_ready_ptr) // Ждем пока супер-коллайдер не сообщит о готовности
// путем выставления 1 в статусном регистре по нулевому адресу
*start_ptr = 1; // После чего начинаем испытания путем записи единицы
// в контрольный регистр по адресу 4.
}
}
Порядок выполнения задания
- Написать программу для своего индивидуального задания на языке C или C++.
- Скомпилировать программу и стартап-файл в объектные файлы.
- Скомпоновать объектные файлы исполняемый файл, передав компоновщику соответствующий скрипт.
- Экспортировать из объектного файла секции
.text
и.data
в текстовые файлыprogram.txt
,data.txt
. Если вы не создавали инициализированных статических массивов, то файлdata.txt
может быть оказаться пустым.- Если файл
data.txt
не пустой, необходимо добавить инициализацию памяти данных этим файлом добавлением системной функции$readmemh
как это было сделано для памяти инструкций.
- Если файл
- Добавить получившиеся текстовые файлы в проект Vivado.
- Запустить моделирование исполнения программы вашим процессором. Для отладки во время моделирования будет удобно использовать дизасемблерный файл, ориентируясь на сигналы адреса и данных шины инструкций.
- Проверить исполнение программы процессором в ПЛИС.