Motivation

Der ESP32 ist mit 240 MHz Prozessortakt ein sehr schneller μC. In einem Projekt ging es darum, einen schnellen A/D-Wandler (AD7606) mit 200 kSpS  auszulesen, vor zu verarbeiten und weiter zu leiten. Bei den E/A-Funktionen ist der ESP32 aber erstaunlich langsam. Die Arduino-Funktion digitalWrite benötigt zur Ausführung etwa 120ns. Das sind etwa 29 Prozessor-Takte. Ähnliches gilt auch für die lange Response-Zeit bei den Pin-Change-Interrupts, die zwischen 3 und 5μs beträgt.

In­halts­ver­zeich­nis

GPIO-Timing

1. Erzeugen eines Takt-Signals

2. Taktsignal mit Datenerfassung

Einlesen eines einzelnen Bits

Einlesen von zwei Bits

Einlesen von vier Bits

Einlesen von acht Bits

Pin-Change Interrupt

Latenzzeit-Test

Ursache

GPIO-Timing

Sucht man im Netz findet, findet man einige Hinweise. Zunächst einmal werden die GPIOs nicht mit dem Prozessor-Takt angesteuert, sondern über den APB-Bus. Dieser Bus wird lediglich mit 80 MHz betrieben, ist also dreimal langsamer als der CPU-Takt. Im Espressif-Blog (www.esp32.com) findet man:

The APB bus has some latency, and as the GPIO registers are defined as volatile, the core will make sure the APB write has succeeded before sending a new one. That takes a few clock cycles, so I'm entirely not surprised that you only reach 50ns (www.esp32.com/viewtopic.php?t=17152)).

Die Methoden in der Arduino-Umgebung sind darauf ausgelegt, dass sie möglichst problemlos benutzt werden können. Das wird i.d.R. mit zusätzlichem Code erkauft, der die Funktionen verlangsamt. Will man die maximale Geschwindigkeit erreichen, muss man sich also näher mit dem zu Grunde leigen Code beschäftigen.

