Programming Paradigms: The Three Thinking Modes Every DV Engineer Uses

Here's a secret: every time you write a UVM testbench, you're switching between three different programming paradigms—three fundamentally different ways of thinking about code. You've been doing this for years without knowing the names. Understanding these paradigms explicitly makes you a better engineer because you'll know which thinking mode fits which problem.

This post introduces the three paradigms—Imperative, Object-Oriented, and Declarative—through examples you already write every day.

What Is a Programming Paradigm?

A paradigm is a way of thinking about and structuring code. Just like natural languages have different ways of expressing ideas (some languages put verbs first, others put them last), programming paradigms are different approaches to expressing computation.

Think of it this way:

  • Imperative: Give step-by-step instructions (like a recipe)
  • Object-Oriented: Model things as objects with properties and behaviors (like actors in a play)
  • Declarative: Describe what you want, not how to get it (like ordering at a restaurant)

You already use all three in your testbenches. Let's see how.

Paradigm 1: Imperative Programming

What It Means

Imperative programming means giving the computer a sequence of commands to execute, one after another. You're telling the computer how to do something, step by step. It's the most intuitive paradigm because it mirrors how we give instructions to humans.

Key characteristics:

  • Statements execute in order
  • Variables change over time (mutable state)
  • Control flow: if/else, loops, function calls
  • You specify the exact steps

Where You Already Use It: Sequences

Every UVM sequence body is imperative programming:

task body();
  axi_transaction req;
  
  // Step 1: Create a transaction
  req = axi_transaction::type_id::create("req");
  
  // Step 2: Configure it
  req.addr = 32'h1000;
  req.data = 32'hDEAD_BEEF;
  req.write = 1;
  
  // Step 3: Send it
  start_item(req);
  finish_item(req);
  
  // Step 4: Wait for response
  get_response(rsp);
  
  // Step 5: Check result
  if (rsp.status != OK) begin
    `uvm_error("SEQ", "Transaction failed")
  end
endtask

This is pure imperative code: do step 1, then step 2, then step 3... The order matters. Each statement changes some state (creates an object, modifies a field, triggers an action).

Where You Already Use It: Drivers

task drive_transaction(axi_transaction t);
  // Step-by-step signal manipulation
  vif.awvalid <= 1'b1;           // Step 1: Assert address valid
  vif.awaddr  <= t.addr;          // Step 2: Drive address
  
  @(posedge vif.clk);             // Step 3: Wait for clock
  
  while (!vif.awready) begin      // Step 4: Wait for ready
    @(posedge vif.clk);
  end
  
  vif.awvalid <= 1'b0;            // Step 5: Deassert valid
  
  // Continue with data phase...
endtask

Driving signals onto a bus is inherently imperative—you're giving explicit instructions for what to do at each moment in time.

Imperative Thinking

When you think imperatively, you ask: "What are the exact steps to accomplish this?"

Imperative code reads like a recipe:

  1. First, do this
  2. Then, do that
  3. If X, do Y, otherwise do Z
  4. Repeat until done

Paradigm 2: Object-Oriented Programming (OOP)

What It Means

Object-Oriented Programming organizes code around "objects"—bundles of data (properties) and behavior (methods) that represent things in your system. Instead of thinking about steps, you think about things and how they interact.

Key characteristics:

  • Encapsulation: Objects bundle data with the code that operates on it
  • Inheritance: New classes can extend existing ones
  • Polymorphism: Different objects can respond to the same message differently
  • Abstraction: Hide complexity behind simple interfaces

Where You Already Use It: Transactions

Every transaction class is object-oriented design:

class axi_transaction extends uvm_sequence_item;
  
  // DATA (properties/fields)
  rand bit [31:0] addr;
  rand bit [31:0] data;
  rand bit        write;
  rand bit [3:0]  strobe;
  rand burst_e    burst;
  
  // BEHAVIOR (methods)
  function bit is_aligned();
    return (addr[1:0] == 2'b00);
  endfunction
  
  function int get_byte_count();
    int count = 0;
    for (int i = 0; i < 4; i++)
      if (strobe[i]) count++;
    return count;
  endfunction
  
  function string convert2string();
    return $sformatf("AXI %s addr=0x%08h data=0x%08h",
                     write ? "WR" : "RD", addr, data);
  endfunction
  
endclass

The transaction object bundles:

  • Data: addr, data, write, strobe, burst
  • Behavior: is_aligned(), get_byte_count(), convert2string()

You don't have separate functions and separate data structures—they're unified in one object.

Where You Already Use It: UVM Components

The entire UVM architecture is object-oriented:

// INHERITANCE: my_driver extends (inherits from) uvm_driver
class my_driver extends uvm_driver #(axi_transaction);
  
  // ENCAPSULATION: driver owns its interface handle
  virtual axi_if vif;
  
  // POLYMORPHISM: we override the parent's run_phase
  // When UVM calls run_phase, it calls OUR version
  task run_phase(uvm_phase phase);
    forever begin
      seq_item_port.get_next_item(req);
      drive_transaction(req);  // Our implementation
      seq_item_port.item_done();
    end
  endtask
  
endclass

Let's break down the OOP concepts:

Inheritance (extends): Your driver inherits all the machinery of uvm_driver—ports, phases, reporting—without rewriting it. You only add what's specific to your protocol.

class axi_driver extends uvm_driver;        // Base functionality
class axi_master_driver extends axi_driver;  // Adds master-specific behavior
class axi_slave_driver extends axi_driver;   // Adds slave-specific behavior

Polymorphism (virtual methods): Different drivers can have different run_phase implementations, but UVM calls them all the same way. The system doesn't need to know which specific driver it's dealing with.

// UVM internally does something like:
foreach (all_components[i]) begin
  all_components[i].run_phase(phase);  // Calls the RIGHT version
end
// Works whether it's axi_driver, pcie_driver, or usb_driver

Encapsulation: The driver's internal state (vif, pending transactions, etc.) is hidden. Other components interact through defined interfaces (TLM ports), not by reaching into the driver's internals.

OOP Thinking

When you think in OOP, you ask: "What are the things in my system, and how do they interact?"

  • A transaction is a thing that carries data between components
  • A driver is a thing that converts transactions to signals
  • A monitor is a thing that observes signals and creates transactions
  • An agent is a thing that contains a driver, monitor, and sequencer

Paradigm 3: Declarative Programming

What It Means

Declarative programming describes what you want, not how to achieve it. You state the desired outcome or the rules that must be satisfied, and the system figures out how to make it happen.

Key characteristics:

  • Describe relationships and constraints
  • No explicit control flow
  • The "how" is handled by a solver/engine
  • Focus on correctness, not procedure

Where You Already Use It: Constraints

This is the paradigm shift most DV engineers don't recognize. When you write constraints, you're doing declarative programming:

class ethernet_frame extends uvm_sequence_item;
  
  rand bit [47:0] dst_mac;
  rand bit [47:0] src_mac;
  rand bit [15:0] ethertype;
  rand bit [7:0]  payload[];
  rand int        frame_size;
  
  // DECLARATIVE: State WHAT must be true, not HOW to make it true
  constraint valid_frame {
    frame_size inside {[64:1518]};                    // Rule 1
    payload.size() == frame_size - 18;               // Rule 2
    ethertype >= 16'h0600;                            // Rule 3
    dst_mac != src_mac;                               // Rule 4
    (ethertype == 16'h0800) -> (payload.size() >= 20); // Rule 5 (IP)
  }
  
endclass

Notice what you did NOT write:

  • No loop to try different frame sizes
  • No if/else to check validity
  • No algorithm to find valid combinations
  • No backtracking when a choice doesn't work

You just declared the rules. The constraint solver figures out how to generate valid values.

Imperative vs Declarative: The Same Problem

Let's generate a valid ethernet frame both ways:

Imperative (How):

function void generate_frame_imperative();
  // Step 1: Pick a frame size
  frame_size = $urandom_range(64, 1518);
  
  // Step 2: Calculate payload size
  int payload_size = frame_size - 18;
  
  // Step 3: Allocate payload
  payload = new[payload_size];
  
  // Step 4: Pick ethertype (must be >= 0x0600)
  ethertype = $urandom_range(16'h0600, 16'hFFFF);
  
  // Step 5: If IP, check payload is big enough
  if (ethertype == 16'h0800 && payload_size < 20) begin
    // Oops, need to fix it
    frame_size = 64;  // Minimum that works
    payload_size = frame_size - 18;
    payload = new[payload_size];
  end
  
  // Step 6: Generate MACs (must be different)
  dst_mac = {$urandom(), $urandom()}[47:0];
  do begin
    src_mac = {$urandom(), $urandom()}[47:0];
  end while (src_mac == dst_mac);
  
  // Step 7: Fill payload
  foreach (payload[i]) payload[i] = $urandom();
endfunction

Declarative (What):

constraint valid_frame {
  frame_size inside {[64:1518]};
  payload.size() == frame_size - 18;
  ethertype >= 16'h0600;
  dst_mac != src_mac;
  (ethertype == 16'h0800) -> (payload.size() >= 20);
}

// Usage: just call randomize()
if (!frame.randomize()) `uvm_fatal("GEN", "Randomization failed")

