· embedded programming

Arduino Internals — What's behind the magic?

Embedded programming abstraction layers and reverse engineering shenanigans.

The Arduino ecosystem changed the world of DIY electronics, let's take a look under the hood to figure out how that happened.

Arduino is an open-source software and hardware platform tailored for electronics beginners and enthusiasts alike. Since the early models’ releases (Arduino Diecimila in 2007 and Duemilanove in 2009), they have become ubiquitous to the point that “an Arduino” is often used as a synonym for “a microcontroller”, and have been used in countless projects by makers around the world.

Arduino Duemilanove 2009b

An Arduino Duemilanove. The first Arduino I actually programmed. Source: Wikipedia

What this project actually achieved is simplifying microcontroller programming to the extreme, replacing obscure datasheet diving and reference implementation diagrams dissection with intuitive function calls mostly portable across the entire official product range. The “hello world” of electronics, the blinking LED was simplified from this:

// Compile with:       avr-gcc -mmcu=atmega328p -DF_CPU=16000000L
// Create binary with: avr-objcopy -O ihex -R .eeprom a.out a.hex
// Upload with:        avrdude -p atmega328p -c arduino -P /dev/ttyUSB0 -b 57600 -D -U flash:w:a.hex:i
// Only works on the Arduino Uno series, with a 16MHz µC
#include <avr/io.h>
#include <util/delay.h>

int main() {
  // Set the LED's pin to output
  DDRB |= (1 << PB5);

  while (1) {
    // Toggle the LED pin state
    PORTB ^= (1 << PB5);
    // Wait
    _delay_ms(1000);
  }
}

To this:

// In the Arduino IDE, click "upload"
// Portable across all Arduinos which have a built-in LED
#include <Arduino.h>

void setup() {
  // Set the LED's pin to output
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  // Switch on the LED
  digitalWrite(LED_BUILTIN, HIGH);
  // Wait
  delay(1000);
  // Switch off the LED
  digitalWrite(LED_BUILTIN, LOW);
  // Wait
  delay(1000);
}

Of course, this is just scratching the surface, since opening up the world of microcontrollers to the masses also sparked a lot of open-source development, and many libraries can be used to interact with the “shields”, add-ons to the base Arduino board: motor drivers, Ethernet interfaces, IMUs and countless others.

Various starter kits can also be used if you want to prototype your own circuits with an Arduino Uno compatible board and some standard components, such as an ultrasonic distance reader, humidity and temperature sensor, 7-segment displays, etc.

The USB (data and power!) connection on most boards means these are just plug and play: buy an Arduino board from your favorite reseller, and you can program it from any desktop or laptop PC.

Recently however, a lot of “Arduino-compatible” boards started appearing on the market, such as the hugely popular ESP8266 from Espressif Systems, available on the original NodeMCU, but also in Arduino-Uno footprints, with built-in Wi-Fi capabilities, much more processing power as well as large storage.

WeMos D1

WeMos D1, one of the many ESP8266 boards

Even though those were not initially programmable from the Arduino IDE, the Arduino community added support for these as they became one of the most go-to choices for IoT projects over the recent years.

But with such a wide variety of hardware, how does the Arduino platform provide an almost seamless development experience on AVR, ARM, Xtensa and many other architectures?

Experienced embedded developers know this means installing various toolchains for cross-compiling, fiddling with linker scripts to get the right memory layout for your specific board revision, and a bunch of other boring stuff we won’t get to in this article. Instead, we’ll be focusing on the software side: what’s exactly behind #include <Arduino.h> on a few select platforms, and why we would need to care about it.

All the examples in this article were developed using PlatformIO, which I highly recommend instead of the Arduino IDE. Although the recent releases have made huge progress, I prefer the versatility of my text editor (with ccls completion) and compiling/uploading with a simple pio run -t upload command.

Arduino framework architecture

For most software projects, the go-to way for getting information about library internals would be hitting the reference manual online. In the case of Arduino, it is located at https://www.arduino.cc/reference/en. However, it is surprisingly succint: function signatures included in the documentation don’t even show the arguments’ type1.

For a beginner, using the C++ language, whose type conversions are a minefield2, on a target that doesn’t support debugging (and where buffer overflows result in usually very funny looking output on your serial port monitor) can lead to many headaches and hours of painful trial and error.

