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 | #Werte | Wertebereich | entspricht | Bemerkung |
---|---|---|---|---|---|
bool | 8 | 2 | false (=0), true (≠0) | ||
char (ist unsigned) | 8 | 256 | 0..255 (0..28-1) | uint8_t | Byte |
signed char | 8 | 256 | -128..127 (-27..27-1) | int8_t | |
short (ist signed) | 16 | 65536= 64 Ki | -32768..32767 (-215..215-1) | int16_t | |
unsigned short | 16 | 65536= 64 Ki | 0..65536 (0..216-1) | uint16_t | |
int (ist signed) | 32 | 232 = 4 Gi | -2 Gi..2 Gi -1 (-231..231-1) | int32_t | |
unsigned int | 32 | 232 = 4 Gi | 0.. 4 Gi (0..232-1) | uint32_t | |
long (ist signed) | 32 | 232 = 4 Gi | -2 Gi..2 Gi -1 (-231..231-1) | int32_t | 9600L |
unsigned long | 32 | 232 = 4 Gi | 0.. 4 Gi (0..232-1) | uint32_t | 9600UL |
long long (ist signed) | 64 | 264 | -263..263-1 | int64_t | |
unsigned long long | 64 | 264 | 0..264-1 | uint64_t | |
float ToDo: Überprüfen | 32 | 232 | ca. +-1.4 * 10-45 .. +-3.4 * 1038 Genauigkeit ca. 7 Stellen | float | |
double ToDo: Überprüfen | 64 | 264 | ca. +-4.9 * 10-324 .. +-1.8 * 10308 Genauigkeit ca. 15 Stellen | double |
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) | Datentyp | Speicheradresse &ID | Wert | sizeof(ID) | Daten im Speicher |
---|---|---|---|---|---|
c | char | 0x20000004 | 0x42 | sizeof(c) = 1 | 0x42 |
i | int | 0x20000000 | 0x1234 | sizeof(i) = 4 | 0x34 0x12 0x00 0x00 |
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) | Datentyp | Speicheradresse &ID | Wert | sizeof(ID) | Daten im Speicher | *ID | sizeof(*ID) |
---|---|---|---|---|---|---|---|
c | char | 0x20000004 | 0x42 | sizeof(c) = 1 | 0x42 | ||
c_p | char* | 0x20000000 | 0x20000004 | sizeof(c_p) = 4 | 0x04 0x00 0x00 0x20 | 0x42 | 1 |
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) | Datentyp | Speicheradresse &ID | Wert | sizeof(ID) | Daten im Speicher | *ID | sizeof(*ID) |
---|---|---|---|---|---|---|---|
i | int | 0x20000004 | 0x12345678 | sizeof(i) = 4 | 0x78 0x56 0x34 0x12 | ||
i_p | int* | 0x20000000 | 0x20000004 | sizeof(i_p) = 4 | 0x04 0x00 0x00 0x20 | 0x12345678 | 4 |
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 | #Werte | Wertebereich | entspricht |
---|---|---|---|---|
char (ist signed!) | 8 | 256 | -128..127 (-27..27-1) | int8_t |
unsigned char | 8 | 256 | 0.255 | uint8_t |
int (ist signed) | 16 | 65536= 64 Ki | -32768..32767 (-215..215-1) | int16_t |
unsigned int | 16 | 65536= 64 Ki | 0..65536 (0..216-1) | uint16_t |
double | 32 | 232 | ca. +-1.4 * 10-45 .. +-3.4 * 1038 Genauigkeit ca. 7 Stellen | float |
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