Using the SPI library for AD5686R DAC with Teensy 4.1

SethGo

Member
Hello friends! I am looking for help figuring out how to use an AD5686R DAC chip with Teensy 4.1

Some details about the project:
This is for a hardware synthesizer instrument that has 4 analog oscillators, each of which are independently quantized up to an 11 octave range with at least +/- 1 cent accuracy. Based on button and knob interactions, the Teensy will tell a DAC exactly what pitch CV (DC voltage) to apply to each of the 4 oscillators. The final output range needs to be 0V -11V, with minimum +/- 0.83mv accuracy. If you do the math you’ll see I could probably get away with a 14-bit DAC but opted for the 16-bit so that I could correct for any linearity error that the extremes of the output range may produce. It is important to the project goals that the pitch control be extremely accurate.

The chip:
https://www.analog.com/media/en/technical-documentation/data-sheets/ad5686r_5685r_5684r.pdf
I selected this one because compared to other options:
- it seemed like a good price per channel
- pretty good linearity
- convenience, it has all 4 channels at the bit depth I want in one tiny package
- it is currently available

I got the “R” variant with onboard Vref, because at the time of ordering I think I was confused about what the Vref was for, so I thought I needed it. Do I? Please let me know your thoughts on this chip or if there are others that have worked well in your projects. I’m not married to it, just got a couple to prototype to see how I like them. All I need are 4 channels of HIGHLY accurate and stable DC.

Pin connections:
DAC pin-1, Vref —> NC (putting out 2.5v)
DAC pin-2, VoutB —> NC
DAC pin-3, VoutA —> connected to the multimeter
DAC pin-4, GND —> Teensy GND
DAC pin-5, Vdd —> Teensy +5.5V
DAC pin-6, VoutC —> NC
DAC pin-7, VoutD —> NC
DAC pin-8, SDO —> NC
DAC pin-9, LDAC (latch) —> Teensy pin-0, set LOW
DAC pin-10, Gain —> Teensy GND
DAC pin-11, Vlogic —> Teensy +3.3V
DAC pin-12, SCLK —> Teensy “SCK”, pin-13
DAC pin-13, SYNC —> Teensy “CS”, pin-10, set HIGH initially
DAC pin-14, SDIN —> Teensy “MOSI”, pin-11
DAC pin-15, RESET —> NC
DAC pin-16, RSTSEL —> Teensy GND
Teensy powered via USB

Some nomenclature assumptions I’ve made:
- AD5686R’s “SYNC” should connect to Teensy’s “CS”
- AD5686R’s “SDIN” should connect to Teensy’s “MOSI”
- AD5686R’s “SCLK” should connect to Teensy’s “SCK”

Code:
#include <SPI.h>

const int ldac = 0;
const int slaveSelect = SS; // 10 (teensy 'CS' connected to AD5686R 'SYNC')

void setup()
{
  pinMode(ldac, OUTPUT);
  pinMode(slaveSelect, OUTPUT);
  digitalWrite(ldac, LOW);
  digitalWrite(slaveSelect, HIGH);
  SPI.begin();
  delay(1000);
}

void loop()
{
  // Setting all channels to ~middle of range
  Serial.println("Setting to middle");
  for (int i = 0; i < 4; i++)
  {
    setDacChannelLevel(i, 35532);
  }
  delay(1000);

  // Setting all channels to ~bottom of range
  Serial.println("Setting to low");
  for (int i = 0; i < 4; i++)
  {
    setDacChannelLevel(i, 24);
  }
  delay(1000);
}

int setDacChannelLevel(int address, int value)
{
  digitalWrite(slaveSelect, LOW);
  SPI.transfer(address);
  SPI.transfer(value);
  digitalWrite(slaveSelect, HIGH);
}


I found a few tutorials that indicate I can use the SPI library like I have in the code above, maybe there is a better way? Right now all I’m trying to do is get all channels to alternate its output level from approximately low to medium parts of the 16-bit range in a slow loop. If I can do that I think I can figure out the rest of what I need to do. Currently I am getting no voltage at all from any of the 4 channels.

My questions:
- Why does my setup not work?
- Does my DAC choice make sense for my use case?
 
Are you using the right SPI mode? That chip wants SCLK to be HIGH on idle, with data writes on SCLK rising edge and reads on falling edge.
 
Are you using the right SPI mode? That chip wants SCLK to be HIGH on idle, with data writes on SCLK rising edge and reads on falling edge.

Yes I see what you are saying! I believe this means I need SPI_MODE3

I tried setting this mode via a call to SPI.beginTransaction() in the setup method, so now it looks like this:

