Zeichenketten in C / C++

Viele haben Schwierigkeiten in C auf dem Arduino Zeichenketten zu bearbeiten. Deshalb will ich mit diesem Tutorial einiges dazu erklären. Es gibt 3 (mir bekannte) Möglichkeiten auf dem Arduino mit Zeichenketten zu arbeiten:
  1. Arrays vom Typ char
  2. Typ String
  3. Typ PString (mit Lib PString.h)
Dabei ist 1 die älteste und vielseitigste, 2 die speicherfressende Variante und 3 ein Mittelding mit geringem Overhead und einigem Komfort.

Dieses Tutorial wird sich hauptsächlich mit der 1. Variante beschäftigen, das Speicherproblem von 2 kurz beleuchten und einen Abriss zu 3 bringen. Dynamische Speicherzuweisungen werden keine Rolle spielen.

Arrays vom Typ char

Was ist ein Array? Ein Array ist ein festgelegter Speicherbereich, der eine (zur Kompilezeit) festgelegte Menge an gleichen Elementen aufnehmen kann.
Hier kommt schon die erste Falle: Diese Menge wird nirgendwo überprüft. Wenn wir also Platz für 5 Elemente anlegen und ein 6. Element schreiben, dann wird der Prozessor das ohne zu meckern tun. Er wird dabei irgend was anderes überschreiben. Was das ist, ist vorher nicht eindeutig bestimmbar. Es kann ein unbenutzter Speicherbereich sein (dann haben wir Glück), eine andere Variable (dann wundern wir uns, warum da Blödsinn drin steht) oder Rücksprungadressen aus Funktionen (dann macht das Programm einfach Blödsinn).


Das sollte man im Hinterkopf behalten: Das Problem sitzt immer vorm Monitor, auch wenn wir das nicht gern hören.
Was ist ein char? Eine Datenstruktur von 1 Byte (oder 8 Bit) Länge.

Kommen wir zu den Arrays von Typ char. Wie werden sie angelegt? Der einfachste Fall ist ein Literal. Wir überlassen es den Kompiler, die Zeichen zu zählen.
char text[] = "Hallo Welt";

Im Speicher sieht das so aus:

|H|a|l|l|o| |W|e|l|t|0|
|0|1|2|3|4|5|6|7|8|9|10|
Nanu, da ist doch plötzlich noch eine 0 (nicht das Zeichen '0' sondern die Zahl 0, als Zeichen auch '\0' geschrieben). Diese 0 ist der Terminator, als das Zeichen, dass hier unsere Zeichenkette zu Ende ist. Das ist ganz wichtig, da sich alle Funktionen, die mit Arrays von Typ Char arbeiten, darauf verlassen, dass diese 0 da ist. Wir brauchen also für die 10 Zeichen ein Array mit der Länge 11 (Wir fangen beim Zählen der Position immer mit 0 an!)

Wir hätten das gleiche auch anlegen können mit:
char text[14] = "Hallo Welt"; // hätten wir [3] geschrieben, hätte der Compiler gemeckert
Im Speicher sieht das so aus:

|H|a|l|l|o| |W|e|l|t|0| x | x | x |
|0|1|2|3|4|5|6|7|8|9|10|11|12 |13 |
Was sind die char, die ich mit x gekennzeichnet habe? Ihr Inhalt ist uns egal, da unser letztes interessantes Zeichen die 0 ist. Wenn text eine globale Variable ist, werden sie beim Start des Arduino mit 0 initialisiert.

Was drückt nun der wert der Variablen Text aus? Da gibt es einen alten Merksatz: "Der Name eines Arrays ist ein Zeiger (Pointer) auf sein erstes Element." Anders ausgedrückt die Variable text sagt uns, wo der Compiler das Array im Speicher abgelegt hat.

