Thanks to visit codestin.com
Credit goes to github.com

Skip to content

davidel/pyxhdl

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

366 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PyXHDL - Python Frontend For VHDL And Verilog

PyXHDL born for developers who are not really in love with any of the HDL languages and instead appreciate the simplicity and flexibility of using Python for their workflows.

Install

The easiest way to install PyXHDL is using PIP from the direct Github repo.

$ pip install git+https://github.com/davidel/pyxhdl.git

Description

PyXHDL allows to write HDL code in Python, generating VHDL (>= 2008) and Verilog (SystemVerilog >= 2012) code to be used for synthesis and simulation.

PyXHDL does not try to create an IR to be lowered, but instead interprets Python AST code and maps that directly into the selected HDL backend code. The optimizations are left to the OEM HDL compiler used to syntesize the design.

The main advantage of PyXHDL is that you can write functions and modules/entities wihtout explicit parametrization. The function calls, and the modules/entities instantiations automatically capture the call/instantiation site types, similarly to what C++ template programming allows.

Even though user HDL code in Python can use loops and functions, everything will be unrolled in the final VHDL/Verilog code (this is what the synthesis will do anyway, since there are no loops or function calls in HW).

PyXHDL does not try to map Python loops to VHDL/Verilog ones, as those are too limited when compared to the power of the Python ones, but instead unrolls them like the OEM HDL compiler will.

The workflow with PyXHDL is not meant as to generate code to be manually edited, but to be directly fed into OEM HDL synthesis (and testing, when using the testbench code generation) tools.

An IF statement in Python gets emitted as IF of the target HDL language, if the test depends on HDL variables (the X.Value type), or gets statically resolved to the if/else branch like it normally would in Python.

Note that in case the IF test condition depends on HDL variables, both branches are executed in order to generate the proper HDL code. See the following code for the pyint pitfall.

pyint = 0
if hdlvar > 2:
  # IF HDL code
  ...
  pyint += 1
else:
  # ELSE HDL code
  ...
  pyint += 1

# pyint == 2 here!

On the contrary, IF statements whose condition only depends on Python variables, is resolved statically and the "false branch" never executed/emitted.

if pyint > 2:
  hdl_var1 = hdl_var2 + 17
else:
  hdl_array[2, 1] = hdl_array[1, 2]

In the above example if, say, pyint is 3, the ELSE branch won't be evaluated, which means that no code will be emitted for the HDL array operation in it.

An IF condition containing a mix of Python and HDL conditions get shortcutted. Example:

# Given a Python scalar A with value 3, and HDL variables B and C, we have ...
if A < 2 and B >= C:
  # Code below never executed because of AND(False, B >= C) shortcut.
  ...

if A > 2 or B >= C:
  # Code below always executed (the B >= C won't be generated) because of
  # OR(True, B >= C) shortcut.
  ...

if A > 2 and B >= C:
  # The above test reduces to:
  #
  #  if B >= C:
  #    ...
  #
  # Because the A > 2 statement is known to be True at compile time.
  ...

If an HDL function contains return statements nested within a branch depending on the value of an HDL variable, all the "return paths" must have the same signature.

Below, if hdl_var1 is X.Bits(5) and hdl_var2 is X.Bits(8), the function is valid because both return paths return a two-element tuple with signature X.Bits(11), X.Bits(8) (PyXHDL borrowed the Python MatMul operator "@" for bits concatenation).

@X.hdl
def tuple_return(hdl_var1, hdl_var2):
  if hdl_var1 == "0b01101":
    return hdl_var1 @ "0b110110", hdl_var2
  ...
  return hdl_var2 @ "0b101", hdl_var1 @ "0b001"

The normal Python FOR and WHILE loops are supported in full, but the generators and tests should not depend on HDL code (IOW the loop has to be deterministic, as required by synthesis).

Within Python loops, it is not possible to have BREAK and CONTINUE within a branch (IF) that depends on an HDL variable. Even this case behavior is dictated by the fact that PyXHDL emitted code must be synthesisazible (deterministic loops). So this is not possible:

for i, hdl_value in enumerate(my_hdl_generator(...)):
  ...
  if hdl_value == 0:
    break

While this is (assuming max_count a Python scalar):

for i, hdl_value in enumerate(my_hdl_generator(...)):
  ...
  if i > max_count:
    break

The data access model of VHDL and Verilog differs quite a bit from a user level POV (though it converges at the end at lower level).

In VHDL it is not possible (modulo declaring them shared which is usually a bad idea) to have global variables, while it is possible to assign wires signals from within processes, and the assignment will be immediate or delayed (at the next clock cycle) depending on the process type (combinatorial vs sequantial).

On the contrary in Verilog it is possible to declare registers at module level (though only one process can write them), but it is not possible to assign wires from within processes (always blocks of kind).

In PyXHDL registers (by the means of X.mkreg() or X.mkvreg()) should be used for sequential logic, and wires (X.mkwire() and X.mkvwire()) should be the glue for combinatorial logic.

PyXHDL registers map to signal in VHDL and to logic in SystemVerilog.

PyXHDL wires map to variable in VHDL and to logic in SystemVerilog.

HDL variables should be declared before being assigned. An assignment to a bare Python variable with the result of an HDL operation, will simply create the X.Value result and store it to the Python variable, no HDL assignment will be generated.

Example:

wtemp = X.mkreg(hdl_var1.dtype)
temp = hdl_var1 + hdl_var2
wtemp = hdl_var1 - hdl_var2
# Somewhere below the "temp" value will be used ...

In the above code, an HDL assignment to the wtemp HDL variable will be generated, while the temp assignment won't generate one (it can be seen as temporary value to be used in following computations, but without explicit instantiation at HDL level).

When an HDL variable is declared of a given type, assignments to it will cast the RHS to the type of the variable.

C = X.mkreg(X.Bits(10))

# If A is Bits(4) and B is Bits(8), C will be (as declared) Bits(10) obtained by truncating
# the concatenation result of Bits(12).
C = A @ B

Python functions which contains operations on X.Value types need to be marked with the @X.hdl decorator, while X.Entity methods which are to be translated to processes, need to be marked with the @X.hdl_process decorator.

Note that if a Python function simply handles HDL variables as data, or uses their object APIs, there is no need to decorate the functions as HDL. So this is valid from a PyXHDL point of view:

def hdl_handler(hdl_v1, hdl_v2):
  assert hdl_v1.dtype == hdl_v2.dtype, f'Types must match: {hdl_v1.dtype} vs. {hdl_v2.dtype}'

  return dict(v1=hdl_v1, v2=hdl_v2), (hdl_v1, hdl_v2)

The Python matrix multiplication operator "@" has been repurposed to mean concatenation of bits sequences.

The Python slice works with a base that is an HDL variable. The slice stop must be left empty, and the step must be constant.

class VarSlice(X.Entity):

  PORTS = 'A, B, =XOUT'

  @X.hdl_process(sens='A, B')
  def var_slice():
    XOUT = A[B + 1::4]

Produces the following Verilog code:

