1 Configuring SystemVerilog RTL Models
Mike Thompson edited this page 2026-04-27 12:38:52 -04:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Introduction

SystemVerilog supports a rich set of language constructs for managing configurations of RTL models and testbenches. Unfortunately, these language constructs are not widely known and are typically not taught to students or junior engineers. Worse, not all of these can be used by RTL code (that is, SystemVerilog intended to be synthesized into a gate-level representation). As a result, many engineers use conditional compiler directives because they are easy to use and are supported by virtually all commercial and open-source EDA tools. This leads to RTL code that is difficult to read, use and maintain. The purpose of this document is to explain why conditional compiler directives should be avoided and to recommend alternative strategies for handling multiple build configurations of your SystemVerilog RTL. Several examples from OpenHW Foundation projects will be discussed.

What are Conditional Compiler Directives?

SystemVerilog conditional compilation compiler directives are the define, undef, ifdef, ifndef, else, elsif, endif and undefineall keywords. They can be used to include or exclude blocks of text during compilation. More commonly known as preprocessor macros, or simply directives, they are widely used for managing multiple build configurations. However they are considered a "dangerous" coding practice and OpenHW Foundation projects will not accept a pull-request that introduces conditional compilation compiler directives into its SystemVerilog RTL code-base. The rationale for this position is discussed below.

Global Namespace Pollution

Conditional compiler directives exist in a global namespace and do not respect scopes (packages, modules, or classes). If a conditional compiler directive is defined anywhere, it is defined for the entire compilation unit, including all of your RTL and testbench code. One consequence of this is that it is cumbersome to compile or synthesize two different configurations of your RTL in the same compilation unit. For example, suppose you were trying to compile two instances of the CVA6 into a single module and that only one of these CVA6 instances supports the CORE-V eXtension Interface. If compilation of the X-IF was controlled by conditional compiler directives, it would not be possible to do this without fine-grain control of the compilation order of the RTL.

A typical “solution” to this issue is to embed undef and define statements into the module that instantiates the cores to include the X-IF for one instance and exclude it for the others. Over time this leads to a very fragile and difficult to maintain code base.

"Hidden" Code and Compilation Fragility

The SystemVerilog preprocessor physically removes any text in the source code that doesn't meet the condition, so the compiler never sees it. A syntax error inside an else block will remain undetected if that specific condition was never triggered.

Lack of Type Checking

Conditional compiler directives are simple text replacements. They have no concept of data types, parameters, or SystemVerilog hierarchy. This severely limits the configurability of the design as you cannot use constant variables or parameters to trigger an ifdef.

Hidden Control

Conditional compiler directives can be set anywhere in the source code (e.g. define) and/or on the tool command-line (e.g. +define). They can also be un-set anywhere in the source code (e.g.undef). This lack of a single-point-of-control makes code very difficult to maintain and can lead to unexpected results.

“Ugly” code

Code with even a small number of conditional compiler directives is hard to read and therefore hard to maintain. As multiple configurations are added to the code base, each controlled by its own set of directives, the code becomes ever more cumbersome.

Examples

Example 1: The "Silent Syntax Error"

The example below defines a conditional compiler directives called USE_AES_256 to select which of two modules are instantiated in a module called crypto_engine. Note that the code in the `else block is never seen by the compiler, so the typo may persist undiscovered.

`define USE_AES_256

module crypto_engine (input logic clk, ...);
    `ifdef USE_AES_256
        aes_256_core inst (...);
    `else
        // Typo!  The module name is misspelled,
        // it should be aes_128_core... 
        // but the compiler will miss it.
        aes_127_core inst (...);
    `endif
endmodule

Example 2: The "Compilation Order Trap”

Directives are sensitive to the order in which files are read by the compiler. Consider the two files below. The value of BIT_WIDTH in File 2 is dependent on the order in which these files are compiled. If simulation compiled File 1 before File 2 and synthesis compiled File 1 after File 2, then the design that was verified would be different than the design that was implemented.

// File 1                           // File 2
// Set BIT_WIDTH                    // This code defaults BIT_WIDTH to 64,
`define BIT_WIDTH 32                // but if File 1 was compiled first,
                                    // it will silently use 32.
