Integrate a Peripheral IP

This guide walks through the end-to-end flow to add a new memory-mapped peripheral to X-HEEP. We use the existing dlc IP as the blueprint because it exercises the full hardware, software, and tooling stack.

Set up the X-HEEP environment by following the Getting Started guide.

1. Create the IP Structure

This is how the folder should look like:

└── hw/ip/<peripheral>
    ├── data/
    │   └──<peripheral>.hjson           # register description (for regtool generation)
    ├── rtl/
    │   ├──<peripheral>.sv              # RTL of the peripheral
    │   ├── <peripheral>_reg_top.sv     # generated by regtool
    │   └── <peripheral>_reg_pkg.sv     # generated by regtool
    ├── <peripheral>.core               # FuseSoC description
    ├── <peripheral>.vlt                # Verilator waiver
    └── <peripheral>_gen.sh             # helper script running regtool

X-HEEP-compatible module ports

All X-HEEP peripheral modules require specific ports that allows it to communicate with the rest of the system.

The following is the absolute minimum starting point to develop your peripheral module ports:

module peripheral (
    input logic clk_i,
    input logic rst_ni,

    // Register interface
    input reg_pkg::reg_req_t reg_req_i,
    output reg_pkg::reg_rsp_t reg_rsp_o
);

The CPU can access the configuration registers of the peripheral via the register interface:

  • reg_req_i is the input request, which can either request to write some value into a register or read values from a register

  • reg_rsp_o is the output response, which may contains register data if the request was a read operation

In any case, the implementation of this protocol is transparent to the RTL designer, as its included in the top register module generated via regtool depending on the data/<peripheral>.hjson information.

Here is an extended example of a module port structure which includes additional features:

module peripheral (
    input logic clk_i,
    input logic rst_ni,

    // Register interface
    input reg_pkg::reg_req_t reg_req_i,
    output reg_pkg::reg_rsp_t reg_rsp_o,

    // Done signal
    output logic peripheral_done_o,

    // Interrupt signal
    output logic peripheral_interrupt_o,

    // Master ports on the system bus
    output obi_req_t  peripheral_master_bus_req_o,
    input  obi_resp_t peripheral_master_bus_resp_i
);

About the additional features:

  • peripheral_done_o: this done signal could be useful if you want X-HEEP to expose it, so that external units can be aware of it. For example, each X-HEEP’s DMA channel exposes a dma_done_o signal. This could be used for example by accelerators that exploit the DMA functionalities to move data in and out of their local memory

  • peripheral_interrupt_o: this signal can be used to set up a dedicated interrupt line reserved for your peripheral. This would allow the execution of custom interrupt handlers in SW, a common and useful feature.

  • peripheral_master_bus_req_o / peripheral_master_bus_resp_i: these are OBI request and response signals, necessary if you want your peripheral to be a master on the system bus. In other words, this would allow your peripheral to read and write data in every memory mapped space of X-HEEP. For example, this could allow your peripheral to read from RAM, or it could allow it to configure other peripherals.

2. Describe and Generate Registers

In X-HEEP, registers are automatically generated using RegTool by OpenTitan. Please refer to the official documentation for additional information: https://opentitan.org/book/util/reggen/index.html. In anycase, this tool will generate both RTL and SW components starting from an HJSON description. These are the steps needed to set everything up:

  1. Populate data/<peripheral>.hjson with registers, fields, reset values, and optional interrupts. DLC’s dlc.hjson is a good example, but you could take inspiration from any X-HEEP .hjson.

  2. Generate RTL wrappers and the C header files by running the <peripheral>_gen.sh script.

3. Expose the IP to FuseSoC

This is a crucial step in integrating the new peripheral in X-HEEP’s flow:

  1. Adapt hw/ip/<peripheral>/<peripheral>.core from dlc.core. List all RTL files under files: and declare dependencies (for example pulp-platform.org::register_interface).

  2. Add the peripheral as a dependency of the SoC in core-v-mini-mcu.core:

    filesets:
      files_rtl_generic:
        depend:
          - example:ip:<peripheral>  # use the namespace chosen in your .core file
    
  3. If the IP has lint waivers for Verilator, reference the waiver in the .core file:

    filesets:
      depend:
       - openhwgroup.org:ip:verilator_waiver
      files:
       - hw/ip/<peripheral>/<peripheral>.vlt  # use the namespace chosen in your .core file
    

