Skocz do zawartości

Tablice w Arduino – #3 – tablice w pamięci


Pomocna odpowiedź

W tej części kursu wracamy do tematu tablic, ale tym razem na dobre 🥳 Wiemy już jak mniej więcej wygląda tablica (jednowymiarowa tablica statyczna) oraz jak wygląda pamięć operacyjna. Tym razem przypatrzymy się bliżej tworzeniu i inicjalizacji elementów tablicy oraz poruszaniu się w pamięci.

rysunki_4.thumb.jpg.70e0da96867293bb55ffdb5ee873b14f.jpg

Spis treści

Dlaczego statyczna?

Jak już wiemy istnieją dane statyczne – takie które zostały "powołane do życia" na etapie kompilacji i mają stałe miejsce w pamięci. Można tu wyróżnić zmienne globalne i zmienne z dopiskiem (słowem kluczowym/specyfikatorem) static. Jest to tylko informacja dla kompilatora ile bajtów danych powinien (zarezerwować) zaalokować w pamięci.

Statycznie nie znaczy stałe – zmienne można nadpisywać (nie jest to specyfikator const czy constexpr).

Są to bardzo ważne informacje, ponieważ jak wiemy tablica służy do przechowywania danych tego samego typu, ale mogą mieć różną liczbie elementów. Skoro wiec kompilator życzy sobie mieć podany rozmiar obszaru pamięci jaki ma przydzielić określonym danym statycznym, to dlatego musimy w jakiś sposób podać informację ile ma być tych danych. Możemy tego dokonać na kilka sposobów.

t5.thumb.png.68c2468e5208498a44e1c149524629d4.png

Tworzenie tablic statycznych

W jednym z przykładów pokazałem zapis:

int lockers[5];

Wiemy, że wpisywanie magicznych liczb nie jest dobrą praktyką, co więc powinno się tu znaleźć? Coś co znane jest na etapie kompilacji.

Można tu więc podać wyrażenie, które utworzy dla nas preprocesor (działający przed kompilacją na kodzie źródłowy). Dyrektywą #define utworzy alias na ciąg znaków (tzw. tokenówLOCKERS_COUNT. Wystąpienia aliasu zostaną przetłumaczone na  token 5 i wstawione w kod źródłowy, który zostanie przekazany do kompilacji.

#define LOCKERS_COUNT 5

int lockers[LOCKERS_COUNT];

Takie podejście przez kompilator jest widziane tak samo jak używanie magicznych liczb, ale na etapie pisania kodu poprawia czytelność i jest wspierane prze środowiska programistyczne (IDE).

Użycie dyrektywy zamiast zmiennej pozwala uniknąć alokacji pamięci operacyjnej.

Tak przygotowany alias możemy używać w różnych miejscach kodu, np. w pętli:

for(int i = 0; i < LOCKERS_COUNT; i++) {
  Serial.print(F("Locker ")); Serial.print(i);
  if(lockers[i])
     Serial.println(F(" is open."));
  else
     Serial.println(F(" is closed."));
}

Efekt działania preprocesora widoczny jest w pliku wynikowym w katalogu preproc, który znajdziemy w tymczasowym katalogu roboczym Ardunio IDE – jego ścieżkę znajdziemy przeglądając log kompilacji.

image.thumb.png.561eb9fbb83e1fe0667527f98d787e51.png

Innym sposobem zadeklarowania tablicy jest użycie zmiennej wewnątrz nawiasów kwadratowych. Wymaga to tylko zapewnienia kompilatora, że to co tam wpisujemy jest stałe. Można tego dokonać dopisując przed typem danych zmiennej słowo kluczowe... const?

Niekoniecznie... dlatego, że specyfikator const nie daje gwarancji, że w czasie kompilacji wartość będzie stała. Jak to? Prześledźmy przykład:

void setup() {
  const int sensor_value = digitalRead(0);
  sensor_value = LOW;
}

Pierwsza linia jest w porządku – wartość raz ustalona, nie ulegnie zmianie, acz zmienna zapisana na stosie (adres 0x8F6). Druga linia spowoduje błąd kompilacji, bo zgodnie z prawdą raz ustalonej zmiennej const nie możemy zmienić.

A czy da się użyć tej zmiennej do stworzenia tablicy statycznej? Da się:

void setup() {
  const int sensor_value = digitalRead(0);
  int suspicious_tab[sensor_value];
}