Derf Jagged hat einige Test zur GPIO-Performance durchgeführt (https://www.reddit.com/r/esp32/comments/f529hf/results_comparing_the_speeds_of_different_gpio/), die mich dazu angeregt haben das Problem tiefer zu durchleuchten.

1. Erzeugen eines Takt-Signals

Im ersten Versuch wird ein einfaches Rechteck-Signal erzeugt. Dazu wird ein GPIO in einer Schleife abwechselnd auf HIGH und LOW gelegt. Benutzt wird GPIO 21.

Zunächst über die Standard-Methode digitalWrite:

void test1() {
   for (uint8_t i = 0; i < 10; i++) {
      digitalWrite(21, HIGH);
      digitalWrite(21, LOW);
   }
}

Das Ergebnis ist ernüchternd. Die Standard-Arduino-Funktion digitalWrite, benötigt zu Ausführung rund 120ns. Zum Erzeugen des Clock-Signals werden zwei Schreiboperationen benötigt (240ns). Somit liegt die maximale erzeugbare Clock-Signal-Frequenz bei etwa 4,2 MHz.

Schaut man sich den Code von digitalWrite an (ESP32-Library, Version 2.02, esp32-hal-gpio.c),

extern void ARDUINO_ISR_ATTR __digitalWrite(uint8_t pin, uint8_t val) {
   if (val) {
      if (pin < 32) {
         GPIO.out_w1ts = ((uint32_t)1 << pin);
      }
      else if (pin < 34) {
         GPIO.out1_w1ts.val = ((uint32_t)1 << (pin - 32));
      }
   }
   else {
      if (pin < 32) {
         GPIO.out_w1tc = ((uint32_t)1 << pin);
      }
      else if (pin < 34) {
         GPIO.out1_w1tc.val = ((uint32_t)1 << (pin - 32));
      }
   }
}

sieht man, dass man einiges optimieren kann.

Zwischenbemerkung:

digitalWrite wird übrigens über ein weak Alias auf __digitalWrite umgeleitet:

extern void digitalWrite(uint8_t pin, uint8_t val) __attribute__ ((weak, alias("__digitalWrite")));

Dies ermöglicht es, die Funktion durch eine eigene zu ersetzen. ARDUINO_ISR_ATTR ist im Standardfall unbelegt (s. esp32-hal.h). Damit kann es ggf. Probleme geben, wenn die Methode innerhalb einer ISR benutzt wird.

 

Im zweiten Versuch werden die Register direkt mit konstanten Werten beschrieben:

void test2() {
   for (uint8_t i = 0; i < 10; i++) {
      GPIO.out_w1ts = ((uint32_t)1 << 21);
      GPIO.out_w1tc = ((uint32_t)1 << 21);
   }
}

Hierdurch wird die Verarbeitungszeit etwa halbiert.

Die Auflösung der Grafik beträgt 5 ns. Man sieht, dass die HIGH- und die LOW-Zeit identisch sind. Die Schleifenlogik verbraucht praktisch keine Zeit. Die gesamte Zeit entfällt auf die E/A-Operationen. Bei Memory mapped GPIO access and memw findet man die Ursache, warum auch dieser Code immer noch langsam ist.

GPIO.out_w1ts = SOME_VAL;

wird übersetzt mit

l32r    a9, SOME_VAL
l32r    a8, GPIO
memw
s32i.n  a9, a8, 8

Das Problem ist, dass die E/A-Funktion von einem separatem Subsystem ausgeführt wird, das mit einem anderen Takt als der Prozessor läuft. Die Anweisung memw synchronisiert die Systeme.

2. Taktsignal mit Datenerfassung

Zum Taktsignal wird nach der steigenden Flanke an GPIO 21 der Wert von mehreren GPIOs parallel eingelesen und gespeichert. Das ist das typische Szenario für ein getaktetes Auslesen von Daten eines Peripherie-Bausteins.

Einlesen eines einzelnen Bits

Dieser Code entspricht in etwa dem seriellen Einlesen von einem Bit.

uint32_t value; // Wert, der zusammengestellt werden soll
constexpr uint32_t bm0 = 1 << 22; // Bitmap für GPIO 22, Bit 0

void test3() {
   for (uint8_t i = 0; i < 10; i++) {
      register uint32_t temp;
      GPIO.out_w1ts = ((uint32_t)1 << 21); // steigende Flanke des Takt-Signals
      temp = GPIO.in;
      if ((temp & bm0) != 0)               // Wert einlesen und GPIO 22 maskieren
         value += 1 << 0;                  // Input verarbeiten
      GPIO.out_w1tc = ((uint32_t)1 << 21); // fallende Flanke des Takt-Signals
   }
}

Das Ergebnis:

135 ns für das Erzeugen der steigenden Flanke und das Einlesen und Verarbeiten des Werts. 50 ns für die Erzeugung der fallenden Flanke. Insgesamt 185 ns (≈ 5,4 MHz.).

Einlesen von zwei Bits

uint32_t value; // Wert, der zusammen gestellt werden soll.
constexpr uint32_t bm0 = 1 << 22; // Bitmap für GPIO 22, Bit 0
constexpr uint32_t bm1 = 1 << 23; // Bitmap für GPIO 23, Bit 1

void test4() {
   for (uint8_t i = 0; i < 10; i++) {
      uint32_t temp;
      GPIO.out_w1ts = ((uint32_t)1 << 21); // steigende Flanke des Takt-Signals
      temp = GPIO.in;
      if ((temp & bm0) != 0)               // Wert einlesen und GPIO 22 maskieren
         value += 1 << 0;                  // Input verarbeiten
      if ((temp & bm1) != 0)               // Wert einlesen und GPIO 23 maskieren
         value += 1 << 1;                  // Input verarbeiten
      GPIO.out_w1tc = ((uint32_t)1 << 21); // fallende Flanke des Takt-Signals
   }
}

175 ns für das Erzeugen der steigenden Flanke und das Einlesen und Verarbeiten des Werts. 50 ns für die Erzeugung der fallenden Flanke. Insgesamt 225 ns (≈ 4,4 MHz.). Die Verarbeitung für das zweite Bit benötigt 40 ns zusätzlich.

Einlesen von vier Bits

240 ns : 105 ns für 3 Bits = 35 ns pro Bit. 290 ns. gesamt = 3,4 MHz.

Einlesen von acht Bits

340 ns: 205 für 8 Bits = 25,6 ns pro Bit. gesamt 390 ns = 2,6 MHz.

Pin-Change Interrupt

Die Latenzzeit beim Pin-Change-Interrupt ist mit 3μs (5μs bei der ersten Unterbrechung ebenfalls unerwartet groß.

Latenzzeit-Test

Zum Test habe ich ein kleines Programm geschrieben:

Auf GPIO 27 wird ein Pin-Change-Interrupt (attachInterrupt) angemeldet. Beim Empfang eines beliebigen Zeichens über die Serielle Schnittstelle wird GPIO 18 (Trigger) kurz auf HIGH gezogen. GPIO 18 ist extern mit GPIO 27 verbunden. Die steigende Flanke löst somit den Interrupt aus. In der ISR wird GPIO 19 (Response) kurz auf High gesetzt. GPIO 18 und GPIO 19 werden mit einem Logic-Analyser beobachtet.

Das Ergebnis:

Erster Interrupt nach einem Reset: Latenzzeit ca. 5μs
   
Folgende Interrupts: Latenzzeit ca. 2,8μs

Hier der gesamte Code:

IRAM_ATTR void isr() {
   GPIO.out_w1ts = ((uint32_t)1 << 19); // GPIO 19 auf HIGH, als Trigger für Response-Time
   GPIO.out_w1tc = ((uint32_t)1 << 19); // GPIO 19 wieder auf LOW
}

void setup() {
   Serial.begin(115200);
   while (Serial.available()) { // Rest-Müll auslesen
      Serial.read();
   }
   Serial.println("\nStart Interrupt-Test");
   Serial.println("====================");

   pinMode(18, OUTPUT);
   pinMode(19, OUTPUT);
   digitalWrite(18, LOW);
   digitalWrite(19, LOW);
   attachInterrupt(27, isr, RISING); // ISR registrieren
}

void loop() {
   if (Serial.available()) {
      while (Serial.available()) {
         Serial.read();
      }

      GPIO.out_w1ts = ((uint32_t)1 << 18); // GPIO 18 auf HIGH, löst Interrupt aus
      GPIO.out_w1tc = ((uint32_t)1 << 18); // GPIO 18 wieder auf LOW

      Serial.println("Interrupt done");
   }
}

Ursache

Einer der Gründe für die lange Latenzzeit ist, dass es für sämtliche Pins nur einen Interrupt-Vektor gibt. Um zu erkennen, welche ISR ausgeführt werden soll, müssen sämtliche GPIO-Interrupt-Flags abgefragt werden. Die Arduino-Library macht dies wie folgt

Die Methode attachInterrupt wird weiter geleitet an __attachInterruptFunctionalArg in esp32-hal-gpio.c. Diese beschreibt den Interrupt-Vektor mit der generellen ISR __onPinInterrupt und füllt das interne Array __pinInterruptHandlers im Wesentlichen mit der Adresse der ISR.

extern void __attachInterruptFunctionalArg(uint8_t pin, voidFuncPtrArg userFunc, void * arg, int intr_type, bool functional)
   ...
   esp_intr_alloc(ETS_GPIO_INTR_SOURCE, (int)ARDUINO_ISR_FLAG, __onPinInterrupt, NULL, &gpio_intr_handle);
   ...
   __pinInterruptHandlers[pin].fn = (voidFuncPtr)userFunc;
   __pinInterruptHandlers[pin].arg = arg;
   __pinInterruptHandlers[pin].functional = functional;
   ...

Wenn der Interrupt ausgelöst wird, prüft die ISR sämtliche Interrupt-Flags und ruft die gespeicherten Funktionen auf.

static void ARDUINO_ISR_ATTR __onPinInterrupt() {
    uint32_t gpio_intr_status_l=0;
    uint32_t gpio_intr_status_h=0;

    gpio_intr_status_l = GPIO.status;
    gpio_intr_status_h = GPIO.status1.val;
    GPIO.status_w1tc = gpio_intr_status_l;//Clear intr for gpio0-gpio31
    GPIO.status1_w1tc.val = gpio_intr_status_h;//Clear intr for gpio32-39

    uint8_t pin=0;
    if(gpio_intr_status_l) {
        do {
            if(gpio_intr_status_l & ((uint32_t)1 << pin)) {
                if(__pinInterruptHandlers[pin].fn) {
                    if(__pinInterruptHandlers[pin].arg){
                        ((voidFuncPtrArg)__pinInterruptHandlers[pin].fn)(__pinInterruptHandlers[pin].arg);
                    } else {
                        __pinInterruptHandlers[pin].fn();
                    }
                }
            }
        } while(++pin<32);
    }
    if(gpio_intr_status_h) {
        pin=32;
        do {
            if(gpio_intr_status_h & ((uint32_t)1 << (pin - 32))) {
                if(__pinInterruptHandlers[pin].fn) {
                    if(__pinInterruptHandlers[pin].arg){
                        ((voidFuncPtrArg)__pinInterruptHandlers[pin].fn)(__pinInterruptHandlers[pin].arg);
                    } else {
                        __pinInterruptHandlers[pin].fn();
                    }
                }
            }
        } while(++pin<GPIO_PIN_COUNT);
    }
}

Dass die Latenzzeit des ersten Interrupts länger dauert, liegt am wirkungslosen ARDUINO_ISR_ATTR. In esp32-hal-gpio.h ist ARDUINO_ISR_ATTR an CONFIG_ARDUINO_ISR_IRAM gebunden. CONFIG_ARDUINO_ISR_IRAM ist nicht definiert.

__onPinInterrupt muss demnach beim ersten Aufruf erst in das IRAM geladen werden, was die zusätzliche Verzögerung ausmacht.