Mikrokontrolery - ukryty świat

| Technika

Tworząc oprogramowanie dla mikrokontrolerów, najczęściej wykorzystuje się zaawansowane środowiska programistyczne. Oprócz wielu udogodnień, takich jak podświetlanie składni, wspomaganie wyszukiwania błędów czy programowanie z poziomu IDE, istnieją mechanizmy, które działają w tle, w sposób niewidoczny dla programisty. Zanim rozpocznie się wykonywanie napisanego kodu, konieczne jest wykonanie szeregu zadań, które są organizowane przez narzędzia, bez angażowania uwagi programisty. Sprawia to jednak, że projektanci często nie mają pełnej świadomości tego, jak przebiega wykonanie kodu przez CPU.

Mikrokontrolery - ukryty świat

Rys. 1. Początek wykonywania programu - najczęściej na początku funkcji main

Tworząc oprogramowanie dla mikrokontrolera, często wykorzystuje się debugger w celu szybszego i łatwiejszego odkrycia popełnionych błędów. Po kompilacji, zaprogramowaniu i uruchomieniu oczom projektanta ukazuje się ekran, na którym widać początek wykonywania programu - najczęściej na początku funkcji main (rys. 1).

Zanim to jednak nastąpi, wykonywany jest fragment kodu odpowiadający za przygotowanie mikrokontrolera do pracy poprzez ustawienie jego rejestrów oraz inicjację pamięci. Warto zauważyć, że po uruchomieniu kodu wartości zmiennych są takie, jakie ustawiono w programie. Nie znajdują się one tam jednak samoistnie i muszą zostać zapisane przez program startowy.

Zapisana musi zostać również tablica przerwań, która zawiera adresy obsługi dla poszczególnych przerwań. Najczęściej jest ona umieszczona zaraz za wektorem resetu, na początku przestrzeni adresowej. W przypadku niektórych rodzin mikrokontrolerów tablica wektorów może zostać skopiowana do pamięci RAM po uruchomieniu.

Pozwala to na zmniejszenie liczby cykli dostępu do pamięci Flash, która ze swej natury ma dłuższy czas odczytu niż pamięć operacyjna RAM. Rozwiązanie takie jest pożądane o tyle, że czas obsługi przerwania może mieć krytyczne znaczenie dla poprawnego funkcjonowania programu i każda optymalizacja jest pożądana.

Pierwszą czynnością po rozpoczęciu pracy mikrokontrolera jest odczyt wektora resetu, załadowanie go do licznika programu, skok do tego miejsca i rozpoczęcie wykonywania instrukcji tam umieszczonych. W niektórych mikrokontrolerach nie ma możliwości ustawienia wektora resetu, więc program wykonuje skok do z góry określonego adresu, jakim najczęściej jest adres zerowy.

W typowym przypadku w tym miejscu umieszczone są procedury startowe (kod startowy), które są wykonywany przed rozpoczęciem wykonywania programu użytkownika. Za wygenerowanie poprawnego kodu startowego najczęściej odpowiedzialny jest kompilator, ale może być on również stworzony przez projektanta.

Zadanie podejmowane przez kod startowy w dużej mierze zależą od mikrokontrolera, dla jakiego został on przeznaczony. Można jednak wyróżnić wspólne cechy wśród których znajduje się przygotowanie zegara systemowego. Jest to istotne szczególnie w złożonych mikrokontrolerach i sprowadza się do konfiguracji odpowiednich rejestrów.

Pozwala to uzyskać pożądaną częstotliwość taktowania rdzenia, a przed rozpoczęciem dalszej pracy konieczne może być oczekiwanie na stabilizację sygnału zegarowego. Na dalszym etapie następuje inicjacja zmiennych (w tym tablic) oraz zerowanie nieużywanej pamięci RAM.

Kolejnym krokiem jest ustawienie wskaźnika stosu, który odpowiada za przechowywanie zmiennych lokalnych, parametrów funkcji, danych zachowywanych przed przystąpieniem do obsługi przerwania oraz adresów powrotu z funkcji. Praca stosu jest krytyczna dla poprawnego funkcjonowania oprogramowania, stąd jego właściwe przygotowanie nie może zostać pominięte.

Kompilatory podczas swojej pracy tworzą sekcje danych takie jak .bss czy .data. Do sekcji .bss trafiają zmienne niezainicjowane przez programistę i podczas wykonywania kodu startowego przypisana zostanie im wartość zero. Kolejną sekcją jest sekcja .data, w której kompilator gromadzi wartości zmiennych przypisywane przez projektanta (przykład - ramka). Wszystkie te sekcje są kopiowane do pamięci RAM przez kod startowy, aby używane w programie zmienne miały deterministyczne wartości: nadane przez programistę bezpośrednio lub zerowe przy braku świadomej inicjacji.

