cvscpp – alokacja pamięci – pierwsze podejście

Zagadnienia związane z dynamiczną alokacją pamięci poruszane w tym poście zostały ograniczone do rozwiązań dostępnych w standardowych bibliotekach (funkcja malloc i free oraz operator new i delete). Dodatkowo analiza szybkości przydziału pamięci jest nie pełna, gdyż pełne przebadanie operacji przydziału pamięci wymagają o wiele szerszego spojrzenia i zagłębienia się w temat (m.in czas scalania wolnych bloków, czas alokacji przy dużej fragmentacji bloków pamięci).

 Analiza alokacji pamięci

Przechodzimy od razu do przedstawienia wyników. Z uwagi na to, że korzystamy z już przekompilowanej biblioteki standardowej opcje optymalizacji nie wpływają na wyniki.

Dynamiczna alokacja pamięci z wykorzystaniem biblioteki standardowej komilatora CodeBench i MDK-ARM

Spostrzeżenia:

  • Alokacja dynamiczna pamięci dla kompilacji MDK-ARM jest zdecydowanie szybsza niż kompilatora CodeBench (także pod względem zajętości stosu)
  • Użycie operatora new i delete zgodnie z przewidywaniem zwiększa czas przydziału pamięci, ale przyrost ten jest stosunkowo niewielki. W obu kompilatorach narzut operatora new to zaledwie ok 50 taktów zegara – a więc nie ma tu drastycznego spadku wydajności c++ w porównaniu z c.
  • Z powyższego rysunku tego nie widać, ale jest spora rozpiętość rozmiaru kodu wynikowego między kompilatorem CodeBench a MDK-ARM. Plik wynikowy (z użyciem operatora new i całym framework’iem cvscpp) dla:
    • MDK-ARM ma rozmiar  6656 bajtów
    • CodeBench ma rozmiar 85272 bajtów !!!
      1. Kompilacja projektu z wykorzystaniem funkcji malloc i free w języku c ma rozmiar kodu wynikowego 8988 bajtów (przyrost kodu ok 2200 bajtów na implementacje funkcji przydziału pamięci) – wartość uzyskana z analizy pliku cvscpp.map)
      2. Kompilacja projektu z wykorzystaniem funkcji malloc i free w języku c++ (za pomocą g++) bez wyłączenia obsługi wyjątków daje rozmiar kodu wynikowego 14560. Kod ten różni się w stosunku do punktu 1 o narzut wprowadzony na mechanizmy przygotowujące do obsługi wyjątków (Unwind)
      3. Kompilacja projektu z wykorzystaniem funkcji malloc i free w języku c++ (za pomocą g++) z wyłączoną obsługą wyjątków (flagi -fno-rtii  -fno-exceptions) daje rozmiar identyczny jak w punkcie 2.
      4. Kompilacja projektu z wykorzystaniem operatorów new i delete daje rozmiar 85272 bajtów, niezależnie czy obsługa przerwań jest włączona, czy wyłączona. Prawdopodobnie tak się dzieje gdyż biblioteka standardowa libstdc++ została domyślnie skompilowana z obsługą wyjątków. Nie wiem teraz, czy można jakość obejść ten problem (nie przeciążając operatorów new i delete). Może trzeba przekompilować bibliotekę libstdc++?

Implementacja funkcji testującej przydział pamięci

#define TABLE_SIZE	16

static char *pMemTable[TABLE_SIZE];
static int   allocated_package[TABLE_SIZE] = {8, 8, 16, 256, 42, 8, 87, 12, 234, 45, 12, 78, 45, 345, 789, 34};
static int   index_table;

static void MemTableFree()
{
	for (int i = 0; i < TABLE_SIZE; i++)
	 	if (pMemTable[i])
			free(pMemTable[i]);
}

typedef struct
{
	int p0;
	int p1;
	int p2;
} S_TEST_MALLOC_STRUCT;

static int TestMalloc_MallocConst(int n)
{
	char* pMem = (char*) malloc(n);

	free(pMem);

	return n;
}
static int TestMalloc_MallocTable(int n)
{
	if (pMemTable[index_table])
		free(pMemTable[index_table]);

	pMemTable[index_table] = (char*) malloc(allocated_package[index_table]);

	index_table++;
	index_table &= TABLE_SIZE - 1;

	return n;
}

static int TestMalloc_MallocStruct(int n)
{
	S_TEST_MALLOC_STRUCT* pStruct = (S_TEST_MALLOC_STRUCT*) malloc(sizeof(S_TEST_MALLOC_STRUCT));

	free(pStruct);

	return n;
}

#if (TEST_CORE_MALLOC_NEW == 1)

class C_TestMallocClass
{
	int p0;
	int p1;
	int p2;

public:
	C_TestMallocClass()
	{
		p0 = 0;
		p1 = 0;
		p2 = 0;
	}
};

static int TestMalloc_NewConst(int n)
{
	char* pMem = new char[n];

	delete[] pMem;

	return n;
}

