Substituting for Teensy 3.6 DACs

StanfordEE

Well-known member
I have been working for some time on a low-cost, hobbyist-friendly substitute for the Teensy 3.6's beloved 12-bit DACS. Seeing that the MK66FX1M0VMD18 chip is available from NXP, Digikey or Mouser now, but not seeing anyone wanting to revive the Teensy 3.6, here we go…

These enable two very clean analog waveform synthesis channels and driving vector graphics displays (a former student of mine, Bill Esposito collaborated with the brilliant Ed Andrews, who designed a very nice vector display around the Teensy 3.6 that appeared in Nuts & Volts, November 2018). In both use-cases, how fast the DAC output swings (slew-rate, more below) is critical.

https://www.nutsvolts.com/magazine/article/tgi-the-teensy-graphics-interface

Typically, DACs integrated on to microcontrollers are not very good for reasons such as noise coupled through the chip substrate, and not often more than 8-bit resolution (not to be confused with actual performance, such Equivalent Number of Bits [ENOB], distortion, etc.).

The Teensy 3.6 remains, to my knowledge the best widely-available board with two quality DACs (testing recent DAC-equipped Arduinos and ESP-32's was disappointing, for example).

So… After much looking, the MicroChip MCP4822 dual, SPI I/O, voltage-output DAC looks like the best we can do now for a sub-$10, "available in DIP," single-chip DAC. It contains an onboard reference, can cover 0 to 2.048 or 0 to 4.096V outputs (noting that the T3.6 DAC was 0 to 3.3V), the latter only if powered from +5V.

For initial testing, to save both SPI ports, we used shiftOut(), which is limited in frequency, but can still keep up with the 200kSPS sample rate set using intervalTimer(). Of course one could get faster sample rates using hardware SPI and the MCP4822 can be clocked at 20MHz for a theoretical maximum sample rate above 600kSPS.

The code shown here is rough, but shows the pin connections and will work with the MCP4822 and Teensy 3.6 for direct comparison. It also contains helpers for the much faster, single-channel AD5452, which can support sample rates of around 2.5MSPS, but is more expensive, not available in DIP and requires one chip per channel.

The results showing a 10kHz output signal, where second and third harmonics would be within the output filter's pass-band (assuming we use, as is typical, about 60% of the frequency range to the Nyquist frequency, or half the sample rate). The Teensy 3.6 DACs won hands-down, with a spur-free dynamic range (SFDR) of 54.4 dB versus the MCP8422's 44.3 dB. Still, the MCP4822 is a very available, very good chip! Note here that the testing was done on a higher-end 12-bit scope, otherwise it would be very silly to try to characterize 12-bit DACs with the usual cheap 8-bit scopes, which often barely deliver 6.5 real bits of quality! Always beware!)

While I will save the details for a PDF tutorial on this, the key limiter for the MCP4822 is the output slew rate, or how fast the voltage can swing. It is not specified for how slow it *could* be, but is listed as 0.55 V/us "typical." The minimum specified slew rate for the Teensy 3.6 DACs is 1.2 V/us. This pretty much explains what is seen in the results.

In any case, this post should help folks who might have built cool projects then being sad when the Teensy 3.6 was discontinued. Happy to dialog with anyone wanting to do a build of Teensy 3.6's too!

Thanks,
Greg

C++:
// Quick two-channel, interrupt-driven DDS on Teensy 4.1 (or Teensy 3.6 to use onboard DACs)
// G. Kovacs, 7/26/26
// Based on GK code 10/23/16 for Teensy 3.6
// Incorporate ZM's bit-banging/shiftOut code for MCP4822 and AD5452
// Update to allow use of T3.6 DACs as well, hoping for a revival of this great board!

// Instead of using externally pre-computed waveform look-up-table, compute it here.
// User can also define any waveform mathematically subject to the constraints of any sampled-data system.
// Can be extended to two separate LUT's if desired, one per channel, or used for qudrature with a simple phase offset,
// and can then use one LUT to feed both DACs.
// Original DDS code on Teensy 3.1 Bill Esposito

// Tutorial comments:
// Basics of DDS:
// fout= (M X fc)/2^n
// fout = output frequency
// M = binary tuning word
// n = lentght of phase accumulator in bits, here 32 bits.
// solving for M...
// M=(fout*2^n/fc), where 2^n/fc is the "tuning constant" used below

// Create an IntervalTimer object
// See: https://www.pjrc.com/teensy/td_timing_IntervalTimer.html
IntervalTimer DDSTimer;

int16_t WaveTable[4096];
int interruptperiod = 5; //interrupt period in us - with my crappy code, 7us (143kSPS) is the fastest it will do at 600MHz CPU clock.
float tuningConstant = (pow(2,32))*(interruptperiod*1E-6);
float freq0 = 1000.0;
float freq1 = 1000.1;
volatile unsigned int tuningWord0 = tuningConstant * freq0, tuningWord1 = tuningConstant * freq1;
volatile unsigned int phaseAccumulator0 = 0, phaseAccumulator1 = 0; //32 bits
unsigned int tempPhase0, tempPhase1; //calculated phases for this sample

// AD5452 pin mapping (Alpine spare pins - modify to match physical wiring - available pins include: 15, 16, 17, 18, 19, 20)
const int AD5452SYNC = 15, AD5452SCLK = 16, AD5452SDIN = 17;

// MCP4822 pin mapping (shares the AD5452 serial pins by default, but declared
// separately so each DAC can be remapped independently). LDAC gets its own
// spare pin so both DAC registers can be latched to the outputs from code.
const int MCP4822CS = 15, MCP4822SCK = 16, MCP4822SDI = 17, MCP4822LDAC = 18;

// MCP4822 gain select: false = 1x gain (2.048 V full scale),
// true = 2x gain (4.096 V full scale, needs VDD > ~4.2 V)
//const bool MCP4822gain2x = false;
const bool MCP4822gain2x = false; //In prototype, use +5.0V power for MCP4822, so can use this.

// MCP4822 channel select (frame bit 15: 0 = DACA, 1 = DACB)
enum Mcp4822Channel { MCP4822_DAC_A, MCP4822_DAC_B };

// Which DAC dacWrite() talks to (the MCP4822 is default)
enum DacType { DAC_AD5452, DAC_MCP4822 };
DacType activeDac = DAC_MCP4822;

void setup(void) {
 DDSTimer.begin(dds, interruptperiod);  // run DDS routine every N us
 for (int tablepointer = 0; tablepointer < 4096; tablepointer++)
  {
    WaveTable[tablepointer]=(2047+2048*sin(2*3.141592654*(float(tablepointer)/4096))+.5);
  }

 //MCP4822pinSetup(); //Need to choose the appropriate pinSetup helper
 Teensy3_6dacPinSetup();

  freq0 = 50000;
  freq1 =1001;
  tuningWord0 = tuningConstant * freq0;
  tuningWord1 = tuningConstant * freq1;
}

void loop(void) {
  }

volatile int16_t outVal0 = 0, outVal1 = 0; // use volatile for shared variables

FASTRUN void dds(void) {
 noInterrupts();
    //MCP4822writeBoth(outVal0, outVal1);
    Teensy3_6writeBoth(outVal0, outVal1);
    phaseAccumulator0 += tuningWord0; //32 bits
    phaseAccumulator1 += tuningWord1; //32 bits
    tempPhase0 = (unsigned long)(phaseAccumulator0 >> 20); //use only top 12 bits of 32 bit phase accumulator
    tempPhase1 = (unsigned long)(phaseAccumulator1 >> 20); //use only top 12 bits of 32 bit phase accumulator
    outVal0 = WaveTable[tempPhase0];
    outVal1 = WaveTable[tempPhase1];
 interrupts();
}

// AD5452 helpers

void AD5452pinSetup()
{
  pinMode(AD5452SYNC, OUTPUT);
  pinMode(AD5452SCLK, OUTPUT);
  pinMode(AD5452SDIN, OUTPUT);

  digitalWrite(AD5452SYNC, HIGH); // Idle high
  digitalWrite(AD5452SCLK, LOW);

  // Load zero-scale to establish a known starting point
  AD5452write(0);
}

void AD5452write(int code12)
{
  // AD5452 16-bit frame (Figure 51 in datasheet):
  //   [C1][C0][DB11..DB0][X][X]
  // C1=0, C0=0 selects "load and update" (Datasheet - Table 10).
  // The two LSBs are don't-care for the 12-bit part.
  int frame = (code12 & 4095) << 2;

  // Pull SYNC low to begin the transfer
  digitalWrite(AD5452SYNC, LOW);

  // Shift 16 bits MSB first. Data is latched on the falling edge of SCLK.
  shiftOut(AD5452SDIN, AD5452SCLK, MSBFIRST, (frame >> 8) & 0xFF);
  shiftOut(AD5452SDIN, AD5452SCLK, MSBFIRST, frame & 0xFF);

  // Return SYNC high to latch the new code into the DAC register
  digitalWrite(AD5452SYNC, HIGH);
}

// MCP4822 helpers
//
// The MCP4822 is double buffered: an SPI write only loads a channel's INPUT
// register, and nothing reaches the output until LDAC goes low, which
// transfers BOTH input registers to BOTH output registers at the same time
// (datasheet DS20002249B, sections 3.5 and 5.2). Driving LDAC from code
// gives two update patterns:
//   - MCP4822write(ch, code): load one channel, latch immediately. The other
//     channel's input register is untouched, so its output holds.
//   - MCP4822load() per channel + MCP4822latch(): stage both channels, then
//     update both outputs on the same edge (wrapped by MCP4822writeBoth()).

void MCP4822pinSetup()
{
  pinMode(MCP4822CS, OUTPUT);
  pinMode(MCP4822SCK, OUTPUT);
  pinMode(MCP4822SDI, OUTPUT);
  pinMode(MCP4822LDAC, OUTPUT);

  digitalWrite(MCP4822CS, HIGH);   // Idle high (active low)
  digitalWrite(MCP4822SCK, LOW);   // SPI mode 0,0: clock idles low
  digitalWrite(MCP4822LDAC, HIGH); // Idle high; outputs move only on our latch pulse

  // Both channels power up in shutdown (VOUT is ~500 kOhm to GND until a
  // valid write with the SHDN bit set). Stage zero-scale on both channels
  // and latch once to establish a known active starting point.
  MCP4822writeBoth(0, 0);
}

