Dlaczego Stringi w Arduino są złe?
Kiedy komunikujemy się z naszym Arduino za pomocą np. portu szeregowego przez konwerter USB-TTL, płytkę Arduino czy też moduł Bluetooth lub np. wyświetlamy jakieś dane na ekranie LCD to z reguły używamy do tego celu napisów pod postacią obiektów String. Czy jednak kiedyś zastanawialiście się jak ta klasa tak bardzo ułatwiająca życie działa pod spodem? Niestety w tym przypadku wygoda okupiona jest wydajnością. W tym artykule przedstawię Wam pokrótce jak działa klasa String, dlaczego to co robi jest złe dla wydajności i przedstawię kilka funkcji ze standardowej biblioteki C do operowania na odpowiednich tablicach charów (C stringach).
Stos i sterta
Aby dobrze wytłumaczyć działanie omawianej klasy trzeba najpierw krótko opowiedzieć o strukturze pamięci Arduino. Pomyślmy o pamięci RAM jak o osi liczbowej w jednym wymiarze. Zaczyna się ona w punkcie 0, a kończy na wartości 2047 (w przypadku Arduino Uno mamy 2kB RAMu). W momencie uruchomienia procesora od punktu 0 inicjowane są statyczne dane takie jak stałe, zmienne globalne itp. Po nich do pamięci wczytywany jest już właściwy program i wtedy właśnie zaczyna się odkładanie danych na stos. Załóżmy że w funkcji loop() wykonujemy takie operacje:
int mojaLiczba = 10; char mojZnak = 'a';
W tym momencie na stosie (ang. stack) alokowane są 2 bajty dla zmiennej int, następnie bajt dla chara. Wskaźnik na naszej osi idzie w górę w miarę alokowania kolejnych bajtów. Po wyjściu z funkcji wszystkie dane zaalokowane przez funkcję są czyszczone w kolejności odwrotnej (czyli najpierw ze stosu ściągamy bajt chara, a później 2 bajty inta).
W przypadku alokacji danych na stercie (ang. heap) zaczynamy z drugiej storny osi. Ustawiamy nasz wskaźnik w pozycji 2047 i wczytujemy taką zmienną:
String mojTekst = "To jest tekst";
W tym momencie procesor sprawdza ile miejsca potrzebuje. W tym przypadku jest to 14 bajtów (13 znaków + znak końca ciągu znakowego \0). Alokuje więc 14 bajtów na stercie i tam trzyma dane.
Analogiczny ciąg możemy zapisać w postaci C stringa jako
char cString[] = "To jest tekst";
Ktoś zapyta co za różnica? I tu i tu alokujemy 14 bajtów, a Stringiem z Arduino dużo łatwiej manipulować, porównywać go itp.
Nasz problem zacznie się dopiero wtedy, kiedy wprowadzimy więcej stringów i zaczniemy je modyfikować.
Weźmy np. taki kod:
char hello1[20] = "Hello Arduino"; char hello2[20]; strcpy(hello2, hello1); strcat(hello2, " Uno");
w porównaniu z analogicznym kodem opartym o String:
String hello1 = "Hello Arduino"; hello2 = hello1 + " Uno";
Na pierwszy rzut oka od razu widać, że kod z użyciem Stringa jest dużo łatwiejszy do napisania i czytelniejszy. Sprawdźmy co stanie się „pod maską”.
W przypadku pierwszego kodu zostanie zaalokowana na stosie pamięć wielkości 40 bajtów (20 bajtów na hello1 oraz 20 bajtów na hello2). Po wyjściu z funkcji, która używa tych stringów zostaną one zdjęte po kolei ze stosu i wrócimy w miejsce, gdzie zaczęliśmy budować stos. Zanim to nastąpi możemy sobie dopisać jeszcze kilka znaków do hello1, gdyż mamy zapas.
strcat(hello1, "!!!");
Teraz w hello1 będzie Hello Arduino!!!, a w hello2 – Hello Arduino Uno
W przypadku drugiego kodu alokujemy na stercie 14 bajtów na hello1, a następnie (na naszej osi w miejscu zaraz po zakończeniu 14 bajtu) 18 bajtów na hello2. Co się stanie, kiedy zechcemy do hello1 dopisać „!!!”?
hello1 += "!!!";
Z pozomiu kodu wygląda bardzo niewinnie i elegancko. Jednak w pamięci nie jest już tak różowo. Dla hello1 mamy zaalokowane 14 bajtów sterty, a zaraz po nich są jakieś inne dane. Nie możemy tam nic dopisać. Jedyną opcją jest zwolnienie tych 14 bajtów, odnalezienie w stercie wolnych 17 bajtów (dla ciągu „Hello Arduino!!!”), zaalokowanie ich i wpisanie tam danych. W ten sposób pierwsze 14 bajtów naszej sterty jest puste, a po tej pustej przestrzeni dopiero trafimy na 18 bajtów hello2 i 17 bajtów hello1. Co dzieje się z tą pustą przestrzenią? Ano w przypadku kolejnych alokacji kontroler pamięci będzie próbował wcisnąć dane w tę lukę. Co jednak jesli okaże się że wypełniliśmy 13 bajtów tej luki, a teraz potrzebujemy dwóch? Musimy zapisać je w pierwszym wolnym miejscu, które jest w pamięci po hello1. W ten sposób została nam luka 1 bajta, której możemy nie wykorzystać. To powoli prowadzi nas do fragmentacji sterty.
Fragmentacja sterty
Wykonując sporą ilość konkatenacji Stringów zmuszamy procesor do nieustannego realokowania naszych danych. Nie dość, że zajmuje to czas (trzeba znaleźć wolne miejsce i przepisać tam cały tekst) to jeszcze powoduje wyżej opisane dziury. W ten sposób poprostu marnujemy pamięć. Ktoś powie że kilka straconych bajtów to jeszcze nis strasznego. Należy jednak wziąć poprawkę na fakt, że takich luk może być znacznie więcej oraz mogą zajmować większą objętość. Przy ilości 2kB RAMu, któym dysponuje Arduino raczej nie możemy sobie pozwalać na takie zabawy.
Kabooom
Jeżeli nie przekonało Was to co napisałem powyżej to przedstawiam kolejny argument przeciwko stosowaniu Stringów. Na początku opisywałem, że stos na naszej osi zaczynam od dołu i lecimy w górę. Na stercie alokujemy od góry i szukamy wolnejgo miejsca w dół. Zapisujemy więc do tej samej pamięci tylko z różnych stron. Im więcej danych będizemy przechowywali (i im więcej miejsca stracimy przez fragmentację) tym szybciej stos i sterta się spotkają, a kiedy to nastąpi i spróbujemy dopisać coś do którejść z tych struktur nastapi katastrofa – fatalny wyjątek, który spowoduje reset mikrokontrolera.
Co robić, jak żyć?
Zamiast korzystać z klasy String możemy w miarę możliwości operować na C stringach. Pomocne przy tym okażą się takie funkcje jak:
- strcpy – kopiowanie stringów
- strcmp – porównywanie stringów
- strncmp – porównywanie n początkowych znaków stringów
- strcat – konkatenacja stringów (łączenie)
- strlen – obliczenie długości stringów
To kilka najważniejszych funkcji wraz z odnośnikami do dokumentacji. Wszystkie funkcje dotyczące operacji na cstringach znajdziemy tutaj.
Prosto i przystępnie. Nawet ja zrozumiałem różnice. Keksowe czy można jakoś podglądać na żywo stos, byc moze w jakimś emulatorze?
Samo środowisko Arduino IDE nie oferuje takiej możliwości, ale bardzo możliwe że mając jakiś oficjalny programator Atmela i korzystając z Atmel Studio możnaby w debuggerze podglądać całą pamięć na żywo.
Poczytałem jeszcze trochę o organizacji pamięci i jakbyś chciał to wgraj sobie taki szkic: https://pastebin.com/XxxCLXYP
Zaznaczyłem tam komentarzami co zrobić i na co zwrócić uwagę. Jak łatwo zauważyć w większości w RAMie są jakieś śmieci nic nie znaczące, więc ciężko będzie samemu stwierdzić które obszary są używane, a które nie. Objaśnienie co oznaczają te zmienne brkval itp. masz tutaj: http://www.nongnu.org/avr-libc/user-manual/malloc.html
W artykule pomyliłem gdzie zaczyna się heap a gdzie stack. W rzeczywistości jest odwrotnie (heap zaraz po stałych, a stack na końcu RAMu), ale myślę że to nie przeszkadza w zrozumieniu problemu :D
Serdeczne dzięki za tak wyczerpującą informację, jak mam być szczery to się doskonale nadaje na kolejny wpis żeby nie umknęło potomnym :). W weekend popatrzę jak to działa. Przy okazji jest ten cały WireDebug, który opisywali na hackaday.com. Pozwala podejrzeć stan pamięci, rejestry itd.
Artykuł dobry, pamięć na Arduino rzeczywiście należy oszczędzać i używanie tablic zamiast Stringów w tym pomaga, ale w wielu przypadkach ludziom używającym Arduino nie zależy na takich drobnych zyskach. Dostępne są mikrokontrolery z większą ilością pamięci, są też coraz tańsze Raspberry Pi i inne ARM-y z pełnym systemem operacyjnym i gigabajtami RAM.
Przyznam szczerze, że jeszcze całkiem niedawno sam większośc projektów na Arduino robiłem na Stringach i do tej pory niektóre mniej wymagające w ten sposób robię bo jest zwyczajnie prościej ;) Nawet niektóre moje stare, ale dość rozbudowane projekty działają póki co bez błędów mimo, że operują na Stringach. Warto jednak ludzi uświadomić co dzieje się pod spodem, bo na pewno niejednemu użytkownikowi zdarzyło się kiedyś tak, że naprodukował w kodzie Stringów, a potem coś nie działało i nie wiadomo było gdzie jest źródło problemu.
Arduino wiele rzeczy „ukrywa”. Dlatego zdobyło popularność.
Podobnie NodeMCU możliwie upraszcza programowanie, nawet kosztem optymalizacji.
To miała być odpowiedź do @Kamil 2018/04/26 o 14:26″ :)