Nie jest to jednak poprawne, gdyż patrząc na ten kod nie da się stwierdzić jaki rozmiar ma tablica na etapie kompilacji. To dlaczego kompilacja przeszła? Bo aż do wersji C++11 był to dopuszczony sposób o ile był on używany z głową (podobnie jak nikt nie zabroni sięgnąć do pamięci poza ustalonym obszarem tablicy).

W C++11 doszedł nowy specyfikator, który tworzy bardziej stałe... stałe, jest nim constexpr. Tego słowa kluczowego niestety nie znajdziemy wśród typowej składni Arduino IDE (znajdziemy np. w Visual Studio Code), ale działa i nie da się go oszukać, bo wartość constexpr musi być znana na etapie kompilacji:

void setup() {
  constexpr int really_constant_lenght = 5;
  int tab[really_constant_lenght];
}

Takie coś uda się na pewno. Pamiętajmy, że choć constexpr jest stałe to nie jest niezniszczalne - ten egzemplarz został zapisany na stosie więc po opuszczeniu funkcji setup() zostanie usunięty.

g6.thumb.jpg.440a4398636e9b4c50ad9c5c95ef99d3.jpg

Inicjalizacja tablic statycznych

Podobnie jak zwykłe zmienne, tablice też podlegają inicjalizacji wartościami początkowymi co pobieżnie sprawdziliśmy w pierwszej części kursu. Jeżeli tablica jest widoczna globalnie lub jest statyczna (tak zgadza się statyczna tablica statyczna) to zostanie zapisana w bloku pamięci statycznej.

To czy dane tablicy trafią do sekcji .data czy .bss zależy od tego czy zostanie przez nas zainicjowana. Zasada jest prosta: Jeżeli nie zainicjowaliśmy żadnego elementu globalnej/statycznej tablicy to zostanie zarezerwowany blok pamięci w .bss. Przykład:

int lockers[LOCKERS_COUNT];

Jak wiemy do inicjalizacji możemy wykorzystać nawiasy klamrowe. Co ciekawe, nie musimy w takim przypadku inicjalizować wszystkich wartości, bo jeżeli mamy tablicę globalną/statyczną to pozostałe elementy (zgodnie z zadanym rozmiarem tablicy) otrzymają domyślne 0:

int lockers[LOCKERS_COUNT] = {1, 0, 1}; // wartości tablicy: {1, 0, 1, 0, 0} 

Taka tablica trafi (zostanie zaalokowany obszar) w sekcji .data.

W ramach ciekawostki – znak równości można pominąć (podobnie co z przypadku pojedynczej zmiennej):

int lockers[LOCKERS_COUNT] {1, 0, 1}; // wartości tablicy: {1, 0, 1, 0, 0} 

A co z tablicami lokalnymi? Zasada jest ta sama co w przypadku zmiennych lokalnych. Tak naprawdę tablica to taka bardziej złożona zmienna obejmująca dużo więcej miejsca i posiadająca pewną specyficzną cechę - możliwość indeksowania.

t6.thumb.jpg.d20643ae5d96a2a06639c4959031061c.jpg

Tablica w pamięci

Dobrze, a jak wygląda pamięć zajęta przez tablicę? Na wstępie do kursu wspomniałem, że wrócimy do podanej definicji tablicy, gdyż nie omówiłem na czym polega ciągłość bloku pamięci który zajmuje.

Jako, że dane w tablicy są usystematyzowane i możliwe jest sprawne przechodzenie pomiędzy jej elementami, to też miejsce w pamięci jest zajęte w usystematyzowany sposób. Każda z wartości ma swój adres, który możemy uzyskać w taki sam sposób jak w przypadku zwykłej zmiennej:

int *address_locker_3 = &lockers[3];

Zapis po lewej stronie już widzieliśmy. Wyrażenie po prawej stronie też jest znane, tylko że w miejsce nazwy zwykłej zmiennej podajemy wybrany element tablicy (tu 3).

Wiedząc to spróbujmy wypisać wszystkie adresy i wartości tablicy z przykładu:

int lockers[LOCKERS_COUNT] = {1, 0, 1};

void setup() {
  Serial.begin(9600);
  for (int i = 0; i < LOCKERS_COUNT; i++) {
    Serial.print(F("0x")); Serial.print((int)&lockers[i], HEX); Serial.print(F(" locker: ")); Serial.println(lockers[i]);
  }
}