4. Reserve Address Space in the Configuration

This is another crucial step in the integration of the peripheral. Please be aware that the address map of X-HEEP might change in the future, so consider this for the addresses of the following examples. As said at the beginning of this guide, these steps are the same for any domain you want to integrate the peripheral in, both user peripherals and base peripherals.

  1. Extend the configuration you built (HJSON or Python) with a new entry in the preferred domain. Example (configs/general.hjson):

    peripherals: {
        address: 0x30000000
        length:  0x00100000
        // existing peripherals…
        <peripheral>: {
            offset:  0x00080000
            length:  0x00010000
            is_included: "yes"
        }
        // existing peripherals…
    }
    
  2. For Python configs, import a new class in util/xheep_gen/peripherals/user_peripherals.py (or base_peripherals.py if mandatory):

    class MyPeripheral(UserPeripheral):
        _name = "my_peripheral"
    

    Instantiate the class in your config script and add it to the user domain before calling build().

  3. If your IP generates an interrupt signal, extend the interrupt list with it.

Warning

This step is necessary only for peripheral domain IPs.

interrupts: {
      number: 64 // Do not change this number!
      list: {
          // First one is always zero
          null_intr:               0
          uart_intr_tx_watermark:  1
          // Other interrupt signals...
          i2s_intr_event:          50

          // Your own peripheral:
          <peripheral>_intr_event: 51
      }
   }

5. Integrate the IP in the Peripheral domain

Now, let’s instantiate the peripheral in the peripheral_subsystem.sv.tpl, as the .sv will be regenerated every make mcu-gen ran. As a good reference, please look at the i2s peripheral. There are some important considerations to make, so this is an example of a peripheral instantiation that mirrors the previous examples:

peripheral_ip #(
      .reg_req_t(reg_pkg::reg_req_t),
      .reg_rsp_t(reg_pkg::reg_rsp_t)
  ) peripheral_ip_i (
      .clk_i(clk_cg),
      .rst_ni,

      // Register interface
      .reg_req_i(peripheral_slv_req[core_v_mini_mcu_pkg::PERIPHERL_IDX]),
      .reg_rsp_o(peripheral_slv_rsp[core_v_mini_mcu_pkg::PERIPHERL_IDX]),

      // Done signal
      .peripheral_done_o,

      // Interrupt signal
      peripheral_interrupt_o(peripheral_intr_event),

      // Master ports on the system bus
      .peripheral_master_bus_req_o,
      .peripheral_master_bus_resp_i
  );

The ports of the peripheral subsystem itself have to be changed too if you want to add a master port on the bus or if you want to expose a signal:

module peripheral_subsystem
  import obi_pkg::*;
  import reg_pkg::*;
#(
    //do not touch these parameters
    parameter NEXT_INT_RND = core_v_mini_mcu_pkg::NEXT_INT == 0 ? 1 : core_v_mini_mcu_pkg::NEXT_INT
) (
    input logic clk_i,
    input logic rst_ni,

    // Other signals...

    // PDM2PCM Interface
    output logic pdm2pcm_clk_o,
    output logic pdm2pcm_clk_en_o,
    input  logic pdm2pcm_pdm_i,

    // Your peripheral signals
    output logic     peripheral_done_o,
    output obi_req_t peripheral_master_bus_req_o,
    input  obi_rsp_t peripheral_master_bus_rsp_i
);

Some notes:

  • clk_i is connected to clk_cg, as the peripheral subsystem can be clock gated. If you want to clock gate individually your peripheral, implement that feature inside your IP.

  • reg_req_i / reg_rsp_o are connected to a register-interface demultiplexer which routes all requests from the bus to the correct peripheral based on their address map. That’s why it’s very important that you select the correct peripheral_slv_req and peripheral_slv_rsp signal with the ID associated with your peripheral.

  • peripheral_done_o, peripheral_master_bus_req_o and peripheral_master_bus_rsp_i connects the module’s ports to the peripheral’s ports.

