
В прошлой части собрали минимальное ядро, теперь хотелось бы его запустить. Для запуска нужен исполняемый файл, потом его надо загрузить в память процессора и запустить симуляцию. Ещё неплохо, чтобы в процессе работы можно было выводить что-то в консоль для отладки.
Исполняемый файл
Чтобы запуск был ещё и с пользой, можно собрать тест CoreMark. Качаем его с гитхаба. Собирать будем с помощью gcc для архитектуры Risc-V. Чтобы получить чистый код, в котором поломки будут происходить в понятных местах, и сэкономить место, никакие библиотеки использовать не будем.
В самом тесте в файле core_portme.h надо настроить параметры сборки.
#define HAS_FLOAT 0 // мы пока не умеем аппаратно считать числа с плавающей точкой #define HAS_TIME_H 0 // никаких лишних библиотек для получения времени писать не будем #define USE_CLOCK 0 #define MAIN_HAS_NOARGC 1 //ну и в main мы ничего не присылаем, ибо неоткуда
Для использования аппаратного таймера пропишем в core_portme.c делитель таймера. Симуляция медленная, поэтому конкретное значение не важно, всё равно в удобные значения не влезть. В аппаратном таймере ещё дополнительно поделим, когда можно будет прогнать в железе, пригодится.
#define CLOCKS_PER_SEC 100
В ee_printf.c в функции uart_send_char записываем символ по специальному адресу. В процессоре при попытке записи по этому адресу будем выдавать значение в консоль симулятора. С таймером аналогично, читаем со специального адреса, который сами задали.
void uart_send_char(char c) { *((volatile int*)0x40000004) = c; } CORETIMETYPE barebones_clock() { return *((volatile int*)0x40000008); }
Операционки нет, поэтому заводим boot.s и вызываем оттуда main руками.
.globl _start _start: la sp, _stack la gp, _global call main
Аппаратного умножения пока тоже нет, придётся накидать библиотечных функций.
unsigned __umulsi3(unsigned a, unsigned b) { unsigned result = 0; while (b) { if (b & 1) result += a; a <<= 1; b >>= 1; } return result; } int __mulsi3(int a, int b) { unsigned sign = 0; if (a < 0) { sign ^= 1; a = -a; } if (b < 0) { sign ^= 1; b = -b; } unsigned result = __umulsi3(a, b); return sign ? -result : result; } unsigned __udivmod(unsigned a, unsigned b, int var) { if (b == 0) return 0; unsigned msb = 1; while(b < a && !(b & (1<<31))) { msb <<= 1; b <<= 1; }; unsigned result = 0; while (a && msb) { if (b <= a) { a -= b; result |= msb; } msb >>= 1; b >>= 1; } return var ? result : a; } int __divmod(int a, int b, int var) { unsigned sign = 0, asign = a & (1<<31); if (b == 0) return 0; if (a < 0) { sign ^= 1; a = -a; } if (b < 0) { sign ^= 1; b = -b; } unsigned result = __udivmod(a, b, var); if (var) return sign ? -result : result; else return asign ? -result : result; } int __udivsi3(int a, int b) { return __udivmod(a, b, 1); } int __umodsi3(int a, int b) { return __udivmod(a, b, 0); } int __divsi3(int a, int b) { return __divmod(a, b, 1); } int __modsi3(int a, int b) { return __divmod(a, b, 0); }
Поскольку адреса памяти у нас расположены по-своему, для линковщика заводим свой конфиг core.ld.
MEMORY { MEM (rwx) : ORIGIN = 0x00000000, LENGTH = 65536 // здесь по размеру должно не вылезать за размеры памяти процессора } SECTIONS { .text : { _text = .; *boot*.o(.text) // сначала закидываем код (секция text) *(.text*) _etext = .; } > MEM .data : { _data = .; *(.rodata*) // за кодом накидываем инициализированные данные *(.data*) _global = . + 0x800; *(.sbss*) // потом инициализируемые нулём данные *(.bss*) *(.sdata*) *(.*) _edata = .; . = ALIGN(4); } > MEM PROVIDE ( _stack = ORIGIN(MEM) + LENGTH(MEM) ); //стек начинается с конца памяти и растёт вниз, в сторону глобальных переменных }
Всё подготовлено, теперь можно компилировать. Заводим батник, прописываем путь до распакованного компилятора (bin\riscv-none-elf), под какую архитектуру собираем (rv32e для расширения embedded, ilp32e, elf32lriscv). Задаём параметры для теста (PERFORMANCE_RUN для запуска измерения, ITERATIONS=1 для одного прохода теста, COMPILER_FLAGS какие-нибудь для понятности), уровень оптимизации ставим O2. И важный пункт, добавляем флаг -ffreestanding, чтобы компилятор не вставлял memmove везде где ни попадя (даже в memmove, да где логика то).
set gcc_bin=..\xpack-riscv-none-elf-gcc-12.2.0-3\bin\riscv-none-elf set arch=rv32e set abi=ilp32e set COMPILER_FLAGS=\"compiler_flags\" set ccflags=-march=%arch% -mabi=%abi% -ffreestanding -I "./src" -O2 -DPERFORMANCE_RUN=1 -DITERATIONS=1 -DCOMPILER_FLAGS=%COMPILER_FLAGS% set ldflags=-Tcore.ld -Map coremark.map -m elf32lriscv
Теперь можно компилировать.
%gcc_bin%-gcc %ccflags% -c src/core_list_join.c -o build/core_list_join.o
После того как компилятор навалил объектных файлов, можно их линковать. Если кто-то решит начинать не с CoreMark, а с единичного файла, учтите, что вызов компилятора с одним файлом, чтобы сразу получить из него exe’шник, приводит к автоматическому добавлению стандартной библиотеки.
%gcc_bin%-ld %ldflags% -o coremark.o build/boot.o build/core_list_join.o build/core_main.o ...
Для загрузки в симулятор нужен другой порядок байт, так что переставляем их наоборот (возможно, ALIGN(4) в core.ld было надо для ровного значения, без которого objcopy мог сказать, что размер не делится на 4).
%gcc_bin%-objcopy -O binary --reverse-bytes=4 coremark.o coremark.bin
Ещё есть смысл выдать листинг ассемблера, там и машинный код команд есть с адресами, и номера регистров, очень полезно при отладке. Но в полном листинге теряется исходный код, поэтому при отладке конкретных функций может пригодиться дизассемблирование конкретных файлов.
%gcc_bin%-objdump -D -S coremark.o > coremark.S %gcc_bin%-gcc %ccflags% -fverbose-asm -S src/core_state.c -o build/core_state.s
Потом при симуляции можно подглядывать в листинг.
Disassembly of section .text: 00000000 <_start>: 0:00010117 auipcsp,0x10 4:00010113 mvsp,sp 8:00005197 auipcgp,0x5 c:93418193 addigp,gp,-1740 # 493c <_global> 10:179000ef jalra,988 <main> ...
Суммарно получаем такой батник.
set gcc_bin=..\xpack-riscv-none-elf-gcc-12.2.0-3\bin\riscv-none-elf set arch=rv32e set abi=ilp32e set COMPILER_FLAGS=\"compiler_flags\" set ccflags=-march=%arch% -mabi=%abi% -ffreestanding -I "./src" -O2 -DPERFORMANCE_RUN=1 -DITERATIONS=1 -DCOMPILER_FLAGS=%COMPILER_FLAGS% set ldflags=-Tcore.ld -Map coremark.map -m elf32lriscv rmdir /q /s build del /q coremark.hex del /q coremark.bin del /q coremark.map del /q coremark.o del /q coremark.S mkdir build %gcc_bin%-gcc %ccflags% -c src/core_list_join.c -o build/core_list_join.o %gcc_bin%-gcc %ccflags% -c src/core_main.c -o build/core_main.o %gcc_bin%-gcc %ccflags% -c src/core_matrix.c -o build/core_matrix.o %gcc_bin%-gcc %ccflags% -c src/core_state.c -o build/core_state.o %gcc_bin%-gcc %ccflags% -c src/core_util.c -o build/core_util.o %gcc_bin%-gcc %ccflags% -c src/core_portme.c -o build/core_portme.o %gcc_bin%-gcc %ccflags% -c src/ee_printf.c -o build/ee_printf.o %gcc_bin%-gcc %ccflags% -c mylib.c -o build/mylib.o %gcc_bin%-gcc %ccflags% -c boot.s -o build/boot.o %gcc_bin%-gcc %ccflags% -fverbose-asm -S src/core_state.c -o build/core_state.s %gcc_bin%-gcc %ccflags% -fverbose-asm -S src/core_list_join.c -o build/core_list_join.s %gcc_bin%-gcc %ccflags% -fverbose-asm -S mylib.c -o build/mylib.s %gcc_bin%-ld %ldflags% -o coremark.o build/boot.o build/core_list_join.o build/core_main.o build/core_matrix.o build/core_state.o build/core_util.o build/core_portme.o build/ee_printf.o build/mylib.o %gcc_bin%-objdump -D -S coremark.o > coremark.S %gcc_bin%-objcopy -O binary --reverse-bytes=4 coremark.o coremark.bin pause
Бинарник готов, теперь соберём систему, в которую его можно загрузить.
Обвязка ядра
Всю обвязку сделаем в одном тестовом файле. Для начала выдадим интерфейс ядра.
RiscVCore core0 ( .clock(clock), .reset(reset), .instruction_address(i_addr), .instruction_data(i_data), .data_address(d_addr), .data_width(d_width), .data_in(d_data_in), .data_out(d_data_out), .data_read(data_r), .data_write(data_w) );
Генерацию тактового сигнала для симулятора сделаем в коде.
reg clock = 0; initial while(1) #1 clock = !clock;
Для сброса сделаем небольшую задержку, может пригодиться в будущем, чтобы гарантированно всё сбросилось.
reg reset = 1; reg [1:0] reset_counter = 2; always@(posedge clock) begin reset_counter <= reset_counter == 0 ? 0 : reset_counter - 1; reset <= reset_counter != 0; end
Ещё из периферии надо завести таймер для измерений.
reg [31:0] timer; reg [31:0] timer_divider; always@(posedge clock) begin if(reset) begin timer = 0; timer_divider = 0; end else begin if (timer_divider == 100) begin timer <= timer + 1; timer_divider <= 0; end else begin timer_divider <= timer_divider + 1; end end end
Теперь надо как-то подключить память и загрузить в неё программу. Пока нет кэша и тому подобного, можно считать, что память лежит в одном месте.
`define MEMORY_SIZE (2**16/4) reg [31:0] rom [0:`MEMORY_SIZE-1]; begin $dumpfile("core_tb.vcd"); //файл с результатами симуляции $dumpvars(); //и в нём все переменные какие есть for (i = 0; i < `MEMORY_SIZE; i = i + 1) //память на старте зануляем, чтобы bss секция программы была чистой rom[i] = 32'b0; fdesc = $fopen("code.bin", "rb"); //открываем экзешник fres = $fread(rom, fdesc, 0, `MEMORY_SIZE); //пишем его в память $fclose(fdesc); #3000000 //ждём 3кк циклов (у нас цикл - это 1 наносекунда) $finish(); //завершаем симуляцию end
К памяти идёт куча проводков, сейчас разберёмся, что куда включать.
wire [31:0] i_addr; wire [31:0] i_data; wire [31:0] d_addr; wire [31:0] d_data_in; wire [31:0] d_data_out; wire data_r; wire data_w; wire [1:0] d_width; //0-byte, 1-half, 2-word
Память получается двухпортовая. С точки зрения FPGA это означает, что надо сделать два экземпляра памяти, писать в обе по одному адресу, а читать с разных.
Для шины инструкций всё прозрачно: подставляем адрес, получаем значение. Всё потому, что машинный код выровнен. Хотя есть ещё расширение со сжатыми 16-битными командами, но пока их нет, можно расслабиться.
assign i_data = rom[i_addr[31:2]];
Дальше подключаем шину данных. ROM собран по 4 байта, придётся как-то выравнивать. Сначала прочитаем машинное слово.
wire [31:0] old_data = rom[d_addr[31:2]]; wire [1:0] addr_tail = d_addr[1:0]; //отступ внутри слова
Если была команда чтения, значит мы уже сделали то что нужно, ядро дальше внутри само выровняетданные как надо. В дальнейшем понадобится учитывать флаг data_r, чтобы не занимать шину без необходимости, но сейчас шина у нас всегда подключена в одно место, никакой конкуренции нет. Ещё подсунем в мультиплексор таймер, который нужен для теста.
assign d_data_in = d_addr == 32'h40000008 ? timer : d_addr < `MEMORY_SIZE * 4 ? (old_data >> (addr_tail * 8)) : 0;
Запись может быть разного размера, поэтому надо пометить, какие байты надо менять, какие нет.
assign byte_mask = d_width == 0 ? 4'b0001 << addr_tail : d_width == 1 ? 4'b0011 << addr_tail : 4'b1111;
Данные, присланные процессором, надо сдвинуть до нужного байта, и можно записывать. Тут есть небольшая опасность, что данные будут размером 2 байта, а сдвиг на 3 байта (это единственный вариант, когда можно вылезть за границы). При наличии исключений можно кинуть что-нибудь про невыровненное обращение.
wire [31:0] aligned_out = d_data_out << addr_tail * 8; always@(posedge clock) begin if (data_w) begin rom[d_addr[31:2]] <= {byte_mask[3] ? aligned_out[31:24] : old_data[31:24], byte_mask[2] ? aligned_out[23:16] : old_data[23:16], byte_mask[1] ? aligned_out[15:8] : old_data[15:8], byte_mask[0] ? aligned_out[7:0] : old_data[7:0]}; end end
Осталось сделать отладочный вывод и ещё добавить остановку симуляции при выходе за границы памяти, это сильно помогает при отладке.
always@(posedge clock) begin if (data_w && d_addr == 32'h40000004) begin //отладочный вывод $write("%c", d_data_out); end else if (data_r && d_addr == 32'h40000008) begin ; //таймер читать можно end else if ((data_r || data_w) && (d_addr < 8'hff || d_addr > `MEMORY_SIZE * 4)) begin //нулевые указатели и выход за границы приводит к прекращению работы $finish(); end end
Складываем всё в один файл.
`timescale 1ns / 1ns module core_tb; `define MEMORY_SIZE (2**16/4) reg clock = 0; reg reset = 1; reg [1:0] reset_counter = 2; reg [31:0] rom [0:`MEMORY_SIZE-1]; //память wire [31:0] i_addr; wire [31:0] i_data; wire [31:0] d_addr; wire [31:0] d_data_in; wire [31:0] d_data_out; wire data_r; wire data_w; wire [1:0] d_width; //0-byte, 1-half, 2-word wire [3:0] byte_mask; reg [31:0] timer; reg [31:0] timer_divider; integer i, fdesc, fres; initial while(1) #1 clock = !clock; initial begin $dumpfile("core_tb.vcd"); $dumpvars(); for (i = 0; i < `MEMORY_SIZE; i = i + 1) rom[i] = 32'b0; //$readmemh("code.hex", rom); fdesc = $fopen("code.bin", "rb"); fres = $fread(rom, fdesc, 0, `MEMORY_SIZE); $fclose(fdesc); #3000000 $finish(); end always@(posedge clock) begin reset_counter <= reset_counter == 0 ? 0 : reset_counter - 1; reset <= reset_counter != 0; end RiscVCore core0 ( .clock(clock), .reset(reset), //.irq, .instruction_address(i_addr), .instruction_data(i_data), .data_address(d_addr), .data_width(d_width), .data_in(d_data_in), .data_out(d_data_out), .data_read(data_r), .data_write(data_w) ); //шина инструкций всегда выровнена assign i_data = rom[i_addr[31:2]]; //теперь выравниваем данные //делаем невыровненный доступ, точнее выровненный по байтам wire [31:0] old_data = rom[d_addr[31:2]]; wire [1:0] addr_tail = d_addr[1:0]; //вешаем на общую шину регистры и память assign d_data_in = d_addr == 32'h40000008 ? timer : d_addr < `MEMORY_SIZE * 4 ? (old_data >> (addr_tail * 8)) : 0; //TODO data_read не нужен? //для чтения данных маска накладывается в ядре, здесь только для записи assign byte_mask = d_width == 0 ? 4'b0001 << addr_tail : d_width == 1 ? 4'b0011 << addr_tail : 4'b1111; //раз для побайтового чтения надо делать побайтовый сдвиг //то для полуслов дешевле не ограничивать выравниванием на два байта //TODO нужна проверка выхода за границы слова? wire [31:0] aligned_out = d_data_out << addr_tail * 8; always@(posedge clock) begin if (data_w) begin rom[d_addr[31:2]] <= {byte_mask[3] ? aligned_out[31:24] : old_data[31:24], byte_mask[2] ? aligned_out[23:16] : old_data[23:16], byte_mask[1] ? aligned_out[15:8] : old_data[15:8], byte_mask[0] ? aligned_out[7:0] : old_data[7:0]}; end if (data_w && d_addr == 32'h4000_0004) begin //отладочный вывод $write("%c", d_data_out); end else if (data_r && d_addr == 32'h40000008) begin ; //таймер читать можно end else if ((data_r || data_w) && (d_addr < 8'hff || d_addr > `MEMORY_SIZE * 4)) begin //нулевые указатели и выход за границы приводит к прекращению работы $finish(); end if(reset) begin timer = 0; timer_divider = 0; end else begin if (timer_divider == 100) begin timer <= timer + 1; timer_divider <= 0; end else begin timer_divider <= timer_divider + 1; end end end endmodule
Ура, можно запускать. А где?
Симуляция
Для симуляции используем icarus verilog. Запуск делается в два этапа.
::компилируем файл для икаруса "..\iverilog\bin\iverilog.exe" -I ../core -o tmp_sim core_tb.v ../core/core.v ::симулируем "..\iverilog\bin\vvp.exe" tmp_sim
В результате тест выдаёт статистику. Как её интерпретировать, написано в предыдущей статье, но если коротко, 100/число секунд даст число операций на клок.

Результат симуляции будет записан в файл, который был задан в файле обвязки. Его можно открыть в Gtk Wave, который идёт в комплекте с икарусом.
::смотрим результат call "..\iverilog\gtkwave\bin\gtkwave.exe" core_tb.vcd
Там можно помотреть на регистры и всё прочее.

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

Ещё в какой-то момент каждая четвёртая команда была условным переходом.

В общем, теперь есть паровозик, на котором можно покататься и посмотреть, как работает простой процессор. Пока что он не слишком быстрый, так что в следующий раз попробуем прикрутить модуль умножения. Лучше это сделать до конвейера, потому что тогда сложность отладки сильно вырастет.
ссылка на оригинал статьи https://habr.com/ru/articles/910440/
Добавить комментарий