ESP32 - Der ULP-CoProzessor

Vorstellung

Der ESP32 verfügt neben den beiden Hauptprozessoren über einen weiteren Rechenkern, der im Hintergrund unabhängig einfache Steurungsaufgaben erledigen kann.
Dieser Prozessor ist in der RTC-Domain untergebracht. Programmcode und Daten werden im RTC-Slow-Memory, einem 8KB SRAM-Bereich, abgelegt. Das System hat Zugriff auf die Peripherie der RTC-Unit. Es kann während der verschiedenen Betriebsmodi der Hauptprozessoren laufen, also auch während einer Tiefschlafphase des Hauptsytems. Der ULP-Prozessor begnügt sich mit ca. 1.5mA und ist weniger anspruchsvoll an die Untergrenze der Versorgungsspannung.

Das Laden des Programmcodes und den Start des Subsystems ist vom Hauptprogramm zu erledigen. Dies erfolgt zumeist nach dem Booten in Abhängigkeit von der Ursache des Reset.
Hat der Co-Prozessor seine Arbeit erledigt, schickt er sich selbst wieder in eine stromsparende Schlafphase. Zuvor kann er entweder das Hauptsystem aufwecken oder bei wachem System per RTC-Interrupt melden, dass bspw. Messergebnisse vorliegen.
Der Datenaustausch zwischen Haupt- und Subsystem erfolgt über Variablen im RTC-Speicher.

Die Abarbeitung der Befehle erfolgt in Echtzeit, d.h. es ist vorhersagbar, wann ein Befehl ausgeführt wird. Man muss sich nicht (wie im Real-Time-OS) darum sorgen, ob höher priorisierte Tasks den Steuerungsablauf unterbrechen.
Protokollimplementationen wie bspw. für den One-Wire-Bus (Temperatursensor DB18B20) lassen sich über den CoProzessor releativ einfach realisieren.

Die Architektur ist recht einfach gehalten. Ein Z80 aus den 1980'ern sieht daneben wie ein Rechenbolide aus.
Es stehen 4 allgemeine 16-Bit-Register und ein 8-Bit-Schleifengegister zur Verfügung. Die Logik der Rechneneinheit (ALU) beschränkt sich auf Addition, Subtraktion, logisches UND / ODER, sowie bitweises Lins-/Rechtsschieben. Dazu kommen Sprungbefehle sowie Befehle zur Ablaufsteuerung.
Angetrieben wird das Ganze durch einen internen 8MHz-Takt.

Programmiert wird der CoProzessor in Assembler. Hochsprachen stehen nicht zur Verfügung. Hier hilft aber die Simplizität. Der Einarbeitungsaufwand ist nicht hoch und ein Merkzettel mit einer Befehlsübersicht lässt sich auf einer A4-Seite zusammenfassen.

Zur Integration in eigene Projekte stellt das Framework die notwendigen Funktionen und Makros bereit. Das Arbeiten nach Schablonen bspw. aus den Beispielprogrammen erleichtert den Einstieg.

Vorbereitung

Vor Verwendung des ULP-Prozessors müssen in jedem Projekt einige Vorbereitung getroffen werden.


Config-Variablen setzen

In der zum Projekt gehörenden Datei sdkconfig sind zwei Variablen zu setzen, die im Build-Prozess die Einbindung des ULP-Systems beinflussen. Das Editieren der Datei erfolgt entweder im historischen Look auf der Kommandozeile per idf.py menuconfig oder deutlich komfortabler mit einem Doppelclick im Projekt-Explorer von Eclipse.
Die betreffenden Punkte sind über die Baumstruktur zu finden -> Component config -> ESP32-specific. icon
Der zu reservierende Speicher wird mit 1024 Bytes initialisiert. Je nach Anzahl der Befehle + Daten muss der Bereich ggf. vergrößert werden. Jedes Kommando und jede Variable belegen jeweils 4 Bytes. Entweder setzt man den Wert gleich höher (max. 8MB) an oder reagiert auf Fehlermeldungen im Build-Prozess.


icon

Unterverzeichnis anlegen

