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.