forth j1

Наверное каждый разработчик FPGA рано или поздно решает создать свой процессор. Понятно, что есть много общеизвестных и распространенных процессоров у которых исходные тексты открыты для изучения и использования. Даже у нас на сайте мы уже запускали в плате Марсоход2 систему на кристалле ARMv2a и в плате Марсоход3 систему с процессором MIPS. Но и это не все! На opencores.org в разделе "процессоры" можно взять различные исходники более двухсот процессоров! Из широко известных там и Z80 и OpenRISC и RISC-V.

В этой статье я не буду изобретать свой процессор, однако, расскажу о довольно специфичном ядре - Forth-J1. Этот процессор специально разработан для исполнения "шитого кода" языка программирования Forth и хорошо подходит для использования внутри FPGA.

Автор этого ядра James Bowman, его страница на github здесь https://github.com/jamesbowman/j1
Я взял его Verilog исходники и адаптировал к нашим платам: Марсоход3, Марсоход3bis и новой плате M02mini. Вы можете взять мой адаптированный проект вот здесь: https://github.com/marsohod4you/M02mini/tree/master/forth_j1

Этот проект компилируется в среде САПР Intel Quartus Prime Lite для ПЛИС MAX10 наших плат и, в частности, для платы M02mini. При компиляции проекта смотрите, какая выбрана ревизия, для какой платы.

На плате M02mini стоит ПЛИС Intel MAX10 всего 2 тысячи логических элементов, но и здесь может работать 32х битный процессор J1 благодаря своей компактности.

Ядро процессора занимает меньше 150 строк кода на Verilog HDL:

`include "common.h"

module j1(
	input wire clk,
	input wire resetq,

	output wire io_wr,
	output wire [15:0] mem_addr,
	output wire mem_wr,
	output wire [`WIDTH-1:0] dout,
	input  wire [`WIDTH-1:0] mem_din,

	input  wire [`WIDTH-1:0] io_din,

	output wire [12:0] code_addr,
	input  wire [15:0] insn
	);

reg [12:0] pc, pcN;      
reg reboot = 1;
wire [12:0] pc_plus_1 = pc + 1;
	
//D stack
reg  [`WIDTH-1:0] st0;	// Top of data stack
wire [`WIDTH-1:0] st1;	// Next data on stack
reg  [`WIDTH-1:0] st0N;	// Next calculated value of st0
reg  [`DEPTH-1:0] dsp;	// Data stack pointer, points to st1 value
reg  [`DEPTH-1:0] dspN;	// Next calculated value of dsp
reg dstkW;					// D stack write enable signal

assign mem_addr = st0N[15:0];
assign code_addr = {pcN};

stack #(.DEPTH(`DEPTH))
	dstack(
		.clk(clk),
		.resetq(resetq),
		.ra(dsp),
		.rd(st1),
		.wa(dspN),
		.wd(st0),
		.we(dstkW)
		);

//R stack
wire [`WIDTH-1:0] rst0;	// Top value on return stack
reg  [`DEPTH-1:0] rsp;	// Return stack pointer
reg  [`DEPTH-1:0] rspN;	// Next calculated value of Return stack pointer
wire [`WIDTH-1:0] rstkD;// R stack value for write
reg rstkW;					// R stack write enable signal
stack #(.DEPTH(`DEPTH))
	rstack(
		.clk(clk),
		.resetq(resetq),
		.ra(rsp),
		.rd(rst0),
		.wa(rspN),
		.wd(rstkD),
		.we(rstkW)
		);

always @*
begin
	// Compute the new value of st0
	casez ({insn[15:8]})
		8'b1??_?????: st0N = { {(`WIDTH - 15){1'b0}}, insn[14:0] };    // literal
		8'b000_?????: st0N = st0;                     // jump
		8'b010_?????: st0N = st0;                     // call
		8'b001_?????: st0N = st1;                     // conditional jump
		8'b011_?0000: st0N = st0;                     // ALU operations...
		8'b011_?0001: st0N = st1;
		8'b011_?0010: st0N = st0 + st1;
		8'b011_?0011: st0N = st0 & st1;
		8'b011_?0100: st0N = st0 | st1;
		8'b011_?0101: st0N = st0 ^ st1;
		8'b011_?0110: st0N = ~st0;
		8'b011_?0111: st0N = {`WIDTH{(st1 == st0)}};
		8'b011_?1000: st0N = {`WIDTH{($signed(st1) < $signed(st0))}};
`ifdef NOSHIFTER // `define NOSHIFTER in common.h to cut slice usage in half and shift by 1 only
		8'b011_?1001: st0N = st1 >> 1;
		8'b011_?1010: st0N = st1 << 1;
`else      // otherwise shift by 1-any number of bits
		8'b011_?1001: st0N = st1 >> st0[4:0];
		8'b011_?1010: st0N = st1 << st0[4:0];
`endif
		8'b011_?1011: st0N = rst0;
		8'b011_?1100: st0N = mem_din;
		8'b011_?1101: st0N = io_din;
		8'b011_?1110: st0N = {{(`WIDTH - 8){1'b0}}, rsp, dsp};
		8'b011_?1111: st0N = {`WIDTH{(st1 < st0)}};
	default: st0N = {`WIDTH{1'bx}};
	endcase
end

wire is_alu = (insn[15:13] == 3'b011);
wire func_T_N =   (insn[6:4] == 1);
wire func_T_R =   (insn[6:4] == 2);
wire func_write = (insn[6:4] == 3);
wire func_iow =   (insn[6:4] == 4);
assign mem_wr = !reboot & is_alu & func_write;
assign io_wr  = !reboot & is_alu & func_iow;
assign dout = st1;

assign rstkD = (insn[13]==1'b0) ? {{(`WIDTH - 14){1'b0}}, pc_plus_1, 1'b0} : st0;

  reg [`DEPTH-1:0] dspI, rspI;
  always @*
  begin
    casez ({insn[15:13]})
    3'b1??:   {dstkW, dspI} = {1'b1,      4'b0001};
    3'b001:   {dstkW, dspI} = {1'b0,      4'b1111};
    3'b011:   {dstkW, dspI} = {func_T_N,  {insn[1], insn[1], insn[1:0]}};
    default:  {dstkW, dspI} = {1'b0,      4'b0000};
    endcase
    dspN = dsp + dspI;

    casez ({insn[15:13]})
    3'b010:   {rstkW, rspI} = {1'b1,      4'b0001};
    3'b011:   {rstkW, rspI} = {func_T_R,  {insn[3], insn[3], insn[3:2]}};
    default:  {rstkW, rspI} = {1'b0,      4'b0000};
    endcase
    rspN = rsp + rspI;

    casez ({reboot, insn[15:13], insn[7], |st0})
    6'b1_???_?_?:   pcN = 0;
    6'b0_000_?_?,
    6'b0_010_?_?,
    6'b0_001_?_0:   pcN = insn[12:0];
    6'b0_011_1_?:   pcN = rst0[13:1];
    default:        pcN = pc_plus_1;
    endcase
  end

  always @(negedge resetq or posedge clk)
  begin
    if (!resetq) begin
      reboot <= 1'b1;
      { pc, dsp, st0, rsp } <= 0;
    end else begin
      reboot <= 0;
      { pc, dsp, st0, rsp } <= { pcN, dspN, st0N, rspN };
    end
  end
endmodule

В процессоре имеются два стека: стек данных и стек возвратов. Глубина стека, впрочем, как и разрядность процессора определяется в файле include "common.h". Как я уже выше написал я буду компилировать 32х разрядный процессор. Глубина стека у меня будет 16 (для стека данных 17).

По внешним сигналам процессор имеет два интерфейса для доступа к памяти. Один интерфейс для чтения инструкций программы insn[15:0] по адресу code_addr[12:0]. Второй интерфейс для доступа к памяти данных и для доступа к портам ввода вывода. Процессор адресует 64К слов данных dout[`WIDTH-1:0]/mem_din[`WIDTH-1:0] через сигналы адресов mem_addr[15:0]. Запись в память или в устройство ввода вывода определяется сигналами mem_wr/io_wr.

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

В модуле стека так же чтение одного значения и запись другого происходят одновременно. Это обеспечивается вот таким кодом на Verilog HDL:

`include "common.h"

module stack #(parameter DEPTH=4) (
	input wire clk,
	/* verilator lint_off UNUSED */
	input wire resetq,
	/* verilator lint_on UNUSED */
	input wire [DEPTH-1:0] ra,
	output wire [`WIDTH-1:0] rd,
	input wire we,
	input wire [DEPTH-1:0] wa,
	input wire [`WIDTH-1:0] wd);

	reg [`WIDTH-1:0] store[0:(2**DEPTH)-1];

	always @(posedge clk)
		if(we)
			store[wa] <= wd;

	assign rd = store[ra];

endmodule

Модулей стека в ядре процессора два. Стек данных и стек возвратов. В Форте все делается через стек. Ведь язык Forth стековый. Все операции выполняются с данными лежащими на стеке. Даже можно слово данных переместить с вершины стека данных на вершину стека возвратов и наоборот.

Вот типичный пример на Форте. Пусть, нужно сложить два числа:
> 2 3 + .

Интерпретатор Форта возьмет из консоли число 2 и положит на стек, потом возьмет число 3 и так же положит на стек. Далее интерпретатор найдет во входном потоке символов слово "+" и будет исполнять его. А слово "+" снимает со стека два операнда, складывает их и результат сложения кладет на стек. А вот Форт слово "." снимет одно значение со стека и выведет его в консоль, напечатает результат. После всех этих действий на стеке данных уже не останется ничего, но "полезная работа" будет выполнена.

Те, кто впервые видят такую запись, а она называется "обратная польская запись", очень удивляются и считают этот подход очень неудобным. Но на самом деле это все дело привычки. Мозг человека весьма гибок. Энтузиасты Форта с легкостью пишут код для него и не очень страдают от непривычной записи.

Кроме того, у обратной польской записи есть и несомненные преимущества - отсутствие скобок в выражениях и из-за этого нет необходимости анализировать приоритеты операций.

Возвращаясь к ядру процессора Forth можно отметить, что стековая организация данных сильно упрощает выбор операндов для АЛУ процессора, ведь в операции ALU всегда участвуют значения, находящиеся на стеке. Интересно, что в этом ядре J1 операнд на вершине стека находится не внутри модуля стека данных, а в отдельном регистре st0. И понятно почему так сделано - ведь для АЛУ обычно нужно два операнда, а как из стека считать одновременно сразу два верхних элемента? Никак. Вот и получается, что из регистра st0 берется Top, а из модуля стека данных берется значение st1 то есть Next. Из за этого как раз лубина стека не степень двойки: 16+1=17.

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

instr

Интересно, что числовые значения-операнды кодируются прямо в инструкции, если старший бит ее insn[15] в единице. К сожалению, диапазон хранимых значений при этом всего 0..32767 и это не очень много. Для 32х битного процессора это не очень хорошо. Если потребуется большее число, то его придется моделировать арифметическими операциями и сдвигами. Но тем не менее, такой подход тоже имеет право на жизнь.

Биты insn[14:13] определяют тип операции: безусловный переход, условный переход, вызов подпрограммы или типовая обычная операция ALU.

Операции ALU кодируются в битах insn[11:8]. В основном это те операции, которые определяют, новое значение, которое будет записано на вершину стека.

По коду можно понять, что делает та или иная операция в АЛУ:
4'b0000 - NOP
4'b0001 - NEXT
4'b0010 - ADD
4'b0011 - AND
4'b0100 - OR
4'b0101 - XOR
4'b0111 - EQUAL
4'b1000 - SIGNED COMPARE LESS
4'b1001 - SHIFT RIGHT
4'b1010 - SHIFT LEFT
4'b1011 - Top value on return stack
4'b1100 - MEM READ DATA
4'b1101 - IO READ DATA
4'b1110 - Get stack pointers
4'b1111 - UNSIGNED COMPARE / LESS

Нужно обратить внимание, что практически любая операция может быть скомбинирована с возвратом из подпрограммы. Если установлен бит инструкции insn[12], то на этом же такте, кроме всех других действий будет взято значение со стека возвратов и записано в регистр PC, который указывает на следующую исполняемую инструкцию.

Что еще осталось? Если установлен бит insn[6], то значение со стека данных будет записано в значение стека возвратов. При этом, движутся ли сами указатели обоих стеков определяется битами insn[1:0] для dsp и insn[3:2] для rsp.

Если установлен бит insn[5], то значение NEXT со стека данных будет записано по адресу TOP стека данных.

В этой таблице показано, как некоторые из базовых слов языка Forth могут быть выражены в ассемблерных инструкциях, какие поля инструкции и как должны быть установлены:

forth-j1 insn samples

Вообще, цикл работы процессора можно описать вот такой простой схемой:

forth cycle

Слева показанно, считываются исходные значения для кода текущей инструкции insn, значение из памяти или I/O [T], текущие значения для вершины стека возвратов R и стека данных T и N. Эти исходные значения декодируются и используются для вычислений в ALU и справа показано, как такт завершается и производится запись новых значений в регистры и память.

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

Как же написать и запустить программу для языка Forth в нашей плате?

Обычно, Форт это интерпретатор. Но для embedded систем логично использовать кросс-компиляцию, то есть собирать бинарные данные программы на компьютере, а запускать их уже  в FPGA плате. Поэтому, для кросс компиляции в проекте используется gforth и специальные форт-скрипты.

В папке проекта есть папка toolchain и в н ей все нужные скрипты: make.bat, cross.fs

Скрипт make.bat просто запускает gforth:

>gforth cross.fs basewords.fs demo1.fs

В скприпте cross.fs написан кросскомпилятор на Форте. В скрипте basewords.fs описаны самые первые ассемблерные слова Forth-j1, те которые описывают инструкции процессора. Ну а в скрипте demo1.fs собственно моя программа на Forth.

Честно говоря я не большой специалист по языку Форт, но вот смог написать такую простую программу:


header 1+ : 1+ d# 1 + ;
header 1- : 1- d# -1 + ;
header 0= : 0= d# 0 = ;
header cell+ : cell+ d# 2 + ;

header <> : <> = invert ;
header > : > swap < ;
header 0< : 0< d# 0 < ;
header 0> : 0> d# 0 > ;
header 0<> : 0<> d# 0 <> ;
header u> : u> swap u< ;

( check serial rx port has byte )
header read?
: read?
  d# 0 io@
  d# 8 and
  0<>
;

( read byte from serial port )
header read
: read
  begin
    read?
  until
  d# 0 io@ d# 8 rshift
  d# 0 d# 2 io!
;

( check serial tx port busy )
: tx_busy
  d# 0 io@
  d# 4 and
  0=
;

header emit
: emit
  begin tx_busy until
  h# 0 io!
;

header cr
: cr
  d# 13 emit
  d# 10 emit
;

header space
: spac e
  d# 32 emit
;

: h dup d# 10 < if h# 30 else d# 55 then + emit ;
: hh dup d# 4 rshift d# 15 and h d# 15 and h ;

: main
  noop
  noop
  noop
  noop
  noop
  d# 0
  begin
    read dup hh space emit cr
    d# 1 +
    dup
    d# 4 io!
  again
;


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

Например, слово read? читает из I/O порта по адресу ноль. Для этого на стек кладется число-адрес 0 и вызывается слово io@

Слово io@ забирает со стека число-адрес, считывает из указанного порта значение и кладет его на стек. Потом на стек кладется число 8 и вызывается слово and, которое снимает два числа со стека вычисляет логическую функцию and и кладет результат на стек. Число 8 определяет битовую маску для сигнала готовности принятого байта в последовательном порту Форт-системы. Слово 0<> вычисляет равно ли нулю число на стеке или нет. Таким образом, слово read? возвращает на стеке значение, готов ли символ из последовательного порта или нет. Позволяет опрашивать последовательный порт.

Слово read в цикле begin read? until ожидает прихода символа из последовательного порта. Когда символ будет принят цикл прерывается и происходит реальное чтение из порта принятого байта.

Примерно так же работает пара слов tx_busy и emit. Слово tx_busy ждет когда последовательный порт будет свободен для отправки нового байта, а слово emit записывает передаваемый байт в порт.

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

Выше я определил слово h которое печатает в шестнадцатеричном виде одну тетраду из числа. А второе мое слово hh вызывает слово h два раза и печатает уже байт.

Слово main, определенное последним представляет собой вечный цикл. Последовательность read dup hh space emit cr читает из последовательного порта байт, печатает его в шестнадцатеричном виде, потом печатает пробел space и печатает сам принятый символ, потом переводит каретку консоли на следующую строку. При этом еще и подсчитывается число принятых байтов из последовательного порта и записывается в I/O порт 4, где располагается регистр светодиодов. То есть на светодиодах платы будет отображаться число принятых байт. Конечно, это очень простая программа, но она показывает, что процессор работает.

Я уверен, что такое ядро процессора Forth-J1 может успешно применяться в FPGA системах, ведь оно очень компактно по ресурсам и при этом является очень производительным.

Вот отчет компилятора при компиляции для платы M02mini:

Flow Status Successful - Sun Oct 04 18:09:51 2020
Quartus Prime Version 20.1.0 Build 711 06/05/2020 SJ Lite Edition
Revision Name max10_2
Top-level Entity Name jtop
Family MAX 10
Device 10M02DCV36C8G
Timing Models Final
Total logic elements 1,232 / 2,304 ( 53 % )
Total registers 276
Total pins 9 / 27 ( 33 % )
Total virtual pins 0
Total memory bits 33,792 / 110,592 ( 31 % )
Embedded Multiplier 9-bit elements 0 / 32 ( 0 % )
Total PLLs 1 / 1 ( 100 % )
UFM blocks 1 / 1 ( 100 % )
ADC blocks 0

В чипе всего 2 тысячи логических элементов, но и они заняты только на половину. Хочу еще обратить внимание, что в данной микросхеме 10M02 оказалось невозможным хранить код программы непосредственно в блоке памяти. Из-за этого пришлось делать дополнительную логику, которая считывает код программы из UFM (User Defined Memory) и записывает его в ОЗУ. Для более старших чипов MAX10 (платы Марсоход3 и Марсоход3bis ) этого делать конечно не требуется.

Я запустил процессор Forth-J1 на частоте 50МГц, но компилятор посчитал, что Fmax 72МГц. Очень приличный результат я считаю.

Думаю такой процессор вполне может использоваться там, где нужен софт процессор, может быть даже вместо NIOS или любого другого.

PS: сделал простой интерпретатор Форта.

 


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