Wenn es uns jetzt einfällt, an unseren Text noch 2 Ausrufezeichen ran zu hängen (bei 3 könnte sich der Arduino mit einem alten Boolader aufhängen), was machen wir dann?
name = strcat(name,"!!");
Anstelle "!!" könnt ihr auch eine andere Variable vom Typ Array aus char nehmen. Nehmen wir mal 2 Variablen mit Namen quelle und ziel und schauen mal, was wir damit anfangen können. Immer dran denken: Das Ziel muss genug Platz haben. Ich lasse ein paar Serial.println weg. Die könnt ihr bei Bedarf ergänzen;
char ziel[128];
char zielkurz[4];
char quelle[] = "Arduino Uno";
char vergleich[] = "Arduino Mega"
int zahl;
char * zeiger;
// man kann mit Zeigern auch rechnen
Serial.println(quelle+3);  // ergibt "uino Uno"

// Länge
zahl = strlen(quelle); // liefert 11 also ohne die abschließende 0!
// Zeiger auf die erste Position des Zeichens
zeiger = strchr(quelle,'o'); 
Serial.println(zeiger); // "o Uno" also einen Zeiger auf das erste 'o'
// man kann mit Zeigern auch rechnen
zahl = zeiger - quelle; // ergibt 6, also den Index des ersten 'o' (von 0 an zählen)

// Zeiger auf die letzte Position des Zeichens
zeiger = strrchr(quelle,'o'); 
Serial.println(zeiger); // "o" also einen Zeiger auf das letzte 'o'
// man kann mit Zeigern auch rechnen
zahl = zeiger - quelle; // ergibt 10, also den Index des letzten 'o' (von 0 an zählen)

// Kopieren in den Zielstring
zeiger = strcpy(ziel, quelle);
Serial.println(zeiger);
// oder auch (wenn man das Ziel nicht nochmal abspeichern will
strcpy(ziel, quelle);
Serial.println(ziel);
// auch nicht mehr zulässig
ziel = strcpy(ziel, quelle);

// mit Längenbegrenzung 
// ACHTUNG! strncpy hat ein Problem, wenn als Länge genau die Länge des Zielarrays abgegeben wird. Es schreibt dann keine abschließende 0;
// Danke Serenifly für den Hinweis.
// Es bleiben 2 Möglichkeiten das zu lösen

// Man setzt die abschließende 0 selbst
strncpy(zielkurz,quelle,4);
zielkurz[3]='\0';

// oder man verwendet strlcpy, das vom Arduino-Compiler unterstützt wird, aber nicht im ISO-Standard enthalten ist.
strlcpy(zielkurz,quelle,4);

// Vergleiche ergeben die Differnz des Zeichencodes des ersten ungleichen Zeichens
// aber eigentlich interessiert nur < 0, > 0 oder gleich 0
zahl = strcmp(quelle,vergleich); // ergibt 8 -> größer 0 also ist quelle größer
zahl = strcmp(vergleich,quelle); // ergibt -8 -> kleiner 0 also ist quelle kleiner

// bauen wir uns gleiche Zeichenketten
strcpy(ziel,quelle);
zahl = strcmp(ziel, quelle); // ergibt 0 weil beide gleich sind

// machen wir den falschen Vergleich - der vergleicht ob der Wert der Zeiger gleich ist
if (ziel == quelle) Serial.println("Zeiger gleich");
else Serial.println("zeiger ungleich");

// Der Vergleich geht auch mit Begrenzung der Zeichenzahl
zahl = strncmp(quelle,vergleich,7);  / ergibt 0 weil beide in den ersten 7 Zeichen mit "Arduino" anfangen

// Wir können auch mit den Zeigern rechnen. Nehmen wir an, wir haben ein Array
char befehl[] = "b145";  // das kann auch aus einer Einleseroutine gefüllt sein.

// Das soll bedeuten die Blaue LED soll per PWM auf den Wert 145 gesetzt werden.
// Als Variablen haben wir
char farbe = befehl[0]; also das erste Zeichen ist unsere Farbe. 

// Dann interessiert uns das erste Zeichen nicht mehr, wir wollen den Rest in eine Ganzzahl umwandeln.
int wert = atoi(befehl+1); // Wir zählen einfach auf den Anfang von befehl 1 dazu, dann zeigt dieser Wert auf die 1 von 145. Passt. 
Wenn der RAM langsam voll wird oder schon von Anfang an kann man konstante Zeichenketten auch in den Programmspeicher auslagern und damit RAM sparen. Am einfachsten geht das bei allem, was mit print zusammen hängt. Das ist das F()-Makro.
Serial.println("Das ist ein Text"); // hier steht der Text im RAM
Serial.println(F("Das ist ein Text")); // hier steht der Text Programmspeicher, also in Flash
Bei der Arbeit mit Datenbanken müssen oft längere Select-Statements aufgebaut werden, die dann verschieden gefüllt werden. Wir haben einen Puffer, in dem wir unser Statement zusammenbauen. Der muss natürlich im RAM iegen, den wollen wir ja verändern.
char stmt[80];