Ostatnim etapem kodu startowego jest wywołanie funkcji main. Może się to odbywać poprzez wykonanie skoku bezwzględnego pod wskazany adres lub poprzez wywołanie funkcji. Pierwszy sposób pozwala zmniejszyć zajętość stosu, ponieważ nie zostanie na nim odłożony adres powrotu, który w typowym przypadku i tak nigdy nie zostanie użyty. Wynika to z faktu, że powrót z main zazwyczaj nie ma miejsca, gdyż oznaczałoby to definitywne zakończenie pracy programu. W praktyce w funkcji main najczęściej umieszcza się nieskończoną pętlę.

Uruchomieniu programu w trybie debugowania rozpoczyna się zwyczajowo na początku funkcji main, czyli od kodu napisanego przez programistę. Wykonanie kodu startowego odbywa się chwilę wcześniej, przez co pozostaje on niewidoczny dla projektanta, często nieświadomego, że procesor wykonał już sporo pracy.

Debugowanie kodu startowego i podgląd jego wykonania wymaga świadomej ingerencji w domyślne ustawienia IDE. Staje się to możliwe, gdy w opcjach debuggera uda się wyłączyć opcję skoku od razu do funkcji main. Analizowanie działania kodu startowego jest użyteczne, gdy wprowadzono do niego modyfikacje lub trzeba określić, jak środowisko programistyczne konfiguruje procesor. W ramce pokazano kroki, jakie podejmuje kod startowy w rodzinie mikrokontrolerów PIC32.

Przykłady umieszczania danych w sekcjach

int zmiennaGlobalna ; //sekcja .bss
int zmiennaGlobalna2 = 2016 ; //sekcja .data

Bootloader

Kod startowy może zostać wyposażony w mechanizmy wspierające pracę bootloadera. Obecność bootloadera powoduje, że po rozpoczęciu wykonywania programu zachodzi potrzeba określenia trybu pracy. Pierwszą możliwością jest wykonywanie programu użytkownika. Drugą opcją jest aktywacja trybu aktualizacji oprogramowania.

Podstawowym zadaniem kodu startowego będzie wtedy określenie, który z trybów należy wymusić. Może to polegać na odczycie stanu logicznego wybranego portu I/O, weryfikacji poprawności oprogramowania umieszczonego w pamięci, itd. W zależności od podjętej decyzji nastąpi skok do odpowiedniego fragmentu pamięci, w którym umieszczono bootloader lub program użytkownika.

Aktualizacja oprogramowania może być również wymuszana z poziomu programu użytkownika, ale sprawdzanie trybu pracy przy rozruchu warto uwzględnić zawsze. Takie podejście pozwala na przywrócenie urządzenia do pracy po awarii oryginalnego firmware'm (np. na skutek błędu podczas aktualizacji).

Producenci mikrokontrolerów zapewniają różne mechanizmy chroniące bootloader przed skasowaniem i tym samym całkowitym zablokowaniem oprogramowania i koniecznością odsyłania urządzenia do serwisu. Przykład mechanizmu ochronnego stanowi zabezpieczenie fragmentu pamięci przed skasowaniem i/lub nadpisaniem. Innym podejściem jest wyposażenie mikrokontrolera w oddzielną pamięć przeznaczoną specjalnie na bootloader (np. w układach z rodziny PIC32).

Z uwagi na fakt, że kod startowy jest zależny od bieżącej wersji oprogramowania, w praktyce wygodniejsze może się okazać oddzielenie go od bootloadera. W takiej sytuacji kod startowy będzie częścią firmware'u i będzie podlegał aktualizacji razem z programem użytkownika. Należy pozostawić jednakże w miejscu wektora resetu adres bootloadera, aby był wywoływany każdorazowo po rozpoczęciu pracy przez mikrokontroler.

Dopiero sam bootloader podejmie decyzję, czy wstrzymać dalszą pracę i oczekiwać na polecenia, czy rozpocząć wykonywanie programu użytkownika. W przypadku uszkodzenia firmware'u bootloader może sprawdzać stan wybranego wejścia mikrokontrolera i w przypadku wymuszenia określonego stanu (np. przyciskiem) oczekiwać na załadowanie nowego programu. Niektóre rodziny mikrokontrolerów wyposażone w specjalizowaną pamięć na bootloader mają także bity konfiguracyjne, które pozwolą zdecydować, z jakiej pamięci ma się rozpocząć wykonywanie kodu.

Linker

Skrypty linkera są kolejnym elementem dołączanym do projektu w sposób niejawny i z reguły niewidoczny dla projektanta. Problem ich stworzenia i dołączenia do projektu coraz częściej jest przerzucany na środowiska programistyczne. W znakomitej większości przypadków jest to faktycznie wygodne rozwiązanie, niewymagające zaangażowania programisty - nie traci on czasu na szukanie gotowców i/lub analizę obszernej dokumentacji. Sytuacja komplikuje się, kiedy trzeba wymusić nietypową pracę programu, czego przykładem może być integracja z bootloaderem.

Omawiając temat linkera, warto prześledzić, za jakie rzeczy odpowiada linker. Na potrzeby niniejszego artykułu analiza będzie oparta na kompilatorze C32 firmy Microchip przeznaczonym dla układów PIC32.

