This lesson starts at commit 8e8cf23ea6f3e34ae18a7bddce8bd375d43a85dd.

2. Fetch stage

With our main project structure set up, we can start working on the core itself; specifically, on the fetch stage. This stage does nothing more than loading opcodes from the memory and passing them on to the next stage in the pipeline.

Fetch stage

Loading opcodes from memory? I hear you ask. Erm, yeah... At this point we have not implemented any memory yet. Doing this properly is a bit of work, and for now I just want to get our CPU into a state where it can do something, so that we can get our dopamine hit. So, for now, we're going to implement a quick hack and implement our memory as a simple array of 32-bit bitfields.

First, we'll declare a type instruction_memory_t for holding the instructions. In RISC-V, instructions are 32 bits wide (if you are not using the "C" extension for compressed instructions, which we are not). I choose to make our memory 16 instructions big, because that seemed large enough to hold very simple programs in assembly without becoming unwieldy. We can simply increase this number if we want to execute longer programs, and we'll eventually switch to a better memory implementation anyway.

For now, I'll just fill the instruction memory with zero bits.

src/core/fetch.vhd CHANGED
@@ -15,6 +15,12 @@ end fetch;
15
 
16
 
17
  architecture rtl of fetch is
 
 
 
 
 
 
18
  begin
19
 
20
  process (clk)
 
15
 
16
 
17
  architecture rtl of fetch is
18
+ type instruction_memory_t is array(0 to 15) of std_logic_vector(31 downto 0);
19
+ signal imem: instruction_memory_t := (
20
+ X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000",
21
+ X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000"
22
+ );
23
+
24
  begin
25
 
26
  process (clk)

Now, we'll also want a signal to keep track of number (or address) of the current instruction. This is typically called the program counter and abbreviated to "PC". I'll make our program counter of type unsigned so that we can increase it without having to do too many casts. In RISC-V, the program counter is 32 bits wide, so I'll use 32 bits as well.

src/core/fetch.vhd CHANGED
@@ -21,6 +21,8 @@ architecture rtl of fetch is
21
  X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000"
22
  );
23
 
 
 
24
  begin
25
 
26
  process (clk)
 
21
  X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000"
22
  );
23
 
24
+ signal pc: unsigned(31 downto 0) := (others => '0');
25
+
26
  begin
27
 
28
  process (clk)

Now, we'll want to increase the program counter to point to the next instruction after passing the instruction at the current program counter to the next stage. Now, the program counter is a byte address, and since our instructions are 32 bits wide, we need to increase the program counter by 4 every cycle. For now we'll just assume we can output an instruction every cycle.

src/core/fetch.vhd CHANGED
@@ -28,7 +28,7 @@ begin
28
  process (clk)
29
  begin
30
  if rising_edge(clk) then
31
- -- TODO: implement
32
  end if;
33
  end process;
34
 
 
28
  process (clk)
29
  begin
30
  if rising_edge(clk) then
31
+ pc <= pc + 4;
32
  end if;
33
  end process;
34
 

So far so good. After simulating this we can see it works as expected.

Now, we'll also want to fetch the instruction (or opcode) at the address that the program counter points to, and pass it to the next stage. So, let's add an output for it.

src/core/constants.vhd CHANGED
@@ -6,7 +6,7 @@ use work.core_types.all;
6
 
7
  package core_constants is
8
  constant DEFAULT_FETCH_OUTPUT: fetch_output_t := (
9
- placeholder => '0'
10
  );
11
 