static int TestMalloc_NewTable(int n)
{
	if (pMemTable[index_table])
		delete[] pMemTable[index_table];

	pMemTable[index_table] = new char[allocated_package[index_table]];

	index_table++;
	index_table &= TABLE_SIZE - 1;

	return n;
}

static int TestMalloc_NewStruct(int n)
{
	C_TestMallocClass* pClass = new C_TestMallocClass();

	delete pClass;

	return n;
}
#endif

/*!--------------------------------------------------------------------------------------------------------//
\brief		Testing SysCtlDelay method
//---------------------------------------------------------------------------------------------------------*/
void TestMalloc_RunCpp(void)
{
	BenchCase_Run_int("c_malloc_const    8", TestMalloc_MallocConst,    8,  100000);
	BenchCase_Run_int("c_malloc_const   64", TestMalloc_MallocConst,   64,  100000);
	BenchCase_Run_int("c_malloc_const 1024", TestMalloc_MallocConst, 1024,  100000);
	BenchCase_Run_int("c_malloc_table",      TestMalloc_MallocTable,    0,  100000);
	BenchCase_Run_int("c_malloc_struct",     TestMalloc_MallocStruct,   0,  100000);

	MemTableFree();

#if (TEST_CORE_MALLOC_NEW == 1)
	BenchCase_Run_int("new_const    8", TestMalloc_NewConst,    8,  100000);
	BenchCase_Run_int("new_const   64", TestMalloc_NewConst,   64,  100000);
	BenchCase_Run_int("new_const 1024", TestMalloc_NewConst, 1024,  100000);
	BenchCase_Run_int("new_table",      TestMalloc_NewTable,    0,  100000);
	BenchCase_Run_int("new_struct",     TestMalloc_NewStruct,   0,  100000);
#endif
}

cvscpp – Kopiowanie bloków pamięci (memcpy)

Szybkie wprowadzenie

Jedną z podstawowych operacji wykonywanych przez mikroprocesory jest kopiowanie bloków pamięci. Najprościej jest wykorzystać funkcję memcpy z biblioteki standardowej C (obszary kopiowanych adresów nie mogą nachodzić na siebie).

Ze względu na to, że ustawienia optymalizacji kompilatora nie wpływa na optymalizacje dostarczonych bibliotek (nie kompilujemy ich) upraszcza to porównanie kompilatora CodeBench z RVMDK.

Przed przystąpieniem do analizy szybkości kopiowania kilka dostępnych i znanych spostrzeżeń:

  • kopiowanie małych rozmiarów bloków pamięci przy wykorzystaniu funkcji memcpy jest nieefektywne,
  • brak wyrównania adresów do granicy słowa maszynowego pogarsza szybkość kopiowania,
  • kod funkcji memcpy jest zoptymalizowany do kopiowania większych bloków pamięci – trudno jest napisać coś szybszego w języku C,
  • kopiowane są słowa, nie bajty.

Analiza wydajności funkcji memcpy

Testy przeprowadzane na mikrokontrolerze LM3S6965 pracującym na zegarze 8 MHz. Test polegał na pomiarze czasu kopiowania bloków o ustalonym rozmiarze wykorzystując funkcję memcpy. 

rys 1 - memcpy - Szybkość kopiowania bloków z wyrównanymi adresami

 

rys 2 - memcpy - Szybkość kopiowania bloków, których adresy nie są wyrównane (jednakowe przesunięcie o 3 bajty)

 

rys 3 - memcpy - Szybkość kopiowania bloków, których adresy nie są wyrównane (adres przeznaczenia przesunięty o dwa bajty, adres źródłowy przesunięty o 3 bajty)

 

Spostrzeżenia:

  • Wszystkie powyższe testy jednoznacznie pokazują, że kopiowanie bloków pamięci za pomocą funkcji memcpy jest szybsze w RVMDK niż w CodeBench. Kopiowanie 4096 bajtów w CodeBench zajmuje 4614 taktów (576 us), natomiast w RVMDK kopiowanie tego samego rozmiaru pamięci zajmuje 2984 (373 us).  Porównując także zajętość stosu widać, że do skopiowania bloku kompilator CodeBench wykorzystuje 7 bajtów stosu, a RVMDK tylko 4 bajtów.
  • Jednakowe przesunięcie adresów względem granicy słowa maszynowego dla bloku źródłowego i bloku przeznaczenia jest “mało groźne” – wprowadzony zostaje drobny narzut potrzebny na skopiowanie bajt po bajcie obszarów brzegowych. Im większy rozmiar bloku, tym wprowadzony narzut jest mniej szkodliwy.
  • Różne przesunięcie adresów względem granicy słowa maszynowego dla bloku źródłowego i bloku przeznaczenia jest już groźne. W obu kompilatorach prędkość kopiowania bloku spada trzykrotnie: dla CodeBench z 576 us do 1672 us, dla RVMDK 373 us do 1093 us.

Przyśpieszenie kopiowania bloków o ustalonym rozmiarze