Code:
void setup()
{
  pinMode(ldac, OUTPUT);
  pinMode(slaveSelect, OUTPUT);
  digitalWrite(ldac, LOW);
  digitalWrite(slaveSelect, HIGH);
  SPI.begin();
  SPI.beginTransaction(SPISettings(50000000, MSBFIRST, SPI_MODE3));
  delay(1000);
}

50000000 because the chip is rated at 50Mhz

For the settings, I tested all 8 combinations of (MSBFIRST | LSBFIRST) & (SPI_MODE0 | SPI_MODE1 | SPI_MODE2 | SPI_MODE3)... none worked. So I think you're right that I was using the wrong SPI mode, but that must not be the only thing keeping it from working.
 
The reset pin is active low, it should be high for regular operation but you've got it listed as NC.
 
50MHz is very demanding, have you tried something like 10MHz in case its a signal integrity issue?
 
Your setup does not work because you are sending command 0000 which is "No operation". You need 0001 which is "Write to input Register".

So

Code:
int setDacChannelLevel(int address, int value)
{
  digitalWrite(slaveSelect, LOW);
  SPI.transfer(address + 16);
  SPI.transfer(value);
  digitalWrite(slaveSelect, HIGH);
}

Given that you are powering the DAC from 5V5 you might as well tie the Gain pin to +3V3, giving a 2x gain, so the output is 0V to 5V instead of 0V to 2V5. Although for a range of 11V you presumably have some buffer amp with gain after the DAC.
 
In terms of accuracy, it depends whether you got the A grade or the more expensive B grade. For A, the relative accuracy is ±2 LSB typical, ±8 LSB max. Assuming you have a gain stage that takes the 2V5 or 5V0 DAC output to 11V, then 1 LSB is 11 / 2^16 = 168μV = 0.2 cents (1200 cents to 1 volt). So your worst case mid-scale accuracy will be ±1.6 cents. For the B grade, that reduces to ±1 LSB typical, ±2 LSB max so worst case ±0.4 cents which is under your error budget of 1 cent.

It gets worse at the extremes though, see the data sheet for zero-code error (max 4mV for A grade, which is 4.8 cents; 1.5mV for B grade, which is 1.8 cents) and also Full-Scale error (±0.2% of FSR for A grade, so 22mV or 26.4 cents, and ±0.1% of FSR for A grade, so 11mV or 13.2 cents).

You can get around the scary Full-Scale error by loosing the top few DAC values; increase the gain slightly so that the max DAC output gives a bit more than 11V and note which DAC value gives exactly 11V. For a unipolar DAC, sadly there is not much you can do about the Zero-Code error; adding a trimmer and a mixing stage likely introduces more error from resistor matching and tempco than the small zero error you would be trying to fix.

Note that the pairs of gain-setting resistors in your output gain stage need to be low tolerance, well matched and low ppm tempco, to meet your error budget.
 
Nantonos
In terms of accuracy, it depends whether you got the A grade or the more expensive B grade. For A, the relative accuracy is ±2 LSB typical, ±8 LSB max. Assuming you have a gain stage that takes the 2V5 or 5V0 DAC output to 11V, then 1 LSB is 11 / 2^16 = 168μV = 0.2 cents (1200 cents to 1 volt). So your worst case mid-scale accuracy will be ±1.6 cents. For the B grade, that reduces to ±1 LSB typical, ±2 LSB max so worst case ±0.4 cents which is under your error budget of 1 cent.

It gets worse at the extremes though, see the data sheet for zero-code error (max 4mV for A grade, which is 4.8 cents; 1.5mV for B grade, which is 1.8 cents) and also Full-Scale error (±0.2% of FSR for A grade, so 22mV or 26.4 cents, and ±0.1% of FSR for A grade, so 11mV or 13.2 cents).

You can get around the scary Full-Scale error by loosing the top few DAC values; increase the gain slightly so that the max DAC output gives a bit more than 11V and note which DAC value gives exactly 11V. For a unipolar DAC, sadly there is not much you can do about the Zero-Code error; adding a trimmer and a mixing stage likely introduces more error from resistor matching and tempco than the small zero error you would be trying to fix.

Note that the pairs of gain-setting resistors in your output gain stage need to be low tolerance, well matched and low ppm tempco, to meet your error budget.

Thank you for these details and suggestions. Turns out I have the B grade!

PaulStoffregen
50 MHz is also 20 MHz more than the maximum NXP's datasheet says to use.
MarkT
50MHz is very demanding, have you tried something like 10MHz in case its a signal integrity issue?

