1.1d 🚧 C Datentypen für Arduino

Einfache Datentypen

Die Tabelleninhalte wurden für STM32 Arduino mit Testsoftware ermittelt. Es gibt Unterschiede in der Interpretation der Datentypen bei verschiedenen Compilern und µCs. In C/C++ hat es daher zusätzliche Datentypen, mit denen Vorzeichenbehaftung und Bitbreite compilerunabhängig festgelegt werden können.

Datentyp#Bits#WerteWertebereichentsprichtBemerkung
bool82false (=0), true (≠0)
char (ist unsigned)82560..255 (0..28-1)uint8_tByte
signed char8256-128..127 (-27..27-1)int8_t
short (ist signed)1665536= 64 Ki-32768..32767 (-215..215-1)int16_t
unsigned short1665536= 64 Ki0..65536 (0..216-1)uint16_t
int (ist signed)32232 = 4 Gi-2 Gi..2 Gi -1 (-231..231-1)int32_t
unsigned int32232 = 4 Gi0.. 4 Gi (0..232-1)uint32_t
long (ist signed)32232 = 4 Gi-2 Gi..2 Gi -1 (-231..231-1)int32_t9600L
unsigned long32232 = 4 Gi0.. 4 Gi (0..232-1)uint32_t9600UL
long long (ist signed)64264-263..263-1int64_t
unsigned long long642640..264-1uint64_t
float ToDo: Überprüfen 32232ca. +-1.4 * 10-45 .. +-3.4 * 1038
Genauigkeit ca. 7 Stellen
float
double ToDo: Überprüfen 64264ca. +-4.9 * 10-324 .. +-1.8 * 10308
Genauigkeit ca. 15 Stellen
double
STM32 (ESP32 nochmal testen) Arduino Datentypen

Wo und wie die Daten im Speicher abgelegt werden

Beim Definieren von Variablen und Konstanten wird vom Compiler Speicherplatz entsprechend des Datentyps reserviert.

Machen wir ein Experiment und schauen uns an wo und wie der Compiler die Daten speichert, hier ein wenig Testcode:

char c = 0x42;   // Definition von c als char und Initialisierung mit 0x42
int  i = 0x1234; // Definition von i als int und Initialisierung mit 0x1234

void setup() {
  Serial.begin(9600);
  delay(500);
  Serial.printf("c Adr: %#x Wert: %#6x sizeof: %d\n",&c,c,sizeof(c));
  Serial.printf("i Adr: %#x Wert: %#6x sizeof: %d\n",&i,i,sizeof(i));
  for(char* p=(char*)&i; p<=&c;p++){ // Ausgabe in Bytes (Erklärung unten)
    Serial.printf("%#x\t%#4x\n",p,*p);
  }
}
void loop() {}

&-Operator gibt die Adresse der Variablen zurück

sizeof() gibt die Byte-Grösse einer Variablen zurück.

Ausgabe des Codes:

c Adr: 0x20000004 Wert:   0x42 sizeof: 1
i Adr: 0x20000000 Wert: 0x1234 sizeof: 4
0x20000000	0x34
0x20000001	0x12
0x20000002	   0
0x20000003	   0
0x20000004	0x42

Erkenntnisse:
Die Daten werden beim Arduino-Compiler in der umgekehrten Reihenfolge der Definition abgelegt i vor c.
Ein int kriegt 4 Byte und das LSB wird zuerst gespeichert (Little Endian).

Identifier (ID)DatentypSpeicheradresse &IDWertsizeof(ID)Daten im Speicher
cchar0x200000040x42sizeof(c) = 10x42
iint0x200000000x1234sizeof(i) = 40x34 0x12 0x00 0x00
Variablen aus Compilersicht

Zeiger, Pointer

Sobald man sich näher mit C/C++ beschäftigt wird man um das Thema Zeiger, Pointer nicht herum kommen. Oft werden Werte beim Aufruf von Unterprogrammen nicht kopiert, sondern eine Referenz (Zeiger, Pointer) auf die Adresse der Daten im Speicher als Parameter mit gegeben. Das Hantieren mit den & und * Operatoren kann sehr verwirrend sein. Ich will versuchen die Logik dahinter aus Compilersicht zu beleuchten, das hat mir geholfen nicht mehr so verwirrt zu sein.

