Analiza błędów oprogramowania w systemach wbudowanych

| Technika

Podczas pracy nad oprogramowaniem nieodzowna jest możliwość symulacji i bieżącego testowania różnych stanów systemu. Programiści rozwijający aplikacje przeznaczone dla komputerów PC mają do dyspozycji ogromną liczbę różnorodnych narzędzi wspomagających pracę. Jak wygląda ten problem w systemach wbudowanych? W zależności od stopnia skomplikowania projektu, zaawansowania prac czy mocy obliczeniowej użytego procesora stosuje się różne techniki.

Analiza błędów oprogramowania w systemach wbudowanych

Rys. 1. Tryb debbugera w programie AVRStudio 5.0

Od prostej zmiany stanu wyjść, czyli mrugania diodą, poprzez wysyłanie komunikatów przez interfejs szeregowy, aż do zdalnego zarządzania przebiegiem aplikacji poprzez sieć Ethernet. Liczba oferowanych narzędzi uzależniona jest w dużej mierze od rozpowszechnienia i dostępu do informacji na temat danego procesora.

W artykule omówione zostaną cztery techniki stosowane najczęściej: emulacja na PC, emulacja w systemie, praca za pomocą JTAG-a oraz narzędzia dostępne dla urządzeń z systemem Linux.

Emulacja na PC

Rys. 2. Systemowy emulator procesorów 78k firmy Renesas (NEC)

Producenci procesorów bardzo często udostępniają aplikacje, które potrafią symulować działanie danego procesora na komputerze PC. Jednym z przykładów jest platforma AVRStudio udostępniana za darmo przez firmę Atmel. Dzięki tej aplikacji możemy jeszcze przed uruchomieniem prototypu przetestować programy pisane dla wielu procesorów tego producenta.

Pakiet AVR Studio kompiluje projekt za pomocą Avrgcc. Jeśli wybierzemy opcję kompilacji dla debuggera, aplikacja automatycznie przełączy się do trybu pokazanego na rysunku 1. Przykładowy program dla procesora ATMega328 pokazuje pracę z UART. Uruchamiając symulator, możemy ustawiać zwykłe i warunkowe pułapki, ustawiać parametry pracy i obserwować zmiany rejestrów procesora.

Możemy też zasymulować sytuacje zarówno wysłania, jak i odebrania znaku przez moduł UART. Praca procesora może być wykonywana krokowo i w tym czasie można czytać i analizować dowolne obszary pamięci RAM i stany wejść i wyjść procesora. W przypadku bardzo rozpowszechnionych procesorów posiadających dobrą dokumentację dodatkowe symulatory i narzędzia opracowywane są także przez niezależnych programistów.

Dobrym przykładem takiej aplikacji jest darmowy program Qemu. Kod Qemu jest otwarty, a aplikację można pobrać skompilowaną zarówno dla systemu Linux, jak i Windows. Pozwala na uruchamianie aplikacji (także jądra Linuksa) dla architektur x86, PowerPC, PowerMac, ARM, Sparc, Mips i wielu innych. Emulacja na PC jest najprostszym i jednocześnie najszybszym sposobem na uruchomienie napisanego i skompilowanego przez nas kodu.

Jednak w miarę rozwijania projektu i pojawiania się bardziej skomplikowanych problemów ta technika okazuje się zawodna. Wielu problemów związanych z urządzeniami peryferyjnymi, zegarem, czy zależnościami czasowymi nie będzie można wykryć na emulatorze PC.

Emulacja w systemie

Rys. 3. Przykładowy interfejs JTAG wraz z listą linii sygnałowych

Kolejnym krokiem w procesie wyszukiwania błędów jest przeprowadzenie testów całego układu. Spotyka się jeszcze procesory, które nie mają złącza JTAG (opisanego w kolejnej sekcji). Rozwiązania takie są często stosowane przez producentów procesorów przeznaczonych dla urządzeń produkowanych masowo. Brak złącza JTAG, brak możliwości programowania w systemie, ograniczenia w udostępnianiu dokumentacji mają swoje uzasadnienie.

Takie praktyki są dodatkowym czynnikiem zabezpieczającym prawa autorskie, utrudniają inżynierię odwrotną i kopiowanie urządzeń. Do pracy z takimi procesorami producenci często dostarczają sprzętowe emulatory. Cena takich urządzeń jest często skuteczną barierą dla niezależnych elektroników, nie mając jednocześnie wielkiego wpływu na koszt projektu przy masowej produkcji.

Jako przykład takiego rozwiązania przytoczyć można system IECube firmy Renesas (dawniej NEC) przeznaczony dla procesorów rodziny 78k. Opracowując urządzenie bazujące na tym procesorze, zamiast procesora wlutowuje się w płytkę specjalne złącze. Za pomocą dodatkowych modułów podłączanych do tego złącza uzyskuje się tryb debugowania, możliwość pracy krokowej, tryb Trace zapisujący przebieg programu czy możliwość generowania sygnałów dla całego układu.

System IECube jest przystosowany do pracy ze środowiskiem IAR Embedded Workbench. Emulacja w systemie jest najmniej korzystnym rozwiązaniem dla małych serii produktów. Wymaga dość dużego nakładu kosztów na sprzętowy emulator oraz stworzenia płytek prototypowych dedykowanych dla emulatora.

Choć emulator sprzętowy zapewnia dużo lepsze odwzorowanie systemu niż emulator na PC, także tutaj nie wszystkie problemy będą powtarzane. Często zdarza się, że pamięć Flash jest symulowana w pamięci RAM emulatora. W tym przypadku procedury zapisu danych do pamięci nieulotnej nie będą przebiegały identycznie jak w rzeczywistym systemie.

JTAG

Rys. 4. Etapy uruchamiania urządzenia z systemem Linux

W miarę rozwoju techniki cyfrowej i coraz większej konkurencji na rynku procesorów wzrastało zapotrzebowanie na układy "amatorskie". Główną barierą dla większości inżynierów był wymóg zainwestowania w zestaw drogich narzędzi specjalizowanych dla jednego procesora lub rodziny procesorów danego producenta. Pierwszym krokiem ułatwiającym pracę mniejszym zespołom było zastosowanie technologii ISP (In-System Programming) oraz pamięci Flash.

Dzięki tym rozwiązaniom można wielokrotnie programować układ na stałe wmontowany w system i tym samym testować jego działanie w docelowym środowisku w miarę wprowadzania zmian. Kolejnym krokiem było wbudowanie w procesor dodatkowego modułu "nadzorcy" i umożliwienie zewnętrznej kontroli oraz sterowania pracą procesora.

Standard JTAG (IEEE 1149.1) został stworzony jako protokół testowania połączeń na płytkach drukowanych. W miarę rozwoju standardu, w 1990 roku, rozszerzono go o funkcje programowania pamięci w systemie (ISP) oraz debugowania. Pierwszym procesorem wyposażonym w pełny interfejs JTAG był 80486 firmy Intel. Moduł JTAG w tym procesorze jest uruchamiany jako jeden z pierwszych po resecie.

Rozwiązanie to pozwala na przejęcie kontroli nad pracą układu w bardzo wczesnych fazach pracy i jest stosowane w większości współczesnych rozwiązań. Interfejs JTAG zintegrowany z aplikacją działającą na PC pozwala między innymi na wgrywanie i uruchamianie aplikacji bezpośrednio z pamięci operacyjnej. Oczywiście odnosi się to do procesorów, które umożliwiają wykonywanie programu z pamięci RAM.

W tym przypadku możemy testować aplikację bez przymusu zapisywania jej w pamięci nieulotnej. Wiele środowisk współpracujących z interfejsem JTAG pozwala na połączenie skompilowanego programu w postaci binarnej z plikami kodu źródłowego i wykonywanie programu krokowo bezpośrednio na źródłach.

Używając JTAG-a, uzyskamy także możliwość odczytywania i zmiany komórek pamięci oraz rejestrów procesora. Dzięki zastosowaniu standardowego interfejsu sprzętowego zmiana procesora wymaga często jedynie zmiany aplikacji współpracującej z interfejsem.

Urządzenia z systemem Linux

Rys. 5. Zdalne debugowanie aplikacji za pomocą Gdbserver

Ze względu na dużą dostępność i możliwość wykorzystania darmowego oprogramowania coraz więcej urządzeń pracuje pod kontrolą systemu Linux. Schemat startu takiego urządzenia przedstawia rysunek 4. W zależności od etapu, który chcemy analizować, zastosujemy różne techniki debugowania. Stan procesora przed uruchomieniem pierwszego bootloadera możemy obserwować wyłącznie za pomocą JTAG-a.

Moment, w którym procesor pozwoli na przejęcie kontroli modułowi JTAG, zależny jest od konkretnego rozwiązania, jednak powinien być jak najwcześniejszy. Za pomocą JTAG-a możemy także analizować pierwsze rozkazy wydawane przez bootloader. Jest to aplikacja, która znajduje się w pierwszym sektorze pamięci programu odczytywanym podczas startu procesora.

Najczęściej stosowanym bootloaderem jest Uboot. W jego kodzie znajdują się niskopoziomowe procedury napisane w asemblerze danego procesora. Analizując kod Uboota, możemy odszukać miejsce, w którym skonfigurowany zostanie moduł UART. Od tego momentu możemy wysyłać komunikaty przez interfejs szeregowy i w ten sposób kontrolować pracę systemu.

Głównym zadaniem Uboota jest odczytanie kernela z pamięci nieulotnej, umieszczenie go w pamięci RAM i uruchomienie. Ta standardowa procedura wykonywana jest domyślnie, może jednak zostać przerwana przez użytkownika (podobnie jak BIOS w komputerze PC). Uboot oferuje także własną konsolę, z poziomu której możemy wywoływać wiele przydatnych komend i analizować stan systemu przed uruchomieniem kernela.

Zakres komend Uboota jest ustalany przez programistę podczas jego kompilacji i ograniczony jedynie ilością miejsca przeznaczonego na tę część kodu. Standardowy zestaw umożliwia odczytywanie dowolnych adresów pamięci RAM, operacje zapisu i odczytu z pamięci nieulotnej, uruchamianie kodu znajdującego się w RAM-ie czy pobieranie danych binarnych przez interfejs szeregowy.

Pozwalają one na przesłanie kodu kernela lub Uboota poprzez interfejs szeregowy i zapisanie ich w pamięci nieulotnej lub bezpośrednio w RAM-ie. Bardzo często podstawowy zestaw rozszerzony jest o obsługę Ethernetu, FTp czy USB. Konsola Uboota daje niekontrolowany dostęp do zasobów systemu. Umożliwia też nadpisanie samego obrazu Uboota, co może łatwo zakończyć się unieruchomieniem płytki.

W takim przypadku jedynym ratunkiem jest zastosowanie JTAG-a i uruchomienie poprawnego Uboota bezpośrednio z pamięci RAM i ponowne wgranie go do pamięci nieulotnej. Kolejny etap uruchamiania systemu realizowany jest przez kernel. Obraz systemu oraz systemu plików jest najczęściej spakowany za pomocą LZMA. Aplikacja rozpakowująca zawarta jest jeszcze w kodzie Uboota.

Po rozpakowaniu kernela funkcje Uboota nie są już potrzebne i zajmowana przez niego pamięć może być wykorzystana w innym celu. Mając do dyspozycji jeden z popularnych procesorów, możemy się spodziewać, że opracowano i udostępniono już dla niego działającą konfigurację kernela. Głównym zadaniem programisty w tym przypadku będzie dostosowanie sterowników urządzeń zewnętrznych do zaprojektowanego urządzenia.

Zadanie to zostało ułatwione przez wprowadzenie ładowalnych modułów. Są to dodatkowe porcje kodu, które mają bezpośredni dostęp do obszaru pamięci kernela, lecz mogą być ładowane dynamicznie podczas pracy systemu. Oznacza to, że opracowując sterowniki, nie będziemy zmuszeni do ciągłego kompilowania i restartowania całego systemu.

Praca nad sterownikami przypomina w tym przypadku pracę z aplikacją w systemie. Gotowe moduły można dołączyć do kodu kernela lub uruchamiać automatycznie podczas startu systemu. Debugowanie samego kernela jest ciekawym i rozbudowanym tematem wartym osobnego artykułu. Szczegółowe opisy różnorodnych technik analizowania można znaleźć w Internecie.

Bardzo dużo pomocnych informacji uzyskamy już po uruchomieniu systemu za pomocą komendy Dmesg, która wyświetli nam wszystkie komunikaty wysłane podczas startu kernela. Ostatnim i najważniejszym elementem urządzenia jest aplikacja działająca w obszarze użytkownika. Uniwersalność systemu Linux pozwala na realizowanie aplikacji w jakimkolwiek języku, dla którego dostępny jest kompilator lub maszyna wirtualna.

Do prostych zadań wystarczą skrypty powłoki Bash wywołujące odpowiednie polecenia systemowe (podobne do plików .bat w systemie Windows). Jeśli zasoby systemowe są wystarczające, nie ma problemu z uruchamianiem aplikacji Java w obrębie wirtualnej maszyny. Na podobnej zasadzie opiera się system Android. Najodpowiedniejszymi językami wydają się jednak Ansi C lub C++, sam kod Linuksa jest w przeważającej części napisany w Ansi C.

Dla obu tych języków możliwe jest zastosowanie aplikacji Gdb. Gdb jest debugerem działającym na poziomie konsoli. Udostępnia komendy pozwalające na zarządzanie działającą aplikacją. Komendy te podzielone są na funkcjonalne grupy i dobrze opisane. Zapoznanie się z czystym Gdb pozwoli nam w lepszy sposób kontrolować graficzne interfejsy współpracujące z tym programem.

W przypadku urządzeń wbudowanych bardzo często nie mamy możliwości korzystania z interfejsu graficznego bezpośrednio w opracowywanym systemie. W tym celu Gdb oferuje możliwość pracy w konfiguracji klient-serwer. Gdy dodatkowo zintegrujemy całość ze środowiskiem Eclipse, uzyskamy możliwości, jakie oferują nam pakiety programistyczne na PC.

Obie części systemu możemy przygotować na PC. Gdbserver kompilujemy za pomocą cross-kompilatora i uruchamiamy na urządzeniu. Gdb zarządzające zdalnie kompilujemy dla komputera PC, podając, z jaką architekturą będzie on współpracował. Jeśli wszystko zadziała poprawnie, uzyskamy możliwość zdalnego kontrolowania aplikacji poprzez interfejs Ethernet lub szeregowy. Używając środowiska Eclipse, możemy pracować bezpośrednio z kodem źródłowym.

Bartłomiej Grześkowiak