Piszemy bootloader dla STM32, cz. 2

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

Wstęp

Witaj w drugiej części serii artykułów poświęconej tworzeniu bootloadera dla mikrokontrolera STM32. W pierwszej części przedstawiłem Ci, czym jest, i jak działa bootloader. Napisaliśmy również podstawową funkcję, dzięki której możliwe jest realokowanie wektora przerwań i wykonanie skoku do aplikacji użytkownika. Teraz przyszedł czas na obsługę aktualizacji oprogramowania, czyli programowanie pamięci FLASH. Dodatkowo pokażę Ci prosty sposób na sprawdzenie, czy w ogóle wgrana jest aplikacja użytkownika.

Podstawy obsługi pamięci FLASH

Ponieważ w projekcie bootloadera wykorzystujemy biblioteki HAL, obsługa programowania FLASH nie jest szczególnie trudna. Musimy w zasadzie pamiętać tylko o jednej istotnej sprawie. Z uwagi na naturę działania pamięci FLASH, przed wgraniem nowego programu musimy ją najpierw wyczyścić. Nie możemy zwyczajnie nadpisać istniejącej zawartości, gdyż narazimy się na duże ilości błędów. Dlaczego? Otóż wyczyszczona pamięć FLASH ma ustawioną wartość każdego bajtu na 0xFF, czyli binarnie na 0b11111111. Zapisując dane do pamięci, możemy jedynie ustawić wybrane jedynki na zero, ale nie w drugą stronę. Stąd też, jeżeli nie wyczyścimy wcześniej pamięci i do komórki zawierającej bajt 0b11110000 postanowimy zapisać bajt 0b00001111, uzyskamy 0b00000000 – czyli zdecydowanie nie to, co chcieliśmy. Jest jeszcze jeden haczyk – w przypadku zdecydowanej większości pamięci FLASH, nie możemy wyczyścić tylko wybranego bajtu, a jedynie całą stronę lub sektor. Rozmiar takiej strony lub sektora to zależnie od mikrokontrolera od kilkuset bajtów do kilkuset kilobajtów.

Uzbrojeni w powyższą wiedzę, możemy stwierdzić jakie funkcje potrzebujemy stworzyć:

  1. Czyszczenie pamięci (wybranych stron lub sektorów – zależnie od mikrokontrolera).
  2. Programowanie pamięci.
  3. Odczytywanie pamięci (na przykład na potrzeby pobrania kopii zapasowej).
Organizacja pamięci FLASH dla STM32F207 zaczerpnięta z dokumentacji

Organizacja pamięci FLASH dla STM32F207 zaczerpnięta z dokumentacji

Czyszczenie pamięci

Na początek zajmijmy się czyszczeniem pamięci FLASH. W przypadku naszego mikrokontrolera – STM32F207VCT6 – wyposażonego w 256 kB pamięci FLASH interesują nas sektory od 0 do 5 z powyższej tabeli. W przypadku innych rodzin lub mikrokontrolerów najpewniej będą różnice, tak więc jeżeli wykorzystujesz układ z innej rodziny (np. STM32F1 lub STM32F4), odsyłam Cię do dokumentacji. Ponieważ na bootloader przeznaczyliśmy 64 kB pamięci, znajduje się on w naszym wypadku w sektorach 0, 1, 2 i 3. Wynika z tego zatem, że aplikacja użytkownika może zajmować sektory 4 oraz 5, i to właśnie czyszczeniem tych sektorów zaraz się zajmiemy. Na marginesie, z analizy powyższej tabeli wynika jeszcze jedna lekcja. Na bootloader powinniśmy przeznaczyć obszar pamięci dopasowany do „całkowitej” wielkości jednego lub większej liczby sektorów. W naszym wypadku, gdybyśmy postanowili przeznaczyć na bootloader na przykład 24 kB pamięci, czyli zerowy sektor oraz połowę pierwszego, nie bylibyśmy w stanie poprawnie podmieniać aplikacji użytkownika. Jak wcześniej pisałem, przed zapisaniem nowych danych, musimy wyzerować sektor. Gdyby w sektorze pierwszym znajdował się zarówno kod bootloadera, jak i aplikacji użytkownika, jego wyzerowanie uszkodziłoby bootloader. A teraz przejdźmy już do zerowania interesujących nas sektorów.

uint8_t EraseUserApplication() {
    HAL_StatusTypeDef success = HAL_ERROR;
    uint32_t errorSector = 0;

    if (HAL_FLASH_Unlock() == HAL_OK) {
        FLASH_EraseInitTypeDef eraseInit = {0};
        eraseInit.NbSectors = 2; // Count of sectors to erase
        eraseInit.Sector = FLASH_SECTOR_4; // First sector to erase
        eraseInit.VoltageRange = FLASH_VOLTAGE_RANGE_3; // Device operating range: 2.7V to 3.6V
        eraseInit.TypeErase = FLASH_TYPEERASE_SECTORS;

        success = HAL_FLASHEx_Erase(&eraseInit, &errorSector);

        HAL_FLASH_Lock();
    }

    return success == HAL_OK ? 1 : 0;
}