Unter einem Pointer (deu. Zeiger) versteht man den Verweis, die Referenz auf eine Speicheradresse.

Beim Definieren von Variablen erzeugt * eine Pointer-Variable: char *c_p; // Pointer auf char
Beim Verwenden mit Variablen de-referenziert der *-Operator den Pointer, greift auf Wert der referenzierten Speicheradresse zu.

char c = 0x42;  // Definition von c als char und Initialisierung mit 0x42
char *c_p = &c; // Pointer auf char mit Adresse von c initialisieren

void setup() {
  Serial.begin(9600);
  delay(500);
  Serial.printf("c   Adr: %#x Wert: %#10x sizeof(c):   %d\n",&c,c,sizeof(c));
  Serial.printf("c_p Adr: %#x Wert: %#10x sizeof(c_p): %d *c_p: %#x sizeof(*c_p): %d\n",&c_p,c_p,sizeof(c_p),*c_p,sizeof(*c_p));
  for(char* p=(char*)&c_p; p<=&c;p++){ // Ausgabe in Bytes
    Serial.printf("%#x\t%#4x\n",p,*p);
  }
}
void loop() {}
c   Adr: 0x20000004 Wert:       0x42 sizeof(c):   1
c_p Adr: 0x20000000 Wert: 0x20000004 sizeof(c_p): 4 *c_p: 0x42 sizeof(*c_p): 1
0x20000000	 0x4
0x20000001	   0
0x20000002	   0
0x20000003	0x20
0x20000004	0x42
Identifier (ID)DatentypSpeicheradresse &IDWertsizeof(ID)Daten im Speicher*IDsizeof(*ID)
cchar0x200000040x42sizeof(c) = 10x42
c_pchar*0x200000000x20000004sizeof(c_p) = 40x04 0x00 0x00 0x200x421
Variablen aus Compilersicht

Der char-Pointer char* verweist auf eine Speicheradresse einer char-Variablen. Beim STM32 haben Speicheradressen 32Bit = 4Byte, daher hat der Pointer die Größe 4 Byte. Beim Derefernzieren (mit dem *-Operator) wird *c_p als char interpretiert. D.h. nimm das was bei c_p steht und interpretiere es als Char.
Die Zuweisung *c_p=0x66; bewirkt dass c nun den Wert 0x66 hat.

Datentypen müssen kompatibel sein

Der Compiler wacht darüber, daß die Zuweisungen zu den Referenzen passend sind:

int c = 0x42;
char *c_p = &c; // einem Verweis auf char soll ein int-Speicherplatz untergeschoben werden -> Fehler
--------------
char c = 0x42;
int *c_p = &c;  // einem Verweis auf int soll ein char-Speicherplatz untergeschoben werden -> Fehler

🏋️ Profiwissen, nur für starke Nerven

🏋️ Mit Pointern kann man durch den Speicher wandern

Serial.printf("*c_p: %#x *c_p-1: %#x *(c_p-1): %#x\n",*c_p,*c_p-1,*(c_p-1));
0x20000000	 0x4
0x20000001	   0
0x20000002	   0
0x20000003	0x20
0x20000004	0x42
*c_p: 0x42 *c_p-1: 0x41 *(c_p-1): 0x20 // beachte Operatorenprioritäten

🏋️ Pointer -1 = Pointer -4?

int i = 0x12345678;
int *i_p = &i; // Pointer auf char mit Adresse von c initialisieren

void setup() {
  Serial.begin(9600);
  delay(500);
  Serial.printf("i   Adr: %#x Wert: %#10x sizeof(i):   %d\n",&i,i,sizeof(i));
  Serial.printf("i_p Adr: %#x Wert: %#10x sizeof(i_p): %d *i_p: %#x sizeof(*i_p): %d\n",&i_p,i_p,sizeof(i_p),*i_p,sizeof(*i_p));
  for(char* p=(char*)&i_p; p<=(char*)&i+3;p++){ // Ausgabe in Bytes
    Serial.printf("%#x\t%#4x\n",p,*p);
  }
  Serial.printf("*i_p: %#x i_p-1: %#x *(i_p-1): %#x\n",*i_p,i_p-1,*(i_p-1));
}
void loop() {}
i   Adr: 0x20000004 Wert: 0x12345678 sizeof(i):   4
i_p Adr: 0x20000000 Wert: 0x20000004 sizeof(i_p): 4 *i_p: 0x12345678 sizeof(*i_p): 4
0x20000000	 0x4
0x20000001	   0
0x20000002	   0
0x20000003	0x20
0x20000004	0x78
0x20000005	0x56
0x20000006	0x34
0x20000007	0x12
*i_p: 0x12345678 i_p-1: 0x20000000 *(i_p-1): 0x20000004
Identifier (ID)DatentypSpeicheradresse &IDWertsizeof(ID)Daten im Speicher*IDsizeof(*ID)
iint0x200000040x12345678sizeof(i) = 40x78 0x56 0x34 0x12
i_pint*0x200000000x20000004sizeof(i_p) = 40x04 0x00 0x00 0x200x123456784
Variablen aus Compilersicht

