Generating variable voltage with Arduino, ESP8266 and ESP32

Last modified 2 months

We are going to look at a very necessary topic for some projects and that is how we can generate a variable voltage with Arduino, ESP8266 and ESP32 using different techniques.

Of course, although I have given as examples the Arduino (with ATmega328P, such as the Arduino Uno, the Arduino Pro, the Arduino Nano), the ESP8266 and the ESP32, what we will see here will be applicable to other microcontrollers or even microprocessors (Raspberry Pi, for example) adapting to the characteristics of each platform.

The techniques are repeated on all platforms, it is just a matter of choosing the one available on the platform we are working with (or choosing among several available, knowing the differences, advantages and disadvantages of each one).

Let's take a look at the basics of variable voltage generation with different but very similar microprocessors in the way they are used.

The problem of generating a variable voltage

The microcontrollers (and microprocessors) we work with belong to the digital world, they only know about zeros and ones (and if we talk about the voltages that these zeros and ones represent, we would talk about 0V and 5V or 3.3V).

Microcontrollers natively know nothing of the analogue world and intermediate voltages between "low", or logic 0, and "high", or logic 1.

If a microcontroller operating at 5V is presented with a voltage of, for example, 4.1V on a "normal" input pin, it will take it as a logic high (or 1) and will not differentiate it at all from another 4.3V voltage or another 4.7V voltage.

If we want to generate a voltage of precisely 5V in a microprocessor that works at 5V, we will not have any problem and we can do it directly. We put the pin we are interested in "at 1" and we will already have 5V on that pin.

When we want it to be 0V, we only have to set that pin "to 0" and we will have 0V on that pin.

The problem is that, if we want to generate any intermediate voltage, such as 2.7V or 1.42V, we will have to resort to different techniques, more or less "indirect", that allow us to get around this limitation.

Of course, it will also happen to read those tensions, but that is another story and we will deal with it in another post....

The main ways to generate variable tension

We have mainly three options to generate the necessary voltage with our microcontroller:

  1. Voltage generation with a PWM signal
  2. Voltage generation with a DAC (Digital Analogue Converter)
  3. Sigma-Delta modulation voltage generation

There is no one better than the other, they all have advantages and disadvantages and in this article we will look at the most important ones.

Voltage generation with a PWM signal

In the blog you have several posts talking about PWM signals and how to use them in various situations, here we are going to focus on converting a PWM signal into an analogue voltage that we can use.

The good thing about PWM is that all the microprocessors we are talking about (and most of the existing ones) are capable of generating PWM signals. both hardware and software.

PWM is one of the first systems to be used with microprocessors and is still the most widely used when it comes to voltage generation, as long as its peculiarities and limitations (which we will now discuss) make it acceptable.

If you have no idea what a PWM signal is, I recommend reading the following article before continuing:

The system to generate the voltage is very simple, we convert the PWM pulse length (the duty cycle) into voltage and use it.

Peculiarities and limitations of PWM signals for variable voltage generation

The "PWM voltage

The problem with the PWM system is that, if we use it "as is", what it generates is a voltage that "on average" has the voltage we want, but every time the signal is at 1, the voltage will be whatever the microcontroller provides (usually 5V or 3.3V).

Depending on what we connect to that output (the "consumer" of that voltage) this technique will serve us very well (such as to regulate an LED or the speed of many motors) but other devices will not tolerate it and will malfunction or break down.

Imagine a device operating at 2.5V: if we provide it with a 5V PWM signal with a 50% duty cycle, the "average" voltage it will receive will be 2.5V but it may not tolerate the 50% of the time the voltage feeding it is 5V and it breaks down.

Another more practical example: if we want to power a microcontroller running at 3.3V with a PWM signal of 5V and a duty cycle of the 66%) that microprocessor will die immediately because, even if the "average PWM voltage" is 3.3V, it will not tolerate the 5V pulses.

It's as if we had a tap in the sink that delivers the water "hits". It may not be a problem that the sink drain is not capable of swallowing that amount of water, but every time it delivers one of those "hits" it splashes everything out of the sink, making it a mess.

We need to convert the "PWM voltage" (which moves "on the fly" between 0 and 5V, to a "real" 2.5V voltage).

Convert "PWM voltage" to "analogue voltage".

Fortunately, solving the problem by converting this "pulse voltage" to a smoother "analogue voltage" is fairly straightforward.

For the vast majority of needs we can do this with a simple low-pass filter that can be built with just a capacitor and a resistor (also called R-C filter for "Resistor|Cappeaser").

The low-pass filter (RC filter for PWM)