void MCP4822load(Mcp4822Channel channel, int code12)
{
  // MCP4822 16-bit frame (Register 5-1 in datasheet DS20002249B):
  //   [A/B][X][GA][SHDN][D11..D0]
  // Bit 15 selects the channel (0 = DACA, 1 = DACB). Bit 14 is don't-care.
  // GA = 1 selects 1x gain (2.048 V full scale), GA = 0 selects 2x gain
  // (4.096 V full scale). SHDN = 1 keeps the channel active; SHDN = 0 is
  // software shutdown for that channel.
  int frame = (MCP4822gain2x ? 0x1000 : 0x3000) | (code12 & 4095);
  if (channel == MCP4822_DAC_B) frame |= 0x8000;

  // Pull CS low to begin the transfer
  digitalWriteFast(MCP4822CS, LOW);
  // Shift 16 bits MSB first. Data is latched on the rising edge of SCK
  // (opposite edge from the AD5452; shiftOut() holds data stable across
  // the whole clock pulse, so it satisfies both parts).
  shiftOut(MCP4822SDI, MCP4822SCK, MSBFIRST, (frame >> 8) & 0xFF);
  shiftOut(MCP4822SDI, MCP4822SCK, MSBFIRST, frame & 0xFF);
  // Return CS high to accept the word into the channel's input register.
  // Exactly 16 clocks are required; fewer aborts the write. With LDAC held
  // high, VOUT does NOT change here - call MCP4822latch() to update.
  digitalWriteFast(MCP4822CS, HIGH);
}

void MCP4822latch()
{
  // Pulse LDAC low to copy both input registers to both output registers:
  // VOUTA and VOUTB update on the same edge. Minimum low time is 100 ns
  // (t_LD); 1 us is comfortable margin at any Teensy clock speed.
  digitalWriteFast(MCP4822LDAC, LOW);
  //delayMicroseconds(1);
  delayNanoseconds(100); //Doesn't really help, but you should know about this function.
  digitalWriteFast(MCP4822LDAC, HIGH);
}

void MCP4822write(Mcp4822Channel channel, int code12)
{
  // Update one channel now. The other channel's input register still holds
  // its last-written code, so the latch reloads its output with the same
  // value and it stays put.
  MCP4822load(channel, code12);
  MCP4822latch();
}

void MCP4822writeBoth(int codeA, int codeB)
{
  // Stage both channels, then update both outputs simultaneously with a
  // single LDAC pulse - no intermediate state where one channel is new
  // and the other is stale.
  MCP4822load(MCP4822_DAC_A, codeA);
  MCP4822load(MCP4822_DAC_B, codeB);
  MCP4822latch();
}

// DAC selection

void selectDac(DacType dac)
{
  // Make the chosen DAC active and (re)run its pin setup. Only the active
  // DAC is initialized because both parts share pins 15/16/17 by default.
  activeDac = dac;
  if (activeDac == DAC_AD5452) {
    AD5452pinSetup();
  } else {
    MCP4822pinSetup();
  }
}

void dacWrite(int code12)
{
  MCP4822write(MCP4822_DAC_A, code12);
}

void Teensy3_6dacPinSetup()
{
  analogWriteResolution(12);
  pinMode(A21, OUTPUT); //DAC0
  pinMode(A22, OUTPUT); //DAC1
}

void Teensy3_6writeBoth(int codeA, int codeB)
{
  analogWrite(A21, codeA); //Teensy 3.6 DAC0 on pin
  analogWrite(A22, codeB); //Teensy 3.6 DAC1 on pin
}

/*
void dacWrite(int code12)
{
  // Route a 12-bit amplitude code to whichever DAC is currently selected.
  // On the MCP4822 this drives channel A; channel B holds its last code.
  if (activeDac == DAC_AD5452) {
    AD5452write(code12);
  } else {
    MCP4822write(MCP4822_DAC_A, code12);
  }
}
*/


MCP4822 10KHz Output.jpg
Teensy 3.6 DAC 10kHz Output.jpg
 
Quick follow-on. Here are slew-rate measurements with no load other than 10 Mohm scope probes(as for the above sinewave synthesis tests). Please note that as for most electronic circuit, rising and falling slew rates are different (usualy due to the different carrier mobilities in N- and P-type devices).


The scope photos (different duty cycles simply because I was lazy and used the same ISR-type code and had to account for the extra time to load the DACs with shiftOut) show the results.


Teensy 3.6 DAC Rising SR 4.65V/us, Falling SR 3.83V/us

MCP4822 DAC Rising SR 0.614V/us, Falling SR 0.642V/s


There you go! The Teensy 3.6 DACs are hard to beat unless you spend more on the two DAC channels than a Teensy 3.6 used to cost.

Thanks,
Greg

Teensy 3.6 Slew Rates.jpg


MCP4822 Slew Rates.jpg
 
Back
Top