Piszemy bootloader dla STM32, cz. 1

Napisano dnia 24.01.2021 r. o godzinie 8:00
Autor: Piotr Sperka

Wstęp

Tematem pisania bootloadera dla STM32 zainteresowałem się w momencie, gdy zaczął wymagać tego ode mnie realizowany projekt. Na początku chciałem zastosować jakieś gotowe rozwiązanie, jednak z uwagi na specyfikę projektu praktycznie żadne dostępne darmowe rozwiązanie nie było wystarczające. Wtedy postanowiłem dokładniej zgłębić zagadnienie i samemu napisać potrzebny bootloader. W związku z tym stwierdziłem, że warto stworzyć serię dwóch lub trzech artykułów na ten temat. Doprowadzą one do stworzenia uniwersalnego szablonu bootloadera i aplikacji, które możemy dostosować dokładnie do swoich potrzeb. Tak naprawdę, miały one powstać już blisko dwa lata temu… Cóż, jak to mówią, lepiej późno niż wcale. Ale od początku…

Czym jest bootloader?

Być może zapytasz: a cóż to takiego jest bootloader? Otóż w skrócie bootloader jest programem, który uruchamia inny program. Może on również udostępniać różne inne funkcje, jak na przykład pobieranie kopii zapasowej czy też aktualizację oprogramowania bez konieczności posiadania programatora. I w moim przypadku była to główna funkcja, którą chciałem wykorzystać. Oto co chcemy osiągnąć: po włączeniu zasilania mikrokontroler zaczyna wykonywać program umieszczony na początku pamięci, który to program jest naszym bootloaderem. W normalnych warunkach jedynym zadaniem bootloadera jest załadowanie i uruchomienie docelowego programu. W STM32 (jak i zapewne w większości mikrokontrolerów), bootloader i program docelowy znajdują się w tej samej pamięci FLASH. Wynika z tego, że w normalnych warunkach zadaniem bootloadera jest wykonanie skoku do dalszej części pamięci, gdzie znajduje się aplikacja użytkownika, czyli nasz docelowy program. Jednakże, w przypadku wstąpienia odpowiednich okoliczności (na przykład naciśnięcia przycisku, odebrania odpowiedniej komendy, itp.) bootloader powinien:

  1. Pobrać nową wersję oprogramowania (aplikacji użytkownika) poprzez wybrany interfejs. Może to być na przykład UART, SPI, karta SD, Ethernet, USB, czy też dowolny inny pożądany sposób.
  2. Wgrać ją do pamięci FLASH w odpowiednim miejscu, zastępując poprzednią wersję aplikacji użytkownika.
  3. Opcjonalnie zweryfikować poprawność wgranego programu.
  4. Uruchomić aplikację, czyli skoczyć do jej początku.

Na poniższym rysunku przedstawiłem schematycznie jak może wyglądać organizacja pamięci w przypadku standardowego oprogramowania dla mikrokontrolera, jak i w przypadku zastosowania bootloadera.

Mapa pamięci programu STM32 dla wersji bez oraz z bootloaderem

Mapa pamięci programu STM32 dla wersji bez oraz z bootloaderem

Ponieważ założenie jest takie, że bootloader wgrywany jest raz, przy pomocy programatora, a później już raczej go nie aktualizujemy (po oddaniu urządzenia do użytkownika końcowego), musi być on napisany dosyć porządnie. Oczywiście, można w bootloader lub aplikację użytkownika wbudować opcję aktualizacji bootloadera, jednak w dzisiejszym artykule skupiamy się na prostym rozwiązaniu. Warto też postarać się, aby ilość pamięci zajmowanej przez bootloader była jak najmniejsza. Wiadomo – im większy bootloader, tym mniej miejsca zostanie dla docelowej aplikacji.

Więcej szczegółów…

Nim przejdę do dalszych zagadnień, musimy poznać kilka dodatkowych szczegółów dotyczących budowy programu mikrokontrolera. Jak zapewne wiesz, po uruchomieniu mikrokontroler zaczyna wykonywać program od początkowego adresu w pamięci programu. Najczęściej jednak sam program znajduje się nieco dalej (początek funkcji main() w przypadku języka C/C++), natomiast na samym początku pamięci umieszczone są tak zwane wektory przerwań. Są to wskaźniki na funkcje (adres w pamięci pod który należy skoczyć), które należy wywołać w przypadku wystąpienia danego przerwania. W zrozumieniu tego zagadnienia na pewno pomoże Ci choćby pobieżna znajomość assemblera i zasady działania mikroprocesora, jednak postaram się sprawę wyjaśnić możliwie prosto.