The declarative version is:

  • Shorter: 5 lines vs 20+ lines
  • Clearer: Rules are explicit, not buried in control flow
  • More robust: Solver handles all edge cases
  • Composable: Easy to add more constraints

Where You Already Use It: Assertions

SystemVerilog Assertions (SVA) are declarative:

// DECLARATIVE: State what must be true over time
property req_followed_by_ack;
  @(posedge clk) 
  req |-> ##[1:5] ack;  // If req, then ack within 1-5 cycles
endproperty

assert property (req_followed_by_ack);

You declared the rule. You didn't write code to:

  • Watch for req going high
  • Start a counter
  • Check ack each cycle
  • Report if counter exceeds 5

The assertion engine handles all of that.

Where You Already Use It: Coverage

covergroup axi_cg @(posedge clk);
  
  // DECLARATIVE: What to measure, not how to count
  addr_cp: coverpoint txn.addr[31:28] {
    bins low  = {[0:3]};
    bins mid  = {[4:11]};
    bins high = {[12:15]};
  }
  
  size_cp: coverpoint txn.size {
    bins sizes[] = {1, 2, 4, 8};
  }
  
  // Cross coverage: all combinations
  addr_size_cross: cross addr_cp, size_cp;
  
endgroup

You declared what to measure. The coverage engine tracks it automatically.

Declarative Thinking

When you think declaratively, you ask: "What properties must be true?" or "What are the rules?"

You don't worry about:

  • The order of evaluation
  • How the solver searches for solutions
  • What happens if one rule conflicts with another

The Three Paradigms in One Testbench

Here's a complete example showing all three paradigms working together:

// ═══════════════════════════════════════════════════════════════
// OBJECT-ORIENTED: Define the "things" in our system
// ═══════════════════════════════════════════════════════════════
class pcie_tlp extends uvm_sequence_item;
  `uvm_object_utils(pcie_tlp)
  
  // Data (properties)
  rand tlp_type_e  tlp_type;
  rand bit [63:0]  address;
  rand bit [9:0]   length;
  rand bit [7:0]   tag;
  rand bit [31:0]  data[];
  
  // ═══════════════════════════════════════════════════════════════
  // DECLARATIVE: Define the rules for valid TLPs
  // ════════════════════════���══════════════════════════════════════
  constraint valid_tlp {
    // Memory transactions must be DWORD aligned
    (tlp_type inside {MEM_RD, MEM_WR}) -> (address[1:0] == 2'b00);
    
    // Config transactions limited to 4KB space
    (tlp_type inside {CFG_RD, CFG_WR}) -> (address < 4096);
    
    // Write must have data, read must not
    (tlp_type == MEM_WR) -> (data.size() == length);
    (tlp_type == MEM_RD) -> (data.size() == 0);
    
    // Length limits
    length inside {[1:1024]};
  }
  
  // Behavior (methods)
  function bit is_memory_transaction();
    return tlp_type inside {MEM_RD, MEM_WR};
  endfunction
  
  function int get_payload_bytes();
    return data.size() * 4;
  endfunction
  
endclass


// ═══════════════════════════════════════════════════════════════
// OBJECT-ORIENTED: Component structure with inheritance
// ═════════════════════════��═════════════════════════════════════
class pcie_driver extends uvm_driver #(pcie_tlp);
  `uvm_component_utils(pcie_driver)
  
  virtual pcie_if vif;
  
  // ═══════════════════════════════════════════════════════════════
  // IMPERATIVE: Step-by-step signal driving
  // ═══════════════════════════════════════════════════════════════
  task drive_tlp(pcie_tlp tlp);
    // Step 1: Drive header
    vif.tlp_valid <= 1'b1;
    vif.tlp_type  <= tlp.tlp_type;
    vif.tlp_addr  <= tlp.address;
    vif.tlp_len   <= tlp.length;
    vif.tlp_tag   <= tlp.tag;
    
    // Step 2: Wait for ready
    @(posedge vif.clk);
    while (!vif.tlp_ready) @(posedge vif.clk);
    
    // Step 3: Drive data (if write)
    if (tlp.tlp_type == MEM_WR) begin
      foreach (tlp.data[i]) begin
        vif.tlp_data <= tlp.data[i];
        @(posedge vif.clk);
        while (!vif.tlp_ready) @(posedge vif.clk);
      end
    end
    
    // Step 4: Complete transaction
    vif.tlp_valid <= 1'b0;
  endtask
  
  task run_phase(uvm_phase phase);
    forever begin
      seq_item_port.get_next_item(req);
      drive_tlp(req);
      seq_item_port.item_done();
    end
  endtask
  
endclass


