Table of Contents
- Introduction
- What are Conditional Compiler Directives?
- Global Namespace Pollution
- "Hidden" Code and Compilation Fragility
- Lack of Type Checking
- Hidden Control
- “Ugly” code
- Examples
- Example 1: The "Silent Syntax Error"
- Example 2: The "Compilation Order Trap”
- Example 3: The “Global Namespace Problem”
- Alternatives to Compiler Directives
- Refactoring Example #1: Course-grain configuration
- Refactoring Example #2: Fine-grain configuration
- Refactoring Example #3: Case Selection
- Organizing Configuration with Packages
- Template for a "Top-Level" configuration
- The Multi-Variant Template
- How to Switch Variants
- Heterogeneous Variants
- Advantages of this Template
- Module Interfaces
- Summary
- Use Case: the OpenHW Foundation CVA6
- Exception Cases
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 ifdef…else…endif. 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:
- Change the
TARGETparameter inchip_config_pkg.sv. - 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:
- Expand chip_config_pkg to handle multiple variations.
- 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
Let’s 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_DEPTHchanging automatically when you change theTARGET).
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
Packagesfor global constants and variant selection. - Use
generatefor adding/removing arrays of hardware modules. - Use
if (constant)inside procedural blocks for behavioural toggles and to stub out optional interfaces. - Avoid
defineunless 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 purest’s 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.
- 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.
- 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.
- 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.
- 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.
- 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