module VarSlice(A, B, XOUT);
  input logic [31: 0] A;
  input logic [3: 0] B;
  output logic [3: 0] XOUT;
  always @(A or B)
  var_slice : begin
    XOUT = A[int'(B + 1) -: 4];
  end
endmodule

And VHDL code:

architecture behavior of VarSlice is
begin
  var_slice : process (A, B)
  begin
    XOUT <= std_logic_vector(A((to_integer(B + 1) - 3) downto to_integer(B + 1)));
  end process;
end architecture;

PyXHDL uses the new Python MATCH/CASE statement to map that to the appropriate HDL case select construct, in order to easily code FSMs. The restriction is that the CASE values need to be Python variables (cannot be HDL).

Example:

# Somewhere defined ...
IDLE, START, STOP = 1, 2, 3

@X.hdl_process(sens='A, B')
def tester():
  match A:
    case IDLE:
      XOUT = A + 1
    case START:
      XOUT = A + B
    case STOP:
      XOUT = A - B
    case _:
      XOUT = A * B

Will map to VHDL:

architecture behavior of MatchEnt is
begin
  tester : process (A, B)
  begin
    case A is
      when to_unsigned(1, 8) =>
        XOUT <= A + 1;
      when to_unsigned(2, 8) =>
        XOUT <= A + B;
      when to_unsigned(3, 8) =>
        XOUT <= A - B;
      when others =>
        XOUT <= resize(A * B, 8);
    end case;
  end process;
end architecture;

Data Types

The types supported by PyXHDL are Uint (unsigned integer), Sint (signed integer), Bits (generic bit group), Float (HW synthesizable floating point), Integer (generic integer), Real (generic floating point) and Bool (boolean).

# A 8 bits unsigned integer type.
u8 = X.Uint(8)
# A 15 bits signed integer type.
s15 = X.Sint(15)
# A 32 bits group type.
b32 = X.Bits(32)
# A 32 bits floating point type.
f32 = X.Float(32)

The following types are predefined for an easy use.

INT4 = Sint(4)
INT8 = Sint(8)
INT16 = Sint(16)
INT32 = Sint(32)
INT64 = Sint(64)
INT128 = Sint(128)

UINT4 = Uint(4)
UINT8 = Uint(8)
UINT16 = Uint(16)
UINT32 = Uint(32)
UINT64 = Uint(64)
UINT128 = Uint(128)

FLOAT16 = Float(16)
FLOAT32 = Float(32)
FLOAT64 = Float(64)
FLOAT80 = Float(80)
FLOAT128 = Float(128)

BOOL = Bool()
BIT = Bits(1)
INT = Integer()
REAL = Real()
VOID = Void()

Logic bit values can be 0, 1, X and Z, although if expected to use the VHDL backend only, the full set of VHDL logic values (01XUZWHL) can be used. Bits are assigned with Python strings like '0b110xz' (the '0b' prefix, followed by the logic states for the bits).

In order for the HW synthesizable floating point to be fully specified, a mapping from the full number of bits representation to the exponent and mantissa size is required.

The default mapping is the following, which follows the IEEE standard.

# FSpec = FSpec(EXP_SIZE, MANT_SIZE)
_FLOAT_SPECS = {
  16: FSpec(5, 10),
  32: FSpec(8, 23),
  64: FSpec(11, 52),
  80: FSpec(17, 63),
  128: FSpec(15, 112),
}

It is possible to override that using a configuration file or defining environment variables. Example, define the F16_SPEC environment varible to "8,7" to map the 16 bits floating point number to the BFLOAT16 standard.

It is also possible to configure the floating point type mapping using the "float_specs" entry of the configurations file (see Mock Configuration).

While VHDL (2008) offers a standard HW floating point library, Verilog does not. In order for PyXHDL to be able to handle the Float type, a Verilog FPU Library is included within its standard libraries. Note that this is a very simple library which has been tested, but likely not as fully as it would call for a full IEEE754 compliance.

It is possible to map the Verilog FPU library to a different implementation by adding the following configuration to the YAML configuration file provided to the code generator:

backend:
  verilog:
    vfpu_conf: FPU_CONF_PATH

Where FPU_CONF_PATH is the path to the YAML configuration file describing the mapping between the PyXHDL used functions, and the external module implementation. The default configuration is to use the Verilog FPU Library.

Arrays are created using the X.mkarray() API.

# Creates a (4, 4) array of UINT8 initialized with 0.
ARR = X.mkvreg(X.mkarray(X.UINT8, 4, 4), 0)

Arrays are indexed in the standard Python way.

RES = ARR[1, 2] + ARR[i, j]

When creating bits sequence types (Sint, Uint, Bits), the last dimension of the type shape is the number of bits. The example above shape for ARR will be (4, 4, 8).

Slicing the last dimension of a bits sequence type, will result in selecting the bits.

# C will will Bits(4)
C = ARR[0, 1, 2: 6]

Sliced assign works in a similar fashion:

# XOUT is X.UINT8 and A is X.Uint(4, 4, 8).
# Assign the first 4 bits of XOUT with the last 4 bits of A[1, 0] and assigns the
# last 4 bits of XOUT with the first 4 bits of A[0, 1].
XOUT[: 4] = A[1, 0, 4: 8]
XOUT[4: 8] = B[0, 1, : 4]

Also works assigning whole sub-arrays, if the type matches (in shape and core type):

# XOUT is X.Uint(6, 4, 8) and A is X.Uint(4, 4, 8).
XOUT[1] = A[2]

Complex Python slice operations (e.g A[0 : 8 : 2]) are not supported (though in theory they could be expanded in element-wise operations on the complex slice component).

Creating Modules/Entities

Creating a module/entity with PyHDL is simply a matter of defining a new class inheriting from the X.Entity base class.

The class variable PORTS must be defined, specifying the names of the ports and their direction.

Note that the port data type is not defined statically, but instead during instantiation, allowing to use the same entity code for different types. Clearly the Python code defining the entity should take care of creating intermediate types using the real input types, and use code that is compatible with the entity inputs.

Note that the entity PORTS declaration:

class MyEntity(X.Entity):

  PORTS = 'A, B:u*, C:s*=s16, D=b8, =XOUT'
  ...

is equivalent to the fully expanded form:

class MyEntity(X.Entity):

  PORTS = (
    X.Port('A', X.IN),
    X.Port('B', X.IN, type='u*'),
    X.Port('C', X.IN, type='s*', default=SINT16),
    X.Port('D', X.IN, default=Bits(8)),
    X.Port('XOUT', X.OUT),
  )
  ...

If a port name is prefixed with the "=" character, the port is an output one. If instead is prefixed with a "+" character is an input/output one, otherwise (with no prefix at all) it is an input only port.

The "default" argument in the port declaration is used only when a root entity is being generated, to avoid specifying manually the input to the generator script. In all other cases the type is bound at instantiation time, as usual.

If necessary, it is possible to restrict the port input types to specific types, using the following syntax:

class MyEntity(X.Entity):

  PORTS = 'A:u*, B:u*, =XOUT:s16'
  ...

Above, the A and B ports are restricted to unsigned types of any size, and the XOUT output port to signed 16 bits type.

This is equivalent to the fully expanded form:

class MyEntity(X.Entity):

  PORTS = (
    X.Port('A', X.IN, type='u*'),
    X.Port('B', X.IN, type='u*'),
    X.Port('XOUT', X.OUT, type='s16'),
  )
  ...

If more complex type checking is required, it is of course possible to be implementing the logic within the MyEntity __init__() method.

The processes defining the entity behavior should also be declared, using the @X.hdl_process(...) decorator.

The sens= parameter of the @X.hdl_process(...) specifies the sensitivity list of the process, and can either be a comma separated string of port names, a dictionary whose keys are the port names, and the values instances of the X.Sens class, or a tuple/list with a combination of those:

@X.hdl_process(sens='+CLK, RESET, READY')
def run():
  if RESET != 0:
    XOUT = 0

The + sign in front of an HDL variable name means positive edge (0->1, POSEDGE), while a - means negative edge (1->0, NEGEDGE). The default is LEVEL edge, which triggers at every change of the variable value.

The above could have also been written using the fully expanded X.Sens(...) object.

@X.hdl_process(
  sens=(dict(CLK=X.Sens(X.POSEDGE)), 'RESET, READY'),
)
def run():
  if RESET != 0:
    XOUT = 0

The kind= parameter of the @X.hdl_process(...) specifies the process kind. If not defined, the process is a normal process, with its code enclosed within a process block.

Declaring the process as root will lead to the code generated from the function, to be emitted outside of any process block, within the entity root section.

@X.hdl_process(kind=X.ROOT_PROCESS)
def root():
  temp = X.mkwire(A.dtype)
  temp = A + B
  XOUT = temp * 3

Which produces the following Verilog code:

module Ex2(A, B, XOUT);
  input logic [7: 0] A;
  input logic [7: 0] B;
  output logic [7: 0] XOUT;
  wire logic [7: 0] temp;
  assign temp = A + B;
  assign XOUT = 8'(temp * 3);
endmodule

And VHDL code:

architecture behavior of Ex2 is
  signal temp : unsigned(7 downto 0);
begin
  temp <= A + B;
  XOUT <= resize(temp * 3, 8);
end architecture;

Note that there is no need to declare the ports variables as inputs of the process functions, as those are implicitly defined by PyXHDL before interpreting the function AST code. The same thing is true for any of the kwargs passed during an Entity instantiation, which get the default values from the Entity ARGS class variable.

If a given Entity is instantiated with different input types (or even keyword arguments) as many unique entities will be generated in the HDL emitted code.

The name of the functions within an entity (which will become the name of the processes if the @X.hdl_process(...) decorator is used), as well as the names of the HDL entity inputs and variables, must not conflict with VHDL/Verilog reserved keywords.

Example of simple gates composition in PyXHDL:

import pyxhdl as X
from pyxhdl import xlib as XL

class AndGate(X.Entity):

  PORTS = 'A, B, =XOUT'

  @X.hdl_process(kind=X.ROOT_PROCESS)
  def run():
    XOUT = A & B


class NotGate(X.Entity):

  PORTS = 'A, =XOUT'

  @X.hdl_process(kind=X.ROOT_PROCESS)
  def run():
    XOUT = ~A


class NandGate(X.Entity):

  PORTS = 'A, B, =XOUT'

  @X.hdl_process(kind=X.ROOT_PROCESS)
  def run():
    OOUT = X.mkwire(XOUT.dtype)
    AndGate(A=A,
            B=B,
            XOUT=OOUT)
    NotGate(A=OOUT,
            XOUT=XOUT)

Note that the modules above do not need any parametrization to be specified, and automatically work with any bit vector type. Types are bound at instantiation site, and propagate down without any explicit parametrization.

It is also possible to have Entity other configuration as Python typical kwargs during entity instantiation. This requires the inherited Entity to declare the ARGS class variable defining the valid kwargs and their default values.

Example declaration:

class ArgsEntity(X.Entity):

  PORTS = 'CLK, XIN, =XOUT'

  ARGS = dict(mask=31)

  @X.hdl_process(sens='+CLK, XIN')
  def run():
    XOUT = XIN ^ mask

Example instantiation:

class UseArgsEntity(X.Entity):

  PORTS = 'CLK, A, B, =XOUT'

  @X.hdl_process(kind=X.ROOT_PROCESS)
  def use_proc():
    OOUT = X.mkreg(A.dtype)
    ArgsEntity(CLK=CLK,
               XIN=A,
               XOUT=OOUT,
               mask=63)
    ...

Interfaces

PyXHDL has support for interfaces as well, in order to group signals and simplify modules argument passing.

The interfaces within PyXHDL are not generated into the specific HDL backend ones, but are expanded at code generation time. For the user there is no visible effect among the two options.

Example interface use in PyXHDL:

import pyxhdl as X
from pyxhdl import xlib as XL

class TestIfc(X.Interface):

  FIELDS = 'X:u16, Y:u16=0'

  IPORT = 'CLK, RST_N, +X, +Y, =XOUT'

  def __init__(self, clk, rst_n, xout, **kwargs):
    super().__init__('TEST', **kwargs)
    self.mkfield('CLK', clk)
    self.mkfield('RST_N', rst_n)
    self.mkfield('XOUT', xout)


class IfcEnt(X.Entity):

  PORTS = f'*IFC:{__name__}.TestIfc.IPORT, A'

  @X.hdl_process(sens='+IFC.CLK')
  def run(self):
    if IFC.RST_N != 1:
      IFC.X = 1
      IFC.Y = 0
      IFC.XOUT = 0
    else:
      IFC.XOUT = A * IFC.X + IFC.Y - IFC.an_int
      IFC.X += 1
      IFC.Y += 2


class IfcTest(X.Entity):

  PORTS = 'CLK, RST_N, A, =XOUT'

  @X.hdl_process(kind=X.ROOT_PROCESS)
  def root(self):
    self.ifc = TestIfc(CLK, RST_N, XOUT,
                       an_int=17)

    IfcEnt(IFC=self.ifc,
           A=A)

The generated code is mapped to the following VHDL one:

architecture behavior of IfcTest is
  signal TEST_X : unsigned(15 downto 0);
  signal TEST_Y : unsigned(15 downto 0) := to_unsigned(0, 16);
begin
  IfcEnt_1 : entity IfcEnt
  port map (
    IFC_CLK => CLK,
    IFC_RST_N => RST_N,
    IFC_X => TEST_X,
    IFC_Y => TEST_Y,
    IFC_XOUT => XOUT,
    A => A
  );
end architecture;

architecture behavior of IfcEnt is
begin
  run : process (IFC_CLK)
  begin
    if rising_edge(IFC_CLK) then
      if IFC_RST_N /= '1' then
        IFC_X <= to_unsigned(1, 16);
        IFC_Y <= to_unsigned(0, 16);
        IFC_XOUT <= to_unsigned(0, 16);
      else
        IFC_XOUT <= (resize(A * IFC_X, 16) + IFC_Y) - 17;
        IFC_X <= IFC_X + 1;
        IFC_Y <= IFC_Y + 2;
      end if;
    end if;
  end process;
end architecture;

And the following Verilog code:

module IfcTest(CLK, RST_N, A, XOUT);
  input logic CLK;
  input logic RST_N;
  input logic [15: 0] A;
  output logic [15: 0] XOUT;
  logic [15: 0] TEST_X;
  logic [15: 0] TEST_Y = unsigned'(16'(0));
  IfcEnt IfcEnt_1(
    .IFC_CLK(CLK),
    .IFC_RST_N(RST_N),
    .IFC_X(TEST_X),
    .IFC_Y(TEST_Y),
    .IFC_XOUT(XOUT),
    .A(A)
  );