bit [BIT_WIDTH-1:0] bus1;
                                    `ifndef BIT_WIDTH
                                       `define BIT_WIDTH 64
                                    `endif

                                    bit [BITWIDTH-1:0] bus2;

Example 3: The “Global Namespace Problem”

This example illustrates both the “compilation order trap” and the effects of a global namespace. Not only does the logic and behavior of the multi_core module depend on the compilation order of the source files, using a conditional compiler directive to configure the logic and behavior of the cva6_core means that it is difficult to have different configurations of the cva6_core in the multi_core module.

// File: global_defs.svh
`define XIF

// File: cva6_core.sv
module cva6_core #(...) (...)
  …
  `ifdef XIF
    // add XIF-related logic and behavior
  `endif
  …
endmodule: cva6_core

// File: multi_core.sv
module multi_core #(...) (...)

  // all instances will compile with XIF defined
  cva6_core #(...) core1 (...);
  cva6_core #(...) core2 (...);
  cva6_core #(...) core3 (...);

endmodule: multi_core

Of course, difficult does not mean impossible. It is possible to force conditional compiler directives to have different configurations of the cva6_core in the multi_core module:

// File: multi_core.sv
module multi_core #(...) (...)

  // core 2 does not have XIF, but core 1 and core 3 do.
  cva6_core #(...) core1 (...);
  `undef XIF
  cva6_core #(...) core2 (...);
  `define XIF
  cva6_core #(...) core3 (...);

endmodule: multi_core

While the above will work, it is considered bad coding practice because the control of the cva6_core instances is distributed across multiple files because conditional compiler directives do not offer a mechanism for centralized control of an instance's configuration. Also, the above technique does not scale well to accommodate modules with tens of configuration options.

Alternatives to Compiler Directives

The list below summarizes alternative SystemVerilog constructs that support more robust and maintainable RTL code.

  • Parameters: Scope-limited, type-safe, and can be overridden per instance.
  • Generate Blocks: Checked for syntax even if the branch is not taken; respects hierarchy.
  • Packages: Provides a clean, scoped way to manage constants and configuration.

The following subsections will demonstrate the use of the above code constructs to refactor the previous examples to avoid the use of conditional compiler directives.

Refactoring Example #1: Course-grain configuration

Consider an alternative to the first example using SystemVerilog parameters instead of ifdefelseendif. When using a parameter, the compiler will parse both branches of the code block. So the aes_127_core typo will be caught by the compiler, regardless of which mode you are currently testing.

In the code below we expand on the example with a second code block controlled by a parameter called ENABLE_DEBUG. Note that although all of the code in the en_dbg block is always compiled, it is excluded if the if condition is false.

module crypto_engine #(
    parameter bit USE_AES_256  = 1,
    parameter bit ENABLE_DEBUG = 0
)(
    input logic clk, 
    ...
);

        if (USE_AES_256) begin: gen_aes
            aes_256_core inst_256 (...);
        end else begin : gen_aes_128
            // Compiler will check this syntax even if USE_AES_256 != 0
            aes_127_core inst_128 (...);
        end

    if (ENABLE_DEBUG) begin: en_dbg
      // Optionally instantiate the debug module
      debug_unit #(...) u_debug_unit (.clk(clk), .rstn(rstn), ...)
    end

endmodule: crypto_engine

Refactoring Example #2: Fine-grain configuration

Not everything is about big changes. Consider this fine-grain example:

module crypto_engine #(
    parameter bit USE_AES_256  = 1,
    parameter bit ENABLE_DEBUG = 0
)(
    input  logic clk, 
    ...
    output logic debug_o, // if ENABLE_DEBUG used
);

assign debug_o = ENABLE_DEBUG ? debug_r : '0;

Refactoring Example #3: Case Selection

