Derselbe Object-Pascal-Quellcode kann sich unter Delphi und FPC/Lazarus auf vier verschiedene Arten verhalten, die immer wieder zu Fehlern im Code der PDFium-Komponente führen: FPC gibt temporäre Records aus Funktionsergebnissen frei, bevor eine in-Mengenprüfung sie vollständig gelesen hat. dcc32 liefert standardmäßig deaktivierte Bereichsprüfungen aus, sodass ungültige Array-Indizes stillschweigend Müll-Daten auslesen. Nur Delphi 13 akzeptiert die Zuweisung eines anonymen array of Byte an TBytes ohne Cast. Und Delphis AnsiString-Verkettung kann Bytes ab $80 über einen versteckten Codepage-Round-Trip beschädigen. Jedes dieser Szenarien führt dazu, dass eine Testsuite unter einem Compiler erfolgreich durchläuft, unter dem anderen jedoch fehlschlägt oder — was noch schlimmer ist — stillschweigend falsche Werte liefert
Wenn Sie zum ersten Mal ein Projekt für zwei Compiler einrichten, beschreibt der Leitfaden für Lazarus- und FPC-Viewer den einfachen Weg: Pakete, Suchpfade und das Anzeigen eines Rendering-Fensters. Dieser Artikel ist das genaue Gegenteil. Es ist die Liste von Problemen, auf die wir stießen, nachdem das Grundsetup funktionierte — als die CI unter FPC und Delphi grün war, eine Änderung auf der einen Seite jedoch auf der anderen fehlschlug. Jede im Folgenden beschriebene Falle stammt aus realen Fehlern in der Testsuite von PDFiumPas oder deren Demos, deren forensische Details hier auf das Wesentliche reduziert wurden
Warum wird eine Menge unter FPC als leer ausgelesen, in Delphi jedoch nicht?
Die Kurzfassung lautet: FPC kann die temporäre Variable, die das Record-Ergebnis einer Funktion hält, freigeben, bevor ein Ausdruck, der ein Feld dieses Ergebnisses liest, abgeschlossen ist. Somit kann X in Func().Issues eine Mengenprüfung gegen einen bereits freigegebenen Speicherbereich ausführen, während der entsprechende Delphi-Ausdruck funktioniert. Unsere PDF/E-Konformitätstests stießen in ihrer ersten Version auf dieses Problem. Der Validator gibt einen Record zurück, dessen Feld Issues eine Menge von Fehler-Flags darstellt, und die Assertions riefen diese Funktion direkt auf:
// Unzuverlässig unter FPC: Der temporäre Record aus dem Funktionsergebnis
// kann freigegeben werden, bevor die Mengenprüfung 'in' das Feld Issues liest.
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// Zuverlässig auf beiden Compilern: Weisen Sie das Ergebnis zuerst einer lokalen Variablen zu.
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
Die inline geschriebene Form lieferte unter FPC eine leere Menge zurück, sodass jede Assertion, die ein gesetztes Flag erwartete, fehlschlug, während der identische Delphi-Build erfolgreich durchlief. Die Ursache liegt in der unterschiedlichen Verwaltung der Lebenszeit temporärer Funktionsergebnisse innerhalb größerer Ausdrücke: Delphi hält die temporäre Variable bis zum Ende des Ausdrucks am Leben, während FPC die Variable freigibt, noch während der Operator für die Mengenprüfung darauf zugreift. Wir hatten dieses Verhalten bereits im Helper FlagPresent der PDF/A-Testeinheit dokumentiert und den Fehler beim Schreiben neuer Tests dennoch erneut eingebaut. Dies zeigt, wie natürlich die fehlerhafte Schreibweise wirkt. Die Lösung ist mechanisch und sollte als feste Regel etabliert werden: Greifen Sie niemals direkt nach einem Funktionsaufruf, der einen Record zurückgibt, auf ein Feld oder eine Mengenprüfung zu. Weisen Sie das Ergebnis zuerst einer lokalen Variablen zu und lesen Sie dann das Feld aus. Das kostet eine Zeile Code und schließt diese Fehlerklasse plattformübergreifend aus
Warum akzeptiert Delphi einen Array-Index, den FPC nicht kompilieren will?
Die Kurzfassung lautet: dcc32 kompiliert einen ungültigen Index in ein statisches Array und liest oder schreibt bei standardmäßig deaktivierter Bereichsprüfung zur Laufzeit ohne Fehlermeldung im benachbarten Speicherbereich. FPC hingegen lehnt denselben Index bereits beim Kompilieren ab. Die PDFium-Komponente deklariert QuadPoints als ein 1-basiertes Array (TQuadrilateralPoint = array [1..4] of TPdfPoint), was der üblichen Nummerierung in PDF-Dokumenten entspricht. Eine Demo, die dieses Array in einer 0-basierten Schleife füllte, funktionierte unter Delphi monatelang problemlos:
var
I: Integer;
begin
for I := 0 to 3 do // Falsch: Das Array ist [1..4] deklariert.
Data.AttachmentPoints[I] := Corner[I]; // dcc32-Standard: Kompiliert. Index 0
// greift stillschweigend auf Nachbarspeicher zu.
// FPC: Bereichsprüfungsfehler beim Kompilieren.
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // Korrekt auf beiden Compilern.
end;
Der Delphi-Build lieferte ein falsches positives Ergebnis: Bei deaktivierter Bereichsprüfung (Standard bei dcc32) landete der Index 0 auf dem Feld, das dem Array im Record vorangestellt war, und das Programm lief scheinbar weiter. Das Portieren derselben Demo auf Lazarus führte sofort zu einem Bereichsprüfungsfehler durch den FPC. Das Beheben des Index deckte dann einen tieferen Fehler im Anmerkungspfad der Bibliothek auf, der durch die fehlerhaften Speicherzugriffe maskiert worden war — das Thema, das im Artikel über QuadPoints-Anmerkungen behandelt wird. Zwei Lehren ergeben sich daraus: Erstens: Bevorzugen Sie immer Low() und High() gegenüber festen Literalen, wenn ein Array-Typ nicht standardmäßig 0-basiert ist. Zweitens: Betrachten Sie einen FPC-Build oder zumindest einen Delphi-Build mit aktiviertem {$R+} als zwingenden ersten Prüfschritt. Delphis Standardeinstellungen warnen Sie vor solchen Fehlern nicht, und ein lauffähiges Programm ist kein Beweis für dessen Korrektheit
Die TBytes-Zuweisung, die nur Delphi 13 akzeptiert
Die Kurzfassung lautet: Die Zuweisung eines als anonymes array of Byte deklarierten Feldes an eine TBytes-Variable kompiliert unter Delphi 13 (Compilerversion 37.0), scheitert jedoch unter Delphi 12 Athens und allen früheren Versionen mit der Meldung E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Hierbei handelt es sich weniger um einen Unterschied zwischen Delphi und FPC, sondern um einen Unterschied zwischen Delphi und seinen eigenen Vorgängerversionen. Das Problem betrifft die Multi-Compiler-Codebasis auf die gleiche Weise: Der neueste Compiler akzeptiert eine Schreibweise, die ältere Versionen ablehnen
type
TValidator = class
private
FBuffer: array of Byte; // Anonymer dynamischer Array-Typ
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // Nur unter Delphi 13. E2010 unter Delphi 12
// Athens und früher.
OrigBytes := TBytes(FBuffer); // Kompiliert überall. Identisches Layout des
// dynamischen Arrays, sicherer Hard-Cast.
end;
Wir haben genau diese Zuweisung in einer Validierungsroutine ausgeliefert, die lokal unter Delphi 13 entwickelt und getestet wurde, wo die implizite Konvertierung stillschweigend akzeptiert wurde. Der Quellcode-Installer bedient jedoch viele Entwickler, die noch Delphi 12 oder älter nutzen. Für diese ließ sich die Unit nicht kompilieren. Die Lösung besteht entweder in dem oben gezeigten Hard-Cast, der sicher ist, da ein anonymes array of Byte und TBytes dasselbe Speicherlayout nutzen, oder — noch besser — darin, das Feld direkt als benannten Typ wie TBytes zu deklarieren, sodass keine Konvertierung erforderlich ist. Ein erfolgreicher Kompiliervorgang auf der neuesten Toolzeit beweist nichts bezüglich älterer Versionen. Unsere Release-Skripte kompilieren die Bibliothek nun über die gesamte Compiler-Matrix, da ein lokaler 37.0-Build ältere Compiler-Fehler nicht aufspüren kann
Das AnsiString-Byte, das auf einem chinesischen Windows-System verschwindet
Die Kurzfassung lautet: Das Verketten eines rohen Bytes ab $80 in einen AnsiString mittels + kann unter Delphi stillschweigend dazu führen, dass dieses Byte durch ? ($3F) ersetzt wird. Der Grund ist, dass der Ausdruck implizit einen Round-Trip von AnsiString über UnicodeString zurück zu AnsiString über die Systemcodepage vollzieht. Wir stießen darauf bei einem PDF/A-Test, der einen Namen mit einem isolierten $FE-Byte erzeugte (das niemals ein gültiges UTF-8-Lead-Byte sein kann), um zu prüfen, ob der Validator Namen bemängelt, die gemäß ISO 19005-2 Abschnitt 6.1.8 kein gültiges UTF-8 darstellen
var
BadName: AnsiString;
begin
// Unter Delphi mit einer Multi-Byte-Systemcodepage (beobachtet unter CP936)
// vollzieht die Verkettung einen Round-Trip über UnicodeString. $FE ist keine
// gültige CP936-Sequenz und wird zu '?' ($3F) konvertiert.
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// Sicher: Mit einem ASCII-Platzhalter aufbauen, und das Byte dann direkt ersetzen.
// Die Zuweisung über einen Index in einen bestehenden AnsiString vollzieht keinen Round-Trip.
BadName := '/Bad' + #1 + 'Name';
BadName[5] := AnsiChar($FE);
end;
Auf einem chinesischen Windows-System mit der Codepage 936 enthielt der verkettete String das Byte $FE überhaupt nicht, weshalb die Bibliothek korrekterweise keinen Fehler meldete und der Test fehlschlug. Dies sah zunächst nach einem Fehler der Bibliothek aus, diese arbeitete jedoch fehlerfrei: Ein Test mit FPC, der eine PDF-Datei mit dem echten Byte $FE übergab, meldete den Fehler wie erwartet. Die Beschädigung geschah im Delphi-Testprogramm während der String-Verkettung, da Delphis Unicode-basiertes Stringmodell gemischte AnsiString-Ausdrücke über UnicodeString konvertiert und $FE keine gültige Sequenz unter CP936 darstellt. Auf einem westeuropäischen System mit der Codepage 1252 übersteht der Ausdruck diese Konvertierung meist unbeschadet, weshalb dieser Fehler auf den Systemen der Entwickler unbemerkt blieb und erst auf asiatischen Systemen auftrat. Unsere Regel lautet nun: Erzeugen Sie binäre Testdaten mit Bytes ab $80 niemals über eine AnsiString-Verkettung. Ersetzen Sie die Bytes nach dem Aufbau des Strings direkt über den Index oder nutzen Sie von Anfang an TBytes
Was ein dualer Compiler-Workflow standardmäßig prüfen sollte
Vier Fallen, ein Muster: Jeder Compiler meldet andere Fehler im Code. Der FPC fand einen ungültigen Array-Index, den dcc32 monatelang klaglos ausführte, und dcc32 deckte eine Codepage-Abhängigkeit auf, die ein reiner Byte-basierter FPC-Build niemals getriggert hätte. Beide Compiler-Pipelines ergänzen sich gegenseitig. Die Cross-Kompilierung ist nicht nur ein Kompatibilitätstest, sondern wirkt wie ein zweiter statischer Code-Analysator und ein zweites Laufzeitmodell für denselben Quellcode, ähnlich wie die defensiven Bereichsprüfungen, die im Artikel über die Härtung der ABI und die Speichersicherheit beschrieben werden
Die sich daraus ergebenden Regeln sind einfach: Weisen Sie Record-Ergebnisse von Funktionen vor dem Zugriff auf Felder immer einer lokalen Variablen zu. Durchlaufen Sie Arrays mit statischen Grenzen immer mittels Low() und High() und testen Sie Code mit aktiver Bereichsprüfung. Nutzen Sie bei dynamischen Arrays explizite Casts oder benannte Typen und testen Sie vor jedem Release die vollständige Compiler-Matrix. Vermeiden Sie rohe Bytes ab $80 in AnsiString-Verkettungen. Diese Regeln erfordern kaum Aufwand, sobald sie zur Gewohnheit geworden sind, und schließen Fehlerquellen aus, die bei der Nutzung nur eines Compilers unsichtbar bleiben
Alle vier Probleme wurden im Zuge der Pflege von PDFium Component gefunden und behoben, die denselben Object-Pascal-Quellcode für Delphi, C++Builder und FPC/Lazarus bereitstellt und ihre Testsuiten auf all diesen Systemen ausführt