ESP8266 software PWM performance comparison for controlling 12V LED strips

18 Aug 2021 | all notes

A practical performance comparison of the of the default software PWM that comes with Arduino ESP8266 (both v2.7.4 and v3.0.2) and Stefan Bruen’s new_pwm. Rather than using an oscilloscope to inspect the raw PWM output, I connected two transistors to the PWM outputs to drive dimmable LED strips, and use the LEDs’ brightness output as a proxy for PWM behaviour.

Hardware setup

Software setup

For every combination of PWM configuration settings, given by:

  1. PWM library (Arduino 2.7.4, Arduino 3.0.2, new_pwm)
  2. PWM frequency (20kHz, 10kHz, 1kHz, 500Hz, 100Hz)
  3. for Arduino PWM only: analogWriteRange() (63, 127, 255, …, 65535)

the Arduino sketches (see below) would enumerate the following duty cycle lengths:

For every of these duty cycles, the sketch would produce a series of 3 different PWM outputs:

  1. warm LEDs only
  2. cold LEDs only
  3. both LED types (with identical duty cycle)

10ms after committing new PWM settings, an automatic lux measurement of the effective light output was taken using the TCS34725AutoGain library. Measurement duration depended on the light level (see the library documentation for details), down to 2.4ms for the brightest light levels.

ESP8266 Arduino software PWM

The default software PWM for the ESP8266 is controlled using the Arduino-compatible functions analogWrite(), analogWriteRange() and analogWriteFreq(). This supposedly user-friendly API is maintained for convenience but can actually be misleading when it comes to understanding the effective output of the PWM. In particular, one might be led to believe that setting a higher analogWriteRange() would allow one to achieve more fine-grained (and therefore shorter) PWM output, when in fact they just make underflow more likely (see results below).

Stefan Bruen’s new_pwm

The API is based on the one original Espressif SDK PWM (which I haven’t been able to test myself). Both the PWM frequency and the duty cycle are set in absolute numbers of clock ticks (40ns each), so there is no notion of a PWM ‘range’ like with the Arduino.

Results

The graphs below show the lux output of the LEDs (y-axis, logarithmic) against the duty cycle (x-axis, also logarithmic). All measurements were taken in a dark room, so any measurements <= .01 lux are assumed to correspond to all LEDs being completely off, and are excluded. Interrupted lines (e.g. in the top right graph) signify missing data at intermediate duty cycle values – it’s not clear whether these actually represent discontinuities in the PWM playback, or (more likely) just glitches of the lux measurement code.

The graphs reveal a number of interesting patterns, each of which are explained in turn below:

For both Arduino PWM and new_pwm:

  1. brightness differences between single-LED and both-LED modes
  2. missing data (= LEDs completely off) for shorter duty cycles at higher PWM frequencies

Arduino PWM only:

  1. missing data (= LEDs completely off) for shorter duty cycles at smaller analogWriteRange()s
  2. flattening out (i.e. identical LED output) for a wide range of different duty cycles at the left of the graph
  3. flattening out (i.e. identical LED output) above 90% (0.9) duty cycle
  4. discontinuities/dips at around 10% (.1) duty cycle at low PWM frequencies/high analogWriteRange()s

Raw data for the graphs: new_pwm output, Arduino 3.0.2 PWM output, Arduino 2.7.4 PWM output

1. brightness differences between single-LED and both-LED modes

Comparing the two single-LED modes, same-duty-cycle lux output seems to be negligibly higher for the ‘cold’ white LEDs than the ‘warm’ white ones.

While one would expect the both-LED mode to produce about twice the lux of the single-LED modes, the factor seems to be closer to 3x for short duty cycles of the Arduino PWM, and consistently less than 2x for the new_pwm.

2. missing data (= LEDs completely off) for shorter duty cycles at higher PWM frequencies

The right-most column of graphs (PWM frequency 100Hz) stretch to much shorter duty cycles (and correspondingly lower lux outputs) than the graphs for higher PWM frequencies. This makes sense, as the absolute lower limit of the duty cycle is an absolute number of ticks, which is independent of the PWM frequency. At higher PWM frequencies, the absolute length of one PWM cycle is shortened, which means that the absolute shortest possible duty cycle is relatively longer. For more fine-grained PWM output it is therefore better to stick to as low frequency PWM as possible: 100Hz PWM should not lead to any noticeable flicker for humans, independently of any other software/wiring properties.

