7.0 🚧 Numerik bei µC für C(++) 🤯

Synopsis: 📖 Numerische Mathematik 📖 Datentypen in C

Immer wieder Ärger mit der Rechnerei. Bei Arduino gibt es auch noch 8Bit µC mit wenig RAM-Speicher bei denen geschickter Umgang mit geeigneten Datentypen eine Rolle spielen kann. Mit modernen 32Bit Controllern wie STM32, ESP32 usw. entspannt sich die Lage aber grundlegendes Verständnis sollte vorhanden sein.

Bei praktisch allen neueren ITG Abi-Prüfungen wird im Bereich Hardware dieses Thema tangiert: AD-Wandlerwerte werden in Fließkommazahlen umgewandelt, Sensordaten werden byte-weise eingelesen und in Fließkommawerte umgewandelt.

Betrachte: 1.1d C Datentypen für Arduino

Problemfelder

  • Ganzahldivision: Nachkommastellen werden abgeschnitten
  • Vorzeichenbehaftete ganzzahlige Datentypen und Shift nach Rechts: MSB wird mitgenommen, damit das Vorzeichen erhalten bleibt
  • Konvertierung von Datentypen bei Zuweisung…
  • Geschickte Anordnung von Operationen für maximale Genauigkeit.

🧠 Byte-Werte zusammen setzen und weiter verarbeiten

Typischer Fall, ein Temperaturwert wird von einem I2C Sensor gelesen (Wire.read() gibt int zurück), dabei werden zwei Bytes übertragen und müssen danach zusammengesetzt werden. Hier mein Versuchscode zur Analyse und meine Interpretation was genau passiert (gerne Rückmeldung geben wenn ich falsch liege):

void setup() {
  Serial.begin(115200);
  Serial.println();
  char c1 = 0b10101011;
  Serial.printf("\tchar c1  = 0b10101011 \t-> %#x\n",c1);
  signed char sc1 = 0b10101011;
  Serial.printf("signed \tchar sc1 = 0b10101011 \t-> %#x\n",sc1);
  char c2 = sc1;
  Serial.printf("\tchar c2  = sc1   \t-> %#x\n",c2);
  signed char sc2 = c1;
  Serial.printf("signed \tchar sc2 = c1   \t-> %#x\n",sc2);
  char c3 = sc1>>1;
  Serial.printf("\tchar c3  = sc1>>1 \t-> %#x\n",c3);
  signed char sc3 = c1>>1;
  Serial.printf("signed \tchar sc3 = c1>>1 \t-> %#x\n",sc3);
  int i1 = c1;
  Serial.printf("\tint  i1  = c1   \t-> %#x\n",i1);
  int i2 = sc1;
  Serial.printf("\tint  i2  = sc1   \t-> %#x\n",i2);
  int i3 = sc1>>1;
  Serial.printf("\tint  i3  = sc1>>1 \t-> %#x\n",i3);
  int i4 = (unsigned char)sc1>>1;
  Serial.printf("\tint  i4  = (unsigned char)sc1>>1 \t-> %#x\n",i4);
  int i5 = 0b110101011;
  Serial.printf("\tint  i5  = 0b110101011 \t-> %#x\n",i5);
  char c4 = i5;
  Serial.printf("\tchar c4  = i5   \t-> %#x\n",c4);
  signed char sc4 = i5;
  Serial.printf("signed \tchar sc4 = i5   \t-> %#x\n",sc4);
}

void loop() {}

Mit Serial.printf(..) wird über den Seriellen Monitor ausgegeben, die Ganzzahl-Parameter werden offensichtlich in int (bei STM32 int32_t) umgewandelt, um die Bitmuster zu sehe habe ich Hexadezimalformat gewählt. Vor der Ausgabe erfolgt implizit eine Umwandung von Datentyp der Variablen -> int.
Beim STM32 werden die Variablen <=32 Bit als 32Bit passend der Architektur gespeichert (alignment).

Hinweis: Es gibt arithmetischen Shift Right und logischen Shift Right. Beim arithmetischen Shift Right wird das Vorzeichen bei behalten das MSB weiter kopiert: Damit -2>>1 auch -1 wird. Beim logischen Shift Right wird kein Vorzeichen beachtet. Vorzeichenbehaftete Datentypen werden arithmetisch und vorzeichenlose logisch geschoben.

AusgabeErklärung, meine Interpretation
char c1 = 0b10101011 -> 0xabchar ist Vorzeichenlos und wird einfach als untere 8 Bit eingespeichert
signed char sc1 = 0b10101011 -> 0xffffffabsigned char hat Vorzeichen und beim Einspeichern wird das MSB des Bytes beachtet, daher wird der Wert als 32Bit negative Zahl gespeichert.
char c2 = sc1 -> 0xabDie unteren 8Bit werden einfach kopiert.
signed char sc2 = c1 -> 0xffffffabDie unteren 8Bit werden kopiert aber das Vorzeichen Bit 7 wird beachtet und bei den restlichen Bits angepasst.
char c3 = sc1>>1 -> 0xd5Vorzeichenbehaftete negative Zahl sc1 wird nach rechts verschoben, dabei das Vorzeichen mit genommen, das Ergebnis in ein char gepresst.
signed char sc3 = c1>>1 -> 0x55Vorzeichenlose Zahl c1 wird nach rechts verschoben dabei wird kein Vorzeichen mit genommen.
int i1 = c1 -> 0xabVorzeichenloser Wert aus c1 wird in i1 kopiert.
int i2 = sc1 -> 0xffffffabVorzeichenbehafteter Wert aus sc1 wird in i2 gespeichert, dabei wir das MSB von sc1 beachtet.
int i3 = sc1>>1 -> 0xffffffd5sc1 wird nach rechts geschoben, dabei wird das negative Vorzeichen mit genommen.
int i4 = (unsigned char)sc1>>1 -> 0x55sc1 wird als vorzeichenloser 8Bit Wert interpretiert und dann um 1 Bit nach rechts verschoben.
int i5 = 0b110101011 -> 0x1abBitwert wird in Variable kopiert.
char c4 = i5 -> 0xabDie unteren 8 Bits werden kopiert der obere Rest wird abgeschnitten.
signed char sc4 = i5 -> 0xffffffabDie unteren 8 Bits werden kopiert das MSB wird dabei als Vorzeichen berücksichtigt.