The assembly of the low pass filter is very easy, although calculating it is not so simple (and it is different for each case), although there are calculators on the internet that make the work much easier.

Here is a schematic of a filter that you can use in many cases because it works well at 3.3V and 5V with a 1Khz PWM signal:

And here you can see the effect of this simple filter:

Channel 1 (yellow) corresponds to the PWM signal as generated by the ESP32.

Channel 2 (in blue) is the output of the filter, with that signal applied to the input.

As you can see, the output voltage (blue line) is not a perfectly stable voltage, but has slight rises and falls coinciding with the PWM signal. In this case the voltage of approximately 1V, has small rises and falls of about 42mV.

It is possible, with the right choice of R-C filter values, to make the voltage more stable (at the cost of slower conversion) or to make the conversion faster (at the cost of a more unstable voltage).

Here you can see this filter built on a small plate:

The way a PWM signal is generated on the Arduino, ESP8266 and ESP32 varies very little. Below is the basic code that generates a 1V signal on each of them.

If you want to know more about the low pass filter on Wikipedia.

The resolution of the PWM signal

It is important to keep in mind that, depending on the microcontroller you are using, the resolution may be different.

In the following, I will talk about the native PWM resolution of the different microcontrollers, but you should know that it is possible to increase the resolution using software techniques.

The Arduino Uno uses an ATmega328P microcontroller as its main processing unit, and the PWM resolution on this microcontroller is 8 bits. This means that the range of values for the duty cycle on the PWM outputs is 0 to 255.

The term "8 bits" refers to the number of bits used to represent the output value in binary. With 8 bits, there are 2^8 = 256 possible different values, ranging from 0 to 255. In the case of PWM, these values represent the duty cycle as a percentage of the PWM signal period.

For example, for a PWM output with a value of 128, the duty cycle would be \frac{128}{255} \times 100\%approximately the 50%. Thus, the 8-bit resolution provides 256 different steps for adjusting the duty cycle, allowing relatively fine control of luminous intensity in LEDs, motor speed, among other applications.

The ESP8266, has a 10-bit PWM resolution. This means that the PWM duty cycle can be set to 1024 different levels, from 0 to 1023. The 10-bit resolution provides greater precision in pulse width modulation, allowing finer control over the PWM output.

The ESP32 has a default PWM resolution of 8 bits. This means that the duty cycle value can vary from 0 to 255. The PWM frequency is also configurable in the ESP32. The default frequency is 1 kHz, but it can be adjusted according to the needs of the project by using the ledcSetup() to configure the PWM channel and ledcWrite() to set the duty cycle.

For example, ledcSetup(0, 1000, 10); configures channel 0 with a frequency of 1000 Hz and a resolution of 10 bits. Then, ledcWrite(0, dutyCycle); sets the duty cycle of the PWM channel. Note that the PWM frequency and resolution can be adjusted according to the specific requirements of your project.

PWM signal generation on different microcontrollers

Although the necessary code is very similar between different microcontrollers, there are several differences that are worth knowing.

The possibilities offered by the individual microcontrollers are also different.

Here are some sample codes (these are indicative codes, not all of them have been properly tested)

Generating a 1V PWM signal on Arduino (ATmega328P)

To generate a 1V PWM signal using an Arduino Uno, you can use the function analogWrite() of Arduino. The resolution of the PWM signal in Arduino Uno is 8 bits.The duty cycle value ranges from 0 (completely off) to 255 (completely on).

To get a 1V signal, you must determine what percentage of the duty cycle corresponds to 1V compared to the full 0 to 5V range (we are using a 5V Arduino, so the logic high level will correspond to 5V). The ratio is:

\text{Percentage of duty cycle} = \left( \frac{ Desired value}{ Maximum possible value} right) \times 100

Then, to get 1V, you can use the following code:

const int pwmPin = 5; // You can change the pin number according to your needs.
 
void setup() {
  pinMode(pwmPin, OUTPUT);
}
 
void loop() {
  int dutyCycle = map(100, 0, 500, 0, 255); // Map 0 to 1V over a range of 0 to 255
  analogWrite(pwmPin, dutyCycle);
 
  delay(1000); // Wait 1 second before repeating
}

This code configures the digital pin 5 (you can change it according to your needs) as PWM output and then uses the function analogWrite() to set the duty cycle required to obtain 1V. The mapped value of 100 is obtained by using the function map() to convert the duty cycle range 0 to 255 into the voltage range 0 to 500 (0 to 5V, in millivolts). The frequency of the PWM signal is handled automatically by the Arduino Uno hardware.

