En Pascal-bindning över ett C-bibliotek ser ut som vanlig Pascal. Du anropar en metod, du får en post tillbaka, du frigör det du allokerade. Problemet är att PDFium är ett C- och C++-bibliotek med sin egen anropskonvention, sina egna heltalsbredder och sina egna regler om vem som äger minnet och vem som frigör det. Inget av detta korsar språkbarriären av sig självt. Vart och ett av dessa kontrakt måste definieras för hand i Pascal-deklarationerna, och ett enda felaktigt ord förvandlar ett snyggt anrop till en stack-korruption, en trunkerad offset eller en dubbelfrigöring. En v1.61.0-revision av en PDFium VCL-bindning avslöjade en defekt av varje sort. De är värda att gå igenom eftersom de inte är specifika för just denna bindning. De är de ständiga riskerna med att slå in vilket C-API som helst i Delphi eller Lazarus
cdecl är en del av funktionstypen, inte en dekoration
PDFium är komprimerad C. På Win32 använder dess exporter och, mer specifikt, de återanrop (callbacks) som den anropar anropskonventionen cdecl. Under cdecl städar anroparen upp stacken efter att anropet returnerar. Delphis inbyggda standard är register, och Win32 C-standarden för callbacks är stdcall i vissa bibliotek, där den anropade städar upp istället. När en struktur överlämnar en funktionspekare till PDFium och du glömmer cdecl på den pekarens typ, är de två sidorna oense om vem som justerar stackpekaren. Båda åtgärdar det, eller ingen gör det, och stackpekaren glider med argumentens storlek vid varje anrop
Anledningen till att denna defekt är svår att hitta är att skadan är icke-lokal. Det korrumperade anropet returnerar och ser bra ut. Feljusteringen visar sig senare, i någon orelaterad funktion vars ram nu ligger på en stackpekare som är några bytes fel, och det yttrar sig som en felaktig läsning, en ogiltig returadress eller en krasch med en spårning som inte pekar i närheten av det återanrop du faktiskt missade. Formulärfyllning är det klassiska stället där detta ställer till det, eftersom formulärfyllningsgränssnittet är en post full med återanrop som PDFium anropar. Ett av dem, FFI_OpenFile, ger PDFium en funktion som den kommer att anropa för att öppna en extern fil, deklarerad som function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Det avslutande cdecl är punkten som är värd att kopiera. Utelämna det och koden kompilerar fortfarande, länkar fortfarande och körs ända tills PDFium anropar funktionen. Konventionen tillhör själva funktionstypen. Den är inte valfritt socker, och kompilatorn kommer inte att varna dig när den saknas eftersom en vanlig funktionstyp är en helt laglig Pascal-typ. Det enda försvaret är att behandla anropskonventionen som ett obligatoriskt fält i varje importerad signatur och varje återanrop du skickar utåt
size_t är pekarbredd, och på FPC Win64 betyder det 64 bitar
Den andra defekten är en heltalsbreddsmissmatch som bara uppstår på en målplattform. C-språkets size_t är definierat till att vara tillräckligt brett för att rymma alla objektstorlekar, vilket på en 64-bitars plattform innebär ett 64-bitars osignerat heltal. PDFiums gränssnitt för progressiv inläsning kommunicerar med byte-offsets i size_t. Tillgänglighetsleverantörens FX_FILEAVAIL-post bär ett IsDataAvail-återanrop som PDFium anropar med en offset och en storlek, och FX_DOWNLOADHINTS-postens AddSegment-återanrop tar emot detsamma. Båda parametrarna är size_t
IsDataAvail = function(
pThis : PFX_FILEAVAIL;
offset, size: size_t): FPDF_BOOL; cdecl;
AddSegment = procedure(
pThis : PFX_DOWNLOADHINTS;
offset, size: size_t); cdecl;
Om du deklarerar dessa offsets som en 32-bitars typ fungerar bindningen på Win32 och på Delphi Win64, men går tyst sönder på FPC och Lazarus Win64. Orsaken är subtil. På FPC Win64 är NativeUInt en äkta pekarbredd 64-bitars typ, och size_t är ett alias för den. Bindningen har en kommentar i typsektionen som varnar just för att skugga NativeUInt på FPC, eftersom en omdefiniering till ett 32-bitars alias där skulle tvinga size_t till 32 bitar och korrumpera varje size_t-parameter som skickas till eller skrivs av biblioteket. En 64-bitars offset som anländer till en 32-bitars parameter förlorar sin övre halva. För en liten fil får varje offset plats i 32 bitar och ingenting är fel. För en stor fil, i det ögonblick en offset passerar gränsen på fyra gigabyte, pekar det trunkerade värdet någon helt annanstans, PDFium frågar om fel byte-intervall är tillgängligt och den progressiva laddningen stannar eller läser skräp. Defekten är osynlig tills filen är tillräckligt stor och målet är det där size_t faktiskt utvidgades
Ett Pascal-undantag får aldrig rulla upp genom en C-ram
Den tredje klassen handlar om undantagsmodellen (exception model), som C saknar. När PDFium anropar ett av dina återanrop körs din Pascal-kod inuti en stack av C- och C++-ramar som inte vet någonting om Delphis undantagsmaskineri. Om ditt återanrop utlöser ett undantag och låter det spridas, rullas det upp genom ramar som aldrig har byggts för att rullas upp. PDFiums egen städning körs inte, dess interna invarianter lämnas halvt uppdaterade, och processen är nu i ett tillstånd som biblioteket aldrig förväntat sig. Kontraktet för dessa återanrop är en returkod, inte ett undantag
Två återanrop gör detta konkret. FPDF_FILEWRITE is mottagaren (sink) som PDFium skriver ett sparat dokument till, och FPDF_FILEACCESS är källan den läser ett indatadokument från. Båda är här implementerade över en Delphi-TStream, och båda kan misslyckas på samma sätt som alla strömmar misslyckas: disken blir full, strömmen stängs under dig eller en läsning körs förbi slutet. Skriv-återanropet omsluter sin strömskrivning och omvandlar alla misslyckanden till PDFiums felkod istället för att låta det undslippa
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium treats any non-1 return as a write failure. A Pascal exception
// must not unwind through this cdecl/C++ frame, so trap it and report
// failure instead.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
Läs-sidan gör detsamma: en misslyckad läsning rapporterar noll för att matcha FPDF_FILEACCESS-kontraktet istället för att utlösa undantag över gränsen. En tom except utan återutlösning ser fel ut för en Pascal-programmerare som tränats i av att aldrig svälja undantag, och i vanlig Pascal är det fel. Vid en ABI-gräns är det dock rätt form, eftersom det enda säkra värdet att skicka tillbaka till C-anroparen är en statuskod den vet hur den ska tolka. Misslyckandet sprider sig fortfarande, men genom returvärdet, och anropskoden ovanför biblioteket lyfter fram det som en EPdfError så fort kontrollen är tillbaka på Pascal-sidan
Dubbelfrigöring gömmer sig på felsökvägen
Den fjärde defekten handlar om ägarskap. En PDFium-dokumentpekare öppnas av biblioteket och måste stängas exakt en gång, med FPDF_CloseDocument. Faran är en felsökväg som frigör en pekare som en andra städning också äger. Föreställ dig en rutin som skapar ett omslagsobjekt (wrapper), tilldelar en nyligen öppnad dokumentpekare till det och sedan utför ytterligare konfiguration som kan misslyckas. Om konfigurationen kraschar kommer en hanterare för tidig retur, som anropar FPDF_CloseDocument på den råa pekaren, att stänga den, och sedan kommer omslagsobjektets egen destruktor att stänga den igen när objektet frigörs. Pekaren frigörs två gånger, vilket innebär odefinierat beteende och en trolig krasch
Revisionen hittade detta på en utskriftsimponerande importväg som bygger en TPdf runt en redan öppen pekare. Lösningen är att låt ägarskapsöverföringen vara den enda källan till sanning. När pekaren väl har tilldelats till omslagets fält äger omslaget den, och den enda städningen på felsökvägen är att frigöra omslaget. Omslagets destruktor anropar FPDF_CloseDocument åt dig, så en andra explicit stängning skulle dubbelfrigöra samma dokument. Den korrigerade felhanteraren frigör objektet och utlöser undantaget igen, och det finns exakt en sökväg till stängningen
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result now owns the handle
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
// here would double-free the same PDFium document.
Result.Free;
raise;
end;
Hanterade poster och ett bibliotek fullt med exporter behöver båda explicit nedstängning
Den sista klassen handlar om minne som kompilatorn hanterar åt dig, vilket en C-vana tyst kan korrumpera. Många av denna bindnings hjälparfunktioner returnerar en post (record) som innehåller en WideString eller en dynamisk array. Dessa är referensräknade fält, och kompilatorn genererar dold bokföring för att upprätthålla deras räkningar. Instinkten från C-programmering är att rensa en ny post med FillChar(Result, SizeOf(Result), 0). Detta stämplar nollor över den hanterade referensen inuti posten utan att först dekrementera den. Kompilatorn återanvänder en dold temporär variabel för ett funktionsresultat över loop-iterationer, så vid den andra iterationen skriver FillChar över en levande strängpekare som aldrig frigjordes, och strängen den pekade på läcker. Anropa funktionen i en loop över tusen annoteringar och du läcker tusen strängar
Lösningen är att låta språket rensa posten på det sätt det kan, med Default(T), vilket frigör eventuella hanterade fält innan de nollställs
// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);
Ett relaterat ägarskapsproblem finns vid biblioteksladdningsgränsen. Denna bindning löser flera hundra funktionspekare från PDFium DLL med GetProcAddress efter en LoadLibrary. Om en nödvändig export saknas är det delvis bundna tillståndet farligt: dussintals pekare är giltiga, resten är nil eller föråldrade, och alla senare anrop genom någon av dem hoppar in i en modul som kanske redan har laddats ur. Bindningen hanterar detta genom att ladda ur biblioteket och köra en ifyllnad ClearAllBindings som återställer varje importerad pekare till nil när en nödvändig export inte kan anses vara löst. Efter det hänger ingen funktionspekare kvar i en urladdad modul, och ett senare anrop misslyckas rent med en nil-pekar-kontroll istället för att grena in i frigjord kod
Omslaget är där fyra kontrakt måste definieras manuellt
Ingen av dessa fem defekter är exotisk. De är de förutsägbara fellägena för ett tunt Pascal-skikt över ett C-API, och de samlas eftersom det skiktet är exakt där fyra separata kontrakt måste återdeklareras. Anropskonventionen måste stavas cdecl på varje återanrop. Heltalsbredden måste matcha size_t på det enda målet där den faktiskt utvidgas. Undantagsmodellen måste konverteras till returkoder vid varje återanrop som korsar gränsen ut ur Pascal. Ägarskapet för varje pekare och varje hanterat fält måste definieras en gång och följas på varje sökväg, inklusive felsökvägarna som ingen testar förrän i produktion. Missa något av detta och du får en defekt vars symptom visar sig långt från dess orsak, vilket är vad som gör denna kategori dyrbar. Revisionens värde låg mindre i någon enskild korrigering än i att behandla var och en av dessa som en egen disciplin att kontrollera över hela bindningen
Om du vill se bindningen utföra verkligt arbete snarare än att bara bevaka sina gränser visar renderingsteknikerna och zoom-prestandan i vår anteckning om renderings-cache och zoom-prestanda renderingssökvägen, och genomgången för korskompilatorer i att bygga en Lazarus- och FPC-visare är platsen där Win64-size_t-beteendet som beskrivs här faktiskt spelar roll. Båda bygger på samma minnessäkerhets- och ABI-arbete som levereras i PDFium Component för Delphi, Lazarus och C++Builder, tillsammans med de API:er för rendering, textutdragning och annotering som beskrivs på andra ställen i denna blogg