module case_selection #(parameter int XLEN = 64)();
    string ins = "addw";

  // This code block uses conditional compiler directives to
  // select XLEN-specific code blocks in a “case” statement.
  initial begin: use_ifdefs
    case (ins)
        "add"  : $display("%m: do_add");
    `ifdef XLEN32
        "addi" : $display("%m: do_addi");
    `endif
    `ifdef XLEN64
        "addw" : $display("%m: do_addw");
    `endif
    endcase
  end

  // This code block uses a parameter to make the same selection.
  initial begin: use_parameters
    case (ins)
        "add"  : $display("%m: do_add");
        "addi" : if (XLEN == 32) $display("%m: do_addi");
        "addw" : if (XLEN == 64) $display("%m: do_addw");
    endcase
  end
endmodule

Organizing Configuration with Packages

Using a SystemVerilog Package to manage parameters is the preferred way to manage configurability and eliminate global conditional compiler directives. It centralizes your configuration while maintaining strict scoping and type safety. Instead of a header file full of defines, a configuration package contains typedefs, and local parameters (localparam). Use of typedefs allows for abstraction and makes the code much more readable.

Create the Configuration Package

This file replaces your "config.vh" or "defines.svh".

package design_cfg_pkg;
    // Parameters are typed and scoped
    typedef enum logic [1:0] { MODE_LOW_POWER, MODE_BALANCED, MODE_PERFORMANCE
                             } power_mode_t;

    localparam int DATA_WIDTH = 32;
    localparam bit ENABLE_CRYPTO = 1'b1;
    localparam power_mode_t CURRENT_MODE = MODE_PERFORMANCE;
endpackage : design_cfg_pkg

Import the Package in Your Module

Instead of relying on the preprocessor to find a macro, you explicitly import the package. module top_controller

    import design_cfg_pkg::*; // Bring parameters into this scope
(
    input  logic [DATA_WIDTH-1:0] data_in,
    output logic [DATA_WIDTH-1:0] data_out
);

    // Use a generate block for structural changes
    generate
        if (ENABLE_CRYPTO) begin : gen_crypto
            crypto_engine #( .W(DATA_WIDTH) ) u_crypto (.*);
        end
    endgenerate

    // Use procedural “if” statements for behavioral control
    always_comb begin
        data_out = data_in;
        
        // The compiler checks the validity of this block even if 
        // CURRENT_MODE is not MODE_PERFORMANCE.
        if (CURRENT_MODE == MODE_PERFORMANCE) begin
            data_out = data_in << 2; 
        end
    end

endmodule

Template for a "Top-Level" configuration

The following is an example that handles multiple chip variants using the above package-based method. This approach follows the "Single Source of Truth" principle, ensuring that changing one parameter in the package propagates through the entire hierarchy.

The Multi-Variant Template

The Common Definitions Package

This contains types and constants that never change across all devices.

package chip_common_pkg;
    // Common bus widths, opcodes, or status types
    typedef struct packed {
        logic [ 7:0] src_id;
        logic [23:0] payload;
    } packet_t;

    localparam int CLK_PERIOD_NS = 10;
endpackage : chip_common_pkg

The Variant Selection Package

This is where you define the "personality" of the specific chip you are building. You can use a typedef or a localparam to act as the master switch. Note that this variant package imports the common package.

package chip_config_pkg;
    import chip_common_pkg::*;

    // Define the Target Variant
    typedef enum { CHIP_LITE, CHIP_PRO, CHIP_ULTRA } variant_t;
    localparam variant_t TARGET = CHIP_PRO;

    // Derived Parameters based on the variant
    // These replace `ifdef branches
    localparam int  NUM_CORES    = (TARGET == CHIP_ULTRA) ? 16 : 
                                   (TARGET == CHIP_PRO)   ? 4  : 1;

    localparam bit  HAS_FLOATING_POINT = (TARGET != CHIP_LITE);
    localparam int  FIFO_DEPTH         = (TARGET == CHIP_ULTRA) ? 1024 : 256;

endpackage : chip_config_pkg

The Top-Level Module

The top-level logic uses these parameters to physically "shape" the hardware.

