Sytuacja kobiet w IT w 2024 roku
27.11.20204 min

Bartosz StempieńApplication Development

Wyrównanie pamięci w strukturach C++

Dowiedz się, jak działa mechanizm wyrównania pamięci w strukturach w języku C++.

Wyrównanie pamięci w strukturach C++

W tym krótkim artykule dowiesz się więcej o wyrównaniu pamięci w strukturach w C++. Jest to funkcjonalność, która może zaskoczyć niektórych młodych programistów, a o której naprawdę warto wiedzieć.

W celu lepszego zrozumienia zagadnienia, posłużymy się poniższym kodem:

#include <iostream>
int main()
{
    struct A
    {
        int a;
        char b[3];
        long c;
        char d;
    };
    struct B
    {
        long c;
        int a;
        char b[3];
        char d;
    };
    std::cout << "The size of A is " << sizeof(A) << std::endl;
    std::cout << "The size of B is " << sizeof(B) << std::endl;
    
    return EXIT_SUCCESS;
}


Niektórzy odpowiedzieliby, że strumień wyjścia wypisze na ekranie te same wartości liczbowe, jednakże tak się nie stanie. Rozmiar struktury A wynosi 24 bajtów, a B 16 bajtów.

Operator sizeof zwraca wartość zajmowaną przez dany typ w pamięci w bajtach. W przypadku klas/struktur, zwraca on rozmiar tejże klasy/struktury i dodatkowo potrzebne dopełnienie (padding).

W przypadku struktury A, kompilator samoczynnie dodał dodatkowe bajty, przez co zwiększył ilość zajmowanego miejsca w pamięci przez strukturę. 

Dlaczego tak się dzieje?

Przyjrzyjmy się, jak kompilator ulokował nasze struktury w pamięci bajt po bajcie:

Struktura A:


Struktura B:


(pad - padding - w tekście używane jako słowo dopełnienie.)

Struktura A została wyrównana do 24 bajtów, ponieważ:

1. Kompilator używa następujących zasad przy wyrównywaniu pamięci:

  • obiekty o rozmiarze jednego bajta trafiają do jakichkolwiek adresów
  • obiekty o rozmiarze dwóch bajtów trafiają do adresów które są wielokrotnością dwójki
  • obiekty o rozmiarze czterech bajtów trafiają do adresów które są wielokrotnością czwórki
  • obiekty o rozmiarze ośmiu bajtów trafiają do adresów które są wielokrotnością ósemki


Dzięki temu oszczędzamy cykle procesora na odczyt/zapis pamięci (potrzebujemy jednego cyklu na odczytanie/zapisanie longa o rozmiarze 8 bajtów zamiast 2, ponieważ mieści się on w jednym banku).

2. Następnie kompilator zastosuje metodę dopełnienia pamięci dla każdej struktury. Dopełnienie elementów będzie względem największego pola struktury. W tym przypadku jest to typ long, który zajmuje 8 bajtów (pamiętajmy, że long ma zakres wynoszący minimum 32 bity). Oznacza to, że kompilator doda dopełnienie do pola b, tak by suma bajtów tego pola, pola “a” i dopełnienia wynosiła 8. Analogicznie dla pola d - kompilator doda w tym wypadku 7 bajtów, by suma bajtów tego pola i dopełnienia wyniosła 8.

Punkt 2 jest niezwykle istotny. co obrazuje poniższy przykład:

Załóżmy, że tworzymy tablicę struktur A o rozmiarze 2, a kompilator nie dodał 7 dodatkowych bajtów do pola d. Pierwszy element tablicy zaczyna się od adresu, 0x0000 0000, natomiast kończy się na adresie 0x0000 0010. Drugi element tablicy zacznie się od adresu 0x0000 0011. Pierwszym polem tego elementu jest int. Zgodnie z zasadami, z jakimi kompilator umieszcza obiekty w pamięci, adres musi być wielokrotnością czwórki, a tak nie jest!

Gdybyśmy mieli strukturę A w postaci:

struct A 
{
short a;
char p;
};


...to jej rozmiar wyniósłby 4 bajty, bo wyrównanie jest względem shorta (największy typ w strukturze).

Jak pozbyć się wyrównania i kiedy to robić

Możemy wyłączyć opcję wyrównywania pamięci w strukturach poprzez:

  1. Zmienianie kolejności atrybutów w strukturach.
  2. Użycie: #pragma pack(packed) lub __attribute__ ((packed))


Pierwszy sposób jest nieoptymalny i czasochłonny w przypadku dużych struktur.

Kiedy to robić?

Gdy chcemy zaoszczędzić pamięć.

Zagrożenia, kiedy pozbędziemy się wyrównania:

  1. W niektórych przypadkach może zmniejszyć to czytelność kodu (musimy rozmieścić inaczej atrybuty struktury, przez co może ucierpieć kolejność a to obniża czytelność).
  2. Usunięcie wyrównania może mieć wpływ na wydajność (pogorszenie jej). Przykładowy kod.
  3. Wymuszając brak wyrównania możemy spowodować, że procesor będzie potrzebował np. dwóch cyklów na odczyt danych z pamięci zamiast jednego.
  4. Starsze procesory są bardziej wrażliwe na brak wyrównania pamięci w strukturach. W przypadku procesorów ARM wymagane jest by dane były wyrównane.


Ważna uwaga

Co ciekawe, zgodnie ze standardem C++, dopełnienie może przechowywać jakąkolwiek wartość. Dlatego też warto rozważyć opcję “wyzerowania” pamięci, by uniknąć wycieków danych. Można to zrobić na przykład za pomocą tej krótkiej funkcji: memset(&YOUR_STRUCTURE, 0, sizeof(YOUR_STRUCTURE)). Warto również użyć flagi -Wuninitialized w celu ostrzeżenia nas przed niezainicjowanymi polami (jeśli korzystamy z GCC).

Założenia poczynione w artykule

Makro CHAR_BIT (biblioteka climits) zwraca wartość 8 bitów.
sizeof(size_t) zwraca 8 bajtów (64 bitowa maszyna)
long - 8 bajtów
char - 1 bajt
int - 4 bajty

Jeśli nie wiesz, na jakiej maszynie operujesz, łatwo sprawdzisz to za pomocą tego kodu:

#include <iostream>
int main()
{
    std::cout << sizeof(size_t) << std::endl; 
                                              		       
    return EXIT_SUCCESS;
}


Swoimi przemyśleniami na temat artykułu podziel się w komentarzu poniżej ???

<p>Loading...</p>