Artykuł techniczny

Statyczne linkowanie jbig2enc w Free Pascal bez DLL

PDFlibPas 3.538.0 statycznie linkuje zewnętrzny enkoder JBIG2 do programów Free Pascal i Lazarus. Projekt dodaje unit PDFlibJBIG2EncC, ten sam unit, którego używają już Delphi i C++Builder, a enkoder trafia do pliku wykonywalnego bez żadnego dodatkowego pliku obok niego. Podważa to wcześniejszy wniosek dotyczący tej funkcji, według którego Free Pascal mógł korzystać z zewnętrznego enkodera wyłącznie przez DLL

Dlaczego DLL wyglądał jak jedyna opcja?

DLL wyglądał jak jedyna opcja, ponieważ trzy ścieżki linkowania kończyły się niepowodzeniem na trzy niezależne sposoby i żadna opcja kompilatora nie docierała do żadnego z tych problemów. Wewnętrzny linker od razu odrzuca asocjacyjne sekcje COMDAT. Linkowanie zewnętrzne przez dołączone binutils kończy się awarią podczas garbage collection sekcji, którą Free Pascal bezwarunkowo włącza dla 64-bitowego celu Windows. Nowsze binutils w ogóle nie potrafią przetworzyć skryptu linkera Free Pascala. Przebudowanie części C++ za pomocą drugiego toolchaina zamienia jedną odmowę na inną, ponieważ instancjowanie template'ów i funkcji inline z definicji emituje słabe symbole zewnętrzne, a Free Pascal zgłasza je jako Unsupported COFF symbol type 105. Żadne z tych obserwacji nie było błędne, a wcześniejszy opis backendów enkodera JBIG2 i linkera Free Pascala nadal prowadzi przez każdy z tych ślepych zaułków w sposób, który można dziś odtworzyć. Błędne było założenie dotyczące miejsca, w którym można wprowadzić poprawkę. Każda próba przechodziła przez kompilator albo linker, a żaden z nich nie może zmienić tego, co już zawiera plik obiektowy. Problemem przez cały czas był plik obiektowy. ObjConv odczytuje COFF i zapisuje COFF, a każda konstrukcja, na której potyka się Free Pascal, ma mechaniczny odpowiednik, który akceptuje

Błąd, który nigdy nie nazywa swojej przyczyny

Wewnętrzny linker Free Pascala implementuje pick-any COMDAT tylko częściowo i właśnie ta częściowa implementacja jest tutaj najtrudniejsza do zdiagnozowania. Scala zduplikowane definicje, tak jak przewiduje format. Jednak TExeOutput.RemoveUnreferencedSections podczas oznaczania sekcji jako używanych przekierowuje przez exesymbol do zwycięskiej definicji, natomiast TCoffexeoutput.DoRelocationFixup odczytuje bezpośrednio objreloc.symbol.objsection. Gdy używana sekcja odwołuje się do symbolu, który własny obiekt definiuje w kopii przegranej podczas scalania, oba przebiegi patrzą na różne sekcje, a linkowanie zatrzymuje się na Internal error 200603061

Porównaj to z dwoma komunikatami ograniczeń po obu stronach tego błędu. Unsupported COFF symbol type 105 mówi, że chodzi o weak external. Associative or exact match COMDAT sections are not yet supported mówi o asocjacyjnej sekcji COMDAT i podaje nawet nazwę problematycznego symbolu. Internal error 200603061 nie mówi nic: ani nazwy symbolu, ani nazwy sekcji, ani nazwy pliku, ani fazy. To także przypadek normalny, a nie brzegowy, ponieważ MSVC umieszcza każdy literał tekstowy oraz każdą instancję inline lub template'u w pick-any COMDAT, a dla 186 obiektów tego enkodera linker wykonał 2656 scaleń. Budowanie z /Gy- utrzymuje zwykłe funkcje poza sekcjami COMDAT tworzonymi dla poszczególnych funkcji, ale pozostawia literały tekstowe i instancje template'ów dokładnie tam, gdzie były

Dlaczego dodawanie stubów symboli CRT zawsze wygląda tak, jakby zepsuł go ostatni stub?

Bo linker dociera do fazy fixup dopiero po rozwiązaniu wszystkich symboli. Dopóki czegoś brakuje, przebieg kończy się wcześniej komunikatem Undefined symbol i problem COMDAT nie ma szansy się ujawnić. Uzupełnij ostatni stub C runtime, a linker przejdzie o jedną fazę dalej, prosto do internal error 200603061. Objaw obserwowany w praktyce systematycznie wprowadza więc w błąd: gdy dodajesz po kolei Pascalowe implementacje dla referencjonowanych symboli C, zawsze wygląda to tak, jakby ostatni dodatek zepsuł build albo jakby przekroczony został próg około stu stubów. Żadne z tych wyjaśnień nie jest prawdziwe. To, który symbol dodano na końcu, i to, ile symboli dodano łącznie, nie ma znaczenia, ponieważ awaria była ukryta już w pierwszym obiekcie i stała się osiągalna dopiero po pomyślnym rozwiązaniu symboli. Gdy linker zmienia komunikat po naprawieniu pozornie niezwiązanej rzeczy, sprawdź, czy nie przeszedłeś po prostu o jedną fazę dalej, zamiast spowodować regresję

Poprawką jest jeden przebieg ObjConv, a nie opcja kompilatora

Cała poprawka to pojedyncze polecenie post-processingu uruchamiane dla każdego skompilowanego obiektu: ObjConv -fcoff64 -xw -xc -xn -np:__imp_:pdflibimp_. Trzy z tych opcji dodano na potrzeby tej pracy. -xw zamienia symbole IMAGE_SYM_CLASS_WEAK_EXTERNAL na zwykłe symbole zewnętrzne. -xn normalizuje symbole IMAGE_SYM_CLASS_NULL, takie jak _fltused, które Free Pascal zgłasza jako Unsupported COFF symbol type 0. -xc wykonuje ciężką pracę: obniża każdą sekcję COMDAT do zwykłej sekcji i nadaje zdefiniowanym w niej symbolom charakter statyczny. Awaria znika, bo znika sama decyzja, skoro bez sekcji COMDAT nie ma scalania, zwycięskiej kopii, do której jeden przebieg mógłby przekierować, a drugi jej nie znaleźć, ani asocjacyjnych sekcji unwind .pdata i .xdata. Koszt jest realny, ale niewielki: kopie, które można było legalnie scalić, pozostają teraz wszystkie osobno

Zmiana nazwy prefiksu -np:__imp_:pdflibimp_ rozwiązuje osobny konflikt. MSVC wywołuje importowane API Win32 przez komórki pośrednie o nazwach __imp_*, Free Pascal rezerwuje ten prefiks dla własnego mechanizmu importu, a bezpośrednie zdefiniowanie jednej z tych nazw ponownie wywołuje ten sam internal error 200603061. Zmiana nazw pozwala stronie Pascalowej opublikować je jako zwykłe zmienne i wypełnić w czasie działania. Same obiekty są kompilowane z opcjami statycznego linkowania /GS- /Gs999999 /Gy- /Zl /GR- /EHs-c- oraz z wyłączonymi kodekami obrazów, więc martwe ścieżki I/O plików i kodeków potrzebują znacznie mniej stubów przeznaczonych wyłącznie do linkowania. Trafiają one do Lib\thirdparty\Win64f, podczas gdy ścieżka Delphi i C++Builder nadal linkuje własny zestaw Win64x bez zmian, co jest właściwym rezultatem poprawki przenośności ograniczonej do jednego toolchaina

Co strona Pascalowa musi nadal eksportować

Free Pascal rozwiązuje import obiektu C na podstawie nazwy symbolu i wymaga jawnego zapisania tej nazwy, dlatego każda procedura Pascalowa zastępująca punkt wejścia C ma wyraźną klauzulę public name. Delphi przyjmuje nazwę procedury jako nazwę symbolu i nie potrzebuje żadnej klauzuli, dlatego jeden unit obsługuje oba kompilatory, a klauzule umieszczono pod {$IFDEF FPC}. Pułapka polega na tym, że deklaracja external 'msvcrt.dll' niczego nie zaspokaja: tworzy import, a nie definicję, do której mógłby się przywiązać linkowany obiekt. Ciało przekazujące wywołanie musi istnieć

// Deklaracja external tworzy tylko import. Żaden linkowany obiekt nie może
// się do niego przywiązać.
function crt_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  external 'msvcrt.dll' name 'memcmp';

// Ciało Pascalowe opublikowane pod dokładną nazwą symbolu C jest tym,
// do czego faktycznie przywiązuje się zestaw obiektów.
function jbig2_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  public name 'memcmp';
begin
  Result := crt_memcmp(Buf1, Buf2, Count);
end;

Punkty wejścia o zmiennej liczbie argumentów łamią ten schemat, ponieważ wrapper Pascalowy nie może przekazać własnych argumentów varargs do innego wywołania varargs. Rozwiązaniem jest przestać być wrapperem: eksportuj nagą procedurę pod nazwą C i wykonaj tail-jump do właściwej implementacji, używając rejestrów argumentów i stosu dokładnie w układzie przygotowanym przez caller. Warstwa JPEG 2000 już obsługuje w ten sposób snprintf i vsnprintf, skacząc do nazw msvcrt z prefiksem underscore, ponieważ zwykłe nazwy eksportuje wyłącznie UCRT. Z tego samego internal error wynika jeszcze jedno ograniczenie: przemianowane komórki importu są wypełniane w sekcji initialization przez GetModuleHandleA i GetProcAddress, a nie przez statyczne inicjalizatory, ponieważ pobranie adresu importowanej procedury w inicjalizatorze każe kompilatorowi wygenerować fixup, którego nie potrafi obsłużyć, i ponownie kończy się błędem 200603061

function crt_sprintf(Buffer: PAnsiChar; Fmt: PAnsiChar): Integer; cdecl; varargs;
  external 'msvcrt.dll' name 'sprintf';

var
  SPrintfTarget: Pointer = @crt_sprintf;

// Argumentów varargs nie można przekazać z wrappera Pascalowego, więc eksportowany
// symbol wykonuje tail-jump przy układzie ramki pozostawionym dokładnie przez caller.
procedure jbig2_sprintf; assembler; nostackframe;
  public name 'sprintf';
asm
  jmp qword ptr [rip + SPrintfTarget]
end;

Co projekt Free Pascal robi teraz inaczej

Nic poza nazwą unitu w klauzuli uses, a pliku do wdrożenia już nie ma. Backend rejestruje się z własnej sekcji initialization przez wywołanie RegisterJBIG2EncoderBackend, a wywołujący żądają go dokładnie tak jak wcześniej: przez bit opcji PDF_JBIG2_OPTION_EXTERNAL_ENCODER o wartości 4 albo przez argument UseExternalEncoder rozszerzonych punktów wejścia. Żądanie nadal jest preferencją, a nie gwarancją, ponieważ build pomijający unit po cichu wraca do natywnego enkodera Pascal MMR i zamiast błędu produkuje większe pliki

uses
  Classes, SysUtils,
  PDFlibrary,
  PDFlibJBIG2EncC;   // Delphi, C++Builder oraz od 3.538.0 Free Pascal

var
  Pdf: TPDFlib;
  Scan: TStream;
  ImageId: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.NewPage;
    Scan := TFileStream.Create('scan-page-1.tif', fmOpenRead);
    try
      // Interpolate, SymbolExtract, UseExternalEncoder, SkipBlackDots,
      // BlackDotSize, LossyLevel
      ImageId := Pdf.AddImageJBIG2FromStreamEx(Scan, 0, 1, 1, 0, 0, 0);
    finally
      Scan.Free;
    end;
    if ImageId = 0 then
      raise Exception.Create('JBIG2 encoding failed');
    Pdf.SaveToFile('archive.pdf');
  finally
    Pdf.Free;
  end;
end;

Warto jasno powiedzieć o dwóch ograniczeniach. Istnieje tylko zestaw obiektów Win64, więc dla każdego innego celu Free Pascala zewnętrzny punkt wejścia enkodowania zgłasza niepowodzenie, a pracę wykonuje natywny enkoder Pascalowy. Test regresyjny będący bramką dla całości porównuje renderowanie, a nie rozmiar: oba enkodery są bezstratne dla tego samego źródła, więc wynik jest renderowany i porównywany bajt po bajcie, a pakiet testów Lazarusa przechodzi wszystkie 26 testów, w tym ten. Porównywanie rozmiarów skompresowanych strumieni niczego by nie dowiodło, ponieważ odwrócona strona kompresuje się mniej więcej do tego samego rozmiaru co poprawna

Szersza lekcja wykracza poza JBIG2. DLL ma właściwą postać, gdy granica jest naprawdę dynamiczna, czego dotyczą powierzchnie integracji DLL, ActiveX i dylib; jest niewłaściwą postacią, gdy stanowi jedynie obejście czytnika COFF, ponieważ dodaje plik do każdego instalatora, ścieżkę wyszukiwania do każdego wdrożenia i tryb awarii wynikający z rozjechanych wersji, którego statyczne linkowanie nie może wytworzyć. Znaczenie ma również etap wcześniejszy, bo sposób wytworzenia obrazu dwupoziomowego wpływa na końcowy rozmiar bardziej niż enkoder, a renderowanie monochromatyczne oparte na regionach w Delphi opisuje tę połowę pipeline'u. Zakres obsługiwanych toolchainów, zestawy obiektów dla poszczególnych kompilatorów i obsługiwane cele wymieniono na stronie produktu losLab PDF Developer Library