Introduction: How to Make and Test a Better DAC With ESP32

About: Researcher with a passion for microcontrollers.

The ESP32 has 2 8-bit Digital to Analogue Converters (DACs). These DACs allow us to produce arbitrary voltages within a certain range (0-3.3V) with 8 bits of resolution. In this Instructable, I will show you how to build a DAC and characterise its performance as well as compare it to the ESP32 DAC. The performance indices I will look at include

  • Noise Level
  • Bandwidth
  • Integral nonlinearity
  • Differential nonlinearity

To test these indices I will use the ADS1115.

It's important to note that your assessment of all these indices will only be as accurate as your reference device (in this case the ADS115). For instance, the ADS115 does not have 16-bit precision when it comes to its voltage offset and gain. These errors may be as large as 0.1%. For many systems, these errors can be ignored when absolute accuracy is of limited concern.

Supplies

Step 1: Laying Out the Breadboard

Wire the following pins

Between the ESP32 and the ADS1115

3v3 --> VDD

GND -->GND

GPIO22 -->SCL

GPIO21 -->SDA

At the ADS1115

ADDR -->GND (ADS115)

Making the DAC

There are many ways to make a DAC. The simplest is to low-pass filter a PWM signal with a resistor and a capacitor. I could have added an op-amp in here as a buffer but wanted to keep things simple. This design is simple and cheap to implement with any microcontroller that supports PWM. I'm not going to go through the theory of the design here (google PWM DAC).

Just connect GPIO25-->5 KOhm resistor --> 1 microFarad Capacitor -->gnd

Now connect a jumper wire from the point where the resistor meets the capacitor to A0 on the ADS115.

Step 2: Assess Signal to Noise Level

To assess the noise level simply run the script below. To assess this we simply leave the DAC at a fixed value and measure how the voltage oscillates over time.

Due to the design of the DAC, the noise will be greatest when the PWM signal is at 50% duty cycle. Therefore this is where we will assess it. We will also assess the ESP32 at this same signal level. We will also filter the ESP32 DAC with the same low pass filter so as to make the measurement comparable.