I have changed it to 10Mhz for now. Thanks for pointing out the NXP max, Paul

Nantonos
Your setup does not work because you are sending command 0000 which is "No operation". You need 0001 which is "Write to input Register".

So

Code:
int setDacChannelLevel(int address, int value)
{
digitalWrite(slaveSelect, LOW);
SPI.transfer(address + 16);
SPI.transfer(value);
digitalWrite(slaveSelect, HIGH);
}
Given that you are powering the DAC from 5V5 you might as well tie the Gain pin to +3V3, giving a 2x gain, so the output is 0V to 5V instead of 0V to 2V5. Although for a range of 11V you presumably have some buffer amp with gain after the DAC.

This part still eludes me. So I can pass an int and it is interpreted as byte code? I see there is the SPI.transfer16(val16) method available too. Since it's a 16 bit DAC should I be using this instead? I updated my code to include the + 16 but it still does not work. Is there another type of data I might try passing? I was messing around with binary and hexadecimal and a few others, but nothing has worked yet.

jmarsh
The reset pin is active low, it should be high for regular operation but you've got it listed as NC.

I now have:
DAC pin-15, RESET —> Teensy pin-1, set HIGH

Unfortunately, still not working. I tried LOW, tried pulsing at time of update HIGH to LOW and vice versa.


Thanks for all the advice! I just did a sanity check and made sure all the pins are well soldered and no shorts. Everything's fine there, so for now I will keep messing with it...
 
SPI.transfer expects one byte (8 bits, uint8_t). To send a 16bit quantity you can send two bytes, MSB first.

I don't have the particular DAC you are using but I looked at a couple of sketches using other DACS to see what I did: AD5542, and LT1658C. Try this:

Code:
// Control AD5686R quad 16bit SPI DAC
// https://forum.pjrc.com/threads/72882-Using-the-SPI-library-for-AD5686R-DAC-with-Teensy-4-1

#include <SPI.h>

const int ldac = 0;
const int slaveSelect = SS; // 10 (teensy 'CS' connected to AD5686R 'SYNC')

SPISettings AD5686R(10000000, MSBFIRST, SPI_MODE3);

void setup()
{
  pinMode(ldac, OUTPUT);
  pinMode(slaveSelect, OUTPUT);
  digitalWrite(ldac, LOW);
  digitalWrite(slaveSelect, HIGH);
  SPI.begin();
  delay(1000);
}

void loop()
{
  // Setting all channels to ~middle of range
  Serial.println("Setting to middle");
  for (uint8_t i = 0; i < 4; i++)
  {
    setDacChannelLevel(i, 35532);
  }
  delay(1000);

  // Setting all channels to ~bottom of range
  Serial.println("Setting to low");
  for (uint8_t i = 0; i < 4; i++)
  {
    setDacChannelLevel(i, 24);
  }
  delay(1000);
}

void setDacChannelLevel(uint8_t address, uint16_t value)
{
  SPI.beginTransaction(AD5686R);
  digitalWrite(slaveSelect, LOW);
  SPI.transfer(address+16);   // command 1
  SPI.transfer(highByte(value));
  SPI.transfer(lowByte(value));
  digitalWrite(slaveSelect, HIGH);
  SPI.endTransaction();
}

Also you can just tie LDAC low, it doesn't need to connect to a pin as the value is never changed.
 
Unfortunately, this data type does not work. Still no output. Now it's getting confusing because that should be exactly what that transfer() method expects, right?
 
Hello,

the SPI Mode should be SPI_MODE1 or SPI_MODE2 (either should work), so either CPOL=0 AND CPHA=1 or CPOL=1 AND CPHA=0 but not both 0 or both 1.