On an Arduino Uno, the frequency of the standard PWM is approximately 490 Hz. You can use the library TimerOne to change the PWM frequency. Here is the modified code:

#include 

const int pwmPin = 5; // You can change the pin number according to your needs.

void setup() {
  pinMode(pwmPin, OUTPUT);
  Timer1.initialize(1000); // Set the frequency to 1000 Hz
  Timer1.pwm(pwmPin, 127); // Set the duty cycle to 50% (127 of 255)
}

void loop() {
  // It is not necessary to change the duty cycle in the loop in this case.

  delay(1000); // Wait 1 second before repeating
}

In this code, Timer1.initialize(1000); sets the timer frequency to 1000 Hz, and Timer1.pwm(pwmPin, 512); sets the duty cycle to half to obtain an output of approximately 1V.

Remember that you will need to install the TimerOne before compiling this code. You can do this through the Arduino Library Manager or through PlatformIO.

1V PWM signal generation on ESP8266

Here is the code adapted for an ESP8266.

Please note that the ESP8266 uses an 10-bit resolution for PWMThe range of values for the duty cycle is therefore from 0 to 1023.

const int pwmPin = D1; // You can change the pin number according to your needs on the ESP8266 board.

void setup() {
  pinMode(pwmPin, OUTPUT);
  analogWriteFreq(1000); // Set the frequency to 1000 Hz
}

void loop() {
  int dutyCycle = map(205, 0, 1023, 0, 100); // Map 1V to 205 over a range from 0 to 1023
  analogWrite(pwmPin, dutyCycle);

  delay(1000); // Wait 1 second before repeating
}

This code configures the digital pin D1 (you can change it according to your needs) as a PWM output and uses the function analogWrite() to set the duty cycle required to obtain 1V. The mapped value of 205 is obtained by using the function map() to convert the range 0 to 1023 to the range 0 to 100. The frequency of the PWM signal is automatically handled by the ESP8266 hardware.

Generation of a 1V PWM signal on ESP32

To generate a 1V PWM signal with a frequency of 1000 Hz using an ESP32, you can use the following code:

const int pwmPin = 5; // You can change the pin number according to your needs on the ESP32 board.

void setup() {
  pinMode(pwmPin, OUTPUT);
  ledcSetup(0, 1000, 10); // Set up channel 0, 1000 Hz frequency, 10-bit resolution
  ledcAttachPin(pwmPin, 0); // Associate pin to channel 0
}

void loop() {
  int dutyCycle = map(205, 0, 1023, 0, 1023); // Map 1V to 205 over a range from 0 to 1023
  ledcWrite(0, dutyCycle);

  delay(1000); // Wait 1 second before repeating
}

In this code, ledcSetup(0, 1000, 10); configures channel 0 with a frequency of 1000 Hz and a resolution of 10 bits. Then, ledcAttachPin(pwmPin, 0); associates the pin to channel 0. The duty cycle is set as before using ledcWrite(0, dutyCycle);.

Make sure the pin you are using on the ESP32 board is PWM compatible. You can adjust the mapped value to suit your needs to get the desired 1V amplitude.

On an ESP32, the maximum PWM frequency depends on several factors, including the PWM resolution and the specific PWM channel you are using. The general formula for calculating the PWM frequency on an ESP32 is:

\text{Frecuencia} = \frac{\text{Frecuencia del reloj del timer}}{\text{Divisor del timer} \times \text{Contador del timer}}

In the ESP32, timers and PWM channels are associated. For example, Timer 0 is associated with channels 0 and 1, Timer 1 with channels 2 and 3, and so on.

The timer clock frequency can be varied and is defined in the ESP32 core, and the timer divider and timer counter are configurable.

To calculate the maximum PWM frequency on an ESP32 with 8-bit resolution, you can use the following formula:

\text{Maximum frequency} = \frac{Frequency of the timer clock}{256 \text{Divisor of the timer}}

Where (256) is the maximum number of counts for 8-bit resolution.

In a practical case, if the clock frequency of the timer is 80 \, \text{MHz} and the timer divider is 80 (for example purposes), the maximum frequency would be:

\text{Maximum Frequency} = \frac{80, \text{MHz}{256 times 80} \approx 3.125, \text{kHz}

It is important to note that these values are examples and may vary depending on the specific configuration used in your code. You can adjust the timer and PWM channel settings according to your specific needs and constraints.

Generate a variable voltage by PWM on the ESP32

To make it easier for us to test, I have prepared the following program for ESP32.

It will allow us to adjust the duty cycle of the PWM signal through a web page and to change the resolution of the signal between 8, 10 and 12 bits.

In addition, we can see a simple graphical representation of the generated PWM signal.

We just have to edit the code with the SSID and password of our wifi network, upload it to the ESP and connect to the ESP32 IP from a web browser.

You can see the ESP32's IP on your router. You can also see it on the ESP32's serial port when it is connected (for example, with the Arduino IDE's Serial Monitor).

The PWM output is on pin 27 of the ESP32, you can change it as you see fit.

Remember, before recording the program on your ESP32, edit the code with the SSID and password of your WIFI.

#include <WiFi.h>
#include <ESPAsyncWebServer.h>

// Configure your WiFi credentials
const char* ssid = "your_wifi_name";
const char* password = "your_wifi_password";


const int pwmPin = 27;
int pwmResolution = 8;
int pwmFrequency = 1000;

AsyncWebServer server(80);

void setup() {
  Serial.begin(115200);

  // Connect to the WiFi network
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Connecting to WiFi...");
  }
  Serial.println("Connecting to WiFi...");

  // Print the assigned IP address
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());

  // Configure the pin as PWM output
  ledcAttachPin(pwmPin, 0);
  ledcSetup(0, pwmFrequency, pwmResolution); // Configure the PWM channel
  ledcWrite(0, 0);

  // Configure the routes for the web server
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    String html = "<html><head><style>#pwmSlider { width: calc(100% - 40px); margin: 20px; }</style></head><body><h1>PWM control</h1>";
    html += "<label for='resolution'>PWM resolution:</label>";
    html += "<select id='resolution' onchange='updateResolution()'>";
    html += "<option value='8'" + string((pwmresolution ="=" 8) ? selected" : "")>8 bits</option>";
    html += "<option value='10'" + string((pwmresolution ="=" 10) ? selected" : "")>10 bits</option>";
    html += "<option value='12'" + string((pwmresolution ="=" 12) ? selected" : "")>12 bits</option>";
    html += "</select><br>";

    html += "<input type='range' min='0' max='100' value='0' id='pwmSlider' oninput='updatePWM(this.value)' style='width: calc(100% - 40px); margin: 20px;'>";
    html += "<p>PWM value: <span id='pwmValue'>0</span></p>";
    html += "<p>Percentage: <span id='percentageValue'>0</span>%</p>";
    html += "<canvas id='pwmCanvas' width='600' height='300' style='display: block; margin: 0 auto;'></canvas>";
    html += "<script>function updatePWM(value) {var pwmValue = Math.round(value / 100 * " + String((1 << pwmResolution) - 1) + "); document.getElementById('pwmValue').innerHTML = pwmValue; document.getElementById('percentageValue').innerHTML = value; var xhr = new XMLHttpRequest(); xhr.open('GET', '/setPWM?value=' + pwmValue, true); xhr.send(); drawPWMCanvas(value);}</script>";

    html += "<script>function updateResolution() {var resolution = document.getElementById('resolution').value; var xhr = new XMLHttpRequest(); xhr.open('GET', '/setResolution?value=' + resolution, true); xhr.onreadystatechange = function() { if (xhr.readyState == 4 && xhr.status == 200) { location.reload(); } }; xhr.send();}</script>";
    html += "<script>function drawPWMCanvas(value) {var canvas = document.getElementById('pwmCanvas'); var context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height); var numCycles = 10; var cycleWidth = canvas.width / numCycles; var highWidth = (value / 100) * cycleWidth; for (var i = 0; i < numCycles; i++) { var x = i * cycleWidth; var y = 0; context.fillStyle = 'blue'; context.fillRect(x, y, highWidth, canvas.height); context.fillStyle = 'lightblue'; context.fillRect(x + highWidth, y, cycleWidth - highWidth, canvas.height); }}</script>";
    html += "</body></html>";
    request-&gt;send(200, "text/html; charset=UTF-8", html.c_str());
  });

  server.on("/setPWM", HTTP_GET, [](AsyncWebServerRequest *request) {
    String valueStr = request-&gt;arg("value");
    int value = valueStr.toInt();
    ledcWrite(0, value);
    request-&gt;send(200, "text/plain", "OK");
  });

  server.on("/setResolution", HTTP_GET, [](AsyncWebServerRequest *request) {
    String valueStr = request-&gt;arg("value");
    int newResolution = valueStr.toInt();

    // Stop PWM before changing settings
    ledcWrite(0, 0);
    delay(10); // Give the PWM time to come to a full stop

    // Set up the new PWM channel
    ledcSetup(0, pwmFrequency, newResolution);
    pwmResolution = newResolution;

    // Reset the PWM
    ledcWrite(0, 0);

    request-&gt;send(200, "text/plain", "OK");
  });

  // Start the web server
  server.begin();
}

void loop() {
  // Your main code can go here
}

Generating voltage with a DAC (Digital Analogue Converter)

A DAC, or Digital Analogue Converter, converts a digital value into an analogue voltage.

We simply tell the DAC the voltage we want and it generates it.

Some microcontrollers incorporate one or more DACs in the hardware itself, while others do not and need to be fitted externally.

For example, neither the ATmega328P and similar (Arduino Uno, Pro, Micro and Nano) nor the ESP8266, have internal DACs.

The standard ESP32 includes two identical DACs ready for use, although they have their limitations.

The internal DAC of the ESP32

The ESP32 has two internal 8-bit DACs, which can be used to generate analogue signals in the range of 0 to 3.3 volts.

Unlike other microcontrollers, such as the ESP8366 or the Arduino Uno's ATmega328P, which do not have an internal DAC, this can be a major advantage.

Here is a brief description of the internal DACs of the ESP32:

  1. Resolution:
    • Each internal DAC has a resolution of 8 bits, which means that it can represent values in the range of 0 to 255.
  2. Reference voltage:
    • The voltage range for analogue signals generated by DACs is from 0 to 3.3 volts, which is the typical supply voltage of the ESP32.
  3. DAC number:
    • The ESP32 has two internal DACs, so it can simultaneously generate two analogue signals.
  4. Use:
    • The ESP32's internal DACs can be used for a variety of applications, such as audio signal generation, control signal generation for analogue devices, custom waveform generation and other applications requiring analogue outputs.
  5. Configuration and Use:
    • To use the ESP32's internal DACs, they can be configured in software. Specific values can be assigned to the DACs to generate different levels of analogue voltage.

To calculate the output voltage of the ESP32 DACs, you can use the following equation:

V_{out} = \left( \frac{value}{2^{12}} \right) \times 3.3

This equation shows how the output voltage is calculated. V_{out} as a function of the digital value value sent to the DAC, assuming a resolution of 12 bits and a reference voltage of 3.3V.

ESP32 internal DAC pins

Unlike many other things in ESP32, the DACs have their outputs wired to dedicated pins.

On most ESP32 modules, you will find channel 1 on GPIO pin 25 and channel 2 on GPIO 26. On the ESP32-S2, channel 1 is on GPIO 17 and channel 2 is on GPIO 18.

ESP32 SoCDAC_CH_1DAC_CH_2
ESP32GPIO25GPIO26
ESP32-S2GPIO17GPIO18
ESP32 internal DAC pins

Here is a very simple example where a value is set on one of the internal DACs to generate a 1V analogue output on pin 25 (DAC 1 of the ESP32):

// Defines the DAC pin
#define DAC_CH1 25

void setup() {
  int desired_voltage = 1000; // Desired voltage in millivolts (e.g. 1000mV = 1V)
  
  // Convert the desired voltage value to the range 0-255 for the DAC
  int dac_value = map(desired_voltage, 0, 3300, 0, 255); // Range 0-3300 is for 0-3.3V
  
  // Set the value of the DAC to the desired voltage
  dacWrite(DAC_CH1, dac_value);
}

void loop() {
}

This other simple code generates five voltages (zero included) with a 2-second pause between changes.

// Defines the DAC pin
#define DAC_CH1 25
 
void setup() {
  // Configures and initializes the serial monitor
  Serial.begin(115200);
}
 
void loop() {
 
  // Generate five voltages stopping two seconds between them
  dacWrite(DAC_CH1, 0);
  Serial.println("DAC Value 0");
  delay(2000);
 
  dacWrite(DAC_CH1, 64);
  Serial.println("DAC Value 64");
  delay(2000);
 
  dacWrite(DAC_CH1, 128);
  Serial.println("DAC Value 128");
  delay(2000);
 
  dacWrite(DAC_CH1, 192);
  Serial.println("DAC Value 192");
  delay(2000);
 
  dacWrite(DAC_CH1, 255);
  Serial.println("DAC Value 255");
  delay(2000);
}

Finally, I leave you this third code, very useful for testing, which allows you to choose the output voltage through the web interface.

#include <WiFi.h>
#include <ESPAsyncWebServer.h>

// Configure your WiFi credentials
const char* ssid = "your_wifi_name";
const char* password = "your_wifi_password";

// Define the DAC pin
#define DAC_CH1 25

AsyncWebServer server(80);

void setup() {
  Serial.begin(115200);

  // Connect to the WiFi network
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Connecting to WiFi...");
  }
  Serial.println("Connecting to WiFi...");

  // Print the assigned IP address
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());

  // Initialise the internal DAC
  dacWrite(DAC_CH1, 0); // Set internal DAC 1 (GPIO 25) to 0V

  // Configure the routes for the web server
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    String html = "<html><body><h1>Internal DAC Control</h1>";
    html += "<input type='range' min='0' max='255' value='0' id='dacSlider' oninput='updateDAC(this.value)' style='width: calc(100% - 40px); margin: 20px;'>";
    html += "<p>Volts Internal DAC: <span id='dacValue'>0.00</span> V</p>";
    html += "<p>Percentage: <span id='percentageValue'>0</span>%</p>";
    html += "<script>function updateDAC(value) {var volts = (value / 255 * 3.3).toFixed(2); document.getElementById('dacValue').innerHTML = volts; document.getElementById('percentageValue').innerHTML = ((value / 255) * 100).toFixed(2); var xhr = new XMLHttpRequest(); xhr.open('GET', '/setDAC?value=' + value, true); xhr.send();}</script>";
    html += "</body></html>";
    request-&gt;send(200, "text/html; charset=UTF-8", html.c_str());
  });

  server.on("/setDAC", HTTP_GET, [](AsyncWebServerRequest *request) {
    String valueStr = request-&gt;arg("value");
    int value = valueStr.toInt();
    dacWrite(DAC_CH1, value); // Set value of internal DAC 1 (GPIO 25)
    request-&gt;send(200, "text/plain", "OK");
  });

  // Start the web server
  server.begin();
}

