Pracujemy nad naszą aplikacją, wgrywamy ją na procka, uruchamiamy i nie działa. Uruchamiamy debug i widzimy, że program wchodzi do Hard Faulta. Co teraz?

Na szczęście w STM32 mamy całkiem bogate możliwości sprawdzenia, co dokładnie się wydarzyło. Co więcej – to samo aplikuje się do dowolnych innych ARMów z rdzeniem Cortex-M. W tym artykule pokażę Ci przydatne informacje, do których możesz dotrzeć w debugu.

Skupię się na rdzeniu Cortex-M4, ale podobne mechanizmy znajdziesz w każdym innym Cortexie-M. A jako dokumentacja posłuży mi STM32 Cortex-M4 Core Programming Manual. Znajdziesz go na stronie ST.

A więc zaczynamy.

Fault Status Register

Rdzeń Cortex-M4 zawiera swoje własne rejestry peryferiów. To za ich pomocą konfigurujemy SysTicka, kontroler przerwań, czy koprocesor FPU. Ale w tym wypadku najbardziej interesuje nas System Control Block zawierający między innymi status wyjątków procesora. Czyli przerwań i błędów.

Poszukiwania przyczyny Hard Faulta powinniśmy rozpocząć w rejestrze CFSR (Configurable Fault Status Register).

Nawet bez wchodzenia w detale poszczególnych bitów widzimy, że znajdziemy tutaj ciekawe informację. Flagi DIVBYZERO czy UNALIGNED mówią same za siebie. Mamy też różne rodzaje błędów dotyczących zarządzania pamięcią i szynami danych. Ale to nie wszystko!

Dalej mamy rejestry MMFAR (Memory Management Fault Address Register) i BFAR (Bus Fault Address Register). Przy błędach dotyczących pamięci i szyn danych możemy w nich odczytać adresy, które spowodowały błąd. Ale tutaj ważna uwaga – czasem błąd jest wykryty dopiero kilka instrukcji po wystąpieniu jego przyczyny. Mamy wtedy do czynienia z błędem asynchronicznym. Dlatego operacja na pamięci może być wykonana kilka instrukcji przed linijką wskazywaną przez PC (do tego jeszcze dojdziemy).

Często już sama informacja, że to błąd z wyrównaniem pamięci, albo dzieleniem przez zero jest wystarczająca, abyśmy zlokalizowali przyczynę. Jednak na tym nasze opcje się nie kończą.

Core Dump

Podczas wejścia do każdego exceptiona (nie tylko Hard Faulta) procesor zmienia kontekst i zrzuca na stos zawartość niektórych rejestrów CPU. Czyli ramkę stosu (stack frame). Oczywiście zawartość i kolejność tych rejestrów na stosie jest dokładnie opisana w dokumentacji:

Co w nich znajdziemy?

  • xPSR: Flagi CPU.
  • PC (Program Counter): instrukcja w której nastąpił exception.
  • LR ( Link Register): adres powrotu do funkcji, która wywołała ten kod.
  • R0-R3 – rejestry przechowujące argumenty funkcji i dane tymczasowe.
  • FPSCR i S0-S15: rejestry FPU – zwykle nas nie będą obchodzić.

Najbardziej mogą nam tutaj pomóc adresy zawarte w PC i LR. Kiedy znajdziemy je w disasembly – doprowadzą nas do linijki, która spowodowała błąd i funkcji, która ją wywołała. Czasem mogą się przydać też zawartości rejestrów R0-R3.

Tutaj jeszcze jedna ważna rzecz – rejestry są zrzucane na stosie przerywanego procesu. Dlaczego to ważne? Bo w Cortex-M mamy dwa tryby pracy:

  • Handler Mode – czyli obsługa exceptionów.
  • Thread Mode – czyli obsługa pętli głównej.

Mamy również dwa wskaźniki stosu:

  • MSP (Main Stack Pointer)
  • PSP (Process Stack Pointer)

Możemy nasz procesor skonfigurować żeby zawsze używał MSP, albo żeby w pętli głównej używał PSP, a MSP tylko w exceptionach. Nie będziemy tutaj wchodzić w szczegóły dlaczego są dwa stosy i co z tego wynika. Z punktu widzenia debugu Hard Faulta musimy tylko wiedzieć, że kontekst może znajdować się na jednym z dwóch różnych stosów – MSP albo PSP.

A w związku z tym musimy odpowiedzieć sobie na ważne pytanie.

Jak wykryć na którym stosie jest Core Dump?

Domyślne ustawienie np. w projektach z STM32 Cube zakłada używanie samego MSP, co znacznie ułatwia sprawę. W takim wypadku możesz pominąć ten rozdział i po prostu odczytywać ramkę stosu z jedynego stosu jaki posiadasz.

Jednak w niektórych RTOSach wykorzystywane są oba stosy. I wtedy sytuacja się komplikuje. Na szczęście w dokumentacji możemy znaleźć następujący fragment:

Exception return

Exception return occurs when the processor is in Handler mode and executes one of the following instructions to load the EXC_RETURN value into the PC:

• an LDM or POP instruction that loads the PC

• an LDR instruction with PC as the destination

• a BX instruction using any register.

EXC_RETURN is the value loaded into the LR on exception entry. The exception mechanism relies on this value to detect when the processor has completed an exception handler. The lowest five bits of this value provide information on the return stack and processor mode. Table 18 shows the EXC_RETURN values with a description of the exception return behavior.

All EXC_RETURN values have bits[31:5] set to one. When this value is loaded into the PC it indicates to the processor that the exception is complete, and the processor initiates the appropriate exception return sequence

Następnie mamy taką tabelkę:

Co z tego możemy wywnioskować?

W momencie wejścia do exceptiona procesor umieszcza w rejestrze LR kod zawierający między innymi informacje o używanym stosie. Pozostałe informacje dotyczą, czy wracamy do Handler Mode, czy do Thread mode, albo czy używamy ramki stosu z floatami, czy bez.

Musimy więc odczytać rejestr LR. Oczywiście chodzi o wartość rejestru LR podczas wykonywania kodu obsługi Hard Faulta, a nie w ramce stosu, do której próbujemy się dostać. A następnie określić używany stos na podstawie kodu EXC_RETURN.

Po analizie tabelki możemy dojść do wniosku, że procesor używa PSP, kiedy bit nr 2 jest ustawiony na 1, a MSP, kiedy jest ustawiony na 0.

Mamy to. Teraz możemy zobaczyć, jak te wszystkie informacje podejrzeć w debugu na sprzęcie.

Debug przykładowego Hard Faulta

Debuguję projekt w STM32 Cube i program wchodzi do Hard Faulta. Pierwsze co robię, to przeglądam System Control Block i rejestr CFSR.

Widzę tutaj zapalony bit DIVBYZERO. Normalnie to powinno wystarczyć, ale załóżmy, że chcę znaleźć dokładną linijkę powodującą błąd. Drugim krokiem będzie określenie stosu, czyli odczytanie rejestru LR:

Po raz kolejny – kiedy wiem, że korzystam tylko z jednego stosu – mogę sobie oszczędzić roboty i pominąć ten krok. Odczytałem 0xFFFFFFF9 czyli Handler mode, non floating point, MSP.

Teraz odczytuje aktualny adres ze stack pointera.

Ułatwiłem sobie zadanie, bo korzystam tylko z MSP. Ale w widoku rejestrów mam też dostęp do MSP i PSP.

Teraz pora na odczytanie core dumpa bezpośrednio z pamięci:

Odczytuje kolejne dane od adresu 0x2001FFA8. Dane w Memory Dumpie są zapisane trochę po chińsku ze względu na tryb Little Endian. Czyli najstarszy bajt jest ostatni. W podświetlonej komórce tak naprawdę jest wartość 0x20000000.

Mogę więc odczytać po kolei zgodnie z ramką stosu:

  • R0 = 0x20000000
  • R1 = 0x20000018
  • R2 = 0x0000E803 (czyli 1000 dziesiętnie)
  • R3 = 0x00000000
  • R12 = 0x00000000
  • LR = 0x08000423
  • PC = 0x08000220
  • xPSR = 0x61000000

Czyli exception nastąpił w instrukcji pod adresem spod PC. Idę do niego w Disassembly:

Wpisuję go w wyszukiwarkę adresów na górze i wychodzi mi winowajca:

udiv R3, R2, R3

Udiv to operacja dzielenia. Wynik zapisujemy do R3, a dzielimy R2 przez R3. W R2 mamy wartość 1000 a w R3 mamy 0.

Znaleźliśmy nasze dzielenie przez zero!

Disasembly mówi, że jest na początku funkcji main przed linią 27 w kodzie c.

A oto miejsce zbrodni:

Oczywiście specjalnie zrobiłem taki prosty przykład, żeby pokazać jak wykorzystać wiedzę o działaniu procesora w praktyce. Rzeczywiste błędy na pewno będą dużo lepiej ukryte. Ale korzystając z tych samych kroków jesteśmy w stanie dojść do rozwiązania.

A przynajmniej do instrukcji powodującej błąd. Bo czasem jeszcze musimy spędzić sporo czasu, aby zrozumieć dlaczego tak się dzieje i jak to zmienić. No cóż – takie uroki debugowania.

Gotowy kod do obsługi Hard Faultów

Czasami można znaleźć w internecie gotowce z procedurami obsługi hard faultów. Po wklejeniu do naszego projektu robią dokładnie to samo, co my zrobiliśmy ręcznie – sprawdzają z którego stosu korzystamy, odczytują ramkę stosu i przypisują poszczególne dane z core dumpa do zmiennych w C.

Zwykle taki kod jest napisany w C z wykorzystaniem wstawek asemblerowych. Musmy też uważać, bo implementacje dla Cortex-M0/1/3/4 mogą się trochę różnić. Poza tym do losowego kodu z neta lepiej zawsze podchodzić z rezerwą.

Przykładowa implementacja Hard Faulta dla Cortex-M4 może wyglądać tak:

__attribute__((naked))
void HardFault_Handler(void)
{
asm(
    	"tst lr, 1 << 2\n\t"
    	"ite eq\n\t"
    	"mrseq r0, msp\n\t"
    	"mrsne r0, psp\n\t"
    	"ldr r3, =HardFault_HandlerC\n\t"
    	"bx r3\n\t"
);

void HardFault_HandlerC(uint32_t *hardfault_dump)
{
	__attribute__((unused)) volatile uint32_t r0 = hardfault_dump[0];
	__attribute__((unused)) volatile uint32_t r1 = hardfault_dump[1];
	__attribute__((unused)) volatile uint32_t r2 = hardfault_dump[2];
	__attribute__((unused)) volatile uint32_t r3 = hardfault_dump[3];
	__attribute__((unused)) volatile uint32_t r12 = hardfault_dump[4];
	__attribute__((unused)) volatile uint32_t lr = hardfault_dump[5];
	__attribute__((unused)) volatile uint32_t pc = hardfault_dump[6];
	__attribute__((unused)) volatile uint32_t xpsr = hardfault_dump[7];

  while (1)
	;
}

Podsumowanie

Mam nadzieję, że po przeczytaniu tego artykułu będziesz dokładnie wiedzieć gdzie szukać informacji o przyczynach Hard Faultów i pomoże Ci to w sprawnym usuwaniu błędów z aplikacji.

Jak widzisz potrzebowaliśmy tutaj wiedzy o działaniu procesora podczas obsługi exceptionów, o zawartości poszczególnych rejestrów CPU i stosu. Musieliśmy również podglądać konkretne komórki pamięci RAM i konkretne linie programu w asemblerze.

Oczywiście możemy zamiast tego użyć gotowca z internetu. Ale nawet wtedy musimy zdawać sobie sprawę z istnienia takich możliwości, a odczytane dane musimy potrafić odpowiednio wykorzystać.

Jesteśmy programistami niskopoziomowymi i w pewnym momencie nie uciekniemy od asemblera, rejestrów CPU i adresów pamięci. A jak zdobyć tą wiedzę?

O tym będę mówić na webinarze Asembler w Embedded: Od czego zacząć?

Zapisz się już dziś!