FORTH a procesor J1

Martin Malý ·

Implementace J1 ve Verilogu

Kapitola z knihy Data, čipy, procesory

Začneme společnými definicemi. Vytvoříme soubor common.h, který budeme později vkládat, kde bude zapotřebí.

`default_nettype none
`define WIDTH 16  -- Autor upravil i pro 32 bitů
`define DEPTH 4

Procesor obsahuje dva zásobníky, a protože jsou to reálné paměťové struktury, pojďme si je nadeklarovat jako moduly:

`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

Modul je parametrizovaný jedním parametrem, DEPTH (defaultní hodnota je 4). Parametr udává počet bitů adresy, tedy položek na zásobníku (těch je 2DEPTH). Předna-stavená hodnota 4 tedy znamená, že zásobník je pro 16 položek. Zásobník má dvě adresní sběrnice a dvě datové sběrnice – jeden pár pro čtení, jeden pro zápis. Vstup we (Write Enable) povoluje zápis – pokud je aktivní, zásobník zapíše hodnotu z datového vstupu wd na adresu ze vstupu wa. Na výstupu rd je stále obsah paměti z adresy ra. Zásobník sám o sobě neobsahuje žádnou další logiku, která by se starala o ukazatel, o jeho zvyšování a snižování a o podobné činnosti.

A takhle vypadá celá implementace procesoru, jak ji publikoval autor James Bowman

Dovolím si ji okomentovat a zdůraznit zajímavé pasáže:

`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
   );

Port procesoru kromě „povinné jízdy“, tedy hodinového vstupu a RESETu, obsahuje sběrnice pro čtení instrukcí (code_addr posílá adresu, insn je šestnáctibitová instruk-ce, přečtená z paměti) a pro práci s pamětí dat a periferií.

Paměť dat je opravdu pouze pro data, zásobníky jsou implementovány hardwarově uvnitř.

Procesor posílá 16bitovou adresu paměti mem_addr. Zapisovaná data posílá po sběrnici dout. Pomocí signálů mem_wr a io_wr říká, zda se zapisuje do paměti, nebo do prostoru periferií. Paměti a periferie naopak přivádějí data po sběrnicích mem_din a io_din.

Následují deklarace ukazatelů a přístupových signálů pro zásobníky. Datový zásobník má registr dsp (Data Stack Pointer) a registr st0 (hodnota na vrcholu zásobníku). Tyto registry jsou zdvojené – pracovní registry udržují hodnoty, ke kterým se v aktuálním cyklu přistupuje. Pokud instrukce vyvolá změnu, mění se hodnota registru dspN (Data Stack Pointer New) nebo st0N, a ta je na konci cyklu zkopírována do pracovních registrů.

  reg [`DEPTH-1:0] dsp;     // Data stack pointer
  reg [`DEPTH-1:0] dspN;
  reg [`WIDTH-1:0] st0;     // Top of data stack
  reg [`WIDTH-1:0] st0N;
  reg dstkW;                // D stack write

  reg [`DEPTH-1:0] rsp, rspN;
  reg rstkW;                // R stack write
  wire [`WIDTH-1:0] rstkD;  // R stack write value

Signály dstkW a rstkW slouží k zapsání hodnoty na zásobníky. U zásobníku R se zapi-sovaná hodnota přivádí po datové sběrnici rstkD.

  reg [12:0] pc, pcN;      

Program counter a jeho pracovní zrcadlo.

  reg reboot = 1;
  wire [12:0] pc_plus_1 = pc + 1;

Signál pc_plus_1 obsahuje přesně tu hodnotu, kterou čekáte, a slouží jako pomocný signál pro nejčastější případ, totiž že je nutno zvýšit čítač adres instrukcí.

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

Jako adresa do paměti dat je použita hodnota TOS na datovém zásobníku (resp. dol-ních 16 bitů), adresa do paměti kódu je připojena na registr pcN.

  wire [`WIDTH-1:0] st1, rst0;

Signál st1 vlastně odpovídá FORTHovskému NOS (Next On Stack), tedy položce pod aktuální položkou. Aktuální položka je TOS a je v registru st0.

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

Instancujeme modul stack s hloubkou danou parametrem DEPTH. Hodiny a RESET jsou samozřejmost (RESET tedy nemá žádný efekt). Čtecí adresa je daná ukazatelem dsp, zapisovací adresa je dspN (logicky: zapisuje se na konci cyklu na adresu, kterou si v rámci instrukce teprve spočítáme). Přečtená data tvoří signál st1, data k zápisu jsou daná registrem st0. Zápis povoluje signál dstkW.

  stack #(.DEPTH(`DEPTH))rstack(.clk(clk), .resetq(resetq), .ra(rsp), .rd(rst0), .we(rstkW), .wa(rspN), .wd(rstkD));

Až na konkrétní signály je definice zásobníku R téměř totožná.

Teď začíná ta nejvíc cool pasáž, totiž proces, který počítá novou hodnotu na zásobníku, a to na základě načteného instrukčního kódu. Všimněte si, že rozhoduje pouze horních 8 bitů (15 až 8). Pokud má tvar „1xxx_xxxx“, jde o zápis literálu, tj. konstanty, která se má přenést do st0. Takže je doplněna nulami zleva na plnou šířku datového slova.