Lustiges Ergebnis: i_p hat den Wert 0x20000004, i_p-1 hat den Wert 0x20000000. Der Compiler multipliziert bei Pointerberechnungen den Wert mit der Größe des Datentyps: i_p-1 ist i_p-1*sizeof(int) = i_p-4. Das was er nun bei 0x20000000 findet wird als int interpretiert und ausgegeben.

🏋️ Typecast-Trick bei der for-Schleife

Die for-Schleife zum byteweisen Ausgeben der Inhalte der Speicheradressen hat dafür einen Trick benötigt:

for(char* p=(char*)&i_p; p<=(char*)&i+3;p++){ // Ausgabe in Bytes
  Serial.printf("%#x\t%#4x\n",p,*p);
}

Ich will Byte für Byte im Speicher ausgeben, dazu brauche ich eine Variable char *p mit Referenzgröße 1 Byte. Die Startadresse steht in &i_p diese Variable ist allerdings vom Typ *(Pointer) hat also 4Byte Größe, somit zwinge ich mit Typecast (char*)&i_p die Adresse in *p zu speichern. Die Endadresse ist &i vom Typ int. Mit dem Typecast (char*) mache ich sie für *p passend. Damit die ganze Variable i ausgegeben wird zähle ich noch 3 dazu.

🏋️ const Pointer

const char c = 0x42;
const char *c_p = &c;
c   Adr: 0x800348d Wert:       0x42 sizeof(c):   1
c_p Adr: 0x20000000 Wert:  0x800348d sizeof(c_p): 4 *c_p: 0x42

Eine Konstante darf nicht geändert werden, eine erneute Zuweisung c=10 würde einen Fehler ergeben.
Interessant ist der Speicherort der Konstanten c, er liegt im ROM-Bereich.
Der Pointer c_p liegt allerdings im RAM-Bereich!

const char c = 0x42;
const char *const c_p = &c; // Pointer ist nun auch eine Konstante

void setup() {
  Serial.begin(9600);
  delay(500);
  Serial.printf("c   Adr: %#x Wert: %#10x sizeof(c):   %d\n",&c,c,sizeof(c));
  Serial.printf("c_p Adr: %#x Wert: %#10x sizeof(c_p): %d *c_p: %#x sizeof(*c_p): %d\n",&c_p,c_p,sizeof(c_p),*c_p,sizeof(*c_p));
  for(char* p=(char*)&c_p; p<=(char*)&c;p++){ // Ausgabe in Bytes
    Serial.printf("%#x\t%#4x\n",p,*p);
  }
}
void loop() {}
c   Adr: 0x8003494 Wert:       0x42 sizeof(c):   1
c_p Adr: 0x8003490 Wert:  0x8003494 sizeof(c_p): 4 *c_p: 0x42 sizeof(*c_p): 1
0x8003490	0x94
0x8003491	0x34
0x8003492	   0
0x8003493	 0x8
0x8003494	0x42

Arrays, Felder

char c[] = {1,2,3,4};

void setup() {
  Serial.begin(9600);
  delay(500);
  Serial.printf("c   Adr: %#x Wert: %#10x sizeof(c): %d\n",&c,c,sizeof(c));
  for(char* p=(char*)&c; p<(char*)&c+sizeof(c);p++){ // Ausgabe in Bytes
    Serial.printf("%#x\t%#4x\n",p,*p);
  }
}
void loop() {}
c   Adr: 0x20000000 Wert: 0x20000000 sizeof(c): 4
0x20000000	 0x1
0x20000001	 0x2
0x20000002	 0x3
0x20000003	 0x4