// ═══════════════════════════════════════════════════════════════
// IMPERATIVE: Sequence orchestration
// ═══════════════════════════════════════════════════════════════
class pcie_read_write_sequence extends uvm_sequence #(pcie_tlp);
  `uvm_object_utils(pcie_read_write_sequence)
  
  task body();
    pcie_tlp write_tlp, read_tlp;
    
    // Step 1: Create and send a write
    write_tlp = pcie_tlp::type_id::create("write_tlp");
    start_item(write_tlp);
    
    // DECLARATIVE: Constrain the randomization
    if (!write_tlp.randomize() with {
      tlp_type == MEM_WR;
      address == 64'h0000_1000;
      length == 4;
    }) `uvm_fatal("SEQ", "Randomization failed")
    
    finish_item(write_tlp);
    
    // Step 2: Create and send a read to same address
    read_tlp = pcie_tlp::type_id::create("read_tlp");
    start_item(read_tlp);
    
    // DECLARATIVE: Constrain the randomization
    if (!read_tlp.randomize() with {
      tlp_type == MEM_RD;
      address == write_tlp.address;  // Same address
      length == write_tlp.length;    // Same length
    }) `uvm_fatal("SEQ", "Randomization failed")
    
    finish_item(read_tlp);
    
    // Step 3: Get and check response
    get_response(rsp);
    // ... check data matches
  endtask
  
endclass

Common Paradigm Mistakes

Mistake 1: Imperative Thinking in Constraints

DV engineers new to constraints often try to write them imperatively:

// WRONG: Trying to use assignment (imperative thinking)
constraint bad_constraint {
  if (mode == READ)
    length = 4;        // This is NOT assignment!
  else
    length = 8;
}

// RIGHT: Declarative relationship
constraint good_constraint {
  (mode == READ)  -> (length == 4);
  (mode == WRITE) -> (length == 8);
}

In constraints, = is equality (a relationship), not assignment (an action).

Mistake 2: Over-Using Inheritance (OOP Abuse)

// WRONG: Class explosion for what should be data
class small_packet extends packet;
class medium_packet extends packet;
class large_packet extends packet;
class jumbo_packet extends packet;
// 4 classes for what could be one field!

// RIGHT: Use data, not class hierarchy
class packet extends uvm_sequence_item;
  rand packet_size_e size;  // SMALL, MEDIUM, LARGE, JUMBO
  
  constraint size_ranges {
    (size == SMALL)  -> (length inside {[64:127]});
    (size == MEDIUM) -> (length inside {[128:511]});
    (size == LARGE)  -> (length inside {[512:1518]});
    (size == JUMBO)  -> (length inside {[1519:9000]});
  }
endclass

Use inheritance for different behavior, not different data values.

Mistake 3: Missing Declarative Opportunities

// WRONG: Imperative array processing
function int find_max(int arr[]);
  int max_val = arr[0];
  for (int i = 1; i < arr.size(); i++) begin
    if (arr[i] > max_val)
      max_val = arr[i];
  end
  return max_val;
endfunction

// RIGHT: Use built-in declarative method
int max_val = arr.max();  // SystemVerilog has this!

SystemVerilog has declarative array methods—use them:

// Finding elements
int evens[$] = arr.find() with (item % 2 == 0);    // All even numbers
int first_big = arr.find_first() with (item > 100); // First > 100
int indices[$] = arr.find_index() with (item < 0);  // Indices of negatives

// Aggregation
int total = arr.sum();         // Sum all elements
int count = arr.sum() with (item > 0 ? 1 : 0);  // Count positives
int product = arr.product();   // Multiply all elements

// Ordering
arr.sort();                    // Sort ascending
arr.rsort();                   // Sort descending
arr.shuffle();                 // Random order

// Uniqueness
int unique_vals[$] = arr.unique();  // Remove duplicates

Choosing the Right Paradigm

SituationParadigmWhy
Driving signals on a busImperativeSequence of timed operations
Defining valid transactionsDeclarative (constraints)Rules, not steps
Structuring testbench componentsOOPThings with behavior
Checking protocol timingDeclarative (assertions)Temporal relationships
Orchestrating test flowImperativeSequential phases
Measuring coverageDeclarativeWhat to track, not how
Processing collected dataMix: OOP structure + declarative array methodsBest of both

Key Takeaways

  • You already use three paradigms: imperative (sequences), OOP (components), declarative (constraints)
  • Imperative = How: Step-by-step instructions, order matters
  • OOP = Things: Objects with data and behavior, inheritance, polymorphism
  • Declarative = What: Rules and relationships, let the system figure out how
  • Constraints are NOT imperative: Think relationships, not assignments
  • Match paradigm to problem: Don't force imperative thinking on declarative problems
  • Use declarative array methods: find, sum, sort, unique—they exist!

Further Reading

  • Structure and Interpretation of Computer Programs, Chapter 1 - Abelson & Sussman
  • Programming Paradigms for Dummies - Peter Van Roy (free online)
  • Object-Oriented Software Construction, Chapter 2 - Bertrand Meyer
  • SystemVerilog LRM - Sections on constraints, assertions, and array methods

Interview Corner

Q: What's the difference between imperative and declarative programming?

A: Imperative programming specifies how to accomplish something through a sequence of statements—do this, then that. Declarative programming specifies what you want without detailing the steps—state the rules, and let the system figure out how. In verification, sequences are imperative (step-by-step transaction flow), while constraints are declarative (rules for valid values).

Q: Why does the UVM use object-oriented programming?

A: OOP provides inheritance (reuse base functionality), polymorphism (swap components without changing callers), and encapsulation (hide complexity). This lets you write a driver once and extend it for variants, override methods for customization, and build modular testbenches from reusable components. The factory pattern depends on polymorphism—you can substitute derived classes without modifying the code that creates them.


Return to Software Engineering for DV

Author
Mayur Kubavat
VLSI Design and Verification Engineer sharing knowledge about SystemVerilog, UVM, and hardware verification methodologies.

Comments (0)

Leave a Comment