Nikhil Kaniyeri

Running your own programs on the PicoRV32


PicoRV32 is an open source core that implements the RISC-V RV32IMC Instruction Set. Here’s how to use the core to run your own programs.

This is the structure of the repository:

$ tree -L 1
.
├── dhrystone
├── dotfiles
├── firmware
├── iverilog
├── Makefile
├── obj_dir
├── picoramsoc-master
├── picorv32.core
├── picorv32.v
├── picosoc
├── README.md
├── scripts
├── self
├── shell.nix
├── showtrace.py
├── testbench.cc
├── testbench_ez.v
├── testbench.v
├── testbench.vvp
├── testbench_wb.v
├── tests
└── yosys

Run make test just to make sure you have all the prerequisites installed. (It’ll run the testbench.v testbench and output TESTS PASSED if everything is in order.)

The version of iverilog on the debian apt repository is old (10.3). Build it from source by following the instructions on the iverilog repository.

Makefile and Compilation

The makefile provides the method in which the files are compiled and arranged. Highlighting the important sections:

FIRMWARE_OBJS = firmware/start.o firmware/irq.o firmware/print.o firmware/sieve.o firmware/multest.o firmware/stats.o



test: testbench.vvp firmware/firmware.hex
	vvp -N $<



firmware/firmware.hex: firmware/firmware.bin firmware/makehex.py
	python3 firmware/makehex.py $< 16384 > $@

firmware/firmware.bin: firmware/firmware.elf
	$(TOOLCHAIN_PREFIX)objcopy -O binary $< $@
	chmod -x $@

firmware/firmware.elf: $(FIRMWARE_OBJS) $(TEST_OBJS) firmware/sections.lds 
	$(TOOLCHAIN_PREFIX)gcc -Os -ffreestanding -nostdlib -o $@ \
		-Wl,-Bstatic,-T,firmware/sections.lds,-Map,firmware/firmware.map,--strip-debug \
		$(FIRMWARE_OBJS) $(TEST_OBJS) -lgcc
	chmod -x $@

firmware/start.o: firmware/start.S
	$(TOOLCHAIN_PREFIX)gcc -c -march=rv32im$(subst C,c,$(COMPRESSED_ISA)) -o $@ $<

firmware/%.o: firmware/%.c
	$(TOOLCHAIN_PREFIX)gcc -c -march=rv32i$(subst C,c,$(COMPRESSED_ISA)) -Os --std=c99 $(GCC_WARNS) -ffreestanding -nostdlib -o $@ $<
    

The object files are built first from the source files using the RV32I toolchain. The files are linked with a custom linker file sections.lds in the firmware folder.

SECTIONS {
	.memory : {
		. = 0x000000;
		start*(.text);
		*(.text);
		*(*);
		end = .;
	} > mem
}

Linker files tell the compiler how to place the various objects in the executable. Here . = 0x0 says we start at memory location 0x0 (not necessary, since it is predefined to be 0x0 anyway). start* matches any files with names starting with start, thereby including start.o (.text section) as the first file. Next, it includes the .text section of any file, and then includes any other file left.

From here, firmware.bin is generated by using the objcopy tool. It generates a memory dump that does not have any section headers or other such information.

makehex.py is a utility script included with the PicoRV32 repository. It arranges the generated .bin file in a way that can be copied directly into a memory used in the core. The generated output file firmware.hex can be directly read in a compatible memory by using the $readmemh command. More on that later.

Hardware Interface functions

Let us analyse print.c to check how the processes defined in the high level language trickle down in the hardware. Eventually, if you define your own peripherals to interface with the core, you’ll need this information to write a driver for your required functioning.

#define OUTPORT 0x10000000

void print_chr(char ch)
{
	*((volatile uint32_t*)OUTPORT) = ch;
}

void print_str(const char *p)
{
	while (*p != 0)
		*((volatile uint32_t*)OUTPORT) = *(p++);
}

The source has a defined memory location 0x10000000 as OUTPORT. The implemented functions write into the memory location. But writing to a memory location surely isn’t enough, there must be something more on the hardware side/simulation framework that writes the data onto the console.

Let us look at the assembly to figure out what happens when the above C functions are called. The RV32I toolchain also includes gdb for RISC-V that can be used for this:

$ /opt/riscv32i/bin/riscv32-unknown-elf-gdb firmware/firmware.elf
GNU gdb (GDB) 8.2.50.20181127-git
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "--host=x86_64-pc-linux-gnu --target=riscv32-unknown-elf".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from firmware/firmware.elf...
(No debugging symbols found in firmware/firmware.elf)