&c = c? offensichtlich wird bei einem Array gleich die Startadresse übergeben.

C-String

char c[]= "ABCD";

void setup() {
  Serial.begin(9600);
  delay(500);
  Serial.printf("c Adr: %#x Wert: %#10x sizeof(c): %d\n",&c,c,sizeof(c));
  for(char* p=(char*)&c; p<(char*)&c+sizeof(c);p++){ // Ausgabe in Bytes
    Serial.printf("%#x\t%#4x\n",p,*p);
  }
  Serial.printf("c: %s c[1]: %c *(c+1): %c c+1: %s\n",c,c[1],*(c+1),c+1);
}
void loop() {}
c Adr: 0x20000000 Wert: 0x20000000 sizeof(c): 5
0x20000000	0x41
0x20000001	0x42
0x20000002	0x43
0x20000003	0x44
0x20000004	   0
c: ABCD c[1]: B *(c+1): B c+1: BCD

Strings sind 0-terminiert, am Ende steht eine 0.
c[1] ist gleich *(c+1)
char* werden in C als Strings interpretiert.


Hier meine Testsoftware

Manchmal ist Testen am Ende schneller als Suchen und Lesen.

Testsoftware für STM32 und ESP32

Messen wir die Anzahl der Bytes für verschiedene Controller und Datentypen mit sizeof()!

bool b=false;
char c=0;
short s=0;
int i=0;
long l=0;
long long ll=0;
float f=0;
double d=0;

void setup(){   // Einmalige Ausführung => Initialisierungen...
    Serial.begin(9600); // Serielle Schnittstelle starten und Baudrate festlegen
    delay(1000); // Nötig für Arduino, bei PlatformIO ging es ohne
    Serial.printf("bool: %d char: %d short: %d int: %d long: %d long long: %d float: %d double: %d \n",sizeof(b),sizeof(c),sizeof(s),sizeof(i),sizeof(l),sizeof(ll),sizeof(f),sizeof(d));
    c=c-1; // Test ob char signed ist
    s=s-1; // Test ob short signed ist
    l=l-1;
    Serial.printf("c: %d s: %d l: %d\n",c,s,l);
}
void loop(){
}
bool: 1 char: 1 short: 2 int: 4 long: 4 long long: 8 float: 4 double: 8 
c: 255 s: -1 l: -1

Testsoftware für Arduino Uno/Mega

Der klassische Arduino-Controller war ein Atmel AVR 8 Bit Ding. Bei diesem Controller wird für int 16 Bit verwendet, Zahlenbereich -32 Ki .. + 32 Ki -1.
Für den Arduino-Uno musste ich improvisieren, da printf nicht einfach geht.

char mybuffer[80]; // notwendig für snprintf
bool b=false;
char c=0;
short s=0;
int i=0;
long l=0;
long long ll=0;
float f=0;
double d=0;

void setup(){   // Einmalige Ausführung => Initialisierungen...
    Serial.begin(9600); // Serielle Schnittstelle starten und Baudrate festlegen
    delay(1000); // Nötig für Arduino, bei PlatformIO ging es ohne
    snprintf(mybuffer,80,"bool: %d char: %d short: %d int: %d long: %d long long: %d float: %d double: %d \n",sizeof(b),sizeof(c),sizeof(s),sizeof(i),sizeof(l),sizeof(ll),sizeof(f),sizeof(d));
    Serial.print(mybuffer);
    c=c-1; // Test ob char signed ist
    s=s-1; // Test ob short signed ist
    l=l-1;
    snprintf(mybuffer,80,"c: %d s: %d l: %d\n",c,s,l);
    Serial.print(mybuffer);
}
void loop(){
}
bool: 1 char: 1 short: 2 int: 2 long: 4 long long: 8 float: 4 double: 4 
c: -1 s: -1 l: -1

Überraschung! Char wird anders interpretiert! Abweichungen zu STM32

Datentyp#Bits#WerteWertebereichentspricht
char (ist signed!)8256-128..127 (-27..27-1)int8_t
unsigned char82560.255uint8_t
int (ist signed)1665536= 64 Ki-32768..32767 (-215..215-1)int16_t
unsigned int1665536= 64 Ki0..65536 (0..216-1)uint16_t
double32232ca. +-1.4 * 10-45 .. +-3.4 * 1038
Genauigkeit ca. 7 Stellen
float
Arduino Uno Datentypen