Bardzo łatwo jest napisać funkcje, która w znaczący sposób przyspieszą kopiowanie małych bloków o ustalonym rozmiarze. Takie rozwiązanie jest niewygodne, może prowadzić do nieoczekiwanych problemów (zwiększając rozmiar struktury trzeba zmodyfikować wszystkie wywołania funkcji kopiujących) ale jest zdecydowanie szybsze niż użycie standardowej funkcji memcpy.

static int TestMemCpy_word_08(int *pDst, int *pSrc, int nSize)
{
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;

	return nSize;
}

static int TestMemCpy_word_16(int *pDst, int *pSrc, int nSize)
{
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;

	return nSize;
}

static int TestMemCpy_word_32(int *pDst, int *pSrc, int nSize)
{
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;

	return nSize;
}
static int TestMemCpy_word_64(int *pDst, int *pSrc, int nSize)
{
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;

	return nSize;
}

 

rys 4 - Liczba taktów potrzebnych na przekopiowanie bloku pamięci dla funkcji memcpy (wycinek rysunku 1)

Rys 5 - Liczba taktów potrzebnych na przekopiowanie bloku pamięci stosując funkcje TestMemCpy_word_xx

 

Spostrzeżenia:

  • Znaczna przyspieszenie kopiowanych bloków zarówna dla CodeBench i RVMDK (zwłaszcza dla małych rozmiarów)
  • Optymalizacja O3 dla RVMD jest gorsza niż O2 (wynika to z faktu, że kompilator powtarzające się operacje kopiowania zamienił na wywołanie funkcji memmove, generowany jest wyjątek FAUL przy próbie kopiowania niewyrównanych adresów)
  • Wywołanie funkcji z niewyrównanymi adresami powoduje wydłużenie czasu kopiowania. Do wyjaśnienia pozostaje zagadnienie, czy rdzeń Cortex-M3 sam potrafi zadbać o poprawne kopiowanie niewyrównanych adresów (kod maszynowy się nie zmienia, ale czas wykonaniu już tak)?

 

W docelowej aplikacji zamiast niewygodnych parametrów zdeklarowanych jako wskaźniki do typu int można zmienić je na wskaźniki do typu void. Wprowadzenie tymczasowych zmiennych konwertujących typy nie wpływa na wynikowy kod a znacznie poprawia przejrzystość kodu źródłowego.

void TestMemCpy_word_16(void* pvDst, void* pvSrc, int nSize)
{
	int *pDst = (int*) pvDst;
	int *pSrc = (int*) pvSrc;

	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
	*pDst++ = *pSrc++;
}

cvscpp – Automatyzacja procedury pomiarowej

W poprzednim poście “cvscpp – Wprowadzenie” bardzo ważnym spostrzeżeniem było to, że procedury testowania spełniają swoje zadanie, ale sposób ich przygotowania w sposób manualny (zestawienie wyników w postaci tabeli porównawczej) jest bardzo czasochłonny. Warto też przypomnieć, że lista testów była bardzo uboga ale i tak przygotowanie tabelek zajęło dużo czasu. Aby móc przejść do kolejnego etapu należało zautomatyzować proces pomiarowy. W tym poście postaram się zademonstrować rozwiązanie tego problemu.

W trakcie pracy nad automatyzacją, udało się także rozwiązać bardzo istotne zagadnienie związane z błędnym pomiarem czasu wykonywania szybkich funkcji testowych. Im funkcja testowa wykonywała się w krótszym czasie, tym błąd pomiaru był większy (pomar czasu wykonania funkcji testowej składał się także z niewielkiego narzutu czasu procedury pomiarowej).

Kod źródłowy framworku: cvscpp_v0.02.00.rar

Kalibracja pomiaru czasu funkcji testowych

Pierwszą zauważalną zmianą jest zwiększenie dokładności wyznaczania średniego czasu wykonania funkcji testowych (do 10ns) oraz wyznaczanie średniej liczby taktów przypadającej na daną funkcję testową.

Kluczem do kalibracji czasu jest uruchomienie procedury pomiarowej dla pustych funkcji testowych. Kalibracja musi być uruchamiana dla wszystkich prototypów funkcji testowych.

static void TestVoidNop_0(void)
{

}

static int TestIntNop_0(int param)
{
	return 0;
}

void TestCalibration_Run()
{
	BenchCase_Run_void("Calibration void", TestVoidNop_0,        1000000);
	BenchCase_Run_int ("Calibration int ", TestIntNop_0,  0x666, 1000000);
}

Przykładowy zrzut komunikatów generowanych na porcie UART mikrokontrolera:

-----------------------------------------
  Calibration void
    pCalibration_us=0x0016e36f
  ---------------------------------------
    Stack level:              0 words
    Total time:      1500.01562 ms
    Avg step time:      0.00150 ms
    Total tick:        12000125
    Avg tick:                12
-----------------------------------------
  Calibration int
    pCalibration_us=0x001e8490
  ---------------------------------------
    Stack level:              0 words
    Total time:      2000.01600 ms
    Avg step time:      0.00200 ms
    Total tick:        16000128
    Avg tick:                16