char personalId[] = "U12345678";

// Wir wollen zwei mögliche SELECT-Statements absetzen:

const PROGMEM char query1[] = "SELECT (name, vorname, gehalt, personal_id) FROM mitarbeiter WHERE personal_id = '";
const PROGMEM char query2[] = "UPDATE mitarbeiter SET GEHALT = GEHALT * 1.1 WHERE personal_id = '";

strcpy_p(stmt,query1); // unsere Grundquery
strcat(stmt,personalId); // die PersonalId ranhängen
strcat(stmt,"'"); // das abschließende ' für die Zeichenkette

// Ergibt in stmt:
SELECT (name, vorname, gehalt, personal_id) FROM mitarbeiter WHERE personal_id = 'U12345678'
Genau so kann man mit der 2. Query verfahren. Man braucht im Arbeitsspeicher (RAM) nur einmal den Platz. Für die Nutzung des PROGMEM gibt es noch mehr angepasste Zeichenkettenfunktionen, Bei Interesse nach PROGMEM suchen. Es gibt noch zwei weitere Funktionen, die man als die Könige der Verarbeitung der Arrays vom Typ Char bezeichnen kann. Sowas kann man mit Strings nicht in der Eleganz und Einfachheit machen.

Das sind die Stringtokenizer. Dabei geht es darum, eine Zeichenkette, die bekannte Trennzeichen hat, in ihre Teile zu zerlegen. Es muss vorher nicht bekannt sein, wieviele Teile es sind. Achtung diese Funktion verändert die ursprüngliche Zeichenkette. Wenn die noch gebraucht wird, dann muss man sie mit strcpy in eine andere sichern.

Fangen wir mit einem einfachen Beispiel an. Wir haben eine Folge von Ganzzahlen
char eingabe[] = "22:33:11:44:-10:55";
char *ptr;
  // Erster Versuch 
  ptr = strtok(eingabe, ":");
  // solange was gefunden wurde
  while(ptr != NULL) {
    Serial.println(ptr);  // gibt die einzelnen Zahlen aus
    // neu suchen NULL als Eingabe, die Funktion weiß, wo sie ist
    ptr = strtok(NULL,":");
  }
strtok hat einen kleinen Nachteil, es ist nicht wiedereintrittsfähig, dh. ich kann in dem strtok-Durchlauf nicht einen zweiten strtok-Aufrug starten. Abhilfe schafft hier strtok_r:
// Farbe:faden von:nach oder farbe:auf Wert
char eingabe[] = "rot:44:180|blau:123|gruen:128:50"
char *ptr, *savePtr, *p, *saveP;
const char delim[] = "|";

ptr = strtok_r(eingabe,"|",&savePtr);
while(ptr != NULL) {
  // hier haben wir alles zwischen | (außen)
  Serial.println(ptr);
  p = strtok_r(ptr,":",&saveP);
  while(p != NULL) {
    // alles zwischen : (innen)
    Serial.print("____");Serial.println(p);
    p = strtok_r(NULL, ":", &saveP);
  
  }
  // außen neu testen
  ptr = strtok_r(NULL,delim,&savePtr);

}
Hier mal noch ein Beispiel, wie man Uhrzeit und Datum einfach formatiert an eine Zeichenkette anhängen kann. Das Schöne dabei ist, die Variable "buf" in den Funktionen benötigen nur so lange Speicher, wie die Funktion läuft.
int stunde = 3, minute = 29, sekunde = 7, tag = 7, monat = 8, jahr = 2016;

char puffer[20];

