Debugging Your Hardware

There are a lot of things that can go wrong when building custom hardware. In the software world, tooling builds in debugging support automatically until you explicitly turn it all off. We don’t have that luxury in hardware so we must do it ourselves.

You have already seen testbenches, which are an important tool for testing the functional correctness of your code. However they can only test things at the software abstraction level. They can’t determine issues that come from your use of directives, or from the interfacing between the Arm CPUs and the custom hardware.

Debug Interfaces

Let’s imagine the following custom hardware IP core:

uint32 toplevel(uint32 *ram) { #pragma HLS INTERFACE m_axi port=ram offset=slave bundle=MAXI #pragma HLS INTERFACE s_axilite port=return bundle=AXILiteS ram[2] = ram[1] + ram[0]; return 0; }

This hardware simply adds two number in main memory together and stores the result in a third. If we run this and nothing happens, how do we know what is wrong? There are (at least) the following problems that we need to isolate in order to debug this:

  • Your code

  • Some problem in the tooling - a corrupted project etc.

  • You forgot to rebuild and reexport the hardware

  • The AXI Slave interface (IP core to CPU)

  • The AXI Master interface (IP core to main memory)

  • and probably a few other things

We can make the situation better by building in debug interfaces. Look at the following small modification.

uint32 toplevel(uint32 *ram, int version) { #pragma HLS INTERFACE m_axi port=ram offset=slave bundle=MAXI #pragma HLS INTERFACE s_axilite port=return bundle=AXILiteS #pragma HLS INTERFACE s_axilite port=version bundle=AXILiteS version = 42; ram[2] = ram[1] + ram[0]; return 0; }

We’ve added a version parameter and bundled it into the AXI Slave interface. Now from the Arm CPU we can use the generated API call (which will be called XToplevel_Get_version()) to read this, and if we get 42 then we know that our projects are set up correctly and that we have wired up the slave interface. We can also at any point change this value, rebuild everything, and reassure ourselves that we are running the latest version of everything.

Debug Modes

We can go further and add full debugging modes into our hardware.

uint32 toplevel(uint32 *ram, int version, int mode, int arg1, int arg2) { #pragma HLS INTERFACE m_axi port=ram offset=slave bundle=MAXI #pragma HLS INTERFACE s_axilite port=return bundle=AXILiteS #pragma HLS INTERFACE s_axilite port=version bundle=AXILiteS #pragma HLS INTERFACE s_axilite port=mode bundle=AXILiteS #pragma HLS INTERFACE s_axilite port=arg1 bundle=AXILiteS #pragma HLS INTERFACE s_axilite port=arg2 bundle=AXILiteS version = 42; switch(mode) { //Do our normal thing case 0: ram[2] = ram[1] + ram[0]; return 0; //Simple AXI Slave test case 1: return arg1 + arg2; //AXI Master read tests case 2: return ram[0]; case 3: return ram[arg1]; //AXI Master write tests case 4: ram[arg1] = arg2; return 1; } }

Here, we have added alternate execution modes that let us test different bits of functionality, thereby narrowing down the problem. By setting mode and the debug parameters arg1 and arg2 we can peek and poke main memory and thereby observe the effects from the CPU. If mode 1 is working, but mode 2, 3 and 4 aren’t, then maybe we wired up the slave interface correctly but not the master interface.

You can carry this concept forward into your actual hardware design. Instead of treating it as a single monolithic block, alternate execution modes and debugging registers can help a lot with figuring out why something isn’t doing what you expect.