How HDL simulations are compiled

Most of the time we do not give much thought about the steps that our EDA tools have to go through to run a simple testbench simulation. Starting from a couple HDL files we end up somehow with a simulation executable. This blogpo will try to explain how the compilation and the elaboration steps are generally related to tools such as VCS, DSIM and Vivado. Much of the examples provided here are referring to verilog/systemverilog tools but much of the same principles can be extended to VHDL tools.

The basic concepts:

Starting from the preprocessing step, it is where each source file resolves its macros, defines and include declarations into its intended text replacements. This step generally means generating intermediate temporal source files which funnel into the compilation step.

The compilation is the step in which a program receives a description written in a language that is converted into a particular output. In software terms, a C compiler converts C language into assembly machine code. In our case, the compilation step takes the HDL code and converts it into a binary representation; this representation is later used in the elaboration step.

The elaboration step usually takes the compiled top module in hierarchy and then resolves from top to bottom each module instance; if any submodule has any parameter or configuration provided, it is here where it is resolved. The output of the elaboration step concludes with a simulation executable.

Preprocessing

On this step the following directives are resolve:

1. Include directive: this directive is expanded and replaced in this step by the exact content of the target file. There is no analysis or inspection done here, it is an exact copy of the file.

// The following line would expand on this step by inserting the content of the file to this file.
// Precaution what files you include by this directive it may cause 
// compiling issues when the same definitions are processed twice if the same file is included from multiple sources.
`include “testbench_wrapper.sv”

2. Define directive: this directive sets a variable which is usually written in all caps. The definition could be anything from number variables to written text. It doesn’t hold any typing, the preprocessor only looks at the places where this define is being cast and replaced it with the define value.

// The following define exist but it is replaced with “” if it is used in code
`define SYNTHESIS

`ifndef SYNTHESIS
// The following procedure is either erased or kept according the existence of the definition of SYNTHESIS

// NUM_CORES would be replaced with 4 instead in the preprocessing step
`define NUM_CORES 4
core_cfg core_cfgs [NUM_CORES]; // 4 ← NUM_CORES 