12
  constant DEFAULT_DECODE_OUTPUT: decode_output_t := (
 
6
 
7
  package core_constants is
8
  constant DEFAULT_FETCH_OUTPUT: fetch_output_t := (
9
+ instr => (others => '0')
10
  );
11
 
12
  constant DEFAULT_DECODE_OUTPUT: decode_output_t := (
src/core/types.vhd CHANGED
@@ -4,7 +4,7 @@ use ieee.std_logic_1164.all;
4
 
5
  package core_types is
6
  type fetch_output_t is record
7
- placeholder: std_logic;
8
  end record fetch_output_t;
9
 
10
  type decode_output_t is record
 
4
 
5
  package core_types is
6
  type fetch_output_t is record
7
+ instr: std_logic_vector(31 downto 0);
8
  end record fetch_output_t;
9
 
10
  type decode_output_t is record

Let's actually fetch the opcode from the instruction memory and set it in the output.

src/core/fetch.vhd CHANGED
@@ -29,6 +29,7 @@ begin
29
  begin
30
  if rising_edge(clk) then
31
  pc <= pc + 4;
 
32
  end if;
33
  end process;
34
 
 
29
  begin
30
  if rising_edge(clk) then
31
  pc <= pc + 4;
32
+ output.instr <= imem(to_integer(pc(5 downto 2)));
33
  end if;
34
  end process;
35
 

To test this, let's fill the instruction memory with a counter that starts at one (to be able to distinguish between an "empty" opcode and the first opcode).

src/core/fetch.vhd CHANGED
@@ -17,8 +17,8 @@ end fetch;
17
  architecture rtl of fetch is
18
  type instruction_memory_t is array(0 to 15) of std_logic_vector(31 downto 0);
19
  signal imem: instruction_memory_t := (
20
- X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000",
21
- X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000", X"00000000"
22
  );
23
 
24
  signal pc: unsigned(31 downto 0) := (others => '0');
 
17
  architecture rtl of fetch is
18
  type instruction_memory_t is array(0 to 15) of std_logic_vector(31 downto 0);
19
  signal imem: instruction_memory_t := (
20
+ X"00000001", X"00000002", X"00000003", X"00000004", X"00000005", X"00000006", X"00000007", X"00000008",
21
+ X"00000009", X"0000000A", X"0000000B", X"0000000C", X"0000000D", X"0000000E", X"0000000F", X"00000010"
22
  );
23
 
24
  signal pc: unsigned(31 downto 0) := (others => '0');

If we now simulate the clk, pc, and output for 50 ns, we get the following waveforms:

Simulation waveforms

We can see that:

  1. The value of the opcode lags behind the value of the pc register by one cycle.
  2. For the first cycle, the opcode in the output is empty.

Both are fairly typical problems you run into when doing hardware design.

The issue of different values being out of sync is not a real problem, but can be a bit confusing. It happens because the value of output.opcode is set based on the value of pc. Their values are simultaneously updated on a rising edge of the clock, where output.opcode uses the old value of the pc. This is completely fine, so we don't need to make any changes at this point. The reason I am bringing it up, is that it's very important to be aware of things like this when we try to reason about what's going on in our CPU.

The second point is actually a problem, and we'll address it by adding an is_active flag in the output of the fetch stage and setting it to zero in the default value. If this flag is zero, the output is empty and should be ignored by other stages. We'll have to add similar flags to the output of the other stages later.

src/core/constants.vhd CHANGED
@@ -6,6 +6,7 @@ use work.core_types.all;
6
 
7
  package core_constants is
8
  constant DEFAULT_FETCH_OUTPUT: fetch_output_t := (
 
9
  instr => (others => '0')
10
  );
11
 
 
6
 
7
  package core_constants is
8
  constant DEFAULT_FETCH_OUTPUT: fetch_output_t := (
9
+ is_active => '0',
10
  instr => (others => '0')
11
  );
12
 
src/core/types.vhd CHANGED
@@ -4,6 +4,7 @@ use ieee.std_logic_1164.all;
4
 
5
  package core_types is
6
  type fetch_output_t is record
 
7
  instr: std_logic_vector(31 downto 0);
8
  end record fetch_output_t;
9
 
 
4
 
5
  package core_types is
6
  type fetch_output_t is record
7
+ is_active: std_logic;
8
  instr: std_logic_vector(31 downto 0);
9
  end record fetch_output_t;
10
 

We have to set the flag to one whenever we output an opcode.

src/core/fetch.vhd CHANGED
@@ -29,6 +29,8 @@ begin
29
  begin
30
  if rising_edge(clk) then
31
  pc <= pc + 4;
 
 
32
  output.instr <= imem(to_integer(pc(5 downto 2)));
33
  end if;
34
  end process;
 
29
  begin
30
  if rising_edge(clk) then
31
  pc <= pc + 4;
32
+
33
+ output.is_active <= '1';
34
  output.instr <= imem(to_integer(pc(5 downto 2)));
35
  end if;
36
  end process;

Now, let's simulate the waveforms again to verify that the issue is fixed:

Simulation waveforms

This looks good; the first time the is_active is 1, the opcode field is 00000001. The output.opcode field is still lagging behind pc one cycle, but as noted, this is not a problem.