Direct Digital Synthesizers (DDS) are a key tool in software defined radios and digital communication systems as they provide a way in the digital domain to generate a complex signal that is also variable. While the theory behind the DDS is fairly straightforward, implementing one for the first time in an FPGA can be a bit challenging, which is why I wanted to create this project as a very simple example of how to take the Xilinx DDS Compiler IP and get it running in the programmable logic of the Ultra96 board.
Also referred to as numerically controlled oscillators (NCO), a DDS contains a lookup table for the data values of a sinusoid which takes in a given phase value and outputs the appropriate data/magnitude value for the sinusoid. This input value determines the frequency of the output waveform in that the smaller the value is, the slower the DDS steps through the sinusoid lookup table and the output waveform is lower in frequency. In contrast, the higher the input value, the faster the DDS steps through the lookup table and the higher frequency the output waveform is. This input value is commonly referred to as the tuning word, but in the Xilinx DDS Compiler IP, it is referred to as the phase increment.
As seen in the graphs above, the larger this phase increment value (∆θ) is, the faster the DDS steps around the unit circle representing a complex waveform. When M is doubled, the frequency of the resulting complex waveform is also doubled since it steps around the unit circle twice as fast. The data points in relation to phase values of this unit circle is what is stored in the lookup table of the DDS.
At this point we can see one of the main advantages of the DDS: we can quickly and smoothly change the frequency of the output waveform simply by varying the input value that tells the DDS how quickly to step through the lookup table (aka - how quickly to move around the unit circle).
The input phase increment value is continuously added to itself (A1 & D1) to generate each instantaneous value of the desired output waveform to get the appropriate data value/magnitude for that instantaneous phase value from the lookup table (T1).
To demonstrate the DDS and its ease in variation of the frequency of its output waveform, I decided a simple chirp waveform would be appropriate. A chirp is where a sinusoid starts at one frequency then linearly increases or decreases over a period of time (this is also sometimes referred to as a sweep).
I decided to do a simple chirp from 1MHz to 25MHz in 1MHz steps over a period of 26 microseconds (my fabric clock is 100MHz which is 10 nanoseconds per clock cycle, and I randomly chose to have the DDS Compiler to output each frequency for 1 microsecond just so it would be easily viewable in the logic analyzer window).
By recursively adding the phase increment value for 1MHz to itself then feeding that as the input to the DDS compiler, this achieves my chirp from 1MHz up to half of the fabric clock of the FPGA (to preserve the Nyquist rule when sampling in the ILA) in 1MHz steps. I chose to only go up to 25MHz so the whole chirp could fit on my screen at once for a screenshot, but my fabric clock is set to 100MHz so I could have gone up to 50MHz.
I calculated the phase increment values in column C for each of the output waveform frequencies in column B using the following equations from PG141:
I then converted the phase increment value in column C to hexadecimal to get rid of the decimal places since I'm writing this code in Verilog. I created column E and column F to show that the difference in phase increment did indeed result in the same hex value as for 1MHz.
Starting with the Vivado project I generated for the Ultra96 in one of my previous projects, there are three things that need to be added to the top level wrapper for this DDS chirp:
1 - The Xilinx DDS compiler.2 - Logic to interface with both the AXI Stream slave and master of the DDS. 3 - An integrated logic analyzer (ILA) IP to view the output waveform from the DDS.
Under the Flow Navigator column in Vivado, open the IP repository and search for 'DDS'. After double-clicking on the DDS Compiler IP when it comes up in the list in the IP repository, a dialog box will pop up. Click the button to 'Customize IP' and the configuration window for the DDS Compiler will come up.
In the first tab as shown above, all of the default settings can be left as is for our purposes here.
Under the second tab, select streaming for both the phase increment and offset programmability. I have found this makes the slave AXI Stream interface the simplest.
Also in relation to the AXI Stream interface of the DDS Compiler, under the Detailed Implementation tab, check the box to 'Output TREADY'. I've found that the TREADY signal is almost always a necessary signal when dealing with AXI Stream.
When adding the ILA, I added a total of 4 probes monitoring the input phase increment value on the slave interface of the DDS and the output data and phase value from the DDS on its master interface. I set the depth as large as the UltraScale chip on the Ultra96 could facilitate for my design which was 65536. I recommend always trying to set up your ILAs to capture as much data as possible.
With the ILA and DDS IP blocks instantiated, I wrote my own simple state machine to create an AXI Stream interface, input the phase increment value to the DDS, then wait 1 microsecond before adding the 1MHz step to the phase increment value and inputing it to the DDS. This state machine also kept count so that after it got to the phase increment value for 25MHz, it started back at 1MHz on the next iteration.
I've found this simple AXI Stream interface state machine to be quite handy in many different applications. The main logic steps are:1 - Set initial values.2 - Set the Tvalid signal high on the slave interface of the target IP.3 - Set the data value to input on the slave interface of the target IP (the phase increment value to the DDS Compiler).4 - Check the Tready signal from the slave interface of the target IP to verify it is ready for the next data value.
All of my files are in the linked Github repo, along with the full code for this state machine here.
Once a new bitstream is generated, power on the Ultra96 and connect to its JTAG port (you can do this with flying leads, but the JTAG to USB pod for the Ultra96 will make your life a whole lot easier).
From the Flow Navigator in Vivado, select Open Hardware Manager then select Open Target and the Autodetect option. Once the hardware manager has established connection with the Ultra96, select the option to Program Device and specify the new bitstream just created with the DDS logic.
After a successful flash, the ILA window will appear and if you click the instant capture button (the blue button with >> characters), you will see the chirp from the DDS cycling over and over.
The plot at the top of the ILA is the actual sinusoid waveform output from the DDS Compiler, the plot below is its instantaneous phase value. The third plot down is the phase increment value being input into the DDS Compiler.
The hex values at the bottom are just the state machine states to demonstrate how each state relates to the control of the DDS Compiler.
I hope this simple DDS Compiler example is helpful. If you're looking for some further reading, I have attached Xilinx's product guide for the DDS Compiler (PG141) that explains its theory on operation in depth.