Skocz do zawartości

Tablice w Arduino – #2 – organizacja pamięci


Pomocna odpowiedź

Udało Ci się napisać pierwsze programy z wykorzystaniem tablic. Ta część kursu jest bardziej teoretyczna, ale pozwoli Ci zapoznać się z organizacją pamięci w mikrokontrolerze ATmega328P. Dowiesz się o różnych typach zmiennych i gdzie są zapisywane. Przyda się to w nabraniu świadomości jak działa tablica.

rysunki_3.thumb.jpg.b8e7ec390cb5e00dc8c62739b1db8a37.jpg

Spis treści

Język Arduino?

Zanim przejdę do sedna sprawy zadam szybkie pytanie: W jakim języku programuje się Arduino? Nie jest to takie oczywiste, bo często spotyka się zapis C/C++ lub informację "dialekt C++". Są to poprawne informacje, ale aby być bardzie szczegółowym, wystarczy sprawdzić co znajduje się w pliku związanym z kompilacją.

Zakładając, że używamy Arduino z mikrokontrolerem AVR ATmega328P przechodzimy do katalogu np. C:\Program Files (x86)\Arduino\hardware\arduino\avr i tam szukamy pliku związanego z biblioteką płytek pobranej w menadżerze płytek.

Wewnątrz pliku platform.txt znajdziemy blok okomentowany jako domyślne ustawienia, gdzie jedna z linii informuje nas o użyciu C++11:

compiler.cpp.flags=-c -g -Os {compiler.warning_flags} -std=gnu++11 -fpermissive -fno-exceptions -ffunction-sections -fdata-sections -fno-threadsafe-statics -Wno-error=narrowing -MMD -flto

Podobną informację znajdziemy w logu kompilacji z Arduino IDE.

Jeszcze jednym ciekawym sposobem jest wypisanie predefiniowanej nazwy preprocesora __cplusplus identyfikującej standerd C++. Wypisanie liczby 201103 będzie oznaczać C++11:

Serial.println(__cplusplus);

Wiedząc to możemy być bardzie świadomi co nam wolno, a czego nie  Przykładowo, niektórzy zwolennicy czystego C będą na pewno wystrzegać się klas, wydedukowanego typu danych auto, wyrażeń lambda i innych zdobyczy "zinkrementowanego C", na których bazuje część bibliotek Arduino. Na szczęście tablice występują w C więc nie jest to temat dyskusyjny, ale w ramach kursu skorzystamy z innych dobrodziejstw C++11.

Szczegóły dotyczące języka i procesu kompilacji dostępne są w osobnej serii artykułów.

Optymalizacja

Domyślnie w Arduino IDE włączona jest optymalizacja rozmiaru pliku wynikowego -Os. Jest to potrzebne dla mikrokontrolerów o ograniczonych zasobach (ATmega328P posiada tylko 2 kB RAM). Może to jednak powodować problemy zwłaszcza, gdy eksperymentujemy z pamięciĄ, więc tymczasowo polecam wyłączyć optymalizację podmieniając w pliku platform.txt -Os na -O0:

image.png

Później warto jednak do niej wrócić, optymalizacja rozmiaru -Os może zmniejszyć kod programu nawet o ponad 80%!

Organizacja pamięci operacyjnej

Aby lepiej zrozumieć dalszą treść, niezbędna jest podstawowa wiedza na temat organizacji pamięci operacyjnej RAM. Jak wiemy mikrokontroler ATmega328P posiada 2 kB pamięci SRAM. Przeglądając 18 stronę dokumentacji znajdziemy diagram:

image.png

wynika z niego, że pamięć operacyjna zaczyna się w adresie 0x0100, a kończy w adresie 0x08FF. Oznacza to, że mamy do dyspozycji 2048 bajtów pamięci, czyli tyle ile zadeklarował producent: 2 kB (albo bardziej jednoznacznie 2 KiB).

Pamięć ta jest dalej dzielona na obszary przeznaczone do konkretnych zadań:

t3.thumb.png.21a49249a2b2ee5ba926755f4a38464a.png

