Habt ihr euch vielleicht auch schon mal gefragt was auf einem Rechner wirklich passiert. Dann lest hier weiter. Anhand eines Beispiels für den berühmten Microcontroller ESP32 wird gezeigt was der Controller bei der Programmausführung auf unterster Ebene macht.
Vorbereitung
Wie oben schon erwähnt wird der ESP32 zur Demonstration verwendet. Ich habe da folgenden
https://www.az-delivery.de/products/esp32-developmentboard?variant=36542176914
Aber es kann eigentlich natürlich auch jedes andere Development Board verwendet werden.
Zur Programmierung verwenden wir direkt das ESP-IDF, welches vom Hersteller espressif zur Verfügung gestellt wird. Wie wahrscheinlich die meisten wissen, lässt sich der ESP auch mittels der Arduino Umgebung programmieren, aber diese setzt auch auf dem ESP-IDF auf. Die Arduino Umgebung ist daher eigentlich nur eine Art Wrapper für das IDF um die Verwendung zu vereinfachen. Aber wir wollen uns ja anschauen was auf unterster Ebene passiert.
Die Programmierung, das Bauen der Software und das spätere Debugging könnte man mit dem ESP-IDF auch komplett mit der Konsole und einem einfachen Texteditor durchführen, aber das ist ein wenig unkomfortabel und nicht schön anzuschauen. Daher verwenden wir das schöne Eclipse Plugin. Wenn ihr die Anleitung genau befolgt, sollte es eigentlich direkt laufen. Allerdings gibt es anscheinend, zumindest war es bei mir so, ein Kompatibilitätsproblem mit dem neuesten Eclipse 2020-06, mit 2020-03 läuft es aber.
Als Debugger verwende ich das ESP-Prog Board welches auch von espressif angeboten wird. Man kann aber auch andere Debugger verwenden.
Erstellung des Programms
Nun wenn die ganze Umgebung eingerichtet wurde, können wir mit der Erstellung unseres kleinen Programmes beginnen. Als Basis verwenden wir das hello_world Template aus den Examples
Was soll unser Programm jetzt machen. Eine schöne Sache für den Anfang ist immer das Schalten eines Relais. Und das am Besten noch über ein CLI (Command Line Interface), dann kann man das Relais einfach über einen Konsolenbefehl ansteuern oder später über ein Skript oder irgendein anderes externes Programm.
Hier ist der gesamte Source Code. Ein schönes kleines Beispiel.
/* Hello World Example This example code is in the Public Domain (or CC0 licensed, at your option.) Unless required by applicable law or agreed to in writing, this software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */ #include <stdio.h> #include "sdkconfig.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_system.h" #include "esp_spi_flash.h" #include "esp_console.h" #include "argtable3/argtable3.h" #include "driver/gpio.h" #include <driver/adc.h> static struct { struct arg_int *onOff; struct arg_end *end; } switchRelay_args; #define PIN 27 int switchRelay(int argc, char **argv){ printf("relay switched\n"); int nerrors = arg_parse(argc, argv, (void **)&switchRelay_args); if (nerrors != 0) { arg_print_errors(stderr, switchRelay_args.end, argv[0]); return 0; } /*Select pad as a gpio function from IOMUX*/ gpio_pad_select_gpio(PIN); /* Set the GPIO as a push/pull output */ gpio_set_direction(PIN, GPIO_MODE_OUTPUT); printf("%d\n",switchRelay_args.onOff->ival[0]); if(switchRelay_args.onOff->ival[0] == true) { printf("on\n"); gpio_set_level(PIN, 0); return true; } else if (switchRelay_args.onOff->ival[0] == false) { printf("off\n"); gpio_set_level(PIN, 1); return true; } return false; } static void register_switchRelay(void) { switchRelay_args.onOff = arg_int0(NULL, "onOff", "<0|1>", "Schalte das Relay An oder Aus"); switchRelay_args.end = arg_end(2); const esp_console_cmd_t i2cconfig_cmd = { .command = "switchRelay", .help = "switch Relay", .hint = NULL, .func = &switchRelay, .argtable = &switchRelay_args }; ESP_ERROR_CHECK(esp_console_cmd_register(&i2cconfig_cmd)); } void app_main(void) { printf("Hello world!\n"); esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT(); repl_config.prompt = "switchTheRelay>"; // initialize console REPL environment ESP_ERROR_CHECK(esp_console_repl_init(&repl_config)); register_switchRelay(); // start console REPL ESP_ERROR_CHECK(esp_console_repl_start()); }
Schauen wir uns zunächst die app_main
an. Da wir eine Bedienung über die Konsole möchten, wird zunächst einmal diese repl_config
erstellt. Was dahinter steckt, wird auch in den anderen Beispielen des ESP-IDF gezeigt, das ist ja jetzt nicht das Thema. Dann wird register_switchOnOff
aufgerufen. Damit registrieren wir die Funktion die das Relais schaltet, als Funktion welche über die Konsole aktiviert werden kann.
Diese Funktion werden wir debuggen.
#define PIN 27 int switchRelay(int argc, char **argv){ printf("relay switched\n"); int nerrors = arg_parse(argc, argv, (void **)&switchRelay_args); if (nerrors != 0) { arg_print_errors(stderr, switchRelay_args.end, argv[0]); return 0; } /*Select pad as a gpio function from IOMUX*/ gpio_pad_select_gpio(PIN); /* Set the GPIO as a push/pull output */ gpio_set_direction(PIN, GPIO_MODE_OUTPUT); printf("%d\n",switchRelay_args.onOff->ival[0]); if(switchRelay_args.onOff->ival[0] == true) { printf("on\n"); gpio_set_level(PIN, 0); return true; } else if (switchRelay_args.onOff->ival[0] == false) { printf("off\n"); gpio_set_level(PIN, 1); return true; } return false; }
Der Anfang mit dem arg_parse
ist auch nur für die Konsole. Schauen wir also auf gpio_pad_select_gpio(PIN)
. Diese Funktion wird vom ESP-IDF über den gpio.h bereit gestellt.
C:\esp-idf\components\esp_rom\include\esp32\rom\gpio.h.
Durch diese Funktion wird der PIN 27, siehe das #define PIN 27
am Anfang (es kann auch ein anderer Pin verwendet werden), als GPIO konfiguriert. Die meisten Pins können nämlich die verschiedensten Funktionen wahrnehmen, schaut dazu ins Handbuch. Aber wir brauchen ja nur einen einfachen Output um das Relais anzusteuern. Danach rufen wir dann die Funktion gpio_set_direction(PIN, GPIO_MODE_OUTPUT)
auf. Damit legen wir, wie der Name schon sagt, die Richtung fest, also Output. Wenn ihr euch das typedef anschaut, gibt es noch ein paar andere spezielle modes.
typedef enum { GPIO_MODE_DISABLE = GPIO_MODE_DEF_DISABLE, /*!< GPIO mode : disable input and output */ GPIO_MODE_INPUT = GPIO_MODE_DEF_INPUT, /*!< GPIO mode : input only */ GPIO_MODE_OUTPUT = GPIO_MODE_DEF_OUTPUT, /*!< GPIO mode : output only mode */ GPIO_MODE_OUTPUT_OD = ((GPIO_MODE_DEF_OUTPUT) | (GPIO_MODE_DEF_OD)), /*!< GPIO mode : output only with open-drain mode */ GPIO_MODE_INPUT_OUTPUT_OD = ((GPIO_MODE_DEF_INPUT) | (GPIO_MODE_DEF_OUTPUT) | (GPIO_MODE_DEF_OD)), /*!< GPIO mode : output and input with open-drain mode*/ GPIO_MODE_INPUT_OUTPUT = ((GPIO_MODE_DEF_INPUT) | (GPIO_MODE_DEF_OUTPUT)), /*!< GPIO mode : output and input mode */ } gpio_mode_t;
Die Funktion gpio_set_direction
wird, anders als die erste Funktion gpio_pad_select_gpio
über den Header
C:\esp-idf\components\driver\include\driver\gpio.h
bereitgestellt. Und zu dieser Funktion können wir auch die Definition im c-file öffnen.
Von der gpio_pad_select_gpio
können wir nicht die Definition öffnen und der Pfad des Headers deutet auf den ROM hin. Hm das ist interessant.
Dann gehen wir der Sache mal auf den Grund…
Wir verlieren den Faden zunächst beim Header und es gibt einen Hinweis auf den ROM. Wir durchsuchen einfach mal den ganzen esp-idf Ordner auf dem Rechner. Das mache ich gerne mit Notepad++.
Es werden natürlich hauptsächlich Verwendungsstellen gefunden. Aber die beiden markierten Findings sind interessant. Das erste Finding ist für den ESP32 und das andere für den neueren ESP32s2. Ich habe den älteren ESP. Daher schauen wir uns das erste Finding an. Bei der Datei handelt es sich um ein ld-file. Das ist die Dateiendung der Linkerskripte. Das bedeutet das bei der Verwendung der Funktion gpio_pad_select_gpio
an die Adresse 0x40009fdc
gesprungen wird.
Wollen wir mal schauen, ob wir das beim Debugging beobachten können…
Debugging
Vorher sollten wir noch ein Terminal in eclipse öffnen um die Konsolenausgabe des ESP zu erhalten. Dazu verwende ich den zweiten Kanal des ESP-Prog Board. Man könnte aber auch eine Verbindung über den USB-Anschluss des ESP herstellen.
Man bekommt dann diese schöne Ausgabe…
Nun setzen wir einen Breakpoint an der Funktion und starten das Debugging.
Wenn ihr alles so wie in der Anleitung konfiguriert habt, wird zunächst bei der app_main
angehalten. Daher einfach noch einmal Play drücken oder F8.
Das Programm läuft dann einfach normal weiter. In dem Terminal können wir dann den Befehl zum Schalten des Relais eingeben.
Nachdem Enter gedrückt wurde, wird an dem gesetzten Breakpoint angehalten.
Nun wird’s spannend. Auf der rechten Seite sehen wir den Disassembly. Das ist der „rohe“ Code der auf dem Controller läuft.
In der ersten Spalte steht die Adresse. Wenn wir uns den ProgramCounter (pc) anschauen, sehen wir, dass der Controller jetzt gerade an der Adresse 0x400d3276
steht. Also genau die Zeile aus dem Disassembly. Dort sehen wir nun den Befehl movi.n a10, 27
. Was bedeutet das? Richtig, die 27, unser PIN 27 wird in das Register a10 geschrieben. Leicht zu erkennen.
Wir schauen mal, ob das auch wirklich passiert. Dazu müssen wir den „Instruction Stepping Mode“ aktivieren. Weil wir wollen jetzt ja wirklich die einzelne Instruction beobachten.
Einmal F5 drücken und Voila!
Nun schauen wir uns die nächste Zeile an.400d3278: 0x8141f4 switchRelay+48 l32r a8, 0x400d037c <_stext+860>
Die nehmen wir nun mal genau unter die Lupe.
In der ersten Spalte steht wieder die Adresse. In der zweiten steht der Opcode 0x8141f4
. Das ist das was wirklich im Speicher steht
Und aus diesem Opcode aus dem Speicher wird im Disassembly das l32r a8, 0x400d037c
.
Wie kommt das zu Stande? Dazu müssen wir uns jetzt das Instruction Set des Xtensa chips anschauen. Das findet ihr z.B. hier.
Dort suchen wir nach l32r
.
Hier sehen wir die Struktur des Opcodes, welcher aus 24 Bit besteht, 0x8141f4
.
Binär sieht das so aus 1000 0001 0100 0001 1111 0100
. Jetzt sieht man schon das hier was nicht passt. Am Ende (Bit 0 bis 3) müsste ja eigentlich 0001
stehen, aber das ist nicht der Fall. Das liegt an der Endianess. Daher stellen wir das mal eben kurz um 0xf44181
, binär 1111 0100 0100 0001 1000 0001
. Ok, sieht schon besser aus. Die Bits 4 bis 7, das t von dem L32R at, ist eine 8, wie in der Assemblerzeile l32r a8, 0x400d037c
. Und jetzt stellt sich die Frage wie die 0x400d037c
zu Stande kommt. Da müssen wir mal schauen was das Handbuch/InstructionSet dazu sagt.
L32R forms a virtual address by adding the 16-bit one-extended constant value encoded
in the instruction word shifted left by two to the address of the L32R plus three with the
two least significant bits cleared. Therefore, the offset can always specify 32-bit aligned
addresses from -262141 to -4 bytes from the address of the L32R instruction. 32 bits
(four bytes) are read from the physical address. This data is then written to address
register at.
Ok, das muss man ein paar Mal lesen.
Es geht um die 16 Bit von Bit 8 bis 23, die in dem imm16 Block. Also 0xf441
Also mal Schritt für Schritt:
- one-extended bedeutet wir hängen vorne noch 16 Bit dran, dann haben wir
0xffff f441
- dann ein left-shift um 2, kann man mit dem Windows-Taschenrechner machen
0x 3 FFFF D104
- Die 3 kommt weg, beim Shiften schieben wir die Zahlen raus
0xFFFF D104
- to the address of the L32R plus three with the two least significant bits cleared
Ok die Adresse ist0x400d 3278
, die Adresse des Speichers, das was in der ersten Spalte im Disassembly steht.
Die Adresse addieren wir mit 30x400D 327B
with the two least significant bits cleared0x400D 3278
, oh das ist ja wieder die Adresse, aber das ist nur Zufall, weil die ersten 2 Bit hier Null sind und wir 3 addiert haben was binär ja 11 ist. Bei einer anderen Adresse wo die ersten beiden Bits nicht Null sind, hätten wir einen anderen Wert. - Jetzt müssen wir die beiden Werte addieren („forms a virtual address by adding„)
0xFFFF D104 + 0x400D 3278
=0x 1 400D 037C
, die 1 schmeißen wir wieder weg - Resultat:
0x400D 037C
, das ist jetzt unsere virtuelle Adresse
An dieser virtuellen Adresse steht jetzt 0xDC9F0040
Kurz umstellen wegen der Endianess 0x40009FDC
. Und diese Adresse kennen wir ja aus unserer Suche in dem Linkerscript-Snippet
Diese Adresse wird dann in das Register A8
geschrieben, wie in der Instruction vorgegeben.
Und dann gibt es einen Sprung zu dieser Adresse durch das callx8
Hier stehen wir jetzt an dieser Adresse und sehen die Instruktionen der Funktion gpio_pad_select_gpio
.
Das entry
hat auch noch mit dem Sprung zu tun. Das erkläre ich mal in einem anderen Artikel.
Schauen wir uns das extui
(Extract Unsigned Immediate
) an.
Ok das ist leichter zu verstehen. Es wird einfach nur ein bisschen geshiftet und maskiert.
Es wird der Wert aus dem Register a2 genommen.
Da shiftimm 0 ist wird gar nicht geshiftet. maskimm ist 8. Aber davon werden nur die least-significant 1 bits verwendet. Da bin ich mir nicht ganz sicher was das heißen soll oder ob das vielleicht ein Tippfehler ist. Das least-significant 1 bit wäre ja das bit 0. Das ist bei der 8 in diesem Fall ja 0. Die Maskierung wäre dann 0x1b AND 0x0
. Binär würde es so aussehen
0001 1011 = 1b
---- ---0
——————0001 1010 = 1a
Wenn man aber weiter durch den Code stepped bleibt der Wert in a2 auf 0x1b. Entweder ist da ein Fehler in der Dokumentation oder ich verstehe das falsch. Naja.
Es ist jetzt bestimmt schon jemanden aufgefallen das 1b dezimal 27 ist. Interessant, das ist ja unsere PIN Nummer. Weiter oben haben wir ja gesehen, dass diese 27 in das Register a10 geschrieben wurde. Wie kommt die jetzt in das Register a2? Wir haben ja gerade oben gesehen, dass ein Sprung zu einer anderen Adresse mit der Instruktion callx8 ausgeführt wurde. Und ja die Differenz zwischen 10 und 2 sind 8.
Anscheinend hat das callx8 irgendwie dafür gesorgt das unsere 27 in das a2 Register geschrieben wird. Es ist nämlich so, dass es die Register a0 bis a15 so eigentlich gar nicht wirklich gibt. Es handelt sich dabei um virtuelle Register. Die eigentlichen Register heißen ar und von denen gibt es 64. Die Register a0 bis a15 sind eigentlich nur ein Auszug dieser 64 Register. Es wird sich nur ein gewisses Fenster angeschaut. Und das callx8 verschiebt dieses Fenster um 8 Plätze. Der Wert ist weiterhin einfach im ar38 aber das ist halt a2 nach dem callx8.
Jetzt überspringen wir mal ein paar Instruktionen bis zu dieser hier, was dazwischen ist, könnt ihr ja mal selber nachvollziehen.
Hier wird der Inhalt von a2 an die Adresse von a8 geschrieben.
Die Adresse unter a8 ist nun interessant. Das ist die Adresse des Configuration register unseres PIN 27
Dieses Register hat diesen Aufbau
Der Wert unter a2 ist 0x2800
. Binär ist das
Da sieht man die 10
an Bit 10 und 11. Das sind die 0x2 unter FUN_DRV. An Bit 12 bis 14 steht 010
, was isoliert betrachtet auch 2 sind und 2 bedeutet Function 2, siehe Bild oben, und das wiederum konfiguriert den Pin als GPIO. Die nächste Instruktion ist dann ein return und die Funktion gpio_pad_select_gpio
ist beendet.
Hier haben wir jetzt anhand eines kleinen Beispiels gesehen, dass man, wenn man will, bis ins kleinste Detail nachvollziehen kann, was auf einem Controller wirklich passiert und wie in diesem Beispiel ein Anschlusspin konfiguriert wird.