
В прошлой части собрали минимальное ядро, теперь хотелось бы его запустить. Для запуска нужен исполняемый файл, потом его надо загрузить в память процессора и запустить симуляцию. Ещё неплохо, чтобы в процессе работы можно было выводить что-то в консоль для отладки.
Исполняемый файл
Чтобы запуск был ещё и с пользой, можно собрать тест 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 auipc sp,0x10
4: 00010113 mv sp,sp
8: 00005197 auipc gp,0x5
c: 93418193 addi gp,gp,-1740 # 493c <_global>
10: 179000ef jal ra,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%.

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

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