3. Arduino PWM only: missing data (= LEDs completely off) for shorter duty cycles at smaller analogWriteRange()s

Even at identical PWM frequencies (i.e. columns of graphs), the left end of the graphs are truncated at ever larger duty cycles for smaller analogWriteRange()s (marked as ‘Ard range’ on the graphs). This is simple numeric underflow: at an analogWriteRange() of 63, the shortest non-zero duty cycle that can be represented is 1/63rd.

4. Arduino PWM only: flattening out (i.e. identical LED output) for a wide range of different duty cycles at the left of the graph

While new_pwm output is completely proportional to duty cycle, Arduino PWM appears to suffer from another case of underflow which reveals itself as a rounding issue, where a wide range of representable duty cycles map onto the same effective (lowest possible) LED output. Even at identical PWM frequencies, the lowest possible output duty cycle is much longer (i.e. has worse resolution) than what can be achieved with new_pwm.

Take for example the two top most graphs in the left column: at a PWM frequency of 20kHz, new_pwm can produce a shortest possible duty cycle of 1/127, resulting in a smallest possible LED output of 1 lux (top left graph). Using Arduino PWM on the other hand (one graph down), duty cycles from 1/63 all the way down to 1/2047 produce the same minimal output of 10 lux.

Also noteworthy: this flattening out often occurs earlier (i.e. already for higher duty cycles) for the green curve signifying that both LEDs are driven together, i.e. when there are two PWM outputs and not just one (see e.g. the first two graphs in the second row, or first graph in the second-to-last row).

5. Arduino PWM only: flattening out (i.e. identical LED output) above 90% (0.9) duty cycle

An apparently well-known constraint of the native Espressif software PWM is that it cannot produce duty cycles greater than 90%. With so many other artefacts in the graphs, this flattening out in the highest duty cycle range is only apparent in the right-most (100Hz PWM frequency) of the Arduino PWM graphs with an analogWriteRange() of 4095 and less.

6. Arduino PWM only: discontinuities/dips at around 10% (.1) duty cycle at low PWM frequencies/high analogWriteRange()s

The Arduino PWM output in low PWM frequencies and/or high analogWriteRange()s conditions (in the top right corner) exhibit extreme discontinuties and dips at duty cycles above 10%. No idea why this might be the case, but it’s clearly to do with the PWM library, as new_pwm output increases flawlessly in this region (slight jitter in the topmost graphs might be explained by the shorter lux measurement periods employed by the TCS34725AutoGain library at brighter light conditions).

Other: duty cycles of 4 ticks or less (i.e. 160ns or less) don’t switch the LED on at all

From inspecting the raw duty cycles in number of clock ticks (which is what gets passed to the new_pwm PWM API) it becomes apparent that duty cycles of 4 ticks or less don’t seem to switch on the LEDs at all. Without inspecting the PWM output train it is not possible for me to tell if this is due to:

Conclusions

Arduino test sketches source code

Joint setup() code

#define CW D7 // 'cold' white, color temperature ~ 5500 Kelvin
#define WW D5 // 'warm' white, color temperature ~ 3000 Kelvin

#include "TCS34725AutoGain.h"
TCS34725 tcs;

void setup() {
  pinMode(CW, OUTPUT);
  digitalWrite(CW, 0);
  pinMode(WW, OUTPUT);
  digitalWrite(WW, 0);

  Serial.begin(115200);
  Serial.println();

  Wire.begin();
  if (!tcs.attach(Wire, TCS34725::Mode::Idle)) {
    Serial.println("No light sensor attached, exiting.");
    ESP.deepSleep(0);
  }
}

Measurement loop() for default Arduino IDE (analogWrite())

uint16_t nBitsToMask(byte nBits) {
  return 0xFFFF >> (16 - nBits);
}