Das bzw.die Assemblerfiles werden in einem separaten Ordner gespeichert, der auf Ebene der C-Quellen angelegt wird. Der Name ist beliebig, i.A wird er mit ulp bezeichnet.
Die Assemblerfiles erhalten den Suffix .S.
Die Struktur dieser Files ist recht einfach. Es loht sich jedoch mit Templates zu arbeiten.


Anpassen des CMake-Prozesses

Auf der Verzeichnisebene des C-Quellcodes befindet sich eine Datei CMakeLists.txt. Hier sind einige Textzeilen anzuhängen, die im Build-Prozess gelesen werden und den Ablauf beeiflussen.

# ULP support additions to component CMakeLists.txt.
set(ulp_app_name ulp_${COMPONENT_NAME})
set(ulp_s_sources "ulp/ds18b20.S")
set(ulp_exp_dep_srcs "main_ds18b20.c")

ulp_embed_binary(${ulp_app_name} ${ulp_s_sources} ${ulp_exp_dep_srcs})

Die Parameter sind anzupassen und haben folgende Bedeutung:

ulp_app_name: interner Name im Build-Prozess, muss i.d.R. nicht angepasst werden.
ulp_s_sources: Pfade und Namen aller einzubindenden Assemblerfiles.
ulp_exp_dep_srcs: Pfade und Namen aller C-Soure-Files, die in Beziehung zum ULP-Programm stehen.

Pfadangaben können absolut oder relativ zu CMakeLists.txt sein. Die Einträge werden in Hochkomma eingeschlossen. Mehrere Einträge sind durch Leerzeichen zu trennen.

Speziell der 3.Parameter verlangt einige Sorgfalt. Fehlende Einträge werden durch CMake nicht erkannt.

Hintergrund: Zum Datenaustausch zwischen Haupt- und Co-Programm werden im Build-Prozess automatisch Header-Dateien zu jedem Assemblerfile erstellt, die Referenzen auf die global definierten Variablen enthalten. Da der Buildprozess mehrere PC-Rechnerkerne verwenden kann, ist nicht vorherzusehen, ob zuerst eine Header-Datei neu erstellt wird oder das abhängige C-File bearbeitet wird. Dadurch kann es vorkommen, dass nach Änderungen in einem ASM-File der nächste Build mit Referenzen auf die Vorgängerversion des ASM-Files gebaut wird. Eine verzweifelte Fehlersuche ist hier vorprogrammiert.
Die Auflistung der betroffenen C-Quellen in diesem Parameter vermeidet den Ärger.


Das Assembler-File

Im ulp-Ordner ist min. ein Assemblerfile anzulegen, auf das in der CMakeLists.txt zu verweisen ist. Das Auslagern von Codeteilen in weitere .S-Dateien ist möglich. Diese Dateien sind per #include einzubinden und/oder im zweiten ULP-Parameter der CMakeLists.txt zu registrieren. Abhänig von der Struktur kann es dabei zu Fehlermeldungen wg. doppelter Referenzen kommen.

Ein Assemblerfile endet per Namenskonvention mit dem Suffix .S. Es besteht aus mehrerern Segmenten.

.bbs: enthält alle global und local zu definierenden Variablen. Die Werte werden automatisch mit 0 initialisiert und sind durch das CoProgramm veränderbar. Auf global definiete Variablen besteht auch Zugriff durch das Hauptprogramm.
.data: enthält alle Konstanten. Arraywerte sind durch Komma zu trennen. Zeilenumbrüche innerhalb eines Arrays sind nur maskiert (Backslash) erlaubt.
.text: Dieses Segment enthält den Programmcode. Es muss eine globale Marke für den Programmeintritt definiert sein. Dieser Punkt wird i.A. mit entry bezeichnet.

Die Datensegmente sind optional. Ein Reihenfolgezwang besteht nicht.
Im Code-Segment werden die Befehle der Reihenfolge nach abgearbeitet. Progrmmverzweigungen per JUMP-Befehl sind möglich.

Bei Befehls- und Registerbezeichnern ist eine beliebige Groß-/Kleinschreibung möglich. Variablenbezeichner, Label, u.ä. sind aber case-sensitive.

Hier ein simples Template ohne tiefgründige Funktion:

#include "soc/rtc_cntl_reg.h"
#include "soc/rtc_io_reg.h"
#include "soc/soc_ulp.h"

//Portdefinitionen
#define PORT 8	//RTC-GPIO 8 => GPIO 33

//Macro-Definitionen ------------------------
//Port-Mode => Output
.macro SET_MODE_OUTPUT rtc_port
	WRITE_RTC_REG(RTC_GPIO_ENABLE_W1TS_REG, RTC_GPIO_ENABLE_W1TS_S + \rtc_port, 1, 1)
.endm

//Set Port => 1
.macro SET_PIN_HIGH rtc_port
	WRITE_RTC_REG(RTC_GPIO_OUT_W1TS_REG, RTC_GPIO_OUT_DATA_W1TS_S + \rtc_port, 1, 1)
.endm

//Set Port => 0
.macro SET_PIN_LOW rtc_port
	WRITE_RTC_REG(RTC_GPIO_OUT_W1TC_REG, RTC_GPIO_OUT_DATA_W1TC_S + \rtc_port, 1, 1)
.endm

//-------------------------------------------
//Daten
	.data
mytable:
	.long 1, 2, 3, 4, 5	//locales array
	
	.bss
	.global var1
var1:
	.long 0
	
//Programmcode	
	.text
	.global entry
entry:
	SET_MODE_OUTPUT PORT
	move R1, var1		
	ld   R0, R1, 0
	add  R0, R0, 1
	st   R0, R1, 0
	and  R0, R0, 1
	SET_PIN_LOW PORT
	jump exit, EQ
	SET_PIN_HIGH PORT
exit:	
	halt
Die globale Variable "var1" wird in jedem Programmzyklus um 1 erhöht. In Abhängikeit von Bit0 wird der RTC-Port 8 (GPIO 33) getoggelt.
Das Hauptgramm hat Zugriff auf "var1". Allerdings erfährt es in diesem Fall nichts von einer abgelaufenen Aktion.

ULP-Programmierung

Die nachfolgenden Angaben beziehen sich auf den CoProzessor des ESP32. Der ULP der Typenreihe ESP32S2 verfügt über einen erweiterten Befehlsatz. Der Quellcode ist aufwärtskompatibel, nicht jedoch der Binärcode.

Zur Unterbringung von Code und Daten steht ein Speicherbereich von 8kB zur Verfügung. Im Build-Prozess werden Binärdaten erstellt, die im Datenbereich des Hauptprogramms abgelegt werden. Per Anweisung im Hauptprgramm werden die Daten in den RTC-Slow-Mem geladen und bei Bedarf das CoProgramm gestartet.
Daher ist es nicht möglich, den CoProzessor völlig autark zu betreiben. Zur Initialisierung wird immer das ESP32-Hauptprogramm benötigt.

Grundsätzlich werden alle Befehle und Daten des ULP-Programms im 32-bit-Raster ausgerichtet. Jeder ASM-Befehl wird incl. Parametern in ein 32-bit-Wort übersetzt.
Auf Variablen kann der CoProessor nur zugreifen, wenn diese an 32-bit-Grenzen ausgerichtet sind. Daher sind nur .long-Typen als Variablen sinnvoll. Der Assembler nimmt zwar Typangaben wir ".byte" entgegen und setzt die im Binärcode ordnungsgemäß um. Doch was nützt das, wenn der ULP-Prozessor nicht darauf zugreifen kann.

Das Resultat eines Assemblerlaufs lässt sich als Hexdump im Build-Ordner anschauen: build/ulp_xxx.bin.S. Der belegte Speicherplatz wird darin in 32-bit-Words angezeigt.


Die Register

Der ULP-Prozessor verfügt über 4 allgemeine 16-Bit-Register R0, R1, R2, R3. Register R0 hat bei einigen Befehlen Spezialaufgaben. In JUMPR wird sein Wert mit einem Zahlenwert verglichen und daraus die Sprungbedingung abgeleitet. Beim Lesen interner CPU-Register wird in R0 das Ergebnis zurückgeliefert.