🔨 Nun aus dem Experiment eine leicht merkbare Erkenntnis formulieren..

Beispiele aus der Praxis und Abi-Prüfungen

LM75A I2C Temperatursensor

Beim 🔗LM75A wird die Temperatur mit zwei Bytes übertragen. Zuerst das MSB mit dem Vorzeichenbehafteten Wert in °C, dann das LSB mit den Nachkommawerten.

MSByteLSByteTemperatur
Bit7654321076543210
LM75D8D7D6D5D4D3D2D1D0xxxxxxxD * 0,5°C
LM75AD10D9D8D7D6D5D4D3D2D1D0xxxxxD * 0,125°C
Temperaturwert LM75(A)

Diese beiden Bytes müssen zusammengesetzt und mit der Auflösung multipliziert werden. Auf Seite 8 des Datenblatts gibt es netterweise eine Tabelle mit Beispielen.

Test mit STM32 und „Newlib Nano + Float Printf“

 signed char msb = 0b11111111; // zum Testen ob -0.125 laut Doku rauskommt
 char lsb = 0b11100000;
 float temp = ((msb<<3) | (lsb>>5))*0.125; // LM75A mit 0.125 Grad Auflösung
 Serial.printf("temp  = %f °C\n",temp);

Damit Printf float ausgeben kann Newlib Nano + Float Printf auswählen. Ergebnis stimmt dann.

Test mit Arduino Uno (8Bit ATmega328)

void setup() {
  char mybuffer[80]; // notwendig für snprintf
  char fstr[16];     // Puffer für dtostrf-Funktion
  Serial.begin(115200);
  Serial.println();
  signed char msb = 0b11111111; // zum Testen ob -0.125 laut Doku rauskommt
  unsigned char lsb = 0b11100000;
  int dwert = msb; 
  dwert = (msb << 3) | (lsb>>5);
  //float temp = dwert * 0.125;
  float temp = ((msb<<3) | (lsb>>5))*0.125; // LM75A mit 0.125 Grad Auflösung
  dtostrf(temp,0,3,fstr); // 3 Nachkommastellen
  snprintf(mybuffer,sizeof(mybuffer),"dwert = %#x temp  = %s °C\n",dwert,fstr);
  Serial.print(mybuffer);
  msb = 0b01111110; // zum Testen ob +126.875 laut Doku rauskommt
  lsb = 0b11100000;
  dwert = msb; 
  dwert = (msb << 3) | (lsb>>5);
  //temp = dwert * 0.125;
  temp = ((msb<<3) | (lsb>>5))*0.125; // LM75A mit 0.125 Grad Auflösung
  dtostrf(temp,0,3,fstr); // 3 Nachkommastellen
  snprintf(mybuffer,sizeof(mybuffer),"dwert = %#x temp  = %s °C\n",dwert,fstr);
  Serial.print(mybuffer);
  msb = 0b11001001; // zum Testen ob -54,875 laut Doku rauskommt
  lsb = 0b00100000;
  dwert = msb; 
  dwert = (msb << 3) | (lsb>>5);
  //temp = dwert * 0.125;
  temp = ((msb<<3) | (lsb>>5))*0.125; // LM75A mit 0.125 Grad Auflösung
  dtostrf(temp,0,3,fstr); // 3 Nachkommastellen
  snprintf(mybuffer,sizeof(mybuffer),"dwert = %#x temp  = %s °C\n",dwert,fstr);
  Serial.print(mybuffer);
}

void loop() {}

Hier sind zwei Puffer nötig, einer für snprintf(..) und einer für die Umwandlung von float in einen String mit Funktion dtostrf(..), da snprintf(..) nicht einfach %f kann.
Achtung: char ist bei Arduino Uno signed! Es muss unsigned für lsb-Verarbeitung vorangestellt werden.
Überrascht hat mich, dass ((msb<<3) | (lsb>>5))*0.125 funktioniert, obgleich die Operanden 8-Bit Datentypen sind und der µC 8 Bit hat.

SHT30 HP25 I2C Temperatur- und Feuchtigkeitssensor

Beim 🔗SHT30 wird die Temperatur als aus 2 Byte zusammengesetzte 16-Bit Zahl St übertragen. Die Temperatur in °C wird mit dieser Formel daraus berechnet:
temp = -45 + 175* St/(216 -1). St ist offensichtlich nicht vorzeichenbehaftet!

uint8_t tempMSB = Wire.read(); // lies aus Puffer und konvertiere in uint8_t
uint8_t tempLSB = Wire.read();
int temp = (tempMSB << 8) | tempLSB; // setze die Werte in 16 Bit zusammen
float tempC = -45 + 175 * temp / 65535.0; // Beachte die Reihenfolge der Ausführung

Bei der tempC-Berechnung wird 175 mit temp als Ganzzahl multipliziert und dann in eine Fließkommazahl konvertiert um durch 65535.0 geteilt zu werden. -45 wird in eine Fließkommazahl konvertiert und addiert.