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 entscheiden das Hauptsystem aufzuwecken oder bei wachem System per RTC-Interrupt neue Messergebnisse melden.
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 Co-Processor 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.

Wer vor der Assembler-Programmierung zurückschreckt, sollte einen Blick in die Verwandschaft riskieren. Der ESP32S2 verfügt zusätzlich über einen ULP-RISC-V Prozessor, der alternativ zur ULP-FSM gernutzt werden kann. Der ist deutlich komfortabler in C zu programmieren und weist gerinfügig bessere Leistungewerte auf.

Vorbereitung

Im Folgenden wird davon ausgegangen, dass unter Eclipse CDT mit Espressif-Tools programmiert wird und das IDF-Framework installiert ist.

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. Der Programmablauf endet mit einem HALT.

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 wird 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 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.
Die maximale Zeit wird durch die 32-Bit-Variable begrenzt (2^32µs ==> ca.71 Minuten).

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 !
}

Peripherie


General IO im RTC-Subsystem

Der ULP-Prozessor kann nur einen Teil der Pads des ESP ansteuern. Insgesamt sind dem RTC-Subsystem 18 Ein-/Ausgänge zugeordnet. Die Nummerierung erfolgt von 0 bis 17. Es besteht allerdings kein Zusammenhang mit der Nummerierung der GPIO über den IO-Mux. D.h.: Ein und dasselbe Pad wird in den beiden Systemen über unterschiedliche Pin-Nummern angesprochen. Eine Übersicht und die Zuordnung ist in der RTC_MUX Pin List zu finden.
Es gelten die gleichen Einschränkungen, die auch bei der Funktionalität als GPIO zu beachten sind. Ein Teil der Pads kann nur als Eingang verwendet werden. Andere können mit speziellen Funktionen belegt sein.

Vor Verwendung der Pins im RTC-Subsystem müssen die Ports initialisiert werden. Dies erfolgt i.A im Hauptprozess. Das Framework stellt das notwendige API bereit. Unbedingt zu beachten ist, dass hier die GPIO-Nummerierung zu verwenden ist und nicht die Nummerierung des RTC-Subsystems. Über diese Befehle können auch im Hauptprogramm die RTC-Pads gesteuert werden

Beispiel (main.c):

...
//Ports in GPIO-Matrix
#define GPIO_SCL	27		// => RTC_IO 17
#define GPIO_SDA	26		// => RTC_IO 7
...
void rtc_io_init(){
	rtc_gpio_init(GPIO_SCL);
	rtc_gpio_set_direction(GPIO_SCL, RTC_GPIO_MODE_OUTPUT_ONLY);
	rtc_gpio_pullup_dis(GPIO_SCL);
	rtc_gpio_pulldown_dis(GPIO_SCL);
	
	rtc_gpio_init(GPIO_SDA);
	rtc_gpio_set_direction(GPIO_SDA, RTC_GPIO_MODE_INPUT_ONLY);
	rtc_gpio_pullup_dis(GPIO_SDA);
	rtc_gpio_pulldown_dis(GPIO_SDA);
	rtc_gpio_set_level(GPIO_SDA, 0);
}
...

Wichtig ist an dieser Stelle rtc_gpio_init(). Auch die allgem. Konfiguration wie I/O-Richtung oder pullup/dwn ist hier sinnvoll. Die Änderung der Eigenschaften ist später im ULP-Programm über Registerbefehle möglich.

Der Befehlssatz des Assemblers kennt keine Befehle zur Steuerung der RTC-IO. Das Schalten von Ausgängen oder Einlesen von Eingängen erfolgt über das Schreiben von Registern. Da sich hierbei äußerst sperrige Befehlszeilen ergeben, ist die Verwendung oben beschriebener Macros sinnvoll. Kopiert man sich die Macros an den Anfang seines Assemblerfiles, wird der IO-Zugriff zum Kinderspiel.

Beispiel: (ulp.S)

...
#define RTC_IO_SCL  17   // => GPIO 27
#define RTC_IO_SCL  7    // => GPIO 26
...
// Macros einfügen
...

	.text
	.global entry
entry:
	SET_PIN_LOW   SCL
	READ_PIN      SDA
	SET_PIN_HIGH  SCL
...	

Der Assembler ersetzt vor dem Build die Macro-Bezeichner durch die definierten Befehle und fügt zusätzlich die Argumente an vorgegebener Stelle ein. Hierdurch lässt sich zwar kein Speicherplatz sparen, der Code gewinnt aber deutlich an Lesbarkeit.


I2C - Ports

Grundsätzlich verfügt die RTC-Unit über eine I2C-Funktionalität. Die sinnhafte Verwendung ist allerdings durch mehrere Faktoren eingeschränkt.
Es ist lediglich das Schreiben und Lesen einzelner 8-Bit-Werte möglich. Eine Multibyte-Funktonalität kennt das System nicht. Sollen bspw. mehrere Register eines Slave gelesen werden, so muss jedes Register neu adressiert werden. Auch wenn der Slave das Single-Byte-Reading zulassen sollte, kann dies zu erheblichem Bit-Overhead führen.
Außerdem sind die verwendbaren Pads durch andere Funktionen in ihrer Verwendbarkeit eingeschränkt.

Solange es nur um die simple Kommunikation mit einem Slave geht, lässt sich das I2C-Protokoll auch Bit-by-Bit über beliebige RTC-IO nachbilden. Ein Beispiel zur Kommunikation mit einem Sensormodul BME280 ist hier zu finden.
Die Initialisierung des Sensorchips sowie die rechenintensive Kompensation und Auswertung der Messergebnisse übernimmt der Mainprocess. Der ULP-Process wird timergesteuert angestoßen, initialisiert die Messung, wartet bis zur Bereitstellung der Ergebnisse, und liest die Daten-Register. Anschließend entscheidet er anhand der Raw-Daten, ob der Hauptprozess zu starten ist.


Analog-Digital-Wandler

Die Ansteuerung der ADC-Ports und das Einlesen von Messwerten ist mit dem ULP-Prozessor möglich. Ein Beispiel ist in den IDF-Examples zu finden.

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