Bei der C-Programmierung schleichen sich insbesondere bei unerfahrenen C-Entwicklern leicht Fehler ein, die sich im Nachhinein nur schwer nachvollziehen lassen. Deshalb lohnt es sich, von Anfang an bewährte Programmierpraktiken anzuwenden und auf Details zu achten, um weniger Fehler zu machen und Unit-Tests zuverlässig zu bestehen.
C ist als Programmiersprachen seit Jahrzehnten etabliert, doch bestimmte Anfängerfehler finden sich immer wieder. Vom Vergessen des NULL-Terminators bis zum Abgleich von C-Strings: Dies sind die häufigsten Fehltritte von C-Einsteigern.
(Bild: IAR Systems)
Mit C können Entwickler Raumschiffe programmieren – oder noch vor dem Mittagessen den eigenen Laptop lahmlegen. Manche Fehler werden gerade von Programmierneulingen immer wieder gemacht. Diese Bugs bereiten echtes Kopfzerbrechen und sind bisweilen fast unmöglich zu reproduzieren. Sie sind insbesondere ein Hindernis für Teams, die sich in einer „Sprint-Phase“ der Entwicklung befinden. Aber viele dieser Fehler treten so häufig auf, dass wir sie benennen und vermeiden können. Außerdem gibt es Vorgehensweisen, die den Code auf die bestmögliche Art „langweilig“ machen – also zuverlässig und vorhersehbar über Compiler, Optimierungsstufen und Plattformen hinweg. Mit den folgenden 10 Tipps kann jeder seinen C-Code verbessern.
1. Off-by-one-Fehler bei Strings: Der NULL-Terminator wird vergessen
Bild 1. Off-by-One-Fehler bei Strings: Der NUL-Terminator wurde vergessen.
(Bild: IAR System)
Unerfahrene C-Entwickler reservieren „gerade genug“ Bytes für Text und schreiben dann ein zusätzliches Zeichen (Bild 1). Man legt also die Größe eines Puffers auf n fest, weil der Name n Zeichen hat, und schon ist es passiert: Man vergisst, dass auch der NULL-Terminator Platz benötigt. Dieses fehlende Byte ist die Ursache für viele unbemerkte Overflows und kann dafür sorgen, dass Log-Zeilen in Speicherkorruption enden. Es ist deterministisch, reproduzierbar und, sagen wir mal, ziemlich hässlich.
Profis beheben das Problem wie folgt: Bei C-Strings wird immer die Länge +1 reserviert, statt sprintf wird vorzugsweise snprintf verwendet und anschließend der Rückgabewert überprüft. Mit strnlen lassen sich Lesevorgänge begrenzen, und im Zweifelsfall wird der Puffer mit memset gefüllt, sodass nach dem Schreiben überprüft werden kann, ob das letzte Byte 0 ist. Hier gilt die Faustregel: Wurden die Zeichen gezählt, muss eins hinzugefügt werden. Wurde nicht gezählt, sollte man sich fragen, warum.
2. Vorzeichenverwechslungen, die aus -1 plötzlich vier Milliarden machen
Bild 2. Eine Vorzeichenverwechslung macht aus -1 vier Milliarden Probleme.
(Bild: IAR Systems)
Ein int (möglicherweise -1 als Rückgabewert eines Funktionsfehlers) wird mit einem size_t (Array-Länge) verglichen, und der Compiler wandelt den int in unsigned um, weil die C-Spezifikation genau das vorschreibt. Schon wird aus -1 die Zahl 4.294.967.295, und die Grenzwertprüfung ist nun eine Steilvorlage für eine Katastrophe (Bild 2).
Dieser Fehler ist deterministisch, leicht reproduzierbar und unglaublich subtil. Um ihn zu vermeiden, sollten Größen und Indizes durchgängig als size\_t geführt und explizite, enge casts nur an API-Grenzen eingesetzt werden. Fehler lassen sich zudem besser über separate Kanäle melden, also eigene Rückgabecodes, als über negative Sentinel-Werte. Auch das Aktivieren von -Wsign-compare ist dringend zu empfehlen. Wenn dennoch unterschiedliche Typen gemischt werden müssen, sollte zunächst normalisiert werden: Vor einer Konvertierung nach size_t ist zu überprüfen, ob der Wert ≥ 0 ist. Annahmen sollten anschließend mit assert überprüft werden. Noch dazu ist es wichtig, die Warnungen der Toolchain ernst zu nehmen, denn es handelt sich meist um Fehler, die quasi nur darauf warten, sich bemerkbar zu machen.
3. strncpy für „sicher“ halten und dann nicht-terminierte Strings ausliefern
Bild 3. Kaum wird die strncpy-Funktion für „sicher“ gehalten, werden nicht terminierte Strings ausgeliefert
(Bild: IAR Systems)
strncpy fühlt sich wie eine Rettungsweste an, bis man merkt, dass kein NULL-Terminator garantiert wird, wenn die Quelle zu lang ist (Bild 3). Das Ergebnis ist ein vermeintlich sicherer Puffer, der unbrauchbare Daten ausgibt, Parser verwirrt und strcmp beschädigt. Schlimmer noch: Ist die Quelle zu kurz, füllt strncpy den Rest mit Nullen auf und verschwendet damit unnötige Rechenzeit.
So ist es richtig: Wenn eine Kürzung mit einem Terminator erforderlich ist, wird snprintf verwendet und der Rückgabewert überprüft. Falls strncpy notwendig ist, sollte sofort nachterminiert werden: buf[n-1] = ‚0‘. Dabei gilt eine einfache Regel: Es sollte kein provisorischer Code zusammengeschustert werden in der Annahme, dass man später noch einmal zurückkommen und ihn korrigieren wird. Das wird erfahrungsgemäß nicht geschehen oder zumindest nicht, bevor der Fehler entdeckt wurde.
4. Off-by-one-Schleifen, die über den Puffer hinausgehen
Bild 4. Off-by-one-Schleifen gehen über das Ende des Puffers hinaus.
(Bild: IAR Systems)
Ein klassisches Muster ist die Schleife for (i = 0; i <= len; ++i). Sie schreibt len+1 Elemente und überschreibt das Byte hinter dem Array. Aber was passiert, wenn die Variable ihren maximalen Wert erreicht? Es entsteht eine Endlosschleife (Bild 4).
Gute Compiler müssen davon ausgehen, dass dieser Fall eintreten kann. Eine solche Struktur verhindert daher viele nützliche Schleifenoptimierungen. Der Fehler ist deterministisch, reproduzierbar und stürzt oft ab bei Eingaben, die „gerade groß genug“ sind. Abhilfe schaffen hier klare Invarianten. Für Elementschleifen sollte stets mit i < len iteriert werden, während i ≤ len nur dann verwendet werden sollte, wenn tatsächlich auf eine mit explizit zugewiesene Sentinel-Positionen zugegriffen wird. Bei NULL-terminierten Strings müssen Schreibvorgänge auf i < n-1 für begrenzt und anschließend buf[i] = ‚\\0‘ gesetzt werden. Darüber hinaus sollten in Debug-Builds die Grenzen zusätzlich mit assert abgesichert werden.
Stand: 08.12.2025
Es ist für uns eine Selbstverständlichkeit, dass wir verantwortungsvoll mit Ihren personenbezogenen Daten umgehen. Sofern wir personenbezogene Daten von Ihnen erheben, verarbeiten wir diese unter Beachtung der geltenden Datenschutzvorschriften. Detaillierte Informationen finden Sie in unserer Datenschutzerklärung.
Einwilligung in die Verwendung von Daten zu Werbezwecken
Ich bin damit einverstanden, dass die Vogel Communications Group GmbH & Co. KG, Max-Planckstr. 7-9, 97082 Würzburg einschließlich aller mit ihr im Sinne der §§ 15 ff. AktG verbundenen Unternehmen (im weiteren: Vogel Communications Group) meine E-Mail-Adresse für die Zusendung von redaktionellen Newslettern nutzt. Auflistungen der jeweils zugehörigen Unternehmen können hier abgerufen werden.
Der Newsletterinhalt erstreckt sich dabei auf Produkte und Dienstleistungen aller zuvor genannten Unternehmen, darunter beispielsweise Fachzeitschriften und Fachbücher, Veranstaltungen und Messen sowie veranstaltungsbezogene Produkte und Dienstleistungen, Print- und Digital-Mediaangebote und Services wie weitere (redaktionelle) Newsletter, Gewinnspiele, Lead-Kampagnen, Marktforschung im Online- und Offline-Bereich, fachspezifische Webportale und E-Learning-Angebote. Wenn auch meine persönliche Telefonnummer erhoben wurde, darf diese für die Unterbreitung von Angeboten der vorgenannten Produkte und Dienstleistungen der vorgenannten Unternehmen und Marktforschung genutzt werden.
Meine Einwilligung umfasst zudem die Verarbeitung meiner E-Mail-Adresse und Telefonnummer für den Datenabgleich zu Marketingzwecken mit ausgewählten Werbepartnern wie z.B. LinkedIN, Google und Meta. Hierfür darf die Vogel Communications Group die genannten Daten gehasht an Werbepartner übermitteln, die diese Daten dann nutzen, um feststellen zu können, ob ich ebenfalls Mitglied auf den besagten Werbepartnerportalen bin. Die Vogel Communications Group nutzt diese Funktion zu Zwecken des Retargeting (Upselling, Crossselling und Kundenbindung), der Generierung von sog. Lookalike Audiences zur Neukundengewinnung und als Ausschlussgrundlage für laufende Werbekampagnen. Weitere Informationen kann ich dem Abschnitt „Datenabgleich zu Marketingzwecken“ in der Datenschutzerklärung entnehmen.
Falls ich im Internet auf Portalen der Vogel Communications Group einschließlich deren mit ihr im Sinne der §§ 15 ff. AktG verbundenen Unternehmen geschützte Inhalte abrufe, muss ich mich mit weiteren Daten für den Zugang zu diesen Inhalten registrieren. Im Gegenzug für diesen gebührenlosen Zugang zu redaktionellen Inhalten dürfen meine Daten im Sinne dieser Einwilligung für die hier genannten Zwecke verwendet werden. Dies gilt nicht für den Datenabgleich zu Marketingzwecken.
Recht auf Widerruf
Mir ist bewusst, dass ich diese Einwilligung jederzeit für die Zukunft widerrufen kann. Durch meinen Widerruf wird die Rechtmäßigkeit der aufgrund meiner Einwilligung bis zum Widerruf erfolgten Verarbeitung nicht berührt. Um meinen Widerruf zu erklären, kann ich als eine Möglichkeit das unter https://contact.vogel.de abrufbare Kontaktformular nutzen. Sofern ich einzelne von mir abonnierte Newsletter nicht mehr erhalten möchte, kann ich darüber hinaus auch den am Ende eines Newsletters eingebundenen Abmeldelink anklicken. Weitere Informationen zu meinem Widerrufsrecht und dessen Ausübung sowie zu den Folgen meines Widerrufs finde ich in der Datenschutzerklärung, Abschnitt Redaktionelle Newsletter.
5. memcpy statt memmove bei überlappenden Bereichen
Bild 5. Mit memcpy bei überlappenden Bereichen statt memmove
(Bild: IAR Systems)
memcpy geht davon aus, dass sich Quelle und Ziel nicht überschneiden. Tritt eine Überschneidung auf, ist das Verhalten undefiniert und es kann zu einer scheinbar deterministischen Beschädigung kommen. Jedes Mal, wenn Bytes innerhalb desselben Puffers verschoben werden, zum Beispiel durch Löschen eines Präfixes oder beim Einfügen an derselben Stelle, sollte memmove verwendet werden, da diese Funktion Überschneidungen korrekt behandelt (Bild 5). Es gilt: Gleicher Puffer + mögliche Überschneidung → memmove; garantierte Nicht-Überschneidung → memcpy.
6. Pointer und Referenz auf eine lokale Variable zurückgeben
Bild 6. Die Rückgabe eines Pointers oder einer Referenz auf eine lokale Variable führt zu Problemen.
(Bild: IAR Systems)
Erstellt der Programmierer einen Puffer auf dem Stack und gibt &buf[0] zurück, dann endet die Lebensdauer bereits, sobald die Funktion zurückkehrt. Der Aufrufer hat nun einen Pointer auf den toten Speicher. In trivialen Tests „funktioniert” das vielleicht, doch später führt es deterministisch zu Abstürzen (Bild 6).
Die Lösung ist einfach: Der Anwender stellt den Zielpuffer und dessen Größe bereit, oder er wird mit malloc zugewiesen, wobei klar dokumentiert sein muss, wer diesen wieder freigibt. Dabei gilt zu beachten: Lebt die Variable auf dem Stack, stirbt sie mit der Funktion.
7. switch ohne break führt zu unbeabsichtigtem Durchfallen
Bild 7. Das kann unschön enden: switch ohne break für zu versehentlichem Durchfallen.
(Bild: IAR Systems)
Vergisst der Programmierer einen break zu setzen, wird nicht nur richtige case, sondern auch gleich der nächste ausgeführt. Ein solches Verhalten ist bei jedem Durchlauf deterministisch und manchmal auf überraschend komische Weise falsch (Bild 7).
Deshalb sollte jeder case mit break oder return beendet werden. Ist ein fallthrough beabsichtigt, muss dieser deutlich mit „/* fallthrough */” kommentiert und mit -Wimplicit-fallthrough ermöglicht werden. Profis fügen zur Sicherheit immer einen default switch case hinzu, der gegebenenfalls eine assert auführt, selbst wenn sie glauben, dass sie alle möglichen Fälle des switch-Befehls abgedeckt haben.
8. sizeof eines Pointers ist nicht die Array-Länge
Bild 8. sizeof eines Pointers liefert nicht die Array-Länge
(Bild: IAR Systems)
Ein Array wird an eine Funktion übergeben und zerfällt dort zu einem Pointer. sizeof(ptr) liefert anschließend 8 (oder 4), statt der tatsächlichen Anzahl der Elemente. Das führt zu Unterzuweisungen und halbierten Kopien – ein katastrophales Ergebnis (Bild 8).
sizeof kennt nur die Größe dessen, was es gerade sieht. Längen sollten beim Zeitpunkt der Deklaration mit sizeof arr oder sizeof arr[0] berechnet und die Anzahl zusammen mit dem Pointer übergeben werden.
9. Speicher falsch freigegeben: neu mit delete[], malloc mit delete
Bild 9. Speicher wird falsch zugewiesen: neu mit delete[], malloc mit delete.
(Bild: IAR Systems)
In C gilt eine einfache Regel: Jeder malloc, calloc oder realloc muss genau einem free auf denselben Pointer aus demselben Heap zugewiesen werden. Deterministische Abstürze entstehen, wenn Speicher freigegeben wird, der nicht zugewiesen wurde, mehrfach freigegeben wird oder verschiedene benutzerdefinierte Allokatoren gemischt werden (Bild 9).
Die Eigentumsverhältnisse müssen klar erkennbar sein, indem free im selben Modul, in dem die Zuweisung erfolgt, verwendet oder dies deutlich dokumentiert wird. Direkt nach der Zeile malloc( ) kann die entsprechende free( )-Zeile eingegeben werden. Anschließend wird entschieden, wo free( ) tatsächlich platziert werden soll.
10. Beim Vergleichen von C-Strings mit == stellt sich die Frage, warum „admin” ≠ „admin” ist
10. Erst C-strings mit == vergleichen und sich dann wundern, warum „admin” ≠ „admin” ist.
(Bild: IAR Systems)
In C sind „String“-Variablen Pointer, während == Adressen vergleicht und nicht die Inhalte. Zwei identisch aussehende Strings aus unterschiedlichen Puffern werden beim ==-Test jedes Mal durchfallen, was ärgerlich ist (Bild 10).
Für vollständige Vergleiche sollte strcmp verwendet werden und strncmp für begrenzte Überprüfungen, wobei man bei der case sensitivity explizit sein muss (strcasecmp/strncasecmp, sofern verfügbar). Dabei gilt: Pointer zeigen nur auf, sie beweisen keine Gleichheit.
Fazit und generelle Hinweise
Es ist wichtig, Bytes zu zählen, die Laufzeit zu kontrollieren, Allokatoren abzustimmen, Invarianten zu definieren und die Tools frühzeitig Alarm schlagen zu lassen. Alle Warnungen sollten aktiviert sein, gleichzeitig sollten AddressSanitizer und Undefined Behavior Sanitizer im Debug genutzt werden, während an den entscheidenden Stellen der Logik Assertions eingefügt werden. Noch dazu sollten Größen von Anfang bis Ende in size_t beibehalten werden.
Bevorzugt sollten Muster zum Einsatz kommen, die Korrektheit direkt in einfachem C ausdrücken: Längen zusammen mit Puffern übergeben, Pointer und Länge in kleinen Strukturen kapseln und Eigentumsrechte zentralisieren. So können Compiler und Reviewer auch zuverlässig schützen.
Deterministische Fehler sind ein Geschenk, denn aus ihnen kann man schnell lernen. Wer sich die Beachtung der oben aufgeführten Basics früh zur Gewohnheit macht, wird nicht mehr von seinem Code „überrascht“. Zudem werden Reviews schneller gehen und Releases werden sich nicht mehr wie ein Glücksspiel, sondern eher wie ein gut geöltes Uhrwerk anfühlen. (sg)
* Shawn Prestridge ist Field Applications Engineer Team Leader bei IAR Systems.
* Matthew Thoresen ist Electrical Engineer bei IAR Systems.