Jak możemy zauważyć zaznaczone są tu 4 istotne obszary:

  • sekcja .data,
  • sekcja .bss,
  • sterta (ang. heap),
  • stos (ang. stack) .

t4.thumb.png.2dc1b2cebb88ac0e23202cde6a83c397.png

Dygresja o wskaźnikach

Niestety bez tego nie przejdziemy dalej. Wskaźniki straszą początkujących, ale kiedyś trzeba je polubić, bo bez nich za daleko się nie zajedzie. Na szczęście dla potrzeb poznania adresów zmiennych nie będziemy potrzebować zbyt wiele.

Wskaźnik na zmienną służy do wskazywania... czyli pokazywania, gdzie w pamięci znajduje się zmienna. Informacja gdzie jest dana zmienna zawarta jest w adresie (typie danych wskaźnika). To wszystko? W zasadzie tak 🙂 Działa to na podobnej zasadzie co pocztowe dane adresowe.

image.thumb.png.9265e22fe4f7d684b089d58a43afb666.png

Przykładowo, zdefiniujmy zmienną typu int o nazwie velocity i przypiszmy jej wartość 12:

int velocity = 12;

// alternatywny zapis:
int velocity {12};

To co się wydarzyło to zarezerwowanie w pamięci obszaru, gdzie zapisana została wartość 12. Jak duży to obszar?

Z kursu Arduino dowiedziałeś się, że różne typy danych charakteryzują się różną wielkością (liczoną w bajtach) i przeznaczeniem. Zmienne typu int na tym mikrokontrolerze zajmują w pamięci 2 B i można to sprawdzić operatorem sizeof().

Serial.println(sizeof(velocity));

W tym przypadku użyliśmy operatora na zmiennej, ale można też użyć na samym typie danych:

  Serial.print("Size of char: "); Serial.println(sizeof(char));
  Serial.print("Size of int: "); Serial.println(sizeof(int));
  Serial.print("Size of long: "); Serial.println(sizeof(long));
  Serial.print("Size of float: "); Serial.println(sizeof(float));

W wyniku otrzymamy:

Size of char: 1
Size of int: 2
Size of long: 4
Size of float: 4

Dobrze wiemy jak sprawdzić ile pamięci zajmują zmienne, ale gdzie te wskaźniki? Śpieszę z wyjaśnieniem.

Przypatrzmy się jeszcze raz zmiennej velocity. Wiemy, że zajmuje 2 B pamięci. Aby uzyskać adres tego obszaru pamięci użyjmy jednoargumentowego operatora & udostępniającego adres:

int *address_velocity = &velocity;

A co to za stwór po lewej stronie? Tak wygląda typ danych skojarzony ze wskaźnikiem na zmienną int. Choć gwiazdkę stawia się przy nazwie zmiennej to typ danych to int * – tak się przyjęło (choć możesz spotkać też inne zapisy – ze spacją lub bez niej). Przykładowo w środowisku Visual Studio Code dostępne są do wyboru wszystkie sposoby zapisu:

image.thumb.png.fa3908a74dfc52860bb1e9889412e9b5.png

Dla innych typów danych będą inne skojarzone z nimi wskaźniki – dla long będzie long *, dla char będzie char * itd. Skoro jest to tylko adres, to dlaczego istnieją osobne typy danych, a nie jeden dla adresów? Dzieje się tak, gdyż wskaźnik niesie też dodatkową informację, np. o tym jak wiele miejsca zajmuje zmienna, co przyda się, gdy wrócimy do tematu tablic.

Istnieje uniwersalny wskaźnik, który wskazuje obszar pamięci, ale nie posiada informacji o rozmiarze danych: void *.

Aby poznać adres wskaźnika możemy zrzutować go na int i wyświetlić w monitorze portu szeregowego:

int velocity = 12;
int *address_velocity = &velocity;

void setup() {
  Serial.begin(9600);
  Serial.print(F("0x")); Serial.println((int)address_velocity, HEX);
}

