Schemat blokowy typowego SOCa:
Typowy układ SOC składa się z procesora i szeregu układów dodatkowych, połączonych z główną jednostką specjalną szyną danych.
Każdy „szanujący się” SOC posiada co najmniej:
- kontroler flash do zapisywania ustawień lub jako główny „dysk”
- kontroler pamięci operacyjnej systemu, czyli DDR
- port szeregowy do diagnostyki – UART
- pamięć wewnętrzną SRAM (ang. static ram) – czasami jej rolę pełni pamięć cache procesora
Na szczególną uwagę zasługuje fakt, że zarówno pamięć operacyjna (DDR), jak i pamięć nieulotna (FLASH) są również układami peryferyjnymi. Tzw. „kości” DDR oraz Flash są montowane osobno na płycie głównej, a w SOCu znajduje się jedynie kontroler. Taka architektura powoduje, że tuż po uruchomieniu procesor nie ma dostępu do żadnej zewnętrznej pamięci.
W istocie każdy z kontrolerów należy osobno zaprogramować. Z tego impasu wychodzi się za pomocą dodatkowej, wewnętrznej pamięci procesora (SRAM, ROM). W pamięci ROM producent umieszcza pierwszy program uruchomieniowy, który dostaje w asyście odrobinę pamięci operacyjnej (SRAM ma nie więcej niż kilkadziesiąt KB).
Uruchamianie takiego schematycznego SOCa będzie przebiegało następująco:
1. Uruchomienie programu Boot0 – pierwszego bootloadera – z pamięci ROM
2. Aktywacja szyny danych
3. Aktywacja pamięci nieulotnej FLASH lub innej, np. karty SD, w celu wczytania kolejnego bootloadera
4. Załadowanie bootloadera Boot1 do pamięci SRAM
5. Uruchomienie programu Boot1
6. Aktywacja peryferiów komunikacyjnych: portu szeregowego (UART), pinów zewnętrznych (świecenie diodą LED)
7. Aktywacja pamięci operacyjnej – kontrolera DDR
8. Załadowanie bootloadera właściwego z pamięci zewnętrznej (FLASH, SATA) do RAM
9. Uruchomienie trzeciego, zapewne już ostatniego bootloadera Boot2. W systemach wbudowanych będzie to U-Boot albo UEFI
10. Aktywacja podsystemów pamięci wirtualnej
11. Aktywacja pamięci podręcznej (cache)
12. Załadowanie jądra systemu operacyjnego z wybranego medium (sieć, dysk, FLASH)
Ograniczona ilość pamięci SRAM wymusza więc podział całego procesu na etapy i tak samo etapami ładuje się coraz więcej kodu. W procesie bootowania, aż do momentu uruchomienia pełnego systemu operacyjnego OS, bierze udział tylko jeden rdzeń procesora.
Pamięć dzieli się na kilka rodzajów:
- RAM/DRAM – pamięć operacyjna, zarówno dynamiczna, jak i statyczna
- ROM – pamięć „wbudowana” lub FLASH – z punktu widzenia procesora jest tylko do odczytu i zawiera głównie firmware bootujący oraz dane konfiguracyjne
- I/O – pamięć do komunikacji z urządzeniami zewnętrznymi, np. portem szeregowym lub kontrolerem dysku. Każdy SOC ma część pamięci „zaszytą” na stałe do tego celu. Taka pamięć ma specjalne właściwości, o których będzie mowa później
- Mapowane I/O – pamięć przeznaczona dla urządzeń dynamicznie konfigurowanych, np. dla kart PCI. Nie wiadomo, jakie karty PCI będą obecne i ile ich będzie, więc przydziałem tej pamięci zajmuje się procesor na etapie uruchamiania systemu (Boot2 lub OS)
Ważne jest, żeby nie mylić pamięci fizycznej z pamięcią używaną w programach działających pod kontrolą systemu operacyjnego. Linuks działający na ARMach z serii ARMv7-A korzysta z pamięci wirtualnej, która całkowicie separuje adresację fizyczną od adresacji używanej przez programy. Nawet sterowniki działające w jądrze Linuksa korzystają z pamięci wirtualnej, która nie ma żadnego związku z adresami fizycznymi. Inaczej rzecz się ma w bootloaderach, które działają na pamięci fizycznej, bo jednostka MMU procesora nie jest włączona tuż po starcie.
Allwinner A20
Popularność tych chinskich SOCów w projektach płytek dla hobbystów wynika z niedużej ceny, niezłej wydajności oraz masy peryferiów, w tym kodeka audio, akceleratora 3D, dekodera TV itd.
Rysunek 4 przedstawia mapę pamięci SOCa A20:
- Pierwsze 44KB to wewnętrzny SRAM służący do bootowania
- Do 1GB są zmapowane na stałe urządzenia SOCa: m. in. te umieszczone w bloczkach na Rysunku 3
- Od 1GB do 3GB jest miejsce na pamięć DDR
- Ostatnie 64KB (od 0xFFFF0000) tradycyjnie dla ARMów są okupowaneprzez Boot ROM, czyli pierwszy program rozruchowy Boot0.
Z Rysunku nasuwa się wniosek, że system może zaadresować tylko 2GB pamięci DDR, a pierwszy i drugi program rozruchowy, zanim DDR zostanie włączony, będzie miał do dyspozycji maksymalnie 48KB pamięci.
Warto teraz spojrzeć na obszar I/O z bliska: w Tabeli 1 znajduje się wycinek z dokumentacji Allwinnera opisujący urządzenie portu szeregowego. Port szeregowy jest pierwszym urządzeniem, które warto uruchomić, bo w systemie wbudowanym jest to jedyny sposób komunikacji z programem, zanim wstanie pełny OS. UART może też służyć jako urządzenie do debugowania, szczegółowo rejestrując wszystkie kroki wykonywane przez program.
Warto teraz spojrzeć na obszar I/O z bliska: w Tabeli 1 znajduje się wycinek z dokumentacji Allwinnera opisujący urządzenie portu szeregowego. Port szeregowy jest pierwszym urządzeniem, które warto uruchomić, bo w systemie wbudowanym jest to jedyny sposób komunikacji z programem, zanim wstanie pełny OS. UART może też służyć jako urządzenie do debugowania, szczegółowo rejestrując wszystkie kroki wykonywane przez program.
UART Uniwersalny asynchroniczny nadajnik-odbiornik, (od ang. universal asynchronous receiver-transmitter) – układ scalony służący do asynchronicznego przekazywania i odbierania informacji poprzez port szeregowy. UART posiada bufor do tymczasowego gromadzenia danych w przypadku szybkiej transmisji.
Opis urządzenia składa się z adresu bazowego (Base Address) oraz z listy rejestrów z adresami względnymi (offset). W ten sposób kilka modułów tego samego typu można zaadresować jako:
rejestr = adres_bazowy + offset
a wnikliwy obserwator zauważy jeszcze jedną prawidłowość:
rejestr(port) = adres_bazowy + port * 0x400 + offset
Przykładowo: rejestr LCR portu szeregowego nr 1 (liczone od zera) ma adres
0x01C2840C.
Wszystkie porty UART od 0 do 7 (w tabeli pokazane tylko 3) są oddalone o siebie o 1KB, czyli 0x400 szesnastkowo. Każdy rejestr ma opis zawierający pola bitowe wewnątrz rejestru i ich znaczenie.
Bardzo ważne jest zapamiętanie, że większość rejestrów SOCa ma rozmiar całego słowa, czyli w przypadku ARMv7 – 32 bity. Oznacza to, że zapis i odczyt musi zawsze odbywać się w rozmiarze całego rejestru, nawet gdy modyfikacji ulega tylko część jego bitów.
Dostęp do rejestrów, jak widać, zasadniczo różni się od dostępu do pamięci, którą na ARMie można adresować także bajtowo. Ponadto zapis i odczyt może mieć dodatkowe efekty uboczne, co wyraźnie widać na przykładzie pary rejestrów THR i RBR (nie opisany w Tabeli 1). Oba zajmują ten sam adres (offset 0), ale jeden jest tylko do zapisu (TBR), a drugi tylko do odczytu (RBR), więc w zależności od typu transakcji na szynie danych (odczyt lub zapis) aktywuje się jeden lub drugi rejestr. Informacje o rejestrach UART przydadzą się do napisania programu „Hello World”.
C
Oprogramowanie niskopoziomowe typu firmware, bootloader itp. tradycyjnie pisze się w mieszaninie asemblera i C. Dzisiaj ilość asemblera jest w zasadzie znikoma, bo zawsze można posłużyć się wstawką asemblerową. Język C nadaje się idealnie do tego typu zadania, bo jest dość prosto przenoszony na kod maszynowy, łatwo przewidzieć postać tego kodu i użycie pamięci, a kod języka C nie potrzebuje żadnego dodatkowego otoczenia (runtime), tzn. nie musi korzystać z bibliotek systemowych i może być całkowicie samowystarczalny, co zostanie pokazane poniżej.
static int x[1];
static int y[1] = {0xbabababa};
void foo(const char *s,int *i, int *j) {}
void _start(void)
{
foo("Hello World", y, x);
}
Te kilka linijek wystarczy jednak, aby zademonstrować, jak programy w C funkcjonują w pamięci. Każdy program w C składa się z następujących części:
- kod maszynowy zdefiniowanych funkcji, umownie
.text
obszar danych:
- stałych, read only data:
.rodata
- zainicjalizowanych:
.data
- niezainicjalizowanych, czyli wyzerowanych:
.bss
- sterty (albo dostępnej pamięci)
- stosu
Te obszary programu nazywa się sekcjami lub segmentami (sekcjami, gdy są w pliku, segmentami – w pamięci). Tablica x na powyższym Listingu to zmienna bez wartości początkowej, czyli będzie wypełniona zerami i trafi do sekcji
.bss, tablica y ma wartość początkową, więc trafi do sekcji
.data. Stała łańcuchowa „Hello World” trafi do
.rodata, bo jest stałą.
Na Listingu 2 pokazana jest kompilacja i wydruk zrzutu obrazu programu.
Niektóre fragmenty zostały pominięte i wykropkowane (kolor szary), a istotne wartości zaznaczone na czerwono.
Obecne są wszystkie 4 sekcje oraz ich rozmiar i adres w pamięci. Ten ostatni jest bardzo ważny: programy w języku C posługują się adresami bezwzględnymi, a każda sekcja musi mieć określony adres i rozmiar. Listing zawiera też zawartość sekcji
.rodata i .data.
W sekcji danych wyraźnie widać wartość (0xbabababa) zmiennej y, a stała łańcuchowa „Hello World” wypełnia .rodata.
Listining 2:
$ arm-linux-gnueabihf-gcc -O0 -c hello.c
$ arm-linux-gnueabihf-ld -o hello hello.o
$ arm-linux-gnueabihf-objdump -f -s -h hello
hello: file format elf32-littlearm
architecture: arm, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address
0x000080b8
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000004c 00008094 00008094 00000094 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 0000000c 000080e0 000080e0 000000e0 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .data 00000004 000100ec 000100ec 000000ec 2**2
CONTENTS, ALLOC, LOAD, DATA
3 .bss 00000004 000100f0 000100f0 000000f0 2**2
ALLOC
<...>
Contents of section .text:
<...>
Contents of section .rodata:
80e0 48656c6c 6f20576f 726c6400
Hello World.
Contents of section .data:
100ec
babababa ....
<...>
Na początku powyższego listingu pojawia się adres startowy programu:
0x000080b8.
Jest to adres funkcji _start(). Nigdzie nie ma natomiast wzmianki o stosie. Kompilator zakłada, że stos jest już przygotowany w chwili rozpoczęcia programu, gdyż zajmuje się tym jądro systemu operacyjnego, gdy ładuje plik wykonywalny.
Wynika stąd, że zanim uruchomi się program w C bez pomocy OS, należy „ręcznie” przygotować stos.
Dlaczego kod zaczyna się od _start(), a nie main()?
Funkcja main() wykonuje się dopiero po inicjalizacji biblioteki standardowej C (libc na Linuksie, msvcrt na Windows) i funkcja _start() jest jej częścią. Program hello.c został jednak skompilowany bez żadnej biblioteki, jest całkowicie samowystarczalny i niemal gotowy do uruchomienia w środowisku bare metal.
Uważny czytelnik zauważy, że program został skompilowany jako program w C pod Linuksa, więc jak to możliwe, że można go uruchomić bez systemu? Otóż na maszynie ARM kod maszynowy zawsze ma tę samą formę na wszystkich etapach uruchamiania systemu, co stanowi standard w nowoczesnych architekturach (ale nie w x86). Różnica polega jedynie na braku wywołań systemowych i bibliotek. Język C prawie zawsze gwarantuje (wyjątkiem jest m.in. thread local storage w C99), że żadne wywołanie systemowe lub użycie biblioteki zewnętrznej nie odbędzie się bez jawnego udziału programisty.
Skoro program jest samowystarczalny, to czego jeszcze brakuje?
Skrypt linkera
To, co wyróżnia niskopoziomowy firmware od „zwykłych” programów w C, to przede wszystkim kontrola nad położeniem programu w pamięci. Adresacja sekcji na Listingu 2 jest nieciągła i nie można jej zmienić. Tymczasem bootloader musi wystartować z pamięci FLASH, a potem przenieść się do SRAM, bo tylko ta pamięć jest dostępna na początku. Podczas bootowania używane są adresy fizyczne pamięci, które są ustalone przez SOC. W celu szczegółowego rozmieszczenia sekcji w pamięci fizycznej należy posłużyć się skryptem linkera:
Listining 3
ENTRY(_start)
SECTIONS
{
. = 0x2000
.text :
{
hello.o (.text)
*(.text)
}
.rodata : { *(.rodata) }
.data : { *(.data) }
.bss : { *(.bss COMMON) }
}
Listing 3 pokazuje schematyczny skrypt linkera, który umieszcza wszystkie sekcje programu po kolei w pamięci, począwszy od adresu 0x2000. Pamięć programu zaczyna się od sekcji kodu modułu hello.o. Skrypty linkera pozwalają na szczegółowe zaadresowanie wszystkich sekcji programu z rozróżnieniem na osobne jednostki kompilacji (różne pliki z kodem).
Anatomia bootowania: Hello World na Bare Metal
Mając wszystkie elementy układanki, trzeba przystąpić do realizacji konkretnego projektu. Będzie to program rozruchowy 2-go stopnia, który wypisuje na port szeregowy „Hello World”.
W układzie Allwinner A20 pierwszy program rozruchowy jest dostarczony przez producenta w pamięci ROM i nie podlega wymianie. Schemat działania programu Boot0 opisany jest poniżej.
Zadaniem Boot0 jest znalezienie kolejnego programu rozruchowego i przekazanie mu kontroli, lub też przejście w specjalny tryb diagnostyczny.
Po kolei sprawdzane są:
1. Stan PINu diagnostycznego: gdy włączony, włącza się tryb ładowania kodu przez USB, tzw. tryb FEL.*
2. Karta SD**
3. Pamięć NAND Flash
4. Karta SD nr 2
5. SPI Flash ***
6. Gdy wszystko zawiodło, tryb FEL
*** (SPI is a simple interface for interfacing to slow peripherals. It is somewhat like I2C, but has a chip select line rather than an addressing scheme. Which, plausibly, makes it simpler.
Flash memory is a kind of non-volatile memory much used for storing programs for simple microprocessors.
SPI Flash is simply the cheapest, simplest way of building off-chip non volatile memory at the moment. )
**Karta SD musi posiadać następujący format:
Obraz programu Boot1 jest pod adresem 8KB na karcie SD i zawiera specjalny nagłówek wraz z sumą kontrolną. W typowym zastosowaniu, tj. uruchomienie Linuksa, rolę programu Boot1 przejmuje tzw. U-Boot SPL (Secondary Program Loader), który ładuje U-Boota właściwego po inicjalizacji pamięci DDR.
Pamięć Flash musi być podzielona na partycje, a pierwsza partycja powinna się nazywać boot i zawierać obraz U-Boota.
* FEL is a low-level subroutine contained in the BootROM on Allwinner devices. It is used for initial programming and recovery of devices using USB.
LINK
FEL - tryb diagnostyczny USB
Wszystkie te procedury są użyteczne dla ostatecznej, działającej wersji bootloadera, ale nie są wygodne przy eksperymentowaniu, a w szczególności w naprawianiu zepsutego, niedziałającego firmware. Dlatego najlepiej zacząć od trybu USB. Allwinner Technologies w swojej uprzejmości umieścił na Githubie kod źródłowy programatora diagnostycznego, który potrafi ożywić
SOCa z innego komputera przez port USB. Program nazywa się FEL i ma następujące możliwości:
- załadowanie pliku pod adres fizyczny pamięci
- zapisanie wycinka pamięci do pliku
- wykonanie programu z wybranego adresu fizycznego i (opcjonalnie) powrót do trybu diagnostycznego
- hexdump z wybranego adresu fizycznego, również rejestrów urządzeń!
- wypełnienie wybranej pamięci pewną wartością, np. zerami, ale także zapis do rejestru!
Narzędzie FEL pozwala więc na szeroko zakrojoną modyfikację stanu procesora, i to wielokrotnie, bez konieczności resetowania systemu, bo można powrócić do trybu FEL i załadować kolejny program. Taka możliwość „podglądania” i „próbowania” jest bardzo przydatna na początku przygody z SOCem i może – przy odrobinie sprytu – zastąpić sprzętowy debugger JTAG.
Plan działania
Program „Hello World” na środowisko bare metal będzie działał następująco:
1. Po włączeniu zasilania procesor skoczy do adresu 0xFFFF0000 i uruchomiony zostanie tryb diagnostyczny (płyta Cubieboard 2 ma do tego specjalny przycisk)
2. Obraz programu zostanie skopiowany do pamięci SRAM pod adres 0x2000 (8 KB)
3. Zostanie wykonane polecenie skoku do adresu początkowego programu 0x2000
4. Najpierw zostanie uruchomiona funkcja rozruchowa _start(), napisana w asemblerze, która przygotuje środowisko dla programu w C
5. Funkcja _start() wywoła funkcję main()
6. Funkcja main() wywoła procedurę uruchomienia portu szeregowego UART0
7. Po inicjalizacji UART0, zostanie wypisany napis „Hello World”
8. Nastąpi powrót do Boot ROMu
Na Rysunku znajduje się mapa pamięci programu, tuż po załadowaniu przez Boot ROM do pamięci SRAM. Początek obszaru okupuje moduł start.o, napisany w asemblerze. Potem kolejność jest już dowolna, bo moduł start.o skonfiguruje środowisko uruchomieniowe dla C. Oprócz opisanych w poprzednim rozdziale segmentów, jest zarezerwowane miejsce na stos. To, czy stos zostanie jawnie wkomponowany w mapę pamięci czy ustalony „ręcznie” na jakiś arbitralnie wybrany obszar, zależy tylko od widzimisię programisty.
Segmenty zaznaczone na zielono to te, które są częścią binarnego obrazu programu.
Segmenty szare to te, które należy przygotować po załadowaniu obrazu (pliku) programu do pamięci. Normalnie tą częścią, czyli alokacją segmentu .bss i stosu, zajmuje się system operacyjny. Nic jednak nie stoi na przeszkodzie, żeby zrobić to w procedurze _start(), bo rozmiary obu są znane na etapie działania programu.
Szybki kurs asemblera ARM
W chwili wejścia do main() procesor znajduje się w stanie bliżej nieokreślonym, gdyż nie wiadomo, co znajduje się w rejestrach, na stosie itd.
Bootloadery często zakładają, że „nie wiedzą”, co robił poprzednik, i „czyszczą” cały stan procesora.
Na pierwszy ogień idzie rejestr specjalny CPSR – Current Program Status Register. Jest to pole bitowe, które przechowuje tryb procesora, maskę przerwań i inne informacje.
Kolejnym krokiem jest skonfigurowanie stosu i zapisanie na nim niektórych rejestrów. Będą one przywrócone na samym końcu, żeby program w Boot ROMie mógł poprawnie pracować.
Zmienna stack_top zdefiniowana jest w skrypcie linkera i wskazuje na najwyższy adres obszaru stosu. Zwyczajowo w systemach komputerowych stos zapełniany jest przez zmniejszanie wartości wskaźnika stosu, czyli rośnie „w dół” adresów. Taka organizacja sprzyja wykrywaniu błędów przepełnienia stosu (stack overflow).
więcej: (patrz artykuł Programista 35 str 31)
Debugowanie
Diagnoza problemów w programie rozruchowym może być wyzwaniem samym w sobie. Brak sprzętowego debuggera (i każdego innego też) powoduje, że pierwsze kroki bywają trudne, ale są to trudności do przeskoczenia.
Błąd na tym etapie zazwyczaj kończy się „zamrożeniem” systemu, tzn. brakiem komunikatów i brakiem odpowiedzi na bodźce. Z tego nasuwa się oczywisty wniosek, że najlepszą diagnozą jest gęste usianie kodu komunikatami diagnostycznymi, a ostatni pojawiający się komunikat będzie stanowił wskazówkę, gdzie wystąpił problem.
Sposoby debugowania systemów wbudowanych można podsumować regułą „im prościej, tym lepiej”, bo każda komplikacja może wprowadzić kolejny błąd.
Ze znanych metod można wymienić:
- UART – można powiedzieć, że to jest luksusowy debugger
- Diody (tak tak – debugowanie przez miganie diodami:)
- Inne PINy – jeśli jest pod ręką oscyloskop albo analizator logiczny
(więcej o debugowaniu patrz Programista
82 str 24)
Obsługa portu szeregowego
#define readl(a) (*(volatile unsigned int *)(a))
#define writel(v,a) (*(volatile unsigned int *)(a) = (v))
#define UART_PORT 0
#define UART_BASE 0x01C28000
#define UART_THR (UART_BASE + 0x00)
#define UART_RBR (UART_BASE + 0x00)
#define UART_DLL (UART_BASE + 0x00)
#define UART_DLH (UART_BASE + 0x04)
#define UART_LCR (UART_BASE + 0x0c)
#define UART_LSR (UART_BASE + 0x14)
#define UART_LCR_DLAB (0x1 << 7)
#define UART_LSR_TEMT (0x1 << 6)
#define BAUD_115200 13 /*clock 24M / (16*115200) */
#define NO_PARITY (0)
#define ONE_STOP_BIT (0)
#define DAT_LEN_8_BITS (3)
#define LC_8_N_1 (NO_PARITY << 3 \
| ONE_STOP_BIT << 2 | DAT_LEN_8_BITS)
#include <stdint.h>
volatile uint32_t debug = 0xdeadbeef;
void delay(unsigned cycles)
{
unsigned i;
for (i = 0; i < cycles; i++)
__asm volatile ("nop"::);
}
void uart_init(void)
{
volatile uint32_t *uart_clock =
(uint32_t *) 0x01C2006C;
volatile uint32_t *PB2 =
(uint32_t *) 0x01C2082C;
volatile uint32_t *PB_PULL1 =
(uint32_t *) 0x01C20844;
/* Włączenie zegara dla urządzenia UART na szynie APB */
*uart_clock &=
~(1 << (16 + UART_PORT));
/* Trzeba odczekać parę cykli zegara */
delay(100);
*uart_clock |=
(1 << (16 + UART_PORT));
/* Ustaw piny 22 i 23 portu B
na UART0 RX & UART0_TX */
*PB2 |= 2 << 28 | 2 << 24;
/* Piny muszą być wysterowane jako pull-up
(wartość 2) dla pinów 22 i 23 portu B */
*PB_PULL1 |=
(2 << ((22 - 16) * 2)) |
(2 << ((23 - 16) * 2));
/* Aktywuj konfigurację szybkości transmisji */
writel(UART_LCR_DLAB, UART_LCR);
/* Ustaw prędkość portu na 115200 baud */
writel(0, UART_DLH);
writel(BAUD_115200, UART_DLL);
/* Wyłącz konf. trasmisji i ustaw:
- brak parzystości
- 1 bit stopu
- długość słowa 8 bitów */
writel(LC_8_N_1, UART_LCR);
/* Czekaj 100 cykli */
delay(100);
}
#define TX_READY (readl(UART_LSR) & UART_LSR_TEMT)
void uart_putc(char c)
{
while (!TX_READY);
writel(c, UART_THR);
}
void uart_puts(const char *s)
{
while (*s)
uart_putc(*s++);
}
v
oid main(void)
{
debug = 0x44444444;
uart_init();
uart_puts("Hello world!\n\r");
debug = 0x55555555;
}
Na Listingu przedstawiona jest całość programu w C:
- definicje i makra dotyczące portu UART,
- prosta funkcja opóźniająca delay()
- funkcja inicjalizacji urządzenia UART0 uart_init()
- funkcja wypisująca pojedynczy znak: uart_putc()
- funkcja drukująca łańcuch znakowy
- funkcja main(), która posyła „Hello World” na port szeregowy
Kod obsługi UARTa wzorowany jest na module early_print.c z projektu U-Boot. Jest to popularny UART z rodziny 16550, więc na jego temat jest masę materiałów w sieci, łącznie z gotowym kodem. Definicje i makra nie wymagają dużego komentarza, poza tym, że wartości wzięte są z dokumentacji Allinnera A20. Wartość baud rate dla UARTa wynika z odszukania w dokumentacji domyślnego zegara dla portu szeregowego (24MHz) oraz wzoru na wartość dzielnika zegara.
Na uwagę zasługuje przygotowanie do uruchomienia portu. Na początku trzeba zacząć od włączenia taktowania na szynie do układu UART, bo przy starcie wszystkie zewnętrzne szyny do peryferiów są wyłączone. Potem trzeba skonfigurować odpowiednie piny, które domyślnie pełnią inną rolę. Jest to częsta bolączka, gdy urządzeń jest więcej niż wyprowadzeń („nóżek”) na SOCu i każdym pinem trzeba się dzielić.
Inicjalizacja samego układu UART przebiega standardowo, tj. trzeba ustawić prędkość (rejestry DLL, DLH) i format transmisji (rejestr LCR). Potem wystarczy tylko trochę poczekać za pomocą funkcji delay().
Port szeregowy działa tu w trybie pollingowym, tj. bez przerwań. Wysłanie pojedynczego znaku realizuje się jako zapis do rejestru THR. Żeby uniknąć nadpisania niewysłanego znaku, trzeba poczekać, aż rejestr statusu (LSR) będziemiał włączoną flagę transmitter empty (TEMT).
Kompilacja
Skrypt linkera:
ENTRY(_start)
SECTIONS
{
. = 0x2000; /* Zaczynaj od 0x2000 */
.text :
{
start.o (.text)
*(.text)
}
.rodata : { *(.rodata) }
.data : { *(.data) }
.stack : {
. = ALIGN(8); /* Stos wyrównany do 8 */
. = . + 0x400; /* 1KB */
stack_top = .;
}
.bss ALIGN(4) : { /* .bss wyrównany do 4 */
bss_start = .;
*(.bss COMMON)
bss_end = ALIGN(4)
/* .bss kończy się wyrównaniem do 4,
dzięki czemu rozmiar jest wielokrotnością 4 */;
}
}
Skrypt linkera, scalający cały program, znajduje się na Listingu 6. Rozmieszcza on poszczególne sekcje obok siebie ze szczególną dbałością o adresację pierwszej sekcji. Początkowy segment, jak ustalono w planie, musi zaczynać się od adresu 0x2000, a pierwszym modułem w sekcji kodu ma
być start.o. To gwarantuje, że symbol początkowy _start, wyznaczony za pomocą funkcji ENTRY na Listingu 6, będzie miał adres 0x2000.
Segmenty stosu i danych niezainicjalizowanych znajdują się w skrypcie, choć nie są częścią binarnego obrazu programu. Segment stosu może na wejściu zawierać losowe wartości, a .bss ma być wyzerowany, w związku z tym oba nie zawierają żadnych istotnych wartości, oprócz swojego rozmiaru.
Na Listingu został użyty symbol stack_top, który jest zdefiniowany w skrypcie. W ten sposób na etapie linkowania programu wartość stack_top jest prawidłowo ustalana. Tak samo można ustalić granice segmentu .bss, żeby potem go wyzerować. Funkcja ALIGN służy wyrównaniu adresacji do podanej wartości. Standard ARM EABI określa wyrównanie stosu na 4 lub 8 bajtów, więc najlepiej wyrównać do 8. Wyrównanie sekcji danych nie jest określone, ale 4 bajty dla .bss pozwala na wygodne wyzerowanie całości. Kod zerujący został pominięty na listingach, bo sekcja .bss i tak jest
pusta, a sam kod to zwykłe zerowanie tablicy.
Kompilacja i uruchomienie projektu
$ arm-linux-gnueabihf-gcc -g -marm -c -o main.o main.c
$ arm-linux-gnueabihf-ld -T image-sram.lds main.o start.o -o image.elf
$ arm-linux-gnueabihf-objcopy -O binary image.elf image.bin
$ fel write 0x2000 image.bin
$ fel exe 0x2000
Listing pokazuje sesję kompilacji i uruchomienia programu. Kompilacja na Linuksie odbywa się do postaci ELF, więc plik trzeba przekonwertować na postać „binarną”, to jest pozbawioną wszelkich metadanych i informacji dodatkowych, z których korzystają systemy operacyjne. Listing 2 programu
objdump bazuje właśnie na metadanych pliku ELF, ale ten format jest bezużyteczny
na etapie bootowania.
Pozyskany plik binarny image.bin ładuje się do pamięci SOCa za pomocą narzędzia FEL, o którym była wcześniej mowa. Po wykonaniu komendy “fel exe” na terminalu portu szeregowego widać upragniony napis.
artykuł z Programista 35 str 28
Linki:
Youtube: Lecture 13: Booting Process
Udemykurs
Artykuły:
Bootloader:
Bootloader! Pr35 str28
własny RTOS Pr42 str14
Linux na nowej plafrotmie Pr33 str20
botloader1 Pr82 str 16
wlasny Linux PI Pr84 str 38
LinuxJądro - LinuxMagazyn 187 str 32
GDB and OpenOCD
http://openocd.org/doc/html/GDB-and-OpenOCD.html
Debugowanie za pomocą openocd+gdb+stlinkv2
https://phabricator.hskrk.pl/w/poradniki/stm32/debugging/
JTAG,OpenOCD,BDM and GDB
http://rts.lab.asu.edu/web_438/project_final/Talk%205%20JTAG,OpenOCD,BDM%20and%20GDB.pdf
...
The
Black Magic Probe is a modern, in-application debugging tool for embedded microprocessors. ... It is able to control and examine the state of the target microprocessor using a JTAG or Serial Wire Debugging (SWD) port and on-chip debug logic provided by the microprocessor.
...