Backend, Inżynieria

5 narzędzi używanych w inżynierii systemów wbudowanych, których będziesz musiał się nauczyć

Narzędzia używane w inżynierii systemów wbudowanych

Jednym ze schematów, za którym podążają osoby chcące rozpocząć swoją karierę w świecie systemów wbudowanych, jest przygotowanie do pracy oparte na kilku podstawach. Są to m.in. lektura popularnych książek poruszających tematykę programowania w języku C mikrokontrolerów z rodziny AVR lub STM32, znajomość podstaw elektroniki cyfrowej i analogowej, wykształcenie techniczne skorelowane z tematyką embedded oraz wykorzystanie tej wiedzy w praktyce poprzez wykonanie, chociaż kilku działających układów opartych o wymienione wcześniej platformy. Jest to niewątpliwie słuszne podejście. Warto jednak móc dodatkowo wyróżnić się czymś więcej na tle innych kandydatów – czymś, czym można zaskoczyć swojego przyszłego pracodawcę podczas rozmowy rekrutacyjnej.

Michał Łokcewicz. Programista systemów wbudowanych i Lider zespołu programistów w Comarch. Zajmuje się analizą wymagań, implementacją i nadzorem nad architekturą oprogramowania dla urządzeń telemedycznych (przede wszystkim rejestratorów EKG). Pracuje głównie z językami C i C++. Prywatnie gitarzysta w zespole nic ważnego.


Poza umiejętnościami i narzędziami, które stanowią rdzeń pracy programisty systemów wbudowanych, jak np. znajomość C/C++, znajomość architektury i układów peryferyjnych stosowanego mikrokontrolera), w profesjonalnym wytwarzaniu oprogramowania wykorzystywane jest dodatkowo wiele narzędzi, mających usprawnić lub zautomatyzować proces. Do takich rozwiązań zaliczyć możemy recenzję kodu (ang. code review), testowanie, kompilację projektu lub analizę statyczną kodu – czyli wszystkie te procesy, którymi nie zajmuje się hobbysta chcący po prostu tworzyć ciekawe projekty. Wiele z narzędzi jest powszechnych w produkcji oprogramowania niezależnie od jego specyfiki – np. narzędzia kontroli wersji takie jak Git lub systemy zarządzania rozwojem projektu, np. Jira, ale często używane są też narzędzia dedykowane i przystosowane do specyfiki inżynierii systemów wbudowanych. Prędzej czy później zetkniesz się z nimi podczas swojej kariery. Opanowanie ich wcześniej przyniesie Ci wiele pożytku, nie tylko podczas rozmowy rekrutacyjnej, ale też podczas wdrażania się do nowego projektu.

CMake

Jak skompilować kod? W najprostszym przypadku ograniczać się to będzie do skorzystania z przycisku w oknie środowiska programistycznego (IDE). W analogiczny sposób możemy załadować program do pamięci i nie przejmować się całą tą złożoną sekwencją zdarzeń, w wyniku której otrzymujemy działający na mikrokontrolerze program. W praktyce jednak zachodzi potrzeba uniezależnienia się od środowiska programistycznego oraz dostosowania procesu budowania projektu do własnych potrzeb. Ponadto proces budowania projektu przez IDE, choć wygodny dla programistów, zwykle nie jest łatwy do automatyzacji. Odpowiedzmy sobie zatem na pytanie – co tak naprawdę trzeba zrobić, aby skompilować kod źródłowy do pliku binarnego? W pierwszym podejściu, budowanie projektu ogranicza się do długiej listy wywołań szeregu aplikacji toolchaina z odpowiednimi parametrami – trzeba jedynie wskazać gdzie znajdują się pliki źródłowe, gdzie znajdują się pliki nagłówkowe, jak ma wyglądać plik wynikowy (czy to w postaci binarnej, Intel hex, czy bardziej uniwersalnej postaci pliku .elf), wskazać opcje kompilacji takie jak optymalizacja, wybrać standard języka, flagi manipulujące listą wyświetlanych przez kompilator ostrzeżeń i sprawdzanych błędów… no właśnie. O ile taka metoda bezpośredniego wywołania kompilatora i ręczne wpisywanie parametrów za każdym razem może się sprawdzić w przypadku kompilacji kilku plików źródłowych (chociaż i to jest mocno naciągane – nikt tak nie robi), o tyle w przypadku odrobinę poważniejszych projektów, plików i wariantów tworzenia pliku wynikowego przybywa tyle, że trudno jest zapanować nad prostą czynnością, jaką jest kompilacja kodu.

Pierwszym krokiem, aby uporządkować i uprościć tę czynność jest tworzenie plików Makefile – w dużym skrócie jest to plik zawierający przepis na plik wynikowy. Zawiera dane niezbędne dla kompilatora takie jak ścieżki do plików źródłowych i nagłówkowych.
Właśnie w ten sposób popularne w środowiskach embedded IDE kompilują projekty. Każdy nowo utworzony plik uwzględniany jest w tworzonych na bieżąco przez IDE plikach Makefile. Aby jednak nie było potrzeby polegania na konkretnym środowisku oraz aby nasz projekt zawierał wszystkie niezbędne do kompilacji informacje zapisane w postaci kodu (zgodnie z IaC, Infrastructure as Code), należy uzyskać możliwość samodzielnego tworzenia plików Makefile. Opanowanie składni plików Makefile, chociaż w podstawowym zakresie, jest bardzo przydatne w zrozumieniu procesu tworzenia pliku wynikowego.
Problem jednak w tym, że analogicznie jak przy poprzedniej metodzie, przy większych projektach i złożonych wariantach kompilacji, tworzenie i utrzymywanie plików Makefile staje się czasochłonne i obarczone ryzykiem błędów. Rozwiązaniem takich problemów jest narzędzie CMake.

CMake to narzędzie pozwalające w prosty sposób tworzyć przepis na plik z regułami dla kompilacji (np. Makefile) – analogicznie do tego, że Makefile jest przepisem na plik binarny). Składnia CMake’a jest prostsza i bardziej przejrzysta niż składnia plików Makefile. Dzięki temu aktualizacja plików CMake’a podczas rozwoju projektu, uwzględnianie nowych wersji platformy docelowej, praca z różnymi wersjami kompilatorów oraz jakakolwiek parametryzacja procesu budowania staje się dużo szybsza i łatwiejsza. Jednak najważniejszą zaletą użycia CMake względem Makefile jest przenośność – projekty budowane przy pomocy CMake można zbudować na szeregu systemów operacyjnych i szeregu systemów budowania, niekoniecznie z użyciem Makefile.

Najprostszy plik CMake pozwalający zbudować aplikację na podstawie pliku źródłowego oraz katalogu z plikami nagłówkowymi przedstawiono poniżej:

cmake_minimum_required(VERSION 3.10)

# set the project name
project(simple_project)

# include headers
include_directories(inc)

# add the executable
add_executable(simple_project main.c)

Skrypty CMake stanowią bardzo często integralną część projektu, a potrzeba ich modyfikacji może występować stosunkowo często. Warto więc zaznajomić się z ideą tworzenia plików CMake, ich składnią oraz procesem kompilacji.

Unity

Jednym z etapów weryfikacji oprogramowania są testy jednostkowe, tzn. testy obejmujące jeden konkretny moduł oprogramowania w oderwaniu od całości kodu aplikacji. Testy jednostkowe tworzone są przy użyciu odpowiedniego środowiska, które wymusza określoną strukturę ułatwiającą wykonywanie testów. Zbiór testów jednostkowych weryfikuje funkcjonalność testowanego modułu według zadanego scenariusza (zbioru przypadków testowych). Przypadki testowe sprawdzają funkcjonalności modułu, dostarczając zestawu danych wejściowych i weryfikując uzyskane dane wyjściowe.

Jednym z najpopularniejszych narzędzi umożliwiających wygodne tworzenie scenariuszy testowych projektów embedded tworzonych w języku C jest Unity. Framework ten zawiera zestaw makr oraz funkcji pozwalających w prosty sposób tworzyć pliki z definicją przypadków testowych, uruchamiać testy i tworzyć raport z wynikami w popularnym formacie Junit.

W najprostszym przypadku, do przeprowadzenia testu potrzebujemy:
● plików modułu testowanego,
● plików źródłowych (lub obiektowych) frameworku do testów,
● pliku z przypadkami testowymi,
● test runnera, czyli aplikacji wykonującej poszczególne przypadki testowe i generującej raport z wynikami.

Wyobraź sobie, że utworzyłeś moduł network_utils, którego jednym z zadań jest przeliczanie siły sygnału odbieranego przez modem na wartość w dBm. Scenariusz testowy dla takiego modułu mógłby wyglądać tak:

#include <unity.h>
##include 
#include 

void test_convert_csq_to_dbm(void)
{
    TEST_ASSERT_EQUAL_INT(-87, csq_to_dbm(13));
    TEST_ASSERT_EQUAL_INT(-83, csq_to_dbm(15));
    TEST_ASSERT_EQUAL_INT(-75, csq_to_dbm(19));
    TEST_ASSERT_EQUAL_INT(-61, csq_to_dbm(26));
}

void test_convert_dbm_to_csq(void)
{
    TEST_ASSERT_EQUAL_INT(13, dbm_to_csq(-87));
    TEST_ASSERT_EQUAL_INT(15, dbm_to_csq(-83));
    TEST_ASSERT_EQUAL_INT(19, dbm_to_csq(-75));
    TEST_ASSERT_EQUAL_INT(26, dbm_to_csq(-61));
}

int main(void)
{
    UNITY_BEGIN();

    RUN_TEST(test_convert_csq_to_dbm);
    RUN_TEST(test_convert_dbm_to_csq);

    ret

Po skompilowaniu, co ważne, nie na platformie docelowej, a w środowisku developerskim i uruchomieniu powyższego kodu uzyskamy raport z wynikami testów:

../src/test_simple_project.c:17:test_convert_csq_to_dbm:FAIL: Expected -75 Was 20
../src/test_simple_project.c:34:test_convert_dbm_to_csq:PASS

-----------------------
2 Tests 1 Failures 0 Ignored
FAIL

Warto zaznaczyć jeszcze jeden, bardzo ważny element procesu testowania jednostkowego. Żeby umożliwić kompilację oddzielonego modułu należy stworzyć puste lub częściowe implementacje wszystkich zależności danego modułu. W najprostszym przypadku, będzie to jedynie zdefiniowanie pustej funkcji. Często jednak zachodzi potrzeba nie tylko odwzorowania działania funkcji, ale również kontrola wartości zwracanych przez funkcję, zliczanie jej wywołań lub odczytanie wartości argumentów, z jakimi została wywołana. Taka na nowo zaimplementowana funkcja zastępująca funkcję oryginalną, w zależności od poziomu złożoności nosi nazwę mock, fake lub stub. Narzędziem do wygodnego tworzenia i kontroli wywołań takich funkcji są narzędzia, takie jak np. CMock lub Fake Function Framework. Wykorzystuje się je podczas tworzenia scenariuszy testowych – np. definiując jako zestaw danych wejściowych do testu konkretne wartości zwracane przez imitowaną funkcję, z której korzysta moduł testowany.

Warto w tym momencie nadmienić, że możliwość podziału kodu na moduły i testowania ich niezależnie w dużej mierze zależy od stylu pisania kodu. Nie każdy kod da się efektywnie przetestować testami jednostkowymi i jest to zjawisko niepożądane. Kod taki zwie się wówczas „nietestowalnym”. Metoda odpowiedniego pisania kodu tak, by dało się go łatwo testować, jest sztuką samą w sobie.

Dlaczego warto nauczyć się pisania testów jednostkowych? Duża część zadań implementacyjnych podczas pracy programisty, kończy się utworzeniem bądź rozwinięciem istniejących scenariuszy testowych. Dopiero po ukończeniu testów wynikiem pozytywnym można mówić o ukończeniu pracy nad zagadnieniem. Aby tworzyć poprawne testy, niezbędne jest zrozumienie idei testów jednostkowych oraz narzędzi pozwalających na ich przeprowadzanie.

Jenkins oraz testy HIL

Wymienione powyżej czynności, takie jak budowanie projektu oraz wykonywanie testów jednostkowych podczas rozwoju oprogramowania wykonuje się nie tylko lokalnie (w celu weryfikacji programu i sprawdzenia wyników testów), ale również na serwerze, który uruchamia budowanie projektu przy każdym wypchnięciu zmian na zdalne repozytorium. Podejście to jest częścią procesu tzw. Ciągłej Integracji (ang. Continuous Integration) opierającym się, w skrócie, na częstym integrowaniu i testowaniu zmian wprowadzonych w oprogramowaniu. Pojęcie Continuous Integration jest bardzo szerokie i obejmuje wiele etapów, m.in. kompilację, testy jednostkowe, statyczną analizę kodu i wiele innych. Proces CI odbywa się w predefiniowanym środowisku uruchomieniowym na serwerze. Dzięki temu, możemy mieć stałą kontrolę nad stanem oprogramowania. Dobrze zbudowany proces CI, obejmujący analizy statyczne, testy jednostkowe, testy funkcjonalne, testy HIL oraz testy integracyjne, pozwala na zidentyfikowanie większości regresji odpowiednio wcześnie, dzięki raportowi wygenerowanemu przez odpowiednie narzędzie.
Jednym z najpopularniejszych tego typu narzędzi jest stosowany powszechnie Jenkins. Jest to darmowy system, pozwalający na automatyzację budowania kodu poprzez tworzenie tzw. Zadań (ang. Job). Zadanie to zbiór instrukcji, które ma wykonać Jenkins w następstwie zdefiniowanego wcześniej zdarzenia (np. wypchnięcie zmian na repozytorium Git). Zadania można definiować z poziomu serwera, jak również (zgodnie ze wspomnianą już ideą IaC), z poziomu struktury projektu, dzięki plikom z instrukcjami dla Jenkinsa (Jenkinsfile).

Przykładowy plik Jenkinsfile definiujący tzw. pipeline (jeden z rodzajów Zadań) obejmujący etapy budowania projektu za pomocą CMake’a oraz testów jednostkowych przedstawiono poniżej:

pipeline { 
    agent any 
    options {
        skipStagesAfterUnstable()
    }
    stages {
        stage('Build') { 
            steps { 
                sh 'make' 
            }
        }
        stage('Test'){
            steps {
                sh 'make check'
                junit 'reports/**/*.xml' 
            }
        }
    }
}

Jedną z możliwości, jakie daje Jenkins w obszarze systemów wbudowanych, jest wykorzystanie go do przeprowadzania automatycznych testów aplikacji na platformie docelowej (ang. Hardware-in-the-loop). Wymaga to dodania kolejnego etapu (stage) do pliku Jenkinsfile oraz umożliwienie komunikacji pomiędzy urządzeniem docelowym, a węzłem Jenkinsa (np. poprzez SSH lub port szeregowy). Do węzła takiego podłącza się również programator, umożliwiając tym samym automatyczne ładowanie programu do pamięci mikrokontrolera przy każdej modyfikacji kodu.

Pliki Jenkinsfile, podobnie jak pliki CMake traktowane są jako część projektu. Tak samo jak kod, są rozwijane i utrzymywane. Warto więc zapoznać się zarówno z samą ideą CI, funkcjami Jenkinsa, jak i składni plików Jenkinsfile.

Valgrind

Duża grupę błędów w oprogramowaniu stanowią błędy w zarządzaniu pamięcią. Może być to m.in. brak zwolnienia zaalokowanej wcześniej pamięci (wyciek) lub nieuprawniony dostęp do pamięci. Problemy tego typu zyskują na znaczeniu w systemach wbudowanych, gdzie często ilość dostępnej pamięci RAM jest bardzo ograniczona, a skutki błędu zarządzania pamięcią mogą nieść ryzyko utraty funkcjonalności całego urządzenia. Dodatkowo, nie wszystkie mikrokontrolery posiadają MPU (Memory Protection Unit, sprzętowa ochrona pamięci) a jeśli je mają, funkcjonalność MPU jest często nieprawidłowo skonfigurowana lub ograniczająca rozwój oprogramowania na tyle, że programiści celowo nie korzystają z niej wcale. To zdecydowanie ogranicza możliwość wczesnej detekcji w dostępie czy zapisie pamięci.

Aby wykrywać tego rodzaju błędy już na etapie rozwoju oprogramowania, stosuje się narzędzia do kontroli przydziału pamięci dynamicznej. Jednym z najpopularniejszych jest Valgrind – aplikacja analizująca dynamicznie proces zarządzania pamięcią. W systemach wbudowanych narzędzie to bardzo często wykorzystuje się podczas tworzenia testów jednostkowych (co pozwala używać Valgrinda nie na platformie docelowej, lecz na serwerze CI).

Przykładowy program z błędem wycieku pamięci przedstawiono poniżej

 #include <stdlib.h>

  void foo(void)
  {
     int *x = malloc(10 * sizeof(int));
     x[1] = 0;        
  }               

  int main(void)
  {
     f();
     return 0;
  }

Po uruchomienia analizy poleceniem:

valgrind --leak-check=yes myprog

Uzyskamy m.in. takie informacje:

 ==19182== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
  ==19182==    at 0x1B8FF5CD: malloc (vg_replace_malloc.c:130)
  ==19182==    by 0x8048385: foo (a.c:5)
  ==19182==    by 0x80483AB: main (a.c:11)

Valgrind wskazał dokładnie co się stało i w którym miejscu tkwi błąd – pamięć zaalokowana w funkcji foo, jest tracona bezpowrotnie. W większych projektach, w których błędy nie są tak oczywiste, pozwala to zaoszczędzić wiele czasu spędzonego na poszukiwaniu błędu oraz znacznie zmniejszyć ryzyko braku stabilności aplikacji.

Clang-tidy

Clang to front-end dla kompilatora LLVM zaprojektowany m.in. z myślą o językach C i C++. Jest to projekt open-source, jedna z alternatyw wobec kompilatora GCC. W pakiecie Clang poza samym kompilatorem, znajdziemy narzędzie do analizy statycznej – Clang-tidy.

Analiza statyczna to proces przeglądu struktury kodu źródłowego pod kątem podatności na błędy, przestrzegania dobrych praktyk programistycznych, duplikacji lub bezpieczeństwa kodu. Celem jest usunięcie wszelkich błędów możliwych do zidentyfikowania jeszcze przed uruchomieniem aplikacji.

Clang tidy pozwala na skanowanie plików źródłowych w celu znalezienia fragmentów niezgodnych ze zdefiniowanymi regułami. Reguły podzielone są na wiele grup, m.in. związane ze stylem konkretnych projektów (Boost, C++ Core Guidlines, Google, Linux Kernel), a także dotyczące wydajności, czytelności lub przestarzałych konstrukcji językowych. Błędy wynikające z łamania wielu z reguł możemy naprawić automatycznie za pomocą odpowiedniej flagi.

Pora na przykład – poniższa funkcja ma za zadanie przypisać do globalnego wskaźnika p, adres łańcucha znakowego (tablicy znaków):

char const *p;

void test(void)
{
   char str[] = "string";
   p = str;
}

Kompilacja nie wykazała żadnych błędów. Wynik analizy powyższej funkcji przedstawiono poniżej:

C:ProjectsTesttest.c:16:1: warning: Address of stack memory associated with local variable 'str' is still referred to by the global variable 'p' upon returning to the caller. This will be a dangling reference [clang-analyzer-core.StackAddrEscapeBase]

Wygenerowane zostało ostrzeżenie – funkcja zwraca adres obiektu przechowywanego na stosie. Oznacza to, że po wyjściu z funkcji nie ma gwarancji, że pod adresem przechowywanym we wskaźniku p, dalej znajduje się utworzony wcześniej łańcuch znaków. Dane, na które wskazuje p, będą się zmieniać w zależności od danych umieszczanych na stosie podczas dalszego działania programu. Ostateczny efekt w działaniu aplikacji jest na tyle nieprzewidywalny, że skutecznie utrudnia poszukiwanie błędu – warto zaoszczędzić sobie wiele czasu, wykrywając go już na etapie analizy statycznej.

Podobnie jak wszystkie poprzednio omawiane narzędzia, Clang-tidy można zintegrować je z procesem CI (np. z Jenkinsem).

Jak widać, droga od ukończenia pracy nad samym kodem, do momentu faktycznego zakończenia zadania jest długa i skomplikowana. Jest to jednak niezbędne, aby zapewnić maksymalną niezawodność tworzonego oprogramowania oraz uczynić proces możliwie płynnym i zautomatyzowanym – a co za tym idzie – szybszym. Wszystkie wymienione narzędzia, z pewnością przydadzą Ci się w pracy zawodowej – nawet sama świadomość ich istnienia oraz zrozumienie powodów, dla których się je stosuje – są dobrym wstępem do poznania praktyk stosowanych o inżynierii oprogramowania wbudowanego. Jednak żadna teoria, nie zastąpi przygotowania praktycznego. Dlatego dobrym pomysłem na rozpoczęcie kariery jest staż o profilu embedded, organizowany co roku przez firmę Comarch, a którego sam byłem uczestnikiem. Pod okiem specjalistów z wieloletnim doświadczeniem studenci mają możliwość uczenia się i realizowania projektów, co jest bardzo ważne, w praktyce. Dzięki temu mogą skonfrontować swoje oczekiwania już na początku kariery, zobaczyć, jak wygląda praca programistów w praktyce oraz „doszlifować” kompetencje miękkie. Jeżeli chcesz rozwijać się w obszarze elektroniki i hardware oraz tworzyć, integrować i weryfikować oprogramowanie, jest to idealna ścieżka, by rozpocząć przygodę z IT! Zachęcamy do zgłaszania się tu: https://kariera.comarch.pl/praca/staz-embedded/

 

Zdjęcie główne artykułu pochodzi z canva.com

 

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/5-narzedzi-uzywanych-w-inzynierii-systemow-wbudowanych-ktorych-bedziesz-musial-sie-nauczyc/" order_type="social" width="100%" count_of_comments="8" ]