Jak to działa w praktyce? Załóżmy, że wektor przerwania timera SysTick znajduje się pod adresem 0x003C w tablicy przerwań i jest tam zapisana czterobajtowa wartość 0x00010020. W przypadku wystąpienia tego przerwania, mikrokontroler wywoła funkcję znajdującą się pod adresem 0x00010020. Innymi słowy, wykona skok pod wspomniany adres. Jest to dla nas bardzo ważna informacja, ponieważ nie chcemy, aby po uruchomieniu aplikacji użytkownika wciąż wykonywały się funkcje przerwań zdefiniowane w bootloaderze.

W przypadku STM32 pod adresem 0x0000 znajduje się wartość jaką należy wpisać do rejestru SP (Stack Pointer), natomiast pod adresem 0x0004 umieszczony jest pierwszy wektor przerwania – Reset. To pod ten adres wykonywany jest skok podczas resetu (lub uruchomienia) mikrokontrolera. Jak się okaże, oba te adresy będą dla nas bardzo istotne podczas wykonywania skoku do aplikacji użytkownika. Dodatkowo muszę też wspomnieć o rejestrze VTOR, który również będzie dla nas bardzo istotny. Umożliwia on bowiem realokację tablicy wektorów przerwań pod inny adres początkowy, czyli, mówiąc inaczej, określa początkowy adres tablicy przerwań. Opierając się na wcześniejszym przykładzie załóżmy, że do VTOR wpiszemy wartość 0x00010000. Wtedy, po wystąpieniu przerwania SysTick, mikrokontroler nie będzie szukał adresu funkcji pod adresem 0x003C w pamięci, a pod adresem 0x0001003C. Mam nadzieję, że nawet jeśli w tym momencie nie wszystko jest jasne, stanie się jasne po przejściu do przykładów.

Fragment tablicy wektorów przerwań dla STM32F2 zaczerpnięty z dokumentacji

Fragment tablicy wektorów przerwań dla STM32F2 zaczerpnięty z dokumentacji

Czym będziemy się dzisiaj zajmować?

Zagadnienie napisania bootloadera jest jednocześnie łatwe i trudne. Łatwe, ponieważ na tle różnych programów jego działanie nie jest bardzo złożone. Trudne, ponieważ pośród dostępnych opisów i źródeł, zagadnienie bootloadera i jego działania stosunkowo rzadko poruszane. W części pierwszej tej serii zajmiemy się stworzeniem absolutnych podstaw. Stworzymy dwie aplikacje: bootloader oraz aplikację użytkownika. Zadaniem tego pierwszego będzie trzykrotnie mignięcie diodą i wykonanie skoku do aplikacji użytkownika. Zadaniem tej drugiej będzie miganie inną diodą. Może wydawać Ci się to absolutnie trywialne, jednak solidne podstawy są najważniejsze w każdym projekcie. Mając takie podstawy oraz działający szablon, będziemy mogli bez przeszkód w kolejnej części zająć się rozszerzeniem funkcjonalności bootloadera o programowanie pamięci FLASH.

W prototypie wykorzystałem płytkę z mikrokontrolerem STM32F207VCT6. Oprogramowanie napisałem w środowisku JetBrains CLion wraz z toolchainem arm-none-eabi oraz STM32CubeMx. Zestaw ten wykorzystuje CMake, a więc powinien się łatwo zintegrować z wieloma innymi IDE.

Do dzieła!

Zacznijmy od stworzenia dwóch kompilujących się projektów pod nasz mikrokontroler. Ponieważ z czasem oba projekty będą rozbudowywane, wykorzystałem bibliotekę HAL. W podstawowej wersji jedyną konfiguracją wykonaną w STM32CubeMx było ustawienie pinów sterujących diodami jako wyjścia. W moim przypadku w obu z nich migałem jedną z dwóch diod z różną częstotliwością:

while (1) {
    HAL_GPIO_TogglePin(GPIOE, GPIO_PIN_7);
    HAL_Delay(500);
}
while (1) {
    HAL_GPIO_TogglePin(GPIOE, GPIO_PIN_8);
    HAL_Delay(250);
}

Jeżeli po zaprogramowaniu układu nasza dioda miga zgodnie z oczekiwaniami, pierwszy jest krok za nami. W tym momencie oczywiście wgranie drugiego programu nadpisuje pierwszy. Przyszedł czas to zmienić. W tym celu drugi program (aplikację użytkownika) musimy przenieść w dalszą część pamięci. Aby tego dokonać, po pierwsze musimy zmodyfikować skrypt linkera w obu programach. Ponieważ bootloader będzie rozbudowywany, dla testu postanowiłem przeznaczyć na niego pierwsze 64kb pamięci. W przypadku bootloadera zmodyfikowałem w pliku STM32F207VCTX_FLASH.ld linię:

FLASH    (rx)    : ORIGIN = 0x8000000,   LENGTH = 256K

na:

FLASH    (rx)    : ORIGIN = 0x8000000,   LENGTH = 64K

W przypadku aplikacji użytkownika, chcemy przeznaczyć na nią resztę dostępnej pamięci, czyli w tym wypadku: 256kb – 64kb = 192kb. Chcemy także odsunąć ją od początkowego adresu o 64kb (które zajmuje bootloader), czyli w systemie heksadecymalnym o 0x10000. Aby to osiągnąć, modyfikujemy w projekcie aplikacji użytkownika tą samą linię co powyżej na:

FLASH    (rx)    : ORIGIN = 0x8010000,   LENGTH = 192K

Po tej modyfikacji linker powinien odpowiednio umieścić nasz program w pamięci. Wspomniałem wcześniej o tablicy wektorów przerwań, i o tym, że będziemy ją realokować. Aby wszystko działało poprawnie, poza wskazaniem offsetu w rejestrze VTOR, trzeba również wskazać offset tablicy przerwań w pliku /Core/Src/system_stm32f2xx.c. Linię:

#define VECT_TAB_OFFSET         0x00000000U

zmieniamy na:

#define VECT_TAB_OFFSET         0x00010000U

Musimy równiez odkomentować linię:

#define USER_VECT_TAB_ADDRESS

Jeżeli teraz skompilujemy oba programy, niezależnie który wgramy jako ostatni, po resecie zawsze powinien wykonać się kod bootloadera.

Skok do aplikacji użytkownika

Skoro mamy już odpowiednio umieszczony w pamięci bootloader oraz aplikację użytkownika, możemy zająć się wykonaniem skoku z bootloadera do aplikacji. Aby to zrobić poprawnie, musimy wykonać kilka czynności:

  1. Zdeinicjalizować wszystkie wykorzystywane w bootloaderze peryferia.
  2. Zresetować timer SysTick.
  3. Ustawić offset tablicy wektorów przerwań (SCB→VTOR).
  4. Ustawić wskaźnik stosu (odczytany z tablicy przerwań z adresu 0x0000 [względnego do początku programu aplikacji użytkownika]).
  5. Wykonać skok do początku (punktu wejściowego) aplikacji użytkownika, czyli pod adres wskazywany przez wektor Reset. Jak wcześniej wspominałem, znajduje się on pod adresem 0x0004 względem początku programu.

Kod realizujący powyższe czynności wygląda następująco:

#define APP_ADDRESS (uint32_t)0x08010000

typedef void (*pFunction)(void);
void JumpToAddress(uint32_t addr) {
    uint32_t JumpAddress = *(uint32_t *) (addr + 4);
    pFunction Jump = (pFunction) JumpAddress;

    HAL_RCC_DeInit();
    HAL_DeInit();
    SysTick->CTRL = 0;
    SysTick->LOAD = 0;
    SysTick->VAL = 0;

    SCB->VTOR = addr;
    __set_MSP(*(uint32_t *) addr);

    Jump();
}

void JumpToApplication() {
    JumpToAddress(APP_ADDRESS);
}

Jako parametr funkcji podajemy oczywiście adres początku aplikacji użytkownika, czyli w tym wypadku 0x8010000. Teraz, jeżeli zmodyfikujemy pętlę migającą diodą w bootloaderze w ten sposób:

while (1) {
    for (int i = 0; i < 6; i++) {
        HAL_GPIO_TogglePin(GPIOE, GPIO_PIN_7);
        HAL_Delay(500);
    }

    JumpToApplication();
}

program powinien mignąć nią trzykrotnie, a następnie rozpocząć wykonywanie aplikacji użytkownika. Czyli zacząć migać inną diodą… W tym momencie możemy bez problemów modyfikować zarówno bootloader, jak i aplikację użytkownika. Debugowanie również działa bez zastrzeżeń.

Podsumowanie

Podsumowując, w dzisiejszym artykule przedstawiłem ogólną koncepcję działania bootloadera. Wykorzystałem do tego mikrokontroler z rodziny STM32, jednak koncepcja ta będzie podobna w przypadku wielu innych mikrokontrolerów. Pokazałem również, w jaki sposób można wykonać skok do aplikacji użytkownika. W następnej części serii, która ukaże się za tydzień, zajmiemy się bardzo ważną funkcją bootloadera, czyli programowaniem aplikacji użytkownika do pamięci FLASH.

Cały kod, który powstał na podstawie dzisiejszego artykułu jest dostępny TUTAJ.

I jak zawsze: jeśli masz jakieś pytania lub uwagi, nie wahaj się napisać do mnie maila. Część druga dostępna jest TUTAJ.