void loop() {
  // Your main code can go here
}

Operating modes of the ESP32 internal DACs

The ESP32 DAC has 3 different modes of operation, and we will look at them briefly in this tutorial, although we will only use the first mode in this tutorial.

  • Direct Voltage Output Mode
  • Continuous Analogue Output with DMA
  • Cosine waveform output with cosine wave generator (CWG)
ESP32 DACs Direct Voltage Output Mode

This is the easiest and most straightforward way. Just say the digital output (8-Bit) to the DAC and it will convert it to the corresponding analogue voltage level on the DAC output channel you have selected. The voltage will remain constant until you send a new command to the DAC. Also known as "One-Shot Mode" or "Direct Mode".

DMA continuous output mode of the ESP32 DACs

The ESP32 DAC channels can use a technique called "DMA" (Direct Memory Access) to generate the analogue output waveforms without the CPU having to intervene too much, thanks to DMA. This can work in 3 different ways:

ESP32-IDF has very good documentation and APIs for these modes and we will use them in future tutorials.

  1. Synchronous writingData in the DMA buffer is continuously transferred to the DAC (a form of blocking operation). This means that no more data can be sent to the buffer until the transfer operation is finished. This is the fastest way to transfer a piece of audio buffer for playback, for example.
  2. Synchronous writingData in the DMA buffer is continuously transferred to the DAC (a form of blocking operation). This means that no more data can be sent to the buffer until the transfer operation is finished. This is the fastest way to transfer a piece of audio buffer for playback, for example.
  3. Cyclical writingData is loaded once into the DMA buffer and the DMA buffer will continue to cycle through the data and send it to the DAC one by one until it is finished. Then, it will repeat from the beginning of the data buffer without reloading the buffer, unlike the previous 2 modes. It is very efficient for pattern/waveform generation applications.