`endif

3. Macro directive: it functions as a define directive but is more powerful. The typical structure starts with a template where you set variables that are defined every time you call said macro. The method to use it is similar to how a function is being called. The variables of the macro are only resolved on this step.


`define REG_BLOCK_PATH (REG_NAME) tb.sub_path.regblock.REG_NAME
Logic [31:0] csr_val;
// the following macro expands to tb.sub_path.regblock.uart_status
Assign csr_val = `REG_BLOCK_PATH(uart_status);


Take into account that define and macro directives require precedent definitions before being used. Generally it is a good idea to have most of these definitions being concentrated in a single or several files which are read by the preprocessor first before going through the other files that use them.

Typically we don’t call the preprocessor directly when we do the compilation because this step is usually already baked-in the compilation step.

Compilation

Possibly the most important step. This step parses and processes the file previously treated by the preprocessor and converts the unit blocks described by the HDL definitions to a binary representation. There is not a standard binary representation across vendors and it depends on the tool version too. These structures are compatible within the same tool-generated version. This stage is where dependency issues appear and is important to plan how to compile each file of our project.

There are two strategies to follow according to the needs of the overall project:

  • Smaller size projects tend to follow the strategy 1 which consists of passing all the HDL files of the project in a single batch and then generating a single binary object or directory where most of the results live on. This tends to be rather easy to implement, but usually the compilation time tends to increase rapidly as the number of HDL files pile up so every recompilation requires compiling every single file again.
  • That’s where the second strategy gains importance in the project day to day usage. It saves time as most of the project is already compiled when small changes are done.

Regarding the second strategy, some tools have support for automatic incremental compilations, but in case that manual control is needed or is the only option the project has to be compiled using third party tools to manage the compilation project or in-house make scripts.

Elaboration

This step is pretty straightforward. It consists of targeting a single top unit, most likely a top testbench or more, at starting solving from top to bottom the configuration overrides. The elaboration also attaches the verification code (also compiled in binary objects) to the simulation executable.

Tools examples

The following examples show how the compilation steps are done in diverse vendor tools.

Synopsys


## vlog makes the preprocessing and compilation steps for verilog/systemverilog files
vlogan -sv <path_to_file>/design.sv <path_to_file>/tb.sv
## vcs makes the elaboration steps. It takes the binary structures created from the compilation step previously and build it on top of it the simulation executable 
vcs -s <name_of_elaboration> top_tb -timescale 1ns/1ps
## run simulation
work_dir/simv

Vivado


## load vivado program binaries path to the shell environment
source <vivado_installation_path>vivado_settings.sh

## xvlog makes the preprocessing and compilation steps for verilog/systemverilog files
xvlog -sv <path_to_file>/design.sv <path_to_file>/tb.sv
## xelab makes the elaboration steps. It takes the binary structures created from the compilation step previously and build it on top of it the simulation executable 
xelab -s <name_of_elaboration> top_tb -timescale 1ns/1ps
## xsim call the executable elaborated from the previous step
xsim <name_of_elaboration> -runall

DSIM

 
## compiles a library based of the systemverilog files
dvlcom -lib 'lib_name' -sv <path_to_file>/design.sv <path_to_file>/tb.sv
dsim -top lib_name.top_tb -L lib_name

Código de ejemplo

// Simple CPU design in Verilog
module simple_cpu (
input clk,
input reset,
output [7:0] acc, // Accumulator register output
output [7:0] pc // Program counter output
);

// Define instruction set
parameter NOP = 4'b0000; // No Operation
parameter LDA = 4'b0001; // Load Accumulator
parameter ADD = 4'b0010; // Add to Accumulator
parameter SUB = 4'b0011; // Subtract from Accumulator
parameter STA = 4'b0100; // Store Accumulator
parameter JMP = 4'b0101; // Jump to Address
parameter HLT = 4'b1111; // Halt CPU

// CPU Registers
reg [7:0] accumulator; // Accumulator register
reg [7:0] program_counter; // Program Counter
reg [7:0] memory [0:255]; // Memory (256 x 8 bits)
reg [7:0] instruction; // Current instruction
reg [3:0] opcode; // Operation code
reg [7:0] operand; // Operand (data or address)

// Assign outputs
assign acc = accumulator;
assign pc = program_counter;

// CPU Reset
always @(posedge clk or posedge reset) begin
if (reset) begin
program_counter <= 8'b0;
accumulator <= 8'b0;
end else begin
// Fetch instruction
instruction <= memory[program_counter];
opcode <= instruction[7:4];
operand <= instruction[3:0];

// Increment program counter
program_counter <= program_counter + 1;

// Decode and execute instruction
case (opcode)
NOP: ; // Do nothing
LDA: accumulator <= memory[operand]; // Load value from memory
ADD: accumulator <= accumulator + memory[operand]; // Add to accumulator
SUB: accumulator <= accumulator - memory[operand]; // Subtract from accumulator
STA: memory[operand] <= accumulator; // Store accumulator to memory
JMP: program_counter <= operand; // Jump to address
HLT: program_counter <= program_counter; // Halt CPU
default: ; // Undefined operation
endcase
end
end

endmodule
function App() {
    return (
      <div className="App">
       <header className= "App-header">
         <img src={logo} className="App-logo" alt="logo"/>
         <p>
         Edit <code>src/App.tsx</code> and save to reload.
         </p>
         <a
         className="App-link"
         href="https://reactjs.org"
         target="_blank"
         rel="noopener noreferrer"
         >
         Learn reactjs
         </a>
       </header>
      </div>
   );
}

//ejemplo de posible sintaxis en verilog
// Comentario de una sola línea
`define DATA_WIDTH 8 // Macro definida

module ExampleModule (
    input wire clk,       // Señal de reloj
    input wire reset,     // Señal de reinicio
    input wire [`DATA_WIDTH-1:0] in_data, // Entrada de datos
    output reg [`DATA_WIDTH-1:0] out_data // Salida de datos
);

    // Declaración de registros y cables
    reg [`DATA_WIDTH-1:0] accumulator; // Acumulador
    wire enable;

    // Asignación continua
    assign enable = (in_data > 0) ? 1'b1 : 1'b0;

    // Bloque inicial
    initial begin
        out_data = 0;
        accumulator = 0;
    end

    // Bloque siempre
    always @(posedge clk or negedge reset) begin
        if (!reset) begin
            // Reiniciar valores
            accumulator <= 0;
            out_data <= 0;
        end else if (enable) begin
            // Acumular y actualizar salida
            accumulator <= accumulator + in_data;
            out_data <= accumulator;
        end
    end

endmodule

// Otro módulo con instanciación
module TopModule;
    // Señales internas
    reg clk;
    reg reset;
    reg [`DATA_WIDTH-1:0] in_data;
    wire [`DATA_WIDTH-1:0] out_data;

    // Instancia del módulo
    ExampleModule uut (
        .clk(clk),
        .reset(reset),
        .in_data(in_data),
        .out_data(out_data)
    );

    // Generador de reloj
    initial begin
        clk = 0;
        forever #5 clk = ~clk; // Invertir cada 5 unidades de tiempo
    end

    // Proceso de prueba
    initial begin
        reset = 0;
        in_data = 0;

        // Reinicio del sistema
        #10 reset = 1;

        // Probar datos de entrada
        #10 in_data = 8'b00000001; // Valor binario
        #10 in_data = 8'hFF;      // Valor hexadecimal
        #10 in_data = 8'd128;     // Valor decimal
        #10 in_data = 8'o77;      // Valor octal

        // Finalizar simulación
        #50 $finish;
    end
endmodule