This reference was also written for the original Arduino boards, i.e. the AVR-based ones. The most notable example is the PROGMEM variable modifier, which is only available on AVR targets. Trying to use it on non-AVR targets results in a compile-error, unless your code detects it is being compiled for a non-AVR target.

The reference documentation also mixes standard library functions, plain datatypes and complex ones3, Arduino program structure and C++ language details on a single page.

To be absolutely clear: I am not saying this is a bad thing. This documentation has been written with the clear intent of being a one-stop point, easy to approach for a beginner, and should be sufficient for hobbyists to get started, until they are more at ease with electronics and/or software development — and it accomplishes this goal rather well. But for more advanced users, it is clearly lacking information about design choices and platform differences — a notable issue when promoting Arduino code as platform-independent.

My goal when writing this article was to share with the world the process I went through to unearth some differences I noticed when working with various boards (ESP8266, Arduino Nano 33 BLE, ATTiny85-based Digispark and others). So let’s get started!

What’s behind Arduino.h exactly?

Using a new board in the Arduino IDE is as simple as downloading the right core through the board manager. We can look around starting from a basic sketch to peek at the internals of what this core really is. Starting with Arduino.h, which all Arduino code should #include to access the standard library functions described in the reference documentation, using an IDE with code completion4, we can just ask the editor to open the header file under our cursor and follow the include graph.

This will bring up this version of Arduino.h from the core installation path. When building for an AVR board such as the Arduino Uno, this will bring up $AVR_CORE/cores/arduino/Arduino.h, which already teaches us a lot:

  • There’s all the constants in the documentation, as #defines, and some functions which are actually implemented as macros (e.g. min and max).
  • Gated behind __cplusplus, includes for some types defined by Wiring, the predecessor of the Arduino framework, itself based on Processing. The main one being WString.h, defining the String type.
  • Some board-specific stuff, like including USB API support or hardware serial support depending on preprocessor definitions.
  • A final include for pins_arduino.h, which contains mappings from Arduino pin numbers (0-13 as what’s passed to digitalWrite) to physical microcontroller pins (PORTB, PORTC registers and others). This one is stored in the $AVR_CORE/variants/$VARIANT folder: depending on the current board, the right -I flag will be passed to the compiler to include mappings for the target, for example the Arduino Mega ($VARIANT = mega), or Uno ($VARIANT = standard).

Noting the dependencies while following along, we can draw a (partial so it fits in a blog post) include graph between the different components of this core:

flowchart TB
  sketch[Blink.ino] --> arduino_h

  subgraph avr_std[AVR Toolchain]
    avr_io[avr/io.h]
    avr_pgmspace[avr/pgmspace.h]
    avr_stdio[stdio.h]
  end

  subgraph avr_core[Arduino AVR Core]
    subgraph cores/arduino
      arduino_h[Arduino.h]
      arduino_h --> wstring_h[WString.h]
      arduino_h --> print_h[Print.h]

      arduino_h --> avr_pgmspace
      print_h --> avr_stdio
      wstring_h --> avr_pgmspace
    end

    arduino_h -- -I flag --> arduino_pins_h_standard
    arduino_h -.-> arduino_pins_h_others

    arduino_h --> avr_io

    subgraph variants
      subgraph standard
	 arduino_pins_h_standard[arduino_pins.h] ---> avr_io
	 arduino_pins_h_standard ---> avr_pgmspace
      end

      subgraph others[...]
	 arduino_pins_h_others[arduino_pins.h] ---> avr_io
	 arduino_pins_h_others ---> avr_pgmspace
      end
    end
  end

Arduino AVR partial include graph

This Arduino.h is already hardware-specific, even if most of it is hardware-independent. Its definitions follow what we can see in the reference documentation, but it is definitely not portable. It is however shared for all boards using the same architecture, and maintained by the same entity. In this case, this is the original AVR core, and it is even available on GitHub, in a sensible location: https://github.com/arduino/ArduinoCore-avr.

Let’s explore it a bit more: the core also contains the actual implementations of functions from the Arduino framework, which is what we’re after. To keep this short, we will look at digitalWrite, about which there is already a lot to say.

Arduino’s digitalWrite on Atmel’s AVR platform

Using our trusty editor and it is go to implementation feature, we can quickly find it in wiring_digital.c, in all its microcontroller-programming-simplifying glory:

void digitalWrite(uint8_t pin, uint8_t val)
{
	uint8_t timer = digitalPinToTimer(pin);
	uint8_t bit = digitalPinToBitMask(pin);
	uint8_t port = digitalPinToPort(pin);
	volatile uint8_t *out;

	if (port == NOT_A_PIN) return;

	// If the pin that support PWM output, we need to turn it off
	// before doing a digital write.
	if (timer != NOT_ON_TIMER) turnOffPWM(timer);

	out = portOutputRegister(port);

	uint8_t oldSREG = SREG;
	cli();

	if (val == LOW) {
		*out &= ~bit;
	} else {
		*out |= bit;
	}

	SREG = oldSREG;
}

To set the logic level of an output pin, digitalWrite actually does the following:

  • Check that the given pin is suitable for digital output (the digitalPinTo* family of functions will return NOT_* sentinel values otherwise)
  • Disable hardware PWM on the target pin if it was enabled
  • Disable hardware interrupts, set the target value, and then re-enable them

For a microcontroller running at 16MHz, digitalWrite is a noticeably expensive operation. Indeed, it does:

  • 3 PROGMEM reads (digitalPinTo* function calls) for timer, bit and port.
  • 2 branches to validate the pin values. Note that the function fails silently for wrong pin values.
  • 1 more PROGMEM read for getting the port’s output register from its port number port.

    Note: by this point, all the values in the data flow are returned from reading constant arrays in program memory with pgm_read_byte. Even though these values should be constant folded if pin and val are themselves constants, the compiler can’t reason enough yet to perform this optimization. Which means the rest of the operations can’t be optimized for constants.

  • Disable interrupts5.
  • One last branch to determine if a bit should be cleared or set.
  • Apply the bitmask: this is a read-modify-write operation, and thus non-atomic, which requires disabled interrupts for correctness.
  • Restore interrupts.

These multiple levels of indirections are, at the time of writing, not optimized away by avr-gcc. The following Arduino sketch (which only uses constants for both the pin and value):

#include <Arduino.h>

void setup() { pinMode(LED_BUILTIN, OUTPUT); }

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  digitalWrite(LED_BUILTIN, LOW);
}

Compiles to the following assembly6:

000000e0 <digitalWrite.constprop.0>:
  # [truncated: 54 instructions for computing timer, bit, port and other checks]
  #   uint8_t oldSREG = SREG;
 158:	9f b7       	in	r25, 0x3f	# 63
  #   cli();
 15a:	f8 94       	cli
  #   if (val == LOW) {
 15c:	81 11       	cpse	r24, r1
 15e:	04 c0       	rjmp	.+8      	# 0x168 <digitalWrite.constprop.0+0x88>
  #     *out &= ~bit;
 160:	8c 91       	ld	r24, X
 162:	20 95       	com	r18
 164:	28 23       	and	r18, r24
  #   } else {
 166:	02 c0       	rjmp	.+4      	# 0x16c <digitalWrite.constprop.0+0x8c>
  #     *out |= bit;
 168:	ec 91       	ld	r30, X
 16a:	2e 2b       	or	r18, r30
 16c:	2c 93       	st	X, r18
  #   }
  #   SREG = oldSREG;
 16e:	9f bf       	out	0x3f, r25	# 63
  # }
 170:	08 95       	ret

00000206 <main>:
 # [truncated: Arduino framework initialization]
 # [truncated: inlined call to pinMode(...) ]

 # Note: loop is inlined since it is only called from Arduino's main
 # digitalWrite(LED_BUILTIN, HIGH);
 2c6:	81 e0       	ldi	r24, 0x01	# 1
 2c8:	0e 94 70 00 	call	0xe0	# 0xe0 <digitalWrite.constprop.0>
 # digitalWrite(LED_BUILTIN, LOW);
 2cc:	80 e0       	ldi	r24, 0x00	# 0
 2ce:	0e 94 70 00 	call	0xe0	# 0xe0 <digitalWrite.constprop.0>
 # Note: the following is the Arduino's while loop around calling loop()
 2d2:	20 97       	sbiw	r28, 0x00	# 0
 2d4:	c1 f3       	breq	.-16     	# 0x2c6 <main+0xc0>

 # [truncated: more main code]

While its equivalent pure-AVR code:

#include <avr/io.h>