ESP32 DAC Cosine Wave Generator Mode

The ESP32 DAC has an integrated cosine waveform generator that provides a cosine waveform on either of the two output channels with adjustable frequency, amplitude and phase shift. The output frequency of the ESP32 DACs in this case is 130Hz-100kHz.

The internal DACs of the ESP32 also have some disadvantageswhich are worth knowing:

  1. Limited Resolution:
    • The ESP32's internal DAC has a resolution of 8 bits, which means that it can represent values in the range of 0 to 255. This limited resolution can affect the accuracy of the generated analogue signals, especially compared to higher resolution DACs.
  2. Noise and Linearity:
    • Internal DACs can often introduce noise and non-linearities in the output signal, which can affect the quality of the generated analogue signal. For signal quality sensitive applications, it may be necessary to consider higher quality external DACs.
  3. Limited number of DACs:
    • The ESP32 has two internal DACs, which may be sufficient for many applications, but could be limiting in cases where more independent DAC channels are needed.
  4. Limited Sampling Frequency:
    • The maximum sampling rate of the ESP32's internal DAC may be limited compared to some dedicated external DACs. This may be a factor to consider in applications requiring high sampling rates.
  5. Influence of temperature:
    • Like many electronic components, the performance of the ESP32's internal DAC can be influenced by changes in ambient temperature. In environments where thermal conditions are extreme, this may affect the accuracy of the analogue output.
  6. Supply Voltage Dependence:
    • The performance of the internal DAC may depend on the power supply voltage of the ESP32. Variations in the power supply voltage may affect the accuracy of the generated analogue signals.