In the Datasheet they do not state if /RESET has an internal Pullup, so i would tie it with a 10k resistor to VLogic. (ah ok, i see, you solved that above in post #9)

Also it is generally good practice to put a bypass capacitor of at least 100nF or so at the VREF-Output to GND. Also the Chip itself (VDD and VLogic) should of course be bypassed properly (also with 100nF per pin close to the pins) to prevent it from glitching especially at higher SPI freqs.

And finally, if you're breadbording your Test-Setup (with flywires etc.), it might be helpful to put resistors of approx. 30 - 100 Ohm in line with SCK, MOSI and CS, to shave of the fast rise and fall times of the Teensy-GPIO a bit and thus prevent the signals from ringing to much.

Best regards
Neni
 
Also it is generally good practice to put a bypass capacitor of at least 100nF or so at the VREF-Output to GND. Also the Chip itself (VDD and VLogic) should of course be bypassed properly (also with 100nF per pin close to the pins) to prevent it from glitching especially at higher SPI freqs.
i

Oh, I considered asking about that but forgot. The datasheet, section Layout Guidelines, says there should be 10μF plus 100nF at the power pins (digital and analog). Those would be a low-ESR electrolytic and a C0G ceramic, typically.
 
C0G are available in small values only (for suitable small packages), decoupling MLCC's are normally X7R or similar as the stability and microphony is not usually an issue and these types are much smaller (far higher dielectric constant), being made from ferro-electric materials such as BaTiO4. The 10uF can be MLCC too if you want these days.
 
Hey it works! It was the mode, needs to be SPI_MODE1 or 2 as Synvox suggests (MarkT said it too!). And though I tried all the modes before, I think this was before I had tied the RESET pin to HIGH, an oversight on my part.

I understand mode 1 to be: data sampled on the falling edge and shifted out on the rising edge
and mode 2 to be: data sampled on the rising edge and shifted out on the falling edge

These two ideas seem logically opposed, if a device is setup to allow one of these modes to work, how could the other also work? Indeed as Synvox says, both modes work the same.. at least in my write-only use case, perhaps if I was also reading this would make a difference? Anyone able to clear this up?

...

I found that I needed to perform one more operation to the address variable to render the actual address of each DAC channel. I had to change the first SPI.transfer() call to this:

SPI.transfer(pow(2, address) + 16);

This means the uint8_t address values that refer to each channel are:
Ch A: 1 + 16
Ch B: 2 + 16
Ch C: 4 + 16
Ch D: 8 + 16

which is:
Ch A: 17
Ch B: 18
Ch C: 20
Ch D: 24

Which, when you Serial.print(number, BIN) each of those, you get:
Ch A: 10001
Ch B: 10010
Ch C: 10100
Ch D: 11000

The last four digits in the binary serial output are the channel code I’m pretty sure, but why is there an extra 1 at the beginning? Are leading zeros chopped off and the 1 at the beginning is what’s left of the first 0001 (C3, C2, C1, C0) command to write to the input register? If not, is that first 0001 command sent under the hood with the SPI lib in beginTransaction()?

To get the right DAC channel address, I needed to use the formula 2^n + 16. Why is it 2^n? Why is it + 16? I’m not able to make sense of this yet.

...

Regarding SPI speed, is there some convention to apply here in choosing a speed? My users will likely not appreciate the difference between 10MHz and 30MHZ, so in the interest of signal integrity, should I normally opt for a speed well under device maximums?

...

Here is the minimum code and pin connections to get this DAC working with a Teensy, hopefully others will find the reference useful:
Code:
#include <SPI.h>

// Pin configuration for AD5686R DAC and Teensy 4.1
// DAC pin-1, Vref —> NC (putting out 2.5v)
// DAC pin-2, VoutB —> NC
// DAC pin-3, VoutA —> NC
// DAC pin-4, GND —> GND
// DAC pin-5, Vdd —> Teensy +5.5V 
// DAC pin-6, VoutC —> NC
// DAC pin-7, VoutD —> NC
// DAC pin-8, SDO —> NC
// DAC pin-9, LDAC -> GND
// DAC pin-10, Gain —> GND
// DAC pin-11, Vlogic —> Teensy +3.3V
// DAC pin-12, SCLK —> Teensy “SCK”, pin-13
// DAC pin-13, SYNC —> Teensy “CS”, pin-10
// DAC pin-14, SDIN —> Teensy “MOSI”, pin-11
// DAC pin-15, RESET —> tied HIGH (Vlogic)
// DAC pin-16, RSTSEL —> GND

const int slaveSelect = SS; // 10 (teensy 'CS' connected to AD5686R 'SYNC')

SPISettings AD5686R(10000000, MSBFIRST, SPI_MODE1);

void setup()
{
  pinMode(slaveSelect, OUTPUT);
  digitalWrite(slaveSelect, HIGH);
  SPI.begin();
  delay(1000);
}

void loop()
{
  // Setting all channels to ~middle of range for 1 sec
  Serial.println("Setting to middle");
  for (uint8_t i = 0; i < 4; i++)
  {
    setDacChannelLevel(i, 35532);
  }
  delay(1000);

  // Setting all channels to ~bottom of range for 1 sec
  Serial.println("Setting to low");
  for (uint8_t i = 0; i < 4; i++)
  {
    setDacChannelLevel(i, 24);
  }
  delay(1000);
}

void setDacChannelLevel(uint8_t address, uint16_t value)
{
  SPI.beginTransaction(AD5686R);
  digitalWrite(slaveSelect, LOW);
  SPI.transfer(pow(2, address) + 16);
  SPI.transfer(highByte(value));
  SPI.transfer(lowByte(value));
  digitalWrite(slaveSelect, HIGH);
  SPI.endTransaction();
}

Thanks to all who have contributed!
 
I understand mode 1 to be: data sampled on the falling edge and shifted out on the rising edge
and mode 2 to be: data sampled on the rising edge and shifted out on the falling edge
No, you're wrong. In both modes data is sampled on the falling and shifted out on the rising edge. That's the reason why both modes work. The only difference between SPI_MODE1 and SPI_MODE2 ist the Clock Polarity at start and end of each transfer. In SPI_MODE1 it is LOW (0), and in SPI_MODE2 it is HIGH. In SPI the actual behaviour is the result of the combination of the two parameters "Clock polarity" (CPOL) and "Clock phase" (CPHA), where their values (0 or 1 for each) combined determine the SPI-mode (0 - 3). There's a good read on wikipedia to better understand the concept of SPI:https://en.wikipedia.org/wiki/Serial_Peripheral_Interface.

As for the address and command of the AD5686R you're sending them in your first SPI.transfer() command in the transaction each time. You're sending 3 x SPI.transfer(), so that's 3 x 8 Bit = 24 Bit. And the first transfer (the first 8 Bit) consicts of the 4 command Bits and the 4 address bits. The command bits need to be 0001. This not only writes to the corresponding input register but also updates the corresponding DAC register, when LDAC is permanently LOW (i.e. not used).
You question about the binary combination made me doubt a bit, if you really understand the concept of binary numbers, Bits and Bytes etc. Anyway, if you read the first 8 Bits (command and address) "C3 C2 C1 C0 DACD DACC DACB DACA" as one 8 Bit binary number, you should be able to uderstand the numbers. The 4 DACs each have their individual Bit, so you could thoretically send one value to all 4 of them in one transaction by sending the value 15 (1111 in binary) as the address. You still need to add C0 as 1 to the whole 8 Bit value. And since C0 is Bit 4 from the right (when you start with Bit 0), you have to add 2^4=16 to the value in order for C0 to be 1. So to write to all 4 DACs simultaneously, you would be sending a value 0f 16+15=31 as the first Byte (8 Bit) of the transaction. This simultaneous write to all DACs may come in handy, if you want to set them all together to the same value like 0 or midpoint or whatever (like for initialization or calibration etc.), because it saves you some transaction time in such a case.

Finally, for the SPI speed i would recommend to go as slow, as is feasible for your application/program while still not stalling the CPU too much (it's basically blocked until a transfer is finished). 10 MHz or even a bit slower like 8 MHz or so is a good compromise i think, and the signal integrity will thank you.

I hope that i could remedy some of your confusion.

Best regards
Neni
 
And actually you don't need to use the "power of"- function to achieve just a 2^n result. You can also just left shift a value of 1 by n; that gives you the same result but is much faster.
Code:
void setDacChannelLevel(uint8_t address, uint16_t value)
{
  SPI.beginTransaction(AD5686R);
  digitalWrite(slaveSelect, LOW);
  SPI.transfer((1 << address) + 16);
  SPI.transfer(highByte(value));
  SPI.transfer(lowByte(value));
  digitalWrite(slaveSelect, HIGH);
  SPI.endTransaction();
}

But if you would like to sometimes write the same value to more than one DAC simultaneously, you would have to rethink and rewrite your setDacChannelLevel()-Function anyway.
 
"int(pow(2, x))" in general doesn't equal a power of two due to floating point rounding errors and integer truncation....

"1<<x" is much simpler and will be correct.

or at least use "round(pow(2, x))" so its rounded to the nearest integer, not truncated.
 
Yes all is cleared up now. Synvox your explanation helped a lot. Though I will leave the code as is, it makes more sense to me to think of it this way:

Code:
uint8_t writeCommand = 16;
SPI.transfer((1 << address) + writeCommand);


I will be using the bit shifting SPI.transfer((1 << address) + 16); as well. MarkT, thanks for pointing out integer truncation issues with pow().

I think the crux of my confusion with regards to what was being sent in that first transfer was around the fact that Serial.print will leave off all the leading 0’s when it prints something as a binary. It was also helpful to look at a chart with binaries next to the ints they represent. https://web.cecs.pdx.edu/~harry/compilers/ASCIIChart.pdf
 
Last edited:
Back
Top