Skip to content

WabashOS/firebox

 
 

Repository files navigation

RISC-V Project Template

This is a starter template for your custom RISC-V project. It will allow you to leverage the Chisel HDL and RocketChip SoC generator to produce a RISC-V SoC with MMIO-mapped peripherals, DMA, and custom accelerators.

Getting started

Checking out the sources

After cloning this repo, you will need to initialize all of the submodules

git clone https://github.com/ucb-bar/project-template.git
cd project-template
git submodule update --init --recursive

Building the tools

The tools repo contains the cross-compiler toolchain, frontend server, and proxy kernel, which you will need in order to compile code to RISC-V instructions and run them on your design. There are detailed instructions at https://github.com/riscv/riscv-tools. But to get a basic installation, just the following steps are necessary.

# You may want to add the following two lines to your shell profile
export RISCV=/path/to/install/dir
export PATH=$RISCV/bin:$PATH

cd riscv-tools
./build.sh

Compiling and running the Verilator simulation

To compile the example design, run make in the "verisim" directory. This will elaborate the DefaultExampleConfig in the example project. It will produce an executable called simulator-example-DefaultExampleConfig. You can then use this executable to run any compatible RV64 code. For instance, to run one of the riscv-tools assembly tests.

./simulator-example-DefaultExampleConfig $RISCV/riscv64-unknown-elf/share/riscv-tests/isa/rv64ui-p-simple

If you later create your own project, you can use environment variables to build an alternate configuration.

make PROJECT=yourproject CONFIG=YourConfig
./simulator-yourproject-YourConfig ...

Submodules and Subdirectories

The submodules and subdirectories for the project template are organized as follows.

  • rocket-chip - contains code for the RocketChip generator and Chisel HDL
  • testchipip - contains the serial adapter and associated verilog and C++ code
  • verisim - directory in which Verilator simulations are compiled and run
  • vsim - directory in which Synopsys VCS simulations are compiled and run
  • bootrom - sources for the first-stage bootloader included in the Boot ROM
  • src/main/scala - scala source files for your project go here

Creating your own project

To create your own project, you should create your own scala package. Let's use the PWM package as an example. First you create a directory under src/main/scala that has the same name as your package.

mkdir src/main/scala/pwm

Now let's add a peripheral device to the new SoC. First, add a Chisel module for your device.

package pwm

import chisel3._
import cde.{Parameters, Field}
import uncore.tilelink._

class PWMTL(implicit p: Parameters) extends Module {
  val io = new Bundle {
    val tl = new ClientUncachedTileLinkIO().flip
    val pwmout = Bool(OUTPUT)
  }

  // ...
}

The io bundle holds the ports that this module exposes externally. It has two parts, a uncached TileLink port (tl), and a single-bit output (pwmout). The TL port is how the core communicates to the peripheral over MMIO. The pwmout signal is what we will drive as our PWM output.

Note that we have made our package pwm to match the subdirectory name. Also note that we have imported the chisel3 package, which contains all the HDL directives; cde.Parameters, an object which allows us to pass configurations to different parts of the code (mainly used by TileLink in this case); and the TileLink definitions from the uncore.tilelink package.

The full module code, with comments can be found in src/main/scala/pwm/PWM.scala.

After creating the module, we need to hook it up to our SoC. Rocketchip accomplishes this using the cake pattern. This basically involves placing code inside traits. In RocketChip, there are three kinds of traits: a LazyModule trait, and IO bundle trait, and a module implementation trait.

The LazyModule trait runs setup code that must execute before all the hardware gets elaborated. For a simple memory-mapped peripheral, this just involves adding an entry to the address map. The SoC generator provides each leaf node in the address map with a port from the MMIO interconnect.

import junctions._
import diplomacy._
import rocketchip._

trait PeripheryPWM extends LazyModule {
  val pDevices: ResourceManager[AddrMapEntry]

  pDevices.add(AddrMapEntry("pwm", MemSize(4096, MemAttr(AddrMapProt.RW))))
}

This adds an entry called "pwm" and makes it 4096 bytes in size. This is more than we really need, but to play nicely with the core's virtual memory system, address map regions must be page-aligned. We also give this regions read/write permissions. This device can be loaded from or stored to, but CPU instructions cannot be fetched from this location.

The IO bundle trait contains the extra IO ports that will be exported off-chip. For the PWM peripheral, this will just be the pwmout pin.

trait PeripheryPWMBundle {
  val pwmout = Bool(OUTPUT)
}

The module implementation trait is where we instantiate our PWM module and connect it to the rest of the SoC.

case object BuildPWM extends Field[(ClientUncachedTileLinkIO, Parameters) => Bool]