Otrzymamy takie wyniki:

0x102 locker: 1
0x104 locker: 0
0x106 locker: 1
0x108 locker: 0
0x10A locker: 0

Jak widać elementy tablicy zaczynają się od adresu 0x102 i są zwiększane co 2 B (tyle ile zajmuje typ danych int). Dlatego możliwa jest bezpieczna iteracja.

W bloku pamięci tablicy nie ma żadnych przerw.

A jak wygląda sytuacja, w której wyjdziemy poza tablicę? Co się wtedy stanie? Dla przedstawienia efektu po tablicy utwórzmy zmienną w sekcji .data:

int lockers[LOCKERS_COUNT] = {1, 0, 1};
int notimportant = (1<<15) - 1; // maksymalna wartość int (ze znakiem)

void setup() {
  Serial.begin(9600);
  for (int i = 0; i < LOCKERS_COUNT + 1; i++) {
    Serial.print(F("0x")); Serial.print((int)&lockers[i], HEX); Serial.print(F(" locker: ")); Serial.println(lockers[i]);
  }
}

Zgodnie z oczekiwaniem kod się skompilował, bezbłędnie uruchomił i odczytaliśmy wartość sąsiedniego bloku pamięci (akurat udało się pobrać wartość zmiennej):

0x102 locker: 1
0x104 locker: 0
0x106 locker: 1
0x108 locker: 0
0x10A locker: 0
0x10C locker: 32767

Widzimy zatem jak niebezpieczne jest złe indeksowanie. Dobrze, że tylko odczytywaliśmy dane i udało się trafić na ten sam typ danych, gorzej gdybyśmy chcieli coś zapisać do takiej "tablicy".

Dygresja o skakaniu

Z powyższego przykładu, choć pokazującego błędne zachowanie można wyciągnąć wniosek – możliwe jest skakanie pomiędzy obszarami pamięci – udało się wyskoczyć z tablicy i przejść do innej zmiennej. Czy gdybyśmy zamiast tablicy mieli kilka zmiennych, czy możliwe byłoby przechodzenie pomiędzy nimi?

image.thumb.png.ab33ebb0b2493cbb568a817a4547c251.png

Załóżmy że zmienne te są zaalokowane w kolejności. Bierzemy adres pierwszej z nich:

int variable_1 = 6;
int variable_2 = 123;
int variable_3 = -555;

int *var_1_address = &variable_1;

Będzie to adres 0x100, kolejny będzie o 2 B większy więc będzie to 0x102, kolejny 0x104. Wydaje się, że wystarczy dodać do pierwszego adresu 2 i gotowe. Niekoniecznie...

int variable_1 = 6;
int variable_2 = 123;
int variable_3 = -555;

int *var_1_address = &variable_1;
int *var_2_address = var_1_address + 2;

void setup() {
  Serial.begin(9600);
  
  Serial.print(F("0x")); Serial.print((int)&variable_1, HEX); Serial.print(F(", variable_1: ")); Serial.println(variable_1);
  Serial.print(F("0x")); Serial.print((int)&variable_2, HEX); Serial.print(F(", variable_2: ")); Serial.println(variable_2);
  Serial.print(F("0x")); Serial.print((int)&variable_3, HEX); Serial.print(F(", variable_3: ")); Serial.println(variable_3);

  Serial.print(F("0x")); Serial.print((int)var_1_address, HEX); Serial.print(F(", var_1_address: ")); Serial.println(*var_1_address);
  Serial.print(F("0x")); Serial.print((int)var_2_address, HEX); Serial.print(F(", var_2_address: ")); Serial.println(*var_2_address);
}

void loop() {
}

Zwróćmy uwagę na ostatnią linię w wyniku ze zmienną var_2_address:

0x100, variable_1: 6
0x102, variable_2: 123
0x104, variable_3: -555
0x100, var_1_address: 6
0x104, var_2_address: -555

Nie tak miało być, ale widać że dodawanie działa! W poprzednim rozdziale wspomniałem, że wskaźnik niesie informację o wielkości obszaru pamięci, więc to że int zajmuje 2 B jest zachowane. Dodając coś do adresu zwiększamy go nie o liczbę bajtów tylko o liczbę związaną z obszarem zajmowanym przez ten typ danych.

Dlatego wskaźnik zazwyczaj jest skojarzony z konkretnym typem danych.