In der Datenbreite von 16 Bit liegt eine Diskrepanz zur 32-bit-Breite von Variablen. In der Tat lassen sich nur die unteren 16 Bit einer Variablen im Speicher lesen und schreiben.
Dies muss beim Zugriff auf diese Daten aus dem Hauptprogramm beachtet werden, aus dessen Sicht es sich um UINT32 handelt, aber nur die unteren 16Bit gültig sind. Die oberen 16 Bit enthalten den Wert des Programm-Counters zum Zeitpunkt der Schreibens der Variablen (zzgl. Linksshift). Nach dem Lesen einer Variablen im Hauptprogramm sollte der Wert mit & 0xFFFF maskiert werden.

Als Spezialregister für Zählschleifen steht außerdem der Stage-Counter zur Verfügung. Er hat eine Breite von 8Bit. Der Wert lässt sich zurücksetzen, erhöhen und verringern.

Das war's, mehr Register gibt es nicht. Es gibt auch keinen Stack, in den man mal schnell pushen könnte. Dererlei Funktionen müssen über Speichervariablen nachgebildet werden.


Die Flags

Auch die Flags des CoProzessors sind übersichtlich. Es gibt das Zero-Flag EQ und das Overflow-Flag OV. Beide Flags werden durch ALU-Operationen gesetzt und können im JUMP-Befehl als Bedingung ausgewertet werden.
Die Logik beim Sezten der Flags ist nicht immer nachvollziehbar. Bei ADD un SUB werden die Flags gesetzt wie erwartet. Ein LSH beinflusst das OV-Flag nicht, auch wenn eine 1 nach links rausgeschoben wird. Dafür wird das EQ bei jedem MOVE-Kommando beeinflusst.
Aber was soll's. Man muss es nur wissen.


Zahlenwerte

Einige Befehle nehmen direkte Zahlenwerte (Immediate) entgegen. Unzulässige Werte bemängelt der Assembler nicht in jedem Fall. Die eingegeben Zahlen werden intern durch Maskierung auf den zulässigen Wertebereich begrenzt. Dies kann zu unerwarteten Ergebnissen führen.
Die Doku beschreibt die Zahlenwerte i.A. als 16-bit-signed. Ob man die Werte als signiert oder unsigniert betrachtet, hängt vom Anwendungsfall ab. Bei vergleichenden Operationen (JUMPR / JUMPS) werden immer unsignierte Werte miteinander verglichen.


Der Befehlssatz


ULP-Instructions
   Ansicht / Download

Auf den Herstellerseiten ist eine Syntaxbeschreibung und eine Hardwaredokumentation zu finden.

Hier eine Zusammenfassung unter Weglassen der Peripherie-Befehle.

ALU - Die Recheneinheit

Folgende Befehle nutzen die ALU:

ADD   Rdst=Rsrc1+Rsrc2   Addition
SUB   Rdst=Rsrc1-Rsrc2   Subtraktion
AND   Rdst=Rsrc1&Rsrc2   Logisches UND
OR    Rdst=Rsrc1|Rsrc2   Logisches ODER
MOVE  Rdst=Rsrc1         Register mit Wert laden
LSH   Rdst=Rsrc1<<Rsrc2  bitweise Linkschieben
RSH   Rdst=Rsrc1>>Rsrc2  bitweise Rechtsschieben
Hinweise:
  • Alle Befehle beeinflussen das Zero-Flag
  • Nur Add und SUB beeinflussen das Overflow-Flag
  • Alle Operationen werden mit 16-Bit-Werten ausgeführt
  • Der maximale Shiftwert bei LSH/RSH beträgt 0x0F (0x10 => 0x00, 0x11 => 0x01)
  • ...


Speicherzugriff

Zum Zugriff auf Speicher-Variablen stehen folgende Befehle zur Verfügung:

ST    Rsrc,Rdst,offset   mem[rdst+offset] = Rsrc
LD    Rdst,Rsrc,offset   Rdst = mem[Rsrc+offset]
Hinweise:
  • Die Adressierung des Speichers im 2.Parameter erfolgt im 32-Bit-Raster
  • Der Offset wir in Bytes angegeben
  • Der Offset lässt sich nur als Direktwert angeben [0..0x7FF]
Beispiel:
       .bss
