Beginning Logic Design – Part 8

Hello and welcome to Part 8 of my Beginning Logic Design series. In this episode I will be implementing a system bus to allow one master component to communicate with many other components.

Building a Bus

The style of bus I will be implementing is quite common. There will be a few control signals and a 16-bit address bus controlled by the master. There will also be a bidirectional 8-bit data bus that is used for the master to write to and read from to the slave components.

System Bus graphic from Wikipedia

In my example, the control bus will be used to provide a clock to the slave devices as well as some signals to indicate if a read or write action is being performed. The address bus is how a slave device is selected, a slave may listen to a single address or a range of addresses. The data bus is used for the actual data being read or written.

For this design, I will have a RAM implementation listening to any address that starts with 0, and a second component I will call a writer that will, in simulation, print out the data written to any address that starts with 1. Based on this design, each will own half of the address space.

I’ll first create the rough outline of these modules and a top module to tie it all together.

First the master.sv definition.

`timescale 1ns / 1ps

module master (
  input logic clock,
  output logic read,
  output logic write,
  output logic [15:0] address_bus,
  inout logic [7:0] data_bus
);

endmodule

The master drives most of the bus signals, controlling the operations. The data_bus is declared as an inout so that it can be used as an input or as an output.

Next the ram and writer modules.

`timescale 1ns / 1ps

module ram (
  input logic clock,
  input logic read,
  input logic write,
  input logic [15:0] address_bus,
  inout logic [7:0] data_bus
);

endmodule
`timescale 1ns / 1ps

module writer (
  input logic clock,
  input logic read,
  input logic write,
  input logic [15:0] address_bus,
  inout logic [7:0] data_bus
);

endmodule

These modules start off with a nearly identical design. Lastly the top module to pull these together.

`timescale 1ns / 1ps

module top ();
  logic clock;

  // System Bus
  logic slave_clock;
  logic read;
  logic write;
  logic [15:0] address_bus;
  wire logic [7:0] data_bus;

  assign slave_clock = ~clock;

  master controller (
    clock,
    read,
    write,
    address_bus,
    data_bus
  );

  ram memory (
    slave_clock,
    read,
    write,
    address_bus,
    data_bus
  );

  writer io (
    slave_clock,
    read,
    write,
    address_bus,
    data_bus
  );

  initial clock = 0;

  always begin
    #1 clock = ~clock;
  end

endmodule

This top module declares the variable that are used to refer to the bus components. I will have the master follow the main clock but have the slaves follow the inverse of that clock, slave_clock. For the data_bus I need to add the wire keyword to explicitly describe it as a network shared by multiple components.



Writer Module

The writer module here will only be useful for read operations, so I will build that out first and test its usage from the master.

It’s a relatively simple module, every clock cycle the module will look to see if the highest address bit is 1 and the write control signal is high, if so it’ll display the character in the simulation output.

`timescale 1ns / 1ps

module writer (
  input logic clock,
  input logic read,
  input logic write,
  input logic [15:0] address_bus,
  inout logic [7:0] data_bus
);

  always_ff @ (posedge clock) begin
    if (write && address_bus[15] == 1) begin
      $display("%d: %c", data_bus, data_bus);
    end
  end

endmodule

Writing to the bidirectional data_bus is a bit trickier than writing to an ordinary register variable. We will need to use a register as a scratch pad to hold the write data, and use a conditional assign to put that registers data on the data_bus during the right condition (when write is 1).

logic [7:0] write_data;

assign data_bus = (write) ? write_data : 'bZ;

This assign statement will put the data from write_data into data_bus when write is 1. When write is 0, it will assign the Z (high impedance) state, which allows the signal to be driven elsewhere.

With that in place to let my master write to the data_bus, I’ll use an initial block to perform a series of write operations.

`timescale 1ns / 1ps

module master (
  input logic clock,
  output logic read,
  output logic write,
  output logic [15:0] address_bus,
  inout logic [7:0] data_bus
);

  logic [7:0] write_data;

  assign data_bus = (write) ? write_data : 'bZ;

  initial begin
    read <= 0;
    write <= 0;
    address_bus <= 16'b1000_0000_0000_0000;
    #1 write_data <= "H";
    write <= 1;
    #2 write_data <= "E";
    #2 write_data <= "L";
    #4 write_data <= "O";
    #2 write <= 0;
    #2 $finish();
  end

endmodule

As a note the underscores in my address write do not have an effect on the value, I put them there to more easily identify the 16 bits.