If your peripheral need a new system bus master port, like in our example, you need to modify other files too. In order:

  • core_v_mini_pkg.sv.tpl: this file generates parameter for many X-HEEP modules, included the system bus. In particular, we are interested in increasing the SYSTEM_XBAR_NMASTER parameter by the number of master ports we need. In our example, one is sufficient.

  • system_bus.sv.tpl: this is the template for the system bus. Here we need to add a new port of the module and connect int_master_req and int_master_rsp signals to the new port:

  assign int_master_req[9] = peripheral_master_bus_req_i;
  // Other code...
  assign peripheral_master_bus_req_o = int_master_resp[9];
  • core_v_mini_mcu.sv.tpl: finally, we need to update the system_bus instantiation, and connect the peripheral master bus signals to the system bus ones.

As a good reference for this entire process, consider once again the DMA subsystem.

Regardless of system bus master ports, we need to update the peripheral_susystem or ao_peripheral_subsystem instantiation in core_v_mini_mcu.sv.tpl to match out modifications.

Furthermore, if your peripheral needs to expose a signal, like peripheral_done_o in our case, we need to perform some additional modifications. In order:

  • Add to the ports of the core_v_mini_mcu module the signal itself

  • Update x_heep_system.sv.tpl to match the new core_v_mini_mcu ports

  • Update testharness.sv to match the new x_heep_system ports

Finally, if you want, in testharness.sv you can instantiate an accelerator that can exploit the peripheral_done_o signal.

Warning

After updating all these templates, run make mcu-gen to regenerate them!

6. Hook Up FPGA Wrappers

  1. Edit the board wrapper templates (e.g. hw/fpga/xilinx_core_v_mini_mcu_wrapper.sv.tpl) to expose the new signals and regenerate the concrete .sv file.

  2. Update board constraint files or scripts (such as hw/fpga/scripts/generate_sram.tcl.tpl) when the IP drives physical I/O.

  3. Re-run make mcu-gen to propagate template changes before rebuilding bitstreams.

7. Provide Software Accessors

In order to simplify the user experience, create a driver in sw/device/lib/drivers/<peripheral>/. We suggest taking as a reference the structure of sw/device/lib/drivers/dlc, but feel free to be inspired by any X-HEEP driver module. Typical contents:

  • <peripheral>.c / <peripheral>.h – MMIO helper functions.

  • <peripheral>.h (generated earlier via regtool).

  • <peripheral>_structs.h – emitted by util/structs_periph_gen.py if needed.

8. Add Firmware Examples and Tests

This is strongly suggested, as it can serve both as a guide for future developers and a debugging tool. To do so, write an example application under sw/applications/example_<name>/ that exercises the new driver.

9. (For pull requests) Update Documentation

This is mandatory for any new IP that you would like to propose to be included in the official X-HEEP repository. Students, companies and passionate individuals can all contribute to X-HEEP, and we encourage you to do so. However, we need to keep this project maintainable. For this reason, please add a dedicated page in the Peripherals folder of the documentation. For reference, please check out our guide on how to update the documentation here.

Checklist

  • [ ] IP directory added under hw/ip/<name> (or hw/ip_examples/<name>) with .core, RTL, and HJSON files.

  • [ ] regtool executed; generated RTL and headers checked in.

  • [ ] Configuration (HJSON or Python) updated with offset, length, and optional interrupt entries.

  • [ ] Templates updated and make mcu-gen run; regenerated outputs reviewed.

  • [ ] peripheral_subsystem (and other templates) instantiate and connect the IP.

  • [ ] Testbench and FPGA wrappers handle the new signals.

  • [ ] Driver (sw/device/lib/drivers/<name>) implemented; structs regenerated if needed.

  • [ ] Example firmware compiled and simulations pass.

With these steps complete, your peripheral is fully integrated into the X-HEEP hardware, verification, and software flow.