arr:   .long  0	        //Beispiel-Array
       .long  0

       .text
       MOVE R3, arr      //Zeiger auf 1.Arrayelement
       MOVE R0, 1234
       ST   R0, R3, 0
       
       MOVE R0, 5678
       ST   R0, R3, 4    //2.Element über Offset adressieren
       //oder       
       ADD  R3, R3, 1	 //Zeiger auf nächsten 32-Bit-Speicherplatz
       ST   R0, R3, 0    //2.Element mit Nulloffset adressieren


Sprungbefehle

JUMP  Sprung zu abs. Adresse, EQ, OV als Bedingung möglich
JUMPR Sprung zu rel. Adresse, Bedingung: Vergleich R0 mit 16Bit-Direktwert
JUMPS Sprung zu rel. Adresse, Bedingung: Vergleich STAGE_CNT mit 8Bit-Direktwert
Hinweise:
  • Die rel. Sprungdistanz errechnet der Assembler selbst (Adressierung per label)
  • Die max. rel. Sprungdistanz beträgt +/- 64 Befehle
  • Derzeit verursacht der Assembler (2.28.51.20170517) einen Fehler beim JUMPR-Befehl:
    Direktwerte > 0x7FFF führen zu einer unsinnigen Fehlermeldung


Stage-Count-Register

STAGE_INC  Stage_cnt=Stage_cnt+Imm   Increment 
STAGE_DEC  Stage_cnt=Stage_cnt-Imm   Decrement
STAGE_RST  Stage_cnt= 0              Reset
Hinweise:
  • Das Register ist 8Bit breit
  • Der Inhalt kann nicht gelesen werden
  • Es kann nur als Vergleichswert im JUMPS-Befehl genutzt werden
  • nützlich als Schleifenzähler


Ablaufsteuerung

HALT        Ende des ULP-Programms, Startet WakeUp-Timer wenn enabled
WAKE        Aufwecken aus dem DeepSleep oder RTC-Interrupt
SLEEP       Auswahl [0..4] eines Sleep-Profils
WAIT   U16  Ausführung des Programms für n*125ns unterbrechen
NOP         Mache für die Dauer eines Befehls nichts (750ns) 


Peripherie

ADC    Spannungsmessung
I2C_RD I2C-Port lesen
I2C_WR I2C-Port schreiben
REG_RD interne Register lesen
REG_WR interne Register schreiben
Desweiteren ist ein Befehl TSENS zur Abfrage eines internen Temperatursensors dokumentiert. Dieser Sensor scheint aber obsolete zu sein. Das Ergebnis dieses Befehls ist immer 128.


Makros

Speziell Befehle zum Zugriff auf interne Register sind ungeeignet für das Langzeitgedächtnis. Die Definition von Makros hilft hier weiter. Folgende Zeilen lassen sich zur Steuerung eines einzelnen RTC-Ports in den Quelltext einbinden:

//Port-Status "rtc_port" read => R0
.macro READ_PIN rtc_port
	READ_RTC_REG(RTC_GPIO_IN_REG, RTC_GPIO_IN_NEXT_S + \rtc_port, 1)
.endm

//Port-Mode => Input (floating / high impedance)
.macro SET_MODE_INPUT rtc_port
	WRITE_RTC_REG(RTC_GPIO_ENABLE_W1TC_REG, RTC_GPIO_ENABLE_W1TC_S + \rtc_port, 1, 1)
.endm

//Port-Mode => Output
.macro SET_MODE_OUTPUT rtc_port
	WRITE_RTC_REG(RTC_GPIO_ENABLE_W1TS_REG, RTC_GPIO_ENABLE_W1TS_S + \rtc_port, 1, 1)
.endm

//Set Port => 1
.macro SET_PIN_HIGH rtc_port
	WRITE_RTC_REG(RTC_GPIO_OUT_W1TS_REG, RTC_GPIO_OUT_DATA_W1TS_S + \rtc_port, 1, 1)
.endm

//Set Port => 0
.macro SET_PIN_LOW rtc_port
	WRITE_RTC_REG(RTC_GPIO_OUT_W1TC_REG, RTC_GPIO_OUT_DATA_W1TC_S + \rtc_port, 1, 1)