When I run this in the simulator, I get exactly the output I expected.

Vivado Simulator 2017.2
Time resolution is 1 ps
run -all
 72: H
 69: E
 76: L
 76: L
 79: O
$finish called at time : 13 ns : File "/home/kwilke/suchprogramming/systembus/master.sv" Line 25
exit

Implementing RAM

The RAM is a little bit trickier, but still just a few lines of SystemVerilog. The first thing needed is the space for storing data itself. We can use an array for this.

logic [7:0] memory [0:(1<<15)-1];

This is declaring an array of 8 bit registers, since we’re using the first address bit to point to memory, this leaves 15 bits for my ram module to interpret as it pleases. So I declare the array to contain 2^15 elements with [0:(1<<15)-1].

To write to this space, it’s nearly identical to how we read from the data bus for the writer module. A difference this time is that we’re also looking at the rest of the address_bus to specify which location in RAM we want this data.

always_ff @ (posedge clock) begin
  if (address_bus[15] == 0 && write) begin
    memory[address_bus[14:0]] <= data_bus;
  end
end

For reading, I’ll need a conditional assignment similar to how the master module is writing (since from the ram module perspective, a read IS a write. In this case I’ll need to consider the most significant address bit in my condition. I’ll also need to consider the lower 15 bits of the address_bus in the cases where the read condition is true.

assign data_bus = (read && address_bus[15] == 0) ? memory[address_bus[14:0]] : 'bZ;

With that in place, the RAM module should be functional.

`timescale 1ns / 1ps

module ram (
  input logic clock,
  input logic read,
  input logic write,
  input logic [15:0] address_bus,
  inout logic [7:0] data_bus
);

  assign data_bus = (read && address_bus[15] == 0) ? memory[address_bus[14:0]] : 'bZ;

  logic [7:0] memory [0:(1<<15)-1];

  always_ff @ (posedge clock) begin
    if (address_bus[15] == 0 && write) begin
      memory[address_bus[14:0]] <= data_bus;
    end
  end


endmodule

Moar Testing

With my RAM module in place I am ready to test to make sure that reads and writes can work from that as well. To test this I’d like to first write a few characters to store in RAM, then copy what’s in the RAM to the writer.

It sounds complicated, but it’ll be easy! First I’ll restructure my master to write my HELLO message to the ram module. To separate out how I control my address bus, I’ll separate out the variables I use to set the address_bus bits.

logic device; // 0 = ram; 1 = writer
logic [14:0] address;

assign address_bus = {device, address};

Then I’ll change my initial block so that I write a byte, and on the next few cycles I increment the address and write the next byte.

initial begin
  read <= 0;
  write <= 0;
  device <= 0;
  address <= 0;

  #1 write_data <= "H";
  write <= 1;

  #2 address++;
  write_data <= "E";

  #2 address++;
  write_data <= "L";

  #2 address++;

  #2 address++;
  write_data <= "O";

  #2 write <= 0;

  #2 $finish();
end

To verify this is working as I expect, and visualize what’s in my RAM, I’ll need to run my xelab command with the options -debug all so I can monitor all the signals, then launch the simulation using xsim -g top so that it loads the Vivado GUI and doesn’t automatically run the simulation.

To open the waveform viewer, you can go to Window -> Waveform. I’ll step 1ns at a time to see what my design is doing. By clicking on memory from my Scope tab, and expanding memory in the Objects tab I can see each memory location.

I’ll change the radix of memory to be ASCII so I can see the character values more easily, and step through the rest of the simulation.

It looks good to go!

Now to implement the copy, I’ll use a for loop after I’ve written my bytes to ram to read one byte and write the returned data back to my writer, looping 5 times.

// Copy 5 bytes from ram to writer
for (int i = 0; i < 5; i++) begin
  // Read byte from ram
  #2 device <= 0;
  address <= i;
  read <= 1;
  write <= 0;
  // Store byte and write to writer
  #2 write_data <= data_bus;
  device <= 1;
  read <= 0;
  write <= 1;
end

Testing again in the simulator I can verify it writes the data to RAM, then proceeds through my loop to read each byte and send it to the writer!

Bus getting busy

And that does it for this post! I have a working bus system that can allow a single component a means to interface with multiple devices through a shared bus. Please leave any feedback or questions you may have in the comments. Keep tinkering!

One thought on “Beginning Logic Design – Part 8”

Leave a Reply

Your email address will not be published. Required fields are marked *