Busses consist of both bus masters, and bus slaves. Masters command which addresses need to be read from or written to, while slaves respond to the requests of the bus master(s). For this article, we’ll discuss the logic necessary to implement a very simple bus slave. Why? Because at the core of every memory mapped peripheral is just a touch of simple bus logic.

To understand this concept, let’s consider a very simple bus. Three things can only ever happen on this bus on any given clock cycle. 1) The vast majority of the time, the bus will be idle. 2) You might write to the bus. Or 3), you might read from the bus. On a simple bus, this type of interaction can easily be understood with just a few simple wires:

  • i_wr: True any time a write is requested. If true for two clocks in a row, then two writes should take place in a row.

  • i_rd: True any time a read is requested

  • i_addr: The address of the value to read/write

  • i_data: The data to be written on any write access

  • o_data: The data to be returned on any read access

A Simple Write Cycle

Using these values, writing to a memory might be as simple as:

always @(posedge i_clk)
	if (i_wr)
		memory[i_addr] <= i_data;

Writing to an arbitrary peripheral has much the same logic, but what it does is a little bit different:

always @(posedge i_clk)
	if (i_wr) case(i_addr)
	4'h0: config <= i_data;
	4'h1: begin end // A read only address
	4'h2: begin end // A read only address
	4'h3: begin
		transmitter_data <= i_data;
		transmitter_stb  <= 1'b1;
	default: begin end
	endcase else
		transmitter_stb <= 1'b0;

Specifically, writes to a peripheral specify some logic to be done for each possible address you might write to.

In this example, writing to address zero simply sets a configuration register. Writes to addresses one and two are ignored: these addresses reference read only registers. Finally, address three sets a strobe value indicating that some action is to take place, together with the data necessary for that action.

Indeed, from this perspective, the write cycle is pretty simple.

A Simple Read Cycle

The read cycle isn’t really that much more complicated either. For a simple memory, it would look like:

always @(posedge i_clk)
	o_data <= memory[i_addr];

Within a more generic peripheral, where reads might have side-effects, the read logic might look instead like:

always @(posedge i_clk)
	4'h0: o_data <= config;
	4'h1: begin o_data <= w_internal_values; advance_data_stb <= i_rd; end
	4'h2: o_data <= w_other_internal_values;
	// 4'h3: // A write only address
	default: o_data <= 0;
	endcase else
		advance_data_stb <= 1'b0;

You may notice that o_data gets set any time the address changes. What if nothing is being read? In this case, it’s not a problem. The right answer is being placed on the bus either way. However, if you have an input queue, such as in a read FIFO, you might wish to advance that queue on reads only. This is the idea behind the advance_data_stb logic above.

Shared data lines

Having separate input data lines, i_data, and output data lines, o_data, makes a lot of sense within an FPGA.

However, between components on a circuit board, cost may be measured in both area and the number of wires routed from one place to another.

For this reason, when communicating over an external bus, the simple bus we have been describing has one more step:

assign io_data = (i_rd) ? o_data : 32'bzzzz_..._zzzz;

This last step determines which of multiple digital pieces of logic drives the bus.

That’s it!

That’s all there is to working with a bus. You need to read the address, check if you are being read or written to, and return a value and act appropriately. At some level, every bus acts in this fashion. Hence, if you can understand how to interact with a bus like this, you’ve gone through the first step of ever interacting with any bus.