.endm


ULP-Programm starten

Im Buildprozesses wird der Binärcode des ULP-Programms erzeugt und im Datenbereich des Hauptprogramms abgelegt. In Zwischenschritten werden dabei zahlreiche Dateien im build-Verzeichnis des Projektes automatisch angelegt. Deren Inhalt muss man nicht kennen. Zum Verständnis der Vorgänge kann es aber hilfreich sein, sich die eine oder andere anzuschauen.

Hält man sich an die o.g. Namenskonventionen, ist in der Datei build/ulp_main.bin.S der Maschinencode in Hexform zu sehen. Interessant sind die vergebenen globalen Namen, die im Hauptprogramm zu verwenden sind.
Etwas tiefer versteckt ist die Header-Datei build/esp-idf/main/ulp_main/ulp_main.h. Sie enthält Deklarationen der globalen Variablen des ASM-Files. Den Variablennamen wird dabei der Prefix ulp_ vorangestellt. Über diese Namen hat das Hauptprogramm Zugriff auf die Variablen im RTC-Memory und ein Datenaustausch mit dem Co-Programm ist möglich.
Der Header muss in den C-Quellen per #include ulp_main.h eingebunden werden. Pfadangaben sind dabei nicht erforderlich.


Bekanntmachung

Im C-Sourcefile, in dem das ULP-Programm geladen werden soll, ist der Datenbereich zu deklarieren und sind die erforderlichen Definitionen einzubinden:

...
#include "driver/rtc_io.h"
#include "esp32/ulp.h"
#include "ulp_main.h"
...
extern const uint8_t ulp_main_bin_start[] asm("_binary_ulp_main_bin_start");
extern const uint8_t ulp_main_bin_end[]   asm("_binary_ulp_main_bin_end");
...


Initialisieren und Starten

Das Laden des Maschinencodes in den RTC-Slow-Mem, die Initialisierung und der Start des ULP-Programms werden werden im Hauptprogramm erledigt. Der Zeitpunkt hängt vom Anwendungsfall ab. In vielen Fällen ist es sinnvoll, dies gleich nach dem Programmstart in Abhängigkeit vom letzten Resetgrund zu erledigen. Kommt der Soc bspw. aus dem DeepSleep, was ggf. durch den ULP-Prozessor angestoßen wurde, ist davon auszugehen, dass der Code intakt ist und keine Initialisierung erforderlich ist.
Nach allen anderen Reset-Ursachen ist der Code erneut hochzuladen. Anschließend können globale Variablen und ggf. die verwendeten RTC-Ports initialisiert werden.

...
gpio_num_t owp  = GPIO_NUM_33;	//RTC_GPIO_8

static void init_ulp_program(void)
{
    //ULP-Programm in RTC laden
    ESP_ERROR_CHECK(ulp_load_binary(0, ulp_main_bin_start,
        (ulp_main_bin_end - ulp_main_bin_start) / sizeof(uint32_t)));

    //RTC-Port(s) initialisieren
    rtc_gpio_init(owp);
    rtc_gpio_set_direction(owp, RTC_GPIO_MODE_INPUT_ONLY);
    rtc_gpio_pulldown_dis(owp);
    rtc_gpio_pullup_en(owp);
    rtc_gpio_hold_dis(owp);


    //ULP-Wakeup (Period-Register 0, 60Sek)
    ulp_set_wakeup_period(0, 60*1000*1000);

    //ULP-Programm starten
    ESP_ERROR_CHECK(ulp_run(&ulp_entry- RTC_SLOW_MEM));
}