U instrukcí „000x“ a „010x“ (JUMP a CALL) se stav st0 nemění, u podmíněného skoku se načte hodnota NOS (podmíněný skok „zkonzumuje“ hodnotu TOS.

U instrukcí s ALU („011x_iiii“) se podle příslušných bitů „i“ zvolí požadovaná operace.

  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[3:0];
      8'b011_?1010: st0N = st1 << st0[3:0];
`endif
      8'b011_?1011: st0N = rst0;
      8'b011_?1100: st0N = mem_din;
      8'b011_?1101: st0N = io_din;
      8'b011_?1110: st0N = , rsp, dsp};
      8'b011_?1111: st0N = {`WIDTH{(st1 < st0)}};
      default: st0N = {`WIDTH{1'bx}};
    endcase
  end

Všimněte si znaku „?“ jako placeholderu pro hodnotu, která nás v tu chvíli nezajímá.

Pokud v common.h definujeme konstantu `define NOSHIFTER, můžeme ušet-řit logické buňky a místo shiftování až o 16 pozic (uvažují se 4 bity ST0) na-definovat operaci posunu tak, že posouvá vždy o jediný bit.

  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);

Bity 6, 5 a 4 instrukčního slova kódují požadovanou operaci, jak jsme si popisovali dříve.

  wire is_alu = (insn[15:13] == 3'b011);

Signál is_alu je 1, pokud instrukce byla typu „011“, tedy operace s daty (ne skoky ani literál).

  assign mem_wr = !reboot & is_alu & func_write;
  assign dout = st1;

Signál pro zápis do externí paměti přijde ve chvíli, kdy není stav reboot, proběhla operace s ALU a zároveň byla požadována funkce „write“. Data, která se zapisují, jsou vždy v NOS (tedy druhá nejvyšší položka na zásobníku).

  assign io_wr = !reboot & is_alu & func_iow;

Pro zápis do periferií platí totéž.

  assign rstkD = (insn[13] == 1'b0) ? , pc_plus_1, 1'b0} : st0;

Data pro zápis do RS se liší podle stavu bitu 13 instrukčního slova. Pokud je 0 (mají ji instrukce JUMP a CALL), připraví se hodnota PC+1, pokud je 1 (mají ji JUMPZ nebo ALU), připraví se TOS.

Může se stát, že instrukce obsahuje literál, který má bit 13 nastavený nebo vynulovaný – je to jedno, protože tyto instrukce nevyvolají zápis do RS. Stej-ně tak instrukce JMP – sice připraví návratovou adresu, ale nezapíše ji.

  reg [`DEPTH-1:0] dspI, rspI;

Hodnoty dspI a rspI představují inkrement ukazatele DS, resp. RS. Nejčastěji bývá jedničkový (do zásobníku přibyla hodnota), nulový (zásobník nemění svůj stav), nebo minus jedna (ze zásobníku ubyla hodnota).

  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;

Podle typu instrukce se nastavuje inkrement pro ukazatel DS a zároveň signál zápisu do DS. Instrukce „literál“ (1xx) nastaví zápis na 1 a inkrement jedničkový. Podmíněný skok konzumuje hodnotu z datového zásobníku, takže zápisový bit je 0 a inkrement -1. Pro ALU se zápis řídí příznakem požadavku T->N a inkrement je dán bity 1 a 0 instrukčního slova (dstack). V ostatních případech se hodnota nemění a nic se nezapisuje.

    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;

Podobná logika řídí i zásobník návratových adres RS. Instrukce CALL (010) zvyšuje ukazatel RS o 1 a zapisuje (rstkW=1). Instrukce ALU zapisuje, pokud aktivuje funkci T->R, a změna ukazatele se řídí bity 3 a 2 instrukčního slova (rstack).

    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

Nepřekvapí vás, že se podobně jednoduše řeší i hodnota PC. Její nová hodnota je většinou PC+1, s několika výjimkami. Pokud je „reboot = 1“, je nová hodnota PC rov-na nule. Pro instrukce CALL a JUMP je nová hodnota PC rovna bitům 13 – 0 z instrukčního slova.

Pro instrukci JUMPZ to platí, pokud je st0 nulový. Pokud jde o instrukci ALU a má nastavený bit 7 (RET), nastaví se hodnota ze zásobníku návratových adres.

  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

A toto je poslední proces procesoru J1. Je aktivní při sestupné hraně signálu resetq nebo při náběžné hraně hodin. Při sestupné hraně se nastavuje reboot na 1 a nulují se registry pc, dsp, rsp a st0.

Při náběžné hraně hodin se nuluje reboot a hodnoty pc, dsp, rsp a st0 se nastavují podle svých zrcadlových registrů pcN, dspN, rspN a st0N na novou hodnotu.

Příklady použití, stejně jako definice základních FORThových slov, najdete na autorově stránce

Předchozí díl

Twitter, Facebook