Despite these limitations, the ESP32's internal DAC is still useful in many applications and may be sufficient for projects that do not require extremely accurate or high resolution analogue output. For more demanding applications, it may be necessary to consider external solutions, such as external DACs.

An important feature of the ESP32 DAC (and one that will sometimes be a disadvantage and sometimes not, depending on the use case) is that it will zero volts are not achieved. The output always has some mV, even if we set it to zero.

Of course, its main advantage is that no additional hardware required and that programming is very easy.

Applying an offset to the ESP32 DAC output voltage with an OpAmp

As we said, the biggest disadvantage of the ESP32's internal DACs is that their output never drops to zero volts, with a few millivolts always coming out of the pin, even if we ask for a value of zero (dacWrite(DAC_PIN, 0)).

There would be an easy solution to this problem, and that is to use an integrated circuit. Operational Amplifier.

For example, the LM358 operational amplifier, which has two operational amplifiers inside and their speciality is precisely doing things with voltages, (like adding and subtracting voltages).

We could even generate the offset voltage with the microprocessor to put it into the OpAmp. We could probably even automate the zeroing and calculation of the offset with the microprocessor itself. Well, why bother now...?

Use an external DAC MCP4725 for voltage generation

Using an external DAC is the perfect solution from the point of view of quality of the generated voltage and ease of use. It is the "CorrectThe "microcontroller" is used to generate an arbitrary analogue voltage with a microcontroller.

Some of the popular DACs include the MCP4725 from Microchip or the ADS1220 from Texas Instruments. These devices are capable of providing resolutions of 12 bits (MCP4728) or more (the ADS1220 has a resolution of 24 bits).

Using an inexpensive and easy-to-find DAC, such as the MCP4725we would have 12 bits of resolution (212 = 4096 different voltage levels).

For less than a couple of euros we can add this option to our project.

The MCP4725 has the following basic features:

  • 12-bit resolution (0.0012 V to 5 V)
  • I2C interface
  • Power supply voltage range 2.7 - 5.5V
  • Set up time of 6 us
  • EEPROM to store a power-on value

With a supply voltage of 5 V it is possible to select output voltages between 0 V and 4.9988 V in 0.0012 V steps. In the Arduino program the output voltage is represented by an integer between 0 and 4095. The conversion between this value and the actual voltage is possible using the following equation:

V_{\text{out}} = \frac{V_{\text{CC}} \times \text{value}}{4095}

Here,

  • V_{\text{out}}is the output voltage.
  • V_{\text{CC} is the supply voltage (VDC) of the MCP4725.
  • {\text{value} is the digital value set for the DAC (between 0 and 4095).

I have prepared a small firmware for the ESP32 that will allow you to operate the MCP4725 and do experiments with it easily.

The program features a web interface where you can select the output voltage of the MCP4725.

Remember, before recording the program on your ESP32, edit the code with the SSID and password of your WIFI.

You can also set the I2C pins to be used via SDA_PIN and SCL_PIN, if you don't want them to be on pins 33 and 32.

#include <Wire.h>
#include <Adafruit_MCP4725.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>

// Configure your WiFi credentials
const char* ssid = "your_wifi_name";
const char* password = "your_wifi_password";

#define SDA_PIN 33
#define SCL_PIN 32
#define VCC_MCP4725 3.3 // Supply voltage of the MCP4725 in volts

Adafruit_MCP4725 dac;

AsyncWebServer server(80);

void setup() {
  Serial.begin(115200);

  // Connect to the WiFi network
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Connecting to WiFi...");
  }
  Serial.println("Connecting to WiFi...");

  // Print the assigned IP address
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());

  // Initialize I2C communication with custom pins
  Wire.begin(SDA_PIN, SCL_PIN);

  // MCP4725 configuration
  if (!dac.begin(0x60)) // The I2C address of the MCP4725 can vary (default is 0x60)
  {
    Serial.println("The MCP4725 sensor could not be found. Make sure it is connected correctly.");
  }
  else
  {
    dac.setVoltage(0, false); // Set voltage to 0, do not write to EEPROM
    Serial.println("MCP4725 found");
  }

  // Set the routes for the web server
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    String html = "<html><body><h1>Control of MCP4725</h1>";
    html += "<input type='range' min='0' max='4095' value='0' id='dacSlider' oninput='updateDAC(this.value)' style='width: calc(100% - 40px); margin: 20px;'>";
    html += "<p>Volts MCP4725: <span id='dacValue'>0.00</span> V</p>";
    html += "<p>Percentage: <span id='percentageValue'>0</span>%</p>";
    html += "<script>function updateDAC(value) {var volts = (value / 4095 * " + String(VCC_MCP4725) + ").toFixed(2); document.getElementById('dacValue').innerHTML = volts; document.getElementById('percentageValue').innerHTML = ((value / 4095) * 100).toFixed(2); var xhr = new XMLHttpRequest(); xhr.open('GET', '/setDAC?value=' + value, true); xhr.send();}</script>";
    html += "</body></html>";
    request-&gt;send(200, "text/html; charset=UTF-8", html.c_str());
  });

  server.on("/setDAC", HTTP_GET, [](AsyncWebServerRequest *request) {
    String valueStr = request-&gt;arg("value");
    int value = valueStr.toInt();
    dac.setVoltage(value, false); // Second parameter indicates whether to write to EEPROM
    request-&gt;send(200, "text/plain", "OK");
  });

  // Start the web server
  server.begin();
}