int main() {
  DDRB |= (1 << PB5);

  while (1) {
    PORTB |= (1 << PB5);
    PORTB &= ~(1 << PB5);
  }
}

Compiles to the optimal assembly below (sbi sets a specific bit in a port register, and cbi clears it):

00000080 <main>:
  # DDRB |= (1 << PB5);
  80:	25 9a       	sbi	0x04, 5	# 4
  # while (1) {
  # PORTB |= (1 << PB5);
  82:	2d 9a       	sbi	0x05, 5	# 5
  # PORTB &= ~(1 << PB5);
  84:	2d 98       	cbi	0x05, 5	# 5
  # }
  86:	fd cf       	rjmp	.-6      	# 0x82 <main+0x2>

Which brings us to the essential question: is the Arduino framework’s implementation right?

In a sense, yes: it translates user-facing Arduino board pin identifiers to hardware pins under the hood, ensures there are no conflicts with PWM settings, and does update the output register in a correct (atomic) way. But, this is a very costly abstraction (67 instructions + function call compared to one single-cycle instruction). If your code does some high-frequency bit banging (every few clock cycles), you won’t be able to use digitalWrite, which is orders of magnitude slower, and has side effects (disabling interrupts temporarily).

Of course, these abstractions are what make Arduino beginner-friendly, but are limited because of their implementation in C++. A language with more robust compile-time guarantees could reason more efficiently about hardware port usage, and could also compile to the optimal version — but the Rust backend isn’t there yet.

More details about this assembly comparison are available on this page. As an extra comparison point, a proof of concept for a template-based hardware abstraction is included, which compiles to the same optimal assembly as raw AVR code. Please note that the Arduino version also has timer interrupts for maintaining a clock, which explains most of the program size difference — aside from the actual digitalWrite calls.

Personally, I think these limitations should at least be mentioned in the reference documentation. This wouldn’t overload beginners browsing the function reference for the first time, as long as it is hidden in an expandable “For advanced users” section. This function isn’t the only one with caveats, and it is likely that large projects using the Arduino platform will encounter more of those — undocumented — limitations and performance/readability trade-offs.

Arduino’s analogWrite on the Mbed framework

Backtracking a bit, we can look at a core’s implementation for another platform. In this case, the ARM Mbed core for Mbed enabled devices. Mbed is an open-source operating system for IoT applications running on the ARM architecture. The goal of this kind of operating systems is to simplify embedded development for the supported platforms, the same way the Arduino framework simplifies development for the Arduino-enabled boards.

The Arduino Nano 33 BLE is one example of such a board. Its nRF52840 SoC contains a 32-bit ARM Cortex-M4 CPU running at 64MHz as well as a Bluetooth chip which supports BLE. This is the board we will be using as a target for looking under the hood.

Arduino Nano 33 BLE

Arduino Nano 33 BLE. Source: arduino.cc

The Arduino.h header is located in the same directory, relative to the Mbed core root, and corresponds roughly to what we saw before for the AVR core: generic Arduino definitions and “hardware”-specific. “Hardware” in quotes since the Mbed framework is already an abstraction layer over the actual processor:

graph LR
  sketch[Blink.ino] --> arduino_h

  subgraph arm_mbed[Arm Mbed]
    subgraph mbed_target_nrf5x[nrf5X SDK]
      nrfx_h[nrfx.h]
    end

    mbed_h[mbed.h] -.-> nrfx_h
  end

  subgraph mbed_core[Arduino Mbed Core]
    arduino_h[Arduino.h] --> mbed_h
  end

Arduino Mbed partial include graph

In this case, the Arduino core provides:

  • An abstraction over the Mbed API, for platform-independence of Arduino code, and to simplify it for beginners
  • The hardware pin mappings for the various boards (variants), since Mbed is only responsible for interacting with the CPU

This can easily be seen by looking at the implementation of, let’s say analogWrite in cores/arduino/wiring_analog.cpp:

static int write_resolution = 8;
// [...]
void analogWrite(PinName pin, int val)
{
  pin_size_t idx = PinNameToIndex(pin);
  if (idx != NOT_A_PIN) {
    analogWrite(idx, val);
  } else {
    mbed::PwmOut* pwm = new mbed::PwmOut(pin);
    pwm->period_ms(2); //500Hz
    float percent = (float)val/(float)(1 << write_resolution);
    pwm->write(percent);
  }
}