void app_main(void)
{
    esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
    if (cause != ESP_SLEEP_WAKEUP_ULP) {
        init_ulp_program();
    } else {
        // Verarbeitung der ULP-Daten
        // ...    
    }    
        
    //allgem. Stromsparmaßnahmen
    rtc_gpio_isolate(GPIO_NUM_12);
    rtc_gpio_isolate(GPIO_NUM_15);        
    
    ...
ulp_load_binary: Laden des Binärcodes in den RTC-Speicher
ab Addresse des RTC-Slow_Mem (0), Zeiger auf Datenbereich des Binarycodes, Länge der Daten in 32Bit-Worten

ulp_set_wakeup_period: Ruhezeit des ULP-Programms nach einem HALT.
Es lassen sich bis zu 5 (Reg[0..4]) unterschiedliche Zeitspannen in µs setzen, die im ULP-Programm durch den SLEEP-Befehl ausgewählt werden können. Default ist Register 0.

ulp_run: Start des ULP-Programms.
Es erfolgt ein Sprung zu der im ASM-File als zur global definierten Marke .entry.


Wecker einschalten

In Anwendungen, die in den DeepSleep geschickt werden und durch ein WAKE aus dem Co-Programm wieder zum Leben erweckt werden sollen, muss als WakeUp-Quelle der ULP-Prozessor zugelassen werden. Dies erfolgt mit folgendem Codeschnipsel bspw. zum Abschluss des Hauptprogramms.

    ...
    ESP_ERROR_CHECK( esp_sleep_enable_ulp_wakeup() );
    esp_deep_sleep_disable_rom_logging();
    esp_deep_sleep_start();  //... und Gute Nacht !
}

RTC Interrupt

Im vorhergehenden Scenario wurde davon ausgegangen, dass Haupt- und Co-Programm zeitversetzt ausgeführt werden. Das Hauptprogramm befindet sich im DeepSleep, das Co-Programm erwacht zeitgesteuert, führt seine Aktionen aus, weckt bei Bedarf das Hauptprogramm und geht wieder in den Haltzustand.
Aber auch zeitgleich zum Hauptprogramm kann der ULP-Prozessor sinnvolle Aufgaben übernehmen. Von Vorteil ist, dass das ULP-Programm im wahren Echtzeitbetrieb abläuft. Unterbrechungen durch einen höher priorisierten Prozess sind nicht zu erwarten. Damit lassen sich einfache Kommunikationsprotokolle mit zeitkritischen Merkmalen implementieren, die durch die Hardware des ESP32 nicht abgedeckt werden.
Aufgabe des Co-Programms ist es dann, eine Meldung über das Vorliegen neuer Daten zu senden. Eine Möglichkeit wäre es, eine globale Variable als Flag zu setzen. Das Hauptprogramm müsste dieses Flag zyklisch abfragen und nach Übernahme der Daten zurücksetzen.
Deutlich eleganter ist das Auslösen eines RTC-Interrupts durch das ULP-Programm. Dies erfolgt ebenfalls durch den WAKE-Befehl des ULP-Prozessors. Der Unterschied liegt dabei im Hauptprogramm. Statt eine WakeUp-Quelle freizugeben, wird das ULP Interruptbit im entsprechenden Register gesetzt und ein Interrupt-Hander initialisiert.

Beispiel:

void ISR_HandleTask () {
    bool terminated = false;
    RTC_ISR_Semaphore = xSemaphoreCreateBinary();

    while (!terminated) {
        if( xSemaphoreTake(RTC_ISR_Semaphore, portMAX_DELAY ) == pdTRUE ){
            // Verarbeitung der ULP-Daten
            // ...
        }
    } 
}

static void IRAM_ATTR ulp_isr_handler(void *args)
{
    xSemaphoreGiveFromISR(RTC_ISR_Semaphore, NULL);
}

// -------------------------------------------------------------------------------------

void app_main(void)
{
    xTaskCreate(ISR_HandleTask, "ISR_HandleTask", 2048, NULL, 1, NULL);

    init_ulp_program();

    ESP_ERROR_CHECK( rtc_isr_register(ulp_isr_handler, NULL, RTC_CNTL_ULP_CP_INT_ENA_M) );
    REG_SET_BIT(RTC_CNTL_INT_ENA_REG, RTC_CNTL_ULP_CP_INT_ENA_M);

    //allgem. Stromsparmaßnahmen
    rtc_gpio_isolate(GPIO_NUM_12);
    rtc_gpio_isolate(GPIO_NUM_15);

}
Die eigentliche Datenverarbeitung wird an einen separaten Task niedriger Priorität delegiert und die ISR-Routine so schnell wie möglich wieder verlassen.

nach oben