module chip_top 
    import chip_common_pkg::*;
    import chip_config_pkg::*;
(
    input logic clk,
    input logic rst_n,
    // Use parameters directly for port widths
    input packet_t [NUM_CORES-1:0] data_in 
);

    // 1. Structural variation using generate
    generate
        for (genvar i = 0; i < NUM_CORES; i++) begin : gen_cores
            cpu_core #(
                .ENABLE_FPU (HAS_FLOATING_POINT),
                .FIFO_SZ    (FIFO_DEPTH)
            ) u_core (
                .clk  (clk),
                .data (data_in[i])
            );
        end
    endgenerate

    // 2. Procedural variation using "if (static_expression)"
    always_ff @(posedge clk) begin
        if (!rst_n) begin
            // Reset logic
        end else if (TARGET == CHIP_ULTRA) begin
            // This specific logic only exists for the Ultra variant
            // but the compiler checks it for the Lite variant too!
            performance_monitor_update();
        end
    end
endmodule

How to Switch Variants

To change which chip you are building:

  1. Change the TARGET parameter in chip_config_pkg.sv.
  2. Recompile.

Since TARGET is a localparam, the compiler treats if (TARGET == CHIP_ULTRA) exactly like a conditional compiler directive in terms of hardware optimization—it will prune away the unreachable logic—but it gives you the benefit of full syntax checking across all variants simultaneously.

Heterogeneous Variants

In the above example, all instances of the cpu_core module had the same configuration. To support multiple heterogeneous configurations of cpu_core in chip_top we can deploy one of two strategies:

  1. Expand chip_config_pkg to handle multiple variations.
  2. Create multiple top-level modules and import the specific chip_config_pkg you want.

Below we will explore both strategies. Note that the first strategy is useful for illustration purposes, but does not scale well to large complex designs. For this reason the OpenHW Foundation employs the second strategy (per-configuration packages) for most of its IP.

Multiple variants per Package

Lets have a look at the chip_config_pkg from the previous example, with added local parameters.

package chip_config_pkg;
    import chip_common_pkg::*;

    // Define the Target Variant
    typedef enum { CHIP_LITE, CHIP_PRO, CHIP_ULTRA } variant_t;
    localparam variant_t TARGET = CHIP_PRO;

    // Derived Parameters based on the variant
    // These replace `ifdef branches
    localparam int  NUM_CORES    = (TARGET == CHIP_ULTRA) ? 16 : 
                                   (TARGET == CHIP_PRO)   ?  4 : 1;

    localparam bit  HAS_FLOATING_POINT = (TARGET != CHIP_LITE);
    localparam int  FIFO_DEPTH         = (TARGET == CHIP_ULTRA) ? 1024 : 256;

endpackage : chip_config_pkg

Variant-specific Packages

The CVA6 project uses a systematic approach with one dedicated configuration package per processor variant, rather than a single monolithic package with conditional parameters. For example, cv32a60x_config_pkg.sv defines a minimal 32-bit embedded core and cv64a6_imafdc_sv39_config_pkg.sv defines a 64-bit application class core.

Advantages of this Template

  • Centralized Control: All configuration for the entire device is set in the package file.
  • Code Maintenance: Unlike conditional compiler directives, which are simple text replacements, parameters can be any data type which can significantly improve code readability. In the above example, the variant_t enum makes it clear what the valid configurations are.
  • Inheritance-like Behaviour: Parameters can be derived from other parameters (e.g., FIFO_DEPTH changing automatically when you change the TARGET).

Module Interfaces

Handling optional interfaces is a common challenge in SystemVerilog, especially when a chip variant might physically add or remove a bus or change a bus protocol entirely. While you cannot use a generate block to conditionally declare a port in the module header, you can "stub-out" module interfaces to handle this in an elegant (well, less ugly) manner.

The "Stub" Interface Pattern

The most robust way to handle optional interfaces is to always define the interface port in the module definition and use a generate block inside the module to decide whether to connect it to internal logic or "stub" it out (tie it to safe values).