For me the output was clear. The PWM design had >6dB better SNR (that's 2 times better). A clear win for new DAC. One slight confound is that there are filters built into the ADC that are most definitely enhancing the SNR. So the absolute values may be difficult to interpret. If I had used a second-order filter this would not be the case.

Anyway code is below

#include <Wire.h>
#include <Adafruit_ADS1015.h>

Adafruit_ADS1115 ads;            // adafruit library for adc
int16_t adc0;                    //



void setup(void) {
  Serial.begin(115200);         // Start serial
  ads.setGain(GAIN_TWO);        // 2x gain   +/- 2.048V  1 bit =0.0625mV
  ads.begin();                  // begin adc

  float M = 0;                  // initial mean
  float Mp = 0;                 // previouos mean
  float S = 0;                  // initial Variance
  float Sp = 0;                 // previous variance
  const int reps = 500;         // number of repitions
  int n = 256;                  // number of samples

  ledcSetup(0, 25000, 8);       // set pwm frequecny =25000 Hz at 8 bits resolution
  ledcAttachPin(25, 0);         // set pwm on pin 25
  ledcWrite(0, 128);            // set it to half duty cycle(biggest noise)

  delay(3000);                            // wait for settling time

  float snrPWM[reps];                     // array of snrs for PWM
  float snrDAC[reps];                     // array of snrs for DAC

  for (int i = 0; i < reps; i++) {        // loope over repititions
    for (int k = 1; k < (n + 1); k++) {   // loope over samples
      adc0 = ads.readADC_SingleEnded(0);  // get reading
      M = Mp + (adc0 - Mp) / k;           // compute rolling mean
      Mp = M;                             // set previous mean
      S = Sp + (adc0 - Mp) * (adc0 - M);  // compute rolling variance
      Sp = S;                             // set previous variance
    }
    // snr in dB
    snrPWM[i] = 20 * log10(3.3 / (sqrt(S / n) * .0625 * .001));
    
    //reset values 
    M = 0;
    Mp = 0;
    S = 0;
    Sp = 0;
  }


  ledcDetachPin(25);                       // detach PWM from pin 25
  dacWrite(25, 128);                       // write to DAC
  delay(3000);                             // wait to settle
  for (int i = 0; i < reps; i++) {         // same as PWM loop
    for (int k = 1; k < (n + 1); k++) {
      adc0 = ads.readADC_SingleEnded(0);
      M = Mp + (adc0 - Mp) / k;
      Mp = M;
      S = Sp + (adc0 - Mp) * (adc0 - M);
      Sp = S;
    }
    
    snrDAC[i] = 20 * log10(3.3 / (sqrt(S / n) * .0625 * .001));
    M = 0;
    Mp = 0;
    S = 0;
    Sp = 0;
  }
  
  // plot SNRs on one graph
  for (int i = 1; i < reps; i++) {
    Serial.print("PWM_SNR(dB):");
    Serial.print(snrPWM[i]);
    Serial.print(", ");
    Serial.print("ESP32_SNR(dB):");
    Serial.println(snrDAC[i]);
  }
}

void loop(void) {


}<br>

Step 3: Integral Nonlinearity and Differential Nonlinearity

The integral nonlinearity is a measure of roughly how much deviation there is between your DAC output voltage and a straight line. The bigger this is the worse it is...

The differential nonlinearity is a measure of roughly how much the observed change in the voltage (from one code to the next) deviates from what would be expected from a straight line.

The results here were really interesting. First of all, both have less than 0.5lsb error (at 8-bit resolution) which is good but the PWM has much better integral linearity. Both have comparable differential nonlinearity but the ESP32 DAC has some very weird spikes. What is more, the PWM method has some structure to the errors. Essentially it overshoots and undershoots the correct voltage in an alternating fashion.

My suspicion is this is some weird rounding error in how an 8-bit PWM signal is produced on the ESP32.

One way to correct for this is to rapidly cycle between two adjacent codes (e.g 128,129) with the PWM. With an analogue lowpass filter, the resulting errors will average to zero. I simulated this in software and indeed all the errors disappeared. Now the PWM method has linearity that is accurate to 16-bits!

Anywho the code to generate the data is below. The output will be on the serial monitor in .csv format. Just copy it to a text file for further processing.

#include <Wire.h>
#include <Adafruit_ADS1015.h>

Adafruit_ADS1115 ads;  /* Use this for the 16-bit version */
int16_t adc0;

void setup(void) {
  Serial.begin(115200);
  ads.setGain(GAIN_ONE);        // 2x gain   +/- 2.048V  1 bit = 1mV      0.0625mV
  ads.begin();
  ledcSetup(0, 25000, 8);
  ledcAttachPin(25, 0);

  Serial.println("Expected,Observed ");

  ledcWrite(0, 2);
  delay(3000);

  for (int i = 2; i < 255; i++) {
    ledcWrite(0, i );
    delay(100);
    adc0 = ads.readADC_SingleEnded(0);
    float expected = (i / 256.0 * 3.3) / 4.096 * 32767;
    Serial.print(expected);
    Serial.print(",");
    Serial.println(adc0);
  }

}

void loop(void) {

}<br>

Step 4: Bandwidth

I'm going to define bandwidth as here as the frequency at which the output of the DAC drops by 3dB. This is a convention and, to a degree, arbitrary. For instance, at the 6dB point, the DAC will still output a signal it will just be ~50% amplitude.

To measure this we simply pass sine waves at an increasing frequency from the DAC to the ADC and measure their standard deviation. Unsurprisingly, the 3dB point is at 30Hz (1/(2*pi*5000*1e-6)).

The ESP32 can do 1 Mega sample per second. This is a hands-down win for the ESP32. Its amplitude does not decay at all in the 100Hz bandwidth test region.

The code below can test the PWM DAC bandwidth.

#include <Wire.h>
#include <Adafruit_ADS1015.h>

Adafruit_ADS1115 ads;  /* Use this for the 16-bit version */
int16_t adc0;
int16_t adc1;


void setup(void) {

  float M;
  float Mp = 0;
  float S = 0;
  float Sp = 0;

  Serial.begin(115200);
  ads.setGain(GAIN_ONE);        // 1x gain   +/- 4.096V  1 bit = 2mV      0.125mV
  ads.begin();
  ledcSetup(0, 25000, 8);
  ledcAttachPin(25, 0);
  delay(5000);
  Serial.println("Frequency,Amplitude ");
  for (int i = 1; i < 100; i++) {

    unsigned long start = millis();
    unsigned long T = millis();
    Sp = 0;
    S = 0;
    M = 0;
    Mp = 0;
    int k = 1;
    float norm;
    while ((T - start) < 1000) {
      int out = 24 * sin(2 * PI * i  * (T - start) / 1000.0) + 128;
      ledcWrite(0, out);
      adc0 = ads.readADC_SingleEnded(0);
      M = Mp + (adc0 - Mp) / k;
      Mp = M;
      S = Sp + (adc0 - Mp) * (adc0 - M);
      Sp = S;
      T = millis();
      k++;
    }
    if (i == 1) {
      norm = sqrt(S / k);
    }
    Serial.print(i);
    Serial.print(",");
    Serial.println(sqrt(S / k) / norm,3);

    k = 0;
  }

}

void loop(void) {
}

And this code will test the ESP32 bandwidth. Make sure to remove the capacitor or the results will be the same for both methods.

#include <Wire.h>
#include <Adafruit_ADS1015.h>

Adafruit_ADS1115 ads;  /* Use this for the 16-bit version */
int16_t adc0;
int16_t adc1;


void setup(void) {

  float M;
  float Mp = 0;
  float S = 0;
  float Sp = 0;

  Serial.begin(115200);
  ads.setGain(GAIN_ONE);        // 1x gain   +/- 4.096V  1 bit = 2mV      0.125mV
  ads.begin();
  delay(5000);
  Serial.println("Frequency,Amplitude ");
  for (int i = 1; i < 100; i++) {

    unsigned long start = millis();
    unsigned long T = millis();
    Sp = 0;
    S = 0;
    M = 0;
    Mp = 0;
    int k = 1;
    float norm;
    while ((T - start) < 1000) {
      int out = 24 * sin(2 * PI * i  * (T - start) / 1000.0) + 128;
      dacWrite(25, out);
      adc0 = ads.readADC_SingleEnded(0);
      M = Mp + (adc0 - Mp) / k;
      Mp = M;
      S = Sp + (adc0 - Mp) * (adc0 - M);
      Sp = S;
      T = millis();
      k++;
    }
    if (i == 1) {
      norm = sqrt(S / k);
    }
    Serial.print(i);
    Serial.print(",");
    Serial.println(sqrt(S / k) / norm,3);

    k = 0;
  }

}

void loop(void) {



}

Step 5: Closing Thoughts

The new DAC design wins on linearity and noise but loses on bandwidth. Depending on your application one of these indices may be more important than the other. With these testing procedures, you should be able to objectively make that decision!

Also, I think it's worth pointing out here that because PWM output is low noise, with exceptional linearity it should be possible to construct a much higher resolution DAC with the PWM output (maybe even 16-bit precision). That's gonna take some work. Until then, I bid you adieu!