Czasami na spotkaniu może się zdarzyć, że pojawia się pytanie, które z pozoru wydaje się łatwe, jednakże rodzi szereg dalszych zapytań. Tak też miało miejsce w omawianej sytuacji, kiedy to z pozornie prostej kwestii dotyczącej słowa kluczowego w języku C, "Static", wyłoniła się cała seria pytań.
A oto nasza lista:
- Gdzie przechowywana jest niezainicjalizowana zmienna statyczna?
- Gdzie przechowywana jest zainicjalizowana zmienna statyczna?
- Kiedy zmienna statyczna jest inicjalizowana i jaką wartością?
- Gdzie przechowywana jest stała statyczna?
- Kiedy stała statyczna jest inicjalizowana i jaką wartością?
- Gdzie przechowywana jest zmienna statyczna funkcji?
- Kiedy zmienna statyczna funkcji jest inicjalizowana i jaką wartością?
- Gdzie przechowywana jest stała statyczna funkcji?
- Kiedy stała statyczna funkcji jest inicjalizowana i jaką wartością?
- Czy zmienną statyczną funkcji można zainicjalizować argumentem funkcji?
BONUS -> Co z tablicą znaków i wskaźnikiem na tablicę znaków?
Środowisko
Do zaprezentowania wyników posłużyłem się poniższym środowiskiem. Sama platforma uruchomieniowa nie powinna mieć to większego znaczenia dla zrozumienia specyfiki wykorzystania danych statycznych w środowisku embedded.
- Target: STM32WB55
- IDE: STM32CubeIDE
- Toolchain (kompilator, linker itp) : arm-none-eabi w wersji 10.3-2021.10 dostarczony razem z IDE
Kod był kompilowany z flagami:
- ffunction-section - przydziela każdej funkcji osobną sekcję w pamięci
- fdata-section - przydziela wszystkim danym osobne sekcje w pamieci
- g3 - włączenie najwyższego poziomu debugowania
- O0 - wyłączenie optymalizacji
Powyższe flagi pozwolą nam łatwiej zlokalizować nasze dane statyczne w pliku z mapą pamieci oraz ułatwią poruszanie się po kodzie źródłowym i analizie podczas debugowania.
Wśród flag linkera zastosowałem:
- Wl,-Map=path_to_map_file flaga ta włącza generację plików z mapą pamieci niezbędną nam do odszukania poszczególnych danych;
Kod bazowy
Pierwotna postać pliku main.c ma postać jak poniżej i będzie wykorzystana w każdym przykładzie jako punkt startowy.
Jak widać poza inicjalizacją platformy przy użyciu funkcji platform_init() mamy wyłącznie pętle nieskończoną. Funkcja platform_init() odpowiada za inicjalizację naszej platformy sprzętowej czyli mikrokontrolera stm32wb55.
Dla tak przygotowanego kodu zapisałem w osobnym katalogu plik mapy pamięci (tzw. *.map file) oraz rozmiary poszczególnych sekcji pamięci. Przydadzą się one w dalszym etapie jako punkt odniesienia aby przedstawiać różnice jakie wprowadza użycie danych statycznych.
Już teraz patrząc na adresy i znając nasz mikrokontroler możemy powiedzieć, w jakiej pamięci znajdują się poszczególne sekcje:
Realizacja
1 . Gdzie przechowywana jest niezainicjalizowana zmienna statyczna?
W pierwszej kolejności dodałem niezainicjalizowaną zmienną statyczną do naszego pliku main.c i porównałem rozmiary sekcji oraz pliki *.map (po lewej postać bazowa, po prawej wynik dla naszego nowego kodu).
Porównanie rozmiarów poszczególnych sekcji:
Porównanie plików *.map:
Z porównania rozmiarów sekcji widzimy, że dodanie jednej zmiennej statycznej w pliku main.c spowodowało zwiększenie się sekcji .bss o 4 bajty oraz sekcji ._user_heap_stack o 4 bajty.
Dzięki plikom *.map możemy stwierdzić, że nasza nowa niezainicjalizowana zmienna wylądowała w sekcji .bss. a zwiększenie sekcji ._user_heap_stack o 4 bajty spowodowane jest wyrównaniem danych do 8 bajtów (tzw. alignment). Gdybym zdefiniował dwie zmienne statyczne to obie wylądowałyby w sekcji .bss a sekcja ._user_heap_stack powróciłaby do swojego oryginalnego rozmiaru.
Czym jest sekcja .bss i sekcja ._user_heap_stack?
Obie te sekcje znajdują się w pamięci RAM tak wiec aby dobrze odpowiedzieć na to pytanie możemy zajrzeć do skryptu linkera (poniżej fragment przedstawiający skrypt linkera).Poniższy zapis oznacza nie mniej nie więcej jak to, że dla danych w tych sekcjach zarezerwowana została przestrzeń w pamięci RAM1.
Zwróćmy uwagę na linijkę 1 i 17
- .bss - to sekcja przechowująca niezainicjalizowane zmienne (zarówno globalne jak i statyczne)
- ._user_heap_stack to sekcja stworzona na potrzeby pilnowania wystarczającej ilość pamięci RAM na potrzeby stosu (stack) oraz sterty (heap).
2 . Gdzie przechowywana jest zainicjalizowana zmienna statyczna?
Do tego punktu podchodzimy jak do poprzedniego. Dodajemy wyłącznie wartość inicjalizacyjną do zmiennej statycznej i obserwujemy co się zmieni w rozmiarach sekcji oraz pliku *.map.
Porównanie rozmiarów poszczególnych sekcji:
Porównanie plików *.map:
Z powyższego wynika, że nasza nowa zainicjalizowana zmienna wylądowała w sekcji pamięci .data zajmując tam 4 bajty. Tak jak poprzednio z racji wyrównania pamięci do 8 bajtów w sekcji ._user_heap_stack pojawił się dodatkowy obszar 4 bajtów.
Czym jest sekcja .data?
Sekcję ._user_heap_stack opisałem już wcześniej więc skupię się na sekcji .data.
Ponownie zaglądam do skryptu linkera i znajduję interesujący mnie fragment. Jak widać w opisie do tej sekcji lądują zmienne posiadające wartość inicjalizacyjną.
3 . Gdzie przechowywana jest niezainicjalizowana zmienna statyczna?
W pierwszej kolejności kod:
We wcześniejszych przykładach, gdy prezentowałem fragmenty skryptu linkera przy klamrach zamykających daną sekcję można znaleźć oznaczenia >RAM1
lub >RAM1 AT> FLASH
- >RAM1 - w pamięci RAM zarezerwowano przestrzeń dla zmiennych z danej sekcji.
- >RAM1 AT> FLASH - w pamięci RAM zarezerwowano przestrzeń dla zmiennych z danej sekcji. Ponadto ma ona swój odpowiednik w pamięci FLASH
I co nam to daje?
Podczas kompilacji i linkowania w pamieci RAM zarezerwowana będzie przestrzeń dla sekcji .bss (ze zmiennymi niezainicjalizowanymi) jak i dla sekcji .data (zmienne zainicjalizowane). Dodatkowo w pamięci Flash (pamięć programu) umieszczone zostaną wartości użyte do inicjalizacji zmiennych z sekcji .data.
Podczas uruchamiania naszego oprogramowania zanim wywołana zostanie funkcja main() dzieje się całkiem sporo. Wszystko zaczyna sie od punktu startowego całego firmware zdefiniowanego w skrypcie linkera. Określa on miejsce od którego rozpocznie się wykonywanie naszej aplikacji.
Wiem już, że aplikacja rozpoczyna wykonywanie od funkcji Reset_Handler. Jej implementację zazwyczaj znajdziemy w pliku startup.
Poza ustawieniem wskaźnika stosu wykonywane są kolejno:
- SystemInit - Inicjalizacja naszego mikrokontrolera (tylko najważniejsze z najważniejszych ustawień)
- INIT_DATA - Inicjalizacja sekcji .data w pamieci RAM danymi z pamięci FLASH
- INIT_BSS - Inicjalizacja sekcji .bss w pamieci RAM. Domyślnie jest to wypełnienie całej sekcji wartościami 0.
- __libc_init_array - Wywołanie statycznych konstruktorów
- main - Skok do głównej funkcji naszego programu
Aby to lepiej zobrazować posłuże się małym rysunkiem i kilkoma krokami z sesji debugowej kodu.
Przed inicjalizacją sekcji .data:
Po inicjalizacji sekcji .data, przed inicjalizacją sekcji .bss:
Po inicjalizacji sekcji .data oraz .bss:
Podsumowując to pytanie, zarówno statyczne zmienne zainicjalizowane jak i niezainicjalizowane są inicjalizowane zgodnie z plikiem startup. Dane do zainicjalizowanej zmiennej statycznej kopiowane są z pamięci Flash, a do niezainicjalizowanych zmiennych statycznych przypisywane są domyślnie zera (ale można to zmienić). Przypisywanie wartości 0x00 do niezainicjalizowanych zmiennych statycznych jest zgodne ze standardem ISO 9899 sekcja 6.7.9.
4 . Gdzie przechowywana jest stała statyczna?
Czas iść dalej w głąb lasu i zajrzeć, co się stanie, jeśli zamiast zmiennymi zajmiemy się stałymi. Początkowa baza kodu jest identyczna jak wcześniej. Aby trochę skrócić całość zajmę się teraz stałymi zarówno tymi zainicjalizowanymi na początku jak i niezainicjalizowanymi. Pominę fakt, że osobiście nie widzę żadnego powodu aby ktokolwiek potrzebował niezainicjalizowaną stała. Kompilator pozwala nam na stworzenie takiej więc ją tutaj sprawdzimy.
Porównanie rozmiarów poszczególnych sekcji:
Porównanie plików *.map:
Jak widać na powyższym zmienił nam się rozmiar sekcji .rodata o 8 bajtów. Nie trudno się domyśleć, że to właśnie do tej sekcji trafiły obie nasze stałe. .rodata jest sekcją w pamięci Flash przechowująca stałe dane przeznaczone wyłącznie do odczytu (Read only data).
5 . Kiedy stała statyczna jest inicjalizowana i jaką wartością?
Wiemy już, że stałe przechowywane są w pamieci Flash w sekcji .rodata i są one wyłącznie do odczytu. Znając adresy poszczególnych stałych możemy odczytać ich wartości (np. w trakcie sesji debugowej wykorzystując widok Memory).
W naszym przykładzie:
- Pod adresem 0x08002804 znajduje sie nasza stała niezainicjalizowana (z wartością 0)
- Pod adresem 0x08002808 znajduje się nasza stała zainicjalizowana (z wartością 0x64 czyli 100)
Stałe te są inicjalizowane w trakcie kompilacji na podstawie przypisanych wartości i nie ulegają zmienie w trakcie trwania programu (choć i na to są sposoby). Stałe niezainicjalizowane posiadają wartość 0, stałe zainicjalizowane posiadają wartość jaka została do nich przypisana podczas definicji.
6 . Gdzie przechowywana jest zmienna statyczna funkcji?
Omówiliśmy już zmienne i stałe, które są zdefiniowane globalnie dla konkretnej jednostki translacyjnej, teraz zobaczmy, jak będą się zachowywały dane zdefiniowane lokalnie w obrębie pojedynczej funkcji.Na potrzeby tego fragmentu przygotowałem nowy kod bazowy. Tak prosty jak to tylko możliwe.
Dodałem jedną funkcję foo(int const) przyjmującą jeden argument i wywołałem ją z funkcji main() ze stałą wartością.
Do tak przygotowanego kodu bazowego dodałem niezainicjalizowaną zmienną statyczną w obrębie funkcji foo() i porównałem rozmiary sekcji oraz pliki *.map.
Porównanie rozmiarów poszczególnych sekcji:
Porównanie plików *.map:
Szybko zauważamy, że zmienna ta wylądowała w sekcji .bss pamięci RAM. Przy okazji ponownie zwiększyła się sekcja ._user_heap_stack ze względu na wyrównanie do 8 bajtów. Dokładnie tak samo jak to się działo przy zmiennych zdefiniowanych w obrębie pliku.
Co jeśli zmienną statyczną funkcji zainicjalizujemy konkretną wartością?
Porównanie rozmiarów poszczególnych sekcji:
Porównanie plików *.map:
Tutaj identycznie jak przy statycznych zmiennych pliku, nasza lokalna statyczna zmienna funkcji wylądowała w sekcji .data.
7 . Kiedy zmienna statyczna funkcji jest inicjalizowana i jaką wartością?
Zmienne lokalne w funkcji, niezależnie od tego, czy są inicjalizowane wartością początkową, czy nie, trafiają do tych samych sekcji pamięci, co zmienne statyczne zdefiniowane globalnie w pliku.
W związku z tym możemy stwierdzić, że inicjalizują się identycznie podczas startupu.
- Zmienna z wartością początkową jest inicjalizowana daną z pamięci flash;
- Zmienna bez wartości zostaje zapisana wartością zero;
NOTE: Tutaj należy wspomnieć, że w języku C++ zmienne statyczne funkcji są inicjalizowane przy pierwszym wywołaniu funkcji.
Więcej można poczytać tutaj.
8. Gdzie przechowywana jest stała statyczna funkcji?
Kolejny krok to stała statyczna funkcji. Dla uproszczenia dodałem dwie stałe do naszej funkcji foo i przeanalizowałem rozmiary sekcji i pliki *.map.
Przypomnę, że nie nie widzę powodu aby ktokolwiek potrzebował stałą niezainicjalizowaną w swoim projekcie. Kompilator pozwala taką stworzyć, więc ją sprawdzamy.
Porównanie rozmiarów poszczególnych sekcji:
Porównanie plików *.map:
Tutaj nasze stałe również zachowują się identycznie jak w przypadku stałych statycznych zdefiniowanych globalnie dla pliku i lądują w pamieci Flash w sektorze .rodata (sekcja wyłącznie do odczytu).
9. Kiedy stała statyczna funkcji jest inicializowana i jaką wartością?
Stałe nie są inicjalizowane. Wartości są im przypisane w momencie kompilacji i nie zmieniają się w czasie pracy programu. Sprawdzając w pamięci adresy naszych stałych możemy się upewnić jakie przechowują wartości.
- Pod adresem 0x0800281c umieszczona jest nasza zainicjalizowana stała i przechowuje wartość 0x25
- Pod adresem 0x08002820 umieszczona jest stała niezainicjalizowana i przechowuje wartość 0x00
10. Czy zmienną statyczną funkcji można zainicjalizować argumentem funkcji?
Na koniec zostawiłem sobie pytanie co do którego sam nie miałem pewności jak powinienem odpowiedzieć. Skoro zmienna statyczna funkcji może mieć wartość 0 (jeśli jest niezainicjalizowana) bądź wartość z góry ustaloną (kopiowaną z pamięci Flash) to czy możliwe jest zainicjalizowanie takiej zmiennej wartością przekazaną do funkcji poprzez argument?
Opis brzmi strasznie ale w kodzie nie wygląda to tak źle.
Otóż w tym przypadku kompilator zgłosi błąd domagając się stałej (znanej podczas kompilacji) wartości inicjalizującej dla zmiennej statycznej.
Jest to również zgodne ze standardem ISO 9899 Sekcja 6.7.9 Initialization.
"All the expressions in an initializer for an object that has static or thread storage duration shall be constant expressions or string literals."
Tak więc wszystkie wartości używane do inicjalizacji muszą być stałym wyrażeniem, czyli wartością znaną w czasie kompilacji.
BONUS -> Co z tablicą znaków i wskaźnikiem na tablicę znaków?
A co z wskaźnikiem, który wskazuje na ciąg znaków "string" przechowywany w stałej części pamięci?
A co z tablicą, która zawiera ciąg znaków "string"?
Również polecam wrzucić sobie taki lub podobny kawałek kodu i podejrzeć zawartość plików *.map przeanalizować co się gdzie znajduje oraz jak się zachowuje po uruchomieniu (wystarczy sam startup).
W mapie pamięci znajdziemy:
- 0x20000004 → __dso_handle → obiekt używany przy wykonywaniu globalnych destruktorów (o nim innym razem)
- 0x20000008 → glob_string_ptr → czyli nasz wskaźnik na napis (rozmiar wskaźnika 4 bajty)
- 0x2000000c → glob_string_arr → czyli tablicę 16 znaków (rozmiar 16 bajtów = 15 znaków i terminator ‘\0’)
Pamiętamy że:
- sekcja .data, w której znajdują się nasze obiekty to tylko zarezerwowany obszar pamięci RAM dla zainicjalizowanych danych/zmiennych
- sekcja .data ma swój odpowiednik z danymi inicjalizujacymi w pamięci Flash (u nas zaczyna się pod adresem wskazywanym przez _sidata czyli 0x08002924
- dane inicjalizacyjne są kopiowane z pamieci Flash do sekcji .data podczas wykonywania startupu
Skoro wiemy, że dane spod adresu 0x08002924 są kopiowane do pamięci RAM do sekcji .data pod adres 0x20000004. Podejrzyjmy co się tam znajduje:
Adresy po lewej podawane są jako offset do adresu 0x80000000.
Nie bez powodu zaznaczyłem taki obszar pamieci flash. Przeanalizujmy krok po kroku co tutaj mamy:
- Spod adresu 0x08002924 kopiujemy dane 0000 0000 b427 0008 676c 6f62 5f73 7472 696e 675f 6172 7200 …… pod adres 0x20000004
- W ten oto sposób nasze kolejne zmienne dostają wartości (pamietając o little endian):
0x20000004 → __dso_handle → 0x00000000
0x20000008 → glob_string_ptr → 0x080027b4 → 676c 6f62 5f73 7472 696e 675f 7074 7200
0x2000000c → glob_string_arr → 676c 6f62 5f73 7472 696e 675f 6172 7200
Jak widać:
- __dso_handle jest wyzerowany = NULL
- wskaźnik glob_string_ptr dostał adres do napisu “glob_string_ptr” znajdującego się pamięci Flash.
- tablica glob_string_arr została zainicjalizowana napisem “glob_string_arr”. Napis ten znajduje się teraz w pamięci RAM
Dla pewności odszukajmy w mapie pamieci również wpisu z adresem 0x080027b4
Widzimy tutaj, że nasz obszar pamięci .rodata przechowujący stałe dane zwiększył się o 16 bajtów i pochodzą one z pliku obiektowego main.o. Nie trudno zgadnąć, że wylądował tutaj napis, na który wskazuje wskaźnik glob_string_ptr co chwilę wcześniej udowodniliśmy przeglądając binarkę
W kodzie jaki przygotowaliśmy mamy także pobieranie rozmiarów obu tych obiektów.
Jak nie trudno zgadnąć (znając już zawartość pliku *.map):
- Rozmiar glob_string_ptr wynosi 4 bajty (rozmiar wskaźnika)
- Rozmiar glob_string_arr wynowsi 16 bajtów (tyle ile jest znaków + 1 dla termiantora ‘\0')
Dodatkowo:
- napis zawarrty w obiekcie glob_string_arr możemy bez przeszkód zmieniać, gdyż cały obiekt i jego zawartość znajduje się w pamieci RAM
- napisu zawartego w obiektu glob_string_ptr nie możemy zmodyfikować gdyż znajduje się pamieci Flash. Jedyne co możemy to zmodyfikować adres wskaźnika
Kopiowanie danych inicjalizacyjnych z pamieci Flash do sekcji .data w pamięci RAM w startupie.
Przed:
Po:
Rozmiary obiektów oraz wpływ modyfikacji 1 znaku w obu obiektach. Jak widać zmiana nastąpiła w wyłącznie jednym obiekcie glob_string_arr
Podsumowanie
Z dziesiejszego artykułu wyciągnąć można kilka kluczowych informacji:
- Niezainicjalizowane zmienne statyczne (globalne i lokalne) mają swoje miejsce w sekcji .bss pamieci RAM;
- Zmienne znajdujące się w sekcji .bss domyślnie inicjalizowane są podczas startupu wartością 0x00 ale można to zmienić odpowiednio modyfikując plik startup;
- Zainicjaizowane zmienne statyczne (globalne i lokalne) mają swoje miejsce zarezerowane w sekcji .data w pamieci RAM, a wartości jakimi są inicjalizowane znajdują się w pamieci Flash;
- Podczas startupu wartości inicjalizujące są kopiowane z pamieci Flash do sekcji .data w pamieci RAM;
- Stałe statyczne (zainicjalizowane i niezainicjalizowane) (globalne i lokalne) mają swoje miejsce w sekcji .rodata (tylko do odczytu);
- Niezainicjalializowane stałe statyczne oraz zmienne globalne zgodnie ze standardem są inicjalizowane wartością 0x00. Pomijamy fakt, że raczej nikomu nie jest potrzebna stała niezainicjalizowana;
- Argumentem funkcji nie można zainicjalizować bezpośrednio zmiennej statycznej. Do inicjalizacji musi być wykorzystana wartość znana w czasie kompilacji;
O czym należy pamiętać po przeczytaniu tego artykułu:
- Analizowałem tutaj kod wyłącznie w języku C. Jeśli chodzi o C++ to tam są pewne odstępstwa od przedstawionych wyników i wniosków
- Mam całkowicie wyłączoną optymalizację. Przy włączonej optymalizacji (w zależności od poziomu) toolchain potrafi wprowadzić znaczne modyfikacje i uproszczenia;
REFERENCJE