Ważne jest aby wypisać liczbę w systemie heksadecymalnym (HEX). W wyniku otrzymamy adres zmiennej:

0x100

Dlaczego akurat taki adres, o tym za chwilę. Na razie zakończę temat wskaźników jeszcze jedną informacją – dysponując wskaźnikiem można uzyskać wartość, na którą wskazuje (obszar pamięci pod adresem wskaźnika). Służy do tego jednoargumentowy operator *, który używamy następująco:

  Serial.println(*address_velocity);

W wyniku z powrotem otrzymamy wartość 12.

image.png

Kapitanie... do brzegu – obszary pamięci

Tak, tak... już już wracam do tematu 🙂 Wiemy jak sprawdzić adres zmiennej w pamięci i wiemy, że są jakieś obszary pamięci: .data, .bss, heap i stack. Co dokładnie oznaczają?

.data – ten obszar pamięci jest na samym początku SRAM i zaczyna się od adresu 0x100 (adres ten zdążyliśmy już zauważyć w zmiennej predkosc). Sekcja .data służy do przechowywania danych statycznych to znaczy takich, które istnieją przez cały czas działania programu. Do tych zmiennych należą:

  • zmienne globalne (te które zainicjalizowaliśmy "na górze" kodu) ale mające wartość inną niż zero (zero jest domyślnie wartością zmiennych niezainicjowanych... tak wiem, pokręcone),
  • zmienne lokalne z słowem kluczowym static, czyli lokalne zmienne statyczne (też w wywołaniach funkcji) z przypisaną niezerową wartością.

.bss – kolejny obszar służący do przechowywania danych statycznych ale niezainicjowanych lub posiadających wartość 0. Do tych zmiennych należą:

  • zmienne globalne niezainicjowane lub o wartości 0,
  • statyczne zmienne lokalne niezainicjowane lub o wartości 0.

Uwaga! Zmienne statyczne to nie zmienne stałe, ich wartość może się zmieniać.

Przykłady zmiennych statycznych:

int velocity = 5;     // globalna zmienna, zainicjowana, niezerowa – .data
int momentum;         // globalna zmienna, niezainicjowana, domyślnie wartość 0 – .bss

void setup() {
  // wewnątrz funkcji – zmienne lokalne
  
  static int acceleration = 2;   // statyczna zmienna lokalna, zainicjowana, niezerowa – .data
  static int steps_numer;        // statyczna zmienna lokalna, niezainicjowana, domyślna wartość 0 – .bss
  steps_numer = 5;               // niezerowa wartość, ale dalej w .bss
}

Zmienne statyczne mogą się przydać w sytuacji, gdy projektujemy funkcję, która powinna mieć zapamiętany jakiś swój wewnętrzny stan. Zamiast tworzyć zmienną globalną, która byłaby widoczna w wielu miejscach w kodzie i mogłaby być myląca, to można utworzyć lokalną zmienną statyczną, której wartość nie zostanie utracona nawet po wyjściu z funkcji, ale będzie widoczna tylko wewnątrz tej funkcji.

Takie zachowanie zmiennej może być zaskakujące, ale dzieje się tak, gdyż adres obszaru pamięci jest na stałe zarezerwowany. Jeżeli utworzymy zmienną jedynie przez definicję, to jej początkowa wartość będzie wynosić 0 lecz przy kolejnych wywołaniach wartość będzie aktualizowana.

Przykładowo następujący kod zawiera statyczną zmienną lokalną i intuicja podpowiada, że pewnie jej wartość zostanie utracona po wyjściu z funkcji.

void use_counter() {
  static int count;
  Serial.print(F("Function used: ")); Serial.print(count++); Serial.println(F(" times."));
}

void loop() {
  use_counter();
  delay(1000);
}

Efekt działania jednak wskazuje, że zmienna nie jest wymazana:

Function used: 0 times.
Function used: 1 times.
Function used: 2 times.
Function used: 3 times.
Function used: 4 times.

Zmienne i funkcje statyczne na pewno spotkasz nieraz w plikach bibliotecznych, gdyż użycie ich poprawia hermetyzację danych (widoczność tylko w ograniczonym zakresie) i wymusza pierwszeństwo przy kompilacji. Nie można tego mylić z metodami statycznymi klas, gdzie static oznacza przynależność do klasy, a nie poszczególnych instancji.

image.thumb.png.9a35a2347fe573a84027921b2fd98f1c.png

stack – specjalnie zmieniłem kolejność, bo stos jest bardziej intuicyjny. Jest to obszar pamięci dynamicznej (czyli takiej która jest zapełniania i zwalniana w czasie działania programu) zaczynający się od końca RAM i rosnący w stronę początku pamięci. Zapełnianie stosu następuje automatycznie gdy:

  • wywoływane są funkcje,
  • tworzone są zmienne lokalne.

Dane są dopisywane jedna po drugiej, stąd są one ciasno upchane (nie posiada luk). Posługując się przykładem z ilustracji jest to podobne do poukładanej ścianki narzędziowej, gdzie pobieramy i odkładamy narzędzia wiszące obok siebie.

Zwalnianie stosu następuje automatycznie wraz z zakończeniem wywołania funkcji. Przykład zmiennej zapisanej na stosie:

void setup() {
 int local_variable = 5; 
}

Uwaga! Zmienne lokalne zapisane na stosie domyślnie mają wartość losową. Dzieje się dlatego, że przy większej liczbie danych (np. w pętli) ustawianie każdorazowo wartości na 0 pogarszałoby czas wykonania. Przykładowo:

  int local_variable;
  Serial.print(F("0x")); Serial.print((int)&local_variable, HEX); Serial.print(F(", value: ")); Serial.println(local_variable);

Jak widać adres zmiennej jest bardzo blisko końca: 0x8FF i wskazywana wartość jest zupełnie przypadkowa:

0x8F6, value: -29696

heap – sterta to również obszar pamięci dynamicznej ale przeznaczony do przechowywania danych, gdy tworzone są nowe obiekty (alokowana pamięć) w czasie działania programu. Dokonuje się tego:

  • używając operatora new, który przydziela pamięć do obiektu lub tablicy,
  • alokując blok pamięci funkcją malloc().

Różnica polega na tym, że obiekty te tworzy programista – dodawanie i ściąganie obiektów ze starty nie jest zautomatyzowane.

Sterta jest bardziej chaotyczna, może posiadać luki (można zwolnić dowolny blok pamięci pozostawiając dziurę). Choć możliwa jest większa swoboda (np. rozszerzenie obszaru pamięci w tablicy dynamicznej) to może to wiązać się, z przeniesieniem bloku pamięci. W ogólności dostęp jest wolniejszy od obiektów ze stosu.

Posługując się przykładem stołu warsztatowego, jest to blat na którym sporo się dzieje – w różnych miejscach dokładane i przenoszone są materiały i narzędzia.

Więcej na temat zagrożeń używania pamięci dynamicznej w części kurs o tablicach znaków.

Dobrym podsumowaniem będzie analiza adresów zmiennych z przykładu:

int zm_glob_niezer = 12;
int zm_glob_zer = 0;
int zm_glob_niezainc;
static int zm_glob_niezer_static = 18;
static int zm_glob_zer_static = 0;