Z powyższego zrzutu można się dowiedzieć, że czas wykonania funkcji TestVoidNop_0 (powtórzonej 1000000 razy) wynosi 1500 ms. Średnia liczba taktów to 12. Oznacza to, że jednorazowy krok procedury pomiarowej wynosi 12 taktów zegara (pętla, za pomocą której uruchamia się funkcje testową x-razy). A więc odejmując te 12 taktów/krok w przeprowadzanej procedurze pomiarowej pozwoli wyznaczyć rzeczywisty czas wykonania kodu zawartego w funkcji TestVoidNop_0.

Analogicznie rozumowanie można przeprowadzić dla funkcji testowej TestIntNop_0.

Bardzo dobrą cechą takiego rozwiązania, jest automatyczna kalibracja dla każdej kompilacji (czas wykonania procedury pomiarowej jest zależny kompilatora i opcji związanych z optymalizacją). Więcej informacji można zdobyć analizując plik benchcase.c.

Weryfikacja metody kalibracji

Pomiar czasu wykonania funkcji testowych:

static void TestVoidNop_16(void)
{
    __asm volatile ("nop"); //  0
    __asm volatile ("nop"); //  1
    __asm volatile ("nop"); //  2
    __asm volatile ("nop"); //  3
    __asm volatile ("nop"); //  4
    __asm volatile ("nop"); //  5
    __asm volatile ("nop"); //  6
    __asm volatile ("nop"); //  7
    __asm volatile ("nop"); //  8
    __asm volatile ("nop"); //  9
    __asm volatile ("nop"); // 10
    __asm volatile ("nop"); // 11
    __asm volatile ("nop"); // 12
    __asm volatile ("nop"); // 13
    __asm volatile ("nop"); // 14
    __asm volatile ("nop"); // 15
}
static int TestIntNop_16(int param)
{
    __asm volatile ("nop"); //  0
    __asm volatile ("nop"); //  1
    __asm volatile ("nop"); //  2
    __asm volatile ("nop"); //  3
    __asm volatile ("nop"); //  4
    __asm volatile ("nop"); //  5
    __asm volatile ("nop"); //  6
    __asm volatile ("nop"); //  7
    __asm volatile ("nop"); //  8
    __asm volatile ("nop"); //  9
    __asm volatile ("nop"); // 10
    __asm volatile ("nop"); // 11
    __asm volatile ("nop"); // 12
    __asm volatile ("nop"); // 13
    __asm volatile ("nop"); // 14
    __asm volatile ("nop"); // 15

    return 0;
}

Zrzut komunikatów diagnostycznych:

-----------------------------------------
  TestVoidNop 1000000x16
  ---------------------------------------
    Stack level:              0 words
    Total time:      2000.00062 ms
    Avg step time:      0.00200 ms
    Total tick:        28000125
    Avg tick:                16
-----------------------------------------
  TestIntNop  1000000x16
  ---------------------------------------
    Stack level:              0 words
    Total time:      2000.00000 ms
    Avg step time:      0.00200 ms
    Total tick:        32000128
    Avg tick:                16

Dla zegara 8000000MHz okres jednego taktu to 125ns. Czas wykonania 16′tu rozkazów nop to 2 us (16 * 125ns = 2us). A więc powyższe wyniki są zgodne z rzeczywistością (z dokładnością do jednego taktu).

acvscpp – automatyzacja procedury pomiarowej

Na potrzeby automatyzacji powstała odrębna aplikacja, która w pełni automatyzuje pomiary. Idea działania jest bardzo prosta. W aplikacji zaznacza się, dla jakich ustawień kompilatora należy przeprowadzić pomiary. Aplikacja ta automatycznie (dla każdej wybranej opcji):

  • kompiluje projekt,
  • programuje mikrokontroler,
  • uruchamia kod na płytce ewaluacyjnej,
  • wydobywa istotne informacje z portu szeregowego i umieszcza je w tabeli.

Główne okno aplikacji acvscpp zaraz po uruchomieniu

Przeprowadzenie wszystkich pomiarów ogranicza się do trzech kliknięć myszką.


Główne okno aplikacji acvscpp po przeprowadzeniu serii pomiarowych

Aplikację to można byłoby jeszcze rozbudować (brakuje wiele pożytecznych mechanizmów m.in analiza rozmiaru kodu), ale już w obecnej postaci spełnia swoje zadanie.

cvccpp – Wprowadzenie

Wprowadzenie

Jak już wspomniałem w pierwszym poście, głównym celem założenia serwisu emdev.pl była chęć podzielenia się zdobytym doświadzczeniem w analizie wykorzystania języka c++ w małych systemach wbudowanych. Pod pojęciem małego systemu należy rozumieć taki system, w którym główną jednostkę procesorową jest mikrokontroler z niewielką pamięcią SRAM (np 64kB).

