Процессор на коленке ч.2

от автора


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

Исполняемый файл

Чтобы запуск был ещё и с пользой, можно собрать тест 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 

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

mylib.c

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> ... 

Суммарно получаем такой батник.

make.bat

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 

Складываем всё в один файл.

core_tb.v

`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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *