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
- Results
- 1. brightness differences between single-LED and both-LED modes
- 2. missing data (= LEDs completely off) for shorter duty cycles at higher PWM frequencies
- 3. Arduino PWM only: missing data (= LEDs completely off) for shorter duty cycles at smaller
analogWriteRange()
s - 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
- 5. Arduino PWM only: flattening out (i.e. identical LED output) above 90% (0.9) duty cycle
- 6. Arduino PWM only: discontinuities/dips at around 10% (.1) duty cycle at low PWM frequencies/high
analogWriteRange()
s - Other: duty cycles of 4 ticks or less (i.e. 160ns or less) don’t switch the LED on at all
- Conclusions
- Related links
- Arduino test sketches source code
Hardware setup
- a WeMos D1 Mini…
- …outputting PWM on
GPIO14
(akaD5
) andGPIO13
(akaD7
)… - …each driving a FQP30N06L transistor…
- …each switching the drain from the two LED types of a 5m tunable color temperature white LED strip (2836 type)…
- …which are powered from a 12V 5A power supply.
- each GPIO/transistor input is pulled down by a
100K
resistor (to avoid bright flashes on startup when the output pins are still floating) - effective lux output of the LED strips is measured using a TCS34725 sensor pointing at the LED strip (since only relative changes in brightness in response to PWM input are of interest I did not carry out any further calibration of the lux levels)
Software setup
For every combination of PWM configuration settings, given by:
- PWM library (Arduino 2.7.4, Arduino 3.0.2,
new_pwm
) - PWM frequency (20kHz, 10kHz, 1kHz, 500Hz, 100Hz)
- for Arduino PWM only:
analogWriteRange()
(63, 127, 255, …, 65535)
the Arduino sketches (see below) would enumerate the following duty cycle lengths:
- 100%, 90%, …, 50%
- starting from 50%, halving the duty cycle (i.e. 1/4, 1/8, 1/16,…) until the shortest representable non-zero duty cycle is reached
For every of these duty cycles, the sketch would produce a series of 3 different PWM outputs:
- warm LEDs only
- cold LEDs only
- 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
:
- brightness differences between single-LED and both-LED modes
- missing data (= LEDs completely off) for shorter duty cycles at higher PWM frequencies
Arduino PWM only:
- missing data (= LEDs completely off) for shorter duty cycles at
smaller
analogWriteRange()
s - flattening out (i.e. identical LED output) for a wide range of different duty cycles at the left of the graph
- flattening out (i.e. identical LED output) above 90% (0.9) duty cycle
- 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:
- the PWM library
- the transistor characteristics
- the pulldown resistor
Conclusions
- Based on measuring LED output, Arduino ESP8266 software PWM is inaccurate/unreliable at both very short and very long duty cycles. Improvements to software PWM with Arduino 3.0.2, appear to have smoothed out some of the rounding underflow problems, but fundamental flaws remain.
- there seems to be a general sentiment that inclusion of the
new_pwm
code into Arduino is not possible, mostly based on some bickering about license differences which actually looks pretty irrelevant given thatnew_pwm
is a drop-in replacement for an existing API rather than a new core piece of functionality
Related links
- Transistor transfer characteristics
- Choosing a MOSFET for switching power from logic level input
- Gate and pull-down resistor value for MOSFET
- Picking a pull-up resistor value
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");
}
}
}
}
}