Od dłuższego czasu zastanawiam się nad przeniesieniem metodyki obiektowego projektowania do projektów realizowanych na mikrokontrolerach Cortex-M3. Co prawda projektując w czystym języku c, można tak stworzyć projekt, że przypomina on trochę obiektywizm, ale daleko temu do tej “prawdziwej obiektowości”.

Przykładowo projektując moduł obsługujący szeregową pamięć FLASH wykorzystującą interfejs SPI można podzielić taki moduł na kilka sekcji:

  • sterownik magistrali SPI – obsługa magistrali SPI (zarządzanie peryferiami mikrokontrolera, ochrona zasobów w systemach czasu rzeczywistego)
  • niskopoziomowy sterownik pamięci – realizacja podstawowych operacji I/O (odczyt rejestru statusowego, zapis bajtu itp)
  • wysokopoziomowy sterownik pamięci – realizacja wysokopoziomowych procedur (zapis bloku danych wraz z weryfikacją poprawności zapisu)

Od dobrze napisanego kodu wymaga się szybkiej i łatwej możliwości wykorzystania kodu na różnych platformach i w różnych konfiguracjach. Kod sterownika pamięci nie powinien zależeć od wykorzystanego systemu operacyjnego. Dodatkowo powinien działać na różnych platformach sprzętwych (albo mieć zdefiniowany interfejs do portowania). Nie wspomnę już o potrzebie zmian pinów sterujących czy peryferiów mikrokontrolera (przeważnie w jednym układzie peryferia są zdublowane).

Ograniczając się do metodyki projektowania w języku c najszybciej większość rozwiązań można zrobić wykorzystując preprocesor i dyrektywę #define. Jenak postępując w taki sposób, w pewien sposób ograniczamy modularność kodu i w przyszłości się do zemści. Wyobraźmy sobie że stworzyliśmy kod rozbudowanego modułu obsługi szeregowej pamięci flash (niezależny od systemu operacyjnego, mikrokontrolera i konfiguracji) wykorzystując dyrektywę #define.  Cieszymy się “z dobrego kodu” do momentu, w którym powstała potrzeba wykorzystania kodu w innym “nieprzewidzianym” scenariuszu. Prosty przykład: na PCB znalazły się dwie pamięci FLASH. I teraz, mając kod konfigurowany statycznie nie da się go wykorzystać do takiego przypadku (można co prawda połatać istniejący kod dostosowując go do obsługi dwóch pamięci, ale wynikiem tego będzie nieuporządkowana budowa modułu i trudne utrzymanie kodu). Co w takiej sytuacji można zrobić? Można przebudować moduł, aby jego konfiguracja i interfejsy były konfigurowane dynamicznie. Ale z prostego kodu robi się kod wolniejszy, rozmiarowo większy i jego zapotrzebowanie na pamięć SRAM drastycznie wzrasta (gdzieś trzeba trzymać konfiguracje i wskaźniki do funkcji interfejsowych). Takie podejście także nie jest idealne, chcąc zaimplementować nowy mechanizm powiadomień zwrotnych dla konkretnego projektu (np. po zakończeniu wysokopoziomowego zapisu danych odbywającego się w tle, sterownik powinien powiadomić wątek sterujący o statusie operacji zapisu) należy zmodyfikować wiele plików źródłowych, które już są wykorzystywane w innych projektach.

I tutaj dochodzimy do podstawowego pytania: Czy takie zmagania z językiem c są warte takiej pracy, czy może lepiej jest wykorzystać mechanizmy obiektowe języka c++, nie tracąc przy tym na wydajności? Dzisiejsze mikrokontrolery ARM są na tyle wydajne, że prawdopodobnie przejście na język wyższego poziomu nie spowoduje spadku wydajności, a tylko poprawi metodykę projektowania i uprości sprawy związane z utrzymaniem kodu.

Framework cvscpp

Aby móc rzetelnie odpowiedzieć na postawione pytanie należałoby przeprowadzić pewne analizy i badania. Do tego celu powstał mini projekt cvscpp. Jest to taki framework, przy pomocą którego będą przeprowadzane eksperymenty w wykorzystaniu języka c++. Kod źródłowy framworku: cvscpp_v0.01.00.rar

Platformą sprzętową do testów jest płytka ewaluacyjna EKK-LM3S6965 Ethernet Evaluation Kit posiadająca mikrokontroler LM3S6965 rodziny ARM (Cortex-M3). Przykładowy projekt w minimalnym stopniu korzysta z peryferiów sprzętowych mikrokontrolera, więc można go uruchamiać na dowolnym mikrokontrolerze rodziny Stellaris. W nowszych układach (klasy TEMPEST) być może wymagane będzie skonfigurowanie multipleksacji portów I/O.

Analiza kodu wynikowego zostanie przeprowadzana z użyciem dwóch kompilatorów:

 