Das mit dem Char ist übel, aus meinen AVR-Zeiten war ich gewohnt, dass char signed ist und wunderte mich bei einem Temperatur-Sketch, dass ich explizit signed char angeben musste. Jetzt weis ich warum. Tja, werde wohl zukünftig die konkreten C-Datentypen verwenden, damit es keine Verwirrung gibt..

Testsoftware für Speicherbelegung zum Erkenntnisgewinn

bool b = true;
char c1 = 3;
int zahl = 0x1234;
char c2 = 15;
char feld[] = {1,2,3,4};
char s[]="ABCD";
String str="ABCD";
const char k = 7; // wo landen Konstanten?
char buf[100]; // Ausgabepuffer für sprintf

void setup() {
  char c3 = 7;
  Serial.begin(9600);
  delay(500);
  sprintf(buf,"b: %#x c1: %#x zahl: %#x c2: %#x feld: %#x s: %#x\n",&b,&c1,&zahl,&c2,feld,s);
  Serial.print(buf);
  sprintf(buf,"str: %#x k: %#x c3: %#x\n",str,&k,&c3);
  Serial.print(buf);
  for(char* i=(char*)s; i<=(char*)&b;i++){
    sprintf(buf,"%#x\t%x\n",i,*i);
    Serial.print(buf);
  }
}
void loop() {
}

Ausgabe STM32 L152RE

b: 0x20000011 c1: 0x20000010 zahl: 0x2000000c c2: 0x20000009 feld: 0x20000005 s: 0x20000000
str: 0x20013fd4 k: 0x8003331 c3: 0x20013fd3
0x20000000	41
0x20000001	42
0x20000002	43
0x20000003	44
0x20000004	0
0x20000005	1
0x20000006	2
0x20000007	3
0x20000008	4
0x20000009	f
0x2000000a	0
0x2000000b	0
0x2000000c	34
0x2000000d	12
0x2000000e	0
0x2000000f	0
0x20000010	3
0x20000011	1

Ausgabe Arduino Uno

b: 0x10d c1: 0x10c zahl: 0x10a c2: 0x109 feld: 0x105 s: 0x100
str: 0x8f5 k: 0x114 c3: 0x8fb
0x100	41
0x101	42
0x102	43
0x103	44
0x104	0
0x105	1
0x106	2
0x107	3
0x108	4
0x109	f
0x10a	34
0x10b	12
0x10c	3
0x10d	1

Erkenntnisse

  • Überraschung: Bei Arduino werden die Variablen in der umgekehrten Reihenfolge des Definierens im Speicher angelegt 🤪.
  • Strings “ABCD” sind Null-Terminiert, also wird am Ende eine 0 mit abgespeichert, damit das Ende des Strings markiert ist.
  • Beim STM werden int Speichergerecht (32Bit) ausgerichtet (aligned), damit beim Verarbeiten nur ein Buszyklus nötig ist.
  • Lokale Variablen werden im Stack gespeichert (keine Überraschung) s2 wird offensichtlich auch im Stack gespeichert.
  • Konstanten werden bei STM32 brav im ROM gespeichert, beim Uno im RAM, daher das ganze PROGMEM-Zeug…

Ausgabe ESP32

b: 0x3ffbdb79 c1: 0x3ffbdb78 zahl: 0x3ffbdb74 c2: 0x3ffbdb71 feld: 0x3ffbdb6d s: 0x3ffbdb68
str: 0x3ffb225c k: 0x3f400179 c3: 0x3ffb225b
0x3ffbdb68	41
0x3ffbdb69	42
0x3ffbdb6a	43
0x3ffbdb6b	44
0x3ffbdb6c	0
0x3ffbdb6d	1
0x3ffbdb6e	2
0x3ffbdb6f	3
0x3ffbdb70	4
0x3ffbdb71	f
0x3ffbdb72	0
0x3ffbdb73	0
0x3ffbdb74	34
0x3ffbdb75	12
0x3ffbdb76	0
0x3ffbdb77	0
0x3ffbdb78	3
0x3ffbdb79	1