Na początku musimy odblokować możliwość zapisu i czyszczenia pamięci FLASH. Robimy to, wywołując funkcję HAL_FLASH_Unlock. Jeśli operacja się powiedzie, dokonujemy wyzerowania interesujących nas sektorów, czyli 4 i 5. Na koniec ponownie blokujemy zapis i czyszczenie pamięci FLASH.

Programowanie i odczyt pamięci

O ile czyszczenie pamięci różni się pomiędzy rodzinami, o tyle programowanie i odczyt są takie same. Do programowania pamięci, podobnie jak do czyszczenia, wykorzystamy bibliotekę HAL. Odczyt za to jest najprostszy, ponieważ wystarczy, że ustawimy wskaźnik w wybranym miejscu pamięci i już możemy odczytywać dane w taki sam sposób, jak z pamięci RAM. Poniżej przedstawiłem dwie funkcje. Pierwsza z nich pozwala zaprogramować wybrany obszar pamięci FLASH, natomiast druga wykorzystuje hipotetyczną funkcję SendByte() do odczytu i wysłania kopii całej aplikacji użytkownika.

uint8_t WriteUserApplication(uint32_t* data, uint32_t dataSize, uint32_t offset) {
    if (HAL_FLASH_Unlock() == HAL_OK) {
        for (int i = 0; i < dataSize; i++) {
            HAL_StatusTypeDef success = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, APP_ADDRESS + offset + (i * 4), data[i]);

            if (success != HAL_OK) {
                HAL_FLASH_Lock();
                return 0;
            }
        }

        HAL_FLASH_Lock();
    } else {
        return 0;
    }

    return 1;
}
void SendBackup() {
    uint8_t* app = (uint8_t *) (APP_ADDRESS);
    
    // Send 192 kB of user application
    for (int i = 0; i < 192 * 1024; i++) {
        SendByte(app[i]);
    }
}

Oczywiście, powyższe funkcje traktuj jako szkice, prototypy. W następnej, trzeciej części artykułu zajmiemy się dopracowaniem ich i sprawieniem, że wraz z niewielką aplikacją na komputerze pozwolą nam pobrać kopię zapasową i wgrać nową wersję aplikacji użytkownika.

Czy jest dokąd skoczyć?

W kwestii skoku do aplikacji użytkownika możemy sobie zadać jeszcze jedno pytanie – czy jest do czego skoczyć? Bo przecież może się zdarzyć, że wgrany będzie jedynie bootloader. Moim zdaniem najprostszym sposobem jest odczyt pierwszego słowa programu (czterech bajtów). Normalnie znajduje się tam wartość początkowa wskaźnika stosu. W przypadku programów pisanych w C powinna ona wskazywać na koniec pamięci RAM. Dzięki temu możemy stworzyć prostą funkcję, która zwróci 1 w przypadku, gdy pierwsze słowo aplikacji użytkownika jest poprawne oraz 0 w przeciwnym wypadku. Jeżeli nie będzie do czego skoczyć, wiemy, że musimy oczekiwać na wgranie aplikacji przez użytkownika, czyli pozostajemy w trybie bootloadera. Można również w wybrany sposób przekazać stosowny komunikat użytkownikowi. Poniżej znajdziesz przykładową implementację, która bazuje na porównaniu pierwszych czterech bajtów bootloadera i aplikacji użytkownika:

uint8_t UserApplicationExists() {
    uint32_t bootloaderMspValue = *(uint32_t *) (FLASH_BASE);
    uint32_t appMspValue = *(uint32_t *) (APP_ADDRESS);

    return appMspValue == bootloaderMspValue ? 1 : 0;
}

Możemy ją wykorzystać na przykład w sposób podany poniżej. W tym przykładzie, jeżeli nie została znaleziona aplikacja użytkownika, dioda podłączona do pinu PE7 zacznie migać z częstotliwością 5 Hz. Jej działanie można wypróbować poprzez wyczyszczenie aplikacji użytkownika. Aby to zrobić, należy zewrzeć do masy pin PB13 i zresetować mikrokontroler.

while (1) {
    if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_13) == GPIO_PIN_RESET) {
        EraseUserApplication();
    }

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

    if (UserApplicationExists()) {
        JumpToApplication();
    } else {
        while (1) {
            HAL_GPIO_TogglePin(GPIOE, GPIO_PIN_7);
            HAL_Delay(100);
        }
    }
}

Podsumowanie

W ten sposób stworzyliśmy prosty szkielet aplikacji użytkownika oraz bootloadera. Szkielet bootloadera zawiera wszystkie najważniejsze funkcje: sprawdza, czy jest wgrana aplikacja użytkownika i wykonuje do niej skok, a także umożliwia programowanie i odczyt pamięci FLASH. W ostatniej części serii przedstawię bardzo prosty przykład wykorzystujący ten szkielet oraz wybrany sposób komunikacji, aby stworzyć kompletny bootloader.

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. Zapraszam do trzeciej, ostatniej części.