Budowa framework’u cvscpp jest bardzo minimalistyczna, zaimplementowano w niej podstawowe mechanizmy pomocne podczas badania kodu:

  • Mechanizm wyświetlania wyników (UART)
  • Dokładny pomiar czasu testowanych procedur
  • Badanie zagnieżdżenia stosu
  • Zarządzanie procedurami testowymi (uruchamianie i obrazowanie wyników)

Przeprowadzając takie eksperymenty z wykorzystaniem dwóch kompilatorów, przy okazji będzie okazja na porównanie ich między sobą pod względem jakości uzyskiwanego kodu. Będą to bardzo ciekawe spostrzeżenia, gdzie z jednej strony jest darmowy, dobrze znany kompilator GCC a z drugiej strony komercyjny kompilator, za który trzeba zapłacić niemałe pieniądze. Bez dyskusji środowisko Keil’a przyspiesza start projektu, jest wygodniejsze do debugowania i pozwala bez dodatkowych narzędzi na szybką analizę wydajności kodu (z wizualizacją). I to wszystko zawarte jest w jednym pakiecie, także z prostym RTOS, stosem TCP, bibliotekami do obsługi pamięci flash i USB. Dla osób którzy nie mieli większego styku z GCC (i Linuksem) jest to bardzo ważny argument za wyborem środowiska komercyjnego (chociaż ma on swoje mankamenty). Z mojego punktu widzenia rozwiązania oparte na GCC równie są bardzo dobre, tylko trzeba mieć doświadczenie w ich stosowaniu i umieć sobie radzić z często podstawowymi problemami. A co można powiedzieć o jakości uzyskiwanego kodu wynikowego? Tutaj także trzeba spojrzeć w szerokim kontekście i trudno jednoznacznie odpowiedzieć na pytanie, który z nich daje lepszy kod.

Struktura cvscpp

W rozdziale tym skrótowo zostanie przedstawiony zarys architektury i funkcjonalności framework’u cvscpp bez zbytniego zagłębiania się w szczegóły.

Podstawowa konfiguracja framework’u odbywa się w pliku config.h. Konfiguracja opcji związanych z kompilatorem odbywa się w różnych miejscach, w zależności od użytego kompilatora:
- dla GCC w plikach makefile
- dla KEIL’a w zintegrowanym środowisku uVision

makefile

Plik makefile składa się z dwóch plików:
- makefile – podstawowa konfiguracja związana z konkretnym projektem (nazwa projektu, pliki źródłowe)
- makedefs – właściwy makefile (kompilacja plików, linkowanie, szczegółowa konfiguracja kompilatora, programowanie płytki i wiele drobnych użytecznych funkcji)

Podstawowa składnia:
- make – kompilacja projektu
- make clean – wyczyszczenie plików tymczasowych
- make size – wyświetlenie romiazu skompilowanych plików źródłowych (dla GCC)
- make lm – zaprogramowanie płytki EKK-LM3S6965 wykorzystując tryb konsolowy aplikacji LM Flash Programmer (dla GCC).

kdbg

Moduł implementujący uproszczoną wersję funkcji printf. Składa się z dwóch plików: kdbg.h i kdbg.c. Rozmiar kodu wynikowego jest niewielki ok 700kB.

Zestaw zaimplementowanych pól:
‘c’ – znak typu char
‘d’ – liczba typu signed – reprezentacja dziesiętna
‘s’ – łańcuch tekstowy zakończony znakiem ‘/0′
‘u’ – liczba typu unsigned – reprezentacja dziesiąta
‘x’ – reprezentacja szesnastkowa

Dodatkowo dla zmiennych liczbowych:
- wyrównanie np. %4d – wyrównanie do 4 znaków (spacje)
- wypełnienie np. %04d – wyrównanie do 4 znaków (zerami)

ktimer

Moduł implementujący prosty pomiar czasu z dokładnością do taktów zegara. Składa się z dwóch plików: ktimer.h i ktimer.c. W celu przyśpieszenia czasu spędzonego na jego napisanie i uruchomienie, uproszczono jego budowę, co pociągnęło za sobą pewne dość istotne ograniczenie. Zliczanie taktów odbywa się za pomocą typu unsigned long, a więc stosunkowo szybko nastąpi przepełnienie zmiennej i zafałszowanie odczytu (przy zegarze 50MHz wystarczy 85 sekund). Jednak w takim podejściu do mierzenia wydajności kodu (szybki ale dokładny pomiar czasu do pojedynczego cyklu zegara) takie ograniczenie zbytnio nie przeszkadza. Należy tylko pamiętać, że przed rozpoczynaniem pojedynczych testów należy wyzerować zawartość licznika.

stack

Moduł obsługujący testowy stos, z które korzystają procedury testowe. Przed uruchomieniem testu stos jest inicjowany tzn. wypełniany znanym wzorcem, aby po zakończeniu testów dało się wyznaczyć zagłębienie stosu. Przed samym uruchomieniem testu stos systemowy (rejestr MSP) jest podmienieny na testowy stos, po zakończeniu testu zawartość rejestru MSP jest odtwarzana. Zaimplementowano procedury wykrywania błędów typu overflow i underflow oraz funkcje zrzucającą zawartość pamięci testowego stosu. Moduł składa się z kilku plików: stack.h, stack.c, stack_gcc.c i stack_rvmdk.s.