We can use info functions to see what we can disassemble:

(gdb) info functions
All defined functions:

Non-debugging symbols:
0x00000000  reset_vec
0x00000010  irq_vec
0x00000160  irq_regs
0x000003e0  irq_stack
0x000003e0  start
0x000004a2  hard_mul
0x000004a8  hard_mulh
0x000004ae  hard_mulhsu
0x000004b4  hard_mulhu
0x000004c8  irq
0x00000780  print_chr
0x00000788  print_str
0x0000079a  print_dec
0x000007e6  print_hex
0x0000080e  stats_print_dec
0x0000089c  stats
0x0000091e  add1
0x00000984  __divsi3
0x0000098c  __udivsi3
0x000009d4  __umodsi3
--Type <RET> for more, q to quit, c to continue without paging--q

Let us try disassembling the functions:

(gdb) disas print_chr
Dump of assembler code for function print_chr:
   0x00000780 <+0>:	lui	a5,0x10000
   0x00000784 <+4>:	sw	a0,0(a5)
   0x00000786 <+6>:	ret
End of assembler dump.

(gdb) disas print_str
Dump of assembler code for function print_str:
   0x00000788 <+0>:	lui	a4,0x10000
   0x0000078c <+4>:	lbu	a5,0(a0)
   0x00000790 <+8>:	bnez	a5,0x794 <print_str+12>
   0x00000792 <+10>:	ret
   0x00000794 <+12>:	addi	a0,a0,1
   0x00000796 <+14>:	sw	a5,0(a4)
   0x00000798 <+16>:	j	0x78c <print_str+4>
End of assembler dump.

We see that the instruction loads the location 0x10000000 into a register by using the lui instruction. The sw instruction reads the lower 4 bytes of your source reg and stores them into the memory location in a5.

From the RISC-V ISA documentation:

LUI (load upper immediate) is used to build 32-bit constants and uses the U-type format. LUI places the U-immediate value in the top 20 bits of the destination register rd, filling in the lowest 12 bits with zeros.

How does it get displayed on the console?

Looking at testbench.v and searching for special memory locations, we find:

	task handle_axi_bvalid; begin
		if (verbose)
			$display("WR: ADDR=%08x DATA=%08x STRB=%04b", latched_waddr, latched_wdata, latched_wstrb);
		if (latched_waddr < 64*1024) begin
			if (latched_wstrb[0]) memory[latched_waddr >> 2][ 7: 0] <= latched_wdata[ 7: 0];
			if (latched_wstrb[1]) memory[latched_waddr >> 2][15: 8] <= latched_wdata[15: 8];
			if (latched_wstrb[2]) memory[latched_waddr >> 2][23:16] <= latched_wdata[23:16];
			if (latched_wstrb[3]) memory[latched_waddr >> 2][31:24] <= latched_wdata[31:24];
		end else
		if (latched_waddr == 32'h1000_0000) begin
			if (verbose) begin
				if (32 <= latched_wdata && latched_wdata < 128)
					$display("OUT: '%c'", latched_wdata[7:0]);
				else
					$display("OUT: %3d", latched_wdata);
			end else begin
				$write("%c", latched_wdata[7:0]);

The testbench checks if the current memory location being latched is the special one that we previously defined. If so, it writes lower byte onto the console. This way, after execution, we get the text on stdout.

Writing your own programs

Now that we know how we can interact with the core, we can now write programs for it. Here’s a simple program without any custom peripherals that uses the available functions to compute something and print to the screen.

#include "firmware.h"
#include <stdint.h>
uint32_t add1(void) {
    uint32_t a =3;
    uint32_t b =4;
    uint32_t c=0;
    c = a +b;
    print_dec(c);  print_str("\n");
    print_dec(a);  print_str("\n");
    print_dec(b);  print_str("\n");
    print_dec((unsigned int)&a);  print_str("\n");
    print_dec((unsigned int )&b); print_str("\n");
    print_dec((unsigned int )&c); print_str("\n");
    return c;
}

void main(){
print_dec(add1());
return;
}

7
3
4
65508
65512
65516

This simple program adds two numbers and displays the result on the screen, and shows the memory locations for the variables. It is seen that the memory locations decrement, as the stack grows in size.


This article was written as a reference to myself, since I could not find enough resources online that could explain this to me quickly. Hope you find this useful. Feel free to send your questions/suggestions/error corrections to kaniyeri at pm.me.