void setup() {
  int zm_lok_niezer = 33;
  int zm_lok_niezain;
  static int zm_lok_zer_stat = 6;
  static int zm_lok_niezain_stat;
  String *napis = new String("3333");
  
  Serial.begin(9600);
  Serial.print(F("0x")); Serial.print((int)&zm_glob_niezer, HEX); Serial.print(F(" - zm_glob_niezer, ")); Serial.println(zm_glob_niezer);
  Serial.print(F("0x")); Serial.print((int)&zm_glob_zer, HEX); Serial.print(F(" - zm_glob_zer, ")); Serial.println(zm_glob_zer);
  Serial.print(F("0x")); Serial.print((int)&zm_glob_niezainc, HEX); Serial.print(F(" - zm_glob_niezainc, ")); Serial.println(zm_glob_niezainc);
  Serial.print(F("0x")); Serial.print((int)&zm_glob_niezer_static, HEX); Serial.print(F(" - zm_glob_niezer_static, ")); Serial.println(zm_glob_niezer_static);
  Serial.print(F("0x")); Serial.print((int)&zm_glob_zer_static, HEX); Serial.print(F(" - zm_glob_zer_static, ")); Serial.println(zm_glob_zer_static);
  
  Serial.print(F("0x")); Serial.print((int)&zm_lok_niezer, HEX); Serial.print(F(" - zm_lok_niezer, ")); Serial.println(zm_lok_niezer);
  Serial.print(F("0x")); Serial.print((int)&zm_lok_niezain, HEX); Serial.print(F(" - zm_lok_niezain, ")); Serial.println(zm_lok_niezain);
  Serial.print(F("0x")); Serial.print((int)&zm_lok_zer_stat, HEX); Serial.print(F(" - zm_lok_zer_stat, ")); Serial.println(zm_lok_zer_stat);
  Serial.print(F("0x")); Serial.print((int)&zm_lok_niezain_stat, HEX); Serial.print(F(" - zm_lok_niezain_stat, ")); Serial.println(zm_lok_niezain_stat);
  
  Serial.print(F("0x")); Serial.print((int)napis, HEX); Serial.print(F(" - napis, ")); Serial.println(*napis);
}

void loop() {
}

W wyniku otrzymamy coś takiego:

0x100 - zm_glob_niezer, 12
0x1A4 - zm_glob_zer, 0
0x1A6 - zm_glob_niezainc, 0
0x102 - zm_glob_niezer_static, 18
0x1A8 - zm_glob_zer_static, 0
0x8F2 - zm_lok_niezer, 33
0x8F4 - zm_lok_niezain, -26879
0x104 - zm_lok_zer_stat, 6
0x1AA - zm_lok_niezain_stat, 0
0x25B - napis, 3333

Widzimy wyraźnie adresy zainicjowanych danych statycznych (wartości bliskie 0x100). Nieco większe wartości przyjmują dane z obszaru niezainicjowanych danych statycznych (.bss).

Następnie widoczny jest spory skok adresowania w przypadku zmiennych lokalnych umieszczonych na stosie (w tym tych niezainicjowanych), gdzie została odczytana losowa wartość. Na sam koniec została zaalokowana pamięć w obszarze sterty przy pomocy operatora new, które mają adresy większe od tych z .bss.

Do tego czym jest operator new jeszcze kiedyś wrócimy.

image.thumb.png.1ada5d0b19965582668b200634c9445a.png

Ląd na horyzoncie – gdzie te tablice?

Dygresja od dygresji ale bez tego nie da się omówić tematu 😉 Do czego to wszystko ma się przydać? W kolejnej części zostanie omówiony sposób tworzenia tablic (statycznych), ale tym razem ze świadomością co się dokładnie dzieje, a gdy dojdziemy do tematu alokacji dynamicznej, tworzenia obiektów to zrozumiemy dlaczego ta sterta jest trochę niebezpieczna.

Oczywiście, bez tego da się żyć, dlatego też pierwsza część jest typowo praktyczna. Sam przeżyłem bez znajomości tego tematu kilka lat, napisałem w tym czasie wiele programów, brałem udział nawet w konkurach z algorytmiki i coś wygrałem! Ale apetyt rośnie w miarę jedzenia i warto kiedyś pochylić się nad tym tematem 📚

 

Edytowano przez Gieneq
  • Lubię! 2
Link to post
Share on other sites

Kolejna część kursu, tym razem postanowiłem zagłębić się w temat pamięci SRAM. Myślę, że w tym temacie można jeszcze sporo napisać, ale ten stopień szczegółowości powinien umożliwić nabudowanie świadomości jak te 2 KiB pamięci są używane, co się właściwie dzieje ze zmiennymi, do czego jest specyfikator static.

  • Lubię! 1
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.