benchcase

Moduł implementujący mechanizm testowania. Do modułu przekazywany jest tylko wskaźnik funkcji testowej i liczbę powtórzeń uruchamianej funkcji testowej. Wszystkie pozostałe procedury są przeprowadzane automatycznie. Składa się z dwóch plików: benchcase.h i benchcase.c.

W obecnej wersji dostępne są dwie wersje testów, zależne od prototypu funkcji testowej:

typedef void (*BenchCaseFunc_void) (void);
typedef int  (*BenchCaseFunc_int)  (int);

void BenchCase_Run_void(char* pStrTestTitle, BenchCaseFunc_void pFuncTest, int nCount);
void BenchCase_Run_int(char* pStrTestTitle, BenchCaseFunc_int  pFuncTest, int param, int nCount);

Uruchomienie testu polega na wywołaniu jednej z powyższych funkcji i przekazanie wskaźnika do funkcji testowej. Przebieg testu:
- inicjacja stosu
- zerowanie licznika odmierzającego czas
- przełączenie na testowy stos
- uruchomienie nCount razy testową funkcję
- przełączenia na systemowy stos
- analiza testowego stosu (zagłębienie, wykrywanie błędów overflow i underflow)
- analiza czasu działania
- wyświetlenie statystyki

Przykład wyświetlenia statystyki pojedynczego testu:

-----------------------------------------
  Test_Power_r 30
  ---------------------------------------
    Stack level: 58 words
    Total time:       635.013 ms
    Avg step time:      0.063 ms
    Total tick:       5080106

Należy zwrócić tutaj uwagę, że uzyskany wynik jest w niewielkim stopniu zafałszowany. Im funkcja testowa wykona się szybciej, tym błąd pomiaru czasu jest większy. Obrazuje to fragment kodu:

	KTIMER_Reset();
	stack_FreezeMSP = STACK_ChangeMSB(STACK_GetAddressBottom());
	{
		while(g_nCount--)
		{
			pFuncTest();
		}
	}
	stack_ExtendedMSP = STACK_ChangeMSB(stack_FreezeMSP);
	tick = KTIMER_GetTick();

Wyznaczany średni czas działania funkcji testowej jest obarczony narzutem pętli while i wywołaniem procedury. Im czas działania funkcji testowej zbliża się na tego narzutu, to pomiar jest obarczony większym błędem. Jednak w metodzie porównawczej, to nie ma aż takiego znaczenia. A dla bardzo szybkich operacji zawsze można sięgnąć do kodu asemblera i pozliczać takty zegara.

Metodyka porównawcza

Pierwszym krokiem jest porównanie rozmiaru kodu wynikowego framework’u skompilowanego w różnych konfiguracjach optymalizacyjnych kompilatorów. Wyniki są przewidywalne i niczym nie zaskakują.

Porównanie rozmiaru kodu wynikowego (bajty) dla różnych opcji kompilacji.

W framework’u dodano dwie procedury testowe, mające na celu sprawdzenie i zaprezentowanie metodyki porównawczej. Po wykonaniu pomiarów widać pewne niedoskonałości, które nadają się do poprawy. Ale o tym później.

 

Pierwsza procedura polegała na przetestowaniu funkcji SysCtlDelay (z biblioteki Stellaris Peripheral Driver Library) wywoływaną z różnymi parametrami. Zdecydowano się na funkcję opóźniająca, aby sprawdzić poprawność mechanizmu pomiaru czasu wykonywania kodu.

static void TestDelay_1s(void)
{
	SysCtlDelay(SysCtlClockGet() / 3);
}

static void TestDelay_10ms(void)
{
	SysCtlDelay(SysCtlClockGet() / 3 / 100);
}

static void TestDelay_1ms_1(void)
{
	SysCtlDelay(SysCtlClockGet() / 3 / 1000);
}

static void TestDelay_1ms_2(void)
{
	SysCtlDelay(8000000 / 3 / 1000);
}

static void TestDelay_1ms_3(void)
{
	SysCtlDelay(sys_clock / 3 / 1000);
}

void TestDelay_Run()
{
	BenchCase_Run_void("SysCtlDelay 1000ms",                     TestDelay_1s,       1);
	BenchCase_Run_void("SysCtlDelay 100x10ms",                   TestDelay_10ms,   100);
	BenchCase_Run_void("SysCtlDelay 1000x1ms with detect clock", TestDelay_1ms_1, 1000);
	BenchCase_Run_void("SysCtlDelay 1000x1ms with constant",     TestDelay_1ms_2, 1000);
	BenchCase_Run_void("SysCtlDelay 1000x1ms with variable",     TestDelay_1ms_3, 1000);
}

Druga procedura testowa została wybrana pod kątem testowania mechanizmu wykrywania zagłębienia stosu. Do testów wzięto dobrze znaną z podręczników języka c funkcję obliczającą silnie (iteracyjnie i rekursywnie)