// Hängt die formatierte Uhrzeit an eine Zeichenkette an
void addZeit(char * ziel, int st, int mi, int se) {
char buf[9]; // 00:00:00
  buf[0] = (st / 10) + '0'; // Zehner
  buf[1] = (st % 10) + '0'; // Einer
  buf[2] = ':';
  buf[3] = (mi / 10) + '0';
  buf[4] = (mi % 10) + '0';
  buf[5] = ':';
  buf[6] = (se / 10) + '0';
  buf[7] = (se % 10) + '0';
  buf[8] = 0; // die abschließende 0
  strcat(ziel,buf);
} 
// Hängt das formatierte Datum an eine Zeichenkette an
void addDatum(char * ziel, int ta, int mo, int ja) {
char buf[11]; // 00.00.0000
  buf[0] = (ta / 10) + '0'; // Zehner
  buf[1] = (ta % 10) + '0'; // Einer
  buf[2] = '.';
  buf[3] = (mo / 10) + '0';
  buf[4] = (mo % 10) + '0';
  buf[5] = '.';
  buf[6] = (ja / 1000) + '0'; // Tausender
  ja = ja % 1000;             // Rest 3-stellig
  buf[7] = (ja / 100) + '0';  // Hunderter
  ja = ja % 100;              // Rest 2-stellig
  buf[8] = (ja / 10) + '0';   // Zehner
  buf[9] = (ja % 10) + '0';   // Einer
  buf[10] = 0; // die abschließende 0
  strcat(ziel,buf);
}

void setup() {
  Serial.begin(115200);
  Serial.println("start");
  strcpy(puffer,"Zeit: ");
  addZeit(puffer,stunde,minute,sekunde);
  Serial.println(puffer);
  strcpy(puffer,"Datum: ");
  addDatum(puffer, tag,monat,jahr);
  Serial.println(puffer);
}
void loop() {
  
}
Um formatiert Zahlen auszugeben, kann man auch die Funktionen der printf-Familie Nutzen. Achtung! Auf den 8-Bit-AVR ist die Unterstützung für Fließkommazahlen nicht eingebaut.

printf("Format", Liste, von, Variablen);  // Ausgabe auf der Konsole, z.B. Serial - nicht bei AVR!
sprintf(puffer, "Format", Liste, von, Variablen);  // Ausgabe in puffer
snprintf(puffer, laenge, "Format", Liste, von, Variablen);  // Ausgabe in puffer mit max. laenge
Dabei gibt Format an, wie es ausgegeben werden soll (Infos hier) und dahinter kommt eine mit Komma getrennte Liste der Variablen, die ausgegeben werden sollen.

char puffer[9];
byte std = 12, min = 3, sec = 0, anz;
anz = snprintf(puffer, sizeof(puffer), "Zeit: %02d:%02d:%02d", std, min, sec); // ergibt "Zeit: 12:03:00"
In anz steht dann die tatsächlich ausgegebene Anzahl Zeichen.

Es gibt auf Arduino auch noch die Varianten mit _P hinten am Funktionsnamen (z.B. snprintf_P), bei denen der Formatstring im Flash gehalten wird. Dabei gibt es noch eine Besonderheit auf AVR: Zeichenketten können mit %S auch vom Flash eingebaut werden. Bei folgendem Beispiel liegen beide Zeichenketten im Flash.

const char header_1[] PROGMEM = "Text im Flash";

sprintf_P(buffer, PSTR("The column header is %S"), header_1);
Immer wieder gebraucht wird eine Funktion zum Einlesen von einer seriellen Schnittstelle bis zum Zeilenvorschub (NL oder '\n'). Dabei werden Steuerzeichen ausgeblendet und am Ende der Puffer mit '\0' abgeschlossen, si dass alle Zeichenkettenfunktionen damit arbeiten können.
// Länge Nutzdaten + 1
const uint8_t BUFLEN = 11;
char puffer[BUFLEN];

// liest bis zum Zeilenende oder bis der Puffer voll ist und gibt dann true zurück
boolean readSerialNL() {
static uint8_t idx = 0;
static boolean fertig = false;
char c;
  // Neubeginn initialisieren
  if (fertig && idx > 0) {
    idx = 0;
    fertig = false;
  }
  if (Serial.available()) {  // hier könnte man evtl. auch while nehmen
    c = Serial.read();
    if (c == '\n' || idx == BUFLEN -1) { // Zeilenvorschub oder voll
      puffer[idx] = '\0';
      fertig = true;
    }
    else if (c >= 0x20 && idx < BUFLEN -1) { // keine Steuerzeichen
      puffer[idx++] = c;
    }
  }
  return fertig;
}