void analogWrite(pin_size_t pin, int val)
{
  if (pin >= PINS_COUNT) {
    return;
  }
#ifdef DAC
    if (pin == DAC) {
      analogWriteDAC(digitalPinToPinName(pin), val);
      return;
    }
#endif
  float percent = (float)val/(float)(1 << write_resolution);
  mbed::PwmOut* pwm = digitalPinToPwm(pin);
  if (pwm == NULL) {
    pwm = new mbed::PwmOut(digitalPinToPinName(pin));
    digitalPinToPwm(pin) = pwm;
    pwm->period_ms(2); //500Hz
  }
  pwm->write(percent);
}
// [...]

Looking at this implementation:

  • The first overload is called if you pass a PinName for the pin number, which is an enum declared by Mbed’s abstraction layer. If the pin exists in the Arduino’s mapping (PinNameToIndex), it is forwarded to the second overload. Otherwise, writing the PWM parameters is delegated to an mbed::PwmOut which is subsequently leaked.
  • The second overload is called when passing a raw pin number, which is what Arduino sketches typically do. Skipping over the DAC part, this function sets up a singleton mbed::PwmOut for this pin in a global array somewhere whose mapping is resolved by the digitalPinToPwm macro. This allocation only happens once, the PwmOut object being reused on a subsequent call to analogWrite for the same pin.

This implementation is correct: since invoking the constructor for a mbed::PwmOut object actually initializes the PWM hardware for the given pin7, this side effect only appears when the first analogWrite call is issued. Being able to change the PWM frequency would be nice, but it is currently not possible on other platforms either. At least, not through the official Arduino API8.

However, both the Mbed documentation and the Arduino documentation fail to mention one major limitation. The Arduino 33 BLE is equipped with a RGB LED connected to pins 22, 23, and 24 for its R, G and B channels respectively. It’s a perfect candidate for PWM to dim the various channels:

#include <Arduino.h>

void setup() {}

void loop() {
  auto now = millis();

  // Set the RGB LED color
  analogWrite(LEDR, (now + 100) % 255);
  analogWrite(LEDG, (now + 200) % 255);
  analogWrite(LEDB, (now + 300) % 255);
}

This sketch works perfectly fine, but is rather limited. In an actual project, you might also be controlling other devices using PWM, which the Arduino Nano 33 BLE is perfectly able to do, since PWM is supported on all digital pins, according to its technical specifications. Maybe we could control some extra pins:

#include <Arduino.h>

void setup() {}

void loop() {
  auto now = millis();

  // Set the RGB LED color
  analogWrite(LEDR, (now + 100) % 255);
  analogWrite(LEDG, (now + 200) % 255);
  analogWrite(LEDB, (now + 300) % 255);

  // Added: a red LED on 11, and a green one on 12?
  analogWrite(11, (now + 400) % 255);
  analogWrite(12, (now + 500) % 255);
}

When compiled and uploaded to the board, you don’t get five PWM pins but instead a board that disappears from the USB devices on your computer, blinking its builtin LED in a — rather ominous the first time — Morse code SOS pattern. Before assuming the board burnt-out, I tried the following steps:

  • Googling the issue: no significant results at the time to help9.
  • Re-uploading the previous code after resetting: works.
  • Replacing analogWrite calls with mbed::PwmOut objects: doesn’t work.

This rules out hardware issues, as well as the Arduino abstraction layer over Mbed. Off to the Mbed source code we go! Note that the core downloaded by the board manager or PlatformIO does not contain the Mbed source code. It’s a rather large project, and as such is distributed in a pre-compiled version:

$ find -name *.a
./variants/ARDUINO_NANO33BLE/libs/libmbed.a
./variants/ARDUINO_NANO33BLE/libs/libcc_310_core.a
./variants/ARDUINO_NANO33BLE/libs/libcc_310_trng.a
./variants/ARDUINO_NANO33BLE/libs/libcc_310_ext.a
./variants/PORTENTA_H7_M4/libs/libmbed.a
./variants/PORTENTA_H7_M4/libs/libopenamp.a
./variants/PORTENTA_H7_M7/libs/libmbed.a