static int TestFactorial_for(int n)
{
	int i;
	int result = 1;

	for(i = 2; i < n; i++)
		result *= i;

	return result;
}

static int TestFactorial_r(int n)
{
	if ( n <= 1)
        return 1;

    return n * TestFactorial_r(n - 1);
}

void TestFactorial_Run()
{
	BenchCase_Run_int("Test_Power_for 10",                  TestFactorial_for,    10,  10000);
	BenchCase_Run_int("Test_Power_for 30",                  TestFactorial_for,    30,  10000);
	BenchCase_Run_int("Test_Power_for 50",                  TestFactorial_for,    50,  10000);

	BenchCase_Run_int("Test_Power_r 10",                    TestFactorial_r,      10,  10000);
	BenchCase_Run_int("Test_Power_r 30",                    TestFactorial_r,      30,  10000);
}

Wyniki i wnioski

Poniżej w formie tabelki przedstawiono wyniki pomiarowe przykładowych scenariuszy:

Wyniki pomiarowe dla kompilatora GCC

Wyniki pomiarowe dla kompilatora KEIL'a

Pierwszym ważnym stwierdzeniem jest informacja, że przyjęta i zaimplementowana metodyka testowania działa zgodnie z założeniami. Do tego zadania zostały dobrane te dwa scenariusze testowe. Z powyższych wyników nie ma w zasadzie co analizować, nie ma nic odkrywczego (no może z jednym wyjątkiem).

Ciekawostką może być spostrzeżenie, że stosując prostą funkcje rekurencyjną (obliczanie silni) przy odpowiednio dobranej optymalizacji kompilatora, funkcja w ogóle nie korzysta z stosu. Wynika to bezpośrednio z nowoczesnej architektury rdzenia ARM’a. Przyglądając się otrzymanym wynikom, to z funkcją rekurencyjną lepiej sobie poradził kompilator GCC niż KEIL:
- dla GCC z opcją o2,o3,os metoda rekurencyjna nie jest w znaczącym stopniu wolniejsza niż metoda iteracyjna
- dla Keila tego powiedzieć się nie da, metoda rekurencyjna jest wolniejsza od metody iteracyjnej to jeszcze dodatkowo dla opcji o3 funkcja rekurencyjna wykorzystała stos.
Przeanalizowane tylko dwa przypadki to za mało, aby wysunąć daleko posunięte spostrzeżenia.

Po wykonaniu serii pomiarowej można dostrzec pewien manewr, za pomocą którego można zmniejszyć błąd pomiaru czasu wykonywania funkcji testowej. Funkcja SysCtlDelay w bibliotece LibDriver jest napisana jako wstawka asemblerowa, a więc optymalizacja kompilatora na nią nie wpływa. Znając dokładny czas wykonania funkcji SysCtlDelay (co do taktu zegara) można wyznaczyć dodatkowy narzut czasowy algorytmu pomiarowego. Należy tylko zwiększyć dokładność pomiaru czasu (rząd wielkości) a także oszacować narzut na wywołanie funkcji SysCtlDelay z parametrem dla każdej możliwej optymalizacji kompilatora (różni się sposób przekazania argumentu do funkcji w zależności od stopnia optymalizacji). Przyjmuje się, że może powstać wiele metod testowych (tutaj mamy już dwie) więc nie jest najlepszym pomysłem policzenie taktów zegara procedury testowej.

Jednak najważniejszym wnioskiem dla mnie jest to, że czas spędzony na pomiary i wykonanie serii pomiarów był zdecydowanie za długi. Prze większej liczbie testów byłaby to katorga. Więc przed przystąpieniem do dalszych eksperymentów bezwzględnie trzeba zautomatyzować proces “przygotowania tabelek”.

Start witryny

Od pewnego czasu (kilku lat) myślałem o założeniu strony internetowej związanej z szeroko pojętym tematem, jakim są systemy wbudowane. Jednak zawsze było coś innego do zrobienia. No i w końcu determinacja wzięła górę. Serwer został wykupiony, wstępnie skonfigurowany i przygotowany do pracy.

Pierwszym zagadnieniem, które będę chciał poruszyć to analiza wykorzystania języka C++ w środowisku mikrokontrolerów Cortex-M3. Ostatnio coraz częściej są poruszane takie tematy, ale jak dla mnie brakuje szczegółowych informacji jak poszczególne mechanizmy języka C++ wpływają na kod wynikowy. Zwłaszcza interesuje mnie ‘wydajność’ komercyjnego kompilatora Keil’a.

W chwili obecnej nie zastanawiam się, czy będą tutaj poruszane także inne tematy związane z obszernym zagadnieniem projektowania systemów wbudowanych. Możliwe, że mimo wąskiej tematyki poruszanych tutaj zagadnień witryna stanie się na tyle popularna, że zachęci mnie do jej dalszego rozwijania. Ale to czas pokaże…