Deploying your own CPU
This example describes how to integrate a PicoRV32 CPU using an Ez shell
In this example we are going to integrate an existing design: the PicoRV32 CPU designed by Claire Xenia Wolf.

Creating the project

The first step is to create a new Sabana project and to add the required interface to service the CPI. For that we will use the Sabana CLI tool:
  • We will use the Verilog flow
  • We will select start from scratch rather than using an example
  • We will add the interface required for the CPU: a single RAM
Start the process with Sabana new:
sabana new rtl_ez_picorv32_128k
Select the Verilog flow:
Welcome to sabana, let's create a project
? Select a language β€Ί
❯ Verilog
Cpp
Cancel
Select the Ez shells:
Welcome to sabana, let's create a project
βœ” Select a language Β· Verilog
? Select shell type β€Ί
❯ Ez (a highly productive shell)
AXI4
Now select the Start from scratch flow so we can add a single Ez RAM interface:
Welcome to sabana, let's create a project
βœ” Select a language Β· Verilog
βœ” Select shell type Β· Ez (a highly productive shell)
? Select a starting point β€Ί
Add two numpy arrays (three registers)
Add two numpy arrays (three queues)
Add constant to numpy array (single ram)
Add constant to numpy array (two rams)
❯ Start from scratch
Select Ram:
Welcome to sabana, let's create a project
βœ” Select a language Β· Verilog
βœ” Select shell type Β· Ez (a highly productive shell)
βœ” Select a starting point Β· Start from scratch
? Resource type β€Ί
Register
Queue
❯ Ram
Done adding resources
Give this interface a name, we will use a:
Welcome to sabana, let's create a project
βœ” Select a language Β· Verilog
βœ” Select shell type Β· Ez (a highly productive shell)
βœ” Select a starting point Β· Start from scratch
βœ” Resource type Β· Ram
? Name β€Ί a
Select Input/Output so the CPU can read from and write to the RAM:
Welcome to sabana, let's create a project
βœ” Select a language Β· Verilog
βœ” Select shell type Β· Ez (a highly productive shell)
βœ” Select a starting point Β· Start from scratch
βœ” Resource type Β· Ram
βœ” Name Β· a
? I/O type β€Ί
Input
Output
❯ Input/Output
Select 128KB for the size:
Welcome to sabana, let's create a project
βœ” Select a language Β· Verilog
βœ” Select shell type Β· Ez (a highly productive shell)
βœ” Select a starting point Β· Start from scratch
βœ” Resource type Β· Ram
βœ” Name Β· a
βœ” I/O type Β· Input/Output
? Size β€Ί
128B
❯ 128KB
Select Done adding resources to finish creating the project:
Welcome to sabana, let's create a project
βœ” Select a language Β· Verilog
βœ” Select shell type Β· Ez (a highly productive shell)
βœ” Select a starting point Β· Start from scratch
βœ” Resource type Β· Ram
βœ” Name Β· a
βœ” I/O type Β· Input/Output
βœ” Size Β· 128KB
? Resource type β€Ί
Register
Queue
Ram
❯ Done adding resources
Your shell should look like this now:
Welcome to sabana, let's create a project
βœ” Select a language Β· Verilog
βœ” Select shell type Β· Ez (a highly productive shell)
βœ” Select a starting point Β· Start from scratch
βœ” Resource type Β· Ram
βœ” Name Β· a
βœ” I/O type Β· Input/Output
βœ” Size Β· 128KB
βœ” Resource type Β· Done adding resources
We should now have a project with the following structure:
.
β”œβ”€β”€ sabana.json
β”œβ”€β”€ src
β”‚Β Β  └── sabana.v
└── tests
└── test_rtl_ez_picorv32_128k.py

Integrate source files

