Kiedy w programie na PC nastąpi exception, aplikacja zakończy się z błędem. W skrajnym przypadku dostaniemy blue screena i komputer się zresetuje. Wyjątki są obsługiwane przez system operacyjny. W mikrokontrolerach nie mamy dostępu do takich zaawansowanych funkcjonalności. Jednak w dalszym ciągu możemy wykonać dzielenie przez 0 lub odwołać się do null pointera. W tym artykule omówię w jaki sposób obsługiwać exceptiony w mikrokontrolerach. Zacznę od ogólnej procedury, następnie skupię się na detalach specyficznych dla procesorów PIC32 i Cortex-M.

Procedura obsługi wyjątków

Obsługa wyjątków powinna umożliwić developerowi diagnostykę błędu, a następnie w zależności od rodzaju aplikacji albo pozostać w procedurze błędu, albo przywrócić system do działania.

Pierwsze zadanie, czyli diagnostyka, realizuje się poprzez zapisanie stanu odpowiednich rejestrów procesora zawierających informacje o błędach. Pierwszą operacją obsługi błędu jest często wykonanie dumpu rejestrów CPU na stos. Dzięki temu mamy dostęp do ich wartości w momencie wystąpienia błędu. Gdybyśmy chcieli dalej w procedurze obsługi odczytać je bezpośrednio, niektóre wartości mogły by zostać nadpisane.

Bardzo ważnym elementem diagnostyki jest już sama informacja, że system wszedł w obsługę exceptiona. Warto więc postawić tam również instrukcję softwareowego breakpointa, przynajmniej w buildzie debugowym. Jjeżeli procesor nie jest w debugu i trafi na instrukcję breakpointa, może się zawiesić. Dlatego warto skorzystać z makr i kompilacji warunkowej.

Kolejnym ważnym pytaniem jest jaki powinien być efekt funkcji obsługującej exception? Wszystko zależy od wymagań stawianych aplikacji. Jeżeli priorytetem jest dostępność np. w webserwerze, system powinien się zresetować. W innej aplikacji pożądanym działaniem może być przejście w nieskończoną pętlę, aby użytkownik wiedział o wystąpieniu błędu.

Nawet jeśli docelowo procedura obsługi ma wykonować reset, do celów testowych warto jest wyraźnie sygnalizować exception. Jest to szczególnie przydatne przy testach obciążeniowych, które mogą trwać dłuższy czas na większej ilości urządzeń i możemy przegapić restart systemu.

Warto tutaj poruszyć również kwestię logowania błędów. W wielu aplikacjach np. bezpieczeństwa exception powinien być zalogowany np. do pamięci zewnętrznej. Jednak procedura obsługi nie jest do tego najlepszym miejscem. Dlaczego? Ponieważ stan systemu nie jest stabilny oraz nie możemy wykorzystywać przerwań (exception jest najwyższym możliwym przerwaniem, więc nic go nie wywłaszczy). W procedurze obsługi powinniśmy jedynie zebrać informacje o błędzie i zapisać je do obszaru pamięci, który nie zmieni się po restarcie systemu (różne procesory udostępniają w tym celu różne mechanizmy). I dopiero po restarcie wykonać właściwe logowanie.

Exceptiony w PIC32

Procesory PIC32 od Microchipa są wykonane w architekturze MIPS. Obsługując na nich wyjątki należy uważać na pewną pułapkę. Wydawałoby się, że w przypadku wystąpienia exceptiona wywoływana jest funkcja _general_exception_handler. Wystarczy więc umieścić tam obsługę przerwania. Otóż nie! W PIC32 mamy kilka funkcji obsługi exceptionów uruchamiających się w zależności od typu błędu. Są to:

  • Bootstrap Exception – handler dla exceptionów wywołanych w sekcji kodu bootloadera.
  • Simple TLB (Translation Lookaside Buffer) Refill Exception – handler dla błędu związanego z translacją adresów wirtualnych na fizyczne (tak, PIC32 mają adresy fizyczne i wirtualne i w związku z tym powstają różne dziwne problemy).
  • Cache Error Exception – handler dla błędu związanego z cacheowaniem instrukcji i danych.
  • General Exception – handler dla pozostałych exceptionów wywołanych podczas wykonywania właściwego programu.

O typach exceptionów w PIC32 można więcej poczytać tutaj. Dla każdego z tych rodzajów mamy oddzielną procedurę obsługi:

void _bootstrap_exception_handler(void);
void _general_exception_handler (unsigned cause, unsigned status);
void _simple_tlb_refill_exception_handler(void);
void _cache_err_exception_handler(void);

O ile bootstrap exception można pominąć – to kod bootloadera powinien się martwić o ten typ exceptionów – wszystkie pozostałe handlery powinniśmy mieć zaimplementowane. W przeciwnym wypadku mogą zdarzyć się trudne do zdebugowania błędy. Procesor się zawiesza, debugger nic nie pokazuje, a myślimy, że nie ma exceptiona, bo do handlera też nie wszedł. Wiem z autopsji i nie polecam.

W samej procedurze obsługi zbieramy dane z dwóch rejestrów procesora:

  • Cause Register (CP0 Register 13, Select 0) – zawiera kod błędu (listę kodów można znaleźć w dokumentacji procesora).
  • EPC Register (CP0 Register 14, Select 0) – zawiera adres instrukcji, która spowodowała błąd.

Exceptiony w ARM Cortex-M

W architekturze ARM Cortex-M jest wykonanych wiele popularnych mikrokontrolerów takich jak STM32, NXP, Freescale Kinetis, Texas Stellaris, Atmel, Renesas i wiele innych. Mimo, że tutaj również mamy kilka rodzajów exception handlerów, podejście do ich obsługi jest nieco inne niż w PIC32.

Tak naprawdę to podejście sami definiujemy, ponieważ musimy dostarczyć kompilatorowi pliki startupowe. W PIC32 kompilator wykorzystywał domyślny kod startupowy będący częścią biblioteki standardowej. Z kolei w ARM zwykle te pliki generuje nam IDE przy tworzeniu projektu, albo używamy jakiegoś gotowego szablonu (jak na przykład mój szablon na STM32). Wtedy najczęstszą praktyką jest zdefiniowanie głównego handlera – HardFault_Handler, a pozostałe handlery są zdefiniowane jako weak aliasy głównego. Czyli jeżeli nie dodamy ich implementacji, domyślnie będzie się wykonywał HardFault. Na pewno jest to dużo bardziej praktyczne rozwiązanie.

Dostępne rodzaje exception handlerów w Cortexach to:

  • Usage Fault – próba wywołania nieistniejącej instrukcji lub niedozwolony stan procesora.
  • Bus Fault – błędy szyny danych.
  • Memory Manage – błędy związane z Memory Protection Unit i próbą dostępu do niedozwolonego obszaru pamięci.
  • Hard Fault – Wszystkie rodzaje błędów nie obsłużone przez inne handlery.

Obsługując przerwanie zapisujemy zawartość rejestrów:

  • SCB->CFSR – zawiera flagi mówiące o przyczynie błędu.
  • SCB->HFSR – dodatkowe flagi z informacjami o przyczynie błędu.
  • SCB->MMFAR – adres, który spowodował Memory Manage Exception.
  • SCB->BFAR – adres, który spowodował Bus Fault Exception.
  • SCB->AFAR – dodatkowe informacje zależne od konkretnego procesora.

Przydatny jest również dump rejestrów procesora. Szczególnie wskaźnika instrukcji (PC) i adresu powrotu do wywołującej funkcji (LR).  W swoim micromousie w tym celu wykorzystuje poniższy kod na Cortex-M4.

/**
 * @brief Hardfault handler written in assembler.
 *
 * Program jumps here in a case of error. This function dumps core registers
 * and passes them to @HardFault_HandlerC so they could be seen in debugger.
 */
/* Use naked attribute so stack can be handled manually, not by the compiler. */
void __attribute__((naked)) HardFault_Handler(void)
{
    /*
     * Read correct stack pointer depending on core mode of operation. Then
     * is passed to the main hardfault handling function.
     */
    __asm volatile
    (
            " tst lr, #4                                                \n"
            " ite eq                                                    \n"
            " mrseq r0, msp                                             \n"
            " mrsne r0, psp                                             \n"
            " ldr r1, [r0, #24]                                         \n"
            " ldr r2, handler2_address_const                            \n"
            " bx r2                                                     \n"
            " handler2_address_const: .word HardFault_HandlerC			\n"
    );
}

/**
 * @brief Function reading dumped core registers.
 *
 * Function reads CPU registers from the stack dumped by the assembler function.
 *
 * @param hardfault_args    Core registers passed by assembler function.
 */
void HardFault_HandlerC(unsigned long *hardfault_args)
{
    __attribute__((unused)) volatile unsigned long stacked_r0;
    __attribute__((unused)) volatile unsigned long stacked_r1;
    __attribute__((unused)) volatile unsigned long stacked_r2;
    __attribute__((unused)) volatile unsigned long stacked_r3;
    __attribute__((unused)) volatile unsigned long stacked_r12;
    __attribute__((unused)) volatile unsigned long stacked_lr;
    __attribute__((unused)) volatile unsigned long stacked_pc;
    __attribute__((unused)) volatile unsigned long stacked_psr;
    __attribute__((unused)) volatile unsigned long _CFSR;
    __attribute__((unused)) volatile unsigned long _HFSR;
    __attribute__((unused)) volatile unsigned long _AFSR;
    __attribute__((unused)) volatile unsigned long _BFAR;
    __attribute__((unused)) volatile unsigned long _MMAR;

    stacked_r0 = ((unsigned long) hardfault_args[0]);
    stacked_r1 = ((unsigned long) hardfault_args[1]);
    stacked_r2 = ((unsigned long) hardfault_args[2]);
    stacked_r3 = ((unsigned long) hardfault_args[3]);
    stacked_r12 = ((unsigned long) hardfault_args[4]);
    stacked_lr = ((unsigned long) hardfault_args[5]);
    stacked_pc = ((unsigned long) hardfault_args[6]);
    stacked_psr = ((unsigned long) hardfault_args[7]);

    _CFSR = (*((volatile unsigned long *) (0xE000ED28)));
    _HFSR = (*((volatile unsigned long *) (0xE000ED2C)));
    _AFSR = (*((volatile unsigned long *) (0xE000ED3C)));
    _MMAR = (*((volatile unsigned long *) (0xE000ED34)));
    _BFAR = (*((volatile unsigned long *) (0xE000ED38)));

    /* todo: check if program reaches hardfault during OpenOCD start. */
    NVIC_SystemReset();
}

Podsumowanie

W artykule poza ogólnymi zasadami zwróciłem uwagę na kilka specyficznych problemów takich jak różne rodzaje przerwań, czy trudności z zalogowaniem błędu bezpośrednio w procedurze obsługi exceptionów. Informacje tutaj zawarte są efektem kilku lat praktyki i wielu godzin spędzonych na debugowaniu. Mam nadzieję, że dzięki zawartym tu informacją nie będziesz musiał wymyślać koła na nowo.