void loop() {
  // Your main code can go here
}

The delta-sigma modulation

The last method of voltage generation we are going to talk about is delta-sigma modulation.

It is a type of signal in which the analogue value is determined by the "...".pulse densityof the signal (instead of the "signal"), the "signal" (instead of the "signal") and the "signal".pulse width"of a PWM signal).

The main advantage of delta-sigma modulation is that it is very fast in making changes, which can be interesting for sound synthesis, for example, but we don't need that speed to "generate voltages", which is what this article is about.

One important thing is that, as in the case of the PWM signal, a filter is needed at the output and although sometimes an R-C filter (as we used with the PWM signal) may be sufficient, the manufacturer of the ESP32 suggests using a rather more complex low-pass filter.

Code for voltage generation by delta-sigma modulation

Of the microprocessors we are looking at here, the only one that has native support for generating signals with delta-sigma modulation is the ESP32, neither the Arduino nor the ESP8266 have this capability.

Delta-sigma modulation, also known as pulse density modulation, is a technique used in analogue-to-digital conversion (ADC) and modulation of analogue signals. This technique is noted for its ability to achieve high resolution and accuracy in the conversion of analogue to digital signals, especially in applications where a high signal-to-noise ratio is required.

Below is a brief description of how delta-sigma modulation works:

  1. Delta (Δ): Delta-sigma modulation takes its name from the first stage, which involves the formation of the delta signal (Δ). In this stage, the difference between the analogue input signal and the feedback signal is calculated and used to represent the signal information.
  2. Sigma (Σ): The delta signal is cumulatively summed (integrated) in the time domain to form the sigma signal (Σ). This process is performed using an integrator.
  3. Pulse density modulation: The sigma signal is compared to a reference value and a sequence of binary pulses is generated depending on whether the sigma signal is greater or less than the reference value. These binary pulses, which represent the information of the original signal, are known as density pulses.
  4. Filtering and Sampling: The density pulse sequence is filtered to remove unwanted high frequencies. The filtered signal is then sampled at a frequency much higher than the Nyquist frequency, allowing the analogue signal to be reconstructed with high accuracy.
  5. Decoding: In the final stage, the resulting digital signal is decoded to obtain the digital representation of the original analogue signal.

Delta-sigma modulation is commonly used in the conversion of analogue to digital signals in applications such as audio, communications and sensors. It provides high resolution and a good signal-to-noise ratio, although at the expense of a higher sampling rate. This approach has become very popular in oversampling analogue-to-digital converters (ADCs) used in high-fidelity audio applications and other systems where accuracy is critical.

Although delta-sigma modulation is not the main objective of this article, because it is intended for the generation of other types of signals, I leave you a very basic example of ESP32 code, so that you know "what it looks like".

#include 

void setup() {
  //setup delta-sigma on pin 27, channel 0 with a frequency of 1000 Hz
  sigmaDeltaSetup(27, 0, 1000);
  // Initialise channel 0 to 0V
  sigmaDeltaWrite(0, 0);
}

void loop() {
  // Raise the voltage smoothly
  // Will overflow to 256
  static uint8_t i = 0;
  sigmaDeltaWrite(0, i++);
  delay(100);
}

Materials I have used and recommend

👉 MCP4725

👉 Multimeter OWON XDM2041

👉 RIDEN RD6006W power supply unit

👉 Rigol DS1054Z Oscilloscope at Amazon UK

Leave a comment