Przykładowo, utwórzmy zmienną int (2 B) i dalej 2 zmienne char (2 x 1 B) i odczytajmy wartość używając wskaźnika na int. Wartości wypiszmy w systemie szesnastkowym:

int variable_1 = 6;
char variable_2 = 33; // 0x21
char variable_3 = 76; // 0x4C

int *var_1_address = &variable_1;
int *var_2_address = var_1_address + 1;

void setup() {
  Serial.begin(9600);
  
  Serial.print(F("0x")); Serial.print((int)&variable_1, HEX); Serial.print(F(", variable_1: ")); Serial.println(variable_1, HEX);
  Serial.print(F("0x")); Serial.print((int)&variable_2, HEX); Serial.print(F(", variable_2: ")); Serial.println(variable_2, HEX);
  Serial.print(F("0x")); Serial.print((int)&variable_3, HEX); Serial.print(F(", variable_3: ")); Serial.println(variable_3, HEX);

  Serial.print(F("0x")); Serial.print((int)var_1_address, HEX); Serial.print(F(", var_1_address: ")); Serial.println(*var_1_address, HEX);
  Serial.print(F("0x")); Serial.print((int)var_2_address, HEX); Serial.print(F(", var_2_address: ")); Serial.println(*var_2_address, HEX);
}

Co prawda odczytaliśmy kolejny adres 0x102 (ostatnia linia) ale nie taka wartość miała być:

0x100, variable_1: 6
0x102, variable_2: 21
0x103, variable_3: 4C
0x100, var_1_address: 6
0x102, var_2_address: 4C21

Jak widać sąsiednie bajty zostały złączone tak by obszar pamięci pasował do typu danych int. Sytuację można poprawić zmieniając typ danych wskaźnika na ten skojarzony z typem char, oraz rzutując wynik operacji dodawania:

int *var_1_address = &variable_1;
char *var_2_address = (char*)(var_1_address + 1);

Jak można zauważyć przesunięcie adresu jest zgodne z typem danych int, a odczytanie wartości odbywa się zgodnie z char:

0x100, variable_1: 6
0x102, variable_2: 21
0x103, variable_3: 4C
0x100, var_1_address: 6
0x102, var_2_address: 21

"Ciekawe... a do czego mi to potrzebne, skoro tak nie powinno się poruszać pomiędzy zmiennymi?" – taka myśl może się zrodzić, bo wielokrotnie wspominałem, że takie skanie jest dozwolone tylko w obrębie tablic i właśnie tam wykorzystamy podobny mechanizm. Znajdując się w ostatniej scenie typowego cliffhangera zapowiem, że w kolejnej części przybliżę temat tablic, w których nie będziemy posługiwać się nawiasami kwadratowymi do poruszania siępo elementach.

W tej części przybliżyłem różne sposoby tworzenia tablic statycznych oraz alokacji w pamięci. Oczywiście tablice statyczne to nie jedyny rodzaj tablic wiec w przyszłości wrócimy do tego tematu. 

 

Edytowano przez Gieneq
  • Lubię! 1
Link to post
Share on other sites
  • Gieneq zmienił tytuł na: Tablice w Arduino – #3 – tablice w pamięci

Kolejna część kursu, tym razem wgląd w poruszanie się po tablicach i kilka ciekawostek pomocnych przy tworzeniu własnych programów 🛠️

Link to post
Share on other sites

Dołącz do dyskusji, napisz odpowiedź!

Jeśli masz już konto to zaloguj się teraz, aby opublikować wiadomość jako Ty. Możesz też napisać teraz i zarejestrować się później.
Uwaga: wgrywanie zdjęć i załączników dostępne jest po zalogowaniu!

Anonim
Dołącz do dyskusji! Kliknij i zacznij pisać...

×   Wklejony jako tekst z formatowaniem.   Przywróć formatowanie

  Dozwolonych jest tylko 75 emoji.

×   Twój link będzie automatycznie osadzony.   Wyświetlać jako link

×   Twoja poprzednia zawartość została przywrócona.   Wyczyść edytor

×   Nie możesz wkleić zdjęć bezpośrednio. Prześlij lub wstaw obrazy z adresu URL.

×
×
  • Utwórz nowe...

Ważne informacje

Ta strona używa ciasteczek (cookies), dzięki którym może działać lepiej. Więcej na ten temat znajdziesz w Polityce Prywatności.