If you’ve never built your own logic scope to “see” what’s going on within an FPGA, it’s not really that hard to do. There are a couple of steps required to do it, but not really all that more.

Over time, I’ve built several logic scopes–each for a different environment that I’ve been running in. Somewhere around rebuilding the wheel for the third time, and for a different customer each time, I decided to build my own–so that I could have a scope that I legally owned and controlled, and that I could bring with me to any new FPGA job.

Here in this post, we’ll walk through all the steps of building a basic logic scope. We’ll reserve until later how to go about turning this scope’s results into a VCD file for GTKWave viewing–but only because I was using the scope successfully for many years before I ever even discovered GTKWave.

How it works

Before we get into the Verilog code necessary to build one of these things, let’s discuss what we want it to do.

We’d like three capabilities from our scope. First, whenever something goes wrong within our design, we’d like to be able to look back into the past and see what happened, as in Fig. 1.

Fig. 1: Looking back into the past
A scope that looks back in time

Of course, this ability to look back in time will be limited by the size of our scope’s memory.

We’d also like the ability to debug an interaction that we are in the process of designing. For example, if you want to read from flash memory, it might be valid to look at the flash data lines together with some of your internal flash control state. In this case, you would want to start capturing at the time of your read request. This capability would look something like Fig 2.

Fig. 2: After the trigger
Using a scope to see what's happening now

Continuing the our flash controller example, what if you wanted to know what happened when the flash finished its program or erase cycle? You’d then want to know what was happening some distance from your trigger, often a known amount of time later in relation to the trigger. In that case, what you want will look something like Fig 3.

Fig. 3: A future event
Examining logic lines some time in the future

We can handle all three of these needs by counting a programmable number of clock cycles from the scope’s trigger until we stop recording (Fig 4.)

Fig. 4: Trigger Logic
How the holdoff works

There remains one other feature we are going to need and that is that we will need to ensure our memory is initialized before we ever stop recording. In particular, you really don’t want to find yourself chasing some “bug” in your design, something that doesn’t make any sense, only to find out later that the data you were reading from the scope wasn’t valid. For this reason, we need make sure all of the scope’s memory is initialized before it can be triggered.

This leads to the rough timing diagram shown in Fig 5.

Fig. 5: Scope timing
The stages of a scope's capture

In particular, following any scope reset, we’ll insist on setting all of the values in memory before permitting the scope to be triggered.

Okay, now that we know what we want to do, let’s look into how to do it.

Filling the scope

The first step to building a scope is to load your data into the scope. There are two parts to this. The first is keeping track of the address you are writing within your block RAM, and the second part is actually writing to that memory. Both parts are quite simple.

We’ll use a register, waddr to contain the address we wish to write to. We’ll insist that this address start at zero only so we can tell if the scope’s memory was ever fully initialized. This address will quietly wrap around the limits of the memory, whatever those limits might be, as we write. We’ll also increment this address every time we receive a valid data sample until the scope stops.

always @(posedge i_clk)
	if (i_reset)
		waddr <= 0;
	else if ((i_ce)&&(!stopped))
		waddr <= waddr + 1'b1;

Writing to memory is even simpler: On any clock when we’ve got valid data, we write to the scope’s memory–up until the point where we stop the scope.

always @(posedge i_clk)
	if ((i_ce)&&(!stopped))
		mem[waddr] <= i_data;

If you’ve never used block RAM within an FPGA before, the various synthesis tools can be finicky. In general, if you have too much logic within the same always block as the memory write, they won’t recognize the block RAM. That’s why we keep our memory write logic simple. (Ref [1])

We also write to our memory on every clock up until we are stopped. This is how we guarantee that, when we get a trigger, we can have a valid capture of whatever it was that lead up to the trigger.

The final piece to our scope’s write logic is the detection of when the scope was filled. Since we started the write address at zero, we’ll know we have filled the scope on the same clock that we both write to it, and write to it with the address equal to all ones.

always @(posedge i_clk)
	if (i_reset)
		primed <= 1'b0;
	else if ((i_ce)&&(!primed))
		primed <= &waddr;

With this simple logic, you’ve now created your first scope. Now let’s look at how to stop it, and then how to get the results out of it.

Triggering the scope

A quick examination of Fig. 4 above should make plain what we wish to do.

First, from reset until we’ve been triggered, the scope runs and records normally. Once we receive a trigger, and only then, do we move on to the next step. Hence, we need to know when we’ve been triggered.

always @(posedge i_clk)
	if (i_reset)
		triggered <= 1'b0;
	else if ((i_trigger)&&(primed))
		triggered <= 1'b1;

You might notice that this trigger doesn’t depend upon i_ce, even though all of the data reads and writes do. This was done on purpose so that any trigger that takes place between i_ce’s will still get noticed by the scope.

The next step is to count some programmable number of clocks from the trigger until we stop. We’ll call this counter the holdoff_counter, and we’ll start it counting as soon as we are triggered.

always @(posedge i_ck)
	if ((i_reset)||(!triggered))
		holdoff_counter <= 0;
	else if (i_ce)
		holdoff_counter <= holdoff_counter + 1'b1;

Once the holdoff counter reaches the user holdoff, we then set a flag to indicate we have stopped.

always @(posedge i_ck)
	if ((i_reset)||(!triggered))
		stopped <= 0;
	else if ((i_ce)&&(!stopped))
		stopped <= (holdoff_counter >= user_holdoff);

The only tricky part about all of these controls is lining them all up so that the last data sample is the sample that produced the trigger. For now, we’ll leave as an exercise for the student.

The last step will be to read the results out of your local wishbone bus.

Reading the results

When it comes to reading results, there are just a couple of tricks required. Most of these are involved with unwrapping the RAM memory, and the rest of what is required is straight forward.

Remember how we just wrote to successive memory locations until we stopped? We’re going to need to unwrap all of that.

The first step is to calculate the offset we need into the memory array.

always @(posedge i_clk)
	if (i_reset)
		raddr <= 0;
	else if ((i_wb_stb)&&(!i_wb_we)&&(i_wb_addr))
		raddr <= raddr + 1'b1;

We’ll then add this offset to the write address to get the address we need from the scope’s memory. The trick is that this operation has now taken an extra clock. If we want to support a throughput of one read per bus clock, we’re going to need to add one extra unit to keep up with the bus clock. In other words, we’ll pre-compute the next read address if we are in the middle of a read, but otherwise have only the offset plus the last write address.

Will this address calculation wrap around the end of the memory back to the beginning? Most definitely. We are counting upon that.

always @(posedge i_clk)
	if ((i_wb_stb)&&(!i_wb_we)&&(i_wb_addr))
		this_addr <= raddr + waddr + 1'b1;
	else
		this_addr <= raddr + waddr;
end

Now that we have our read address, referenced from the beginning of memory rather than from the beginning of of the capture we are reporting, we can now read from the scope’s memory.

always @(posedge i_clk)
	data <= mem[this_addr];

As you examine this, remember that block RAM logic is special, whether read or write–it must fit the form of what the synthesizer is expecting, or the memory will be implemented in logic instead of RAM. It is for that reason that we keep our memory read as simple as possible and don’t try to merge it with the output case statement below.

The final step is to place this value onto the bus. We hold onto our address for one clock, so that we can align our data read with the bus request, and make certain that we are always returning the right value on the right clock.

always @(posedge i_clk)
	last_bus_addr <= i_wb_addr;

We do the same thing for our acknowledgement line, delaying it for one clock, to give us time to actually read from the bus.

always @(posedge i_clk)
	pre_ack <= i_wb_stb;
always @(posedge i_clk)
	o_wb_ack <= pre_ack;

Otherwise reading from this scope is just as simple as reading from any address from any other simple peripheral.

always @(posedge i_clk)
	case(last_bus_addr)
	1'b0: o_wb_data <= { 1'b0, stopped, triggered, 1'b0,
			3'h0, LGMEMLEN, user_holdoff };

	1'b1: o_wb_data <= data;
	endcase

That’s it! There’s not all that much to it. A scope requires writing to memory, stopping based upon some trigger, and then reading the data back out later. You’ve now seen all of these pieces.

ZipCPU’s Wishbone Scope

Now that you know how one of these scopes works, and even better how to build your own, you might wish to check out my own version of a wishbone scope.

There are a couple of differences between that my own wishbone scope, name wbscope and found on GitHub, and the scope we’ve just outlined above, although not that many. Here’s a quick list of differences:

  • wbscope allows you to use two asynchronous clocks, if you need them: one for the data and another for the bus.

    I needed this capability when testing the RMII ethernet controller within the OpenArty project for the Arty board.

  • Data capture within our wishbone scope has been carefully calibrated so that a hold off of zero will place the trigger in exactly the last location in memory.

  • The wishbone scope in the repo adds a couple extra bus directed capabilities. These include

    o an interrupt output for an on-board CPU,

    o the ability to manually disable the scope’s trigger, or even

    o the ability to manually trigger the scope from the bus independent of the trigger wired into the scope.

  • I’ve also built a compressed version of the scope that applies run length encoding to the data, to allow recording events over a long period of time.

Further, there’s even a AXI-lite version of the scope, although since I don’t use the AXI bus, that version has not been tested very much–so I can’t assure you that it works.

Next Steps

We’ll still queue up for later how to actually create a wishbone bus master that can be commanded from from a local host PC, as well as instructions for how to turn these results into a VCD file that you can view with GTKWave.