Building Your Own SoC#

This tutorial builds an ASIC containing a PicoRV32 RISC-V CPU core, and then the same core wired to an SRAM – the first step toward a real system-on-chip.

../../_images/picorv32_ram_layout.png

It is the natural next step after the Quickstart, because it introduces the two things that quickstart’s single-file design does not: sources fetched from another repository, and composing your design out of someone else’s.

Everything here comes from examples/picorv32, which is three files:

examples/picorv32/
├── make.py            <- the build script; everything below is in here
├── picorv32.sdc       <- clock constraint
└── picorv32_top.v     <- wrapper that connects the core to an SRAM

The CPU source is not among them, and does not need to be downloaded – see below.

Running It#

From that directory:

smake syn                              # synthesis only, quickest check
smake asic                             # full RTL-to-GDS, bare core
smake asic --fileset rtl.memory        # full RTL-to-GDS, core + SRAM

make.py exposes each function as a target; smake discovers them and turns their arguments into command-line switches. smake --help lists what is available.

The default PDK is freepdk45; asap7 and gf180 also work (--pdk asap7), because the design carries a matching constraint fileset for each. The full flow takes appreciably longer than the Quickstart’s heartbeat – most of it in routing.

Where the Sources Come From#

The design does not vendor the CPU. It declares a dataroot pointing at the upstream repository, pinned to a commit, and SiliconCompiler fetches and caches it on first use:

# Here, we're fetching the picorv32 source code directly from its GitHub repository
# at a specific commit hash to ensure reproducibility.
self.set_dataroot('picorv32',
                  'git+https://github.com/YosysHQ/picorv32.git',
                  'c0acaebf0d50afc6e4d15ea9973b60f5f4d03c42')
# We also define a dataroot for local files associated with this example script.
self.set_dataroot('example', __file__)

This is the pattern to copy for any third-party RTL. Pinning the commit is what makes the build reproducible; without it, “the same script” silently means something different next month. The cache lives in ~/.sc/cache, so the fetch happens once per machine, not once per run.

Part 1: The Bare Core#

The rtl fileset is the core on its own:

with self.active_fileset("rtl"):
    # Set the active data source for the following files.
    with self.active_dataroot("picorv32"):
        # Define the top-level module for this configuration.
        self.set_topmodule("picorv32")
        # Add the main Verilog source file to the fileset.
        self.add_file("picorv32.v")

Building it is the same three steps as any other project – load the design, add the filesets, apply a target:

def asic(fileset: str = "rtl", pdk: str = "freepdk45"):
    """
    Configures and runs the full default ASIC flow (synthesis, place-and-route, etc.).
    """
    # Create a standard ASIC project.
    project = ASIC()

    # Load the design configuration.
    project.set_design(PicoRV32Design())
    # Add the RTL fileset.
    project.add_fileset(fileset)
    # Add the corresponding SDC fileset for the selected PDK. This is crucial for timing.
    project.add_fileset(f"sdc.{pdk}")

    # Load the target technology settings for the chosen PDK.
    asic_target(project, pdk=pdk)

    # Run the default flow defined in the target (usually a full ASIC flow).
    project.run()
    # Display a summary of the final results.
    project.summary()
    # Save the project state (results, logs, etc.) to a file for later inspection.
    project.snapshot()
../../_images/picorv32_layout.png

I/O signals are placed around the edges of the die area without a pin constraint, which is why they appear evenly distributed rather than grouped.

Part 2: Adding an SRAM#

A CPU core is not much use without memory. A real SoC would also want a SPI interface for external non-volatile memory, a UART, a debug interface and a cache – this adds the first of those pieces.

The SRAM does not have to be built or downloaded. lambdalib ships a single-port RAM, and the rtl.memory fileset composes it with the core:

with self.active_fileset("rtl.memory"):
    # Add the picorv32 core Verilog file.
    with self.active_dataroot("picorv32"):
        # The top module for this version is a wrapper.
        self.set_topmodule("picorv32_top")
        self.add_depfileset(self, "rtl")
    # Add local wrapper files.
    with self.active_dataroot("example"):
        self.add_file("picorv32_top.v")
        # This is a key feature: it declares that this fileset depends on the
        # 'rtl' fileset from the 'Spram' design. SiliconCompiler will automatically
        # pull in and compile the SRAM module.
        self.add_depfileset(Spram(), "rtl")

Two things are worth pulling out of that block, because together they are the whole mechanism for building a design out of other designs:

  • Design.add_depfileset() declares a dependency on another design’s fileset. add_depfileset(self, "rtl") pulls in this design’s own bare-core fileset, and add_depfileset(Spram(), "rtl") pulls in the RAM. Their sources are resolved and compiled with yours – you never name their files.

  • The top module changes to picorv32_top, the local wrapper that instantiates the core and the RAM and connects them.

So rtl.memory is not a modified copy of rtl; it is rtl plus a library plus a wrapper. Swapping between the two configurations is a fileset argument, nothing more.

Project.write_depgraph() draws what a project resolved to, which is the quickest way to confirm a design is composed the way you think it is. Call it on a project you have added filesets to – no run required:

project.write_depgraph("picorv32.png")
../../_images/bcaa6cc227b9.svg

Reading down from the design: rtl.memory pulls in both picorv32/rtl and la_spram/rtl, and the SRAM brings a dependency of its own that you never had to name. The sdc.freepdk45 branch is the constraint fileset added alongside the RTL one – change the PDK and that branch changes with it.

The graph above is drawn before asic_target is applied, so it is the design’s own shape. Call it after the target and the PDK, the standard cell library and every macro library the target registers join the picture – the honest view of a build, and a considerably wider one.

Note

This SRAM is soft – it is RTL, synthesized along with everything else. That is the simplest thing that works, and it is what makes this example run on any of the three PDKs. A production SoC would use a hardened memory macro instead, with fixed timing and area; see Instantiating a hardened module for how a pre-implemented block is packaged and placed.

Results#

Outputs land in build/picorv32/job0/. The final layout is under write.gds/0/outputs/, and metrics are summarised at the end of the run by Project.summary(). To open the layout:

sc-show -design picorv32                        # the finished GDS
sc-show -design picorv32 -arg_step floorplan.init   # an intermediate stage

sc-show needs a viewer – KLayout is the usual choice. Directory structures explains the rest of the tree, and Working with Metrics covers what the summary is showing you.

Extending Your Design#

You now have the two techniques that hierarchical design rests on: pulling sources from another repository, and composing filesets from other designs. From here: