AdaVirtus

 
  • Increase font size
  • Default font size
  • Decrease font size

Rozdział 7: Bezpieczne zarządzanie pamięcią

Spis treści

Pamięć komputera stanowi żywotną część systemu, w którym rezyduje program. Integralność zawartości pamięci jest niezbędna dla zdrowia programu. Jest tu jakaś analogia z pamięcią człowieka. Jeśli pamięć jest niepewna, to prawidłowe funkcjonowanie osoby jest poważnie zaburzone.

Mamy do czynienia z dwoma głównymi problemami odnośnie zarządzania pamięcią. Pierwszy: informacja może zostać stracona przez niewłaściwe nadpisanie przez inną informację. Drugi: pamięć może zostać zapełniona w całości i bezpowrotnie stracona, co uniemożliwi zapisywanie nowych informacji. Jest to tak zwany problem wycieków pamięci (memory leaks).

Wyciek pamięci jest zdradliwym błędem, często ujawniającym się dopiero po długim czasie. Przykładem był program sterowania procesami chemicznymi, który - wydawało się - działał bezbłędnie przez kilka lat. Co każde trzy miesiące był ponownie uruchamiany z powodu pewnych ograniczeń zewnętrznych (żuraw musiał być przeniesiony, co wymagało zatrzymania instalacji). Ale plan dla żurawia został zmieniony i pogram mógł działać dłużej - awaria nastąpiła po czterech miesiącach. Przyczyną był wyciek pamięci, który powoli "wyjadał" wolną pamięć.

7.1 Przepełnienie bufora

Przepełnienie bufora jest najczęściej ogólnym terminem użytym do określenia naruszenia bezpieczeństwa informacji. Przepełnienie bufora pozwala na nadpisanie informacji lub błędny czy złośliwy odczyt.

Jest to błąd charakterystyczny dla programów pisanych zarówno w języku C, jak i C++. Zazwyczaj spowodowany jest nieobecnością wbudowanych w te języki kontroli zapisu i odczytu poza granicami tablic. Zilustrowaliśmy ten problem w rozdziale Bezpieczny system typów, gdy omawialiśmy grę w kości.

Problem ten nie powinien się normalnie pojawić w przypadku języka Ada, ponieważ ma on wbudowane mechanizmy kontrolne, sprawdzające czy indeks tablicy nie leży poza zakresem dopuszczalnych wartości. Kontrole te mogą być wyłączone jeśli jesteśmy absolutnie pewni, że program jest w 100% prawidłowy. Mimo to nie jest mądrze tak czynić, chyba, że program został poprawnie zweryfikowany przez narzędzia typu Examiner z pakietu Spark (rozdział 11).

Mimo, że brak kontroli zakresów jest podstawową przyczyną problemu przepełnienia bufora w języku C, to sprawa komplikuje się jeszcze bardziej a to dzięki istnieniu cech języka, jak na przykład wybór oznaczenia końca łańcucha jako bajtu o wartości zero. Oznacza to ni mniej, ni więcej, że programista sam musi testować tą wartość (bezpośrednio lub pośrednio) w wielu podprogramach przetwarzających łańcuchy. Łatwo jest popełnić błąd w czasie takich testów, a jeszcze na dodatek wartość zerowa może zostać przypadkowo nadpisana inną. Te problemy są często luką, pozwalającą wirusom na włamania do systemu.

Innym popularnym sposobem, w którym dane mogą zostać przypadkowo zniszczone to użycie nieprawidłowych wskaźników. Wskaźniki w C traktowane są jak adresy, czyli można przeprowadzać na nich operacje arytmetyczne. Dzięki temu łatwo przypisać wskaźnikowi błędną wartość, przez co wskazywać on będzie na niewłaściwy obszar. Zapis przy pomocy takiego wskaźnika niszczy dane.

W rozdziale Bezpieczne wskaźniki widzieliśmy jak Ada strzeże nas przed takimi błędami, a to dzięki ścisłej typizacji wszystkich wskaźników oraz poprzez reguły dostępności, które zapewniają, że obiekty nie znikają w czasie, gdy są do nich odwołania przy pomocy innych obiektów.

Zatem, podstawowe cechy języka Ada chronią nas przed przypadkową utratą danych omyłkowym nadpisaniem pamięci. Reszta tego rozdziału poświęcona jest kwestii utraty pamięci.

7.2 Sterta

Języki programowania zazwyczaj są projektowane tak, że wykorzystują trzy rodzaje przechowywania danych:

  • dane globalne istniejące przez cały czas działania programu, a więc zarezerwowane na stałe (często statycznie),
  • dane przechowywane na stosie, który może się powiększać i zapewnia przepływ sterowania przez różne podprogramy,
  • dane alokowane na stercie i używane oraz zwalniane w sposób nie związany bezpośrednio z przepływem sterowania.

Globalny obszar common Fortranu jest pierwotnym przykładem globalnej pamięci statycznej (odnosi się to do Fortranu z wczesnych dni programowania). Ale globalna pamięć statyczna istnieje we wszystkich językach programowania. W Adzie, jeśli zadeklarujemy:

package Calendar_Data is
   type Month is (Jan, Feb, Mar, ... , Nov, Dec);
 
   Days_In_Month: array (Month) of Integer :=
      (Jan => 31, Feb => 28, Mar => 31, Apr => 30,
       May => 31, Jun => 30, Jul => 31, Aug => 31,
       Sep => 30, Oct => 31, Nov => 30, Dec => 31);
end;

to obszar przeznaczony na  tablicę Days_In_Month będzie naturalnie zadeklarowany w stałej pamięci globalnej.

Stos jest  istotną strukturą pamięci we wszystkich językach programowania. Należy pamiętać o tym, że mówimy tutaj o zasadniczym stosie użytym przez implementację, a nie o obiekcie typu Stack wykorzystanym w przykładach poprzedniego rozdziału. Stos jest wykorzystywany do przekazywania parametrów w czasie wywoływania podprogramów (parametry aktualne, adres powrotu, przechowane rejestry etc.) oraz na zmienne lokalne wewnątrz podprogramów. W programach wielozadaniowych, gdzie kilka wątków działa współbieżnie, każde z zadań ma swój własny stos.

Ponownie spójrzmy na funkcję Nfv_2000 użytą w programie stóp procentowych w rozdziale Bezpieczne wskaźniki:

function Nfv_2000 (X: Float) return Float is
   Factor: constant Float := 1.0 + X / 100.0;
begin
   return 1000.0 * Factor**2 + 500.0 * Factor – 2000.0;
end Nfv;

Obiekt Factor będzie zazwyczaj przechowywany na stosie. Przywołany z niebytu, gdy funkcja zostaje wywołana i znikający, gdy funkcja kończy działanie. Cała ta operacja jest przeprowadzana bezpiecznie i automatycznie przez mechanizm wywoływania i powrotu. Mimo, że Factor jest oznaczony jako stała, to nie jest statyczny ponieważ każde wywołanie funkcji dostarcza różne wartości dla tego obiektu. Dodatkowo, funkcja może być wywoływana w programie wielozadaniowym przez dwa różne zadania w tym samym czasie. Tyma bardziej z tego powodu Factor nie mógłby być przechowywany globalnie.

Wartości parametrów aktualnych - takie jak X - również przechowywane są na stosie.

Teraz przyjrzyjmy się bardziej skomplikowanemu podprogramowi, który deklaruje tablicę lokalną o wielkości nie znanej do momentu wykonania kodu podprogramu - rozważana funkcja zwraca dowolną tablicę w odwrotnym porządku:

function Rev (A: Vector) return Vector is
   Result: Vector(A'Range);
begin
   for K in A'Range loop
      Result (K) := A (A'First + A'Last – K);
   end loop;
 
   return Result;
end Rev;

gdzie typ Vector jest zadeklarowany w następujący sposób:

type Vector is array (Natural range <>) of Float;

Notacja ta oznacza, że Vector jest tablicą, ale bez podanych granic. Jedyne co wiemy to, że indeks musi zawierać się w zbiorze liczb typu Natural (od 0 do Integer'Last). Gdy deklarujemy obiekt typu Vector musimy podać granice dla indeksu.Na przykład:

L: Integer := ... ;
My_Vector, Your_Vector: Vector (1 .. L);                   -- L nie musi być statyczny
...
Your_Vector := Rev (My_Vector);

W większości języków programowania będziemy raczej starać się umieścić obiekt taki, jak zmienna lokalna Result na stercie, a nie jak tutaj stosie, gdyż wielkość tego obiektu nie jest znana do momentu wywołania funkcji. To akurat nie jest potrzebne, gdyż stos jest elastyczny i obszar na zmienne lokalne może zawsze być obsługiwany w oparciu o mechanizm LILO (pierwszy wchodzi - pierwszy wychodzi).

Jednak sterta jest często wykorzystywana, ponieważ trochę uwagi przy projektowaniu i zarządzania danymi o zmiennej długości bez utraty wydajności oraz nie trzeba się troszczyć o to, że mechanizm wywoływania ucierpi na wydajności. Implementacje w Adzie zawsze wykorzystują stos na dane lokalne. Wykorzystana jest do tego wydajna technika wykorzystująca oba krańce stosu: jeden na adres powrotu oraz statyczne dane lokalne, drugi na dane o dynamicznie zmienianej wielkości. To pozwala na to, że miejsce na adres powrotu jest obliczane  bardziej efektywnie, a jeszcze zyskujemy na elastyczności. Dodatkowo systemy adowe zazwyczaj chronią przed przepełnieniem stosu i zgłaszają wyjątek Storage_Error w takiej sytuacji (a raczej, gdy ma taka nastąpić).

Powyższy przykład ilustruje kilka fajnych cech Ady. Z kolei w języku C byłby on raczej trudny do zrealizowania. Jest tak dlatego, że język C nie posiada właściwej abstrakcji tablic i nie możemy przekazać tablicy jako parametru, a tylko wskaźnik na tablicę. Dodatkowo C nie potrafi zwrócić innej niż skalar, a tym samym nie potrafi zwrócić odwróconej tablicy. Moglibyśmy oczywiście zadeklarować funkcję odwracającą argument in situ, a pozostawić użytkownikowi uprzednie skopiowanie takiej tablicy. Lecz odwracanie tablicy in situ wymaga więcej uwagi, gdyż musimy uważać, by nie zniszczyć jakichś wartości przy zamianie elementów tablicy. Najlepiej w tej sytuacji byłoby chyba przekazać wskaźniki na tablicę źródłową i wynikową. Dodatkową trudność wynikającą z zastosowania C stanowi fakt, że nie wiemy jak długie są tablice, więc musimy przekazać długość (lub górną granicę) jako parametr podprogramu. Ryzyko w tym, że łatwo przekazać wartość nie odpowiadającą rzeczywistej długości. Spójrzmy:

void rev(float *a, float *result, int length);
{
   for (k=0; k<length; k++)
      result[k] = a[length – k – 1];
}
...
float my_vector[100], your_vector[100];
...
rev(my_vector, your_vector, 100);

Rozdział ten wprawdzie traktuje o zarządzaniu pamięcią, ale warto tutaj skorzystać z okazji i podać zagrożenia i trudności wynikające z powyższego kodu C.

  • W języku C tablice zawsze mają dolną granicę o wartości 0, więc jeśli aplikacja ma inną naturalną granicę (na przykład 1), może powstać zamieszanie. Ada zawsze umożliwia podanie dolnej granicy tablicy.
  • Długość tablicy musi być podana oddzielnie co niesie ze sobą ryzyko podania złej wartości i powoduje zamieszanie na linii długość - indeks górny. W Adzie atrybuty tablicy są przekazywane jako niepodzielna część samej tablicy.
  • Adres tablicy wynikowej musi być podany oddzielnie. Mamy tutaj niebezpieczeństwo pomylenia dwóch tablic ze sobą, co nigdy nie wystąpi w Adzie, ponieważ przypisanie wyjaśnia, która jest która.
  • Pętla musi być zapisana jawnie ze wszystkimi szczegółami, podczas gdy notacja Ady pozwala na automatyczne pozwiązanie pętli z zakresem indeksów tablicy.

Zboczyliśmy jednak nieco z tematu. Kluczowym punktem jest to, że jeśli zadeklarujemy lokalną tablicę w C++, której wielkość nie jest statyczna:

void f (int n, ... );
{    float a[] = new float [n];
...
}

tablica taka będzie umieszczona na stercie, a nie na stosie. W C moglibyśmy użyć funkcji malloc, która jawnie korzysta ze sterty.

Ogólnie rzecz biorąc, niebezpieczeństwo wynikające ze stosowania sterty to dealokacja pamięci,gdy wciąż jest ona w użyciu lub też pozostawienie zarezerwowanego obszaru mimo, że nie jest już potrzebny. Ponieważ Ada pozwala na umieszczanie obiektów o zmiennej długości na stosie, sterta jest w zasadzie wykorzystywana tylko, gdy są wywoływane alokatory o czym pisaliśmy w rozdziale Bezpieczne wskaźniki. W wyniku otrzymujemy lepszą wydajność i mniejsze prawodpodobieństwo wycieku pamięci.

7.3 Pule pamięci

Teraz zaczniemy korzystać ze sterty w Adzie. Właściwy termin to pula pamięci. Jeśli zrealizujemy alokację jak w procedurze Push z rozdziału Bezpieczne tworzenie obiektów:

procedure Push (S: in out Stack; X: in Float) is
begin
   S := new Cell' (S, X);
end Push;

to obszar na nowy obiekt Cell zostanie uzyskany z puli pamięci. Mamy zawsze do dyspozycji standardową pulę. Nic nie stoi jednak na przeszkodzie byśmy sami zadeklarowali i używali własnych.

Pierwszym językiem programowania, w którym obsługa pamięci była poza zasięgiem programisty był LISP. Był on wyposażony w mechanizm automatycznego odzyskiwania nieużytków. Podejście taki znajdziemy również w ielu innych językach jak Python czy Java. Obecność tego mechanizmu upraszcza znacznie programowanie, lecz rodzi inne problemy. Na przykład, uruchomienie odzyskiwania nieużytków może przerwać wykonywanie programu w nieprzewidywalnym momencie. Z tego powodu jest on bezużyteczny w systemach czasu rzeczywistego. Programista systemów czasu rzeczywistego musi zachować dokładną kontrolę nad rezerwacją i zwalnianiem pamięci. Dodatkowo musi mieć możliwość odzyskiwania pamięci w ściśle określonych momentach, a nie czekać aż uczyni to mechanizm odzyskiwania nieużytków. W konsekwencji mechanizm taki nie jest odpowiedni w językach ogólnego przeznaczenia, a już w szczególności w aplikacjach niskiego poziomu, czasu rzeczywistego i krytycznych dla bezpieczeństwa.

Ada daje programiście wybór mechanizmu. Kontrolę pamięci można realizować:

  • ręcznie (tzn. przez zaprogramowanie zwalniania pamięci zarezerwowanych dla pojedynczych bytów),
  • z wykorzystaniem pul pamięci (z puli pamięci mogą być usuwane pojedyncze elementy, a i sama pula może być zwolniona, jeśli przestaje być potrzebna),
  • z wykorzystaniem mechanizmu odzyskiwania nieużytków (mechanizm ten może nie być dostępny we wszystkich implementacjach).

W celu odzyskania kawałka pamięci, który nie jest już potrzebny możemy skonkretyzować predefiniowaną funkcję rodzajową Unchecked_Deallocation. By ją wykorzystać musimy mieć nazwany typ dostępowy:

type Cell;
type Cell_Ptr is access all Cell;
type Cell is
   record
      Next: Cell_Ptr;
      Value: Float;
   end record;

Zwróćmy uwagę na to, że mamy tu do czynienia z zakmniętym kołem, które przerywamy pierwszą, częściową deklaracją typu Cell. Teraz piszemy:

procedure Free is new Unchecked_Deallocation (Cell, Cell_Ptr);

Aby zwolnić pamięć po prostu wywołujemy procedurę Free z wartością dostępową odnoszącą się do danego obszaru pamięci. Teraz procedura Pop mogłaby wyglądać tak:

procedure Pop (S: in out Stack; X: out Float) is
   Old_S: Stack := S;
begin
   X := S.Value;
   S := S.Next;
   Free (Old_S);
end Pop;

Tutaj wykorzystujemy wersję stosu, który jest prywatnym typem ograniczonym, a nie kontrolowanym.

Mogłoby się wydawać, że korzystanie z procedury Free jest ryzykowne. Mogłoby się tak zdarzyć, gdyby istniało inne odwołanie do zwalnianego obszaru. Lecz w tym przykładzie użytkownik ma do czynienia z typem ograniczonym, więc nie może zrobić kopii struktury. Ponadto użytkownik nie widzi szczegółów typu Stack, a w szczególności nie widzi typów Cell i Cell_Ptr. Dzięki temu nie może wywołać wprost procedury Free. W momencie, gdy upewniliśmy się, że procedura Pop jest prawidłowa, żadnych problemów nie będzie. Na koniec: konkretyzacja procedury Unchecked_Deallocation powoduje krzyżową kontrolę, a to przez wymóg podania nazwanego typu dostępowego, a więc sprawdza, czy parametry są zgodne z deklaracją.

Teraz musimy również zmienić procedurę Clear. Najprościej tak:

procedure Clear (S: in out Stack) is
   Junk: Float;
begin
   while S /= null loop
      Pop (S, Junk);
   end loop;
end Clear;

Mimo, że sposób ten zapewnia prawidłowe zwolnienie pamięci, gdy wywoływane są procedury Pop i Clear, ciągle istnieje ryzyko, że użytkownik mógłby zadeklarować stos i pozostawić go poza zasięgiem, gdy nie jest on pusty:

procedure Do_Something ...
   A_Stack: Stack;
begin
   ...              -- zabawa z A_Stack
   ...              -- jest pusty przy wyjściu?
end Do_Something;

Jeśli A_Stack nie miał wartości null, po wyjściu z procedury Do_Something obszar zajmowany przez ten stos zostanie stracony. Nie możemy obarczać użytkownika dbaniem, by nie tracił pamięci, więc musimy uczynić stos typem kontrolowanym (końcówka rozdziału Bezpieczne tworzenie obiektów). Następnie możemy zadeklarować własną procedurę Finalize:

overriding
procedure Finalize (S: in out Stack) is
begin
   Clear (S);
end Finalize;

Skorzystanie ze znacznika przesłaniania (override) daje pewność, że nie popełnimy pomyłki w nazwie procedury lub jej parametrach.

Ada pozwala także użytkownikowi na zadeklarowanie własnych pul pamięci. Sprawa jest bardzo prosta, lecz zabrałoby zbyt dużo miejsca tutaj. Ogólnie rzecz biorąc, mamy do dyspozycji predefiniowany typ Root_Storage_Pool (który jest typem ograniczonym i kontrolowanym). Własną pulę pamięci możemy zadeklarować w oparciu o ten typ:

type My_Pool_Type (Size: Storage_Count) is
   new Root_Storage_Pool with private;
 
overriding
procedure Allocate ( ... );
 
overriding
procedure Deallocate ( ... );
-- przesłaniamy także Initialize ( ... ) i Finalize ( ... );

Procedura Allocate jest wywoływana automatycznie w momencie, gdy jest tworzony nowy obiekt alokatorem. Deallocate zaś, gdy obiekt jest zwalniany wywołaniem procedury Free. Użytkownik ma za zadanie napisać właściwy kod zarządzania pulą pamięci. Ponieważ typ puli jest również typem kontrolowanym, następuje automatyczne wywoływanie procedur Initialize i Finalize, odpowiednio gdy deklarowana jest cała pula i gdy wychodzi ona z zasięgu.

Aby utworzyć pulę deklarujemy obiekt puli w normalny sposób, a w końcu możemy powiązać konkretny typ dostępowy z nowo utworzoną pulą:

Cell_Ptr_Pool: My_Pool_Type (1000);                    -- pula o wielkości 1000
for Cell_Ptr'Storage_Pool use Cell_Ptr_Pool;

Istotną zaletą deklarowania własnych pul pamięci jest minimalizacja ryzyka fragmentacji, a to dzięki przechowywaniu obiektów różnych typów w różnych pulach. Ponadto możemy napisać własną obsługę alikacji i uwzględnić - jeśli sobie tego życzymy - mechanizmy defragmentacji. Dodatkowym plusem jest to, że jeśli dany typ dostępowy jest zadeklarowany lokalnie, to pula również może być lokalna. Automatycznie znika nam wtedy problem z nieużytkami.

Wreszcie, mamy dodatkową ochronę przed niewłaściwym użyciem procedury Unchecked_Deallocation: dlatego, że jest to predefiniowana jednostka biblioteczna (dotyczy to zresztą wszystkich jednostek bibliotecznych), wymagane jest włączenie pakietu:

with Unchecked_Deallocation;

na samym początku naszego pakietu. Dzięki temu widoczne jest wykorzystanie mechanizmu dealokacji dla każdego czytające, szczególnie dla naszego szefa.

7.4 Ograniczenia

W Adzie istnieje ogólny mechanizm uniemożliwiający na żądanie korzystanie z pewnych cech języka. Jest to pragma Restrictions. Jeśli zapiszemy:

pragma Restrictions(No_Dependence => Unchecked_Deallocation);

to stwierdzamy, że program nie będzie w ogóle używał Unchecked_Deallocation. Jeśli okaże się inaczej, kompilator odrzuci nasz program.

W Adzie 2005 mamy do dyspozycji ponad 40 ograniczeń, które dają nam kontrolę nad różnymi aspektami programu. Większość z nich ma naturę bardzo specjalistyczną i odnosi się do programów wielozadaniowych. Inne dotyczą ogólnie pamięci i dlatego są powiązane z treścią niniejszego rozdziału:

pragma Restrictions(No_Allocators);
pragma Restrictions(No_Implicit_Heap_Allocations);

Pierwsza pragma zabrania całkowicie korzystania z alokatora new - jak w new Cell' (...) oraz jawnego korzystania ze sterty. Czasami niektóre implementacje mogłyby korzystać ze sterty tymczasowo, na obiekty w niektórych szczególnych okolicznościach. Zachodzi to rzadko i może być zabronione pragmą drugą.

Rozdział 6: Bezpieczne tworzenie obiektów Rozdział 8: Bezpieczny start programu
Tekst oryginalny w języku angielskim - pdf2
Zmieniony: Czwartek, 25 Marzec 2010 11:05  

Dodaj swój komentarz

Imię:
Temat:
Komentarz: