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

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


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



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



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

Комментарии (0)