trait PeripheryPWMModule extends HasPeripheryParameters {
  val pBus: TileLinkRecursiveInterconnect
  val io: PeripheryPWMBundle

  io.pwmout := p(BuildPWM)(pBus.port("pwm"), outerMMIOParams)
}

We just need to connect the MMIO TileLink port to the PWM module's TileLink port and connect the PWM module's pwmout pin to the pwmout pin going off-chip. We would like to do this in a configurable way so that we can swap out the PWM module if need be. To do this, we create a new Field for the Parameters object that produces a function taking in the Tilelink port and returning the pwmout as a Bool. We will define this function later in the configuration file.

Note that we extend the HasPeripheryParameters trait. This provides us the outerMMIOParams parameter object, which gets passed in as the p parameters object to the PWM module. We have several parameters objects because different TileLink interfaces need different configurations. For all MMIO ports (i.e. those coming from pBus), you will want to use outerMMIOParams. Peripheral devices can also connect TileLink client ports going into the coreplex, which use the innerParams object.

Now we want to mix our traits into the system as a whole. This code is from src/main/scala/pwm/Top.scala.

package pwm

import chisel3._
import example._
import cde.Parameters

class ExampleTopWithPWM(q: Parameters) extends ExampleTop(q)
    with PeripheryPWM {
  override lazy val module = Module(
    new ExampleTopWithPWMModule(p, this, new ExampleTopWithPWMBundle(p)))
}

class ExampleTopWithPWMBundle(p: Parameters) extends ExampleTopBundle(p)
  with PeripheryPWMBundle

class ExampleTopWithPWMModule(p: Parameters, l: ExampleTopWithPWM, b: => ExampleTopWithPWMBundle)
  extends ExampleTopModule(p, l, b) with PeripheryPWMModule

Just as we have three traits, we have three classes to build the system. The ExampleTop classes from the example package already have the basic peripherals included for us, so we will just extend those.

The ExampleTop class includes the pre-elaboration code and also a lazy val to produce the module implementation (hence LazyModule). The ExampleTopBundle class becomes the top-level IO of the module implementation. And finally the ExampleTopModule class is the actual RTL that gets synthesized.

Now we have the RTL for the chip, but we need a test harness to simulate it.

package pwm

import cde.Parameters
import diplomacy.LazyModule

class TestHarness(q: Parameters) extends example.TestHarness()(q) {
  override def buildTop(p: Parameters) =
    LazyModule(new ExampleTopWithPWM(p))
}

We just extend the TestHarness from the example package, which already has the extra RTL to simulate the memory system and tethered serial port. It provides us the hook function buildTop which we can override to build our ExampleTopWithPWM instead of the regular ExampleTop.

We also need to create a Generator object, which gets called as the entry point for elaboration.

object Generator extends GeneratorApp {
  val longName = names.topModuleProject + "." +
                 names.topModuleClass + "." +
                 names.configs
  generateFirrtl
}

Finally, we need to add a configuration class in src/main/scala/pwm/Configs.scala. This defines all the settings in the Parameters object.

package pwm

import cde.{Parameters, Config, CDEMatchError}

class WithPWMTL extends Config(
  (pname, site, here) => pname match {
    case BuildPWM => (port: ClientUncachedTileLinkIO, p: Parameters) => {
      val pwm = Module(new PWMTL()(p))
      pwm.io.tl <> port
      pwm.io.pwmout
    }
  })

class PWMTLConfig extends Config(new WithPWMTL ++ new example.DefaultExampleConfig)

The only thing we need to add to the DefaultExampleConfig is the definition of the BuildPWM field. We just instantiate our PWMTL module, connect the TileLink port and pass out the pwmout signal.

Now we can test that the PWM is working. The test program is in tests/pwm.c

#define PWM_PERIOD 0x2000
#define PWM_DUTY 0x2008
#define PWM_ENABLE 0x2010

static inline void write_reg(unsigned long addr, unsigned long data)
{
        volatile unsigned long *ptr = (volatile unsigned long *) addr;
        *ptr = data;
}

static inline unsigned long read_reg(unsigned long addr)
{
        volatile unsigned long *ptr = (volatile unsigned long *) addr;
        return *ptr;
}

int main(void)
{
        write_reg(PWM_PERIOD, 20);
        write_reg(PWM_DUTY, 5);
        write_reg(PWM_ENABLE, 1);
}

This just writes out to the registers we defined earlier. The base of the module's MMIO region is at 0x2000. This will be printed out in the address map portion when you generated the verilog code.

Compiling this program with make produces a pwm.riscv executable.

Now with all of that done, we can go ahead and run our simulation.

