Inhalt
Topic:.MemC.
Last changed: 2019-02-09
C hat den Vorteil der Maschinennähe: Es kann alles formuliert werden, was im Maschinencodeablauf notwendig ist. Das ist die Vorausetzung dafür, dass auf Treiberebene oder hardwareorientiert notwendige Dinge programmiert werden können. Aber mit dieser Eigenschaft der Sprache ist die Programmierung in C und gleichermaßen in C++ sensibel bei nicht entdeckten Softwarefehlern.
Im folgenden wird an einem Beispiel-Softwarefehler, der dem Verfasser einige Tage Arbeit gekostet hat, gezeigt, was passieren kann. Folgend wird eine Vermeidungsstrategie im emC-Konzept vorgestellt.
Das MemC
-Konzept wurde vom Verfasser schon ab ca. 2005 eingesetzt, aber jetzt innerhalb emC konsequent verbessert. Dabei stand MemC nicht Pate für emC wie man vermuten könnte. emC ist wesentlich mehr.
Das Prinzip:
Kombination eines Zeigers mit einer Längenangabe für den Speicherbereich.
Eine Grundidee ist dass eine struct { type* ref; int size; }
sich bei 'return per value' im Maschinencode optimal verhält, nämlich über 2 CPU-Register rückgegeben wird. Das ist bei vielen
Compilern beobachtet worden. Man kann also eine solche Struktur als Rückgabedaten ohne Referenz behandeln ('return per value').
Eine weitere Grundidee ist, dass Adresse und Länge zusammengehören. Sie könnten auch im Anwenderalgorithmus auf 2 verschiedenen Variablen liegen und vom Anwender richtig angewendet werden. Besser und auch review-bar ist die Zusammenfassung.
Topic:.MemC.error.
Der einfache Softwarefehler, ein falscher Algorithmus, ist an den Ergebnissen erkennbar und debugbar. Dafür gibt es Tests.
Problematisch wird es, wenn ein Softwarefehler nicht beim Test auffällt da er in einem falschen Speicherbereich schreibt, aber im Praxiseinsatz genau deswegen schwer nachvollziehbare Effekte hervorruft.
Wenn eine Datenstruktur
typedef struct MyType_t { int a,b,c;} MyType_s;
oder auch eine
class MyClass { int a, virtual void op(); };
mittels
MyType_s* refs = (MyType_s*) calloc(sizeof(MyType_s, 1); MyClass* refc = new MyClass();
angelegt wird, und ohne Casting und sonstige Besonderheiten zugegriffen wird:
refs->a = 123;
dann kann nichts passieren. Die Programme sind fehlerfrei hinzubekommen bezüglich Richtigkeit des Speicherzugriffs. Schreibfehler die bei älteren Compilern auch schonmal zu Zugriffsfehlern geführt haben werden vom Compiler erkannt und als Compilerfehler gemeldet.
Anders sieht es dagegen aus wenn Arrays im Spiel sind, deren Größe noch dazu von Parametern abhängen und/oder die Compilerfehlerprüfung
mit void*
-Nutzung abgeschaltet wird:
int nrofData = getItFromArguments(); int32* myData = calloc(sizeof(int32) * nrofData, 1); ... if(ix >=0 && ix < nrofData) { myData[ix] = value; } if(ix >=0 && ix < (nrofData - (zValue>>2)) ) { memcpy(&myData[ix], src, zValue); }
Im letzten Fall sollen eine beliebige Anzahl von Bytes aus einer Quelle auf das Datenfeld kopiert werden, beispielsweise um verschiedene Wertetypen zu speichern (int, float, double, complex, arrays). Daher wird memcpy benutzt. Der Index wird unmittelbar zuvor getestet. Dies sollte eine unbedingte Notwendigkeit sein und nicht nach der Designrichtlinie Design by Contract vorausgesetzt werden, dass kein falscher Index von der Anwendung oder vom davorliegenden Programm vorgegeben werden darf. Besser ist immer die Wareneingangskontrolle als Synonym/Stichwort.
Im konkreten Fall ist nun folgendes passiert: Nachdem der Algorithmus mit dem int32* myData
-Array wie oben gezeigt getestet und durchprogrammiert wurde, ist die Software erweitert worden. Es sind für den Datenspeicher
einige Kopfdaten hinzugekommen, so dass das Ganze etwa wie folgt aussah:
typedef struct MyData_t { HeadData head; int32 data[10]; } MyData; ..... int nrofData = getItFromArguments(); ..... int bytesAlloc = sizeof(MyData) + (nrofData - 10) * sizeof(int32); MyData* myData = calloc(sizeof(bytesAlloc, 1); ..... if(ix >=0 && ix < (nrofData - (zValue>>2)) ) { memcpy(&myData->data[ix], src, zValue); }
Es ist nichts dagegen einzuwenden, dass in der struct MyData
ein Array mit 10 Elementen definiert wird und dann mehr als 10 Elemente benutzt werden. Dies ist beim Allokieren berücksichtigt.
Der Vorteil ist, für das Debugging sieht man die ersten 10 Werte direkt in der Debuganzeige. Das Ganze ist schlüssig lesbar.
Dieses Pattern kann immer empfohlen werden, wenn es sich um ein Array mit variabler Länge zur Runtime handelt. Das ist nicht
das vorzustellende Problem.
Bei der memcpy-Zeile ist nur eine kleine Änderung hinzugekommen:
memcpy(&myData[ix], src, zValue); memcpy(&myData->data[ix], src, zValue);
Diese Änderung wurde in der gesamten Software ordentlich durchgeführt, auf Richtigkeit getestet und - fertig. Außer, es wurde ein Softwaremodul nicht beachtet, vergessen, nicht angeschaut. Die Compilierung stellt den Fehler nicht fest. Die sich hier ergebende fälschliche Verwendung einer Nicht-Array-Referenz als Array hätte vom Compiler erkannt werden können, aber nicht in C und auch nicht in C++ (so weit bekannt). In der Sprache C wurde zu Anfang die Handhabung von Zeigerarithmetik und Array-Indizierung sehr einfach und 'anwenderfreundlich ?' designed, damals gab es noch nicht 'autocompletion', und nie verbessert. Aus heutiger Sicht ist das eigentlich nicht mehr tragbar. Aufgrund des unentdeckten Fehlers in der Quelle wurde zur Runtime über den allokierten Speicher heraus in irgendeinen danebenliegenden Speicher geschrieben.
Der Einsatz des Programms erfolge dann in einer Simulink-Umgebung mit diesen in C geschriebenen S-Functions. Das Gesamtsystem einschließlich noch anderer FBlocks mit diversen Funktionalitäten ist dann schon ein größeres System. Die Software ist modular vorgetestet (in einer Testmodul-C-Umgebung), dann für Simulink compiliert, dann im Simulink-Einsatz getestet.
Ergebnis, vieles geht, aber selten - brachialer Absturz des Simulink ohne erkennbare Verursachung.
Die erste Frage ist: Liegt es überhaupt an der eigenen Software, und an welcher Komponente. Es gibt auch schonmal die Empfehlung, ein Tool neu zu installieren bzw. den Grafiktreiber zu aktualisieren ... Es kommt also eine Unsicherheit auf. Der Absturz passiert offensichtlich nicht in direktem Zusammenhang mit den Sfunction-Blocks.
Weiterarbeit: Einkreisen (Weglassen von Teilen, wann tritt es auf) und Debuggen im 'Attache to Process'-Mode. Hierbei ist es mit Mathworks-Simulink lobenswert, dass die Arbeit des Simulink in den eigenen S-Functions auf C- oder Maschinenebene Schritt-debugged werden kann. Aber das Ganze ist sehr arbeitsaufwendig. Debuggen an sich sowieso (in welchem Heuhaufen sucht man die Nadel). Die Compilierungs- und Startzeiten sind erheblich (einige Sekunden für eine testhalber-Änderung in der C-Umgebung versus Minute(n) für das Compilieren der mex-dll und weitere Minute(n) für das Neustarten, Aufrufen des Testmodells, Einstellen der Bedingungen). Es ist keine sichtliche Korrelation zwischen dem Fehler, bei dem man nicht weiß in welchem Modul er steckt, und der Reaktion erkennbar: Absturz Simulink. Man kann nur in den Zig-Tausend Quellzeilen aller Module vorsichtigt debuggen, nicht finden, wieder ein Absturz mit Neustart.
Topic:.MemC.java.
Es gibt mehrere Möglichkeiten, Programme sicher zu machen:
Quelltext-Reviews mit mindestens 4 Augen
Quelltext-Analysetools
Gute Softwarestukturierung
So die Theorie. Diese Probleme sind nicht neu. Anfang der 90-ger Jahre, genauer zwischen 1990 und 1995 hat sich ein Entwicklerteam einer Aufgabe gestellt: Für die Vielzahl der damals aufkommenden und absehbaren Softwareanwendungen mit verschiedenen CPUs sollte eine einheitliche und sichere Ablaufumgebung entwickelt werden. Beim Start dieses Projektes war die Assemblerprogrammierung mit allen Problemen verbreitet, teils wurde mit Pascal, Turbo-Pascal, auch mit PL/M und solchen Dingen programmiert. Für die ablaufsichere Umgebung wurde ein Meta-Maschinencode definiert, der den Ablauf beschreibt und direkt interpretierbar ist. Bei der Interpretation können Sicherheitschecks für Speicherbereichtests berücksichtigt werden, ohne dass die Anwenderprogrammierung dies selbst (aufwändig) berücksichtigen muss. Damit ist die Ablaufumgebung auch dann sicher, wenn sich ein solcher Fehler wie oben gezeigt eingeschlichen hat und noch nicht gefunden wurde. Das Sicherheitprinzip ist: Es wird nur immer der eigene Speicherbereich beschrieben.
Als diese Entwicklung fertiggestellt war, 1995, hat sich mittlerweile C mit C++ als Programmiersystem auch für Embedded Prozessoren etabliert. Das Programmiersystem, genannt Java, passte aber auch für die aufkommende Variablilität der Internetanwendungen und hat sich also stattdessen dort verbreitet.
Später wurde dieses System 'Java' verbessert: Ein 'Just in Time'-Compiler hat den universellen Meta-Maschinencode, genannt Bytecode vor dem Start des Programms auf den Maschinencode des Zielsystems umgesetzt, damit keine Zeitverluste der Interpretation bestehen. Lediglich die Sicherheitschecks werden auch im Zielcode durchgeführt. Dabei kann man aber optimieren. In einer Schleife mit wohldefinierten Index-Werten braucht man nicht jeden Arrayzugriff zu überprüfen sondern kann sich in dem überschaubaren Bereich auf die Überprüfung der eigentlichen Eingangswerte zu Beginn der Schleife reduzieren. Damit ist der Ablauf von Software in Java systembedingt nur um wenige Prozent langsamer. Böse Zungen behaupten zwar dennoch, Java sei langsam. Der Abarbeitungs-Unterschied zwischen nicht ganz optimal programmierten C++ und Java dürfte größer sein.
Java hat um ca. 2000 einen Stiefbruder bekommen, C#. Auch andere verbreitete Programmiersysteme wie Python sind entsprechend sicher und ähnlich gestaltet.
Java und adäquate Programmiersprachen haben zwei Grenzen, die den Einsatz von C(++) notwendig machen:
a) direkter Zugriff auf die Hardware ist nicht vorgesehen.
b) Das mit der Java-Entwicklung gelöste Problem der dynamischen Speicherverwaltung (Garbage Collector) benötigt zeitweilig etwas eigene Zeit.
Der Punkt a) bedeutet, dass C seine Berechtigung hat wenn es um Treiber und Hardware geht. Das Laufzeitsystem des Java ist selbst in C programmiert.
Der Punkt b) schränkt die Echtzeitfähigkeit ein. Pausenzeiten von einigen zig Millisekunden können vorkommen, die als Response von Serveranwendungen nicht stören, wohl aber in getakteten Systemen der Echtzeit-Signalverarbeitung. Es gibt allerdings echtzeitfähige Garbage-Collector-Lösungen, beispielsweise bei der Jamaica-VM (aicas, https:www.aicas.com).
Ergo: Für reine algorithmische Anwendungen ohne Hardwarebezug sollte man in Java, C# & co programmieren und nicht in C++ und C.
Dennoch braucht man C und C++ für die Hardware- und Treiberebene. Dabei kann man sich nicht auf einfache Algorithmen beschränken, die also insgesamt überschaubar sind, sondern es wird komplexer. Daher braucht es auch in C/++ nebst dem Codereview eine Sicherung von Ablaufumgebungen. Die Grenze ist wie folgt legbar:
Sehr schnelle Programme sind oft einfach und wiederholend, diese können sich keine Zusatzüberwachung leisten, sind aber auf Quelltextebene und im Debugging sicher zu bekommen.
Bei komplexeren Algorithmen kommt es meist nicht auf die letzte Nanosekunde Rechenzeit an.
Automatisch generierte C-Programme können die Sicherheitschecks gezielt und optimiert eingebaut haben.
Topic:.MemC.voidPtr.
Es gehört zum Thema sichere Programmierung, nicht direkt zum Thema MemC:
Zeiger mit dem Type void*
können mit allen Zeigern versorgt werden. Das schafft eine hohe Universalität, die aber den Compiler-Typecheck ausschaltet.
void*
-Zeiger sollte also nur in wohlbegründeten Ausnahmefällen benutzt werden. Zu einer Verwendung von void*
muss es immer entweder eine einfache markante Verwendungsintension geben, bei memcpy
sind die Zeiger die direkten Speicheradressen. Das ist eine einfache Verwendungsintension. Oder es muss eine klare Zusatzangabe
zur Verwendung des Zeigers geben, beispielsweise ein enum-Wert zur Auswahl spezifischer Castings. Das sollte aber nur bei
unbedingter Notwendigkeit erfolgen. Für die Vereinfachung der Parameterübergabe zu komplexen Anwendungsfunktionen ist void*
teils verbreitet, aber nicht zu empfehlen.
In C++ gibt es das static_cast<Type*>
und dynamic_cast<Type*>
. Das erstere ist immer einsetzbar, das dynamic..
nur dann wenn der Typ aufgrund weiterer Angaben gesichert ist. Das ist eine ähnliche aber abgeschwächte Variante der void*
-Problematik. In Java & co sind diese beiden cast-Formen erlaubt, wobei Java beim dynamic-cast immer den Typ zur Laufzeit
testet und keine Fehler zulässt, kostet ein Minimum an Rechenzeit. Der Typ ist bekannt aufgrund der Basisklasse java.lang.Object
und der darin enthaltenen Type-Referenz. Das reinterpret_cast<Type*>
in C++ entspricht dem einfachen C-cast. Damit ist es als Problemstelle so notiert gut erkennbar. Das ist aber nicht der Grund
der Einteilung der cast-Formen in C++. Der Grund für static_cast
und dynamic_cast
ist vielmehr: Bei virtuellen Methoden in einer class und Mehrfachvererbung wird der Zeigerwert entsprechend angepasst. Ohne
static_cast
oder dynamic_cast
, also mit der einfachen (Type*)myRef
- Schreibweise gibt es für C++-classes gröbste Fehler.
Ein cast sollte immer im Comment begründet werden. Es sollte nur eingesetzt werden, wenn es nicht vermeidbar ist. Diverse SIL-Vorschriften (Software mit Sicherheitslevel) sind im einen oder anderen Fall zu beachten.
Topic:.MemC.usg.
Das Prinzip:
Kombination eines Zeigers mit einer Längenangabe für den Speicherbereich.
Eine Grundidee ist dass eine struct { type* ref; int size; }
sich bei 'return per value' im Maschinencode optimal verhält, nämlich über 2 CPU-Register rückgegeben wird. Das ist bei vielen
Compilern beobachtet worden. Man kann also eine solche Struktur als Rückgabedaten ohne Referenz behandeln ('return per value').
Eine weitere Grundidee ist, dass Adresse und Länge zusammengehören. Sie könnten auch im Anwenderalgorithmus auf 2 verschiedenen Variablen liegen und vom Anwender richtig angewendet werden. Besser und auch review-bar ist die Zusammenfassung.
Topic:.MemC.usg.STRUCT_MemC.
Mit Verwendung des Makros
STRUCT_MemC(MyType) myData;
wird eine MemC-Struktur typgerecht angelegt. Es entspricht fast der einfachen Referenzdeklaration MyType* myData
. Man kann auf die Referenz zugreifen mit
myData.ref->...
anstelle des direkten myData->...
. Die Anwendungssoftware ändert sich kaum mit dem Einführen einer MemC-Struktur statt einer Referenz.
Man kann die typisierte MemC-struct
auch ohne Makro anlegen. Makros sind teils nicht zu empfehlen, da sie verdecken, was der Compiler tatsächlich sieht. Andererseits
vereinfachen sie die Schreibweise und auch die Lesbarkeit wenn das Makro bekannt ist.
struct MemC_MyType_t { MyType* ref; intptr_t size; } myData;
Es ist hierbei zu empfehlen, die Bezeichnungen ref
und size
so zu verwenden. Die Verwendung intptr_t
kommt daher, eine reguläre struct
in 2 Registern der Adressierungsbreite (64 bit für High-End-Anwendungen) zu verwenden und das Alignment für optimalen Speicherzugriff
einzuhalten. Der Platzbedarf gegenüber einem 32-bit-int
ist meist kein Problem. Für einen 'normalen' 32-bit-Prozessor in einer Embedded-Anwendung sind dies je 32 bit, auch für einen
16-bit-Prozessor mit 32-bit-Adressraum.
Man kann mit
typedef STRUCT_MemC(MyType) Mem_MyType;
auch einen wiederverwendeten Anwendertyp mit MemC-Ansatz definieren und direkt verwenden. Das ist zu empfehlen, wenn die Daten als MemC weitergegeben werden:
Mem_MyType myRefM; ... Mem_MyType myRef2; //anywhere other ... myRef2 = myRef; //association of references
Die Programmierung ist identisch mit der unter Verwendung einfacher Referenzen, eine Zuweisung für Assoziationen. Es wird
hierbei ref
und size
kopiert.
Topic:.MemC.usg.MemCdef.
In emc/source/MemC_emC.h
wird definiert:
typedef STRUCT_MemC(MemUnit) MemC;
Der Type MemUnit
ist in der jeweiligen compl_adaption.h
so definiert dass er einem Adress-Schritt entspricht. Das ist meist der char
-Typ. Bei einigen Prozessoren aber beispielsweise auch ein int
-Typ. Man kann daher mit der ref
in einer MemC-definition direkt Adressrechnungen ausführen. Das ist nicht für den User-Bereich gedacht sondern für die check-Funktionen.
Die Standard-MemC
-Definition soll also nicht frei in Anwenderprogrammen verwendet werden sondern stellt lediglich die unifizierte Darstellung
eines beliebig getypten Zeigers mit der Länge für MemC-Systemfunktionen dar. Bei einem check-Aufruf:
void* addr = getSpecialAddress(myRefM.ref, further_Args); checkAddress_MemC(&myRefM, addr, nrofBytes)
wird die Referenz auf eine anwenderdefinierte typisierte MemC-struct
entsprechend dem Beispiel oben als void*
-Zeiger intern einen untypisierten MemC
zugeordnet. So kann die Beliebigkeit der typisierten MemC-Definitionen einheitlich verarbeitet werden. Die Verwendung eines
void*
im Argument der checkAddress_MemC(...)
-Routinen kann damit begründet werden und ist eine einfache markante Verwendungsintension nach den Ausführungen in Chapter: 3 Nebenbemerkung void* und casting. Ein Fehler der Zuweisung wird zur Laufzeit sehr wahrscheinlich erkannt da bei beliebig falschen Daten die check-Funktionen
sehr wahrscheinlich false erkennt und mit der entsprechenden Exception reagiert. Die Checkfunktion wird in Chapter: 4.4 Check direkt beim Zugriff auf den Speicher vorgestellt.
Topic:.MemC.usg..
Für die Allokierung steht ein Makro bereit:
ALLOC_MemC(myData, sizeof(MyType)); ALLOC_MemC(myData, sizeof(*myData->ref));
Die zweite Form entnimmt den Typ von der Definition der Referenz und vermeidet damit zusätzlich Verwechslungs-Fehler. Insbesondere kann aber die allokierte Länge dynamisch sein wenn Arrays mit parameterabhängigen Längen im Spiel sind.
Das Makro ist definiert mit
#define ALLOC_MemC(M, SIZE) { *(void**)(&(M).ref) = alloc_MemC(SIZE); (M).size = SIZE; }
Das casting der Referenz auf void*
ist hier zulässig weil beim Allokieren zunächst nur eine Speicheradresse entsteht. Die Richtigkeit der Typisierung kann letzlich
nur aufgrund der angegebenen size sichergestellt werden. Das ist bei allen Allokierungen so. Auch beim new
in C++ wird intern die size der class
als Argument dem low-level-Allokierungsaufruf übergeben.
Die alternative Nicht-Makro-Version dazu kann wie folgt aussehen:
int sizeAlloc = sizeof(MyType); myData.ref = (MyType*) alloc_MemC(sizeAlloc); myData.size = sizeAlloc;
Wichtig ist, dass der selbe Wert für size
gespeichert wird der auch beim Allokieren angebenen wurde.
Das Allokieren mit alloc_MemC(...)
entspricht etwa dem bekannten malloc
mit folgenden Unterschieden:
Es wird statt malloc
über os_AllocMem(...)
eine in der OSAL-Schicht definierte Allokierung aufgerufen. Unter Windows ist dies die API-Funktion LocalAlloc(...)
. Mit der OSAL-Schicht hat man bessere Möglichkeiten, auf spezifische Bedingungen einer Plattform einzugehen.
Es wird im allokierten Bereich vermerkt, wer wann allokiert. Vor dem Anwenderdatenbereich wird eine struct Alloc_MemC_t
vorgeschaltet. Das benötigt 4 Zeigertypen mehr Bytes (16 Bytes in einem 32-Bit-System), damit sind aber Debug-Informationen
vorhanden.
Der allokierte Bereich wird nach hinten mit einem Sicherheitspuffer erweitert. Dessen Größe ist in der applstdef_emC.h
angebbar, auch mit 0: sizeSafetyArea_allocMemC
. Der Bereich wird mit einem Prüfcode initialisiert.
Damit überschreibt eine typisch kleine ungetestete Verletzung des Speicherbereichs nicht andere allokierte oder System-Speicherbereiche.
Beim zugehörigen free_MemC(...)
wird getestet, dass dieser Bereich nicht überschrieben wurde, der Prüfcode wird gecheckt. Im Fall des Überschreibens gibt
es mindestens am Ende des Anwenderprogrammes den notwendigen Fehlerhinweis mit einer Exception. Zum Exceptionhandling siehe
ThCxt-ExcH_emC.html
Damit ist unabhängig von der unmittelbaren Prüfung der size und der Adresse beim Speicherzugriff ein gewisser Sicherheitscheck vorhanden.
Topic:.MemC.usg.check.
Bei einem einfachen Datenzugriff über wohldefinierte Elemente einer struct
oder class
braucht man nicht extra checken, da eine falsche Adresse sich nicht ergeben kann wenn die size beim Allokieren stimmt und
kein falsches casting verwendet wird:
myData.ref->member = 124; //simple, without check
Dagegen ist eine Prüfung der Zugriffsadresse dann zweckmäßig, wenn beispielsweise in einer inneren Routine die Herkunft der Daten nicht genügend bekannt ist:
void myRoutine(float* dataAddr) { dataAddr[0] = p1; dataAddr{1] = p2; }
In einer Definition über Design by Contract würde hier stehen, dass eine gültige Adresse mit 2 Elementen belegt wird. Aber wer sichert in dieser Routine, dass die Adresse richtig ist. Es hängt vom Aufrufer ab. Dazwischen kann sich ein Casting, eine Array-Indizierung oder sonst etwas befinden. Das Problem an Softwarefehlern ist, dass sie nicht bekannt sind. Sie schleichen sich ein. Besser ist es daher, wie folgt zu arbeiten:
void myRoutine(void* memC, float* dataAddr) { if(checkAddress_MemC(memC, dataAddr, 8)) { dataAddr[0] = p1; dataAddr{1] = p2; } }
Das Design by Contract regelt in diesem Fall, dass zusätzlich der Speicherbereich der Daten angegeben werden muss, und zwar als Zeiger auf eine
typisierte MemC-Struktur. Dieses Aufrufargument sollte direkt aus dem Alloc- oder Definitionsteil der Daten kommen ohne Zwischenverarbeitung.
Damit ist einfach Quelltext-reviewbar, dass dies korrekt ist. Die Übergabe als void*
ist hier notwendig, da der Zeigertyp auf einen Allgemeintyp für checkAddress_MemC(...)
reduziert werden muss. Er ist nicht kritisch, da direkt auf die korrekte oder vermeintliche MemC
zugegriffen wird und nur ein unwahrscheinlicher Fehler nicht entdeckt wird.
Die Routine arbeitet inline und damit im Nicht-Fehlerfall rechenezeitoptimiert:
inline bool checkAddress_MemC(void* memC, void* addr, int nrofBytes){ MemC* mem = (MemC*)memC; //Note: the mem as param can have any Type of reference. if (addr >= mem->ref && addr <= (mem->ref + mem->size - nrofBytes)) return true; else { __errorAddress_MemC(mem, addr, nrofBytes); return false; } }
Nur im Fehlerfall wird zu einer Subroutine geprungen, die eine Exception aufruft. Die Exception kann entsprechend der Zielsystemdefinition
in applstdef_emC.h
entweder ein C++- throw
sein, damit in eine Fehlerbehandlung einlaufen, oder das Programm beenden, oder auch lediglich einen Fehlervermerk ausgegeben
und dennoch weiterlaufen. Daher wird hier der Zugriff auf dataAddr
in die if(check...)
eingefasst. Der Zugriff wird also vermieden, das Programm aber nicht angehalten. Damit sind freilich Folgefehler vorhanden,
aber kein Überschreiben falscher Speicherbereiche. Man kann/sollte in die __errorAddress_MemC(...)
-Routine einen Debug-Break setzen. Zur Fehlerbehandlung siehe ThCxt-ExcH_emC.html.
Topic:.MemC.usg.memcpy.
Ein memcpy
ist ein lo-level-Speicherzugriff und im Treiber-, Hardware- und Spezialalgorithmus-Bereich durchaus notwendig. In diesen
Bereichen ist aber häufig die Adressbildung komplex, insbesondere wenn Daten aufbereitet werden in Telegrammen und dergleichen.
Beim memcpy
direkt ist jeglicher Check abgeschaltet. Lediglich die hardwareseitige Speicherüberwachung (Protected Mode) kann grobe Fehler
erkennen.
Ist der Speicherbereich über eine typisierte oder nicht typisierte MemC-Struktur beschrieben mit Adresse und Länge, unabhängig von der Komplexität der Adressbildung intern in diesem Speicherbereich, dann kann dies einfach genutzt werden für ein gesichertes memcpy:
memcpy_MemC(&myData, addr, src, len);
Gegenüber dem normalen memcpy
gibt es lediglich noch die Angabe des MemC-Objektes das wie beim checkAddress_MemC(...)
durchgereicht und gut reviewbar ist.
Die interne Realisierung ist ähnlich wie checkAddress_MemC
nur dass das memcpy
implizit gerufen wird:
inline void memcpy_MemC(void* memC, void* addr, void const* src, int size) { MemC* mem = (MemC*)memC; if (addr < mem->ref || addr >= (mem->ref + mem->size - size)) { __errorAddress_MemC(mem, addr, size); } else { memcpy(addr, src, size); } }
Topic:.MemC.fazit.
Nach Einbau dieser Sicherheitsmechanismen wurde der oben beschriebene Softwarefehler entdeckt.
Die Sicherheitsmechanismen sind weder rechenzeitintensiv noch arbeitsaufwändig. Es lohnt sich an kritischen Stellen oder fast überall mit diesen Mechanismen zu arbeiten. Zumeist ist die Arbeitsaufwand eines Speicherquerschreiber-Softwarefehlers hoch, die Wahrscheinlichkeit dessen Aufretens bei intensiven Codereview zwar gering, aber das Produkt des Auftretens mit dem Aufwand höher als die konsequente Verwendung des MemC zumindestens an kritischen Stellen in C-Programmen.
Für C++ in Zusammenhang mit new muss der operator new
entsprechend überladen werden, das wurde noch nicht ausgeführt. Häufig werden aber komplexe Daten-Zusammenstellungen auch
in C++ mit struct
gehandhabt.
In C(++) hat man keinen doppelten Boden, man muss sich diesen selbst schaffen.