Python for RTL Verification_ a Complete Course in Python, -- Ray Salemi -- FR, 2022 -- Independently Published -- 9788582338148 -- 5f7e63b96fd59e323997a4157f9ce0e1 -- Anna’s Archive
Python for RTL Verification_ a Complete Course in Python, -- Ray Salemi -- FR, 2022 -- Independently Published -- 9788582338148 -- 5f7e63b96fd59e323997a4157f9ce0e1 -- Anna’s Archive
Contents
Python for RTL Verification
Contents
Introduction
The book assumes you know how to program
Code examples
Why Python and why UVM?
The methodology wars
What about VHDL?
Why Python?
Why cocotb?
Why UVM and pyuvm?
Summary
Python concepts
Python objects
Functions vs methods
Dynamic typing
Summary
Python basics
Built-in constants
None
True/False
Built-in number types
bool
int
float
complex
Operators
Bitwise operators
Conditional operators
The id() function
Combining conditions
Augmented assignments
Constructors
Setting variables to constants
Calling constructors
Comments
Summary
Conditions and loops
if Statements
elif replaces case and switch
Block comments and indentation
Conditional assignment
while loops
The continue statement
The break statement
Summary
Python sequences
Immutable sequence operations
Mutable sequence operations
The for loop
Summary
Tuples
Creating tuples
Assigning tuple values
Accessing individual tuple items
Looping through a tuple
Tuples are immutable
Returning multiple values
Summary
Ranges
Creating ranges
Using a range in a for loop
Summary
Strings
Triple quotes and triple double-quotes
String operations
String methods
Formatted Strings
Summary
Lists
Three ways to create lists
List comprehension
Sorting a list using a method
Sorting a list using a function
Summary
Sets
Creating sets
Set Operations
Checking set memberships
Checking for overlap
Comparison Operators
Combination Operations
Removing Duplicates
Summary
Commonly used sequence operations
Checking for membership
Finding the number of items in a sequence
Concatenating sequences
Repeating sequences
Indexing into a sequence
Emulating a two-dimensional array
Create a new sequence from an existing sequence using a slice
Access a range of sequence objects using a step greater than one
Find the maximum and minimum objects in a sequence
Find the index of a value in a sequence
Finding the first instance of a value
Finding the first instance of a value after a specified index
Finding the index of an item in a range
Count the number of values in a sequence
Summary
Modules
A quick word about scopes
What happens when you import a module?
Importing a module with the import statement
Aliasing a module
Importing resources to our dir() list
Importing all resources from a module
Python coding style shuns import *
Finding modules
Summary
Functions
Defining a function
Functions are callable objects
Naming convention in this book
Arguments
Defining Arguments
Default and non-default arguments
Calling functions using arguments
Defining a variable number of arguments
Documentation strings
Summary
Exceptions
Catching exceptions
Catching multiple exceptions
Raising an exception after processing it
Printing an exception object
Finally! A finally block
The assert statement
The assert statement is not a function
Summa2ry
Dictionaries
Creating a Dictionary
Create and fill an empty dictionary
Create a dictionary using the data that will fill it
Use dictionary comprehension
Handling missing keys
The get() method
The setdefault() method
Dictionaries and for loops
The .keys(), .values(), and .items() iterators
Removing items from a dictionary
Summary
Generators
Summary
Classes
Defining classes and instantiating objects
Functions vs. methods
Defining method attributes
The self variable
Initializing an object
Class variables
Class methods
Static methods
Summary
Protecting attributes
Protected attributes
Private attributes
Properties
Summary
Inheritance
Inheritance in Python
The super() function
Calling methods using class objects
Multiple Inheritance
Multiple Inheritance and init()
Summary
Design patterns
The singleton pattern
The factory pattern
Summary
Coroutines
An event loop for simulation
Defining coroutines
Awaiting simulation time
Starting tasks
Ignoring a running task
Awaiting a running task
Running tasks in parallel
Returning values from tasks
Killing a task
Summary
cocotb Queue
Task communication
Blocking communication
An infinitely long queue
A Queue of size 1
Queues and simulation delay
Nonblocking communication
Summary
Simulating with cocotb
Verifying a counter
cocotb triggers
Testing reset_n
Importing modules and objects
The tinyalu_utils module
The get_int() function
The logger object
Starting the clock and running the test
Passing (or failing) a test
Checking that the counter counts
A common coroutine mistake
Summary
Basic testbench: 1.0
The TinyALU
A cocotb testbench
Importing modules
The Ops enumeration
The alu_prediction() function
Setting up the cocotb TinyALU test
Sending commands
Sending a command and waiting for it to complete
Checking the result
Finishing the test
Summary
TinyAluBfm
The TinyALU BFM coroutines
The tinyalu_utils module
Living on the clock edge
The TinyAluBfm singleton
Initializing the TinyAluBfm object
The reset() coroutine
The communication coroutines
result_mon()
cmd_mon()
cmd_driver()
Launching the coroutines using start_soon()
Interacting with the bfm loops
get_cmd()
get_result()
send_op()
The cocotb test
Sending commands
Monitoring the commands
Summary
Class-based testbench: 2.0
The class structure
The BaseTester class
The RandomTester
The MaxTester
The Scoreboard class
Initialize the scoreboard
Define the data-gathering tasks
The Scoreboard’s start_tasks() function
The Scoreboard’s check_results() function
The execute_test() coroutine
The cocotb tests
Summary
Why UVM?
How do we define tests?
How do we build testbenches?
How do we reuse testbench components?
How do we create verification IP?
How do multiple components monitor the DUT?
How do we create stimulus?
How do we share common data?
How do we modify our testbench’s structure in each test?
How do we log messages?
How do we pass data around the testbench?
Summary
uvm_test testbench: 3.0
The HelloWorldTest class
Refactoring testbench 2.0 into the UVM
The BaseTest class
The RandomTest and MaxTest classes
Summary
uvm_component
1. build_phase(self)
2. connect_phase(self)
3. end_of_elaboration_phase(self)
4. start_of_simulation_phase(self)
5. run_phase(self)
6. extract_phase(self)
7. check_phase(self)
8. report_phase(self)
9. final_phase(self)
Running the phases
Building the testbench hierarchy
The uvm_component instantiation arguments.
TestTop (uvm_test_top)
MiddleComp (uvm_test_top.mc)
BottomComp (uvm_test_top.mc.bc)
Running the simulation
Summary
uvm_env testbench: 4.0
Converting the testers to UVM components
BaseTester
RandomTester and MaxTester
__init()__ and UVM components
Scoreboard
Using an environment
Creating RandomTest and MaxTest
Summary
Logging
Creating log messages
Logging Levels
Setting Logging Levels
Logging Handlers
logging.StreamHandler
logging.FileHandler
Adding a handler
Removing a handler
Removing the default StreamHandler
Disabling logging
Changing the log message format
Summary
ConfigDB()
A hierarchy-aware dictionary
The ConfigDB().get() method
The ConfigDB().set() method
Wildcards
Global data
Longest path wins
Parent/child conflicts
Summary
Debugging the ConfigDB()
Missing data
Catching exceptions
Printing the ConfigDB
Debugging parent/child conflict
Tracing ConfigDB() operations
Summary
The UVM factory
The create() method
uvm_factory()
Creating objects using uvm_factory()
Overriding types
Factory overrides by instance
Using the create() method carefully
Debugging uvm_factory()
Logging uvm_factory() data
Summary
UVM factory testbench: 5.0
AluEnv
RandomTest
MaxTest
Summary
Component communications
Why use TLM 1.0?
Operations
Styles of operations
Ports
Exports
uvm_tlm_fifo
Nonblocking communication in pyuvm
Debugging uvm_tlm_fifo
Summary
Analysis ports
The uvm_analysis_port
Extend the uvm_analysis_export class
Forgetting to override write()
Extend the uvm_subscriber class
Instantiate a uvm_tlm_analysis_fifo
Providing the analysis_export data attribute
Summary
Components in testbench 6.0
The testers
BaseTester
RandomTester and MaxTester
Driver
Monitor
Coverage
The Scoreboard
Scoreboard.build_phase()
Scoreboard.connect_phase()
Scoreboard.check_phase()
Summary
Connections in testbench 6.0
The AluEnv TLM diagram
AluEnv.build_phase()
AluEnv.connect_phase()
RandomTest and MaxTest
Summary
uvm_object in Python
Creating a string from an object
Comparing objects
Copying and cloning
Copying with Python
Using super() in methods
Copying using the UVM
do_copy()
Summary
Sequence testbench: 7.0
UVM Sequence Overview
Driver
AluEnv
AluSeqItem
Creating sequences
BaseSeq
RandomSeq and MaxSeq
Starting a sequence in a test
BaseTest
RandomTest and MaxTest
Summary
Fibonacci testbench: 7.1
Fibonacci numbers
FibonacciSeq
Driver
Sequence timing
AluEnv
Summary
get_response() testbench: 7.2
AluResultItem
Driver
FibonacciSeq
get_response() pitfalls
Summary
Virtual sequence testbench: 8.0
Launching sequences from a virtual sequence
Running sequences in parallel
Creating a programming interface
OpSeq
TinyALU programming interface
FibonacciSeq
Summary
The future of Python in verification
Leverage the existing SystemVerilog ecosystem.
Create a Python verification ecosystem.
Bringing EDA into the 21st century
Acknowledgements
Appendix
Other Books by Ray Salemi
The Tucker Mysteries (Writing as Ray Daniel)
Copyright
License
Indemnification
Introduction
Python for RTL Verification is a book for verification engineers who want to use Python and Python engineers
who want to learn the Universal Verification Methodology (UVM).
New applications such as machine learning are forcing verification engineers to develop new verification
techniques and leverage broader ecosystems of libraries. As the most popular programming language on the
planet, Python has the capacity and ecosystem to solve modern verification challenges. Testbench developers
will either access Python libraries from SystemVerilog or VHDL or write their testbenches in Python.
This book teaches you how to write testbenches in Python. It teaches you enough Python to use the pyuvm
and cocotb modules to create the next generation of testbenches.
Code examples
You can get a copy of the code examples in this book at pythonverification.com. There are two types of
examples, Jupyter Notebook examples and directory examples. The Jupyter Notebook examples demonstrate
the Python language. The directory examples are in directories demonstrate cocotb interacting with a
simulator. We store these in directories. The instructions to run cocotb examples are also in README.md .
The example files and directories have the same names as the chapters, and the numbers at the beginning the
names cause them to print in the same order as the book chapters when you list them alphabetically.
Each example has a figure number that matches the example in the book to the example in the code. The
numbers reset each chapter. For example, figure 1 demonstrates the classic first program we write in all
languages. It is in the file 00_lntroduction.ipynb .
All the book's code examples use the same format, a figure number and description, the code, then a line with
a separator --, followed by the output.
print("Hello, world.")
- -
Hello, world.
We have argued whether VHDL or Verilog is the better hardware design language for thirty years. But one
cannot claim that either of them was initially designed to create testbenches. Testbench development is a
software problem not readily solved by hardware design languages. Several approaches have recognized and
addressed the need for a testbench language.
In 1996, a startup named InSpec took the most obvious approach and announced an entirely new language
named e . Explicitly designed for verification, e introduced the industry to object-oriented and aspect-oriented
programming. It also championed what was then an advanced verification technique, constrained-random
verification, implemented with a methodology called the e Reuse Methodology (eRM).
e gained popularity, but it was limited in its growth by its very newness. Teams that adopted e often needed
considerable consulting help to create their first testbenches. The other problem was that e was proprietary to
Verisity (the renamed InSpec). You needed to buy Verisity’s software, and only their software, to run an e
testbench.
Three years later, another startup named Co-Design Automation announced SUPERLOG, which extended the
standard Verilog language to provide verification-friendly concepts such as interfaces. Co-Design immediately
put SUPERLOG into the public domain, intending to create an open ecosystem.
The competition between e and SUPERLOG tilted in SUPERLOG’s favor when Synopsys bought Co-Design.
Synopsys donated its verification languages Vera and SUPERLOG to the Accellera standards body. Accellera
created SystemVerilog by merging Vera, SUPERLOG, and the ForSpec assertion language.
In 2005 and 2006, the EDA industry’s three dominant software companies responded to the need for a single,
compatible methodology by creating three incompatible methodologies:
Cadence, who had bought Verisity, delivered the Universal Reuse Methodology (URM)
Synopsys, who had bought Co-Design, delivered the Verification Methodology Manual (VMM)
Mentor Graphics, who had committed to SystemVerilog, delivered the Advanced Verification Methodology
(AVM)
These three companies vied for dominance, trying to convince customers to rewrite testbenches from one
methodology to the other. Unfortunately (from the EDA customer’s point of view), incompatible methodologies
made it hard to switch simulators. This angered simulator customers who craved healthy competition between
their simulation vendors.
Having three standard methodologies wasn’t serving anyone. So in 2008, Mentor Graphics and Cadence
merged their methodologies to create the Open Verification Methodology (OVM).ethodology (OVM).
Once two EDA vendors make a move, the third is sure to follow. So, when Cadence and Mentor Graphics
donated the OVM to Accellera, Synopsys joined the team to create the Universal Verification Methodology
(UVM).
In 2017, ten years after creating the first methodologies, the IEEE released the 1800.2 Spec, codifying the UVM.
The VHDL engineering community sidestepped the methodology wars for many years, allowing engineers to
devise unique solutions to creating VHDL testbenches.
This state of affairs changed in 2012 when Jim Lewis created the Open Source VHDL Verification Methodology
(OSVVM), a VHDL-based methodology that provided constrained-random constructs and a standardized way of
encapsulating functionality. In 2015 Espen Tallaksen created the Universal VHDL Verification Methodology
(UVVM) 2 . As of this writing, engineers are using both methodologies.
Why Python?
It is natural to think of testbenches as hardware test fixtures, and so one would design “hardware” that drives
signals into the device under test (DUT) and checks results.
However, this is a rudimentary way of thinking about testbenches. It can work for the most uncomplicated
cases, but it is quickly overwhelmed by larger designs.
It is better to think of testbenches as software that communicates with the DUT through drivers. The drivers, in
this case, are APIs that communicate with a bus functional model.
The device under test (DUT) —This is the hardware block or chip we’re verifying. We instantiate the
DUT in the testbench and connect testbench signals to its inputs and outputs.
Bus functional models (BFMs) —Bus functional models connect to the DUT and implement the
communication protocols the DUT expects. For example, you’d have a USB bus functional model if the
DUT talks using the USB protocol. The diagram calls the interfaces A and B.
BFM application programming interface (API) —Programming interfaces are blocking function calls
that connect the testbench software to an RTL simulation. One can implement them using threads,
SystemC, or, in our case, cocotb.
Testbench software —This software creates operations and sends them to the DUT through the BFMs. It
reads results using the BFMs and compares the actual results to the expected results. It also checks that
we’ve verified all the functionality in the DUT.
Once we understand that our testbench is software, we have to ask, “Why are we writing complicated software
using a register transfer language, even an enhanced RTL such as SystemVerilog?” The answer is the same as
why the size of the space shuttle’s solid rocket boosters was related to the axle length of a Roman chariot—
incrementalism.
The EDA industry created the UVM so that engineers could use a supercharged register transfer language,
running in an event-driven simulator, to write software. But, a register transfer language, even a supercharged
one, is not the best tool for software development.
It makes more sense to use a powerful software programming language to write powerful software. Python is
a powerful programming language, and packages such as cocotb and pyuvm allow us to create powerful
testbenches.
Why cocotb?
cocotb stands for Coroutine CoSimulation TestBench. Python developers added coroutines to Python to
provide a mechanism for event-driven code. For example, you might want code to respond when a user
presses a key on a keyboard.
cocotb’s developers recognized that event-driven Python code would work well with an event-driven
simulator. So they created a package that interacts with a running simulator. cocotb beautifully links Python
code to the simulator, and so it was the perfect foundation for pyuvm.
The UVM is a flexible class library that supports creating reusable hierarchical testbenches in which block-level
testbenches quickly become monitoring resources when instantiated in larger testbenches. It has a powerful
stimulus-generating system that hides testbench complexity from test writers. And it provides a standardized
Verification IP (VIP) structure that allows VIP vendors to ensure that their offerings will fit into any UVM
testbench.
What makes the UVM challenging to use often has more to do with the limitations of SystemVerilog than the
inherent complexity of the class library. For example, the obscure incantations needed to operate the UVM
factory or configuration database are responses to the strong typing that permeates SystemVerilog.
pyuvm implements the IEEE 1800.2 UVM specification in Python. We’ll see that problems such as typing
complexity don’t exist in Python, making reuse more straightforward.
Combining a world-class software development language with a world-class verification class library creates a
world-class solution. One you will learn to use in this book.
Summary
Python combined with the UVM is a powerful way to create testbenches. This book teaches you enough
Python to create testbenches using cocotb and pyuvm . You can find the example at
www.pythonverification.com .
Python concepts
To appreciate the difference between Python and SystemVerilog or VHDL, one has to indulge in a little bit of
computer language history and remember that programming started with engineers writing machine code to
manipulate bits. Eventually, the machine code was abstracted to assembly code, and the assembly code was
abstracted to programming languages. But after compilation, all of these languages boiled down to
manipulating bits.
The challenge in manipulating bits is that the data they represent come in many sizes. For example, an int in
C contains 16 bits, and a char contains 8 bits. Data would get lost if a programmer accidentally wrote 16 bits
of an int into the 8 bits reserved for a char .
Most programming languages address this problem with types . Variable types define the memory size
associated with variables and check that programs transfer data between compatible variables, also known as
compatible types.
Languages such as Pascal and VHDL are strict when it comes to typing. For example, they issue a syntax error if
you try to assign variables of different types, even if the sizes are the same.
On the other hand, C and SystemVerilog are more permissive, allowing you to transfer data between different
types of compatible sizes. In SystemVerilog, for example, you can put an 8-bit byte unsigned into a 16-bit int
unsigned , and SystemVerilog will automatically set the extra eight bits to zeros. This can be dangerous since
SystemVerilog will happily chop off the top eight bits if you put a 16-bit int unsigned into an 8-bit byte
unsigned .
Typing becomes a language challenge when creating higher-level data structures. For example, if you create a
SystemVerilog FIFO that transfers int data, you don’t want to copy and modify the FIFO to create one for
byte data. Instead, you want to reuse the code and change only the type.
Languages such as SystemVerilog and C++ address this problem using parameterization . You write the FIFO
once, but leave the data’s type flexible until you declare a variable as in the SystemVerilog in figure 1.
SystemVerilog calls the spot between the #() a parameter , while VHDL calls the equivalent thing a generic.
Managing parameters can make it challenging to learn the SystemVerilog UVM.
Python objects
We’ve seen that languages such as SystemVerilog use types to define the number of bits needed to store a
datum. Python sidesteps the problem of counting bits because assignments in Python do not transfer bits.
They transfer objects. More precisely, Python variables store object handles.
Everything we manipulate in Python is an object. An object is an instance of a class . A class defines how an
object behaves. All objects of the same class behave the same way. We’ll see later that we can define classes,
but we will focus on Python’s built-in classes, for now, the ones that come defined with the language.
Languages such as C++ differentiate between types and classes. However, in Python, types are the same things
as classes. So we use the type() function to find an object’s class.
For example, let’s consider the number 5 . Using the type function in figure 2, we see that the number 5 is an
instance of class int . We call instances of classes objects .
print(type(5) )
--
<class 'int'>
Since everything in a Python program (classes, functions, constants, types, etc.) is an object, we don’t have to
worry about data size when transferring data between variables. And that means no type checking and no
parameterization.
Consider a situation where we want to store a floating-point number in a variable containing an integer.
Unfortunately, this transfer can’t work in a typed language because the memory size is different for floating-
point and integer.
Figure 3 illustrates a Python example with a variable named AA that stores an integer and a variable named
BB that stores a floating-point number. We see that when we assign BB to AA , Python copies the handle to
the object, not the data.
Figure 3: Storing handles not data
Python disposes of the 6000 during periodic garbage collection. We can implement the scenario in figure 3
using the code in figure 4.
Functions vs methods
Functions and methods are both subroutines that return a value when you call them. The difference between
them is that we call functions directly, whereas we call methods in the context of a given object. Methods are
functions defined inside a class.
For example, figure 5 creates a string (type str ) object by setting a variable ( msg ) to a string. Then we’ll do
the following:
Call the type() function to get the type of msg and pass that to the print() function. We see that msg
is an instance of the str class.
The str class defines a function named endswith() . (A function defined within a class is called a
method .) endswith() returns True if its argument matches the end of the string. Notice that we call
endswith() using the msg variable. We can do this because endswith() is a method in the str class.
The msg.endswith("a bang") line demonstrates another feature of methods. They can operate on the object
that calls them. The msg.endswith("a bang") automatically compares " a bang " to msg . There is no need to
put both in the argument list.
Dynamic typing
SystemVerilog and VHDL force their programmers to ask permission before accessing data. The programmers
must declare a variable to hold the data, and the compiler checks the types on the assignment statement. This
declaration-based typing is called static typing.
Python uses dynamic typing . Dynamic typing allows programmers to ask forgiveness rather than permission.
It allows us to attempt any operation on any object. Python raises an error if the object cannot implement the
operation.
The code in figure 6 tries to use a string method on an integer. The endswith() returns True if the string
ends with the argument. This works when we call mystring.endswith() because str defines endswith() . If
we try to call myint.endswith() , we get an error message.
# Figure 6: Calling an undefined method
mystring = "Hello, World"
print(mystring.endswith("orld") )
myint = 42
print(myint.endswith("a whimper") )
--
True
-----------
AttributeError Traceback
2 print(mystring.endswith("orld") )
3 myint = 42
----> 4 print(myint.endswith("a whimper") )
AttributeError: 'int' object has no attribute 'endswith'
The AttributeError is called an exception and we’ll look at them in more detail later. The exception stops the
program at the spot where we transgressed. We’ll see later how to ask forgiveness when an operation raises
an exception.
Summary
Python for RTL Verification teaches enough Python for a verification engineer to create standard testbenches
using the UVM.
Python is different than SystemVerilog and VHDL in that it is dynamically typed. Every variable stores a handle
to an object, and everything is an instance of an object. Dynamic typing makes it easier to create testbenches
and results in a more straightforward implementation of the UVM in Python than in SystemVerilog.
Of course, there will be runtime bugs that would have been caught by static typing. It’s been my experience
that these errors are rare enough that the work of matching parameters is not worth the time lost on a
testbench that takes more than a minute to compile.
Python basics
Since everything in Python is an instance of a class, Python defines a set of built-in classes, such as str and
int alongside built-in constants. We'll examine these in this chapter.
The word type is interchangeable with class , though it usually refers to a built-in class.
Built-in constants
When you say byte xx = 5 in a language such as SystemVerilog, the program sets aside an 8-bit memory
location and stores 0x05 in it. When you say xx = 5 in Python, the program instantiates an instance of an
int class of value 5 . The xx variable gets the handle to that int object.
Once Python instantiates a constant value, it uses the same handle whenever any other part of your program
uses that constant. In addition to user-defined constants such as 5 , Python has built-in constants. Here is a
list of some of them.
None
Use this object to initialize an empty variable. Functions with no useful return value such as print() also
return None .
True/False
These are Boolean constants that you can test in a conditional. If you pass a value to a conditional statement, it
gets converted to one of these constants. For example, None always converts to False .
bool
The bool type has two self-explanatory constants True and False .
int
A numeric value without a decimal point. There is no maximum int in Python.
float
A numeric value with a decimal point. An int value converts to a float when in an operation with a float .
The division operator ( / ) always returns a float .
complex
Not used in verification often, but cool nonetheless. Any number with a j (engineering style imaginary
notation) is complex .
Operators
Python has all the same operators as SystemVerilog and VHDL. You can read the details of all the operators
(and anything Python) at docs.python.org . This section provides a reference overview of operators often used
in verification.
Bitwise operators
Python has the same bitwise operators as SystemVerilog or C.
Conditional operators
Python has the same conditional operators as SystemVerilog and C with the addition of is .
Table 2: Conditional operators
The is operator checks that the two objects being compared are the same object. You should use is to
check whether a variable contains None .
This is an appropriate moment to talk about the id() function. Every object in a Python program has a unique
identification number. If two variables contain objects with the same ID number, they contain handles to the
same object.
For example, we put the constant 5 into xx and yy and then see that the constant is the same object in both
variables. We can check this using the is operator.
Python is efficient in that it only evaluates enough conditions to tell whether the overall condition will be True
or False .
The or operator only evaluates the second half of an expression if the first half is False since, if the first
half of the expression is True , the second half is irrelevant.
The and operator only evaluates the second half of an expression if the first half is True since, if the first
half is False , the second half is irrelevant.
The not operator is evaluated after conditional expressions. not 1 == 2 acts like not (1 == 2) .
This behavior allows us to test that an operation is legal before trying it. For example, figure 6 assumes that we
can increment xx , but since xx is None , we get a TypeError exception. Here is the error.
Figure 7 avoids the error by checking that xx is not None in the first half of the conditional statement and
taking advantage of the fact that Python stops evaluating as soon as xx is not None returns False .
# Figure 7: False first "and" expression
xx = None
if xx is not None and (xx + 1) == 5:
print("True")
else:
print("False")
--
False
Augmented assignments
Languages such as SystemVerilog and C have a ++ operator that increments a variable. If we have xx set to 1
, the xx++ operation sets xx to 2.
Python has a generalized version of this operation. It allows you to use any operator before the = to change a
value. Figure 8 shows us that if we have xx set to 1 we can increment xx , multiply it, and divide it using
augmented assignments.
Notice that xx changed from 1 to 2 to 6 to 1.5 over the course of these operations. xx also changed type
from int to float due to the /= operator.
Constructors
Everything in Python is an object, which raises a question about how to create these objects. There are two
ways:
Calling constructors
Setting variables to constants
The simplest way to create an object is to set a constant to a variable. Figure 9 shows that we can set a variable
to 5 and get an int object, or we can set a variable to None and get a NoneType object.
Calling constructors
A constructor is a function that creates an object of a specific type. We can call a constructor using the type
name as a function call with trailing parentheses. The argument is the value we want in the object.
One typical use for calling constructors is to “convert” a variable from one type to another. I use quotation
marks around “convert” because we’re not really converting the object; we’re creating a new object using the
old object in the constructor’s argument list.
Figure 10 shows that we can create a float from a str . Python makes sure that the value in the argument
makes sense for the type you’re creating. We see that the string "3.14159" can convert to a float but not an
int .
Notice that Python does not cast a variable to be a different type, instead it creates a new object using the first
variable as an argument to the constructor.
Comments
Comments are our gift to future programmers (unless we let them get out of date, but that is a different story.)
Python provides two kinds of comments, line comments and block comments.
To create a line comment, use the # character. Python ignores everything on a line after the # . So in this
example, we have one line that is all comment and another that is an assignment followed by a comment.
# Figure 11: Various comments
# This is a comment
PI = 3.14159 # Comment to end of line
To create a block comment, use a set of triple double-quotes at the beginning of the block comment and the
end of the block comment. For example, in this block comment, we define PI .
There is a difference between the # comment and the triple double-quote comment. The first is an actual
comment, ignored by Python. The second is a Python convention that uses an unassigned multi-line string as a
comment, though this distinction does not affect your program.
Summary
The chapter discussed the nuts and bolts of Python:
if Statements
if statements consist of a conditional test and a block of code to run if the condition evaluates as True .
Python is famous for designating its blocks of code using indentation rather than curly braces as in C or begin
and end as in SystemVerilog.
Python does not require parentheses in the if statement, unlike C and SystemVerilog. Instead, you write the
conditional expression, using parentheses for clarity if necessary. As with all blocks, we use a colon to
designate a block as we see in Figure 1.
It is best to use spaces for indentation instead of tabs and you should use four spaces or the space police will
come for you. It is illegal to have different indentations on different lines in the same file. Most editors handle
spaces for you.
Python generalizes this idea with the elif statement. As the name implies, elif combines else and if so
that you can make multiple cascading comparisons.
# Figure 3: Using elif as a switch
A = B = 5
operation = "divide"
if operation == "add":
print("A + B =", A + B)
elif operation == "subtract":
print("A - B =", A - B)
elif operation == "multiply":
print("A * B = ", A * B)
else:
print("Illegal Operation:", operation)
--
Illegal Operation: divide
Thus the elif provides similar functionality to case and switch statements, but with more flexibility at the cost
of more code.
Conditional assignment
Ternary operations assign one of two expressions to a variable based on a conditional value. In SystemVerilog
and C, they look like the code in figure 5.
Figure 5 checks that aa is a 5 and sets my_reg to the variable five_val if the condition is True . Otherwise,
it sets my_reg to other_val .
Python also has a ternary assignment but without the weird ? operator as in figure 6.
# Figure 6: Ternary operation in Python
aa = 5
message = "five_val" if aa == 5 else "other_val"
print(message)
--
five_val
while loops
Python has two looping statements:
The while statement starts with a conditional expression and loops for as long as the expression returns
True . The looping ends when the condition returns False or the code reaches a break statement.
Figure 7 uses a loop to print the integers from 0 to 13 . (The print() function has an end argument that
replaces the \n (carriage return) with whatever you specify. In figure 7, we use a space " " ).
Python has no such loop, but you can emulate one using the break statement. Figure 9 prints out the
numbers 0 to 13 by breaking when we see the 14 .
# Figure 9: Breaking out of a loop
nn = 0
while True:
print(nn, end=" ")
nn += 1
if nn == 14:
break
--
0 1 2 3 4 5 6 7 8 9 10 11 12 13
Summary
Conditions test values. You can use them in if statements, elif constructs, and loops. Python contains only
for loops and while loops. You can use these to recreate the kinds of loops available in other languages.
Python has no case or switch statement. Instead, it uses the elif and else constructs.
Python uses indentation to designate code blocks rather than curly brace or words such as begin and end .
Python sequences
Many algorithms boil down to looping through a series of objects and operating on them. This programming
approach is so common that software developers created the concept of an iterable. An iterable object can
provide one datum at a time when asked.
A deck of playing cards is a good example of an iterable. It contains 52 cards, and when you ask for the next
card, you get the one on the top of the deck.
An iterator is an object that takes an iterable and provides one item at a time until all the items are used up.
For example, you've created an iterator when you shuffle a deck of cards. Now the dealer (who is acting as an
iterator) can get cards from the top of the deck until there are no more cards.
Python sequences are iterables that store ordered data and provide the data one at a time. Sequences come in
two flavors:
immutable —These cannot be changed once created. Immutable classes feature a standard set of
operations.
mutable —You can add and remove elements from these objects or sort them. Mutable objects feature
all the operations in immutable objects plus the add , remove , and sort operations.
The for loop uses the iterable to create an iterator that deposits one value at a time into the variable, which
we can use in the body.
For example, since a string is an iterable, Figure 4 prints the first four numbers using a str as an iterable
object. In figure 5, cc is the variable, and " 01234 " is the str .
# Figure 5: A string as an iterable
for cc in "01234":
print(cc, end=" ")
--
0 1 2 3 4
As in the while loop, the continue statement jumps to the top of the for loop, and the break statement
breaks out of the loop.
The code in Figure 5 executes continue when we see the " 1 " and break at the " 3 " leaving only " 0 " , and
" 2 " to be printed.
Summary
Iterators are at the heart of Python, and sequences are ordered iterators that can be looped, indexed, and
searched. You can add and remove values from mutable objects such as list but cannot change immutable
objects such as tuple .
We’ll examine sequences such as tuples, ranges, sets, strings, and lists in the following chapters.
Tuples
There is nothing like a tuple in SystemVerilog, VHDL, or any other languages based on Algol or CPL. A tuple is
an immutable object that stores more than one value.
Creating tuples
There are several ways to create a tuple. One is to list values with commas between them. Figure 1 stores the
Dynamic Duo.
We can also explicitly put parentheses around tuple’s values to match the way Python prints tuples.
Finally, we can create a tuple from an iterator by passing it to the tuple constructor. Figure 3 uses a string as
an iterator that returns one character at a time so that we can convert a string to a tuple full of single
characters.
We can also assign the tuple to a pair of variables, caped_crusader , and boy_wonder . The first variable gets
" Batman " and the second gets" Robin "
# Figure 4: Assigning tuple values
dynamic_duo = ("Batman", "Robin")
print("Dynamic Duo:", dynamic_duo)
# Assign each to their own variable
caped_crusader, boy_wonder = dynamic_duo
print("Caped Crusader:", caped_crusader)
print("Boy Wonder:", boy_wonder)
--
Dynamic Duo: ("Batman","Robin")
Caped Crusader: Batman
Boy Wonder: Robin
Python functions can return any number of values using tuples. It’s up to the calling code to decipher the tuple
that the function returns, as happens in figure 8.
Summary
We learned about the immutable sequence type, the tuple. We saw that SystemVerilog, C, and VHDL, have no
data type analogous to the tuple.
The tuple allows us to store multiple values in a single object. We can return a tuple from a function that must
return multiple values. When you read a tuple, you can store it in a single object handle or store its values in
separate variables.
Ranges
A range is an iterable object that returns numbers in order. By default, we create a range object by calling the
range() constructor and passing how many numbers we want. range(n) starts counting at zero and stops at
n-1
For example, to print the numbers from 0 to 2 , we create a range using range(3) and loop through it with a
for loop.
You’ll note that I’m being careful with my language here. The range(3) in figure 1 does not return a tuple with
three numbers. That would waste memory. Instead, range(3) returns an iterator, and the iterator returns the
numbers one at a time.
Because a range is an iterator object and not a tuple, printing a range object does not print the numbers in the
range. Instead, it prints the range’s definition.
You can print the numbers using a for loop, or you can use the range as an argument to create a tuple()
and print the tuple.
We’ll use tuples to print the output as we examine different ways to create ranges.
Creating ranges
By default, range iterators start returning numbers with zero and increment the numbers by one. But you can
create other ranges and increments using the range() constructor.
range(stop) —This is the typical constructor call. The numbers in the range will start at 0 and end at one less
than stop .
# Figure 4: The range stop argument
stop = 5
print(tuple(range(stop)))
--
(0, 1, 2, 3, 4)
range(start, stop) —The numbers will be greater than or equal to the start and less than the stop .
range(start, stop, step) —The numbers will be greater than or equal to the start , less than the stop ,
and incremented by the step .
Summary
Ranges are iterators that return numbers in order. When creating the range, you can define the starting
number, stopping number, and step. They are often used to create for loops that use sequential numbers.
Strings
The most significant difference between strings in Python and C is that Python strings are not arrays of char .
Instead, they are data objects that provide formatting and parsing functions.
You can define strings using either the single quote ' or double-quote " characters. Having two kinds of
definition characters allows you to create a string using one character that contains the other. For example,
let's create a string with a quote from Morpheus and then print out the last six characters, including the ending
double-quote ( " ). Notice that you can use square brackets to create slices of a string.
Strings are immutable. You cannot change a string. In figure 2, we change desert of the real to desert of
the mall .
You can’t change a string, but you can create a new string that replaces characters using the replace()
method. Here we create mall_line from real_line using replace() .
String operations
Strings implement all the immutable operations shown in the Python Sequences chapter. Here is a table of
commonly used string operations.
String methods
All str objects have methods to help manipulate string data. There are 45 methods, so buckle up, we’re going
to—just kidding. We will look only at the methods most commonly used when reading a file. docs.python.org
documents the rest.
str.join(<iterable>) —Given an iterable containing strings, this method concatenates them together using
the calling string. We can use join to combine lines from a file into a single string using "\n".join() .
# Figure 6: Joining a list of strings to make a new string
haiku_lines = ("Lines in a tuple",
"Seemingly unrelated",
"Can make a haiku")
haiku = "\n".join(haiku_lines)
print(haiku)
--
Lines in a tuple
Seemingly unrelated
Can make a haiku
str.split(<separator>, maxsplit=-1) —This function splits a string into a list using the separator string .
maxsplit specifies the number of splits you want. For example, you may want to only split off the first word in
a sentence. The -1 default splits all occurrences. The figure 7 example splits a CSV line and stores the result
into four variables.
str.splitlines(keepends=False) —When we read a file, we receive a long string with lines separated by
carriage returns and linefeeds. The splitlines() method splits the lines at the line ending characters and
returns them as a list.
str.strip(<chars>) —This method strips the leading and trailing whitespace characters from a string. It’s
useful for cleaning spaces from user
input. In figure 9 we clean up user_input by removing leading and trailing spaces.
Formatted Strings
So far in this book, we’ve been printing output messages by calling print() with a string, a comma, and a
value. Python has a better method for placing data into a string. While Python has developed several ways of
formatting strings over the years, we’re going to examine the most recent formatting tool, Formatted string
literals or f-strings for short.
F-strings start with the letter f or F and then a quote or double quote or their tripled versions. The f tells
Python to watch for expressions between curly braces {} in the string. Figure 11 uses an f-string to create
answer_str , which contains the product of two complex numbers. Notice that we put answer in curly braces
to get it into the string.
This is a vast improvement over C-format strings containing placeholders. It’s easy to read this string and see
what the developer intended.
An f-string can contain formatting information between the curly braces 3 . For example, figure 12 specifies
the width of the field to make the first name, and the last name fields 12 characters wide so they line up:
It is also helpful to output binary data in different bases. Figure 13 prints decimal 17 in different bases. Notice
that we have four ways of printing the number widths:
{val} prints the default decimal base with the minimum number of digits.
{val:08b} prints the number in 8 binary digits and pads the upper bits with 0 ’s.
{val:3o} prints the number in 3 octal digits but uses spaces instead of 0 ’s in the unused upper digits.
{val:x} prints the number in hex using the minimum number of digits.
I have added square brackets to the output in figure 13 so you can see the widths.
Summary
Strings are immutable sequences of str objects. We can create strings using single or double quotes. Using
single quotes allows you to put double quotes in a string and vice-versa. The triple version of both quotes
allows us to create strings containing carriage returns.
Strings have methods that allow you to manipulate them. Formatted strings, or f-strings, allow us to insert
values into a string.
Lists
Lists are ordered iterators of objects. Lists are mutable, so you can add objects to them, remove objects from
them, reverse the order of the objects, and sort the objects. They are Python’s workhorse.
The second way to create a list is by using the list constructor. The constructor takes any iterable object,
loops over it, and puts the returned objects in a new list. If you don’t pass an argument to list() , you get an
empty list.
The third way to create a list is by using a list comprehension . List comprehensions are a compact way to
create a list that you could create using a for loop. Let’s look at this construct in more detail.
List comprehension
Let’s create a list by squaring only the even numbers between zero and ten in figure 3. First, we create an
empty list. Then we use a for loop to process the numbers from 0 to 10 . Finally, if the number is even, we
use the append() method from the list of methods available in mutable types to add it to the list.
The list comprehension statement strips the for loop down to its basics in one statement. List
comprehension statements have four parts contained in square brackets as we see in figure 4.
Let’s use list comprehension to rewrite the for loop in figure 4, printing even squares from 0 to 10 using a lot
less code:
<var> —A variable that holds the objects from the iterator (nn)
<filter expression> —A conditional that filters the var objects allowing you to only operate on some
of them ( nn % 2 == 0 )
While it’s cool to do this all on one line, it can be hard to read that line, so Python allows you to write a list
comprehension statement using multiple lines.
<list>.sort(<key>=None, <reverse>=False)
key —This is a function that takes a list item as an input and returns a value to sort. It defaults to None .
reverse —This is a boolean that says whether the sort should be reversed. It defaults to False .
As an example of using the key, let’s say we want to sort some of the Avengers in a list alphabetically:
['antman','Hulk', 'Ironman'] . Figure 7 shows that the capitalization causes a sorting error.
The lowercase a comes after all the uppercase letters, so antman gets put at the end of the list when he
should be at the beginning. We can sort the names properly by using the str method casefold , which
eliminates the case.
We change the sort order by passing the str.casefold method as the key argument. Notice that we are
passing the method itself—we are not calling the method. We would use parentheses if we were calling the
casefold method ( str.casefold() ), but we're not calling it, we're passing it as an argument. The sort()
method will call the str.casefold method and use the result as the key for the sort.
However, there are cases where we will want to create a new sorted list rather than change an existing list. In
figure 9, we can create a sorted_avengers list.
# Figure 9: Creating a new sorted list using the sorted() function
avengers = ["antman", "Hulk", "Black Widow"]
sorted_avengers = sorted(avengers, key=str.casefold)
print("Correctly sorted:", sorted_avengers)
--
Correctly sorted: ['antman', 'Black Widow', 'Hulk']
The sorted() function works with any iterable. For example, we could pass it a string and get a list of sorted
characters.
Summary
A list is an ordered iterator containing objects. Since a list is mutable, it implements all the immutable and
mutable functions defined in the Python sequences chapter. Lists have a sort() method that sorts the
elements in the list. The sorted() function takes any iterable and returns a sorted list.
Sets
Parents have been complaining about new math for as long as students have been taught math. In the 1960s,
my mother complained bitterly that my math teachers were focused on sets and their operations rather than
making us memorize the multiplication tables. Little did she know that my early school education would
prepare me for Python programming.
Sets are unordered groups of unique items. Sets can be tested and combined with other sets using
mathematical operations such as union, intersection, difference, and symmetric difference. They help us check
that inputs are in a legal set of values, remove duplicates from a list, and find data intersections.
Creating sets
There are three ways to create a set object.
The first way is to use the set or frozenset constructors, depending upon whether you want your set to be
mutable.
The constructors take an iterable as an argument and create a set by looping through it and adding unique
values. So, for example, we can create a frozenset from a list of the first eight digits of pi.
Notice that there are multiple 1s in 3.14159 , but only a single 1 got stored in the set because, by definition,
sets can only have one entry for each value.
Python printed the numbers in order, but sets have no inherent order, so you cannot count on this behavior.
The second way is to create sets (but not frozensets) using curly braces.
The third way is to use a set comprehension. Set comprehension works the same way as list comprehension
but has curly braces instead of brackets. In figure 3, the variable nn loops through the iterable, and the result
of the operation on nn gets stored in the set.
# Figure 3: Using set comprehension
first_squares = {nn**2 for nn in range(1,6) }
print(f"first_squares: {first_squares}")
--
first_squares: {1, 4, 9, 16, 25}
Set Operations
We check to see whether an object is in a set, compare sets, combine them, and use them to remove
duplicates. This section shows how we do these things.
Notice that the >= and <= operators map to set methods that perform the same function.
Combination Operations
You combine sets in much the same way as you combine strings of bits or integers. Set combination
operations act like or, and , subtract and xor operations.
Figure 9: Set operations
Removing Duplicates
Sets help you remove duplicates from a list. You convert the list to a set and back to a list. However, you lose
the list order as we see in figure 10.
Summary
Sets are groups of unique objects that use the set operations some of us learned in grade school. We can
check that an object is in a set, compare sets, and add and subtract them.
Commonly used sequence operations
The Python Sequences chapter contained immutable and mutable sequence operations tables. This chapter
provides examples and details on common operations. 4
Concatenating sequences
You can use the + operator on sequences. It acts as you’d expect, returning a new object that is the
concatenation of the sequences in the expression.
Repeating sequences
Use the * operator to concatenate multiple handles of the objects in a single sequence. You can put the
number before or after the * .
Note: The * operator copies handles. It does not create new objects. So changing one of the objects will change
all the objects with the same handle.
The * operator is helpful when creating horizontal bars in output reports as we see in figure 4.
Use len() function with the * operator to do fancy things such as embed a word in a header as in figure 5.
In figure 9, we create a list of the first ten integers and then print the even ones up to 9 using a step of 2 .
In figure 10, we see that lowercase letters have higher Unicode values than upper case. Therefore, we need to
use str.casefold as the key to get the correct min and max.
Summary
This chapter demonstrated commonly used sequence operations. We’ve now learned the basics of Python.
Modules
Like SystemVerilog and VHDL, Python allows us to import types and values from an external file. Python calls
these files modules . When the developer stores modules in a directory structure, we call the directory a
package.cocotb, for example, is delivered as a package.
Python stores a program’s variable names in a list. You can see the list using the dir() function. For example,
we see in figure 1 that the variable aa and the class foo we created are in the dir() list.
Understanding scopes makes it easier to understand what happens when you import a module.
Python will store the resources defined in a module in either the module’s scope or your program’s scope. The
location depends upon how you import the module.
In figure 2, we import the pyuvm module, which defines a variable named FIFO_DEBUG and sets it to 5 . Notice
that the module pyuvm became part of our scope, but that we need to reference the FIFO_DEBUG variable
using a . between the module name and the variable name.
Some contend that this approach to importing creates the most readable code because the reader knows that
pyuvm defines FIFO_DEBUG .
Aliasing a module
The style of using the . operator to reference variables in an imported module can lead to long variable
names, especially with modules that have long names. You can shorten the name using the as statement in
the import to provide a shorter alias.
The from keywords defines a module’s objects in our program’s scope. This means that we don’t need the
<module>. string to access the object.
In figure 5, we use from to put FIFO_DEBUG into our dir() list and reference it without needing the .
In the print() function, you can see that we did not need the pyuvm . reference.
Python has the same functionality using the from statement and the * . All the pyuvm examples in this book
mimic the SystemVerilog approach by importing all pyuvm’s resources into the local dir() list. The
statement from pyuvm import * puts everything defined in pyuvm into our dir() list. For example, the
uvm_object class.
Wildcard imports (from import *) should be avoided, as they make it unclear which names are present in the
namespace, confusing both readers and many automated tools.
I decided to ignore this rule for from pyuvm import * because I wanted Python UVM code to look like
SystemVerilog UVM code. But one has to admit that it is clearer to write @pyuvm.test() , so I recommend
both.
Finding modules
When you import a module, Python searches for it in a list of filesystem paths stored in sys.path .
Python fills the list using the PATH environment variable and the PYTHONPATH environment variable. We can
also append paths to this list. In later example files, we will use this to import a module named
tinyalu_utils into our testbenches. The code in figure 8 uses the Path class from the pathlib module to
get the full path to the parent directory of the directory we’re running in; then, it prepends the parent’s path to
sys.path . Now Python will be able to find tinyalu_utils.py .
# Figure 8: Adding the directory above ours to the path
from pathlib import Path
import sys
# All testbenches use tinyalu_utils, so store it in a central
# place and add its path to the sys path so we can import it
sys.path.insert(0, str(Path("..") .resolve() ) )
from tinyalu_utils import get_int, logger # noqa: E402
We use tinyalu_utils as a place to store utility routines such as get_int , which we’ll explain when we talk
about cocotb in a later chapter.
Summary
In this chapter, we learned about importing modules and controlling which items get defined in a module or
added to our dir() list. This ability to import functionality is key to Python and enables an enormous Python
ecosystem.
Functions
We create subroutines in Python using functions. As with mathematical functions, Python functions take zero
or more arguments and return a value. Which reminds me of a Pascal joke.
Niklaus Wirth, Pascal’s inventor, said, “Whereas Europeans generally pronounce my name the right way ('Ni-
klows Wirt'), Americans invariably mangle it into 'Nick-les Worth'. This is to say that Europeans call me by name,
but Americans call me by value.”
While Python calls its functions by value, that is to say the function cannot change the value of the argument,
that value is a handle to an object, and the function can use the handle to change the data attributes in the
object.
It’s best to avoid such games, and returns all values using the return statement. But we’ll see cases later
where modifying the object using its handle is the right approach.
Defining a function
The def keyword defines a function. The function name comes next with parentheses that contain optional
arguments. A function with no arguments still needs the parentheses. As with all Python blocks, we follow the
definition with the : , and indent the function statements.
# Figure 2: You can try to call any object, but it doesn't always work.
5()
--
SyntaxWarning: 'int' object is not callable; perhaps you missed a comma?
Functions are callable objects. Here we define say_hello() and show its type to be function .
sh = say_hello
sh("Ali")
--
Hello, Ali.
Treating functions as objects is mind-blowing for a SystemVerilog or VHDL programmer, as those languages
have no way to store a function in a variable. Functions in those languages are not objects and cannot be
stored or passed directly as arguments, though one can approximate the behavior in some languages.
As we saw when we discussed the min() and max() functions in the Commonly used sequence operations
chapter, we can pass function objects as arguments.
For example, Python allows you to create a filter object. Like the range object, a filter is an iterator that
you can use in a for loop or a list comprehension. You instantiate a filter by passing it a filtering function and
an iterator.
The filter uses the filtering function to test each item in the iterator and returns only those items for which the
filtering function returns True . Functions that provide this True / False behavior are also called predicates.
Let’s create a filter that provides only the short words in a list, where we define short as fewer than five
characters. First, we’ll create a list by splitting a sentence into its words, and then we’ll filter out all the long
words by creating a predicate named short_words_only . Finally, we’ll use the predicate and the word list to
create a filter object.
for sw in short_word_iterator :
print(sw, end = " ")
--
When Mr of Bag End
Naming convention in this book
Function objects can be passed as arguments, stored in variables, or called using parentheses. When this book
talks about a function you'll call, it puts parentheses after the name. However, the book doesn't use
parentheses when we talk about a function object that we will pass as an argument or store in a variable.
For example, this book refers to the print() function using parentheses because we typically call that
function. However, we refer to short_words_only without parentheses in the preceding code because we
pass it as an argument.
Arguments
There are two sides to every argument (heh). There is the defining side and the calling side. This chapter will
discuss defining arguments in functions and then using them when we call the functions. Then we'll examine
functions with a variable number of arguments.
Defining Arguments
Defining arguments in a Python function is more straightforward than in other languages. First, Python doesn’t
require argument types. Instead, you name the argument and provide a default value optionally. Second, there
are no output arguments. Results get returned through the return statement. Figure 7 shows a simple
function that increments a number and returns its remainder when divided by three. The argument is xx .
The user calling this function must provide a value for xx because xx has no default.
Non-default Arguments —These have no default value. This kind of argument is also called positional
arguments because the user must use them in the correct order.
Default Arguments —These have a default value. This kind of argument is also called a keyword argument
since its definition contains a keyword (the argument name) and a default value. You can call default
arguments in any order by providing the keyword with a value.
For example, we can create a function named my_power that raises a base to an exponent. In this first
example, we define two non-default arguments. When you call this function, you must provide the base and
exponent in the correct order.
But if we make the base a default argument we must also make the exponent a default argument since we’ve
stopped defining non-default arguments at that point.
# Figure 14: Calling the same function with and without keyword assignments
import math
def vec_length(x,y,z) :
return math.sqrt(x**2 + y**2 + z**2)
We’ll see that if we use a keyword for calling one of the arguments, we must do the same for all the following
arguments. Unfortunately, here we make the mistake of using the keyword only for y .
# Figure 15: Once you provide a value, all the following arguments need one.
print(f"{vec_length(y=1, 2, 3) :.2f}")
--
SyntaxError: positional argument follows keyword argument
We know that 1 goes with y , but where does 2 go? Does it match up with the x or the z ? Since there is no
way of telling, Python returns an error.
Defining a variable number of arguments
Many functions take a variable number of arguments, print() for example. We implement variable numbers
of arguments with two special arguments: *args and **kwargs .
Let’s extend our vec_length() function to create print_vec_length() which takes any number of
dimensions as arguments and prints out the length. The code in figure 16 calls print_vec_length() with
different numbers of coordinates.
Our print_vec_length() function needs to take any number of non-default arguments and two default
arguments. The code in Figure 17 implements this using *args and **kwargs in the argument list.
The *args argument gives us a tuple named args which contains any number of non-default arguments.
First, we loop through the numbers in the args summing the squares. Then we find the length by taking the
square root of the sum.
The **kwargs argument gives us an object called a dict (for dictionary) that we will discuss in a later chapter.
Here we need only to know that the dict class has a get() method that takes a key and a default value. If
the key returns a value, we get the value. Otherwise, we get the default value.
We don’t explicitly declare default arguments using **kwargs . Instead, the default values flow from our calls
to get() . The calls to kwargs.get() in figure 17 have the same effect as putting label= "" and units= "cm"
in the argument list. label and units are now optional arguments with default values.
# Figure 18: Using the default values for label and unit
print_vec_length(1,2,3)
--
3.74 cm
Documentation strings
Python has a built-in documentation system that generates documentation, supports the help command, and
works with advanced editors such as Visual Studio Code that show programmers hints for a function.
We must place documentation in docstrings to take advantage of these features. Docstrings start with three
double quotes and end with three double quotes. They go right after the def statement. By convention, they
have a blank line after them.
print("Hello, world.")
help(print_hello_world)
--
Help on function print_hello_world in module __main__:
print_hello_world()
It does what you think it does
Figure 19 uses a single-line docstring, but you can make multiple-line docstrings by putting the triple double-
quotes on their own lines. By Python convention, the first line should be a complete sentence followed by a
blank line as in figure 20.
help(my_power)
--
Help on function my_power in module __main__:
my_power(base, exponent)
Return the base to the exponent
We can also see this information in Visual Studio Code when we hover the mouse over the function.
(function) my_power:(base,exponent)->Any
Raises the base to the exponent
Creating docstrings for all your functions is an excellent habit and will save you time and documentation work
in the long run.
Summary
Functions are objects that contain other statements and execute those statements when called. For example,
call function object foo with the statement foo() . Function arguments are only inputs. All return data must
come through the return statement.
Every function has a return value; if you don’t provide a return statement, the function returns None .
Functions have positional arguments and keyword arguments. We use *args and **kwargs to create
functions that accept a variable number of arguments.
Writing docstrings for all of your functions allows you to quickly generate documentation and provide hints to
programmers using an advanced editor.
Exceptions
Exceptions are an error reporting mechanism that does not exist in SystemVerilog or VHDL. As a metaphor,
consider a situation where your boss asks you to do a task. In doing it, you find an error, so you raise it to your
boss. Now it’s off your plate. Your boss, presented with the error, raises it to their boss, and that boss raises it
to their boss who, one hopes, handles it because if nobody handles the error, it gets reported to the public.
Python exceptions work the same way, except instead of raising the exception (error) to your boss, you raise it
to the function that called you. The exception travels up the stack of functions until it gets to the top. If no
function in the stack handles the exception, then it gets reported to the public as what looks like a crash. The
user sees the entire call stack down to the line that raised the exception.
Figure 1 creates an exception by dividing by zero. Python catches the error and raises an exception .
Exceptions, like everything else in Python, are objects. The object’s class gives us a clue as to what caused the
error. In this case, the exception object is of type ZeroDivisionError .
In this example, the division operator raised a ZeroDivisionError exception. This exception was passed up
the call stack until Python printed the error message with the list of function calls that led to the exception (the
Traceback.)
We’ve mentioned the way exceptions travel up the call stack. Figure 2 shows an example where we create a
function to divide and then use it to divide by zero. We see the stack trace that shows that we called nice_div()
on line 5, but the error originated at line 2 in the function nice_div, where it divided by zero.
Notice that the program never reached line 3. Code execution stops as soon as a function or operation raises
an exception.
Exceptions that go all the way up the stack stop the program with an ugly-looking error message. But, Python
allows us to catch exceptions and respond to them. We’ll discuss that next.
Catching exceptions
Python catches exceptions with the try / except keywords. We try an operation, and if the operation raises an
exception, we catch it and process it.
The code in Figure 3 improves our nice_div() function by having it return the mathematically correct answer
to division by zero, which is infinity. We represent infinity in Python using the math module and the math.inf
constant.
We implement this by using a try block and an except block. If any of the statements in the try block raises
an exception, control immediately passes to the except block. If the exception’s type matches the type called
for in the except block, we run the code in the block. Otherwise, we raise the exception up the call stack.
We need to catch more than the ZeroDivError exception type. We do this by using multiple except blocks.
First, we look for ZeroDivError , then we look for TypeError . Now, if we divide by a string, we get told the
result is not a number ( math.nan ).
In cases where you want to treat multiple exceptions the same way, you can catch them with one line as in
figure 6.
xx = nice_div(3,"zero")
The stack trace in figure 8 includes the raise call and the original error.
In figure 9, we catch the ZeroDivisionError and TypeError exceptions in a single except block since we do
the same thing for both exception types.
We also get a handle to the exception using the as keyword and passing it the ex variable. This stores the
exception in a handle named ex . We print a snarky message and the exception, then return math.nan .
print_div(33, 0) catches the ZeroDivisionError exception and runs the finally block.
print_div(33, "zero) does not catch the TypeError exception but still runs the finally block.
print_div(33, 2)
print_div(33, 0)
print_div(33, "zero")
--
The result of 33/2 is: 16.5
Division complete.
You screwed up your division, human. 33/0 returned: division by zero
Division complete.
Division complete.
---------------------------------------------------------------------------
TypeError Traceback
10 print_div(33, 2)
11 print_div(33, 0)
---> 12 print_div(33, "zero")
...
The finally block allows you to guarantee that an operation will happen even if the code throws an
exception.
The assert statement
Python programmers ask forgiveness, not permission. Unlike SystemVerilog and VHDL, Python does not
require that variables be declared with a type. Instead, it allows you to put any object into any operation and
raises an exception if you got it wrong.
We can create customized error checking using the assert statement. The assert statement checks a
conditional expression and raises an AssertionError exception if the result is False . We can append a
message to the assert statement to tell the user what they did wrong.
In figure 11, we create a function that XORs any number of bytes and raises an error if you give it a number
less than zero or greater than 255 (the maximum number in eight bits). Note: the 0x makes constants hex
numbers instead of decimal.
def xor_bytes(*args) :
xor = 0
for bits in args:
assert bits <= 255 and bits >= 0, f"Invalid byte: {bits}"
xor ^= bits
return xor
Whenever you write code that depends upon some condition being true, it is wise to insert an assert
statement to catch inconsistencies as early as possible.
You would be tragically wrong .assert is a statement, not a function. So this code creates a ( True,
"Obviously false" ) tuple. Since the tuple is not None the assert statement evaluates it as True and this
check never happens.
Summa2ry
Python exceptions provide a robust system for catching errors and responding to them. Statements raise
exceptions, and Python programmers catch them using try/except/finally blocks.
The try block runs the code. If a statement raises an exception, it stops execution and jumps to one or more
except blocks. If the types on these blocks match the exception type, the block runs. Otherwise, the exception
rises up the call stack.
In old-fashioned C, we did this with an array. The key was the integer index into the array. But in Python and
later languages, this notion has been generalized so that you can use a wide variety of types as the key. These
are called associative arrays. Python calls associative arrays dictionaries , implemented with the dict type.
players = {}
players["Messi"] = 10
players["Beckham"] = 7
players["Salah"] = 11
print("Messi's number:", players["Messi"])
--
Messi's number: 10
In this example, the strings " Messi " , " Beckham " , and " Salah " are called keys. The numbers are called values.
Key/value pairs are called items.
Creating a Dictionary
The simplest way to create an empty dictionary is using the curly braces as in players = {} . Once we’ve
done that, we can fill the dictionary using keys and values. There are several ways to do this.
Here we mixed and matched keys: the first item uses an int as a key ( 1 ) and a str as the value ( 'one' ). The
second item uses a str as the key ( 'two' ) and an int as the value ( 2 ).
Create a dictionary using the data that will fill it
The code in figure 2 created an empty dictionary ( {} ). We filled it with data, but we could also have created
the entire dictionary in one statement by using a colon ( : ) to separate the keys and values.
There are other ways to create dictionaries, but this is the simplest, so it’s the one we’ll use in this book.
Similarly, we can create dictionaries using dictionary comprehension. In figure 4, we create a dictionary that
stores the cubes of the first three numbers using a for loop.
Figure 5 creates the same dictionary using dictionary comprehension. The comprehension statement loops
through an iterator using the : character to store a key and a value as in <key> : <value> .
KeyError: 4
As with all exceptions, we can use the try and except blocks to handle missing keys.
We can use the KeyError to solve programming problems. The code in figure 8 counts the number of times a
letter appears in a string. We implement it by storing the count in a dictionary. If there is a KeyError , we
know we’ve never seen that letter, and we set the count to one.
The setdefault() method allows us to write the same behavior more compactly. In figure 11, we use
setdefault() to get the current count, and if there is no entry in the dictionary, we set the entry to
0.setdefault() returns the 0 , we increment it, and store it in the entry for that letter.
count = {}
for cc in "Mississippi":
count[cc] = count.setdefault(cc, 0) + 1
for cc in count:
print(cc, count[cc])
--
M 1
i 4
s 4
p 2
++ <snip> ++
for cc in count:
print(cc, count[cc] )
We see that count returned the letters in the order they appeared in the word " Mississippi " . That is
because dictionaries in a for loop return their keys in the order they were created 5 .
++ <snip> ++
As with all tuples, you can supply variables to take the values. This brings us back to the behavior from the first
example written without indexing the dictionary.
# Figure 16: Populating the key and value variables in a for loop
You can remove an individual item by using the del statement and providing the dictionary entry to remove.
You can also “pop” items from a dictionary using the pop() function. Popping returns the value at the key and
removes the item. Here we print that
Beckham (7) has retired.
Accessing a dictionary using a missing key raises a KeyError exception. We can catch the exception to handle
missing items.
When you use a dictionary in a for loop, you get the keys in the order they were created.
Generators
A generator is a customized iterator object. We can use generators in for loops or any other statement that
takes an iterator.
We create generators using the yield statement. The yield statement is like the return statement in that it
yields a number to the caller. Unlike return , yield continues running in the function like any other
statement.
A simple example, my_range() acts just like the range() function. It returns numbers for 0 to one less than
the argument by looping through a while loop and calling yield . Here a for loop uses my_range to print
three numbers.
for ii in my_range(3) :
print(ii, end=" ")
--
0 1 2
The for loop called my_range(3) . The generator set counter to 0 and yielded that number. Then it
incremented the counter, checked the while loop, and yielded the new number.
My favorite generator gives us the Fibonacci sequence 6 . In figure 2, notice the two yield statements. The
code shows that yield statements don’t have to be inside a loop. The generator returns 0 at the first yield
and sets numb to 1 , the next Fibonacci number. Next, the generator enters the while loop and yields the
second number. Then it starts using the Fibonacci equation of adding two numbers to get the next one. It
loops until counter reaches nn .
for ii in Fibonacci(8) :
print(ii, end=" ")
--
0 1 1 2 3 5 8 13
Summary
Create new iterators using generators. Generators are functions that use a yield statement to yield a number
but not return from the function call.
Classes
Object-oriented programming (OOP) is the foundation of the expandability and reusability of UVM-based
verification. It’s an excellent match with Python, which provides a clean and readily understandable
implementation of classes and OOP. In addition, Python supports modern object-oriented features such as
multiple inheritance and dynamic class definitions.
EmptyClass has a docstring after it for use in help() statements and in editors. There is nothing in this class
except for what it inherited from type , the default inheritance of all classes. The ... constant is an intentionally
empty line.
A class is an object, and as we saw in the Functions chapter, you can call any object by appending parentheses
to its name. When we call a class object, we get a handle to an object of that class.
A new object has no data attributes. We add data attributes to the object by referencing it and using the dot
operator ( . ). In figure 2, we create a class named Animal . Then call Animal() to get a handle to an animal
object which we store in walrus . Then we can add a data attribute named kg to walrus and print it.
class Animal:
"""Holds a generic Animal"""
walrus = Animal()
walrus.kg = 1000
print("Walrus mass:", walrus.kg)
--
Walrus mass: 1000
Dynamically adding a data attribute to an object is nothing like SystemVerilog, where the Animal class would
have to define the kg variable at definition as in Figure 3.
class Animal;
int unsigned kg;
endclass
You don’t do this in Python. Instead, you create the object and start adding data attributes to it. We’ll see later
there is a recommended way to do this.
While we define a function with a def statement in the first column of a Python script, we define a method
with a def statement indented within a class . This defines a method attribute in the class, also called a
“method”.
walrus = Animal()
walrus.kg = 1000
print(f"Walrus weight in pounds {walrus.get_pounds() :2.2f}")
--
Walrus weight in pounds 454.55
The reader may have noticed a discrepancy between the definition of the get_pounds() method attribute and
its use. The definition has one argument, while the call to the method in the print() function has none.
Python assumes that a class method will want to access the attributes in an object, so Python passes the
handle to the object as the first argument. By convention, we name this argument self .
It is possible to call the function using the class name and explicitly pass it an object handle. For example, here
we instantiate walrus and then call Animal.get_pounds(walrus) . This function call passes walrus to the
self argument the same way walrus.get_pounds() would.
walrus = Animal()
walrus.kg = 1000
While one could call class methods this way, it is better to let Python do the work by using the object handle to
reference the method like this : pounds = walrus.get_pounds() .
When you call a method this way, Python automatically passes the object handle to the self variable.
Initializing an object
In figure 2, we showed that in Python, you create a handle to an object by calling its class and adding data
attributes to the object. This behavior is different than C and SystemVerilog, where you declare all the data
attributes in the class definition.
Figure 6 shows the Animal class written in SystemVerilog. This class defines a method named new() , called a
constructor. In SystemVerilog, you declare variables in a class and pass values to them in the constructor.
function get_pounds() ;
return kg/2.2;
endfunction
endclass
This approach is different than initializing data attributes in Python. In Python, we get a handle to the object
and add data attributes. This approach can lead to errors such as the one in figure 7 where the get_pounds()
method assumes that the self.kg variable exists, but we forgot to set it after creating a yorkie .
class Animal:
"""Holds a generic Animal"""
def get_pounds(self) :
return self.kg/2.2
yorkie = Animal()
pounds = yorkie.get_pounds()
print(f"Yorkie weight in pounds {pounds:2.2f}")
--
AttributeError
2 """Holds a generic Animal"""
3 def get_pounds(self) :
----> 4 return self.kg/2.2
SystemVerilog would have issued a syntax error if we had tried to reference kg without declaring it. But
Python doesn’t declare data attributes in a class. Instead, it wants you to add them to the object before using
them. That didn’t happen here, so Python raised the AttributeError exception.
The standard way of adding data to an object in Python is to create an __init__(self) method. This
method‘s name has two leading underscores, the word init , two trailing underscores, and a self
argument.
When you create a new instance of an object by calling its class name with parentheses, Python automatically
calls the __init__(self) method. The __init__() method and other methods that use the same double-
underscore naming convention are called magic methods. You’ll also see these methods called dunder methods
where “dunder" is a contraction of “double underscore”.
The __init__() method defines arguments in the class creation call. Python uses the values of the argument
and calls __init__() whenever you create a new object.
The code in figure 8 defines an __init__() method in the Animal class. The method defines two arguments
.self is mandatory and is a handle to the new object you’ve just created .kg is an additional non-default
argument that contains the mass .kg becomes a required argument in Animal() .
The code instantiates an Animal by calling the class name and passing the mass. We create a Yorkie Animal
in this example and store it in yorkie .
def get_pounds(self) :
return self.kg/2.2
yorkie = Animal(20)
print(f"The Yorkie weighs {yorkie.get_pounds() :2.1f} pounds")
--
The Yorkie weighs 9.1 pounds
The __init__(self) magic method set the self.kg attribute. Then get_pounds() could accesse the kg
attribute using the self handle.
Class variables
Like all things in Python, classes are objects, and as objects, they can have data attributes. We add data
attributes to classes by assigning values to them in the class definition block. In figure 9, we create a class
named Triangle , and since all triangles have three sides, we give the class a data attribute named
side_count . We reference side_count using the Triangle object.
class Triangle:
side_count = 3
...
When we access a data attribute in an object, Python will first search the object for the data attribute. If it
doesn’t find it there, it looks in the object’s class for the data attribute.
class Triangle:
side_count = 3
def print_side_count(self) :
print(f"I have {self.side_count} sides.")
tri = Triangle()
tri.print_side_count()
--
I have 3 sides.
Class methods
A method that uses only class variables is called a class method. Python passes the first argument to class
methods automatically. The difference is that instead of passing a handle to the instantiated object in the first
argument, Python passes a handle to the class object.
Python implements class methods using the @classmethod decorator. A decorator is a Python statement that
changes the function’s behavior defined right after it. Decorators start with an @ symbol and go into the line
right before the line containing the def statement, as in this fictional example in figure 11.
@mydecorator
def my_function() :
...
The @classmethod decorator tells Python to pass the class object handle as the first argument in the method
call instead of the instance handle. By convention, we name the first argument cls in a class method.
class Triangle:
side_count = 3
@classmethod
def print_side_count(cls) :
print(f"I have {cls.side_count} sides.")
Triangle.print_side_count()
--
I have 3 sides.
In figure 12, print_side_count() gets a handle to the Triangle class object where it can reference the
side_count class variable.
Static methods
Some methods don‘t need access to an object or its class. These are called static methods and have no
predefined first argument. We define static methods using the @staticmethod decorator.
Why would we define it within a class if the static method doesn’t use self or cls ? There are two reasons:
1. We want to have a logical place to store the method so people know where to find it.
2. We want to invoke the static method in the same way as all other methods related to the class, e.g.,
self.my_static_method(foo) .
All triangles have three sides, so the code in figure 13 implements print_side_count() in Triangle using a
@staticmethod . Notice that it has no arguments and that you can call the static method using the class handle
or the class name.
class Triangle:
@staticmethod
def print_side_count() :
print(f"I have 3 sides.")
tri = Triangle()
Triangle.print_side_count()
tri.print_side_count()
--
I have 3 sides.
I have 3 sides.
Summary
Classes are the basic building block of Python object-oriented programming. We do not declare data attributes
in classes. Instead, we usually assign a value to them using the self variable in the __init__() magic
method. The self variable is a handle to the object instance and provides access to the data attributes.
Functions defined in the context of a class definition are called methods. There are three kinds of methods:
Instance methods —These operate on a single instance of a class. Instance methods receive the self
variable as their first argument. Python does a little magic to make that happen when you call a method
using an object handle.
Class methods —These operate on the class variables. The @classmethod decorator causes Python to
pass these methods the cls variable in the first argument instead of self .
Static methods —These do not receive a handle as their first argument, but they still relate to the class, so
we define them in the class as a
matter of convenience. We create static methods using the @staticmethod decorator.
Protecting attributes
When we create a class, we make assumptions about its data attributes and how they will be used. For
example, in figure 1 we create a class that stores the temperature in Celsius and returns it in either Fahrenheit
or Celsius.
Here is a flawed attempt to implement this. We want to store the freezing point of water. tt =
Temperature(0) does this correctly, but later someone changes the attribute to 32 , so we print, incorrectly,
Freezing is 32 degrees Celsius .
class Temperature:
def __init__(self, temp) :
self.temp = temp
def to_f(self) :
ft = self.temp * 9 / 5 + 32
def to_c(self) :
return self.temp
tt = Temperature(0)
# Later in the code
tt.temp = 32
print(f"Freezing is {tt.to_c() } Celsius")
--
Freezing is 32 Celsius
Here is a case where someone using our class would file a bug report saying that it printed the wrong
temperature when, in fact, they were the ones who caused the error.
Protected attributes
Protected attributes tell users that they should not be accessing certain data attributes in a class. We protect
data attributes and methods by putting an underscore before the name to make them protected attributes.
In figure 2, we improve our Temperature class. We protect the temp attribute by putting an underscore in
front of it: _temp . We also provide methods that allow the user to set and get the temperature using Celsius
or Fahrenheit. Unfortunately, the user is still misusing our class.
class Temperature:
def __init__(self, temp) :
self._temp = temp
def get_f_temp(self) :
ft = self._temp * 9 / 5 + 32
def get_c_temp(self) :
return self._temp
tt = Temperature(0)
tt._temp = 32 #No! Bad programmer!
print(f"Freezing is {tt.get_c_temp() } Celsius")
--
Freezing is 32 Celsius
Figure 2 demonstrates that we should never access attributes with an underscore in front of them. These are
protected. Even if our code works today, it may not work in the future. The user is breaking that rule.
Private attributes
This user is irritating us, so it is time to get serious. Figure 3 makes the temperature a private attribute by
putting two underscores in front of it. That will show them. Here the thoughtless programmer sets tt.__temp
to 32, but this does not affect our class’s proper operation.
def get_f_temp(self) :
ft = self.__temp * 9 / 5 + 32
def get_c_temp(self) :
return self.__temp
tt = Temperature(0)
tt.__temp = 32 # Bad programmer!
print ("Temp in Celsius:", tt.get_c_temp())
--
Temp in Celsius: 0
We have finally protected ourselves and our attributes. When you put the double-underscore in front of a
variable, Python mangles the variable’s name inside the class so that programmers outside the class cannot
modify it. These private attributes are also called private variables.
Classes that extend a class with private variables cannot access them either. They, like all users, must use
accessor functions to access a class’s private variables. For example set_f_temp() and get_f_temp() are
accessor functions.
Many classes, including the SystemVerilog UVM classes, provide accessor functions that start with set_ or
get_ , but Python provides a cleaner way of accessing private variables using properties.
Properties
Python provides additional support for accessor methods with the @property decorator. This decorator
allows us to create accessor methods that appear to be a data attribute to a programmer using the class.
We use the @property decorator in front of the “get” method. Then, we use @<variable_name>.setter in
front of the “set” method.
In figures 4-6, the Temperature class creates the c_temp and f_temp properties. The c_temp() and
f_temp() methods raises ValueError if the temperature has not been set. This check is an example of how
we can do error checking in properties. We store the temperature in self.__temp in Celsius.
@property
def c_temp(self) :
if self.__temp is None:
raise ValueError("Temperature not set")
return self.__temp
@c_temp.setter
def c_temp(self, c_temp) :
self.__temp = c_temp
In figure 5, we add properties to Temperature for f_temp that convert Fahrenheit temperatures to and from
Celsius.
@property
def f_temp(self) :
if self.__temp is None:
raise ValueError("Temperature not set")
return None
return self.__temp * 9 / 5 + 32
@f_temp.setter
def f_temp(self, f_temp) :
self.__temp = (f_temp - 32) * 5 / 9
def __str__(self) :
return f"{self.c_temp:.1f} C == {self.f_temp:.1f} F"
The code in figure 5 creates a __str__() magic method that print() calls to print the object. The output
shows the temperature in Fahrenheit and Celsius.
The code in figure 6 uses the Temperature class properties. First, we instantiate the Temperature class with
the temperature set to 0 and print it. Then we instantiate the Temperature class with no temperature and try
to print it, causing a ValueError .
tt = Temperature(0)
print("tt:", tt)
tt = Temperature()
print("unset tt:", tt)
--
tt: 0.0 C == 32.0 F
unset tt:
--------------------
ValueError Traceback
37 print("tt:", tt)
38 tt = Temperature()
---> 39 print("unset tt:", tt)
Summary
Underscores before variable names designate protected and private data attributes and methods. The
underscores tell users that they should not be accessing these class members directly.
One leading underscore is a convention that says, “Do not use this variable,” but Python does nothing to
enforce this. Two leading underscores make a variable private, and Python enforces this by mangling the
variable name so those outside the class cannot access the variable.
You should provide accessor methods for protected and private variables. You make the accessor methods
look like variables using the @property and @<property>.setter decorators.
Inheritance
If you find yourself leveraging existing code by copying and modifying it, you are almost certainly doing it
wrong. For example, here is the wrong way to create a Dog class and a Cat class.
class Dog:
def make_sound(self) :
print("The dog says, 'bow bow'")
class Cat:
def make_sound(self) :
print("The cat says, 'miāo'")
Dog() .make_sound()
Cat() .make_sound()
--
The dog says, 'bow bow'
The cat says, 'miāo'
The problem with the code in figure 1 is that you‘ll have to write the code twice if you start adding other
behaviors such as sleeping or eating. Then you’ll have to maintain it twice. Any bug you find will have to be
fixed twice.
Inheritance allows us to avoid copying code and modifying it by creating a base class that contains shared
behavior. To use inheritance, you recognize that one class is a specialization of a broader class, which becomes
the base class. We call the extension a child class. In figure 2 we see that a dog is an animal, and a cat is an
animal, so we can create a base class named Animal and extend it to create child classes Dog and Cat .
class Animal:
def __init__(self) :
self.species = None
self.sound = None
def make_sound(self) :
print(f"The {self.species} says '{self.sound}' ")
class Dog(Animal) :
def __init__(self) :
self.species = "dog"
self.sound = "bow bow"
class Cat(Animal) :
def __init__(self) :
self.species = "cat"
self.sound = "miāo"
Dog() .make_sound()
Cat() .make_sound()
--
The dog says, 'bow bow'
The cat says, 'miāo'
The code in figure 2 demonstrates simple inheritance. The Dog class definition has the Animal class in
parentheses as in class Dog(Animal) : . Putting Animal in parentheses means that the Dog inherits the
make_sound() method from the Animal method.
Also, notice that the Dog and Cat classes override the __init__() method by defining it. So when you create
Dog or Cat , they call their __init__() method instead of the __init__() in Animal . But they don’t
override make_sound() , so when you call make_sound() in Dog and Cat , they run the inherited method
from Animal .
Inheritance in Python
Inheritance in Python is different than inheritance in C or SystemVerilog. Python classes do not declare
variables, and their extensions do not inherit variables.Python classes inherit only methods.
When you call a method in an object, Python searches the object’s class for the method. If it does not find it, it
searches the parent class for the method, and if it doesn’t find it there, it follows the list of extended classes
until it reaches the end and declares an error.
For example, there are dogs, and then there are small dogs. Small dogs don’t say woof woof or bow bow. They
say yap yap. The code in figure 3 captures this by replacing a Cat with a SmallDog class 7 that extends Dog .
class Animal:
def __init__(self) :
self.species = None
self.sound = None
def make_sound(self) :
print(f"The {self.species} says '{self.sound}' ")
class Dog(Animal) :
def __init__(self) :
self.species = "dog"
self.sound = "bow bow"
class SmallDog(Dog) :
def __init__(self) :
self.sound = "yap yap"
SmallDog().make_sound()
Figure 4 shows an error that would surprise a SystemVerilog programmer who would expect SmallDog to
inherit the variable self.species .
in make_sound(self)
4 self.sound = None
5 def make_sound(self) :
----> 6 print(f"The {self.species} says '{self.sound}' ")
7
8 class Dog(Animal) :
We got this attribute error because the __init__() function in SmallDog did not set the self.species data
attribute. So while Dog has a species data attribute, SmallDog does not.
To solve this problem, we need to have SmallDog.__init__() call Dog.__init__() method. One way to do
that is with the super() function.
The super() function
When you call a class’s method, Python searches the class for the method, and if it does not find the class it
searches the parent classes.
Python calls this method search path the method resolution order (MRO). Every class has a class method named
mro() that returns the method resolution order for that class:
print(SmallDog.mro() )
--
[<class '__main__.SmallDog'>,
<class '__main__.Dog'>,
<class '__main__.Animal'>,
<class 'object'>]
We see that the SmallDog class searches itself, then Dog , then Animal , and finally object . So when we
called self.__init__() in a SmallDog object, Python found the method in SmallDog and stopped looking.
class SmallDog(Dog) :
def __init__(self) :
super() .__init__()
self.sound = "yap yap"
SmallDog() .make_sound()
--
The dog says 'yap yap'
The super() function looks at the mro list and, if the argument list is empty, returns the class after the one
calling super() . If you pass super() one of the MRO entries as an argument, it will return the MRO entry
after the one you passed.
We called super() with no argument and the class after SmallDog in the SmallDog.mro list is Dog . So,
super().__init__() calls the __init__() method in Dog .
You should make it a habit to call super().__init__() whenever you override an __init__() method. You
must do this for pyuvm to work correctly.
class SmallDog(Dog) :
def __init__(self) :
Dog.__init__(self)
self.sound = "yap yap"
SmallDog() .make_sound()
--
The dog says 'yap yap'
The advantage of this approach over using super() is that you know which copy of __init__() you are
calling.
Multiple Inheritance
We have multiple roles in our lives. For example, we can be a parent who is also a firefighter. Most object-
oriented programming languages capture this fact using multiple inheritance. (We have to say most because
SystemVerilog does not have multiple inheritance.)
Let’s take the notion of our roles to create a multiple inheritance example. Consider the Human class that sets
a human’s name and age and has a say_age() method.
def say_age(self) :
print(f"{self.name} is {self.age} years old.")
Now we can extend Human to create different new classes: Parent and Firefighter . The Parent class
defines the kiss() method and the Firefighter class defines the hose() method.
class Parent(Human) :
def kiss(self) :
print(f"{self.name} gives the baby a kiss.")
class Firefighter(Human) :
def hose(self) :
print(f"{self.name} sprays water.")
In figure 10 we instantiate a 35-year-old person named Pat who is both a parent and a firefighter. One might
represent this by creating two objects with the same name and age.
pp = Parent("Pat", 35)
ff = Firefighter("Pat", 35)
pp.kiss()
pp.say_age()
ff.hose()
ff.say_age()
--
Pat gives the baby a kiss.
Pat is 35 old.
Pat sprays water.
Pat is 35 old.
We’ve created two objects to represent Pat’s life, which is inefficient and aesthetically wrong because Pat is one
person. Instead, let’s create a new class for someone who lives as Pat does. The diagram in figure 11 illustrates
the solution. We create a new class named FirefighterWithKids that inherits from Parent and
Firefighter . This is multiple inheritance.
The code in figure 12 defines Human and extends it to create the roles Parent and Firefighter . Then it
defines FirefighterWithKids which inherits from both Parent and Firefighter . This is multiple
inheritance.
class Human:
def __init__(self, name, age) :
self.name = name
self.age = age
def say_age(self) :
print(f"{self.name} is {self.age} old.")
class Parent(Human) :
def kiss(self) :
print(f"{self.name} gives the baby a kiss.")
class Firefighter(Human) :
def hose(self) :
print(f"{self.name} sprays water.")
The FirefighterWithKids.mro list shows where the class gets its methods.
The FirefighterWithKids.mro() list shows that we look for methods in the FirefighterWithKids class first,
and then we search through the Parent then Firefighter class, and finally the Human class. The order is
Parent followed by Firefighter because that’s the order between the parentheses in the class
FirefighterWithKids(Parent, Firefighter) statement.
We now have a class, FirefighterWithKids , that can kiss the baby and spray the hose as in figure 14.
The preceding design placed the __init__() in the base class, and its children used the data attributes the
parent defined.
Sometimes you can’t define all the data attributes in the ultimate parent class. The code in figure 16 adds new
methods to Parent and Firefighter that need data unique to their roles. The Parent.feed() method uses
the number of children, and the Firefighter.drive() method uses the truck number. The Human cannot
have the __init__ because not every human has a truck number.
We solve this problem by moving the __init__() from the top of the class hierarchy to the bottom.
Now the Human , Parent , and Firefighter classes depend upon their child classes to set their data
attributes as in figure 16.
The Human , Parent , and Firefighter classes can only function if you extend them because they depend
upon their child classes to implement the __init__() method. Classes that depend upon their children to
implement needed methods are called abstract classes. In figure 16, the FirefighterWithKids class
implements __init__() , so it is called a concrete class.
Summary
Python classes inherit methods from their parent classes using the method resolution order (MRO) to find the
method. Python classes do not inherit data members. These must be set in __init__() . Use super() to call
the parent’s version of an overridden method including __init__() .
Python supports multiple inheritance. When you design a class hierarchy using multiple inheritance, you
should have only one __init__() method in the hierarchy, either at the top of the hierarchy or at the bottom.
Design patterns
Design Patterns are object-oriented tricks used so often that people write entire books about them. Recognizing
the tricks as patterns, and naming the patterns, allows programmers to communicate design intentions more
easily.
It’s worth investing in a good book on design patterns. My favorite is named Head First Design Patterns by
Freeman, Bates, Sierra, and Robson. I like it because it has a lot of pictures, though its examples are in Java.
The code in figure 1 defines an empty class named MyClass . When we call MyClass() by appending
parentheses to the class name, it returns a new MyClass object. We can see that these objects are unique by
using the id() function, which returns the object ID number.
class MyClass:
...
nc1 = MyClass()
nc2 = MyClass()
print("id(nc1) :", id(nc1) )
print("id(nc2) :", id(nc2) )
--
id(nc1) : 140221022182848
id(nc2) : 140221022380096
A class that implements the singleton pattern changes this behavior. It returns the same handle no matter how
many times you call it. That way, code across the program can share this object.
At this point, I’ll ask for your indulgence to accept a little pyuvm magic. Python can implement Singletons in
many ways, but it’s beyond the scope of this book to discuss them. We’re going to leverage the approach
defined in pyuvm.
We define a singleton class using the metaclass = argument 8 and passing it Singleton imported from
pyuvm. Now, when we call MySingleton twice, it returns the same object, as we see from the id() function in
figure 2.
# Figure 2: The singleton returns the same object for every call
class MySingleton(metaclass=Singleton) :
...
ms1 = MySingleton()
ms2 = MySingleton()
print("id(ms1) :", id(ms1) )
print("id(ms2) :", id(ms2) )
--
id(ms1) : 140198470115872
id(ms2) : 140198470115872
I first learned about factory patterns in a book about writing a space war game. You couldn’t tell which weapon
the user would fire, so you used a factory to create an object based on the weapon chosen. Let’s see how we
do that.
Our example game defines three kinds of weapons that print out different sounds. The Federation has the
Phaser that prints " Zzzap! " and the PhotonTorpedo that prints " Pew! Pew! "
We’ll use a list to implement a factory in this example. The Weapon class implements sound_effect , then
Phaser , PhotonTorpedo , and GiantRedBall extend Weapon . They set their sound effect in their
__init__() method.
We store the classes in a list named factory_list . Notice that we are storing the classes in the list, not
instances of the classes.
import random
class Weapon:
def sound_effect(self) :
print(self.sound)
class Phaser(Weapon) :
def __init__(self) :
self.sound = "Zzzap!"
class PhotonTorpedo(Weapon) :
def __init__(self) :
self.sound = "Pew! Pew!"
class GiantRedBall(Weapon) :
def __init__(self) :
self.sound = "WHOOSH BOOM!"
# Figure 4: Testing the factory list by calling the class in the list
for _ in range(6) :
weapon_no = random.randint(0,2)
weapon_cls = factory_list[weapon_no]
weapon = weapon_cls()
weapon.sound_effect()
--
WHOOSH BOOM!
Zzzap!
Pew! Pew!
Pew! Pew!
Pew! Pew!
Zzzap!
Creating different kinds of objects based on incoming data is the essence of a factory class. We don’t hard code
our class instantiations. Instead, we choose them at run time.
Summary
This chapter taught us about the singleton and factory design patterns. We’ve now completed our overview of
Python. Upcoming chapters explain how to connect Python to a simulator using coroutines and cocotb.
Coroutines
Imagine you’re writing a program such as the old adventure game Rogue that takes keystrokes as commands.
For example, the user presses j to move the hero left, ; to move the hero right, etc. You need to write
software that waits for a key to be pressed and processes the keystroke.
Such software usually has a construct called an event loop that blocks code until an event happens and then
responds. Fortunately, we don’t need to write such a construct in Python because there are modules that
provide event loops. The objects that go into the event loops and wait for events are called coroutines.
The most commonly used event-loop module is named asyncio since it’s intended to process asynchronous
I/O, as in the game example. But we’re not going to use that module. Instead, we’re going to use a module
named cocotb , whose event loop works with a simulator. 9
2. Identify a top-level coroutine (called a test ) that kicks off the simulation.
Defining coroutines
Coroutines are very similar to functions in that we define them using the def statement.
In figure 1 we first tell cocotb that the following coroutine is a test by using the @cocotb.test() decorator.
Then, we define a coroutine by adding the async keyword before the function def keyword.
We see the async keyword in the async def hello_world(_) example. The _ variable inside the argument
list is a convention that says we’re not using the argument.
import cocotb
@cocotb.test()
async def hello_world(_) :
"""Say hello!"""
logger.info("Hello, world.")
The coroutine contains a docstring in triple-quotes that cocotb prints when cocotb runs the test. The logger
object allows us to log messages to the screen using the simulation time stamp. We will discuss logger objects
in a later chapter.
We run the simulation, and cocotb puts the test coroutine on its event loop, where it runs immediately. Not
surprisingly, we pass the test.
# Figure 2: Hello world from the simulator
Note: The cocotb outputs in this book reflect cocotb 1.7.0.dev0, the latest version in April of 2022. They may
change in the future.
Figure 3 contains a VHDL example where our process waits for two nanoseconds before printing its message.
process is
begin
wait for 2 ns;
report "I am DONE waiting!";
wait;
end process;
The VHDL process automatically loops back to the top and would run forever if we didn’t stop it with the
second wait statement. SystemVerilog has different constructs for looping and non-looping behavior. In
figure 4 we use an initial block to run once in SystemVerilog 10 .
initial begin
#2ns;
$display("I am DONE waiting!") ;
end
We can write the same behavior in Python using cocotb. In figure 5, we import a cocotb coroutine named
Timer() to wait for two nanoseconds. Then we use the await keyword to block our execution until Timer()
returns control two simulated nanoseconds later. The logger output in figure 5 shows that we started at time
0.00ns and logged the message at 2.00ns .
# Figure 5 Python waits for 2 nanoseconds
import cocotb
from cocotb.triggers import Timer
@cocotb.test()
async def wait_2ns(_) :
"""Waits for two ns then prints"""
await Timer(2, units="ns")
logger.info("I am DONE waiting!")
--
2.00ns INFO I am DONE waiting!
Starting tasks
Simulating hardware often means launching behavior in the background and interacting with it. For example,
you may want to test a design where you put data into one side of the device under test (DUT) and want to see
it come out the other. In this case, you’d launch a coroutine that runs on its own and waits for output. We call
coroutine running in the background a running task or a task.
SystemVerilog launches tasks using the fork and join keywords. cocotb launches tasks with the
cocotb.start_soon() function.
You pass cocotb.start_soon() a handle to a coroutine, and it schedules the task to run and returns a handle
to a RunningTask object, also called a task . You can await the task, kill it, get a value from it, or ignore it.
In figure 6, we create a coroutine to run in the background and launch it in various ways to demonstrate these
behaviors. The counter counts from 1 up to count with a delay of delay ns between counts.
import cocotb
from cocotb.triggers import Timer, Combine
import logging
@cocotb.test()
async def do_not_wait(_) :
"""Launch a counter"""
logger.info("start counting to 3")
cocotb.start_soon(counter("simple count", 1, 3) )
logger.info("ignored running_task")
--
INFO start counting to 3
INFO ignored running_task
Note: The output messages do not include the simulation time, test name, and other irrelevant information to
save space on the page.
Well, that was unsatisfying. We launched our counter, and it did not count. That is because we didn’t wait for it.
We ended the simulation almost as soon as it launched.
There are situations where we’ll launch a task and ignore it, for example, when the coroutine contains an
infinite while loop. In those cases, the task will run until the simulation ends.
However, we should wait for the running task to finish in this case.
@cocotb.test()
async def wait_for_it(_) :
"""Launch a counter"""
logger.info("start counting to 3")
running_task = cocotb.start_soon(counter("simple count", 1, 3) )
await running_task
logger.info("waited for running task")
--
2.00ns INFO start counting to 3
3.00ns INFO simple count counts 1
4.00ns INFO simple count counts 2
5.00ns INFO simple count counts 3
5.00ns INFO waited for running task
We import the Combine() coroutine from cocotb.triggers . It blocks until all the tasks in its argument list
have returned 11 . Now we can keep the simulation running until both tasks have finished.
# Figure 9: Mom and The Count count in parallel
@cocotb.test()
async def counters(_) :
"""Test that starts two counters and waits for them"""
logger.info("The Count will count to five.")
logger.info("Mom will count to three.")
the_count = cocotb.start_soon(counter("The Count", 1, 5))
mom_warning = cocotb.start_soon(counter("Mom", 2, 3))
await Combine(the_count, mom_warning)
logger.info("All the counting is finished")
Since The Count waits for one ns between counts and Mom waits for two ns. Figure 10 shows interleaved
counting between The Count and Mom.
cocotb also provides a First() task that returns when the first task has been completed. 12
We use await on the right side of the equal sign whenever we get a value from a coroutine. We can also use it
to get a value from a task. For example, here is a coroutine that waits for delay and then returns the
increment of a number.
# Figure 12: A coroutine that increments a number
# and returns it after a delay
We call cocotb.start_soon() to start this task and await its result twice.
@cocotb.test()
async def inc_test(_) :
"""Demonstrates start_soon() return values"""
logging.info("sent 1")
inc1 = cocotb.start_soon(wait_for_numb(1, 1) )
nn = await inc1
logging.info(f"returned {nn}")
logging.info(f"sent {nn}")
inc2 = cocotb.start_soon(wait_for_numb(10, nn) )
nn = await inc2
logging.info(f"returned {nn}")
In the output, we see the number is incremented, and that time advances 1 ns for the first task and 10 ns for
the second.
Killing a task
There is usually no need to kill a task, as they all stop running when the simulation ends. However, there may
be cases where you want to kill a task. You do that by calling the kill() method on a RunningTask() object
as in figure 15.
@cocotb.test()
async def kill_a_running_task(_) :
"""Kill a running task"""
kill_me = cocotb.start_soon(counter("Kill me", 1, 1000) )
await Timer(5, units="ns")
kill_me.kill()
logger.info("Killed the long-running task.")
--
23.01ns INFO Kill me counts 1
24.01ns INFO Kill me counts 2
25.01ns INFO Kill me counts 3
26.01ns INFO Kill me counts 4
27.01ns INFO Killed the long-running task.
Summary
This chapter examined coroutines, event-driven functions, and cocotb , a module that provides an event loop
that interacts with a simulator. We learned to use cocotb coroutines such as Timer to await the simulated
passage of time using the await keyword.
We also learned how to use the cocotb.start_soon() function to run coroutines in the background as tasks.
We use cocotb coroutines such as Combine() and First() to wait for background coroutines to complete.
We saw how to get a value back from a coroutine by using the await keyword on the right side of the
assignment, and we learned how to kill a task.
cocotb Queue
Starting a task would be of limited use if tasks could not communicate. So as we begin work on testbenches,
we’ll be creating tasks that share data.
The Python Queue class allows task communication. The cocotb extension of Queue enables this class to work
within cocotb . We get the cocotb Queue by importing it from the module cocotb.queue .
This import statement imports the cocotb version of the Queue alongside the QueueFull and QueueEmpty
exceptions that we‘ll use in our examples.
Task communication
Communicating tasks need to share data in the correct order. If a producer sends 1, 2, 3, the consumer
must receive 1, 2, 3 in that order. Since the tasks are running in parallel, we can’t know whether the
producer will send the data before the consumer reads it. Queue objects solve this problem by making
consumers wait until the producer has sent the data or forcing the producer to wait until the consumer is
ready to receive the data.
We can handle the waiting between tasks in two ways. Either the task blocks until it can send or receive data,
or the task polls the queue and waits on another coroutine before polling the queue again. The first is called
blocking communication and the second is called, oddly enough, nonblocking communication.
Blocking communication
Queues implement blocking communication with two coroutines named put() and get() . We’ll demonstrate
these with two coroutines named Producer and Consumer . The Producer sends numbers through a queue,
and the consumer reads them from a queue.
In figure 2 we define the Producer coroutine. It takes three arguments: the handle to a queue, the largest
number to send, and the delay between sending the numbers. Finally, the Producer prints a log message
when it successfully puts data into the queue.
The Consumer in figure 3 takes only a queue argument. The coroutine loops forever, getting numbers from
the queue using datum = await queue.get() if the queue is empty, the Consumer loop blocks. It logs the
times that it got the datum.
# Figure 3: A coroutine using a Queue to receive data
The code in figure 5 instantiates a Queue and passes it to the Producer and Consumer coroutines in
cocotb.start_soon() . The await Timer statement gives the Consumer the time to empty the queue before
the simulation finishes.
@cocotb.test()
async def infinite_queue(_) :
"""Show an infinite queue"""
queue = Queue()
cocotb.start_soon(Consumer(queue) )
cocotb.start_soon(Producer(queue, 3) )
await Timer(1, units="ns")
--
0ns INFO Producer sent 1
0ns INFO Producer sent 2
0ns INFO Producer sent 3
0ns INFO Consumer got 1
0ns INFO Consumer got 2
0ns INFO Consumer got 3
The infinite Queue allows the Producer to run to completion, then hand off execution to the Consumer .
A Queue of size 1
If we want the producer to block until the consumer has processed the data, we set the queue maxsize to 1 .
Setting the maxsize to 1 means the producer can put one number into the queue but blocks when it tries to
put (,) the second number into the queue. The blocked put() returns after the consumer empties the queue.
@cocotb.test()
async def queue_max_size_1(_) :
"""Show producer and consumer with maxsize 1"""
queue = Queue(maxsize=1)
cocotb.start_soon(Consumer(queue) )
cocotb.start_soon(Producer(queue, 3) )
await Timer(1, units="ns")
--
1.00ns INFO Producer sent 1
1.00ns INFO Consumer got 1
1.00ns INFO Producer sent 2
1.00ns INFO Consumer got 2
1.00ns INFO Producer sent 3
1.00ns INFO Consumer got 3
We see that the Producer() puts a 1 into the queue but blocks trying to put 2 because the queue is full. The
Consumer() then gets the 1 out of the queue but blocks when the queue is empty. The Consumer blocking
gives the Producer() a chance to write the 2 . The Producer and Consumer alternate running and blocking,
unlike the case with the infinite queue.
@cocotb.test()
async def producer_consumer_sim_delay(_) :
"""Show producer and consumer with simulation delay"""
queue = Queue(maxsize=1)
cocotb.start_soon(Consumer(queue) )
ptask = cocotb.start_soon(Producer(queue, 3, 5) )
await ptask
await Timer(1, units="ns")
--
7.00ns INFO Producer sent 1
7.00ns INFO Consumer got 1
12.00ns INFO Producer sent 2
12.00ns INFO Consumer got 2
17.00ns INFO Producer sent 3
17.00ns INFO Consumer got 3
The test started running at 2 ns. The Producer waited for 5 ns, then put the 1 into the queue at 7 ns. While the
Producer awaited the Timer() , the Consumer ran and read the number. The Consumer tried to read the
following number but was blocked because the Queue was empty. Both tasks were blocked while the Timer
counted off the 5 ns. The process repeated for all the numbers.
This section demonstrated coroutine communication using the blocking coroutines named put() and get() .
But there are situations where we cannot block the testbench while waiting for data. For example, we may be
in a loop that awaits the negative edge of the clock. If that loop blocked waiting for data, it would miss clock
edges.
We solve this problem using nonblocking communication.
Nonblocking communication
In blocking communication the Queue put() or get() block until the Queue is ready to communicate, no
matter how long this takes.
In nonblocking communication, you poll the Queue and block using other coroutines such as a Timer or
FallingEdge .
The put_nowait() and get_nowait() functions provide the ability to check a Queue and respond to the
queue being full or empty. We do not await put_nowait() and get_nowait() because they are functions, not
coroutines.
The code for ProducerNoWait() in figure 7 loops through numbers and sends them to the queue using
put_nowait() . If put_nowait() raises the QueueFull exception, the code skips over the’ break’ line. Instead,
the code jumps to the except block, where it waits for one simulated nanosecond. If put_nowait() returns
normally, the code breaks out of the while loop.
At the other end, ConsumerNoWait contains two while loops. The outer one loops forever, reading and
printing data. The inner loop loops until it successfully reads data using queue.get_nowait() and breaks out
of the loop. Otherwise, the QueueEmpty exception causes it to jump to the except block and wait for 2 ns.
There is one other difference. Rather than calling cocotb.start_soon() , this code shows that since we don't
need to do anything while ProducerNoWait is running, we can await it directly. So we await
ProducerNoWait(queue, 3) , which creates the coroutine and puts it on the event queue. We get control back
when ProducerNoWait has done its work, and we await the Timer to let the ConsumerNoWait finish.
At time 0, The Producer puts 1 into the queue but then finds the queue is full, so it waits for 1 ns. The
Producer waiting allows the Consumer to
read the 1 , but when the Consumer tries to read the following number, the queue is empty, so it waits for 2
ns.
# Figure 10: The Producer and Consumer waiting when the queue is full
0 ns INFO Producer sent 1
0 ns INFO Queue Full, waiting 1ns
0 ns INFO Consumer got 1
0 ns INFO Queue Empty, waiting 2 ns
1 ns INFO Producer sent 2
1 ns INFO Queue Full, waiting 1ns
2 ns INFO Consumer got 2
2 ns INFO Queue Empty, waiting 2 ns
2 ns INFO Producer sent 3
4 ns INFO Consumer got 3
The Producer waited for 1 ns at 0 ns, so we see it send the next number at 1 ns. Then the queue is full again.
The consumer waited for 2 ns at 0 ns, and so it gets the number at 2 ns. Because the consumer always waits 2
ns it gets the next number at 4 ns.
Summary
This chapter taught us about the Python Queue and how simultaneously running tasks communicate. We also
learned that cocotb had extended the class to create the cocotb.queue.Queue class to work with coroutines.
We imported the cocotb.queue.Queue and used it in a Producer and Consumer and demonstrated both the
blocking and nonblocking modes of communication. Now we’re ready to see cocotb simulating.
Simulating with cocotb
The code in figure 1 defines a “Hello, world” cocotb program.
@cocotb.test()
async def hello_world(_) :
"""Say hello!"""
logger.info("Hello, world.")
We discussed the @cocotb.test() decorator and the docstring, and we ignored the argument _ . The unused
argument in hello_world(_) provides the top of the simulation hierarchy. Usually, this is the device under
test (DUT). In this chapter, name the argument dut and use it.
Verifying a counter
Figure 2 defines a SystemVerilog counter module to show how we interact with the DUT. You reset this
counter by lowering reset_n and raising it again ( reset_n is synchronous). The counter then counts one
number per clock.
`timescale 1ns/1ns
module counter( input bit clk,
input bit reset_n,
output byte unsigned count) ;
always @(posedge clk)
count <= reset_n ? count + 1 : 'b0;
endmodule
We’ll use this to demonstrate using triggers and reading and setting values.
cocotb triggers
Triggers are cocotb coroutines that allow us to interact with the simulator timing. We use them to await the
clock or any other signal, either by counting clock cycles or waiting for signal edges. The cocotb.triggers
module contains the triggers. The signal provides the signal to watch:
import cocotb
from cocotb.clock import Clock
from cocotb.triggers import ClockCycles, FallingEdge
from pathlib import Path
import sys
parent_path = Path("..") .resolve()
sys.path.insert(0, str(parent_path) )
from tinyalu_utils import get_int, logger
def get_int(signal) :
try:
int_val = int(signal.value)
except ValueError:
int_val = 0
return int_val
The decision to return a 0 for x or z is testbench-specific. For example, other testbenches may want to let
the ValueError stop the simulation.
The logger object
The Python logging module prints log messages. Within cocotb , it also prints simulation time stamps. The
logger requires initialization. The tinyalu_utils module handles this initialization and defines an object
named logger to provide all logging functions.
import logging
logging.basicConfig(level=logging.NOTSET)
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
4. logger.setLevel(logging.DEBUG) —Allow logger to log most messages. The Logging chapter has
details about logging.DEBUG
Next we see how cocotb sets signal values. dut.reset_n is a handle to the reset_n port on the counter.
dut.reset_n.value = 0 sets the dut.reset_n signal to 0 . Now the counter is reset.
@cocotb.test()
async def no_count(dut) :
"""Test no count if reset is 0"""
cocotb.start_soon(Clock(dut.clk, 2, units="ns") .start() )
dut.reset_n.value = 0
The clock is running, and the reset is low, so the counter should remain 0. We use the ClockCycles(dut.clk,
5) coroutine to await five clock cycles. After that, we get the counter's value and assert that it is 0 . The
assertion in figure 7 tells cocotb whether the test passed.
await ClockCycles(dut.clk, 5)
count = get_int(dut.count)
logger.info(f"After 5 clocks count is {count}")
assert count == 0
--
8.00ns INFO After 5 clocks count is 0
cocotb generated messages to tell us the test it was running and whether the test had passed. In this case, the
test has passed. Our reset works.
The code in figure 8 waits for three clock cycles and checks that the count value is 3 .
@cocotb.test()
async def three_count(dut) :
"""Test that we count up as expected"""
cocotb.start_soon(Clock(dut.clk, 2, units="ns").start())
dut.reset_n.value = 0
await FallingEdge(dut.clk)
dut.reset_n.value = 1
await ClockCycles(dut.clk, 3, rising=False)
count = get_int(dut.count)
logger.info(f"After 3 clocks, count is {count}")
assert count == 3
--
16.00ns INFO After 3 clocks, count is 3
For example, this code in figure 9 wants to wait for six clocks but waits for zero clocks.
@cocotb.test()
async def oops(dut) :
"""Demonstrate a coroutine mistake"""
cocotb.start_soon(Clock(dut.clk, 2, units="ns") .start() )
dut.reset_n.value = 0
ClockCycles(dut.clk, 6)
logger.info("Did not await")
--
81.00ns INFO Starting test: "oops"
Description: Demonstrate a coroutine mistake
81.00ns INFO Did not await
82.00ns INFO Test Passed: oops
Notice that the time advanced only one nanosecond. Keep this mistake in mind when you have a testbench
that doesn’t seem to be advancing correctly.
Summary
cocotb passes the test coroutine a dut argument. You can use the dut object to access the clock and await
RisingEdge , FallingEdge , Edge , or ClockCycle .
You can set values in the dut with simple assignments to the signal using dut.<signal>.value = <value>
and you can read values from the dut object using the value property as in <var> = dut.<signal>.value .
Instantiating a coroutine such as RisingEdge() does not cause Python to await it. You must explicitly use the
await keyword like this: await RisingEdge(dut.clk) .
Basic testbench: 1.0
The rest of the book uses a simple DUT to teach cocotb coding techniques and then the Universal Verification
Methodology (UVM). All chapters from this point on will write increasingly modular and maintainable versions
of a testbench that verifies the TinyALU.
The TinyALU
The TinyALU is, as its name implies, a tiny ALU that has four operations. It takes two 8-bit legs named A and B
as input and produces a 16-bit result . Here is its block diagram.
The TinyALU has four operations: ADD , AND , XOR , and MUL , which allow it to implement any algorithm. The
ADD , AND and XOR operations take one clock cycle. The MUL takes three clock cycles. Here is the timing
diagram.
Figure 2: The TinyALU timing diagram
The user puts the operands on the A and B busses and the operation on the op bus, then raises the start
signal. Thus, the waveform diagram shows that the ALU command is valid in a clock cycle where the start
signal was 0 in the previous clock cycle and is 1 on this clock cycle. Therefore, the user must keep the start
signal at 1 until the done signal goes to 1 .
The TinyALU calculates and places the result on the result bus while raising the done signal. Therefore
result is valid in any clock cycle where the done signal is 0 in the previous clock cycle and is 1 on this clock
cycle.
Our monitors will keep a copy of the previous start and done signals and use them to grab data.
A cocotb testbench
The rest of this book transforms a simplistic testbench into a modular, maintainable UVM testbench. Each
chapter describes a testbench version that gets closer to the ultimate goal. This chapter describes the first
version.
This basic testbench is a simple, easy-to-understand loop that would be difficult to maintain or enhance. This
chapter intersperses the testbench code with commentary. You can see the entire testbench in the
23_Basic_testbench_1.0 directory in the python4uvm_examples repository.
Importing modules
n figure 3, we import the modules we need for the testbench. The first three imports are simple.
# Figure 3: Importing needed resources
import cocotb
from cocotb.triggers import FallingEdge
import random
from pathlib import Path
parent_path = Path("..") .resolve()
sys.path.insert(0, str(parent_path) )
from tinyalu_utils import Ops, alu_prediction, logger, get_int
import cocotb —Provides basic cocotb functionality including the @cocotb.test() decorator.
from cocotb.triggers import FallingEdge —We await the FallingEdge coroutine to synchronize
ourselves with the falling edge of the clock.
from tinyalu_utils import Ops, alu_prediction, logger, get_int —Import resources we’ll need
for the testbench
Python implements enumerations using the enum module. The enum module defines base classes that we
extend to create enumerations in our programs.
In our example, the TinyALU operations are an enumeration: ADD, AND, XOR, and MUL. The tinyalu_utils
module uses an enum.IntEnum to define the Ops enumeration 13 . This class not only names the operations
but also maps the names to the correct opcode integer.
import enum
class Ops(enum.IntEnum) :
"""Legal ops for the TinyALU"""
ADD = 1
AND = 2
XOR = 3
MUL = 4
Next we’ll use the Ops class to predict the TinyALU’s behavior.
The function takes A , B , and op as inputs. First, it asserts that the op is an Ops object. Then it uses the op
to pick the correct operation and perform it.
# Figure 5: The prediction function for the scoreboard
The alu_test coroutine first sets the passed variable to True . Then, we create a set named cvg that we’ll
use to check that we’ve tested all the operations defined in the Ops enumeration. Finally, we reset the DUT by
waiting for the falling edge of the clock and lowering the reset_n and start signals. After a clock edge, we
raise the reset_n signal, and we’re ready to run.
Now that we've reset the DUT, we can start sending commands.
Sending commands
In figure 7, the testbench creates one of each of the commands defined in Ops . It sends them into the TinyALU
by looping on the negative edge of the dut.clk signal, checking the status of the start and done signals to
figure out whether it should send a command or look for a result.
We set the cmd_count to 1 as we are simulating the first command. The op_list is a list of the operations
defined in Ops , and num_ops is the length of that list. We’ll send one command per operation.
After that, we run in a while loop as long as cmd_count is less than the number of commands. We await the
falling edge of the clock, then get the start and done values as integers.
# Figure 7: Creating one transaction for each operation
cmd_count = 1
op_list = list(Ops)
num_ops = len(op_list)
while cmd_count <= num_ops:
await FallingEdge(dut.clk)
st = get_int(dut.start)
dn = get_int(dut.done)
We now have the values of the start and done signals at the falling edge of the clock. We’re ready to check
the state of the start and done signals and send a command.
if st == 0 and dn == 0:
aa = random.randint(0, 255)
bb = random.randint(0, 255)
op = op_list.pop(0)
cvg.add(op)
dut.A.value = aa
dut.B.value = bb
dut.op.value = op
dut.start.value = 1
Now, the loop goes back to the top and waits for the next negative edge of the clock. As you can see in the
timing diagram in figure 2, the operation is complete once the start signal and the done signal are both 1
on the same negative clock edge, so we monitor the state of start and dn in the following if statements.
The first if statement (figure 9) checks for illegal DUT behavior. The timing diagram shows that done should
never go high if start is low.
if st == 0 and dn == 1:
raise AssertionError("DUT Error: done set to 1 without start")
In figure 10, we check to see if we are in the middle of a multi-clock operation. If the start signal is high, but
the done signal is not, we go back to the top of the loop and wait for the next negative edge of the clock.
# Figure 10: If we are in an operation, continue
if st == 1 and dn == 0:
continue
if st == 1 and dn == 1:
dut.start.value = 0
cmd_count += 1
result = get_int(dut.result)
In figure 12, we check that we got the correct results. We pass aa , bb , and op to the alu_prediction()
function and compare the predicted result, pr , to result . We log an information message if we pass and an
error message if we fail. Notice that we use the op.name operation value to print the operation's name. Also, if
we fail, we set passed to False .
If we missed any operations, we log an error message and set passed to false. Otherwise, we log a happy
informational message.
# Figure 13: Checking functional coverage using a set
We end the test by asserting that passed is True . This tells cocotb whether we passed the test.
assert passed
Summary
This chapter used cocotb to create a simple TinyALU test. The test created four commands with randomized
operands and ran them through the testbench.
This testbench fit entirely in one loop and was easy to follow. But it would be hard to maintain or expand
because different testbench functions are jammed together in one big while loop. A testbench like this works
for the TinyALU because it’s tiny. But it won’t work for large or complex devices. Large testbenches need to be
modular so that a team of engineers can work on them together and so that each operation is as simple as
possible.
We’re going to break the TinyALU into modular pieces. The first step is to separate the signal-level operations
that control the DUT from the testbench operations that create stimulus and check results.
Generates stimulus
Checks results
Mixing these behaviors makes the testbench tough to maintain or extend. For example, say we want to write a
different TinyALU test. Do we copy all this code and change only the differences? Copying and changing code
will lead only to frustration and tears, as every time we fix a bug or add functionality, we have to do it in
multiple files.
A maintainable testbench puts behaviors into different classes and leverages those classes for all the tests.
That way, we can extend or modify the testbench by changing only one class, and the behavior propagates to
all the tests. Modularity is essential when we create testbenches for complicated designs.
The rest of the book will break our testbench down into smaller, simpler pieces, and in the end, we’ll have a
modular, scalable testbench. But, first, we’ll move our communication with the DUT into a bus functional
model class.
cmd_driver —This coroutine checks the state of the start and done signals on each negative edge of
the clock. If both are 0 , the coroutine check the command queue for a command, and puts data on A ,
B , and op if there is a command in the queue.
cmd_mon — This coroutine watches for start transitioning from 0 to 1 over two clocks. When this
happens, the coroutine creates a tuple with the values from A , B , and op buses and places it into a
command monitor queue.
result_mon —This courting watches for the done signal transitioning from 0 to 1 over two clocks.
When this happens it puts the value on result bus into the result monitor queue.
while True:
await FallingEdge(dut.clk)
<check signals and do the work>
The code bfm = TinyAluBfm() delivers a handle to the same object regardless of where the testbench calls it.
The following sections document TinyAluBfm .
class TinyAluBfm(metaclass=pyuvm.Singleton) :
def __init__(self) :
self.dut = cocotb.top
self.cmd_driver_queue = Queue(maxsize=1)
self.cmd_mon_queue = Queue(maxsize=0)
self.result_mon_queue = Queue(maxsize=0)
self.dut = cocotb.top —The cocotb.top variable contains a handle to the DUT, the same handle the
cocotb.test() coroutine got as its dut argument. We will ignore that dut argument and use
cocotb.top instead.
self.driver_queue —The maxsize=1 argument means this queue only has room for one operation, so
the send_op() coroutine will block if the queue is full.
14 . The command
cmd_mon_queue —The maxsize=0 argument means this queue is infinitely large
monitor BFM uses put_nowait() to put (A, B, op) command tuples into this queue. The get_cmd()
coroutine reads data from the queue using get() and blocks if the queue is empty.
result_mon_queue —The maxsize=0 argument means this queue is infinitely large. The result monitor
BFM uses put_nowait() to put the value from the result bus into the queue. The get_result()
coroutine uses get() to read results from the queue and blocks if the queue is empty.
The reset() coroutine
We always reset our DUT before we run tests. In figure 4, the reset() coroutine sets the TinyALU inputs to
zero and sets reset_n to 0 . Then it waits for one clock and sets reset_n back to 1.
result_mon()
The result_mon monitors the done signal and the result bus. The timing diagram in figure 5 shows that
result is valid when done goes from 0 to 1 between clock edges. So we read result when prev_done is
0 and done is 1 .
# Figure 6: Monitoring the result bus
We create an integer from result and put the integer into the result_mon_queue using put_nowait() .
cmd_mon()
cmd_mon() reads the A , B , and op buses when the start signal goes from 0 to 1 , converting the values
to integers. It creates a tuple from the three integers and puts it into the cmd_mon_queue with put_nowait() .
cmd_driver()
The cmd_driver() coroutine loops on the falling edge of the clock and checks start and done . When start
and done are zero we try to get a command out of the driver_queue using get_nowait() . If the queue is
empty, get_nowait() raises the QueueEmpty exception.
In figure 8, we set all start and all the input buses to 0 . Then, we loop and wait for the negative edge of the
clock. At the clock edge, we read dut.start and dut.done .
# Figure 8: Driving commands on the falling edge of clk
Now, we check st and dn . If they are both 0 we try to get a command from self.driver_queue() using
get_nowait() . If we get a command, we put it on the buses and set start to 1 . If get_nowait() raises a
QueueEmpty exception we go back to the top of the while loop.
elif st == 1:
if dn == 1:
self.dut.start.value = 0
Notice that the driver coroutine doesn’t worry about reading the result. Instead, it only drives commands to
the TinyALU and relies upon the result_mon() coroutine to get the result. The ability to ignore other
testbench elements is an advantage of modularity.
def start_tasks(self) :
cocotb.start_soon(self.cmd_driver())
cocotb.start_soon(self.cmd_mon())
cocotb.start_soon(self.result_mon())
Notice that start_soon() is a function, not a coroutine, so start_tasks() is also a function. Neither
consumes simulation time when it runs.
get_cmd()
This coroutine awaits until there is a command in the cmd_mon_queue and then returns it.
get_result()
This coroutine awaits until a result is in the result_mon_queue and then returns it.
send_op()
We give the send_op() coroutine the values for A , B , and op , and it combines them into a command and
puts the command in the cmd_driver_queue . If the cmd_driver_queue contains a previous command, then
put() blocks until the queue is free.
# Figure 14: send_op puts the command into the command Queue
Since we instantiate the TinyAluBfm we ignore the argument to test_alu . We’ll never use that argument as
all future testbench versions will use TinyAluBfm .
Our test gets a handle to TinyAluBfm , resets the DUT, and launches the tasks that drive and monitor the
TinyALU signals. Then it creates a functional coverage set named cvg .
@cocotb.test()
async def test_alu(_) :
"""Test all TinyALU Operations"""
passed = True
bfm = TinyAluBfm()
await bfm.reset()
bfm.start_tasks()
cvg = set()
Sending commands
We have a more straightforward while loop because we’re not contending with a clock edge. Instead, we can
create a list of operations and loop through them, using await bfm.send_op(aa, bb, op) to send the
command to the TinyALU.
ops = list(Ops)
for op in ops:
aa = random.randint(0, 255)
bb = random.randint(0, 255)
await bfm.send_op(aa, bb, op)
1. We can catch errors in the driver task by independently reading the inputs from the TinyALU. Such an
error is unlikely in a simple BFM such as the TinyALU driver task but is more valuable when working with
complicated interfaces such as USB.
2. We may want to use the BFM in a future testbench where the TinyALU is part of a higher-level design. In
that case, we won’t create the commands but will still want to monitor them.
First, we get the command tuple back from the BFM by awaiting bfm.get_cmd() . Then we pull the operation
out of the tuple and add it to the cvg set using the seen_op variable.
Now that we have the command we await the result from the BFM and create a predicted result ( pr ) using
alu_prediction .
if result == pr:
logger.info(f"PASSED: {aa:02x} {op.name} {bb:02x} = {result:04x}")
else:
logger.error( f"FAILED: {aa:02x} {op.name} {bb:02x} = "
f"{result:04x} - predicted {pr:04x}")
passed = False
We’re done with our test loop, so we check that we’ve covered all the operations and finish the cocotb test by
asserting that passed is still True .
assert passed
Success!
Encapsulating DUT communication in TinyAluBfm is a good start toward breaking the testbench into pieces.
However, we still have operations such as stimulus generation and result checking mixed in one loop.
In the next chapter, we’ll take the next step to modularize the testbench by refactoring it to use classes.
Class-based testbench: 2.0
This chapter demonstrates how to write a cocotb testbench using classes and objects. Classes allow us to
break testbench functionality into small, easier-to-maintain pieces.
The testbench in this chapter shows how we use classes to avoid copying code and making small changes.
Figure 1 says that we’ll create a class named BaseTester that contains an execute() method. The
RandomTester and MaxTester classes extend BaseTester , inheriting the execute() method. They add a
get_operands() method that either returns eight random bits, or a xFF to use on the A and B buses.
The execute() coroutine gets a handle to the BFM and creates a list of the operations. Then it loops through
the operations and uses self.get_operands() to get values for aa and bb . Finally, it uses
self.bfm.send_op() to send the command to the TinyALU.
The code in figure 2 implements the BaseTester . Surprisingly, the get_operands() method seems to be
missing. As the diagram shows, it gets defined in the child classes
class BaseTester() :
BaseTester is an abstract class that depends upon its descendent classes to define the get_operands()
method. Python enables this design style as part of its “ask forgiveness not permission” dynamic typing
philosophy.
We call classes that serve as the parent to many related classes base classes , hence the name BaseTester.
The RandomTester
We’ve said that we want to create many small objects that do one thing and then put them together. The
RandomTester is a good example of this. All it does is extend BaseTester and provide a get_operands()
function. This makes RandomTester a concrete class that inherits from an abstract class.
The RandomTester inherits execute() from the BaseTester , so we don’t need to copy that code here.
The MaxTester
The MaxTester extends BaseTester and provides xFF as the operands for A and B .
1. It starts two tasks that gather the commands and results from the DUT and store them in lists. The
commands and results match up because the tasks store them in the same order.
2. It defines a check_results() function that loops through the lists, predicting results from the
commands and comparing them to the results. The function also fills the coverage set to ensure the test
reaches all the commands.
One might ask why we need functional coverage if the Tester loops through all the operations. We check
coverage because the Scoreboard needs to work with any tester, and some testers might not invoke all
operations.
Initialize the scoreboard
First, we store a handle to the BFM and initialize the lists that will store commands and results. Then, we create
a cvg set to use for functional coverage.
class Scoreboard() :
def __init__(self) :
self.bfm = TinyAluBfm()
self.cmds = []
self.results = []
self.cvg = set()
The get_result() coroutine loops forever and awaits self.bfm.get_result() . When it gets results it
appends to self.results .
def check_results(self) :
passed = True
for cmd in self.cmds:
aa, bb, op_int = cmd
op = Ops(op_int)
self.cvg.add(op)
actual = self.results.pop(0)
prediction = alu_prediction(aa, bb, op)
if actual == prediction:
logger.info(f"PASSED: {aa:02x} {op.name} {bb:02x} =
{actual:04x}")
else:
passed = False
logger.error(
f"FAILED: {aa:02x} {op.name} {bb:02x} =
{actual:04x}"
f" - predicted {prediction:04x}")
We return the passed variable to tell the testbench whether the test has passed.
First, we start up the testbench by resetting the DUT and starting the tasks in bfm and scoreboard .
# Figure 11: The execute_test coroutine starts the tasks
Now we’re ready to create stimulus and test the results. The tester_class argument contains either a
RandomTester or a MaxTester . It doesn’t matter which. We instantiate tester_class and await
tester.execute() .
When we return from tester.execute() the scoreboard will have filled its lists. We call
scoreboard.check_results() to check for errors and return the passed variable to the cocotb test.
tester = tester_class()
await tester.execute()
passed = scoreboard.check_results()
return passed
We pass the execute_test() coroutine a tester class. It returns a bool to the passed variable, and we assert
that passed is True . If it is false, cocotb logs an error.
@cocotb.test()
async def random_test(_) :
"""Random operands"""
passed = await execute_test(RandomTester)
assert passed
@cocotb.test()
async def max_test(_) :
"""Maximum operands"""
passed = await execute_test(MaxTester)
assert passed
Summary
This chapter created a TinyALU testbench using classes. We saw that we could share common code by creating
a BaseTester that drove commands into the TinyALU. We extended BaseTester to create RandomTester
and MaxTester .
Our Scoreboard object monitored the TinyALU output and checked that the operations worked correctly.
We saw that we could pass classes to an execute_test() coroutine that would initialize the testbench and
run the test. Once again, we avoided copying code.
The cocotb tests passed execute_test() a RandomTester or MaxTester class and awaited the passed
variable. Then it asserted that passed should be True .
Why UVM?
While the Universal Verification Methodology is the most successful verification methodology in the history of
the world, it’s not the first one. The eRM, VMM, AVM, and OVM came earlier, but the UVM came last and is the
sole survivor thanks to an agreement between Cadence, Synopsys, and Mentor Graphics (now Siemens).
Today, the UVM’s evolution is in the hands of a committee of EDA vendors and end-users.
A verification methodology provides a standard set of answers to questions common to all testbench
developers. Here are some of the questions the UVM answers.
The UVM solves this problem by allowing components to send data to each other.
The UVM has a mechanism for sharing objects across the entire testbench and giving different data to
different parts of the testbench.
The UVM provides logging modules to all testbench components and a way to change logging levels in
different parts of the testbench.
Summary
One does not have to use the UVM to create testbenches with Python and cocotb, but one does have to
answer all the questions in this chapter for testbenches of various sizes. However, if engineers answer these
questions differently for their testbenches, they’ll have trouble reusing components across teams and projects.
Using the UVM (through pyuvm ) automatically gives us testbenches that work the same way regardless of the
device under test. Learning and using the UVM allows us to move beyond basic questions of structure and
reuse and focus on the more complicated question of verifying our DUT.
uvm_test testbench: 3.0
We made testbench 2.0 modular by adding a Tester class and a Scoreboard class. We’re now ready to use
the UVM and import pyuvm.
import pyuvm
from pyuvm import *
@pyuvm.test()
class HelloWorldTest(uvm_test) :
async def run_phase(self) :
self.raise_objection()
self.logger.info("Hello, world.")
self.drop_objection()
import pyuvm —Import pyuvm so we can use the @pyuvm.test() decorator to identify our test to
cocotb.
from pyuvm import * —Importing all the objects from the pyuvm module matches how we do imports
in SystemVerilog UVM where we type import uvm_pkg::* .
@pyuvm.test() —The @pyuvm.test() decorator tells cocotb that this pyuvm class is a cocotb test.
class HelloWorldTest(uvm_test) —The HelloWorldTest class extends the uvm_test class. Python's
tradition is to capitalize the first character of each word in a class name, but pyuvm uses the underscored
class names from the UVM specification.
async def run_phase(self) —We will discuss phasing in the next chapter, but suffice it to say this
coroutine must be named run_phase() so that the UVM can find it and execute it. It is the equivalent of
the execute() function in our class-based testbench.
self.raise_objection() —This function tells the UVM that a test is running. The UVM will stay in the
run phase (discussed next chapter) until the test drops the objection.
self.drop_objection() —This function tells the UVM that the test has finished and that the
run_phase() can end.
This simple example demonstrates the basics of defining a UVM test. Now we’ll run the test.
The INFO: testbench.py(15) [uvm_test_top] : Hello, world . line comes from pyuvm and looks like the
reports in SystemVerilog UVM. The pyuvm logger puts the path to the component that logged the message
between the square brackets.
The UVM instantiates the class with the @pyuvm.test() decorator and names it uvm_test_top . For example,
uvm_test_top is the name of the HelloWorldTest object.
The diagram shows that we will extend uvm_test to create BaseTest and then RandomTest and MaxTest .
These mirror the behavior in testbench 2.0. Here is a short description of the UVM classes in the diagram.
uvm_void —This is the ancestor of all UVM classes. The UVM factory (discussed in chapter UVM Factory )
can create any class that extends uvm_void . Users don’t extend uvm_void (hence the name.)
uvm_object —Extend this class when you want to define a class in your testbench that leverages
uvm_object features.
uvm_report_object —Provides the self.logger object that we’ll discuss in the Logging chapter.
uvm_component —The backbone of the testbench’s structure. It provides phase methods that we’ll
discuss in the uvm_component chapter. We’ll use two of those methods in this testbench.
uvm_test —Extends uvm_component and should be used when creating test classes.
Testbench 2.0 contained an execute_test() coroutine that we launched in the cocotb test. We’ll take that
code and put it into the BaseTest , but
we’ll call the coroutine run_phase() instead of execute_test() .
class BaseTest(uvm_test) :
async def run_phase(self) :
self.raise_objection()
bfm = TinyAluBfm()
scoreboard = Scoreboard()
await bfm.reset()
bfm.start_tasks()
scoreboard.start_tasks()
await self.tester.execute()
passed = scoreboard.check_results()
assert passed
self.drop_objection()
Figure 5 provides our first exposure to the phase methods in the UVM. In the uvm_component chapter, we'll see
that components inherit a set of methods with names that end in _phase and that the UVM calls these
methods in a predefined order.
The run_phase() is the only coroutine phase. In BaseTest (and its child classes) run_phase() starts up the
scoreboard and awaits the self.tester() .
@pyuvm.test()
class RandomTest(BaseTest) :
"""Run with random operations"""
def build_phase(self) :
self.tester = RandomTester()
@pyuvm.test()
class MaxTest(BaseTest) :
"""Run with random operations"""
def build_phase(self) :
self.tester = MaxTester()
(We defined the RandomTester and MaxTester classes in the Class-based testbench 2.0 chapter.)
When we run one of these tests, the UVM calls build_phase() , which instantiates self.tester , and then
launches run_phase() , which uses the tester to drive stimulus into the TinyALU.
Notice that BaseTest is an abstract class. It cannot run without being extended by a class that provides
build_phase() . This is why BaseTest does not use the @pyuvm.test() decorator. It cannot be run directly.
Summary
This chapter featured our first foray into pyuvm. We defined two test classes that extended uvm_test and
used the build_phase() and run_phase() to run the random operand and maximum operand tests.
We saw that we launch UVM tests by decorating them with @pyuvm.test() .
When defining a uvm_component , you must override one or more phase functions defined in the base
uvm_component class. The UVM calls these phase functions in the same order in all components.
If a given component does not override a phase function, then that component does nothing in that phase.
Here are the phases in the order that the UVM calls them. Most testbenches must use the build_phase() ,
connect_phase() , and run_phase() .
1. build_phase(self)
This function instantiates objects in the component. Typically, the function creates child components who
recognize the creator as their parent. Once build_phase() completes, the UVM calls the build_phase of the
children. The UVM builds a hierarchy of components from the top down.
2. connect_phase(self)
We’ll see later that components can send data to each other if connected. This phase connects the
components created in the build_phase() . Unlike other phases, the connect_phase() runs first on the leaf-
level components in the testbench and works its way up. This makes it a bottom up phase.
3. end_of_elaboration_phase(self)
Building and connecting the components is called elaboration. Once we’ve elaborated the testbench, we can
execute operations that require the finished component hierarchy. For example, we may want to change the
logging level of a child component and all its children. We do that in this function.
4. start_of_simulation_phase(self)
This function is similar to end_of_elaboration_phase() in that it runs after all the components have been
instantiated and connected, but just before pyuvm starts the run_phase() coroutines.
5. run_phase(self)
The run_phase() is the only coroutine in a UVM component. The UVM forks all the run_phase() coroutines
in the hierarchy at the same simulation time.
Components keep the simulation in the run phase by calling self.raise_objection() . Any number of
components can call self.raise_objection() . The simulation will stay in the run phase until every
component that called self.raise_objection() calls self.drop_objection() .
6. extract_phase(self)
This function runs immediately after the simulation completes. You can use it to prepare simulation data to be
checked.
7. check_phase(self)
Use this function to check that your simulation results are correct and complete.
8. report_phase(self)
This is where we report the results of the extract_phase() and check_phase() functions.
9. final_phase(self)
This method prepares for the end of the simulation.
@pyuvm.test()
class PhaseTest(uvm_test) : # uvm_test extends uvm_component
def build_phase(self) :
print("1 build_phase")
def connect_phase(self) :
print("2 connect_phase")
def end_of_elaboration_phase(self) :
print("3 end_of_elaboration_phase")
def start_of_simulation_phase(self) :
print("4 start_of_simulation_phase")
def extract_phase(self) :
print("6 extract_phase")
def check_phase(self) :
print("7 check_phase")
def report_phase(self) :
print("8 report_phase")
def final_phase(self) :
print("9 final_phase")
Running the test gives us the output in figure 2 as the UVM calls the phases in order.
# Figure 2: pyuvm calls the phase methods in this order
1 build_phase
2 connect_phase
3 end_of_elaboration_phase
4 start_of_simulation_phase
5 run_phase
6 extract_phase
7 check_phase
8 report_phase
9 final_phase
name —This string is the component’s name. pyuvm concatenates the name to the parent’s name to
create the hierarchy path.
parent —This is the handle to the component’s parent. Since the component instantiating the child
component is the parent, this argument typically receives the self variable.
TestTop (uvm_test_top)
pyuvm creates an instance of TestTop and names it uvm_test_top . Then it begins calling the phase
methods. The first phase it calls is build_phase() . The build_phase() method in uvm_test_top
instantiates a MiddleComp component and passes it its name ( " mc " ) and its parent. Since the object running
build_phase() is the parent, we pass self as the parent.
# Figure 4: pyuvm instantiates TestTop as uvm_test_top
# The test is always named uvm_test_top
@pyuvm.test()
class TestTop(uvm_test) :
def build_phase(self) :
self.logger.info(f"{self.get_name() } build_phase")
self.mc = MiddleComp("mc", self)
def final_phase(self) :
self.logger.info("final phase")
When this build_phase() completes pyuvm calls build_phase in self.mc . The final_phase() will run at
the end of the test. TestTop inherited self.get_name() from uvm_object . The method returns the
component name, uvm_test_top in this case.
MiddleComp (uvm_test_top.mc)
The MiddleComp has a build_phase that instantiates a BottomComp by passing it its name ( " bc " ) and its
parent ( self ). We give the components the same name as their variable as a convention. They can be named
anything.
class MiddleComp(uvm_component) :
def build_phase(self) :
self.bc = BottomComp(name="bc", parent=self)
def end_of_elaboration_phase(self) :
self.logger.info(f"{self.get_name() } end of elaboration phase")
When the build_phase() completes, pyuvm runs the build_phase() in self.bc . It will run the
end_of_elaboration_phase() once the testbench is completely built.
BottomComp (uvm_test_top.mc.bc)
The BottomComp , being the bottom, has no build_phase() . It has only a run_phase() which pyuvm will call
after the end_of_elaboration_phase() .
Summary
In this chapter, we learned about the uvm_component class. We saw that UVM components inherit common
phase methods and that the UVM calls these phases in a specific order.
We saw that we override phase methods to define a component’s behavior and that the build_phase()
builds a hierarchy of components.
Next, we’ll learn about the UVM environment ( uvm_env ). This extension of uvm_component provides a place
to instantiate components.
uvm_env testbench: 4.0
Testbench 4.0 converts the classes from testbench 3.0 into UVM components and instantiates those
components inside a container class called an environment, created by extending uvm_env . We will do this in
three steps:
The classes follow the same structure as in testbench 3.0 in that we’ll have a base version of the tester and
environment which we extend to create a random operand version and a max operand version.
BaseTester
Here is the code for BaseTester . The UVM phases tell us where to put the functionality we need. The
BaseTester does two things:
1. Starts the TinyAluBfm tasks. We do this in the start_of_simulation() phase. (We could have done it in
the end_of_elaboration_phase() . It is a programmer’s choice.)
2. Loops through the operations, create commands, and send them to the TinyALU. We do this in the
run_phase() , which pyuvm starts as a task.
class BaseTester(uvm_component) :
def start_of_simulation_phase(self) :
TinyAluBfm() .start_tasks()
The BaseTester calls self.raise_objection() and self.drop_objection() to tell the UVM when it can
end the run phase.
class RandomTester(BaseTester) :
def get_operands(self) :
return random.randint(0, 255) , random.randint(0, 255)
class MaxTester(BaseTester) :
def get_operands(self) :
return 0xFF, 0xFF
The only reason to override __init__() is to create a class using arguments beyond name and parent . But,
as we’ll see, there are other ways to get data into a UVM component, and there are methods such as
end_of_elaboration_phase() and start_of_simulation_phase() that initialize a component.
If you do override __init__() you must call super().__init__(name, parent) . Otherwise, pyuvm will fail.
Scoreboard
The Scoreboard class uses UVM methods to set up its monitoring tasks and to check the results after the
simulation. First, it defines the coroutines that gather data from the TinyALU.
class Scoreboard(uvm_component) :
The Scoreboard in testbench 3.0 used the __init__() method to initialize itself. This version uses
start_of_simulation_phase() to initialize itself and to start its monitoring tasks.
def start_of_simulation_phase(self) :
self.bfm = TinyAluBfm()
self.cmds = []
self.results = []
self.cvg = set()
cocotb.start_soon(self.get_cmd())
cocotb.start_soon(self.get_result())
Scoreboard spends the simulation gathering data from the TinyALU and storing it in the self.cmds and
self.results lists. It also fills the cvg set with all the operations it sees. Once the simulation has finished, it
checks the results using the check_phase() .
def check_phase(self) :
passed = True
for cmd in self.cmds:
aa, bb, op_int = cmd
op = Ops(op_int)
self.cvg.add(op)
actual = self.results.pop(0)
prediction = alu_prediction(aa, bb, op)
if actual == prediction:
self.logger.info(
f"PASSED: {aa:02x} {op.name} {bb:02x} =
{actual:04x}")
else:
passed = False
self.logger.error(
f"FAILED: {aa:02x} {op.name} {bb:02x} = {actual:04x}"
f" - predicted {prediction:04x}")
if len(set(Ops) - self.cvg) > 0:
self.logger.error(
f"Functional coverage error. Missed: {set(Ops) -self.cvg}")
passed = False
else:
self.logger.info("Covered all operations")
assert passed
This 4.0 check_phase() is different than the 3.0 version in two ways:
1. Instead of using the logger variable to log messages, this version uses self.logger , an object
inherited from uvm_component . The chapter Logging delves into self.logger.
2. Unlike the Scoreboard in testbench 3.0, this version uses assert passed to tell cocotb whether the test
passed. If the passed is false, the assert statement raises an AssertionError exception. cocotb
catches that exception and marks the test as having failed.
Using an environment
Environments allow us to instantiate a set of components that work together. For example, it would make little
sense to have a tester without a scoreboard. So, we will instantiate our new components in an environment
that keeps them together. First, we create a class structure that mirrors the testers.
In figure 8, we see that the BaseEnv instantiates the Scoreboard because all testers need a scoreboard. We
extend BaseEnv to RandomEnv and MaxEnv and instantiate the correct tester. Here is the BaseEnv .
# Figure 8: All environments need the scoreboard
class BaseEnv(uvm_env) :
"""Instantiate the scoreboard"""
def build_phase(self) :
self.scoreboard = Scoreboard("scoreboard", self)
Now we’ll extend BaseEnv and add the RandomTester . We must call super() .build_phase() to get the
scoreboard instantiated.
class RandomEnv(BaseEnv) :
"""Generate random operands"""
def build_phase(self) :
super() .build_phase()
self.tester = RandomTester("tester", self)
class MaxEnv(BaseEnv) :
"""Generate maximum operands"""
def build_phase(self) :
super() .build_phase()
self.tester = MaxTester("tester", self)
This gives us the expected testbench output with check_phase() running after the simulation has been
completed.
# Figure 11: The test running using environments
# Random test
INFO PASSED: 5b ADD 7b = 00d6
INFO PASSED: 3d AND 71 = 0031
INFO PASSED: 79 XOR b6 = 00cf
INFO PASSED: 88 MUL 12 = 0990
INFO Covered all operations
# Max operand test
INFO PASSED: ff ADD ff = 01fe
INFO PASSED: ff AND ff = 00ff
INFO PASSED: ff XOR ff = 0000
INFO PASSED: ff MUL ff = fe01
INFO Covered all operations
Summary
This chapter created testbench 4.0 by converting our testbench classes to UVM components and instantiating
those components in a UVM environment. RandomTest and MaxTest had only to instantiate the correct
environment.
We will take a break from upgrading the testbench to examine other features the UVM provides, such as
logging and the configuration database.
Logging
Now that we’ve learned to create a UVM testbench hierarchy, we can discuss UVM features that take
advantage of that hierarchy. The first of these is logging.
Large testbenches generate so much information that we need a simple way to filter and direct output. The
SystemVerilog UVM defines a “reporting” system to address this issue, but pyuvm does not implement this
system. Instead, pyuvm leverages the Python logging module and provides methods to control Python logging
using the UVM testbench hierarchy.
The uvm_report_object class creates an object named self.logger that we use to create logging messages.
In testbench 4.0, we called self.logger.info() to create information messages when tests passed and
self.logger.error() to create error messages when tests failed. These are two of six logging levels defined
by the logging module.
We create messages using logging methods with the same name as the logging levels. For example,
self.logger.error() creates a message with an ERROR logging level.
class LogComp(uvm_component) :
async def run_phase(self) :
self.raise_objection()
self.logger.debug("This is debug")
self.logger.info("This is info")
self.logger.warning("This is warning")
self.logger.error("This is error")
self.logger.critical("This is critical")
self.drop_objection()
class LogTest(uvm_test) :
def build_phase(self) :
self.comp = LogComp("comp", self)
--
INFO testbench.py(10) [uvm_test_top.comp] : This is info
WARNING testbench.py(11) [uvm_test_top.comp] : This is warning
ERROR testbench.py(12) [uvm_test_top.comp] : This is error
CRITICAL testbench.py(13) [uvm_test_top.comp] : This is critical
Logging Levels
The logging module defines six logging levels, shown in figure 3. We filter log messages using logging levels.
Each level has an associated numeric value, and each self.logger object has a logging level that filters
messages.
By default, the self.logger logging level is INFO , which means that a log entry must have a logging level
greater than or equal to 20 to get logged. The example in figure 2 called self.logger.debug() , but we don’t
see the DEBUG message because the debug message has a logging level of 10.
Setting Logging Levels
Every UVM component comes with its own self.logger data member whose logging level is logging.INFO .
You can change the logging level with two methods:
self.set_logging_level(level) —This sets the logging level for the self.logger in the component.
self.set_logging_level_hier(level) —This sets the logging level for the self.logger object in the
component and all of the components
below it in the UVM hierarchy.
We can only set logging to a hierarchy after the hierarchy has been built, so we call
self.set_logging_level_hier() in the end_of_elaboration_phase() or start_of_simulation_phase()
methods.
In figure 2, LogComp logged messages of all six levels, but the debug message did not print because the
logging level was set to INFO.
The DebugTest in figure 4 extends LogTest but changes the logging levels in its
end_of_elaboration_phase() method. Now the debug message prints.
@pyuvm.test()
class DebugTest(LogTest) :
def end_of_elaboration_phase(self) :
self.set_logging_level_hier(DEBUG)
---
DEBUG testbench.py(9) [uvm_test_top.comp] : This is debug
INFO testbench.py(10) [uvm_test_top.comp] : This is info
WARNING testbench.py(11) [uvm_test_top.comp] : This is warning
ERROR testbench.py(12) [uvm_test_top.comp] : This is error
CRITICAL testbench.py(13) [uvm_test_top.comp] : This is critical
Logging Handlers
Logging handlers tell the logger where to send the message. The logging module defines many handlers. We
are going to examine two of them. You must import logging to create a handler.
logging.StreamHandler
By default, a StreamHandler writes messages to the screen using sys.stderr . pyuvm modifies this behavior.
By default self.logger contains a StreamHandler that writes to sys.stdout .
logging.FileHandler
This handler writes the logs messages to a file. When you create a FileHandler , you give it the filename and
an optional writing mode like this : file_handler = logging.FileHandler("log.txt", mode="w") .
Adding a handler
Once we have a handler, we need to add it to our component. You can do this with two UVM component
methods:
Removing a handler
We can also remove a specific handler. The handler argument must hold the same handler you added
previously.
class FileTest(LogTest) :
def end_of_elaboration_phase(self) :
file_handler = logging.FileHandler("log.txt", mode="w")
self.add_logging_handler_hier(file_handler)
self.remove_streaming_handler_hier()
--
INFO Starting test: "log_to_file"
Description: Write log messages to file with no streaming
INFO Test Passed: log_to_file
% cat log.txt
INFO: testbench.py(10) [uvm_test_top] : This is info
WARNING: testbench.py(11) [uvm_test_top] : This is warning
ERROR: testbench.py(12) [uvm_test_top] : This is error
CRITICAL: testbench.py(13) [uvm_test_top] : This is critical
Disabling logging
To completely disable logging, use these methods:
@pyuvm.test()
class NoLog(LogTest) :
def end_of_elaboration_phase(self) :
self.disable_logging_hier()
--
INFO NoLog passed
If you create a handler and do not provide a formatter, add_logging_handler() will use the default pyuvm
formatter.
Summary
This chapter discussed logging in Python UVM, which uses the Python logging module rather than
implementing the UVM reporting system. The two systems are similar in that they can filter and direct logging.
Each uvm_component has a data member named self.logger that you use to log messages. pyuvm allows
you to manipulate handlers on self.logger . You can disable all logging with the disable_logging()
method.
ConfigDB()
In the uvm_component chapter, we learned to pass a uvm_component a name and a parent handle when we
instantiate it. The UVM uses this information to create a testbench hierarchy.
The UVM testbench hierarchy enables reuse. We can instantiate components, environments, or even entire
tests in another testbench without modifying it. The component works because properly written UVM code
does not rely upon its location in a testbench hierarchy.
This rule creates a challenge when sharing data across a testbench. In testbench 4.0, we could share the
TinyAluBfm handle with all components because TinyAluBfm is a singleton class. All calls to TinyAluBfm()
returned a handle to the same object.
The singleton strategy will not work in a testbench with two TinyALUs because we’d need two BFM objects, but
all calls to TinyAluBfm() would return handles to only one (it is a singleton, after all.)
We need a way to store data so that components with different paths will get different objects without
interfering with each other. The ConfigDB() solves this problem.
A hierarchy-aware dictionary
We retrieve data from a dictionary by passing it a key and receiving the data stored at that key. We retrieve
data from the ConfigDB() by passing our hierarchical path alongside the key. The ConfigDB() finds all the
data whose path matches ours and then uses the key to retrieve the data.
class MsgLogger(uvm_component) :
The ConfigDB().get() method gets a string out of the ConfigDB() using the path to this component and the
" MSG " key. It passes three arguments:
self —This is called the context object. Typically we pass self as the context object. Config().get()
gets the UVM hierarchy path to the
context object using the get_full_path() method.
"" —The empty string could contain a path to be appended to the context object’s path. We would use this
if we had child components and
wanted to create a path to them. We have no additional path information, so we pass "" .
class MsgEnv(uvm_env) :
def build_phase(self) :
self.loga = MsgLogger("loga", self)
self.logb = MsgLogger("logb", self)
We want these two loggers to log different messages so the MsgTest stores different messages in the
ConfigDB() using the set() method.
@pyuvm.test()
class MsgTest(uvm_test) :
def build_phase(self) :
self.env = MsgEnv("env", self)
ConfigDB() .set(self, "env.loga", "MSG", "LOG A msg")
ConfigDB() .set(self, "env.logb", "MSG", "LOG B msg")
The set() method has four arguments similar to the get() arguments:
self —This is the context object . It works like the context object in get().ConfigDB().set() gets
the full path to this object. In this case, the full path is uvm_test_top .
“env.loga” / “env.logb" —We create the set() path by concatenating these strings to the context
object’s full path. The resulting paths are uvm_test_top.env.loga and uvm_test_top.env.logb . The
data gets stored using this path.
" MSG " —The key.MsgLogger looks for the " MSG " key. The data it finds at the key can depend upon its
place in the component hierarchy.
“LOG A msg” / “LOG B msg" —These are the messages for each logger.
When we run the test, the get() methods use a path such as uvm_test_top.env.loga to search the
ConfigDB. If it finds a matching path, it uses the " MSG " key to get the data.loga and logb each get a different
message this way.
# Figure 4: The loga and logb components have different
# things to say
--
INFO testbench.py(10) [uvm_test_top.env.loga] : LOG A msg
INFO testbench.py(10) [uvm_test_top.env.logb] : LOG B msg
Wildcards
We store data in the ConfigDB() using a path and a key. All the components whose paths match the path can
use the key. Our example showed us storing " MSG " using a fully qualified path, for example,
‘ uvm_test_top.env.loga ’ . But, we also need to handle the case where we want multiple components to see
our data. For that, we need wildcards.
Wildcard paths in pyuvm work like wildcards on a Linux command line. The "*" character matches everything.
For example, consider the case
where we have components talka and talkb who should get the string “ TALK TALK ” at " MSG " . Figure 5
shows an environment that instantiates
these components as well as loga and logb .
class MultiMsgEnv(MsgEnv) :
def build_phase(self) :
self.talka = MsgLogger("talka", self)
self.talkb = MsgLogger("talkb", self)
super() .build_phase()
Now we create a test with different " MSG " strings for different paths. It uses a wildcard ( "*" ) to give talka
and talkb the same " MSG "
@pyuvm.test()
class MultiMsgTest(uvm_test) :
def build_phase(self) :
self.env = MultiMsgEnv("env", self)
ConfigDB() .set(self, "env.loga", "MSG", "LOG A msg")
ConfigDB() .set(self, "env.logb", "MSG", "LOG B msg")
ConfigDB() .set(self, "env.t*", "MSG", "TALK TALK")
We see that talka and talkb say " TALK TALK " while loga and logb are unchanged.
When the first argument is None the set() method uses uvm_test_top as the context path. Then it appends
"*" to it. All paths in the testbench hierarchy match this pattern since all paths start with uvm_test_top .
class GlobalEnv(MultiMsgEnv) :
def build_phase(self) :
self.gtalk = MsgLogger("gtalk", self)
super() .build_phase()
The GlobalTest adds a new " MSG " using the global path.
The ConfigDB().get() solves the problem by matching the most specific path it finds. The get() command
in loga matched uvm_test_top.* and uvm_test_top.env.loga . The longer, more specific path won.
Parent/child conflicts
ConfigDB().set() uses the supplied context object and a string to create the path stored along with the key
and the data. This system allows a component to control which of its children gets a piece of data. But, it also
allows conflicts between a parent and child object. Consider this code:
class ConflictEnv(uvm_env) :
def build_phase(self) :
self.loga = MsgLogger("loga", self)
ConfigDB() .set(self, "loga", "MSG", "CHILD RULES!")
class ConflictTest(uvm_test) :
def build_phase(self) :
self.env = ConflictEnv("env", self)
ConfigDB() .set(self, "env.loga", "MSG", "PARENT RULES!")
The ConflictEnv combines its self variable and " loga " to create the path " uvm_test_top.env.loga " .
Then the ConflictTest uses its self variable and the string " env.loga " to create the path
uvm_test_top.env.loga . Who wins this conflict?
The UVM specifies that when you call the ConfigDB().set() method in the build phase, the parent value wins
in a conflict. However, if you call ConfigDB().set() outside the build phase, the data gets overwritten.
Summary
This chapter introduced us to the ConfigDB() . We saw how the ConfigDB() allows us to store different
objects for different instances of the same UVM component.
The ConfigDB() can get complicated in a large testbench. In the next chapter, we’ll learn about methods that
control the ConfigDB() and help us debug it.
Debugging the ConfigDB()
Problems using the ConfigDB() come in two classes. Either you can't find the data or you get the wrong data.
This chapter demonstrates how to catch missing data, trace ConfigDB() operations, and print the
ConfigDB() state.
Missing data
If the ConfigDB() does not contain a key at the path we presented, it raises the UVMConfigItemNotFound
exception. We forget to provide a " MSG " for logb . In figure 1, MsgEnv instantiates two loggers.
class MsgEnv(uvm_env) :
def build_phase(self) :
self.loga = MsgLogger("loga", self)
self.logb = MsgLogger("logb", self)
In figure 2, MsgTest fails to give logb a " MSG " . As a result, ConfigDB() complains that it cannot find the
provided UVM testbench path. We expect the test to fail, so we pass the expected exception to the
@pyuvm.test decorator.
@pyuvm.test(expect_error=UVMConfigItemNotFound)
class MsgTest(uvm_test) :
def build_phase(self) :
self.env = MsgEnv("env", self)
ConfigDB() .set(self, "env.loga", "MSG", "LOG A msg")
--
UVMConfigItemNotFound: "uvm_test_top.env.logb" is not in ConfigDB() .
In figure 3, MsgTestAlmostFixed tries to fix the problem by adding an entry for " env.logb. " Sadly, it spells
the key wrong (“ " MESG " vs " MSG " “) generating the same exception with a different message. The component
is in the ConfigDB() but we cannot find " MESG " .
@pyuvm.test(expect_error=UVMConfigItemNotFound)
class MsgTestAlmostFixed(uvm_test) :
def build_phase(self) :
self.env = MsgEnv("env", self)
ConfigDB() .set(self, "env.loga", "MSG", "LOG A msg")
ConfigDB() .set(self, "env.logb", "MESG", "LOG B msg")
--
UVMConfigItemNotFound: "Component uvm_test_top.env.logb has no key: MSG
Catching exceptions
We no longer want to get ugly exceptions, so let’s create the NiceMsgLogger that catches the
UVMConfigItemNotFound exception, logs a warning, and sets a default.
class NiceMsgLogger(uvm_component) :
In figure 5, we run the same tests with the NiceMsgLogger , and no longer see ugly exceptions.
class NiceMsgTest(uvm_test) :
def build_phase(self) :
self.env = NiceMsgEnv("env", self)
ConfigDB().set(self, "env.loga", "MSG", "LOG A msg")
def end_of_elaboration_phase(self) :
print(ConfigDB())
--
PATH : KEY : DATA
uvm_test_top.env.loga: MSG : {999: 'LOG A msg'}
Figure 6 shows the path, the key, and the data. We’ll discuss the 999 number below. But we see that we have
Log A msg but no data for the logb path. In figure 7, NiceMsgTestAlmostFixed misspells the key ( "MESG"
not "MSG" ) , but prints ConfigDB() so we can see the error.
class NiceMsgTestAlmostFixed(uvm_test) :
def build_phase(self) :
self.env = NiceMsgEnv("env", self)
ConfigDB() .set(self, "env.loga", "MSG", "LOG A msg")
ConfigDB() .set(self, "env.logb", "MESG", "LOG B msg")
def end_of_elaboration_phase(self) :
print(ConfigDB() )
--
PATH : KEY : DATA
uvm_test_top.env.loga: MSG : {999: 'LOG A msg'}
uvm_test_top.env.logb: MESG : {999: 'LOG B msg'}
We see an entry for uvm_test_top.env_logb , but it has the wrong key name ( " MESG " ). You can debug most
ConfigDB() problems this way.
class ConflictTest(uvm_test) :
def build_phase(self) :
self.env = ConflictEnv("env", self)
ConfigDB().set(self, "env.loga", "MSG", "PARENT RULES!")
def end_of_elaboration_phase(self) :
print(ConfigDB())
In figure 9, we see that there is one entry for uvm_test_top.env.loga with the " MSG " key, but there are two
values. The UVM specification says that the ConfigDB() should store a level number with each value counting
down from 1000 at the top level. The parent value got the number 999 , and the child value got 998 , so the
parent won. We see this when we print ConfigDB() .
The parent value is higher than the child value, overruling it.
Tracing ConfigDB() operations
Printing the ConfigDB() shows the state of the database, not how it got into that state. Tracing provides this
information.
We trace ConfigDB() operations by setting its is_tracing variable to True . In this example we set
ConfigDB().is_tracing to True and then see the set and get operations.
class GlobalTest(uvm_test) :
def build_phase(self) :
ConfigDB() .is_tracing = True
self.env = GlobalEnv("env", self)
ConfigDB() .set(self, "env.loga", "MSG", "LOG A msg")
ConfigDB() .set(self, "env.logb", "MSG", "LOG B msg")
ConfigDB() .set(self, "env.t*", "MSG", "TALK TALK")
ConfigDB() .set(None, "*", "MSG", "GLOBAL")
The ConfigDB() sends its operations to the log and identifies them with the CFGDB/SET and CFGDB/GET
labels. First, we set the data. Notice that when we pass None to set() that the context is empty.
The CFGDB/GET messages show the get() context and where get() found the data. Then it shows the data
being returned. In this case, the MsgLogger logs the data immediately.
The UVM provides a factory to use in our testbenches. Unlike the Space Game factory, the UVM factory allows
us to override one class with another.
Remember that testbench 4.0 defined different environments for random tests and max tests. We’ll see that
the UVM factory allows us to use the same environment for both tests.
class TinyComponent(uvm_component) :
async def run_phase(self) :
self.raise_objection()
self.logger.info("I'm so tiny!")
self.drop_objection()
In figure 2, TinyTest instantiates the component by calling the class as we’ve done in all previous examples.
@pyuvm.test()
class TinyTest(uvm_test) :
def build_phase(self) :
self.tc = TinyComponent("tc", self)
In figure 4, we modify the TinyTest to use the factory to create the TinyComponent . This test instantiates the
component using the Tinycomponent.create() class method instead of calling the class using
Tinycomponent() .
# Figure 4: Instantiating a component using the UVM factory
@pyuvm.test()
class TinyFactoryTest(uvm_test) :
def build_phase(self) :
self.tc = TinyComponent.create("tc", self)
When calling a class method such as create() , do not put parenthesis after the class name. The result using
create() is exactly the same as instantiating the object by calling the class ( TinyComponent() .)
Now that we’re instantiating Tinycomponent using the factory, we can override Tinycomponent to create new
behaviors.
uvm_factory()
The code in figure 4 used the uvm_object.create() class method to instantiate an object using the factory.
We can also instantiate objects using the factory directly using the uvm_factory() singleton.
Note: The SystemVerilog UVM uses the word type where Python uses the word class.
Figure 6 shows an example of creating a component from its name using the factory.
# Figure 6: Passing a component name to the factory
class CreateTest(uvm_test) :
def build_phase(self) :
self.tc = uvm_factory().create_component_by_name(
"TinyComponent",
name="tc", parent=self)
--
INFO testbench.py(13) [uvm_test_top.tc] : I'm so tiny!
Creating objects using their class names is useful if you’d like to read a file and create objects listed there. For
example, you could create a command file this way.
(I did this once in a testbench that tested numbered requirements. We had a file of requirement numbers, and
the testbench read it and ran a test for each one.)
Overriding types
The UVM factory allows us to choose the object to create at run time.
The UVM factory allows you to override one class with another. If you override class A with class B , requests
to create an object of class A will return a B instead.
We create overrides to change a testbench’s behavior without modifying its structure. We’ll see this in
testbench 5.0 when we replace RandomEnv and MaxEnv with a single AluEnv .
Here are the override methods. Call them by using the uvm_factory() singleton as in
uvm_factory().set_type_override_by_type(...) . As before the words type and class mean the same thing
in pyuvm:
This example will override TinyComponent with MediumComponent . In figure 7, we define the
MediumComponent .
class MediumComponent(uvm_component) :
async def run_phase(self) :
self.raise_objection()
self.logger.info("I'm medium size.")
self.drop_objection()
Next we extend TinyFactoryTest to create MediumFactoryTest . Then, we use a factory override in the
build_phase() before calling super().build_phase() . That code has not changed, but the factory override
changes its behavior. Here is our MediumFactoryTest .
# Figure 8: Overriding TinyComponent with MediumComponent
@pyuvm.test()
class MediumFactoryTest(TinyFactoryTest) :
def build_phase(self) :
uvm_factory().set_type_override_by_type(
TinyComponent, MediumComponent)
super().build_phase()
We have not copied any code, and we still got the result we want.
The code in figure 10 overrides TinyComponent with MediumComponent using the string names.
@pyuvm.test()
class MediumNameTest(TinyFactoryTest) :
def build_phase(self) :
uvm_factory().set_type_override_by_name(
"TinyComponent", "MediumComponent")
super().build_phase()
In figure 12, we have two TinyComponents and will override one using its instance path. First, create an
environment that instantiates the components, giving them the names tc1 and tc2 .
# Figure 12: Creating instances to be overridden
class TwoCompEnv(uvm_env) :
def build_phase(self) :
self.tc1 = TinyComponent.create("tc1", self)
self.tc2 = TinyComponent.create("tc2", self)
class TwoCompTest(uvm_test) :
def build_phase(self) :
uvm_factory().set_inst_override_by_type(
TinyComponent, MediumComponent, "uvm_test_top.env.tc1")
self.env = TwoCompEnv("env", self)
The result is that tc1 becomes a MediumComponent while tc2 remains a TinyComponent .
inst_override_by_name() uses the path the same way as inst_override_by_type() but takes strings as
the original and override types.
Some argue that we should instantiate all our components using create() . They say this gives later users
maximum flexibility to override anything.
I beg to differ.
As a testbench developer, I don’t want a user to override a component I never intended to be overridden.
Therefore, I recommend using create() only on objects that you intend to be overridden to maintain control
of what is happening in your code.
Debugging uvm_factory()
The UVM describes a factory debug system that pyuvm augments for Python. The uvm_factory.print()
method prints out information from the factory. It looks like this: uvm_factory() .print(all_types=1) .
The all_types argument controls how much information gets printed. It has three legal values:
1 —Print user-defined types and overrides. User-defined types don’t start with " uvm ".
2 —Print types whose names start with uvm_ , user-defined types, and overrides.
We override TinyComponent with MediumComponent in figure 15. We can print this override in the pyuvm test:
@pyuvm.test()
class PrintOverrides(MediumNameTest) :
def final_phase(self) :
uvm_factory() .print(0)
The MediumComponent prints its message. Then the final_phase() calls uvm_factory.print(0) to print the
override. We see in figure 16 that MediumComponent overrides TinyComponent and that there are no instance
overrides.
@pyuvm.test()
class PrintInstanceOverrides(TwoCompTest) :
def final_phase(self) :
uvm_factory() .print(0)
This example shows only uvm_test_top.env.tc1 being overridden with no generalized type override.
Overrides:
TinyComponent: Type Override: None | | Instance Overrides: uvm_test_top.env.tc1 =>
MediumComponent
uvm_factory_str = str(uvm_factory() )
self.logger.info(uvm_factory_str)
The information in uvm_factory_str depends upon a variable named uvm_factory().debug_level . The
debug level has the same definition as the all_types argument in the print() method.
1 —Show user-defined types and overrides. User-defined types don’t start with " uvm_ " .
2 —Show types whose names start with uvm_ , user-defined types, and overrides.
@pyuvm.test()
class LoggingOverrides(MediumNameTest) :
def final_phase(self) :
uvm_factory() .debug_level = 0
factory_log = "\n"+str(uvm_factory() )
self.logger.info(factory_log)
Summary
In this chapter, we learned how to instantiate objects using the create() method when we want to enable the
factory to override the class.
We learned how to override types using the class objects as arguments and how to override types using strings
that contain the class names. We also learned to limit overrides to one point in the UVM hierarchy.
Finally, we learned how to print out the state of the factory and see the overrides.
UVM factory testbench: 5.0
In testbench 4.0, we created a class hierarchy containing a BaseEnv , a RandomEnv , and MaxEnv . RandomEnv
instantiated the RandomTester and MaxEnv instantiated the MaxTester .
In testbench 5.0, we’re eliminating the three kinds of environments and creating only one environment named
AluEnv . Then, we’ll use factory overrides to create tests.
AluEnv
In figure 1, the AluEnv instantiates the scoreboard and tester. Then it starts the TinyAluBfm() DUT-
communication tasks.
class AluEnv(uvm_env) :
"""Instantiate the scoreboard"""
def build_phase(self) :
self.scoreboard = Scoreboard("scoreboard", self)
self.tester = BaseTester.create("tester", self)
def start_of_simulation_phase(self) :
TinyAluBfm() .start_tasks()
We instantiate the BaseTester using the create() method so that the factory can override the instance with
other classes.
The BaseTester is an abstract base class, and so it raises a RuntimeError exception if a user tries to run the
test without an override.
class BaseTester(uvm_component) :
def get_operands(self) :
raise RuntimeError("You must extend BaseTester and override
get_operands().")
# The code beyond this point never runs due to the exception
RandomTest
The RandomTest overrides the BaseTester with the RandomTester . The component path
uvm_test_top.env.scoreboard has been snipped to fit on the page.
@pyuvm.test()
class RandomTest(uvm_test) :
"""Run with random operands"""
def build_phase(self) :
uvm_factory() .set_type_override_by_type(BaseTester, RandomTester)
self.env = AluEnv("env", self)
--
INFO testbench.py(74) [<snip>] : PASSED: ff ADD f5 = 01f4
INFO testbench.py(74) [<snip>] : PASSED: 66 AND 72 = 0062
INFO testbench.py(74) [<snip>] : PASSED: 78 XOR 62 = 001a
INFO testbench.py(74) [<snip>] : PASSED: 68 MUL 88 = 3740
INFO testbench.py(87) [<snip>] : Covered all operations
MaxTest
MaxTest works the same way as RandomTest . It overrides BaseTester and instantiates the AluEnv .
class MaxTest(uvm_test) :
"""Run with max operands"""
def build_phase(self) :
uvm_factory().set_type_override_by_type(BaseTester, MaxTester)
self.env = AluEnv("env", self)
--
INFO testbench.py(74) [<snip>] : PASSED: ff ADD ff = 01fe
INFO testbench.py(74) [<snip>] : PASSED: ff AND ff = 00ff
INFO testbench.py(74) [<snip>] : PASSED: ff XOR ff = 0000
INFO testbench.py(74) [<snip>] : PASSED: ff MUL ff = fe01
INFO testbench.py(87) [<snip>] : Covered all operations
Summary
This chapter simplified testbench 4.0 using the factory. Testbench 5.0 has a single environment class that
instantiates BaseTester , allowing RandomTester and MaxTester to override the BaseTester create tests.
Component communications
In the cocotb Queue chapter, we learned that cocotb allows parallel tasks to communicate using the
cocotb.queue.Queue class. The UVM builds upon Queue using a standards named Transaction Level Modeling
1.0 (TLM 1.0). TLM 1.0 was initially developed to allow SystemC transaction-level models to communicate. The
idea was that you would design a system using C modules that communicated through TLM 1.0 and then
replace the C modules with RTL modules as you implemented the design. TLM also works well for testbench
components.
The Queue solves the problem of allowing one coroutine to wait for data from another coroutine, and it stores
the data being transferred. But, it doesn’t solve the problem of creating a completely self-contained UVM
component that can be reused in any testbench.
TLM 1.0 solves the problem of creating self-contained UVM components by defining the following:
3. A list of classes named port and export that combine the operations and their style.
Operations
The UVM 1800.2 specification names more than three communication operations, but we are only going to
study the three popular ones:
put —As with the Queue , the put operation sends a datum somewhere.
get —As with the Queue , the get operation retrieves a datum from somewhere.
peek —The peek() operation is unique to TLM 1.0 and is not in the Queue . It returns the datum that
would have been retrieved by a get but
leaves the datum in place.
Styles of operations
As with the Queue , TLM 1.0 defines two styles of operations:
blocking —The coroutine blocks if the operation cannot be performed because there is either no datum
or no room for a datum.
nonblocking —The function does not block but returns a success flag to indicate whether the put, get, or
peek was successful.
Ports
The UVM encapsulates the operations and their styles in port classes. It names the classes using the operations
and the styles in all possible combinations. For example:
uvm_blocking_put_port —Provides a put() coroutine that blocks if the data cannot be placed.
The basic UVM ports provide one function such as the put() in uvm_blocking_put_port and try_get() in
uvm_nonblocking_get_port . This allows the developer to limit the possible operations and styles in a
component. However, there are times when a developer wants ports that take all styles. So the UVM provides
generalized ports that provide all styles of operation.
This example uses ports to create producer/consumer components. Figure 1 shows a BlockingProducer
component that instantiates a uvm_blocking_put_port and then awaits self.bpp.put() to put data into the
port and blocks if the port is full.
class BlockingProducer(uvm_component) :
def build_phase(self) :
self.bpp = uvm_blocking_put_port("bpp", self)
The figure 1 code puts the data into the port and blocks if the port is full. The port must be connected to
something, but we don‘t worry about that. Our parent component needs to connect our port to something.
The BlockingConsumer in figure 2 instantiates a uvm_blocking_get_port and awaits the get() coroutine to
get data from the port and blocks if the port is empty.
class BlockingConsumer(uvm_component) :
def build_phase(self) :
self.bgp = uvm_blocking_get_port("bgp", self)
We’ve mentioned that components using ports can put or get data to and from “wherever it is connected.”
Ports connect to another class of objects named exports.
Exports
Our port needs to be connected to an object that will retrieve or provide data. This object is called an export ,
and the UVM defines export classes that match the port classes, for example:
uvm_blocking_put_export
uvm_nonblocking_get_export
uvm_blocking_peek_export
TLM 1.0 also defines exports that can be used for both blocking and nonblocking communication:
uvm_put_export
uvm_get_export
uvm_peek_export
Users typically leverage export objects defined in classes such as the uvm_tlm_fifo .
uvm_tlm_fifo
The uvm_tlm_fifo class encapsulates a Python Queue and defines export classes to communicate with the
Queue . The uvm_tlm_fifo proves all the combinations of put, get, peek, blocking, and nonblocking exports.
This example uvm_test connects the BlockingProducer to the BlockingConsumer through a uvm_tlm_fifo .
Figure 3 contains a diagram of the connections in BlockingTest .
The code in figure 4 instantiates the objects in build_phase() and connects the ports to the exports in
connect_phase() .
# Figure 4: The connect_phase() in action
@pyuvm.test()
class BlockingTest(uvm_test) :
def build_phase(self) :
self.producer = BlockingProducer("producer", self)
self.consumer = BlockingConsumer("consumer", self)
self.fifo = uvm_tlm_fifo("fifo", self)
def connect_phase(self) :
self.producer.bpp.connect(self.fifo.put_export)
self.consumer.bgp.connect(self.fifo.get_export)
Like all components, the uvm_tlm_fifo takes a name ( " fifo " ) and the handle to its parent ( self ). The
uvm_tlm_fifo() call could also take a third argument named size that defaults to 1 . The size argument
in uvm_tlm_fifo is analogous to the maxsize argument in Queue , though maxsize defaults to 0 . Since we
used the default for size , self.fifo can only hold one object at a time.
This is the first time we’ve used the connect_phase() function. The function calls the connect() method in
ports and passes them exports. For example, we call self.producer.bpp.connect() and pass it
self.fifo.put_export .
Notice that the producer instantiated a uvm_blocking_put_port and the consumer instantiated a
uvm_blocking_get_port , yet we connected them to a uvm_put_export and a uvm_get_export . These
exports support both blocking and nonblocking communication.
The result in figure 5 is as we expect. The Producer produces numbers, and the Consumer consumes them.
The SystemVerilog also implements nonblocking communication, using functions named try_put() and
try_get() . Because SystemVerilog has
no exception mechanism, try_put() and try_get() return a bit that is set to 1 on success and 0 on
failure.
Since one cannot return a value in the argument list in Python, try_get() returns a tuple containing
( success, datum ) .
Let’s create the classes NonBlockingProducer and NonBlockingConsumer and instantiate them in
NonBlockingTest .
class NonBlockingProducer(uvm_component) :
def build_phase(self) :
self.nbpp = uvm_nonblocking_put_port("nbpp", self)
The NonBlockingConsumer uses a similar while loop to get the datum. If the queue is empty, success
returns False , and the while loop waits 3 ns of simulation time before trying again.
class NonBlockingConsumer(uvm_component) :
def build_phase(self) :
self.nbgp = uvm_nonblocking_get_port("nbgp", self)
The NonBlockingTest instantiates these components and connects them as we did in BlockingTest .
@pyuvm.test()
class NonBlockingTest(uvm_test) :
def build_phase(self) :
self.producer = NonBlockingProducer("producer", self)
self.consumer = NonBlockingConsumer("consumer", self)
self.fifo = uvm_tlm_fifo("fifo", self)
def connect_phase(self) :
self.producer.nbpp.connect(self.fifo.put_export)
self.consumer.nbgp.connect(self.fifo.get_export)
The test gives us the result in figure 9. The timestamps show the Timer delays. We snip the uvm_test_top
part of the component path to fit on the page.
Debugging uvm_tlm_fifo
In the chapter on logging, we learned about logging levels. The logging module defines levels ranging from 10
to 50. In addition, pyuvm adds another level for FIFO debugging.
Figure 10: The FIFO_DEBUG logging level
We see the FIFO operations when we set the logging level for a component to FIFO_DEBUG . Logging FIFO
activity results in a lot of messages, so it is best to target only one component when setting this level. Notice
that we use set_logging_level_hier() since a uvm_tlm_fifo contains other components.
@pyuvm.test()
class LoggedBlockingtest(BlockingTest) :
def end_of_elaboration_phase(self) :
self.fifo.set_logging_level_hier(FIFO_DEBUG)
--
8.00ns FIFO_DEBUG [<snip>.fifo.put_export] : blocking put: 0
8.00ns FIFO_DEBUG [<snip>.fifo.put_export] : success put 0
8.00ns INFO testbench.py(14) [<snip>.producer] : Put 0
8.00ns FIFO_DEBUG [<snip>.fifo.put_export] : blocking put: 1
8.00ns FIFO_DEBUG [<snip>.fifo.get_export] : Attempting blocking get
8.00ns FIFO_DEBUG [<snip>.fifo.get_export] : got 0
8.00ns INFO testbench.py(25) [<snip>.consumer] : Got 0
<etc>
Summary
This chapter taught us how UVM components communicate using ports, exports, and TLM FIFOS. UVM
components instantiate ports and call communication methods such as put() and try_get() .
We learned how to connect ports to exports in the connect_phase() . The uvm_tlm_fifo provides exports
that match all the ports defined in the UVM.
Finally, we learned how to enable FIFO logging messages using the FIFO_DEBUG logging level.
Analysis ports
In the Component communications chapter, we examined communication between a single producer and a
single consumer. This chapter will show how a single producer can broadcast data to many consumers.
We use this feature in testbenches to send monitored data to be analyzed by multiple components. For
example, one component may check that the DUT is working correctly, while another component athers
coverage data.
Components that capture and analyze data from a testbench are said to make up the testbench’s analysis layer
. Analysis layer components receive data from the testbench and never block testbench operation. They are
passive observers.
The uvm_analysis_port
The UVM implements the analysis layer using the uvm_analysis_port class. An analysis port cannot block, so
it does not have a put() method. Instead, it has a write() method.
The UVM analysis system implements the subscriber design pattern. In the subscriber pattern, one object
publishes data to zero or more subscribers (as with Twitter, for example.) The publisher in UVM instantiates an
analysis port, and a higher-level component connects it to zero or more analysis exports.
For example, let’s create a UVM component that generates ten random numbers and writes them to an
analysis port. The NumberGenerator instantiates an analysis port named self.ap in the build_phase() .
Then it generates ten random numbers in the run_phase() and calls self.ap.write() to send it to the
subscribers.
class NumberGenerator(uvm_component) :
def build_phase(self) :
self.ap = uvm_analysis_port("ap", self)
The write() method is a function, and so it never blocks. If there are no subscribers, then nothing happens.
1. Extend the uvm_analysis_export class and instantiate the class. It’s best to name the instantiated object
with the _export suffix so as to be clear that it is an export.
2. Instantiate a uvm_tlm_analysis_fifo which contains a uvm_analysis_export object named
analysis_export .
Let’s create an analysis export class named Adder that sums up the numbers it gets from the
NumberGenerator . The required write() method adds the numbers it receives to self.sum . The
report_phase() logs the sum.
class Adder(uvm_analysis_export) :
def start_of_simulation_phase(self) :
self.sum = 0
def report_phase(self) :
self.logger.info(f"Sum: {self.sum}")
We instantiate the NumberGenerator and Adder in AdderTest and connect the self.sum_export to the
number generator’s analysis port in the connect_phase() .
@pyuvm.test()
class AdderTest(uvm_test) :
def build_phase(self) :
self.num_gen = NumberGenerator("num_gen", self)
self.sum_export = Adder("sum", self)
def connect_phase(self) :
self.num_gen.ap.connect(self.sum_export)
--
1 4 5 9 2 1 4 9 1
INFO testbench.py(33) [uvm_test_top.sum] : Sum: 36
The generator created the numbers, and its subscriber summed them.
Forgetting to override write()
Since uvm_tlm_analysis_export is an abstract class, SystemVerilog gives you a syntax error if you forget to
override the write() method. Python gives you a runtime error defined in pyuvm.
In figure 5, we extend the uvm_subscriber to write Median . The Median class creates a list in the
start_of_simulation_phase() method. Then it fills the list in the write() method. It uses the statistics
package to log the median number.
import statistics
class Median(uvm_subscriber) :
def start_of_simulation_phase(self) :
self.numb_list = []
def report_phase(self) :
self.logger.info(f"Median: {statistics.median(self.numb_list) }")
In figure 6, we instantiate the Median and connect it in the MedianTest . Notice that self.median is different
than self.sum_export in that we connect the self.median.analysis_export data member.
@pyuvm.test()
class MedianTest(uvm_test) :
def build_phase(self) :
self.num_gen = NumberGenerator("num_gen", self)
self.sum_export = Adder("sum", self)
self.median = Median("median", self)
def connect_phase(self) :
self.num_gen.ap.connect(self.sum_export)
self.num_gen.ap.connect(self.median.analysis_export)
--
3 5 2 9 10 9 2 2 10
INFO testbench.py(35) [uvm_test_top.sum] : Sum: 52
INFO testbench.py(61) [uvm_test_top.median] : Median: 5
Here we see the analysis port’s superpower. It can connect to any number of analysis exports, and they’ll all
get a copy of the data. Next we’ll demonstrate the third way to connect to an analysis port.
Instantiate a uvm_tlm_analysis_fifo
The uvm_tlm_analysis_fifo extends the uvm_tlm_fifo discussed in the Component communications
chapter.
This allows the analysis FIFO to store all the data from a simulation and analyze it in the check_phase()
method. You can use a get port to remove the data from the FIFO when you want to check it.
The Averag e component stores all the data in the analysis FIFO and averages it after the run phase ends. We
instantiate a uvm_tlm_analysis_fifo to receive the data. The non_blocking_get_port allows us to loop
through the data in the FIFO and average it.
class Average(uvm_component) :
def build_phase(self) :
self.fifo = uvm_tlm_analysis_fifo("fifo", self)
self.nbgp = uvm_nonblocking_get_port("nbgp", self)
def connect_phase(self) :
self.nbgp.connect(self.fifo.get_export)
self.analysis_export = self.fifo.analysis_export
def report_phase(self) :
success = True
sum = 0
count = 0
while success:
success, nn = self.nbgp.try_get()
if success:
sum += nn
count += 1
self.logger.info(f"Average: {sum/count:0.2f}")
Providing the analysis_export data attribute
In figure 7, We created the Averag e class by instantiating a uvm_tlm_analysis_fifo as self.fifo.The
self.fifo object provides an analysis_export .
The user could connect the analysis port to the FIFO’s analysis export, but we don’t want users to access
objects inside our Average class. To avoid this, we copy self.fifo.analysis_export to
self.analysis_export so that the user can pass self.avg.analysis_export to the analysis port’s
connect() method.
The AverageTest in figure 8 instantiates the Adder , Median , and Average classes in the build phase. Then
it connects self.sum_export directly to self.num_gen.ap and it connects self.avg.analysis_export to
the same analysis port.
class AverageTest(uvm_test) :
def build_phase(self) :
self.num_gen = NumberGenerator("num_gen", self)
self.sum_export = Adder("sum", self)
self.median = Median("median", self)
self.avg = Average("avg", self)
def connect_phase(self) :
self.num_gen.ap.connect(self.sum_export)
self.num_gen.ap.connect(self.median.analysis_export)
self.num_gen.ap.connect(self.avg.analysis_export)
--
3 7 9 8 10 4 5 10 10
INFO testbench.py(35) [uvm_test_top.sum] : Sum: 66
INFO testbench.py(61) [uvm_test_top.median] : Median: 8
INFO testbench.py(97) [uvm_test_top.avg] : Average: 7.33
Summary
In this chapter, we learned about analysis ports and exports. Analysis ports contain a write() function
instead of a put() coroutine or a try_put() function. They never block.
We attach analysis ports to analysis exports. There are three ways to get an analysis export:
2. Extend uvm_subscriber and override the write() method. Then connect it using its analysis_export
data member.
We will use port objects to create components with a single purpose, and then we’ll connect these components
using FIFOs to create the testbench.
We’re going to create 6.0 in two steps. In this chapter, we’ll refactor the components so that each one does a
single job and uses a port to communicate with the rest of the testbench. Then, in the next chapter, we’ll
connect the components using uvm_tlm_fifo objects.
The testers
As in testbench 5.0, the tester class hierarchy consists of a BaseTester and extensions named RandomTester
and MaxTester .
The class structure in 6.0 is the same as 5.0. We have a BaseTester extended by RandomTester and
MaxTester .
BaseTester
Instead of calling self.bfm.send_op(aa, bb, op) the 6.0 BaseTester puts the command data into a tuple
and writes it to a blocking put port.
class BaseTester(uvm_component) :
def build_phase(self) :
self.pp = uvm_put_port("pp", self)
def get_operands(self) :
raise RuntimeError("You must extend BaseTester and override it.")
The figure 2 version of the BaseTester raises an exception if you try to instantiate it directly rather than using
one of its child classes. The ClockCycles trigger waits for ten clocks and demonstrates how you can access
DUT signals from anywhere in your testbench using cocotb.top . The ten clocks allow the multiply operation
to get through the FIFOs and complete.
class RandomTester(BaseTester) :
def get_operands(self) :
return random.randint(0, 255) , random.randint(0, 255)
class MaxTester(BaseTester) :
def get_operands(self) :
return 0xFF, 0xFF
Since the BaseTester no longer talks to the TinyALU directly, we’ll need a Driver class.
Driver
The Driver gets commands from a blocking get port and calls bfm.send_op() to send them to the TinyALU.
The build_phase() gets the TinyAluBfm() singleton and instantiates a uvm_get_port . The uvm_get_port
provides blocking and nonblocking methods.
Its run_phase() resets the TinyALU and starts the BFM tasks. Then it loops forever, getting commands from
the get port and sending them to the BFM.
class Driver(uvm_driver) :
def build_phase(self) :
self.bfm = TinyAluBfm()
self.gp = uvm_get_port("gp", self)
Monitor
The Monitor class demonstrates a Python feature that does not exist in SystemVerilog—the ability to
dynamically access a method in an object using the method’s name.
Recall that the TinyAluBfm provides get_cmd() and get_result() methods. In SystemVerilog, we would
have to create two Monitor classes, one that calls get_cmd() and another that calls get_result() . In
Python, we can write a single class that takes the method's name as an instantiation argument. We instantiate
it like this.
Since Monitor has an additional argument in its instantiation, we must override its __init__() method. The
Monitor.__init__() immediately calls super().__init__(name, parent) as is required. Then it stores
method_name .
class Monitor(uvm_monitor) :
def __init__(self, name, parent, method_name) :
super() .__init__(name, parent)
self.method_name = method_name
The build_phase() instantiates a uvm_analysis_port and gets the TinyAluBfm() singleton. Then, it uses
getattr(self.bfm,self.method_name) to get the handle to the monitoring method from the BFM.
Coverage extends uvm_analysis_export , so you can directly connect it to an analysis port. However, you
must override the write() method. This write method in Coverage extracts the op from the cmd tuple and
adds it to the cvg set.
class Coverage(uvm_analysis_export) :
def start_of_simulation_phase(self) :
self.cvg = set()
def check_phase(self) :
if len(set(Ops) - self.cvg) > 0:
self.logger.error(
f"Functional coverage error. Missed: {set(Ops) -self.cvg}")
assert False
else:
self.logger.info("Covered all operations")
The check_phase() method ensures that we’ve tested all the operations. The line assert False tells cocotb
that we have missed an operation.
The Scoreboard
Like the Scoreboard in testbench 5.0, the Scoreboard in testbench 6.0 stores commands and results during
the simulation and checks them after the run phase has been completed. The difference is that the 6.0
Scoreboard stores simulation in two uvm_tlm_analysis_fifo objects. Then reads the data from the FIFOs in
the check_phase .
Scoreboard.build_phase()
We start with a build_phase() that instantiates two uvm_tlm_analysis_fifo objects and two uvm_get_port
objects. We’ll use the get ports to read the FIFOs.
# Figure 10: Using uvm_tlm_analysis_fifo to provide
# multiple analysis_exports
# and creating ports to read the fifos
class Scoreboard(uvm_component) :
def build_phase(self) :
self.cmd_mon_fifo = uvm_tlm_analysis_fifo("cmd_mon_fifo", self)
self.result_mon_fifo = uvm_tlm_analysis_fifo("result_mon_fifo", self)
self.cmd_gp = uvm_get_port("cmd_gp", self)
self.result_gp = uvm_get_port("result_gp", self)
Scoreboard.connect_phase()
We connect the FIFOs to the get ports by passing the FIFO’s get_export object to the get port’s connect()
method.
def connect_phase(self) :
self.cmd_gp.connect(self.cmd_mon_fifo.get_export)
self.result_gp.connect(self.result_mon_fifo.get_export)
self.cmd_export = self.cmd_mon_fifo.analysis_export
self.result_export = self.result_mon_fifo.analysis_export
We also copy the FIFO analysis_export objects to self.cmd_export and self.result_export to make it
easy to connect the scoreboard to analysis ports.
Scoreboard.check_phase()
The 6.0 check_phase() gets the data from the cmd_gp get port using try_get . When get_next_cmd is
False we break out of the loop as we have emptied the FIFO.
Once we have a command, we get the associated result. If there is no result, the testbench is broken, so we
raise a RuntimeError exception.
def check_phase(self) :
passed = True
while True:
got_next_cmd, cmd = self.cmd_gp.try_get()
if not got_next_cmd:
break
result_exists, actual = self.result_gp.try_get()
if not result_exists:
raise RuntimeError(f"Missing result for command {cmd}")
<snipped checking code>
The rest of the code is identical to 5.0. It predicts the result and compares it to the actual result.
Summary
We’ve now simplified the components in the UVM testbench down to their minimum behavior. Each
component either creates data and writes it to a port or gets data from a port and processes it.
We’ll connect these components to create the testbench in the next chapter.
Connections in testbench 6.0
In the Testbench components chapter, we modularized the testbench by creating components that did one thing
each and used ports to send data to another component or get data from another component. Here are the
components:
RandomTester —Puts TinyALU command tuples with random operands into a blocking put port.
MaxTester —Puts TinyALU command tuples with 0xFF operands into a blocking put port.
Driver —Gets TinyALU commands from a blocking get port and uses bfm.send_op() to send them to
the TinyALU DUT.
Monitor("get_cmd") —Uses bfm.get_cmd() to get a command from the TinyALU DUT and puts the
tuple into an analysis port.
Monitor("get_result") —Uses bfm.get_result() to get a result from the TInyALU DUT and puts the
result into an analysis port.
Coverage —Receives commands in its write() method and stores the operation in a set. Then it checks
that we’ve covered all the operations.
Scoreboard —Receives commands and their results and stores them in uvm_tlm_analysis_fifo
objects, then uses nonblocking get ports to read them and compare actual and predicted results.
Each of these components is easy to understand and easy to maintain. Next, we connect them to create a
testbench. We do that in the AluEnv class.
This diagram shows how the environment connects the components. The small squares represent ports, such
as the blocking put port named pp on the Tester . The circles represent exports such as the put_export on
the cmd_fifo . The Coverage class extends uvm_analysis_export , so the entire object is a circle. The lines
represent times that we called connect() on a port and passed it an export.
AluEnv.build_phase()
The build_phase() function in figure 2 instantiates all the components and the FIFOs. It instantiates
BaseTester using the create() method so that the factory can override the BaseTester with
RandomTester and MaxTester .
class AluEnv(uvm_env) :
def build_phase(self) :
self.tester = BaseTester.create("tester", self)
self.driver = Driver("driver", self)
self.cmd_fifo = uvm_tlm_fifo("cmd_fifo", self)
The Scoreboard and Coverage objects in figure 3 comprise the analysis layer. We will connect them to the
analysis ports in the Monitor objects.
The code in figure 4 instantiates the self.cmd_mon and self.result_mon using the same Monitor class, but
pass the names of the monitoring methods. The " get_cmd " or " get_result " strings differentiate between the
two Monitor objects.
AluEnv.connect_phase()
Each line in the AluEnv TLM diagram in figure 1 represents a call to the port’s connect() method. When we
pass an export to the connect() method, we connect the port to the export. First we connect the tester.pp
and driver.gp ports to the cmd_fifo exports.
def connect_phase(self) :
self.tester.pp.connect(self.cmd_fifo.put_export)
self.driver.gp.connect(self.cmd_fifo.get_export)
Since the Coverage class extends uvm_analysis_export we can pass self.coverage directly to the
cmd_mon.ap.connect() method, as in figure 6. Notice that we easily connect the scoreboard as we planned
when we copied the FIFO analysis_export objects to scoreboard data attributes.
# Figure 6: Connecting the coverage analysis_export
# to the cmd_mon analysis port
self.cmd_mon.ap.connect(self.coverage)
self.cmd_mon.ap.connect(self.scoreboard.cmd_export)
self.result_mon.ap.connect(self.scoreboard.result_export)
@pyuvm.test()
class RandomTest(uvm_test) :
def build_phase(self) :
uvm_factory().set_type_override_by_type(BaseTester, RandomTester)
self.env = AluEnv("env", self)
@pyuvm.test()
class MaxTest(uvm_test) :
def build_phase(self) :
uvm_factory().set_type_override_by_type(BaseTester, MaxTester)
self.env = AluEnv("env", self)
The two tests run and use random operands, then maximum operands. The component paths have been
snipped so as to fit the output on the page.
Summary
This chapter completed testbench 6.0, a UVM testbench made of simple components that communicate using
ports and an environment that instantiates the components and connects them.
uvm_object in Python
It’s tempting to think that testbench 6.0 is the final step in our journey to the best UVM testbench. It’s modular,
uses TLM communications, has logging, a ConfigDB() , and the uvm_factory() . What more could we need?
We need a way to create a testbench that doesn’t need to be restructured every time we change the test
stimulus. For example, we’d like to create a testbench that can read commands from a file and execute them.
Or we’d like our test writers to be able to write tests using standardized testbench commands. We can’t do
these things if we have to rebuild the testbench each time we change stimulus.
The UVM solves this problem using UVM sequences , which must not be confused with Python sequences. The
UVM and Python happen to use the same word for different things.
UVM sequences send commands to the DUT in a class called a uvm_sequence_item . Here is the
uvm_sequence_item UML diagram.
In the SystemVerilog UVM, the uvm_object contains convenience routines that allow programmers to print
uvm_objects and copy or clone them easily. This chapter shows how pyuvm replaces these convenience
routines with Python magic methods and modules from the Python ecosystem.
Python has the same functionality but uses the magic method __str__() to return a string representation of
the object. Then you can use the str() function to create a new string from the object, print the object, or do
any operation that needs a string.
In figure 2, we create a PersonRecord class that extends uvm_object and adds an ID number. The
uvm_object class already has a name field that we access using get_name() . The __str__() magic method
returns a string with the record information.
# Figure 2: Extending uvm_object
class PersonRecord(uvm_object) :
def __init__(self, name="", id_number=None) :
super().__init__(name)
self.id_number = id_number
def __str__(self) :
return f"Name: {self.get_name() }, ID: {self.id_number}"
The __str__() magic method allows us to print the record. We could also create a str from the record and
log it. Figure 3 uses a cocotb test to create the object and print it.
@pyuvm.test()
class TestStr(uvm_test) :
async def run_phase(self) :
self.raise_objection()
xx = PersonRecord("Joe Shmoe", 37)
print("Printing the record:", xx)
logger.info("Logging the record: " + str(xx) )
self.drop_objection()
--
Printing the record: Name: Joe Shmoe, ID: 37
INFO Logging the record: Name: Joe Shmoe, ID: 37
Comparing objects
The SystemVerilog UVM compare() method compares two objects like this.
same = xx.compare(yy)
same receives a 1 if xx and yy are considered to contain the same data. I say “are considered to contain”
because different objects may have different definitions of being “the same.” For example, our PersonRecord
class will compare only the id_number . In SystemVerilog, we implement this using the do_compare()
function.
class PersonRecord(uvm_object) :
def __init__(self, name="", id_number=None) :
super() .__init__(name)
self.id_number = id_number
Unlike SystemVerilog, Python allows us to use the == operator to compare objects and print an important bit
of info.
@pyuvm.test()
class TestEq(uvm_test) :
async def run_phase(self) :
self.raise_objection()
batman = PersonRecord("Batman", 27)
bruce_wayne = PersonRecord("Bruce Wayne", 27)
if batman == bruce_wayne:
logger.info("Batman is really Bruce Wayne!")
else:
logger.info("Who is Batman?")
self.drop_objection()
--
INFO Batman is really Bruce Wayne!
This example uses a PersonRecord (defined in figure 2) and its extension the StudentRecord . A person can
be a student. The difference between them is that a student has a list of grades which must also be printed.
class StudentRecord(PersonRecord) :
def __init__(self, name="", id_number=None, grades=[] ) :
super() .__init__(name, id_number)
self.grades = grades
def __str__(self) :
return super() .__str__() + f" Grades: {self.grades}"
Copying with Python
The code in Figure 10 creates a student object named Mary and copies it using the Python copy module
function copy.copy() . Notice that mary.grades and mary_copy.grades are the same object with the same
ID.
import copy
@pyuvm.test()
class CopyCopyTest(uvm_test) :
async def run_phase(self) :
self.raise_objection()
mary = StudentRecord("Mary", 33, [97, 82] )
mary_copy = StudentRecord()
mary_copy = copy.copy(mary)
print("mary: ", mary)
print("mary_copy:", mary_copy)
print("-- grades are SAME id --")
print("id(mary.grades) : ", id(mary.grades) )
print("id(mary_copy.grades) :", id(mary_copy.grades) )
self.drop_objection())
--
mary: Name: Mary, ID: 33 Grades: [97, 82]
mary_copy: Name: Mary, ID: 33 Grades: [97, 82]
-- grades are SAME id --
id(mary.grades) : 140369743774784
id(mary_copy.grades) : 140369743774784
This is a shallow copy .mary and mary_copy have a handle to the same list, so changing the list using mary
will also change the list in mary_copy . Python resolves his problem using copy.deepcopy() .
@pyuvm.test()
class CopyDeepCopyTest(uvm_test) :
async def run_phase(self) :
self.raise_objection()
mary = StudentRecord("Mary", 33, [97, 82] )
mary_copy = StudentRecord()
mary_copy = copy.deepcopy(mary)
<snipped print statements>
--
mary: Name: Mary, ID: 33 Grades: [97, 82]
mary_copy: Name: Mary, ID: 33 Grades: [97, 82]
-- grades are DIFFERENT id --
id(mary.grades) : 140369746999360
id(mary_copy.grades) : 140369746860096
deepcopy() creates a new instance of every object it copies, so changes to mary.grades no longer affect
mary_copy.grades .
Using super() in methods
The __str__() method in figure 8 shows how we can use super() to leverage a method from a parent class.
The StudentRecord.__str__() leverages PersonRecord.__str__() (defined in figure 2) by using the
super() function to get the name and ID number string and then append the grades.
def __str__(self) :
return super() .__str__() + f" Grades: {self.grades}"
uvm_object.copy(<other>) —The copy() function copies the data fields from the <other> object into
the calling object.
uvm_object.clone() —The clone() function returns a new object that is a copy of the calling object.
do_copy()
Both the copy() and clone() methods call a user-defined method named do_copy() . The do_copy()
method implements copying. Figure 12 shows the do_copy() method in PersonRecord . It calls
super().do_copy(other) to leverage the do_copy() implemented in uvm_object to copy the name, then it
copies the data unique to itself.
class PersonRecord(uvm_object) :
def __init__(self, name="", id_number=None) :
super() .__init__(name)
self.id_number = id_number
When you override do_copy() , you should always call super().do_copy(other) at the beginning of your
method. StudentRecord works the same way.
# Figure 13: Using super() in do_copy to do a deep copy correctly
class StudentRecord(PersonRecord) :
def __init__(self, name="", id_number=None, grades=[] ) :
super() .__init__(name, id_number)
self.grades = grades
The version of do_copy() in figure 13 implements a deep copy by creating a new list and passing it to the
other.grades list to initialize it. We could have copied self.grades to other.grades if we wanted to
implement a shallow copy. Implementing a deep or shallow copy is your choice as the programmer.
@pyuvm.test()
class CopyTest(uvm_test) :
async def run_phase(self) :
self.raise_objection()
mary = StudentRecord("Mary", 33, [97, 82] )
mary_copy = StudentRecord()
mary_copy.copy(mary)
<snipped print statements>
@pyuvm.test()
class CloneTest(uvm_test) :
async def run_phase(self) :
self.raise_objection()
mary = StudentRecord("Mary", 33, [97, 82] )
mary_copy = mary.clone()
<snipped print statements>
Both tests return the same results so we print the output once in figure 16. We see that copy() and clone()
are doing a deep copy because that’s what we implemented in do_copy() .
We examined copying and cloning and saw the difference between copy.copy() and copy.deepcopy() .
Finally, we learned how to implement copy() and clone() in the UVM by overriding do_copy() , though I
recommend using the Python copy module functions.
Sequence testbench: 7.0
Testbench 6.0, our most advanced so far, used UVM components to build a testbench out of single-function
components that communicated using TLM ports. We created different tests by overriding the BaseTester
component.
Our next step is to create a testbench whose structure stays the same while generating different stimuli. This
approach allows us to create new tests by writing many test programs rather than creating many testbench
structures.
The UVM sequence 15 system separates stimulus from testbench structure and allows us to write test
programs that run on an unchanging testbench.
UVM Sequencer —The uvm_sequencer takes UVM sequence items from one or more UVM sequences
and sends them to the UVM driver. We pass the handle to the sequencer to a sequence when we start it.
UVM Driver —The uvm_driver gets sequence items from a sequencer and sends them to the DUT using
the BFM.
This chapter examines each of these pieces starting closest to the DUT with the uvm_driver .
Driver
Testbench 6.0 extended uvm_driver to create Driver but didn’t use any driver features. We’ll use those
features now that we’re implementing sequences.
The uvm_driver class contains a data member named seq_item_port , a blocking port that delivers the next
sequence item. Here is the new TinyALU Driver . There are four differences between this version and the 6.0
version:
1. We get the TinyAluBfm in the start_of_simulation_phase() function instead of build_phase() , and
we do not instantiate a get port. Instead we inherit self.seq_item_port from uvm_driver .
2. The while loop gets the next sequence item by awaiting self.seq_item_port.get_next_item() ,
storing the sequence item in cmd .
class Driver(uvm_driver) :
def start_of_simulation_phase(self) :
self.bfm = TinyAluBfm()
AluEnv
The Driver uses the inherited self.seq_item_port to get data from a source and notify the source when it
is ready to get a new item. The source is called a sequencer . We instantiate and connect the Driver and
uvm_sequencer in the AluEnv :
Figure 3 shows that we no longer have the cmd_fifo . This is because the driver gets sequence items directly
from the sequencer. Also, we don’t extend uvm_sequencer , and we instantiate it as provided by pyuvm.
In figure 4, the AluEnv.build_phase() instantiates the objects in figure 3. We instantiate self.seqr and
immediately store its handle in the ConfigDB() . The UVM test will use the handle later to start a sequence.
def build_phase(self) :
self.seqr = uvm_sequencer("seqr", self)
ConfigDB() .set(None, "*", "SEQR", self.seqr)
self.driver = Driver("driver", self)
self.cmd_mon = Monitor("cmd_mon", self, "get_cmd")
self.result_mon = Monitor("result_mon", self, "get_result")
self.scoreboard = Scoreboard("scoreboard", self)
self.coverage = Coverage("coverage", self)
def connect_phase(self) :
self.driver.seq_item_port.connect(
self.seqr.seq_item_export)
self.cmd_mon.ap.connect(self.scoreboard.cmd_export)
self.cmd_mon.ap.connect(self.coverage.analysis_export)
self.result_mon.ap.connect(self.scoreboard.result_export)
Now the driver can get sequence items from the sequencer. The sequencer will get them from the sequence.
Next, we define the sequence item.
AluSeqItem
Until now, we’ve stored TinyALU commands in the (A, B, op) tuple. We’ll now replace that object by
extending uvm_sequence_item . A UVM sequence item holds the information the BFM needs to execute a
command and can hold the resulting data from the command.
Here, we create an AluSeqItem that extends uvm_sequence_item . We override __init__ to force the user to
provide the command data. We call super().__init__() because pyuvm requires it. We’ll break the
AluSeqItem definition into three pieces.
The __init__() method takes the operands aa , bb , and operation op as inputs and stores them in the
sequence item. We use op to create a new Ops() object.
# Figure 6: Defining an ALU command as a sequence item
class AluSeqItem(uvm_sequence_item) :
We add the __eq__() and __str__() magic methods to check the sequence item against another one and
print it.
def __str__(self) :
return f"{self.get_name() } : A: 0x{self.A:02x} \
OP: {self.op.name} ({self.op.value}) B: 0x{self.B:02x}"
Creating sequences
Starting with testbench 3.0, we’ve created a class structure using a base object that generated ALU commands
and sent them to the TinyALU and a random and max extension that defined the operands. So the class
structure is back, but this time the testbench remains unchanged as we use different sequences to send
different commands.
BaseSeq
A sequence contains a body() coroutine that creates sequence items and sends them to the DUT. BaseSeq
loops through the operations, generating sequence items using them and sending the items to the sequencer
using start_item() and finish_item() .
# Figure 9: The BaseSeq contains the common body() method
class BaseSeq(uvm_sequence) :
The example illustrates how to define a sequence by looping through all the operations and randomizing the
operands. Here are the sequence-specific elements:
self.set_operands(cmd_tr) —This functions sets the operands in cmd_tr . It does nothing in BaseSeq
because it is supposed to be overridden. This code illustrates late stimulus setting , which means we don’t
set the stimulus until the driver is ready to receive the item. Some testbenches need the latest state of the
system to set the stimulus properly, and this feature supports that need.
This example is the simplest way to write a sequence. Later in the chapter, we will look at sequences that get
data from the DUT.
# Figure 10: Extending BaseSeq to create the random and maximum stimulus
class RandomSeq(BaseSeq) :
def set_operands(self, tr) :
tr.A = random.randint(0, 255)
tr.B = random.randint(0, 255)
class MaxSeq(BaseSeq) :
def set_operands(self, tr) :
tr.A = 0xff
tr.B = 0xff
Starting a sequence in a test
We usually start sequences in a UVM test's run phase. We create the sequence and then call its start() method,
passing a handle to the sequencer.
BaseTest
BaseTest instantiates the environment in build_phase() , and then uses the end_of_elaboration_phase()
method to get a handle to the sequencer.
# Figure 11: All tests use the same environment and need a sequence
class BaseTest(uvm_test) :
def build_phase(self) :
self.env = AluEnv("env", self)
def end_of_elaboration_phase(self) :
self.seqr = ConfigDB() .get(self, "", "SEQR")
The run_phase() creates a sequence using the factory. Then it awaits seq.start(self.seqr) to start the
sequence. The start() coroutine returns when the sequence has been completed. We then use
ClockCycles() to wait until all the operations are complete.
We see that the TinyALU works properly with all zeros as data. The zeros are from the BaseSeq .
@pyuvm.test()
class RandomTest(BaseTest) :
def start_of_simulation_phase(self) :
uvm_factory().set_type_override_by_type(BaseSeq, RandomSeq)
@pyuvm.test()
class MaxTest(BaseTest) :
def start_of_simulation_phase(self) :
uvm_factory().set_type_override_by_type(BaseSeq, MaxSeq)
Summary
This chapter created testbench 7.0, which used UVM sequences to test the TinyALU. We learned about the
uvm_sequencer and uvm_driver and how the uvm_driver uses the seq_item_port to get sequence items
from the uvm_sequencer .
We learned to store sequencer handles in the ConfigDB() so that other parts of the testbench, such as tests,
can get them and use them to start sequences.
Finally, we created sequences that generated the random and max operands that we’ve seen through the
book.
Now that we’ve created testbench 7.0, we will tweak it to demonstrate different aspects of sequence behavior.
These examples will give us testbench 7.1 and 7.2.
Fibonacci testbench: 7.1
Testbench 7.0 is a full-fledged UVM testbench. We've modularized its functions, connected components
together to test the DUT, and now are using sequences to provide stimulus.
Driver in Testbench 7.0 acts like the BaseTester class in Testbench 2.0 in that it sends commands into the
DUT and doesn't worry about the result. Instead, the testbench uses the monitors to gather the commands
and the results and send them to the scoreboard and coverage components.
Sending sequence items into the DUT and ignoring the result does not work for all testbenches because
sometimes the data in one sequence item depends upon the results of a previous one. Testbench 7.1 will
implement such a test by using the TinyALU to generate the Fibonacci sequence.
Fibonacci numbers
We first met Fibonacci numbers in the Generators chapter (so long ago)! The Fibonacci sequence starts with 0
and 1 then adds two Fibonacci numbers to get the following Fibonacci number. Testbench 7.1 logs the first 9
Fibonacci numbers as a list: [0, 1, 1, 2, 3, 5, 8, 13, 21] .
Testbench 7.1 generates the Fibonacci numbers using TinyALU's Ops . ADD operation. So, we need to modify
the driver to get the sum from the TinyALU before sending the next command.
FibonacciSeq
FibonacciSeq is a uvm_sequence whose body() method prints the first nice Fibonacci numbers. It creates a
list of the numbers and then prints the list. First, we initialize prev_num and cur_num to the first two Fibonacci
numbers, 0 and 1 , and store them in fib_list . Then we instantiate an add operation by calling
AluSeqItem (0, 0, Ops.ADD) and storing the handle in cmd . The handle does not change throughout the
program since we reuse the sequence item for all commands.
class FibonacciSeq(uvm_sequence) :
async def body(self) :
prev_num = 0
cur_num = 1
fib_list = [prev_num, cur_num]
cmd = AluSeqItem("cmd", None, None, Ops.ADD)
We have two Fibonacci numbers. Now we’ll generate seven more using the TinyAlu. We await
self.start_item(cmd) . When the driver is ready, we store prev_num and cur_num as operands. Then we
await self.finish_item(cmd) , and when the coroutine returns, a miracle happens: cmd.result contains
the sum of the numbers. We append the result to fib_list , move cur_num to prev_num and cmd.result
to cur_num . Then we go back to the top of the loop.
# Figure 2: Generating Fibonacci numbers
for _ in range(7) :
await self.start_item(cmd)
cmd.A = prev_num
cmd.B = cur_num
await self.finish_item(cmd)
fib_list.append(cmd.result)
prev_num = cur_num
cur_num = cmd.result
Now that we’re through the loop, we log the Fibonacci numbers. The uvm_sequence class does not extend
uvm_report_object , so it has no logger. Instead, we use the logger in the uvm_root() singleton.
--
INFO testbench.py(46) [] : Fibonacci Sequence: [0, 1, 1, 2, 3, 5, 8, 13, 21]
INFO fibonaaci_test pass
This sequence works because the Driver stores the result in the AluSeqItem using the shared handle. Next,
we’ll look at the Driver code to see how it does this.
Driver
The 7.1 Driver does three things that the 7.0 Driver does not:
3. It copies the result into the sequence item so the sequence can use it.
The code in figure 4 creates the analysis port and gets the handle to TinyAluBfm .
class Driver(uvm_driver) :
def build_phase(self) :
self.ap = uvm_analysis_port("ap", self)
def start_of_simulation_phase(self) :
self.bfm = TinyAluBfm()
The run_phase() coroutine resets the DUT, starts the BFM tasks, then starts a while loop.
First, the while loop gets cmd by awaiting self.seq_item_port.get_next_item() . Next, it sends cmd to the
TinyALU. Finally, it awaits self.bfm.get_result() to get the result.
# Figure 5: The run phase gets a result back from the BFM
Now that the Driver has result it writes it to the analysis port, copies it into cmd , and calls
self.seq_item_port.item_done() to allow the sequence to return from finish_item() .
self.ap.write(result)
cmd.result = result
self.seq_item_port.item_done()
Sequence timing
Python for RTL Verification
1. The sequence awaits start_item() and is now blocked, waiting for the sequencer.
2. The driver awaits self.seq_item_port.get_next_item() . This allows the sequence to move past
start_item() .
3. The sequence awaits finish_item() . This sends the sequence item to seq_item_port and blocks the
sequence.
4. The driver completes its work and calls self.seq_item_port.item_done() . This allows finish_item()
to return and the sequence continues.
AluEnv
We modified Driver to get a command's result and return it to the calling sequence. The Driver also creates
an analysis port to provide the results to the Scoreboard . We connect self.scorboard.result_export to
the Driver as this diagram shows.
Figure 7: AluEnv with driver getting results
class AluEnv(uvm_env) :
The 7.1 connect_phase() in figure 9 connects the result_export to the driver’s analysis port.
def connect_phase(self) :
self.driver.seq_item_port.connect(
self.seqr.seq_item_export)
self.cmd_mon.ap.connect(self.scoreboard.cmd_export)
self.cmd_mon.ap.connect(self.coverage.analysis_export)
self.driver.ap.connect(self.scoreboard.result_export)
Summary
In Testbench 7.1, we modified Driver to get the result of its operations and write them into the AluSeqItem
handle so the sequence could use the data.
The UVM defines another way to pass data back to the sequence from the driver. We’ll examine that approach
in Testbench 7.2.
get_response() testbench: 7.2
The UVM allows you to return result sequence items from the driver to the sequence. First, the driver
instantiates a result sequence item containing the result then sends it to the sequence using
seq_item_port.item_done() . Finally, The sequence gets the result sequence item by awaiting
get_response() .
Testbench 7.2 modifies Driver and FibonacciSeq to use get_response() We also define the result
sequence item.
AluResultItem
The get_response() system expects the testbench to create a new sequence item containing the return data.
We must return a uvm_sequence_item because pyuvm uses data in that class to send the correct response to
the correct sequence.
Here is AluResultItem .
class AluResultItem(uvm_sequence_item) :
def __init__(self, name, result) :
super() .__init__(name)
self.result = result
def __str__(self) :
return f"RESULT: {self.result}"
Driver
The Testbench 7.2 Driver , like the 7.1 Driver , instantiates an analysis port, sends commands to the
TinyALU, and awaits self.bfm.get_result() . Once it has a result, it writes it to the analysis port.
Driver sends the result back to the sequence using three steps:
FibonacciSeq
The Testbench 7.2 FibonacciSeq in figure 4 is identical to the 7.1 version though awaiting finish_item() .
class FibonacciSeq(uvm_sequence) :
async def body(self) :
prev_num = 0
cur_num = 1
fib_list = [prev_num, cur_num]
cmd = AluSeqItem("cmd", None, None, Ops.ADD)
for _ in range(7) :
await self.start_item(cmd)
cmd.A = prev_num
cmd.B = cur_num
await self.finish_item(cmd)
As we see in figure 5, once finish_item() returns, the sequence awaits get_response() to get the
AluResultItem the driver created. It writes rsp.result into the analysis port and stores it in cur_num .
get_response() pitfalls
We’ve examined two ways the driver can return results to a sequence:
1. Store the result in the sequence item using the shared sequence item handle.
2. Create a response item and send it back to the sequence using item_done() .
Writing to the shared sequence item handle is the cleaner of the two approaches and easier to use. In addition
it avoids a pitfall with get_response() .
In the TinyALU testbench, all operations create a response. However, this isn’t true of all designs. For example,
a RAM may only send a response when you do a READ operation since a WRITE operation doesn’t need a
response.
A sequence that tests this RAM must only call get_response() on read because it will hang if it calls
get_response() on a write and the driver doesn’t send a response.
One can get around this by having the driver always send back something, but that is a needless complication
we don't need if we return data using item handles.
Summary
This chapter taught us the get_response() approach for a sequence to get a result back from a driver.
Using get_response() requires that the driver instantiate a new sequence item and call set_id_info to
indicate that the new sequence item is the response to the item that came from seq_item_port.get_item() .
We discussed the pitfalls of the get_response() approach, and I recommended using the shared handle
approach illustrated in Testbench 7.1.
Virtual sequence testbench: 8.0
We launched sequences in testbench 7.x by passing the handle to a sequencer. The start_item() and
finish_item() inherited coroutines used the sequencer to send sequence items to the driver.
However, it’s possible to launch a sequence without a sequencer. A sequence you start without a sequencer is
called a virtual sequence . The virtual sequence does not use start_item() or finish_item() . Instead, it
launches other sequences. We can use virtual sequences to create programming interfaces that make it easy
to write test programs.
@pyuvm.test()
class AluTest(uvm_test) :
def build_phase(self) :
self.env = AluEnv("env", self)
def end_of_elaboration_phase(self) :
self.test_all = TestAllSeq.create("test_all")
The body() coroutine in a virtual sequence looks like a program as it doesn’t create items or call
start_item() or finish_item() . In this case, body() launches our two TinyALU test sequences by getting
the sequencer handle from the ConfigDB() and using it to await start() .
class TestAllSeq(uvm_sequence) :
class TestAllParallelSeq(uvm_sequence) :
The sequencer runs both sequences in parallel, alternating sequence items between them. The result is that
we see random operands interspersed with maximum operands.
class OpSeq(uvm_sequence) :
def __init__(self, name, aa, bb, op) :
super() .__init__(name)
self.aa = aa
self.bb = bb
self.op = Ops(op)
class FibonacciSeq(uvm_sequence) :
def __init__(self, name) :
super().__init__(name)
self.seqr = ConfigDB().get(None, "", "SEQR")
Summary
In this chapter, we learned about virtual sequences . These are sequences you start() without passing them
a sequencer. Instead, these sequences typically create other sequences and pass them a handle to the
sequencer.
We saw how virtual sequences allow us to combine many sequences into one. We saw how to run sequences
sequentially and in parallel using cocotb.start_soon() .
Finally, we saw how we could use virtual sequences to create programming interfaces that hide the details of a
testbench from the test writer. This abstract programming makes writing and maintaining tests for complex
systems easier.
The future of Python in verification
We’ve seen that Python brings considerable advantages to the IC verification problem, and it’s natural to
wonder whether Python will replace existing languages such as SystemVerilog.
The EDA industry has never turned its back on a computer language that supports a significant ecosystem.
Even the ancient language e is running today in large testbenches due to the e Verification Component ( e VC)
library.
This dynamic tells us that Python needs access to a verification ecosystem if it is going to become a
mainstream verification language. It can get such access in two ways: leverage the SystemVerilog ecosystem
and create a Python verification ecosystem.
SystemVerilog provides an enormous library of Verification IP, and Python will need to leverage that library to
interact with many new and popular interfaces.
Python needs a library that allows users to control SystemVerilog components using DPI-C function calls. Such
a library would provide a way to transfer objects between Python and SystemVerilog and provide a polling
mechanism that allows Python to check transaction statuses using function calls (which don’t block.)
Of course, the Python ecosystem could go beyond transactors. Modern designs that leverage machine learning
and artificial intelligence will need stimulus-generation techniques other than existing constrained-random
methodologies. With its extensive, existing AI/ML modules, numerical analysis tools, and other advanced
resources, Python could provide the foundation for testing future designs.
A new generation of developers has entered the workforce in the twenty years since constrained-random
verification technologies were developed. These new developers are attuned to modern development
techniques such as agile and modern tech stacks featuring editors such as Visual Studio Code.
The days of Emacs/VI dominance have passed as today’s developers embrace software development
techniques for EDA and bring the power of open-source development to EDA.
As designs become more complicated and adopt technologies such as machine learning and artificial
intelligence, I hope that Python, cocotb , and pyuvm enable a new generation of engineers to tackle the
verification challenges ahead.
Acknowledgements
It would have been impossible to create this book without many people’s help.
Thank you to my wife Karen Salemi who not only provides moral support for my writing efforts but is also an
outstanding editor. Karen tirelessly worked on this book to guide me in making it as understandable and as
easy to read as possible. If you found the book clear and helpful, thank Karen. If the book is confusing in spots,
that’s on me.
A book such as this depends upon diligent beta readers to find errors in grammar, technology, and
explanations. I was blessed with a generous group of beta readers. Kaleb Barrett, Colin Marquardt, and Jon
Povey ensured not only that the cocotb chapters were correct, but that the Python explanations were clear
and accurate.
Tom Fitzpatrick of Siemens EDA, helped me clean up the grammar, explanations, and writing, as he has for
many of my books (including the Tucker mysteries.)
Bob Oden reviewed the first draft, which is always yeoman’s work. His comments and reactions assured me
that the book would be timely and helpful.
Umut Guvenc caught explanations that didn’t hit home, while my Siemens EDA cohorts David Aerne, Amr
Baher, Tom Franco, Amr Hany, Robert Jeffrey, Amr Okasha, and Dave Rich provided corrections and convinced
me to add figure numbers.
Dave Rich and Tom Fitzpatrick also helped me capture the history of SystemVerilog correctly.
This book would not exist and would be much the poorer without everyone’s help.
Thank you!
Appendix
Other Books by Ray Salemi
Leading after a Layoff (McGraw-Hill)
Terminated
Corrupted Memory
Hacked
Copyright
© 2022, Ray Salemi. All rights reserved. No part of this book (other than the source code examples, as the MIT
License protects them) may be reproduced or transmitted in any form or by any means, electronic or
mechanical, including photocopying, scanning to PDF, recording, or by any information storage and retrieval
system without permission in writing from the publisher. For information, please contact Boston Light Press at
www.bostonlightpress.com.
License
All source code in this book is protected by the following MIT license:
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of
the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Indemnification
Although the author and publisher have made every effort to ensure the accuracy and completeness of the
information contained in this book, we assume no responsibility for errors, inaccuracies, omissions, or any
inconsistency herein. Any slights of people, places, or organizations are unintentional.
“Python” and the Python Logo are trademarks of the Python Software Foundation.
1. MIL-STD-454N ↩
2. It shouldbe noted that UVVM has no relationship to the UVM other than starting with the letters UV. ↩
3. The formatting characters make up the Format Specification Mini-Language and it is fully documented on docs.python.com. ↩
5. As of Python 3.7. ↩
6. The Fibonacci sequence adds two numbers to get the next number in the sequence. 0 + 1 = 1, 1 + 1 = 2, 1 + 2 = 3, etc. ↩
8. Metaclasses are beyond the scope of this book, so we’ll simply use the argument without explanation. ↩
9. We now move from using Jupyter notebooks to hold the examples to simulation directories. Instructions are in python4uvm_examples . ↩
10. I’ve often thought the initial block in SystemVerilog should be called a once block in contrast with the always block. ↩
13. The Modules chapter shows how we import tinyalu_utils from our parent directory. ↩
15. Do not confuse UVM sequences with the Python sequences. Same word, different meanings. ↩