endmodule

module IfcEnt(IFC_CLK, IFC_RST_N, IFC_X, IFC_Y, IFC_XOUT, A);
  input logic IFC_CLK;
  input logic IFC_RST_N;
  inout logic [15: 0] IFC_X;
  inout logic [15: 0] IFC_Y;
  output logic [15: 0] IFC_XOUT;
  input logic [15: 0] A;
  always_ff @(posedge IFC_CLK)
  run : begin
    if (IFC_RST_N != unsigned'(1'(1))) begin
      IFC_X <= unsigned'(16'(1));
      IFC_Y <= unsigned'(16'(0));
      IFC_XOUT <= unsigned'(16'(0));
    end else begin
      IFC_XOUT <= (16'(A * IFC_X) + IFC_Y) - 17;
      IFC_X <= IFC_X + 1;
      IFC_Y <= IFC_Y + 2;
    end
  end
endmodule

Attributes

PyXHDL allows the user to specify the equivalent of VHDL and Verilog attributes, when creating new objects. Example:

class BlockRam(X.Entity):

  PORTS = 'CLK, RST_N, RDEN, WREN, ADDR, IN_DATA, =OUT_DATA'

  ARGS = dict(RAM_SIZE=None)

  # The "vhdl" and "verilog" entries are for backend-specific attributes, and do
  # not need to be present if empty. The "$common" ones will be used for all backends.
  RAM_ATTRIBUTES = {
    '$common': {
      'ram_style': 'block',
    },
    'vhdl': {
    },
    'verilog': {
    }
  }

  @X.hdl_process(sens='+CLK')
  def run(self):
    mem = X.mkreg(X.mkarray(IN_DATA.dtype, RAM_SIZE),
                  attributes=self.RAM_ATTRIBUTES)

    if not RST_N:
      OUT_DATA = 0
    else:
      if WREN:
        mem[ADDR] = IN_DATA
      elif RDEN:
        OUT_DATA = mem[ADDR]

The above Python code generates the following VHDL:

architecture behavior of BlockRam is
  signal mem : pyxhdl.bits_array1d(0 to 3071)(15 downto 0);
  attribute ram_style : string;
  attribute ram_style of mem : signal is "block";
begin
  run : process (CLK)
  begin
    if rising_edge(CLK) then
      if (not RST_N) /= '0' then
        OUT_DATA <= std_logic_vector(to_unsigned(0, 16));
      elsif WREN /= '0' then
        mem(to_integer(unsigned(ADDR))) <= IN_DATA;
      elsif RDEN /= '0' then
        OUT_DATA <= mem(to_integer(unsigned(ADDR)));
      end if;
    end if;
  end process;
end architecture;

And the following Verilog:

module BlockRam(CLK, RST_N, RDEN, WREN, ADDR, IN_DATA, OUT_DATA);
  input logic CLK;
  input logic RST_N;
  input logic RDEN;
  input logic WREN;
  input logic [11: 0] ADDR;
  input logic [15: 0] IN_DATA;
  output logic [15: 0] OUT_DATA;
  (* ram_style = "block" *)
  logic [15: 0] mem[3072];
  always_ff @(posedge CLK)
  run : begin
    if (&(!RST_N)) begin
      OUT_DATA <= unsigned'(16'(0));
    end else if (&WREN) begin
      mem[int'(ADDR)] <= IN_DATA;
    end else if (&RDEN) begin
      OUT_DATA <= mem[int'(ADDR)];
    end
  end
endmodule

Generating For AutoGenerated Code

It is possible to combine the flixibilty that Python offers as scripting language, with the code generation capabilities of PyXHDL to generate the Python code to be parsed.

import py_misc_utils.template_replace as pytr

import pyxhdl as X
from pyxhdl import xlib as XL

class And(X.Entity):

  PORTS = 'A, B, =XOUT'

  @X.hdl_process(kind=X.ROOT_PROCESS)
  def run():
    XOUT = A & B


_TEMPLATE = """
And(A=A[$i], B=B[$i], XOUT=XOUT[$i])
"""

class Ex3(X.Entity):

  PORTS = 'A, B, =XOUT'

  @X.hdl_process(kind=X.ROOT_PROCESS)
  def run():
    for i in range(A.dtype.nbits):
      code = pytr.template_replace(_TEMPLATE, vals=dict(i=i))
      XL.xexec(code)

Which generates the following Verilog code.

// Entity "Ex3" is "Ex3" with:
// 	args={'A': 'bits(4)', 'B': 'bits(4)', 'XOUT': 'bits(4)'}
// 	kwargs={}
module Ex3(A, B, XOUT);
  input logic [3: 0] A;
  input logic [3: 0] B;
  output logic [3: 0] XOUT;
  And And_1(
    .A(A[0]),
    .B(B[0]),
    .XOUT(XOUT[0])
  );
  And And_2(
    .A(A[1]),
    .B(B[1]),
    .XOUT(XOUT[1])
  );
  And And_3(
    .A(A[2]),
    .B(B[2]),
    .XOUT(XOUT[2])
  );
  And And_4(
    .A(A[3]),
    .B(B[3]),
    .XOUT(XOUT[3])
  );
endmodule
// Entity "And" is "And" with:
// 	args={'A': 'bits(1)', 'B': 'bits(1)', 'XOUT': 'bits(1)'}
// 	kwargs={}
module And(A, B, XOUT);
  input logic A;
  input logic B;
  output logic XOUT;
  assign XOUT = A & B;
endmodule

The above is only an example of using the XL.xexec() API, as that could have been simply done with a bare And entity instantiation loop (generating the exact same code).

import py_misc_utils.utils as pyu

import pyxhdl as X
from pyxhdl import xlib as XL

class And(X.Entity):

  PORTS = 'A, B, =XOUT'

  @X.hdl_process(kind=X.ROOT_PROCESS)
  def run():
    XOUT = A & B


class Ex3(X.Entity):

  PORTS = 'A, B, =XOUT'

  @X.hdl_process(kind=X.ROOT_PROCESS)
  def run():
    for i in range(A.dtype.nbits):
      And(A=A[i], B=B[i], XOUT=XOUT[i])

Loading External Libraries

PyXHDL loads its support libraries from the package hdl_libs/{BACKEND} folder, according to the LIBS manifest file present within such folder.

On top of the required libraries, the user can also inject its owns, by the following means:

  • Define the environment variable PYXHDL_{BACKEND}_LIBS to contain a semicolon separated list of source files to load.

  • List the library files within the configuration file, under the libs.{BACKEND} key. Files listed there can be aither absolute paths, or paths relative to the configuration file path folder (see Mock Configuration).

    libs:
      vhdl:
        - LIBNAME
        - /PATH/TO/LIB
        - ...
      verilog:
        - ...

The specified libraries will be loaded from the PyXHDL hdl_libs/{BACKEND} folder, from one of the "lib_paths" configuration, or from one of the PYXHDL_{BACKEND}_LIBPATH (semicolon separated) environment variable.

It is also possible to register HDL specific modules at runtime using the XL.register_module() API.

Furthermore, although the Python functions marked with @X.hdl and @X.hdl_process(...) decorators lead to inlined HDL code, it is possible to define an inject HDL functions from within PyXHDL and use them as normal Python functions.

See the example below that shows how to use XL.register_module() and XL.create_function() to register HDL specific code to be used within PyXHDL.

MY_VHDL_MODULE = """
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

package mypkg is
  function func(a : in unsigned; b : in unsigned) return unsigned;
  procedure proc(a : in unsigned; b : in unsigned);
end package;

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

package body mypkg is
  function func(a : in unsigned; b : in unsigned) return unsigned is
  begin
    return a + b;
  end function;

  procedure proc(a : in unsigned; b : in unsigned) is
  begin
    assert a > b report "Compare failed!" severity error;
  end procedure;
end package body;
"""

def not_in_global_context(...):
  # If XL.register_module() is called from a global context (or more in general,
  # when no CodeGen context are active), it will register modules globally.
  # Register globally here means that running multiple generations from within the
  # same Python process will find the module regsitered.
  # Otherwise the module will be registered within the active CodeGen context.
  # For anyone using the `generator` module to emit HDL code (which will be the
  # majority), there will be no difference between global and context-local registrations.
  XL.register_module('mypkg', {'vhdl': MY_VHDL_MODULE})

  # Note that the 'mypkg' argument to *XL.register_module()* is just a unique ID
  # (further registrations with such ID will override the previous ones) which does
  # not have to match the HDL package/module name (though it likely helps if it does
  # resemble it).

# Versus ...
# This will register globally. This and not_in_global_context() should not be used at
# the same time.
XL.register_module('mypkg', {'vhdl': MY_VHDL_MODULE})

# Then it is possible to define Python functions using them, like describe below.
# Note that we we did not register the Verilog variant with the XL.register_module()
# above, so any attempt to use the APIs defined below while selecting a Verilog
# backend will fail.
my_func = XL.create_function('my_func',
                             {
                               'vhdl': 'mypkg.func',
                               'verilog: 'mypkg.func',
                             },
                             fnsig='u*, u*',
                             dtype=XL.argn_dtype(0))
my_proc = XL.create_function('my_proc',
                             {
                               'vhdl': 'mypkg.proc',
                               'verilog: 'mypkg.proc',
                             },
                             fnsig='u*, u*')

@X.hdl
def using_userdef(a, b):
  c = my_func(a + b, b - a)
  my_proc(a, c * 17)

Note that calls to my_func() and my_proc() will emit a function/procedure call in the target HDL backend, and will not get inlined (though at the end they will, by the backend HDL compiler in order to generate the matching HW).

The dtype argument to XL.create_function() can be a direct X.Type object, or a lambda to which the arguments of a call are passed, and has to return the proper type. In the above example, the utility XL.argn_dtype() is used, to return a lambda which returns the type of the 0-th (first) argument.

Placing External Modules/Entities

It is also possible to create entities class which are not defined within the PyXHDL Python framework, but which instead refers to external entities (IP blocks). This is accomplished by simply defining an X.Entity subclass with the NAME class property specified (this is the full entity path, included the package scope, if any).

class ExternIP(X.Entity):

  PORTS = 'IN_A:u*, IN_B:u*, =OUT_DATA:u*'
  NAME = 'extern_pkg.Entity'
  LIBNAME = 'my_extern_pkg'

After that it can be instantiated like a normal PyXHDL entity.

# To be called from a root process.
ExternIP(IN_A=A,
         IN_B=B,
         OUT_DATA=XOUT,
         _P=dict(NBITS=A.dtype.nbits,
                 SIGNED=1))

The extern_pkg.Entity will have to be defined in backend specific library files to be loaded within PyXHDL (see Loading External Libraries). The LIBNAME setting is optional in case the library containing such entity is force loaded (not on demand).

The _P argument allow to specify module/entity instantiation parameters/generics.

The XL.ExternalEntity() class should be used from the root process of a module or entity, otherwise the generated code will not be valid.

Note that modules/entities defined with Python using PyXHDL can be instatiated by simply constructing an object of that class from within a root process:

@X.hdl_process(kind=X.ROOT_PROCESS)
def root():
  OOUT = X.mkwire(XOUT.dtype)
  # AndGate defined within the PyXHDL Python framework.
  AndGate(A=A,
          B=B,
          XOUT=OOUT)

Code Generation

In order to generate code for the target backend, the generator module is used, like in the following example:

$ python -m pyxhdl.generator \
    --input_file src/my_entity.py \
    --entity MyEntity \
    --backend vhdl \
    --inputs 'CLK,RESET,READY=mkwire(BIT)' \
    --inputs 'A,B,XOUT=mkwire(UINT8)' \
    --kwargs 'mode="simple"' \
    --kwargs 'steps=8' \
    --output_file my_entity.vhd

The --input_file argument specifies the path to the Python file defining the root entity, while the --entity sets the name of the root entity itself.

The example above specifies the CLK, RESET and READY ports of the root entity to be one bit, while the A, B and XOUT ones to be 8bit unsigned integers.

It is also possible to pass keyword arguments to the entity, allowing runtime configuration similar to what VHDL generics do (note that string inputs should be quoted). This requires the user inherited Entity to specify the keyword arguments within the ARGS class variable of the new entity.

TestBench Code Generation

Using the same generator module, it is possible to generate a testbench feeding the generator itself with the input data (YAML or JSON) to be used for the test.

$ python -m pyxhdl.generator \
    --input_file src/my_entity.py \
    --entity MyEntity \
    --backend vhdl \
    --inputs 'CLK,RESET,READY=mkvreg(BIT, 0)' \
    --inputs 'A,B,XOUT=mkvreg(UINT8, 0)' \
    --kwargs 'mode="simple"' \
    --kwargs 'steps=8' \
    --output_file my_entity_tb.vhd \
    --testbench \
    --tb_input_file test/my_entity_input_data.yaml \
    --tb_clock 'CLK,10ns'

The --testbench argument triggers the testbench module code generation.

The --tb_input_file points to the input data file for the test (both YAML and JSON are supported), which has the following format:

env:
  ENV_VAR: WAIT_MODE
conf:
  loaders:
    - A:
        dtype: uint8
        kind: numpy
    - B:
        dtype: uint8
        kind: numpy
data:
  - RESET: 1
    _wait_expr: XL.wait_rising(CLK)
  - RESET: 0
    _wait_expr: XL.wait_rising(CLK)
  - A: 17
    B: 21
    XOUT: 134
    _wait_expr: XL.wait_rising(CLK)
  - A: 3
    B: 11
    XOUT: 77
    _wait_expr: XL.wait_rising(CLK)
  - ...

The loaders section of the input data is optional, and if missing the input types will be the ones created by the YAML (or JSON) parser.

The testbench works by iterating the data section, setting the inputs to the specified values, waiting according to the _wait_expr rule (see below for more options), and comparing the outputs of the module/entity with the expected values specified by the data.

The _wait_expr can be any Python code, and can also be multiline, by using the pipe ("|") YAML separator. It needs to be properly indented though, example:

  - A: 17
    B: 21
    XOUT: 134
    _wait_expr: |
      if ENV_VAR == 'WAIT_MODE':
        XL.wait_rising(CLK)

Where the value of ENV_VAR comes from the env section of the --tb_input_file configuration.

The wait condition can also be specified in the command line, using the --tb_wait argument. The --tb_wait specifies a wait time in nanoseconds. In case the --tb_wait option is used in the command line, there is no need for _wait_expr entries in the data.

Via the --tb_clock_sync argument it is also possible to configure a different wait rule, specifying the clock port name and the sync mode. Example:

--tb_clock_sync 'CLK,rising'

In such cases, there is no need for the explicit _wait_expr in the input data at all.

The --tb_clock enables the generation of a clock signal, on the specified port. For example, to generate a 10ns period clock signal on the CLK port:

--tb_clock 'CLK,10ns'

A simpler version of the run above, using command line specified wait/sync could be:

$ python -m pyxhdl.generator \
    --input_file src/my_entity.py \
    --entity MyEntity \
    --backend vhdl \
    --inputs 'CLK,RESET,READY=mkvreg(BIT, 0)' \
    --inputs 'A,B,XOUT=mkvreg(UINT8, 0)' \
    --kwargs 'mode="simple"' \
    --kwargs 'steps=8' \
    --output_file my_entity_tb.vhd \
    --testbench \
    --tb_input_file test/my_entity_input_data.yaml \
    --tb_clock 'CLK,10ns' \
    --tb_clock_sync 'CLK,rising'

With data:

data:
  - RESET: 1
  - RESET: 0
  - A: 17
    B: 21
    XOUT: 134
  - A: 3
    B: 11
    XOUT: 77
  - ...

Essentially the testbench iterates through each data entry, feed the tested entity input ports with the data specified in the current entry (if an entry does not contain data for an input, such input is not changed from the previous one), wait according to the specified rules, and then compares the ouput ports of the tested entity with the matching data in the current entry. Same as the inputs, if the current entry does not contain data for a given output port, nothing is compared for that port.

So, taking as example the above test data, the testbench will generate HDL code to:

  • Set the RESET input to 1, and wait for the CLK rising edge (according to the --tb_clock_sync command line argument).

  • Set the RESET input to 0, and wait for the CLK rising edge.

  • Set the tested entity input ports A and B to 17 and 21 respectively, wait for the CLK rising edge, and then compare the XOUT ouput port with 134.

  • Set the tested entity input ports A and B to 3 and 11 respectively, wait for the CLK rising edge, and then compare the XOUT ouput port with 77.

  • ...

The --tb_input_file argument can also point to a Python file, implementing a tb_iterator() API, returning a Python iterator yielding TbData structures. For a full example, see UART TB Generator.

Less Used Features

Below are briefly illustrated some less common features which are supported, with the generated VHDL code letting explain their purpose:

# 1
XL.wait_until(A == 1)

# 2
with XL.context(delay=10):
  ctx = A * B

Generated VHDL code assuming A and B being an X.UINT8 and CLK an X.BIT:

-- 1
wait until (A = to_unsigned(1, 8));

-- 2
ctx <= resize(A * B, 8) after 10 ns;

It is possible, within an HDL function, to disable to Python to HDL rewrite by using the XL.no_hdl() Python context manager:

@X.hdl
def my_hdl_function(a, b):
  c = a + b
  with XL.no_hdl():
    # Some code with HDL remapping disabled...
    ...

  return c * b

Verifying Generated HDL Output

A script is provided to verify the output generated by PyXHDL.

It can be used to verify both VHDL (with GHDL, Vivado and YoSys/GHDL) and Verilog (Vivado, Verilator, SLANG and YoSys/SLANG).

Example use to verify a generated VHDL file generate_output.vhd with a RootEntity top:

$ python3 -m pyxhdl.tools.verify --inputs generated_output.vhd --backend vhdl --entity RootEntity

About

Python Frontend For VHDL And Verilog

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published