cd verisim
make PROJECT=pwm CONFIG=PWMTLConfig
./simulator-pwm-PWMTLConfig ../tests/pwm.riscv

Adding a DMA port

In the example above, we gave allowed the processor to communicate with the peripheral through MMIO. However, for IO devices (like a disk or network driver), we may want to have the device write directly to the coherent memory system instead. To add a device like that, you would do the following.

package dmadevice

import chisel3._
import cde.Parameters

class ExtBundle extends Bundle {
    ...
}

class DMADevice(implicit p: Parameters) extends Module {
  val io = new Bundle {
    val tl = new ClientUncachedTileLinkIO
    val ext = new ExtBundle
  }

  ...
}

trait PeripheryDMA extends LazyModule {
  val pBusMasters: RangeManager

  pBusMasters.add("dma", 1)
}

trait PeripheryDMABundle extends HasPeripheryParameters {
  val ext = new ExtBundle
}

trait PeripheryDMAModule {
  val outer: PeripheryDMA
  val io: PeripheryDMABundle
  val coreplexIO: BaseCoreplexBundle

  val (r_start, r_end) = outer.pBusMasters.range("dma")

  val device = Module(new DMADevice()(innerParams))
  io.ext <> device.io.ext
  coreplexIO.slave(r_start) <> device.io.tl
}

The ExtBundle contains the signals we connect off-chip that we get data from. The DMADevice also has a Tilelink port to communicate with the coherent memory system (note that there's no .flip() call). Another thing to note is that when we instantiate DMADevice in PeripheryDMAModule, the Parameters object we give to it is innerParams instead of outerMMIOParams.

Adding a RoCC accelerator

Besides peripheral devices, a RocketChip-based SoC can also be customized with coprocessor accelerators. Each core can have up to four accelerators that are controlled by custom instructions and share resources with the CPU.

A RoCC instruction

Coprocessor instructions have the following form.

customX rd, rs1, rs2, funct

The X will be a number 0-3, and determines the opcode of the instruction, which controls which accelerator an instruction will be routed to. The rd, rs1, and rs2 fields are the register numbers of the destination register and two source registers. The funct field is a 7-bit integer that the accelerator can use to distinguish different instructions from each other.

Creating an accelerator

RoCC accelerators should extends the RoCC class.

package accel

import chisel3._
import rocket._

class CustomAccelerator(implicit p: Parameters) extends RoCC()(p) {
  val cmd = Queue(io.cmd)
  // The parts of the command are as follows
  // inst - the parts of the instruction itself
  //   opcode
  //   rd - destination register number
  //   rs1 - first source register number
  //   rs2 - second source register number
  //   funct
  //   xd - is the destination register being used?
  //   xs1 - is the first source register being used?
  //   xs2 - is the second source register being used?
  // rs1 - the value of source register 1
  // rs2 - the value of source register 2
  ...
}

The other interfaces available to the accelerator are mem, which provides access to the L1 cache, ptw which provides access to the page-table walker, autl which provides shared access to the L2 alongside the ICache refill, and utl which provides dedicated access to the L2.

Look at the examples in rocket-chip/src/main/scala/rocket/rocc.scala for detailed information on the different IOs

Adding RoCC accelerator to Config

RoCC accelerators can be added to a core by overriding the BuildRoCC parameter in the configuration. This takes a sequence of RoccParameters objects, one for each accelerator you wish to add. The two required fields for this object are opcodes which determines which custom opcodes get routed to the accelerator, and generator which specifies how to build the accelerator itself. For instance, if we wanted to add the previously defined accelerator and route custom0 and custom1 instructions to it, we could do the following.

class WithCustomAccelerator extends Config(
  (pname, site, here) => pname match {
    case BuildRoCC => Seq(
      opcodes = OpcodeSet.custom0 | OpcodeSet.custom1,
      generator = (p: Parameters) => Module(new CustomAccelerator()(p)))
  })

class CustomAcceleratorConfig extends Config(
  new WithCustomAccelerator ++ new BaseConfig)

Adding a submodule

While developing, you want to include Chisel code in a submodule so that it can be shared by different projects. To add a submodule to the project template, make sure that your project is organized as follows.

yourproject/
    build.sbt
    src/main/scala/
        YourFile.scala

Put this in a git repository and make it accessible. Then add it as a submodule to the project template.

git submodule add https://git-repository.com/yourproject.git

Then add yourproject to the EXTRA_PACKAGES variable in the Makefrag. Now your project will be bundled into a jar file alongside the rocket-chip and testchipip libraries. You can then import the classes defined in the submodule in a new project.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • C 76.5%
  • Scala 11.4%
  • Assembly 6.5%
  • Makefile 5.1%
  • Shell 0.5%