Pierwszym elementem skryptu linkera dla PIC32 jest zdefiniowanie, z jakim formatem utworzonego przez kompilator pliku obiektowego (object file) linker będzie miał do czynienia. W dalszej kolejności skrypt zawiera określenie architektury procesora, na której będzie uruchamiany końcowy program.

Dalej wskazywany jest rozmiar stosu (stack) oraz sterty (heap) używanych w programie. Stos jest odpowiedzialny za przechowywanie m.in. lokalnych zmiennych, wartości rejestrów zapamiętywanych przed rozpoczęciem obsługi przerwania, argumentów funkcji itp. Stertę natomiast kompilator wykorzystuje dla dynamicznie alokowanej pamięci, tj. gospodaruje w niej miejscami dla tworzonych dynamicznie zmiennych - deklarowanych w czasie działania programu np. za pomocą funkcji malloc() w języku C bądź operatora new w języku C++).

Skrypt linkera zawiera ponadto definicje obszarów pamięci, w których podawany są ich rozmiary oraz adresy. Przykładowe obszary to: pamięć programu, pamięć RAM, wydzielony obszar dla bootloadera czy przestrzeń bitów konfiguracyjnych. W zależności od wykorzystanego rodzaju mikrokontrolera rozmiary tych obszarów będą różne - większe dla bardziej rozbudowanych układów i mniejsze dla prostszych przedstawicieli rodziny PIC32. Tym samym skrypt musi być dostosowany do konkretnego modelu mikrokontrolera i nie może być ogólny dla całej ich rodziny.

W dalszej części skryptu następuje mapowanie - polega to na zdefiniowaniu, gdzie ma być rozmieszczony kod wynikowy programu (sekcje .bss, .data, .text, etc.). Zadaniem tej części skryptu jest sprawienie, aby program użytkownika trafił do pamięci Flash, dane inicjujące zmienne zostały umieszczone pod wskazanym adresem, bity konfiguracyjne do odpowiednich rejestrów itp. W tej części umieszcza się także wektory przerwań, następuje tu wskazanie adresu skoku po wystąpieniu przerwania o określonym numerze.

Ingerencja w skrypt linkera na ogół nie jest potrzebna, jednak zdarzają się przypadki, że jest ona nieunikniona. Może mieć to miejsce w sytuacji wyposażenia urządzenia w bootloader. Domyślny skrypt w tej sytuacji nie pozwoli na poprawne wygenerowanie programu i bootloadera. Umieści on bowiem początek kodu tam, gdzie znajduje się już kod bootloadera lub odwrotnie.

Konieczne będzie wprowadzenie modyfikacji w skrypcie i wskazanie, od którego miejsca ma być umieszczany program, czyli miejsca, gdzie znajduje się wolna pamięć niezajęta przez bootloader. Tym samym skrypt używany do kompilacji bootloadera i programu będzie inny. Kolejnym aspektem są przerwania. Jeżeli wektory przerwań są w danej architekturze umieszczone na stałe i dodatkowo pokrywają się z obszarem zajętym przez bootloader, to zachodzi konieczność ich mapowania, czyli wskazania w skrypcie lokalizacji nowych wektorów przerwań.

Przykładowe działania podejmowane przez kod startowy dla układów PIC32

Kod startowy przeznaczony dla mikrokontrolerów z rodziny PIC32 jest odpowiedzialny za:

  1. Skok do obsługi przerwania niemaskowanego NMI (Non-Maskable Interrupt), jeżeli ono wystąpi
  2. Inicjację stosu i wskaźnika stosu
  3. Inicjację wskaźnika globalnego GP (odpowiedzialny za szybki dostęp do danych)
  4. Wywołanie procedury resetu
  5. Zerowanie niezainicjowanych obszarów danych (w tym sekcja .bss)
  6. Kopiowanie danych inicjujących zmienne z pamięci Flash do pamięci RAM
  7. Kopiowanie funkcji RAM z pamięci programu do pamięci danych
  8. Inicjację rejestrów Bus Matrix
  9. Inicjację rejestrów CP0
  10. Inicjację rejestrów Trace Control 2 (wsparcie dla EJTAG)
  11. Wywołanie procedury bootstrap (pozwala wykonać własne instrukcje przed wywołaniem main)
  12. Zmianę lokalizacji wektorów wyjątków (Exception Vectors)
  13. Wywołanie funkcji main

Podsumowanie

Współczesne środowiska projektowe odpowiadają za szereg działań niezbędnych do prawidłowej pracy mikrokontrolera, aby usprawnić i przyspieszyć tworzenie właściwego kodu. W większości przypadków można na nich polegać i pozostawić im to zadanie bez obawy, że coś będzie zrobione źle.

Niemniej świadomość istnienia ukrytych mechanizmów i wiedza o działaniach, jakie podejmują, bywa w określonych sytuacjach potrzebna. Samodzielna modyfikacja skryptów linkera czy kodu startowego okazuje się konieczna, gdy trzeba zmusić mikrokontroler do nietypowej pracy, wykraczającej poza utarte schematy.

Jakub Borzdyński