Thankfully, the Mbed source code is available on GitHub. Following the dependency chain, we can find the source of our issue:

  • mbed::PwmOut is declared in drivers/include/drivers/PwmOut.h. It references hal/pwmout_api.h which, as the name indicates, is an Hardware Abstraction Layer (HAL) for the PwmOut API.
  • The implementation of mbed::PwmOut in drivers/source/PwmOut.cpp confirms this: the class methods call methods from this HAL API in order to change the actual hardware state.
  • Looking at hal/include/hal/pwmout_api.h, the comments mention that the target should implement the methods declared in this file in order to provide PWM output capabilities to the user.
  • The CPU on the Arduino Nano 33 BLE is a Nordic nRF52840. The Mbed source code does indeed contain a target for this architecture, in targets/TARGET_NORDIC/TARGET_NRF5x.
  • A quick find targets/TARGET_NORDIC/TARGET_NRF5x -name pwmout_api.* reveals the PWM HAL implementation for this target resides in targets/TARGET_NORDIC/TARGET_NRF5x/TARGET_NRF52/pwmout_api.c. Bingo!

Skimming through this implementation, the first interesting thing is the nordic_pwm_init function:

static void nordic_pwm_init(pwmout_t *obj)
{
    MBED_ASSERT(obj);

    /* Default configuration:
     * 1 pin per instance, otherwise they would share base count.
     * 1 MHz clock source to match the 1 us resolution.
     */
    nrfx_pwm_config_t config = {
        .output_pins  = {
            obj->pin,
            NRFX_PWM_PIN_NOT_USED,
            NRFX_PWM_PIN_NOT_USED,
            NRFX_PWM_PIN_NOT_USED,
        },
        .irq_priority = PWM_DEFAULT_CONFIG_IRQ_PRIORITY,
        .base_clock   = NRF_PWM_CLK_1MHz,
        .count_mode   = NRF_PWM_MODE_UP,
        .top_value    = obj->period,
        .load_mode    = NRF_PWM_LOAD_COMMON,
        .step_mode    = NRF_PWM_STEP_AUTO,
    };

    /* Initialize instance with new configuration. */
    ret_code_t result = nrfx_pwm_init(&nordic_nrf5_pwm_instance[obj->instance],
                                      &config,
                                      NULL);

    MBED_ASSERT(result == NRFX_SUCCESS);
}

A couple of things worth noting:

  • It only uses one pin out of four in what looks like a configuration structure for PWM (nrfx_pwm_config_t.output_pins).
  • Initializing a PWM instance an fail, and is thus checked by the MBED_ASSERT macro. This macro is used multiple times through this implementation. Would this assertion failing trigger the blinking SOS behavior we have encountered?

Finding out the behavior of this macro is left as an exercise to the reader. But since you are still reading this, you probably quickly found out that a failed assertion leads to an eventual call to mbed_die which blinks LED1 in an SOS pattern indefinitely. Our hypothesis is confirmed!

If you somehow managed to follow this source-digging carefully, you might have noticed nordic_pwm_init is a static function: it is only a helper function for implementing the HAL, which is later defined in the file. Namely, pwmout_init:

void pwmout_init(pwmout_t *obj, PinName pin)
{
    DEBUG_PRINTF("pwmout_init: %d\r\n", pin);

    MBED_ASSERT(obj);

    /* Get hardware instance from pinmap. */
    int instance = pin_instance_pwm(pin);

    MBED_ASSERT(instance < (int)(sizeof(nordic_nrf5_pwm_instance) / sizeof(nrfx_pwm_t)));

    /* Populate PWM object with default values. */
    obj->instance = instance;
    obj->pin = pin;
    obj->pulse = 0;
    obj->period = MAX_PWM_COUNTERTOP;
    obj->percent = 0;
    obj->sequence.values.p_common = &obj->pulse;
    obj->sequence.length = NRF_PWM_VALUES_LENGTH(obj->pulse);
    obj->sequence.repeats = 0;
    obj->sequence.end_delay = 0;

    /* Set active low logic. */
    obj->pulse |= SEQ_POLARITY_BIT;

    /* Initialize PWM instance. */
    nordic_pwm_init(obj);
}

Interestingly enough, this code is very similar to the Arduino implementation of analogWrite. The pin number is mapped to an instance number:

    /* Get hardware instance from pinmap. */
    int instance = pin_instance_pwm(pin);

pin_instance_pwm actually allocates hardware PWM instance numbers for the given pin. If no more instances are available, it will return the NC constant to indicate no such instance is available:

/**
 * Brief       Find hardware instance for the provided PWM pin.
 *
 *             The function will search the PeripheralPin map for a pre-allocated
 *             assignment. If none is found the allocation map will be searched
 *             to see if the same pins have been assigned an instance before.
 *
 *             If no assignement is found and there is an empty slot left in the
 *             map, the pins are stored in the map and the hardware instance is
 *             returned.
 *
 *             If no free instances are available, the default instance is returned.
 *
 * Parameter   pwm   pwm pin.
 *
 * Return      Hardware instance associated with provided pins.
 */
int pin_instance_pwm(PinName pwm)

Thus, how many instances are there? four, according to the declaration preceding pin_instance_pwm. This means that when pwmout_init runs out of hardware PWM instances, the following assertion will fail:

    MBED_ASSERT(instance < (int)(sizeof(nordic_nrf5_pwm_instance) / sizeof(nrfx_pwm_t)));

Which in turn, stops the CPU and blinks the builtin LED in an SOS pattern. Mystery solved! 🎉

But hold on. Why say PWM output is supported on all digital pins if we can only use four of them at the same time?

Well, according to the product specification, the nRF52840 does feature 4x four channel pulse width modulator (PWM) unit with EasyDMA. What this actually means is that there are four fully-independent PWM modules (the hardware instances in the code we just studied), each of which can drive four pins. This brings the total of PWM pins to 16, which is much more reasonable as long as you can group them into the four PWM modules available.

If you spend more time digging through the Nordic SDK documentation for PWM, you will find out that the 4 pins of a single instance will share the same PWM frequency, but you can still set different duty cycles. ARMed with this knowledge, you may now implement an nRF-specific PWM port that can drive 4 pins at the same time, but only using one hardware instance. See my implementation which resulted from this reverse-engineering at nrf_pwm_port.hpp and nrf_pwm_port.cpp.

This leaves us with a fully-dimmable RGB LED and 3 possible analogWrite-controllable pins. Problem solved!

Conclusion

I wanted to share this reverse-engineering adventure as I think it could be useful for other developers working with the Arduino ecosystem. Most of the discoveries mentioned here are to my knowledge, at the time of writing this article, not documented anywhere.

To me, even with those hidden caveats, Arduinos are still an excellent platform for beginners and hobbyists. These limitations should not be encountered in most cases, and more experienced developers should be able to work around these issues by reaching for the hardware SDKs directly.

The only major pain point I had working with Arduino libraries is the strong coupling with the hardware. If you wish to test some of your code on your computer instead of the target board (PlatformIO has a native target just for this), you have to design your code around the fact that Arduino.h is not available for desktop targets, even if most of it is hardware-independent. But we shall go down this rabbit hole another time.

Until then, happy hacking!

Footnotes

  1. See “Syntax” and “Parameters” in https://www.arduino.cc/reference/en/language/functions/digital-io/digitalwrite/

  2. Using this digest version of the C++ standard for implicit conversions, try predicting what passing an int to a function that expects a byte (Arduino-specific definition). Bonus points if you can describe what happens on overflow on the various architectures this code could be run on.

  3. int, char, float and other built-in data-types are C++ built-ins, supported directly by the target platform and are basically free to create. This isn’t the case of the String type, which allocates memory, an expensive and error-prone operation (running out of heap space overwrites your program’s stack — a “technicality” not mentioned in the documentation).

  4. Creating a project with pio init --ide vim will create the necessary files for the ccls language server to provide completion and go-to definition for Arduino code. This what I mainly used for this work.

  5. On AVR, setting a bit in an output register is an atomic operation (sbi instruction). However, *port |= mask is a read-modify-write operation since mask may set more than one bit and/or not be a constant. By being 3 instructions long, there is a chance an interruption could happen between the read and write, and break the code. Thus, a critical section is required, which equates to temporarily disabling interrupt handling.

  6. PlatformIO uses -flto -Os as the default optimization level, but changing those settings didn’t influence the result significantly.

  7. The official documentation for mbed::PwmOut does not mention it, but looking through the source code you can find that PwmOut::init invokes pwmout_init from the hardware abstraction layer, which does indeed initialize the PWM hardware.

  8. If you pick the right PWM pins on an AVR board, you can access the registers directly and set the PWM frequency, as described on the Arduino Wiki. On an Mbed board, you can just use the mbed::PwmOut class directly.

Back to Blog