With the base project in place let's now add the source file for the CPU.
Conveniently the PicoRV32 is completely contained within a single file of Verilog. Download the picorv32.v file from the PicoRV32 repository in GitHub and place it within the src folder.
The PicoRV32 has got a series of signals and parameters at its top level, however we only need to focus on some of them. Bellow is a snippet of code with the module instantiation we will use within the sabana.v project for the purpose of integrating the CPU within the shell. We will place this under the your code here marker we get provided with on the file:
// your code here
picorv32 #(
.ENABLE_MUL (1),
.ENABLE_FAST_MUL (1)
) picorv32_core (
.clk (clock),
.resetn (picorv32_nreset),
.trap (trap),
​
.mem_valid(mem_valid),
.mem_addr (mem_addr ),
.mem_wdata(mem_wdata),
.mem_wstrb(mem_wstrb),
.mem_instr(mem_instr),
.mem_ready(mem_ready),
.mem_rdata(mem_rdata),
​
.pcpi_valid(),
.pcpi_insn (),
.pcpi_rs1 (),
.pcpi_rs2 (),
.pcpi_wr (1'b0),
.pcpi_rd (32'b0),
.pcpi_wait (1'b0),
.pcpi_ready(1'b0),
.irq(32'd0),
.eoi(),
​
.trace_valid(),
.trace_data ()
);

Adaptation logic

As you may imagine, just instantiating the module is not enough. There is a bit of adaptation logic we need to write in order to interface the memory interface used by the PicoRV with the Ez RAM our shell provides. Here is a snippet of the ports of the Ez shell we just generated:
module sabana (
input logic clock,
input logic reset,
input logic start,
output logic finish,
input logic [32-1:0] a_in,
output logic [32-1:0] a_out,
output logic [15-1:0] a_addr,
output logic a_we
);
Below is a snippet of the declaration of the wires we will need to adapt the two interfaces, paste this before the PicoRV instantiation in the sabana.v file:
logic picorv32_nreset;
logic trap;
logic mem_valid;
logic [31:0] mem_addr;
logic [31:0] mem_wdata;
logic [ 3:0] mem_wstrb;
logic mem_ready;
logic [31:0] mem_rdata;
logic [31:0] timeout;
Below we have several snippets implementing different bits of adaptation logic. Paste them one after another, just under the instantiation of the PicoRV32 CPU in the sabana.v file.
Let's begin with the CPU control logic. We don't want the CPU to just run freely after reset. Below we implement a small state machine that uses the start input bit to let the CPU run. It also waits for the trap signal coming from the CPU to detect that it has reached the end of the program. We use this to generate the finish flag:
typedef enum logic [1:0] {
CPU_IDLE,
CPU_RUN,
CPU_DONE
} cpu_state_t;
cpu_state_t cpu_state, cpu_next;
​
// Very simple watchdog counter
always_ff @(posedge clock) begin
if (reset) begin
cpu_state <= CPU_IDLE;
timeout <= 0;
end else begin
cpu_state <= cpu_next;
timeout <= timeout + 1'b1;
end
end
​
// Controls for the CPU
always_comb begin
case(cpu_state)
CPU_IDLE: begin
if (start) begin
cpu_next = CPU_RUN;
end else begin
cpu_next = CPU_IDLE;
end
end
CPU_RUN: begin
if (trap) begin
cpu_next = CPU_DONE;
end else begin
cpu_next = CPU_RUN;
end
end
CPU_DONE: begin
cpu_next = CPU_IDLE;
end
default: begin
cpu_next = CPU_IDLE;
end
endcase
end
​
assign picorv32_nreset = cpu_state == CPU_RUN;
assign finish = cpu_state == CPU_DONE;
Next is the adaptation logic of the memory bus. Ez RAMs do not implement strobe flags, this is the reason why we have to implement a state machine that implements a read-modify-write operation whenever there is a partial write request:
typedef enum logic [1:0] {
MEM_IDLE,
MEM_READ,
MEM_WRITE
} mem_state_t;
mem_state_t mem_state, mem_next;
​
// Memory interface adaptater
always_ff @(posedge clock) begin
if (reset) begin
mem_state <= MEM_IDLE;
end else begin
mem_state <= mem_next;
end
end
​
always_comb begin
case(mem_state)
MEM_IDLE: begin
if (mem_valid) begin
if (mem_wstrb == 4'b1111) begin
mem_next = MEM_WRITE;
end else begin
mem_next = MEM_READ;
end
end else begin
mem_next = MEM_IDLE;
end
end
MEM_READ: begin
if (|mem_wstrb) begin
mem_next = MEM_WRITE;
end else begin
mem_next = MEM_IDLE;
end
end
MEM_WRITE: begin
mem_next = MEM_IDLE;
end
default: begin
mem_next = MEM_IDLE;
end
endcase
end
​
assign a_we = |mem_wstrb & mem_state == MEM_WRITE;
assign mem_ready = (mem_state == MEM_WRITE) | (mem_wstrb == 4'd0 & mem_state == MEM_READ);
assign a_addr = mem_addr[16:2];
assign mem_rdata = a_in;
​
genvar i;
generate
for (i = 0; i < 4; i = i + 1) begin
assign a_out[(i*8+7):(i*8)] = mem_wstrb[i] ? mem_wdata[(i*8+7):(i*8)] : a_in[(i*8+7):(i*8)];
end
endgenerate
After integrating the PicoRV32 and the adaptation logic within the sabana.v file we are now ready to build the image.

Building the image

Let's now use the push command to get the image built:
sabana push -d
We use -d to run the process in the background and get the terminal back. Whilst the project gets build let's continue by preparing the program to test our CPU.

Memory Map

Before we can deploy the image we first need to understand the memory map of the shell so that we can understand how to interact with it.
The project file (sabana.json) is the place to check for this information. Let's take a look at the shell section for the project we just created:
"shell": {
"mmio": {
"base_address": "0xa0000000",
"size": "0x00010000",
"values": [
{
"name": "control",
"offset": "0x0",
"data_type": "int32"
},
{
"name": "a_read_data",
"offset": "0x10",
"data_type": "int64"
},
{
"name": "a_read_length",
"offset": "0x1c",
"data_type": "int32"
},
{
"name": "a_write_data",
"offset": "0x24",
"data_type": "int64"
},
{
"name": "a_write_length",
"offset": "0x30",
"data_type": "int32"
}
]
},
"register": [],
"queue": [],
"ram": [
{
"name": "a",
"data_width": "32",
"length": "32768",
"state": "readwrite"
}
]
}
As you can see the MMIO section has a couple of registers, let's take a look at the purpose of each:
name
offset
purpose
control
0x0
Controls the hardware module
a_read_data
0x10
Address pointer to host memory
a_read_length
0x1C
Integer with length of buffer
a_write_data
0x24
Address pointer to host memory
a_write_length
0x30
Integer with length of buffer
As you can see we have two pairs of control registers for the buffer:
  • _read registers control the initial DMA transfer that copies data from the host's memory buffer to the RAM used by the PicoRV32.
  • _write registers control the final DMA transfer that copies data from the RAM used by the PicoRV32 back to the host's memory buffer.
For more details about the purpose of each register checkout the Ez RAMs guide.

Creating a program

With the memory map at hand we now have all the information we need in order to interact with an instance. We do this by creating a test program that will be submitted for execution by the instance.

RISC-V C program

The test program depends heavily on the C program executed by the PicoRV32. We need to adapt it depending on the parameters and data required. You can find several examples for different programs in the ez_rtl_picorv32_128k directory.
For this guide we will use a very simple one: A constant multiplication of two numbers. Let's take a look at the source code for this C program:
int main(int argc, char ** argv){
const int a = 3;
int *c = (int*)0x7FFC;
*c = a * 6;
return 0;
}
In this very simple C program we are just writing the result of multiplying 3 by 6 to a specific memory location.

Test program

Keeping the C program for the CPU in mind from above, the test program needs to execute the following steps:
  • Compile the C code using Sabana and generate the program binary
  • Allocate 2 Buffer memory spaces to copy in and out the contents of the CPU's RAM.
  • Write the transfer length to each of the length registers
  • Write the C program binary into the input buffer
  • Start the CPU
  • Wait for the processing to finish
  • Read the result from the predefined offset (0x7FFC) from the output buffer.
The following snippet shows you how to do the first step, to compile the C program using Sabana:
1
def create_main_c():
2
cwd = Path(__file__).resolve().parent.parent
3
mainc = str(cwd.joinpath("cc/main_mul_by_six.c"))
4
boots = str(cwd.joinpath("cc/boot.S"))
5
picld = str(cwd.joinpath("cc/picorv32.ld"))
6
​
7
build = Build(toolchain="riscv64-unknown-elf", version="10.2.0", verbose=True)
8
cflags = "-c -Qn --std=c99 -march=rv32im -mabi=ilp32"
9
build.cflags(cflags)
10
ldflags = "-Ofast -ffreestanding -nostdlib -lgcc -march=rv32im -mabi=ilp32"
11
build.ldflags(ldflags)
12
build.file(mainc, flags="-Ofast")
13
build.file(boots, flags="-Os")
14
build.file(picld, flags="-Bstatic -T")
15
​
16
result = build.compile()
17
​
18
return result["binarray"]
You will notice that we are using a few extra files:
  • boot.S: Assembly for CPU initialization
  • picorv32.ld: Linker script
You will have to add these to your project. You can find them in the finished example in our GitHub repository.
The snippet below shows you the rest of the steps from the pseudo-code above, translated into requests using the Program class:
1
def create_program(inputs):
2
riscv_mainc = inputs["main"]
3
​
4
# create inputs
5
dt = np.uint32
6
start = np.ones([1], dt)
7
finish = np.array([14], dt)
8
​
9
program = Program()
10
program.mmio_alloc(name="c0", size=0x10000, base_address=0xA0000000)
11
# allocate space for the whole 128K memory
12
program.buffer_alloc(name="in", size=131072, mmio_name="c0", mmio_offset=0x10)
13
program.buffer_alloc(name="out", size=131072, mmio_name="c0", mmio_offset=0x24)
14
# copy the full 128K memory.
15
program.mmio_write(np.array([32768], dtype=dt), name="c0", offset=0x1C)
16
program.mmio_write(np.array([32768], dtype=dt), name="c0", offset=0x30)
17
program.buffer_write(riscv_mainc, name="in", offset=0x0)
18
# start the CPU
19
program.mmio_write(start, name="c0", offset=0x0)
20
# wait for the CPU to finish
21
program.mmio_wait(finish, name="c0", offset=0x0, timeout=10)
22
program.buffer_read(name="out", offset=0x7FFC, dtype=dt, shape=(1,))
23
program.mmio_dealloc(name="c0")
24
program.buffer_dealloc(name="in")
25
program.buffer_dealloc(name="out")
26
return program

Automated deployment

Having created a program we now have a list of requests that we can submit for execution.
Let's take a look at the next section of the test script to see how this is done:
# compile RISC-V program
inputs = {"main": create_main_c()}
​
# create program
program = create_program(inputs)
​
# deploy instance
image_file = Path(__file__).resolve().parent.parent.joinpath("sabana.json")
inst = Instance(image_file=image_file, verbose=True)
inst.up()
​
# run program
responses = inst.execute(program)
​
# terminate instance
inst.down()
​
# compute expected
expected = 18
​
# check results
check_results(expected, responses)
First we call the support functions we defined above in order to generate the program object.
Then, as in previous examples, we use the Path library to build a safe path to the project file. We pass this path to the instance constructor. It will use the image information on the file to deploy the instance.
With an instance handler we now request the execution of the program we previously built. The execute function returns a list with responses for each of the read instructions we issued in the program.
Finally we check that the response from the CPU has the expected value.

Running the program

In the previous section we took a look at the test program generated together with the project example. Let's now run this script to check that the image has been correctly pushed to the platform:
python3 tests/test_rtl_ez_single_ram.py
The test script generates the inputs and also checks that the results are correct. The output of the script looks like this:
πŸŽ‰ program compilation successful
πŸŽ‰ robot/rtl_ez_picorv32_128k:0.1.0 is ready ➑ https://zorro.sabana.dev
πŸš€ executing program
βœ” https://zorro.sabana.dev is down
​
Multiplication of two constants was successfully
computed in a picorv32 deployed in Sabana!

GitHub

In case you need it this project is also committed to our examples repository in GitHub. You can get it on the rtl_ez_picorv32_128k project folder. In there you will also find three more C program examples with their respective test programs:
  • Element-wise vector multiplication
  • Matrix multiplication
  • String Print example

Next steps

Congratulations! you have now integrated your first CPU as a Sabana image!
In the following sections you will find useful information to help you on your projects.
Also don't forget to consult the reference pages for more information about Sabana:
Copy link
On this page
Creating the project
Integrate source files
Adaptation logic
Building the image
Memory Map
Creating a program
RISC-V C program
Test program
Automated deployment
Running the program
GitHub
Next steps