Workshop 4 of the Summer of FPGA series for the Ultra96-V2 board went through a design for using the Mezzanine Click board. It was covered 'at pace' and this blog is my jottings on the steps taken, specifically elaborating on creating and using a PWM custom IP. The video covered how to do an initial creation of a new IP but then copied pre-made files to provide the implementation. I wanted to understand how this process would work from scratch: please bear in mind that I've been involved with FPGAs, the Ultra96-V2 and the Xilinx toolsets for all of 4 weeks at the time of writing this and I'm still learning this stuff myself.
You should watch the Video associated with this workshop and read this blog at the appropriate point as I won't be re-creating pre- or post- steps.
The steps I run through below were completed with Vivado 2021.1 on Ubuntu 20.04.
The PWM IP
The structure of the new IP is covered in the lab notes and I've taken the liberty of copying the block diagram below for reference:
Image copyright 2021, Avnet inc.
Note that this is an AXI-4 IP and provides for the following Inputs and Outputs:
- Reset: INPUT
- Clock: INPUT
- PWM out: OUTPUT - to be connected to the Mezzanine LCD click board
- Interrupt: OUTPUT - to indicate an invalid PWM setting
- Count: OUTPUT - PWM counter for debug
- Duty Cycle: OUTPUT - the duty cycle for debug
There are also 'internal' ports used:
- SLV_REG0: to transfer the duty cycle from the AXI Slave.
- Period: Actually, a parameter and not a Port but will be used to control the period for the PWM signal. The overall PWM width is equal to 1,000,000 clock cycles and this parameter is set at 20 to divide it into .
Creating the IP
From the Tools menu in Vivado, select Create and Package IP, then select to create an AXI4 Peripheral. This step is the same as that taken in the video with two exceptions, as I'll cover in a bit. Use the following parameters for setting the Peripheral Details (the first exception):
My projects live in a subdirectory called 'projects' and I'm creating re-usable IPs so they will be in a specific subdirectory for all projects: 'ip_repo'. Further, I've decided that each IP should be in its own sub directory because a number of files are created, as well as a, potentially long-lived, Vivado project. This will keep them self contained.
When you click on Next, you'll see this dialog which is the same as the video and the information given in that is still relevant.
The final step is also the second exception:
We are going to edit the IP immediately. In the video, the IP is added to the repository so that various files can be copied into place before the IP is opened for editing: that's a step we're skipping.
When you edit the IP, Vivado will create a new project for that IP if one doesn't already exist, and open it.
Note that it created two files for us and the astute will observe these match the names in the block diagram shown above; there's a third file mentioned in the block diagram which we'll come on to. All changes to the IP will be done in this Vivado project which, by default, will be deleted when the IP is packaged. That could be a bit of a pain, so make sure that this doesn't happen by changing this setting (and whilst your at it, make the changes to use VHDL if necessary):
Note that the project can always be deleted manually, it is stored in the same subdirectory as the IP. You can also use this project to open up the IP directly if you need to come back to it later on.
Coding the IP
I'm no expert at VHDL or Verilog so we will be using the code already created, but copying it into the files rather than copying the files themselves. I'm going to assume you have enough knowledge of VHDL and Verilog to understand the syntax and won't be explaining it. Refer back to the block diagram above as necessary. There are three files (only two created as yet):
- PWM_w_Int_v1_0.vhd: created automatically, this is the 'wrapper' or control for the code execution. It has no inputs and four outputs: PWM_out; Interrupt; Count; DutyCycle
- PWM_w_Int_v1_0_S00_AXI.vhd: created automatically, this is the AXI interface that provides a connection between PS and PL. It has quite a few inputs generated by the system to allow the AXI functionality to work, including a Clock and Reset; it has one output: SLV_REG0 which carries the duty cycle. Note that the Block Diagram implies that it is also an input and this is true in an indirect way. Whilst not actually defined as an input, the PS code will load a value into the IP's address space (actually its Base Address) which 'maps' to Slave Register 0. If you remember, when creating the IP you specified 4 registers to be used - these are called SLV_REG0..SLV_REG3.
- PWM_Controller_Int.v: created manually as a 'helper' to actually contain the code that drives the output pins. As far as I can tell, this code could just as easily have been written in VHDL in PWM_w_Int_v1_0.vhd but being no expert, we're going to keep the same structure. Note that this file is written in Verilog, not VHDL.
Let's add the new file.
Add a new source file to the project:
Right click and Add Sources, Create File and make sure you create it as a Verilog file with the name shown. When you click on Ok, you'll be invited to Define a Module for the file - this sets up the Ports and you can refer back to the Block Diagram:
Enter the values as I show EXCEPT use DutyCycle rather than Dutycycle as I did. If you missed that, don't worry, change it in the code when the file is created. We'll be coming back to adjust the count Port shortly, but 19:0 is fine for now. When you click Ok, the source file will be added into Design Sources - don't worry if the hierarchy structure doesn't look like that in the workshop, it will get sorted later. If you open it to edit, it should look like:
`timescale 1ns / 1ps ////////////////////////////////////////////////////////////////////////////////// // Company: // Engineer: // // Create Date: 08/27/2021 12:32:45 PM // Design Name: // Module Name: PWM_Controller_Int // Project Name: // Target Devices: // Tool Versions: // Description: // // Dependencies: // // Revision: // Revision 0.01 - File Created // Additional Comments: // ////////////////////////////////////////////////////////////////////////////////// module PWM_Controller_Int( input Clk, input [31:0] DutyCycle, input Reset, output [0:0] PWM_out, output Interrupt, output [19:0] count ); endmodule
Port count actually defines the width of the pulse and is intended to be parameterised but at the moment is a fixed size of 20 (19 down to 0). You may decided it's as easy to copy and paste the whole contents of the provided workshop file but I'm taking it step-by-step here. We need to add the parameter and change the definition of counter accordingly; There are also some other subtle changes incorporated so be careful:
`timescale 1ns / 1ps ////////////////////////////////////////////////////////////////////////////////// // Company: Avnet // Engineer: JLB // // Create Date: 09/10/2012 03:32:02 PM // Design Name: // Module Name: PWM_Controller_Int // Project Name: PWM Controller_Int // Target Devices: Any Xilinx FPGA // Tool Versions: Created in Vivado 2013.3 // Description: PWM Controller with Interrupt output to PS // Generates Interrupt when invalid PWM range is written into block. // // Dependencies: // // Revision: // Revision 0.01 - File Created // Additional Comments: // ////////////////////////////////////////////////////////////////////////////////// module PWM_Controller_Int #( parameter integer period = 20) ( input Clk, input [31:0] DutyCycle, input Reset, output reg [0:0] PWM_out, output reg Interrupt, output reg [period-1:0] count ); endmodule
I've added the header comments in as it is Avnet's code. The parameter is defined with a default value of 20 and count has been updated to use this value as its MSB (-1 otherwise there would be 21 bits in its size.) The output Ports have been defined as REG because Verilog requires this if they are updated in an @always block (coming up.)
We can now add in the logic which goes like this. If reset is active then any existing Port values are reset - note that Reset is active low. Otherwise, on every clock cycle:
- clock is incremented. Once it reaches its maximum value, it will 'overflow' back to zero.
- If the clock value is less than the requested duty cycle then the PWM Port output is high, otherwise it is turned/remains low. The duty cycle is passed in the PL from PS by a user entering a value between 0 and 9 with 0 being OFF and 9 being MAX. The period length is defined by the period parameter and represents 2^20 or approximately 1million, actually a bit more than that. The user entered value is rebased from 0 to 1000000 in the PS code by multiplying by 110000 so will be an actual value between 0 and 990000. Not quite 1 million which is not quite 2^20 but the clock speed is such that these discrepancies don't matter. You can see then that if, for example, the user entered 5 the duty cycle would be 550000 and whilst count < 550000 PWM will be HIGH; once count reaches 550000 PWM goes LOW and stays LOW until count overflows back to 0 and thus is once again less than duty cycle.
- If the user entered a value that created a duty cycle greater than 990000 then there is no change to the current PWM output and the interrupt Port is set. This will generate an interrupt for the PS which will then take corrective action.
Add the following code in between the end of the Port definitions and the end module statement:
output reg [period-1:0] count ); // Sets PWM Period. Must be calculated vs. input clk period. // For example, setting this to 20 will divide the input clock by 2^20, or 1 Million. // So a 50 MHz input clock will be divided by 1e6, thus this will have a period of 1/50 // reg [period-1:0] count; // reg [0:0] PWM_out; always @(posedge Clk) if (!Reset) count <= 0; else count <= count + 1; always @(posedge Clk) if (count < DutyCycle) PWM_out <= 1; else PWM_out <= 0; always @(posedge Clk) if (!Reset) Interrupt <= 0; else if (DutyCycle > 990000) Interrupt <= 1; else Interrupt <= 0; endmodule
Save the file and done with this one.
Updating the PWM_w_Int_v1_0_S00_AXI.vhd file
Open the source file so you can edit it; the first few lines should look like this:
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity PWM_w_Int_v1_0_S00_AXI is generic ( -- Users to add parameters here -- User parameters ends -- Do not modify the parameters beyond this line -- Width of S_AXI data bus C_S_AXI_DATA_WIDTH : integer := 32; -- Width of S_AXI address bus C_S_AXI_ADDR_WIDTH : integer := 4 ); port ( -- Users to add ports here -- User ports ends -- Do not modify the ports beyond this line -- Global Clock Signal S_AXI_ACLK : in std_logic; -- Global Reset Signal. This Signal is Active LOW S_AXI_ARESETN : in std_logic;
From the Block Diagram, the AXI module has an output called SLV_REG0 which is used to represent the Duty Cycle. In actual fact, this is a generated register name: when you created the IP you requested 4 registers, as the minimum, which were created as SLV_REG0..SLV_REG3. Check out lines 111 to 114 of the file:
signal slv_reg0 :std_logic_vector(C_S_AXI_DATA_WIDTH-1 downto 0); signal slv_reg1 :std_logic_vector(C_S_AXI_DATA_WIDTH-1 downto 0); signal slv_reg2 :std_logic_vector(C_S_AXI_DATA_WIDTH-1 downto 0); signal slv_reg3 :std_logic_vector(C_S_AXI_DATA_WIDTH-1 downto 0);
What that actually means is we can't actually call the Port SLV_REG0 so we use slave_reg0 instead. That port needs defining so in the code on line 19, after the --Users to add ports here comment, add this line:
port ( -- Users to add ports here slave_reg0: out std_logic_vector(C_S_AXI_DATA_WIDTH-1 downto 0); -- User ports ends -- Do not modify the ports beyond this line -- Global Clock Signal S_AXI_ACLK : in std_logic;
The port is actually loaded with the value of the slv_reg0 register so do that at line 123 just after the begin statement:
begin -- I/O Connections assignments slave_reg0 <= slv_reg0; S_AXI_AWREADY <= axi_awready; S_AXI_WREADY <= axi_wready;
That's the only changes for this file so save it. Now whenever slv_reg0 register changes, the output Port slave_reg0 will be updated with that change. You may be asking where is the input parameter to actually change that register? It's not needed in this implementation: the PS will load the duty cycle value directly into the base address of the IP which corresponds to slv_reg0.
Updating the PWM_w_Int_v1_0.vhd file
Open the source file so you can edit it; the first few lines should look like this.
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity PWM_w_Int_v1_0 is generic ( -- Users to add parameters here -- User parameters ends -- Do not modify the parameters beyond this line -- Parameters of Axi Slave Bus Interface S00_AXI C_S00_AXI_DATA_WIDTH : integer := 32; C_S00_AXI_ADDR_WIDTH : integer := 4 ); port ( -- Users to add ports here -- User ports ends -- Do not modify the ports beyond this line -- Ports of Axi Slave Bus Interface S00_AXI s00_axi_aclk : in std_logic; s00_axi_aresetn : in std_logic;
We need to add in the Parameters and Ports and there are placeholders at line 08 and 19. You can either type in the entries, or copy and paste from the workshop file but either way, it should end up looking like this:
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity PWM_w_Int_v1_0 is generic ( -- Users to add parameters here PWM_PERIOD : INTEGER := 20; -- User parameters ends -- Do not modify the parameters beyond this line -- Parameters of Axi Slave Bus Interface S00_AXI C_S00_AXI_DATA_WIDTH : integer := 32; C_S00_AXI_ADDR_WIDTH : integer := 4 ); port ( -- Users to add ports here PWM_out : OUT std_logic_vector(0 DOWNTO 0); Interrupt_Out : OUT std_logic; PWM_Counter : OUT std_logic_vector(PWM_PERIOD - 1 DOWNTO 0); DutyCycle : OUT std_logic_vector(31 DOWNTO 0); -- User ports ends
These match the parameter (generic in VHDL) and Ports shown in the Block Diagram. Note that the parameter PWM_PERIOD is used to define the width of the pulse by controlling the number of bits in PWM_Counter. Every clock cycle, PWM_Counter is incremented until it reaches the value of Duty Cycle at which point the pulse will be turned off. You'll see this in the code as we update it.
Scoot down to line 53, and then insert the following lines as 56 to 70, under -- component declaration:
COMPONENT PWM_Controller_Int IS GENERIC ( period : INTEGER := 20 ); PORT ( Clk : IN std_logic; DutyCycle : IN std_logic_vector(31 DOWNTO 0); Reset : IN std_logic; PWM_out : OUT std_logic_vector(0 DOWNTO 0); Interrupt : OUT std_logic; count : OUT std_logic_vector(period - 1 DOWNTO 0) ); END COMPONENT PWM_Controller_Int;
This defines a new component which is mapped to the PWM_Controller_Int module we created. Directly following this new component in the code is an existing component definition for the PWM_w_Int_v1_0_S00_AXI, now on line 72. Referring to the Block Diagram, you will note that it has an output port called SLV_REG0 so the definition here needs to be updated to reflect that. Add in a new line 78 so the code looks like this:
component PWM_w_Int_v1_0_S00_AXI is generic ( C_S_AXI_DATA_WIDTH: integer:= 32; C_S_AXI_ADDR_WIDTH: integer:= 4 ); port ( slave_reg0 : out std_logic_vector(C_S_AXI_DATA_WIDTH-1 downto 0); S_AXI_ACLK : in std_logic; S_AXI_ARESETN : in std_logic; S_AXI_AWADDR : in std_logic_vector(C_S_AXI_ADDR_WIDTH-1 downto 0);
Note that here the Port is actually called slave_reg0 as it can't take the same name as the register. Having set that up, we now need to load the signals with relevant values, remembering that there are 4 outputs all together. Scoot down to what is now line 101, and adjust the code to define a duty cycle signal and load it with the value from the slave_reg0 signal, in the code below lines 03 and 16:
end component PWM_w_Int_v1_0_S00_AXI; signal DutyCycle_int : std_logic_vector(31 downto 0); begin DutyCycle <= DutyCycle_int; -- Instantiation of Axi Bus Interface S00_AXI PWM_w_Int_v1_0_S00_AXI_inst : PWM_w_Int_v1_0_S00_AXI generic map ( C_S_AXI_DATA_WIDTH => C_S00_AXI_DATA_WIDTH, C_S_AXI_ADDR_WIDTH => C_S00_AXI_ADDR_WIDTH ) port map ( slave_reg0 => DutyCycle_int, S_AXI_ACLK => s00_axi_aclk, S_AXI_ARESETN => s00_axi_aresetn,
It's worth remembering here that VHDL, and Verilog, code runs concurrently unless wrapped in a Process statement. You can't read it sequentially like a typical program! So here, it may look like we're setting DutyCycle to be the value of signal DutyCycle_int before we've actually loaded it with the signal slave_reg0 but it doesn't work like that: as soon as DutyCycle_Int is assigned the signal slave_reg0, then DutyCycle is assigned the signal DutyCycle_int even though it looks like that opportunity has passed, but only if you read the code sequentially. Don't do that.
We can assign the signals to the Ports in the PWM_Controller_Int, which is the source, if you like, of the output ports on the Block Diagram. Scoot down to what is now line 140 and add this code in-between the Add user logic here comments:
PWM_Controller_Int_Inst : PWM_Controller_Int GENERIC MAP ( period => PWM_PERIOD ) PORT MAP ( Clk => s00_axi_aclk, DutyCycle => DutyCycle_int, Reset => s00_axi_aresetn, PWM_out => PWM_out, Interrupt => Interrupt_Out, count => PWM_Counter ); -- User logic ends
When you save the code, you'll notice that there is an adjustment to the Design Sources hierarchy such that the new file created is now indented under the PWM_w_Int_V1_0 file. This is because it is used within that file as a component and instantiated as PWM_Controller_Int_Inst.
Packaging the IP
We're done with defining the IP, now we need to package it for use. You can get to the Packaging Steps by double-clicking on the component.xml file:
The structure of Component.xml is an industry-standard way of representing IPs. The Green flags represent steps that are good; the greyed flags are those that need addressing.
First, run Synthesis to validate the design and when it completes there should be no errors - you'll need to address them first if necessary.
At this point, you can follow the workshop steps from synthesising the IP onwards - Step 40 on page 25 of the lab notes. Hopefully this has given you some insight into creating your own IP.
It's worth pointing out that having followed these steps, there is no problem with the makefile as has been alluded to in the workshop. Whatever the bug that was causing the issue has been resolved in this version of Vivado.