void loop() {
  for (byte pwmBits = 6; pwmBits <= 16; pwmBits++) {
    uint16_t maxValue = nBitsToMask(pwmBits);
    analogWriteRange(maxValue);

    // pre-compute all cycle lengths we're interested in (never more than 22 actually)
    uint32_t duties[30];
    duties[0] = maxValue; // 100%
    // number of duty cycle lengths
    byte n = 1;
    for (; n <= 5; n++) {
      // 90% down to 50%
      duties[n] = ((10 - n) * duties[0]) / 10;
    }
    // half (right shift) as long as values are still positive
    for (n -= 1; duties[n] > 0; n++) {
      duties[n+1] = duties[n] >> 1;
    }
    for (byte i = 0; i < n; i++) {
      for (byte mix = 3; mix >= 1; mix--) {
        // 100 - 20.000Hz, default is 1000
        uint16_t freqs[5] = {100, 500, 1000, 10000, 20000};
        for (byte f = 0; f < 5; f++) {
          analogWriteFreq(freqs[f]);
          // need to be written after for the frequency to take effect?
          analogWrite(WW, duties[i] * (mix & 0x1));
          analogWrite(CW, duties[i] * (mix >> 1));
          delay(10);
          Serial.print(freqs[f]);
          Serial.print(',');
          Serial.print(maxValue);
          Serial.print(',');
          Serial.print(duties[i]);
          Serial.print(',');
          Serial.print(mix);
          Serial.print(',');
          if ((mix == 3 && f == 0) ? (tcs.autoGain() || true) : tcs.singleRead()) {
            Serial.print(tcs.lux());
            Serial.print(',');
            Serial.println((int16_t) tcs.colorTemperature());
          } else {
            Serial.println("NA,NA");
          }
        }
      }
    }
  }
}

Measurement loop() for Stefan Bruen’s new_pwm

// make newpwm use period of 1us, duty is always 40ns (i.e. 1/25th)
#define SDK_PWM_PERIOD_COMPAT_MODE 1
#include "newpwm.h"

uint32 io_info[2][3] = {
  {PERIPHS_IO_MUX_MTMS_U,  FUNC_GPIO14, 14}, // D5
  {PERIPHS_IO_MUX_MTCK_U,  FUNC_GPIO13, 13}, // D7
};

// frequency = 1.000.000 / period (which is given in us)
// periods corresponding to frequencies of:
// 100Hz, 500Hz, 1kHz, 10kHz 20kHz
uint32_t periods[5] = {10000, 2000, 1000, 100, 50};
// duty is 40ns, i.e. period*25 means 100% duty cycle

void loop() {
  pwm_init(periods[0], NULL, 2, io_info);
  // go through all frequencies (periods)
  for (byte f = 0; f < 5; f++) {
    pwm_set_period(periods[f]);

    // pre-compute all cycle lengths we're interested in (never more than 22 actually)
    uint32_t duties[30];
    duties[0] = 25*periods[f]; // 100%
    // number of duty cycle lengths
    byte n = 1;
    for (; n <= 5; n++) {
      // 90% down to 50%
      duties[n] = ((10 - n) * duties[0]) / 10;
    }
    // half (right shift) as long as values are still positive
    for (n -= 1; duties[n] > 0; n++) {
      duties[n+1] = duties[n] >> 1;
    }
    for (byte i = 0; i < n; i++) {
      for (byte mix = 3; mix >= 1; mix--) {
        pwm_set_duty(duties[i] * (mix & 0x1), 0); // WW=0
        pwm_set_duty(duties[i] * (mix >> 1), 1); // CW=0
        pwm_start();
        delay(10);
        Serial.print(1000000 / periods[f]);
//        Serial.print(',');
//        Serial.print(25*periods[f]); = 25000000 / periods[f]
        Serial.print(',');
        Serial.print(duties[i]);
        Serial.print(',');
        Serial.print(mix);
        Serial.print(',');
        if ((mix == 3) ? (tcs.autoGain() || true) : tcs.singleRead()) {
          Serial.print(tcs.lux());
          Serial.print(',');
          Serial.println((int16_t) tcs.colorTemperature());
        } else {
          Serial.println("NA,NA");
        }
      }
    }
  }
}

Comments