void setup() {
  Serial.begin(115200);
  Serial.println("Start");
}

void loop() {
  // solange lesen lassen, bis Zeilenende erkannt wurde
  boolean anzeigen = readSerialNL();
  if (anzeigen) Serial.println(puffer);
}

Strings

Strings werden zum Teil zur Laufzeit neu angelegt und wieder zerstört. Wenn wir z.B. einen String und zwei Zahlen haben, dann können wir diese mit Strings augenscheinlich sehr einfach verketten:
String s1 = "Das ist ein Text ";
int i1 = 15, i2 = 33;

  s1 = s1 +String(i1) + ":" + String(i2);
  Serial.println(s1);  // ergibt: Das ist ein Text 15:33
Was passiert aber (vereinfacht) dabei. Der String s1 existiert. Aus i1 wird ein neuer String erstellt und gemeinsam mit dem alten s1 in einen neuen s1 kopiert. Das gleiche passiert beim ":" und bei i2.

Es werden also immer wieder neue Strings angelegt und wieder verworfen. Das kann dazu führen, dass der Speicher fragmentiert.
Es sind also z.B. 40 Byte frei, danach 30 Byte belegt, danach 20 Byte frei, danach 60 Byte belegt, dann 5 Byte frei. Wir hätten also 65 Byte freien Speicher, aber nicht am Stück. Wenn wir also z.B. 50 Byte brauchen, haben wir die nicht mehr, weil unser größtes freies Stück nur 40 Byte groß ist. Man sollte damit also vorsichtig sein, da man sich evtl. schwer lokalisierbare Fehler einfängt. Ein Weg, um die Fragmentierung zu verringern, ist die Benutzung der Methode reserve(groesse) für Strings, die immer wieder verwendet werden. Dabei sollte größe der maximal erwarteten Belegung entsprechen.

PString

PString ist eine Bibliothek von Mikal Hart. Ausführlichere Informationen gibt es hier. Auch den Download gibt es dort. PString heißt "Print to String" und verfolgt den Ansatz die Formatierungen, die uns die print(...)-Funktionen z.B. der seriellen Schnittstelle bieten, auch in Arrays von Char, also Zeichenketten abzuspeichern. PString hat auch einige Zuweisungsoperatoren überladen, so dass diese für einfachere Schreibweisen benutzt werden können. PString braucht immer ein CharArray als Speicher. Es legt von sich aus keine Speicherbereiche an. Damit kann auch nichts fragmentieren. Jeder PString benötigt 8 zusätzliche Bytes an Verwaltungsinformation und ist immer ordentlich mit '\0' abgeschlossen. Genug der Vorrede, kommen wir zum praktischen Teil:
// Bibliothek einbinden
#include <PString.h>

// unser Speicher
char puffer[30];
// Wir legen einen PString an
PString str(puffer, sizeof(puffer));
// Zuweisung einer Zeichenkette
str = "Hallo";
// was passt insgesammt rein (30) inclusive '\0'
Serial.println(str.capacity());
// Wieviel ist belegt? (5)
Serial.println(str.length());
// Wir hängen eine Zeichenkette an
str += " Arduino";
// Hallo Arduino
Serial.println(str);
Serial.println(puffer);

// Neuzuweisung
str = "Test";
Serial.println(puffer);
// leer machen
str.begin();
// Wir können alle Vorteile von Print nutzen, z.B. HEX-Darstellung
str.print("Das ist eine Zahl ");
str.print(123,HEX);
Serial.println(str); 

// Als Abschluss: Wir können PStrings inhaltlich mit == vergleichen
str = "Hallo";
str += " Arduino";
if (str == "Hallo Arduino") Serial.println("beide sind gleich");
else Serial.println("beide sind ungleich");
Ich hoffe, diese kurze Zusammenfassung erleichtert etwas den Einstieg in die Welt der Zeichenketten in C.