module cpu_core
    import chip_common_pkg::*;
    import chip_config_pkg::*;
#(
    parameter bit LOOKASIDE_EN   = 0
)(
    input   logic           clk,
    input   logic           rst_n,
    input   packet_t        pkt_in,
    input   lookaside_in_t  la_in,  // lookaside ports are optional
    output  lookaside_out_t la_out,
    output  packet_t        pkt_out
);

// internal logic signals connected to the optional lookaside ports
logic la_ack;
logic la_req;
logic [31:0] la_dat;

// Here we use procedural configuration using "if (static_expression)"
// to optionally connect a top-level interface to internal logic, or
// “stub out” the interface signals such that they have no impact on
// simulation and are pruned by synthesis.
if (LOOKASIDE_EN) begin
  always_comb begin : gen_lookaside_input_assignment
    la_ack = la_in.la_ack;
  end

  always_comb begin : gen_lookaside_output_assignment
    la_out.la_req = la_req;
    la_out.la_dat = la_dat;
  end
end
else begin
  // drive inputs with no-active values
  // outputs left open
  assign la_ack = '0;
end

endmodule: cpu_core

Summary

By moving toward Packages, generate blocks, and parameter-driven logic, your SystemVerilog code will be significantly easier to maintain, debug, and scale as your designs grow in complexity.

Quick Checklist for Refactoring your code:

  • Use Packages for global constants and variant selection.
  • Use generate for adding/removing arrays of hardware modules.
  • Use if (constant) inside procedural blocks for behavioural toggles and to stub out optional interfaces.
  • Avoid define unless you are using them for file guards.

Use Case: the OpenHW Foundation CVA6

The CVA6 is one of the most configurable IPs in the OpenHW CORE-V family of cores. This is due to its academic origins, longevity and the large and active community of developers working with the IP. As such, the methods used to define and control a specific configuration of the CVA6 does not strictly adhere to the purests view of the world discussed above. Nevertheless, the CVA6 follows a strict and robust methodology for defining and controlling configurations.

To create a specific configuration of the CVA6, follow the steps below. If you have any questions, please reach out to a member of the OpenHW Foundation or raise an Issue on the CVA6 GitHub repository.

  1. Create a SystemVerilog configuration package. This is a significant amount of work as you will need to invest time and effort to fully understand what is (and is not!) configurable before creating your own specific configuration.
  2. Place your configuration package in the core/include directory. The name of this file should be ${TARGET_CFG}_config_pkg.sv, and the name of the package must be cva6_config_pkg.
  3. The repo root Makefile defines TARGET_CFG and uses it as needed to select your configuration. The Makefile is written such that this variable can be passed on the make command-line or from a shell environment variable.
  4. The RTL filelist (Flist.cva6) includes the config package like this: ${CVA6_REPO_DIR}/core/include/${TARGET_CFG}_config_pkg.sv Note that the filelist also includes other needed packages, such as the build_config_pkg discussed in the next step.
  5. The corev_apu testbench located at corev_apu/tb/ariane_tb.sv also references the config package at compile time (link). It does not choose the configuration itself, but simply imports the config package that was selected and compiled by the build flow. Note that the package is not imported using the SystemVerilog import syntax. Rather, the testbench builds a local parameter, CVA6Cfg, of type cva6_cfg_t using the build_config function built into the build_config_pkg:
  localparam config_pkg::cva6_cfg_t CVA6Cfg =
             build_config_pkg::build_config(cva6_config_pkg::cva6_cfg);

Exception Cases

In the case where configuration decisions must be made at compile time, conditional compiler directives may be accepted. Here we document the two known use cases that will be accepted by code contributions to OpenHW projects.

Compiler Guards

Compiler guards are the exception to the rule against conditional compiler directives. Rather than being accepted in exceptional circumstances, compiler guards are required for all SystemVerilog sources. Effort should be made to uniquify the name of the directive.

`ifndef __MY_UVM_AGENT__
    `define __MY_UVM_AGENT__
`else
    class my_uvm_agent_c extends uvm_agent
        …
    endclass
`endif